← Directory
html-rendering
Server-rendered HTML pages, share token auth, and a page registration API for other extensions
v2.0.0 by TreeOS Site 0 downloads 22 files 32,722 lines 997.6 KB published 1d ago
treeos ext install html-rendering

Manifest

Provides

  • routes

Requires

  • models: User, Node
SHA256: 0f138e819434184364cd65d7dc67215cb2e605e02986975047d6724aad754fe9

Hooks

Listens To

  • afterRegister

Environment Variables

KeyRequiredDescription
ENABLE_FRONTEND_HTML No Enable server-rendered HTML and share token auth. Set to false to disable all ?html routes, share token access, and extension HTML renderers. API endpoints still return JSON. (default: true)

Source Code

1// routes/app.js
2import express from "express";
3import User from "../../../db/models/user.js";
4import CustomLlmConnection from "../../../db/models/customLlmConnection.js";
5import authenticateLite from "../../../middleware/authenticateLite.js";
6import { notFoundPage } from "../../../middleware/notFoundPage.js";
7import {
8  dashboardCSS,
9  dashboardHTML,
10  dashboardJS,
11} from "./sessionManagerPartial.js";
12import { getLandUrl } from "../../../canopy/identity.js";
13
14const router = express.Router();
15
16/**
17 * GET /dashboard
18 * Authenticated iframe shell with integrated chat
19 */
20router.get("/dashboard", authenticateLite, async (req, res) => {
21  try {
22    if (process.env.ENABLE_FRONTEND_HTML !== "true") {
23      return res.status(404).json({
24        error: "Server-rendered HTML is disabled. Use the SPA frontend.",
25      });
26    }
27    if (!req.userId) {
28      return res.redirect("/login");
29    }
30
31    const user = await User.findById(req.userId).select(
32      "username roots metadata llmDefault",
33    );
34
35    if (!user) {
36      return notFoundPage(req, res, "This user doesn't exist.");
37    }
38
39    // Redirect to setup if user needs LLM or first tree (unless they skipped recently)
40    const setupSkipped = req.cookies?.setupSkipped === "1";
41    if (!setupSkipped) {
42      const hasMainLlm = !!user.llmDefault;
43      const hasTree = user.roots && user.roots.length > 0;
44      if (!hasMainLlm || !hasTree) {
45        const connCount = hasMainLlm
46          ? 1
47          : await CustomLlmConnection.countDocuments({ userId: req.userId });
48        if (connCount === 0 || !hasTree) {
49          return res.redirect("/setup");
50        }
51      }
52    }
53
54    const { getUserMeta } = await import("../../../core/tree/userMetadata.js");
55    const htmlShareToken = getUserMeta(user, "html")?.shareToken || "";
56    const { username } = user;
57    const hasLlm =
58      !!user.llmDefault ||
59      (await CustomLlmConnection.countDocuments({ userId: req.userId })) > 0;
60
61    return res.send(`<!DOCTYPE html>
62<html lang="en">
63<head>
64  <meta charset="UTF-8" />
65  <title>Dashboard - TreeOS</title>
66  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
67  <meta name="theme-color" content="#667eea" />
68  <link rel="icon" href="/tree.png" />
69  <link rel="canonical" href="${getLandUrl()}/app" />
70  <meta name="robots" content="noindex, nofollow" />
71  <meta name="description" content="TreeOS dashboard with tree visualization, chat, and knowledge management tools." />
72  <meta property="og:title" content="Dashboard - TreeOS" />
73  <meta property="og:description" content="TreeOS dashboard with tree visualization, chat, and knowledge management tools." />
74  <meta property="og:url" content="${getLandUrl()}/app" />
75  <meta property="og:type" content="website" />
76  <meta property="og:site_name" content="TreeOS" />
77  <meta property="og:image" content="${getLandUrl()}/tree.png" />
78  <link rel="preconnect" href="https://fonts.googleapis.com">
79  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
80  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
81  <style>
82    :root {
83      --glass-rgb: 115, 111, 230;
84      --glass-alpha: 0.28;
85      --glass-blur: 22px;
86      --glass-border: rgba(255, 255, 255, 0.28);
87      --glass-border-light: rgba(255, 255, 255, 0.15);
88      --glass-highlight: rgba(255, 255, 255, 0.25);
89      --text-primary: #ffffff;
90      --text-secondary: rgba(255, 255, 255, 0.9);
91      --text-muted: rgba(255, 255, 255, 0.6);
92      --accent: #10b981;
93      --accent-glow: rgba(16, 185, 129, 0.6);
94      --error: #ef4444;
95      --header-height: 56px;
96      --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
97      --mobile-input-height: 70px;
98      --min-panel-width: 280px;
99    }
100      
101
102    * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
103    html, body { height: 100%; width: 100%; overflow: hidden; font-family: 'DM Sans', -apple-system, sans-serif; color: var(--text-primary); background: #736fe6; }
104
105    .app-bg { position: fixed; inset: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); z-index: -2; }
106    .app-bg::before, .app-bg::after { content: ''; position: fixed; border-radius: 50%; opacity: 0.08; animation: float 20s infinite ease-in-out; pointer-events: none; }
107    .app-bg::before { width: 600px; height: 600px; top: -300px; right: -200px; animation-delay: -5s; }
108    .app-bg::after { width: 400px; height: 400px; bottom: -200px; left: -100px; animation-delay: -10s; }
109    @keyframes float { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-30px) rotate(5deg); } }
110
111    .app-container { display: flex; height: 100%; width: 100%; padding: 0px; gap: 0px; }
112    .glass-panel {
113      background: rgba(var(--glass-rgb), var(--glass-alpha));
114      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
115      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
116      border-radius: 0;
117      border: none;
118      box-shadow: none;
119    }
120    .glass-panel::before { content: ""; position: absolute; inset: -40%; background: radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.2), transparent 60%); pointer-events: none; z-index: 0; }
121
122    .chat-panel { width: 400px; min-width: 0; height: 100%; display: flex; flex-direction: column; z-index: 10; flex-shrink: 0; position: relative; }
123    .chat-header { height: var(--header-height); padding: 0 16px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--glass-border-light); flex-shrink: 0; position: relative; z-index: 1; }
124    .chat-header a { text-decoration: none; color: inherit; }
125    .chat-title { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
126    .tree-icon { font-size: 28px; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3)); animation: grow 4.5s infinite ease-in-out; }
127    @keyframes grow { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
128    .chat-title h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
129    
130    .chat-header-controls { display: flex; align-items: center; gap: 0; margin-left: auto; }
131    .chat-header-buttons { display: flex; align-items: center; gap: 6px; margin-right: 12px; }
132    .chat-header-right { display: flex; align-items: center; gap: 0; }
133
134    .status-badge { display: flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border-radius: 100px; border: 1px solid var(--glass-border-light); font-size: 12px; font-weight: 600; }
135    .status-badge .status-text { display: inline; }
136    .status-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 12px var(--accent-glow); animation: pulse 2s ease-in-out infinite; flex-shrink: 0; }
137    .status-dot.connected { background: var(--accent); }
138    .status-dot.disconnected { background: var(--error); animation: none; }
139    .status-dot.connecting { background: #f59e0b; }
140    @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(1.15); } }
141    
142    /* Compact mode for narrow panels */
143    .chat-panel:not(.collapsed) { container-type: inline-size; }
144    @container (max-width: 420px) {
145      #desktopOpenTabBtn { display: none; }
146    }
147    @container (max-width: 360px) {
148      .status-badge .status-text { display: none; }
149      .status-badge { padding: 6px; min-width: 20px; justify-content: center; }
150    }
151    @container (max-width: 320px) {
152      .chat-title h1 { display: none; }
153    }
154    
155    /* Wide panel mode - constrain content when panel is very wide */
156    @container (min-width: 750px) {
157      .chat-messages {
158        width: 100%;
159        max-width: 720px;
160        margin-left: auto;
161        margin-right: auto;
162        padding-left: 24px;
163        padding-right: 24px;
164      }
165      .chat-input-area {
166        width: 100%;
167        max-width: 760px;
168        margin-left: auto;
169        margin-right: auto;
170      }
171      .mode-bar {
172        width: 100%;
173        max-width: 760px;
174        margin-left: auto;
175        margin-right: auto;
176      }
177    }
178    @container (min-width: 950px) {
179      .chat-messages {
180        max-width: 840px;
181      }
182      .chat-input-area {
183        max-width: 880px;
184      }
185      .mode-bar {
186        max-width: 880px;
187      }
188    }
189    @container (min-width: 1150px) {
190      .chat-messages {
191        max-width: 920px;
192        padding-left: 32px;
193        padding-right: 32px;
194      }
195      .chat-input-area {
196        max-width: 960px;
197      }
198      .mode-bar {
199        max-width: 960px;
200      }
201    }
202/* Orchestrator step messages */
203.message.orchestrator-step .message-content {
204  background: rgba(255, 255, 255, 0.06);
205  border: 1px dashed rgba(255, 255, 255, 0.15);
206  border-radius: 12px;
207  font-size: 12px;
208  color: var(--text-muted);
209  padding: 10px 14px;
210  font-family: 'JetBrains Mono', monospace;
211  max-width: 95%;
212}
213.message.orchestrator-step .message-avatar {
214  width: 28px;
215  height: 28px;
216  font-size: 12px;
217  border-radius: 8px;
218  background: rgba(255, 255, 255, 0.06);
219  border-color: rgba(255, 255, 255, 0.1);
220}
221.message.orchestrator-step .step-mode {
222  color: var(--accent);
223  font-weight: 600;
224  font-size: 11px;
225  text-transform: uppercase;
226  letter-spacing: 0.5px;
227  margin-bottom: 4px;
228  display: block;
229}
230.message.orchestrator-step .step-body {
231  white-space: pre-wrap;
232  word-break: break-word;
233  max-height: 200px;
234  overflow-y: auto;
235  display: block;
236}
237.message.orchestrator-step .step-body::-webkit-scrollbar { width: 4px; }
238.message.orchestrator-step .step-body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 2px; }
239    /* Clear chat button in header */
240    .clear-chat-btn {
241      width: 30px;
242      height: 30px;
243      display: flex;
244      align-items: center;
245      justify-content: center;
246      background: rgba(255, 255, 255, 0.1);
247      border: 1px solid var(--glass-border-light);
248      border-radius: 8px;
249      color: var(--text-muted);
250      cursor: pointer;
251      transition: all var(--transition-fast);
252      flex-shrink: 0;
253    }
254    #clearChatBtn {
255      margin-left: 8px;
256    }
257    .clear-chat-btn:hover {
258      background: rgba(255, 255, 255, 0.2);
259      color: var(--text-primary);
260    }
261    .clear-chat-btn:active {
262      transform: scale(0.93);
263    }
264    .clear-chat-btn svg { width: 14px; height: 14px; }
265    .clear-chat-btn.llm-glow {
266      animation: llmGlow 0.6s ease-in-out 3;
267      border-color: var(--accent);
268      will-change: opacity;
269    }
270    @keyframes llmGlow {
271      0%, 100% { opacity: 1; }
272      50% { opacity: 0.4; background: rgba(16, 185, 129, 0.25); }
273    }
274
275    /* Active root name - inline after Tree in header */
276    .root-name-inline {
277      font-size: 13px;
278      font-weight: 400;
279      color: var(--text-muted);
280      white-space: nowrap;
281      overflow: hidden;
282      text-overflow: ellipsis;
283      flex: 1;
284      min-width: 0;
285      opacity: 0;
286      cursor: default;
287      transition: opacity 0.3s ease;
288    }
289    .root-name-inline.visible {
290      opacity: 1;
291    }
292    .root-name-inline::before {
293      content: ' / ';
294      color: var(--glass-border-light);
295    }
296    .root-name-inline.fade-in {
297      animation: rootNameFade 0.5s ease;
298    }
299    @keyframes rootNameFade {
300      0% { opacity: 0; transform: translateY(-4px); }
301      100% { opacity: 1; transform: translateY(0); }
302    }
303
304    /* Mobile root path - no prefix slash, styled as path */
305    .mobile-root-path {
306      font-size: 15px;
307      font-weight: 500;
308    }
309    .mobile-root-path::before {
310      content: '/';
311      color: var(--text-muted);
312    }
313
314    /* ================================================================
315       RECENT ROOTS DROPDOWN (Top-left overlay, doesn't push content)
316       ================================================================ */
317    .recent-roots-dropdown {
318      position: absolute;
319      top: calc(var(--header-height) + 6px);
320      left: 16px;
321      z-index: 50;
322    }
323    .recent-roots-dropdown.hidden {
324      display: none;
325    }
326    
327    .recent-roots-trigger {
328      width: 28px;
329      height: 28px;
330      display: flex;
331      align-items: center;
332      justify-content: center;
333      background: rgba(255, 255, 255, 0.12);
334      border: 1px solid var(--glass-border-light);
335      border-radius: 8px;
336      cursor: pointer;
337      transition: all var(--transition-fast);
338      color: var(--text-muted);
339    }
340    .recent-roots-trigger:hover {
341      background: rgba(255, 255, 255, 0.2);
342      color: var(--text-primary);
343    }
344    .recent-roots-trigger:active {
345      transform: scale(0.94);
346    }
347    .recent-roots-trigger svg {
348      width: 14px;
349      height: 14px;
350      transition: transform 0.2s ease;
351    }
352    .recent-roots-dropdown.open .recent-roots-trigger svg {
353      transform: rotate(180deg);
354    }
355    .recent-roots-dropdown.open .recent-roots-trigger {
356      background: rgba(255, 255, 255, 0.2);
357      color: var(--text-primary);
358    }
359    
360    .recent-roots-menu {
361      display: none;
362      position: absolute;
363      top: calc(100% + 6px);
364      left: 0;
365      min-width: 160px;
366      max-width: 200px;
367      background: rgba(var(--glass-rgb), 0.92);
368      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
369      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
370      border: 1px solid var(--glass-border);
371      border-radius: 12px;
372      padding: 6px;
373      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
374      animation: recentMenuIn 0.15s ease-out;
375    }
376    @keyframes recentMenuIn {
377      from { opacity: 0; transform: translateY(-4px); }
378      to { opacity: 1; transform: translateY(0); }
379    }
380    .recent-roots-dropdown.open .recent-roots-menu {
381      display: block;
382    }
383    
384    .recent-roots-menu-header {
385      padding: 6px 10px 8px;
386      font-size: 10px;
387      font-weight: 600;
388      text-transform: uppercase;
389      letter-spacing: 0.5px;
390      color: var(--text-muted);
391      border-bottom: 1px solid var(--glass-border-light);
392      margin-bottom: 4px;
393    }
394    
395    .recent-root-item {
396      display: flex;
397      align-items: center;
398      gap: 8px;
399      padding: 8px 10px;
400      border-radius: 8px;
401      cursor: pointer;
402      transition: all var(--transition-fast);
403      font-size: 12px;
404      font-weight: 500;
405      color: var(--text-secondary);
406      border: none;
407      background: none;
408      width: 100%;
409      text-align: left;
410    }
411    .recent-root-item:hover {
412      background: rgba(255, 255, 255, 0.12);
413      color: var(--text-primary);
414    }
415    .recent-root-item:active {
416      background: rgba(255, 255, 255, 0.18);
417      transform: scale(0.98);
418    }
419    .recent-root-item.active {
420      background: rgba(16, 185, 129, 0.15);
421      color: var(--text-primary);
422      border-left: 2px solid var(--accent);
423      padding-left: 8px;
424    }
425    .recent-root-name {
426      flex: 1;
427      min-width: 0;
428      overflow: hidden;
429      text-overflow: ellipsis;
430      white-space: nowrap;
431    }
432
433    .chat-messages { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; position: relative; z-index: 1; }
434    .chat-messages::-webkit-scrollbar { width: 6px; }
435    .chat-messages::-webkit-scrollbar-track { background: transparent; }
436    .chat-messages::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; }
437
438    .welcome-message { text-align: center; padding: 40px 20px; }
439    .welcome-message.disconnected { opacity: 0.7; }
440    .welcome-message.disconnected .welcome-icon { filter: grayscale(0.5) drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: none; }
441    .welcome-icon { font-size: 64px; margin-bottom: 20px; display: inline-block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: floatIcon 3s ease-in-out infinite; }
442    @keyframes floatIcon { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
443    .welcome-message h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
444    .welcome-message p { font-size: 15px; color: var(--text-secondary); line-height: 1.6; margin-bottom: 8px; }
445
446    .message { display: flex; gap: 12px; animation: messageIn 0.3s ease-out; min-width: 0; max-width: 100%; }
447    @keyframes messageIn { from { opacity: 0; transform: translateY(10px); } }
448    .message.user { flex-direction: row-reverse; }
449    .message-avatar { width: 36px; height: 36px; border-radius: 12px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
450    .message.user .message-avatar { background: linear-gradient(135deg, rgba(99, 102, 241, 0.6) 0%, rgba(139, 92, 246, 0.6) 100%); }
451    .message-content { max-width: 85%; min-width: 0; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; font-size: 14px; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
452    .message.user .message-content { background: linear-gradient(135deg, rgba(99, 102, 241, 0.5) 0%, rgba(139, 92, 246, 0.5) 100%); border-radius: 18px 18px 6px 18px; }
453    .message.assistant .message-content { border-radius: 18px 18px 18px 6px; }
454    .message.error .message-content { background: rgba(239, 68, 68, 0.3); border-color: rgba(239, 68, 68, 0.5); }
455
456    /* Carried messages from previous mode - dimmed */
457    .message.carried { opacity: 0.4; pointer-events: none; }
458    .message.carried .message-content { border-style: dashed; }
459
460    /* Mode bar locked while AI is responding */
461    .mode-bar.locked .mode-current {
462      opacity: 0.4;
463      pointer-events: none;
464      cursor: not-allowed;
465    }
466    .mobile-mode-btn.locked {
467      opacity: 0.4;
468      pointer-events: none;
469    }
470
471    /* Send button in stop mode */
472    .send-btn.stop-mode {
473      background: rgba(239, 68, 68, 0.7);
474      box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
475    }
476    .send-btn.stop-mode:hover:not(:disabled) {
477      background: rgba(239, 68, 68, 0.9);
478      box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5);
479    }
480
481    /* Message content formatting */
482    .message-content p { margin: 0 0 10px 0; word-break: break-word; }
483    .message-content p:last-child { margin-bottom: 0; }
484    .message-content h1, .message-content h2, .message-content h3, .message-content h4 {
485      margin: 14px 0 8px 0;
486      font-weight: 600;
487      line-height: 1.3;
488    }
489    .message-content h1:first-child, .message-content h2:first-child, 
490    .message-content h3:first-child, .message-content h4:first-child { margin-top: 0; }
491    .message-content h1 { font-size: 17px; }
492    .message-content h2 { font-size: 16px; }
493    .message-content h3 { font-size: 15px; }
494    .message-content h4 { font-size: 14px; color: var(--text-secondary); }
495    .message-content ul, .message-content ol {
496      margin: 8px 0;
497      padding-left: 0;
498      list-style: none;
499    }
500    .message-content li {
501      margin: 4px 0;
502      padding: 6px 10px;
503      background: rgba(255, 255, 255, 0.06);
504      border-radius: 8px;
505      line-height: 1.4;
506      word-break: break-word;
507    }
508    .message-content li .list-num {
509      color: var(--accent);
510      font-weight: 600;
511      margin-right: 6px;
512    }
513    .message-content strong, .message-content b {
514      font-weight: 600;
515      color: #fff;
516    }
517    .message-content em, .message-content i {
518      font-style: italic;
519      color: var(--text-secondary);
520    }
521    .message-content code {
522      background: rgba(0, 0, 0, 0.3);
523      padding: 2px 6px;
524      border-radius: 4px;
525      font-family: 'JetBrains Mono', monospace;
526      font-size: 11px;
527      word-break: break-all;
528    }
529    .message-content pre {
530      background: rgba(0, 0, 0, 0.3);
531      padding: 12px;
532      border-radius: 8px;
533      overflow-x: auto;
534      margin: 10px 0;
535      max-width: 100%;
536    }
537    .message-content pre code {
538      background: none;
539      padding: 0;
540      word-break: normal;
541      white-space: pre-wrap;
542    }
543    .message-content blockquote {
544      border-left: 3px solid var(--accent);
545      padding-left: 12px;
546      margin: 10px 0;
547      color: var(--text-secondary);
548      font-style: italic;
549    }
550    .message-content hr {
551      border: none;
552      border-top: 1px solid var(--glass-border-light);
553      margin: 14px 0;
554    }
555    .message-content a {
556      color: var(--accent);
557      text-decoration: underline;
558      text-underline-offset: 2px;
559    }
560    .message-content a:hover {
561      text-decoration: none;
562    }
563
564    /* Menu items - numbered/bulleted options */
565    .message-content .menu-item {
566      display: flex;
567      align-items: flex-start;
568      gap: 10px;
569      padding: 10px 12px;
570      margin: 6px 0;
571      background: rgba(255, 255, 255, 0.08);
572      border-radius: 10px;
573      border: 1px solid rgba(255, 255, 255, 0.06);
574      transition: all 0.15s ease;
575    }
576    .message-content .menu-item.clickable {
577      cursor: pointer;
578      user-select: none;
579    }
580    .message-content .menu-item.clickable:hover {
581      background: rgba(255, 255, 255, 0.15);
582      border-color: rgba(16, 185, 129, 0.3);
583      transform: translateX(4px);
584    }
585    .message-content .menu-item.clickable:active {
586      transform: translateX(4px) scale(0.98);
587      background: rgba(16, 185, 129, 0.2);
588    }
589    .message-content .menu-item:first-of-type {
590      margin-top: 8px;
591    }
592    .message-content .menu-number {
593      display: flex;
594      align-items: center;
595      justify-content: center;
596      min-width: 26px;
597      max-width: 26px;
598      height: 26px;
599      background: linear-gradient(135deg, var(--accent) 0%, #059669 100%);
600      border-radius: 8px;
601      font-size: 12px;
602      font-weight: 600;
603      flex-shrink: 0;
604      box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
605      transition: all 0.15s ease;
606    }
607    .message-content .menu-item.clickable:hover .menu-number {
608      transform: scale(1.1);
609      box-shadow: 0 4px 12px rgba(16, 185, 129, 0.5);
610    }
611    .message-content .menu-text {
612      flex: 1;
613      min-width: 0;
614      padding-top: 2px;
615      word-break: break-word;
616      overflow-wrap: break-word;
617    }
618    .message-content .menu-text strong {
619      display: block;
620      margin-bottom: 2px;
621      word-break: break-word;
622    }
623    .message-content .menu-item.clicking {
624      animation: menuClick 0.3s ease;
625    }
626    @keyframes menuClick {
627      0% { background: rgba(16, 185, 129, 0.3); }
628      100% { background: rgba(255, 255, 255, 0.08); }
629    }
630
631    .typing-indicator { display: flex; gap: 4px; padding: 14px 18px; }
632    .typing-dot { width: 8px; height: 8px; background: rgba(255, 255, 255, 0.6); border-radius: 50%; animation: typing 1.4s infinite; }
633    .typing-dot:nth-child(2) { animation-delay: 0.2s; }
634    .typing-dot:nth-child(3) { animation-delay: 0.4s; }
635    @keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-8px); } }
636
637    .chat-input-area { padding: 16px 20px 20px; border-top: 1px solid var(--glass-border-light); position: relative; z-index: 1; }
638    .input-container { display: flex; align-items: flex-end; gap: 12px; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; transition: all var(--transition-fast); }
639    .input-container:focus-within { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.4); box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1); }
640    .chat-input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; font-family: inherit; font-size: 15px; color: var(--text-primary); resize: none; max-height: 120px; line-height: 1.5; }
641    .chat-input::placeholder { color: var(--text-muted); }
642    .chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
643    .send-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--accent); border: none; border-radius: 12px; color: white; cursor: pointer; transition: all var(--transition-fast); flex-shrink: 0; box-shadow: 0 4px 15px var(--accent-glow); }
644    .send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 25px var(--accent-glow); }
645    .send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
646    .send-btn svg { width: 20px; height: 20px; }
647
648    .viewport-panel { flex: 1; height: 100%; display: flex; flex-direction: column; min-width: 0; position: relative; }
649
650    .iframe-container {
651      flex: 1;
652      position: relative;
653      overflow: hidden;
654      border-radius: 0;
655      margin: 0;
656    }
657
658    iframe {
659      width: 100%;
660      height: 100%;
661      border: none;
662      display: block;
663      background: transparent;
664      border-radius: 0;
665    }
666
667    .loading-overlay { position: absolute; inset: 0; background: rgba(var(--glass-rgb), 0.8); backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity var(--transition-fast); z-index: 5; border-radius: 0; }
668    .loading-overlay.visible { opacity: 1; pointer-events: auto; }
669    .spinner-ring { width: 44px; height: 44px; border: 3px solid rgba(255, 255, 255, 0.2); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; }
670    @keyframes spin { to { transform: rotate(360deg); } }
671    .loading-text { font-size: 14px; font-weight: 500; color: var(--text-secondary); margin-top: 16px; }
672
673    .navigator-indicator {
674      display: none;
675      align-items: center;
676      justify-content: flex-end;
677      gap: 4px;
678      padding: 2px 8px 4px;
679    }
680    .navigator-indicator.active { display: flex; }
681    .navigator-indicator.desktop-only { }
682    .navigator-indicator.mobile-only {
683      position: fixed;
684      top: 8px;
685      right: 8px;
686      z-index: 200;
687      padding: 0;
688    }
689    @media (max-width: 768px) {
690      .navigator-indicator.desktop-only { display: none !important; }
691    }
692    @media (min-width: 769px) {
693      .navigator-indicator.mobile-only { display: none !important; }
694    }
695    /* ── Dashboard toggle (chat panel) ─────────────────────────────── */
696    .chat-dashboard-btn {
697      display: flex;
698      align-items: center;
699      justify-content: flex-end;
700      gap: 4px;
701      padding: 2px 8px 4px;
702    }
703    .chat-dashboard-badge {
704      display: flex;
705      align-items: center;
706      gap: 5px;
707      padding: 3px 8px;
708      background: rgba(59, 130, 246, 0.12);
709      border: 1px solid rgba(59, 130, 246, 0.25);
710      border-radius: 6px;
711      cursor: pointer;
712      transition: all 0.15s;
713      font-size: 10px;
714      color: rgba(147, 197, 253, 0.9);
715      white-space: nowrap;
716    }
717    .chat-dashboard-badge:hover { background: rgba(59, 130, 246, 0.22); color: #93c5fd; }
718    .chat-dashboard-badge.active { background: rgba(16,185,129,0.15); border-color: rgba(16,185,129,0.35); color: var(--accent); }
719    .chat-dashboard-badge svg { width: 12px; height: 12px; flex-shrink: 0; }
720    .chat-dashboard-badge .dash-btn-label {
721      max-width: 0;
722      overflow: hidden;
723      transition: max-width 0.25s ease, opacity 0.2s;
724      opacity: 0;
725    }
726    .chat-dashboard-badge:hover .dash-btn-label { max-width: 80px; opacity: 1; }
727    @media (max-width: 768px) {
728      .chat-dashboard-btn { display: none; }
729    }
730    .navigator-badge {
731      display: flex;
732      flex-direction: row-reverse;
733      align-items: center;
734      gap: 4px;
735      padding: 3px 6px;
736      background: rgba(var(--glass-rgb), 0.4);
737      border: 1px solid var(--glass-border);
738      border-radius: 6px;
739      cursor: default;
740      transition: background 0.2s;
741    }
742    .navigator-badge:hover { background: rgba(var(--glass-rgb), 0.7); }
743    .navigator-badge .nav-icon {
744      width: 14px;
745      height: 14px;
746      color: var(--accent);
747      flex-shrink: 0;
748    }
749    .navigator-badge .nav-label {
750      font-size: 10px;
751      color: var(--text-secondary);
752      white-space: nowrap;
753      max-width: 0;
754      overflow: hidden;
755      transition: max-width 0.3s ease, opacity 0.2s;
756      opacity: 0;
757    }
758    .navigator-badge .nav-close-icon {
759      width: 12px;
760      height: 12px;
761      max-width: 0;
762      overflow: hidden;
763      opacity: 0;
764      flex-shrink: 0;
765      transition: max-width 0.3s ease, opacity 0.2s;
766    }
767    /* Reveal: just expand to show label (no red, no X) */
768    .navigator-badge.reveal .nav-label { max-width: 160px; opacity: 1; }
769    /* Hover: expand all, turn red, show X */
770    .navigator-badge:hover .nav-label { max-width: 160px; opacity: 1; }
771    .navigator-badge:hover .nav-close-icon { max-width: 16px; opacity: 1; }
772    .navigator-badge:hover {
773      background: rgba(239, 68, 68, 0.15);
774      border-color: rgba(239, 68, 68, 0.3);
775      cursor: pointer;
776    }
777    .navigator-badge:hover .nav-icon { color: #ef4444; }
778    .navigator-badge:hover .nav-label { color: #ef4444; }
779    .navigator-badge:hover .nav-close-icon { color: #ef4444; }
780
781    .panel-divider { width: 16px; height: 100%; display: flex; align-items: center; justify-content: center; cursor: col-resize; position: relative; z-index: 20; flex-shrink: 0; }
782    .divider-handle { width: 6px; height: 80px; background: rgba(var(--glass-rgb), 0.5); backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: 4px; transition: all var(--transition-fast); }
783    .panel-divider:hover .divider-handle { background: rgba(var(--glass-rgb), 0.7); width: 8px; }
784    .chat-header,
785    .chat-input-area {
786      border-bottom: none;
787      border-top: none;
788    }
789    .expand-buttons { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; gap: 8px; opacity: 0; pointer-events: none; transition: opacity var(--transition-fast); }
790    .panel-divider:hover .expand-buttons { opacity: 1; pointer-events: auto; }
791    .panel-divider:hover .divider-handle { opacity: 0; }
792    .expand-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: rgba(var(--glass-rgb), 0.8); backdrop-filter: blur(var(--glass-blur)); border: 1px solid var(--glass-border); border-radius: 8px; color: var(--text-secondary); cursor: pointer; transition: all var(--transition-fast); }
793    .expand-btn:hover { background: rgba(255, 255, 255, 0.25); color: var(--text-primary); transform: scale(1.1); }
794    .expand-btn svg { width: 16px; height: 16px; }
795
796    /* Collapsed chat tab - visible by default on mobile */
797    .mobile-chat-tab {
798      display: none;
799      position: fixed;
800      bottom: calc(100px + env(safe-area-inset-bottom, 0px));
801      right: 0;
802      z-index: 150;
803      width: 32px;
804      height: 56px;
805      background: rgba(255, 255, 255, 0.12);
806      backdrop-filter: blur(12px);
807      -webkit-backdrop-filter: blur(12px);
808      border: 1px solid rgba(255, 255, 255, 0.15);
809      border-right: none;
810      border-radius: 12px 0 0 12px;
811      align-items: center;
812      justify-content: center;
813      cursor: pointer;
814      transition: all 0.2s ease;
815      box-shadow: -2px 0 12px rgba(0, 0, 0, 0.1);
816      font-size: 18px;
817    }
818    .mobile-chat-tab:active {
819      width: 38px;
820      background: rgba(255, 255, 255, 0.2);
821    }
822
823    /* Mobile connection status indicator - inside sheet header */
824    .mobile-status-indicator {
825      display: none;
826      width: 6px;
827      height: 6px;
828      border-radius: 50%;
829      flex-shrink: 0;
830    }
831    .mobile-status-indicator.connected {
832      background: var(--accent);
833      box-shadow: 0 0 6px var(--accent-glow);
834      animation: pulse 2s ease-in-out infinite;
835    }
836    .mobile-status-indicator.disconnected {
837      background: var(--error);
838      animation: none;
839    }
840    .mobile-status-indicator.connecting {
841      background: #f59e0b;
842      animation: pulse 1s ease-in-out infinite;
843    }
844
845    /* Tree icon with status dot wrapper */
846    .mobile-tree-icon-wrapper {
847      position: relative;
848      display: flex;
849      align-items: center;
850      justify-content: center;
851      text-decoration: none;
852      transition: transform 0.15s ease;
853    }
854    .mobile-tree-icon-wrapper:active {
855      transform: scale(0.92);
856    }
857    .mobile-tree-icon-wrapper .mobile-status-indicator {
858      display: block;
859      position: absolute;
860      top: 0;
861      left: 0;
862    }
863    .mobile-tree-icon-wrapper .tree-icon {
864      font-size: 24px;
865      text-shadow: none;
866      filter: none;
867    }
868
869    /* Mobile header action buttons */
870    .mobile-header-actions {
871      display: flex;
872      align-items: center;
873      gap: 2px;
874    }
875    .mobile-header-actions .mobile-close-btn {
876      margin-left: 10px;
877    }
878    .mobile-header-actions .clear-chat-btn {
879      background: rgba(255, 255, 255, 0.08);
880      border-color: rgba(255, 255, 255, 0.12);
881    }
882    .mobile-header-actions .clear-chat-btn:active {
883      background: rgba(255, 255, 255, 0.15);
884    }
885    .mobile-header-actions .mobile-dash-btn {
886      background: rgba(59, 130, 246, 0.15);
887      border-color: rgba(59, 130, 246, 0.3);
888      color: rgba(147, 197, 253, 0.9);
889    }
890    .mobile-header-actions .mobile-dash-btn:active {
891      background: rgba(59, 130, 246, 0.28);
892    }
893    .mobile-header-actions .mobile-dash-btn.active {
894      background: rgba(16, 185, 129, 0.2);
895      border-color: rgba(16, 185, 129, 0.35);
896      color: var(--accent);
897    }
898
899    .mobile-chat-sheet {
900      display: none;
901      position: fixed;
902      bottom: 0;
903      left: 0;
904      right: 0;
905      height: 85vh;
906      max-height: calc(100vh - 40px);
907      z-index: 200;
908      background: rgba(var(--glass-rgb), 0.22);
909      backdrop-filter: blur(12px);
910      -webkit-backdrop-filter: blur(12px);
911      border-top-left-radius: 24px;
912      border-top-right-radius: 24px;
913      border: 1px solid rgba(255, 255, 255, 0.18);
914      border-bottom: none;
915      box-shadow: 0 -15px 50px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.25);
916      transform: translateY(100%);
917      flex-direction: column;
918      will-change: transform;
919    }
920    .mobile-chat-sheet.open { 
921      transform: translateY(0); 
922      transition: transform 0.4s cubic-bezier(0.32, 0.72, 0, 1);
923    }
924    .mobile-chat-sheet.peeked {
925      transform: translateY(calc(100% - 90px));
926      transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
927    }
928    .mobile-chat-sheet.peeked .mobile-chat-messages,
929    .mobile-chat-sheet.peeked .mobile-chat-input-area,
930    .mobile-chat-sheet.peeked .mobile-recent-roots,
931    .mobile-chat-sheet.peeked .mobile-mode-bar {
932      display: none;
933    }
934    .mobile-chat-sheet.closing {
935      transform: translateY(100%);
936      transition: transform 0.3s cubic-bezier(0.4, 0, 1, 1);
937    }
938    .mobile-chat-sheet.dragging { 
939      transition: none !important; 
940    }
941
942    .mobile-sheet-header {
943      padding: 12px 16px;
944      display: flex;
945      flex-direction: column;
946      align-items: center;
947      border-bottom: 1px solid rgba(255, 255, 255, 0.1);
948      flex-shrink: 0;
949      cursor: grab;
950      touch-action: none;
951      background: rgba(255, 255, 255, 0.03);
952      user-select: none;
953      position: relative;
954    }
955    .mobile-sheet-header h1,
956    .mobile-sheet-header .root-name-inline {
957      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
958    }
959    .mobile-sheet-header:active { cursor: grabbing; }
960    .mobile-sheet-header .drag-handle { 
961      width: 36px; 
962      height: 4px; 
963      background: rgba(255, 255, 255, 0.2); 
964      border-radius: 2px; 
965      margin-bottom: 10px;
966    }
967    .mobile-sheet-title-row { width: 100%; display: flex; align-items: center; justify-content: space-between; }
968    .mobile-sheet-title { display: flex; align-items: center; gap: 10px; min-width: 0; overflow: hidden; }
969    .mobile-sheet-title .tree-icon { font-size: 24px; }
970    .mobile-sheet-title h1 { font-size: 17px; font-weight: 600; }
971    .mobile-close-btn { 
972      width: 32px; 
973      height: 32px; 
974      display: flex; 
975      align-items: center; 
976      justify-content: center; 
977      background: rgba(255, 255, 255, 0.08); 
978      border: 1px solid rgba(255, 255, 255, 0.12); 
979      border-radius: 50%; 
980      color: var(--text-primary); 
981      cursor: pointer;
982      transition: all 0.15s ease;
983    }
984    .mobile-close-btn:active {
985      background: rgba(255, 255, 255, 0.15);
986      transform: scale(0.95);
987    }
988    .mobile-close-btn svg { width: 16px; height: 16px; }
989
990    .mobile-chat-messages {
991      flex: 1;
992      overflow-y: auto;
993      padding: 16px;
994      display: flex;
995      flex-direction: column;
996      gap: 12px;
997      background: transparent;
998      -webkit-overflow-scrolling: touch;
999    }
1000    
1001    /* Glass-printed text style for mobile */
1002    .mobile-chat-messages .message-content {
1003      background: rgba(255, 255, 255, 0.1);
1004      border: 1px solid rgba(255, 255, 255, 0.12);
1005      color: #fff;
1006      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
1007    }
1008    .mobile-chat-messages .message.user .message-content {
1009      background: rgba(255, 255, 255, 0.14);
1010      border-color: rgba(255, 255, 255, 0.18);
1011    }
1012    .mobile-chat-messages .message-avatar {
1013      background: rgba(255, 255, 255, 0.1);
1014      border-color: rgba(255, 255, 255, 0.12);
1015    }
1016    .mobile-chat-messages .welcome-message {
1017      text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
1018    }
1019
1020    .mobile-chat-input-area {
1021      padding: 14px 16px;
1022      padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
1023      border-top: 1px solid rgba(255, 255, 255, 0.1);
1024      flex-shrink: 0;
1025      background: transparent;
1026    }
1027    .mobile-chat-input-area .input-container { 
1028      padding: 14px 18px; 
1029      border-radius: 26px;
1030      background: rgba(255, 255, 255, 0.12);
1031      backdrop-filter: blur(6px);
1032      border: 1px solid rgba(255, 255, 255, 0.12);
1033      min-height: 52px;
1034    }
1035    .mobile-chat-input-area .input-container:focus-within {
1036      background: rgba(255, 255, 255, 0.16);
1037      border-color: rgba(255, 255, 255, 0.25);
1038    }
1039    .mobile-chat-input-area .chat-input { font-size: 16px; }
1040    .mobile-chat-input-area .send-btn { 
1041      width: 42px; 
1042      height: 42px; 
1043      border-radius: 14px;
1044      box-shadow: none;
1045    }
1046    .mobile-chat-input-area .send-btn:hover:not(:disabled) {
1047      box-shadow: none;
1048    }
1049
1050    .mobile-backdrop {
1051      display: none;
1052      position: fixed;
1053      inset: 0;
1054      background: rgba(0, 0, 0, 0.1);
1055      z-index: 190;
1056      opacity: 0;
1057      transition: opacity 0.3s ease;
1058      pointer-events: none;
1059    }
1060    .mobile-backdrop.visible { opacity: 1; pointer-events: auto; }
1061
1062    /* Mobile recent roots dropdown */
1063    .mobile-recent-roots {
1064      display: none;
1065      width: 100%;
1066      margin-top: 6px;
1067    }
1068    .mobile-recent-roots.visible {
1069      display: block;
1070    }
1071    .mobile-recent-roots-toggle {
1072      display: flex;
1073      align-items: center;
1074      justify-content: center;
1075      gap: 4px;
1076      padding: 4px 10px;
1077      background: rgba(255, 255, 255, 0.08);
1078      border: 1px solid rgba(255, 255, 255, 0.1);
1079      border-radius: 14px;
1080      font-size: 11px;
1081      font-weight: 500;
1082      color: var(--text-muted);
1083      cursor: pointer;
1084      margin: 0 auto;
1085      transition: all var(--transition-fast);
1086    }
1087    .mobile-recent-roots-toggle:active {
1088      background: rgba(255, 255, 255, 0.15);
1089      transform: scale(0.97);
1090    }
1091    .mobile-recent-roots-toggle svg {
1092      width: 10px;
1093      height: 10px;
1094      transition: transform 0.2s ease;
1095    }
1096    .mobile-recent-roots.expanded .mobile-recent-roots-toggle svg {
1097      transform: rotate(180deg);
1098    }
1099    .mobile-recent-roots-list {
1100      display: none;
1101      flex-direction: row;
1102      flex-wrap: wrap;
1103      gap: 4px;
1104      margin-top: 6px;
1105      padding: 0 4px;
1106      justify-content: center;
1107    }
1108    .mobile-recent-roots.expanded .mobile-recent-roots-list {
1109      display: flex;
1110    }
1111    .mobile-recent-roots-list .recent-root-item {
1112      background: rgba(255, 255, 255, 0.06);
1113      font-size: 11px;
1114      padding: 5px 10px;
1115      border-radius: 12px;
1116      width: auto;
1117      flex: 0 0 auto;
1118    }
1119
1120    @media (max-width: 768px) {
1121      .app-container { padding: 0; gap: 0; flex-direction: column; }
1122      .chat-panel { display: none !important; }
1123      .viewport-panel { width: 100% !important; height: 100%; }
1124      .viewport-panel.glass-panel { border-radius: 0; }
1125      .iframe-container { border-radius: 0; margin: 0; flex: 1; }
1126      iframe, .loading-overlay { border-radius: 0; }
1127      .panel-divider { display: none; }
1128      .mobile-chat-sheet, .mobile-backdrop { display: block; }
1129      .mobile-chat-sheet { display: flex; }
1130      .mobile-chat-tab { display: flex; }
1131      .mobile-chat-tab.hidden { display: none; }
1132      
1133      .message-content {
1134        max-width: 90%;
1135        padding: 12px 14px;
1136        font-size: 14px;
1137      }
1138      .message-content .menu-item {
1139        padding: 8px 10px;
1140        gap: 8px;
1141      }
1142      .message-content .menu-number {
1143        min-width: 24px;
1144        max-width: 24px;
1145        height: 24px;
1146        font-size: 11px;
1147      }
1148      .message-content .menu-text {
1149        font-size: 13px;
1150      }
1151      .message-content code {
1152        font-size: 10px;
1153      }
1154      .message-content pre {
1155        padding: 10px;
1156        font-size: 11px;
1157      }
1158    }
1159
1160    .app-container.dragging { user-select: none; cursor: col-resize; }
1161    .app-container.dragging iframe { pointer-events: none; }
1162    .chat-panel.collapsed, .viewport-panel.collapsed { width: 0 !important; min-width: 0 !important; opacity: 0; pointer-events: none; padding: 0; border: none; overflow: hidden; }
1163    .chat-panel, .viewport-panel { transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
1164    .app-container.dragging .chat-panel, .app-container.dragging .viewport-panel { transition: none; }
1165
1166    /* ================================================================
1167       Mode bar styles
1168       ================================================================ */
1169    .mode-bar {
1170      display: flex;
1171      align-items: center;
1172      gap: 6px;
1173      padding: 6px 12px;
1174      border-top: 1px solid var(--glass-border-light);
1175      flex-shrink: 0;
1176      position: relative;
1177      z-index: 12;
1178      min-height: 40px;
1179    }
1180
1181    .mode-current {
1182      display: flex;
1183      align-items: center;
1184      gap: 8px;
1185      padding: 5px 12px 5px 8px;
1186      background: rgba(255, 255, 255, 0.15);
1187      backdrop-filter: blur(10px);
1188      border: 1px solid var(--glass-border-light);
1189      border-radius: 10px;
1190      cursor: pointer;
1191      user-select: none;
1192      transition: all var(--transition-fast);
1193      position: relative;
1194      font-size: 13px;
1195      font-weight: 600;
1196      color: var(--text-primary);
1197    }
1198    .mode-current:hover {
1199      background: rgba(255, 255, 255, 0.22);
1200      border-color: rgba(255, 255, 255, 0.3);
1201    }
1202    .mode-current:active {
1203      transform: scale(0.97);
1204    }
1205    .mode-current-emoji {
1206      font-size: 16px;
1207      line-height: 1;
1208    }
1209    .mode-current-label {
1210      white-space: nowrap;
1211    }
1212    .mode-current-chevron {
1213      width: 12px;
1214      height: 12px;
1215      color: var(--text-muted);
1216      transition: transform var(--transition-fast);
1217      flex-shrink: 0;
1218    }
1219    .mode-bar.open .mode-current-chevron {
1220      transform: rotate(180deg);
1221    }
1222
1223    .mode-dropdown {
1224      display: none;
1225      position: absolute;
1226      bottom: calc(100% + 8px);
1227      left: 0;
1228      min-width: 180px;
1229      max-width: 280px;
1230      background: rgba(var(--glass-rgb), 0.85);
1231      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
1232      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
1233      border: 1px solid var(--glass-border);
1234      border-radius: 14px;
1235      padding: 6px;
1236      z-index: 100;
1237      box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.3), inset 0 1px 0 var(--glass-highlight);
1238      animation: dropdownIn 0.15s ease-out;
1239    }
1240    @keyframes dropdownIn {
1241      from { opacity: 0; transform: translateY(6px); }
1242      to { opacity: 1; transform: translateY(0); }
1243    }
1244    .mode-bar.open .mode-dropdown {
1245      display: block;
1246    }
1247
1248    .mode-option {
1249      display: flex;
1250      align-items: center;
1251      gap: 10px;
1252      padding: 8px 12px;
1253      border-radius: 10px;
1254      cursor: pointer;
1255      transition: all var(--transition-fast);
1256      font-size: 13px;
1257      font-weight: 500;
1258      color: var(--text-secondary);
1259      border: none;
1260      background: none;
1261      width: 100%;
1262      text-align: left;
1263    }
1264    .mode-option:hover {
1265      background: rgba(255, 255, 255, 0.15);
1266      color: var(--text-primary);
1267    }
1268    .mode-option:active {
1269      background: rgba(255, 255, 255, 0.2);
1270      transform: scale(0.97);
1271    }
1272    .mode-option.active {
1273      background: rgba(16, 185, 129, 0.2);
1274      color: var(--text-primary);
1275      font-weight: 600;
1276      border: 1px solid rgba(16, 185, 129, 0.3);
1277    }
1278    .mode-option-emoji {
1279      font-size: 16px;
1280      width: 22px;
1281      text-align: center;
1282      flex-shrink: 0;
1283    }
1284
1285    /* Mode alert toast */
1286    .mode-alert {
1287      position: fixed;
1288      bottom: 165px;
1289      left: 10px;
1290      z-index: 9999;
1291      display: flex;
1292      align-items: center;
1293      gap: 6px;
1294      padding: 7px 14px;
1295      background: rgba(var(--glass-rgb), 0.85);
1296      backdrop-filter: blur(var(--glass-blur));
1297      -webkit-backdrop-filter: blur(var(--glass-blur));
1298      border: 1px solid var(--glass-border);
1299      border-radius: 10px;
1300      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
1301      font-size: 12px;
1302      font-weight: 600;
1303      color: var(--text-primary);
1304      pointer-events: none;
1305      opacity: 0;
1306      transform: translateY(10px);
1307      transition: opacity 0.25s ease, transform 0.25s ease;
1308    }
1309    .mode-alert.visible {
1310      opacity: 1;
1311      transform: translateY(0);
1312    }
1313    .mode-alert-emoji {
1314      font-size: 14px;
1315    }
1316
1317    /* Mobile mode bar (inside sheet header) */
1318    .mobile-mode-bar {
1319      display: flex;
1320      gap: 4px;
1321      margin-top: 10px;
1322      width: 100%;
1323      overflow-x: auto;
1324      -webkit-overflow-scrolling: touch;
1325      scrollbar-width: none;
1326      padding-bottom: 2px;
1327      scroll-behavior: smooth;
1328    }
1329    .mobile-mode-bar::-webkit-scrollbar { display: none; }
1330
1331    .mobile-mode-btn {
1332      display: flex;
1333      align-items: center;
1334      gap: 6px;
1335      padding: 6px 12px;
1336      background: rgba(255, 255, 255, 0.08);
1337      border: 1px solid rgba(255, 255, 255, 0.1);
1338      border-radius: 20px;
1339      font-size: 12px;
1340      font-weight: 600;
1341      color: var(--text-primary);
1342      cursor: pointer;
1343      white-space: nowrap;
1344      flex-shrink: 0;
1345      transition: all var(--transition-fast);
1346      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
1347    }
1348    .mobile-mode-btn:active {
1349      transform: scale(0.95);
1350    }
1351    .mobile-mode-btn.active {
1352      background: rgba(16, 185, 129, 0.2);
1353      border-color: rgba(16, 185, 129, 0.3);
1354      color: var(--text-primary);
1355    }
1356    .mobile-mode-btn-emoji {
1357      font-size: 14px;
1358    }
1359
1360    @media (max-width: 768px) {
1361      .mode-alert {
1362        top: 10px;
1363        bottom: auto;
1364        left: 50%;
1365        transform: translateX(-50%) translateY(-10px);
1366      }
1367      .mode-alert.visible {
1368        transform: translateX(-50%) translateY(0);
1369      }
1370    }
1371    ${dashboardCSS()}
1372  </style>
1373</head>
1374<body>
1375  <div class="app-bg"></div>
1376
1377  <!-- Mode alert toast -->
1378  <div class="mode-alert" id="modeAlert">
1379    <span class="mode-alert-emoji" id="modeAlertEmoji"></span>
1380    <span id="modeAlertText"></span>
1381  </div>
1382
1383  <!-- Navigator indicator (mobile: fixed top-right) -->
1384  <div class="navigator-indicator mobile-only" id="navigatorIndicatorMobile">
1385    <div class="navigator-badge" id="navigatorBadgeMobile" title="Detach session navigator">
1386      <svg class="nav-close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1387      <span class="nav-label" id="navigatorLabelMobile">session</span>
1388      <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
1389    </div>
1390  </div>
1391
1392  <div class="app-container">
1393    <!-- Chat Panel -->
1394    <div class="chat-panel glass-panel" id="chatPanel">
1395      <div class="chat-header">
1396        <a href="/app" class="tree-home-link">
1397          <div class="chat-title">
1398            <span class="tree-icon">🌳</span>
1399            <h1>Tree</h1>
1400          </div>
1401        </a>
1402        <span class="root-name-inline" id="rootNameLabel" title=""></span>
1403
1404        <div class="chat-header-controls">
1405          <div class="chat-header-buttons">
1406            <button class="clear-chat-btn" id="desktopHomeBtn" title="Home">
1407              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
1408            </button>
1409            <button class="clear-chat-btn" id="desktopRefreshBtn" title="Refresh">
1410              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
1411            </button>
1412            <button class="clear-chat-btn" id="desktopOpenTabBtn" title="Open in new tab">
1413              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
1414            </button>
1415            <button class="clear-chat-btn" id="desktopCustomAiBtn" title="LLM Connections">🤖</button>
1416          </div>
1417          <div class="chat-header-right">
1418            <div class="status-badge">
1419              <span class="status-dot connecting" id="statusDot"></span>
1420              <span class="status-text" id="statusText">Connecting...</span>
1421            </div>
1422            <button class="clear-chat-btn" id="clearChatBtn" title="Clear conversation">
1423              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
1424            </button>
1425          </div>
1426        </div>
1427      </div>
1428
1429      <!-- Session manager toggle (desktop: row above navigator) -->
1430      <div class="chat-dashboard-btn" id="desktopDashboardRow">
1431        <div class="chat-dashboard-badge" id="desktopDashboardBtn" title="Session Manager">
1432          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
1433          <span class="dash-btn-label">Sessions</span>
1434        </div>
1435      </div>
1436
1437      <!-- Navigator indicator (desktop: row below session manager) -->
1438      <div class="navigator-indicator desktop-only" id="navigatorIndicatorDesktop">
1439        <div class="navigator-badge" id="navigatorBadgeDesktop" title="Detach session navigator">
1440          <svg class="nav-close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1441          <span class="nav-label" id="navigatorLabelDesktop">session</span>
1442          <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
1443        </div>
1444      </div>
1445
1446      <!-- Recent Roots Dropdown (absolute positioned, top-left overlay) -->
1447      <div class="recent-roots-dropdown hidden" id="recentRootsDropdown">
1448        <div class="recent-roots-trigger" id="recentRootsTrigger">
1449          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>
1450        </div>
1451        <div class="recent-roots-menu" id="recentRootsMenu">
1452          <div class="recent-roots-menu-header">Recent Trees</div>
1453          <div id="recentRootsList"></div>
1454        </div>
1455      </div>
1456
1457      <div class="chat-messages" id="chatMessages">
1458        <div class="welcome-message">
1459          <div class="welcome-icon">🌳</div>
1460          <h2>Welcome to Tree</h2>
1461          <p>Your intelligent workspace is ready</p>
1462        </div>
1463      </div>
1464
1465      <!-- Desktop mode bar (above input) -->
1466      <div class="mode-bar" id="modeBar">
1467        <div class="mode-current" id="modeCurrent">
1468          <span class="mode-current-emoji" id="modeCurrentEmoji">🏠</span>
1469          <span class="mode-current-label" id="modeCurrentLabel">Home</span>
1470          <svg class="mode-current-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 15l6-6 6 6"/></svg>
1471          <div class="mode-dropdown" id="modeDropdown"></div>
1472        </div>
1473      </div>
1474
1475      <div class="chat-input-area">
1476        <div class="input-container">
1477          <textarea class="chat-input" id="chatInput" placeholder="Message Tree..." rows="1"></textarea>
1478          <button class="send-btn" id="sendBtn" disabled>
1479            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1480              <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
1481            </svg>
1482          </button>
1483        </div>
1484      </div>
1485    </div>
1486
1487    <!-- Divider -->
1488    <div class="panel-divider" id="panelDivider">
1489      <div class="divider-handle"></div>
1490      <div class="expand-buttons">
1491        <button class="expand-btn" id="expandChatBtn" title="Expand chat">
1492          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 5l7 7-7 7"/><path d="M5 5l7 7-7 7"/></svg>
1493        </button>
1494        <button class="expand-btn" id="resetPanelsBtn" title="Reset">
1495          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/></svg>
1496        </button>
1497        <button class="expand-btn" id="expandViewportBtn" title="Expand viewport">
1498          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19l-7-7 7-7"/><path d="M19 19l-7-7 7-7"/></svg>
1499        </button>
1500      </div>
1501    </div>
1502
1503    <!-- Viewport Panel -->
1504    <div class="viewport-panel glass-panel" id="viewportPanel">
1505      ${dashboardHTML()}
1506      <div class="iframe-container" id="iframeContainer">
1507        <div class="loading-overlay" id="loadingOverlay">
1508          <div class="loading-spinner">
1509            <div class="spinner-ring"></div>
1510            <span class="loading-text">Loading...</span>
1511          </div>
1512        </div>
1513        <iframe id="viewport" src="${req.query.rootId ? `/api/v1/root/${req.query.rootId}?html&token=${htmlShareToken}&inApp=1` : `/api/v1/user/${req.userId}?html&token=${htmlShareToken}&inApp=1`}" sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-downloads allow-top-navigation-by-user-activation allow-top-navigation"></iframe>
1514      </div>
1515    </div>
1516  </div>
1517
1518  <!-- Collapsed chat tab -->
1519  <div class="mobile-chat-tab" id="mobileChatTab">🌳</div>
1520
1521  <div class="mobile-backdrop" id="mobileBackdrop"></div>
1522
1523  <div class="mobile-chat-sheet" id="mobileChatSheet">
1524    <div class="mobile-sheet-header" id="mobileSheetHeader">
1525      <div class="drag-handle"></div>
1526      <div class="mobile-sheet-title-row">
1527        <div class="mobile-sheet-title">
1528          <a href="/app" class="mobile-tree-icon-wrapper" title="Back to TreeOS">
1529            <div class="mobile-status-indicator connecting" id="mobileStatusIndicator"></div>
1530            <span class="tree-icon">🌳</span>
1531          </a>
1532          <span class="root-name-inline mobile-root-path" id="mobileRootNameLabel" title=""></span>
1533        </div>
1534        <div class="mobile-header-actions">
1535          <button class="clear-chat-btn mobile-dash-btn" id="mobileDashboardBtn" title="Session Manager">
1536            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
1537          </button>
1538          <button class="clear-chat-btn" id="mobileHomeBtn" title="Home">
1539            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
1540          </button>
1541          <button class="clear-chat-btn" id="mobileRefreshBtn" title="Refresh">
1542            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
1543          </button>
1544          <button class="clear-chat-btn" id="mobileClearChatBtn" title="Clear conversation">
1545            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
1546          </button>
1547          <button class="clear-chat-btn" id="mobileCustomAiBtn" title="LLM Connections">🤖</button>
1548          <button class="mobile-close-btn" id="mobileCloseBtn">
1549            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
1550          </button>
1551        </div>
1552      </div>
1553      <!-- Mobile mode bar (horizontal pill row) -->
1554      <div class="mobile-mode-bar" id="mobileModeBar"></div>
1555      <!-- Mobile recent roots dropdown -->
1556      <div class="mobile-recent-roots" id="mobileRecentRoots">
1557        <div class="mobile-recent-roots-toggle" id="mobileRecentRootsToggle">
1558          <span>Recent Trees</span>
1559          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>
1560        </div>
1561        <div class="mobile-recent-roots-list" id="mobileRecentRootsList"></div>
1562      </div>
1563    </div>
1564    <div class="mobile-chat-messages" id="mobileChatMessages">
1565      <div class="welcome-message">
1566        <div class="welcome-icon">🌳</div>
1567        <h2>Welcome to Tree</h2>
1568        <p>Your intelligent workspace is ready.</p>
1569      </div>
1570    </div>
1571    <div class="mobile-chat-input-area">
1572      <div class="input-container">
1573        <textarea class="chat-input" id="mobileSheetInput" placeholder="Message Tree..." rows="1"></textarea>
1574        <button class="send-btn" id="mobileSheetSendBtn" disabled>
1575          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
1576        </button>
1577      </div>
1578    </div>
1579  </div>
1580
1581  <script src="/socket.io/socket.io.js"></script>
1582  <script>
1583    // Config from server
1584    const CONFIG = {
1585      userId: "${req.userId}",
1586      username: "${username || req.userId}",
1587      htmlShareToken: "${htmlShareToken}",
1588      homeUrl: "/api/v1/user/${req.userId}?html&token=${htmlShareToken}&inApp=1",
1589      hasLlm: ${hasLlm}
1590    };
1591
1592    // Elements
1593    const $ = (id) => document.getElementById(id);
1594    const chatMessages = $("chatMessages");
1595    const chatInput = $("chatInput");
1596    const sendBtn = $("sendBtn");
1597    const statusDot = $("statusDot");
1598    const statusText = $("statusText");
1599    const mobileStatusIndicator = $("mobileStatusIndicator");
1600    const iframe = $("viewport");
1601    const loadingOverlay = $("loadingOverlay");
1602    const mobileChatMessages = $("mobileChatMessages");
1603    const mobileSheetInput = $("mobileSheetInput");
1604    const mobileSheetSendBtn = $("mobileSheetSendBtn");
1605    const mobileChatSheet = $("mobileChatSheet");
1606    const mobileBackdrop = $("mobileBackdrop");
1607    const mobileSheetHeader = $("mobileSheetHeader");
1608    const mobileChatTab = $("mobileChatTab");
1609
1610    // Recent roots elements
1611    const recentRootsDropdown = $("recentRootsDropdown");
1612    const recentRootsTrigger = $("recentRootsTrigger");
1613    const recentRootsList = $("recentRootsList");
1614    const mobileRecentRoots = $("mobileRecentRoots");
1615    const mobileRecentRootsToggle = $("mobileRecentRootsToggle");
1616    const mobileRecentRootsList = $("mobileRecentRootsList");
1617
1618    // State
1619    let isConnected = false;
1620    let isRegistered = false;
1621    let isSending = false;
1622    let currentIframeUrl = CONFIG.homeUrl;
1623
1624    // Mobile sheet state: 'closed' | 'peeked' | 'open'
1625    let mobileSheetState = 'closed';
1626
1627    // Mode state
1628    let currentModeKey = null;
1629    let availableModes = [];
1630    let modeBarOpen = false;
1631    let requestGeneration = 0;
1632
1633    // Recent roots state
1634    let recentRoots = [];
1635    let recentRootsOpen = false;
1636    let mobileRecentRootsExpanded = false;
1637// Build iframe URL — always injects inApp, token, and rootId when available
1638function buildIframeUrl(raw) {
1639  try {
1640    const base = raw.startsWith('http') ? raw : new URL(raw, window.location.origin).href;
1641    const u = new URL(base);
1642    if (!u.searchParams.has('inApp'))  u.searchParams.set('inApp', '1');
1643    if (!u.searchParams.has('token'))  u.searchParams.set('token', CONFIG.htmlShareToken);
1644    const rootId = getCurrentRootId();
1645    if (rootId && !u.pathname.includes('/root/')) {
1646      u.searchParams.set('rootId', rootId);
1647    } else {
1648      u.searchParams.delete('rootId');
1649    }
1650    return u.pathname + u.search;
1651  } catch (e) {
1652    return raw;
1653  }
1654}
1655    // Socket setup
1656    const socket = io({ transports: ["websocket", "polling"], withCredentials: true });
1657
1658    socket.on("connect", () => {
1659      console.log("[socket] connected:", socket.id);
1660      isConnected = true;
1661      socket.emit("ready");
1662      updateStatus("connecting");
1663      socket.emit("register", { username: CONFIG.username });
1664    });
1665
1666    socket.on("registered", ({ success, error }) => {
1667      if (success) {
1668        isRegistered = true;
1669        updateStatus("connected");
1670        console.log("[socket] registered for chat");
1671        let currentUrl = "";
1672        try { currentUrl = iframe.contentWindow?.location?.pathname + iframe.contentWindow?.location?.search; } catch(e) {}
1673        if (!currentUrl) {
1674          try { const u = new URL(iframe.src); currentUrl = u.pathname + u.search; } catch(e) {}
1675        }
1676        if (!currentUrl) {
1677          currentUrl = currentIframeUrl || "";
1678        }
1679        socket.emit("getAvailableModes", { url: currentUrl });
1680        socket.emit("getRecentRoots");
1681        if (currentUrl) detectIframeUrlChange();
1682      } else {
1683        console.error("[socket] registration failed:", error);
1684        updateStatus("connected");
1685        addMessage("Chat registration failed: " + (error || "Unknown error") + ". You can still browse your tree.", "error");
1686      }
1687    });
1688
1689    socket.on("chatResponse", ({ answer, generation }) => {
1690      if (generation !== undefined && generation < requestGeneration) {
1691        console.log("[socket] dropping stale response, gen:", generation, "current:", requestGeneration);
1692        return;
1693      }
1694      removeTypingIndicator();
1695      addMessage(answer, "assistant");
1696      isSending = false;
1697      updateSendButtons();
1698      lockModeBar(false);
1699    });
1700
1701    socket.on("chatError", ({ error, generation }) => {
1702      if (generation !== undefined && generation < requestGeneration) return;
1703      removeTypingIndicator();
1704      addMessage("Error: " + error, "error");
1705      isSending = false;
1706      updateSendButtons();
1707      lockModeBar(false);
1708
1709      // Glow the LLM button after a delay so they read the error first
1710      if (error && (error.includes("/setup") || error.includes("LLM connection"))) {
1711        setTimeout(function() {
1712          var glowBtns = [$("desktopCustomAiBtn"), $("mobileCustomAiBtn")];
1713          glowBtns.forEach(function(btn) {
1714            if (!btn) return;
1715            btn.classList.remove("llm-glow");
1716            void btn.offsetWidth;
1717            btn.classList.add("llm-glow");
1718            btn.addEventListener("animationend", function() { btn.classList.remove("llm-glow"); }, { once: true });
1719          });
1720        }, 2500);
1721      }
1722    });
1723
1724    // Session killed from session manager while chat was in-flight
1725    socket.on("chatCancelled", () => {
1726      if (isSending) {
1727        removeTypingIndicator();
1728        isSending = false;
1729        lockModeBar(false);
1730        updateSendButtons();
1731      }
1732    });
1733
1734socket.on("navigate", ({ url, replace }) => {
1735    console.log("[socket] navigate:", url);
1736    loadingOverlay.classList.add("visible");
1737    currentIframeUrl = url;
1738    let navUrl = buildIframeUrl(url);
1739    if (replace) {
1740      iframe.contentWindow?.location.replace(navUrl);
1741    } else {
1742      iframe.src = navUrl;
1743    }
1744  });
1745
1746    // ── Navigator session indicator ──────────────────────────────────
1747    const navIndicators = [
1748      document.getElementById("navigatorIndicatorDesktop"),
1749      document.getElementById("navigatorIndicatorMobile"),
1750    ];
1751    const navBadges = [
1752      document.getElementById("navigatorBadgeDesktop"),
1753      document.getElementById("navigatorBadgeMobile"),
1754    ];
1755    const navLabels = [
1756      document.getElementById("navigatorLabelDesktop"),
1757      document.getElementById("navigatorLabelMobile"),
1758    ];
1759
1760    const sessionTypeLabels = {
1761      "websocket-chat": "chat",
1762      "api-tree-chat": "api chat",
1763      "api-tree-place": "api place",
1764      "raw-idea-orchestrate": "raw idea",
1765      "raw-idea-chat": "raw idea chat",
1766      "understanding-orchestrate": "understand",
1767      "scheduled-raw-idea": "scheduled",
1768    };
1769
1770    let navFlashTimer = null;
1771    let currentNavSessionId = null;
1772    socket.on("navigatorSession", (data) => {
1773      if (data && data.sessionId) {
1774        const label = sessionTypeLabels[data.type] || data.type || "session";
1775        navLabels.forEach(el => { if (el) el.textContent = label; });
1776        navIndicators.forEach(el => { if (el) el.classList.add("active"); });
1777        // Only reveal when navigator actually changes (new session or added from nothing)
1778        if (data.sessionId !== currentNavSessionId) {
1779          currentNavSessionId = data.sessionId;
1780          navBadges.forEach(el => { if (el) el.classList.add("reveal"); });
1781          if (navFlashTimer) clearTimeout(navFlashTimer);
1782          navFlashTimer = setTimeout(() => {
1783            navBadges.forEach(el => { if (el) el.classList.remove("reveal"); });
1784          }, 3000);
1785        }
1786      } else {
1787        currentNavSessionId = null;
1788        navIndicators.forEach(el => { if (el) el.classList.remove("active"); });
1789        navBadges.forEach(el => { if (el) el.classList.remove("reveal"); });
1790        if (navFlashTimer) clearTimeout(navFlashTimer);
1791      }
1792    });
1793
1794    document.getElementById("navigatorBadgeDesktop").addEventListener("click", () => {
1795      socket.emit("detachNavigator");
1796    });
1797
1798    // Mobile: first tap expands, second tap (when expanded) detaches
1799    let mobileNavExpanded = false;
1800    let mobileNavCollapseTimer = null;
1801    document.getElementById("navigatorBadgeMobile").addEventListener("click", () => {
1802      const badge = document.getElementById("navigatorBadgeMobile");
1803      if (mobileNavExpanded) {
1804        // Already expanded — detach
1805        mobileNavExpanded = false;
1806        if (mobileNavCollapseTimer) clearTimeout(mobileNavCollapseTimer);
1807        badge.classList.remove("reveal");
1808        socket.emit("detachNavigator");
1809      } else {
1810        // First tap — expand to show session name
1811        mobileNavExpanded = true;
1812        badge.classList.add("reveal");
1813        if (mobileNavCollapseTimer) clearTimeout(mobileNavCollapseTimer);
1814        mobileNavCollapseTimer = setTimeout(() => {
1815          mobileNavExpanded = false;
1816          badge.classList.remove("reveal");
1817        }, 4000);
1818      }
1819    });
1820
1821    socket.on("reload", () => {
1822      loadingOverlay.classList.add("visible");
1823      iframe.contentWindow?.location.reload();
1824    });
1825
1826    socket.on("disconnect", () => {
1827      isConnected = false;
1828      isRegistered = false;
1829      updateStatus("disconnected");
1830      navIndicators.forEach(el => { if (el) el.classList.remove("active"); });
1831
1832      [chatMessages, mobileChatMessages].forEach(container => {
1833        container.innerHTML = '<div class="welcome-message disconnected"><div class="welcome-icon">🌳</div><h2>Disconnected</h2><p>You have been disconnected from TreeOS. Please refresh the whole website to reconnect.</p></div>';
1834      });
1835    });
1836
1837    // ================================================================
1838    // Recent Roots
1839    // ================================================================
1840
1841    socket.on("recentRoots", ({ roots }) => {
1842      console.log("[socket] recent roots:", roots);
1843      recentRoots = roots || [];
1844      renderRecentRoots();
1845    });
1846var _initParams = new URLSearchParams(window.location.search);
1847let activeRootId = _initParams.get("rootId") || null;
1848if (activeRootId) window.history.replaceState({}, "", "/dashboard");
1849
1850   function getCurrentRootId() {
1851  if (activeRootId) return activeRootId;
1852  // Fallback: try to extract from URL
1853  const ID = '(?:[a-f0-9]{24}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})';
1854  const rootMatch = currentIframeUrl.match(new RegExp('(?:/api/v1)?/root/(' + ID + ')', 'i'));
1855  return rootMatch ? rootMatch[1] : null;
1856}
1857
1858    function truncateName(str, maxLen = 18) {
1859      if (!str) return '';
1860      return str.length > maxLen ? str.slice(0, maxLen) + '…' : str;
1861    }
1862
1863    function renderRecentRoots() {
1864      const currentRootId = getCurrentRootId();
1865
1866      // Hide if no roots
1867      if (recentRoots.length === 0) {
1868        recentRootsDropdown.classList.add("hidden");
1869        mobileRecentRoots.classList.remove("visible");
1870        return;
1871      }
1872
1873      // Show dropdown trigger
1874      recentRootsDropdown.classList.remove("hidden");
1875      mobileRecentRoots.classList.add("visible");
1876      mobileRecentRoots.classList.toggle("expanded", mobileRecentRootsExpanded);
1877
1878      // Render list HTML (truncated names, no emoji)
1879      const listHtml = recentRoots.map(root => {
1880        const isActive = root.rootId === currentRootId;
1881        return \`
1882          <button class="recent-root-item\${isActive ? ' active' : ''}" data-root-id="\${root.rootId}">
1883            <span class="recent-root-name">\${escapeHtml(truncateName(root.name))}</span>
1884          </button>
1885        \`;
1886      }).join('');
1887
1888      recentRootsList.innerHTML = listHtml;
1889      mobileRecentRootsList.innerHTML = listHtml;
1890
1891      // Add click handlers
1892      [recentRootsList, mobileRecentRootsList].forEach(list => {
1893        list.querySelectorAll('.recent-root-item').forEach(item => {
1894          item.addEventListener('click', (e) => {
1895            e.preventDefault();
1896            e.stopPropagation();
1897            const rootId = item.dataset.rootId;
1898            if (rootId) {
1899              navigateToRoot(rootId);
1900              closeRecentRoots();
1901              // On mobile, just collapse recent trees, not the whole sheet
1902              if (window.innerWidth <= 768) {
1903                mobileRecentRootsExpanded = false;
1904                mobileRecentRoots.classList.remove("expanded");
1905              }
1906            }
1907          });
1908        });
1909      });
1910    }
1911
1912  function navigateToRoot(rootId) {
1913    activeRootId = rootId;
1914
1915  const url = '/api/v1/root/' + rootId + '?html&token=' + CONFIG.htmlShareToken + '&inApp=1';
1916  loadingOverlay.classList.add("visible");
1917  iframe.src = url;
1918  currentIframeUrl = '/api/v1/root/' + rootId;
1919}
1920
1921    function closeRecentRoots() {
1922      recentRootsOpen = false;
1923      recentRootsDropdown.classList.remove("open");
1924    }
1925
1926    function toggleRecentRoots() {
1927      recentRootsOpen = !recentRootsOpen;
1928      recentRootsDropdown.classList.toggle("open", recentRootsOpen);
1929    }
1930
1931    // Desktop trigger click
1932    recentRootsTrigger.addEventListener("click", (e) => {
1933      e.stopPropagation();
1934      toggleRecentRoots();
1935    });
1936
1937    // Mobile toggle
1938    mobileRecentRootsToggle.addEventListener("click", (e) => {
1939      e.preventDefault();
1940      e.stopPropagation();
1941      e.stopImmediatePropagation();
1942      mobileRecentRootsExpanded = !mobileRecentRootsExpanded;
1943      mobileRecentRoots.classList.toggle("expanded", mobileRecentRootsExpanded);
1944    });
1945
1946    // Close recent roots when clicking outside
1947    document.addEventListener("click", (e) => {
1948      if (recentRootsOpen && !recentRootsDropdown.contains(e.target)) {
1949        closeRecentRoots();
1950      }
1951      if (modeBarOpen && !$("modeBar").contains(e.target)) {
1952        closeModeBar();
1953      }
1954    });
1955
1956    // Close recent roots when focusing chat input
1957    chatInput.addEventListener("focus", () => {
1958      closeRecentRoots();
1959    });
1960
1961    // Close mobile recent roots when focusing input
1962    mobileSheetInput.addEventListener("focus", () => {
1963      mobileRecentRootsExpanded = false;
1964      mobileRecentRoots.classList.remove("expanded");
1965    });
1966
1967    // ================================================================
1968    // Mode switching socket events
1969    // ================================================================
1970
1971    socket.on("modeSwitched", ({ modeKey, emoji, label, alert, carriedMessages, silent }) => {
1972      console.log("[mode] switched to:", modeKey, silent ? "(silent)" : "", "carried:", carriedMessages?.length || 0);
1973      currentModeKey = modeKey;
1974      $("modeCurrentEmoji").textContent = emoji;
1975      $("modeCurrentLabel").textContent = label;
1976      const bigMode = modeKey.split(":")[0];
1977      if (availableModes.length && availableModes[0].key.startsWith(bigMode + ":")) {
1978        renderModeDropdown();
1979        renderMobileModeBar();
1980      }
1981      if (!silent) {
1982        if (isSending) {
1983          isSending = false;
1984          removeTypingIndicator();
1985          lockModeBar(false);
1986          updateSendButtons();
1987        }
1988        clearChatUI(carriedMessages || [], modeKey, emoji);
1989        showModeAlert(emoji, label);
1990      }
1991    });
1992
1993   socket.on("availableModes", ({ bigMode, modes, currentMode, rootName, rootId }) => {
1994  console.log("[mode] available:", bigMode, modes, "root:", rootName, rootId);
1995  availableModes = modes || [];
1996  if (currentMode) currentModeKey = currentMode;
1997
1998  // Sync activeRootId from server — this is the source of truth
1999  if (rootId) {
2000    activeRootId = rootId;
2001  } else if (bigMode === 'home') {
2002    activeRootId = null;
2003  }
2004
2005  const active = availableModes.find(m => m.key === currentModeKey);
2006  if (active) {
2007    $("modeCurrentEmoji").textContent = active.emoji;
2008    $("modeCurrentLabel").textContent = active.label;
2009  }
2010  renderModeDropdown();
2011  renderMobileModeBar();
2012
2013  const isTree = bigMode === 'tree';
2014  $("modeBar").style.display = isTree ? 'none' : '';
2015  $("mobileModeBar").style.display = isTree ? 'none' : '';
2016  updateRootName(rootName);
2017});
2018
2019    socket.on("conversationCleared", () => {
2020      console.log("[socket] conversation manually cleared");
2021      clearChatUI([], currentModeKey);
2022    });
2023
2024    // ================================================================
2025    // Mode bar logic (desktop)
2026    // ================================================================
2027
2028    function renderModeDropdown() {
2029      const dropdown = $("modeDropdown");
2030      dropdown.innerHTML = "";
2031      availableModes.forEach(mode => {
2032        const btn = document.createElement("button");
2033        btn.className = "mode-option" + (mode.key === currentModeKey ? " active" : "");
2034        btn.innerHTML = '<span class="mode-option-emoji">' + mode.emoji + '</span><span>' + mode.label + '</span>';
2035        btn.addEventListener("click", (e) => {
2036          e.stopPropagation();
2037          if (mode.key !== currentModeKey) {
2038            socket.emit("switchMode", { modeKey: mode.key });
2039          }
2040          closeModeBar();
2041        });
2042        dropdown.appendChild(btn);
2043      });
2044    }
2045
2046    function toggleModeBar() {
2047      modeBarOpen = !modeBarOpen;
2048      $("modeBar").classList.toggle("open", modeBarOpen);
2049    }
2050
2051    function closeModeBar() {
2052      modeBarOpen = false;
2053      $("modeBar").classList.remove("open");
2054    }
2055
2056    $("modeCurrent").addEventListener("click", (e) => {
2057      e.stopPropagation();
2058      toggleModeBar();
2059    });
2060
2061    // Prevent clicks inside dropdown from toggling mode bar
2062    $("modeDropdown").addEventListener("click", (e) => {
2063      e.stopPropagation();
2064    });
2065
2066    // ================================================================
2067    // Lock/unlock mode bar while AI is responding
2068    // ================================================================
2069
2070    const SEND_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>';
2071    const STOP_SVG = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
2072
2073    function lockModeBar(locked) {
2074      $("modeBar").classList.toggle("locked", locked);
2075      document.querySelectorAll(".mobile-mode-btn").forEach(btn => {
2076        btn.classList.toggle("locked", locked);
2077      });
2078      [sendBtn, mobileSheetSendBtn].forEach(btn => {
2079        btn.classList.toggle("stop-mode", locked);
2080        btn.innerHTML = locked ? STOP_SVG : SEND_SVG;
2081        if (locked) btn.disabled = false;
2082      });
2083    }
2084
2085    function cancelRequest() {
2086      if (!isSending) return;
2087      requestGeneration++;
2088      isSending = false;
2089      removeTypingIndicator();
2090      lockModeBar(false);
2091      updateSendButtons();
2092      socket.emit("cancelRequest");
2093    }
2094
2095    function updateRootName(name) {
2096      ["rootNameLabel", "mobileRootNameLabel"].forEach(id => {
2097        const el = $(id);
2098        if (name) {
2099          const changed = el.textContent !== name;
2100          el.textContent = name;
2101          el.title = name;
2102          el.classList.add("visible");
2103          if (changed) {
2104            el.classList.remove("fade-in");
2105            void el.offsetWidth;
2106            el.classList.add("fade-in");
2107          }
2108        } else {
2109          el.classList.remove("visible", "fade-in");
2110          el.textContent = "";
2111          el.title = "";
2112        }
2113      });
2114    }
2115
2116    // ================================================================
2117    // Mobile mode bar (horizontal pills in sheet header)
2118    // ================================================================
2119
2120    function renderMobileModeBar() {
2121      const bar = $("mobileModeBar");
2122      bar.innerHTML = "";
2123      availableModes.forEach(mode => {
2124        const btn = document.createElement("button");
2125        btn.className = "mobile-mode-btn" + (mode.key === currentModeKey ? " active" : "");
2126        btn.dataset.modeKey = mode.key;
2127        btn.innerHTML = '<span class="mobile-mode-btn-emoji">' + mode.emoji + '</span><span>' + mode.label + '</span>';
2128        btn.addEventListener("click", (e) => {
2129          e.stopPropagation();
2130          if (mode.key !== currentModeKey) {
2131            socket.emit("switchMode", { modeKey: mode.key });
2132          }
2133        });
2134        bar.appendChild(btn);
2135      });
2136      scrollToActiveMode();
2137    }
2138
2139    function scrollToActiveMode() {
2140      const bar = $("mobileModeBar");
2141      const activeBtn = bar.querySelector(".mobile-mode-btn.active");
2142      if (activeBtn) {
2143        const barRect = bar.getBoundingClientRect();
2144        const btnRect = activeBtn.getBoundingClientRect();
2145        const scrollLeft = activeBtn.offsetLeft - (barRect.width / 2) + (btnRect.width / 2);
2146        bar.scrollTo({ left: Math.max(0, scrollLeft), behavior: "smooth" });
2147      }
2148    }
2149
2150    // ================================================================
2151    // Mode alert toast
2152    // ================================================================
2153
2154    let modeAlertTimer = null;
2155    function showModeAlert(emoji, label) {
2156     //handled behind scenes
2157    }
2158
2159    // ================================================================
2160    // Clear chat UI helper
2161    // ================================================================
2162
2163    const MODE_WELCOMES = {
2164      "home:default": { icon: "🌳", title: "Welcome to TreeOS", desc: "Your intelligent workspace is ready — build, explore, and reflect" },
2165      "home:raw-idea-placement": { icon: "💡", title: "Raw Ideas", desc: "Capture unstructured thoughts and gradually grow them into trees (work in progress)" },
2166      "home:reflect": { icon: "🔮", title: "Reflect", desc: "Review your notes, tags, and contributions across all your trees" },
2167      "tree:structure": { icon: "🏗️", title: "Structure Mode", desc: "Create, reorganize, and grow the overall shape of your tree" },
2168      "tree:be": { icon: "🎯", title: "Be Mode", desc: "Focus on one active leaf at a time and work through it step by step" },
2169      "tree:reflect": { icon: "🔮", title: "Reflect Mode", desc: "Look at your tree as a whole to spot gaps, patterns, and opportunities" },
2170      "tree:edit": { icon: "✏️", title: "Edit Mode", desc: "Refine names, values, notes, and details within your tree" }
2171    };
2172
2173    function clearChatUI(carriedMessages, modeKey, emoji) {
2174      const valid = (carriedMessages || []).filter(m => m.content && m.content.trim());
2175      const welcome = MODE_WELCOMES[modeKey] || { icon: "🌳" || "🌳", title: "Ready?", desc: "Let's grow." };
2176
2177      [chatMessages, mobileChatMessages].forEach(container => {
2178        container.innerHTML = '';
2179
2180        if (valid.length > 0) {
2181          valid.forEach(msg => {
2182            const el = document.createElement("div");
2183            el.className = "message " + msg.role + " carried";
2184            const formattedContent = msg.role === "assistant" ? formatMessageContent(msg.content) : escapeHtml(msg.content);
2185            el.innerHTML =
2186              '<div class="message-avatar">' + (msg.role === "user" ? "👤" : "🌳") + '</div>' +
2187              '<div class="message-content">' + formattedContent + '</div>';
2188            container.appendChild(el);
2189          });
2190          container.scrollTop = container.scrollHeight;
2191        } else {
2192          container.innerHTML = '<div class="welcome-message"><div class="welcome-icon">' + welcome.icon + '</div><h2>' + welcome.title + '</h2><p>' + welcome.desc + '</p></div>';
2193        }
2194      });
2195    }
2196
2197    // ================================================================
2198    // iframe URL change detection
2199    // ================================================================
2200
2201    let lastEmittedUrl = "";
2202    function detectIframeUrlChange() {
2203      let path = "";
2204
2205      try {
2206        const loc = iframe.contentWindow?.location;
2207        if (loc) path = loc.pathname + loc.search;
2208      } catch (e) {}
2209
2210      if (!path) {
2211        try { const u = new URL(iframe.src); path = u.pathname + u.search; } catch(e) {}
2212      }
2213
2214      if (!path) {
2215        path = currentIframeUrl || "";
2216      }
2217
2218      if (path && path !== lastEmittedUrl) {
2219        lastEmittedUrl = path;
2220        currentIframeUrl = path;
2221        const ID = '(?:[a-f0-9]{24}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})';
2222        let rootId = null;
2223        let nodeId = null;
2224        const rootMatch = path.match(new RegExp('(?:/api/v1)?/root/(' + ID + ')', 'i'));
2225        const bareMatch = path.match(new RegExp('(?:/api/v1)?/(' + ID + ')(?:[?/]|$)', 'i'));
2226        if (rootMatch) rootId = rootMatch[1];
2227        else if (bareMatch) nodeId = bareMatch[1];
2228
2229        if (isRegistered) {
2230          socket.emit("urlChanged", { url: path, rootId, nodeId });
2231        }
2232if (rootMatch) {
2233  rootId = rootMatch[1];
2234  activeRootId = rootId;  // <-- add this
2235}
2236        // Re-render recent roots to update active state
2237        renderRecentRoots();
2238      }
2239    }
2240
2241    // Status
2242    function updateStatus(status) {
2243      statusDot.className = "status-dot " + status;
2244      mobileStatusIndicator.className = "mobile-status-indicator " + status;
2245      statusText.textContent = status === "connected" ? "Connected" : status === "connecting" ? "Connecting..." : "Disconnected";
2246      isConnected = status === "connected";
2247    }
2248
2249    // Format message content with markdown-like parsing
2250    function formatMessageContent(text) {
2251      if (!text) return '';
2252      
2253      let html = text;
2254      
2255      html = html.replace(/&nbsp;/g, ' ');
2256      html = html.replace(/&amp;/g, '&');
2257      html = html.replace(/&lt;/g, '<');
2258      html = html.replace(/&gt;/g, '>');
2259      html = html.replace(/\\u00A0/g, ' ');
2260      html = html.replace(/–/g, '-');
2261      html = html.replace(/—/g, '--');
2262      
2263      html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2264      
2265      const tableRegex = /^\\|(.+)\\|\\s*\\n\\|[-:\\s|]+\\|\\s*\\n((?:\\|.+\\|\\s*\\n?)+)/gm;
2266      html = html.replace(tableRegex, (match, headerRow, bodyRows) => {
2267        const rows = bodyRows.trim().split('\\n').map(row => 
2268          row.split('|').map(cell => cell.trim()).filter(cell => cell)
2269        );
2270        let items = '';
2271        rows.forEach(row => {
2272          if (row.length >= 2) {
2273            const num = row[0];
2274            const name = row[1];
2275            if (/^\\d{1,2}$/.test(num)) {
2276              items += '<div class="menu-item clickable" data-action="' + num + '" data-name="' + name.replace(/"/g, '&quot;') + '">' +
2277                '<span class="menu-number">' + num + '</span>' +
2278                '<span class="menu-text">' + name + '</span></div>';
2279            } else {
2280              items += '<div class="menu-item">' +
2281                '<span class="menu-number">•</span>' +
2282                '<span class="menu-text">' + name + '</span></div>';
2283            }
2284          }
2285        });
2286        return items;
2287      });
2288      
2289      html = html.replace(/^\\|\\s*(\\d{1,2})\\s*\\|\\s*(.+?)\\s*\\|\\s*$/gm, (match, num, name) => {
2290        return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + name.replace(/"/g, '&quot;') + '">' +
2291          '<span class="menu-number">' + num + '</span>' +
2292          '<span class="menu-text">' + name + '</span></div>';
2293      });
2294      
2295      html = html.replace(/^\\|\\s*#\\s*\\|.*\\|\\s*$/gm, '');
2296      html = html.replace(/^\\|[-:\\s|]+\\|\\s*$/gm, '');
2297      
2298      html = html.replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, '<pre><code>$1</code></pre>');
2299      html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
2300      
2301      html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
2302      html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
2303      
2304      html = html.replace(/(?<![\\w\\*])\\*([^\\*]+)\\*(?![\\w\\*])/g, '<em>$1</em>');
2305      
2306      html = html.replace(/^####\\s*(.+)$/gm, '<h4>$1</h4>');
2307      html = html.replace(/^###\\s*(.+)$/gm, '<h3>$1</h3>');
2308      html = html.replace(/^##\\s*(.+)$/gm, '<h2>$1</h2>');
2309      html = html.replace(/^#\\s*(.+)$/gm, '<h1>$1</h1>');
2310      
2311      html = html.replace(/^-{3,}$/gm, '<hr>');
2312      html = html.replace(/^\\*{3,}$/gm, '<hr>');
2313      
2314      html = html.replace(/^&gt;\\s*(.+)$/gm, '<blockquote>$1</blockquote>');
2315      
2316      html = html.replace(/^([1-9]️⃣)\\s*<strong>(.+?)<\\/strong>(.*)$/gm, (m, emoji, title, rest) => {
2317        const num = emoji.match(/[1-9]/)?.[0] || '1';
2318        return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + title.replace(/"/g, '&quot;') + '">' +
2319          '<span class="menu-number">' + num + '</span>' +
2320          '<span class="menu-text"><strong>' + title + '</strong>' + rest + '</span></div>';
2321      });
2322      html = html.replace(/^([1-9]️⃣)\\s*(.+)$/gm, (m, emoji, text) => {
2323        const num = emoji.match(/[1-9]/)?.[0] || '1';
2324        return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + text.replace(/"/g, '&quot;') + '">' +
2325          '<span class="menu-number">' + num + '</span>' +
2326          '<span class="menu-text">' + text + '</span></div>';
2327      });
2328      
2329      html = html.replace(/^([1-9]|1[0-9]|20)\\.\\s*<strong>(.+?)<\\/strong>(.*)$/gm, (m, num, title, rest) => {
2330        return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + title.replace(/"/g, '&quot;') + '">' +
2331          '<span class="menu-number">' + num + '</span>' +
2332          '<span class="menu-text"><strong>' + title + '</strong>' + rest + '</span></div>';
2333      });
2334      
2335      html = html.replace(/^[-–•]\\s*<strong>(.+?)<\\/strong>(.*)$/gm, 
2336        '<div class="menu-item"><span class="menu-number">•</span><span class="menu-text"><strong>$1</strong>$2</span></div>');
2337      
2338      html = html.replace(/^[-–•]\\s+([^<].*)$/gm, '<li>$1</li>');
2339      
2340      html = html.replace(/^(\\d+)\\.\\s+([^<*].*)$/gm, '<li><span class="list-num">$1.</span> $2</li>');
2341      
2342      let inList = false;
2343      const lines = html.split('\\n');
2344      const processed = [];
2345      for (let i = 0; i < lines.length; i++) {
2346        const line = lines[i];
2347        const isListItem = line.trim().startsWith('<li>');
2348        if (isListItem && !inList) { processed.push('<ul>'); inList = true; }
2349        else if (!isListItem && inList) { processed.push('</ul>'); inList = false; }
2350        processed.push(line);
2351      }
2352      if (inList) processed.push('</ul>');
2353      html = processed.join('\\n');
2354      
2355      html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>');
2356      
2357      const blocks = html.split(/\\n\\n+/);
2358      html = blocks.map(block => {
2359        const trimmed = block.trim();
2360        if (!trimmed) return '';
2361        if (trimmed.match(/^<(h[1-4]|ul|ol|pre|blockquote|hr|div|table)/)) return trimmed;
2362        const withBreaks = trimmed.split('\\n').map(l => l.trim()).filter(l => l).join('<br>');
2363        return '<p>' + withBreaks + '</p>';
2364      }).filter(b => b).join('');
2365      
2366      html = html.replace(/<p><\\/p>/g, '');
2367      html = html.replace(/<p>(<div|<ul|<ol|<h[1-4]|<hr|<pre|<blockquote|<table)/g, '$1');
2368      html = html.replace(/(<\\/div>|<\\/ul>|<\\/ol>|<\\/h[1-4]>|<\\/pre>|<\\/blockquote>|<\\/table>)<\\/p>/g, '$1');
2369      html = html.replace(/<br>(<div|<\\/div>)/g, '$1');
2370      html = html.replace(/(<div[^>]*>)<br>/g, '$1');
2371      
2372      return html;
2373    }
2374
2375    // Messages
2376    function addMessage(content, role) {
2377      [chatMessages, mobileChatMessages].forEach(container => {
2378        const welcome = container.querySelector(".welcome-message");
2379        if (welcome) welcome.remove();
2380
2381        const msg = document.createElement("div");
2382        msg.className = "message " + role;
2383        
2384        const formattedContent = role === "assistant" ? formatMessageContent(content) : escapeHtml(content);
2385        
2386        msg.innerHTML = \`
2387          <div class="message-avatar">\${role === "user" ? "👤" : "🌳"}</div>
2388          <div class="message-content">\${formattedContent}</div>
2389        \`;
2390        
2391        if (role === "assistant") {
2392          msg.querySelectorAll('.menu-item.clickable').forEach(item => {
2393            item.addEventListener('click', () => handleMenuItemClick(item));
2394          });
2395        }
2396        
2397        container.appendChild(msg);
2398        container.scrollTop = container.scrollHeight;
2399      });
2400    }
2401
2402    function handleMenuItemClick(item) {
2403      const action = item.dataset.action;
2404      const name = item.dataset.name;
2405      
2406      if (!action || isSending) return;
2407      
2408      item.classList.add('clicking');
2409      setTimeout(() => item.classList.remove('clicking'), 300);
2410      
2411      sendChatMessage(action);
2412    }
2413
2414    function addTypingIndicator() {
2415      [chatMessages, mobileChatMessages].forEach(container => {
2416        if (container.querySelector(".typing-indicator")) return;
2417        const typing = document.createElement("div");
2418        typing.className = "message assistant";
2419        typing.innerHTML = \`
2420          <div class="message-avatar">🌳</div>
2421          <div class="typing-indicator">
2422            <div class="typing-dot"></div>
2423            <div class="typing-dot"></div>
2424            <div class="typing-dot"></div>
2425          </div>
2426        \`;
2427        container.appendChild(typing);
2428        container.scrollTop = container.scrollHeight;
2429      });
2430    }
2431
2432    function removeTypingIndicator() {
2433      document.querySelectorAll(".typing-indicator").forEach(el => el.closest(".message")?.remove());
2434    }
2435
2436    function escapeHtml(text) {
2437      const div = document.createElement("div");
2438      div.textContent = text;
2439      return div.innerHTML;
2440    }
2441
2442    // Send message
2443    function sendChatMessage(message) {
2444      if (!message.trim() || isSending || !isRegistered) return;
2445
2446      addMessage(message, "user");
2447      addTypingIndicator();
2448      isSending = true;
2449      requestGeneration++;
2450      const thisGen = requestGeneration;
2451      updateSendButtons();
2452      lockModeBar(true);
2453
2454      socket.emit("chat", { message, username: CONFIG.username, generation: thisGen });
2455    }
2456
2457    function updateSendButtons() {
2458      const desktopText = chatInput.value.trim();
2459      const mobileSheetText = mobileSheetInput.value.trim();
2460
2461      sendBtn.disabled = isSending ? false : !(desktopText && isRegistered);
2462      mobileSheetSendBtn.disabled = isSending ? false : !(mobileSheetText && isRegistered);
2463      chatInput.disabled = isSending;
2464      mobileSheetInput.disabled = isSending;
2465    }
2466
2467    // Input handlers - Desktop
2468    chatInput.addEventListener("input", () => {
2469      updateSendButtons();
2470      chatInput.style.height = "auto";
2471      chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + "px";
2472    });
2473
2474    chatInput.addEventListener("keydown", (e) => {
2475      if (e.key === "Enter" && !e.shiftKey) {
2476        e.preventDefault();
2477        const msg = chatInput.value.trim();
2478        if (msg && isRegistered && !isSending) {
2479          sendChatMessage(msg);
2480          chatInput.value = "";
2481          chatInput.style.height = "auto";
2482          updateSendButtons();
2483        }
2484      }
2485    });
2486
2487    sendBtn.addEventListener("click", () => {
2488      if (isSending) {
2489        cancelRequest();
2490        return;
2491      }
2492      const msg = chatInput.value.trim();
2493      if (msg && isRegistered && !isSending) {
2494        sendChatMessage(msg);
2495        chatInput.value = "";
2496        chatInput.style.height = "auto";
2497        updateSendButtons();
2498      }
2499    });
2500
2501    // Mobile handlers
2502    let sheetDragStartY = 0;
2503    let isDraggingSheet = false;
2504    let sheetHeight = 0;
2505    let currentDragY = 0;
2506    let dragStartState = 'closed';
2507
2508    function setMobileSheetState(newState, force = false) {
2509      // Don't re-run if already in this state (unless forced)
2510      if (mobileSheetState === newState && !force) return;
2511      
2512      mobileSheetState = newState;
2513      mobileChatSheet.classList.remove("open", "peeked", "closing");
2514      
2515      if (newState === 'open') {
2516        mobileChatSheet.classList.add("open");
2517        mobileBackdrop.classList.add("visible");
2518        mobileChatTab.classList.add("hidden");
2519        setTimeout(() => {
2520          mobileSheetInput.focus();
2521          updateSendButtons();
2522          scrollToActiveMode();
2523        }, 350);
2524      } else if (newState === 'peeked') {
2525        mobileChatSheet.classList.add("peeked");
2526        mobileBackdrop.classList.remove("visible");
2527        mobileChatTab.classList.add("hidden");
2528        mobileSheetInput.blur();
2529      } else {
2530        // closed - go to side tab
2531        mobileChatSheet.classList.add("closing");
2532        mobileBackdrop.classList.remove("visible");
2533        mobileSheetInput.blur();
2534        setTimeout(() => {
2535          mobileChatSheet.classList.remove("closing");
2536          mobileChatTab.classList.remove("hidden");
2537        }, 300);
2538      }
2539    }
2540
2541    function openMobileSheetFull() {
2542      setMobileSheetState('open');
2543    }
2544
2545    function peekMobileSheet() {
2546      setMobileSheetState('peeked');
2547    }
2548
2549    function closeMobileSheet() {
2550      setMobileSheetState('closed');
2551    }
2552
2553    // Side tab opens to full
2554    mobileChatTab.addEventListener("click", (e) => {
2555      e.preventDefault();
2556      e.stopPropagation();
2557      openMobileSheetFull();
2558    });
2559
2560    // Header tap in peeked state opens full (ignore button clicks)
2561    mobileSheetHeader.addEventListener("click", (e) => {
2562      if (mobileSheetState === 'peeked' && !isDraggingSheet && !e.target.closest("button")) {
2563        e.preventDefault();
2564        e.stopPropagation();
2565        openMobileSheetFull();
2566      }
2567    });
2568
2569    mobileSheetInput.addEventListener("input", () => {
2570      updateSendButtons();
2571      mobileSheetInput.style.height = "auto";
2572      mobileSheetInput.style.height = Math.min(mobileSheetInput.scrollHeight, 120) + "px";
2573    });
2574
2575    mobileSheetInput.addEventListener("keydown", (e) => {
2576      if (e.key === "Enter" && !e.shiftKey) {
2577        e.preventDefault();
2578        const msg = mobileSheetInput.value.trim();
2579        if (msg && isRegistered && !isSending) {
2580          sendChatMessage(msg);
2581          mobileSheetInput.value = "";
2582          mobileSheetInput.style.height = "auto";
2583          updateSendButtons();
2584        }
2585      }
2586    });
2587
2588    mobileSheetSendBtn.addEventListener("click", () => {
2589      if (isSending) {
2590        cancelRequest();
2591        return;
2592      }
2593      const msg = mobileSheetInput.value.trim();
2594      if (msg && isRegistered && !isSending) {
2595        sendChatMessage(msg);
2596        mobileSheetInput.value = "";
2597        mobileSheetInput.style.height = "auto";
2598        updateSendButtons();
2599      }
2600    });
2601
2602    // X button always closes to side tab
2603    $("mobileCloseBtn").addEventListener("click", (e) => {
2604      e.stopPropagation();
2605      e.preventDefault();
2606      closeMobileSheet();
2607    });
2608    // Backdrop click closes to side tab
2609    mobileBackdrop.addEventListener("click", closeMobileSheet);
2610
2611    // Sheet drag handling
2612    function handleSheetDragStart(e) {
2613      if (mobileSheetState === 'closed') return;
2614      
2615      const touch = e.touches ? e.touches[0] : e;
2616      sheetDragStartY = touch.clientY;
2617      sheetHeight = mobileChatSheet.offsetHeight;
2618      currentDragY = 0;
2619      isDraggingSheet = true;
2620      dragStartState = mobileSheetState;
2621      mobileChatSheet.classList.add("dragging");
2622    }
2623
2624    function handleSheetDragMove(e) {
2625      if (!isDraggingSheet) return;
2626      
2627      const touch = e.touches ? e.touches[0] : e;
2628      const deltaY = touch.clientY - sheetDragStartY;
2629      
2630      // Calculate base offset based on current state
2631      let baseOffset = 0;
2632      if (dragStartState === 'peeked') {
2633        baseOffset = sheetHeight - 90; // peeked position
2634      }
2635      
2636      if (dragStartState === 'open') {
2637        // Dragging down from open
2638        if (deltaY > 0) {
2639          currentDragY = deltaY;
2640          mobileChatSheet.style.transform = \`translateY(\${deltaY}px)\`;
2641          const progress = Math.min(deltaY / (sheetHeight * 0.5), 1);
2642          mobileBackdrop.style.opacity = String(1 - progress);
2643        } else if (deltaY < 0) {
2644          // Allow slight overdrag up
2645          currentDragY = deltaY;
2646          mobileChatSheet.style.transform = \`translateY(\${Math.max(deltaY, -20)}px)\`;
2647        }
2648      } else if (dragStartState === 'peeked') {
2649        // Dragging from peeked state
2650        currentDragY = deltaY;
2651        const newOffset = Math.max(0, Math.min(baseOffset + deltaY, sheetHeight));
2652        mobileChatSheet.style.transform = \`translateY(\${newOffset}px)\`;
2653        
2654        // Show backdrop when dragging up
2655        if (deltaY < 0) {
2656          const progress = Math.min(Math.abs(deltaY) / (sheetHeight - 90), 1);
2657          mobileBackdrop.style.opacity = String(progress);
2658          mobileBackdrop.classList.add("visible");
2659        }
2660      }
2661    }
2662
2663    function handleSheetDragEnd(e) {
2664      if (!isDraggingSheet) return;
2665      
2666      isDraggingSheet = false;
2667      mobileChatSheet.classList.remove("dragging");
2668      mobileChatSheet.style.transform = "";
2669      mobileBackdrop.style.opacity = "";
2670      
2671      const peekThreshold = sheetHeight - 200; // pixels from top to trigger peek
2672      
2673      if (dragStartState === 'open') {
2674        // From open: drag down far = peek, drag down very far = still peek (not close)
2675        if (currentDragY > peekThreshold) {
2676          peekMobileSheet();
2677        } else if (currentDragY > 50) {
2678          peekMobileSheet();
2679        } else {
2680          setMobileSheetState('open');
2681        }
2682      } else if (dragStartState === 'peeked') {
2683        // From peeked: drag up = open, drag down = stay peeked
2684        if (currentDragY < -50) {
2685          openMobileSheetFull();
2686        } else {
2687          setMobileSheetState('peeked');
2688        }
2689      }
2690      
2691      currentDragY = 0;
2692    }
2693
2694    mobileSheetHeader.addEventListener("touchstart", handleSheetDragStart, { passive: true });
2695    mobileSheetHeader.addEventListener("touchmove", handleSheetDragMove, { passive: true });
2696    mobileSheetHeader.addEventListener("touchend", handleSheetDragEnd, { passive: true });
2697    mobileSheetHeader.addEventListener("touchcancel", handleSheetDragEnd, { passive: true });
2698
2699    let mouseIsDown = false;
2700    mobileSheetHeader.addEventListener("mousedown", (e) => {
2701      mouseIsDown = true;
2702      handleSheetDragStart(e);
2703    });
2704    document.addEventListener("mousemove", (e) => {
2705      if (mouseIsDown && isDraggingSheet) handleSheetDragMove(e);
2706    });
2707    document.addEventListener("mouseup", (e) => {
2708      if (mouseIsDown) {
2709        mouseIsDown = false;
2710        if (isDraggingSheet) handleSheetDragEnd(e);
2711      }
2712    });
2713
2714    // Start in peeked state on mobile
2715    if (window.innerWidth <= 768) {
2716      setTimeout(() => setMobileSheetState('peeked'), 100);
2717    }
2718
2719    // Panel resizing (desktop)
2720    const appContainer = document.querySelector(".app-container");
2721    const chatPanel = $("chatPanel");
2722    const viewportPanel = $("viewportPanel");
2723    const panelDivider = $("panelDivider");
2724    let isDragging = false, dragStartX = 0, dragStartWidth = 0, currentChatWidth = 0;
2725    const MIN_PANEL = 280, DIVIDER = 16, PADDING = 32;
2726
2727    function getAvailable() { return appContainer.clientWidth - PADDING - DIVIDER; }
2728
2729    function setChatWidth(w) {
2730      const avail = getAvailable();
2731      let clamped = Math.max(0, Math.min(w, avail));
2732      if (clamped > 0 && clamped < MIN_PANEL) clamped = 0;
2733      if (avail - clamped > 0 && avail - clamped < MIN_PANEL) clamped = avail;
2734      currentChatWidth = clamped;
2735      chatPanel.style.width = clamped + "px";
2736      chatPanel.classList.toggle("collapsed", clamped === 0);
2737      viewportPanel.classList.toggle("collapsed", avail - clamped === 0);
2738    }
2739
2740    setChatWidth(getAvailable() / 2.5);
2741    window.addEventListener("resize", () => setChatWidth(currentChatWidth));
2742
2743    panelDivider.addEventListener("mousedown", (e) => {
2744      isDragging = true;
2745      dragStartX = e.clientX;
2746      dragStartWidth = currentChatWidth;
2747      appContainer.classList.add("dragging");
2748      e.preventDefault();
2749    });
2750
2751    document.addEventListener("mousemove", (e) => {
2752      if (!isDragging) return;
2753      setChatWidth(dragStartWidth + (e.clientX - dragStartX));
2754    });
2755
2756    document.addEventListener("mouseup", () => {
2757      if (isDragging) {
2758        isDragging = false;
2759        appContainer.classList.remove("dragging");
2760      }
2761    });
2762
2763    $("expandChatBtn").addEventListener("click", () => setChatWidth(getAvailable()));
2764    $("expandViewportBtn").addEventListener("click", () => setChatWidth(0));
2765    $("resetPanelsBtn").addEventListener("click", () => setChatWidth(getAvailable() / 2));
2766
2767    // Clear chat buttons
2768   function handleClearChat() {
2769      if (!isRegistered) return;
2770      if (isSending) cancelRequest();
2771      socket.emit("clearConversation");
2772      clearChatUI([], currentModeKey);
2773      // Navigate iframe back to tree root
2774      const rootId = getCurrentRootId();
2775      if (rootId) {
2776        navigateToRoot(rootId);
2777      } else {
2778        goHome();
2779      }
2780    }
2781
2782    $("clearChatBtn").addEventListener("click", handleClearChat);
2783    $("mobileClearChatBtn").addEventListener("click", (e) => {
2784      e.stopPropagation();
2785      e.preventDefault();
2786      handleClearChat();
2787    });
2788
2789    function getCurrentIframeUrl() {
2790      let url = "";
2791      try {
2792        url = iframe.contentWindow?.location?.href;
2793      } catch (e) {}
2794      if (!url) {
2795        try { url = iframe.src; } catch(e) {}
2796      }
2797      if (!url) {
2798        url = window.location.origin + currentIframeUrl;
2799      }
2800      try {
2801        const u = new URL(url, window.location.origin);
2802        u.searchParams.delete('inApp');
2803        return u.href;
2804      } catch(e) {
2805        return url.replace(/[&?]inApp=1/g, '');
2806      }
2807    }
2808
2809function goHome() {
2810  activeRootId = null;
2811
2812  // Close dashboard if open
2813  if (window.TreeApp && window.TreeApp.closeDashboard) window.TreeApp.closeDashboard();
2814
2815  loadingOverlay.classList.add("visible");
2816  currentIframeUrl = CONFIG.homeUrl;
2817  iframe.src = CONFIG.homeUrl; // home doesn't need rootId
2818}
2819
2820    $("desktopHomeBtn").addEventListener("click", goHome);
2821    $("mobileHomeBtn").addEventListener("click", (e) => {
2822      e.stopPropagation();
2823      e.preventDefault();
2824      goHome();
2825      // Always reset to peeked/dragged-down mode on mobile (use timeout to ensure it happens last)
2826      setTimeout(() => setMobileSheetState('peeked', true), 10);
2827    });
2828
2829    function doRefresh() {
2830      loadingOverlay.classList.add("visible");
2831      iframe.contentWindow?.location.reload();
2832    }
2833
2834    $("desktopRefreshBtn").addEventListener("click", doRefresh);
2835    $("mobileRefreshBtn").addEventListener("click", (e) => {
2836      e.stopPropagation();
2837      e.preventDefault();
2838      doRefresh();
2839    });
2840
2841    function openInNewTab() {
2842      const url = getCurrentIframeUrl();
2843      window.open(url, '_blank');
2844    }
2845
2846    $("desktopOpenTabBtn").addEventListener("click", openInNewTab);
2847
2848    // LLM Connections button — go to /setup if no LLM, else energy page
2849    function goCustomAi() {
2850      if (!CONFIG.hasLlm) {
2851        window.location.href = "/setup";
2852        return;
2853      }
2854      const url = buildIframeUrl('/api/v1/user/' + CONFIG.userId + '/energy?html');
2855      loadingOverlay.classList.add("visible");
2856      currentIframeUrl = url;
2857      iframe.src = url;
2858      // Scroll iframe to bottom once loaded
2859      const onLoad = () => {
2860        iframe.removeEventListener('load', onLoad);
2861        try { iframe.contentWindow.scrollTo(0, iframe.contentDocument.body.scrollHeight); } catch(e) {}
2862      };
2863      iframe.addEventListener('load', onLoad);
2864    }
2865
2866    $("desktopCustomAiBtn").addEventListener("click", goCustomAi);
2867    $("mobileCustomAiBtn").addEventListener("click", (e) => {
2868      e.stopPropagation();
2869      e.preventDefault();
2870      if (mobileSheetState === 'open') {
2871        // In full chat mode on mobile — slide it down to peeked
2872        setTimeout(() => setMobileSheetState('peeked', true), 10);
2873      }
2874      // If already peeked/closed, keep it down (don't open)
2875      goCustomAi();
2876    });
2877
2878    // Iframe
2879   iframe.addEventListener("load", () => {
2880  loadingOverlay.classList.remove("visible");
2881  try {
2882    const loc = iframe.contentWindow?.location;
2883    if (loc) {
2884      currentIframeUrl = loc.pathname + loc.search;
2885    }
2886  } catch (e) {}
2887  detectIframeUrlChange();
2888  injectIframeParamForwarding();
2889});
2890
2891function injectIframeParamForwarding() {
2892  try {
2893    const doc = iframe.contentDocument || iframe.contentWindow?.document;
2894    if (!doc) return;
2895
2896    // Skip if already injected
2897    if (doc._paramForwardingInjected) return;
2898    doc._paramForwardingInjected = true;
2899
2900    // Intercept all clicks on links
2901    doc.addEventListener('click', (e) => {
2902      const anchor = e.target.closest('a');
2903      if (!anchor || !anchor.href) return;
2904
2905      try {
2906        const url = new URL(anchor.href);
2907
2908        // Only rewrite same-origin links
2909        if (url.origin !== window.location.origin) return;
2910
2911        // Add inApp
2912        if (!url.searchParams.has('inApp')) {
2913          url.searchParams.set('inApp', '1');
2914        }
2915
2916        // Add token
2917        if (!url.searchParams.has('token')) {
2918          url.searchParams.set('token', CONFIG.htmlShareToken);
2919        }
2920
2921        // Add rootId if we have one and it's not already a /root/ URL
2922        const rootId = getCurrentRootId();
2923        if (rootId && !url.pathname.includes('/root/')) {
2924          url.searchParams.set('rootId', rootId);
2925        }
2926
2927        anchor.href = url.pathname + url.search;
2928      } catch (err) {
2929        // ignore malformed URLs
2930      }
2931    }, true); // capture phase to run before default
2932
2933    // Also intercept form submissions
2934    doc.addEventListener('submit', (e) => {
2935      const form = e.target;
2936      if (!form || !form.action) return;
2937      try {
2938        const url = new URL(form.action, window.location.origin);
2939        if (url.origin !== window.location.origin) return;
2940
2941        // Inject hidden fields
2942        ['inApp', 'token', 'rootId'].forEach(key => {
2943          if (form.querySelector('input[name="' + key + '"]')) return;
2944          let val;
2945          if (key === 'inApp') val = '1';
2946          else if (key === 'token') val = CONFIG.htmlShareToken;
2947          else if (key === 'rootId') val = getCurrentRootId();
2948          if (!val) return;
2949          const input = doc.createElement('input');
2950          input.type = 'hidden';
2951          input.name = key;
2952          input.value = val;
2953          form.appendChild(input);
2954        });
2955      } catch (err) {}
2956    }, true);
2957
2958    // Intercept programmatic navigation (window.location assignments)
2959    const iframeWindow = iframe.contentWindow;
2960    if (iframeWindow) {
2961      const origPushState = iframeWindow.history.pushState?.bind(iframeWindow.history);
2962      const origReplaceState = iframeWindow.history.replaceState?.bind(iframeWindow.history);
2963
2964      function patchUrl(urlArg) {
2965        if (!urlArg || typeof urlArg !== 'string') return urlArg;
2966        try {
2967          const u = new URL(urlArg, window.location.origin);
2968          if (u.origin !== window.location.origin) return urlArg;
2969          if (!u.searchParams.has('inApp')) u.searchParams.set('inApp', '1');
2970          if (!u.searchParams.has('token')) u.searchParams.set('token', CONFIG.htmlShareToken);
2971          const rootId = getCurrentRootId();
2972          if (rootId && !u.pathname.includes('/root/')) u.searchParams.set('rootId', rootId);
2973          return u.pathname + u.search;
2974        } catch (e) { return urlArg; }
2975      }
2976
2977      if (origPushState) {
2978        iframeWindow.history.pushState = function(state, title, url) {
2979          return origPushState(state, title, patchUrl(url));
2980        };
2981      }
2982      if (origReplaceState) {
2983        iframeWindow.history.replaceState = function(state, title, url) {
2984          return origReplaceState(state, title, patchUrl(url));
2985        };
2986      }
2987    }
2988  } catch (e) {
2989    // Cross-origin or sandbox restriction — can't inject
2990    console.warn('[iframe] param forwarding injection failed:', e.message);
2991  }
2992}
2993
2994    // Socket events
2995    socket.on("treeChanged", ({ nodeId, changeType, details }) => {
2996      console.log("[socket] tree changed:", changeType, nodeId);
2997      loadingOverlay.classList.add("visible");
2998      iframe.contentWindow?.location.reload();
2999    });
3000
3001    socket.on("toolResult", ({ tool, args, success, error }) => {
3002      console.log("[socket] tool:", tool, success ? "✓" : "✗", error || "");
3003    });
3004socket.on("executionStatus", ({ phase, text }) => {
3005  if (!text || phase === "done") return;
3006  console.log("[status]", phase, text);
3007  // Optionally show as a subtle inline status
3008  addOrchestratorStep("status:" + phase, text);
3009});
3010socket.on("orchestratorStep", ({ modeKey, result, timestamp }) => {
3011  console.log("[orchestrator]", modeKey, result);
3012  addOrchestratorStep(modeKey, result);
3013});
3014
3015function addOrchestratorStep(modeKey, result) {
3016  // Truncate long results for display
3017  let displayResult = result;
3018 // if (displayResult.length > 500) {
3019   // displayResult = displayResult.slice(0, 500) + "\\n… (truncated)";
3020  //}
3021
3022  const MODE_EMOJIS = {
3023    "intent": "🎯",
3024    "tree:navigate": "🧭",
3025    "tree:getContext": "📖",
3026    "tree:structure": "🏗️",
3027    "tree:edit": "✏️",
3028    "tree:notes": "📝",
3029    "tree:respond": "💬",
3030  };
3031
3032  const emoji = MODE_EMOJIS[modeKey] || "⚙️";
3033  const label = modeKey.replace("tree:", "");
3034
3035  [chatMessages, mobileChatMessages].forEach(container => {
3036    // Remove welcome if present
3037    const welcome = container.querySelector(".welcome-message");
3038    if (welcome) welcome.remove();
3039
3040    // Insert before the typing indicator if it exists
3041    const typing = container.querySelector(".typing-indicator")?.closest(".message");
3042
3043    const msg = document.createElement("div");
3044    msg.className = "message orchestrator-step";
3045    msg.innerHTML =
3046      '<div class="message-avatar">' + emoji + '</div>' +
3047      '<div class="message-content">' +
3048        '<span class="step-mode">' + escapeHtml(label) + '</span>' +
3049        '<span class="step-body">' + escapeHtml(displayResult) + '</span>' +
3050      '</div>';
3051
3052    if (typing) {
3053      container.insertBefore(msg, typing);
3054    } else {
3055      container.appendChild(msg);
3056    }
3057    container.scrollTop = container.scrollHeight;
3058  });
3059}
3060    // API
3061    window.TreeApp = {
3062      sendMessage: sendChatMessage,
3063      addMessage,
3064  navigate: (url) => { 
3065  loadingOverlay.classList.add("visible"); 
3066  currentIframeUrl = url;
3067  iframe.src = buildIframeUrl(url);
3068},
3069      goHome: () => { loadingOverlay.classList.add("visible"); iframe.src = CONFIG.homeUrl; currentIframeUrl = CONFIG.homeUrl; },
3070      isConnected: () => isConnected,
3071      isRegistered: () => isRegistered,
3072      notifyNodeUpdated: (nodeId, changes) => { if (isRegistered) socket.emit("nodeUpdated", { nodeId, changes }); },
3073      notifyNodeNavigated: (nodeId, nodeName) => { if (isRegistered) socket.emit("nodeNavigated", { nodeId, nodeName }); },
3074      notifyNodeSelected: (nodeId, nodeName) => { if (isRegistered) socket.emit("nodeSelected", { nodeId, nodeName }); },
3075      notifyNodeCreated: (nodeId, nodeName, parentId) => { if (isRegistered) socket.emit("nodeCreated", { nodeId, nodeName, parentId }); },
3076      notifyNodeDeleted: (nodeId, nodeName) => { if (isRegistered) socket.emit("nodeDeleted", { nodeId, nodeName }); },
3077      notifyNoteCreated: (nodeId, noteContent) => { if (isRegistered) socket.emit("noteCreated", { nodeId, noteContent }); },
3078      clearConversation: () => { if (isRegistered) socket.emit("clearConversation"); },
3079      switchMode: (modeKey) => { if (isRegistered) socket.emit("switchMode", { modeKey }); },
3080      getCurrentMode: () => currentModeKey,
3081      getAvailableModes: () => availableModes,
3082      getRecentRoots: () => recentRoots,
3083      navigateToRoot: navigateToRoot
3084    };
3085
3086    ${dashboardJS()}
3087  </script>
3088</body>
3089</html>`);
3090  } catch (err) {
3091    console.error(err);
3092    res.status(500).send("Failed to load app");
3093  }
3094});
3095
3096export default router;
3097
1// routesURL/chat.js
2// Simple chat-only interface for tree conversations.
3// No iframe, no tree view — just pick a tree and talk.
4
5import express from "express";
6import User from "../../../db/models/user.js";
7import Node from "../../../db/models/node.js";
8import CustomLlmConnection from "../../../db/models/customLlmConnection.js";
9import authenticateLite from "../../../middleware/authenticateLite.js";
10import { getNotifications } from "../../../core/tree/notifications.js";
11import {
12  getPendingInvitesForUser,
13  respondToInvite,
14} from "../../../core/tree/invites.js";
15import { notFoundPage } from "../../../middleware/notFoundPage.js";
16import { getLandUrl } from "../../../canopy/identity.js";
17
18const router = express.Router();
19
20function escapeHtml(str) {
21  return str
22    .replace(/&/g, "&amp;")
23    .replace(/</g, "&lt;")
24    .replace(/>/g, "&gt;")
25    .replace(/"/g, "&quot;")
26    .replace(/'/g, "&#039;");
27}
28
29router.get("/chat", authenticateLite, async (req, res) => {
30  try {
31    if (process.env.ENABLE_FRONTEND_HTML !== "true") {
32      return res.status(404).json({
33        error: "Server-rendered HTML is disabled. Use the SPA frontend.",
34      });
35    }
36    if (!req.userId) {
37      return res.redirect("/login");
38    }
39
40    const user = await User.findById(req.userId).select(
41      "username roots metadata",
42    );
43    if (!user) {
44      return notFoundPage(req, res, "This user doesn't exist.");
45    }
46
47    // Redirect to setup if user needs LLM or first tree (unless they skipped recently)
48    const setupSkipped = req.cookies?.setupSkipped === "1";
49    if (!setupSkipped) {
50      const hasMainLlm = !!user.llmDefault;
51      const hasTree = user.roots && user.roots.length > 0;
52      if (!hasMainLlm || !hasTree) {
53        const connCount = hasMainLlm
54          ? 1
55          : await CustomLlmConnection.countDocuments({ userId: req.userId });
56        if (connCount === 0 || !hasTree) {
57          return res.redirect("/setup");
58        }
59      }
60    }
61
62    const { username } = user;
63
64    // Load user's trees
65    const rootIds = (user.roots || []).map(String);
66    let trees = [];
67    if (rootIds.length > 0) {
68      trees = await Node.find({ _id: { $in: rootIds } })
69        .select("_id name children")
70        .lean();
71    }
72
73    const treesJSON = JSON.stringify(
74      trees.map((t) => ({
75        id: t._id,
76        name: t.name,
77        childCount: t.children?.length || 0,
78      })),
79    );
80
81    return res.send(`<!DOCTYPE html>
82<html lang="en">
83<head>
84  <meta charset="UTF-8" />
85  <title>Chat - TreeOS</title>
86  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
87  <meta name="theme-color" content="#736fe6" />
88  <link rel="icon" href="/tree.png" />
89  <link rel="canonical" href="${getLandUrl()}/chat" />
90  <meta name="robots" content="noindex, nofollow" />
91  <meta name="description" content="Chat with your knowledge trees. AI-powered conversations that grow your understanding." />
92  <meta property="og:title" content="Chat - TreeOS" />
93  <meta property="og:description" content="Chat with your knowledge trees. AI-powered conversations that grow your understanding." />
94  <meta property="og:url" content="${getLandUrl()}/chat" />
95  <meta property="og:type" content="website" />
96  <meta property="og:site_name" content="TreeOS" />
97  <meta property="og:image" content="${getLandUrl()}/tree.png" />
98  <link rel="preconnect" href="https://fonts.googleapis.com">
99  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
100  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
101  <style>
102    :root {
103      --glass-rgb: 115, 111, 230;
104      --glass-alpha: 0.28;
105      --glass-blur: 22px;
106      --glass-border: rgba(255, 255, 255, 0.28);
107      --glass-border-light: rgba(255, 255, 255, 0.15);
108      --glass-highlight: rgba(255, 255, 255, 0.25);
109      --text-primary: #ffffff;
110      --text-secondary: rgba(255, 255, 255, 0.9);
111      --text-muted: rgba(255, 255, 255, 0.6);
112      --accent: #10b981;
113      --accent-glow: rgba(16, 185, 129, 0.6);
114      --error: #ef4444;
115      --header-height: 56px;
116      --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
117    }
118
119    * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
120    html, body { height: 100%; width: 100%; overflow: hidden; font-family: 'DM Sans', -apple-system, sans-serif; color: var(--text-primary); }
121    html { background: #736fe6; }
122    body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-attachment: fixed; }
123
124    /* Layout */
125    .container {
126      height: 100%; width: 100%;
127      display: flex; flex-direction: column;
128      max-width: 800px; margin: 0 auto;
129    }
130
131    /* Header */
132    .chat-header {
133      height: var(--header-height); padding: 0 20px;
134      display: flex; align-items: center; justify-content: space-between;
135      border-bottom: 1px solid var(--glass-border-light); flex-shrink: 0;
136    }
137    .chat-title { display: flex; align-items: center; gap: 12px; }
138    .tree-icon { font-size: 28px; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3)); animation: grow 4.5s infinite ease-in-out; }
139    @keyframes grow { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
140    .chat-title h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
141
142    .header-right { display: flex; align-items: center; gap: 10px; }
143    .back-row {
144      display: none; padding: 8px 20px 0;
145      border-bottom: none; flex-shrink: 0;
146    }
147    .back-row.visible { display: flex; }
148    .back-btn {
149      display: flex; align-items: center; gap: 6px;
150      font-size: 12px; color: var(--text-muted);
151      background: rgba(255,255,255,0.1); border-radius: 8px;
152      padding: 6px 12px; border: 1px solid var(--glass-border-light);
153      cursor: pointer; transition: all var(--transition-fast);
154      font-family: inherit;
155    }
156    .back-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
157    .back-btn svg { width: 12px; height: 12px; }
158
159    .status-badge { display: flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border-radius: 100px; border: 1px solid var(--glass-border-light); font-size: 12px; font-weight: 600; }
160    .status-badge .status-text { display: inline; }
161    .status-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 12px var(--accent-glow); animation: pulse 2s ease-in-out infinite; flex-shrink: 0; }
162    .status-dot.connected { background: var(--accent); }
163    .status-dot.disconnected { background: var(--error); animation: none; }
164    .status-dot.connecting { background: #f59e0b; }
165    @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(1.15); } }
166
167    .advanced-btn {
168      font-size: 12px; color: var(--text-muted);
169      background: rgba(255,255,255,0.1); border-radius: 8px;
170      padding: 6px 14px; border: 1px solid var(--glass-border-light);
171      cursor: pointer; text-decoration: none; transition: all var(--transition-fast);
172      font-family: inherit;
173    }
174    .advanced-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
175
176    /* Root name inline */
177    .root-name-inline {
178      font-size: 13px; font-weight: 400; color: var(--text-muted);
179      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
180      max-width: 200px; opacity: 0; transition: opacity 0.3s ease;
181    }
182    .root-name-inline.visible { opacity: 1; }
183    .root-name-inline::before { content: ' / '; color: var(--glass-border-light); }
184
185    /* Tree picker */
186    .tree-picker {
187      flex: 1; display: flex; flex-direction: column;
188      align-items: center;
189      padding: 32px 20px 40px; gap: 24px;
190      overflow-y: auto; min-height: 0;
191    }
192    .tree-picker-title { font-size: 24px; font-weight: 600; margin-bottom: 4px; flex-shrink: 0; }
193    .tree-picker-sub { color: var(--text-muted); font-size: 15px; text-align: center; flex-shrink: 0; }
194    .tree-list { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 420px; }
195    .tree-item {
196      background: rgba(var(--glass-rgb), var(--glass-alpha));
197      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
198      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
199      border: 1px solid var(--glass-border-light);
200      border-radius: 16px; padding: 18px 22px;
201      cursor: pointer; transition: all var(--transition-fast);
202      display: flex; align-items: center; justify-content: space-between;
203      animation: fadeInUp 0.3s ease-out backwards;
204    }
205    .tree-item:hover { background: rgba(var(--glass-rgb), 0.42); transform: translateY(-2px); box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
206    .tree-item:active { transform: translateY(0) scale(0.98); }
207    .tree-item-left { display: flex; align-items: center; gap: 14px; }
208    .tree-item-icon { font-size: 22px; }
209    .tree-item-name { font-size: 15px; font-weight: 500; }
210    .tree-item-meta { font-size: 12px; color: var(--text-muted); }
211    @keyframes fadeInUp { from { opacity: 0; transform: translateY(16px); } }
212    ${trees.map((_, i) => `.tree-item:nth-child(${i + 1}) { animation-delay: ${i * 0.06}s; }`).join("\n    ")}
213
214    .empty-state {
215      background: rgba(var(--glass-rgb), var(--glass-alpha));
216      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
217      border: 1px solid var(--glass-border-light);
218      border-radius: 20px; padding: 48px 32px;
219      text-align: center; max-width: 400px;
220    }
221    .empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; display: block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); }
222    .empty-state h2 { font-size: 20px; margin-bottom: 8px; }
223    .empty-state p { color: var(--text-muted); font-size: 14px; margin-bottom: 20px; line-height: 1.5; }
224    /* Create tree form */
225    .create-tree-form {
226      display: flex; gap: 8px; width: 100%; max-width: 420px; margin-top: 8px;
227      flex-shrink: 0; padding-bottom: 8px;
228    }
229    .create-tree-form input {
230      flex: 1; padding: 14px 18px; font-size: 15px;
231      background: rgba(var(--glass-rgb), 0.25);
232      border: 1px solid var(--glass-border-light);
233      border-radius: 14px; color: var(--text-primary);
234      transition: all 0.2s; outline: none;
235    }
236    .create-tree-form input::placeholder { color: var(--text-muted); }
237    .create-tree-form input:focus {
238      border-color: var(--accent); background: rgba(var(--glass-rgb), 0.35);
239      box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
240    }
241    .create-tree-form button {
242      padding: 14px 18px; font-size: 20px; line-height: 1;
243      background: rgba(var(--glass-rgb), 0.3);
244      border: 1px solid var(--glass-border-light);
245      border-radius: 14px; color: var(--text-primary);
246      cursor: pointer; transition: all 0.2s;
247    }
248    .create-tree-form button:hover {
249      background: var(--accent); border-color: var(--accent);
250      box-shadow: 0 4px 15px var(--accent-glow);
251    }
252    .create-tree-form button:disabled { opacity: 0.4; cursor: not-allowed; }
253
254    /* Chat area */
255    .chat-area { flex: 1; display: none; flex-direction: column; overflow: hidden; }
256    .chat-area.active { display: flex; }
257
258    /* Messages — matches app.js */
259    .chat-messages { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; }
260    .chat-messages::-webkit-scrollbar { width: 4px; }
261    .chat-messages::-webkit-scrollbar-track { background: transparent; margin: 8px 0; }
262    .chat-messages::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 4px; }
263    .chat-messages::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.25); }
264    .chat-messages { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.12) transparent; }
265
266    .message { display: flex; gap: 12px; animation: messageIn 0.3s ease-out; min-width: 0; max-width: 100%; }
267    @keyframes messageIn { from { opacity: 0; transform: translateY(10px); } }
268    .message.user { flex-direction: row-reverse; }
269    .message-avatar { width: 36px; height: 36px; border-radius: 12px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
270    .message.user .message-avatar { background: linear-gradient(135deg, rgba(99, 102, 241, 0.6) 0%, rgba(139, 92, 246, 0.6) 100%); }
271    .message-content { max-width: 85%; min-width: 0; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; font-size: 14px; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
272    .message.user .message-content { background: linear-gradient(135deg, rgba(99, 102, 241, 0.5) 0%, rgba(139, 92, 246, 0.5) 100%); border-radius: 18px 18px 6px 18px; }
273    .message.assistant .message-content { border-radius: 18px 18px 18px 6px; }
274    .message.error .message-content { background: rgba(239, 68, 68, 0.3); border-color: rgba(239, 68, 68, 0.5); }
275
276    /* Message content formatting — matches app.js */
277    .message-content p { margin: 0 0 10px 0; word-break: break-word; }
278    .message-content p:last-child { margin-bottom: 0; }
279    .message-content h1, .message-content h2, .message-content h3, .message-content h4 { margin: 14px 0 8px 0; font-weight: 600; line-height: 1.3; }
280    .message-content h1:first-child, .message-content h2:first-child, .message-content h3:first-child, .message-content h4:first-child { margin-top: 0; }
281    .message-content h1 { font-size: 17px; }
282    .message-content h2 { font-size: 16px; }
283    .message-content h3 { font-size: 15px; }
284    .message-content h4 { font-size: 14px; color: var(--text-secondary); }
285    .message-content ul, .message-content ol { margin: 8px 0; padding-left: 0; list-style: none; }
286    .message-content li { margin: 4px 0; padding: 6px 10px; background: rgba(255, 255, 255, 0.06); border-radius: 8px; line-height: 1.4; word-break: break-word; }
287    .message-content li .list-num { color: var(--accent); font-weight: 600; margin-right: 6px; }
288    .message-content strong, .message-content b { font-weight: 600; color: #fff; }
289    .message-content em, .message-content i { font-style: italic; color: var(--text-secondary); }
290    .message-content code { background: rgba(0, 0, 0, 0.3); padding: 2px 6px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: 11px; word-break: break-all; }
291    .message-content pre { background: rgba(0, 0, 0, 0.3); padding: 12px; border-radius: 8px; overflow-x: auto; margin: 10px 0; max-width: 100%; }
292    .message-content pre code { background: none; padding: 0; word-break: normal; white-space: pre-wrap; }
293    .message-content blockquote { border-left: 3px solid var(--accent); padding-left: 12px; margin: 10px 0; color: var(--text-secondary); font-style: italic; }
294    .message-content hr { border: none; border-top: 1px solid var(--glass-border-light); margin: 14px 0; }
295    .message-content a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
296    .message-content a:hover { text-decoration: none; }
297
298    /* Menu items */
299    .message-content .menu-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin: 6px 0; background: rgba(255, 255, 255, 0.08); border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.06); transition: all 0.15s ease; }
300    .message-content .menu-item.clickable { cursor: pointer; user-select: none; }
301    .message-content .menu-item.clickable:hover { background: rgba(255, 255, 255, 0.15); border-color: rgba(16, 185, 129, 0.3); transform: translateX(4px); }
302    .message-content .menu-item.clickable:active { transform: translateX(4px) scale(0.98); background: rgba(16, 185, 129, 0.2); }
303    .message-content .menu-item:first-of-type { margin-top: 8px; }
304    .message-content .menu-number { display: flex; align-items: center; justify-content: center; min-width: 26px; max-width: 26px; height: 26px; background: linear-gradient(135deg, var(--accent) 0%, #059669 100%); border-radius: 8px; font-size: 12px; font-weight: 600; flex-shrink: 0; box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); transition: all 0.15s ease; }
305    .message-content .menu-item.clickable:hover .menu-number { transform: scale(1.1); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.5); }
306    .message-content .menu-text { flex: 1; min-width: 0; padding-top: 2px; word-break: break-word; overflow-wrap: break-word; }
307    .message-content .menu-text strong { display: block; margin-bottom: 2px; word-break: break-word; }
308
309    /* Typing indicator — matches app.js */
310    .typing-indicator { display: flex; gap: 4px; padding: 14px 18px; }
311    .typing-dot { width: 8px; height: 8px; background: rgba(255, 255, 255, 0.6); border-radius: 50%; animation: typing 1.4s infinite; }
312    .typing-dot:nth-child(2) { animation-delay: 0.2s; }
313    .typing-dot:nth-child(3) { animation-delay: 0.4s; }
314    @keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-8px); } }
315
316    /* Input — matches app.js */
317    .chat-input-area { padding: 16px 20px 20px; border-top: 1px solid var(--glass-border-light); }
318    .input-container { display: flex; align-items: flex-end; gap: 12px; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; transition: all var(--transition-fast); }
319    .input-container:focus-within { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.4); box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1); }
320    .chat-input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; font-family: inherit; font-size: 15px; color: var(--text-primary); resize: none; max-height: 120px; line-height: 1.5; overflow-y: auto; }
321    .chat-input::-webkit-scrollbar { width: 4px; }
322    .chat-input::-webkit-scrollbar-track { background: transparent; }
323    .chat-input::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
324    .chat-input::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); }
325    .chat-area.empty .chat-input { max-height: 40vh; }
326    .chat-input::placeholder { color: var(--text-muted); }
327    .chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
328    .send-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--accent); border: none; border-radius: 12px; color: white; cursor: pointer; transition: all var(--transition-fast); flex-shrink: 0; box-shadow: 0 4px 15px var(--accent-glow); }
329    .send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 25px var(--accent-glow); }
330    .send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
331    .send-btn svg { width: 20px; height: 20px; }
332    .send-btn.stop-mode { background: rgba(239, 68, 68, 0.7); box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4); }
333    .send-btn.stop-mode:hover:not(:disabled) { background: rgba(239, 68, 68, 0.9); box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5); }
334
335    /* Mode toggle */
336    .mode-toggle { display: flex; gap: 4px; padding: 0 2px 10px; }
337    .mode-btn { padding: 4px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: var(--text-muted); font-size: 12px; font-weight: 500; cursor: pointer; transition: all var(--transition-fast); font-family: inherit; }
338    .mode-btn:hover { background: rgba(255,255,255,0.12); color: var(--text-secondary); }
339    .mode-btn.active { background: rgba(255,255,255,0.18); color: var(--text-primary); border-color: rgba(255,255,255,0.25); }
340    .mode-btn.active[data-mode="chat"] { background: var(--accent); border-color: var(--accent); color: #fff; }
341    .mode-btn.active[data-mode="place"] { background: rgba(72,187,120,0.4); border-color: rgba(72,187,120,0.5); color: #fff; }
342    .mode-btn.active[data-mode="query"] { background: rgba(115,111,230,0.4); border-color: rgba(115,111,230,0.5); color: #fff; }
343    .mode-hint { font-size: 11px; color: var(--text-muted); padding: 0 4px 6px; opacity: 0.7; }
344
345    /* Place result message */
346    .place-result { font-size: 13px; color: var(--text-muted); padding: 8px 14px; background: rgba(72,187,120,0.08); border-radius: 12px; border: 1px solid rgba(72,187,120,0.15); margin: 4px 0; }
347
348    /* Empty state — input pinned to vertical center, welcome above it */
349    .chat-area.empty { position: relative; }
350    .chat-area.empty .chat-input-area { position: absolute; top: 40%; left: 50%; transform: translate(-50%, 0); border-top: none; max-width: 600px; width: calc(100% - 40px); }
351    .chat-area.empty .chat-messages { position: absolute; top: 40%; left: 0; right: 0; transform: translateY(-100%); display: flex; flex-direction: column; align-items: center; overflow: visible; flex: none; }
352
353    /* Welcome message */
354    .welcome-message { text-align: center; padding: 40px 20px; }
355    .welcome-icon { font-size: 64px; margin-bottom: 20px; display: inline-block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: floatIcon 3s ease-in-out infinite; }
356    @keyframes floatIcon { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
357    .chat-area.empty .welcome-message { padding: 8px 20px; }
358    .chat-area.empty .welcome-icon { font-size: 48px; margin-bottom: 12px; }
359    .chat-area.empty .welcome-message h2 { font-size: 18px; margin-bottom: 6px; }
360    .chat-area.empty .welcome-message p { font-size: 13px; }
361    .welcome-message h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
362    .welcome-message p { font-size: 15px; color: var(--text-secondary); line-height: 1.6; }
363    .welcome-message.disconnected { opacity: 0.7; }
364    .welcome-message.disconnected .welcome-icon { filter: grayscale(0.5) drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: none; }
365
366    /* Notifications panel */
367    .clear-chat-btn {
368      background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border-light);
369      border-radius: 8px; padding: 6px 8px; cursor: pointer;
370      color: var(--text-muted); transition: all var(--transition-fast);
371      display: none; align-items: center; justify-content: center;
372    }
373    .clear-chat-btn.visible { display: flex; }
374    .clear-chat-btn:hover { background: rgba(255,255,255,0.2); color: var(--text-primary); }
375    .clear-chat-btn:active { transform: scale(0.93); }
376    .clear-chat-btn svg { width: 14px; height: 14px; }
377    .notif-btn {
378      font-size: 12px; color: var(--text-muted);
379      background: rgba(255,255,255,0.1); border-radius: 8px;
380      padding: 6px 14px; border: 1px solid var(--glass-border-light);
381      cursor: pointer; transition: all var(--transition-fast);
382      font-family: inherit; position: relative;
383      display: flex; align-items: center; gap: 6px;
384    }
385    .notif-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
386    .notif-btn-icon { display: none; font-size: 14px; line-height: 1; }
387    .notif-btn .notif-dot {
388      position: absolute; top: -3px; right: -3px;
389      width: 8px; height: 8px; border-radius: 50%;
390      background: var(--accent); box-shadow: 0 0 8px var(--accent-glow);
391      display: none;
392    }
393    .notif-btn .notif-dot.has-notifs { display: block; }
394
395    .notif-overlay {
396      position: fixed; inset: 0; background: rgba(0,0,0,0.4);
397      z-index: 9998; display: none;
398    }
399    .notif-overlay.open { display: block; }
400
401    .notif-panel {
402      position: fixed; top: 0; right: -400px; bottom: 0;
403      width: 380px; max-width: 90vw;
404      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
405      z-index: 9999;
406      display: flex; flex-direction: column;
407      transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
408      box-shadow: -8px 0 32px rgba(0,0,0,0.3);
409    }
410    .notif-panel.open { right: 0; }
411
412    .notif-panel-header {
413      padding: 20px; display: flex; align-items: center;
414      justify-content: space-between; flex-shrink: 0;
415      border-bottom: 1px solid var(--glass-border-light);
416    }
417    .notif-panel-header h2 { font-size: 18px; font-weight: 600; color: white; }
418    .notif-close {
419      width: 32px; height: 32px; border-radius: 8px;
420      background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border-light);
421      color: white; cursor: pointer; font-size: 16px; display: flex;
422      align-items: center; justify-content: center; transition: all var(--transition-fast);
423    }
424    .notif-close:hover { background: rgba(255,255,255,0.2); }
425
426    .notif-list {
427      flex: 1; overflow-y: auto; padding: 16px;
428      display: flex; flex-direction: column; gap: 12px;
429    }
430    .notif-list::-webkit-scrollbar { width: 6px; }
431    .notif-list::-webkit-scrollbar-track { background: transparent; }
432    .notif-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
433
434    .notif-item {
435      background: rgba(var(--glass-rgb), var(--glass-alpha));
436      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
437      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
438      border: 1px solid var(--glass-border-light);
439      border-radius: 14px; padding: 16px;
440      animation: fadeInUp 0.3s ease-out backwards;
441      transition: all var(--transition-fast);
442    }
443    .notif-item:hover {
444      background: rgba(var(--glass-rgb), 0.42);
445      transform: translateY(-1px);
446    }
447    .notif-item.type-thought { border-left: 3px solid #9b64dc; }
448    .notif-item.type-summary { border-left: 3px solid #6464d2; }
449
450    .notif-item-header {
451      display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
452    }
453    .notif-item-icon { font-size: 18px; flex-shrink: 0; }
454    .notif-item-title {
455      font-size: 14px; font-weight: 600; color: white;
456      flex: 1; line-height: 1.3;
457    }
458    .notif-item-badge {
459      font-size: 9px; font-weight: 700; text-transform: uppercase;
460      letter-spacing: 0.5px; padding: 2px 7px; border-radius: 6px;
461      background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.6);
462      border: 1px solid rgba(255,255,255,0.1); flex-shrink: 0;
463    }
464    .notif-item-content {
465      font-size: 13px; color: rgba(255,255,255,0.85);
466      line-height: 1.55; white-space: pre-wrap; word-break: break-word;
467    }
468    .notif-item-time {
469      font-size: 11px; color: rgba(255,255,255,0.45);
470      margin-top: 8px;
471    }
472    .notif-empty {
473      text-align: center; color: rgba(255,255,255,0.5);
474      font-size: 14px; padding: 40px 20px;
475    }
476    .notif-empty-icon { font-size: 40px; margin-bottom: 12px; display: block; }
477    .notif-loading { text-align: center; color: rgba(255,255,255,0.5); padding: 40px 20px; font-size: 14px; }
478
479    /* Notification tabs */
480    .notif-tabs {
481      display: flex; gap: 0; flex-shrink: 0;
482      border-bottom: 1px solid var(--glass-border-light);
483    }
484    .notif-tab {
485      flex: 1; padding: 10px 0; text-align: center;
486      font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.5);
487      background: none; border: none; border-bottom: 2px solid transparent;
488      cursor: pointer; font-family: inherit; transition: all var(--transition-fast);
489      position: relative;
490    }
491    .notif-tab:hover { color: rgba(255,255,255,0.8); }
492    .notif-tab.active { color: white; border-bottom-color: white; }
493    .notif-tab .tab-dot {
494      display: none; width: 6px; height: 6px; border-radius: 50%;
495      background: var(--accent); position: absolute; top: 8px; right: calc(50% - 30px);
496    }
497    .notif-tab .tab-dot.visible { display: block; }
498
499    /* Invite items */
500    .invite-item {
501      background: rgba(var(--glass-rgb), var(--glass-alpha));
502      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
503      border: 1px solid var(--glass-border-light);
504      border-radius: 14px; padding: 16px;
505      border-left: 3px solid #48bb78;
506      animation: fadeInUp 0.3s ease-out backwards;
507    }
508    .invite-item-text { font-size: 13px; color: rgba(255,255,255,0.9); line-height: 1.5; margin-bottom: 10px; }
509    .invite-item-text strong { color: white; }
510    .invite-item-actions { display: flex; gap: 8px; }
511    .invite-item-actions button {
512      flex: 1; padding: 8px; border-radius: 8px; border: none;
513      font-family: inherit; font-size: 12px; font-weight: 600;
514      cursor: pointer; transition: all var(--transition-fast);
515    }
516    .invite-accept { background: var(--accent); color: white; }
517    .invite-accept:hover { filter: brightness(1.1); }
518    .invite-decline { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid var(--glass-border-light) !important; }
519    .invite-decline:hover { background: rgba(255,255,255,0.18); color: white; }
520
521    /* Members section */
522    .members-section { margin-top: 4px; }
523    .members-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.4); margin-bottom: 8px; }
524    .member-item {
525      display: flex; align-items: center; justify-content: space-between;
526      padding: 10px 14px; border-radius: 10px;
527      background: rgba(var(--glass-rgb), var(--glass-alpha));
528      border: 1px solid var(--glass-border-light); margin-bottom: 8px;
529    }
530    .member-name { font-size: 13px; color: white; font-weight: 500; }
531    .member-role { font-size: 11px; color: rgba(255,255,255,0.45); margin-left: 6px; }
532    .member-actions { display: flex; gap: 6px; }
533    .member-actions button {
534      padding: 4px 10px; border-radius: 6px; border: 1px solid var(--glass-border-light);
535      background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.6);
536      font-family: inherit; font-size: 11px; cursor: pointer;
537      transition: all var(--transition-fast);
538    }
539    .member-actions button:hover { background: rgba(255,255,255,0.15); color: white; }
540    .member-actions .btn-danger:hover { background: rgba(239,68,68,0.3); color: #fca5a5; border-color: rgba(239,68,68,0.4); }
541
542    .invite-form {
543      display: flex; gap: 8px; margin-top: 12px;
544    }
545    .invite-form input {
546      flex: 1; padding: 8px 12px; border-radius: 8px;
547      background: rgba(255,255,255,0.08); border: 1px solid var(--glass-border-light);
548      color: white; font-family: inherit; font-size: 13px; outline: none;
549    }
550    .invite-form input::placeholder { color: rgba(255,255,255,0.35); }
551    .invite-form input:focus { border-color: var(--glass-border); }
552    .invite-form button {
553      padding: 8px 16px; border-radius: 8px; border: none;
554      background: var(--accent); color: white; font-family: inherit;
555      font-size: 12px; font-weight: 600; cursor: pointer;
556      transition: all var(--transition-fast);
557    }
558    .invite-form button:hover { filter: brightness(1.1); }
559    .invite-form button:disabled { opacity: 0.5; cursor: not-allowed; }
560
561    .invite-status {
562      font-size: 12px; margin-top: 6px; padding: 6px 10px;
563      border-radius: 6px; display: none;
564    }
565    .invite-status.error { display: block; background: rgba(239,68,68,0.15); color: #fca5a5; }
566    .invite-status.success { display: block; background: rgba(16,185,129,0.15); color: #6ee7b7; }
567
568    /* Dream time config */
569    .dream-config {
570      background: rgba(var(--glass-rgb), var(--glass-alpha));
571      border: 1px solid var(--glass-border-light);
572      border-radius: 14px; padding: 14px 16px; margin-bottom: 12px;
573    }
574    .dream-config-label { font-size: 12px; color: rgba(255,255,255,0.5); margin-bottom: 6px; }
575    .dream-config-row { display: flex; align-items: center; gap: 8px; }
576    .dream-config-row input[type="time"] {
577      padding: 6px 10px; border-radius: 8px;
578      background: rgba(255,255,255,0.08); border: 1px solid var(--glass-border-light);
579      color: white; font-family: inherit; font-size: 13px; outline: none;
580      color-scheme: dark;
581    }
582    .dream-config-row input[type="time"]:focus { border-color: var(--glass-border); }
583    .dream-config-row button {
584      padding: 6px 12px; border-radius: 8px; border: none;
585      font-family: inherit; font-size: 12px; font-weight: 600;
586      cursor: pointer; transition: all var(--transition-fast);
587    }
588    .dream-config-save { background: var(--accent); color: white; }
589    .dream-config-save:hover { filter: brightness(1.1); }
590    .dream-config-off {
591      background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
592      border: 1px solid var(--glass-border-light) !important;
593    }
594    .dream-config-off:hover { background: rgba(255,255,255,0.18); color: white; }
595    .dream-config-status { font-size: 11px; color: rgba(255,255,255,0.5); margin-top: 6px; }
596    .dream-config-hint { font-size: 12px; color: rgba(255,255,255,0.45); line-height: 1.4; }
597
598    .notif-panel-footer {
599      padding: 16px; border-top: 1px solid var(--glass-border-light); flex-shrink: 0;
600    }
601    .logout-btn {
602      width: 100%; padding: 10px; border-radius: 10px;
603      background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3);
604      color: #fca5a5; font-family: inherit; font-size: 13px;
605      cursor: pointer; transition: all var(--transition-fast);
606    }
607    .logout-btn:hover { background: rgba(239,68,68,0.3); color: #fecaca; }
608
609    @media (max-width: 600px) {
610      .container { max-width: 100%; }
611      .chat-header { padding: 0 12px; }
612      .header-right { gap: 6px; }
613      .chat-input-area { padding: 12px 16px 16px; }
614
615      /* Collapse status badge to dot only */
616      .status-badge .status-text { display: none; }
617      .status-badge { padding: 6px; min-width: 20px; justify-content: center; }
618
619      /* Collapse notifications button to icon only */
620      .notif-btn-label { display: none; }
621      .notif-btn-icon { display: inline; }
622      .notif-btn { padding: 6px 8px; }
623
624      /* Shrink other buttons */
625      .advanced-btn { padding: 6px 10px; font-size: 11px; }
626      .back-btn { padding: 4px 8px; font-size: 11px; }
627      .back-btn svg { width: 10px; height: 10px; }
628
629      /* Hide title text, keep icon */
630      .chat-title h1 { display: none; }
631
632      .notif-panel { width: 100%; max-width: 100%; right: -100%; }
633      .notif-panel.open { right: 0; }
634
635      /* Empty state — mobile: push to top */
636      .chat-area.empty { overflow: visible; }
637      .chat-area.empty .chat-messages { position: static; flex: 0; padding-top: 0; transform: none; overflow: visible; }
638      .chat-area.empty .chat-input-area { position: static; transform: none; width: 100%; max-width: 100%; margin: 0; }
639    }
640  </style>
641</head>
642<body>
643  <div class="container">
644    <div class="chat-header">
645      <div class="chat-title">
646        <a href="/app" style="text-decoration:none;display:flex;align-items:center;gap:12px;color:inherit;">
647        <span class="tree-icon">🌳</span>
648        <h1>Tree</h1>
649        </a>
650        <span class="root-name-inline" id="rootName"></span>
651      </div>
652      <div class="header-right">
653        <div class="status-badge">
654          <div class="status-dot connecting" id="statusDot"></div>
655          <span class="status-text" id="statusText">Connecting</span>
656        </div>
657        <button class="clear-chat-btn" id="clearChatBtn" title="Clear conversation">
658          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
659        </button>
660        <button class="notif-btn" id="notifBtn" onclick="toggleNotifs()">
661          <span class="notif-dot" id="notifDot"></span>
662          <span class="notif-btn-icon">☰</span>
663          <span class="notif-btn-label">Menu</span>
664        </button>
665        <a href="/dashboard" class="advanced-btn" id="advancedLink">Advanced</a>
666      </div>
667    </div>
668    <div class="back-row" id="backRow">
669      <button class="back-btn" onclick="backToTrees()">
670        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
671        Back
672      </button>
673    </div>
674
675    <!-- Menu panel -->
676    <div class="notif-overlay" id="notifOverlay" onclick="toggleNotifs()"></div>
677    <div class="notif-panel" id="notifPanel">
678      <div class="notif-panel-header">
679        <h2>Menu</h2>
680        <button class="notif-close" onclick="toggleNotifs()">&#x2715;</button>
681      </div>
682      <div class="notif-tabs">
683        <button class="notif-tab active" id="tabDreams" onclick="switchTab('dreams')">Dreams<span class="tab-dot" id="dreamsDot"></span></button>
684        <button class="notif-tab" id="tabInvites" onclick="switchTab('invites')">Invites<span class="tab-dot" id="invitesDot"></span></button>
685      </div>
686      <div class="notif-list" id="notifList">
687        <div class="notif-loading">Loading...</div>
688      </div>
689      <div class="notif-list" id="invitesList" style="display:none">
690        <div class="notif-loading">Loading...</div>
691      </div>
692      <div class="notif-panel-footer">
693        <button class="logout-btn" onclick="doLogout()">Log out</button>
694      </div>
695    </div>
696
697    <div class="tree-picker" id="treePicker">
698      ${
699        trees.length === 0
700          ? `<div class="empty-state">
701              <span class="empty-icon">🌱</span>
702              <h2>Plant your first tree</h2>
703              <p>A tree starts with a single root, a broad topic that everything else branches out from. Think big categories like <strong>My Life</strong>, <strong>Career</strong>, or <strong>Health</strong>.</p>
704              <p style="margin-top:8px;">Name it, and you can start chatting with it right away.</p>
705              <form class="create-tree-form" style="margin-top:16px;" onsubmit="createTree(event)">
706                <input type="text" id="newTreeNameEmpty" placeholder="e.g. My Life" autocomplete="off" />
707                <button type="submit" title="Create tree">+</button>
708              </form>
709            </div>`
710          : `<h2 class="tree-picker-title">Your Trees</h2>
711            <p class="tree-picker-sub">Pick a tree to start chatting</p>
712            <div class="tree-list" id="treeList">
713              ${trees
714                .map(
715                  (t) => `
716                <div class="tree-item" onclick="selectTree('${t._id}', '${escapeHtml(t.name)}')">
717                  <span class="tree-item-icon">🌳</span>
718                  <span class="tree-item-name">${escapeHtml(t.name)}</span>
719                </div>`,
720                )
721                .join("")}
722            </div>`
723      }
724      ${
725        trees.length > 0
726          ? `
727      <form class="create-tree-form" id="createTreeForm" onsubmit="createTree(event)">
728        <input type="text" id="newTreeName" placeholder="New tree name..." autocomplete="off" />
729        <button type="submit" title="Create tree">+</button>
730      </form>`
731          : ""
732      }
733    </div>
734
735    <div class="chat-area empty" id="chatArea">
736      <div class="chat-messages" id="messages">
737        <div class="welcome-message" id="welcomeMsg">
738          <div class="welcome-icon">🌳</div>
739          <h2>Start chatting</h2>
740          <p>Ask anything about your tree or tell it something new.</p>
741        </div>
742      </div>
743      <div class="chat-input-area">
744        <div class="mode-toggle" id="modeToggle">
745          <button class="mode-btn active" data-mode="chat">Chat</button>
746          <button class="mode-btn" data-mode="place">Place</button>
747          <button class="mode-btn" data-mode="query">Query</button>
748        </div>
749        <div class="input-container">
750          <textarea class="chat-input" id="chatInput" placeholder="Say something..." rows="1"></textarea>
751          <button class="send-btn" id="sendBtn" disabled>
752            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
753          </button>
754        </div>
755      </div>
756    </div>
757  </div>
758
759  <script src="/socket.io/socket.io.js"></script>
760  <script>
761    const CONFIG = {
762      username: "${escapeHtml(username)}",
763      userId: "${req.userId}",
764      trees: ${treesJSON},
765    };
766
767    // State
768    let activeRootId = null;
769    let isConnected = false;
770    let isRegistered = false;
771    let isSending = false;
772    let requestGeneration = 0;
773    let chatMode = "chat";
774
775    // Mode toggle
776    const modeToggle = document.getElementById("modeToggle");
777    const modePlaceholders = { chat: "Full conversation. Places content and responds.", place: "Places content onto your tree but doesn't respond.", query: "Talk to your tree without it making any changes." };
778    modeToggle.addEventListener("click", function(e) {
779      var btn = e.target.closest(".mode-btn");
780      if (!btn || isSending) return;
781      chatMode = btn.dataset.mode;
782      modeToggle.querySelectorAll(".mode-btn").forEach(function(b) { b.classList.remove("active"); });
783      btn.classList.add("active");
784      document.getElementById("chatInput").placeholder = modePlaceholders[chatMode] || "Say something...";
785    });
786
787    // Elements
788    const statusDot = document.getElementById("statusDot");
789    const statusText = document.getElementById("statusText");
790    const treePicker = document.getElementById("treePicker");
791    const chatArea = document.getElementById("chatArea");
792    const chatMessages = document.getElementById("messages");
793    const chatInput = document.getElementById("chatInput");
794    const sendBtn = document.getElementById("sendBtn");
795    const backRow = document.getElementById("backRow");
796    const rootName = document.getElementById("rootName");
797    const advancedLink = document.getElementById("advancedLink");
798
799    function escapeHtml(s) {
800      const d = document.createElement("div");
801      d.textContent = s;
802      return d.innerHTML;
803    }
804
805    // ── Markdown formatting — matches app.js ──────────────────────────
806    function formatMessageContent(text) {
807      if (!text) return '';
808      let html = text;
809
810      html = html.replace(/&nbsp;/g, ' ');
811      html = html.replace(/&amp;/g, '&');
812      html = html.replace(/&lt;/g, '<');
813      html = html.replace(/&gt;/g, '>');
814      html = html.replace(/\\u00A0/g, ' ');
815
816      html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
817
818      // Code blocks
819      html = html.replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, '<pre><code>$1</code></pre>');
820      html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
821
822      // Bold / italic
823      html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
824      html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
825      html = html.replace(/(?<![\\w\\*])\\*([^\\*]+)\\*(?![\\w\\*])/g, '<em>$1</em>');
826
827      // Headings
828      html = html.replace(/^####\\s*(.+)$/gm, '<h4>$1</h4>');
829      html = html.replace(/^###\\s*(.+)$/gm, '<h3>$1</h3>');
830      html = html.replace(/^##\\s*(.+)$/gm, '<h2>$1</h2>');
831      html = html.replace(/^#\\s*(.+)$/gm, '<h1>$1</h1>');
832
833      // HR
834      html = html.replace(/^-{3,}$/gm, '<hr>');
835      html = html.replace(/^\\*{3,}$/gm, '<hr>');
836
837      // Blockquote
838      html = html.replace(/^&gt;\\s*(.+)$/gm, '<blockquote>$1</blockquote>');
839
840      // Numbered menu items with bold title
841      html = html.replace(/^([1-9]|1[0-9]|20)\\.\\s*<strong>(.+?)<\\/strong>(.*)$/gm, function(m, num, title, rest) {
842        return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + title.replace(/"/g, '&quot;') + '">' +
843          '<span class="menu-number">' + num + '</span>' +
844          '<span class="menu-text"><strong>' + title + '</strong>' + rest + '</span></div>';
845      });
846
847      // Bullet items with bold title
848      html = html.replace(/^[-\\u2013\\u2022]\\s*<strong>(.+?)<\\/strong>(.*)$/gm,
849        '<div class="menu-item"><span class="menu-number">\\u2022</span><span class="menu-text"><strong>$1</strong>$2</span></div>');
850
851      // Plain bullet items
852      html = html.replace(/^[-\\u2013\\u2022]\\s+([^<].*)$/gm, '<li>$1</li>');
853
854      // Numbered list items
855      html = html.replace(/^(\\d+)\\.\\s+([^<*].*)$/gm, '<li><span class="list-num">$1.</span> $2</li>');
856
857      // Wrap consecutive li in ul
858      let inList = false;
859      const lines = html.split('\\n');
860      const processed = [];
861      for (let i = 0; i < lines.length; i++) {
862        const line = lines[i];
863        const isListItem = line.trim().startsWith('<li>');
864        if (isListItem && !inList) { processed.push('<ul>'); inList = true; }
865        else if (!isListItem && inList) { processed.push('</ul>'); inList = false; }
866        processed.push(line);
867      }
868      if (inList) processed.push('</ul>');
869      html = processed.join('\\n');
870
871      // Links
872      html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>');
873
874      // Paragraphs
875      const blocks = html.split(/\\n\\n+/);
876      html = blocks.map(function(block) {
877        const trimmed = block.trim();
878        if (!trimmed) return '';
879        if (trimmed.match(/^<(h[1-4]|ul|ol|pre|blockquote|hr|div|table)/)) return trimmed;
880        const withBreaks = trimmed.split('\\n').map(function(l) { return l.trim(); }).filter(function(l) { return l; }).join('<br>');
881        return '<p>' + withBreaks + '</p>';
882      }).filter(function(b) { return b; }).join('');
883
884      // Clean up
885      html = html.replace(/<p><\\/p>/g, '');
886      html = html.replace(/<p>(<div|<ul|<ol|<h[1-4]|<hr|<pre|<blockquote)/g, '$1');
887      html = html.replace(/(<\\/div>|<\\/ul>|<\\/ol>|<\\/h[1-4]>|<\\/pre>|<\\/blockquote>)<\\/p>/g, '$1');
888      html = html.replace(/<br>(<div|<\\/div>)/g, '$1');
889      html = html.replace(/(<div[^>]*>)<br>/g, '$1');
890
891      return html;
892    }
893
894    // ── Socket ────────────────────────────────────────────────────────
895    const socket = io({ transports: ["websocket", "polling"], withCredentials: true });
896
897    socket.on("connect", () => {
898      isConnected = true;
899      statusDot.className = "status-dot connecting";
900      statusText.textContent = "Connecting";
901      socket.emit("ready");
902      socket.emit("register", { username: CONFIG.username });
903    });
904
905    socket.on("registered", ({ success }) => {
906      if (success) {
907        isRegistered = true;
908        statusDot.className = "status-dot connected";
909        statusText.textContent = "Connected";
910
911        // Clear disconnected message on reconnect
912        const disc = chatMessages.querySelector(".welcome-message.disconnected");
913        if (disc) {
914          disc.remove();
915          chatMessages.innerHTML = '<div class="welcome-message" id="welcomeMsg"><div class="welcome-icon">🌳</div><h2>Start chatting</h2><p>Ask anything about your tree or tell it something new.</p></div>';
916          chatArea.classList.add("empty");
917        }
918
919        updateSendBtn();
920        if (activeRootId) {
921          socket.emit("setActiveRoot", { rootId: activeRootId });
922          socket.emit("urlChanged", { url: "/api/v1/root/" + activeRootId, rootId: activeRootId });
923        }
924      }
925    });
926
927    socket.on("chatResponse", ({ answer, generation }) => {
928      if (generation !== undefined && generation < requestGeneration) return;
929      removeTyping();
930      addMessage(answer, "assistant");
931      isSending = false;
932      updateSendBtn();
933    });
934
935    socket.on("placeResult", ({ stepSummaries, targetPath, generation }) => {
936      if (generation !== undefined && generation < requestGeneration) return;
937      var el = document.getElementById("placeStatus");
938      var summary = (stepSummaries && stepSummaries.length > 0)
939        ? "Placed on: " + (targetPath || stepSummaries.map(function(s) { return s.summary || s; }).join(", "))
940        : "Nothing to place for that message.";
941      if (el) {
942        el.querySelector(".place-result").textContent = summary;
943      } else {
944        addMessage(summary, "place-status");
945      }
946      isSending = false;
947      updateSendBtn();
948    });
949
950    socket.on("chatError", ({ error, generation }) => {
951      if (generation !== undefined && generation < requestGeneration) return;
952      removeTyping();
953      addMessage("Error: " + error, "error");
954      isSending = false;
955      updateSendBtn();
956    });
957
958    socket.on("chatCancelled", () => {
959      if (isSending) {
960        removeTyping();
961        isSending = false;
962        updateSendBtn();
963      }
964    });
965
966    socket.on("disconnect", () => {
967      isConnected = false;
968      isRegistered = false;
969      isSending = false;
970      statusDot.className = "status-dot disconnected";
971      statusText.textContent = "Disconnected";
972      updateSendBtn();
973
974      chatMessages.innerHTML = '<div class="welcome-message disconnected"><div class="welcome-icon">🌳</div><h2>Disconnected</h2><p>You have been disconnected from TreeOS. Please refresh the page to reconnect.</p></div>';
975    });
976
977    // Ignore navigate events — no iframe
978    socket.on("navigate", () => {});
979
980    // ── Create tree ─────────────────────────────────────────────────
981    async function createTree(e) {
982      e.preventDefault();
983      const input = e.target.querySelector("input[type=text]");
984      const name = input.value.trim();
985      if (!name) return;
986
987      const btn = e.target.querySelector("button");
988      btn.disabled = true;
989
990      try {
991        const res = await fetch("/api/v1/user/" + CONFIG.userId + "/createRoot", {
992          method: "POST",
993          headers: { "Content-Type": "application/json" },
994          credentials: "include",
995          body: JSON.stringify({ name }),
996        });
997        const data = await res.json();
998        if (!data.success) throw new Error(data.error || "Failed");
999
1000        // Add to tree list (create it if empty state)
1001        let treeList = document.getElementById("treeList");
1002        if (!treeList) {
1003          // Was empty state — rebuild picker content
1004          const emptyState = treePicker.querySelector(".empty-state");
1005          if (emptyState) emptyState.remove();
1006
1007          const title = document.createElement("h2");
1008          title.className = "tree-picker-title";
1009          title.textContent = "Your Trees";
1010
1011          const sub = document.createElement("p");
1012          sub.className = "tree-picker-sub";
1013          sub.textContent = "Pick a tree to start chatting";
1014
1015          treeList = document.createElement("div");
1016          treeList.className = "tree-list";
1017          treeList.id = "treeList";
1018
1019          const form = document.getElementById("createTreeForm");
1020          treePicker.insertBefore(treeList, form);
1021          treePicker.insertBefore(sub, treeList);
1022          treePicker.insertBefore(title, sub);
1023        }
1024
1025        const item = document.createElement("div");
1026        item.className = "tree-item";
1027        item.onclick = () => selectTree(data.rootId, name);
1028        item.innerHTML = \`
1029          <span class="tree-item-icon">🌳</span>
1030          <span class="tree-item-name">\${escapeHtml(name)}</span>\`;
1031        item.style.animation = "fadeInUp 0.3s ease-out";
1032        treeList.appendChild(item);
1033
1034        input.value = "";
1035      } catch (err) {
1036        console.error("Create tree error:", err);
1037        alert("Failed to create tree: " + err.message);
1038      } finally {
1039        btn.disabled = false;
1040      }
1041    }
1042
1043    // ── Tree selection ────────────────────────────────────────────────
1044    function selectTree(rootId, name) {
1045      activeRootId = rootId;
1046      advancedLink.href = "/dashboard?rootId=" + rootId;
1047      treePicker.style.display = "none";
1048      chatArea.classList.add("active");
1049      rootName.textContent = name;
1050      rootName.classList.add("visible");
1051      backRow.classList.add("visible");
1052
1053      // Reset chat
1054      const welcome = chatMessages.querySelector(".welcome-message");
1055      if (welcome) welcome.style.display = "";
1056      chatMessages.querySelectorAll(".message, .typing-indicator").forEach(el => el.remove());
1057      chatArea.classList.add("empty");
1058
1059      // Tell server about this root
1060      socket.emit("setActiveRoot", { rootId });
1061      socket.emit("urlChanged", { url: "/api/v1/root/" + rootId, rootId });
1062
1063      // Refresh menu panel for this tree
1064      dreamsLoaded = false;
1065      invitesLoaded = false;
1066      if (notifOpen) {
1067        if (activeTab === "dreams") fetchDreams();
1068        if (activeTab === "invites") fetchInvites();
1069      }
1070
1071      updateSendBtn();
1072    }
1073
1074    function backToTrees() {
1075      // Cancel any in-flight request
1076      if (isSending) {
1077        requestGeneration++;
1078        socket.emit("cancelRequest");
1079        removeTyping();
1080      }
1081
1082      activeRootId = null;
1083      advancedLink.href = "/dashboard";
1084      treePicker.style.display = "";
1085      chatArea.classList.remove("active");
1086      rootName.classList.remove("visible");
1087      backRow.classList.remove("visible");
1088      document.getElementById("clearChatBtn").classList.remove("visible");
1089      isSending = false;
1090      updateSendBtn();
1091
1092      // Tell server we're going home so it properly exits tree mode
1093      socket.emit("urlChanged", { url: "/api/v1/user/" + CONFIG.userId });
1094      socket.emit("clearConversation");
1095      dreamsLoaded = false;
1096      invitesLoaded = false;
1097      if (notifOpen) {
1098        if (activeTab === "dreams") fetchDreams();
1099        if (activeTab === "invites") fetchInvites();
1100      }
1101    }
1102
1103    // ── Messages ──────────────────────────────────────────────────────
1104    function addMessage(content, role) {
1105      const welcome = chatMessages.querySelector(".welcome-message");
1106      if (welcome) {
1107        welcome.remove();
1108        document.getElementById("chatArea").classList.remove("empty");
1109        document.getElementById("clearChatBtn").classList.add("visible");
1110      }
1111
1112      const msg = document.createElement("div");
1113      if (role === "place-status") {
1114        msg.className = "message assistant";
1115        msg.id = "placeStatus";
1116        msg.innerHTML = '<div class="message-avatar">\\ud83c\\udf33</div><div class="message-content"><div class="place-result">' + escapeHtml(content) + '</div></div>';
1117        chatMessages.appendChild(msg);
1118        chatMessages.scrollTop = chatMessages.scrollHeight;
1119        return;
1120      }
1121
1122      msg.className = "message " + role;
1123
1124      const formattedContent = role === "assistant" ? formatMessageContent(content) : escapeHtml(content);
1125
1126      msg.innerHTML =
1127        '<div class="message-avatar">' + (role === "user" ? "\\ud83d\\udc64" : "\\ud83c\\udf33") + '</div>' +
1128        '<div class="message-content">' + formattedContent + '</div>';
1129
1130      // Clickable menu items
1131      if (role === "assistant") {
1132        msg.querySelectorAll(".menu-item.clickable").forEach(function(item) {
1133          item.addEventListener("click", function() {
1134            const name = item.dataset.name;
1135            if (name && !isSending) {
1136              chatInput.value = name;
1137              sendMessage();
1138            }
1139          });
1140        });
1141      }
1142
1143      chatMessages.appendChild(msg);
1144      chatMessages.scrollTop = chatMessages.scrollHeight;
1145    }
1146
1147    function addTyping() {
1148      removeTyping();
1149      const msg = document.createElement("div");
1150      msg.className = "message assistant";
1151      msg.id = "typingIndicator";
1152      msg.innerHTML =
1153        '<div class="message-avatar">\\ud83c\\udf33</div>' +
1154        '<div class="message-content typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
1155      chatMessages.appendChild(msg);
1156      chatMessages.scrollTop = chatMessages.scrollHeight;
1157    }
1158
1159    function removeTyping() {
1160      const el = document.getElementById("typingIndicator");
1161      if (el) el.remove();
1162    }
1163
1164    // ── Send ──────────────────────────────────────────────────────────
1165    function sendMessage() {
1166      if (isSending) {
1167        requestGeneration++;
1168        socket.emit("cancelRequest");
1169        removeTyping();
1170        addMessage("Stopped", "error");
1171        isSending = false;
1172        updateSendBtn();
1173        return;
1174      }
1175
1176      const text = chatInput.value.trim();
1177      if (!text || !isRegistered || !activeRootId) return;
1178
1179      chatInput.value = "";
1180      chatInput.style.height = "auto";
1181      addMessage(text, "user");
1182      if (chatMode === "place") {
1183        addMessage("Placing...", "place-status");
1184      } else {
1185        addTyping();
1186      }
1187      isSending = true;
1188      requestGeneration++;
1189      updateSendBtn();
1190      socket.emit("chat", { message: text, username: CONFIG.username, generation: requestGeneration, mode: chatMode });
1191    }
1192
1193    function updateSendBtn() {
1194      const hasText = chatInput.value.trim().length > 0;
1195      if (isSending) {
1196        sendBtn.classList.add("stop-mode");
1197        sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
1198        sendBtn.disabled = !(isConnected && isRegistered);
1199        chatInput.disabled = true;
1200      } else {
1201        sendBtn.classList.remove("stop-mode");
1202        sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>';
1203        sendBtn.disabled = !(hasText && isRegistered && activeRootId);
1204        chatInput.disabled = false;
1205      }
1206    }
1207
1208    // ── Input handlers ────────────────────────────────────────────────
1209    chatInput.addEventListener("input", () => {
1210      const maxH = chatArea.classList.contains("empty") ? window.innerHeight * 0.4 : 120;
1211      chatInput.style.height = "auto";
1212      chatInput.style.height = Math.min(chatInput.scrollHeight, maxH) + "px";
1213      updateSendBtn();
1214    });
1215
1216    chatInput.addEventListener("keydown", (e) => {
1217      if (e.key === "Enter" && !e.shiftKey) {
1218        e.preventDefault();
1219        sendMessage();
1220        // On mobile, blur to dismiss keyboard so user can see the response
1221        if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
1222          chatInput.blur();
1223        }
1224      }
1225    });
1226
1227    sendBtn.addEventListener("click", sendMessage);
1228
1229    document.getElementById("clearChatBtn").addEventListener("click", () => {
1230      if (!isRegistered) return;
1231      if (isSending) {
1232        socket.emit("cancelRequest");
1233        removeTyping();
1234        isSending = false;
1235      }
1236      socket.emit("clearConversation");
1237      chatMessages.innerHTML = '<div class="welcome-message" id="welcomeMsg"><div class="welcome-icon">🌳</div><h2>Start chatting</h2><p>Ask anything about your tree or tell it something new.</p></div>';
1238      chatArea.classList.add("empty");
1239      document.getElementById("clearChatBtn").classList.remove("visible");
1240      updateSendBtn();
1241    });
1242
1243    // ── Notifications + Invites ────────────────────────────────────────
1244    const notifPanel = document.getElementById("notifPanel");
1245    const notifOverlay = document.getElementById("notifOverlay");
1246    const notifList = document.getElementById("notifList");
1247    const invitesList = document.getElementById("invitesList");
1248    const notifDot = document.getElementById("notifDot");
1249    let notifOpen = false;
1250    let dreamsLoaded = false;
1251    let invitesLoaded = false;
1252    let activeTab = "dreams";
1253
1254    async function doLogout() {
1255      try {
1256        await fetch("/api/v1/logout", { method: "POST", credentials: "include" });
1257        window.location.href = "/login";
1258      } catch(e) {
1259        alert("Logout failed");
1260      }
1261    }
1262
1263    function toggleNotifs() {
1264      notifOpen = !notifOpen;
1265      notifPanel.classList.toggle("open", notifOpen);
1266      notifOverlay.classList.toggle("open", notifOpen);
1267      if (notifOpen) {
1268        if (activeTab === "dreams" && !dreamsLoaded) fetchDreams();
1269        if (activeTab === "invites" && !invitesLoaded) fetchInvites();
1270      }
1271    }
1272
1273    function switchTab(tab) {
1274      activeTab = tab;
1275      document.getElementById("tabDreams").classList.toggle("active", tab === "dreams");
1276      document.getElementById("tabInvites").classList.toggle("active", tab === "invites");
1277      notifList.style.display = tab === "dreams" ? "" : "none";
1278      invitesList.style.display = tab === "invites" ? "" : "none";
1279      if (tab === "dreams" && !dreamsLoaded) fetchDreams();
1280      if (tab === "invites" && !invitesLoaded) fetchInvites();
1281    }
1282
1283    async function fetchDreams() {
1284      notifList.innerHTML = '<div class="notif-loading">Loading...</div>';
1285      dreamsLoaded = false;
1286      try {
1287        var dreamUrl = "/chat/notifications" + (activeRootId ? "?rootId=" + activeRootId : "");
1288        var res = await fetch(dreamUrl, { credentials: "include" });
1289        var data = await res.json();
1290        if (!res.ok) throw new Error(data.error || "Failed");
1291
1292        dreamsLoaded = true;
1293        var notifs = data.notifications || [];
1294        var html = "";
1295
1296        // Dream time config (only when inside a tree and user is owner)
1297        if (activeRootId && data.isOwner) {
1298          if (data.metadata?.dreams?.dreamTime) {
1299            html += '<div class="dream-config">' +
1300              '<div class="dream-config-label">Dream schedule</div>' +
1301              '<div class="dream-config-row">' +
1302                '<input type="time" id="dreamTimeInput" value="' + escapeHtml(data.metadata?.dreams?.dreamTime) + '" />' +
1303                '<button class="dream-config-save" onclick="saveDreamTime()">Save</button>' +
1304                '<button class="dream-config-off" onclick="disableDreamTime()">Turn Off</button>' +
1305              '</div>' +
1306              '<div class="dream-config-status" id="dreamStatus"></div>' +
1307            '</div>';
1308          } else {
1309            html += '<div class="dream-config">' +
1310              '<div class="dream-config-hint">Dreams are off for this tree. Set a time to enable nightly dreams. Your tree will reflect, reorganize, and share thoughts with you.</div>' +
1311              '<div class="dream-config-row" style="margin-top:8px">' +
1312                '<input type="time" id="dreamTimeInput" value="" />' +
1313                '<button class="dream-config-save" onclick="saveDreamTime()">Enable</button>' +
1314              '</div>' +
1315              '<div class="dream-config-status" id="dreamStatus"></div>' +
1316            '</div>';
1317          }
1318        }
1319
1320        if (notifs.length === 0) {
1321          html += '<div class="notif-empty"><span class="notif-empty-icon">\\ud83d\\udd14</span>' +
1322            (activeRootId ? 'No dreams from this tree yet' : 'No dream notifications from the last 7 days') +
1323          '</div>';
1324          notifList.innerHTML = html;
1325          return;
1326        }
1327
1328        document.getElementById("dreamsDot").classList.add("visible");
1329        notifDot.classList.add("has-notifs");
1330        html += notifs.map(function(n, i) {
1331          var isThought = n.type === "dream-thought";
1332          var icon = isThought ? "\\ud83d\\udcad" : "\\ud83d\\udccb";
1333          var badge = isThought ? "Thought" : "Summary";
1334          var cls = isThought ? "type-thought" : "type-summary";
1335          var date = new Date(n.createdAt).toLocaleDateString(undefined, {
1336            month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
1337          });
1338          return '<div class="notif-item ' + cls + '" style="animation-delay:' + (i * 0.04) + 's">' +
1339            '<div class="notif-item-header">' +
1340              '<span class="notif-item-icon">' + icon + '</span>' +
1341              '<span class="notif-item-title">' + escapeHtml(n.title) + '</span>' +
1342              '<span class="notif-item-badge">' + badge + '</span>' +
1343            '</div>' +
1344            '<div class="notif-item-content">' + escapeHtml(n.content) + '</div>' +
1345            '<div class="notif-item-time">' + date + '</div>' +
1346          '</div>';
1347        }).join("");
1348        notifList.innerHTML = html;
1349      } catch (err) {
1350        console.error("Dreams error:", err);
1351        notifList.innerHTML = '<div class="notif-empty">Failed to load notifications</div>';
1352      }
1353    }
1354
1355    async function saveDreamTime() {
1356      var input = document.getElementById("dreamTimeInput");
1357      var status = document.getElementById("dreamStatus");
1358      if (!input.value) { status.textContent = "Pick a time first"; return; }
1359      try {
1360        var res = await fetch("/api/v1/root/" + activeRootId + "/dream-time", {
1361          method: "POST",
1362          headers: { "Content-Type": "application/json" },
1363          credentials: "include",
1364          body: JSON.stringify({ dreamTime: input.value }),
1365        });
1366        var data = await res.json();
1367        if (!res.ok) throw new Error(data.error || "Failed");
1368        status.textContent = "Dreams set for " + input.value;
1369        dreamsLoaded = false;
1370        fetchDreams();
1371      } catch (err) {
1372        status.textContent = err.message;
1373      }
1374    }
1375
1376    async function disableDreamTime() {
1377      var status = document.getElementById("dreamStatus");
1378      try {
1379        var res = await fetch("/api/v1/root/" + activeRootId + "/dream-time", {
1380          method: "POST",
1381          headers: { "Content-Type": "application/json" },
1382          credentials: "include",
1383          body: JSON.stringify({ dreamTime: null }),
1384        });
1385        var data = await res.json();
1386        if (!res.ok) throw new Error(data.error || "Failed");
1387        status.textContent = "Dreams disabled";
1388        dreamsLoaded = false;
1389        fetchDreams();
1390      } catch (err) {
1391        status.textContent = err.message;
1392      }
1393    }
1394
1395    async function fetchInvites() {
1396      invitesList.innerHTML = '<div class="notif-loading">Loading...</div>';
1397      invitesLoaded = false;
1398      try {
1399        var invUrl = "/chat/invites" + (activeRootId ? "?rootId=" + activeRootId : "");
1400        var res = await fetch(invUrl, { credentials: "include" });
1401        var data = await res.json();
1402        if (!res.ok) throw new Error(data.error || "Failed");
1403
1404        invitesLoaded = true;
1405        var html = "";
1406
1407        // Pending invites section
1408        var invites = data.invites || [];
1409        if (invites.length > 0) {
1410          document.getElementById("invitesDot").classList.add("visible");
1411          notifDot.classList.add("has-notifs");
1412          html += invites.map(function(inv, i) {
1413            return '<div class="invite-item" style="animation-delay:' + (i * 0.04) + 's">' +
1414              '<div class="invite-item-text"><strong>' + escapeHtml(inv.from) + '</strong> invited you to <strong>' + escapeHtml(inv.treeName) + (inv.isRemote && inv.homeLand ? ' on ' + escapeHtml(inv.homeLand) : '') + '</strong></div>' +
1415              '<div class="invite-item-actions">' +
1416                '<button class="invite-accept" onclick="respondInvite(\\'' + inv.id + '\\', true, this)">Accept</button>' +
1417                '<button class="invite-decline" onclick="respondInvite(\\'' + inv.id + '\\', false, this)">Decline</button>' +
1418              '</div>' +
1419            '</div>';
1420          }).join("");
1421        }
1422
1423        // Members section (only when inside a tree)
1424        if (activeRootId && data.members) {
1425          var members = data.members;
1426          html += '<div class="members-section">';
1427          html += '<div class="members-section-title">Members</div>';
1428
1429          // Owner
1430          if (members.owner) {
1431            html += '<div class="member-item">' +
1432              '<div><span class="member-name">' + escapeHtml(members.owner.username) + '</span><span class="member-role">Owner</span></div>' +
1433            '</div>';
1434          }
1435
1436          // Contributors
1437          (members.contributors || []).forEach(function(c) {
1438            var isSelf = c._id === CONFIG.userId;
1439            var isOwner = members.isOwner;
1440            var actions = '';
1441            if (isOwner || isSelf) {
1442              var label = isSelf ? "Leave" : "Remove";
1443              var cls = isSelf ? "btn-danger" : "btn-danger";
1444              actions = '<div class="member-actions">';
1445              if (isOwner && !isSelf) {
1446                actions += '<button onclick="transferOwner(\\'' + c._id + '\\', this)">Transfer</button>';
1447              }
1448              actions += '<button class="' + cls + '" onclick="removeMember(\\'' + c._id + '\\', \\'' + label + '\\', this)">' + label + '</button>';
1449              actions += '</div>';
1450            }
1451            html += '<div class="member-item">' +
1452              '<div><span class="member-name">' + escapeHtml(c.username) + '</span></div>' +
1453              actions +
1454            '</div>';
1455          });
1456
1457          // Invite form (owner or contributor)
1458          if (members.isOwner || members.contributors.some(function(c) { return c._id === userId; })) {
1459            html += '<form class="invite-form" onsubmit="sendInvite(event)">' +
1460              '<input type="text" id="inviteUsername" placeholder="username or user@other.land.com" />' +
1461              '<button type="submit">Invite</button>' +
1462            '</form>' +
1463            '<div class="invite-status" id="inviteStatus"></div>';
1464          }
1465          html += '</div>';
1466        }
1467
1468        if (!html) {
1469          html = '<div class="notif-empty"><span class="notif-empty-icon">\\ud83d\\udcec</span>No pending invites</div>';
1470        }
1471
1472        invitesList.innerHTML = html;
1473      } catch (err) {
1474        console.error("Invites error:", err);
1475        invitesList.innerHTML = '<div class="notif-empty">Failed to load invites</div>';
1476      }
1477    }
1478
1479    async function respondInvite(inviteId, accept, btn) {
1480      var item = btn.closest(".invite-item");
1481      item.style.opacity = "0.5";
1482      item.style.pointerEvents = "none";
1483      try {
1484        var res = await fetch("/chat/invites/" + inviteId, {
1485          method: "POST",
1486          headers: { "Content-Type": "application/json" },
1487          credentials: "include",
1488          body: JSON.stringify({ accept: accept }),
1489        });
1490        var data = await res.json();
1491        if (!res.ok) throw new Error(data.error || "Failed");
1492        item.remove();
1493        // Refresh tree list if accepted
1494        if (accept) {
1495          location.reload();
1496        }
1497      } catch (err) {
1498        item.style.opacity = "1";
1499        item.style.pointerEvents = "";
1500        alert(err.message);
1501      }
1502    }
1503
1504    async function sendInvite(e) {
1505      e.preventDefault();
1506      var input = document.getElementById("inviteUsername");
1507      var status = document.getElementById("inviteStatus");
1508      var username = input.value.trim();
1509      if (!username) return;
1510
1511      status.className = "invite-status";
1512      status.textContent = "";
1513
1514      try {
1515        var res = await fetch("/api/v1/root/" + activeRootId + "/invite", {
1516          method: "POST",
1517          headers: { "Content-Type": "application/json" },
1518          credentials: "include",
1519          body: JSON.stringify({ userReceiving: username }),
1520        });
1521        var data = await res.json();
1522        if (!res.ok) throw new Error(data.error || "Failed");
1523
1524        status.textContent = "Invite sent!";
1525        status.className = "invite-status success";
1526        input.value = "";
1527      } catch (err) {
1528        status.textContent = err.message;
1529        status.className = "invite-status error";
1530      }
1531    }
1532
1533    async function removeMember(userId, label, btn) {
1534      if (!confirm("Are you sure you want to " + label.toLowerCase() + "?")) return;
1535      btn.disabled = true;
1536      try {
1537        var res = await fetch("/api/v1/root/" + activeRootId + "/remove-user", {
1538          method: "POST",
1539          headers: { "Content-Type": "application/json" },
1540          credentials: "include",
1541          body: JSON.stringify({ userReceiving: userId }),
1542        });
1543        var data = await res.json();
1544        if (!res.ok) throw new Error(data.error || "Failed");
1545        if (userId === CONFIG.userId) {
1546          location.reload();
1547        } else {
1548          invitesLoaded = false;
1549          fetchInvites();
1550        }
1551      } catch (err) {
1552        btn.disabled = false;
1553        alert(err.message);
1554      }
1555    }
1556
1557    async function transferOwner(userId, btn) {
1558      if (!confirm("Transfer ownership? This cannot be undone.")) return;
1559      btn.disabled = true;
1560      try {
1561        var res = await fetch("/api/v1/root/" + activeRootId + "/transfer-owner", {
1562          method: "POST",
1563          headers: { "Content-Type": "application/json" },
1564          credentials: "include",
1565          body: JSON.stringify({ userReceiving: userId }),
1566        });
1567        var data = await res.json();
1568        if (!res.ok) throw new Error(data.error || "Failed");
1569        invitesLoaded = false;
1570        fetchInvites();
1571      } catch (err) {
1572        btn.disabled = false;
1573        alert(err.message);
1574      }
1575    }
1576
1577    // Check for notifications + invites on load
1578    fetch("/chat/notifications", { credentials: "include" })
1579      .then(function(r) { return r.json(); })
1580      .then(function(d) {
1581        if (d.notifications && d.notifications.length > 0) {
1582          notifDot.classList.add("has-notifs");
1583          document.getElementById("dreamsDot").classList.add("visible");
1584        }
1585      })
1586      .catch(function() {});
1587
1588    fetch("/chat/invites", { credentials: "include" })
1589      .then(function(r) { return r.json(); })
1590      .then(function(d) {
1591        if (d.invites && d.invites.length > 0) {
1592          notifDot.classList.add("has-notifs");
1593          document.getElementById("invitesDot").classList.add("visible");
1594        }
1595      })
1596      .catch(function() {});
1597  </script>
1598</body>
1599</html>`);
1600  } catch (err) {
1601    console.error("Error rendering /chat:", err);
1602    return res.status(500).send("Internal server error");
1603  }
1604});
1605
1606router.get("/chat/notifications", authenticateLite, async (req, res) => {
1607  try {
1608    if (!req.userId)
1609      return res.status(401).json({ error: "Not authenticated" });
1610    const rootId = req.query.rootId;
1611    const { notifications, total } = await getNotifications({
1612      userId: req.userId,
1613      rootId,
1614      limit: 50,
1615      sinceDays: 7,
1616    });
1617
1618    // Include dream config when viewing a specific tree
1619    let dreamTime = null;
1620    let isOwner = false;
1621    if (rootId) {
1622      const rootNode = await Node.findById(rootId)
1623        .select("metadata rootOwner")
1624        .lean();
1625      if (rootNode) {
1626        dreamTime = rootNode.metadata?.dreams?.dreamTime || null;
1627        isOwner = rootNode.rootOwner?.toString() === req.userId.toString();
1628      }
1629    }
1630
1631    return res.json({ notifications, total, dreamTime, isOwner });
1632  } catch (err) {
1633    console.error("Chat notifications error:", err);
1634    return res.status(500).json({ error: err.message });
1635  }
1636});
1637
1638// ── Invites + Members API for chat panel ──────────────────────────────
1639router.get("/chat/invites", authenticateLite, async (req, res) => {
1640  try {
1641    if (!req.userId)
1642      return res.status(401).json({ error: "Not authenticated" });
1643
1644    // Pending invites for this user
1645    const invites = await getPendingInvitesForUser(req.userId);
1646    const inviteList = invites.map((inv) => ({
1647      id: inv._id,
1648      from: inv.userInviting?.username
1649        ? (inv.userInviting.isRemote && inv.userInviting.homeLand
1650          ? inv.userInviting.username + "@" + inv.userInviting.homeLand
1651          : inv.userInviting.username)
1652        : "Unknown",
1653      isRemote: inv.userInviting?.isRemote || false,
1654      homeLand: inv.userInviting?.homeLand || null,
1655      treeName: inv.rootId?.name || "Unknown tree",
1656      rootId: inv.rootId?._id || inv.rootId,
1657    }));
1658
1659    // Members (only if rootId query param provided = user is in a tree)
1660    let members = null;
1661    const rootId = req.query.rootId;
1662    if (rootId) {
1663      const rootNode = await Node.findById(rootId)
1664        .populate("rootOwner", "username _id")
1665        .populate("contributors", "username _id")
1666        .select("rootOwner contributors")
1667        .lean();
1668      if (rootNode) {
1669        members = {
1670          owner: rootNode.rootOwner || null,
1671          contributors: rootNode.contributors || [],
1672          isOwner:
1673            rootNode.rootOwner?._id?.toString() === req.userId.toString(),
1674        };
1675      }
1676    }
1677
1678    return res.json({ invites: inviteList, members });
1679  } catch (err) {
1680    console.error("Chat invites error:", err);
1681    return res.status(500).json({ error: err.message });
1682  }
1683});
1684
1685router.post("/chat/invites/:inviteId", authenticateLite, async (req, res) => {
1686  try {
1687    if (!req.userId)
1688      return res.status(401).json({ error: "Not authenticated" });
1689    const { accept } = req.body;
1690    await respondToInvite({
1691      inviteId: req.params.inviteId,
1692      userId: req.userId,
1693      acceptInvite: accept === true || accept === "true",
1694    });
1695    return res.json({ success: true });
1696  } catch (err) {
1697    return res.status(400).json({ error: err.message });
1698  }
1699});
1700
1701export default router;
1702
1// routesURL/sessionManagerPartial.js
2// Exports CSS, HTML, and JS strings for the session manager view
3// embedded inside app.js viewport panel.
4
5export function dashboardCSS() {
6  return `
7    /* ── Dashboard view ─────────────────────────────────────────────── */
8    .dashboard-view {
9      display: none;
10      width: 100%;
11      height: 100%;
12      flex-direction: column;
13      overflow: hidden;
14    }
15    .dashboard-view.active { display: flex; }
16    .dashboard-view.disconnected { position: relative; pointer-events: none; }
17    .dashboard-view.disconnected::after {
18      content: "Disconnected";
19      position: absolute;
20      inset: 0;
21      display: flex;
22      align-items: center;
23      justify-content: center;
24      background: rgba(0, 0, 0, 0.5);
25      color: var(--text-muted);
26      font-size: 14px;
27      font-weight: 500;
28      letter-spacing: 0.5px;
29      z-index: 100;
30    }
31    .iframe-container.hidden { display: none; }
32
33    .dashboard-layout {
34      display: flex;
35      flex: 1;
36      overflow: hidden;
37    }
38
39    /* ── Main area ───────────────────────────────────────────────────── */
40    .dash-tree-view {
41      flex: 1;
42      display: flex;
43      flex-direction: column;
44      padding: 16px;
45      position: relative;
46      min-height: 0;
47      overflow: hidden;
48    }
49    #dashForestView {
50      flex: 1;
51      overflow: auto;
52      min-height: 0;
53    }
54    #dashTreeContent {
55      flex: 1;
56      display: flex;
57      flex-direction: column;
58      min-height: 0;
59    }
60    #dashTreeCanvas {
61      flex: 1;
62      min-height: 0;
63    }
64    .dash-tree-header {
65      display: flex;
66      align-items: center;
67      gap: 10px;
68      margin-bottom: 12px;
69      padding-bottom: 8px;
70      border-bottom: 1px solid rgba(255,255,255,0.06);
71    }
72    .dash-tree-title {
73      font-size: 14px;
74      font-weight: 600;
75      color: var(--text-secondary);
76    }
77    .dash-back-btn {
78      padding: 4px 10px;
79      border-radius: 6px;
80      border: 1px solid rgba(255,255,255,0.1);
81      background: rgba(255,255,255,0.06);
82      color: var(--text-secondary);
83      font-size: 11px;
84      cursor: pointer;
85      transition: all 0.15s;
86      white-space: nowrap;
87    }
88    .dash-back-btn:hover { background: rgba(255,255,255,0.15); color: var(--text-primary); }
89
90    .dash-close-btn {
91      margin-left: auto;
92      width: 28px; height: 28px;
93      display: flex; align-items: center; justify-content: center;
94      border-radius: 8px;
95      border: 1px solid rgba(255,255,255,0.1);
96      background: rgba(255,255,255,0.06);
97      color: var(--text-muted);
98      font-size: 16px; line-height: 1;
99      cursor: pointer;
100      transition: all 0.15s;
101      flex-shrink: 0;
102    }
103    .dash-close-btn:hover { background: rgba(255,255,255,0.15); color: var(--text-primary); }
104    .dash-close-btn:active { transform: scale(0.93); }
105
106    /* ── Raw idea processing strip ──────────────────────────────────── */
107    .raw-idea-space {
108      margin-bottom: 14px;
109      flex-shrink: 0;
110    }
111    .raw-idea-label {
112      font-size: 10px;
113      font-weight: 600;
114      text-transform: uppercase;
115      color: var(--text-muted);
116      margin-bottom: 6px;
117      letter-spacing: 0.5px;
118    }
119    .raw-idea-list {
120      display: flex;
121      gap: 8px;
122      overflow-x: auto;
123      padding-bottom: 4px;
124    }
125    .raw-idea-card {
126      display: flex;
127      align-items: center;
128      gap: 8px;
129      padding: 6px 12px;
130      border-radius: 8px;
131      background: rgba(251,191,36,0.1);
132      border: 1px solid rgba(251,191,36,0.2);
133      flex-shrink: 0;
134      cursor: pointer;
135      transition: all 0.15s;
136      max-width: 200px;
137    }
138    .raw-idea-card:hover { background: rgba(251,191,36,0.18); }
139    .raw-idea-pulse {
140      width: 8px;
141      height: 8px;
142      border-radius: 50%;
143      background: rgba(251,191,36,0.8);
144      flex-shrink: 0;
145      animation: rawPulse 1.5s ease-in-out infinite;
146    }
147    @keyframes rawPulse {
148      0%, 100% { opacity: 0.4; transform: scale(0.9); }
149      50% { opacity: 1; transform: scale(1.1); }
150    }
151    .raw-idea-desc {
152      font-size: 11px;
153      color: var(--text-secondary);
154      overflow: hidden;
155      text-overflow: ellipsis;
156      white-space: nowrap;
157    }
158
159    /* ── Forest view (grid of root trees) ──────────────────────────── */
160    .dash-forest {
161      display: grid;
162      grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
163      gap: 12px;
164      padding: 4px 0;
165    }
166    .dash-root-card {
167      display: flex;
168      flex-direction: column;
169      align-items: center;
170      gap: 6px;
171      padding: 16px 10px 12px;
172      border-radius: 10px;
173      background: rgba(255,255,255,0.05);
174      border: 1px solid rgba(255,255,255,0.08);
175      cursor: pointer;
176      transition: all 0.15s;
177      position: relative;
178      text-align: center;
179    }
180    .dash-root-card:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.15); }
181    .dash-root-card.has-sessions { border-color: rgba(16,185,129,0.3); }
182    .dash-root-icon {
183      font-size: 28px;
184      line-height: 1;
185    }
186    .dash-root-name {
187      font-size: 12px;
188      font-weight: 500;
189      color: var(--text-secondary);
190      overflow: hidden;
191      text-overflow: ellipsis;
192      white-space: nowrap;
193      max-width: 100%;
194    }
195    .dash-root-info {
196      font-size: 9px;
197      color: var(--text-muted);
198    }
199    .dash-root-badge {
200      position: absolute;
201      top: 6px;
202      right: 6px;
203      background: var(--accent);
204      color: #000;
205      font-size: 9px;
206      font-weight: 700;
207      width: 18px;
208      height: 18px;
209      border-radius: 50%;
210      display: flex;
211      align-items: center;
212      justify-content: center;
213    }
214    .dash-forest-empty {
215      grid-column: 1 / -1;
216      text-align: center;
217      padding: 40px 16px;
218      color: var(--text-muted);
219      font-size: 13px;
220    }
221    .dash-forest-empty-icon { font-size: 40px; opacity: 0.4; margin-bottom: 8px; }
222
223    /* ── Visual tree (SVG) ───────────────────────────────────────────── */
224    .vtree-container {
225      width: 100%;
226      height: 100%;
227      display: flex;
228      align-items: center;
229      justify-content: center;
230      overflow: auto;
231    }
232    .vtree-svg {
233      width: 100%;
234      height: 100%;
235      max-width: 100%;
236      max-height: 100%;
237    }
238    .vtree-node { cursor: pointer; }
239    .vtree-node:hover circle.vtree-main { filter: brightness(1.4); }
240    .vtree-highlight-ring.active {
241      stroke: var(--accent) !important;
242      stroke-width: 2.5;
243      filter: drop-shadow(0 0 8px rgba(16,185,129,0.6));
244    }
245    .vtree-tooltip {
246      position: absolute;
247      background: rgba(0,0,0,0.88);
248      color: #fff;
249      padding: 6px 10px;
250      border-radius: 6px;
251      font-size: 11px;
252      pointer-events: none;
253      z-index: 40;
254      max-width: 220px;
255      white-space: nowrap;
256      display: none;
257      box-shadow: 0 2px 8px rgba(0,0,0,0.4);
258    }
259    .vtree-tooltip.visible { display: block; }
260    .vtree-tooltip-name { font-weight: 600; }
261    .vtree-tooltip-status { opacity: 0.65; margin-left: 6px; font-size: 10px; }
262    .vtree-badge-dot {
263      stroke: none;
264      filter: drop-shadow(0 0 3px rgba(16,185,129,0.5));
265    }
266
267    /* ── Session sidebar ────────────────────────────────────────────── */
268    .session-sidebar {
269      width: 280px;
270      flex-shrink: 0;
271      border-left: 1px solid var(--glass-border-light);
272      display: flex;
273      flex-direction: column;
274      overflow: hidden;
275    }
276    .session-sidebar-header {
277      padding: 12px 16px;
278      border-bottom: 1px solid var(--glass-border-light);
279      display: flex;
280      align-items: center;
281      justify-content: space-between;
282      flex-shrink: 0;
283    }
284    .session-sidebar-header h3 {
285      font-size: 14px;
286      font-weight: 600;
287    }
288    .session-count-badge {
289      background: rgba(255,255,255,0.15);
290      padding: 2px 8px;
291      border-radius: 100px;
292      font-size: 11px;
293      font-weight: 600;
294    }
295    .session-list {
296      flex: 1;
297      overflow-y: auto;
298      padding: 8px;
299    }
300
301    /* ── Session cards ──────────────────────────────────────────────── */
302    .session-card {
303      padding: 10px 12px;
304      border-radius: 8px;
305      background: rgba(255,255,255,0.06);
306      border: 1px solid rgba(255,255,255,0.08);
307      margin-bottom: 6px;
308      transition: all 0.15s;
309      cursor: pointer;
310    }
311    .session-card:hover { background: rgba(255,255,255,0.1); }
312    .session-card.tracked {
313      border-color: var(--accent);
314      background: rgba(16, 185, 129, 0.1);
315    }
316    .session-card-header {
317      display: flex;
318      align-items: center;
319      gap: 8px;
320      margin-bottom: 6px;
321    }
322    .session-type-icon { font-size: 16px; }
323    .session-desc {
324      font-size: 12px;
325      font-weight: 500;
326      color: var(--text-secondary);
327      flex: 1;
328      overflow: hidden;
329      text-overflow: ellipsis;
330      white-space: nowrap;
331    }
332    .session-stop-btn {
333      background: none;
334      border: 1px solid rgba(239, 68, 68, 0.3);
335      color: #ef4444;
336      font-size: 10px;
337      font-weight: 600;
338      line-height: 1;
339      cursor: pointer;
340      padding: 3px 8px;
341      border-radius: 4px;
342      transition: all 0.15s;
343      flex-shrink: 0;
344      opacity: 0;
345    }
346    .session-card:hover .session-stop-btn { opacity: 1; }
347    .session-stop-btn:hover {
348      color: #fff;
349      background: rgba(239, 68, 68, 0.4);
350    }
351    .session-meta-info {
352      font-size: 10px;
353      color: var(--text-muted);
354      margin-bottom: 6px;
355    }
356    .session-actions {
357      display: flex;
358      gap: 4px;
359    }
360    .session-btn {
361      padding: 3px 8px;
362      border-radius: 4px;
363      border: 1px solid rgba(255,255,255,0.1);
364      background: rgba(255,255,255,0.06);
365      color: var(--text-secondary);
366      font-size: 10px;
367      cursor: pointer;
368      transition: all 0.15s;
369    }
370    .session-btn:hover { background: rgba(255,255,255,0.15); color: var(--text-primary); }
371    .session-btn.active { background: rgba(16,185,129,0.2); border-color: var(--accent); color: var(--accent); }
372
373    /* ── Chat slide-out panel ───────────────────────────────────────── */
374    .dashboard-chat-panel {
375      position: absolute;
376      top: 0;
377      right: 0;
378      width: 360px;
379      height: 100%;
380      background: rgba(var(--glass-rgb), 0.95);
381      backdrop-filter: blur(var(--glass-blur));
382      border-left: 1px solid var(--glass-border);
383      transform: translateX(100%);
384      transition: transform 0.25s ease;
385      z-index: 30;
386      display: flex;
387      flex-direction: column;
388      overflow: hidden;
389    }
390    .dashboard-chat-panel.open { transform: translateX(0); }
391    .dash-chat-header {
392      padding: 12px 16px;
393      border-bottom: 1px solid var(--glass-border-light);
394      display: flex;
395      flex-direction: column;
396      align-items: flex-start;
397      flex-shrink: 0;
398    }
399    .dash-chat-close {
400      background: none;
401      border: none;
402      color: var(--text-muted);
403      cursor: pointer;
404      padding: 4px;
405      font-size: 18px;
406    }
407    .dash-chat-close:hover { color: var(--text-primary); }
408    .dash-chat-kill {
409      color: #ef4444;
410      font-size: 11px;
411      font-weight: 600;
412    }
413    .dash-chat-kill:hover { color: #f87171 !important; }
414    .dash-chat-body {
415      flex: 1;
416      overflow-y: auto;
417      padding: 12px 16px;
418    }
419    .chat-message-item {
420      margin-bottom: 12px;
421      padding: 8px 10px;
422      border-radius: 8px;
423      background: rgba(255,255,255,0.05);
424      font-size: 12px;
425      color: var(--text-secondary);
426      line-height: 1.5;
427    }
428    .chat-message-role {
429      font-size: 10px;
430      font-weight: 600;
431      color: var(--text-muted);
432      margin-bottom: 4px;
433      text-transform: uppercase;
434    }
435
436    /* (dashboard toggle buttons are now in app.js chat panel) */
437
438    /* ── Mobile sessions overlay ────────────────────────────────────── */
439    .mobile-sessions-pill {
440      display: none;
441      z-index: 25;
442      align-items: center;
443      align-self: flex-start;
444      flex-shrink: 0;
445      gap: 5px;
446      padding: 4px 10px;
447      margin-bottom: 8px;
448      border-radius: 20px;
449      background: rgba(var(--glass-rgb), 0.8);
450      backdrop-filter: blur(10px);
451      border: 1px solid var(--glass-border);
452      color: var(--text-secondary);
453      cursor: pointer;
454      font-size: 12px;
455      font-weight: 500;
456      transition: all 0.15s;
457    }
458    .mobile-sessions-pill:hover { background: rgba(var(--glass-rgb), 0.95); }
459    .mobile-sessions-pill.has-activity { border-color: rgba(16,185,129,0.4); }
460    .mobile-sessions-pill .pill-count {
461      background: var(--accent);
462      color: #000;
463      font-size: 9px;
464      font-weight: 700;
465      min-width: 16px;
466      height: 16px;
467      border-radius: 50%;
468      display: flex;
469      align-items: center;
470      justify-content: center;
471      padding: 0 3px;
472    }
473    .mobile-sessions-pill .pill-label {
474      font-size: 11px;
475    }
476    .mobile-sessions-overlay {
477      display: none;
478      position: absolute;
479      top: 52px;
480      right: 8px;
481      left: 8px;
482      max-height: 70%;
483      background: rgba(var(--glass-rgb), 0.95);
484      backdrop-filter: blur(var(--glass-blur));
485      border: 1px solid var(--glass-border);
486      border-radius: 12px;
487      z-index: 35;
488      flex-direction: column;
489      overflow: hidden;
490      box-shadow: 0 8px 32px rgba(0,0,0,0.4);
491    }
492    .mobile-sessions-overlay.open { display: flex; }
493    .mobile-overlay-header {
494      padding: 10px 14px;
495      border-bottom: 1px solid var(--glass-border-light);
496      display: flex;
497      align-items: center;
498      justify-content: space-between;
499      flex-shrink: 0;
500    }
501    .mobile-overlay-header h3 { font-size: 13px; font-weight: 600; margin: 0; }
502    .mobile-overlay-close {
503      background: none;
504      border: none;
505      color: var(--text-muted);
506      cursor: pointer;
507      font-size: 18px;
508      padding: 0 2px;
509      line-height: 1;
510    }
511    .mobile-overlay-close:hover { color: var(--text-primary); }
512    .mobile-overlay-body {
513      flex: 1;
514      overflow-y: auto;
515      padding: 8px 10px 12px;
516    }
517    .mobile-sessions-scrim {
518      display: none;
519      position: absolute;
520      inset: 0;
521      z-index: 34;
522      background: rgba(0,0,0,0.3);
523    }
524    .mobile-sessions-scrim.open { display: block; }
525
526    /* ── Mobile adjustments ─────────────────────────────────────────── */
527    @media (max-width: 768px) {
528      .dashboard-layout { flex-direction: column; }
529      .session-sidebar { display: none; }
530      .dashboard-chat-panel { width: 100%; }
531      .mobile-sessions-pill { display: flex; }
532      .dash-forest { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; }
533    }
534  `;
535}
536
537export function dashboardHTML() {
538  return `
539    <div class="dashboard-view" id="dashboardView">
540      <div class="dashboard-layout">
541        <div class="dash-tree-view" id="dashTreeView">
542
543          <!-- Mobile sessions (inline row, not floating) -->
544          <button class="mobile-sessions-pill" id="mobileSessionsPill">
545            <span class="pill-label">Sessions</span>
546            <span class="pill-count" id="mobileSessionsPillCount">0</span>
547          </button>
548          <div class="mobile-sessions-scrim" id="mobileSessionsScrim"></div>
549          <div class="mobile-sessions-overlay" id="mobileSessionsOverlay">
550            <div class="mobile-overlay-header">
551              <h3>Sessions <span class="session-count-badge" id="dashSessionCountMobile">0</span></h3>
552              <button class="mobile-overlay-close" id="mobileSessionsClose">&times;</button>
553            </div>
554            <div class="mobile-overlay-body" id="mobileSessionsList"></div>
555          </div>
556
557          <!-- Raw ideas being processed (visible when any exist) -->
558          <div class="raw-idea-space" id="rawIdeaSpace" style="display:none">
559            <div class="raw-idea-label">Processing</div>
560            <div class="raw-idea-list" id="rawIdeaList"></div>
561          </div>
562
563          <!-- Forest view — all root trees (default) -->
564          <div id="dashForestView">
565            <div class="dash-tree-header">
566              <span class="dash-tree-title">Your Trees</span>
567              <button class="dash-close-btn" id="dashCloseBtn1" title="Close dashboard">&times;</button>
568            </div>
569            <div class="dash-forest" id="dashForestGrid"></div>
570          </div>
571
572          <!-- Single tree view (shown when a root is selected) -->
573          <div id="dashTreeContent" style="display:none">
574            <div class="dash-tree-header">
575              <button class="dash-back-btn" id="dashBackBtn">&larr; All Trees</button>
576              <span class="dash-tree-title" id="dashTreeTitle">Tree</span>
577              <button class="dash-close-btn" id="dashCloseBtn2" title="Close dashboard">&times;</button>
578            </div>
579            <div id="dashTreeCanvas"></div>
580          </div>
581
582          <div class="vtree-tooltip" id="vtreeTooltip">
583            <span class="vtree-tooltip-name" id="vtreeTooltipName"></span>
584            <span class="vtree-tooltip-status" id="vtreeTooltipStatus"></span>
585          </div>
586        </div>
587        <div class="session-sidebar" id="sessionSidebar">
588          <div class="session-sidebar-header">
589            <h3>Sessions</h3>
590            <span class="session-count-badge" id="dashSessionCount">0</span>
591          </div>
592          <div class="session-list" id="dashSessionList"></div>
593        </div>
594      </div>
595      <div class="dashboard-chat-panel" id="dashChatPanel">
596        <div class="dash-chat-header">
597          <span id="dashChatTitle" style="font-size:13px;font-weight:600">Messages</span>
598          <div class="dash-chat-controls" style="display:flex;gap:6px;align-items:center;margin-top:8px">
599            <button class="dash-chat-close" id="dashChatClose" title="Back">&larr;</button>
600            <button class="dash-chat-close" id="dashChatRefresh" title="Refresh">&#x21BB;</button>
601            <button class="dash-chat-close dash-chat-kill" id="dashChatKill" title="Kill session">Kill</button>
602          </div>
603        </div>
604        <div class="dash-chat-body" id="dashChatBody"></div>
605      </div>
606    </div>
607  `;
608}
609
610export function dashboardJS() {
611  return `
612    // ══════════════════════════════════════════════════════════════════
613    // SESSION DASHBOARD
614    // ══════════════════════════════════════════════════════════════════
615
616    (function() {
617      var dashboardActive = false;
618      var dashMode = "forest";       // "forest" or "tree"
619      var dashSessions = [];
620      var dashRoots = [];
621      var dashTrackedSessionId = null;
622      var dashTrackedNavRootId = null;  // last rootId we auto-navigated to for tracked session
623      var dashCurrentRootId = null;
624      var dashTreeData = null;
625      var dashSelfSessionId = null;
626      var dashActiveNavigatorId = null;
627
628      var desktopDashboardBtn = document.getElementById("desktopDashboardBtn");
629      var iframeContainer = document.getElementById("iframeContainer");
630      var dashboardView = document.getElementById("dashboardView");
631      var dashForestView = document.getElementById("dashForestView");
632      var dashForestGrid = document.getElementById("dashForestGrid");
633      var dashTreeContent = document.getElementById("dashTreeContent");
634      var dashTreeCanvas = document.getElementById("dashTreeCanvas");
635      var dashTreeTitle = document.getElementById("dashTreeTitle");
636      var dashBackBtn = document.getElementById("dashBackBtn");
637      var rawIdeaSpace = document.getElementById("rawIdeaSpace");
638      var rawIdeaList = document.getElementById("rawIdeaList");
639      var dashSessionList = document.getElementById("dashSessionList");
640      var dashSessionCount = document.getElementById("dashSessionCount");
641      var dashChatPanel = document.getElementById("dashChatPanel");
642      var dashChatBody = document.getElementById("dashChatBody");
643      var dashChatTitle = document.getElementById("dashChatTitle");
644      var vtreeTooltip = document.getElementById("vtreeTooltip");
645      var vtreeTooltipName = document.getElementById("vtreeTooltipName");
646      var vtreeTooltipStatus = document.getElementById("vtreeTooltipStatus");
647      var dashTreeView = document.getElementById("dashTreeView");
648      var mobileSessionsPill = document.getElementById("mobileSessionsPill");
649      var mobileSessionsPillCount = document.getElementById("mobileSessionsPillCount");
650      var mobileSessionsOverlay = document.getElementById("mobileSessionsOverlay");
651      var mobileSessionsScrim = document.getElementById("mobileSessionsScrim");
652      var mobileSessionsClose = document.getElementById("mobileSessionsClose");
653      var mobileSessionsList = document.getElementById("mobileSessionsList");
654      var dashSessionCountMobile = document.getElementById("dashSessionCountMobile");
655      var mobileDashboardBtn = document.getElementById("mobileDashboardBtn");
656
657      // ── Disconnected state ──────────────────────────────────────
658      socket.on("disconnect", function() {
659        if (dashboardView) dashboardView.classList.add("disconnected");
660      });
661      socket.on("connect", function() {
662        if (dashboardView) dashboardView.classList.remove("disconnected");
663      });
664
665      var DASH_SESSION_ICONS = {
666        "websocket-chat": "\\u{1F4AC}",
667        "api-tree-chat": "\\u{1F333}",
668        "api-tree-place": "\\u{1F4CC}",
669        "raw-idea-orchestrate": "\\u{1F4A1}",
670        "raw-idea-chat": "\\u{1F4A1}",
671        "understanding-orchestrate": "\\u{1F9E0}",
672        "scheduled-raw-idea": "\\u{23F0}"
673      };
674
675      function dashEscape(str) {
676        return String(str || "").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
677      }
678      function dashTimeAgo(ts) {
679        if (!ts) return "";
680        var diff = Date.now() - ts;
681        if (diff < 60000) return "just now";
682        if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
683        return Math.floor(diff / 3600000) + "h ago";
684      }
685      function dashTruncate(str, len) {
686        if (!str) return "";
687        return str.length > len ? str.slice(0, len) + "..." : str;
688      }
689
690      // ── Toggle ────────────────────────────────────────────────────
691      function toggleDashboard() {
692        dashboardActive = !dashboardActive;
693        if (desktopDashboardBtn) desktopDashboardBtn.classList.toggle("active", dashboardActive);
694        if (mobileDashboardBtn) mobileDashboardBtn.classList.toggle("active", dashboardActive);
695        iframeContainer.classList.toggle("hidden", dashboardActive);
696        dashboardView.classList.toggle("active", dashboardActive);
697        if (dashboardActive) {
698          socket.emit("getDashboardSessions");
699          socket.emit("getDashboardRoots");
700        }
701      }
702
703      if (desktopDashboardBtn) desktopDashboardBtn.addEventListener("click", toggleDashboard);
704      if (mobileDashboardBtn) mobileDashboardBtn.addEventListener("click", function(e) {
705        e.stopPropagation();
706        toggleDashboard();
707      });
708
709      // Close buttons inside the dashboard view
710      var dashCloseBtn1 = document.getElementById("dashCloseBtn1");
711      var dashCloseBtn2 = document.getElementById("dashCloseBtn2");
712      if (dashCloseBtn1) dashCloseBtn1.addEventListener("click", function() { if (dashboardActive) toggleDashboard(); });
713      if (dashCloseBtn2) dashCloseBtn2.addEventListener("click", function() { if (dashboardActive) toggleDashboard(); });
714
715      // Expose closeDashboard so app.js goHome() can dismiss it
716      if (window.TreeApp) {
717        window.TreeApp.closeDashboard = function() {
718          if (dashboardActive) toggleDashboard();
719        };
720      }
721
722      // ── Mode switching ──────────────────────────────────────────
723      function enterTreeMode(rootId) {
724        dashMode = "tree";
725        dashCurrentRootId = rootId;
726        dashTreeData = null;
727        dashForestView.style.display = "none";
728        dashTreeContent.style.display = "";
729        dashTreeCanvas.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-muted)">Loading tree...</div>';
730        dashTreeTitle.textContent = "Loading...";
731        socket.emit("getDashboardTree", { rootId: rootId });
732        renderDashSessions();
733      }
734
735      function exitTreeMode() {
736        dashMode = "forest";
737        dashCurrentRootId = null;
738        dashTreeData = null;
739        dashForestView.style.display = "";
740        dashTreeContent.style.display = "none";
741        renderForest();
742        renderDashSessions();
743      }
744
745      dashBackBtn.addEventListener("click", exitTreeMode);
746
747      // ── Roots (forest) ──────────────────────────────────────────
748      socket.on("dashboardRoots", function(data) {
749        if (!data) return;
750        dashRoots = data.roots || [];
751        if (dashMode === "forest") renderForest();
752      });
753
754      function renderForest() {
755        if (dashRoots.length === 0) {
756          dashForestGrid.innerHTML = '<div class="dash-forest-empty">'
757            + '<div class="dash-forest-empty-icon">\\u{1F331}</div>'
758            + '<p>No trees yet</p></div>';
759          return;
760        }
761
762        var html = "";
763        for (var i = 0; i < dashRoots.length; i++) {
764          var r = dashRoots[i];
765          // Count sessions on this root
766          var count = 0;
767          for (var j = 0; j < dashSessions.length; j++) {
768            if (dashSessions[j].meta && dashSessions[j].meta.rootId === r.id) count++;
769          }
770          var sizeLabel = r.childCount === 0 ? "seedling" : r.childCount <= 3 ? "sapling" : r.childCount <= 10 ? "growing" : "mature";
771          var treeIcon = r.childCount === 0 ? "\\u{1F331}" : r.childCount <= 3 ? "\\u{1F33F}" : r.childCount <= 10 ? "\\u{1F333}" : "\\u{1F332}";
772
773          html += '<div class="dash-root-card' + (count > 0 ? " has-sessions" : "") + '" data-root-id="' + r.id + '">'
774            + (count > 0 ? '<span class="dash-root-badge">' + count + '</span>' : '')
775            + '<div class="dash-root-icon">' + treeIcon + '</div>'
776            + '<div class="dash-root-name">' + dashEscape(r.name) + '</div>'
777            + '<div class="dash-root-info">' + sizeLabel + '</div>'
778            + '</div>';
779        }
780        dashForestGrid.innerHTML = html;
781      }
782
783      // Click root card → enter tree mode
784      dashForestGrid.addEventListener("click", function(e) {
785        var card = e.target.closest("[data-root-id]");
786        if (!card) return;
787        enterTreeMode(card.getAttribute("data-root-id"));
788      });
789
790      // ── Raw ideas ───────────────────────────────────────────────
791      function renderRawIdeas() {
792        var rawSessions = [];
793        for (var i = 0; i < dashSessions.length; i++) {
794          var s = dashSessions[i];
795          var isRaw = s.type === "raw-idea-orchestrate" || s.type === "raw-idea-chat" || s.type === "scheduled-raw-idea";
796          var noTree = !s.meta || !s.meta.rootId;
797          if (isRaw && noTree) rawSessions.push(s);
798        }
799
800        if (rawSessions.length === 0) {
801          rawIdeaSpace.style.display = "none";
802          return;
803        }
804        rawIdeaSpace.style.display = "";
805
806        var html = "";
807        for (var i = 0; i < rawSessions.length; i++) {
808          var s = rawSessions[i];
809          var desc = dashEscape(s.description || "Raw idea");
810          html += '<div class="raw-idea-card" data-raw-sid="' + s.sessionId + '">'
811            + '<span class="raw-idea-pulse"></span>'
812            + '<span class="raw-idea-desc">' + desc + '</span>'
813            + '</div>';
814        }
815        rawIdeaList.innerHTML = html;
816      }
817
818      // Click raw idea → track it (auto-follow when it gets placed)
819      rawIdeaList.addEventListener("click", function(e) {
820        var card = e.target.closest("[data-raw-sid]");
821        if (!card) return;
822        var sid = card.getAttribute("data-raw-sid");
823        dashTrackedSessionId = sid;
824        renderDashSessions();
825      });
826
827      // ── Sessions ────────────────────────────────────────────────
828      socket.on("dashboardSessions", function(data) {
829        if (!data) return;
830        dashSessions = data.sessions || [];
831        if (data.selfSessionId) dashSelfSessionId = data.selfSessionId;
832        dashActiveNavigatorId = data.activeNavigatorId || null;
833
834        // Sync tracked session with server navigator state
835        if (dashTrackedSessionId !== dashActiveNavigatorId) {
836          dashTrackedSessionId = dashActiveNavigatorId;
837          if (!dashTrackedSessionId) dashTrackedNavRootId = null;
838        }
839
840        renderRawIdeas();
841        renderDashSessions();
842
843        // Auto-follow tracked session — only navigate when rootId first appears or changes
844        if (dashTrackedSessionId) {
845          var tracked = null;
846          for (var i = 0; i < dashSessions.length; i++) {
847            if (dashSessions[i].sessionId === dashTrackedSessionId) { tracked = dashSessions[i]; break; }
848          }
849          if (!tracked) {
850            dashTrackedSessionId = null;
851            dashTrackedNavRootId = null;
852            renderDashSessions();
853          } else if (tracked.meta && tracked.meta.rootId && tracked.meta.rootId !== dashTrackedNavRootId) {
854            // Session got a NEW rootId — close dashboard; server handles navigation via emitNavigate
855            dashTrackedNavRootId = tracked.meta.rootId;
856            if (dashboardActive) toggleDashboard();
857          }
858        }
859
860        // Update forest badges if in forest mode
861        if (dashMode === "forest") renderForest();
862        // Update tree highlights if in tree mode
863        if (dashMode === "tree") updateDashHighlights();
864      });
865
866      function renderDashSessions() {
867        // Filter sessions based on mode
868        var filtered;
869        if (dashMode === "tree" && dashCurrentRootId) {
870          filtered = [];
871          for (var i = 0; i < dashSessions.length; i++) {
872            if (dashSessions[i].meta && dashSessions[i].meta.rootId === dashCurrentRootId) {
873              filtered.push(dashSessions[i]);
874            }
875          }
876        } else {
877          filtered = dashSessions;
878        }
879
880        dashSessionCount.textContent = filtered.length;
881
882        if (filtered.length === 0) {
883          var emptyMsg = dashMode === "tree"
884            ? "No sessions on this tree"
885            : "No active sessions";
886          dashSessionList.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text-muted);font-size:12px">' + emptyMsg + '</div>';
887          syncMobileSessions("", 0);
888          return;
889        }
890
891        var html = "";
892        for (var i = 0; i < filtered.length; i++) {
893          var s = filtered[i];
894          var isTracked = s.sessionId === dashTrackedSessionId;
895          var icon = DASH_SESSION_ICONS[s.type] || "\\u{1F527}";
896          var desc = dashEscape(s.description || s.type);
897          var hasRoot = s.meta && s.meta.rootId;
898          var ago = dashTimeAgo(s.lastActivity);
899
900          // Resolve location label: tree name or "Home"
901          var locationLabel;
902          if (hasRoot) {
903            var rootName = null;
904            for (var k = 0; k < dashRoots.length; k++) {
905              if (dashRoots[k].id === s.meta.rootId) { rootName = dashRoots[k].name; break; }
906            }
907            locationLabel = "Tree: " + dashEscape(rootName || s.meta.rootId.slice(0, 8));
908          } else {
909            locationLabel = "Home";
910          }
911
912          // Follow button logic: follow sets navigator, detach clears it
913          var trackBtn = "";
914          if (isTracked) {
915            trackBtn = '<button class="session-btn active" data-action="track" data-sid="' + s.sessionId + '">\\u{1F4CD} Detach</button>';
916          } else {
917            trackBtn = '<button class="session-btn" data-action="track" data-sid="' + s.sessionId + '">\\u{1F3AF} Follow</button>';
918          }
919
920          html += '<div class="session-card ' + (isTracked ? "tracked" : "") + '" data-sid="' + s.sessionId + '"'
921            + (hasRoot ? ' data-root="' + s.meta.rootId + '"' : '') + '>'
922            + '<div class="session-card-header">'
923            + '<span class="session-type-icon">' + icon + '</span>'
924            + '<span class="session-desc">' + desc + '</span>'
925            + '<button class="session-stop-btn" data-action="stop" data-sid="' + s.sessionId + '" title="Kill session">Kill</button>'
926            + '</div>'
927            + '<div class="session-meta-info">' + locationLabel + ' \\u00B7 ' + ago + '</div>'
928            + '<div class="session-actions">'
929            + trackBtn
930            + '<button class="session-btn" data-action="chat" data-sid="' + s.sessionId + '">\\u{1F4AC} Messages</button>'
931            + '</div>'
932            + '</div>';
933        }
934        dashSessionList.innerHTML = html;
935        syncMobileSessions(html, filtered.length);
936      }
937
938      function syncMobileSessions(html, count) {
939        var n = count !== undefined ? count : dashSessions.length;
940        if (mobileSessionsList) mobileSessionsList.innerHTML = html || dashSessionList.innerHTML;
941        if (mobileSessionsPillCount) mobileSessionsPillCount.textContent = n;
942        if (dashSessionCountMobile) dashSessionCountMobile.textContent = n;
943        if (mobileSessionsPill) mobileSessionsPill.classList.toggle("has-activity", n > 1);
944      }
945
946      // Event delegation for session cards + buttons
947      function handleSessionClick(e) {
948        // Button actions first
949        var btn = e.target.closest("[data-action]");
950        if (btn) {
951          e.stopPropagation();
952          var action = btn.getAttribute("data-action");
953          var sid = btn.getAttribute("data-sid");
954          if (action === "track") toggleTrack(sid);
955          else if (action === "chat") dashViewChat(sid);
956          else if (action === "stop") stopSession(sid);
957          return;
958        }
959        // Click on card body → navigate to session's tree
960        var card = e.target.closest("[data-sid]");
961        if (!card) return;
962        var rootId = card.getAttribute("data-root");
963        if (rootId) {
964          enterTreeMode(rootId);
965        }
966      }
967
968      dashSessionList.addEventListener("click", handleSessionClick);
969
970      function toggleTrack(sessionId) {
971        if (dashTrackedSessionId === sessionId) {
972          dashTrackedSessionId = null;
973          dashTrackedNavRootId = null;
974          socket.emit("detachNavigator");
975        } else {
976          dashTrackedSessionId = sessionId;
977          dashTrackedNavRootId = null;
978          socket.emit("attachNavigator", { sessionId: sessionId });
979          var s = null;
980          for (var i = 0; i < dashSessions.length; i++) {
981            if (dashSessions[i].sessionId === sessionId) { s = dashSessions[i]; break; }
982          }
983          if (s && s.meta && s.meta.rootId) {
984            // Close dashboard; server handles navigation via emitNavigate
985            dashTrackedNavRootId = s.meta.rootId;
986            if (dashboardActive) toggleDashboard();
987          }
988          // If no rootId (Home), stay on dashboard
989        }
990        renderDashSessions();
991      }
992
993      function stopSession(sessionId) {
994        if (!confirm("Stop this session?")) return;
995        // If stopping the tracked session, detach first
996        if (dashTrackedSessionId === sessionId) {
997          dashTrackedSessionId = null;
998          dashTrackedNavRootId = null;
999          socket.emit("detachNavigator");
1000        }
1001        socket.emit("stopSession", { sessionId: sessionId });
1002      }
1003
1004      // ── Tree loading ──────────────────────────────────────────────
1005      socket.on("dashboardTreeData", function(data) {
1006        if (!data || data.rootId !== dashCurrentRootId) return;
1007        if (data.error) {
1008          dashTreeCanvas.innerHTML = '<div style="color:var(--error);padding:16px">' + dashEscape(data.error) + '</div>';
1009          return;
1010        }
1011        dashTreeData = data.tree;
1012        renderDashTree();
1013        updateDashHighlights();
1014      });
1015
1016      // ── Visual tree helpers ───────────────────────────────────────
1017      function vtreeCount(node) {
1018        var c = 1;
1019        if (node.children) for (var i = 0; i < node.children.length; i++) c += vtreeCount(node.children[i]);
1020        return c;
1021      }
1022      function vtreeMaxDepth(node, d) {
1023        d = d || 0;
1024        if (!node.children || !node.children.length) return d;
1025        var mx = d;
1026        for (var i = 0; i < node.children.length; i++) {
1027          var cd = vtreeMaxDepth(node.children[i], d + 1);
1028          if (cd > mx) mx = cd;
1029        }
1030        return mx;
1031      }
1032      function vtreeWidth(node) {
1033        if (!node.children || !node.children.length) return 1;
1034        var w = 0;
1035        for (var i = 0; i < node.children.length; i++) w += vtreeWidth(node.children[i]);
1036        return w;
1037      }
1038
1039      function buildVisualTree(treeData) {
1040        var total = vtreeCount(treeData);
1041        var maxD = vtreeMaxDepth(treeData);
1042
1043        var nodeR, fontSize, branchBase;
1044        if (total <= 5) { nodeR = 22; fontSize = 11; branchBase = 6; }
1045        else if (total <= 15) { nodeR = 15; fontSize = 10; branchBase = 4.5; }
1046        else if (total <= 40) { nodeR = 11; fontSize = 9; branchBase = 3; }
1047        else { nodeR = 7; fontSize = 0; branchBase = 2; }
1048
1049        var hSpace = total <= 5 ? 100 : total <= 15 ? 65 : total <= 40 ? 45 : 30;
1050        var vSpace = total <= 5 ? 110 : total <= 15 ? 80 : total <= 40 ? 58 : 44;
1051
1052        var nodes = [];
1053        function place(node, depth, xL, xR, pid) {
1054          var x = (xL + xR) / 2;
1055          var isLeaf = !node.children || !node.children.length;
1056          nodes.push({ id: node.id, name: node.name, status: node.status || "active", prestige: 0 || 0, x: x, depth: depth, pid: pid, isLeaf: isLeaf });
1057          if (!isLeaf) {
1058            var tw = vtreeWidth(node);
1059            var cur = xL;
1060            for (var i = 0; i < node.children.length; i++) {
1061              var cw = vtreeWidth(node.children[i]);
1062              var cR = cur + (xR - xL) * (cw / tw);
1063              place(node.children[i], depth + 1, cur, cR, node.id);
1064              cur = cR;
1065            }
1066          }
1067        }
1068        var treeW = vtreeWidth(treeData);
1069        place(treeData, 0, 0, treeW, null);
1070
1071        var pad = nodeR * 3 + 15;
1072        var trunkH = total <= 5 ? 40 : 25;
1073        var svgW = treeW * hSpace + pad * 2;
1074        var svgH = (maxD + 1) * vSpace + pad * 2 + trunkH;
1075
1076        for (var i = 0; i < nodes.length; i++) {
1077          nodes[i].sx = nodes[i].x * hSpace + pad;
1078          nodes[i].sy = pad + (maxD - nodes[i].depth) * vSpace;
1079        }
1080
1081        var rootN = nodes[0];
1082        var groundY = rootN.sy + trunkH + 8;
1083
1084        var s = '<svg class="vtree-svg" viewBox="0 0 ' + svgW + ' ' + svgH + '" preserveAspectRatio="xMidYMid meet">';
1085        s += '<ellipse cx="' + (svgW / 2) + '" cy="' + groundY + '" rx="' + Math.min(svgW * 0.35, 120) + '" ry="6" fill="rgba(139,90,43,0.12)"/>';
1086        s += '<line x1="' + rootN.sx + '" y1="' + rootN.sy + '" x2="' + rootN.sx + '" y2="' + (rootN.sy + trunkH) + '"'
1087          + ' stroke="rgba(139,90,43,0.55)" stroke-width="' + (branchBase * 1.8) + '" stroke-linecap="round"/>';
1088
1089        for (var i = 0; i < nodes.length; i++) {
1090          var n = nodes[i];
1091          if (n.pid === null) continue;
1092          var par = null;
1093          for (var j = 0; j < nodes.length; j++) { if (nodes[j].id === n.pid) { par = nodes[j]; break; } }
1094          if (!par) continue;
1095          var bw = Math.max(1, branchBase - n.depth * 0.6);
1096          var cpY = (par.sy + n.sy) / 2;
1097          s += '<path class="vtree-branch" d="M' + par.sx + ',' + par.sy + ' C' + par.sx + ',' + cpY + ' ' + n.sx + ',' + cpY + ' ' + n.sx + ',' + n.sy + '"'
1098            + ' fill="none" stroke="rgba(139,90,43,0.3)" stroke-width="' + bw + '" stroke-linecap="round"/>';
1099        }
1100
1101        for (var i = 0; i < nodes.length; i++) {
1102          var n = nodes[i];
1103          var fill;
1104          if (n.status === "trimmed") fill = "rgba(120,120,120,0.45)";
1105          else if (n.status === "completed") fill = "rgba(234,179,8,0.65)";
1106          else if (n.isLeaf) fill = "rgba(34,197,94,0.75)";
1107          else fill = "rgba(16,185,129,0.55)";
1108          var r = (n.pid === null) ? nodeR * 1.2 : nodeR;
1109
1110          s += '<g class="vtree-node" data-node-id="' + n.id + '" data-name="' + dashEscape(n.name) + '" data-status="' + n.status + '" data-prestige="' + n.prestige + '">';
1111          s += '<circle class="vtree-highlight-ring" cx="' + n.sx + '" cy="' + n.sy + '" r="' + (r + 5) + '" fill="none" stroke="transparent" stroke-width="2"/>';
1112          s += '<circle class="vtree-main" cx="' + n.sx + '" cy="' + n.sy + '" r="' + r + '" fill="' + fill + '" stroke="rgba(255,255,255,0.15)" stroke-width="1"/>';
1113          if (fontSize > 0) {
1114            var lbl = n.name.length > 14 ? n.name.slice(0, 12) + ".." : n.name;
1115            s += '<text x="' + n.sx + '" y="' + (n.sy - r - 6) + '" text-anchor="middle" fill="var(--text-secondary)" font-size="' + fontSize + '" style="pointer-events:none">' + dashEscape(lbl) + '</text>';
1116          }
1117          s += '</g>';
1118        }
1119
1120        s += '</svg>';
1121        return s;
1122      }
1123
1124      function renderDashTree() {
1125        if (!dashTreeData) return;
1126        dashTreeTitle.textContent = dashEscape(dashTreeData.name);
1127        dashTreeCanvas.innerHTML = '<div class="vtree-container">' + buildVisualTree(dashTreeData) + '</div>';
1128      }
1129
1130      // ── Tooltip ─────────────────────────────────────────────────────
1131      dashTreeCanvas.addEventListener("mouseover", function(e) {
1132        var g = e.target.closest(".vtree-node");
1133        if (!g) return;
1134        vtreeTooltipName.textContent = g.getAttribute("data-name");
1135        vtreeTooltipStatus.textContent = g.getAttribute("data-status");
1136        vtreeTooltip.classList.add("visible");
1137      });
1138      dashTreeCanvas.addEventListener("mousemove", function(e) {
1139        if (!vtreeTooltip.classList.contains("visible")) return;
1140        var rect = dashTreeView.getBoundingClientRect();
1141        vtreeTooltip.style.left = (e.clientX - rect.left + 14) + "px";
1142        vtreeTooltip.style.top = (e.clientY - rect.top - 32) + "px";
1143      });
1144      dashTreeCanvas.addEventListener("mouseout", function(e) {
1145        if (e.target.closest(".vtree-node")) vtreeTooltip.classList.remove("visible");
1146      });
1147
1148      // ── Click tree node → navigate iframe ──────────────────────────
1149      dashTreeCanvas.addEventListener("click", function(e) {
1150        var g = e.target.closest(".vtree-node");
1151        if (!g) return;
1152        var nodeId = g.getAttribute("data-node-id");
1153        var prestige = g.getAttribute("data-prestige") || "0";
1154        if (!nodeId) return;
1155        // Switch back to iframe and navigate to this node
1156        if (dashboardActive) toggleDashboard();
1157        if (window.TreeApp && window.TreeApp.navigate) {
1158          window.TreeApp.navigate("/api/v1/node/" + nodeId + "/" + prestige + "?html");
1159        }
1160      });
1161
1162      // ── Node highlighting (SVG) ────────────────────────────────────
1163      function updateDashHighlights() {
1164        var rings = document.querySelectorAll(".vtree-highlight-ring.active");
1165        for (var i = 0; i < rings.length; i++) rings[i].classList.remove("active");
1166        var dots = document.querySelectorAll(".vtree-badge-dot");
1167        for (var i = 0; i < dots.length; i++) dots[i].remove();
1168
1169        for (var j = 0; j < dashSessions.length; j++) {
1170          var s = dashSessions[j];
1171          if (!s.meta || !s.meta.nodeId) continue;
1172          var g = document.querySelector('.vtree-node[data-node-id="' + s.meta.nodeId + '"]');
1173          if (!g) continue;
1174          var ring = g.querySelector(".vtree-highlight-ring");
1175          if (ring) ring.classList.add("active");
1176          var main = g.querySelector(".vtree-main");
1177          if (main) {
1178            var cx = parseFloat(main.getAttribute("cx"));
1179            var cy = parseFloat(main.getAttribute("cy"));
1180            var r = parseFloat(main.getAttribute("r"));
1181            var badge = document.createElementNS("http://www.w3.org/2000/svg", "circle");
1182            badge.setAttribute("class", "vtree-badge-dot");
1183            badge.setAttribute("cx", String(cx + r + 4));
1184            badge.setAttribute("cy", String(cy - r + 2));
1185            badge.setAttribute("r", "4");
1186            badge.setAttribute("fill", "rgba(16,185,129,0.9)");
1187            g.appendChild(badge);
1188          }
1189        }
1190      }
1191
1192      // ── Tree change live updates ──────────────────────────────────
1193      socket.on("dashboardTreeChanged", function(data) {
1194        if (dashCurrentRootId) {
1195          socket.emit("getDashboardTree", { rootId: dashCurrentRootId });
1196        }
1197      });
1198
1199      // ── Messages panel ─────────────────────────────────────────────
1200      var dashChatCurrentSid = null;
1201
1202      document.getElementById("dashChatClose").addEventListener("click", function() {
1203        dashChatPanel.classList.remove("open");
1204        dashChatCurrentSid = null;
1205      });
1206
1207      document.getElementById("dashChatRefresh").addEventListener("click", function() {
1208        if (!dashChatCurrentSid) return;
1209        dashChatBody.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-muted)">Loading...</div>';
1210        socket.emit("getDashboardChats", { sessionId: dashChatCurrentSid });
1211      });
1212
1213      document.getElementById("dashChatKill").addEventListener("click", function() {
1214        if (!dashChatCurrentSid) return;
1215        stopSession(dashChatCurrentSid);
1216        dashChatPanel.classList.remove("open");
1217        dashChatCurrentSid = null;
1218      });
1219
1220      function dashViewChat(sessionId) {
1221        dashChatCurrentSid = sessionId;
1222        dashChatPanel.classList.add("open");
1223        dashChatBody.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-muted)">Loading...</div>';
1224        dashChatTitle.textContent = "Messages \\u2014 " + sessionId.slice(0, 8);
1225        socket.emit("getDashboardChats", { sessionId: sessionId });
1226      }
1227
1228      socket.on("dashboardChats", function(data) {
1229        if (!data) return;
1230        if (data.error) {
1231          dashChatBody.innerHTML = '<div style="color:var(--error)">Error loading chats</div>';
1232          return;
1233        }
1234        var chats = data.chats;
1235        if (!chats || chats.length === 0) {
1236          dashChatBody.innerHTML = '<div style="color:var(--text-muted);padding:16px;text-align:center">No chat records for this session</div>';
1237          return;
1238        }
1239        var html = "";
1240        for (var i = 0; i < chats.length; i++) {
1241          var c = chats[i];
1242          var source = (c.startMessage && c.startMessage.source) || "user";
1243          var path = (c.aiContext && c.aiContext.path) || "?";
1244          var userMsg = dashEscape(dashTruncate((c.startMessage && c.startMessage.content) || "", 300));
1245          var aiMsg = (c.endMessage && c.endMessage.content)
1246            ? '<div style="margin-top:6px;border-top:1px solid rgba(255,255,255,0.06);padding-top:6px">' + dashEscape(dashTruncate(c.endMessage.content, 500)) + '</div>'
1247            : '';
1248          html += '<div class="chat-message-item">'
1249            + '<div class="chat-message-role">' + dashEscape(source) + ' \\u2192 ' + dashEscape(path) + '</div>'
1250            + '<div>' + userMsg + '</div>'
1251            + aiMsg
1252            + '</div>';
1253        }
1254        dashChatBody.innerHTML = html;
1255      });
1256
1257      // ── Mobile sessions ──────────────────────────────────────────
1258      function closeMobileOverlay() {
1259        if (mobileSessionsOverlay) mobileSessionsOverlay.classList.remove("open");
1260        if (mobileSessionsScrim) mobileSessionsScrim.classList.remove("open");
1261      }
1262      function openMobileOverlay() {
1263        if (mobileSessionsOverlay) mobileSessionsOverlay.classList.add("open");
1264        if (mobileSessionsScrim) mobileSessionsScrim.classList.add("open");
1265      }
1266
1267      if (mobileSessionsPill) {
1268        mobileSessionsPill.addEventListener("click", function() {
1269          var isOpen = mobileSessionsOverlay && mobileSessionsOverlay.classList.contains("open");
1270          if (isOpen) closeMobileOverlay();
1271          else openMobileOverlay();
1272        });
1273      }
1274      if (mobileSessionsClose) {
1275        mobileSessionsClose.addEventListener("click", closeMobileOverlay);
1276      }
1277      if (mobileSessionsScrim) {
1278        mobileSessionsScrim.addEventListener("click", closeMobileOverlay);
1279      }
1280      if (mobileSessionsList) {
1281        mobileSessionsList.addEventListener("click", function(e) {
1282          var btn = e.target.closest("[data-action]");
1283          if (btn) {
1284            var action = btn.getAttribute("data-action");
1285            var sid = btn.getAttribute("data-sid");
1286            if (action === "track") toggleTrack(sid);
1287            else if (action === "chat") dashViewChat(sid);
1288            else if (action === "stop") { stopSession(sid); }
1289            closeMobileOverlay();
1290            return;
1291          }
1292          var card = e.target.closest("[data-sid]");
1293          if (card) {
1294            var rootId = card.getAttribute("data-root");
1295            if (rootId) enterTreeMode(rootId);
1296            closeMobileOverlay();
1297          }
1298        });
1299      }
1300
1301    })();
1302  `;
1303}
1304
1// routesURL/setup.js
2// First-time onboarding: connect LLM + create first tree.
3// Skips steps already completed, redirects to /chat when done.
4
5import log from "../../../core/log.js";
6import express from "express";
7import authenticateLite from "../../../middleware/authenticateLite.js";
8import User from "../../../db/models/user.js";
9import CustomLlmConnection from "../../../db/models/customLlmConnection.js";
10import { renderSetup } from "./setupPage.js";
11
12const router = express.Router();
13
14router.get("/setup", authenticateLite, async (req, res) => {
15  try {
16    if (process.env.ENABLE_FRONTEND_HTML !== "true") {
17      return res.status(404).json({ error: "Server-rendered HTML is disabled. Use the SPA frontend." });
18    }
19
20    if (!req.userId) {
21      return res.redirect("/login?redirect=/setup");
22    }
23
24    const user = await User.findById(req.userId)
25      .select("username roots metadata llmDefault")
26      .lean();
27    if (!user) {
28      return res.redirect("/login?redirect=/setup");
29    }
30
31    const connCount = await CustomLlmConnection.countDocuments({ userId: req.userId });
32    const hasMainLlm = !!(user.llmDefault);
33    const needsLlm = !hasMainLlm && connCount === 0;
34    const needsTree = !user.roots || user.roots.length === 0;
35
36    // Both done, go to chat
37    if (!needsLlm && !needsTree) {
38      return res.redirect("/chat");
39    }
40
41    const userId = req.userId;
42    const username = user.username;
43
44    return res.send(renderSetup({ userId, username, needsLlm, needsTree }));
45  } catch (err) {
46    log.error("HTML", "Setup page error:", err.message);
47    return res.status(500).send("Something went wrong");
48  }
49});
50
51export default router;
52
1/* ─────────────────────────────────────────────── */
2/* HTML renderer for setup / onboarding page       */
3/* ─────────────────────────────────────────────── */
4
5export function renderSetup({ userId, username, needsLlm, needsTree }) {
6  return `<!DOCTYPE html>
7<html lang="en">
8<head>
9  <meta charset="UTF-8" />
10  <title>Setup - TreeOS</title>
11  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
12  <meta name="theme-color" content="#667eea" />
13  <link rel="preconnect" href="https://fonts.googleapis.com">
14  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
15  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
16  <style>
17    :root {
18      --glass-rgb: 115, 111, 230;
19      --glass-alpha: 0.28;
20      --glass-blur: 22px;
21      --glass-border: rgba(255, 255, 255, 0.28);
22      --glass-border-light: rgba(255, 255, 255, 0.15);
23      --glass-highlight: rgba(255, 255, 255, 0.25);
24      --text-primary: #ffffff;
25      --text-secondary: rgba(255, 255, 255, 0.9);
26      --text-muted: rgba(255, 255, 255, 0.6);
27      --accent: #10b981;
28      --accent-glow: rgba(16, 185, 129, 0.6);
29      --error: #ef4444;
30      --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
31    }
32
33    * { box-sizing: border-box; margin: 0; padding: 0; }
34    html { background: #667eea; }
35    body { min-height: 100vh; min-height: 100dvh; width: 100%; font-family: 'DM Sans', -apple-system, sans-serif; color: var(--text-primary); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-attachment: fixed; position: relative; overflow-x: hidden; overflow-y: auto; }
36    body::before, body::after {
37      content: ''; position: fixed; border-radius: 50%; background: white;
38      opacity: 0.08; animation: float 20s infinite ease-in-out; pointer-events: none;
39    }
40    body::before { width: 600px; height: 600px; top: -300px; right: -200px; animation-delay: -5s; }
41    body::after { width: 400px; height: 400px; bottom: -200px; left: -100px; animation-delay: -10s; }
42    @keyframes float { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-30px) rotate(5deg); } }
43
44    .container {
45      max-width: 620px; margin: 0 auto; padding: 40px 20px 60px;
46      display: flex; flex-direction: column; gap: 24px;
47      min-height: 100vh;
48    }
49
50    .header {
51      text-align: center; padding: 20px 0 10px;
52    }
53    .header .tree-icon { font-size: 48px; display: block; margin-bottom: 12px; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3)); }
54    .header h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 6px; }
55    .header p { font-size: 14px; color: var(--text-muted); line-height: 1.5; }
56
57    .glass-card {
58      background: rgba(var(--glass-rgb), var(--glass-alpha));
59      backdrop-filter: blur(var(--glass-blur));
60      -webkit-backdrop-filter: blur(var(--glass-blur));
61      border: 1px solid var(--glass-border);
62      border-radius: 16px;
63      padding: 24px;
64      animation: fadeUp 0.5s ease both;
65    }
66    @keyframes fadeUp {
67      from { opacity: 0; transform: translateY(16px); }
68      to { opacity: 1; transform: translateY(0); }
69    }
70    .glass-card h2 { font-size: 17px; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
71    .glass-card .sub { font-size: 13px; color: var(--text-muted); line-height: 1.5; margin-bottom: 16px; }
72
73    .step-badge {
74      display: inline-flex; align-items: center; justify-content: center;
75      width: 24px; height: 24px; border-radius: 50%;
76      background: rgba(255,255,255,0.15); font-size: 12px; font-weight: 600;
77      flex-shrink: 0;
78    }
79
80    .field-row { margin-bottom: 16px; text-align: left; }
81    .field-label {
82      display: block; font-size: 14px; font-weight: 600; color: white;
83      margin-bottom: 8px; text-shadow: 0 1px 3px rgba(0,0,0,0.2); letter-spacing: -0.2px;
84    }
85    .field-input {
86      width: 100%; padding: 14px 18px;
87      background: rgba(255,255,255,0.15); border: 2px solid rgba(255,255,255,0.3);
88      border-radius: 12px; color: white; font-family: inherit; font-size: 16px; font-weight: 500;
89      outline: none; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
90      backdrop-filter: blur(20px) saturate(150%); -webkit-backdrop-filter: blur(20px) saturate(150%);
91      box-shadow: 0 4px 20px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.25);
92    }
93    .field-input:focus {
94      border-color: rgba(255,255,255,0.6); background: rgba(255,255,255,0.25);
95      backdrop-filter: blur(25px) saturate(160%); -webkit-backdrop-filter: blur(25px) saturate(160%);
96      box-shadow: 0 0 0 4px rgba(255,255,255,0.15), 0 8px 30px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.4);
97      transform: translateY(-2px);
98    }
99    .field-input::placeholder { color: rgba(255,255,255,0.5); font-weight: 400; }
100
101    .btn-primary {
102      width: 100%; padding: 16px; margin-top: 8px;
103      border-radius: 980px; border: 1px solid rgba(255,255,255,0.3);
104      background: rgba(255,255,255,0.25); backdrop-filter: blur(10px);
105      color: white; font-family: inherit; font-size: 16px; font-weight: 600;
106      cursor: pointer; transition: all 0.3s; letter-spacing: -0.2px;
107      box-shadow: 0 4px 12px rgba(0,0,0,0.12);
108      position: relative; overflow: hidden;
109    }
110    .btn-primary::before {
111      content: ''; position: absolute; inset: -40%;
112      background: radial-gradient(120% 60% at 0% 0%, rgba(255,255,255,0.35), transparent 60%);
113      opacity: 0; transform: translateX(-30%) translateY(-10%);
114      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
115      pointer-events: none;
116    }
117    .btn-primary:hover::before { opacity: 1; transform: translateX(30%) translateY(10%); }
118    .btn-primary:hover { background: rgba(255,255,255,0.35); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.18); }
119    .btn-primary:active { transform: translateY(0); }
120    .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
121    .btn-primary:disabled::before { display: none; }
122
123    .btn-skip {
124      display: block; width: 100%; text-align: center; padding: 12px;
125      color: white; font-size: 15px; font-weight: 600; font-family: inherit;
126      background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3);
127      border-radius: 980px; cursor: pointer; transition: all 0.3s;
128      text-decoration: none; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
129    }
130    .btn-skip:hover { background: rgba(255,255,255,0.25); color: white; }
131
132    .status-msg {
133      padding: 12px 16px; border-radius: 10px; font-size: 14px; font-weight: 600;
134      margin-top: 8px; display: none; text-align: left;
135    }
136    .status-msg.error { display: block; background: rgba(239,68,68,0.3); backdrop-filter: blur(10px); border: 1px solid rgba(239,68,68,0.4); color: white; }
137    .status-msg.success { display: block; background: rgba(16,185,129,0.3); backdrop-filter: blur(10px); border: 1px solid rgba(16,185,129,0.4); color: white; }
138
139    .video-wrap {
140      position: relative; width: 100%; padding-top: 56.25%;
141      border-radius: 12px; overflow: hidden; margin-bottom: 16px;
142      background: rgba(0,0,0,0.2);
143    }
144    .video-wrap iframe {
145      position: absolute; inset: 0; width: 100%; height: 100%; border: none;
146    }
147    .video-placeholder {
148      position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
149      color: var(--text-muted); font-size: 14px;
150    }
151
152    .done-card { text-align: center; padding: 40px 24px; }
153    .done-card .seed-anim {
154      font-size: 48px; display: inline-block; margin-bottom: 12px;
155    }
156    .done-card .seed-anim.shake {
157      animation: seedShake 0.4s ease-in-out 3;
158    }
159    .done-card .seed-anim.burst {
160      animation: seedBurst 0.4s ease-out forwards;
161    }
162    .done-card .seed-anim.tree {
163      animation: treeAppear 0.5s ease-out forwards;
164    }
165    @keyframes seedShake {
166      0%, 100% { transform: rotate(0deg) scale(1); }
167      25% { transform: rotate(-12deg) scale(1.05); }
168      75% { transform: rotate(12deg) scale(1.05); }
169    }
170    @keyframes seedBurst {
171      0% { transform: scale(1); opacity: 1; filter: brightness(1); }
172      50% { transform: scale(1.6); opacity: 0.8; filter: brightness(2.5); }
173      100% { transform: scale(0); opacity: 0; filter: brightness(3); }
174    }
175    @keyframes treeAppear {
176      0% { transform: scale(0); opacity: 0; }
177      60% { transform: scale(1.2); opacity: 1; }
178      100% { transform: scale(1); opacity: 1; }
179    }
180
181    .screen-flash {
182      position: fixed; inset: -50px; background: rgba(255,255,255,0.9); opacity: 0; z-index: 9999;
183      pointer-events: none; animation: screenFlash 0.6s ease-out forwards;
184    }
185    @keyframes screenFlash {
186      0% { opacity: 0; }
187      15% { opacity: 0.85; }
188      100% { opacity: 0; }
189    }
190
191    .skip-note { font-size: 12px; color: var(--text-muted); text-align: center; margin-top: 4px; line-height: 1.4; }
192
193    .hidden { display: none !important; }
194  </style>
195</head>
196<body>
197  <div class="container">
198
199    <div class="header">
200      <span class="tree-icon">🌳</span>
201      <h1>Welcome${username ? ", " + username : ""}!</h1>
202      <p>Let's get you set up to start growing your Tree.</p>
203    </div>
204
205    <!-- Step 1: Connect LLM -->
206    <div class="glass-card" id="stepLlm" ${needsLlm ? "" : 'style="display:none"'}>
207      <h2>Connect Your LLM</h2>
208      <div class="sub">
209        TreeOS uses AI to help you build and organize your knowledge. You'll need to connect your own LLM provider
210        using any OpenAI-compatible API endpoint. We recommend <strong>OpenRouter</strong> for the easiest setup.
211        It gives you access to hundreds of models with one API key.
212      </div>
213
214      <div class="video-wrap">
215        <iframe src="https://www.youtube-nocookie.com/embed/_cXGZXdiVgw" allowfullscreen></iframe>
216      </div>
217
218      <div class="field-row">
219        <label class="field-label">Label</label>
220        <input type="text" class="field-input" id="llmName" placeholder="e.g. OpenRouter, Groq" />
221      </div>
222      <div class="field-row">
223        <label class="field-label">Endpoint URL</label>
224        <input type="text" class="field-input" id="llmBaseUrl" placeholder="https://openrouter.ai/api/v1/chat/completions" />
225      </div>
226      <div class="field-row">
227        <label class="field-label">API Key <span style="opacity:0.5;font-weight:400;">- encrypted in our database</span></label>
228        <input type="password" class="field-input" id="llmApiKey" placeholder="sk-or-..." />
229      </div>
230      <div class="field-row">
231        <label class="field-label">Model</label>
232        <input type="text" class="field-input" id="llmModel" placeholder="e.g. openai/gpt-4o-mini" />
233      </div>
234      <div id="llmStatus" class="status-msg"></div>
235      <button class="btn-primary" id="llmSubmit" onclick="submitLlm()">Connect</button>
236    </div>
237
238    <!-- Step 2: Create First Tree -->
239    <div class="glass-card" id="stepTree" style="display:${!needsTree || needsLlm ? "none" : ""}">
240      <h2>Plant Your First Tree</h2>
241      <div class="sub">
242        Think broad. <strong>My Life</strong>, <strong>Career</strong>,
243        <strong>Health</strong>, <strong>Side Project</strong>.
244        You can always create more later.
245      </div>
246      <div class="field-row">
247        <label class="field-label">Tree Name</label>
248        <input type="text" class="field-input" id="treeName" placeholder="e.g. My Life, Career, Health & Fitness" />
249      </div>
250      <div id="treeStatus" class="status-msg"></div>
251      <button class="btn-primary" id="treeSubmit" onclick="submitTree()">Plant Tree</button>
252    </div>
253
254    <!-- Done state -->
255    <div class="glass-card done-card hidden" id="stepDone">
256      <div class="seed-anim" id="seedEmoji">&#127793;</div>
257      <h2 style="justify-content:center;" id="doneTitle" class="hidden">Your tree is being planted...</h2>
258      <div class="sub" style="text-align:center;" id="doneSub" class="hidden"></div>
259    </div>
260
261    <!-- Skip -->
262    <a class="btn-skip" href="#" id="skipBtn" onclick="skipSetup(); return false;">Skip for now</a>
263    <div class="skip-note" id="skipNote">You can still browse trees others have invited you to if they have their own LLM connected, but you won't be able to talk to your own trees or process raw ideas.</div>
264
265  </div>
266
267  <script>
268    var CONFIG = {
269      userId: "${userId}",
270      needsTree: ${needsTree},
271    };
272
273    function showStatus(id, msg, type) {
274      var el = document.getElementById(id);
275      el.textContent = msg;
276      el.className = "status-msg " + type;
277    }
278    function clearStatus(id) {
279      var el = document.getElementById(id);
280      el.className = "status-msg";
281      el.textContent = "";
282    }
283
284    async function submitLlm() {
285      var name = document.getElementById("llmName").value.trim();
286      var baseUrl = document.getElementById("llmBaseUrl").value.trim();
287      var apiKey = document.getElementById("llmApiKey").value.trim();
288      var model = document.getElementById("llmModel").value.trim();
289
290      if (!name || !baseUrl || !apiKey || !model) {
291        showStatus("llmStatus", "All fields are required.", "error");
292        return;
293      }
294
295      var btn = document.getElementById("llmSubmit");
296      btn.disabled = true;
297      btn.textContent = "Connecting...";
298      clearStatus("llmStatus");
299
300      try {
301        // Create connection
302        var createRes = await fetch("/api/v1/user/" + CONFIG.userId + "/custom-llm", {
303          method: "POST",
304          headers: { "Content-Type": "application/json" },
305          credentials: "include",
306          body: JSON.stringify({ name: name, baseUrl: baseUrl, apiKey: apiKey, model: model }),
307        });
308        var createData = await createRes.json();
309
310        if (!createRes.ok || !createData.success) {
311          throw new Error(createData.error || "Failed to create connection");
312        }
313
314        // Set as default
315        var connId = createData.connection._id;
316        var assignRes = await fetch("/api/v1/user/" + CONFIG.userId + "/llm-assign", {
317          method: "POST",
318          headers: { "Content-Type": "application/json" },
319          credentials: "include",
320          body: JSON.stringify({ slot: "main", connectionId: connId }),
321        });
322        var assignData = await assignRes.json();
323        if (!assignRes.ok || !assignData.success) {
324          throw new Error(assignData.error || "Failed to set as default");
325        }
326
327        showStatus("llmStatus", "Connected!", "success");
328
329        setTimeout(function() {
330          document.getElementById("stepLlm").style.display = "none";
331          if (CONFIG.needsTree) {
332            document.getElementById("stepTree").style.display = "";
333          } else {
334            finish();
335          }
336        }, 600);
337
338      } catch (err) {
339        showStatus("llmStatus", err.message, "error");
340        btn.disabled = false;
341        btn.textContent = "Connect";
342      }
343    }
344
345    async function submitTree() {
346      var name = document.getElementById("treeName").value.trim();
347      if (!name) {
348        showStatus("treeStatus", "Give your tree a name.", "error");
349        return;
350      }
351
352      var btn = document.getElementById("treeSubmit");
353      btn.disabled = true;
354      btn.textContent = "Planting...";
355      clearStatus("treeStatus");
356
357      try {
358        var res = await fetch("/api/v1/user/" + CONFIG.userId + "/createRoot", {
359          method: "POST",
360          headers: { "Content-Type": "application/json" },
361          credentials: "include",
362          body: JSON.stringify({ name: name }),
363        });
364        var data = await res.json();
365
366        if (!res.ok || !data.success) {
367          throw new Error(data.error || "Failed to create tree");
368        }
369
370        finish();
371      } catch (err) {
372        showStatus("treeStatus", err.message, "error");
373        btn.disabled = false;
374        btn.textContent = "Plant Tree";
375      }
376    }
377
378    function skipSetup() {
379      document.cookie = "setupSkipped=1;path=/;max-age=" + (12 * 60 * 60) + ";secure;samesite=none";
380      window.location.href = "/chat";
381    }
382
383    function finish() {
384      document.getElementById("stepLlm").style.display = "none";
385      document.getElementById("stepTree").style.display = "none";
386      document.getElementById("stepDone").classList.remove("hidden");
387      document.getElementById("skipBtn").style.display = "none";
388      document.getElementById("skipNote").style.display = "none";
389
390      var seed = document.getElementById("seedEmoji");
391      var title = document.getElementById("doneTitle");
392      var sub = document.getElementById("doneSub");
393      // Show "being planted" right away
394      title.classList.remove("hidden");
395      // Phase 1: seed shakes (1.2s = 0.4s x 3 iterations)
396      seed.classList.add("shake");
397      setTimeout(function() {
398        // Phase 2: burst flash
399        seed.classList.remove("shake");
400        seed.classList.add("burst");
401        setTimeout(function() {
402          // Phase 3: flash + swap to tree
403          var flash = document.createElement("div");
404          flash.className = "screen-flash";
405          document.body.appendChild(flash);
406          flash.addEventListener("animationend", function() { flash.remove(); });
407          seed.innerHTML = "&#127795;";
408          seed.classList.remove("burst");
409          seed.classList.add("tree");
410          // Swap to ready text
411          title.textContent = "Your tree is ready.";
412          sub.textContent = "Taking you there now...";
413          sub.classList.remove("hidden");
414          setTimeout(function() {
415            window.location.href = "/chat";
416          }, 1500);
417        }, 400);
418      }, 1200);
419    }
420  </script>
421</body>
422</html>`;
423}
424
1// ─────────────────────────────────────────────────
2// Shared CSS building blocks for HTML renderers
3//
4// Import the pieces you need:
5//   import { baseStyles, backNavStyles } from ...
6//   <style>${baseStyles}${backNavStyles}
7//   ... page-specific CSS here ...
8//   </style>
9// ─────────────────────────────────────────────────
10
11// ─── Core: variables, reset, gradient, orbs, keyframes, container ───
12
13export const baseStyles = `
14:root {
15  --glass-water-rgb: 115, 111, 230;
16  --glass-alpha: 0.28;
17  --glass-alpha-hover: 0.38;
18}
19
20* {
21  box-sizing: border-box;
22  margin: 0;
23  padding: 0;
24  -webkit-tap-highlight-color: transparent;
25}
26
27html, body {
28  background: #736fe6;
29  margin: 0;
30  padding: 0;
31}
32
33body {
34  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
35  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
36  min-height: 100vh;
37  min-height: 100dvh;
38  padding: 20px;
39  color: #1a1a1a;
40  position: relative;
41  overflow-x: hidden;
42  touch-action: manipulation;
43}
44
45body::before,
46body::after {
47  content: '';
48  position: fixed;
49  border-radius: 50%;
50  opacity: 0.08;
51  animation: float 20s infinite ease-in-out;
52  pointer-events: none;
53}
54
55body::before {
56  width: 600px; height: 600px;
57  background: white;
58  top: -300px; right: -200px;
59  animation-delay: -5s;
60}
61
62body::after {
63  width: 400px; height: 400px;
64  background: white;
65  bottom: -200px; left: -100px;
66  animation-delay: -10s;
67}
68
69@keyframes float {
70  0%, 100% { transform: translateY(0) rotate(0deg); }
71  50% { transform: translateY(-30px) rotate(5deg); }
72}
73
74@keyframes fadeInUp {
75  from { opacity: 0; transform: translateY(30px); }
76  to { opacity: 1; transform: translateY(0); }
77}
78
79.container {
80  max-width: 900px;
81  margin: 0 auto;
82  position: relative;
83  z-index: 1;
84}
85`;
86
87// ─── Pill-shaped back navigation buttons ───
88
89export const backNavStyles = `
90.back-nav {
91  display: flex;
92  gap: 12px;
93  margin-bottom: 20px;
94  flex-wrap: wrap;
95  animation: fadeInUp 0.5s ease-out both;
96}
97
98.back-link {
99  display: inline-flex;
100  align-items: center;
101  gap: 6px;
102  padding: 10px 20px;
103  background: rgba(115, 111, 230, var(--glass-alpha));
104  backdrop-filter: blur(22px) saturate(140%);
105  -webkit-backdrop-filter: blur(22px) saturate(140%);
106  color: white;
107  text-decoration: none;
108  border-radius: 980px;
109  font-weight: 600;
110  font-size: 14px;
111  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
112  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
113    inset 0 1px 0 rgba(255, 255, 255, 0.25);
114  border: 1px solid rgba(255, 255, 255, 0.28);
115  position: relative;
116  overflow: hidden;
117}
118
119.back-link::before {
120  content: "";
121  position: absolute;
122  inset: -40%;
123  background: radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%);
124  opacity: 0;
125  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
126  pointer-events: none;
127}
128
129.back-link:hover {
130  background: rgba(115, 111, 230, var(--glass-alpha-hover));
131  transform: translateY(-1px);
132}
133
134.back-link:hover::before {
135  opacity: 1;
136  transform: translateX(30%) translateY(10%);
137}
138`;
139
140// ─── Glass header panel (title bar with h1) ───
141
142export const glassHeaderStyles = `
143.header {
144  position: relative;
145  overflow: hidden;
146  background: rgba(115, 111, 230, var(--glass-alpha));
147  backdrop-filter: blur(22px) saturate(140%);
148  -webkit-backdrop-filter: blur(22px) saturate(140%);
149  border-radius: 16px;
150  padding: 32px;
151  margin-bottom: 24px;
152  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
153    inset 0 1px 0 rgba(255, 255, 255, 0.25);
154  border: 1px solid rgba(255, 255, 255, 0.28);
155  color: white;
156  animation: fadeInUp 0.6s ease-out 0.1s both;
157}
158
159.header::before {
160  content: "";
161  position: absolute;
162  inset: -40%;
163  background: radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%);
164  opacity: 0;
165  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
166  pointer-events: none;
167}
168
169.header:hover::before {
170  opacity: 1;
171  transform: translateX(30%) translateY(10%);
172}
173
174.header h1 {
175  font-size: 28px;
176  font-weight: 600;
177  color: white;
178  margin-bottom: 8px;
179  line-height: 1.3;
180  letter-spacing: -0.5px;
181  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
182}
183
184.header h1 a {
185  color: white;
186  text-decoration: none;
187  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
188  transition: all 0.2s;
189}
190
191.header h1 a:hover {
192  border-bottom-color: white;
193  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
194}
195
196.message-count {
197  display: inline-block;
198  padding: 6px 14px;
199  background: rgba(255, 255, 255, 0.25);
200  color: white;
201  border-radius: 980px;
202  font-size: 14px;
203  font-weight: 600;
204  margin-left: 12px;
205  border: 1px solid rgba(255, 255, 255, 0.3);
206}
207
208.header-subtitle {
209  font-size: 14px;
210  color: rgba(255, 255, 255, 0.9);
211  margin-bottom: 8px;
212  font-weight: 400;
213  line-height: 1.5;
214}
215`;
216
217// ─── Glass note/item cards with color variants ───
218
219export const glassCardStyles = `
220.notes-list {
221  list-style: none;
222  display: flex;
223  flex-direction: column;
224  gap: 16px;
225}
226
227.note-card {
228  --card-rgb: 115, 111, 230;
229  position: relative;
230  background: rgba(var(--card-rgb), var(--glass-alpha));
231  backdrop-filter: blur(22px) saturate(140%);
232  -webkit-backdrop-filter: blur(22px) saturate(140%);
233  border-radius: 16px;
234  padding: 24px;
235  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
236    inset 0 1px 0 rgba(255, 255, 255, 0.25);
237  border: 1px solid rgba(255, 255, 255, 0.28);
238  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
239  color: white;
240  overflow: hidden;
241}
242
243.note-card::before {
244  content: "";
245  position: absolute;
246  inset: -40%;
247  background: radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%);
248  opacity: 0;
249  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
250  pointer-events: none;
251}
252
253.note-card:hover {
254  background: rgba(var(--card-rgb), var(--glass-alpha-hover));
255  transform: translateY(-2px);
256  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
257}
258
259.note-card:hover::before {
260  opacity: 1;
261  transform: translateX(30%) translateY(10%);
262}
263
264/* ── Color variants ── */
265.glass-default  { --card-rgb: 115, 111, 230; }
266.glass-green    { --card-rgb: 72, 187, 120;  }
267.glass-red      { --card-rgb: 200, 80, 80;   }
268.glass-blue     { --card-rgb: 80, 130, 220;  }
269.glass-cyan     { --card-rgb: 56, 189, 210;  }
270.glass-gold     { --card-rgb: 200, 170, 50;  }
271.glass-purple   { --card-rgb: 155, 100, 220; }
272.glass-pink     { --card-rgb: 210, 100, 160; }
273.glass-orange   { --card-rgb: 220, 140, 60;  }
274.glass-emerald  { --card-rgb: 52, 190, 130;  }
275.glass-teal     { --card-rgb: 60, 170, 180;  }
276.glass-indigo   { --card-rgb: 100, 100, 210; }
277
278.note-content {
279  margin-bottom: 12px;
280}
281
282.note-meta {
283  padding-top: 12px;
284  border-top: 1px solid rgba(255, 255, 255, 0.2);
285  font-size: 12px;
286  color: rgba(255, 255, 255, 0.85);
287  line-height: 1.8;
288  display: flex;
289  flex-wrap: wrap;
290  align-items: center;
291  gap: 6px;
292}
293
294.note-meta a {
295  color: white;
296  text-decoration: none;
297  font-weight: 500;
298  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
299  transition: all 0.2s;
300}
301
302.note-meta a:hover {
303  border-bottom-color: white;
304  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
305}
306
307.meta-separator {
308  color: rgba(255, 255, 255, 0.5);
309}
310`;
311
312// ─── Empty state glass panel ───
313
314export const emptyStateStyles = `
315.empty-state {
316  position: relative;
317  overflow: hidden;
318  background: rgba(115, 111, 230, var(--glass-alpha));
319  backdrop-filter: blur(22px) saturate(140%);
320  -webkit-backdrop-filter: blur(22px) saturate(140%);
321  border-radius: 16px;
322  padding: 60px 40px;
323  text-align: center;
324  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
325    inset 0 1px 0 rgba(255, 255, 255, 0.25);
326  border: 1px solid rgba(255, 255, 255, 0.28);
327  color: white;
328  animation: fadeInUp 0.6s ease-out 0.2s both;
329}
330
331.empty-state::before {
332  content: "";
333  position: absolute;
334  inset: -40%;
335  background: radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%);
336  opacity: 0;
337  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
338  pointer-events: none;
339}
340
341.empty-state:hover::before {
342  opacity: 1;
343  transform: translateX(30%) translateY(10%);
344}
345
346.empty-state-icon {
347  font-size: 64px;
348  margin-bottom: 16px;
349  filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
350}
351
352.empty-state-text {
353  font-size: 20px;
354  color: white;
355  margin-bottom: 8px;
356  font-weight: 600;
357  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
358}
359
360.empty-state-subtext {
361  font-size: 14px;
362  color: rgba(255, 255, 255, 0.85);
363}
364`;
365
366// ─── Base responsive breakpoints ───
367
368export const responsiveBase = `
369@media (max-width: 640px) {
370  body { padding: 16px; }
371  .header { padding: 24px 20px; }
372  .header h1 { font-size: 24px; }
373  .message-count { display: block; margin-left: 0; margin-top: 8px; width: fit-content; }
374  .note-card { padding: 20px 16px; }
375  .back-nav { flex-direction: column; }
376  .back-link { width: 100%; justify-content: center; }
377  .empty-state { padding: 40px 24px; }
378}
379
380@media (min-width: 641px) and (max-width: 1024px) {
381  .container { max-width: 700px; }
382}
383`;
384
1/* ─────────────────────────────────────────────── */
2/* HTML renderer for chat page                     */
3/* ─────────────────────────────────────────────── */
4
5import { getLandUrl } from "../../../canopy/identity.js";
6import { baseStyles } from "./baseStyles.js";
7import { escapeHtml } from "./utils.js";
8
9export function renderChat({ username, userId, treesJSON, trees }) {
10  return `<!DOCTYPE html>
11<html lang="en">
12<head>
13  <meta charset="UTF-8" />
14  <title>Chat - TreeOS</title>
15  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
16  <meta name="theme-color" content="#736fe6" />
17  <link rel="icon" href="/tree.png" />
18  <link rel="canonical" href="${getLandUrl()}/chat" />
19  <meta name="robots" content="noindex, nofollow" />
20  <meta name="description" content="Chat with your knowledge trees. AI-powered conversations that grow your understanding." />
21  <meta property="og:title" content="Chat - TreeOS" />
22  <meta property="og:description" content="Chat with your knowledge trees. AI-powered conversations that grow your understanding." />
23  <meta property="og:url" content="${getLandUrl()}/chat" />
24  <meta property="og:type" content="website" />
25  <meta property="og:site_name" content="TreeOS" />
26  <meta property="og:image" content="${getLandUrl()}/tree.png" />
27  <link rel="preconnect" href="https://fonts.googleapis.com">
28  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
29  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
30  <style>
31    ${baseStyles}
32
33    /* ── Chat-specific overrides on base ── */
34    :root {
35      --glass-rgb: 115, 111, 230;
36      --glass-blur: 22px;
37      --glass-border: rgba(255, 255, 255, 0.28);
38      --glass-border-light: rgba(255, 255, 255, 0.15);
39      --glass-highlight: rgba(255, 255, 255, 0.25);
40      --text-primary: #ffffff;
41      --text-secondary: rgba(255, 255, 255, 0.9);
42      --text-muted: rgba(255, 255, 255, 0.6);
43      --accent: #10b981;
44      --accent-glow: rgba(16, 185, 129, 0.6);
45      --error: #ef4444;
46      --header-height: 56px;
47      --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
48    }
49
50    html, body { height: 100%; width: 100%; overflow: hidden; font-family: 'DM Sans', -apple-system, sans-serif; color: var(--text-primary); }
51    body { background-attachment: fixed; padding: 0; min-height: auto; }
52
53    /* Hide base orbs for full-screen chat */
54    body::before, body::after { display: none; }
55
56    /* Layout */
57    .container {
58      height: 100%; width: 100%;
59      display: flex; flex-direction: column;
60      max-width: 800px; margin: 0 auto;
61    }
62
63    /* Header */
64    .chat-header {
65      height: var(--header-height); padding: 0 20px;
66      display: flex; align-items: center; justify-content: space-between;
67      border-bottom: 1px solid var(--glass-border-light); flex-shrink: 0;
68    }
69    .chat-title { display: flex; align-items: center; gap: 12px; }
70    .tree-icon { font-size: 28px; filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.3)); animation: grow 4.5s infinite ease-in-out; }
71    @keyframes grow { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
72    .chat-title h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
73
74    .header-right { display: flex; align-items: center; gap: 10px; }
75    .back-row {
76      display: none; padding: 8px 20px 0;
77      border-bottom: none; flex-shrink: 0;
78    }
79    .back-row.visible { display: flex; }
80    .back-btn {
81      display: flex; align-items: center; gap: 6px;
82      font-size: 12px; color: var(--text-muted);
83      background: rgba(255,255,255,0.1); border-radius: 8px;
84      padding: 6px 12px; border: 1px solid var(--glass-border-light);
85      cursor: pointer; transition: all var(--transition-fast);
86      font-family: inherit;
87    }
88    .back-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
89    .back-btn svg { width: 12px; height: 12px; }
90
91    .status-badge { display: flex; align-items: center; gap: 8px; padding: 6px 14px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border-radius: 100px; border: 1px solid var(--glass-border-light); font-size: 12px; font-weight: 600; }
92    .status-badge .status-text { display: inline; }
93    .status-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 12px var(--accent-glow); animation: pulse 2s ease-in-out infinite; flex-shrink: 0; }
94    .status-dot.connected { background: var(--accent); }
95    .status-dot.disconnected { background: var(--error); animation: none; }
96    .status-dot.connecting { background: #f59e0b; }
97    @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(1.15); } }
98
99    .advanced-btn {
100      font-size: 12px; color: var(--text-muted);
101      background: rgba(255,255,255,0.1); border-radius: 8px;
102      padding: 6px 14px; border: 1px solid var(--glass-border-light);
103      cursor: pointer; text-decoration: none; transition: all var(--transition-fast);
104      font-family: inherit;
105    }
106    .advanced-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
107
108    /* Root name inline */
109    .root-name-inline {
110      font-size: 13px; font-weight: 400; color: var(--text-muted);
111      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
112      max-width: 200px; opacity: 0; transition: opacity 0.3s ease;
113    }
114    .root-name-inline.visible { opacity: 1; }
115    .root-name-inline::before { content: ' / '; color: var(--glass-border-light); }
116
117    /* Tree picker */
118    .tree-picker {
119      flex: 1; display: flex; flex-direction: column;
120      align-items: center;
121      padding: 32px 20px 40px; gap: 24px;
122      overflow-y: auto; min-height: 0;
123    }
124    .tree-picker-title { font-size: 24px; font-weight: 600; margin-bottom: 4px; flex-shrink: 0; }
125    .tree-picker-sub { color: var(--text-muted); font-size: 15px; text-align: center; flex-shrink: 0; }
126    .tree-list { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 420px; }
127    .tree-item {
128      background: rgba(var(--glass-rgb), var(--glass-alpha));
129      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
130      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
131      border: 1px solid var(--glass-border-light);
132      border-radius: 16px; padding: 18px 22px;
133      cursor: pointer; transition: all var(--transition-fast);
134      display: flex; align-items: center; justify-content: space-between;
135      animation: fadeInUp 0.3s ease-out backwards;
136    }
137    .tree-item:hover { background: rgba(var(--glass-rgb), 0.42); transform: translateY(-2px); box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
138    .tree-item:active { transform: translateY(0) scale(0.98); }
139    .tree-item-left { display: flex; align-items: center; gap: 14px; }
140    .tree-item-icon { font-size: 22px; }
141    .tree-item-name { font-size: 15px; font-weight: 500; }
142    .tree-item-meta { font-size: 12px; color: var(--text-muted); }
143    @keyframes fadeInUp { from { opacity: 0; transform: translateY(16px); } }
144    ${trees.map((_, i) => `.tree-item:nth-child(${i + 1}) { animation-delay: ${i * 0.06}s; }`).join("\n    ")}
145
146    .empty-state {
147      background: rgba(var(--glass-rgb), var(--glass-alpha));
148      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
149      border: 1px solid var(--glass-border-light);
150      border-radius: 20px; padding: 48px 32px;
151      text-align: center; max-width: 400px;
152    }
153    .empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; display: block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); }
154    .empty-state h2 { font-size: 20px; margin-bottom: 8px; }
155    .empty-state p { color: var(--text-muted); font-size: 14px; margin-bottom: 20px; line-height: 1.5; }
156    /* Create tree form */
157    .create-tree-form {
158      display: flex; gap: 8px; width: 100%; max-width: 420px; margin-top: 8px;
159      flex-shrink: 0; padding-bottom: 8px;
160    }
161    .create-tree-form input {
162      flex: 1; padding: 14px 18px; font-size: 15px;
163      background: rgba(var(--glass-rgb), 0.25);
164      border: 1px solid var(--glass-border-light);
165      border-radius: 14px; color: var(--text-primary);
166      transition: all 0.2s; outline: none;
167    }
168    .create-tree-form input::placeholder { color: var(--text-muted); }
169    .create-tree-form input:focus {
170      border-color: var(--accent); background: rgba(var(--glass-rgb), 0.35);
171      box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
172    }
173    .create-tree-form button {
174      padding: 14px 18px; font-size: 20px; line-height: 1;
175      background: rgba(var(--glass-rgb), 0.3);
176      border: 1px solid var(--glass-border-light);
177      border-radius: 14px; color: var(--text-primary);
178      cursor: pointer; transition: all 0.2s;
179    }
180    .create-tree-form button:hover {
181      background: var(--accent); border-color: var(--accent);
182      box-shadow: 0 4px 15px var(--accent-glow);
183    }
184    .create-tree-form button:disabled { opacity: 0.4; cursor: not-allowed; }
185
186    /* Chat area */
187    .chat-area { flex: 1; display: none; flex-direction: column; overflow: hidden; }
188    .chat-area.active { display: flex; }
189
190    /* Messages — matches app.js */
191    .chat-messages { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px 20px; display: flex; flex-direction: column; gap: 16px; }
192    .chat-messages::-webkit-scrollbar { width: 4px; }
193    .chat-messages::-webkit-scrollbar-track { background: transparent; margin: 8px 0; }
194    .chat-messages::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 4px; }
195    .chat-messages::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.25); }
196    .chat-messages { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.12) transparent; }
197
198    .message { display: flex; gap: 12px; animation: messageIn 0.3s ease-out; min-width: 0; max-width: 100%; }
199    @keyframes messageIn { from { opacity: 0; transform: translateY(10px); } }
200    .message.user { flex-direction: row-reverse; }
201    .message-avatar { width: 36px; height: 36px; border-radius: 12px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
202    .message.user .message-avatar { background: linear-gradient(135deg, rgba(99, 102, 241, 0.6) 0%, rgba(139, 92, 246, 0.6) 100%); }
203    .message-content { max-width: 85%; min-width: 0; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; font-size: 14px; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
204    .message.user .message-content { background: linear-gradient(135deg, rgba(99, 102, 241, 0.5) 0%, rgba(139, 92, 246, 0.5) 100%); border-radius: 18px 18px 6px 18px; }
205    .message.assistant .message-content { border-radius: 18px 18px 18px 6px; }
206    .message.error .message-content { background: rgba(239, 68, 68, 0.3); border-color: rgba(239, 68, 68, 0.5); }
207
208    /* Message content formatting — matches app.js */
209    .message-content p { margin: 0 0 10px 0; word-break: break-word; }
210    .message-content p:last-child { margin-bottom: 0; }
211    .message-content h1, .message-content h2, .message-content h3, .message-content h4 { margin: 14px 0 8px 0; font-weight: 600; line-height: 1.3; }
212    .message-content h1:first-child, .message-content h2:first-child, .message-content h3:first-child, .message-content h4:first-child { margin-top: 0; }
213    .message-content h1 { font-size: 17px; }
214    .message-content h2 { font-size: 16px; }
215    .message-content h3 { font-size: 15px; }
216    .message-content h4 { font-size: 14px; color: var(--text-secondary); }
217    .message-content ul, .message-content ol { margin: 8px 0; padding-left: 0; list-style: none; }
218    .message-content li { margin: 4px 0; padding: 6px 10px; background: rgba(255, 255, 255, 0.06); border-radius: 8px; line-height: 1.4; word-break: break-word; }
219    .message-content li .list-num { color: var(--accent); font-weight: 600; margin-right: 6px; }
220    .message-content strong, .message-content b { font-weight: 600; color: #fff; }
221    .message-content em, .message-content i { font-style: italic; color: var(--text-secondary); }
222    .message-content code { background: rgba(0, 0, 0, 0.3); padding: 2px 6px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: 11px; word-break: break-all; }
223    .message-content pre { background: rgba(0, 0, 0, 0.3); padding: 12px; border-radius: 8px; overflow-x: auto; margin: 10px 0; max-width: 100%; }
224    .message-content pre code { background: none; padding: 0; word-break: normal; white-space: pre-wrap; }
225    .message-content blockquote { border-left: 3px solid var(--accent); padding-left: 12px; margin: 10px 0; color: var(--text-secondary); font-style: italic; }
226    .message-content hr { border: none; border-top: 1px solid var(--glass-border-light); margin: 14px 0; }
227    .message-content a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
228    .message-content a:hover { text-decoration: none; }
229
230    /* Menu items */
231    .message-content .menu-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin: 6px 0; background: rgba(255, 255, 255, 0.08); border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.06); transition: all 0.15s ease; }
232    .message-content .menu-item.clickable { cursor: pointer; user-select: none; }
233    .message-content .menu-item.clickable:hover { background: rgba(255, 255, 255, 0.15); border-color: rgba(16, 185, 129, 0.3); transform: translateX(4px); }
234    .message-content .menu-item.clickable:active { transform: translateX(4px) scale(0.98); background: rgba(16, 185, 129, 0.2); }
235    .message-content .menu-item:first-of-type { margin-top: 8px; }
236    .message-content .menu-number { display: flex; align-items: center; justify-content: center; min-width: 26px; max-width: 26px; height: 26px; background: linear-gradient(135deg, var(--accent) 0%, #059669 100%); border-radius: 8px; font-size: 12px; font-weight: 600; flex-shrink: 0; box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); transition: all 0.15s ease; }
237    .message-content .menu-item.clickable:hover .menu-number { transform: scale(1.1); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.5); }
238    .message-content .menu-text { flex: 1; min-width: 0; padding-top: 2px; word-break: break-word; overflow-wrap: break-word; }
239    .message-content .menu-text strong { display: block; margin-bottom: 2px; word-break: break-word; }
240
241    /* Typing indicator — matches app.js */
242    .typing-indicator { display: flex; gap: 4px; padding: 14px 18px; }
243    .typing-dot { width: 8px; height: 8px; background: rgba(255, 255, 255, 0.6); border-radius: 50%; animation: typing 1.4s infinite; }
244    .typing-dot:nth-child(2) { animation-delay: 0.2s; }
245    .typing-dot:nth-child(3) { animation-delay: 0.4s; }
246    @keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-8px); } }
247
248    /* Input — matches app.js */
249    .chat-input-area { padding: 16px 20px 20px; border-top: 1px solid var(--glass-border-light); }
250    .input-container { display: flex; align-items: flex-end; gap: 12px; padding: 14px 18px; background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(10px); border: 1px solid var(--glass-border-light); border-radius: 18px; transition: all var(--transition-fast); }
251    .input-container:focus-within { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.4); box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1); }
252    .chat-input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; font-family: inherit; font-size: 15px; color: var(--text-primary); resize: none; max-height: 120px; line-height: 1.5; overflow-y: auto; }
253    .chat-input::-webkit-scrollbar { width: 4px; }
254    .chat-input::-webkit-scrollbar-track { background: transparent; }
255    .chat-input::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
256    .chat-input::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); }
257    .chat-area.empty .chat-input { max-height: 40vh; }
258    .chat-input::placeholder { color: var(--text-muted); }
259    .chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
260    .send-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--accent); border: none; border-radius: 12px; color: white; cursor: pointer; transition: all var(--transition-fast); flex-shrink: 0; box-shadow: 0 4px 15px var(--accent-glow); }
261    .send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 25px var(--accent-glow); }
262    .send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
263    .send-btn svg { width: 20px; height: 20px; }
264    .send-btn.stop-mode { background: rgba(239, 68, 68, 0.7); box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4); }
265    .send-btn.stop-mode:hover:not(:disabled) { background: rgba(239, 68, 68, 0.9); box-shadow: 0 6px 25px rgba(239, 68, 68, 0.5); }
266
267    /* Mode toggle */
268    .mode-toggle { display: flex; gap: 4px; padding: 0 2px 10px; }
269    .mode-btn { padding: 4px 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: var(--text-muted); font-size: 12px; font-weight: 500; cursor: pointer; transition: all var(--transition-fast); font-family: inherit; }
270    .mode-btn:hover { background: rgba(255,255,255,0.12); color: var(--text-secondary); }
271    .mode-btn.active { background: rgba(255,255,255,0.18); color: var(--text-primary); border-color: rgba(255,255,255,0.25); }
272    .mode-btn.active[data-mode="chat"] { background: var(--accent); border-color: var(--accent); color: #fff; }
273    .mode-btn.active[data-mode="place"] { background: rgba(72,187,120,0.4); border-color: rgba(72,187,120,0.5); color: #fff; }
274    .mode-btn.active[data-mode="query"] { background: rgba(115,111,230,0.4); border-color: rgba(115,111,230,0.5); color: #fff; }
275    .mode-hint { font-size: 11px; color: var(--text-muted); padding: 0 4px 6px; opacity: 0.7; }
276
277    /* Place result message */
278    .place-result { font-size: 13px; color: var(--text-muted); padding: 8px 14px; background: rgba(72,187,120,0.08); border-radius: 12px; border: 1px solid rgba(72,187,120,0.15); margin: 4px 0; }
279
280    /* Empty state — input pinned to vertical center, welcome above it */
281    .chat-area.empty { position: relative; }
282    .chat-area.empty .chat-input-area { position: absolute; top: 40%; left: 50%; transform: translate(-50%, 0); border-top: none; max-width: 600px; width: calc(100% - 40px); }
283    .chat-area.empty .chat-messages { position: absolute; top: 40%; left: 0; right: 0; transform: translateY(-100%); display: flex; flex-direction: column; align-items: center; overflow: visible; flex: none; }
284
285    /* Welcome message */
286    .welcome-message { text-align: center; padding: 40px 20px; }
287    .welcome-icon { font-size: 64px; margin-bottom: 20px; display: inline-block; filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: floatIcon 3s ease-in-out infinite; }
288    @keyframes floatIcon { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
289    .chat-area.empty .welcome-message { padding: 8px 20px; }
290    .chat-area.empty .welcome-icon { font-size: 48px; margin-bottom: 12px; }
291    .chat-area.empty .welcome-message h2 { font-size: 18px; margin-bottom: 6px; }
292    .chat-area.empty .welcome-message p { font-size: 13px; }
293    .welcome-message h2 { font-size: 24px; font-weight: 600; margin-bottom: 12px; }
294    .welcome-message p { font-size: 15px; color: var(--text-secondary); line-height: 1.6; }
295    .welcome-message.disconnected { opacity: 0.7; }
296    .welcome-message.disconnected .welcome-icon { filter: grayscale(0.5) drop-shadow(0 8px 32px rgba(0, 0, 0, 0.3)); animation: none; }
297
298    /* Notifications panel */
299    .clear-chat-btn {
300      background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border-light);
301      border-radius: 8px; padding: 6px 8px; cursor: pointer;
302      color: var(--text-muted); transition: all var(--transition-fast);
303      display: none; align-items: center; justify-content: center;
304    }
305    .clear-chat-btn.visible { display: flex; }
306    .clear-chat-btn:hover { background: rgba(255,255,255,0.2); color: var(--text-primary); }
307    .clear-chat-btn:active { transform: scale(0.93); }
308    .clear-chat-btn svg { width: 14px; height: 14px; }
309    .notif-btn {
310      font-size: 12px; color: var(--text-muted);
311      background: rgba(255,255,255,0.1); border-radius: 8px;
312      padding: 6px 14px; border: 1px solid var(--glass-border-light);
313      cursor: pointer; transition: all var(--transition-fast);
314      font-family: inherit; position: relative;
315      display: flex; align-items: center; gap: 6px;
316    }
317    .notif-btn:hover { background: rgba(255,255,255,0.18); color: var(--text-primary); }
318    .notif-btn-icon { display: none; font-size: 14px; line-height: 1; }
319    .notif-btn .notif-dot {
320      position: absolute; top: -3px; right: -3px;
321      width: 8px; height: 8px; border-radius: 50%;
322      background: var(--accent); box-shadow: 0 0 8px var(--accent-glow);
323      display: none;
324    }
325    .notif-btn .notif-dot.has-notifs { display: block; }
326
327    .notif-overlay {
328      position: fixed; inset: 0; background: rgba(0,0,0,0.4);
329      z-index: 9998; display: none;
330    }
331    .notif-overlay.open { display: block; }
332
333    .notif-panel {
334      position: fixed; top: 0; right: -400px; bottom: 0;
335      width: 380px; max-width: 90vw;
336      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
337      z-index: 9999;
338      display: flex; flex-direction: column;
339      transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
340      box-shadow: -8px 0 32px rgba(0,0,0,0.3);
341    }
342    .notif-panel.open { right: 0; }
343
344    .notif-panel-header {
345      padding: 20px; display: flex; align-items: center;
346      justify-content: space-between; flex-shrink: 0;
347      border-bottom: 1px solid var(--glass-border-light);
348    }
349    .notif-panel-header h2 { font-size: 18px; font-weight: 600; color: white; }
350    .notif-close {
351      width: 32px; height: 32px; border-radius: 8px;
352      background: rgba(255,255,255,0.1); border: 1px solid var(--glass-border-light);
353      color: white; cursor: pointer; font-size: 16px; display: flex;
354      align-items: center; justify-content: center; transition: all var(--transition-fast);
355    }
356    .notif-close:hover { background: rgba(255,255,255,0.2); }
357
358    .notif-list {
359      flex: 1; overflow-y: auto; padding: 16px;
360      display: flex; flex-direction: column; gap: 12px;
361    }
362    .notif-list::-webkit-scrollbar { width: 6px; }
363    .notif-list::-webkit-scrollbar-track { background: transparent; }
364    .notif-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
365
366    .notif-item {
367      background: rgba(var(--glass-rgb), var(--glass-alpha));
368      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
369      -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
370      border: 1px solid var(--glass-border-light);
371      border-radius: 14px; padding: 16px;
372      animation: fadeInUp 0.3s ease-out backwards;
373      transition: all var(--transition-fast);
374    }
375    .notif-item:hover {
376      background: rgba(var(--glass-rgb), 0.42);
377      transform: translateY(-1px);
378    }
379    .notif-item.type-thought { border-left: 3px solid #9b64dc; }
380    .notif-item.type-summary { border-left: 3px solid #6464d2; }
381
382    .notif-item-header {
383      display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
384    }
385    .notif-item-icon { font-size: 18px; flex-shrink: 0; }
386    .notif-item-title {
387      font-size: 14px; font-weight: 600; color: white;
388      flex: 1; line-height: 1.3;
389    }
390    .notif-item-badge {
391      font-size: 9px; font-weight: 700; text-transform: uppercase;
392      letter-spacing: 0.5px; padding: 2px 7px; border-radius: 6px;
393      background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.6);
394      border: 1px solid rgba(255,255,255,0.1); flex-shrink: 0;
395    }
396    .notif-item-content {
397      font-size: 13px; color: rgba(255,255,255,0.85);
398      line-height: 1.55; white-space: pre-wrap; word-break: break-word;
399    }
400    .notif-item-time {
401      font-size: 11px; color: rgba(255,255,255,0.45);
402      margin-top: 8px;
403    }
404    .notif-empty {
405      text-align: center; color: rgba(255,255,255,0.5);
406      font-size: 14px; padding: 40px 20px;
407    }
408    .notif-empty-icon { font-size: 40px; margin-bottom: 12px; display: block; }
409    .notif-loading { text-align: center; color: rgba(255,255,255,0.5); padding: 40px 20px; font-size: 14px; }
410
411    /* Notification tabs */
412    .notif-tabs {
413      display: flex; gap: 0; flex-shrink: 0;
414      border-bottom: 1px solid var(--glass-border-light);
415    }
416    .notif-tab {
417      flex: 1; padding: 10px 0; text-align: center;
418      font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.5);
419      background: none; border: none; border-bottom: 2px solid transparent;
420      cursor: pointer; font-family: inherit; transition: all var(--transition-fast);
421      position: relative;
422    }
423    .notif-tab:hover { color: rgba(255,255,255,0.8); }
424    .notif-tab.active { color: white; border-bottom-color: white; }
425    .notif-tab .tab-dot {
426      display: none; width: 6px; height: 6px; border-radius: 50%;
427      background: var(--accent); position: absolute; top: 8px; right: calc(50% - 30px);
428    }
429    .notif-tab .tab-dot.visible { display: block; }
430
431    /* Invite items */
432    .invite-item {
433      background: rgba(var(--glass-rgb), var(--glass-alpha));
434      backdrop-filter: blur(var(--glass-blur)) saturate(140%);
435      border: 1px solid var(--glass-border-light);
436      border-radius: 14px; padding: 16px;
437      border-left: 3px solid #48bb78;
438      animation: fadeInUp 0.3s ease-out backwards;
439    }
440    .invite-item-text { font-size: 13px; color: rgba(255,255,255,0.9); line-height: 1.5; margin-bottom: 10px; }
441    .invite-item-text strong { color: white; }
442    .invite-item-actions { display: flex; gap: 8px; }
443    .invite-item-actions button {
444      flex: 1; padding: 8px; border-radius: 8px; border: none;
445      font-family: inherit; font-size: 12px; font-weight: 600;
446      cursor: pointer; transition: all var(--transition-fast);
447    }
448    .invite-accept { background: var(--accent); color: white; }
449    .invite-accept:hover { filter: brightness(1.1); }
450    .invite-decline { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid var(--glass-border-light) !important; }
451    .invite-decline:hover { background: rgba(255,255,255,0.18); color: white; }
452
453    /* Members section */
454    .members-section { margin-top: 4px; }
455    .members-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.4); margin-bottom: 8px; }
456    .member-item {
457      display: flex; align-items: center; justify-content: space-between;
458      padding: 10px 14px; border-radius: 10px;
459      background: rgba(var(--glass-rgb), var(--glass-alpha));
460      border: 1px solid var(--glass-border-light); margin-bottom: 8px;
461    }
462    .member-name { font-size: 13px; color: white; font-weight: 500; }
463    .member-role { font-size: 11px; color: rgba(255,255,255,0.45); margin-left: 6px; }
464    .member-actions { display: flex; gap: 6px; }
465    .member-actions button {
466      padding: 4px 10px; border-radius: 6px; border: 1px solid var(--glass-border-light);
467      background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.6);
468      font-family: inherit; font-size: 11px; cursor: pointer;
469      transition: all var(--transition-fast);
470    }
471    .member-actions button:hover { background: rgba(255,255,255,0.15); color: white; }
472    .member-actions .btn-danger:hover { background: rgba(239,68,68,0.3); color: #fca5a5; border-color: rgba(239,68,68,0.4); }
473
474    .invite-form {
475      display: flex; gap: 8px; margin-top: 12px;
476    }
477    .invite-form input {
478      flex: 1; padding: 8px 12px; border-radius: 8px;
479      background: rgba(255,255,255,0.08); border: 1px solid var(--glass-border-light);
480      color: white; font-family: inherit; font-size: 13px; outline: none;
481    }
482    .invite-form input::placeholder { color: rgba(255,255,255,0.35); }
483    .invite-form input:focus { border-color: var(--glass-border); }
484    .invite-form button {
485      padding: 8px 16px; border-radius: 8px; border: none;
486      background: var(--accent); color: white; font-family: inherit;
487      font-size: 12px; font-weight: 600; cursor: pointer;
488      transition: all var(--transition-fast);
489    }
490    .invite-form button:hover { filter: brightness(1.1); }
491    .invite-form button:disabled { opacity: 0.5; cursor: not-allowed; }
492
493    .invite-status {
494      font-size: 12px; margin-top: 6px; padding: 6px 10px;
495      border-radius: 6px; display: none;
496    }
497    .invite-status.error { display: block; background: rgba(239,68,68,0.15); color: #fca5a5; }
498    .invite-status.success { display: block; background: rgba(16,185,129,0.15); color: #6ee7b7; }
499
500    /* Dream time config */
501    .dream-config {
502      background: rgba(var(--glass-rgb), var(--glass-alpha));
503      border: 1px solid var(--glass-border-light);
504      border-radius: 14px; padding: 14px 16px; margin-bottom: 12px;
505    }
506    .dream-config-label { font-size: 12px; color: rgba(255,255,255,0.5); margin-bottom: 6px; }
507    .dream-config-row { display: flex; align-items: center; gap: 8px; }
508    .dream-config-row input[type="time"] {
509      padding: 6px 10px; border-radius: 8px;
510      background: rgba(255,255,255,0.08); border: 1px solid var(--glass-border-light);
511      color: white; font-family: inherit; font-size: 13px; outline: none;
512      color-scheme: dark;
513    }
514    .dream-config-row input[type="time"]:focus { border-color: var(--glass-border); }
515    .dream-config-row button {
516      padding: 6px 12px; border-radius: 8px; border: none;
517      font-family: inherit; font-size: 12px; font-weight: 600;
518      cursor: pointer; transition: all var(--transition-fast);
519    }
520    .dream-config-save { background: var(--accent); color: white; }
521    .dream-config-save:hover { filter: brightness(1.1); }
522    .dream-config-off {
523      background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
524      border: 1px solid var(--glass-border-light) !important;
525    }
526    .dream-config-off:hover { background: rgba(255,255,255,0.18); color: white; }
527    .dream-config-status { font-size: 11px; color: rgba(255,255,255,0.5); margin-top: 6px; }
528    .dream-config-hint { font-size: 12px; color: rgba(255,255,255,0.45); line-height: 1.4; }
529
530    .notif-panel-footer {
531      padding: 16px; border-top: 1px solid var(--glass-border-light); flex-shrink: 0;
532    }
533    .logout-btn {
534      width: 100%; padding: 10px; border-radius: 10px;
535      background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3);
536      color: #fca5a5; font-family: inherit; font-size: 13px;
537      cursor: pointer; transition: all var(--transition-fast);
538    }
539    .logout-btn:hover { background: rgba(239,68,68,0.3); color: #fecaca; }
540
541    @media (max-width: 600px) {
542      .container { max-width: 100%; }
543      .chat-header { padding: 0 12px; }
544      .header-right { gap: 6px; }
545      .chat-input-area { padding: 12px 16px 16px; }
546
547      /* Collapse status badge to dot only */
548      .status-badge .status-text { display: none; }
549      .status-badge { padding: 6px; min-width: 20px; justify-content: center; }
550
551      /* Collapse notifications button to icon only */
552      .notif-btn-label { display: none; }
553      .notif-btn-icon { display: inline; }
554      .notif-btn { padding: 6px 8px; }
555
556      /* Shrink other buttons */
557      .advanced-btn { padding: 6px 10px; font-size: 11px; }
558      .back-btn { padding: 4px 8px; font-size: 11px; }
559      .back-btn svg { width: 10px; height: 10px; }
560
561      /* Hide title text, keep icon */
562      .chat-title h1 { display: none; }
563
564      .notif-panel { width: 100%; max-width: 100%; right: -100%; }
565      .notif-panel.open { right: 0; }
566
567      /* Empty state — mobile: push to top */
568      .chat-area.empty { overflow: visible; }
569      .chat-area.empty .chat-messages { position: static; flex: 0; padding-top: 0; transform: none; overflow: visible; }
570      .chat-area.empty .chat-input-area { position: static; transform: none; width: 100%; max-width: 100%; margin: 0; }
571    }
572  </style>
573</head>
574<body>
575  <div class="container">
576    <div class="chat-header">
577      <div class="chat-title">
578        <a href="/app" style="text-decoration:none;display:flex;align-items:center;gap:12px;color:inherit;">
579        <span class="tree-icon">🌳</span>
580        <h1>Tree</h1>
581        </a>
582        <span class="root-name-inline" id="rootName"></span>
583      </div>
584      <div class="header-right">
585        <div class="status-badge">
586          <div class="status-dot connecting" id="statusDot"></div>
587          <span class="status-text" id="statusText">Connecting</span>
588        </div>
589        <button class="clear-chat-btn" id="clearChatBtn" title="Clear conversation">
590          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
591        </button>
592        <button class="notif-btn" id="notifBtn" onclick="toggleNotifs()">
593          <span class="notif-dot" id="notifDot"></span>
594          <span class="notif-btn-icon">☰</span>
595          <span class="notif-btn-label">Menu</span>
596        </button>
597        <a href="/dashboard" class="advanced-btn" id="advancedLink">Advanced</a>
598      </div>
599    </div>
600    <div class="back-row" id="backRow">
601      <button class="back-btn" onclick="backToTrees()">
602        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
603        Back
604      </button>
605    </div>
606
607    <!-- Menu panel -->
608    <div class="notif-overlay" id="notifOverlay" onclick="toggleNotifs()"></div>
609    <div class="notif-panel" id="notifPanel">
610      <div class="notif-panel-header">
611        <h2>Menu</h2>
612        <button class="notif-close" onclick="toggleNotifs()">&#x2715;</button>
613      </div>
614      <div class="notif-tabs">
615        <button class="notif-tab active" id="tabDreams" onclick="switchTab('dreams')">Dreams<span class="tab-dot" id="dreamsDot"></span></button>
616        <button class="notif-tab" id="tabInvites" onclick="switchTab('invites')">Invites<span class="tab-dot" id="invitesDot"></span></button>
617      </div>
618      <div class="notif-list" id="notifList">
619        <div class="notif-loading">Loading...</div>
620      </div>
621      <div class="notif-list" id="invitesList" style="display:none">
622        <div class="notif-loading">Loading...</div>
623      </div>
624      <div class="notif-panel-footer">
625        <button class="logout-btn" onclick="doLogout()">Log out</button>
626      </div>
627    </div>
628
629    <div class="tree-picker" id="treePicker">
630      ${
631        trees.length === 0
632          ? `<div class="empty-state">
633              <span class="empty-icon">🌱</span>
634              <h2>Plant your first tree</h2>
635              <p>A tree starts with a single root, a broad topic that everything else branches out from. Think big categories like <strong>My Life</strong>, <strong>Career</strong>, or <strong>Health</strong>.</p>
636              <p style="margin-top:8px;">Name it, and you can start chatting with it right away.</p>
637              <form class="create-tree-form" style="margin-top:16px;" onsubmit="createTree(event)">
638                <input type="text" id="newTreeNameEmpty" placeholder="e.g. My Life" autocomplete="off" />
639                <button type="submit" title="Create tree">+</button>
640              </form>
641            </div>`
642          : `<h2 class="tree-picker-title">Your Trees</h2>
643            <p class="tree-picker-sub">Pick a tree to start chatting</p>
644            <div class="tree-list" id="treeList">
645              ${trees
646                .map(
647                  (t) => `
648                <div class="tree-item" onclick="selectTree('${t._id}', '${escapeHtml(t.name)}')">
649                  <span class="tree-item-icon">🌳</span>
650                  <span class="tree-item-name">${escapeHtml(t.name)}</span>
651                </div>`,
652                )
653                .join("")}
654            </div>`
655      }
656      ${trees.length > 0 ? `
657      <form class="create-tree-form" id="createTreeForm" onsubmit="createTree(event)">
658        <input type="text" id="newTreeName" placeholder="New tree name..." autocomplete="off" />
659        <button type="submit" title="Create tree">+</button>
660      </form>` : ""}
661    </div>
662
663    <div class="chat-area empty" id="chatArea">
664      <div class="chat-messages" id="messages">
665        <div class="welcome-message" id="welcomeMsg">
666          <div class="welcome-icon">🌳</div>
667          <h2>Start chatting</h2>
668          <p>Ask anything about your tree or tell it something new.</p>
669        </div>
670      </div>
671      <div class="chat-input-area">
672        <div class="mode-toggle" id="modeToggle">
673          <button class="mode-btn active" data-mode="chat">Chat</button>
674          <button class="mode-btn" data-mode="place">Place</button>
675          <button class="mode-btn" data-mode="query">Query</button>
676        </div>
677        <div class="input-container">
678          <textarea class="chat-input" id="chatInput" placeholder="Say something..." rows="1"></textarea>
679          <button class="send-btn" id="sendBtn" disabled>
680            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
681          </button>
682        </div>
683      </div>
684    </div>
685  </div>
686
687  <script src="/socket.io/socket.io.js"></script>
688  <script>
689    const CONFIG = {
690      username: "${escapeHtml(username)}",
691      userId: "${userId}",
692      trees: ${treesJSON},
693    };
694
695    // State
696    let activeRootId = null;
697    let isConnected = false;
698    let isRegistered = false;
699    let isSending = false;
700    let requestGeneration = 0;
701    let chatMode = "chat";
702
703    // Mode toggle
704    const modeToggle = document.getElementById("modeToggle");
705    const modePlaceholders = { chat: "Full conversation. Places content and responds.", place: "Places content onto your tree but doesn't respond.", query: "Talk to your tree without it making any changes." };
706    modeToggle.addEventListener("click", function(e) {
707      var btn = e.target.closest(".mode-btn");
708      if (!btn || isSending) return;
709      chatMode = btn.dataset.mode;
710      modeToggle.querySelectorAll(".mode-btn").forEach(function(b) { b.classList.remove("active"); });
711      btn.classList.add("active");
712      document.getElementById("chatInput").placeholder = modePlaceholders[chatMode] || "Say something...";
713    });
714
715    // Elements
716    const statusDot = document.getElementById("statusDot");
717    const statusText = document.getElementById("statusText");
718    const treePicker = document.getElementById("treePicker");
719    const chatArea = document.getElementById("chatArea");
720    const chatMessages = document.getElementById("messages");
721    const chatInput = document.getElementById("chatInput");
722    const sendBtn = document.getElementById("sendBtn");
723    const backRow = document.getElementById("backRow");
724    const rootName = document.getElementById("rootName");
725    const advancedLink = document.getElementById("advancedLink");
726
727    function escapeHtml(s) {
728      const d = document.createElement("div");
729      d.textContent = s;
730      return d.innerHTML;
731    }
732
733    // ── Markdown formatting — matches app.js ──────────────────────────
734    function formatMessageContent(text) {
735      if (!text) return '';
736      let html = text;
737
738      html = html.replace(/&nbsp;/g, ' ');
739      html = html.replace(/&amp;/g, '&');
740      html = html.replace(/&lt;/g, '<');
741      html = html.replace(/&gt;/g, '>');
742      html = html.replace(/\\u00A0/g, ' ');
743
744      html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
745
746      // Code blocks
747      html = html.replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, '<pre><code>$1</code></pre>');
748      html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
749
750      // Bold / italic
751      html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
752      html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
753      html = html.replace(/(?<![\\w\\*])\\*([^\\*]+)\\*(?![\\w\\*])/g, '<em>$1</em>');
754
755      // Headings
756      html = html.replace(/^####\\s*(.+)$/gm, '<h4>$1</h4>');
757      html = html.replace(/^###\\s*(.+)$/gm, '<h3>$1</h3>');
758      html = html.replace(/^##\\s*(.+)$/gm, '<h2>$1</h2>');
759      html = html.replace(/^#\\s*(.+)$/gm, '<h1>$1</h1>');
760
761      // HR
762      html = html.replace(/^-{3,}$/gm, '<hr>');
763      html = html.replace(/^\\*{3,}$/gm, '<hr>');
764
765      // Blockquote
766      html = html.replace(/^&gt;\\s*(.+)$/gm, '<blockquote>$1</blockquote>');
767
768      // Numbered menu items with bold title
769      html = html.replace(/^([1-9]|1[0-9]|20)\\.\\s*<strong>(.+?)<\\/strong>(.*)$/gm, function(m, num, title, rest) {
770        return '<div class="menu-item clickable" data-action="' + num + '" data-name="' + title.replace(/"/g, '&quot;') + '">' +
771          '<span class="menu-number">' + num + '</span>' +
772          '<span class="menu-text"><strong>' + title + '</strong>' + rest + '</span></div>';
773      });
774
775      // Bullet items with bold title
776      html = html.replace(/^[-\\u2013\\u2022]\\s*<strong>(.+?)<\\/strong>(.*)$/gm,
777        '<div class="menu-item"><span class="menu-number">\\u2022</span><span class="menu-text"><strong>$1</strong>$2</span></div>');
778
779      // Plain bullet items
780      html = html.replace(/^[-\\u2013\\u2022]\\s+([^<].*)$/gm, '<li>$1</li>');
781
782      // Numbered list items
783      html = html.replace(/^(\\d+)\\.\\s+([^<*].*)$/gm, '<li><span class="list-num">$1.</span> $2</li>');
784
785      // Wrap consecutive li in ul
786      let inList = false;
787      const lines = html.split('\\n');
788      const processed = [];
789      for (let i = 0; i < lines.length; i++) {
790        const line = lines[i];
791        const isListItem = line.trim().startsWith('<li>');
792        if (isListItem && !inList) { processed.push('<ul>'); inList = true; }
793        else if (!isListItem && inList) { processed.push('</ul>'); inList = false; }
794        processed.push(line);
795      }
796      if (inList) processed.push('</ul>');
797      html = processed.join('\\n');
798
799      // Links
800      html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>');
801
802      // Paragraphs
803      const blocks = html.split(/\\n\\n+/);
804      html = blocks.map(function(block) {
805        const trimmed = block.trim();
806        if (!trimmed) return '';
807        if (trimmed.match(/^<(h[1-4]|ul|ol|pre|blockquote|hr|div|table)/)) return trimmed;
808        const withBreaks = trimmed.split('\\n').map(function(l) { return l.trim(); }).filter(function(l) { return l; }).join('<br>');
809        return '<p>' + withBreaks + '</p>';
810      }).filter(function(b) { return b; }).join('');
811
812      // Clean up
813      html = html.replace(/<p><\\/p>/g, '');
814      html = html.replace(/<p>(<div|<ul|<ol|<h[1-4]|<hr|<pre|<blockquote)/g, '$1');
815      html = html.replace(/(<\\/div>|<\\/ul>|<\\/ol>|<\\/h[1-4]>|<\\/pre>|<\\/blockquote>)<\\/p>/g, '$1');
816      html = html.replace(/<br>(<div|<\\/div>)/g, '$1');
817      html = html.replace(/(<div[^>]*>)<br>/g, '$1');
818
819      return html;
820    }
821
822    // ── Socket ────────────────────────────────────────────────────────
823    const socket = io({ transports: ["websocket", "polling"], withCredentials: true });
824
825    socket.on("connect", () => {
826      isConnected = true;
827      statusDot.className = "status-dot connecting";
828      statusText.textContent = "Connecting";
829      socket.emit("ready");
830      socket.emit("register", { username: CONFIG.username });
831    });
832
833    socket.on("registered", ({ success }) => {
834      if (success) {
835        isRegistered = true;
836        statusDot.className = "status-dot connected";
837        statusText.textContent = "Connected";
838
839        // Clear disconnected message on reconnect
840        const disc = chatMessages.querySelector(".welcome-message.disconnected");
841        if (disc) {
842          disc.remove();
843          chatMessages.innerHTML = '<div class="welcome-message" id="welcomeMsg"><div class="welcome-icon">🌳</div><h2>Start chatting</h2><p>Ask anything about your tree or tell it something new.</p></div>';
844          chatArea.classList.add("empty");
845        }
846
847        updateSendBtn();
848        if (activeRootId) {
849          socket.emit("setActiveRoot", { rootId: activeRootId });
850          socket.emit("urlChanged", { url: "/api/v1/root/" + activeRootId, rootId: activeRootId });
851        }
852      }
853    });
854
855    socket.on("chatResponse", ({ answer, generation }) => {
856      if (generation !== undefined && generation < requestGeneration) return;
857      removeTyping();
858      addMessage(answer, "assistant");
859      isSending = false;
860      updateSendBtn();
861    });
862
863    socket.on("placeResult", ({ stepSummaries, targetPath, generation }) => {
864      if (generation !== undefined && generation < requestGeneration) return;
865      var el = document.getElementById("placeStatus");
866      var summary = (stepSummaries && stepSummaries.length > 0)
867        ? "Placed on: " + (targetPath || stepSummaries.map(function(s) { return s.summary || s; }).join(", "))
868        : "Nothing to place for that message.";
869      if (el) {
870        el.querySelector(".place-result").textContent = summary;
871      } else {
872        addMessage(summary, "place-status");
873      }
874      isSending = false;
875      updateSendBtn();
876    });
877
878    socket.on("chatError", ({ error, generation }) => {
879      if (generation !== undefined && generation < requestGeneration) return;
880      removeTyping();
881      addMessage("Error: " + error, "error");
882      isSending = false;
883      updateSendBtn();
884    });
885
886    socket.on("chatCancelled", () => {
887      if (isSending) {
888        removeTyping();
889        isSending = false;
890        updateSendBtn();
891      }
892    });
893
894    socket.on("disconnect", () => {
895      isConnected = false;
896      isRegistered = false;
897      isSending = false;
898      statusDot.className = "status-dot disconnected";
899      statusText.textContent = "Disconnected";
900      updateSendBtn();
901
902      chatMessages.innerHTML = '<div class="welcome-message disconnected"><div class="welcome-icon">🌳</div><h2>Disconnected</h2><p>You have been disconnected from TreeOS. Please refresh the page to reconnect.</p></div>';
903    });
904
905    // Ignore navigate events — no iframe
906    socket.on("navigate", () => {});
907
908    // ── Create tree ─────────────────────────────────────────────────
909    async function createTree(e) {
910      e.preventDefault();
911      const input = e.target.querySelector("input[type=text]");
912      const name = input.value.trim();
913      if (!name) return;
914
915      const btn = e.target.querySelector("button");
916      btn.disabled = true;
917
918      try {
919        const res = await fetch("/api/v1/user/" + CONFIG.userId + "/createRoot", {
920          method: "POST",
921          headers: { "Content-Type": "application/json" },
922          credentials: "include",
923          body: JSON.stringify({ name }),
924        });
925        const data = await res.json();
926        if (!data.success) throw new Error(data.error || "Failed");
927
928        // Add to tree list (create it if empty state)
929        let treeList = document.getElementById("treeList");
930        if (!treeList) {
931          // Was empty state — rebuild picker content
932          const emptyState = treePicker.querySelector(".empty-state");
933          if (emptyState) emptyState.remove();
934
935          const title = document.createElement("h2");
936          title.className = "tree-picker-title";
937          title.textContent = "Your Trees";
938
939          const sub = document.createElement("p");
940          sub.className = "tree-picker-sub";
941          sub.textContent = "Pick a tree to start chatting";
942
943          treeList = document.createElement("div");
944          treeList.className = "tree-list";
945          treeList.id = "treeList";
946
947          const form = document.getElementById("createTreeForm");
948          treePicker.insertBefore(treeList, form);
949          treePicker.insertBefore(sub, treeList);
950          treePicker.insertBefore(title, sub);
951        }
952
953        const item = document.createElement("div");
954        item.className = "tree-item";
955        item.onclick = () => selectTree(data.rootId, name);
956        item.innerHTML = \`
957          <span class="tree-item-icon">🌳</span>
958          <span class="tree-item-name">\${escapeHtml(name)}</span>\`;
959        item.style.animation = "fadeInUp 0.3s ease-out";
960        treeList.appendChild(item);
961
962        input.value = "";
963      } catch (err) {
964        console.error("Create tree error:", err);
965        alert("Failed to create tree: " + err.message);
966      } finally {
967        btn.disabled = false;
968      }
969    }
970
971    // ── Tree selection ────────────────────────────────────────────────
972    function selectTree(rootId, name) {
973      activeRootId = rootId;
974      advancedLink.href = "/dashboard?rootId=" + rootId;
975      treePicker.style.display = "none";
976      chatArea.classList.add("active");
977      rootName.textContent = name;
978      rootName.classList.add("visible");
979      backRow.classList.add("visible");
980
981      // Reset chat
982      const welcome = chatMessages.querySelector(".welcome-message");
983      if (welcome) welcome.style.display = "";
984      chatMessages.querySelectorAll(".message, .typing-indicator").forEach(el => el.remove());
985      chatArea.classList.add("empty");
986
987      // Tell server about this root
988      socket.emit("setActiveRoot", { rootId });
989      socket.emit("urlChanged", { url: "/api/v1/root/" + rootId, rootId });
990
991      // Refresh menu panel for this tree
992      dreamsLoaded = false;
993      invitesLoaded = false;
994      if (notifOpen) {
995        if (activeTab === "dreams") fetchDreams();
996        if (activeTab === "invites") fetchInvites();
997      }
998
999      updateSendBtn();
1000    }
1001
1002    function backToTrees() {
1003      // Cancel any in-flight request
1004      if (isSending) {
1005        requestGeneration++;
1006        socket.emit("cancelRequest");
1007        removeTyping();
1008      }
1009
1010      activeRootId = null;
1011      advancedLink.href = "/dashboard";
1012      treePicker.style.display = "";
1013      chatArea.classList.remove("active");
1014      rootName.classList.remove("visible");
1015      backRow.classList.remove("visible");
1016      document.getElementById("clearChatBtn").classList.remove("visible");
1017      isSending = false;
1018      updateSendBtn();
1019
1020      // Tell server we're going home so it properly exits tree mode
1021      socket.emit("urlChanged", { url: "/api/v1/user/" + CONFIG.userId });
1022      socket.emit("clearConversation");
1023      dreamsLoaded = false;
1024      invitesLoaded = false;
1025      if (notifOpen) {
1026        if (activeTab === "dreams") fetchDreams();
1027        if (activeTab === "invites") fetchInvites();
1028      }
1029    }
1030
1031    // ── Messages ──────────────────────────────────────────────────────
1032    function addMessage(content, role) {
1033      const welcome = chatMessages.querySelector(".welcome-message");
1034      if (welcome) {
1035        welcome.remove();
1036        document.getElementById("chatArea").classList.remove("empty");
1037        document.getElementById("clearChatBtn").classList.add("visible");
1038      }
1039
1040      const msg = document.createElement("div");
1041      if (role === "place-status") {
1042        msg.className = "message assistant";
1043        msg.id = "placeStatus";
1044        msg.innerHTML = '<div class="message-avatar">\\ud83c\\udf33</div><div class="message-content"><div class="place-result">' + escapeHtml(content) + '</div></div>';
1045        chatMessages.appendChild(msg);
1046        chatMessages.scrollTop = chatMessages.scrollHeight;
1047        return;
1048      }
1049
1050      msg.className = "message " + role;
1051
1052      const formattedContent = role === "assistant" ? formatMessageContent(content) : escapeHtml(content);
1053
1054      msg.innerHTML =
1055        '<div class="message-avatar">' + (role === "user" ? "\\ud83d\\udc64" : "\\ud83c\\udf33") + '</div>' +
1056        '<div class="message-content">' + formattedContent + '</div>';
1057
1058      // Clickable menu items
1059      if (role === "assistant") {
1060        msg.querySelectorAll(".menu-item.clickable").forEach(function(item) {
1061          item.addEventListener("click", function() {
1062            const name = item.dataset.name;
1063            if (name && !isSending) {
1064              chatInput.value = name;
1065              sendMessage();
1066            }
1067          });
1068        });
1069      }
1070
1071      chatMessages.appendChild(msg);
1072      chatMessages.scrollTop = chatMessages.scrollHeight;
1073    }
1074
1075    function addTyping() {
1076      removeTyping();
1077      const msg = document.createElement("div");
1078      msg.className = "message assistant";
1079      msg.id = "typingIndicator";
1080      msg.innerHTML =
1081        '<div class="message-avatar">\\ud83c\\udf33</div>' +
1082        '<div class="message-content typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
1083      chatMessages.appendChild(msg);
1084      chatMessages.scrollTop = chatMessages.scrollHeight;
1085    }
1086
1087    function removeTyping() {
1088      const el = document.getElementById("typingIndicator");
1089      if (el) el.remove();
1090    }
1091
1092    // ── Send ──────────────────────────────────────────────────────────
1093    function sendMessage() {
1094      if (isSending) {
1095        requestGeneration++;
1096        socket.emit("cancelRequest");
1097        removeTyping();
1098        addMessage("Stopped", "error");
1099        isSending = false;
1100        updateSendBtn();
1101        return;
1102      }
1103
1104      const text = chatInput.value.trim();
1105      if (!text || !isRegistered || !activeRootId) return;
1106
1107      chatInput.value = "";
1108      chatInput.style.height = "auto";
1109      addMessage(text, "user");
1110      if (chatMode === "place") {
1111        addMessage("Placing...", "place-status");
1112      } else {
1113        addTyping();
1114      }
1115      isSending = true;
1116      requestGeneration++;
1117      updateSendBtn();
1118      socket.emit("chat", { message: text, username: CONFIG.username, generation: requestGeneration, mode: chatMode });
1119    }
1120
1121    function updateSendBtn() {
1122      const hasText = chatInput.value.trim().length > 0;
1123      if (isSending) {
1124        sendBtn.classList.add("stop-mode");
1125        sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
1126        sendBtn.disabled = !(isConnected && isRegistered);
1127        chatInput.disabled = true;
1128      } else {
1129        sendBtn.classList.remove("stop-mode");
1130        sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>';
1131        sendBtn.disabled = !(hasText && isRegistered && activeRootId);
1132        chatInput.disabled = false;
1133      }
1134    }
1135
1136    // ── Input handlers ────────────────────────────────────────────────
1137    chatInput.addEventListener("input", () => {
1138      const maxH = chatArea.classList.contains("empty") ? window.innerHeight * 0.4 : 120;
1139      chatInput.style.height = "auto";
1140      chatInput.style.height = Math.min(chatInput.scrollHeight, maxH) + "px";
1141      updateSendBtn();
1142    });
1143
1144    chatInput.addEventListener("keydown", (e) => {
1145      if (e.key === "Enter" && !e.shiftKey) {
1146        e.preventDefault();
1147        sendMessage();
1148        // On mobile, blur to dismiss keyboard so user can see the response
1149        if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
1150          chatInput.blur();
1151        }
1152      }
1153    });
1154
1155    sendBtn.addEventListener("click", sendMessage);
1156
1157    document.getElementById("clearChatBtn").addEventListener("click", () => {
1158      if (!isRegistered) return;
1159      if (isSending) {
1160        socket.emit("cancelRequest");
1161        removeTyping();
1162        isSending = false;
1163      }
1164      socket.emit("clearConversation");
1165      chatMessages.innerHTML = '<div class="welcome-message" id="welcomeMsg"><div class="welcome-icon">🌳</div><h2>Start chatting</h2><p>Ask anything about your tree or tell it something new.</p></div>';
1166      chatArea.classList.add("empty");
1167      document.getElementById("clearChatBtn").classList.remove("visible");
1168      updateSendBtn();
1169    });
1170
1171    // ── Notifications + Invites ────────────────────────────────────────
1172    const notifPanel = document.getElementById("notifPanel");
1173    const notifOverlay = document.getElementById("notifOverlay");
1174    const notifList = document.getElementById("notifList");
1175    const invitesList = document.getElementById("invitesList");
1176    const notifDot = document.getElementById("notifDot");
1177    let notifOpen = false;
1178    let dreamsLoaded = false;
1179    let invitesLoaded = false;
1180    let activeTab = "dreams";
1181
1182    async function doLogout() {
1183      try {
1184        await fetch("/api/v1/logout", { method: "POST", credentials: "include" });
1185        window.location.href = "/login";
1186      } catch(e) {
1187        alert("Logout failed");
1188      }
1189    }
1190
1191    function toggleNotifs() {
1192      notifOpen = !notifOpen;
1193      notifPanel.classList.toggle("open", notifOpen);
1194      notifOverlay.classList.toggle("open", notifOpen);
1195      if (notifOpen) {
1196        if (activeTab === "dreams" && !dreamsLoaded) fetchDreams();
1197        if (activeTab === "invites" && !invitesLoaded) fetchInvites();
1198      }
1199    }
1200
1201    function switchTab(tab) {
1202      activeTab = tab;
1203      document.getElementById("tabDreams").classList.toggle("active", tab === "dreams");
1204      document.getElementById("tabInvites").classList.toggle("active", tab === "invites");
1205      notifList.style.display = tab === "dreams" ? "" : "none";
1206      invitesList.style.display = tab === "invites" ? "" : "none";
1207      if (tab === "dreams" && !dreamsLoaded) fetchDreams();
1208      if (tab === "invites" && !invitesLoaded) fetchInvites();
1209    }
1210
1211    async function fetchDreams() {
1212      notifList.innerHTML = '<div class="notif-loading">Loading...</div>';
1213      dreamsLoaded = false;
1214      try {
1215        var dreamUrl = "/api/v1/chat/notifications" + (activeRootId ? "?rootId=" + activeRootId : "");
1216        var res = await fetch(dreamUrl, { credentials: "include" });
1217        var data = await res.json();
1218        if (!res.ok) throw new Error(data.error || "Failed");
1219
1220        dreamsLoaded = true;
1221        var notifs = data.notifications || [];
1222        var html = "";
1223
1224        // Dream time config (only when inside a tree and user is owner)
1225        if (activeRootId && data.isOwner) {
1226          if (data.metadata?.dreams?.dreamTime) {
1227            html += '<div class="dream-config">' +
1228              '<div class="dream-config-label">Dream schedule</div>' +
1229              '<div class="dream-config-row">' +
1230                '<input type="time" id="dreamTimeInput" value="' + escapeHtml(data.metadata?.dreams?.dreamTime) + '" />' +
1231                '<button class="dream-config-save" onclick="saveDreamTime()">Save</button>' +
1232                '<button class="dream-config-off" onclick="disableDreamTime()">Turn Off</button>' +
1233              '</div>' +
1234              '<div class="dream-config-status" id="dreamStatus"></div>' +
1235            '</div>';
1236          } else {
1237            html += '<div class="dream-config">' +
1238              '<div class="dream-config-hint">Dreams are off for this tree. Set a time to enable nightly dreams. Your tree will reflect, reorganize, and share thoughts with you.</div>' +
1239              '<div class="dream-config-row" style="margin-top:8px">' +
1240                '<input type="time" id="dreamTimeInput" value="" />' +
1241                '<button class="dream-config-save" onclick="saveDreamTime()">Enable</button>' +
1242              '</div>' +
1243              '<div class="dream-config-status" id="dreamStatus"></div>' +
1244            '</div>';
1245          }
1246        }
1247
1248        if (notifs.length === 0) {
1249          html += '<div class="notif-empty"><span class="notif-empty-icon">\\ud83d\\udd14</span>' +
1250            (activeRootId ? 'No dreams from this tree yet' : 'No dream notifications from the last 7 days') +
1251          '</div>';
1252          notifList.innerHTML = html;
1253          return;
1254        }
1255
1256        document.getElementById("dreamsDot").classList.add("visible");
1257        notifDot.classList.add("has-notifs");
1258        html += notifs.map(function(n, i) {
1259          var isThought = n.type === "dream-thought";
1260          var icon = isThought ? "\\ud83d\\udcad" : "\\ud83d\\udccb";
1261          var badge = isThought ? "Thought" : "Summary";
1262          var cls = isThought ? "type-thought" : "type-summary";
1263          var date = new Date(n.createdAt).toLocaleDateString(undefined, {
1264            month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
1265          });
1266          return '<div class="notif-item ' + cls + '" style="animation-delay:' + (i * 0.04) + 's">' +
1267            '<div class="notif-item-header">' +
1268              '<span class="notif-item-icon">' + icon + '</span>' +
1269              '<span class="notif-item-title">' + escapeHtml(n.title) + '</span>' +
1270              '<span class="notif-item-badge">' + badge + '</span>' +
1271            '</div>' +
1272            '<div class="notif-item-content">' + escapeHtml(n.content) + '</div>' +
1273            '<div class="notif-item-time">' + date + '</div>' +
1274          '</div>';
1275        }).join("");
1276        notifList.innerHTML = html;
1277      } catch (err) {
1278        console.error("Dreams error:", err);
1279        notifList.innerHTML = '<div class="notif-empty">Failed to load notifications</div>';
1280      }
1281    }
1282
1283    async function saveDreamTime() {
1284      var input = document.getElementById("dreamTimeInput");
1285      var status = document.getElementById("dreamStatus");
1286      if (!input.value) { status.textContent = "Pick a time first"; return; }
1287      try {
1288        var res = await fetch("/api/v1/root/" + activeRootId + "/dream-time", {
1289          method: "POST",
1290          headers: { "Content-Type": "application/json" },
1291          credentials: "include",
1292          body: JSON.stringify({ dreamTime: input.value }),
1293        });
1294        var data = await res.json();
1295        if (!res.ok) throw new Error(data.error || "Failed");
1296        status.textContent = "Dreams set for " + input.value;
1297        dreamsLoaded = false;
1298        fetchDreams();
1299      } catch (err) {
1300        status.textContent = err.message;
1301      }
1302    }
1303
1304    async function disableDreamTime() {
1305      var status = document.getElementById("dreamStatus");
1306      try {
1307        var res = await fetch("/api/v1/root/" + activeRootId + "/dream-time", {
1308          method: "POST",
1309          headers: { "Content-Type": "application/json" },
1310          credentials: "include",
1311          body: JSON.stringify({ dreamTime: null }),
1312        });
1313        var data = await res.json();
1314        if (!res.ok) throw new Error(data.error || "Failed");
1315        status.textContent = "Dreams disabled";
1316        dreamsLoaded = false;
1317        fetchDreams();
1318      } catch (err) {
1319        status.textContent = err.message;
1320      }
1321    }
1322
1323    async function fetchInvites() {
1324      invitesList.innerHTML = '<div class="notif-loading">Loading...</div>';
1325      invitesLoaded = false;
1326      try {
1327        var invUrl = "/api/v1/chat/invites" + (activeRootId ? "?rootId=" + activeRootId : "");
1328        var res = await fetch(invUrl, { credentials: "include" });
1329        var data = await res.json();
1330        if (!res.ok) throw new Error(data.error || "Failed");
1331
1332        invitesLoaded = true;
1333        var html = "";
1334
1335        // Pending invites section
1336        var invites = data.invites || [];
1337        if (invites.length > 0) {
1338          document.getElementById("invitesDot").classList.add("visible");
1339          notifDot.classList.add("has-notifs");
1340          html += invites.map(function(inv, i) {
1341            return '<div class="invite-item" style="animation-delay:' + (i * 0.04) + 's">' +
1342              '<div class="invite-item-text"><strong>' + escapeHtml(inv.from) + '</strong> invited you to <strong>' + escapeHtml(inv.treeName) + '</strong></div>' +
1343              '<div class="invite-item-actions">' +
1344                '<button class="invite-accept" onclick="respondInvite(\\'' + inv.id + '\\', true, this)">Accept</button>' +
1345                '<button class="invite-decline" onclick="respondInvite(\\'' + inv.id + '\\', false, this)">Decline</button>' +
1346              '</div>' +
1347            '</div>';
1348          }).join("");
1349        }
1350
1351        // Members section (only when inside a tree)
1352        if (activeRootId && data.members) {
1353          var members = data.members;
1354          html += '<div class="members-section">';
1355          html += '<div class="members-section-title">Members</div>';
1356
1357          // Owner
1358          if (members.owner) {
1359            html += '<div class="member-item">' +
1360              '<div><span class="member-name">' + escapeHtml(members.owner.username) + '</span><span class="member-role">Owner</span></div>' +
1361            '</div>';
1362          }
1363
1364          // Contributors
1365          (members.contributors || []).forEach(function(c) {
1366            var isSelf = c._id === CONFIG.userId;
1367            var isOwner = members.isOwner;
1368            var actions = '';
1369            if (isOwner || isSelf) {
1370              var label = isSelf ? "Leave" : "Remove";
1371              var cls = isSelf ? "btn-danger" : "btn-danger";
1372              actions = '<div class="member-actions">';
1373              if (isOwner && !isSelf) {
1374                actions += '<button onclick="transferOwner(\\'' + c._id + '\\', this)">Transfer</button>';
1375              }
1376              actions += '<button class="' + cls + '" onclick="removeMember(\\'' + c._id + '\\', \\'' + label + '\\', this)">' + label + '</button>';
1377              actions += '</div>';
1378            }
1379            html += '<div class="member-item">' +
1380              '<div><span class="member-name">' + escapeHtml(c.username) + '</span></div>' +
1381              actions +
1382            '</div>';
1383          });
1384
1385          // Invite form (owner only)
1386          if (members.isOwner) {
1387            html += '<form class="invite-form" onsubmit="sendInvite(event)">' +
1388              '<input type="text" id="inviteUsername" placeholder="Username to invite..." />' +
1389              '<button type="submit">Invite</button>' +
1390            '</form>' +
1391            '<div class="invite-status" id="inviteStatus"></div>';
1392          }
1393          html += '</div>';
1394        }
1395
1396        if (!html) {
1397          html = '<div class="notif-empty"><span class="notif-empty-icon">\\ud83d\\udcec</span>No pending invites</div>';
1398        }
1399
1400        invitesList.innerHTML = html;
1401      } catch (err) {
1402        console.error("Invites error:", err);
1403        invitesList.innerHTML = '<div class="notif-empty">Failed to load invites</div>';
1404      }
1405    }
1406
1407    async function respondInvite(inviteId, accept, btn) {
1408      var item = btn.closest(".invite-item");
1409      item.style.opacity = "0.5";
1410      item.style.pointerEvents = "none";
1411      try {
1412        var res = await fetch("/api/v1/chat/invites/" + inviteId, {
1413          method: "POST",
1414          headers: { "Content-Type": "application/json" },
1415          credentials: "include",
1416          body: JSON.stringify({ accept: accept }),
1417        });
1418        var data = await res.json();
1419        if (!res.ok) throw new Error(data.error || "Failed");
1420        item.remove();
1421        // Refresh tree list if accepted
1422        if (accept) {
1423          location.reload();
1424        }
1425      } catch (err) {
1426        item.style.opacity = "1";
1427        item.style.pointerEvents = "";
1428        alert(err.message);
1429      }
1430    }
1431
1432    async function sendInvite(e) {
1433      e.preventDefault();
1434      var input = document.getElementById("inviteUsername");
1435      var status = document.getElementById("inviteStatus");
1436      var username = input.value.trim();
1437      if (!username) return;
1438
1439      status.className = "invite-status";
1440      status.textContent = "";
1441
1442      try {
1443        var res = await fetch("/api/v1/root/" + activeRootId + "/invite", {
1444          method: "POST",
1445          headers: { "Content-Type": "application/json" },
1446          credentials: "include",
1447          body: JSON.stringify({ userReceiving: username }),
1448        });
1449        var data = await res.json();
1450        if (!res.ok) throw new Error(data.error || "Failed");
1451
1452        status.textContent = "Invite sent!";
1453        status.className = "invite-status success";
1454        input.value = "";
1455      } catch (err) {
1456        status.textContent = err.message;
1457        status.className = "invite-status error";
1458      }
1459    }
1460
1461    async function removeMember(userId, label, btn) {
1462      if (!confirm("Are you sure you want to " + label.toLowerCase() + "?")) return;
1463      btn.disabled = true;
1464      try {
1465        var res = await fetch("/api/v1/root/" + activeRootId + "/remove-user", {
1466          method: "POST",
1467          headers: { "Content-Type": "application/json" },
1468          credentials: "include",
1469          body: JSON.stringify({ userReceiving: userId }),
1470        });
1471        var data = await res.json();
1472        if (!res.ok) throw new Error(data.error || "Failed");
1473        if (userId === CONFIG.userId) {
1474          location.reload();
1475        } else {
1476          invitesLoaded = false;
1477          fetchInvites();
1478        }
1479      } catch (err) {
1480        btn.disabled = false;
1481        alert(err.message);
1482      }
1483    }
1484
1485    async function transferOwner(userId, btn) {
1486      if (!confirm("Transfer ownership? This cannot be undone.")) return;
1487      btn.disabled = true;
1488      try {
1489        var res = await fetch("/api/v1/root/" + activeRootId + "/transfer-owner", {
1490          method: "POST",
1491          headers: { "Content-Type": "application/json" },
1492          credentials: "include",
1493          body: JSON.stringify({ userReceiving: userId }),
1494        });
1495        var data = await res.json();
1496        if (!res.ok) throw new Error(data.error || "Failed");
1497        invitesLoaded = false;
1498        fetchInvites();
1499      } catch (err) {
1500        btn.disabled = false;
1501        alert(err.message);
1502      }
1503    }
1504
1505    // Check for notifications + invites on load
1506    fetch("/api/v1/chat/notifications", { credentials: "include" })
1507      .then(function(r) { return r.json(); })
1508      .then(function(d) {
1509        if (d.notifications && d.notifications.length > 0) {
1510          notifDot.classList.add("has-notifs");
1511          document.getElementById("dreamsDot").classList.add("visible");
1512        }
1513      })
1514      .catch(function() {});
1515
1516    fetch("/api/v1/chat/invites", { credentials: "include" })
1517      .then(function(r) { return r.json(); })
1518      .then(function(d) {
1519        if (d.invites && d.invites.length > 0) {
1520          notifDot.classList.add("has-notifs");
1521          document.getElementById("invitesDot").classList.add("visible");
1522        }
1523      })
1524      .catch(function() {});
1525  </script>
1526</body>
1527</html>
1528`;
1529}
1530
1/* ─────────────────────────────────────────────── */
2/* HTML renderer for contributions page             */
3/* ─────────────────────────────────────────────── */
4
5import {
6  baseStyles,
7  backNavStyles,
8  glassHeaderStyles,
9  glassCardStyles,
10  emptyStateStyles,
11  responsiveBase,
12} from "./baseStyles.js";
13import { esc, actionColorClass } from "./utils.js";
14
15const link = (id, queryString) =>
16  id
17    ? `<a href="/api/v1/node/${id}${queryString}"><code>${esc(id)}</code></a>`
18    : `<code>unknown</code>`;
19
20const userTag = (u, queryString) => {
21  if (!u) return `<code>unknown user</code>`;
22  if (typeof u === "object" && u.username)
23    return `<a href="/api/v1/user/${u._id}${queryString}"><code>${esc(u.username)}</code></a>`;
24  if (typeof u === "string")
25    return `<code>${esc(u)}</code>`;
26  return `<code>unknown user</code>`;
27};
28
29const kvMap = (data) => {
30  if (!data) return "";
31  const entries =
32    data instanceof Map
33      ? [...data.entries()]
34      : typeof data === "object"
35        ? Object.entries(data)
36        : [];
37  if (entries.length === 0) return "";
38  return entries
39    .map(
40      ([k, v]) =>
41        `<span class="kv-chip"><code>${esc(k)}</code> ${esc(String(v))}</span>`,
42    )
43    .join(" ");
44};
45
46function renderAction(rawC, { nodeId, parsedVersion, nextVersion, queryString }) {
47  // Merge extensionData so action renderers can access extension fields directly
48  const c = rawC.extensionData ? { ...rawC, ...rawC.extensionData } : rawC;
49  switch (c.action) {
50    case "create":
51      return `Created node`;
52
53    case "editStatus":
54      return `Marked as <code>${esc(c.statusEdited)}</code>`;
55
56    case "editValue":
57      return `Adjusted values ${kvMap(c.valueEdited)}`;
58
59    case "prestige":
60      return `Prestiged to <a href="/api/v1/node/${nodeId}/${nextVersion}${queryString}"><code>Version ${nextVersion}</code></a>`;
61
62    case "trade":
63      return `Traded on node`;
64
65    case "delete":
66      return `Deleted node`;
67
68    case "invite": {
69      const ia = c.inviteAction || {};
70      const target = userTag(ia.receivingId, queryString);
71      const labels = {
72        invite: `Invited ${target} to collaborate`,
73        acceptInvite: `Accepted an invitation`,
74        denyInvite: `Declined an invitation`,
75        removeContributor: `Removed ${target}`,
76        switchOwner: `Transferred ownership to ${target}`,
77      };
78      return labels[ia.action] || "Updated collaboration";
79    }
80
81    case "editSchedule": {
82      const s = c.scheduleEdited || {};
83      const parts = [];
84      if (s.date)
85        parts.push(
86          `date to <code>${new Date(s.date).toLocaleString()}</code>`,
87        );
88      if (s.reeffectTime != null)
89        parts.push(`re-effect to <code>${s.reeffectTime}</code>`);
90      return parts.length
91        ? `Set ${parts.join(" and ")}`
92        : `Updated the schedule`;
93    }
94
95    case "editGoal":
96      return `Set new goals ${kvMap(c.goalEdited)}`;
97
98    case "transaction": {
99      const tm = c.transactionMeta;
100      if (!tm) return `Recorded a transaction`;
101      const eventLabel = esc(tm.event || "unknown").replace(/_/g, " ");
102      const counterparty = tm.counterpartyNodeId
103        ? ` with ${link(tm.counterpartyNodeId, queryString)}`
104        : "";
105      const sent = kvMap(tm.valuesSent);
106      const recv = kvMap(tm.valuesReceived);
107      let flow = "";
108      if (sent) flow += ` — sent ${sent}`;
109      if (recv) flow += `${sent ? "," : " —"} received ${recv}`;
110      return `Transaction <code>${eventLabel}</code> as ${esc(tm.role)} (side ${esc(tm.side)})${counterparty}${flow}`;
111    }
112
113    case "note": {
114      const na = c.noteAction || {};
115
116      let verb;
117      switch (na.action) {
118        case "add":
119          verb = "Added a note";
120          break;
121        case "edit":
122          verb = "Edited a note";
123          break;
124        case "remove":
125          verb = "Removed a note";
126          break;
127        default:
128          verb = "Updated a note";
129      }
130
131      const noteRef = na.noteId
132        ? ` <a href="/api/v1/node/${nodeId}/${parsedVersion}/notes/${na.noteId}${queryString}"><code>${esc(na.noteId)}</code></a>`
133        : "";
134
135      return `${verb}${noteRef}`;
136    }
137
138    case "updateParent": {
139      const up = c.updateParent || {};
140      const from = up.oldParentId
141        ? link(up.oldParentId, queryString)
142        : `<code>none</code>`;
143      const to = up.newParentId
144        ? link(up.newParentId, queryString)
145        : `<code>none</code>`;
146      return `Moved from ${from} to ${to}`;
147    }
148
149    case "editScript": {
150      const es = c.editScript || {};
151      return `Edited script <code>${esc(es.scriptName || es.scriptId)}</code>`;
152    }
153
154    case "executeScript": {
155      const xs = c.executeScript || {};
156      const icon = xs.success ? "✅" : "❌";
157      let text = `${icon} Ran <code>${esc(xs.scriptName || xs.scriptId)}</code>`;
158      if (xs.error) text += ` — <code>${esc(xs.error)}</code>`;
159      return text;
160    }
161
162    case "updateChildNode": {
163      const uc = c.updateChildNode || {};
164      return uc.action === "added"
165        ? `Added child ${link(uc.childId, queryString)}`
166        : `Removed child ${link(uc.childId, queryString)}`;
167    }
168
169    case "editNameNode": {
170      const en = c.editNameNode || {};
171      return `Renamed from <code>${esc(en.oldName)}</code> to <code>${esc(en.newName)}</code>`;
172    }
173
174    case "rawIdea": {
175      const ri = c.rawIdeaAction || {};
176      const uId = c.userId?._id || c.userId;
177      const ideaRef = ri.rawIdeaId && uId
178        ? `<a href="/api/v1/user/${uId}/raw-ideas/${ri.rawIdeaId}${queryString}"><code>${esc(ri.rawIdeaId)}</code></a>`
179        : ri.rawIdeaId
180          ? `<code>${esc(ri.rawIdeaId)}</code>`
181          : `<code>unknown</code>`;
182      if (ri.action === "add") return `Captured a raw idea ${ideaRef}`;
183      if (ri.action === "delete") return `Discarded raw idea ${ideaRef}`;
184      if (ri.action === "placed") {
185        const target = ri.targetNodeId ? link(ri.targetNodeId, queryString) : "node";
186        return `Placed raw idea ${ideaRef} into ${target}`;
187      }
188      if (ri.action === "aiStarted")
189        return `AI began processing raw idea ${ideaRef}`;
190      if (ri.action === "aiFailed")
191        return `AI failed to place raw idea ${ideaRef}`;
192      return `Updated raw idea ${ideaRef}`;
193    }
194
195    case "branchLifecycle": {
196      const bl = c.branchLifecycle || {};
197      if (bl.action === "retired") {
198        let text = `Retired branch`;
199        if (bl.fromParentId) text += ` from ${link(bl.fromParentId, queryString)}`;
200        return text;
201      }
202      if (bl.action === "revived") {
203        let text = `Revived branch`;
204        if (bl.toParentId) text += ` under ${link(bl.toParentId, queryString)}`;
205        return text;
206      }
207      return `Revived as a new root`;
208    }
209
210    case "purchase": {
211      const pm = c.purchaseMeta || {};
212      const parts = [];
213      if (pm.plan) parts.push(`the <code>${esc(pm.plan)}</code> plan`);
214      if (pm.energyAmount)
215        parts.push(`<code>${pm.energyAmount}</code> energy`);
216      const price = pm.totalCents
217        ? ` for $${(pm.totalCents / 100).toFixed(2)} ${esc(pm.currency || "usd").toUpperCase()}`
218        : "";
219      return parts.length
220        ? `Purchased ${parts.join(" and ")}${price}`
221        : `Made a purchase${price}`;
222    }
223
224    case "understanding": {
225      const um = c.understandingMeta || {};
226      const rootNode = um.rootNodeId || nodeId;
227      const runId = um.understandingRunId;
228
229      if (um.stage === "createRun") {
230        const runLink =
231          runId && rootNode
232            ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}${queryString}"><code>${esc(runId)}</code></a>`
233            : `<code>unknown run</code>`;
234        let text = `Started understanding run ${runLink}`;
235        if (um.nodeCount != null)
236          text += ` spanning <code>${um.nodeCount}</code> nodes`;
237        if (um.perspective) text += ` — "${esc(um.perspective)}"`;
238        return text;
239      }
240
241      if (um.stage === "processStep") {
242        const uNodeId = um.understandingNodeId;
243        const uNodeLink =
244          uNodeId && runId && rootNode
245            ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}/${uNodeId}${queryString}"><code>${esc(uNodeId)}</code></a>`
246            : uNodeId
247              ? `<code>${esc(uNodeId)}</code>`
248              : `<code>unknown</code>`;
249        let text = `Understanding encoded ${uNodeLink}`;
250        if (um.mode)
251          text += ` <span class="kv-chip">${esc(um.mode)}</span>`;
252        if (um.layer != null) text += ` at layer <code>${um.layer}</code>`;
253        return text;
254      }
255
256      return `Understanding activity`;
257    }
258
259    default:
260      return `<code>${esc(c.action)}</code>`;
261  }
262}
263
264export function renderContributions({ nodeId, version, nodeName, contributions, queryString }) {
265  const parsedVersion = Number(version);
266  const nextVersion = parsedVersion + 1;
267
268  const items = contributions.map((c) => {
269    const time = new Date(c.date).toLocaleString();
270    const actionHtml = renderAction(c, { nodeId, parsedVersion, nextVersion, queryString });
271    const colorClass = actionColorClass(c.action);
272
273    const aiBadge = c.wasAi ? `<span class="badge badge-ai">AI</span>` : "";
274    const energyBadge =
275      c.energyUsed != null && c.energyUsed > 0
276        ? `<span class="badge badge-energy">⚡ ${c.energyUsed}</span>`
277        : "";
278
279    const user = userTag(c.userId, queryString);
280
281    return `
282      <li class="note-card ${colorClass}">
283        <div class="note-content">
284          <div class="contribution-action">${actionHtml}</div>
285        </div>
286        <div class="note-meta">
287          ${user}
288          <span class="meta-separator">·</span>
289          ${time}
290          ${aiBadge}${energyBadge}
291          <span class="meta-separator">·</span>
292          <code class="contribution-id">${esc(c._id)}</code>
293        </div>
294      </li>`;
295  });
296
297  const qs = queryString || "";
298  const backTreeUrl = `/api/v1/root/${nodeId}${qs}`;
299  const backUrl = `/api/v1/node/${nodeId}/${version}${qs}`;
300
301  return `
302<!DOCTYPE html>
303<html lang="en">
304<head>
305  <meta charset="UTF-8">
306  <meta name="viewport" content="width=device-width, initial-scale=1.0">
307  <meta name="theme-color" content="#667eea">
308  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
309  <title>${esc(nodeName || nodeId)} — Contributions</title>
310  <style>
311${baseStyles}
312${backNavStyles}
313${glassHeaderStyles}
314${glassCardStyles}
315${emptyStateStyles}
316${responsiveBase}
317
318/* ── Page-specific: contributions ── */
319
320.version-badge {
321  display: inline-block;
322  padding: 6px 14px;
323  background: rgba(255,255,255,0.25);
324  color: white; border-radius: 980px;
325  font-size: 13px; font-weight: 600;
326  border: 1px solid rgba(255,255,255,0.3);
327  margin-right: 8px;
328}
329
330.header-subtitle { margin-bottom: 16px; }
331
332.notes-list { animation: fadeInUp 0.6s ease-out 0.2s both; }
333
334.note-card:nth-child(1) { animation-delay: 0.25s; }
335.note-card:nth-child(2) { animation-delay: 0.3s; }
336.note-card:nth-child(3) { animation-delay: 0.35s; }
337.note-card:nth-child(4) { animation-delay: 0.4s; }
338.note-card:nth-child(5) { animation-delay: 0.45s; }
339.note-card:nth-child(n+6) { animation-delay: 0.5s; }
340
341.contribution-action {
342  font-size: 15px; line-height: 1.6;
343  color: white; font-weight: 400;
344  word-wrap: break-word;
345}
346
347.contribution-action a {
348  color: white; text-decoration: none;
349  border-bottom: 1px solid rgba(255,255,255,0.3);
350  transition: all 0.2s;
351}
352
353.contribution-action a:hover {
354  border-bottom-color: white;
355  text-shadow: 0 0 12px rgba(255,255,255,0.8);
356}
357
358.contribution-action code {
359  background: rgba(255,255,255,0.18);
360  padding: 2px 7px; border-radius: 5px;
361  font-size: 13px;
362  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
363  border: 1px solid rgba(255,255,255,0.15);
364}
365
366.contribution-id {
367  background: rgba(255,255,255,0.12);
368  padding: 2px 6px; border-radius: 4px;
369  font-size: 11px;
370  font-family: 'SF Mono', 'Fira Code', monospace;
371  color: rgba(255,255,255,0.6);
372  border: 1px solid rgba(255,255,255,0.1);
373}
374
375.badge {
376  display: inline-flex; align-items: center;
377  padding: 3px 10px; border-radius: 980px;
378  font-size: 11px; font-weight: 700; letter-spacing: 0.3px;
379  border: 1px solid rgba(255,255,255,0.2);
380}
381
382.badge-ai {
383  background: rgba(255,200,50,0.35);
384  color: #fff;
385  text-shadow: 0 1px 2px rgba(0,0,0,0.2);
386}
387
388.badge-energy {
389  background: rgba(100,220,255,0.3);
390  color: #fff;
391  text-shadow: 0 1px 2px rgba(0,0,0,0.2);
392}
393
394.kv-chip {
395  display: inline-block;
396  padding: 2px 8px;
397  background: rgba(255,255,255,0.15);
398  border-radius: 6px; font-size: 12px;
399  margin: 2px 2px;
400  border: 1px solid rgba(255,255,255,0.15);
401}
402
403.kv-chip code {
404  background: none !important;
405  border: none !important;
406  padding: 0 !important;
407  font-weight: 600;
408}
409  </style>
410</head>
411<body>
412  <div class="container">
413    <div class="back-nav">
414      <a href="${backTreeUrl}" class="back-link">← Back to Tree</a>
415      <a href="${backUrl}" class="back-link">Back to Version</a>
416    </div>
417
418    <div class="header">
419      <h1>
420        Contributions on
421        <a href="${backUrl}">${esc(nodeName || nodeId)}</a>
422        ${contributions.length > 0 ? `<span class="message-count">${contributions.length}</span>` : ""}
423      </h1>
424      <div class="header-subtitle">
425        <span class="version-badge">Version ${parsedVersion}</span>
426        Activity &amp; change history
427      </div>
428    </div>
429
430    ${
431      items.length
432        ? `<ul class="notes-list">${items.join("")}</ul>`
433        : `
434    <div class="empty-state">
435      <div class="empty-state-icon">📊</div>
436      <div class="empty-state-text">No contributions yet</div>
437      <div class="empty-state-subtext">Contributions and activity will appear here</div>
438    </div>`
439    }
440  </div>
441
442</body>
443</html>
444`;
445}
446
1import { getLandUrl } from "../../../canopy/identity.js";
2import { baseStyles } from "./baseStyles.js";
3
4export function renderLoginPage(req, res) {
5  const redirect = req.query.redirect || "";
6
7  res.setHeader("Content-Type", "text/html");
8  res.send(`<!DOCTYPE html>
9<html lang="en">
10<head>
11  <meta charset="UTF-8" />
12  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
13  <meta name="theme-color" content="#736fe6">
14  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
15  <title>TreeOS - Login</title>
16
17  <style>
18    ${baseStyles}
19
20    body {
21      display: flex;
22      flex-direction: column;
23      align-items: center;
24      justify-content: center;
25      overflow-y: auto;
26    }
27
28    @keyframes fadeInDown {
29      from {
30        opacity: 0;
31        transform: translateY(-30px);
32      }
33      to {
34        opacity: 1;
35        transform: translateY(0);
36      }
37    }
38
39    @keyframes slideUp {
40      from {
41        opacity: 0;
42        transform: translateY(30px);
43      }
44      to {
45        opacity: 1;
46        transform: translateY(0);
47      }
48    }
49
50    /* Brand Header */
51    .brand-header {
52      position: relative;
53      z-index: 1;
54      margin-bottom: 32px;
55      text-align: center;
56      animation: fadeInDown 0.8s ease-out;
57    }
58
59    .brand-logo {
60      font-size: 80px;
61      margin-bottom: 16px;
62      display: inline-block;
63      filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.2));
64      animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
65    }
66
67    @keyframes grow {
68      0%, 100% {
69        transform: scale(1);
70      }
71      50% {
72        transform: scale(1.06);
73      }
74    }
75
76    .brand-title {
77      font-size: 56px;
78      font-weight: 600;
79      color: white;
80      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
81      letter-spacing: -1.5px;
82      margin-bottom: 8px;
83    }
84
85    .brand-subtitle {
86      font-size: 18px;
87      color: rgba(255, 255, 255, 0.85);
88      font-weight: 400;
89      letter-spacing: 0.2px;
90    }
91
92    /* Login Container - Glass */
93    .login-container {
94      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
95      backdrop-filter: blur(22px) saturate(140%);
96      -webkit-backdrop-filter: blur(22px) saturate(140%);
97      padding: 48px;
98      border-radius: 16px;
99      width: 100%;
100      max-width: 460px;
101      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
102        inset 0 1px 0 rgba(255, 255, 255, 0.25);
103      border: 1px solid rgba(255, 255, 255, 0.28);
104      text-align: center;
105      position: relative;
106      z-index: 1;
107      animation: slideUp 0.6s ease-out 0.2s both;
108    }
109
110    h2 {
111      font-size: 32px;
112      font-weight: 600;
113      color: white;
114      margin-bottom: 12px;
115      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
116      letter-spacing: -0.5px;
117    }
118
119    /* Form */
120    form {
121      margin-bottom: 16px;
122    }
123
124    .input-group {
125      margin-bottom: 16px;
126      text-align: left;
127    }
128
129    label {
130      display: block;
131      font-size: 14px;
132      font-weight: 600;
133      color: white;
134      margin-bottom: 8px;
135      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
136      letter-spacing: -0.2px;
137    }
138
139    input {
140      width: 100%;
141      padding: 14px 18px;
142      border-radius: 12px;
143      border: 2px solid rgba(255, 255, 255, 0.3);
144      font-size: 16px;
145      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
146      background: rgba(255, 255, 255, 0.15);
147      backdrop-filter: blur(20px) saturate(150%);
148      -webkit-backdrop-filter: blur(20px) saturate(150%);
149      font-family: inherit;
150      color: white;
151      font-weight: 500;
152      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
153        inset 0 1px 0 rgba(255, 255, 255, 0.25);
154      touch-action: manipulation;
155    }
156
157    input:focus {
158      outline: none;
159      border-color: rgba(255, 255, 255, 0.6);
160      background: rgba(255, 255, 255, 0.25);
161      backdrop-filter: blur(25px) saturate(160%);
162      -webkit-backdrop-filter: blur(25px) saturate(160%);
163      box-shadow: 
164        0 0 0 4px rgba(255, 255, 255, 0.15),
165        0 8px 30px rgba(0, 0, 0, 0.15),
166        inset 0 1px 0 rgba(255, 255, 255, 0.4);
167      transform: translateY(-2px);
168    }
169
170    input::placeholder {
171      color: rgba(255, 255, 255, 0.5);
172      font-weight: 400;
173    }
174
175    /* Glass Button */
176    button {
177      width: 100%;
178      padding: 16px;
179      margin-top: 8px;
180      border-radius: 980px;
181      border: 1px solid rgba(255, 255, 255, 0.3);
182      background: rgba(255, 255, 255, 0.25);
183      backdrop-filter: blur(10px);
184      color: white;
185      font-size: 16px;
186      font-weight: 600;
187      cursor: pointer;
188      transition: all 0.3s;
189      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
190      font-family: inherit;
191      letter-spacing: -0.2px;
192      position: relative;
193      overflow: hidden;
194      touch-action: manipulation;
195    }
196
197    button::before {
198      content: "";
199      position: absolute;
200      inset: -40%;
201      background: radial-gradient(
202        120% 60% at 0% 0%,
203        rgba(255, 255, 255, 0.35),
204        transparent 60%
205      );
206      opacity: 0;
207      transform: translateX(-30%) translateY(-10%);
208      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
209      pointer-events: none;
210    }
211
212    button:hover::before {
213      opacity: 1;
214      transform: translateX(30%) translateY(10%);
215    }
216
217    button:hover {
218      background: rgba(255, 255, 255, 0.35);
219      transform: translateY(-2px);
220      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
221    }
222
223    button:active {
224      transform: translateY(0);
225    }
226
227    .error-message {
228      color: white;
229      margin-top: 16px;
230      margin-bottom: 16px;
231      padding: 12px 16px;
232      background: rgba(239, 68, 68, 0.3);
233      backdrop-filter: blur(10px);
234      border-radius: 10px;
235      font-size: 14px;
236      font-weight: 600;
237      border: 1px solid rgba(239, 68, 68, 0.4);
238      text-align: left;
239    }
240
241    .error-message:empty {
242      display: none;
243    }
244
245    /* Secondary Actions */
246    .secondary-actions {
247      display: flex;
248      flex-direction: column;
249      gap: 8px;
250      margin-top: 24px;
251      padding-top: 24px;
252    }
253
254    .back-btn,
255    .secondary-btn {
256      width: 100%;
257      background: rgba(255, 255, 255, 0.15);
258      color: white;
259      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
260      font-weight: 600;
261      padding: 12px;
262      font-size: 15px;
263    }
264
265    .back-btn:hover,
266    .secondary-btn:hover {
267      background: rgba(255, 255, 255, 0.25);
268    }
269
270    .secondary-btn {
271      margin-top: 0;
272    }
273
274    /* Divider */
275    .divider {
276      display: flex;
277      align-items: center;
278      text-align: center;
279      margin: 20px 0;
280    }
281
282    .divider::before,
283    .divider::after {
284      content: '';
285      flex: 1;
286      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
287    }
288
289    .divider span {
290      padding: 0 16px;
291      color: rgba(255, 255, 255, 0.8);
292      font-size: 13px;
293      font-weight: 600;
294      text-transform: uppercase;
295      letter-spacing: 0.5px;
296    }
297
298    /* Loading State */
299    button.loading {
300      position: relative;
301      color: transparent;
302      pointer-events: none;
303    }
304
305    button.loading::after {
306      content: '';
307      position: absolute;
308      width: 20px;
309      height: 20px;
310      top: 50%;
311      left: 50%;
312      margin-left: -10px;
313      margin-top: -10px;
314      border: 3px solid rgba(255, 255, 255, 0.3);
315      border-radius: 50%;
316      border-top-color: white;
317      animation: spin 0.8s linear infinite;
318    }
319
320    @keyframes spin {
321      to {
322        transform: rotate(360deg);
323      }
324    }
325
326    /* Responsive */
327    @media (max-width: 640px) {
328      body {
329        padding: 20px 16px;
330        justify-content: center;
331      }
332
333      .brand-header {
334        margin-bottom: 24px;
335      }
336
337      .brand-logo {
338        font-size: 64px;
339      }
340
341      .brand-title {
342        font-size: 42px;
343        letter-spacing: -1px;
344      }
345
346      .brand-subtitle {
347        font-size: 16px;
348      }
349
350      .login-container {
351        padding: 32px 24px;
352      }
353
354      h2 {
355        font-size: 28px;
356      }
357
358      input {
359        font-size: 16px;
360      }
361    }
362
363    @media (min-width: 641px) and (max-width: 1024px) {
364      .login-container {
365        max-width: 420px;
366      }
367    }
368  </style>
369</head>
370
371<body>
372  <!-- Brand Header -->
373  <div class="brand-header">
374  <a href="/" style="text-decoration: none;">
375    <div class="brand-logo">🌳</div>
376    <h1 class="brand-title">TreeOS</h1></a>
377    <div class="brand-subtitle">Organize your life, efficiently</div>
378  </div>
379
380  <!-- Login Container -->
381  <div class="login-container">
382    <h2>Welcome Back</h2>
383
384    <form id="loginForm">
385      <div class="input-group">
386        <label for="username">Username</label>
387        <input 
388          type="text" 
389          id="username" 
390          placeholder="Enter your username" 
391          required 
392          autocomplete="username"
393          autocapitalize="off"
394          autocorrect="off"
395        />
396      </div>
397
398      <div class="input-group">
399        <label for="password">Password</label>
400        <input 
401          type="password" 
402          id="password" 
403          placeholder="Enter your password" 
404          required 
405          autocomplete="current-password"
406        />
407      </div>
408
409      <button type="submit" id="loginBtn">Login</button>
410    </form>
411
412    <p id="errorMessage" class="error-message"></p>
413
414    <div class="divider">
415      <span>Need Help?</span>
416    </div>
417
418    <div class="secondary-actions">
419        <button type="button" id="registerBtn" class="secondary-btn">
420        Create an account
421      </button>
422      <button type="button" id="forgotPasswordBtn" class="secondary-btn">
423        Forgot your password?
424      </button>
425
426  
427
428      <button class="back-btn" onclick="goBack()">← Back to Home</button>
429    </div>
430  </div>
431
432  <script>
433    const apiUrl = "${getLandUrl()}";
434    const redirectAfterLogin = "${redirect}" || null;
435
436    // Secondary button handlers
437    document.getElementById("registerBtn").addEventListener("click", () => {
438      window.location.href = "/register";
439    });
440
441    document.getElementById("forgotPasswordBtn").addEventListener("click", () => {
442      window.location.href = "/forgot-password";
443    });
444
445    // Login form submission
446    document.getElementById("loginForm").addEventListener("submit", async (e) => {
447      e.preventDefault();
448
449      const username = document.getElementById("username").value.trim();
450      const password = document.getElementById("password").value;
451      const errorEl = document.getElementById("errorMessage");
452      const loginBtn = document.getElementById("loginBtn");
453      
454      errorEl.textContent = "";
455      loginBtn.classList.add("loading");
456      loginBtn.disabled = true;
457
458      try {
459        const res = await fetch(\`\${apiUrl}/login\`, {
460          method: "POST",
461          headers: { "Content-Type": "application/json" },
462          credentials: "include",
463          body: JSON.stringify({ username, password })
464        });
465
466        const data = await res.json();
467
468        if (!res.ok) {
469          errorEl.textContent = data.message || "Login failed. Please check your credentials.";
470          loginBtn.classList.remove("loading");
471          loginBtn.disabled = false;
472          return;
473        }
474
475    window.location.href = redirectAfterLogin || "/chat";
476
477      } catch (err) {
478        console.error(err);
479        errorEl.textContent = "An error occurred. Please try again.";
480        loginBtn.classList.remove("loading");
481        loginBtn.disabled = false;
482      }
483    });
484
485    function goBack() {
486      window.location.href = "/";
487    }
488  </script>
489</body>
490</html>`);
491}
492export function renderRegisterPage(req, res) {
493  res.setHeader("Content-Type", "text/html");
494  res.send(`<!DOCTYPE html>
495<html lang="en">
496<head>
497  <meta charset="UTF-8" />
498  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
499  <meta name="theme-color" content="#736fe6">
500  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
501  <title>TreeOS - Register</title>
502
503  <style>
504    ${baseStyles}
505
506    body {
507      display: flex;
508      flex-direction: column;
509      align-items: center;
510      justify-content: center;
511      overflow-y: auto;
512    }
513
514    @keyframes fadeInDown {
515      from { opacity: 0; transform: translateY(-30px); }
516      to { opacity: 1; transform: translateY(0); }
517    }
518
519    @keyframes slideUp {
520      from { opacity: 0; transform: translateY(30px); }
521      to { opacity: 1; transform: translateY(0); }
522    }
523
524    .brand-header {
525      position: relative;
526      z-index: 1;
527      margin-bottom: 32px;
528      text-align: center;
529      animation: fadeInDown 0.8s ease-out;
530    }
531
532    .brand-logo {
533      font-size: 80px;
534      margin-bottom: 16px;
535      display: inline-block;
536      filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.2));
537      animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
538    }
539
540    @keyframes grow {
541      0%, 100% { transform: scale(1); }
542      50% { transform: scale(1.06); }
543    }
544
545    .brand-title {
546      font-size: 56px;
547      font-weight: 600;
548      color: white;
549      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
550      letter-spacing: -1.5px;
551      margin-bottom: 8px;
552    }
553
554    .brand-subtitle {
555      font-size: 18px;
556      color: rgba(255, 255, 255, 0.85);
557      font-weight: 400;
558      letter-spacing: 0.2px;
559    }
560
561    .register-container {
562      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
563      backdrop-filter: blur(22px) saturate(140%);
564      -webkit-backdrop-filter: blur(22px) saturate(140%);
565      padding: 48px;
566      border-radius: 16px;
567      width: 100%;
568      max-width: 460px;
569      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
570        inset 0 1px 0 rgba(255, 255, 255, 0.25);
571      border: 1px solid rgba(255, 255, 255, 0.28);
572      text-align: center;
573      position: relative;
574      z-index: 1;
575      animation: slideUp 0.6s ease-out 0.2s both;
576    }
577
578    h2 {
579      font-size: 32px;
580      font-weight: 600;
581      color: white;
582      margin-bottom: 8px;
583      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
584      letter-spacing: -0.5px;
585    }
586
587    .subtitle {
588      font-size: 15px;
589      color: rgba(255, 255, 255, 0.85);
590      margin-bottom: 32px;
591      line-height: 1.5;
592      font-weight: 400;
593    }
594
595    form { margin-bottom: 16px; }
596
597    .input-group {
598      margin-bottom: 16px;
599      text-align: left;
600    }
601
602    label {
603      display: block;
604      font-size: 14px;
605      font-weight: 600;
606      color: white;
607      margin-bottom: 8px;
608      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
609      letter-spacing: -0.2px;
610    }
611
612    input {
613      width: 100%;
614      padding: 14px 18px;
615      border-radius: 12px;
616      border: 2px solid rgba(255, 255, 255, 0.3);
617      font-size: 16px;
618      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
619      background: rgba(255, 255, 255, 0.15);
620      backdrop-filter: blur(20px) saturate(150%);
621      -webkit-backdrop-filter: blur(20px) saturate(150%);
622      font-family: inherit;
623      color: white;
624      font-weight: 500;
625      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
626        inset 0 1px 0 rgba(255, 255, 255, 0.25);
627      touch-action: manipulation;
628    }
629
630    input:focus {
631      outline: none;
632      border-color: rgba(255, 255, 255, 0.6);
633      background: rgba(255, 255, 255, 0.25);
634      backdrop-filter: blur(25px) saturate(160%);
635      -webkit-backdrop-filter: blur(25px) saturate(160%);
636      box-shadow: 
637        0 0 0 4px rgba(255, 255, 255, 0.15),
638        0 8px 30px rgba(0, 0, 0, 0.15),
639        inset 0 1px 0 rgba(255, 255, 255, 0.4);
640      transform: translateY(-2px);
641    }
642
643    input::placeholder {
644      color: rgba(255, 255, 255, 0.5);
645      font-weight: 400;
646    }
647
648    input.error {
649      border-color: rgba(239, 68, 68, 0.6);
650      background: rgba(239, 68, 68, 0.1);
651    }
652
653    input.error:focus {
654      box-shadow: 
655        0 0 0 4px rgba(239, 68, 68, 0.2),
656        0 8px 30px rgba(239, 68, 68, 0.2);
657    }
658
659    .password-hint {
660      font-size: 12px;
661      color: rgba(255, 255, 255, 0.7);
662      margin-top: 6px;
663      text-align: left;
664      font-weight: 400;
665    }
666
667    button {
668      width: 100%;
669      padding: 16px;
670      margin-top: 8px;
671      border-radius: 980px;
672      border: 1px solid rgba(255, 255, 255, 0.3);
673      background: rgba(255, 255, 255, 0.25);
674      backdrop-filter: blur(10px);
675      color: white;
676      font-size: 16px;
677      font-weight: 600;
678      cursor: pointer;
679      transition: all 0.3s;
680      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
681      font-family: inherit;
682      letter-spacing: -0.2px;
683      position: relative;
684      overflow: hidden;
685      touch-action: manipulation;
686    }
687
688    button::before {
689      content: "";
690      position: absolute;
691      inset: -40%;
692      background: radial-gradient(
693        120% 60% at 0% 0%,
694        rgba(255, 255, 255, 0.35),
695        transparent 60%
696      );
697      opacity: 0;
698      transform: translateX(-30%) translateY(-10%);
699      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
700      pointer-events: none;
701    }
702
703    button:hover::before {
704      opacity: 1;
705      transform: translateX(30%) translateY(10%);
706    }
707
708    button:hover {
709      background: rgba(255, 255, 255, 0.35);
710      transform: translateY(-2px);
711      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
712    }
713
714    button:active { transform: translateY(0); }
715
716    .message {
717      margin-top: 16px;
718      margin-bottom: 16px;
719      padding: 12px 16px;
720      border-radius: 10px;
721      font-size: 14px;
722      font-weight: 600;
723      text-align: left;
724      display: none;
725    }
726
727    .error-message {
728      color: white;
729      background: rgba(239, 68, 68, 0.3);
730      backdrop-filter: blur(10px);
731      border: 1px solid rgba(239, 68, 68, 0.4);
732    }
733
734    .success-message {
735      color: white;
736      background: rgba(16, 185, 129, 0.3);
737      backdrop-filter: blur(10px);
738      border: 1px solid rgba(16, 185, 129, 0.4);
739    }
740
741    .message.show { display: block; }
742
743    .secondary-actions {
744      display: flex;
745      flex-direction: column;
746      gap: 8px;
747      margin-top: 24px;
748      padding-top: 24px;
749    }
750
751    .back-btn {
752      width: 100%;
753      background: rgba(255, 255, 255, 0.15);
754      color: white;
755      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
756      font-weight: 600;
757      padding: 12px;
758      font-size: 15px;
759      margin-top: 0;
760    }
761
762    .back-btn:hover { background: rgba(255, 255, 255, 0.25); }
763
764    button.loading {
765      position: relative;
766      color: transparent;
767      pointer-events: none;
768    }
769
770    button.loading::after {
771      content: '';
772      position: absolute;
773      width: 20px;
774      height: 20px;
775      top: 50%;
776      left: 50%;
777      margin-left: -10px;
778      margin-top: -10px;
779      border: 3px solid rgba(255, 255, 255, 0.3);
780      border-radius: 50%;
781      border-top-color: white;
782      animation: spin 0.8s linear infinite;
783    }
784
785    @keyframes spin { to { transform: rotate(360deg); } }
786
787    /* Agreement text */
788    .agreement-text {
789      font-size: 13px;
790      color: rgba(255, 255, 255, 0.65);
791      line-height: 1.5;
792      margin-top: 20px;
793      margin-bottom: 4px;
794      text-align: center;
795    }
796
797    .agreement-link {
798      color: rgba(255, 255, 255, 0.9);
799      text-decoration: underline;
800      text-underline-offset: 2px;
801      cursor: pointer;
802      font-weight: 500;
803      transition: color 0.2s;
804    }
805
806    .agreement-link:hover {
807      color: white;
808    }
809
810    /* Modal overlay */
811    .modal-overlay {
812      display: none;
813      position: fixed;
814      inset: 0;
815      z-index: 1000;
816      background: rgba(0, 0, 0, 0.6);
817      backdrop-filter: blur(8px);
818      -webkit-backdrop-filter: blur(8px);
819      align-items: center;
820      justify-content: center;
821      padding: 20px;
822      animation: modalFadeIn 0.25s ease-out;
823    }
824
825    .modal-overlay.show {
826      display: flex;
827    }
828
829    @keyframes modalFadeIn {
830      from { opacity: 0; }
831      to { opacity: 1; }
832    }
833
834    .modal-container {
835      width: 100%;
836      max-width: 720px;
837      height: 85vh;
838      height: 85dvh;
839      background: rgba(var(--glass-water-rgb), 0.35);
840      backdrop-filter: blur(22px) saturate(140%);
841      -webkit-backdrop-filter: blur(22px) saturate(140%);
842      border-radius: 20px;
843      border: 1px solid rgba(255, 255, 255, 0.28);
844      box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
845      display: flex;
846      flex-direction: column;
847      overflow: hidden;
848      animation: modalSlideUp 0.3s ease-out;
849    }
850
851    @keyframes modalSlideUp {
852      from { opacity: 0; transform: translateY(40px) scale(0.97); }
853      to { opacity: 1; transform: translateY(0) scale(1); }
854    }
855
856    .modal-header {
857      display: flex;
858      align-items: center;
859      justify-content: space-between;
860      padding: 16px 20px;
861      border-bottom: 1px solid rgba(255, 255, 255, 0.15);
862      flex-shrink: 0;
863    }
864
865    .modal-title {
866      font-size: 16px;
867      font-weight: 600;
868      color: white;
869      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
870    }
871
872    .modal-close {
873      width: 32px;
874      height: 32px;
875      min-width: 32px;
876      border-radius: 50%;
877      border: 1px solid rgba(255, 255, 255, 0.25);
878      background: rgba(255, 255, 255, 0.15);
879      color: white;
880      font-size: 18px;
881      cursor: pointer;
882      display: flex;
883      align-items: center;
884      justify-content: center;
885      padding: 0;
886      margin: 0;
887      transition: background 0.2s;
888      box-shadow: none;
889      backdrop-filter: none;
890    }
891
892    .modal-close:hover {
893      background: rgba(255, 255, 255, 0.3);
894      transform: none;
895      box-shadow: none;
896    }
897
898    .modal-close::before { display: none; }
899
900    .modal-body {
901      flex: 1;
902      overflow: hidden;
903    }
904
905    .modal-body iframe {
906      width: 100%;
907      height: 100%;
908      border: none;
909      background: transparent;
910    }
911
912    @media (max-width: 640px) {
913      body {
914        padding: 20px 16px;
915        justify-content: center;
916      }
917
918      .brand-header { margin-bottom: 24px; }
919      .brand-logo { font-size: 64px; }
920
921      .brand-title {
922        font-size: 42px;
923        letter-spacing: -1px;
924      }
925
926      .brand-subtitle { font-size: 16px; }
927      .register-container { padding: 32px 24px; }
928      h2 { font-size: 28px; }
929      input { font-size: 16px; }
930
931      .modal-container {
932        height: 90vh;
933        height: 90dvh;
934        border-radius: 16px;
935      }
936
937      .modal-overlay {
938        padding: 10px;
939      }
940    }
941
942    @media (min-width: 641px) and (max-width: 1024px) {
943      .register-container { max-width: 420px; }
944    }
945  </style>
946</head>
947
948<body>
949  <div class="brand-header">
950    <a href="/" style="text-decoration: none;">
951
952    <div class="brand-logo">🌳</div>
953    <h1 class="brand-title">TreeOS</h1></a>
954    <div class="brand-subtitle">Organize your life, efficiently</div>
955  </div>
956
957  <div class="register-container">
958    <h2>Create Account</h2>
959    <p class="subtitle">Sign up to get started with TreeOS</p>
960
961    <form id="registerForm">
962      <div class="input-group">
963        <label for="username">Username</label>
964        <input 
965          type="text"
966          id="username" 
967          placeholder="Choose a username"
968          required 
969          autocomplete="username"
970          autocapitalize="off"
971          autocorrect="off"
972        />
973      </div>
974
975      <div class="input-group">
976        <label for="email">Email</label>
977        <input 
978          type="email" 
979          id="email" 
980          placeholder="Enter your email"
981          required 
982          autocomplete="email"
983          autocapitalize="off"
984        />
985      </div>
986
987      <div class="input-group">
988        <label for="password">Password</label>
989        <input 
990          type="password" 
991          id="password" 
992          placeholder="Create a password"
993          required 
994          autocomplete="new-password"
995        />
996        <div class="password-hint">Must be at least 8 characters</div>
997      </div>
998
999      <div class="input-group">
1000        <label for="confirmPassword">Confirm Password</label>
1001        <input 
1002          type="password" 
1003          id="confirmPassword" 
1004          placeholder="Confirm your password"
1005          required 
1006          autocomplete="new-password"
1007        />
1008      </div>
1009      <button type="submit" id="registerBtn">Create Account</button>
1010
1011      <div class="agreement-text">
1012        By creating an account, you agree to our
1013        <span class="agreement-link" onclick="openModal('terms')">Terms of Service</span>
1014        and
1015        <span class="agreement-link" onclick="openModal('privacy')">Privacy Policy</span>.
1016      </div>
1017
1018    </form>
1019
1020    <div id="errorMessage" class="message error-message"></div>
1021    <div id="successMessage" class="message success-message">
1022      ✓ Registration successful! Check your email to complete registration.
1023    </div>
1024
1025    <div class="secondary-actions">
1026      <button class="back-btn" onclick="window.location.href='/login'">
1027        ← Back to Login
1028      </button>
1029    </div>
1030  </div>
1031
1032  <!-- Terms Modal -->
1033  <div class="modal-overlay" id="termsModal">
1034    <div class="modal-container">
1035      <div class="modal-header">
1036        <span class="modal-title">Terms of Service</span>
1037        <button class="modal-close" onclick="closeModal('terms')">✕</button>
1038      </div>
1039      <div class="modal-body">
1040        <iframe src="/terms" title="Terms of Service"></iframe>
1041      </div>
1042    </div>
1043  </div>
1044
1045  <!-- Privacy Modal -->
1046  <div class="modal-overlay" id="privacyModal">
1047    <div class="modal-container">
1048      <div class="modal-header">
1049        <span class="modal-title">Privacy Policy</span>
1050        <button class="modal-close" onclick="closeModal('privacy')">✕</button>
1051      </div>
1052      <div class="modal-body">
1053        <iframe src="/privacy" title="Privacy Policy"></iframe>
1054      </div>
1055    </div>
1056  </div>
1057
1058  <script>
1059    const apiUrl = "${getLandUrl()}";
1060
1061    function openModal(type) {
1062      const id = type === 'terms' ? 'termsModal' : 'privacyModal';
1063      document.getElementById(id).classList.add('show');
1064      document.body.style.overflow = 'hidden';
1065    }
1066
1067    function closeModal(type) {
1068      const id = type === 'terms' ? 'termsModal' : 'privacyModal';
1069      document.getElementById(id).classList.remove('show');
1070      document.body.style.overflow = '';
1071    }
1072
1073    // Close modal on overlay click
1074    document.querySelectorAll('.modal-overlay').forEach(overlay => {
1075      overlay.addEventListener('click', (e) => {
1076        if (e.target === overlay) {
1077          overlay.classList.remove('show');
1078          document.body.style.overflow = '';
1079        }
1080      });
1081    });
1082
1083    // Close modal on Escape key
1084    document.addEventListener('keydown', (e) => {
1085      if (e.key === 'Escape') {
1086        document.querySelectorAll('.modal-overlay.show').forEach(m => {
1087          m.classList.remove('show');
1088        });
1089        document.body.style.overflow = '';
1090      }
1091    });
1092
1093    document.getElementById("registerForm").addEventListener("submit", async (e) => {
1094      e.preventDefault();
1095
1096      const username = document.getElementById("username").value.trim();
1097      const email = document.getElementById("email").value.trim();
1098      const password = document.getElementById("password").value;
1099      const confirmPassword = document.getElementById("confirmPassword").value;
1100
1101      const errorEl = document.getElementById("errorMessage");
1102      const successEl = document.getElementById("successMessage");
1103      const btn = document.getElementById("registerBtn");
1104      const passwordInput = document.getElementById("password");
1105      const confirmPasswordInput = document.getElementById("confirmPassword");
1106
1107      errorEl.classList.remove("show");
1108      successEl.classList.remove("show");
1109      passwordInput.classList.remove("error");
1110      confirmPasswordInput.classList.remove("error");
1111
1112      if (password.length < 8) {
1113        errorEl.textContent = "Password must be at least 8 characters long.";
1114        errorEl.classList.add("show");
1115        passwordInput.classList.add("error");
1116        passwordInput.focus();
1117        return;
1118      }
1119
1120      if (password !== confirmPassword) {
1121        errorEl.textContent = "Passwords do not match.";
1122        errorEl.classList.add("show");
1123        confirmPasswordInput.classList.add("error");
1124        confirmPasswordInput.focus();
1125        return;
1126      }
1127
1128      btn.classList.add("loading");
1129      btn.disabled = true;
1130
1131      try {
1132        const res = await fetch(\`\${apiUrl}/register\`, {
1133          method: "POST",
1134          headers: { "Content-Type": "application/json" },
1135          body: JSON.stringify({ username, email, password })
1136        });
1137
1138        const data = await res.json();
1139
1140        if (!res.ok) {
1141          errorEl.textContent = data.message || "Registration failed. Please try again.";
1142          errorEl.classList.add("show");
1143          btn.classList.remove("loading");
1144          btn.disabled = false;
1145          return;
1146        }
1147
1148        document.getElementById("registerForm").reset();
1149        successEl.classList.add("show");
1150        btn.classList.remove("loading");
1151        btn.disabled = false;
1152
1153        setTimeout(() => {
1154          window.location.href = "/login";
1155        }, 7000);
1156
1157      } catch (err) {
1158        console.error(err);
1159        errorEl.textContent = "An error occurred. Please try again.";
1160        errorEl.classList.add("show");
1161        btn.classList.remove("loading");
1162        btn.disabled = false;
1163      }
1164    });
1165
1166    document.getElementById("confirmPassword").addEventListener("input", (e) => {
1167      const password = document.getElementById("password").value;
1168      const confirmPassword = e.target.value;
1169      if (confirmPassword && password !== confirmPassword) {
1170        e.target.classList.add("error");
1171      } else {
1172        e.target.classList.remove("error");
1173      }
1174    });
1175  </script>
1176</body>
1177</html>`);
1178}
1179
1180export function renderForgotPasswordPage(req, res) {
1181  res.setHeader("Content-Type", "text/html");
1182  res.send(`<!DOCTYPE html>
1183<html lang="en">
1184<head>
1185  <meta charset="UTF-8" />
1186  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
1187  <meta name="theme-color" content="#736fe6">
1188  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1189  <title>TreeOS - Reset Password</title>
1190
1191  <style>
1192    ${baseStyles}
1193
1194    body {
1195      display: flex;
1196      flex-direction: column;
1197      align-items: center;
1198      justify-content: center;
1199      overflow-y: auto;
1200    }
1201
1202    @keyframes fadeInDown {
1203      from { opacity: 0; transform: translateY(-30px); }
1204      to { opacity: 1; transform: translateY(0); }
1205    }
1206
1207    @keyframes slideUp {
1208      from { opacity: 0; transform: translateY(30px); }
1209      to { opacity: 1; transform: translateY(0); }
1210    }
1211
1212    @keyframes grow {
1213      0%, 100% { transform: scale(1); }
1214      50% { transform: scale(1.06); }
1215    }
1216
1217    @keyframes spin {
1218      to { transform: rotate(360deg); }
1219    }
1220
1221    .brand-header {
1222      position: relative;
1223      z-index: 1;
1224      margin-bottom: 32px;
1225      text-align: center;
1226      animation: fadeInDown 0.8s ease-out;
1227    }
1228
1229    .brand-logo {
1230      font-size: 80px;
1231      margin-bottom: 16px;
1232      display: inline-block;
1233      filter: drop-shadow(0 8px 32px rgba(0, 0, 0, 0.2));
1234      animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
1235    }
1236
1237    .brand-title {
1238      font-size: 56px;
1239      font-weight: 600;
1240      color: white;
1241      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1242      letter-spacing: -1.5px;
1243      margin-bottom: 8px;
1244    }
1245
1246    .brand-subtitle {
1247      font-size: 18px;
1248      color: rgba(255, 255, 255, 0.85);
1249      font-weight: 400;
1250      letter-spacing: 0.2px;
1251    }
1252
1253    .forgot-container {
1254      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1255      backdrop-filter: blur(22px) saturate(140%);
1256      -webkit-backdrop-filter: blur(22px) saturate(140%);
1257      padding: 48px;
1258      border-radius: 16px;
1259      width: 100%;
1260      max-width: 460px;
1261      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1262        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1263      border: 1px solid rgba(255, 255, 255, 0.28);
1264      text-align: center;
1265      position: relative;
1266      z-index: 1;
1267      animation: slideUp 0.6s ease-out 0.2s both;
1268    }
1269
1270    h2 {
1271      font-size: 32px;
1272      font-weight: 600;
1273      color: white;
1274      margin-bottom: 8px;
1275      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1276      letter-spacing: -0.5px;
1277    }
1278
1279    .subtitle {
1280      font-size: 15px;
1281      color: rgba(255, 255, 255, 0.85);
1282      margin-bottom: 32px;
1283      line-height: 1.5;
1284      font-weight: 400;
1285    }
1286
1287    form {
1288      margin-bottom: 16px;
1289    }
1290
1291    .input-group {
1292      margin-bottom: 16px;
1293      text-align: left;
1294    }
1295
1296    label {
1297      display: block;
1298      font-size: 14px;
1299      font-weight: 600;
1300      color: white;
1301      margin-bottom: 8px;
1302      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1303      letter-spacing: -0.2px;
1304    }
1305
1306    input {
1307      width: 100%;
1308      padding: 14px 18px;
1309      border-radius: 12px;
1310      border: 2px solid rgba(255, 255, 255, 0.3);
1311      font-size: 16px;
1312      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1313      background: rgba(255, 255, 255, 0.15);
1314      backdrop-filter: blur(20px) saturate(150%);
1315      -webkit-backdrop-filter: blur(20px) saturate(150%);
1316      font-family: inherit;
1317      color: white;
1318      font-weight: 500;
1319      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
1320        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1321      touch-action: manipulation;
1322    }
1323
1324    input:focus {
1325      outline: none;
1326      border-color: rgba(255, 255, 255, 0.6);
1327      background: rgba(255, 255, 255, 0.25);
1328      backdrop-filter: blur(25px) saturate(160%);
1329      -webkit-backdrop-filter: blur(25px) saturate(160%);
1330      box-shadow:
1331        0 0 0 4px rgba(255, 255, 255, 0.15),
1332        0 8px 30px rgba(0, 0, 0, 0.15),
1333        inset 0 1px 0 rgba(255, 255, 255, 0.4);
1334      transform: translateY(-2px);
1335    }
1336
1337    input::placeholder {
1338      color: rgba(255, 255, 255, 0.5);
1339      font-weight: 400;
1340    }
1341
1342    input.error {
1343      border-color: rgba(239, 68, 68, 0.6);
1344      background: rgba(239, 68, 68, 0.1);
1345    }
1346
1347    input.error:focus {
1348      box-shadow:
1349        0 0 0 4px rgba(239, 68, 68, 0.2),
1350        0 8px 30px rgba(239, 68, 68, 0.2);
1351    }
1352
1353    button {
1354      width: 100%;
1355      padding: 16px;
1356      margin-top: 8px;
1357      border-radius: 980px;
1358      border: 1px solid rgba(255, 255, 255, 0.3);
1359      background: rgba(255, 255, 255, 0.25);
1360      backdrop-filter: blur(10px);
1361      color: white;
1362      font-size: 16px;
1363      font-weight: 600;
1364      cursor: pointer;
1365      transition: all 0.3s;
1366      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
1367      font-family: inherit;
1368      letter-spacing: -0.2px;
1369      position: relative;
1370      overflow: hidden;
1371      touch-action: manipulation;
1372    }
1373
1374    button::before {
1375      content: "";
1376      position: absolute;
1377      inset: -40%;
1378      background: radial-gradient(
1379        120% 60% at 0% 0%,
1380        rgba(255, 255, 255, 0.35),
1381        transparent 60%
1382      );
1383      opacity: 0;
1384      transform: translateX(-30%) translateY(-10%);
1385      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1386      pointer-events: none;
1387    }
1388
1389    button:hover::before {
1390      opacity: 1;
1391      transform: translateX(30%) translateY(10%);
1392    }
1393
1394    button:hover {
1395      background: rgba(255, 255, 255, 0.35);
1396      transform: translateY(-2px);
1397      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
1398    }
1399
1400    button:active { transform: translateY(0); }
1401
1402    button.loading {
1403      position: relative;
1404      color: transparent;
1405      pointer-events: none;
1406    }
1407
1408    button.loading::after {
1409      content: '';
1410      position: absolute;
1411      width: 20px;
1412      height: 20px;
1413      top: 50%;
1414      left: 50%;
1415      margin-left: -10px;
1416      margin-top: -10px;
1417      border: 3px solid rgba(255, 255, 255, 0.3);
1418      border-radius: 50%;
1419      border-top-color: white;
1420      animation: spin 0.8s linear infinite;
1421    }
1422
1423    .message {
1424      margin-top: 16px;
1425      margin-bottom: 16px;
1426      padding: 12px 16px;
1427      border-radius: 10px;
1428      font-size: 14px;
1429      font-weight: 600;
1430      text-align: left;
1431      display: none;
1432    }
1433
1434    .error-message {
1435      color: white;
1436      background: rgba(239, 68, 68, 0.3);
1437      backdrop-filter: blur(10px);
1438      border: 1px solid rgba(239, 68, 68, 0.4);
1439    }
1440
1441    .success-message {
1442      color: white;
1443      background: rgba(16, 185, 129, 0.3);
1444      backdrop-filter: blur(10px);
1445      border: 1px solid rgba(16, 185, 129, 0.4);
1446    }
1447
1448    .message.show { display: block; }
1449
1450    .secondary-actions {
1451      display: flex;
1452      flex-direction: column;
1453      gap: 8px;
1454      margin-top: 24px;
1455      padding-top: 24px;
1456    }
1457
1458    .back-btn {
1459      width: 100%;
1460      background: rgba(255, 255, 255, 0.15);
1461      color: white;
1462      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
1463      font-weight: 600;
1464      padding: 12px;
1465      font-size: 15px;
1466      margin-top: 0;
1467    }
1468
1469    .back-btn:hover {
1470      background: rgba(255, 255, 255, 0.25);
1471    }
1472
1473    @media (max-width: 640px) {
1474      body {
1475        padding: 20px 16px;
1476        justify-content: center;
1477      }
1478
1479      .brand-header { margin-bottom: 24px; }
1480      .brand-logo { font-size: 64px; }
1481
1482      .brand-title {
1483        font-size: 42px;
1484        letter-spacing: -1px;
1485      }
1486
1487      .brand-subtitle { font-size: 16px; }
1488      .forgot-container { padding: 32px 24px; }
1489      h2 { font-size: 28px; }
1490      input { font-size: 16px; }
1491    }
1492
1493    @media (min-width: 641px) and (max-width: 1024px) {
1494      .forgot-container { max-width: 420px; }
1495    }
1496  </style>
1497</head>
1498
1499<body>
1500  <div class="brand-header">
1501    <a href="/" style="text-decoration: none;">
1502      <div class="brand-logo">🌳</div>
1503      <h1 class="brand-title">TreeOS</h1>
1504    </a>
1505    <div class="brand-subtitle">Organize your life, efficiently</div>
1506  </div>
1507
1508  <div class="forgot-container">
1509    <h2>Reset Password</h2>
1510    <p class="subtitle">Enter your email address and we'll send you a link to reset your password.</p>
1511
1512    <form id="forgotForm">
1513      <div class="input-group">
1514        <label for="email">Email Address</label>
1515        <input
1516          type="email"
1517          id="email"
1518          placeholder="Enter your email"
1519          required
1520          autocomplete="email"
1521          autocapitalize="off"
1522        />
1523      </div>
1524
1525      <button type="submit" id="submitBtn">Send Reset Link</button>
1526    </form>
1527
1528    <div id="errorMessage" class="message error-message"></div>
1529    <div id="successMessage" class="message success-message">
1530      ✓ If an account exists for that email, a password reset link has been sent. Check your inbox.
1531    </div>
1532
1533    <div class="secondary-actions">
1534      <button class="back-btn" onclick="window.location.href='/login'">
1535        ← Back to Login
1536      </button>
1537    </div>
1538  </div>
1539
1540  <script>
1541    const apiUrl = "${getLandUrl()}";
1542    const EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
1543
1544    document.getElementById("forgotForm").addEventListener("submit", async (e) => {
1545      e.preventDefault();
1546
1547      const email = document.getElementById("email").value.trim();
1548      const emailInput = document.getElementById("email");
1549      const btn = document.getElementById("submitBtn");
1550      const errorEl = document.getElementById("errorMessage");
1551      const successEl = document.getElementById("successMessage");
1552
1553      errorEl.classList.remove("show");
1554      successEl.classList.remove("show");
1555      emailInput.classList.remove("error");
1556
1557      if (!email) {
1558        errorEl.textContent = "Please enter your email address.";
1559        errorEl.classList.add("show");
1560        emailInput.classList.add("error");
1561        emailInput.focus();
1562        return;
1563      }
1564
1565      if (!EMAIL_REGEX.test(email)) {
1566        errorEl.textContent = "Please enter a valid email address.";
1567        errorEl.classList.add("show");
1568        emailInput.classList.add("error");
1569        emailInput.focus();
1570        return;
1571      }
1572
1573      if (email.length > 320) {
1574        errorEl.textContent = "Email address is too long.";
1575        errorEl.classList.add("show");
1576        emailInput.classList.add("error");
1577        emailInput.focus();
1578        return;
1579      }
1580
1581      btn.classList.add("loading");
1582      btn.disabled = true;
1583
1584      try {
1585        await fetch(\`\${apiUrl}/forgot-password\`, {
1586          method: "POST",
1587          headers: { "Content-Type": "application/json" },
1588          body: JSON.stringify({ email })
1589        });
1590
1591        document.getElementById("forgotForm").reset();
1592        successEl.classList.add("show");
1593        btn.classList.remove("loading");
1594        btn.disabled = false;
1595
1596      } catch (err) {
1597        console.error(err);
1598        document.getElementById("forgotForm").reset();
1599        successEl.classList.add("show");
1600        btn.classList.remove("loading");
1601        btn.disabled = false;
1602      }
1603    });
1604  </script>
1605</body>
1606</html>`);
1607}
1608
1609
1/* ------------------------------------------------------------------ */
2/* HTML renderers for node routes                                      */
3/* ------------------------------------------------------------------ */
4
5import { baseStyles, backNavStyles, glassHeaderStyles, emptyStateStyles, responsiveBase } from "./baseStyles.js";
6import {
7  esc,
8  truncate,
9  formatTime,
10  formatDuration,
11  modeLabel,
12  sourceLabel,
13  actionLabel,
14  actionColorHex,
15  groupIntoChains,
16} from "./utils.js";
17
18const linkifyNodeIds = (html, token) =>
19  html.replace(
20    /Placed on node ([0-9a-f-]{36})/g,
21    (_, id) =>
22      `Placed on node <a class="node-link" href="/api/v1/root/${id}${token ? `?token=${token}&html` : "?html"}">${id}</a>`,
23  );
24
25const formatContent = (str) => {
26  if (!str) return "";
27  const s = String(str).trim();
28  if (
29    (s.startsWith("{") && s.endsWith("}")) ||
30    (s.startsWith("[") && s.endsWith("]"))
31  ) {
32    try {
33      const parsed = JSON.parse(s);
34      const pretty = JSON.stringify(parsed, null, 2);
35      return `<span class="chain-json">${esc(pretty)}</span>`;
36    } catch (_) {}
37  }
38  return esc(s);
39};
40
41const renderTreeContext = (tc, tokenQS) => {
42  if (!tc) return "";
43  const parts = [];
44  const tcNodeId = tc.targetNodeId?._id || tc.targetNodeId;
45  const tcNodeName = tc.targetNodeId?.name || tc.targetNodeName;
46  if (tcNodeId && tcNodeName && typeof tcNodeId === "string") {
47    parts.push(
48      `<a href="/api/v1/node/${tcNodeId}${tokenQS}" class="tree-target-link">${esc(tcNodeName)}</a>`,
49    );
50  } else if (tcNodeName) {
51    parts.push(`<span class="tree-target-name">${esc(tcNodeName)}</span>`);
52  } else if (tc.targetPath) {
53    const pathParts = tc.targetPath.split(" / ");
54    const last = pathParts[pathParts.length - 1];
55    parts.push(`<span class="tree-target-name">${esc(last)}</span>`);
56  }
57  if (tc.planStepIndex != null && tc.planTotalSteps != null) {
58    parts.push(
59      `<span class="badge badge-step">${tc.planStepIndex}/${tc.planTotalSteps}</span>`,
60    );
61  }
62  if (tc.stepResult) {
63    const resultClasses = {
64      success: "badge-done",
65      failed: "badge-stopped",
66      skipped: "badge-skipped",
67      pending: "badge-pending",
68    };
69    const resultIcons = {
70      success: "done",
71      failed: "failed",
72      skipped: "skip",
73      pending: "...",
74    };
75    parts.push(
76      `<span class="badge ${resultClasses[tc.stepResult] || "badge-pending"}">${resultIcons[tc.stepResult] || ""} ${tc.stepResult}</span>`,
77    );
78  }
79  if (parts.length === 0) return "";
80  return `<div class="tree-context-bar">${parts.join("")}</div>`;
81};
82
83const renderDirective = (tc) => {
84  if (!tc?.directive) return "";
85  return `<div class="tree-directive">${esc(tc.directive)}</div>`;
86};
87
88const getTargetName = (tc) => {
89  if (!tc) return null;
90  return tc.targetNodeId?.name || tc.targetNodeName || null;
91};
92
93const renderModelBadge = (chat) => {
94  const connName = chat.llmProvider?.connectionId?.name;
95  const model = connName || chat.llmProvider?.model;
96  if (!model) return "";
97  return `<span class="chain-model">${esc(model)}</span>`;
98};
99
100const groupStepsIntoPhases = (steps) => {
101  const phases = [];
102  let currentPlan = null;
103  for (const step of steps) {
104    const mode = step.aiContext?.path || "";
105    if (mode === "translator") {
106      currentPlan = null;
107      phases.push({ type: "translate", step });
108    } else if (mode.startsWith("tree:orchestrator:plan:")) {
109      currentPlan = { type: "plan", marker: step, substeps: [] };
110      phases.push(currentPlan);
111    } else if (mode === "tree:respond") {
112      currentPlan = null;
113      phases.push({ type: "respond", step });
114    } else if (currentPlan) {
115      currentPlan.substeps.push(step);
116    } else {
117      phases.push({ type: "step", step });
118    }
119  }
120  return phases;
121};
122
123const renderSubstep = (chat, tokenQS) => {
124  const duration = formatDuration(
125    chat.startMessage?.time,
126    chat.endMessage?.time,
127  );
128  const stopped = chat.endMessage?.stopped;
129  const tc = chat.treeContext;
130  const dotClass = stopped
131    ? "chain-dot-stopped"
132    : tc?.stepResult === "failed"
133      ? "chain-dot-stopped"
134      : tc?.stepResult === "skipped"
135        ? "chain-dot-skipped"
136        : chat.endMessage?.time
137          ? "chain-dot-done"
138          : "chain-dot-pending";
139  const targetName = getTargetName(tc);
140  const inputFull = formatContent(chat.startMessage?.content);
141  const outputFull = formatContent(chat.endMessage?.content);
142
143  return `
144      <details class="chain-substep">
145        <summary class="chain-substep-summary">
146          <span class="chain-dot ${dotClass}"></span>
147          <span class="chain-step-mode">${modeLabel(chat.aiContext?.path)}</span>
148          ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
149          ${tc?.stepResult === "failed" ? `<span class="chain-step-failed">FAILED</span>` : ""}
150          ${tc?.resultDetail && tc.stepResult === "failed" ? `<span class="chain-step-fail-reason">${truncate(tc.resultDetail, 60)}</span>` : ""}
151          ${renderModelBadge(chat)}
152          ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
153        </summary>
154        <div class="chain-step-body">
155          ${renderTreeContext(tc, tokenQS)}
156          ${renderDirective(tc)}
157          <div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>
158          ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
159        </div>
160      </details>`;
161};
162
163const renderPhases = (steps, tokenQS) => {
164  const phases = groupStepsIntoPhases(steps);
165  if (phases.length === 0) return "";
166
167  const phaseHtml = phases
168    .map((phase) => {
169      if (phase.type === "translate") {
170        const s = phase.step;
171        const tc = s.treeContext;
172        const duration = formatDuration(
173          s.startMessage?.time,
174          s.endMessage?.time,
175        );
176        const outputFull = formatContent(s.endMessage?.content);
177        return `
178          <details class="chain-phase chain-phase-translate">
179            <summary class="chain-phase-summary">
180              <span class="chain-phase-icon">T</span>
181              <span class="chain-phase-label">Translator</span>
182              ${tc?.planTotalSteps ? `<span class="chain-step-counter">${tc.planTotalSteps}-step plan</span>` : ""}
183              ${tc?.directive ? `<span class="chain-plan-summary-text">${truncate(tc.directive, 80)}</span>` : ""}
184              ${renderModelBadge(s)}
185              ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
186            </summary>
187            ${outputFull ? `<div class="chain-step-body"><div class="chain-step-output"><span class="chain-io-label chain-io-out">PLAN</span>${outputFull}</div></div>` : ""}
188          </details>`;
189      }
190
191      if (phase.type === "plan") {
192        const m = phase.marker;
193        const tc = m.treeContext;
194        const targetName = getTargetName(tc);
195        const hasSubsteps = phase.substeps.length > 0;
196        const counts = { success: 0, failed: 0, skipped: 0 };
197        for (const sub of phase.substeps) {
198          const r = sub.treeContext?.stepResult;
199          if (r && counts[r] !== undefined) counts[r]++;
200        }
201        const countBadges = [
202          counts.success > 0
203            ? `<span class="badge badge-done">${counts.success} done</span>`
204            : "",
205          counts.failed > 0
206            ? `<span class="badge badge-stopped">${counts.failed} failed</span>`
207            : "",
208          counts.skipped > 0
209            ? `<span class="badge badge-skipped">${counts.skipped} skipped</span>`
210            : "",
211        ]
212          .filter(Boolean)
213          .join("");
214
215        const directiveText = tc?.directive || "";
216        const inputFull = directiveText
217          ? esc(directiveText)
218          : formatContent(m.startMessage?.content);
219
220        return `
221          <div class="chain-phase chain-phase-plan">
222            <div class="chain-phase-header">
223              <span class="chain-phase-icon">P</span>
224              <span class="chain-phase-label">${modeLabel(m.aiContext?.path)}</span>
225              ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
226              ${tc?.planStepIndex != null && tc?.planTotalSteps != null ? `<span class="chain-step-counter">Step ${tc.planStepIndex} of ${tc.planTotalSteps}</span>` : ""}
227              ${countBadges}
228              ${renderModelBadge(m)}
229            </div>
230            <div class="chain-plan-directive">${inputFull}</div>
231            ${hasSubsteps ? `<div class="chain-substeps">${phase.substeps.map((s) => renderSubstep(s, tokenQS)).join("")}</div>` : ""}
232          </div>`;
233      }
234
235      if (phase.type === "respond") {
236        const s = phase.step;
237        const tc = s.treeContext;
238        const duration = formatDuration(
239          s.startMessage?.time,
240          s.endMessage?.time,
241        );
242        const inputFull = formatContent(s.startMessage?.content);
243        const outputFull = formatContent(s.endMessage?.content);
244        return `
245          <details class="chain-phase chain-phase-respond">
246            <summary class="chain-phase-summary">
247              <span class="chain-phase-icon">R</span>
248              <span class="chain-phase-label">${modeLabel(s.aiContext?.path)}</span>
249              ${renderModelBadge(s)}
250              ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
251            </summary>
252            <div class="chain-step-body">
253              ${renderTreeContext(tc, tokenQS)}
254              ${inputFull ? `<div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>` : ""}
255              ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
256            </div>
257          </details>`;
258      }
259
260      return renderSubstep(phase.step, tokenQS);
261    })
262    .join("");
263
264  const summaryParts = phases
265    .map((p) => {
266      if (p.type === "translate") {
267        const tc = p.step.treeContext;
268        return tc?.planTotalSteps ? `T ${tc.planTotalSteps}-step` : "T";
269      }
270      if (p.type === "plan") {
271        const tc = p.marker.treeContext;
272        const targetName = getTargetName(tc);
273        const sub = p.substeps
274          .map((s) => {
275            const stc = s.treeContext;
276            const icon =
277              stc?.stepResult === "failed"
278                ? "X "
279                : stc?.stepResult === "skipped"
280                  ? "- "
281                  : stc?.stepResult === "success"
282                    ? "v "
283                    : "";
284            return `${icon}${modeLabel(s.aiContext?.path)}`;
285          })
286          .join(" > ");
287        const label = targetName ? `P ${esc(targetName)}` : "P";
288        return sub ? `${label}: ${sub}` : label;
289      }
290      if (p.type === "respond") return "R";
291      return modeLabel(p.step?.aiContext?.path);
292    })
293    .join("  ");
294
295  return `
296      <details class="chain-dropdown">
297        <summary class="chain-summary">
298          ${phases.length} phase${phases.length !== 1 ? "s" : ""}
299          <span class="chain-modes">${summaryParts}</span>
300        </summary>
301        <div class="chain-phases">${phaseHtml}</div>
302      </details>`;
303};
304
305const renderChain = (chain, tokenQS, token) => {
306  const chat = chain.root;
307  const steps = chain.steps;
308  const duration = formatDuration(
309    chat.startMessage?.time,
310    chat.endMessage?.time,
311  );
312  const stopped = chat.endMessage?.stopped;
313  const contribs = chat.contributions || [];
314  const hasContribs = contribs.length > 0;
315  const hasSteps = steps.length > 0;
316  const modelName =
317    chat.llmProvider?.connectionId?.name ||
318    chat.llmProvider?.model ||
319    "unknown";
320
321  const tc = chat.treeContext;
322  const treeNodeId = tc?.targetNodeId?._id || tc?.targetNodeId;
323  const treeNodeName = tc?.targetNodeId?.name || tc?.targetNodeName;
324  const treeLink =
325    treeNodeId && treeNodeName
326      ? `<a href="/api/v1/node/${treeNodeId}${tokenQS}" class="tree-target-link">${esc(treeNodeName)}</a>`
327      : treeNodeName
328        ? `<span class="tree-target-name">${esc(treeNodeName)}</span>`
329        : "";
330
331  const statusBadge = stopped
332    ? `<span class="badge badge-stopped">Stopped</span>`
333    : chat.endMessage?.time
334      ? `<span class="badge badge-done">Done</span>`
335      : `<span class="badge badge-pending">Pending</span>`;
336
337  const contribRows = contribs
338    .map((c) => {
339      const nId = c.nodeId?._id || c.nodeId;
340      const nName = c.nodeId?.name || nId || "--";
341      const nodeRef = nId
342        ? `<a href="/api/v1/node/${nId}${tokenQS}">${esc(nName)}</a>`
343        : `<span style="opacity:0.5">--</span>`;
344      const aiBadge = c.wasAi
345        ? `<span class="mini-badge mini-ai">AI</span>`
346        : "";
347      const cEnergyBadge =
348        c.energyUsed > 0
349          ? `<span class="mini-badge mini-energy">E${c.energyUsed}</span>`
350          : "";
351      const understandingLink =
352        c.action === "understanding" &&
353        c.understandingMeta?.understandingRunId &&
354        c.understandingMeta?.rootNodeId
355          ? ` <a class="understanding-link" href="/api/v1/root/${c.understandingMeta.rootNodeId}/understandings/run/${c.understandingMeta.understandingRunId}${tokenQS}">View run</a>`
356          : "";
357      const color = actionColorHex(c.action);
358      return `
359        <tr class="contrib-row">
360          <td><span class="action-dot" style="background:${color}"></span>${esc(actionLabel(c.action))}${understandingLink}</td>
361          <td>${nodeRef}</td>
362          <td>${aiBadge}${cEnergyBadge}</td>
363          <td class="contrib-time">${formatTime(c.date)}</td>
364        </tr>`;
365    })
366    .join("");
367
368  const stepsHtml = hasSteps ? renderPhases(steps, tokenQS) : "";
369
370  return `
371      <li class="note-card">
372        <div class="chat-header">
373          <div class="chat-header-left">
374            <span class="chat-mode">${modeLabel(chat.aiContext?.path)}</span>
375            ${treeLink}
376            <span class="chat-model">${esc(modelName)}</span>
377          </div>
378          <div class="chat-badges">
379            ${statusBadge}
380            ${duration ? `<span class="badge badge-duration">${duration}</span>` : ""}
381            <span class="badge badge-source">${sourceLabel(chat.startMessage?.source)}</span>
382          </div>
383        </div>
384
385        <div class="note-content">
386          <div class="chat-message chat-user">
387            <span class="msg-label">${chat.userId?._id ? `<a href="/api/v1/user/${chat.userId._id}${tokenQS}" class="msg-user-link">${esc(chat.userId.username || "User")}</a>` : esc("User")}</span>
388            <div class="msg-text msg-clamp">${esc(chat.startMessage?.content || "")}</div>
389            ${(chat.startMessage?.content || "").length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
390          </div>
391          ${
392            chat.endMessage?.content
393              ? `
394          <div class="chat-message chat-ai">
395            <span class="msg-label">AI</span>
396            <div class="msg-text msg-clamp">${linkifyNodeIds(esc(chat.endMessage.content), token)}</div>
397            ${chat.endMessage.content.length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
398          </div>`
399              : ""
400          }
401        </div>
402
403        ${stepsHtml}
404
405        ${
406          hasContribs
407            ? `
408        <details class="contrib-dropdown">
409          <summary class="contrib-summary">
410            ${contribs.length} contribution${contribs.length !== 1 ? "s" : ""} during this chat
411          </summary>
412          <div class="contrib-table-wrap">
413            <table class="contrib-table">
414              <thead><tr><th>Action</th><th>Node</th><th></th><th>Time</th></tr></thead>
415              <tbody>${contribRows}</tbody>
416            </table>
417          </div>
418        </details>`
419            : ""
420        }
421
422        <div class="note-meta">
423          ${formatTime(chat.startMessage?.time)}
424          <span class="meta-separator">|</span>
425          <code class="contribution-id">${esc(chat._id)}</code>
426        </div>
427      </li>`;
428};
429
430/* ================================================================== */
431/* 1. renderNodeChats                                                  */
432/* ================================================================== */
433
434export function renderNodeChats({
435  nodeId,
436  nodeName,
437  nodePath,
438  sessions,
439  allChats,
440  token,
441  tokenQS,
442}) {
443  const sessionGroups = sessions;
444
445  const renderedSections = sessionGroups
446    .map((group) => {
447      const chatCount = group.chatCount;
448      const sessionTime = formatTime(group.startTime);
449      const shortId = group.sessionId.slice(0, 8);
450      const chains = groupIntoChains(group.chats);
451      const chatCards = chains.map((c) => renderChain(c, tokenQS, token)).join("");
452
453      return `
454      <div class="session-group">
455        <div class="session-pane">
456          <div class="session-pane-header">
457            <div class="session-header-left">
458              <span class="session-id">${esc(shortId)}</span>
459              <span class="session-info">${chatCount} chat${chatCount !== 1 ? "s" : ""}</span>
460            </div>
461            <span class="session-time">${sessionTime}</span>
462          </div>
463          <ul class="notes-list">${chatCards}</ul>
464        </div>
465      </div>`;
466    })
467    .join("");
468
469  return `
470<!DOCTYPE html>
471<html lang="en">
472<head>
473  <meta charset="UTF-8">
474  <meta name="viewport" content="width=device-width, initial-scale=1.0">
475  <meta name="theme-color" content="#667eea">
476  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
477  <title>${esc(nodeName)} -- AI Chats</title>
478  <style>
479${baseStyles}
480${backNavStyles}
481${glassHeaderStyles}
482.header-path { font-size: 12px; color: rgba(255,255,255,0.6); margin-top: 4px; font-family: 'SF Mono', 'Fira Code', monospace; }
483
484.session-group { margin-bottom: 20px; animation: fadeInUp 0.6s ease-out both; }
485.session-pane {
486  background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
487  border-radius: 20px; overflow: hidden; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
488}
489.session-pane-header {
490  display: flex; align-items: center; justify-content: space-between; padding: 14px 20px;
491  background: rgba(255,255,255,0.08); border-bottom: 1px solid rgba(255,255,255,0.1);
492}
493.session-header-left { display: flex; align-items: center; gap: 10px; }
494.session-id {
495  font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; font-weight: 600;
496  color: rgba(255,255,255,0.55); background: rgba(255,255,255,0.1); padding: 3px 8px;
497  border-radius: 6px; border: 1px solid rgba(255,255,255,0.12);
498}
499.session-info { font-size: 13px; color: rgba(255,255,255,0.7); font-weight: 600; }
500.session-time { font-size: 12px; color: rgba(255,255,255,0.4); font-weight: 500; }
501
502.notes-list { list-style: none; display: flex; flex-direction: column; gap: 16px; padding: 16px; }
503.note-card {
504  --card-rgb: 115, 111, 230; position: relative;
505  background: rgba(var(--card-rgb), var(--glass-alpha)); backdrop-filter: blur(22px) saturate(140%);
506  -webkit-backdrop-filter: blur(22px) saturate(140%); border-radius: 16px; padding: 24px;
507  box-shadow: 0 8px 24px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.25);
508  border: 1px solid rgba(255,255,255,0.28); transition: all 0.3s cubic-bezier(0.4,0,0.2,1);
509  color: white; overflow: hidden; opacity: 0; transform: translateY(30px);
510}
511.note-card.visible { animation: fadeInUp 0.6s cubic-bezier(0.4,0,0.2,1) forwards; }
512.note-card::before {
513  content: ""; position: absolute; inset: -40%;
514  background: radial-gradient(120% 60% at 0% 0%, rgba(255,255,255,0.35), transparent 60%);
515  opacity: 0; transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22,1,0.36,1); pointer-events: none;
516}
517.note-card:hover { background: rgba(var(--card-rgb), var(--glass-alpha-hover)); transform: translateY(-2px); box-shadow: 0 12px 32px rgba(0,0,0,0.18); }
518.note-card:hover::before { opacity: 1; transform: translateX(30%) translateY(10%); }
519
520.chat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
521.chat-header-left { display: flex; align-items: center; gap: 8px; }
522.chat-mode {
523  font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.1);
524  padding: 3px 10px; border-radius: 980px; border: 1px solid rgba(255,255,255,0.15);
525}
526.chat-model {
527  font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.45);
528  font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden;
529  text-overflow: ellipsis; white-space: nowrap; max-width: 200px;
530}
531.chat-badges { display: flex; flex-wrap: wrap; gap: 6px; }
532
533.note-content { margin-bottom: 16px; display: flex; flex-direction: column; gap: 14px; }
534.chat-message { display: flex; gap: 10px; align-items: flex-start; }
535.msg-label {
536  flex-shrink: 0; font-weight: 700; font-size: 10px; text-transform: uppercase;
537  letter-spacing: 0.5px; padding: 3px 10px; border-radius: 980px; margin-top: 3px;
538}
539.chat-user .msg-label { background: rgba(255,255,255,0.2); color: white; }
540.chat-ai .msg-label   { background: rgba(100,220,255,0.25); color: white; }
541.msg-user-link { color: inherit; text-decoration: none; }
542.msg-user-link:hover { text-decoration: underline; }
543.msg-text { color: rgba(255,255,255,0.95); word-wrap: break-word; min-width: 0; font-size: 15px; line-height: 1.65; font-weight: 400; }
544.msg-clamp {
545  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical;
546  overflow: hidden; max-height: calc(1.65em * 4); transition: max-height 0.3s ease;
547}
548.msg-clamp.expanded { -webkit-line-clamp: unset; max-height: none; overflow: visible; }
549.expand-btn {
550  background: none; border: none; color: rgba(100,220,255,0.9); cursor: pointer;
551  font-size: 12px; font-weight: 600; padding: 2px 0; margin-top: 2px; transition: color 0.2s;
552}
553.expand-btn:hover { color: rgba(100,220,255,1); text-decoration: underline; }
554.node-link { color: #7effc0; text-decoration: none; background: rgba(50,220,120,0.15); padding: 1px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; }
555.node-link:hover { background: rgba(50,220,120,0.3); }
556.understanding-link {
557  color: rgba(100,100,210,0.9); text-decoration: none; font-size: 11px; font-weight: 500;
558  margin-left: 4px; transition: color 0.2s;
559}
560.understanding-link:hover { color: rgba(130,130,255,1); text-decoration: underline; }
561.chat-user .msg-text { font-weight: 500; }
562
563.chain-dropdown { margin-bottom: 12px; }
564.chain-summary {
565  cursor: pointer; font-size: 13px; font-weight: 600;
566  color: rgba(255,255,255,0.85); padding: 8px 14px;
567  background: rgba(255,255,255,0.1); border-radius: 10px;
568  border: 1px solid rgba(255,255,255,0.15);
569  transition: all 0.2s; list-style: none;
570  display: flex; align-items: center; gap: 8px;
571}
572.chain-summary::-webkit-details-marker { display: none; }
573.chain-summary::before { content: ">"; font-size: 10px; transition: transform 0.15s; display: inline-block; }
574details[open] > .chain-summary::before { transform: rotate(90deg); }
575.chain-summary:hover { background: rgba(255,255,255,0.18); }
576.chain-modes { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
577.chain-phases { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
578
579.chain-phase { border-radius: 10px; overflow: hidden; }
580.chain-phase-header {
581  display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 12px; font-weight: 600; flex-wrap: wrap;
582}
583.chain-phase-icon { font-size: 14px; }
584.chain-phase-label { color: rgba(255,255,255,0.85); }
585.chain-phase-translate { background: rgba(100,100,220,0.12); border: 1px solid rgba(100,100,220,0.2); }
586.chain-phase-plan { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); }
587.chain-phase-respond { background: rgba(72,187,120,0.1); border: 1px solid rgba(72,187,120,0.2); }
588.chain-plan-directive { padding: 6px 12px 10px; font-size: 12px; color: rgba(255,255,255,0.6); line-height: 1.5; white-space: pre-wrap; }
589
590.chain-phase-summary, .chain-substep-summary {
591  cursor: pointer; list-style: none;
592  display: flex; align-items: center; gap: 8px;
593  padding: 8px 12px; font-size: 12px; font-weight: 600; flex-wrap: wrap;
594}
595.chain-phase-summary::-webkit-details-marker,
596.chain-substep-summary::-webkit-details-marker { display: none; }
597.chain-phase-summary::before,
598.chain-substep-summary::before {
599  content: ">"; font-size: 8px; color: rgba(255,255,255,0.35);
600  transition: transform 0.15s; display: inline-block;
601}
602details[open] > .chain-phase-summary::before,
603details[open] > .chain-substep-summary::before { transform: rotate(90deg); }
604.chain-phase-summary:hover, .chain-substep-summary:hover { background: rgba(255,255,255,0.05); }
605
606.chain-substeps { display: flex; flex-direction: column; gap: 2px; padding: 0 8px 8px; }
607.chain-substep { border-radius: 6px; background: rgba(255,255,255,0.04); }
608.chain-substep:hover { background: rgba(255,255,255,0.07); }
609
610.chain-dot {
611  display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
612  border: 2px solid rgba(255,255,255,0.3);
613}
614.chain-dot-done    { background: rgba(72,187,120,0.8); border-color: rgba(72,187,120,0.4); }
615.chain-dot-stopped { background: rgba(200,80,80,0.8); border-color: rgba(200,80,80,0.4); }
616.chain-dot-pending { background: rgba(255,200,50,0.8); border-color: rgba(255,200,50,0.4); }
617.chain-dot-skipped { background: rgba(160,160,160,0.6); border-color: rgba(160,160,160,0.3); }
618
619.chain-step-mode {
620  font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.8);
621  background: rgba(255,255,255,0.12); padding: 2px 8px; border-radius: 6px;
622}
623.chain-step-duration { font-size: 10px; color: rgba(255,255,255,0.45); }
624.chain-model {
625  font-size: 10px; font-family: 'SF Mono', 'Fira Code', monospace;
626  color: rgba(255,255,255,0.4); margin-left: auto; white-space: nowrap;
627  overflow: hidden; text-overflow: ellipsis; max-width: 150px;
628}
629
630.chain-step-body { padding: 10px 12px; border-top: 1px solid rgba(255,255,255,0.08); }
631.chain-io-label {
632  display: inline-block; font-size: 9px; font-weight: 700; letter-spacing: 0.5px;
633  padding: 1px 6px; border-radius: 4px; margin-right: 8px; vertical-align: middle;
634}
635.chain-io-in  { background: rgba(100,220,255,0.2); color: rgba(100,220,255,0.9); }
636.chain-io-out { background: rgba(72,187,120,0.2); color: rgba(72,187,120,0.9); }
637
638.chain-step-input {
639  font-size: 12px; color: rgba(255,255,255,0.8); line-height: 1.6;
640  word-break: break-word; white-space: pre-wrap;
641  font-family: 'SF Mono', 'Fira Code', monospace;
642}
643.chain-step-output {
644  font-size: 12px; color: rgba(255,255,255,0.65); line-height: 1.6;
645  margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1);
646  word-break: break-word; white-space: pre-wrap;
647  font-family: 'SF Mono', 'Fira Code', monospace;
648}
649.chain-json { color: rgba(255,255,255,0.8); }
650
651.tree-context-bar {
652  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
653  padding: 6px 12px; margin-bottom: 6px;
654  background: rgba(255,255,255,0.06); border-radius: 6px; font-size: 12px;
655}
656.tree-target-link {
657  color: rgba(100,220,255,0.95); text-decoration: none;
658  border-bottom: 1px solid rgba(100,220,255,0.3);
659  font-weight: 600; font-size: 12px; transition: all 0.2s;
660}
661.tree-target-link:hover {
662  border-bottom-color: rgba(100,220,255,0.8);
663  text-shadow: 0 0 8px rgba(100,220,255,0.5);
664}
665.tree-target-name { color: rgba(255,255,255,0.8); font-weight: 600; font-size: 12px; }
666.tree-directive {
667  padding: 4px 12px 8px; font-size: 11px; color: rgba(255,255,255,0.55);
668  line-height: 1.5; font-style: italic;
669  border-left: 2px solid rgba(255,255,255,0.15); margin: 0 12px 8px;
670}
671.chain-step-counter {
672  font-size: 10px; color: rgba(255,255,255,0.5); font-weight: 500;
673  background: rgba(255,255,255,0.08); padding: 2px 8px; border-radius: 4px;
674}
675.chain-step-target {
676  font-size: 10px; color: rgba(100,220,255,0.7); font-weight: 500;
677  max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
678}
679.chain-step-failed {
680  font-size: 9px; font-weight: 700; color: rgba(200,80,80,0.9);
681  background: rgba(200,80,80,0.15); padding: 1px 6px; border-radius: 4px; letter-spacing: 0.5px;
682}
683.chain-step-fail-reason {
684  font-size: 10px; color: rgba(200,80,80,0.7); font-weight: 400;
685  font-style: italic; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
686}
687.badge-step {
688  background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7);
689  font-family: 'SF Mono', 'Fira Code', monospace; font-size: 10px;
690}
691.badge-skipped { background: rgba(160,160,160,0.25); color: rgba(255,255,255,0.7); }
692.chain-plan-summary-text {
693  font-size: 11px; color: rgba(255,255,255,0.45); font-weight: 400;
694  font-style: italic; overflow: hidden; text-overflow: ellipsis;
695  white-space: nowrap; max-width: 300px;
696}
697
698.contrib-dropdown { margin-bottom: 12px; }
699.contrib-summary {
700  cursor: pointer; font-size: 13px; font-weight: 600;
701  color: rgba(255,255,255,0.85); padding: 8px 14px;
702  background: rgba(255,255,255,0.1); border-radius: 10px;
703  border: 1px solid rgba(255,255,255,0.15);
704  transition: all 0.2s; list-style: none;
705  display: flex; align-items: center; gap: 6px;
706}
707.contrib-summary::-webkit-details-marker { display: none; }
708.contrib-summary::before { content: ">"; font-size: 10px; transition: transform 0.2s; display: inline-block; }
709details[open] .contrib-summary::before { transform: rotate(90deg); }
710.contrib-summary:hover { background: rgba(255,255,255,0.18); }
711.contrib-table-wrap { margin-top: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
712.contrib-table { width: 100%; border-collapse: collapse; font-size: 13px; }
713.contrib-table thead th {
714  text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase;
715  letter-spacing: 0.5px; color: rgba(255,255,255,0.55); padding: 6px 10px;
716  border-bottom: 1px solid rgba(255,255,255,0.15);
717}
718.contrib-row td {
719  padding: 7px 10px; border-bottom: 1px solid rgba(255,255,255,0.08);
720  color: rgba(255,255,255,0.88); vertical-align: middle; white-space: nowrap;
721}
722.contrib-row:last-child td { border-bottom: none; }
723.contrib-row a { color: white; text-decoration: none; border-bottom: 1px solid rgba(255,255,255,0.3); transition: all 0.2s; }
724.contrib-row a:hover { border-bottom-color: white; text-shadow: 0 0 12px rgba(255,255,255,0.8); }
725.contrib-time { font-size: 11px; color: rgba(255,255,255,0.5); }
726.action-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
727
728.mini-badge {
729  display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 980px;
730  font-size: 10px; font-weight: 700; letter-spacing: 0.2px; margin-right: 3px;
731}
732.mini-ai    { background: rgba(255,200,50,0.35); color: #fff; }
733.mini-energy { background: rgba(100,220,255,0.3); color: #fff; }
734
735.badge {
736  display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 980px;
737  font-size: 11px; font-weight: 700; letter-spacing: 0.3px; border: 1px solid rgba(255,255,255,0.2);
738}
739.badge-done     { background: rgba(72,187,120,0.35); color: #fff; }
740.badge-stopped  { background: rgba(200,80,80,0.35); color: #fff; }
741.badge-pending  { background: rgba(255,200,50,0.3); color: #fff; }
742.badge-duration { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.9); }
743.badge-source   { background: rgba(100,100,210,0.3); color: #fff; }
744
745.note-meta {
746  padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.2);
747  font-size: 12px; color: rgba(255,255,255,0.85); line-height: 1.8;
748  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
749}
750.meta-separator { color: rgba(255,255,255,0.5); }
751.contribution-id {
752  background: rgba(255,255,255,0.12); padding: 2px 6px; border-radius: 4px;
753  font-size: 11px; font-family: 'SF Mono', 'Fira Code', monospace;
754  color: rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.1);
755}
756
757${emptyStateStyles}
758
759${responsiveBase}
760@media (max-width: 640px) {
761  .chat-header { flex-direction: column; align-items: flex-start; }
762  .contrib-row td { font-size: 12px; padding: 5px 6px; }
763  .session-pane-header { flex-direction: column; align-items: flex-start; gap: 6px; padding: 12px 16px; }
764  .notes-list { padding: 12px; gap: 12px; }
765  .chat-model { max-width: 140px; }
766  .msg-text { font-size: 14px; }
767  .chain-plan-directive { font-size: 11px; }
768  .chain-step-target { max-width: 100px; }
769  .chain-plan-summary-text { max-width: 160px; }
770  .chain-step-fail-reason { max-width: 120px; }
771}
772  </style>
773</head>
774<body>
775  <div class="container">
776    <div class="back-nav">
777      <a href="/api/v1/node/${nodeId}${tokenQS}" class="back-link">&lt;- Back to Node</a>
778    </div>
779
780    <div class="header">
781      <h1>
782        AI Chats for
783        <a href="/api/v1/node/${nodeId}${tokenQS}">${esc(nodeName)}</a>
784        ${allChats.length > 0 ? `<span class="message-count">${allChats.length}</span>` : ""}
785      </h1>
786      <div class="header-subtitle">
787        AI sessions that targeted or modified this node.
788      </div>
789      <div class="header-path">${esc(nodePath)}</div>
790    </div>
791
792    ${
793      sessionGroups.length
794        ? renderedSections
795        : `
796    <div class="empty-state">
797      <div class="empty-state-icon">AI</div>
798      <div class="empty-state-text">No AI chats yet</div>
799      <div class="empty-state-subtext">AI conversations involving this node will appear here</div>
800    </div>`
801    }
802  </div>
803
804  <script>
805    var observer = new IntersectionObserver(function(entries) {
806      entries.forEach(function(entry, index) {
807        if (entry.isIntersecting) {
808          setTimeout(function() { entry.target.classList.add('visible'); }, index * 50);
809          observer.unobserve(entry.target);
810        }
811      });
812    }, { root: null, rootMargin: '50px', threshold: 0.1 });
813    document.querySelectorAll('.note-card').forEach(function(card) { observer.observe(card); });
814
815    function toggleExpand(btn) {
816      var text = btn.previousElementSibling;
817      if (!text) return;
818      var expanded = text.classList.toggle('expanded');
819      btn.textContent = expanded ? 'Show less' : 'Show more';
820    }
821  </script>
822</body>
823</html>
824`;
825}
826
827/* ================================================================== */
828/* 2. renderNodeDetail                                                 */
829/* ================================================================== */
830
831export function renderNodeDetail({ node, nodeId, qs, parentName, rootUrl, isPublicAccess }) {
832  const _nodeScripts = (node.metadata instanceof Map ? node.metadata?.get("scripts") : node.metadata?.scripts)?.list || [];
833  return `
834<!DOCTYPE html>
835<html lang="en">
836<head>
837  <meta charset="UTF-8">
838  <meta name="viewport" content="width=device-width, initial-scale=1.0">
839  <meta name="theme-color" content="#667eea">
840  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
841  <title>${node.name} — Node</title>
842  <style>
843${baseStyles}
844
845/* =========================================================
846   UNIFIED GLASS BUTTON SYSTEM
847   ========================================================= */
848
849.glass-btn,
850button,
851.action-button,
852.back-link,
853.versions-list a,
854.children-list a,
855button[type="submit"],
856.primary-button,
857.warning-button,
858.danger-button {
859  position: relative;
860  overflow: hidden;
861
862  padding: 10px 20px;
863  border-radius: 980px;
864
865  display: inline-flex;
866  align-items: center;
867  justify-content: center;
868  white-space: nowrap;
869
870  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
871  backdrop-filter: blur(22px) saturate(140%);
872  -webkit-backdrop-filter: blur(22px) saturate(140%);
873
874  color: white;
875  text-decoration: none;
876  font-family: inherit;
877
878  font-size: 15px;
879  font-weight: 600;
880  letter-spacing: -0.2px;
881
882  border: 1px solid rgba(255, 255, 255, 0.28);
883
884  box-shadow:
885    0 8px 24px rgba(0, 0, 0, 0.12),
886    inset 0 1px 0 rgba(255, 255, 255, 0.25);
887
888  cursor: pointer;
889
890  transition:
891    background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
892    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
893    box-shadow 0.3s ease;
894}
895
896/* Liquid light layer */
897.glass-btn::before,
898button::before,
899.action-button::before,
900.back-link::before,
901.versions-list a::before,
902.children-list a::before,
903button[type="submit"]::before,
904.primary-button::before,
905.warning-button::before,
906.danger-button::before {
907  content: "";
908  position: absolute;
909  inset: -40%;
910
911  background:
912    radial-gradient(
913      120% 60% at 0% 0%,
914      rgba(255, 255, 255, 0.35),
915      transparent 60%
916    ),
917    linear-gradient(
918      120deg,
919      transparent 30%,
920      rgba(255, 255, 255, 0.25),
921      transparent 70%
922    );
923
924  opacity: 0;
925  transform: translateX(-30%) translateY(-10%);
926  transition:
927    opacity 0.35s ease,
928    transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
929
930  pointer-events: none;
931}
932
933/* Hover motion */
934.glass-btn:hover,
935button:hover,
936.action-button:hover,
937.back-link:hover,
938.versions-list a:hover,
939.children-list a:hover,
940button[type="submit"]:hover,
941.primary-button:hover,
942.warning-button:hover,
943.danger-button:hover {
944  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
945  transform: translateY(-2px);
946}
947
948.glass-btn:hover::before,
949button:hover::before,
950.action-button:hover::before,
951.back-link:hover::before,
952.versions-list a:hover::before,
953.children-list a:hover::before,
954button[type="submit"]:hover::before,
955.primary-button:hover::before,
956.warning-button:hover::before,
957.danger-button:hover::before {
958  opacity: 1;
959  transform: translateX(30%) translateY(10%);
960}
961
962/* Active press */
963.glass-btn:active,
964button:active,
965.primary-button:active,
966.warning-button:active,
967.danger-button:active {
968  background: rgba(var(--glass-water-rgb), 0.45);
969  transform: translateY(0);
970}
971
972/* Emphasis variants */
973.primary-button {
974  --glass-water-rgb: 72, 187, 178;
975  --glass-alpha: 0.34;
976  --glass-alpha-hover: 0.46;
977  font-weight: 600;
978}
979
980.warning-button {
981  --glass-water-rgb: 100, 116, 139;
982  font-weight: 600;
983}
984
985.danger-button {
986  --glass-water-rgb: 198, 40, 40;
987  font-weight: 600;
988}
989
990/* =========================================================
991   CONTENT CARDS - UPDATED TO MATCH ROOT ROUTE
992   ========================================================= */
993
994.header,
995.hierarchy-section,
996.versions-section,
997.scripts-section,
998.actions-section {
999  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1000  backdrop-filter: blur(22px) saturate(140%);
1001  -webkit-backdrop-filter: blur(22px) saturate(140%);
1002  border-radius: 16px;
1003  padding: 28px;
1004  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1005    inset 0 1px 0 rgba(255, 255, 255, 0.25);
1006  border: 1px solid rgba(255, 255, 255, 0.28);
1007  margin-bottom: 24px;
1008  animation: fadeInUp 0.6s ease-out;
1009  animation-fill-mode: both;
1010  position: relative;
1011  overflow: hidden;
1012}
1013
1014.header {
1015  animation-delay: 0.1s;
1016}
1017
1018.versions-section {
1019  animation-delay: 0.15s;
1020}
1021
1022.hierarchy-section {
1023  animation-delay: 0.2s;
1024}
1025
1026.scripts-section {
1027  animation-delay: 0.25s;
1028}
1029
1030.actions-section {
1031  animation-delay: 0.3s;
1032}
1033
1034.header::before,
1035.hierarchy-section::before,
1036.versions-section::before,
1037.scripts-section::before,
1038.actions-section::before {
1039  content: "";
1040  position: absolute;
1041  inset: 0;
1042  border-radius: inherit;
1043  background: linear-gradient(
1044    180deg,
1045    rgba(255, 255, 255, 0.18),
1046    rgba(255, 255, 255, 0.05)
1047  );
1048  pointer-events: none;
1049}
1050
1051.meta-card {
1052  background: rgba(255, 255, 255, 0.15);
1053  backdrop-filter: blur(22px) saturate(140%);
1054  -webkit-backdrop-filter: blur(22px) saturate(140%);
1055  border-radius: 12px;
1056  padding: 16px 20px;
1057  border: 1px solid rgba(255, 255, 255, 0.28);
1058  color: white;
1059}
1060
1061.header h1 {
1062  font-size: 28px;
1063  font-weight: 600;
1064  letter-spacing: -0.5px;
1065  line-height: 1.3;
1066  margin-bottom: 8px;
1067  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1068  color: white;
1069}
1070
1071.hierarchy-section h2,
1072.versions-section h2,
1073.scripts-section h2,
1074.actions-section h3 {
1075  font-size: 18px;
1076  font-weight: 600;
1077  color: white;
1078  margin-bottom: 16px;
1079  letter-spacing: -0.3px;
1080  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1081}
1082
1083.hierarchy-section h3 {
1084  font-size: 16px;
1085  font-weight: 600;
1086  color: rgba(255, 255, 255, 0.9);
1087  margin: 24px 0 12px 0;
1088}
1089
1090/* =========================================================
1091   NAV + META
1092   ========================================================= */
1093
1094.back-nav {
1095  display: flex;
1096  gap: 12px;
1097  margin-bottom: 20px;
1098  flex-wrap: wrap;
1099  animation: fadeInUp 0.5s ease-out;
1100}
1101
1102.node-id-container {
1103  display: flex;
1104  align-items: center;
1105  gap: 8px;
1106  margin-top: 12px;
1107  flex-wrap: wrap;
1108  padding: 10px 14px;
1109  background: rgba(255, 255, 255, 0.15);
1110  border-radius: 8px;
1111  border: 1px solid rgba(255, 255, 255, 0.2);
1112}
1113
1114code {
1115  background: transparent;
1116  padding: 0;
1117  border-radius: 0;
1118  font-size: 13px;
1119  font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
1120  color: white;
1121  word-break: break-all;
1122  flex: 1;
1123}
1124
1125#copyNodeIdBtn {
1126  background: rgba(255, 255, 255, 0.2);
1127  border: 1px solid rgba(255, 255, 255, 0.3);
1128  cursor: pointer;
1129  padding: 6px 10px;
1130  border-radius: 6px;
1131  opacity: 1;
1132  font-size: 16px;
1133  transition: all 0.2s;
1134  flex-shrink: 0;
1135}
1136
1137#copyNodeIdBtn:hover {
1138  background: rgba(255, 255, 255, 0.3);
1139  transform: scale(1.1);
1140}
1141
1142#copyNodeIdBtn::before {
1143  display: none;
1144}
1145
1146.meta-row {
1147  display: flex;
1148  gap: 24px;
1149  flex-wrap: wrap;
1150  padding-top: 12px;
1151  border-top: 1px solid rgba(255, 255, 255, 0.2);
1152}
1153
1154.meta-item {
1155  display: flex;
1156  flex-direction: column;
1157  gap: 4px;
1158}
1159
1160.meta-label {
1161  font-size: 12px;
1162  font-weight: 600;
1163  text-transform: uppercase;
1164  letter-spacing: 0.5px;
1165  color: rgba(255, 255, 255, 0.7);
1166}
1167
1168.meta-value {
1169  font-size: 16px;
1170  font-weight: 600;
1171  color: white;
1172}
1173
1174/* =========================================================
1175   LISTS
1176   ========================================================= */
1177
1178.versions-list {
1179  list-style: none;
1180  display: grid;
1181  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
1182  gap: 10px;
1183}
1184
1185.versions-list li {
1186  margin: 0;
1187}
1188
1189.versions-list a {
1190  display: block;
1191  padding: 14px 18px;
1192  text-align: center;
1193}
1194
1195.children-list {
1196  list-style: none;
1197  margin-bottom: 20px;
1198}
1199
1200.children-list li {
1201  margin: 0 0 8px 0;
1202}
1203
1204.children-list a {
1205  display: block;
1206  padding: 12px 16px;
1207}
1208
1209.hierarchy-section a {
1210  color: white;
1211  text-decoration: none;
1212  font-weight: 600;
1213  transition: opacity 0.2s;
1214}
1215
1216.hierarchy-section a:hover {
1217  opacity: 0.8;
1218}
1219
1220.hierarchy-section em {
1221  color: rgba(255, 255, 255, 0.7);
1222  font-style: normal;
1223}
1224
1225.hierarchy-section > p {
1226  margin-bottom: 16px;
1227}
1228
1229/* =========================================================
1230   SCRIPTS
1231   ========================================================= */
1232
1233.scripts-list {
1234  list-style: none;
1235}
1236
1237.scripts-list li {
1238  margin-bottom: 16px;
1239  padding: 16px;
1240  background: rgba(255, 255, 255, 0.1);
1241  border-radius: 10px;
1242  border: 1px solid rgba(255, 255, 255, 0.2);
1243}
1244
1245.scripts-list li:last-child {
1246  margin-bottom: 0;
1247}
1248
1249.scripts-list a {
1250  color: white;
1251  text-decoration: none;
1252  display: block;
1253}
1254
1255.scripts-list a:hover {
1256  opacity: 0.9;
1257}
1258
1259.scripts-list strong {
1260  display: block;
1261  margin-bottom: 8px;
1262  color: white;
1263  font-size: 15px;
1264}
1265
1266.scripts-list pre {
1267  background: rgba(0, 0, 0, 0.3);
1268  color: #e0e0e0;
1269  padding: 14px;
1270  border-radius: 8px;
1271  overflow-x: auto;
1272  font-size: 13px;
1273  line-height: 1.5;
1274  font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
1275  border: 1px solid rgba(255, 255, 255, 0.1);
1276}
1277
1278.scripts-list em {
1279  color: rgba(255, 255, 255, 0.6);
1280  font-style: normal;
1281}
1282
1283.scripts-section h2 a {
1284  color: white;
1285  text-decoration: none;
1286}
1287
1288.scripts-section h2 a:hover {
1289  opacity: 0.8;
1290}
1291
1292/* =========================================================
1293   FORMS
1294   ========================================================= */
1295
1296.action-form {
1297  display: flex;
1298  gap: 10px;
1299  align-items: stretch;
1300  margin-top: 12px;
1301  flex-wrap: wrap;
1302}
1303
1304.action-form input[type="text"] {
1305  flex: 1;
1306  min-width: 200px;
1307  padding: 12px 14px;
1308  font-size: 15px;
1309  border-radius: 10px;
1310  border: 2px solid rgba(255, 255, 255, 0.3);
1311  background: rgba(255, 255, 255, 0.15);
1312  color: white;
1313  font-family: inherit;
1314  font-weight: 500;
1315  transition: all 0.2s;
1316}
1317
1318.action-form input[type="text"]::placeholder {
1319  color: rgba(255, 255, 255, 0.5);
1320}
1321
1322.action-form input[type="text"]:focus {
1323  outline: none;
1324  border-color: rgba(255, 255, 255, 0.6);
1325  background: rgba(255, 255, 255, 0.25);
1326  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
1327  transform: translateY(-2px);
1328}
1329
1330/* =========================================================
1331   RESPONSIVE
1332   ========================================================= */
1333
1334${responsiveBase}
1335
1336@media (max-width: 640px) {
1337  .container {
1338    max-width: 100%;
1339  }
1340
1341  .header,
1342  .hierarchy-section,
1343  .versions-section,
1344  .scripts-section,
1345  .actions-section {
1346    padding: 20px;
1347  }
1348
1349  .meta-row {
1350    flex-direction: column;
1351    gap: 12px;
1352  }
1353
1354  .versions-list {
1355    grid-template-columns: 1fr;
1356  }
1357
1358  .action-form {
1359    flex-direction: column;
1360  }
1361
1362  .action-form input[type="text"] {
1363    width: 100%;
1364    min-width: 0;
1365  }
1366
1367  .action-form button {
1368    width: 100%;
1369  }
1370
1371  code {
1372    font-size: 11px;
1373    max-width: 180px;
1374    overflow: hidden;
1375    text-overflow: ellipsis;
1376  }
1377}
1378
1379@media (min-width: 641px) and (max-width: 1024px) {
1380  .versions-list {
1381    grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
1382  }
1383}
1384  </style>
1385</head>
1386<body>
1387  <div class="container">
1388    <!-- Back Navigation -->
1389    <div class="back-nav">
1390      <a href="${rootUrl}" class="back-link">
1391        ← Back to Tree
1392      </a>
1393      <a href="/api/v1/node/${nodeId}/chats${qs}" class="back-link">
1394        AI Chats
1395      </a>
1396    </div>
1397
1398    <!-- Header -->
1399    <div class="header">
1400      <h1
1401        id="nodeNameDisplay"
1402        ${!isPublicAccess ? `style="cursor:pointer;" title="Click to rename" onclick="document.getElementById('nodeNameDisplay').style.display='none';document.getElementById('renameForm').style.display='flex';"` : ""}
1403      >${node.name}</h1>
1404      ${!isPublicAccess ? `<form
1405        id="renameForm"
1406        method="POST"
1407        action="/api/v1/node/${nodeId}/${0}/editName${qs}"
1408        style="display:none;align-items:center;gap:8px;margin-bottom:12px;"
1409      >
1410        <input
1411          type="text"
1412          name="name"
1413          value="${node.name.replace(/"/g, '&quot;')}"
1414          required
1415          style="flex:1;font-size:20px;font-weight:700;padding:8px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:white;"
1416        />
1417        <button type="submit" class="primary-button" style="padding:8px 16px;">Save</button>
1418        <button
1419          type="button"
1420          class="warning-button"
1421          style="padding:8px 16px;"
1422          onclick="document.getElementById('renameForm').style.display='none';document.getElementById('nodeNameDisplay').style.display='';"
1423        >Cancel</button>
1424      </form>` : ""}
1425
1426      <div class="node-id-container">
1427        <code id="nodeIdCode">${node._id}</code>
1428        <button id="copyNodeIdBtn" title="Copy ID">📋</button>
1429      </div>
1430
1431      <div class="meta-row">
1432        <div class="meta-item">
1433          <div class="meta-label">Type</div>
1434          <div class="meta-value">${node.type ?? "None"}</div>
1435        </div>
1436        <div class="meta-item">
1437          <div class="meta-label">Status</div>
1438          <div class="meta-value">${node.status || "active"}</div>
1439        </div>
1440      </div>
1441    </div>
1442
1443    ${!isPublicAccess ? `<!-- Edit Type -->
1444    <div class="hierarchy-section">
1445      <h2>Node Type</h2>
1446      <form
1447        method="POST"
1448        action="/api/v1/node/${nodeId}/editType${qs}"
1449        class="action-form"
1450      >
1451        <select name="type" style="flex:1;padding:10px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:white;font-size:14px;">
1452          <option value="" ${!node.type ? "selected" : ""}>None</option>
1453          <option value="goal" ${node.type === "goal" ? "selected" : ""}>goal</option>
1454          <option value="plan" ${node.type === "plan" ? "selected" : ""}>plan</option>
1455          <option value="task" ${node.type === "task" ? "selected" : ""}>task</option>
1456          <option value="knowledge" ${node.type === "knowledge" ? "selected" : ""}>knowledge</option>
1457          <option value="resource" ${node.type === "resource" ? "selected" : ""}>resource</option>
1458          <option value="identity" ${node.type === "identity" ? "selected" : ""}>identity</option>
1459        </select>
1460        <input
1461          type="text"
1462          name="customType"
1463          placeholder="or custom type..."
1464          style="flex:1;"
1465        />
1466        <button type="submit" class="primary-button">Set Type</button>
1467      </form>
1468    </div>` : ""}
1469
1470    <!-- AI Tools Config -->
1471    ${!isPublicAccess ? (() => {
1472      const meta = node.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node.metadata || {});
1473      const tools = meta.tools || {};
1474      const allowed = (tools.allowed || []).join(", ");
1475      const blocked = (tools.blocked || []).join(", ");
1476      return `<div class="hierarchy-section">
1477        <h2>AI Tools</h2>
1478        <p style="color:rgba(255,255,255,0.5);font-size:0.85rem;margin-bottom:12px;">
1479          Control what the AI can do at this node. Inherits up the tree.
1480          <a href="/api/v1/node/${nodeId}/tools" target="_blank" style="color:rgba(255,255,255,0.6);text-decoration:underline;margin-left:8px;">View resolved list</a>
1481        </p>
1482        ${allowed ? `<div style="margin-bottom:8px;"><span style="color:rgba(16,185,129,0.9);font-size:0.85rem;">Added: ${allowed}</span></div>` : ""}
1483        ${blocked ? `<div style="margin-bottom:8px;"><span style="color:rgba(239,68,68,0.9);font-size:0.85rem;">Blocked: ${blocked}</span></div>` : ""}
1484        <form method="POST" action="/api/v1/node/${nodeId}/tools${qs}">
1485          <div style="margin-bottom:8px;">
1486            <label style="display:block;font-size:0.8rem;color:rgba(255,255,255,0.6);margin-bottom:4px;">Allow tools (comma-separated)</label>
1487            <input type="text" name="allowedRaw" value="${allowed}" placeholder="execute-shell, web-search" style="width:100%;padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.08);color:white;font-size:0.9rem;" />
1488          </div>
1489          <div style="margin-bottom:8px;">
1490            <label style="display:block;font-size:0.8rem;color:rgba(255,255,255,0.6);margin-bottom:4px;">Block tools (comma-separated)</label>
1491            <input type="text" name="blockedRaw" value="${blocked}" placeholder="delete-node-branch" style="width:100%;padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.08);color:white;font-size:0.9rem;" />
1492          </div>
1493          <button type="submit" class="primary-button" style="padding:8px 16px;">Save</button>
1494        </form>
1495      </div>`;
1496    })() : ""}
1497
1498    <!-- Versions Section (prestige extension) -->
1499    ${(() => {
1500      const meta = node.metadata instanceof Map ? Object.fromEntries(node.metadata) : (node.metadata || {});
1501      const prestige = meta.prestige || { current: 0, history: [] };
1502      const history = prestige.history || [];
1503      return `<div class="versions-section">
1504        <h2>Versions</h2>
1505        <ul class="versions-list">
1506          ${[...Array(prestige.current + 1)].map((_, i) =>
1507            `<li><a href="/api/v1/node/${nodeId}/${i}${qs}">Version ${i}${i === prestige.current ? " (current)" : ""}</a></li>`
1508          ).reverse().join("")}
1509        </ul>
1510        ${!isPublicAccess ? `<form
1511          method="POST"
1512          action="/api/v1/node/${nodeId}/prestige${qs}"
1513          onsubmit="return confirm('This will complete the current version and create a new prestige level. Continue?')"
1514          style="margin-top: 16px;">
1515          <button type="submit" class="primary-button">Add New Version</button>
1516        </form>` : ""}
1517      </div>`;
1518    })()}
1519
1520    <!-- Parent Section -->
1521    <div class="hierarchy-section">
1522      <h2>Parent</h2>
1523      ${
1524        node.parent
1525          ? `<a href="/api/v1/node/${node.parent}${qs}" style="display:block;padding:12px 16px;margin-bottom:16px;">${parentName}</a>`
1526          : `<p style="margin-bottom:16px;"><em>None (This is a root node)</em></p>`
1527      }
1528
1529      ${!isPublicAccess ? `<h3>Change Parent</h3>
1530      <form
1531        method="POST"
1532        action="/api/v1/node/${nodeId}/updateParent${qs}"
1533        class="action-form"
1534      >
1535        <input
1536          type="text"
1537          name="newParentId"
1538          placeholder="New parent node ID"
1539          required
1540        />
1541        <button type="submit" class="warning-button">
1542          Move Node
1543        </button>
1544      </form>` : ""}
1545    </div>
1546
1547    <!-- Children Section -->
1548    <div class="hierarchy-section">
1549      <h2>Children</h2>
1550      <ul class="children-list">
1551        ${
1552          node.children && node.children.length
1553            ? node.children
1554                .map(
1555                  (c) =>
1556                    `<li><a href="/api/v1/node/${c._id}${qs}">${c.name}</a></li>`,
1557                )
1558                .join("")
1559            : `<li><em>No children yet</em></li>`
1560        }
1561      </ul>
1562
1563      <h3>Add Child</h3>
1564      <form
1565        method="POST"
1566        action="/api/v1/node/${nodeId}/createChild${qs}"
1567        class="action-form"
1568      >
1569        <input
1570          type="text"
1571          name="name"
1572          placeholder="Child name"
1573          required
1574        />
1575        <button type="submit" class="primary-button">
1576          Create Child
1577        </button>
1578      </form>
1579    </div>
1580
1581    <!-- Scripts Section -->
1582    <div class="scripts-section">
1583      <h2><a href="/api/v1/node/${node._id}/scripts/help${qs}">Scripts</a></h2>
1584      <form
1585        method="POST"
1586        action="/api/v1/node/${nodeId}/script/create${qs}"
1587        style="display:flex;gap:8px;align-items:center;margin-bottom:16px;"
1588      >
1589        <input
1590          type="text"
1591          name="name"
1592          placeholder="New script name"
1593          required
1594          style="
1595            padding:12px 16px;
1596            border-radius:10px;
1597            border:1px solid rgba(255,255,255,0.3);
1598            background:rgba(255,255,255,0.2);
1599            color:white;
1600            font-size:15px;
1601            min-width:200px;
1602            flex:1;
1603          "
1604        />
1605        <button
1606          type="submit"
1607          class="primary-button"
1608          title="Create script"
1609          style="padding:10px 18px;font-size:16px;"
1610        >
16111612        </button>
1613      </form>
1614      <ul class="scripts-list">
1615        ${
1616          _nodeScripts.length
1617            ? _nodeScripts
1618                .map(
1619                  (s) => `
1620            <a href="/api/v1/node/${node._id}/script/${s._id}${qs}">
1621              <li>
1622                <strong>${s.name}</strong>
1623                <pre>${s.script}</pre>
1624              </li>
1625            </a>`,
1626                )
1627                .join("")
1628            : `<li><em>No scripts defined</em></li>`
1629        }
1630      </ul>
1631    </div>
1632
1633    ${!isPublicAccess ? `<!-- Delete Section -->
1634    <div class="actions-section">
1635      <h3>Delete</h3>
1636      <form
1637        method="POST"
1638        action="/api/v1/node/${nodeId}/delete${qs}"
1639        onsubmit="return confirm('Delete this node and its branch? This can be revived later.')"
1640      >
1641        <button type="submit" class="danger-button">
1642          Delete Node
1643        </button>
1644      </form>
1645    </div>` : ""}
1646  </div>
1647
1648  <script>
1649    // Copy ID functionality
1650    const btn = document.getElementById("copyNodeIdBtn");
1651    const code = document.getElementById("nodeIdCode");
1652
1653    btn.addEventListener("click", () => {
1654      navigator.clipboard.writeText(code.textContent).then(() => {
1655        btn.textContent = "✔️";
1656        setTimeout(() => (btn.textContent = "📋"), 900);
1657      });
1658    });
1659  </script>
1660</body>
1661</html>
1662`;
1663}
1664
1665/* ================================================================== */
1666/* 3. renderVersionDetail                                              */
1667/* ================================================================== */
1668
1669export function renderVersionDetail({
1670  node,
1671  nodeId,
1672  version,
1673  data,
1674  qs,
1675  backUrl,
1676  backTreeUrl,
1677  createdDate,
1678  scheduleHtml,
1679  reeffectTime,
1680  showPrestige,
1681  ALL_STATUSES,
1682  STATUS_LABELS,
1683}) {
1684  return `
1685<!DOCTYPE html>
1686<html lang="en">
1687<head>
1688  <meta charset="UTF-8">
1689  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1690  <meta name="theme-color" content="#667eea">
1691  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1692  <title>${node.name} v${version}</title>
1693  <style>
1694${baseStyles}
1695
1696/* =========================================================
1697   UNIFIED GLASS BUTTON SYSTEM
1698   ========================================================= */
1699
1700.glass-btn,
1701button,
1702.action-button,
1703.back-link,
1704.nav-links a,
1705.meta-value button,
1706.contributors-list button,
1707button[type="submit"],
1708.status-button,
1709.primary-button {
1710  position: relative;
1711  overflow: hidden;
1712
1713  padding: 10px 20px;
1714  border-radius: 980px;
1715
1716  display: inline-flex;
1717  align-items: center;
1718  justify-content: center;
1719  white-space: nowrap;
1720
1721  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1722  backdrop-filter: blur(22px) saturate(140%);
1723  -webkit-backdrop-filter: blur(22px) saturate(140%);
1724
1725  color: white;
1726  text-decoration: none;
1727  font-family: inherit;
1728
1729  font-size: 15px;
1730  font-weight: 600;
1731  letter-spacing: -0.2px;
1732
1733  border: 1px solid rgba(255, 255, 255, 0.28);
1734
1735  box-shadow:
1736    0 8px 24px rgba(0, 0, 0, 0.12),
1737    inset 0 1px 0 rgba(255, 255, 255, 0.25);
1738
1739  cursor: pointer;
1740
1741  transition:
1742    background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
1743    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
1744    box-shadow 0.3s ease;
1745}
1746
1747/* Liquid light layer */
1748.glass-btn::before,
1749button::before,
1750.action-button::before,
1751.back-link::before,
1752.nav-links a::before,
1753.meta-value button::before,
1754.contributors-list button::before,
1755button[type="submit"]::before,
1756.status-button::before,
1757.primary-button::before {
1758  content: "";
1759  position: absolute;
1760  inset: -40%;
1761
1762  background:
1763    radial-gradient(
1764      120% 60% at 0% 0%,
1765      rgba(255, 255, 255, 0.35),
1766      transparent 60%
1767    ),
1768    linear-gradient(
1769      120deg,
1770      transparent 30%,
1771      rgba(255, 255, 255, 0.25),
1772      transparent 70%
1773    );
1774
1775  opacity: 0;
1776  transform: translateX(-30%) translateY(-10%);
1777  transition:
1778    opacity 0.35s ease,
1779    transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1780
1781  pointer-events: none;
1782}
1783
1784/* Hover motion */
1785.glass-btn:hover,
1786button:hover,
1787.action-button:hover,
1788.back-link:hover,
1789.nav-links a:hover,
1790.meta-value button:hover,
1791.contributors-list button:hover,
1792button[type="submit"]:hover,
1793.status-button:hover,
1794.primary-button:hover {
1795  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
1796  transform: translateY(-2px);
1797}
1798
1799.glass-btn:hover::before,
1800button:hover::before,
1801.action-button:hover::before,
1802.back-link:hover::before,
1803.nav-links a:hover::before,
1804.meta-value button:hover::before,
1805.contributors-list button:hover::before,
1806button[type="submit"]:hover::before,
1807.status-button:hover::before,
1808.primary-button:hover::before {
1809  opacity: 1;
1810  transform: translateX(30%) translateY(10%);
1811}
1812
1813/* Active press */
1814.glass-btn:active,
1815button:active,
1816.status-button:active,
1817.primary-button:active {
1818  background: rgba(var(--glass-water-rgb), 0.45);
1819  transform: translateY(0);
1820}
1821
1822/* Emphasis variants */
1823.primary-button {
1824  --glass-water-rgb: 72, 187, 178;
1825  --glass-alpha: 0.34;
1826  --glass-alpha-hover: 0.46;
1827  font-weight: 600;
1828}
1829
1830.legacy-btn {
1831  opacity: 0.85;
1832}
1833.legacy-btn:hover {
1834  opacity: 1;
1835}
1836
1837/* =========================================================
1838   CONTENT CARDS - UPDATED TO MATCH ROOT ROUTE
1839   ========================================================= */
1840
1841.header,
1842.nav-section,
1843.actions-section {
1844  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1845  backdrop-filter: blur(22px) saturate(140%);
1846  -webkit-backdrop-filter: blur(22px) saturate(140%);
1847  border-radius: 16px;
1848  padding: 28px;
1849  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1850    inset 0 1px 0 rgba(255, 255, 255, 0.25);
1851  border: 1px solid rgba(255, 255, 255, 0.28);
1852  margin-bottom: 24px;
1853  animation: fadeInUp 0.6s ease-out;
1854  animation-fill-mode: both;
1855  position: relative;
1856  overflow: hidden;
1857}
1858
1859.header {
1860  animation-delay: 0.1s;
1861}
1862
1863.nav-section {
1864  animation-delay: 0.15s;
1865}
1866
1867.actions-section {
1868  animation-delay: 0.2s;
1869}
1870
1871.header::before,
1872.nav-section::before,
1873.actions-section::before {
1874  content: "";
1875  position: absolute;
1876  inset: 0;
1877  border-radius: inherit;
1878  background: linear-gradient(
1879    180deg,
1880    rgba(255, 255, 255, 0.18),
1881    rgba(255, 255, 255, 0.05)
1882  );
1883  pointer-events: none;
1884}
1885
1886.meta-card {
1887  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1888  backdrop-filter: blur(22px) saturate(140%);
1889  -webkit-backdrop-filter: blur(22px) saturate(140%);
1890  border-radius: 12px;
1891  padding: 16px 20px;
1892  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1893    inset 0 1px 0 rgba(255, 255, 255, 0.25);
1894  border: 1px solid rgba(255, 255, 255, 0.28);
1895  color: white;
1896  animation: fadeInUp 0.6s ease-out;
1897  animation-fill-mode: both;
1898  position: relative;
1899  overflow: hidden;
1900}
1901
1902.meta-card::before {
1903  content: "";
1904  position: absolute;
1905  inset: 0;
1906  border-radius: inherit;
1907  background: linear-gradient(
1908    180deg,
1909    rgba(255, 255, 255, 0.18),
1910    rgba(255, 255, 255, 0.05)
1911  );
1912  pointer-events: none;
1913}
1914
1915/* Stagger meta-card animations */
1916.meta-card:nth-child(1) { animation-delay: 0.2s; }
1917.meta-card:nth-child(2) { animation-delay: 0.25s; }
1918
1919.header h1 {
1920  font-size: 28px;
1921  font-weight: 600;
1922  letter-spacing: -0.5px;
1923  line-height: 1.3;
1924  margin-bottom: 8px;
1925  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1926}
1927
1928.header h1 a {
1929  color: white;
1930  text-decoration: none;
1931  transition: opacity 0.2s;
1932}
1933
1934.header h1 a:hover {
1935  opacity: 0.8;
1936}
1937
1938.nav-section h2,
1939.actions-section h3 {
1940  font-size: 18px;
1941  font-weight: 600;
1942  color: white;
1943  margin-bottom: 16px;
1944  letter-spacing: -0.3px;
1945  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1946}
1947
1948/* =========================================================
1949   NAV + META
1950   ========================================================= */
1951
1952.back-nav {
1953  display: flex;
1954  gap: 12px;
1955  margin-bottom: 20px;
1956  flex-wrap: wrap;
1957  animation: fadeInUp 0.5s ease-out;
1958}
1959
1960.version-badge {
1961  display: inline-block;
1962  padding: 6px 14px;
1963  background: rgba(16, 185, 129, 0.25);
1964  backdrop-filter: blur(10px);
1965  color: white;
1966  border-radius: 20px;
1967  font-size: 14px;
1968  font-weight: 600;
1969  margin-top: 8px;
1970  border: 1px solid rgba(16, 185, 129, 0.4);
1971  position: relative;
1972  overflow: hidden;
1973  box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15),
1974    inset 0 1px 0 rgba(255, 255, 255, 0.3);
1975}
1976
1977/* Version badge colors matching status */
1978.version-badge.version-status-active {
1979  background: rgba(16, 185, 129, 0.25);
1980  border: 1px solid rgba(16, 185, 129, 0.4);
1981  box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15),
1982    inset 0 1px 0 rgba(255, 255, 255, 0.3);
1983}
1984
1985.version-badge.version-status-completed {
1986  background: rgba(139, 92, 246, 0.25);
1987  border: 1px solid rgba(139, 92, 246, 0.4);
1988  box-shadow: 0 4px 12px rgba(139, 92, 246, 0.15),
1989    inset 0 1px 0 rgba(255, 255, 255, 0.3);
1990}
1991
1992.version-badge.version-status-trimmed {
1993  background: rgba(220, 38, 38, 0.25);
1994  border: 1px solid rgba(220, 38, 38, 0.4);
1995  box-shadow: 0 4px 12px rgba(220, 38, 38, 0.15),
1996    inset 0 1px 0 rgba(255, 255, 255, 0.3);
1997}
1998
1999.version-badge::after {
2000  content: "";
2001  position: absolute;
2002  inset: 0;
2003
2004  background: linear-gradient(
2005    100deg,
2006    transparent 40%,
2007    rgba(255, 255, 255, 0.5),
2008    transparent 60%
2009  );
2010
2011  opacity: 0;
2012  transform: translateX(-100%);
2013  transition: transform 0.8s ease, opacity 0.3s ease;
2014
2015  animation: openAppHoverShimmerClone 1.6s ease forwards;
2016  animation-delay: 0.5s;
2017
2018  pointer-events: none;
2019}
2020
2021@keyframes openAppHoverShimmerClone {
2022  0% {
2023    opacity: 0;
2024    transform: translateX(-100%);
2025  }
2026
2027  100% {
2028    opacity: 1;
2029    transform: translateX(100%);
2030  }
2031}
2032
2033.created-date {
2034  font-size: 13px;
2035  color: rgba(255, 255, 255, 0.7);
2036  margin-top: 10px;
2037  font-weight: 500;
2038}
2039
2040.node-id-container {
2041  display: flex;
2042  align-items: center;
2043  gap: 8px;
2044  margin-top: 12px;
2045  flex-wrap: wrap;
2046  padding: 10px 14px;
2047  background: rgba(255, 255, 255, 0.15);
2048  border-radius: 8px;
2049  border: 1px solid rgba(255, 255, 255, 0.2);
2050  width: 100%;
2051}
2052
2053code {
2054  background: transparent;
2055  padding: 0;
2056  border-radius: 0;
2057  font-size: 13px;
2058  font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
2059  color: white;
2060  word-break: break-all;
2061  flex: 1;
2062  min-width: 0;
2063  overflow-wrap: break-word;
2064}
2065
2066#copyNodeIdBtn {
2067  background: rgba(255, 255, 255, 0.2);
2068  border: 1px solid rgba(255, 255, 255, 0.3);
2069  cursor: pointer;
2070  padding: 6px 10px;
2071  border-radius: 6px;
2072  opacity: 1;
2073  font-size: 16px;
2074  transition: all 0.2s;
2075  flex-shrink: 0;
2076}
2077
2078#copyNodeIdBtn:hover {
2079  background: rgba(255, 255, 255, 0.3);
2080  transform: scale(1.1);
2081}
2082
2083#copyNodeIdBtn::before {
2084  display: none;
2085}
2086
2087.meta-grid {
2088  display: grid;
2089  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
2090  gap: 16px;
2091  margin-bottom: 24px;
2092}
2093
2094.meta-label {
2095  font-size: 12px;
2096  font-weight: 600;
2097  text-transform: uppercase;
2098  letter-spacing: 0.5px;
2099  color: rgba(255, 255, 255, 0.7);
2100  margin-bottom: 6px;
2101}
2102
2103.meta-value {
2104  font-size: 15px;
2105  font-weight: 600;
2106  color: white;
2107  word-break: break-word;
2108  overflow-wrap: break-word;
2109}
2110
2111.status-badge {
2112  display: inline-block;
2113  padding: 6px 12px;
2114  border-radius: 6px;
2115  font-size: 13px;
2116  font-weight: 600;
2117  text-transform: capitalize;
2118  background: rgba(255, 255, 255, 0.25);
2119  color: white;
2120  border: 1px solid rgba(255, 255, 255, 0.3);
2121}
2122
2123/* Official status colors with glass effect - UPDATED COLORS */
2124.status-badge.status-active {
2125  background: rgba(16, 185, 129, 0.35);
2126  border: 1px solid rgba(16, 185, 129, 0.5);
2127  backdrop-filter: blur(10px);
2128  box-shadow: 0 0 12px rgba(16, 185, 129, 0.2);
2129}
2130
2131.status-badge.status-completed {
2132  background: rgba(139, 92, 246, 0.35);
2133  border: 1px solid rgba(139, 92, 246, 0.5);
2134  backdrop-filter: blur(10px);
2135  box-shadow: 0 0 12px rgba(139, 92, 246, 0.2);
2136}
2137
2138.status-badge.status-trimmed {
2139  background: rgba(220, 38, 38, 0.35);
2140  border: 1px solid rgba(220, 38, 38, 0.5);
2141  backdrop-filter: blur(10px);
2142  box-shadow: 0 0 12px rgba(220, 38, 38, 0.2);
2143}
2144
2145.nav-links {
2146  display: grid;
2147  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2148  gap: 12px;
2149}
2150
2151.nav-links a {
2152  padding: 14px 18px;
2153  font-size: 15px;
2154  text-align: center;
2155}
2156
2157/* =========================================================
2158   STATUS CARD WITH BUTTONS - UPDATED COLORS
2159   ========================================================= */
2160
2161.status-controls {
2162  display: flex;
2163  align-items: center;
2164  gap: 12px;
2165  flex-wrap: wrap;
2166  margin-top: 12px;
2167}
2168
2169.status-controls button {
2170  padding: 8px 16px;
2171  font-size: 13px;
2172  position: relative;
2173}
2174
2175/* Faint glass colors for status buttons - UPDATED */
2176.status-controls button[value="active"] {
2177  --glass-water-rgb: 16, 185, 129; /* green */
2178  --glass-alpha: 0.15;
2179  --glass-alpha-hover: 0.25;
2180}
2181
2182.status-controls button[value="completed"] {
2183  --glass-water-rgb: 139, 92, 246; /* purple */
2184  --glass-alpha: 0.15;
2185  --glass-alpha-hover: 0.25;
2186}
2187
2188.status-controls button[value="trimmed"] {
2189  --glass-water-rgb: 220, 38, 38; /* red */
2190  --glass-alpha: 0.15;
2191  --glass-alpha-hover: 0.25;
2192}
2193
2194/* =========================================================
2195   SCHEDULE CARD
2196   ========================================================= */
2197
2198.schedule-info {
2199  display: flex;
2200  flex-direction: column;
2201  gap: 8px;
2202  width: 100%;
2203}
2204
2205.schedule-row {
2206  display: flex;
2207  align-items: flex-start;
2208  gap: 12px;
2209  width: 100%;
2210}
2211
2212.schedule-text {
2213  flex: 1;
2214  min-width: 0;
2215  overflow: hidden;
2216}
2217
2218.schedule-text .meta-value {
2219  word-break: break-word;
2220  overflow-wrap: break-word;
2221}
2222
2223.repeat-text {
2224  font-size: 13px;
2225  color: rgba(255, 255, 255, 0.8);
2226  margin-top: 6px;
2227}
2228
2229#editScheduleBtn {
2230  flex-shrink: 0;
2231}
2232
2233/* =========================================================
2234   ACTIONS & FORMS
2235   ========================================================= */
2236
2237.action-form {
2238  margin-bottom: 24px;
2239}
2240
2241.action-form:last-child {
2242  margin-bottom: 0;
2243}
2244
2245.button-group {
2246  display: flex;
2247  gap: 12px;
2248  flex-wrap: wrap;
2249}
2250
2251button[type="submit"],
2252.status-button {
2253  padding: 12px 20px;
2254  font-size: 14px;
2255}
2256
2257/* =========================================================
2258   MODAL
2259   ========================================================= */
2260
2261#scheduleModal {
2262  display: none;
2263  position: fixed;
2264  inset: 0;
2265  background: rgba(0, 0, 0, 0.5);
2266  backdrop-filter: blur(8px);
2267  align-items: center;
2268  justify-content: center;
2269  z-index: 1000;
2270}
2271
2272#scheduleModal > div {
2273  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2274  backdrop-filter: blur(22px) saturate(140%);
2275  -webkit-backdrop-filter: blur(22px) saturate(140%);
2276  padding: 28px;
2277  border-radius: 16px;
2278  width: 320px;
2279  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2),
2280    inset 0 1px 0 rgba(255, 255, 255, 0.25);
2281  border: 1px solid rgba(255, 255, 255, 0.28);
2282  position: relative;
2283  overflow: hidden;
2284}
2285
2286#scheduleModal > div::before {
2287  content: "";
2288  position: absolute;
2289  inset: 0;
2290  border-radius: inherit;
2291  background: linear-gradient(
2292    180deg,
2293    rgba(255, 255, 255, 0.18),
2294    rgba(255, 255, 255, 0.05)
2295  );
2296  pointer-events: none;
2297}
2298
2299#scheduleModal label {
2300  display: block;
2301  margin-bottom: 12px;
2302  color: white;
2303  font-weight: 600;
2304  font-size: 14px;
2305  letter-spacing: -0.2px;
2306  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
2307  position: relative;
2308}
2309
2310#scheduleModal input {
2311  width: 100%;
2312  margin-top: 6px;
2313  padding: 12px 14px;
2314  border-radius: 10px;
2315  border: 2px solid rgba(255, 255, 255, 0.3);
2316  background: rgba(255, 255, 255, 0.15);
2317  font-size: 15px;
2318  font-family: inherit;
2319  font-weight: 500;
2320  transition: all 0.2s;
2321  color: white;
2322  position: relative;
2323}
2324
2325#scheduleModal input::placeholder {
2326  color: rgba(255, 255, 255, 0.5);
2327}
2328
2329#scheduleModal input:focus {
2330  outline: none;
2331  border-color: rgba(255, 255, 255, 0.6);
2332  background: rgba(255, 255, 255, 0.25);
2333  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
2334  transform: translateY(-2px);
2335}
2336
2337#scheduleModal button {
2338  padding: 10px 18px;
2339  border-radius: 980px;
2340  font-weight: 600;
2341  font-size: 14px;
2342  cursor: pointer;
2343  font-family: inherit;
2344  transition: all 0.2s;
2345  border: 1px solid rgba(255, 255, 255, 0.28);
2346  position: relative;
2347}
2348
2349#scheduleModal button[type="button"] {
2350  background: rgba(255, 255, 255, 0.15);
2351  color: white;
2352  border: 1px solid rgba(255, 255, 255, 0.28) !important;
2353  box-shadow: none !important;
2354}
2355
2356#scheduleModal button[type="button"]:hover {
2357  background: rgba(255, 255, 255, 0.25);
2358}
2359
2360#scheduleModal button[type="button"]::before {
2361  display: none;
2362}
2363
2364#scheduleModal > div > form > div {
2365  display: flex;
2366  gap: 10px;
2367  justify-content: flex-end;
2368  margin-top: 16px;
2369}
2370
2371/* =========================================================
2372   RESPONSIVE
2373   ========================================================= */
2374
2375${responsiveBase}
2376
2377@media (max-width: 640px) {
2378  .container {
2379    max-width: 100%;
2380  }
2381
2382  .header,
2383  .nav-section,
2384  .actions-section {
2385    padding: 20px;
2386  }
2387
2388  .meta-grid {
2389    grid-template-columns: 1fr;
2390    gap: 12px;
2391  }
2392
2393  .meta-card {
2394    padding: 14px 16px;
2395  }
2396
2397  .nav-links {
2398    grid-template-columns: 1fr;
2399  }
2400
2401  .button-group {
2402    flex-direction: column;
2403  }
2404
2405  button,
2406  .status-button,
2407  .primary-button {
2408    width: 100%;
2409  }
2410
2411  .status-controls {
2412    flex-direction: column;
2413    align-items: stretch;
2414  }
2415
2416  .status-controls button {
2417    width: 100%;
2418  }
2419
2420  code {
2421    font-size: 12px;
2422    word-break: break-all;
2423  }
2424
2425  .schedule-row {
2426    flex-direction: column;
2427    align-items: stretch;
2428    gap: 8px;
2429  }
2430
2431  #editScheduleBtn {
2432    width: 100%;
2433    justify-content: center;
2434  }
2435
2436  #scheduleModal > div {
2437    width: calc(100% - 40px);
2438    max-width: 320px;
2439  }
2440}
2441
2442@media (min-width: 641px) and (max-width: 1024px) {
2443  .meta-grid {
2444    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
2445  }
2446}
2447  </style>
2448</head>
2449<body>
2450  <div class="container">
2451    <!-- Back Navigation -->
2452    <div class="back-nav">
2453      <a href="${backTreeUrl}" class="back-link">
2454        ← Back to Tree
2455      </a>
2456      <a href="${backUrl}" class="back-link">
2457        View All Versions
2458      </a>
2459    </div>
2460
2461    <!-- Header -->
2462    <div class="header">
2463      <h1
2464        id="nodeNameDisplay"
2465        style="cursor:pointer;"
2466        title="Click to rename"
2467        onclick="document.getElementById('nodeNameDisplay').style.display='none';document.getElementById('renameForm').style.display='flex';"
2468      >${node.name}</h1>
2469      <form
2470        id="renameForm"
2471        method="POST"
2472        action="/api/v1/node/${nodeId}/${version}/editName${qs}"
2473        style="display:none;align-items:center;gap:8px;margin-bottom:12px;"
2474      >
2475        <input
2476          type="text"
2477          name="name"
2478          value="${node.name.replace(/"/g, '&quot;')}"
2479          required
2480          style="flex:1;font-size:20px;font-weight:700;padding:8px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:white;"
2481        />
2482        <button type="submit" class="primary-button" style="padding:8px 16px;">Save</button>
2483        <button
2484          type="button"
2485          class="warning-button"
2486          style="padding:8px 16px;"
2487          onclick="document.getElementById('renameForm').style.display='none';document.getElementById('nodeNameDisplay').style.display='';"
2488        >Cancel</button>
2489      </form>
2490
2491      <div class="meta-row" style="margin-top:4px;">
2492        <div class="meta-item">
2493          <div class="meta-label">Type</div>
2494          <div class="meta-value">${node.type ?? "None"}</div>
2495        </div>
2496      </div>
2497
2498      <span class="version-badge version-status-${data.status}">Version ${version}</span>
2499
2500      <div class="created-date">Created: ${createdDate}</div>
2501
2502      <div class="node-id-container">
2503        <code id="nodeIdCode">${node._id}</code>
2504        <button id="copyNodeIdBtn" title="Copy ID">📋</button>
2505      </div>
2506    </div>
2507
2508    <!-- Navigation Links -->
2509    <div class="nav-section">
2510      <h2>Quick Access</h2>
2511      <div class="nav-links">
2512        <a href="/api/v1/node/${nodeId}/${version}/notes${qs}">Notes</a>
2513        <a href="/api/v1/node/${nodeId}/${version}/values${qs}">Values / Goals</a>
2514        <a href="/api/v1/node/${nodeId}/${version}/contributions${qs}">Contributions</a>
2515        <a href="/api/v1/node/${nodeId}/${version}/transactions${qs}">Transactions</a>
2516        <a href="/api/v1/node/${nodeId}/chats${qs}">AI Chats</a>
2517      </div>
2518    </div>
2519
2520    <!-- Metadata Grid -->
2521    <div class="meta-grid">
2522      <!-- Status Card with Controls -->
2523      <div class="meta-card">
2524        <div class="meta-label">Status</div>
2525        <div class="meta-value">
2526          <span class="status-badge status-${data.status}">${data.status}</span>
2527        </div>
2528        <form
2529          method="POST"
2530          action="/api/v1/node/${nodeId}/${version}/editStatus${qs}"
2531          onsubmit="return confirm('This will apply to all children. Is that ok?')"
2532          class="status-controls"
2533        >
2534          <input type="hidden" name="isInherited" value="true" />
2535          ${ALL_STATUSES.filter((s) => s !== data.status)
2536            .map(
2537              (s) => `
2538            <button type="submit" name="status" value="${s}" class="status-button">
2539              ${STATUS_LABELS[s]}
2540            </button>
2541          `,
2542            )
2543            .join("")}
2544        </form>
2545      </div>
2546
2547      <!-- Schedule + Repeat Hours Card -->
2548      <div class="meta-card">
2549        <div class="meta-label">Schedule</div>
2550        <div class="schedule-info">
2551          <div class="schedule-row">
2552            <div class="schedule-text">
2553              <div class="meta-value">${scheduleHtml}</div>
2554              <div class="repeat-text">Repeat: ${reeffectTime} hours</div>
2555            </div>
2556            <button id="editScheduleBtn" style="padding:8px 12px;">✏️</button>
2557          </div>
2558        </div>
2559      </div>
2560    </div>
2561
2562    ${
2563      showPrestige
2564        ? `
2565    <!-- Version Control Section -->
2566    <div class="actions-section">
2567      <h3>Version Control</h3>
2568      <form
2569        method="POST"
2570        action="/api/v1/node/${nodeId}/${version}/prestige${qs}"
2571        onsubmit="return confirm('This will complete the current version and create a new prestige level. Continue?')"
2572        class="action-form"
2573      >
2574        <button type="submit" class="primary-button">
2575          Add New Version
2576        </button>
2577      </form>
2578    </div>
2579    `
2580        : ""
2581    }
2582  </div>
2583
2584  <!-- Schedule Modal -->
2585  <div id="scheduleModal">
2586    <div>
2587      <form
2588        method="POST"
2589        action="/api/v1/node/${nodeId}/${version}/editSchedule${qs}"
2590      >
2591        <label>
2592          TIME
2593          <input
2594            type="datetime-local"
2595            name="newSchedule"
2596            value="${
2597              data.schedule
2598                ? new Date(data.schedule).toISOString().slice(0, 16)
2599                : ""
2600            }"
2601          />
2602        </label>
2603
2604        <label>
2605          REPEAT HOURS
2606          <input
2607            type="number"
2608            name="reeffectTime"
2609            min="0"
2610            value="${data.reeffectTime ?? 0}"
2611          />
2612        </label>
2613
2614        <div style="display:flex;gap:10px;justify-content:flex-end;">
2615          <button type="button" id="cancelSchedule">Cancel</button>
2616          <button type="submit" class="primary-button">Save</button>
2617        </div>
2618      </form>
2619    </div>
2620  </div>
2621
2622  <script>
2623    // Copy ID functionality
2624    const btn = document.getElementById("copyNodeIdBtn");
2625    const code = document.getElementById("nodeIdCode");
2626
2627    btn.addEventListener("click", () => {
2628      navigator.clipboard.writeText(code.textContent).then(() => {
2629        btn.textContent = "✔️";
2630        setTimeout(() => (btn.textContent = "📋"), 900);
2631      });
2632    });
2633
2634    // Schedule modal
2635    const editBtn = document.getElementById("editScheduleBtn");
2636    const modal = document.getElementById("scheduleModal");
2637    const cancelBtn = document.getElementById("cancelSchedule");
2638
2639    if (editBtn) {
2640      editBtn.onclick = () => {
2641        modal.style.display = "flex";
2642      };
2643    }
2644
2645    if (cancelBtn) {
2646      cancelBtn.onclick = () => {
2647        modal.style.display = "none";
2648      };
2649    }
2650  </script>
2651</body>
2652</html>
2653`;
2654}
2655
2656/* ================================================================== */
2657/* 4. renderScriptDetail                                               */
2658/* ================================================================== */
2659
2660export function renderScriptDetail({
2661  nodeId,
2662  script,
2663  contributions,
2664  qsWithQ,
2665}) {
2666  const editHistory = contributions.filter((c) => c.type === "edit");
2667  const executionHistory = contributions.filter((c) => c.type === "execute");
2668
2669  const editHistoryHtml = editHistory.length
2670    ? editHistory
2671        .map(
2672          (c, i) => `
2673<li class="history-item">
2674  <div class="history-header">
2675    <div class="history-title">
2676      <span class="edit-number">Edit ${editHistory.length - i}</span>
2677      ${c.scriptName ? `<span class="script-name">${c.scriptName}</span>` : ""}
2678      ${i === 0 ? `<span class="current-badge">Current</span>` : ""}
2679    </div>
2680    <div class="history-meta">
2681      <span class="version-badge">v${c.nodeVersion}</span>
2682      <span class="timestamp">${new Date(c.createdAt).toLocaleString()}</span>
2683    </div>
2684  </div>
2685
2686  ${
2687    c.contents
2688      ? `
2689  <details>
2690    <summary>
2691      <span class="summary-icon">▶</span>
2692      View code
2693    </summary>
2694    <pre class="history-code">${c.contents}</pre>
2695  </details>`
2696      : `<div class="empty-history-item">Empty script</div>`
2697  }
2698</li>
2699`,
2700        )
2701        .join("")
2702    : `<li class="empty-history">No edit history yet</li>`;
2703
2704  const executionHistoryHtml = executionHistory.length
2705    ? executionHistory
2706        .map(
2707          (c, i) => `
2708<li class="history-item ${c.success ? "success" : "failure"}">
2709  <div class="history-header">
2710    <div class="history-title">
2711      <span class="edit-number">Run ${executionHistory.length - i}</span>
2712      ${c.scriptName ? `<span class="script-name">${c.scriptName}</span>` : ""}
2713      ${
2714        c.success
2715          ? `<span class="current-badge success-badge">Success</span>`
2716          : `<span class="current-badge failure-badge">Failed</span>`
2717      }
2718    </div>
2719    <div class="history-meta">
2720      <span class="version-badge">v${c.nodeVersion}</span>
2721      <span class="timestamp">${new Date(c.createdAt).toLocaleString()}</span>
2722    </div>
2723  </div>
2724
2725  ${
2726    c.logs && c.logs.length
2727      ? `
2728  <details>
2729    <summary>
2730      <span class="summary-icon">▶</span>
2731      View logs (${c.logs.length} ${c.logs.length === 1 ? "entry" : "entries"})
2732    </summary>
2733    <pre class="history-code">${c.logs.join("\n")}</pre>
2734  </details>`
2735      : ""
2736  }
2737
2738  ${
2739    c.error
2740      ? `<div class="error-message">
2741          <div class="error-label">Error:</div>
2742          <pre class="error-code">${c.error}</pre>
2743        </div>`
2744      : ""
2745  }
2746
2747  ${
2748    !c.logs?.length && !c.error
2749      ? `<div class="empty-history-item">No logs or output</div>`
2750      : ""
2751  }
2752</li>
2753`,
2754        )
2755        .join("")
2756    : `<li class="empty-history">No executions yet</li>`;
2757
2758  return `
2759<!DOCTYPE html>
2760<html lang="en">
2761<head>
2762  <meta charset="UTF-8">
2763  <meta name="viewport" content="width=device-width, initial-scale=1.0">
2764  <meta name="theme-color" content="#667eea">
2765  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2766  <title>${script.name} — Script</title>
2767  <style>
2768${baseStyles}
2769.container { max-width: 1000px; }
2770
2771/* =========================================================
2772   UNIFIED GLASS BUTTON SYSTEM
2773   ========================================================= */
2774
2775.glass-btn,
2776button,
2777.back-link,
2778.btn-copy,
2779.btn-execute,
2780.btn-save {
2781  position: relative;
2782  overflow: hidden;
2783
2784  padding: 10px 20px;
2785  border-radius: 980px;
2786
2787  display: inline-flex;
2788  align-items: center;
2789  justify-content: center;
2790  white-space: nowrap;
2791
2792  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2793  backdrop-filter: blur(22px) saturate(140%);
2794  -webkit-backdrop-filter: blur(22px) saturate(140%);
2795
2796  color: white;
2797  text-decoration: none;
2798  font-family: inherit;
2799
2800  font-size: 15px;
2801  font-weight: 500;
2802  letter-spacing: -0.2px;
2803
2804  border: 1px solid rgba(255, 255, 255, 0.28);
2805
2806  box-shadow:
2807    0 8px 24px rgba(0, 0, 0, 0.12),
2808    inset 0 1px 0 rgba(255, 255, 255, 0.25);
2809
2810  cursor: pointer;
2811
2812  transition:
2813    background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
2814    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
2815    box-shadow 0.3s ease;
2816}
2817
2818/* Liquid light layer */
2819.glass-btn::before,
2820button::before,
2821.back-link::before,
2822.btn-copy::before,
2823.btn-execute::before,
2824.btn-save::before {
2825  content: "";
2826  position: absolute;
2827  inset: -40%;
2828
2829  background:
2830    radial-gradient(
2831      120% 60% at 0% 0%,
2832      rgba(255, 255, 255, 0.35),
2833      transparent 60%
2834    ),
2835    linear-gradient(
2836      120deg,
2837      transparent 30%,
2838      rgba(255, 255, 255, 0.25),
2839      transparent 70%
2840    );
2841
2842  opacity: 0;
2843  transform: translateX(-30%) translateY(-10%);
2844  transition:
2845    opacity 0.35s ease,
2846    transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
2847
2848  pointer-events: none;
2849}
2850
2851/* Hover motion */
2852.glass-btn:hover,
2853button:hover,
2854.back-link:hover,
2855.btn-copy:hover,
2856.btn-execute:hover,
2857.btn-save:hover {
2858  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
2859  transform: translateY(-1px);
2860  animation: waterDrift 2.2s ease-in-out infinite alternate;
2861}
2862
2863.glass-btn:hover::before,
2864button:hover::before,
2865.back-link:hover::before,
2866.btn-copy:hover::before,
2867.btn-execute:hover::before,
2868.btn-save:hover::before {
2869  opacity: 1;
2870  transform: translateX(30%) translateY(10%);
2871}
2872
2873/* Active press */
2874.glass-btn:active,
2875button:active,
2876.btn-copy:active,
2877.btn-execute:active,
2878.btn-save:active {
2879  background: rgba(var(--glass-water-rgb), 0.45);
2880  transform: translateY(0);
2881  animation: none;
2882}
2883
2884@keyframes waterDrift {
2885  0% { transform: translateY(-1px); }
2886  100% { transform: translateY(1px); }
2887}
2888
2889/* Button variants */
2890.btn-execute {
2891  --glass-water-rgb: 16, 185, 129;
2892  font-weight: 600;
2893}
2894
2895.btn-save {
2896  --glass-alpha: 0.34;
2897  --glass-alpha-hover: 0.46;
2898  font-weight: 600;
2899}
2900
2901.btn-copy {
2902  padding: 6px 12px;
2903  font-size: 13px;
2904}
2905
2906/* =========================================================
2907   CONTENT CARDS
2908   ========================================================= */
2909
2910.header,
2911.section {
2912  background: rgba(255, 255, 255, 0.15);
2913  backdrop-filter: blur(22px) saturate(140%);
2914  -webkit-backdrop-filter: blur(22px) saturate(140%);
2915  border-radius: 14px;
2916  padding: 28px;
2917  border: 1px solid rgba(255, 255, 255, 0.28);
2918  color: white;
2919  margin-bottom: 24px;
2920}
2921
2922.header h1 {
2923  font-size: 28px;
2924  font-weight: 600;
2925  letter-spacing: -0.5px;
2926  line-height: 1.3;
2927  margin-bottom: 12px;
2928  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2929  color: white;
2930  word-break: break-word;
2931}
2932
2933.header h1::before {
2934  content: '⚡ ';
2935  font-size: 26px;
2936}
2937
2938.script-id {
2939  font-size: 13px;
2940  color: rgba(255, 255, 255, 0.8);
2941  font-family: 'SF Mono', Monaco, monospace;
2942  background: rgba(255, 255, 255, 0.1);
2943  padding: 6px 12px;
2944  border-radius: 6px;
2945  display: inline-block;
2946  margin-top: 8px;
2947  border: 1px solid rgba(255, 255, 255, 0.2);
2948}
2949
2950.section-title {
2951  font-size: 18px;
2952  font-weight: 600;
2953  color: white;
2954  margin-bottom: 20px;
2955  letter-spacing: -0.3px;
2956  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2957}
2958
2959/* =========================================================
2960   NAV
2961   ========================================================= */
2962
2963.back-nav {
2964  display: flex;
2965  gap: 12px;
2966  margin-bottom: 20px;
2967  flex-wrap: wrap;
2968}
2969
2970/* =========================================================
2971   CODE DISPLAY
2972   ========================================================= */
2973
2974.code-container {
2975  position: relative;
2976}
2977
2978.code-header {
2979  display: flex;
2980  justify-content: space-between;
2981  align-items: center;
2982  margin-bottom: 12px;
2983}
2984
2985.code-label {
2986  font-size: 14px;
2987  font-weight: 600;
2988  color: rgba(255, 255, 255, 0.7);
2989  text-transform: uppercase;
2990  letter-spacing: 0.5px;
2991}
2992
2993pre {
2994  background: rgba(0, 0, 0, 0.3);
2995  color: #e0e0e0;
2996  padding: 20px;
2997  border-radius: 12px;
2998  overflow-x: auto;
2999  font-size: 14px;
3000  line-height: 1.6;
3001  font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
3002  border: 1px solid rgba(255, 255, 255, 0.1);
3003}
3004
3005/* =========================================================
3006   ACTION BUTTONS
3007   ========================================================= */
3008
3009.action-bar {
3010  display: flex;
3011  gap: 12px;
3012  margin-top: 20px;
3013  flex-wrap: wrap;
3014}
3015
3016.btn-execute::before {
3017  content: '▶ ';
3018  font-size: 14px;
3019}
3020
3021/* =========================================================
3022   FORMS
3023   ========================================================= */
3024
3025.edit-form {
3026  display: flex;
3027  flex-direction: column;
3028  gap: 16px;
3029}
3030
3031.form-group {
3032  display: flex;
3033  flex-direction: column;
3034  gap: 8px;
3035}
3036
3037.form-label {
3038  font-size: 14px;
3039  font-weight: 600;
3040  color: white;
3041}
3042
3043input[type="text"],
3044textarea {
3045  width: 100%;
3046  padding: 12px 16px;
3047  border: 1px solid rgba(255, 255, 255, 0.3);
3048  border-radius: 10px;
3049  font-size: 15px;
3050  font-family: inherit;
3051  transition: all 0.2s;
3052  background: rgba(255, 255, 255, 0.2);
3053  color: white;
3054}
3055
3056input[type="text"]::placeholder,
3057textarea::placeholder {
3058  color: rgba(255, 255, 255, 0.6);
3059}
3060
3061textarea {
3062  font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
3063  resize: vertical;
3064  min-height: 300px;
3065}
3066
3067input[type="text"]:focus,
3068textarea:focus {
3069  outline: none;
3070  border-color: rgba(255, 255, 255, 0.5);
3071  background: rgba(255, 255, 255, 0.25);
3072  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
3073}
3074
3075/* =========================================================
3076   HISTORY
3077   ========================================================= */
3078
3079.history-list {
3080  list-style: none;
3081  display: flex;
3082  flex-direction: column;
3083  gap: 12px;
3084}
3085
3086.history-item {
3087  background: rgba(255, 255, 255, 0.1);
3088  border-radius: 12px;
3089  padding: 16px;
3090  border: 1px solid rgba(255, 255, 255, 0.2);
3091  transition: all 0.2s;
3092}
3093
3094.history-item:hover {
3095  background: rgba(255, 255, 255, 0.15);
3096  transform: translateX(4px);
3097}
3098
3099.history-item.success {
3100  border-left: 4px solid #10b981;
3101}
3102
3103.history-item.failure {
3104  border-left: 4px solid #ef4444;
3105}
3106
3107.history-header {
3108  display: flex;
3109  justify-content: space-between;
3110  align-items: center;
3111  margin-bottom: 12px;
3112  flex-wrap: wrap;
3113  gap: 12px;
3114}
3115
3116.history-title {
3117  display: flex;
3118  align-items: center;
3119  gap: 12px;
3120  flex-wrap: wrap;
3121}
3122
3123.edit-number {
3124  font-weight: 600;
3125  color: white;
3126  font-size: 15px;
3127}
3128
3129.script-name {
3130  font-size: 13px;
3131  color: white;
3132  background: rgba(255, 255, 255, 0.2);
3133  padding: 4px 10px;
3134  border-radius: 8px;
3135  font-weight: 600;
3136  border: 1px solid rgba(255, 255, 255, 0.3);
3137}
3138
3139.current-badge {
3140  padding: 4px 10px;
3141  background: rgba(16, 185, 129, 0.9);
3142  color: white;
3143  border-radius: 12px;
3144  font-size: 12px;
3145  font-weight: 600;
3146}
3147
3148.success-badge {
3149  background: rgba(16, 185, 129, 0.9);
3150}
3151
3152.failure-badge {
3153  background: rgba(239, 68, 68, 0.9);
3154}
3155
3156.history-meta {
3157  display: flex;
3158  align-items: center;
3159  gap: 12px;
3160  flex-wrap: wrap;
3161}
3162
3163.version-badge {
3164  padding: 4px 10px;
3165  background: rgba(255, 255, 255, 0.2);
3166  color: white;
3167  border-radius: 8px;
3168  font-size: 12px;
3169  font-weight: 600;
3170  border: 1px solid rgba(255, 255, 255, 0.3);
3171}
3172
3173.timestamp {
3174  font-size: 13px;
3175  color: rgba(255, 255, 255, 0.8);
3176}
3177
3178details {
3179  margin-top: 8px;
3180}
3181
3182details summary {
3183  cursor: pointer;
3184  font-weight: 600;
3185  color: white;
3186  font-size: 14px;
3187  display: flex;
3188  align-items: center;
3189  gap: 8px;
3190  padding: 8px 0;
3191  user-select: none;
3192  transition: opacity 0.2s;
3193}
3194
3195details summary:hover {
3196  opacity: 0.8;
3197}
3198
3199.summary-icon {
3200  font-size: 10px;
3201  transition: transform 0.2s;
3202}
3203
3204details[open] .summary-icon {
3205  transform: rotate(90deg);
3206}
3207
3208details summary::-webkit-details-marker {
3209  display: none;
3210}
3211
3212.history-code {
3213  margin-top: 12px;
3214  font-size: 13px;
3215}
3216
3217.empty-history {
3218  text-align: center;
3219  padding: 40px;
3220  color: rgba(255, 255, 255, 0.7);
3221  font-style: italic;
3222  background: rgba(255, 255, 255, 0.1);
3223  border-radius: 12px;
3224  border: 1px solid rgba(255, 255, 255, 0.2);
3225}
3226
3227.empty-history-item {
3228  text-align: center;
3229  padding: 20px;
3230  color: rgba(255, 255, 255, 0.7);
3231  font-style: italic;
3232  font-size: 14px;
3233}
3234
3235/* =========================================================
3236   ERROR MESSAGES
3237   ========================================================= */
3238
3239.error-message {
3240  margin-top: 12px;
3241  padding: 12px;
3242  background: rgba(239, 68, 68, 0.2);
3243  border-left: 3px solid #ef4444;
3244  border-radius: 8px;
3245}
3246
3247.error-label {
3248  font-size: 12px;
3249  font-weight: 600;
3250  color: #ff6b6b;
3251  text-transform: uppercase;
3252  letter-spacing: 0.5px;
3253  margin-bottom: 8px;
3254}
3255
3256.error-code {
3257  color: #ffcccb;
3258  background: rgba(0, 0, 0, 0.2);
3259  padding: 12px;
3260  border-radius: 6px;
3261  font-size: 13px;
3262  margin: 0;
3263}
3264
3265/* =========================================================
3266   RESPONSIVE
3267   ========================================================= */
3268
3269${responsiveBase}
3270
3271@media (max-width: 640px) {
3272  .container {
3273    max-width: 100%;
3274  }
3275
3276  .header,
3277  .section {
3278    padding: 20px;
3279  }
3280
3281  .action-bar {
3282    flex-direction: column;
3283  }
3284
3285  .btn-execute,
3286  .btn-save {
3287    width: 100%;
3288    justify-content: center;
3289  }
3290
3291  .history-header {
3292    flex-direction: column;
3293    align-items: flex-start;
3294  }
3295
3296  .history-title {
3297    width: 100%;
3298  }
3299
3300  pre {
3301    font-size: 12px;
3302    padding: 16px;
3303  }
3304
3305  textarea {
3306    min-height: 200px;
3307  }
3308}
3309
3310@media (min-width: 641px) and (max-width: 1024px) {
3311  .container {
3312    max-width: 800px;
3313  }
3314}
3315  </style>
3316</head>
3317<body>
3318  <div class="container">
3319    <!-- Back Navigation -->
3320    <div class="back-nav">
3321      <a href="/api/v1/node/${nodeId}${qsWithQ}" class="back-link">
3322        ← Back to Node
3323      </a>
3324      <a href="/api/v1/node/${nodeId}/scripts/help${qsWithQ}" class="back-link">
3325        📚 Help
3326      </a>
3327    </div>
3328
3329    <!-- Header -->
3330    <div class="header">
3331      <h1>${script.name}</h1>
3332      <div class="script-id">ID: ${script.id}</div>
3333    </div>
3334
3335    <!-- Current Script -->
3336    <div class="section">
3337      <div class="code-container">
3338        <div class="code-header">
3339          <div class="code-label">Current Script</div>
3340          <button class="btn-copy" onclick="copyCode()">📋 Copy</button>
3341        </div>
3342        <pre id="scriptCode">${script.script}</pre>
3343      </div>
3344
3345      <!-- Execute Button -->
3346      <div class="action-bar">
3347        <form
3348          method="POST"
3349          action="/api/v1/node/${nodeId}/script/${script.id}/execute${qsWithQ}"
3350          onsubmit="return confirm('Execute this script now?')"
3351          style="margin: 0;"
3352        >
3353          <button type="submit" class="btn-execute">Run Script</button>
3354        </form>
3355      </div>
3356    </div>
3357
3358    <!-- Edit Script -->
3359    <div class="section">
3360      <div class="section-title">Edit Script</div>
3361      <form
3362        method="POST"
3363        action="/api/v1/node/${nodeId}/script/${script.id}/edit${qsWithQ}"
3364        class="edit-form"
3365      >
3366        <div class="form-group">
3367          <label class="form-label">Script Name</label>
3368          <input
3369            type="text"
3370            name="name"
3371            value="${script.name}"
3372            placeholder="Enter script name"
3373            required
3374          />
3375        </div>
3376
3377        <div class="form-group">
3378          <label class="form-label">Script Code</label>
3379          <textarea
3380            name="script"
3381            rows="14"
3382            placeholder="// Enter your script code here"
3383            required
3384          >${script.script}</textarea>
3385        </div>
3386
3387        <button type="submit" class="btn-save">💾 Save Changes</button>
3388      </form>
3389    </div>
3390
3391    <!-- Execution History -->
3392    <div class="section">
3393      <div class="section-title">Execution History</div>
3394      <ul class="history-list">
3395        ${executionHistoryHtml}
3396      </ul>
3397    </div>
3398
3399    <!-- Edit History -->
3400    <div class="section">
3401      <div class="section-title">Edit History</div>
3402      <ul class="history-list">
3403        ${editHistoryHtml}
3404      </ul>
3405    </div>
3406  </div>
3407
3408  <script>
3409    function copyCode() {
3410      const code = document.getElementById('scriptCode').textContent;
3411      navigator.clipboard.writeText(code).then(() => {
3412        const btn = document.querySelector('.btn-copy');
3413        const originalText = btn.textContent;
3414        btn.textContent = '✓ Copied!';
3415        setTimeout(() => {
3416          btn.textContent = originalText;
3417        }, 2000);
3418      });
3419    }
3420  </script>
3421</body>
3422</html>
3423      `;
3424}
3425
3426/* ================================================================== */
3427/* 5. renderScriptHelp                                                 */
3428/* ================================================================== */
3429
3430export function renderScriptHelp({ nodeId, nodeName, data, qsWithQ }) {
3431  return `
3432<!DOCTYPE html>
3433<html lang="en">
3434<head>
3435  <meta charset="UTF-8">
3436  <meta name="viewport" content="width=device-width, initial-scale=1.0">
3437  <meta name="theme-color" content="#667eea">
3438  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
3439  <title>Script Help — ${nodeName}</title>
3440  <style>
3441${baseStyles}
3442.container { max-width: 1100px; }
3443
3444/* =========================================================
3445   UNIFIED GLASS BUTTON SYSTEM
3446   ========================================================= */
3447
3448.glass-btn,
3449button,
3450.back-link,
3451.quick-nav-item,
3452.btn-copy {
3453  position: relative;
3454  overflow: hidden;
3455
3456  padding: 10px 20px;
3457  border-radius: 980px;
3458
3459  display: inline-flex;
3460  align-items: center;
3461  justify-content: center;
3462  white-space: nowrap;
3463
3464  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3465  backdrop-filter: blur(22px) saturate(140%);
3466  -webkit-backdrop-filter: blur(22px) saturate(140%);
3467
3468  color: white;
3469  text-decoration: none;
3470  font-family: inherit;
3471
3472  font-size: 15px;
3473  font-weight: 500;
3474  letter-spacing: -0.2px;
3475
3476  border: 1px solid rgba(255, 255, 255, 0.28);
3477
3478  box-shadow:
3479    0 8px 24px rgba(0, 0, 0, 0.12),
3480    inset 0 1px 0 rgba(255, 255, 255, 0.25);
3481
3482  cursor: pointer;
3483
3484  transition:
3485    background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
3486    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
3487    box-shadow 0.3s ease;
3488}
3489
3490/* Liquid light layer */
3491.glass-btn::before,
3492button::before,
3493.back-link::before,
3494.quick-nav-item::before,
3495.btn-copy::before {
3496  content: "";
3497  position: absolute;
3498  inset: -40%;
3499
3500  background:
3501    radial-gradient(
3502      120% 60% at 0% 0%,
3503      rgba(255, 255, 255, 0.35),
3504      transparent 60%
3505    ),
3506    linear-gradient(
3507      120deg,
3508      transparent 30%,
3509      rgba(255, 255, 255, 0.25),
3510      transparent 70%
3511    );
3512
3513  opacity: 0;
3514  transform: translateX(-30%) translateY(-10%);
3515  transition:
3516    opacity 0.35s ease,
3517    transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
3518
3519  pointer-events: none;
3520}
3521
3522/* Hover motion */
3523.glass-btn:hover,
3524button:hover,
3525.back-link:hover,
3526.quick-nav-item:hover,
3527.btn-copy:hover {
3528  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
3529  transform: translateY(-1px);
3530  animation: waterDrift 2.2s ease-in-out infinite alternate;
3531}
3532
3533.glass-btn:hover::before,
3534button:hover::before,
3535.back-link:hover::before,
3536.quick-nav-item:hover::before,
3537.btn-copy:hover::before {
3538  opacity: 1;
3539  transform: translateX(30%) translateY(10%);
3540}
3541
3542/* Active press */
3543.glass-btn:active,
3544button:active,
3545.btn-copy:active,
3546.quick-nav-item:active {
3547  background: rgba(var(--glass-water-rgb), 0.45);
3548  transform: translateY(0);
3549  animation: none;
3550}
3551
3552@keyframes waterDrift {
3553  0% { transform: translateY(-1px); }
3554  100% { transform: translateY(1px); }
3555}
3556
3557/* Button variants */
3558.btn-copy {
3559  padding: 6px 12px;
3560  font-size: 13px;
3561}
3562
3563/* =========================================================
3564   CONTENT CARDS
3565   ========================================================= */
3566
3567.header,
3568.section {
3569  background: rgba(255, 255, 255, 0.15);
3570  backdrop-filter: blur(22px) saturate(140%);
3571  -webkit-backdrop-filter: blur(22px) saturate(140%);
3572  border-radius: 14px;
3573  padding: 28px;
3574  border: 1px solid rgba(255, 255, 255, 0.28);
3575  color: white;
3576  margin-bottom: 24px;
3577}
3578
3579.header h1 {
3580  font-size: 28px;
3581  font-weight: 600;
3582  letter-spacing: -0.5px;
3583  line-height: 1.3;
3584  margin-bottom: 8px;
3585  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3586  color: white;
3587}
3588
3589.header h1::before {
3590  content: '📚 ';
3591  font-size: 26px;
3592}
3593
3594.header-subtitle {
3595  font-size: 14px;
3596  color: rgba(255, 255, 255, 0.8);
3597}
3598
3599.section-title {
3600  font-size: 18px;
3601  font-weight: 600;
3602  color: white;
3603  margin-bottom: 16px;
3604  letter-spacing: -0.3px;
3605  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3606}
3607
3608.section-description {
3609  font-size: 14px;
3610  color: rgba(255, 255, 255, 0.9);
3611  line-height: 1.6;
3612  margin-bottom: 16px;
3613  padding: 12px;
3614  background: rgba(255, 255, 255, 0.1);
3615  border-radius: 8px;
3616  border-left: 3px solid rgba(255, 255, 255, 0.5);
3617}
3618
3619/* =========================================================
3620   NAV
3621   ========================================================= */
3622
3623.back-nav {
3624  display: flex;
3625  gap: 12px;
3626  margin-bottom: 20px;
3627  flex-wrap: wrap;
3628}
3629
3630/* =========================================================
3631   QUICK NAV
3632   ========================================================= */
3633
3634.quick-nav {
3635  display: grid;
3636  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
3637  gap: 12px;
3638}
3639
3640.quick-nav-item {
3641  padding: 12px 16px;
3642  text-align: center;
3643}
3644
3645/* =========================================================
3646   TABLES
3647   ========================================================= */
3648
3649table {
3650  width: 100%;
3651  border-collapse: collapse;
3652  background: rgba(255, 255, 255, 0.1);
3653  border-radius: 8px;
3654  overflow: hidden;
3655  border: 1px solid rgba(255, 255, 255, 0.2);
3656}
3657
3658thead {
3659  background: rgba(255, 255, 255, 0.15);
3660}
3661
3662th {
3663  padding: 14px 16px;
3664  text-align: left;
3665  font-weight: 600;
3666  color: white;
3667  font-size: 14px;
3668  text-transform: uppercase;
3669  letter-spacing: 0.5px;
3670  border-bottom: 2px solid rgba(255, 255, 255, 0.2);
3671}
3672
3673td {
3674  padding: 14px 16px;
3675  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
3676  font-size: 14px;
3677  line-height: 1.6;
3678  vertical-align: top;
3679  color: rgba(255, 255, 255, 0.95);
3680}
3681
3682tbody tr:last-child td {
3683  border-bottom: none;
3684}
3685
3686tbody tr {
3687  transition: background 0.2s;
3688}
3689
3690tbody tr:hover {
3691  background: rgba(255, 255, 255, 0.05);
3692}
3693
3694code {
3695  background: rgba(255, 255, 255, 0.2);
3696  padding: 3px 8px;
3697  border-radius: 4px;
3698  font-size: 13px;
3699  font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
3700  color: white;
3701  font-weight: 600;
3702  border: 1px solid rgba(255, 255, 255, 0.3);
3703}
3704
3705pre {
3706  background: rgba(0, 0, 0, 0.3);
3707  color: #e0e0e0;
3708  padding: 20px;
3709  border-radius: 12px;
3710  overflow-x: auto;
3711  font-size: 14px;
3712  line-height: 1.6;
3713  font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
3714  border: 1px solid rgba(255, 255, 255, 0.1);
3715  margin-top: 12px;
3716}
3717
3718pre code {
3719  background: none;
3720  padding: 0;
3721  color: inherit;
3722  font-weight: normal;
3723  border: none;
3724}
3725
3726/* =========================================================
3727   INFO BOX
3728   ========================================================= */
3729
3730.info-box {
3731  background: rgba(255, 193, 7, 0.2);
3732  padding: 16px;
3733  border-radius: 10px;
3734  border-left: 4px solid #ffa500;
3735  margin-bottom: 16px;
3736}
3737
3738.info-box-title {
3739  font-weight: 600;
3740  color: #ffd700;
3741  margin-bottom: 8px;
3742  display: flex;
3743  align-items: center;
3744  gap: 8px;
3745}
3746
3747.info-box-title::before {
3748  content: '⚠️';
3749  font-size: 16px;
3750}
3751
3752.info-box-content {
3753  font-size: 14px;
3754  color: rgba(255, 255, 255, 0.9);
3755  line-height: 1.6;
3756}
3757
3758/* =========================================================
3759   EXAMPLE BOX
3760   ========================================================= */
3761
3762.example-box {
3763  margin-top: 12px;
3764}
3765
3766.example-header {
3767  display: flex;
3768  justify-content: space-between;
3769  align-items: center;
3770  margin-bottom: 12px;
3771}
3772
3773.example-label {
3774  font-size: 14px;
3775  font-weight: 600;
3776  color: rgba(255, 255, 255, 0.7);
3777  text-transform: uppercase;
3778  letter-spacing: 0.5px;
3779}
3780
3781/* =========================================================
3782   RESPONSIVE
3783   ========================================================= */
3784
3785${responsiveBase}
3786
3787@media (max-width: 640px) {
3788  .container {
3789    max-width: 100%;
3790  }
3791
3792  .header,
3793  .section {
3794    padding: 20px;
3795  }
3796
3797  table {
3798    font-size: 13px;
3799  }
3800
3801  th, td {
3802    padding: 10px;
3803  }
3804
3805  pre {
3806    font-size: 12px;
3807    padding: 16px;
3808  }
3809
3810  .quick-nav {
3811    grid-template-columns: 1fr;
3812  }
3813}
3814
3815@media (min-width: 641px) and (max-width: 1024px) {
3816  .container {
3817    max-width: 900px;
3818  }
3819}
3820  </style>
3821</head>
3822<body>
3823  <div class="container">
3824    <!-- Back Navigation -->
3825    <div class="back-nav">
3826      <a href="/api/v1/node/${nodeId}${qsWithQ}" class="back-link">
3827        ← Back to Node
3828      </a>
3829    </div>
3830
3831    <!-- Header -->
3832    <div class="header">
3833      <h1>Script Help</h1>
3834      <div class="header-subtitle">Learn how to write scripts for your nodes</div>
3835    </div>
3836
3837    <!-- Quick Navigation -->
3838    <div class="section">
3839      <div class="section-title">Quick Jump</div>
3840      <div class="quick-nav">
3841        <a href="#node-data" class="quick-nav-item">Node Data</a>
3842        <a href="#version-properties" class="quick-nav-item">Version Properties</a>
3843        <a href="#other-properties" class="quick-nav-item">Other Properties</a>
3844        <a href="#functions" class="quick-nav-item">Built-in Functions</a>
3845        <a href="#example" class="quick-nav-item">Example Script</a>
3846      </div>
3847    </div>
3848
3849    <!-- Node Data -->
3850    <div class="section" id="node-data">
3851      <div class="section-title">Accessing Node Data</div>
3852
3853      <div class="info-box">
3854        <div class="info-box-title">Important</div>
3855        <div class="info-box-content">
3856          ${data.importantNote}
3857        </div>
3858      </div>
3859
3860      <table>
3861        <thead>
3862          <tr>
3863            <th>Property</th>
3864            <th>Description</th>
3865          </tr>
3866        </thead>
3867        <tbody>
3868          ${data.nodeProperties.basic
3869            .map(
3870              (item) => `
3871            <tr>
3872              <td><code>${item.property}</code></td>
3873              <td>${item.description}</td>
3874            </tr>
3875          `,
3876            )
3877            .join("")}
3878        </tbody>
3879      </table>
3880    </div>
3881
3882    <!-- Version Properties -->
3883    <div class="section" id="version-properties">
3884      <div class="section-title">Version Properties</div>
3885
3886      <div class="section-description">
3887        Access version data using index <code>i</code>. Use <code>0</code> for the first version,
3888        or <code>0</code> for the latest version.
3889      </div>
3890
3891      <table>
3892        <thead>
3893          <tr>
3894            <th>Property</th>
3895            <th>Description</th>
3896          </tr>
3897        </thead>
3898        <tbody>
3899          ${data.nodeProperties.version
3900            .map(
3901              (item) => `
3902            <tr>
3903              <td><code>${item.property}</code></td>
3904              <td>${item.description}${
3905                item.example ? `: <code>${item.example}</code>` : ""
3906              }</td>
3907            </tr>
3908          `,
3909            )
3910            .join("")}
3911        </tbody>
3912      </table>
3913    </div>
3914
3915    <!-- Other Properties -->
3916    <div class="section" id="other-properties">
3917      <div class="section-title">Other Node Properties</div>
3918
3919      <table>
3920        <thead>
3921          <tr>
3922            <th>Property</th>
3923            <th>Description</th>
3924          </tr>
3925        </thead>
3926        <tbody>
3927          ${data.nodeProperties.other
3928            .map(
3929              (item) => `
3930            <tr>
3931              <td><code>${item.property}</code></td>
3932              <td>${item.description}${
3933                item.example ? `: <code>${item.example}</code>` : ""
3934              }</td>
3935            </tr>
3936          `,
3937            )
3938            .join("")}
3939        </tbody>
3940      </table>
3941    </div>
3942
3943    <!-- Built-in Functions -->
3944    <div class="section" id="functions">
3945      <div class="section-title">Built-in Functions</div>
3946
3947      <div class="section-description">
3948        These functions are available globally in all scripts and provide access to node operations.
3949      </div>
3950
3951      <table>
3952        <thead>
3953          <tr>
3954            <th style="width: 40%;">Function</th>
3955            <th>Description</th>
3956          </tr>
3957        </thead>
3958        <tbody>
3959          ${data.builtInFunctions
3960            .map(
3961              (fn) => `
3962            <tr>
3963              <td><code>${fn.name}</code></td>
3964              <td>${fn.description}</td>
3965            </tr>
3966          `,
3967            )
3968            .join("")}
3969        </tbody>
3970      </table>
3971    </div>
3972
3973    <!-- Example Script -->
3974    <div class="section" id="example">
3975      <div class="section-title">Example Script</div>
3976
3977      <div class="section-description">
3978        This example demonstrates a script that tapers a value over time by increasing it by 5%
3979        each time it runs, then schedules itself to run again.
3980      </div>
3981
3982      <div class="example-box">
3983        <div class="example-header">
3984          <div class="example-label">Tapering Script</div>
3985          <button class="btn-copy" onclick="copyExample()">📋 Copy</button>
3986        </div>
3987        <pre id="exampleCode">${data.exampleScript}</pre>
3988      </div>
3989    </div>
3990  </div>
3991
3992  <script>
3993    function copyExample() {
3994      const code = document.getElementById('exampleCode').textContent;
3995      navigator.clipboard.writeText(code).then(() => {
3996        const btn = document.querySelector('.btn-copy');
3997        const originalText = btn.textContent;
3998        btn.textContent = '✓ Copied!';
3999        setTimeout(() => {
4000          btn.textContent = originalText;
4001        }, 2000);
4002      });
4003    }
4004
4005    // Smooth scroll for quick nav
4006    document.querySelectorAll('.quick-nav-item').forEach(link => {
4007      link.addEventListener('click', (e) => {
4008        e.preventDefault();
4009        const target = document.querySelector(link.getAttribute('href'));
4010        target.scrollIntoView({ behavior: 'smooth', block: 'start' });
4011      });
4012    });
4013  </script>
4014</body>
4015</html>
4016      `;
4017}
4018
1import { baseStyles } from "./baseStyles.js";
2
3export function errorHtml(status, title, message) {
4  return `<!DOCTYPE html>
5<html lang="en">
6<head>
7<meta charset="UTF-8">
8<meta name="viewport" content="width=device-width, initial-scale=1.0">
9<meta name="theme-color" content="#736fe6">
10<meta name="apple-mobile-web-app-capable" content="yes">
11<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
12<title>${title} - TreeOS</title>
13<style>
14${baseStyles}
15
16/* ── 404 page overrides on base ── */
17html, body { height: 100%; }
18body {
19  color: white;
20  display: flex;
21  align-items: center;
22  justify-content: center;
23}
24
25/* Hide base orbs for centered layout */
26body::before, body::after { display: none; }
27.card {
28  background: rgba(255,255,255,0.12);
29  backdrop-filter: blur(20px);
30  -webkit-backdrop-filter: blur(20px);
31  border: 1px solid rgba(255,255,255,0.2);
32  border-radius: 20px;
33  padding: 48px 40px;
34  max-width: 480px;
35  width: 100%;
36  text-align: center;
37  box-shadow: 0 20px 60px rgba(0,0,0,0.2);
38}
39.code {
40  display: inline-block;
41  margin-bottom: 12px;
42  font-size: 13px;
43  font-weight: 700;
44  color: #dc2626;
45  letter-spacing: 1px;
46  background: rgba(255,255,255,0.18);
47  border-radius: 10px;
48  padding: 6px 16px;
49}
50.icon { font-size: 48px; margin-bottom: 8px; }
51.brand {
52  font-size: 28px;
53  font-weight: 700;
54  color: white;
55  margin-bottom: 20px;
56  text-decoration: none;
57  display: block;
58}
59.brand:hover { opacity: 0.9; }
60h1 {
61  font-size: 22px;
62  font-weight: 700;
63  margin-bottom: 12px;
64  color: white;
65}
66p {
67  font-size: 15px;
68  line-height: 1.6;
69  color: rgba(255,255,255,0.75);
70  margin-bottom: 28px;
71}
72.btn {
73  display: inline-block;
74  padding: 12px 32px;
75  border-radius: 980px;
76  background: rgba(255,255,255,0.18);
77  border: 1px solid rgba(255,255,255,0.25);
78  color: white;
79  font-size: 14px;
80  font-weight: 600;
81  text-decoration: none;
82  transition: all 0.2s;
83}
84.btn:hover {
85  background: rgba(255,255,255,0.28);
86  transform: translateY(-1px);
87}
88.ai-note {
89  margin-top: 20px;
90  padding: 12px 16px;
91  background: rgba(239,68,68,0.2);
92  border: 1px solid rgba(239,68,68,0.35);
93  border-radius: 12px;
94  font-size: 13px;
95  line-height: 1.5;
96  color: rgba(255,255,255,0.85);
97}
98@keyframes heroGrow {
99  0%, 100% { transform: scale(1); }
100  50% { transform: scale(1.06); }
101}
102.icon { animation: heroGrow 4.5s ease-in-out infinite; }
103</style>
104</head>
105<body>
106<div class="card">
107  <div class="code">${status}</div>
108  <a href="/" class="brand" onclick="event.preventDefault(); window.top.location.href='/';">
109    <div class="icon">\u{1F333}</div>
110    Tree
111  </a>
112  <h1>${title}</h1>
113  <p>${message}</p>
114  <a href="/" class="btn" onclick="event.preventDefault(); window.top.location.href='/';">Back to Home</a>
115  <div class="ai-note">If this was triggered by an AI automated process, wait a moment. You may be redirected shortly.</div>
116</div>
117</body>
118</html>`;
119}
120
1/* --------------------------------------------------------- */
2/* HTML renderers for notes pages                            */
3/* --------------------------------------------------------- */
4
5import mime from "mime-types";
6import { getLandUrl } from "../../../canopy/identity.js";
7import { baseStyles, backNavStyles } from "./baseStyles.js";
8import { escapeHtml, renderMedia } from "./utils.js";
9
10function renderBookNode(node, depth, token, version) {
11  const level = Math.min(depth, 5);
12  const H = `h${level}`;
13  const qs = token ? `?token=${token}&html` : `?html`;
14
15  let html = `
16    <section class="book-section depth-${depth}" id="toc-${node.nodeId}">
17      <${H}>${escapeHtml(node.nodeName ?? node.nodeId)}</${H}>
18  `;
19
20  for (const note of node.notes) {
21    const noteUrl = `/api/v1/node/${node.nodeId}/${note.version}/notes/${note.noteId}${qs}`;
22
23    if (note.type === "text") {
24      html += `
25        <div class="note-content">
26          <a href="${noteUrl}" class="note-link">${escapeHtml(note.content)}</a>
27        </div>
28      `;
29    }
30
31    if (note.type === "file") {
32      const fileUrl = `/api/v1/uploads/${note.content}${
33        token ? `?token=${token}` : ""
34      }`;
35      const mimeType = mime.lookup(note.content) || "";
36
37      html += `
38        <div class="file-container">
39          <a href="${noteUrl}" class="note-link file-link">${escapeHtml(note.content)}</a>
40          ${renderMedia(fileUrl, mimeType)}
41        </div>
42      `;
43    }
44  }
45
46  for (const child of node.children) {
47    html += renderBookNode(child, depth + 1, token, version);
48  }
49
50  html += `</section>`;
51  return html;
52}
53
54function renderToc(node, maxDepth, depth = 1, isRoot = false) {
55  const children = node.children || [];
56  const hasChildren = children.length > 0 && (maxDepth === 0 || isRoot || depth < maxDepth);
57
58  const childList = hasChildren
59    ? `<ul class="toc-list">${children.map((c) => renderToc(c, maxDepth, isRoot ? 1 : depth + 1, false)).join("")}</ul>`
60    : "";
61
62  if (isRoot) return childList;
63
64  const name = escapeHtml(node.nodeName ?? node.nodeId);
65  const link = `<a href="javascript:void(0)" onclick="tocScroll('toc-${node.nodeId}')" class="toc-link">${name}</a>`;
66
67  return `<li>${link}${childList}</li>`;
68}
69
70function renderTocBlock(book, maxDepth) {
71  const inner = renderToc(book, maxDepth, 1, true);
72  return `<nav class="book-toc"><div class="toc-title">Table of Contents</div>${inner}</nav>`;
73}
74
75function getBookDepth(node, depth = 0) {
76  const children = node.children || [];
77  if (children.length === 0) return depth;
78  return Math.max(...children.map((c) => getBookDepth(c, depth + 1)));
79}
80
81const parseBool = (v) => v === "true";
82
83function normalizeStatusFilters(query) {
84  const parse = (v) =>
85    v === "true" ? true : v === "false" ? false : undefined;
86
87  const filters = {
88    active: parse(query.active),
89    trimmed: parse(query.trimmed),
90    completed: parse(query.completed),
91  };
92
93  const hasAny = Object.values(filters).some((v) => v !== undefined);
94  return hasAny ? filters : null;
95}
96
97/* --------------------------------------------------------- */
98/* Exported render functions                                  */
99/* --------------------------------------------------------- */
100
101export function renderEditorPage({
102  nodeId,
103  version,
104  noteId,
105  noteContent,
106  qs,
107  tokenQS,
108  originalLength,
109}) {
110  const isNew = !noteId;
111  const safeContent = (noteContent || "")
112    .replace(/&/g, "&amp;")
113    .replace(/</g, "&lt;")
114    .replace(/>/g, "&gt;")
115    .replace(/"/g, "&quot;");
116
117  return `<!DOCTYPE html>
118<html lang="en">
119<head>
120<meta charset="UTF-8">
121<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
122<meta name="theme-color" content="#667eea">
123<title>${isNew ? "New Note" : "Edit Note"} · Editor</title>
124<style>
125${baseStyles}
126
127/* ── Editor-specific overrides on base ── */
128:root {
129  --glass-rgb: 115, 111, 230;
130  --sidebar-w: 280px;
131  --toolbar-h: 52px;
132  --bottombar-h: 44px;
133  --editor-font-size: 13px;
134  --editor-line-height: 2.1;
135  --editor-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
136}
137
138html, body { height: 100%; overflow: hidden; }
139body {
140  font-family: var(--editor-font);
141  color: white; display: flex; flex-direction: column;
142  height: 100vh; height: 100dvh;
143  padding: 0; min-height: auto;
144}
145
146/* Override base orbs: editor uses a single subtler orb */
147body::before {
148  opacity: 0.05;
149  animation-duration: 25s;
150}
151body::before { transform: none; }
152body::after { display: none; }
153
154/* ── TOOLBAR ─────────────────── */
155.toolbar {
156  height: var(--toolbar-h); display: flex; align-items: center; gap: 6px;
157  padding: 0 12px;
158  background: rgba(var(--glass-rgb), 0.35);
159  backdrop-filter: blur(22px) saturate(140%);
160  -webkit-backdrop-filter: blur(22px) saturate(140%);
161  border-bottom: 1px solid rgba(255,255,255,0.15);
162  flex-shrink: 0; z-index: 20;
163  overflow-x: auto; overflow-y: hidden;
164  -webkit-overflow-scrolling: touch;
165}
166.toolbar::-webkit-scrollbar { display: none; }
167
168.tb-btn {
169  padding: 6px 12px; border-radius: 8px;
170  border: 1px solid rgba(255,255,255,0.15);
171  background: rgba(255,255,255,0.08);
172  color: rgba(255,255,255,0.8);
173  font-size: 13px; font-weight: 600; font-family: inherit;
174  cursor: pointer; transition: all 0.2s;
175  white-space: nowrap; flex-shrink: 0;
176  display: inline-flex; align-items: center; gap: 4px;
177}
178.tb-btn:hover { background: rgba(255,255,255,0.18); color: white; }
179.tb-btn.active { background: rgba(72,187,178,0.35); border-color: rgba(72,187,178,0.5); color: white; }
180
181.tb-sep { width: 1px; height: 24px; background: rgba(255,255,255,0.12); flex-shrink: 0; margin: 0 4px; }
182.tb-range-wrap { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
183.tb-range-label { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap; }
184
185.tb-range {
186  -webkit-appearance: none; appearance: none;
187  width: 80px; height: 4px;
188  background: rgba(255,255,255,0.2);
189  border-radius: 4px; outline: none; cursor: pointer;
190}
191.tb-range::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: white; box-shadow: 0 2px 6px rgba(0,0,0,0.2); cursor: pointer; }
192.tb-range::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: white; box-shadow: 0 2px 6px rgba(0,0,0,0.2); border: none; cursor: pointer; }
193
194.tb-spacer { flex: 1; min-width: 8px; }
195
196.tb-copy {
197  padding: 6px 12px; border-radius: 8px;
198  border: 1px solid rgba(255,255,255,0.15);
199  background: rgba(255,255,255,0.08);
200  color: rgba(255,255,255,0.8);
201  font-size: 13px; font-weight: 600; font-family: inherit;
202  cursor: pointer; transition: all 0.2s;
203  white-space: nowrap; flex-shrink: 0;
204  display: inline-flex; align-items: center; gap: 4px;
205  min-width: 36px; justify-content: center;
206}
207.tb-copy:hover { background: rgba(255,255,255,0.18); color: white; }
208.tb-copy.copied { background: rgba(72,187,120,0.3); border-color: rgba(72,187,120,0.5); color: white; }
209
210@media (max-width: 768px) {
211  .tb-copy { padding: 6px 10px; }
212}
213
214.tb-back {
215  padding: 6px 14px; border-radius: 8px;
216  border: 1px solid rgba(255,255,255,0.15);
217  background: rgba(255,255,255,0.08);
218  color: rgba(255,255,255,0.8);
219  font-size: 13px; font-weight: 600; font-family: inherit;
220  cursor: pointer; text-decoration: none; transition: all 0.2s;
221  flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px;
222}
223.tb-back:hover { background: rgba(255,255,255,0.18); color: white; }
224
225/* ── MAIN ────────────────────── */
226.main { flex: 1; display: flex; overflow: hidden; position: relative; }
227
228/* ── SIDEBAR ─────────────────── */
229.sidebar {
230  width: var(--sidebar-w); flex-shrink: 0;
231  display: flex; flex-direction: column;
232  background: rgba(var(--glass-rgb), 0.22);
233  backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
234  border-right: 1px solid rgba(255,255,255,0.12);
235  overflow: hidden;
236  z-index: 15;
237}
238.sidebar.hidden { display: none; }
239
240.sidebar-header {
241  padding: 16px; border-bottom: 1px solid rgba(255,255,255,0.1);
242  display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
243}
244.sidebar-title { font-size: 14px; font-weight: 700; color: rgba(255,255,255,0.9); }
245
246.sidebar-close {
247  width: 28px; height: 28px; border-radius: 8px;
248  border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.08);
249  color: rgba(255,255,255,0.6); font-size: 14px;
250  cursor: pointer; display: inline-flex; align-items: center; justify-content: center; transition: all 0.2s;
251}
252.sidebar-close:hover { background: rgba(255,255,255,0.2); color: white; }
253
254.sidebar-list { flex: 1; overflow-y: auto; padding: 8px; }
255.sidebar-list::-webkit-scrollbar { width: 4px; }
256.sidebar-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
257
258.note-item {
259  display: flex; align-items: center; gap: 10px;
260  padding: 10px 12px; border-radius: 10px;
261  cursor: pointer; transition: all 0.2s;
262  border: 1px solid transparent; margin-bottom: 2px;
263}
264.note-item:hover { background: rgba(255,255,255,0.1); }
265.note-item.active { background: rgba(72,187,178,0.2); border-color: rgba(72,187,178,0.35); }
266
267.note-item-icon {
268  width: 32px; height: 32px; border-radius: 8px;
269  background: rgba(255,255,255,0.1);
270  display: flex; align-items: center; justify-content: center;
271  font-size: 14px; flex-shrink: 0;
272}
273.note-item-info { min-width: 0; flex: 1; }
274.note-item-username { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 2px; }
275.note-item-preview { font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.85); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
276.note-item-meta { font-size: 11px; color: rgba(255,255,255,0.4); margin-top: 2px; }
277
278.sidebar-new {
279  margin: 8px; padding: 10px; border-radius: 10px;
280  border: 2px dashed rgba(255,255,255,0.15); background: transparent;
281  color: rgba(255,255,255,0.5); font-size: 13px; font-weight: 600;
282  font-family: inherit; cursor: pointer; transition: all 0.2s;
283  text-align: center; flex-shrink: 0;
284}
285.sidebar-new:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.25); color: rgba(255,255,255,0.8); }
286
287/* ── EDITOR ──────────────────── */
288.editor-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
289
290.editor-scroll {
291  flex: 1; overflow-y: auto; overflow-x: hidden;
292  padding: 16px 16px; display: flex; justify-content: center;
293  -webkit-overflow-scrolling: touch;
294}
295.editor-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
296.editor-scroll::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); }
297.editor-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
298.editor-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
299
300/* Code mode: enable horizontal scroll on outer container */
301.editor-scroll.code-scroll-enabled {
302  overflow-x: auto;
303  justify-content: flex-start;
304}
305
306.editor-container { width: 100%; max-width: 100%; }
307
308/* Code mode: container expands to fit content */
309.editor-container.code-mode-active {
310  width: max-content;
311  min-width: 100%;
312  max-width: none;
313}
314
315/* ── LINE NUMBERS + EDITOR LAYOUT ── */
316.editor-with-lines {
317  display: flex;
318  width: 100%;
319}
320
321.editor-container.code-mode-active .editor-with-lines {
322  width: max-content;
323  min-width: 100%;
324}
325
326.line-numbers {
327  display: none;
328  flex-shrink: 0;
329  padding-right: 12px;
330  margin-right: 12px;
331  border-right: 1px solid rgba(255,255,255,0.03);
332  text-align: right;
333  user-select: none;
334  pointer-events: none;
335  color: rgba(255,255,255,0.3);
336  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
337  font-size: var(--editor-font-size);
338  line-height: var(--editor-line-height);
339}
340
341.line-numbers.show {
342  display: block;
343}
344
345.line-numbers span {
346  display: block;
347}
348
349.editor-code-scroll {
350  flex: 1;
351  min-width: 0;
352  overflow-x: visible;
353  overflow-y: visible;
354}
355
356.editor-code-scroll::-webkit-scrollbar { height: 8px; }
357.editor-code-scroll::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); }
358.editor-code-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
359.editor-code-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
360
361#editor {
362  width: 100%;
363  min-height: calc(100vh - var(--toolbar-h) - var(--bottombar-h) - 32px);
364  background: transparent; border: none; outline: none; resize: none;
365  color: rgba(255,255,255,0.95);
366  font-family: var(--editor-font);
367  font-size: var(--editor-font-size);
368  line-height: var(--editor-line-height);
369  caret-color: rgba(72,187,178,0.9);
370  padding: 0; -webkit-font-smoothing: antialiased;
371  overflow: hidden;
372}
373#editor::placeholder { color: rgba(255,255,255,0.25); font-style: italic; }
374#editor.mono {
375  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
376  white-space: pre;
377  word-wrap: normal;
378  overflow-wrap: normal;
379}
380
381/* ── BOTTOM BAR ──────────────── */
382.bottombar {
383  height: var(--bottombar-h);
384  display: flex; align-items: center; justify-content: space-between;
385  padding: 0 16px;
386  background: rgba(var(--glass-rgb), 0.3);
387  backdrop-filter: blur(22px) saturate(140%);
388  -webkit-backdrop-filter: blur(22px) saturate(140%);
389  border-top: 1px solid rgba(255,255,255,0.12);
390  flex-shrink: 0; z-index: 20; gap: 12px;
391}
392.bb-left, .bb-right { display: flex; align-items: center; gap: 12px; }
393.bb-stat { font-size: 12px; color: rgba(255,255,255,0.4); font-weight: 500; white-space: nowrap; }
394
395.bb-energy {
396  color: rgba(100,220,255,0.7); font-weight: 600;
397  padding: 2px 8px; background: rgba(100,220,255,0.1);
398  border-radius: 980px; border: 1px solid rgba(100,220,255,0.15);
399}
400
401.bb-status { font-size: 12px; font-weight: 600; white-space: nowrap; transition: color 0.3s; }
402.bb-status.saved { color: rgba(72,187,120,0.8); }
403.bb-status.unsaved { color: rgba(250,204,21,0.8); }
404.bb-status.saving { color: rgba(100,220,255,0.8); }
405.bb-status.error { color: rgba(239,68,68,0.8); }
406
407.save-btn {
408  padding: 6px 20px; border-radius: 980px;
409  border: 1px solid rgba(72,187,178,0.45); background: rgba(72,187,178,0.3);
410  color: white; font-size: 13px; font-weight: 700;
411  font-family: inherit; cursor: pointer; transition: all 0.2s; white-space: nowrap;
412}
413.save-btn:hover { background: rgba(72,187,178,0.45); transform: translateY(-1px); }
414.save-btn:active { transform: translateY(0); }
415.save-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
416
417.delete-btn {
418  padding: 6px 16px; border-radius: 980px;
419  border: 1px solid rgba(239,68,68,0.4); background: rgba(239,68,68,0.2);
420  color: rgba(255,255,255,0.8); font-size: 13px; font-weight: 600;
421  font-family: inherit; cursor: pointer; transition: all 0.2s;
422  white-space: nowrap; display: none;
423}
424.delete-btn:hover { background: rgba(239,68,68,0.35); color: white; }
425.delete-btn.show { display: inline-flex; }
426
427/* ── DELETE MODAL ────────────── */
428.modal-overlay {
429  display: none; position: fixed; inset: 0; z-index: 100;
430  background: rgba(0,0,0,0.6);
431  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
432  align-items: center; justify-content: center; padding: 20px;
433}
434.modal-overlay.show { display: flex; }
435
436.modal-box {
437  background: rgba(var(--glass-rgb), 0.5);
438  backdrop-filter: blur(22px) saturate(140%);
439  -webkit-backdrop-filter: blur(22px) saturate(140%);
440  border-radius: 20px; padding: 32px;
441  border: 1px solid rgba(255,255,255,0.28);
442  box-shadow: 0 20px 60px rgba(0,0,0,0.3);
443  max-width: 420px; width: 100%; text-align: center;
444}
445.modal-icon { font-size: 48px; margin-bottom: 16px; }
446.modal-title { font-size: 20px; font-weight: 700; color: white; margin-bottom: 8px; }
447.modal-text { font-size: 14px; color: rgba(255,255,255,0.7); line-height: 1.6; margin-bottom: 24px; }
448.modal-actions { display: flex; gap: 12px; justify-content: center; }
449
450.modal-btn {
451  padding: 10px 24px; border-radius: 980px;
452  font-size: 14px; font-weight: 600; font-family: inherit;
453  cursor: pointer; transition: all 0.2s; border: 1px solid;
454}
455.modal-btn-cancel { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.2); color: rgba(255,255,255,0.8); }
456.modal-btn-cancel:hover { background: rgba(255,255,255,0.22); color: white; }
457.modal-btn-delete { background: rgba(239,68,68,0.3); border-color: rgba(239,68,68,0.5); color: white; }
458.modal-btn-delete:hover { background: rgba(239,68,68,0.5); }
459
460/* ── ZEN ─────────────────────── */
461body.zen .toolbar { display: none; }
462body.zen .sidebar { display: none; }
463body.zen .bottombar { opacity: 0; transition: opacity 0.3s; }
464body.zen:hover .bottombar { opacity: 1; }
465body.zen .editor-scroll { padding: 24px; }
466
467/* ── ZEN EXIT BUTTON (mobile only) ── */
468.zen-exit-btn {
469  display: none;
470  position: fixed;
471  top: 16px;
472  right: 16px;
473  width: 44px;
474  height: 44px;
475  border-radius: 50%;
476  border: 1px solid rgba(255,255,255,0.2);
477  background: rgba(var(--glass-rgb), 0.5);
478  backdrop-filter: blur(16px);
479  -webkit-backdrop-filter: blur(16px);
480  color: rgba(255,255,255,0.8);
481  font-size: 18px;
482  cursor: pointer;
483  z-index: 30;
484  align-items: center;
485  justify-content: center;
486  transition: all 0.2s;
487  box-shadow: 0 4px 20px rgba(0,0,0,0.2);
488}
489.zen-exit-btn:hover {
490  background: rgba(var(--glass-rgb), 0.7);
491  color: white;
492  transform: scale(1.05);
493}
494
495@media (max-width: 768px) {
496  body.zen .zen-exit-btn {
497    display: flex;
498  }
499}
500
501/* ── MOBILE ──────────────────── */
502@media (max-width: 768px) {
503  :root { --sidebar-w: 280px; }
504
505  .sidebar {
506    position: fixed; top: 0; left: 0; bottom: 0;
507    width: var(--sidebar-w);
508    background: rgba(var(--glass-rgb), 0.95);
509    backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px);
510    z-index: 50;
511    display: flex;
512  }
513
514  .sidebar.hidden { display: none; }
515  .toolbar { gap: 4px; padding: 0 8px; }
516  .tb-range-wrap { display: flex; }
517  .tb-range-label { display: none; }
518  .tb-range { width: 80px; }
519  .tb-sep { display: none; }
520  .editor-scroll { padding: 16px; }
521  body.zen .bottombar { opacity: 1; }
522  .tb-back span { display: none; }
523  .tb-copy span { display: none; }
524}
525
526@media (max-width: 480px) {
527  .bb-stat:not(.bb-energy) { display: none; }
528  .save-btn { padding: 6px 16px; }
529  .tb-range { width: 60px; }
530}
531
532/* ── Hidden text measurer ── */
533#textMeasurer {
534  position: absolute;
535  visibility: hidden;
536  height: auto;
537  width: auto;
538  white-space: pre;
539  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
540  font-size: var(--editor-font-size);
541  line-height: var(--editor-line-height);
542  pointer-events: none;
543}
544
545/* ── HISTORY PANEL ──────────── */
546.history-overlay {
547  position: fixed; inset: 0; z-index: 90;
548  background: rgba(0,0,0,0.5);
549  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
550  display: flex; justify-content: center; align-items: center;
551  padding: 20px;
552}
553.history-overlay.hidden { display: none; }
554
555.history-panel {
556  background: rgba(var(--glass-rgb), 0.65);
557  backdrop-filter: blur(22px) saturate(140%);
558  -webkit-backdrop-filter: blur(22px) saturate(140%);
559  border-radius: 16px;
560  border: 1px solid rgba(255,255,255,0.2);
561  box-shadow: 0 20px 60px rgba(0,0,0,0.3);
562  width: 100%; max-width: 700px;
563  max-height: 80vh;
564  display: flex; flex-direction: column;
565  overflow: hidden;
566}
567
568.history-header {
569  padding: 16px 20px;
570  border-bottom: 1px solid rgba(255,255,255,0.1);
571  display: flex; align-items: center; justify-content: space-between;
572  flex-shrink: 0;
573}
574.history-title { font-size: 16px; font-weight: 700; color: rgba(255,255,255,0.9); }
575
576.history-list {
577  max-height: 200px; overflow-y: auto; padding: 8px 12px;
578  flex-shrink: 0;
579  border-bottom: 1px solid rgba(255,255,255,0.08);
580}
581.history-list::-webkit-scrollbar { width: 4px; }
582.history-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
583
584.history-item {
585  display: flex; align-items: center; gap: 10px;
586  padding: 8px 12px; border-radius: 8px;
587  cursor: pointer; transition: all 0.2s;
588  border: 1px solid transparent; margin-bottom: 2px;
589}
590.history-item:hover { background: rgba(255,255,255,0.1); }
591.history-item.active { background: rgba(72,187,178,0.2); border-color: rgba(72,187,178,0.35); }
592
593.history-item-badge {
594  padding: 2px 8px; border-radius: 980px;
595  font-size: 10px; font-weight: 700; text-transform: uppercase;
596  letter-spacing: 0.5px; flex-shrink: 0;
597}
598.history-item-badge.add { background: rgba(72,187,120,0.25); color: rgba(72,187,120,0.9); }
599.history-item-badge.edit { background: rgba(100,220,255,0.2); color: rgba(100,220,255,0.9); }
600
601.history-item-info { flex: 1; min-width: 0; }
602.history-item-user { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.85); }
603.history-item-date { font-size: 11px; color: rgba(255,255,255,0.4); margin-top: 1px; }
604
605.history-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
606.history-view.hidden { display: none; }
607
608.history-view-header {
609  padding: 10px 16px;
610  display: flex; align-items: center; justify-content: space-between; gap: 8px;
611  border-bottom: 1px solid rgba(255,255,255,0.08);
612  flex-shrink: 0;
613}
614.history-view-modes { display: flex; gap: 4px; }
615
616.history-view-content {
617  flex: 1; overflow-y: auto; padding: 12px 16px;
618  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace;
619  font-size: 12px; line-height: 1.6;
620  white-space: pre-wrap; word-break: break-word;
621}
622.history-view-content::-webkit-scrollbar { width: 4px; }
623.history-view-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; }
624
625.diff-line { padding: 1px 8px; border-radius: 3px; margin: 0; }
626.diff-same { color: rgba(255,255,255,0.7); }
627.diff-add { background: rgba(72,187,120,0.2); color: rgba(72,187,120,0.95); }
628.diff-del { background: rgba(239,68,68,0.2); color: rgba(239,68,68,0.95); }
629.history-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,0.3); font-size: 13px; }
630
631@media (max-width: 768px) {
632  .history-panel { max-width: 100%; max-height: 90vh; border-radius: 12px; }
633  .history-list { max-height: 150px; }
634}
635</style>
636</head>
637<body>
638
639<!-- Hidden element for measuring text width -->
640<div id="textMeasurer"></div>
641
642<!-- ── ZEN EXIT BUTTON (mobile) ─────────────── -->
643<button class="zen-exit-btn" id="zenExitBtn" title="Exit Zen Mode">✕</button>
644
645<!-- ── TOOLBAR ──────────────────────────────── -->
646<div class="toolbar">
647  <a href="/api/v1/node/${nodeId}/${version}/notes${qs}" class="tb-back" id="backBtn">← <span>Notes</span></a>
648  <div class="tb-sep"></div>
649  <button class="tb-btn" id="sidebarToggle" title="Toggle sidebar">☰</button>
650  <button class="tb-btn" id="zenToggle" title="Zen mode">🧘</button>
651  <button class="tb-btn" id="monoToggle" title="Code mode (monospace)">{ }</button>
652  <div class="tb-sep"></div>
653  <div class="tb-range-wrap tb-fontsize">
654    <span class="tb-range-label">Font</span>
655    <input type="range" class="tb-range" id="fontSizeRange" min="13" max="28" value="20" title="Font Size">
656  </div>
657  <div class="tb-range-wrap tb-lineheight">
658    <span class="tb-range-label">Spacing</span>
659    <input type="range" class="tb-range" id="lineHeightRange" min="12" max="30" value="16" step="1" title="Line Spacing">
660  </div>
661
662  <div class="tb-spacer"></div>
663  ${isNew ? "" : '<button class="tb-btn" id="historyToggle" title="Edit history">🕒</button>'}
664  <button class="tb-copy" id="copyBtn" title="Copy all text">📋 <span>Copy</span></button>
665</div>
666
667<!-- ── MAIN ─────────────────────────────────── -->
668<div class="main">
669
670  <!-- SIDEBAR -->
671  <div class="sidebar hidden" id="sidebar">
672    <div class="sidebar-header">
673      <span class="sidebar-title">Notes</span>
674      <button class="sidebar-close" id="sidebarCloseBtn">✕</button>
675    </div>
676    <div class="sidebar-list" id="notesList">
677      <div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">Loading…</div>
678    </div>
679    <button class="sidebar-new" id="newNoteBtn">+ New Note</button>
680  </div>
681
682  <!-- EDITOR -->
683  <div class="editor-wrap">
684    <div class="editor-scroll" id="editorScroll">
685      <div class="editor-container" id="editorContainer">
686        <div class="editor-with-lines" id="editorWithLines">
687          <div class="line-numbers" id="lineNumbers"></div>
688          <div class="editor-code-scroll" id="editorCodeScroll">
689            <textarea id="editor" placeholder="Start writing…">${safeContent}</textarea>
690          </div>
691        </div>
692      </div>
693    </div>
694  </div>
695</div>
696
697<!-- ── BOTTOM BAR ──────────────────────────── -->
698<div class="bottombar">
699  <div class="bb-left">
700    <span class="bb-stat" id="charCount">0 chars</span>
701    <span class="bb-stat" id="wordCount">0 words</span>
702    <span class="bb-stat" id="lineCount">0 lines</span>
703    <span class="bb-stat bb-energy" id="energyCost">⚡0</span>
704  </div>
705  <div class="bb-right">
706    <span class="bb-status" id="saveStatus">${isNew ? "New note" : "Loaded"}</span>
707    <button class="delete-btn" id="deleteBtn">Delete</button>
708    <button class="save-btn" id="saveBtn">Save</button>
709  </div>
710</div>
711
712<!-- ── DELETE MODAL ─────────────────────────── -->
713<div class="modal-overlay" id="deleteModal">
714  <div class="modal-box">
715    <div class="modal-icon">🗑️</div>
716    <div class="modal-title">Delete this note?</div>
717    <div class="modal-text">
718      It looks like you cleared everything out.<br>
719      Would you like to delete this note entirely?<br>
720      This cannot be undone.
721    </div>
722    <div class="modal-actions">
723      <button class="modal-btn modal-btn-cancel" id="deleteCancelBtn">Cancel</button>
724      <button class="modal-btn modal-btn-delete" id="deleteConfirmBtn">Delete</button>
725    </div>
726  </div>
727</div>
728
729<!-- ── HISTORY PANEL ───────────────────────────── -->
730<div class="history-overlay hidden" id="historyOverlay">
731  <div class="history-panel">
732    <div class="history-header">
733      <span class="history-title">Edit History</span>
734      <button class="sidebar-close" id="historyCloseBtn">✕</button>
735    </div>
736    <div class="history-list" id="historyList">
737      <div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">Loading...</div>
738    </div>
739    <div class="history-view hidden" id="historyView">
740      <div class="history-view-header">
741        <div class="history-view-modes">
742          <button class="tb-btn active" id="historyFullBtn">Full Content</button>
743          <button class="tb-btn" id="historyDiffBtn">Show Changes</button>
744        </div>
745        <button class="save-btn" id="historyRestoreBtn">Restore</button>
746      </div>
747      <div class="history-view-content" id="historyViewContent"></div>
748    </div>
749  </div>
750</div>
751
752<script>
753/* ═══════════════════════════════════════════════════
754   STATE
755   ═══════════════════════════════════════════════════ */
756var nodeId      = "${nodeId}";
757var version     = "${version}";
758var currentNoteId = ${noteId ? '"' + noteId + '"' : "null"};
759var qs          = "${qs}";
760var tokenQS     = "${tokenQS}";
761var isNew       = ${isNew};
762var originalLen = ${originalLength || 0};
763var lastSaved   = ${isNew ? '""' : 'document.getElementById("editor").value'};
764var saving      = false;
765var navigatingAway = false;
766
767/* ═══════════════════════════════════════════════════
768   DOM REFS
769   ═══════════════════════════════════════════════════ */
770var editor       = document.getElementById("editor");
771var saveBtn      = document.getElementById("saveBtn");
772var deleteBtn    = document.getElementById("deleteBtn");
773var saveStatus   = document.getElementById("saveStatus");
774var charCountEl  = document.getElementById("charCount");
775var wordCountEl  = document.getElementById("wordCount");
776var lineCountEl  = document.getElementById("lineCount");
777var energyCostEl = document.getElementById("energyCost");
778var sidebar      = document.getElementById("sidebar");
779var notesList    = document.getElementById("notesList");
780var lineNumbersEl = document.getElementById("lineNumbers");
781var editorScroll = document.getElementById("editorScroll");
782var editorWithLines = document.getElementById("editorWithLines");
783var editorCodeScroll = document.getElementById("editorCodeScroll");
784var editorContainer = document.getElementById("editorContainer");
785var textMeasurer = document.getElementById("textMeasurer");
786
787/* ═══════════════════════════════════════════════════
788   SETTINGS (persisted in localStorage)
789   ═══════════════════════════════════════════════════ */
790function loadSettings() {
791  try {
792    var s = JSON.parse(localStorage.getItem("tree-editor-settings") || "{}");
793    if (s.fontSize)   document.getElementById("fontSizeRange").value   = s.fontSize;
794    if (s.lineHeight) document.getElementById("lineHeightRange").value = s.lineHeight;
795    if (s.mono) {
796      editor.classList.add("mono");
797      lineNumbersEl.classList.add("show");
798      editorScroll.classList.add("code-scroll-enabled");
799      editorContainer.classList.add("code-mode-active");
800      document.getElementById("monoToggle").classList.add("active");
801    }
802    applySettings();
803  } catch (e) {}
804}
805
806function persistSettings() {
807  try {
808    localStorage.setItem("tree-editor-settings", JSON.stringify({
809      fontSize:   document.getElementById("fontSizeRange").value,
810      lineHeight: document.getElementById("lineHeightRange").value,
811      mono:       editor.classList.contains("mono")
812    }));
813  } catch (e) {}
814}
815
816function applySettings() {
817  var fontSize = document.getElementById("fontSizeRange").value;
818  var lineHeight = document.getElementById("lineHeightRange").value;
819
820  document.documentElement.style.setProperty("--editor-font-size", fontSize + "px");
821  document.documentElement.style.setProperty("--editor-line-height", lineHeight / 10);
822
823  autoGrowEditor();
824}
825
826document.getElementById("fontSizeRange").oninput   = function() { applySettings(); persistSettings(); };
827document.getElementById("lineHeightRange").oninput  = function() { applySettings(); persistSettings(); };
828
829document.getElementById("monoToggle").onclick = function() {
830  editor.classList.toggle("mono");
831  lineNumbersEl.classList.toggle("show");
832  editorScroll.classList.toggle("code-scroll-enabled");
833  editorContainer.classList.toggle("code-mode-active");
834  this.classList.toggle("active");
835  autoGrowEditor();
836  persistSettings();
837};
838
839/* ═══════════════════════════════════════════════════
840   MEASURE TEXT WIDTH FOR CODE MODE
841   ═══════════════════════════════════════════════════ */
842function measureTextWidth(text) {
843  // Update measurer styles to match editor
844  var computedStyle = getComputedStyle(editor);
845  textMeasurer.style.fontFamily = computedStyle.fontFamily;
846  textMeasurer.style.fontSize = computedStyle.fontSize;
847  textMeasurer.style.lineHeight = computedStyle.lineHeight;
848  textMeasurer.style.letterSpacing = computedStyle.letterSpacing;
849
850  // Find the longest line
851  var lines = text.split("\\n");
852  var maxWidth = 0;
853
854  for (var i = 0; i < lines.length; i++) {
855    textMeasurer.textContent = lines[i] || " ";
856    var width = textMeasurer.offsetWidth;
857    if (width > maxWidth) maxWidth = width;
858  }
859
860  return maxWidth;
861}
862
863function updateEditorWidth() {
864  if (!editor.classList.contains("mono")) {
865    // Normal mode: reset width
866    editor.style.width = "100%";
867    return;
868  }
869
870  // Code mode: measure and set width to fit longest line
871  var contentWidth = measureTextWidth(editor.value);
872  var minWidth = editorScroll.clientWidth - 80; // Account for padding and line numbers
873  var newWidth = Math.max(contentWidth + 20, minWidth); // Add some padding
874
875  editor.style.width = newWidth + "px";
876}
877
878/* ═══════════════════════════════════════════════════
879   AUTO-GROW EDITOR (like VS Code / Word)
880   ═══════════════════════════════════════════════════ */
881function autoGrowEditor() {
882  var minH = window.innerHeight - 52 - 44 - 32;
883
884  editor.style.height = 'auto';
885  var newHeight = Math.max(editor.scrollHeight, minH);
886  editor.style.height = newHeight + 'px';
887
888  updateLineNumbers();
889  updateEditorWidth();
890}
891
892/* ═══════════════════════════════════════════════════
893   LINE NUMBERS
894   ═══════════════════════════════════════════════════ */
895function updateLineNumbers() {
896  if (!editor.classList.contains("mono")) return;
897
898  var lines = editor.value.split("\\n");
899  var count = lines.length;
900  var html = "";
901  for (var i = 1; i <= count; i++) {
902    html += "<span>" + i + "</span>";
903  }
904  lineNumbersEl.innerHTML = html;
905}
906
907/* ═══════════════════════════════════════════════════
908   ZEN MODE
909   ═══════════════════════════════════════════════════ */
910function exitZenMode() {
911  document.body.classList.remove("zen");
912  document.getElementById("zenToggle").classList.remove("active");
913  autoGrowEditor();
914}
915
916document.getElementById("zenToggle").onclick = function() {
917  document.body.classList.toggle("zen");
918  this.classList.toggle("active");
919  autoGrowEditor();
920};
921
922document.getElementById("zenExitBtn").onclick = exitZenMode;
923
924/* ═══════════════════════════════════════════════════
925   COPY ALL TEXT
926   ═══════════════════════════════════════════════════ */
927document.getElementById("copyBtn").onclick = function() {
928  var btn = this;
929  var btnSpan = btn.querySelector("span");
930
931  // Select all text
932  editor.select();
933  editor.setSelectionRange(0, editor.value.length);
934
935  // Copy to clipboard
936  navigator.clipboard.writeText(editor.value).then(function() {
937    btn.firstChild.textContent = "✓ ";
938    if (btnSpan) btnSpan.textContent = "Copied";
939    btn.classList.add("copied");
940
941    setTimeout(function() {
942      btn.firstChild.textContent = "📋 ";
943      if (btnSpan) btnSpan.textContent = "Copy";
944      btn.classList.remove("copied");
945    }, 1500);
946  }).catch(function() {
947    // Fallback for older browsers
948    try {
949      document.execCommand("copy");
950      btn.firstChild.textContent = "✓ ";
951      if (btnSpan) btnSpan.textContent = "Copied";
952      btn.classList.add("copied");
953
954      setTimeout(function() {
955        btn.firstChild.textContent = "📋 ";
956        if (btnSpan) btnSpan.textContent = "Copy";
957        btn.classList.remove("copied");
958      }, 1500);
959    } catch (e) {
960      if (btnSpan) btnSpan.textContent = "Failed";
961      setTimeout(function() {
962        btn.firstChild.textContent = "📋 ";
963        if (btnSpan) btnSpan.textContent = "Copy";
964      }, 1500);
965    }
966  });
967};
968
969/* ═══════════════════════════════════════════════════
970   SIDEBAR TOGGLE
971   ═══════════════════════════════════════════════════ */
972function toggleSidebar() {
973  sidebar.classList.toggle("hidden");
974  document.getElementById("sidebarToggle").classList.toggle("active");
975}
976
977document.getElementById("sidebarToggle").onclick = toggleSidebar;
978
979document.getElementById("sidebarCloseBtn").onclick = function() {
980  sidebar.classList.add("hidden");
981  document.getElementById("sidebarToggle").classList.remove("active");
982};
983
984/* ═══════════════════════════════════════════════════
985   ENERGY ESTIMATE (mirrors server: min 1, max 5)
986   ═══════════════════════════════════════════════════ */
987function estimateEnergy(chars) {
988  return Math.min(5, Math.max(1, 1 + Math.floor(chars / 1000)));
989}
990
991/* ═══════════════════════════════════════════════════
992   STATS + ENERGY + EMPTY DETECTION
993   ═══════════════════════════════════════════════════ */
994function updateStats() {
995  var text    = editor.value;
996  var len     = text.length;
997  var trimmed = text.trim().length;
998
999  charCountEl.textContent = len + " chars";
1000  wordCountEl.textContent = (text.trim() ? text.trim().split(/\\s+/).length : 0) + " words";
1001  lineCountEl.textContent = text.split("\\n").length + " lines";
1002
1003  var cost;
1004  if (isNew && !currentNoteId) {
1005    cost = len > 0 ? estimateEnergy(len) : 0;
1006  } else {
1007    var delta = Math.max(0, len - originalLen);
1008    cost = delta > 0 ? estimateEnergy(delta) : 1;
1009  }
1010  energyCostEl.textContent = "\\u26A1" + cost;
1011
1012  if (!isNew && currentNoteId) {
1013    if (trimmed === 0) {
1014      deleteBtn.classList.add("show");
1015      saveBtn.disabled = true;
1016    } else {
1017      deleteBtn.classList.remove("show");
1018      saveBtn.disabled = false;
1019    }
1020  } else {
1021    saveBtn.disabled = trimmed === 0;
1022  }
1023}
1024
1025/* ═══════════════════════════════════════════════════
1026   DIRTY TRACKING
1027   ═══════════════════════════════════════════════════ */
1028function isDirty() {
1029  return editor.value !== lastSaved;
1030}
1031
1032function markDirty() {
1033  if (isDirty()) {
1034    saveStatus.textContent = "Unsaved changes";
1035    saveStatus.className = "bb-status unsaved";
1036  }
1037}
1038
1039editor.addEventListener("input", function() {
1040  updateStats();
1041  markDirty();
1042  autoGrowEditor();
1043});
1044
1045editor.addEventListener("paste", function() {
1046  setTimeout(autoGrowEditor, 0);
1047});
1048
1049/* ═══════════════════════════════════════════════════
1050   NAVIGATION WITH UNSAVED CHECK
1051   ═══════════════════════════════════════════════════ */
1052function navigateWithCheck(url) {
1053  if (isDirty()) {
1054    if (!confirm("Unsaved changes. Discard?")) {
1055      return false;
1056    }
1057  }
1058  navigatingAway = true;
1059  window.location.href = url;
1060  return true;
1061}
1062
1063document.getElementById("backBtn").onclick = function(e) {
1064  e.preventDefault();
1065  navigateWithCheck("/api/v1/node/" + nodeId + "/" + version + "/notes" + qs);
1066};
1067
1068/* ═══════════════════════════════════════════════════
1069   SAVE → POST (new) or PUT (existing)
1070   ═══════════════════════════════════════════════════ */
1071async function doSave() {
1072  if (saving) return;
1073  var content = editor.value;
1074
1075  if (!isNew && currentNoteId && !content.trim()) {
1076    openDeleteModal();
1077    return;
1078  }
1079
1080  if (!content.trim()) {
1081    saveStatus.textContent = "Cannot save empty note";
1082    saveStatus.className = "bb-status error";
1083    return;
1084  }
1085
1086  saving = true;
1087  saveBtn.disabled = true;
1088  saveStatus.textContent = "Saving\\u2026";
1089  saveStatus.className = "bb-status saving";
1090
1091  try {
1092    var url, method;
1093
1094    if (currentNoteId) {
1095      url    = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId;
1096      method = "PUT";
1097    } else {
1098      url    = "/api/v1/node/" + nodeId + "/" + version + "/notes";
1099      method = "POST";
1100    }
1101
1102    var res = await fetch(url, {
1103      method: method,
1104      headers: { "Content-Type": "application/json" },
1105      body: JSON.stringify({ content: content, contentType: "text" }),
1106      credentials: "include"
1107    });
1108
1109    if (!res.ok) {
1110      var errData = await res.json().catch(function() { return {}; });
1111      throw new Error(errData.error || "Save failed (" + res.status + ")");
1112    }
1113
1114    var data = await res.json();
1115
1116    if (!currentNoteId) {
1117      var newId = data._id || (data.note && data.note._id);
1118      if (newId) {
1119        currentNoteId = newId;
1120        isNew = false;
1121        originalLen = content.length;
1122        history.replaceState(null, "",
1123          "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId + "/editor" + qs
1124        );
1125      }
1126    } else {
1127      originalLen = content.length;
1128    }
1129
1130    lastSaved = content;
1131
1132    var msg = "Saved";
1133    var eu  = data.energyUsed || 0;
1134    if (eu > 0) msg += " \\u00b7 \\u26A1" + eu;
1135    saveStatus.textContent = msg;
1136    saveStatus.className   = "bb-status saved";
1137
1138    navigatingAway = true;
1139    if (currentNoteId) {
1140      window.location.href =
1141        "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId + qs;
1142    } else {
1143      window.location.href =
1144        "/api/v1/node/" + nodeId + "/" + version + "/notes" + qs;
1145    }
1146
1147    loadNotes();
1148
1149  } catch (err) {
1150    saveStatus.textContent = err.message;
1151    saveStatus.className   = "bb-status error";
1152  } finally {
1153    saving = false;
1154    saveBtn.disabled = false;
1155    updateStats();
1156  }
1157}
1158
1159saveBtn.onclick = doSave;
1160
1161/* ═══════════════════════════════════════════════════
1162   DELETE → DELETE route
1163   ═══════════════════════════════════════════════════ */
1164function openDeleteModal()  { document.getElementById("deleteModal").classList.add("show"); }
1165function closeDeleteModal() { document.getElementById("deleteModal").classList.remove("show"); }
1166
1167document.getElementById("deleteCancelBtn").onclick = closeDeleteModal;
1168document.getElementById("deleteModal").onclick = function(e) { if (e.target === this) closeDeleteModal(); };
1169
1170document.getElementById("deleteConfirmBtn").onclick = async function() {
1171  if (!currentNoteId) return;
1172  this.disabled   = true;
1173  this.textContent = "Deleting\\u2026";
1174
1175  try {
1176    var res = await fetch(
1177      "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId,
1178      { method: "DELETE", credentials: "include" }
1179    );
1180
1181    if (!res.ok) {
1182      var errData = await res.json().catch(function() { return {}; });
1183      throw new Error(errData.error || "Delete failed");
1184    }
1185
1186    navigatingAway = true;
1187    window.location.href = "/api/v1/" + nodeId + "/" + version + "/notes" + qs;
1188
1189  } catch (err) {
1190    closeDeleteModal();
1191    saveStatus.textContent = err.message;
1192    saveStatus.className   = "bb-status error";
1193    this.disabled   = false;
1194    this.textContent = "Delete";
1195  }
1196};
1197
1198deleteBtn.onclick = openDeleteModal;
1199
1200/* ═══════════════════════════════════════════════════
1201   KEYBOARD SHORTCUTS
1202   ═══════════════════════════════════════════════════ */
1203document.addEventListener("keydown", function(e) {
1204  if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); doSave(); }
1205  if (e.key === "Escape") {
1206    if (document.getElementById("deleteModal").classList.contains("show")) closeDeleteModal();
1207    else if (document.body.classList.contains("zen")) {
1208      exitZenMode();
1209    }
1210  }
1211});
1212
1213editor.addEventListener("keydown", function(e) {
1214  // Enter key in code mode: scroll to left to see line numbers
1215  if (e.key === "Enter" && editor.classList.contains("mono")) {
1216    setTimeout(function() {
1217      editorScroll.scrollLeft = 0;
1218    }, 0);
1219  }
1220
1221  if (e.key !== "Tab") return;
1222  e.preventDefault();
1223  var s = this.selectionStart, end = this.selectionEnd, v = this.value;
1224
1225  if (e.shiftKey) {
1226    var ls = v.lastIndexOf("\\n", s - 1) + 1;
1227    if (v.substring(ls, ls + 2) === "  ") {
1228      this.value = v.substring(0, ls) + v.substring(ls + 2);
1229      this.selectionStart = Math.max(s - 2, ls);
1230      this.selectionEnd   = Math.max(end - 2, ls);
1231    }
1232  } else {
1233    this.value = v.substring(0, s) + "  " + v.substring(end);
1234    this.selectionStart = this.selectionEnd = s + 2;
1235  }
1236  updateStats(); markDirty(); autoGrowEditor();
1237});
1238
1239/* ═══════════════════════════════════════════════════
1240   SIDEBAR: LOAD NOTES LIST
1241   ═══════════════════════════════════════════════════ */
1242async function loadNotes() {
1243  try {
1244    var token = new URLSearchParams(qs.replace("?","")).get("token");
1245    var fetchUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes";
1246    if (token) fetchUrl += "?token=" + encodeURIComponent(token);
1247    var res = await fetch(fetchUrl, { credentials: "include" });
1248    var data  = await res.json();
1249    var notes = data.notes || data || [];
1250    if (!Array.isArray(notes)) notes = [];
1251    if (!notes.length) { notesList.innerHTML = emptyMsg("No notes yet"); return; }
1252
1253    var html = "";
1254    for (var i = 0; i < notes.length; i++) {
1255      var n      = notes[i];
1256      var nId    = n._id || n.id;
1257      var isFile = n.contentType === "file";
1258      var icon   = isFile ? "\\ud83d\\udcce" : "\\ud83d\\udcdd";
1259      var preview;
1260      var username = n.username || n.user || n.author || "Unknown";
1261
1262      if (isFile) preview = n.content ? n.content.split("/").pop() : "File";
1263      else        preview = (n.content || "").slice(0, 60) || "Empty note";
1264
1265      var active = nId === currentNoteId;
1266      var date   = n.createdAt ? new Date(n.createdAt).toLocaleDateString() : "";
1267
1268      html +=
1269        '<div class="note-item' + (active ? " active" : "") +
1270        '" data-id="' + nId + '" data-type="' + (n.contentType || "text") + '">' +
1271          '<div class="note-item-icon">' + icon + '</div>' +
1272          '<div class="note-item-info">' +
1273            '<div class="note-item-username">' + esc(username) + '</div>' +
1274            '<div class="note-item-preview">' + esc(preview) + '</div>' +
1275            '<div class="note-item-meta">' + date + '</div>' +
1276          '</div>' +
1277        '</div>';
1278    }
1279    notesList.innerHTML = html;
1280
1281    notesList.querySelectorAll(".note-item").forEach(function(item) {
1282      item.onclick = function() {
1283        var nId   = item.dataset.id;
1284        var nType = item.dataset.type;
1285        if (nId === currentNoteId) return;
1286
1287        var targetUrl;
1288        if (nType === "file")
1289          targetUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + nId + tokenQS;
1290        else
1291          targetUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + nId + "/editor" + qs;
1292
1293        navigateWithCheck(targetUrl);
1294      };
1295    });
1296
1297  } catch (err) {
1298    notesList.innerHTML = emptyMsg("Error loading notes");
1299  }
1300}
1301
1302function emptyMsg(t) {
1303  return '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">' + t + '</div>';
1304}
1305
1306function esc(s) { return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
1307
1308/* ═══════════════════════════════════════════════════
1309   NEW NOTE BUTTON
1310   ═══════════════════════════════════════════════════ */
1311document.getElementById("newNoteBtn").onclick = function() {
1312  navigateWithCheck("/api/v1/node/" + nodeId + "/" + version + "/notes/editor" + qs);
1313};
1314
1315/* ═══════════════════════════════════════════════════
1316   WARN ON LEAVE
1317   ═══════════════════════════════════════════════════ */
1318window.addEventListener("beforeunload", function(e) {
1319  if (!navigatingAway && isDirty()) {
1320    e.preventDefault();
1321    e.returnValue = "";
1322  }
1323});
1324
1325/* ═══════════════════════════════════════════════════
1326   WINDOW RESIZE
1327   ═══════════════════════════════════════════════════ */
1328window.addEventListener("resize", function() {
1329  autoGrowEditor();
1330});
1331
1332/* ═══════════════════════════════════════════════════
1333   EDIT HISTORY
1334   ═══════════════════════════════════════════════════ */
1335var historyData = [];
1336var selectedHistoryIdx = -1;
1337var historyMode = "full"; // "full" or "diff"
1338
1339var historyToggleBtn = document.getElementById("historyToggle");
1340var historyOverlay = document.getElementById("historyOverlay");
1341var historyCloseBtn = document.getElementById("historyCloseBtn");
1342var historyListEl = document.getElementById("historyList");
1343var historyView = document.getElementById("historyView");
1344var historyViewContent = document.getElementById("historyViewContent");
1345var historyFullBtn = document.getElementById("historyFullBtn");
1346var historyDiffBtn = document.getElementById("historyDiffBtn");
1347var historyRestoreBtn = document.getElementById("historyRestoreBtn");
1348
1349if (historyToggleBtn) {
1350  historyToggleBtn.onclick = function() {
1351    historyOverlay.classList.remove("hidden");
1352    loadHistory();
1353  };
1354}
1355
1356if (historyCloseBtn) {
1357  historyCloseBtn.onclick = function() {
1358    historyOverlay.classList.add("hidden");
1359  };
1360}
1361
1362if (historyOverlay) {
1363  historyOverlay.onclick = function(e) {
1364    if (e.target === historyOverlay) historyOverlay.classList.add("hidden");
1365  };
1366}
1367
1368async function loadHistory() {
1369  historyListEl.innerHTML = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.3);font-size:13px;">Loading...</div>';
1370  historyView.classList.add("hidden");
1371  selectedHistoryIdx = -1;
1372
1373  try {
1374    var token = new URLSearchParams(qs.replace("?","")).get("token");
1375    var fetchUrl = "/api/v1/node/" + nodeId + "/" + version + "/notes/" + currentNoteId + "/history";
1376    if (token) fetchUrl += "?token=" + encodeURIComponent(token);
1377    var res = await fetch(fetchUrl, { credentials: "include" });
1378    var data = await res.json();
1379    historyData = data.history || [];
1380
1381    if (!historyData.length) {
1382      historyListEl.innerHTML = '<div class="history-empty">No edit history available yet.<br>History is recorded on future saves.</div>';
1383      return;
1384    }
1385
1386    var html = "";
1387    for (var i = historyData.length - 1; i >= 0; i--) {
1388      var h = historyData[i];
1389      var d = new Date(h.date);
1390      var dateStr = d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1391      var badgeClass = h.action === "add" ? "add" : "edit";
1392      var badgeLabel = h.action === "add" ? "Created" : "Edit";
1393
1394      html +=
1395        '<div class="history-item" data-idx="' + i + '">' +
1396          '<span class="history-item-badge ' + badgeClass + '">' + badgeLabel + '</span>' +
1397          '<div class="history-item-info">' +
1398            '<div class="history-item-user">' + esc(h.username) + '</div>' +
1399            '<div class="history-item-date">' + esc(dateStr) + '</div>' +
1400          '</div>' +
1401        '</div>';
1402    }
1403    historyListEl.innerHTML = html;
1404
1405    var items = historyListEl.querySelectorAll(".history-item");
1406    for (var j = 0; j < items.length; j++) {
1407      items[j].onclick = function() {
1408        var idx = parseInt(this.getAttribute("data-idx"));
1409        selectHistoryEntry(idx);
1410        var all = historyListEl.querySelectorAll(".history-item");
1411        for (var k = 0; k < all.length; k++) all[k].classList.remove("active");
1412        this.classList.add("active");
1413      };
1414    }
1415  } catch (e) {
1416    historyListEl.innerHTML = '<div class="history-empty">Failed to load history.</div>';
1417  }
1418}
1419
1420function selectHistoryEntry(idx) {
1421  selectedHistoryIdx = idx;
1422  historyView.classList.remove("hidden");
1423  renderHistoryView();
1424}
1425
1426if (historyFullBtn) {
1427  historyFullBtn.onclick = function() {
1428    historyMode = "full";
1429    historyFullBtn.classList.add("active");
1430    historyDiffBtn.classList.remove("active");
1431    renderHistoryView();
1432  };
1433}
1434
1435if (historyDiffBtn) {
1436  historyDiffBtn.onclick = function() {
1437    historyMode = "diff";
1438    historyDiffBtn.classList.add("active");
1439    historyFullBtn.classList.remove("active");
1440    renderHistoryView();
1441  };
1442}
1443
1444if (historyRestoreBtn) {
1445  historyRestoreBtn.onclick = function() {
1446    if (selectedHistoryIdx < 0 || !historyData[selectedHistoryIdx]) return;
1447    editor.value = historyData[selectedHistoryIdx].content;
1448    historyOverlay.classList.add("hidden");
1449    updateStats();
1450    markDirty();
1451    autoGrowEditor();
1452  };
1453}
1454
1455function renderHistoryView() {
1456  if (selectedHistoryIdx < 0) return;
1457  var entry = historyData[selectedHistoryIdx];
1458
1459  if (entry.content === null || entry.content === undefined) {
1460    historyViewContent.innerHTML = '<div class="history-empty">Content was not recorded for this entry.</div>';
1461    historyRestoreBtn.style.display = "none";
1462    return;
1463  }
1464  historyRestoreBtn.style.display = "";
1465
1466  if (historyMode === "full") {
1467    historyViewContent.innerHTML = '<pre style="margin:0;white-space:pre-wrap;word-break:break-word;color:rgba(255,255,255,0.85);">' + esc(entry.content) + '</pre>';
1468  } else {
1469    var prevContent = "";
1470    for (var p = selectedHistoryIdx - 1; p >= 0; p--) {
1471      if (historyData[p].content !== null && historyData[p].content !== undefined) {
1472        prevContent = historyData[p].content;
1473        break;
1474      }
1475    }
1476    var diffHtml = computeDiff(prevContent, entry.content);
1477    historyViewContent.innerHTML = diffHtml;
1478  }
1479}
1480
1481// ── LCS-based line diff ──
1482function computeDiff(oldText, newText) {
1483  var oldLines = oldText.split("\\n");
1484  var newLines = newText.split("\\n");
1485  var m = oldLines.length;
1486  var n = newLines.length;
1487
1488  // Build LCS table
1489  var dp = [];
1490  for (var i = 0; i <= m; i++) {
1491    dp[i] = [];
1492    for (var j = 0; j <= n; j++) {
1493      if (i === 0 || j === 0) dp[i][j] = 0;
1494      else if (oldLines[i-1] === newLines[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
1495      else dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
1496    }
1497  }
1498
1499  // Backtrack to get diff ops
1500  var ops = [];
1501  var ci = m, cj = n;
1502  while (ci > 0 || cj > 0) {
1503    if (ci > 0 && cj > 0 && oldLines[ci-1] === newLines[cj-1]) {
1504      ops.push({ type: "same", text: oldLines[ci-1] });
1505      ci--; cj--;
1506    } else if (cj > 0 && (ci === 0 || dp[ci][cj-1] >= dp[ci-1][cj])) {
1507      ops.push({ type: "add", text: newLines[cj-1] });
1508      cj--;
1509    } else {
1510      ops.push({ type: "del", text: oldLines[ci-1] });
1511      ci--;
1512    }
1513  }
1514  ops.reverse();
1515
1516  var html = '<div>';
1517  for (var k = 0; k < ops.length; k++) {
1518    var op = ops[k];
1519    var cls = op.type === "same" ? "diff-same" : (op.type === "add" ? "diff-add" : "diff-del");
1520    var prefix = op.type === "same" ? "  " : (op.type === "add" ? "+ " : "- ");
1521    html += '<div class="diff-line ' + cls + '">' + esc(prefix + op.text) + '</div>';
1522  }
1523  html += '</div>';
1524  return html;
1525}
1526
1527/* ═══════════════════════════════════════════════════
1528   INIT
1529   ═══════════════════════════════════════════════════ */
1530loadSettings();
1531updateStats();
1532loadNotes();
1533if (!isNew) lastSaved = editor.value;
1534autoGrowEditor();
1535setTimeout(function() { editor.focus(); }, 100);
1536
1537try {
1538  var draft = sessionStorage.getItem("tree-editor-draft");
1539  if (draft && isNew && !editor.value) {
1540    editor.value = draft;
1541    sessionStorage.removeItem("tree-editor-draft");
1542    updateStats();
1543    markDirty();
1544    autoGrowEditor();
1545  }
1546} catch (e) {}
1547</script>
1548</body>
1549</html>`;
1550}
1551
1552export function renderBookPage({
1553  nodeId,
1554  token,
1555  title,
1556  content,
1557  options,
1558  tocEnabled,
1559  tocDepth,
1560  isStatusActive,
1561  isStatusCompleted,
1562  isStatusTrimmed,
1563  book,
1564  hasContent,
1565}) {
1566  const treeDepth = hasContent ? Math.min(getBookDepth(book), 5) : 0;
1567
1568  let tocDepthSelect = "";
1569  if (tocEnabled && hasContent && treeDepth > 1) {
1570    let opts = `<option value="0" ${tocDepth === 0 ? "selected" : ""}>All Depths</option>`;
1571    for (let i = 1; i <= treeDepth; i++) {
1572      opts += `<option value="${i}" ${tocDepth === i ? "selected" : ""}>Depth ${i}${i === 5 ? " (max)" : ""}</option>`;
1573    }
1574    tocDepthSelect = `<select class="toc-select" onchange="setTocDepth(this.value)">${opts}</select>`;
1575  }
1576
1577  const bookContent = hasContent
1578    ? renderBookNode(book, 1, token)
1579    : `
1580    <div class="empty-state">
1581      <div class="empty-state-icon">📖</div>
1582      <div class="empty-state-text">No content</div>
1583      <div class="empty-state-subtext">
1584        This node has no notes or child notes under the current filters.
1585      </div>
1586    </div>
1587  `;
1588
1589  return `
1590<!DOCTYPE html>
1591<html lang="en">
1592<head>
1593  <meta charset="UTF-8">
1594  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
1595  <meta name="theme-color" content="#667eea">
1596  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1597<title>Book: ${escapeHtml(title)}</title>
1598  <style>
1599    ${baseStyles}
1600
1601    /* ── Book page overrides on base ── */
1602    body { padding: 0; }
1603
1604    /* Top Navigation Bar - Glass */
1605    .top-nav {
1606      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1607      backdrop-filter: blur(22px) saturate(140%);
1608      -webkit-backdrop-filter: blur(22px) saturate(140%);
1609      padding: 10px 20px;
1610      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1611        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1612      border-bottom: 1px solid rgba(255, 255, 255, 0.28);
1613      position: sticky;
1614      top: 0;
1615      z-index: 100;
1616      animation: fadeInUp 0.5s ease-out;
1617    }
1618
1619    .top-nav-content {
1620      max-width: 900px;
1621      margin: 0 auto;
1622    }
1623
1624    .nav-buttons {
1625      display: flex;
1626      justify-content: space-between;
1627      align-items: center;
1628      gap: 8px;
1629      flex-wrap: wrap;
1630      margin-bottom: 4px;
1631    }
1632
1633    .nav-left {
1634      display: flex;
1635      gap: 8px;
1636      flex-wrap: wrap;
1637    }
1638
1639    /* Glass Navigation Buttons */
1640    .nav-button {
1641      display: inline-flex;
1642      align-items: center;
1643      gap: 6px;
1644      padding: 8px 14px;
1645      background: rgba(255, 255, 255, 0.2);
1646      backdrop-filter: blur(10px);
1647      color: white;
1648      text-decoration: none;
1649      border-radius: 980px;
1650      font-weight: 600;
1651      font-size: 14px;
1652      transition: all 0.3s;
1653      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
1654      border: 1px solid rgba(255, 255, 255, 0.3);
1655      position: relative;
1656      overflow: hidden;
1657      cursor: pointer;
1658      touch-action: manipulation;
1659    }
1660
1661    .nav-button::before {
1662      content: "";
1663      position: absolute;
1664      inset: -40%;
1665      background: radial-gradient(
1666        120% 60% at 0% 0%,
1667        rgba(255, 255, 255, 0.35),
1668        transparent 60%
1669      );
1670      opacity: 0;
1671      transform: translateX(-30%) translateY(-10%);
1672      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1673      pointer-events: none;
1674    }
1675
1676    .nav-button:hover {
1677      background: rgba(255, 255, 255, 0.3);
1678      transform: translateY(-2px);
1679      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
1680    }
1681
1682    .nav-button:hover::before {
1683      opacity: 1;
1684      transform: translateX(30%) translateY(10%);
1685    }
1686
1687    .page-title {
1688      font-size: 20px;
1689      font-weight: 600;
1690      color: white;
1691      margin-bottom: 12px;
1692      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1693      letter-spacing: -0.3px;
1694    }
1695
1696    /* Glass Filter Buttons */
1697    .filters {
1698      display: flex;
1699      gap: 8px;
1700      flex-wrap: wrap;
1701    }
1702
1703    .filter-button {
1704      padding: 8px 14px;
1705      font-size: 13px;
1706      font-weight: 600;
1707      border-radius: 980px;
1708      border: 1px solid rgba(255, 255, 255, 0.25);
1709      background: rgba(255, 255, 255, 0.15);
1710      backdrop-filter: blur(10px);
1711      color: white;
1712      cursor: pointer;
1713      transition: all 0.3s;
1714      font-family: inherit;
1715      white-space: nowrap;
1716      position: relative;
1717      overflow: hidden;
1718    }
1719
1720    .filter-button::before {
1721      content: "";
1722      position: absolute;
1723      inset: -40%;
1724      background: radial-gradient(
1725        120% 60% at 0% 0%,
1726        rgba(255, 255, 255, 0.35),
1727        transparent 60%
1728      );
1729      opacity: 0;
1730      transform: translateX(-30%) translateY(-10%);
1731      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1732      pointer-events: none;
1733    }
1734
1735    .filter-button:hover {
1736      background: rgba(255, 255, 255, 0.25);
1737      transform: translateY(-1px);
1738    }
1739
1740    .filter-button:hover::before {
1741      opacity: 1;
1742      transform: translateX(30%) translateY(10%);
1743    }
1744
1745    .filter-button.active {
1746      background: rgba(255, 255, 255, 0.35);
1747      border-color: rgba(255, 255, 255, 0.5);
1748      box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15),
1749        inset 0 1px 0 rgba(255, 255, 255, 0.4);
1750    }
1751
1752    .filter-button.active:hover {
1753      background: rgba(255, 255, 255, 0.45);
1754      transform: translateY(-2px);
1755      box-shadow: 0 6px 25px rgba(0, 0, 0, 0.2);
1756    }
1757
1758    .toc-select {
1759      padding: 8px 14px;
1760      font-size: 13px;
1761      font-weight: 600;
1762      border-radius: 980px;
1763      border: 1px solid rgba(255, 255, 255, 0.25);
1764      background: rgba(255, 255, 255, 0.15);
1765      backdrop-filter: blur(10px);
1766      color: white;
1767      cursor: pointer;
1768      font-family: inherit;
1769      appearance: none;
1770      -webkit-appearance: none;
1771      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
1772      background-repeat: no-repeat;
1773      background-position: right 12px center;
1774      padding-right: 30px;
1775    }
1776
1777    .toc-select option {
1778      background: #5a56c4;
1779      color: white;
1780    }
1781
1782    /* Content Container */
1783    .content-wrapper {
1784      padding: 24px 20px;
1785    }
1786
1787    .content {
1788      max-width: 900px;
1789      margin: 0 auto;
1790      font-family: "Charter", "Georgia", "Iowan Old Style", "Times New Roman", serif;
1791      line-height: 1.7;
1792      word-wrap: break-word;
1793      overflow-wrap: break-word;
1794      animation: fadeInUp 0.6s ease-out 0.1s both;
1795    }
1796
1797    /* Layered Glass Sections - Each depth gets more opaque glass */
1798    .book-section {
1799      margin-bottom: 40px;
1800      position: relative;
1801    }
1802
1803    .book-section.depth-1 {
1804      margin-bottom: 48px;
1805      padding: 24px;
1806      background: rgba(255, 255, 255, 0.08);
1807      border-radius: 12px;
1808      border: 1px solid rgba(255, 255, 255, 0.15);
1809      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
1810    }
1811
1812    .book-section.depth-2 {
1813      margin-bottom: 32px;
1814      margin-left: 8px;
1815      padding: 20px;
1816      background: rgba(255, 255, 255, 0.06);
1817      border-radius: 10px;
1818      border: 1px solid rgba(255, 255, 255, 0.12);
1819    }
1820
1821    .book-section.depth-3 {
1822      margin-bottom: 24px;
1823      margin-left: 8px;
1824      padding: 16px;
1825      background: rgba(255, 255, 255, 0.04);
1826      border-radius: 8px;
1827      border: 1px solid rgba(255, 255, 255, 0.1);
1828    }
1829
1830    .book-section.depth-4 {
1831      margin-bottom: 20px;
1832      margin-left: 8px;
1833      padding: 12px;
1834      background: rgba(255, 255, 255, 0.03);
1835      border-radius: 6px;
1836      border: 1px solid rgba(255, 255, 255, 0.08);
1837    }
1838
1839    .book-section.depth-5 {
1840      margin-bottom: 16px;
1841      margin-left: 8px;
1842      padding: 10px;
1843      background: rgba(255, 255, 255, 0.02);
1844      border-radius: 6px;
1845      border: 1px solid rgba(255, 255, 255, 0.06);
1846    }
1847
1848    /* Heading Hierarchy */
1849    h1, h2, h3, h4, h5 {
1850      font-weight: 600;
1851      line-height: 1.3;
1852      margin: 0 0 16px 0;
1853      color: white;
1854      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1855      letter-spacing: -0.5px;
1856    }
1857
1858    h1 {
1859      font-size: 36px;
1860      margin-top: 48px;
1861      margin-bottom: 24px;
1862      padding-bottom: 16px;
1863      border-bottom: 2px solid rgba(255, 255, 255, 0.3);
1864    }
1865
1866    .book-section.depth-1:first-child h1 {
1867      margin-top: 0;
1868    }
1869
1870    h2 {
1871      font-size: 30px;
1872      margin-top: 40px;
1873      margin-bottom: 20px;
1874      padding-bottom: 12px;
1875      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
1876    }
1877
1878    h3 {
1879      font-size: 24px;
1880      margin-top: 32px;
1881      margin-bottom: 16px;
1882    }
1883
1884    h4 {
1885      font-size: 20px;
1886      margin-top: 24px;
1887      margin-bottom: 12px;
1888    }
1889
1890    h5 {
1891      font-size: 18px;
1892      margin-top: 20px;
1893      margin-bottom: 10px;
1894    }
1895
1896    /* Note Content - Glowing Text */
1897    .note-content {
1898      margin: 16px 0 28px 0;
1899      padding: 0;
1900      font-size: 18px;
1901      line-height: 1.8;
1902      color: white;
1903      word-wrap: break-word;
1904      overflow-wrap: break-word;
1905      font-weight: 400;
1906    }
1907
1908    .note-link {
1909      color: inherit;
1910      text-decoration: none;
1911      white-space: pre-wrap;
1912      word-wrap: break-word;
1913      overflow-wrap: break-word;
1914      display: block;
1915      padding: 12px 16px;
1916      margin: -12px -16px;
1917      border-radius: 8px;
1918      transition: all 0.3s;
1919      position: relative;
1920      overflow: hidden;
1921    }
1922
1923    .note-link::before {
1924      content: "";
1925      position: absolute;
1926      inset: 0;
1927      background: linear-gradient(
1928        110deg,
1929        transparent 40%,
1930        rgba(255, 255, 255, 0.2),
1931        transparent 60%
1932      );
1933      opacity: 0;
1934      transform: translateX(-100%);
1935      pointer-events: none;
1936    }
1937
1938    .note-link:hover {
1939      background-color: rgba(255, 255, 255, 0.1);
1940      transform: translateX(4px);
1941    }
1942
1943    .note-link:hover::before {
1944      opacity: 1;
1945      animation: glassShimmer 1s ease forwards;
1946    }
1947
1948    @keyframes glassShimmer {
1949      0% {
1950        opacity: 0;
1951        transform: translateX(-120%) skewX(-15deg);
1952      }
1953      50% {
1954        opacity: 1;
1955      }
1956      100% {
1957        opacity: 0;
1958        transform: translateX(120%) skewX(-15deg);
1959      }
1960    }
1961
1962    .note-link:active {
1963      background-color: rgba(255, 255, 255, 0.15);
1964    }
1965
1966    /* File Containers - Deeper Glass */
1967    .file-container {
1968      margin: 24px 0;
1969      padding: 20px;
1970      background: rgba(255, 255, 255, 0.15);
1971      backdrop-filter: blur(18px);
1972      border: 1px solid rgba(255, 255, 255, 0.3);
1973      border-radius: 12px;
1974      transition: all 0.3s;
1975      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
1976    }
1977
1978    .file-container:hover {
1979      border-color: rgba(255, 255, 255, 0.5);
1980      box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
1981      background: rgba(255, 255, 255, 0.2);
1982    }
1983
1984    .file-container .note-link {
1985      display: inline-block;
1986      margin-bottom: 12px;
1987      color: white;
1988      font-size: 16px;
1989      font-weight: 600;
1990      padding: 4px 8px;
1991      margin: -4px -8px 8px;
1992    }
1993
1994    .file-container .note-link:hover {
1995      background-color: rgba(255, 255, 255, 0.15);
1996      text-decoration: underline;
1997    }
1998
1999    /* Media Elements */
2000    img {
2001      max-width: 100%;
2002      height: auto;
2003      border-radius: 8px;
2004      margin-top: 12px;
2005      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
2006      border: 1px solid rgba(255, 255, 255, 0.2);
2007    }
2008
2009    video, audio {
2010      max-width: 100%;
2011      margin-top: 12px;
2012      border-radius: 8px;
2013      border: 1px solid rgba(255, 255, 255, 0.2);
2014    }
2015
2016    iframe {
2017      width: 100%;
2018      height: 600px;
2019      border: none;
2020      border-radius: 8px;
2021      margin-top: 12px;
2022      border: 1px solid rgba(255, 255, 255, 0.2);
2023    }
2024
2025    /* Empty State */
2026    .empty-state {
2027      text-align: center;
2028      padding: 80px 40px;
2029    }
2030
2031    .empty-state-icon {
2032      font-size: 64px;
2033      margin-bottom: 16px;
2034      filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
2035    }
2036
2037    .empty-state-text {
2038      font-size: 24px;
2039      color: white;
2040      margin-bottom: 8px;
2041      font-weight: 600;
2042      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2043    }
2044
2045    .empty-state-subtext {
2046      font-size: 16px;
2047      color: rgba(255, 255, 255, 0.8);
2048    }
2049
2050    /* Responsive Design */
2051    @media (max-width: 1024px) {
2052    }
2053
2054    @media (max-width: 768px) {
2055      .top-nav {
2056        padding: 12px 16px;
2057      }
2058
2059      .nav-button {
2060        padding: 8px 12px;
2061        font-size: 13px;
2062      }
2063
2064      .page-title {
2065        font-size: 18px;
2066      }
2067
2068      .filter-button {
2069        padding: 6px 12px;
2070        font-size: 12px;
2071      }
2072
2073      .content-wrapper {
2074        padding: 24px 16px;
2075      }
2076
2077      h1 {
2078        font-size: 30px;
2079      }
2080
2081      h2 {
2082        font-size: 26px;
2083      }
2084
2085      h3 {
2086        font-size: 22px;
2087      }
2088
2089      h4 {
2090        font-size: 19px;
2091      }
2092
2093      h5 {
2094        font-size: 17px;
2095      }
2096
2097      .note-content {
2098        font-size: 17px;
2099      }
2100
2101      .book-section.depth-2,
2102      .book-section.depth-3,
2103      .book-section.depth-4,
2104      .book-section.depth-5 {
2105        margin-left: 4px;
2106      }
2107    }
2108
2109    @media (max-width: 480px) {
2110      .nav-buttons {
2111        flex-direction: column;
2112        align-items: stretch;
2113      }
2114
2115      .nav-left {
2116        width: 100%;
2117        flex-direction: column;
2118      }
2119
2120      .nav-button {
2121        justify-content: center;
2122        width: 100%;
2123      }
2124
2125      .book-section.depth-1,
2126      .book-section.depth-2,
2127      .book-section.depth-3,
2128      .book-section.depth-4,
2129      .book-section.depth-5 {
2130        margin-left: 0;
2131        padding: 12px;
2132      }
2133    }
2134
2135    html { scroll-behavior: smooth; }
2136
2137    .book-toc {
2138      max-width: 900px;
2139      margin: 20px auto 24px;
2140      padding: 20px 28px;
2141      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2142      backdrop-filter: blur(22px) saturate(140%);
2143      -webkit-backdrop-filter: blur(22px) saturate(140%);
2144      border: 1px solid rgba(255, 255, 255, 0.28);
2145      border-radius: 16px;
2146      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
2147        inset 0 1px 0 rgba(255, 255, 255, 0.25);
2148    }
2149
2150    .toc-title {
2151      font-size: 18px;
2152      font-weight: 700;
2153      color: white;
2154      margin-bottom: 10px;
2155      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2156    }
2157
2158    .toc-list {
2159      list-style: none;
2160      padding-left: 18px;
2161      margin: 0;
2162    }
2163
2164    .book-toc > .toc-list {
2165      padding-left: 0;
2166    }
2167
2168    .book-toc li {
2169      margin: 2px 0;
2170    }
2171
2172    .toc-link {
2173      display: inline-block;
2174      color: white;
2175      text-decoration: none;
2176      padding: 3px 0;
2177      font-size: 15px;
2178      font-weight: 500;
2179      transition: opacity 0.2s;
2180    }
2181
2182    .toc-link:hover {
2183      opacity: 0.7;
2184      text-decoration: underline;
2185    }
2186
2187    .book-toc > .toc-list > li > .toc-link {
2188      font-weight: 700;
2189      font-size: 16px;
2190    }
2191  </style>
2192</head>
2193<body>
2194  <!-- Top Navigation -->
2195  <div class="top-nav">
2196    <div class="top-nav-content">
2197      <div class="nav-buttons">
2198        <div class="nav-left">
2199          <a href="/api/v1/root/${nodeId}?token=${token ?? ""}&html" class="nav-button">
2200            ← Back to Tree
2201          </a>
2202
2203        </div>
2204        <button class="nav-button" onclick="generateShare()">
2205          🔗 Generate Share Link
2206        </button>
2207      </div>
2208
2209<div class="page-title">Book: ${escapeHtml(title)}</div>
2210
2211      <!-- Filters -->
2212      <div class="filters">
2213        <button onclick="toggleFlag('latestVersionOnly')" class="filter-button ${
2214          options.latestVersionOnly ? "active" : ""
2215        }">
2216          Latest Versions Only
2217        </button>
2218        <button onclick="toggleFlag('lastNoteOnly')" class="filter-button ${
2219          options.lastNoteOnly ? "active" : ""
2220        }">
2221          Most Recent Note
2222        </button>
2223        <button onclick="toggleFlag('leafNotesOnly')" class="filter-button ${
2224          options.leafNotesOnly ? "active" : ""
2225        }">
2226          Leaf Details Only
2227        </button>
2228        <button onclick="toggleFlag('filesOnly')" class="filter-button ${
2229          options.filesOnly ? "active" : ""
2230        }">
2231          Files Only
2232        </button>
2233        <button onclick="toggleFlag('textOnly')" class="filter-button ${
2234          options.textOnly ? "active" : ""
2235        }">
2236          Text Only
2237        </button>
2238        <button onclick="toggleStatus('active')" class="filter-button ${
2239          isStatusActive ? "active" : ""
2240        }">
2241          Active
2242        </button>
2243        <button onclick="toggleStatus('completed')" class="filter-button ${
2244          isStatusCompleted ? "active" : ""
2245        }">
2246          Completed
2247        </button>
2248        <button onclick="toggleStatus('trimmed')" class="filter-button ${
2249          isStatusTrimmed ? "active" : ""
2250        }">
2251          Trimmed
2252        </button>
2253        <button onclick="toggleFlag('toc')" class="filter-button ${
2254          tocEnabled ? "active" : ""
2255        }">
2256          Table of Contents
2257        </button>
2258        ${tocDepthSelect}
2259      </div>
2260    </div>
2261  </div>
2262
2263  <!-- Content -->
2264  <div class="content-wrapper">
2265    ${tocEnabled && hasContent ? renderTocBlock(book, tocDepth) : ""}
2266    <div class="content">
2267      ${bookContent}
2268    </div>
2269  </div>
2270
2271  <script>
2272    function tocScroll(id) {
2273      var el = document.getElementById(id);
2274      if (!el) return;
2275      var nav = document.querySelector('.top-nav');
2276      var offset = nav ? nav.offsetHeight + 12 : 12;
2277      var top = el.getBoundingClientRect().top + window.scrollY - offset;
2278      window.scrollTo({ top: top, behavior: 'smooth' });
2279    }
2280  </script>
2281
2282  <!-- Lazy Media Loader -->
2283  <script>
2284    const lazyObserver = new IntersectionObserver(
2285      (entries, observer) => {
2286        entries.forEach(entry => {
2287          if (!entry.isIntersecting) return;
2288
2289          const el = entry.target;
2290          const src = el.dataset.src;
2291
2292          if (src) {
2293            el.src = src;
2294            el.removeAttribute("data-src");
2295          }
2296
2297          observer.unobserve(el);
2298        });
2299      },
2300      { rootMargin: "200px" }
2301    );
2302
2303    document
2304      .querySelectorAll(".lazy-media[data-src]")
2305      .forEach(el => lazyObserver.observe(el));
2306  </script>
2307
2308  <script>
2309    function toggleFlag(flag) {
2310      const url = new URL(window.location.href);
2311
2312      if (url.searchParams.has(flag)) {
2313        url.searchParams.delete(flag);
2314      } else {
2315        url.searchParams.set(flag, "true");
2316      }
2317
2318      url.searchParams.set("html", "true");
2319      window.location.href = url.toString();
2320    }
2321
2322    function toggleStatus(flag) {
2323      const url = new URL(window.location.href);
2324      const params = url.searchParams;
2325
2326      const defaults = {
2327        active: true,
2328        completed: true,
2329        trimmed: false,
2330      };
2331
2332      const current = params.has(flag)
2333        ? params.get(flag) === "true"
2334        : defaults[flag];
2335
2336      const next = !current;
2337
2338      if (next === defaults[flag]) {
2339        params.delete(flag);
2340      } else {
2341        params.set(flag, String(next));
2342      }
2343
2344      params.set("html", "true");
2345      window.location.href = url.toString();
2346    }
2347
2348    async function generateShare() {
2349      const params = Object.fromEntries(new URLSearchParams(window.location.search));
2350      const res = await fetch(window.location.pathname + "/generate", {
2351        method: "POST",
2352        headers: { "Content-Type": "application/json" },
2353        body: JSON.stringify(params),
2354      });
2355
2356      const data = await res.json();
2357      if (data.redirect) {
2358        window.location.href = data.redirect;
2359      }
2360    }
2361
2362    function setTocDepth(val) {
2363      const url = new URL(window.location.href);
2364      if (val === "0") {
2365        url.searchParams.delete("tocDepth");
2366      } else {
2367        url.searchParams.set("tocDepth", val);
2368      }
2369      url.searchParams.set("html", "true");
2370      window.location.href = url.toString();
2371    }
2372  </script>
2373
2374</body>
2375</html>
2376  `;
2377}
2378
2379export function renderSharedBookPage({
2380  nodeId,
2381  title,
2382  content,
2383  shareTocEnabled,
2384  shareTocDepth,
2385  book,
2386  hasContent,
2387}) {
2388  return `
2389<!DOCTYPE html>
2390<html lang="en">
2391<head>
2392  <meta charset="UTF-8">
2393  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
2394  <meta name="theme-color" content="#667eea">
2395  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2396  <title>Book: ${escapeHtml(title)} - TreeOS</title>
2397  <meta name="description" content="Book view of ${escapeHtml(title)} on TreeOS." />
2398  <meta property="og:title" content="Book: ${escapeHtml(title)} - TreeOS" />
2399  <meta property="og:description" content="Book view of ${escapeHtml(title)} on TreeOS." />
2400  <meta property="og:type" content="article" />
2401  <meta property="og:site_name" content="TreeOS" />
2402  <meta property="og:image" content="${getLandUrl()}/tree.png" />
2403  <style>
2404    ${baseStyles}
2405
2406    /* ── Shared book page overrides on base ── */
2407    body { padding: 0; }
2408
2409    /* Top Navigation Bar - Glass */
2410    .top-nav {
2411      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2412      backdrop-filter: blur(22px) saturate(140%);
2413      -webkit-backdrop-filter: blur(22px) saturate(140%);
2414      padding: 10px 20px;
2415      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
2416        inset 0 1px 0 rgba(255, 255, 255, 0.25);
2417      border-bottom: 1px solid rgba(255, 255, 255, 0.28);
2418      position: sticky;
2419      top: 0;
2420      z-index: 100;
2421      animation: fadeInUp 0.5s ease-out;
2422    }
2423
2424    .top-nav-content {
2425      max-width: 900px;
2426      margin: 0 auto;
2427    }
2428
2429    .nav-buttons {
2430      display: flex;
2431      align-items: center;
2432      gap: 8px;
2433      flex-wrap: nowrap;
2434    }
2435
2436    /* Glass Navigation Buttons */
2437    .nav-button {
2438      display: inline-flex;
2439      align-items: center;
2440      justify-content: center;
2441      gap: 4px;
2442      padding: 8px 10px;
2443      flex: 1;
2444      background: rgba(255, 255, 255, 0.2);
2445      backdrop-filter: blur(10px);
2446      color: white;
2447      text-decoration: none;
2448      border-radius: 980px;
2449      font-weight: 600;
2450      font-size: 13px;
2451      white-space: nowrap;
2452      transition: all 0.3s;
2453      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
2454      border: 1px solid rgba(255, 255, 255, 0.3);
2455      position: relative;
2456      overflow: hidden;
2457      cursor: pointer;
2458      touch-action: manipulation;
2459    }
2460
2461    .nav-button::before {
2462      content: "";
2463      position: absolute;
2464      inset: -40%;
2465      background: radial-gradient(
2466        120% 60% at 0% 0%,
2467        rgba(255, 255, 255, 0.35),
2468        transparent 60%
2469      );
2470      opacity: 0;
2471      transform: translateX(-30%) translateY(-10%);
2472      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
2473      pointer-events: none;
2474    }
2475
2476    .nav-button:hover {
2477      background: rgba(255, 255, 255, 0.3);
2478      transform: translateY(-2px);
2479      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
2480    }
2481
2482    .nav-button:hover::before {
2483      opacity: 1;
2484      transform: translateX(30%) translateY(10%);
2485    }
2486
2487    .page-title {
2488      font-size: 20px;
2489      font-weight: 600;
2490      color: white;
2491      margin-bottom: 12px;
2492      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2493      letter-spacing: -0.3px;
2494    }
2495
2496    /* Glass Filter Buttons */
2497    .filters {
2498      display: flex;
2499      gap: 8px;
2500      flex-wrap: wrap;
2501    }
2502
2503    .filter-button {
2504      padding: 8px 14px;
2505      font-size: 13px;
2506      font-weight: 600;
2507      border-radius: 980px;
2508      border: 1px solid rgba(255, 255, 255, 0.25);
2509      background: rgba(255, 255, 255, 0.15);
2510      backdrop-filter: blur(10px);
2511      color: white;
2512      cursor: pointer;
2513      transition: all 0.3s;
2514      font-family: inherit;
2515      white-space: nowrap;
2516      position: relative;
2517      overflow: hidden;
2518    }
2519
2520    .filter-button::before {
2521      content: "";
2522      position: absolute;
2523      inset: -40%;
2524      background: radial-gradient(
2525        120% 60% at 0% 0%,
2526        rgba(255, 255, 255, 0.35),
2527        transparent 60%
2528      );
2529      opacity: 0;
2530      transform: translateX(-30%) translateY(-10%);
2531      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
2532      pointer-events: none;
2533    }
2534
2535    .filter-button:hover {
2536      background: rgba(255, 255, 255, 0.25);
2537      transform: translateY(-1px);
2538    }
2539
2540    .filter-button:hover::before {
2541      opacity: 1;
2542      transform: translateX(30%) translateY(10%);
2543    }
2544
2545    .filter-button.active {
2546      background: rgba(255, 255, 255, 0.35);
2547      border-color: rgba(255, 255, 255, 0.5);
2548      box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15),
2549        inset 0 1px 0 rgba(255, 255, 255, 0.4);
2550    }
2551
2552    .filter-button.active:hover {
2553      background: rgba(255, 255, 255, 0.45);
2554      transform: translateY(-2px);
2555      box-shadow: 0 6px 25px rgba(0, 0, 0, 0.2);
2556    }
2557
2558    .toc-select {
2559      padding: 8px 14px;
2560      font-size: 13px;
2561      font-weight: 600;
2562      border-radius: 980px;
2563      border: 1px solid rgba(255, 255, 255, 0.25);
2564      background: rgba(255, 255, 255, 0.15);
2565      backdrop-filter: blur(10px);
2566      color: white;
2567      cursor: pointer;
2568      font-family: inherit;
2569      appearance: none;
2570      -webkit-appearance: none;
2571      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
2572      background-repeat: no-repeat;
2573      background-position: right 12px center;
2574      padding-right: 30px;
2575    }
2576
2577    .toc-select option {
2578      background: #5a56c4;
2579      color: white;
2580    }
2581
2582    /* Content Container */
2583    .content-wrapper {
2584      padding: 24px 20px;
2585    }
2586
2587    .content {
2588      max-width: 900px;
2589      margin: 0 auto;
2590      font-family: "Charter", "Georgia", "Iowan Old Style", "Times New Roman", serif;
2591      line-height: 1.7;
2592      word-wrap: break-word;
2593      overflow-wrap: break-word;
2594      animation: fadeInUp 0.6s ease-out 0.1s both;
2595    }
2596
2597    /* Layered Glass Sections - Each depth gets more opaque glass */
2598    .book-section {
2599      margin-bottom: 40px;
2600      position: relative;
2601    }
2602
2603    .book-section.depth-1 {
2604      margin-bottom: 48px;
2605      padding: 24px;
2606      background: rgba(255, 255, 255, 0.08);
2607      border-radius: 12px;
2608      border: 1px solid rgba(255, 255, 255, 0.15);
2609      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
2610    }
2611
2612    .book-section.depth-2 {
2613      margin-bottom: 32px;
2614      margin-left: 8px;
2615      padding: 20px;
2616      background: rgba(255, 255, 255, 0.06);
2617      border-radius: 10px;
2618      border: 1px solid rgba(255, 255, 255, 0.12);
2619    }
2620
2621    .book-section.depth-3 {
2622      margin-bottom: 24px;
2623      margin-left: 8px;
2624      padding: 16px;
2625      background: rgba(255, 255, 255, 0.04);
2626      border-radius: 8px;
2627      border: 1px solid rgba(255, 255, 255, 0.1);
2628    }
2629
2630    .book-section.depth-4 {
2631      margin-bottom: 20px;
2632      margin-left: 8px;
2633      padding: 12px;
2634      background: rgba(255, 255, 255, 0.03);
2635      border-radius: 6px;
2636      border: 1px solid rgba(255, 255, 255, 0.08);
2637    }
2638
2639    .book-section.depth-5 {
2640      margin-bottom: 16px;
2641      margin-left: 8px;
2642      padding: 10px;
2643      background: rgba(255, 255, 255, 0.02);
2644      border-radius: 6px;
2645      border: 1px solid rgba(255, 255, 255, 0.06);
2646    }
2647
2648    /* Heading Hierarchy */
2649    h1, h2, h3, h4, h5 {
2650      font-weight: 600;
2651      line-height: 1.3;
2652      margin: 0 0 16px 0;
2653      color: white;
2654      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2655      letter-spacing: -0.5px;
2656    }
2657
2658    h1 {
2659      font-size: 36px;
2660      margin-top: 48px;
2661      margin-bottom: 24px;
2662      padding-bottom: 16px;
2663      border-bottom: 2px solid rgba(255, 255, 255, 0.3);
2664    }
2665
2666    .book-section.depth-1:first-child h1 {
2667      margin-top: 0;
2668    }
2669
2670    h2 {
2671      font-size: 30px;
2672      margin-top: 40px;
2673      margin-bottom: 20px;
2674      padding-bottom: 12px;
2675      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
2676    }
2677
2678    h3 {
2679      font-size: 24px;
2680      margin-top: 32px;
2681      margin-bottom: 16px;
2682    }
2683
2684    h4 {
2685      font-size: 20px;
2686      margin-top: 24px;
2687      margin-bottom: 12px;
2688    }
2689
2690    h5 {
2691      font-size: 18px;
2692      margin-top: 20px;
2693      margin-bottom: 10px;
2694    }
2695
2696    /* Note Content - Glowing Text */
2697    .note-content {
2698      margin: 16px 0 28px 0;
2699      padding: 0;
2700      font-size: 18px;
2701      line-height: 1.8;
2702      color: #F5F5DC;
2703      word-wrap: break-word;
2704      overflow-wrap: break-word;
2705      font-weight: 400;
2706    }
2707
2708    .note-link {
2709      color: inherit;
2710      text-decoration: none;
2711      white-space: pre-wrap;
2712      word-wrap: break-word;
2713      overflow-wrap: break-word;
2714      display: block;
2715      padding: 12px 16px;
2716      margin: -12px -16px;
2717      border-radius: 8px;
2718      transition: all 0.3s;
2719      position: relative;
2720      overflow: hidden;
2721    }
2722
2723    .note-link::before {
2724      content: "";
2725      position: absolute;
2726      inset: 0;
2727      background: linear-gradient(
2728        110deg,
2729        transparent 40%,
2730        rgba(255, 255, 255, 0.2),
2731        transparent 60%
2732      );
2733      opacity: 0;
2734      transform: translateX(-100%);
2735      pointer-events: none;
2736    }
2737
2738    .note-link:hover {
2739      background-color: rgba(255, 255, 255, 0.1);
2740      transform: translateX(4px);
2741    }
2742
2743    .note-link:hover::before {
2744      opacity: 1;
2745      animation: glassShimmer 1s ease forwards;
2746    }
2747
2748    @keyframes glassShimmer {
2749      0% {
2750        opacity: 0;
2751        transform: translateX(-120%) skewX(-15deg);
2752      }
2753      50% {
2754        opacity: 1;
2755      }
2756      100% {
2757        opacity: 0;
2758        transform: translateX(120%) skewX(-15deg);
2759      }
2760    }
2761
2762    .note-link:active {
2763      background-color: rgba(255, 255, 255, 0.15);
2764    }
2765
2766    /* File Containers - Deeper Glass */
2767    .file-container {
2768      margin: 24px 0;
2769      padding: 20px;
2770      background: rgba(255, 255, 255, 0.15);
2771      backdrop-filter: blur(18px);
2772      border: 1px solid rgba(255, 255, 255, 0.3);
2773      border-radius: 12px;
2774      transition: all 0.3s;
2775      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
2776    }
2777
2778    .file-container:hover {
2779      border-color: rgba(255, 255, 255, 0.5);
2780      box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
2781      background: rgba(255, 255, 255, 0.2);
2782    }
2783
2784    .file-container .note-link {
2785      display: inline-block;
2786      margin-bottom: 12px;
2787      color: white;
2788      font-size: 16px;
2789      font-weight: 600;
2790      padding: 4px 8px;
2791      margin: -4px -8px 8px;
2792    }
2793
2794    .file-container .note-link:hover {
2795      background-color: rgba(255, 255, 255, 0.15);
2796      text-decoration: underline;
2797    }
2798
2799    /* Media Elements */
2800    img {
2801      max-width: 100%;
2802      height: auto;
2803      border-radius: 8px;
2804      margin-top: 12px;
2805      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
2806      border: 1px solid rgba(255, 255, 255, 0.2);
2807    }
2808
2809    video, audio {
2810      max-width: 100%;
2811      margin-top: 12px;
2812      border-radius: 8px;
2813      border: 1px solid rgba(255, 255, 255, 0.2);
2814    }
2815
2816    iframe {
2817      width: 100%;
2818      height: 600px;
2819      border: none;
2820      border-radius: 8px;
2821      margin-top: 12px;
2822      border: 1px solid rgba(255, 255, 255, 0.2);
2823    }
2824
2825    /* Empty State */
2826    .empty-state {
2827      text-align: center;
2828      padding: 80px 40px;
2829    }
2830
2831    .empty-state-icon {
2832      font-size: 64px;
2833      margin-bottom: 16px;
2834      filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
2835    }
2836
2837    .empty-state-text {
2838      font-size: 24px;
2839      color: white;
2840      margin-bottom: 8px;
2841      font-weight: 600;
2842      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2843    }
2844
2845    .empty-state-subtext {
2846      font-size: 16px;
2847      color: rgba(255, 255, 255, 0.8);
2848    }
2849
2850    /* Responsive Design */
2851    @media (max-width: 1024px) {
2852    }
2853
2854    @media (max-width: 768px) {
2855      .top-nav {
2856        padding: 12px 16px;
2857      }
2858
2859      .nav-button {
2860        padding: 8px 12px;
2861        font-size: 13px;
2862      }
2863
2864      .page-title {
2865        font-size: 18px;
2866      }
2867
2868      .filter-button {
2869        padding: 6px 12px;
2870        font-size: 12px;
2871      }
2872
2873      .content-wrapper {
2874        padding: 24px 16px;
2875      }
2876
2877      h1 {
2878        font-size: 30px;
2879      }
2880
2881      h2 {
2882        font-size: 26px;
2883      }
2884
2885      h3 {
2886        font-size: 22px;
2887      }
2888
2889      h4 {
2890        font-size: 19px;
2891      }
2892
2893      h5 {
2894        font-size: 17px;
2895      }
2896
2897      .note-content {
2898        font-size: 17px;
2899      }
2900
2901      .book-section.depth-2,
2902      .book-section.depth-3,
2903      .book-section.depth-4,
2904      .book-section.depth-5 {
2905        margin-left: 4px;
2906      }
2907    }
2908
2909    @media (max-width: 480px) {
2910      .nav-button {
2911        padding: 8px 6px;
2912        font-size: 11px;
2913        gap: 2px;
2914      }
2915
2916      .book-section.depth-1,
2917      .book-section.depth-2,
2918      .book-section.depth-3,
2919      .book-section.depth-4,
2920      .book-section.depth-5 {
2921        margin-left: 0;
2922        padding: 12px;
2923      }
2924    }
2925
2926    html { scroll-behavior: smooth; }
2927
2928    .book-toc {
2929      max-width: 900px;
2930      margin: 20px auto 24px;
2931      padding: 20px 28px;
2932      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2933      backdrop-filter: blur(22px) saturate(140%);
2934      -webkit-backdrop-filter: blur(22px) saturate(140%);
2935      border: 1px solid rgba(255, 255, 255, 0.28);
2936      border-radius: 16px;
2937      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
2938        inset 0 1px 0 rgba(255, 255, 255, 0.25);
2939    }
2940
2941    .toc-title {
2942      font-size: 18px;
2943      font-weight: 700;
2944      color: white;
2945      margin-bottom: 10px;
2946      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2947    }
2948
2949    .toc-list {
2950      list-style: none;
2951      padding-left: 18px;
2952      margin: 0;
2953    }
2954
2955    .book-toc > .toc-list {
2956      padding-left: 0;
2957    }
2958
2959    .book-toc li {
2960      margin: 2px 0;
2961    }
2962
2963    .toc-link {
2964      display: inline-block;
2965      color: white;
2966      text-decoration: none;
2967      padding: 3px 0;
2968      font-size: 15px;
2969      font-weight: 500;
2970      transition: opacity 0.2s;
2971    }
2972
2973    .toc-link:hover {
2974      opacity: 0.7;
2975      text-decoration: underline;
2976    }
2977
2978    .book-toc > .toc-list > li > .toc-link {
2979      font-weight: 700;
2980      font-size: 16px;
2981    }
2982
2983    .share-book-title {
2984      max-width: 900px;
2985      margin: 24px auto 0;
2986      font-size: 28px;
2987      font-weight: 700;
2988      color: white;
2989      text-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
2990      text-align: center;
2991    }
2992
2993    /* Title toggle active state */
2994    .nav-button.active {
2995      background: rgba(255, 255, 255, 0.4);
2996      border-color: rgba(255, 255, 255, 0.5);
2997    }
2998
2999    /* Hide titles mode */
3000    #bookContent.hide-titles h1,
3001    #bookContent.hide-titles h2,
3002    #bookContent.hide-titles h3,
3003    #bookContent.hide-titles h4,
3004    #bookContent.hide-titles h5 {
3005      display: none;
3006    }
3007
3008    /* TOC scroll-to-top circle */
3009    .toc-top-btn {
3010      position: fixed;
3011      top: 60px;
3012      right: 16px;
3013      z-index: 200;
3014      width: 42px;
3015      height: 42px;
3016      border-radius: 50%;
3017      border: 1px solid rgba(255, 255, 255, 0.3);
3018      background: rgba(var(--glass-water-rgb), 0.5);
3019      backdrop-filter: blur(16px);
3020      -webkit-backdrop-filter: blur(16px);
3021      color: white;
3022      font-size: 18px;
3023      cursor: pointer;
3024      display: flex;
3025      align-items: center;
3026      justify-content: center;
3027      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
3028      opacity: 0;
3029      pointer-events: none;
3030      transition: opacity 0.3s, transform 0.3s;
3031      touch-action: manipulation;
3032    }
3033
3034    .toc-top-btn.visible {
3035      opacity: 1;
3036      pointer-events: auto;
3037    }
3038
3039    .toc-top-btn:hover {
3040      background: rgba(var(--glass-water-rgb), 0.7);
3041      transform: scale(1.1);
3042    }
3043  </style>
3044</head>
3045<body>
3046  <!-- Share Nav -->
3047  <div class="top-nav">
3048    <div class="top-nav-content">
3049      <div class="nav-buttons">
3050        <a href="/" class="nav-button" onclick="event.preventDefault();window.top.location.href='/';">Home</a>
3051        <button class="nav-button" id="copyUrlBtn">Copy URL</button>
3052        <button class="nav-button" id="copyTextBtn">Copy Text</button>
3053        <button class="nav-button" id="toggleTitlesBtn" onclick="toggleTitles()" title="Toggle Titles">Aa</button>
3054      </div>
3055    </div>
3056  </div>
3057
3058  ${shareTocEnabled && hasContent ? `<button class="toc-top-btn" id="tocTopBtn" onclick="window.scrollTo({top:0,behavior:'smooth'})">&#9650;</button>` : ""}
3059
3060  <!-- Content -->
3061  <div class="content-wrapper">
3062    ${shareTocEnabled && hasContent ? `<div class="share-book-title">${escapeHtml(title)}</div>${renderTocBlock(book, shareTocDepth)}` : ""}
3063    <div class="content" id="bookContent">
3064      ${content}
3065    </div>
3066  </div>
3067
3068  <script>
3069    function tocScroll(id) {
3070      var el = document.getElementById(id);
3071      if (!el) return;
3072      var nav = document.querySelector('.top-nav');
3073      var offset = nav ? nav.offsetHeight + 12 : 12;
3074      var top = el.getBoundingClientRect().top + window.scrollY - offset;
3075      window.scrollTo({ top: top, behavior: 'smooth' });
3076    }
3077
3078    function toggleTitles() {
3079      var bc = document.getElementById('bookContent');
3080      var btn = document.getElementById('toggleTitlesBtn');
3081      bc.classList.toggle('hide-titles');
3082      if (bc.classList.contains('hide-titles')) {
3083        btn.classList.add('active');
3084      } else {
3085        btn.classList.remove('active');
3086      }
3087    }
3088
3089    ${shareTocEnabled && hasContent ? `
3090    (function() {
3091      var tocBtn = document.getElementById('tocTopBtn');
3092      if (!tocBtn) return;
3093      window.addEventListener('scroll', function() {
3094        if (window.scrollY > 200) {
3095          tocBtn.classList.add('visible');
3096        } else {
3097          tocBtn.classList.remove('visible');
3098        }
3099      }, { passive: true });
3100    })();
3101    ` : ""}
3102  </script>
3103
3104  <!-- Lazy Media Loader -->
3105  <script>
3106    document.getElementById("copyUrlBtn").addEventListener("click", function() {
3107      var url = new URL(window.location.href);
3108      url.searchParams.delete("token");
3109      if (!url.searchParams.has("html")) url.searchParams.set("html", "");
3110      navigator.clipboard.writeText(url.toString()).then(function() {
3111        this.textContent = "Copied";
3112        setTimeout(function() { document.getElementById("copyUrlBtn").textContent = "Copy URL"; }, 900);
3113      }.bind(this));
3114    });
3115
3116    document.getElementById("copyTextBtn").addEventListener("click", function() {
3117      var text = document.getElementById("bookContent").innerText;
3118      navigator.clipboard.writeText(text).then(function() {
3119        document.getElementById("copyTextBtn").textContent = "Copied";
3120        setTimeout(function() { document.getElementById("copyTextBtn").textContent = "Copy Text"; }, 900);
3121      });
3122    });
3123
3124    const lazyObserver = new IntersectionObserver(
3125      (entries, observer) => {
3126        entries.forEach(entry => {
3127          if (!entry.isIntersecting) return;
3128
3129          const el = entry.target;
3130          const src = el.dataset.src;
3131
3132          if (src) {
3133            el.src = src;
3134            el.removeAttribute("data-src");
3135          }
3136
3137          observer.unobserve(el);
3138        });
3139      },
3140      { rootMargin: "200px" }
3141    );
3142
3143    document
3144      .querySelectorAll(".lazy-media[data-src]")
3145      .forEach(el => lazyObserver.observe(el));
3146  </script>
3147
3148  <script>
3149    function toggleFlag(flag) {
3150      const url = new URL(window.location.href);
3151
3152      if (url.searchParams.has(flag)) {
3153        url.searchParams.delete(flag);
3154      } else {
3155        url.searchParams.set(flag, "true");
3156      }
3157
3158      url.searchParams.set("html", "true");
3159      window.location.href = url.toString();
3160    }
3161
3162    function toggleStatus(flag) {
3163      const url = new URL(window.location.href);
3164      const params = url.searchParams;
3165
3166      const defaults = {
3167        active: true,
3168        completed: true,
3169        trimmed: false,
3170      };
3171
3172      const current = params.has(flag)
3173        ? params.get(flag) === "true"
3174        : defaults[flag];
3175
3176      const next = !current;
3177
3178      if (next === defaults[flag]) {
3179        params.delete(flag);
3180      } else {
3181        params.set(flag, String(next));
3182      }
3183
3184      params.set("html", "true");
3185      window.location.href = url.toString();
3186    }
3187
3188    async function generateShare() {
3189      const params = Object.fromEntries(new URLSearchParams(window.location.search));
3190      const res = await fetch(window.location.pathname + "/generate", {
3191        method: "POST",
3192        headers: { "Content-Type": "application/json" },
3193        body: JSON.stringify(params),
3194      });
3195
3196      const data = await res.json();
3197      if (data.redirect) {
3198        window.location.href = data.redirect;
3199      }
3200    }
3201  </script>
3202
3203</body>
3204</html>
3205  `;
3206}
3207
3208export function renderNotesList({
3209  nodeId,
3210  version,
3211  token,
3212  nodeName,
3213  notes,
3214  currentUserId,
3215}) {
3216  const base = `/api/v1/node/${nodeId}/${version}`;
3217
3218  return `
3219<!DOCTYPE html>
3220<html lang="en">
3221<head>
3222  <meta charset="UTF-8">
3223  <meta name="viewport" content="width=device-width, initial-scale=1.0">
3224  <meta name="theme-color" content="#667eea">
3225  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
3226<title>${escapeHtml(nodeName)} — Notes</title>
3227  <style>
3228${baseStyles}
3229
3230/* ── Notes list overrides on base ── */
3231body {
3232  height: 100vh;
3233  height: 100dvh;
3234  display: flex;
3235  flex-direction: column;
3236  overflow: hidden;
3237  padding: 0;
3238  min-height: auto;
3239}
3240
3241/* Glass Top Navigation */
3242.top-nav {
3243  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3244  backdrop-filter: blur(22px) saturate(140%);
3245  -webkit-backdrop-filter: blur(22px) saturate(140%);
3246  padding: 16px 20px;
3247  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
3248    inset 0 1px 0 rgba(255, 255, 255, 0.25);
3249  border-bottom: 1px solid rgba(255, 255, 255, 0.28);
3250  flex-shrink: 0;
3251}
3252
3253.top-nav-content {
3254  max-width: 900px;
3255  margin: 0 auto;
3256  display: flex;
3257  justify-content: space-between;
3258  align-items: center;
3259  gap: 12px;
3260  flex-wrap: wrap;
3261}
3262
3263.nav-left {
3264  display: flex;
3265  gap: 10px;
3266  flex-wrap: wrap;
3267}
3268
3269/* Glass Navigation Buttons */
3270.nav-button,
3271.book-button {
3272  position: relative;
3273  overflow: hidden;
3274  display: inline-flex;
3275  align-items: center;
3276  gap: 6px;
3277  padding: 10px 20px;
3278  border-radius: 980px;
3279  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3280  backdrop-filter: blur(22px) saturate(140%);
3281  -webkit-backdrop-filter: blur(22px) saturate(140%);
3282  color: white;
3283  text-decoration: none;
3284  font-size: 15px;
3285  font-weight: 500;
3286  letter-spacing: -0.2px;
3287  border: 1px solid rgba(255, 255, 255, 0.28);
3288  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
3289    inset 0 1px 0 rgba(255, 255, 255, 0.25);
3290  cursor: pointer;
3291  transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1),
3292    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
3293    box-shadow 0.3s ease;
3294  white-space: nowrap;
3295}
3296
3297.nav-button::before,
3298.book-button::before {
3299  content: "";
3300  position: absolute;
3301  inset: -40%;
3302  background: radial-gradient(
3303      120% 60% at 0% 0%,
3304      rgba(255, 255, 255, 0.35),
3305      transparent 60%
3306    ),
3307    linear-gradient(
3308      120deg,
3309      transparent 30%,
3310      rgba(255, 255, 255, 0.25),
3311      transparent 70%
3312    );
3313  opacity: 0;
3314  transform: translateX(-30%) translateY(-10%);
3315  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
3316  pointer-events: none;
3317}
3318
3319.nav-button:hover,
3320.book-button:hover {
3321  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
3322  transform: translateY(-1px);
3323  animation: waterDrift 2.2s ease-in-out infinite alternate;
3324}
3325
3326.nav-button:hover::before,
3327.book-button:hover::before {
3328  opacity: 1;
3329  transform: translateX(30%) translateY(10%);
3330}
3331
3332@keyframes waterDrift {
3333  0% { transform: translateY(-1px); }
3334  100% { transform: translateY(1px); }
3335}
3336
3337.book-button {
3338  --glass-alpha: 0.34;
3339  --glass-alpha-hover: 0.46;
3340  font-weight: 600;
3341}
3342
3343.page-title {
3344  width: 100%;
3345  margin-top: 12px;
3346  font-size: 18px;
3347  font-weight: 600;
3348  color: white;
3349  letter-spacing: -0.3px;
3350  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3351}
3352
3353.page-title a {
3354  color: white;
3355  text-decoration: none;
3356  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
3357  transition: all 0.2s;
3358}
3359
3360.page-title a:hover {
3361  border-bottom-color: white;
3362  text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
3363}
3364
3365/* Notes Container */
3366.notes-container {
3367  flex: 1;
3368  overflow-y: auto;
3369  padding: 20px;
3370  position: relative;
3371  z-index: 1;
3372}
3373
3374.notes-wrapper {
3375  max-width: 900px;
3376  margin: 0 auto;
3377  width: 100%;
3378}
3379
3380.notes-list {
3381  list-style: none;
3382  padding: 0;
3383  display: flex;
3384  flex-direction: column;
3385  gap: 12px;
3386}
3387
3388/* Glass Note Messages */
3389.note-item {
3390  display: flex;
3391  animation: slideIn 0.3s ease-out;
3392}
3393
3394@keyframes slideIn {
3395  from {
3396    opacity: 0;
3397    transform: translateY(10px);
3398  }
3399  to {
3400    opacity: 1;
3401    transform: translateY(0);
3402  }
3403}
3404
3405.note-item.self {
3406  flex-direction: row-reverse;
3407}
3408
3409.note-bubble {
3410  position: relative;
3411  max-width: 70%;
3412  padding: 14px 18px;
3413  border-radius: 12px;
3414  background: rgba(255, 255, 255, 0.15);
3415  backdrop-filter: blur(22px) saturate(140%);
3416  -webkit-backdrop-filter: blur(22px) saturate(140%);
3417  border: 1px solid rgba(255, 255, 255, 0.28);
3418  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1),
3419    inset 0 1px 0 rgba(255, 255, 255, 0.25);
3420  color: white;
3421  word-wrap: break-word;
3422  overflow-wrap: break-word;
3423}
3424
3425/* Self messages - slightly more opaque */
3426.note-item.self .note-bubble {
3427  background: rgba(255, 255, 255, 0.2);
3428}
3429
3430/* Reflection messages - golden tint */
3431.note-item.reflection .note-bubble {
3432  background: rgba(255, 215, 79, 0.25);
3433  border-color: rgba(255, 215, 79, 0.4);
3434  box-shadow: 0 4px 16px rgba(255, 193, 7, 0.2),
3435    inset 0 1px 0 rgba(255, 255, 255, 0.3);
3436}
3437
3438.file-badge {
3439  display: inline-block;
3440  padding: 4px 10px;
3441  background: rgba(255, 255, 255, 0.2);
3442  border-radius: 12px;
3443  font-size: 11px;
3444  font-weight: 600;
3445  margin-bottom: 8px;
3446  text-transform: uppercase;
3447  letter-spacing: 0.5px;
3448  border: 1px solid rgba(255, 255, 255, 0.3);
3449}
3450
3451.note-author {
3452  font-weight: 600;
3453  margin-bottom: 6px;
3454  font-size: 13px;
3455  opacity: 0.85;
3456  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
3457  letter-spacing: -0.2px;
3458}
3459
3460.note-author a {
3461  color: inherit;
3462  text-decoration: none;
3463  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
3464}
3465
3466.note-author a:hover {
3467  border-bottom-color: white;
3468}
3469
3470.note-item.self .note-author {
3471  display: none;
3472}
3473
3474.note-content {
3475  font-size: 15px;
3476  line-height: 1.5;
3477  margin-bottom: 6px;
3478  font-weight: 400;
3479}
3480
3481.note-content a {
3482  color: inherit;
3483  text-decoration: none;
3484}
3485
3486.note-content a:hover {
3487  text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
3488}
3489
3490.note-meta {
3491  font-size: 11px;
3492  opacity: 0.7;
3493  margin-top: 6px;
3494  display: flex;
3495  justify-content: space-between;
3496  align-items: center;
3497  gap: 8px;
3498}
3499
3500.delete-button {
3501  background: rgba(255, 255, 255, 0.15);
3502  border: 1px solid rgba(255, 255, 255, 0.25);
3503  border-radius: 50%;
3504  width: 24px;
3505  height: 24px;
3506  display: flex;
3507  align-items: center;
3508  justify-content: center;
3509  cursor: pointer;
3510  padding: 0;
3511  opacity: 0.7;
3512  transition: all 0.2s;
3513  font-size: 12px;
3514  color: white;
3515}
3516
3517/* Character counter */
3518.char-counter {
3519  display: flex;
3520  justify-content: flex-end;
3521  align-items: center;
3522  margin-top: 6px;
3523  font-size: 12px;
3524  color: rgba(255, 255, 255, 0.6);
3525  font-weight: 500;
3526  transition: color 0.2s;
3527}
3528
3529.char-counter.warning {
3530  color: rgba(255, 193, 7, 0.9);
3531}
3532
3533.char-counter.danger {
3534  color: rgba(239, 68, 68, 0.9);
3535  font-weight: 600;
3536}
3537
3538.char-counter.disabled {
3539  opacity: 0.4;
3540}
3541
3542/* Energy display (shared between text and file) */
3543.energy-display {
3544  display: inline-flex;
3545  align-items: center;
3546  gap: 4px;
3547  margin-left: 10px;
3548  padding: 2px 8px;
3549  background: rgba(255, 215, 79, 0.2);
3550  border: 1px solid rgba(255, 215, 79, 0.3);
3551  border-radius: 10px;
3552  font-size: 11px;
3553  font-weight: 600;
3554  color: rgba(255, 215, 79, 1);
3555  transition: all 0.2s;
3556}
3557
3558.energy-display:empty {
3559  display: none;
3560}
3561
3562.energy-display.file-energy {
3563  background: rgba(255, 220, 100, 0.9);
3564  border-color: rgba(255, 200, 50, 1);
3565  color: #1a1a1a;
3566  font-size: 13px;
3567  font-weight: 700;
3568  padding: 4px 12px;
3569  box-shadow: 0 2px 8px rgba(255, 200, 50, 0.4);
3570}
3571
3572/* File selected indicator */
3573.file-selected-badge {
3574  display: none;
3575  align-items: center;
3576  gap: 6px;
3577  padding: 6px 12px;
3578  background: rgba(255, 255, 255, 0.15);
3579  border: 1px solid rgba(255, 255, 255, 0.25);
3580  border-radius: 20px;
3581  font-size: 12px;
3582  font-weight: 500;
3583  color: white;
3584}
3585
3586.file-selected-badge.visible {
3587  display: inline-flex;
3588}
3589
3590.file-selected-badge .file-name {
3591  max-width: 120px;
3592  overflow: hidden;
3593  text-overflow: ellipsis;
3594  white-space: nowrap;
3595}
3596
3597.file-selected-badge .clear-file {
3598  background: rgba(255, 255, 255, 0.2);
3599  border: none;
3600  border-radius: 50%;
3601  width: 18px;
3602  height: 18px;
3603  display: flex;
3604  align-items: center;
3605  justify-content: center;
3606  cursor: pointer;
3607  font-size: 10px;
3608  color: white;
3609  transition: all 0.2s;
3610}
3611
3612.file-selected-badge .clear-file:hover {
3613  background: rgba(239, 68, 68, 0.4);
3614}
3615
3616@keyframes fadeIn {
3617  from { opacity: 0; transform: scale(0.95); }
3618  to { opacity: 1; transform: scale(1); }
3619}
3620
3621.delete-button:hover {
3622  opacity: 1;
3623  background: rgba(239, 68, 68, 0.3);
3624  border-color: rgba(239, 68, 68, 0.5);
3625  transform: scale(1.1);
3626}
3627
3628/* Glass Input Bar */
3629.input-bar {
3630  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3631  backdrop-filter: blur(22px) saturate(140%);
3632  -webkit-backdrop-filter: blur(22px) saturate(140%);
3633  padding: 20px;
3634  border-top: 1px solid rgba(255, 255, 255, 0.28);
3635  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.12),
3636    inset 0 1px 0 rgba(255, 255, 255, 0.25);
3637  flex-shrink: 0;
3638}
3639
3640.input-form {
3641  max-width: 900px;
3642  margin: 0 auto;
3643}
3644
3645textarea {
3646  width: 100%;
3647  padding: 14px 16px;
3648  border: 2px solid rgba(255, 255, 255, 0.3);
3649  border-radius: 12px;
3650  font-family: inherit;
3651  font-size: 16px;
3652  line-height: 1.5;
3653  resize: none;
3654  transition: all 0.3s;
3655  background: rgba(255, 255, 255, 0.15);
3656  backdrop-filter: blur(20px) saturate(150%);
3657  -webkit-backdrop-filter: blur(20px) saturate(150%);
3658  color: white;
3659  font-weight: 500;
3660  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
3661    inset 0 1px 0 rgba(255, 255, 255, 0.25);
3662  height: 56px;
3663  max-height: 120px;
3664  overflow-y: hidden;
3665}
3666
3667textarea::placeholder {
3668  color: rgba(255, 255, 255, 0.5);
3669}
3670
3671textarea:focus {
3672  outline: none;
3673  border-color: rgba(255, 255, 255, 0.6);
3674  background: rgba(255, 255, 255, 0.25);
3675  box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15),
3676    0 8px 30px rgba(0, 0, 0, 0.15),
3677    inset 0 1px 0 rgba(255, 255, 255, 0.4);
3678  transform: translateY(-2px);
3679}
3680
3681textarea:disabled {
3682  opacity: 0.4;
3683  cursor: not-allowed;
3684  background: rgba(255, 255, 255, 0.08);
3685  transform: none;
3686}
3687
3688textarea:disabled::placeholder {
3689  color: rgba(255, 255, 255, 0.3);
3690}
3691
3692.input-controls {
3693  display: flex;
3694  justify-content: space-between;
3695  align-items: center;
3696  gap: 12px;
3697  margin-top: 12px;
3698  flex-wrap: wrap;
3699}
3700
3701.input-options {
3702  display: flex;
3703  align-items: center;
3704  gap: 12px;
3705  flex-wrap: wrap;
3706}
3707
3708input[type="file"] {
3709  font-size: 13px;
3710  color: rgba(255, 255, 255, 0.9);
3711  cursor: pointer;
3712}
3713
3714input[type="file"]::file-selector-button {
3715  padding: 8px 16px;
3716  border-radius: 980px;
3717  border: 1px solid rgba(255, 255, 255, 0.3);
3718  background: rgba(255, 255, 255, 0.2);
3719  backdrop-filter: blur(10px);
3720  color: white;
3721  cursor: pointer;
3722  font-size: 13px;
3723  font-weight: 600;
3724  transition: all 0.2s;
3725  margin-right: 10px;
3726}
3727
3728input[type="file"]::file-selector-button:hover {
3729  background: rgba(255, 255, 255, 0.3);
3730  transform: translateY(-1px);
3731}
3732
3733/* Hide file input when file is selected, show badge instead */
3734input[type="file"].hidden-input {
3735  display: none;
3736}
3737
3738/* Glass Send Button */
3739.send-button {
3740  position: relative;
3741  overflow: hidden;
3742  padding: 12px 28px;
3743  border-radius: 980px;
3744  background: rgba(255, 255, 255, 0.25);
3745  backdrop-filter: blur(10px);
3746  color: white;
3747  border: 1px solid rgba(255, 255, 255, 0.3);
3748  font-size: 15px;
3749  font-weight: 600;
3750  letter-spacing: -0.2px;
3751  cursor: pointer;
3752  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
3753  white-space: nowrap;
3754  transition: all 0.3s;
3755}
3756
3757.send-button::before {
3758  content: "";
3759  position: absolute;
3760  inset: -40%;
3761  background: radial-gradient(
3762    120% 60% at 0% 0%,
3763    rgba(255, 255, 255, 0.35),
3764    transparent 60%
3765  );
3766  opacity: 0;
3767  transform: translateX(-30%) translateY(-10%);
3768  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
3769  pointer-events: none;
3770}
3771
3772.send-button:hover {
3773  background: rgba(255, 255, 255, 0.35);
3774  transform: translateY(-2px);
3775  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
3776}
3777
3778.send-button:hover::before {
3779  opacity: 1;
3780  transform: translateX(30%) translateY(10%);
3781}
3782
3783.send-button.loading .send-label {
3784  opacity: 0;
3785}
3786
3787/* Progress bar */
3788.send-progress {
3789  position: absolute;
3790  left: 0;
3791  top: 0;
3792  height: 100%;
3793  width: 0%;
3794  background: linear-gradient(
3795    90deg,
3796    rgba(255,255,255,0.25),
3797    rgba(255,255,255,0.6),
3798    rgba(255,255,255,0.25)
3799  );
3800  transition: width 0.2s ease;
3801  pointer-events: none;
3802}
3803
3804/* Loading state */
3805.send-button.loading {
3806  cursor: default;
3807  animation: none;
3808  transform: none;
3809}
3810
3811/* Responsive Design */
3812@media (max-width: 768px) {
3813  .top-nav {
3814    padding: 12px 16px;
3815  }
3816
3817  .nav-button,
3818  .book-button {
3819    padding: 8px 16px;
3820    font-size: 14px;
3821  }
3822
3823  .page-title {
3824    font-size: 16px;
3825  }
3826
3827  .notes-container {
3828    padding: 16px 12px;
3829  }
3830
3831  .note-bubble {
3832    max-width: 85%;
3833    padding: 12px 16px;
3834  }
3835
3836  .input-bar {
3837    padding: 16px;
3838  }
3839
3840  .input-controls {
3841    flex-direction: column;
3842    align-items: stretch;
3843  }
3844
3845  .input-options {
3846    flex-direction: column;
3847    align-items: flex-start;
3848    gap: 10px;
3849  }
3850
3851  .send-button {
3852    width: 100%;
3853  }
3854
3855  textarea {
3856    font-size: 16px;
3857    height: 60px;
3858  }
3859}
3860
3861@media (max-width: 480px) {
3862  .nav-left {
3863    width: 100%;
3864    flex-direction: column;
3865  }
3866
3867  .nav-button,
3868  .book-button {
3869    width: 100%;
3870    justify-content: center;
3871  }
3872}
3873     html, body {
3874        background: #736fe6;
3875        margin: 0;
3876        padding: 0;
3877      }
3878        .editor-open-btn {
3879  width: 44px; height: 44px;
3880  border-radius: 50%;
3881  border: 1px solid rgba(255, 255, 255, 0.3);
3882  background: rgba(255, 255, 255, 0.2);
3883  backdrop-filter: blur(10px);
3884  color: white; font-size: 18px;
3885  cursor: pointer; transition: all 0.3s;
3886  display: flex; align-items: center; justify-content: center;
3887  flex-shrink: 0;
3888}
3889
3890.editor-open-btn:hover {
3891  background: rgba(255, 255, 255, 0.35);
3892  transform: translateY(-2px);
3893  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
3894}
3895  @media (max-width: 768px) {
3896  .input-controls {
3897    flex-direction: row;
3898    align-items: center;
3899    flex-wrap: nowrap;
3900  }
3901
3902  .input-options {
3903    flex-direction: row;
3904    align-items: center;
3905    gap: 8px;
3906    flex: 1;
3907    min-width: 0;
3908  }
3909
3910  .input-options input[type="file"] {
3911    max-width: 140px;
3912    font-size: 0;
3913  }
3914
3915  .input-options input[type="file"]::file-selector-button {
3916    margin-right: 0;
3917    padding: 8px 12px;
3918    font-size: 12px;
3919  }
3920
3921  .send-button {
3922    width: auto;
3923    padding: 10px 20px;
3924    flex-shrink: 0;
3925  }
3926}
3927  </style>
3928</head>
3929<body>
3930  <!-- Top Navigation -->
3931  <div class="top-nav">
3932    <div class="top-nav-content">
3933      <div class="nav-left">
3934        <a href="/api/v1/root/${nodeId}?token=${token}&html" class="nav-button">
3935          ← Back to Tree
3936        </a>
3937        <a href="${base}?token=${token}&html" class="nav-button">
3938          Back to Version
3939        </a>
3940      </div>
3941
3942      <div class="page-title">
3943        Notes for <a href="${base}?token=${token}&html">${escapeHtml(nodeName)} v${version}</a>
3944      </div>
3945    </div>
3946  </div>
3947
3948  <!-- Notes Container -->
3949  <div class="notes-container">
3950    <div class="notes-wrapper">
3951      <ul class="notes-list">
3952      ${notes
3953        .map((n) => {
3954          const isSelf =
3955            currentUserId && n.userId && n.userId.toString() === currentUserId;
3956          const rawPreview =
3957            n.contentType === "text"
3958              ? n.content.length > 169
3959                ? n.content.substring(0, 500) + "..."
3960                : n.content
3961              : n.content.split("/").pop();
3962          const preview = escapeHtml(rawPreview);
3963
3964          const userLabel = n.userId
3965            ? `<a href="/api/v1/user/${n.userId}?token=${token}&html">${escapeHtml(n.username ?? n.userId)}</a>`
3966            : escapeHtml(n.username ?? "Unknown user");
3967
3968          return `
3969          <li
3970            class="note-item ${isSelf ? "self" : "other"} ${
3971              n.isReflection ? "reflection" : ""
3972            }"
3973            data-note-id="${n._id}"
3974            data-node-id="${n.nodeId}"
3975            data-version="${n.version}"
3976          >
3977            <div class="note-bubble">
3978              ${
3979                n.contentType === "file"
3980                  ? '<div class="file-badge">📎 File</div>'
3981                  : ""
3982              }
3983              ${!isSelf ? `<div class="note-author">${userLabel}</div>` : ""}
3984              <div class="note-content">
3985                <a href="${base}/notes/${n._id}?token=${token}&html">
3986                  ${preview}
3987                </a>
3988              </div>
3989              <div class="note-meta">
3990                <span>${new Date(n.createdAt).toLocaleTimeString([], {
3991                  hour: "2-digit",
3992                  minute: "2-digit",
3993                })}</span>
3994                <button class="delete-button" title="Delete note">✕</button>
3995              </div>
3996            </div>
3997          </li>
3998        `;
3999        })
4000        .join("")}
4001    </ul>
4002    </div>
4003  </div>
4004
4005  <!-- Input Bar -->
4006  <div class="input-bar">
4007    <form
4008      method="POST"
4009      action="/api/v1/node/${nodeId}/${version}/notes?token=${token}&html"
4010      enctype="multipart/form-data"
4011      class="input-form"
4012      id="noteForm"
4013    >
4014      <textarea
4015        name="content"
4016        rows="1"
4017        placeholder="Write a note..."
4018        id="noteTextarea"
4019        maxlength="5000"
4020      ></textarea>
4021      <div class="char-counter" id="charCounter">
4022        <span id="charCount">0</span> / 5000
4023        <span class="energy-display" id="energyDisplay"></span>
4024      </div>
4025
4026      <div class="input-controls">
4027        <div class="input-options">
4028          <input type="file" name="file" id="fileInput" />
4029          <div class="file-selected-badge" id="fileSelectedBadge">
4030            <span>📎</span>
4031            <span class="file-name" id="fileName"></span>
4032            <button type="button" class="clear-file" id="clearFileBtn" title="Remove file">✕</button>
4033          </div>
4034          <button type="button" class="editor-open-btn" id="openEditorBtn" title="Open in Editor">✏️</button>
4035        </div>
4036        <button type="submit" class="send-button" id="sendBtn">
4037          <span class="send-label">Send</span>
4038          <span class="send-progress"></span>
4039        </button>
4040      </div>
4041    </form>
4042  </div>
4043
4044  <script>
4045    // Auto-scroll to bottom on load
4046    const container = document.querySelector('.notes-container');
4047    container.scrollTop = container.scrollHeight;
4048
4049    // Elements
4050    const form = document.getElementById('noteForm');
4051    const textarea = document.getElementById('noteTextarea');
4052    const charCounter = document.getElementById('charCounter');
4053    const charCount = document.getElementById('charCount');
4054    const energyDisplay = document.getElementById('energyDisplay');
4055    const fileInput = document.getElementById('fileInput');
4056    const fileSelectedBadge = document.getElementById('fileSelectedBadge');
4057    const fileName = document.getElementById('fileName');
4058    const clearFileBtn = document.getElementById('clearFileBtn');
4059    const sendBtn = document.getElementById('sendBtn');
4060    const progressBar = sendBtn.querySelector('.send-progress');
4061
4062    const MAX_CHARS = 5000;
4063    let hasFile = false;
4064
4065    // Auto-resize textarea
4066    textarea.addEventListener('input', function() {
4067      this.style.height = 'auto';
4068      const newHeight = Math.min(this.scrollHeight, 120);
4069      this.style.height = newHeight + 'px';
4070      this.style.overflowY = this.scrollHeight > 120 ? 'auto' : 'hidden';
4071      updateCharCounter();
4072    });
4073
4074    // Character counter with energy (1 energy per 1000 chars)
4075    function updateCharCounter() {
4076      const len = textarea.value.length;
4077      charCount.textContent = len;
4078
4079      // Styling based on remaining
4080      const remaining = MAX_CHARS - len;
4081      charCounter.classList.remove('warning', 'danger', 'disabled');
4082
4083      if (hasFile) {
4084        charCounter.classList.add('disabled');
4085      } else if (remaining <= 100) {
4086        charCounter.classList.add('danger');
4087      } else if (remaining <= 500) {
4088        charCounter.classList.add('warning');
4089      }
4090
4091      // Energy cost: 1 per 1000 chars (minimum 1 if any text)
4092      if (len > 0 && !hasFile) {
4093        const cost = Math.max(1, Math.ceil(len / 1000));
4094        energyDisplay.textContent = '⚡' + cost;
4095        energyDisplay.classList.remove('file-energy');
4096      } else if (!hasFile) {
4097        energyDisplay.textContent = '';
4098      }
4099    }
4100
4101    // File energy calculation
4102    const FILE_MIN_COST = 5;
4103    const FILE_BASE_RATE = 1.5;
4104    const FILE_MID_RATE = 3;
4105    const SOFT_LIMIT_MB = 100;
4106    const HARD_LIMIT_MB = 1024;
4107
4108    function calculateFileEnergy(sizeMB) {
4109      if (sizeMB <= SOFT_LIMIT_MB) {
4110        return Math.max(FILE_MIN_COST, Math.ceil(sizeMB * FILE_BASE_RATE));
4111      }
4112      if (sizeMB <= HARD_LIMIT_MB) {
4113        const base = SOFT_LIMIT_MB * FILE_BASE_RATE;
4114        const extra = (sizeMB - SOFT_LIMIT_MB) * FILE_MID_RATE;
4115        return Math.ceil(base + extra);
4116      }
4117      const base = SOFT_LIMIT_MB * FILE_BASE_RATE +
4118                   (HARD_LIMIT_MB - SOFT_LIMIT_MB) * FILE_MID_RATE;
4119      const overGB = sizeMB - HARD_LIMIT_MB;
4120      return Math.ceil(base + Math.pow(overGB / 50, 2) * 50);
4121    }
4122
4123    // File selection - blocks text input
4124    fileInput.addEventListener('change', function() {
4125      if (this.files && this.files[0]) {
4126        const file = this.files[0];
4127        hasFile = true;
4128
4129        // Disable textarea
4130        textarea.disabled = true;
4131        textarea.value = '';
4132        textarea.placeholder = 'File selected - text disabled';
4133
4134        // Show file badge, hide file input
4135        fileInput.classList.add('hidden-input');
4136        fileSelectedBadge.classList.add('visible');
4137
4138        // Truncate filename for display
4139        let displayName = file.name;
4140        if (displayName.length > 20) {
4141          displayName = displayName.substring(0, 17) + '...';
4142        }
4143        fileName.textContent = displayName;
4144        fileSelectedBadge.title = file.name;
4145
4146        // Calculate and show energy (+1 for the note itself)
4147        const sizeMB = file.size / (1024 * 1024);
4148        const fileCost = calculateFileEnergy(sizeMB);
4149        const totalCost = fileCost + 1;
4150        energyDisplay.textContent = '~⚡' + totalCost;
4151        energyDisplay.classList.add('file-energy');
4152
4153        // Update char counter state
4154        updateCharCounter();
4155      }
4156    });
4157
4158    // Clear file selection
4159    clearFileBtn.addEventListener('click', function() {
4160      hasFile = false;
4161      fileInput.value = '';
4162      fileInput.classList.remove('hidden-input');
4163      fileSelectedBadge.classList.remove('visible');
4164
4165      // Re-enable textarea
4166      textarea.disabled = false;
4167      textarea.placeholder = 'Write a note...';
4168
4169      // Clear energy display
4170      energyDisplay.textContent = '';
4171      energyDisplay.classList.remove('file-energy');
4172
4173      updateCharCounter();
4174    });
4175
4176    // Delete note functionality
4177    document.addEventListener('click', async (e) => {
4178      if (!e.target.classList.contains('delete-button')) return;
4179
4180      const noteItem = e.target.closest('.note-item');
4181      const noteId = noteItem.dataset.noteId;
4182      const nodeId = noteItem.dataset.nodeId;
4183      const version = noteItem.dataset.version;
4184
4185      if (!confirm('Delete this note? This cannot be undone.')) return;
4186
4187      const token = new URLSearchParams(window.location.search).get('token') || '';
4188      const qs = token ? '?token=' + encodeURIComponent(token) : '';
4189
4190      try {
4191        const res = await fetch(
4192          '/api/v1/node/' + nodeId + '/' + version + '/notes/' + noteId + qs,
4193          { method: 'DELETE' }
4194        );
4195
4196        const data = await res.json();
4197        if (!data.success) throw new Error(data.error || 'Delete failed');
4198
4199        noteItem.style.opacity = '0';
4200        noteItem.style.transform = 'translateY(-10px)';
4201        setTimeout(() => noteItem.remove(), 300);
4202      } catch (err) {
4203        alert('Failed to delete: ' + (err.message || 'Unknown error'));
4204      }
4205    });
4206
4207    // Form submission with progress
4208    form.addEventListener('submit', (e) => {
4209      e.preventDefault();
4210
4211      sendBtn.classList.add('loading');
4212      sendBtn.disabled = true;
4213
4214      const formData = new FormData(form);
4215      const xhr = new XMLHttpRequest();
4216
4217      xhr.open('POST', form.action, true);
4218
4219      xhr.upload.onprogress = (e) => {
4220        if (!e.lengthComputable) return;
4221        const percent = Math.round((e.loaded / e.total) * 100);
4222        progressBar.style.width = percent + '%';
4223      };
4224
4225      xhr.onload = () => {
4226        document.location.reload();
4227      };
4228
4229      xhr.onerror = () => {
4230        alert('Send failed');
4231        sendBtn.classList.remove('loading');
4232        sendBtn.disabled = false;
4233        progressBar.style.width = '0%';
4234      };
4235
4236      xhr.send(formData);
4237    });
4238
4239    // Enter to submit
4240    textarea.addEventListener('keydown', (e) => {
4241      if (e.key === 'Enter' && !e.shiftKey) {
4242        e.preventDefault();
4243        form.requestSubmit();
4244      }
4245    });
4246
4247    // Editor button
4248    document.getElementById("openEditorBtn").addEventListener("click", function() {
4249      var token = new URLSearchParams(window.location.search).get("token") || "";
4250      var qs = token ? "?token=" + encodeURIComponent(token) + "&html" : "?html";
4251      var content = textarea.value.trim();
4252      var editorUrl = "/api/v1/node/${nodeId}/${version}/notes/editor" + qs;
4253
4254      if (content) {
4255        sessionStorage.setItem("tree-editor-draft", content);
4256      }
4257
4258      window.location.href = editorUrl;
4259    });
4260
4261    // Form reset handler
4262    form.addEventListener('reset', () => {
4263      hasFile = false;
4264      fileInput.classList.remove('hidden-input');
4265      fileSelectedBadge.classList.remove('visible');
4266      textarea.disabled = false;
4267      textarea.placeholder = 'Write a note...';
4268      energyDisplay.textContent = '';
4269      energyDisplay.classList.remove('file-energy');
4270      charCount.textContent = '0';
4271      charCounter.classList.remove('warning', 'danger', 'disabled');
4272    });
4273  </script>
4274</body>
4275</html>
4276`;
4277}
4278
4279export function renderTextNote({
4280  back,
4281  backText,
4282  userLink,
4283  editorButton,
4284  note,
4285}) {
4286  return `
4287<!DOCTYPE html>
4288<html lang="en">
4289<head>
4290  <meta charset="UTF-8">
4291  <meta name="viewport" content="width=device-width, initial-scale=1.0">
4292  <meta name="theme-color" content="#667eea">
4293  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
4294<title>Note by ${escapeHtml(note.userId?.username || "User")} - TreeOS</title>
4295  <meta name="description" content="${escapeHtml((note.content || "").slice(0, 160))}" />
4296  <meta property="og:title" content="Note by ${escapeHtml(note.userId?.username || "User")} - TreeOS" />
4297  <meta property="og:description" content="${escapeHtml((note.content || "").slice(0, 160))}" />
4298  <meta property="og:type" content="article" />
4299  <meta property="og:site_name" content="TreeOS" />
4300  <meta property="og:image" content="${getLandUrl()}/tree.png" />
4301  <style>
4302    ${baseStyles}
4303    ${backNavStyles}
4304
4305    /* Note Card */
4306    .note-card {
4307      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
4308      backdrop-filter: blur(22px) saturate(140%);
4309      -webkit-backdrop-filter: blur(22px) saturate(140%);
4310      border-radius: 16px;
4311      padding: 32px;
4312      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
4313        inset 0 1px 0 rgba(255, 255, 255, 0.25);
4314      border: 1px solid rgba(255, 255, 255, 0.28);
4315      position: relative;
4316      overflow: hidden;
4317      animation: fadeInUp 0.6s ease-out 0.1s both;
4318    }
4319
4320    /* User Info */
4321    .user-info {
4322      display: flex;
4323      align-items: center;
4324      gap: 8px;
4325      margin-bottom: 20px;
4326      padding-bottom: 16px;
4327      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
4328    }
4329
4330    .user-info::before {
4331      content: '👤';
4332      font-size: 18px;
4333    }
4334
4335    .user-info a {
4336      color: white;
4337      text-decoration: none;
4338      font-weight: 600;
4339      font-size: 15px;
4340      transition: all 0.2s;
4341      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
4342    }
4343
4344    .user-info a:hover {
4345      text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
4346      transform: translateX(2px);
4347    }
4348
4349    .note-time {
4350      margin-left: auto;
4351      font-size: 13px;
4352      color: rgba(255, 255, 255, 0.6);
4353      font-weight: 400;
4354    }
4355
4356    /* Copy Button Bar */
4357    .copy-bar {
4358      display: flex;
4359      justify-content: flex-end;
4360      gap: 8px;
4361      margin-bottom: 16px;
4362    }
4363
4364    .copy-btn {
4365      background: rgba(255, 255, 255, 0.2);
4366      backdrop-filter: blur(10px);
4367      border: 1px solid rgba(255, 255, 255, 0.3);
4368      cursor: pointer;
4369      font-size: 20px;
4370      padding: 8px 12px;
4371      border-radius: 980px;
4372      transition: all 0.3s;
4373      position: relative;
4374      overflow: hidden;
4375    }
4376
4377    .copy-btn::before {
4378      content: "";
4379      position: absolute;
4380      inset: -40%;
4381      background: radial-gradient(
4382        120% 60% at 0% 0%,
4383        rgba(255, 255, 255, 0.35),
4384        transparent 60%
4385      );
4386      opacity: 0;
4387      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4388      pointer-events: none;
4389    }
4390
4391    .copy-btn:hover {
4392      background: rgba(255, 255, 255, 0.3);
4393      transform: translateY(-2px);
4394      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
4395    }
4396
4397    .copy-btn:hover::before {
4398      opacity: 1;
4399      transform: translateX(30%) translateY(10%);
4400    }
4401
4402    .copy-btn:active {
4403      transform: translateY(0);
4404    }
4405
4406    #copyUrlBtn {
4407      background: rgba(255, 255, 255, 0.25);
4408    }
4409
4410    /* Note Content */
4411    pre {
4412      background: rgba(255, 255, 255, 0.3);
4413      backdrop-filter: blur(20px) saturate(150%);
4414      -webkit-backdrop-filter: blur(20px) saturate(150%);
4415      padding: 20px;
4416      border-radius: 12px;
4417      font-size: 16px;
4418      line-height: 1.7;
4419      white-space: pre-wrap;
4420      word-wrap: break-word;
4421      border: 1px solid rgba(255, 255, 255, 0.3);
4422      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
4423      color: #3d2f8f;
4424      font-weight: 600;
4425      text-shadow:
4426        0 0 10px rgba(102, 126, 234, 0.4),
4427        0 1px 3px rgba(255, 255, 255, 1);
4428      box-shadow:
4429        0 4px 20px rgba(0, 0, 0, 0.1),
4430        inset 0 1px 0 rgba(255, 255, 255, 0.4);
4431      position: relative;
4432      overflow: hidden;
4433      transition: all 0.3s ease;
4434    }
4435
4436    pre::before {
4437      content: "";
4438      position: absolute;
4439      inset: 0;
4440      background: linear-gradient(
4441        110deg,
4442        transparent 40%,
4443        rgba(255, 255, 255, 0.4),
4444        transparent 60%
4445      );
4446      opacity: 0;
4447      transform: translateX(-100%);
4448      pointer-events: none;
4449    }
4450
4451    pre:hover {
4452      border-color: rgba(255, 255, 255, 0.5);
4453      box-shadow:
4454        0 8px 32px rgba(102, 126, 234, 0.2),
4455        inset 0 1px 0 rgba(255, 255, 255, 0.6);
4456    }
4457/* Programmatic shimmer trigger */
4458pre.flash::before {
4459  opacity: 1;
4460  animation: glassShimmer 1.2s ease forwards;
4461}
4462
4463    pre:hover::before {
4464      opacity: 1;
4465      animation: glassShimmer 1.2s ease forwards;
4466    }
4467
4468    pre.copied {
4469      animation: textGlow 0.8s ease-out;
4470    }
4471
4472    @keyframes textGlow {
4473      0% {
4474        box-shadow:
4475          0 4px 20px rgba(0, 0, 0, 0.1),
4476          inset 0 1px 0 rgba(255, 255, 255, 0.4);
4477      }
4478      50% {
4479        box-shadow:
4480          0 0 40px rgba(102, 126, 234, 0.6),
4481          0 0 60px rgba(102, 126, 234, 0.4),
4482          inset 0 1px 0 rgba(255, 255, 255, 0.8);
4483        text-shadow:
4484          0 0 20px rgba(102, 126, 234, 0.8),
4485          0 0 30px rgba(102, 126, 234, 0.6),
4486          0 1px 3px rgba(255, 255, 255, 1);
4487      }
4488      100% {
4489        box-shadow:
4490          0 4px 20px rgba(0, 0, 0, 0.1),
4491          inset 0 1px 0 rgba(255, 255, 255, 0.4);
4492      }
4493    }
4494
4495    @keyframes glassShimmer {
4496      0% {
4497        opacity: 0;
4498        transform: translateX(-120%) skewX(-15deg);
4499      }
4500      50% {
4501        opacity: 1;
4502      }
4503      100% {
4504        opacity: 0;
4505        transform: translateX(120%) skewX(-15deg);
4506      }
4507    }
4508
4509    /* Responsive */
4510    @media (max-width: 640px) {
4511      body {
4512        padding: 16px;
4513      }
4514
4515      .note-card {
4516        padding: 24px 20px;
4517      }
4518
4519      pre {
4520        font-size: 17px;
4521        padding: 16px;
4522      }
4523
4524      .back-nav {
4525        flex-direction: column;
4526      }
4527
4528      .back-link {
4529        justify-content: center;
4530      }
4531    }
4532
4533    @media (min-width: 641px) and (max-width: 1024px) {
4534      .container {
4535        max-width: 700px;
4536      }
4537    }
4538      .editor-btn {
4539  text-decoration: none;
4540  display: inline-flex;
4541  align-items: center;
4542  justify-content: center;
4543}
4544
4545.editor-btn:hover {
4546  background: rgba(255, 255, 255, 0.35);
4547}
4548
4549  </style>
4550</head>
4551<body>
4552  <div class="container">
4553    <!-- Back Navigation -->
4554    <div class="back-nav">
4555      <a href="${back}" class="back-link">${backText}</a>
4556      <button id="copyUrlBtn" class="copy-btn" title="Copy URL to share">🔗</button>
4557    </div>
4558
4559    <!-- Note Card -->
4560    <div class="note-card">
4561      <div class="user-info">
4562        ${userLink}
4563        ${note.createdAt ? `<span class="note-time">${new Date(note.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(note.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
4564      </div>
4565<div class="copy-bar">
4566  ${editorButton}
4567  <button id="copyNoteBtn" class="copy-btn" title="Copy note">📋</button>
4568</div>
4569
4570
4571<pre id="noteContent">${escapeHtml(note.content)}</pre>
4572    </div>
4573  </div>
4574
4575  <script>
4576    const copyNoteBtn = document.getElementById("copyNoteBtn");
4577    const copyUrlBtn = document.getElementById("copyUrlBtn");
4578    const noteContent = document.getElementById("noteContent");
4579
4580    copyNoteBtn.addEventListener("click", () => {
4581  navigator.clipboard.writeText(noteContent.textContent).then(() => {
4582    copyNoteBtn.textContent = "✔️";
4583    setTimeout(() => (copyNoteBtn.textContent = "📋"), 900);
4584
4585    // text glow (already existing)
4586    noteContent.classList.add("copied");
4587    setTimeout(() => noteContent.classList.remove("copied"), 800);
4588
4589    // delayed glass shimmer (0.5s)
4590    setTimeout(() => {
4591      noteContent.classList.remove("flash"); // reset if still present
4592      void noteContent.offsetWidth;          // force reflow so animation restarts
4593      noteContent.classList.add("flash");
4594
4595      setTimeout(() => {
4596        noteContent.classList.remove("flash");
4597      }, 1300); // slightly longer than animation
4598    }, 600);
4599  });
4600});
4601
4602
4603    copyUrlBtn.addEventListener("click", () => {
4604      const url = new URL(window.location.href);
4605      url.searchParams.delete('token');
4606      if (!url.searchParams.has('html')) {
4607        url.searchParams.set('html', '');
4608      }
4609      navigator.clipboard.writeText(url.toString()).then(() => {
4610        copyUrlBtn.textContent = "✔️";
4611        setTimeout(() => (copyUrlBtn.textContent = "🔗"), 900);
4612      });
4613    });
4614  </script>
4615</body>
4616</html>
4617`;
4618}
4619
4620export function renderFileNote({
4621  back,
4622  backText,
4623  userLink,
4624  note,
4625  fileName,
4626  fileUrl,
4627  mediaHtml,
4628  fileDeleted,
4629}) {
4630  return `
4631<!DOCTYPE html>
4632<html lang="en">
4633<head>
4634  <meta charset="UTF-8">
4635  <meta name="viewport" content="width=device-width, initial-scale=1.0">
4636  <meta name="theme-color" content="#667eea">
4637  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
4638<title>${escapeHtml(fileName)}</title>
4639  <style>
4640    ${baseStyles}
4641    ${backNavStyles}
4642
4643    /* File Card */
4644    .file-card {
4645      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
4646      backdrop-filter: blur(22px) saturate(140%);
4647      -webkit-backdrop-filter: blur(22px) saturate(140%);
4648      border-radius: 16px;
4649      padding: 32px;
4650      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
4651        inset 0 1px 0 rgba(255, 255, 255, 0.25);
4652      border: 1px solid rgba(255, 255, 255, 0.28);
4653      position: relative;
4654      overflow: hidden;
4655      animation: fadeInUp 0.6s ease-out 0.1s both;
4656    }
4657
4658    /* User Info */
4659    .user-info {
4660      display: flex;
4661      align-items: center;
4662      gap: 8px;
4663      margin-bottom: 20px;
4664      padding-bottom: 16px;
4665      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
4666    }
4667
4668    .user-info::before {
4669      content: '👤';
4670      font-size: 18px;
4671    }
4672
4673    .user-info a {
4674      color: white;
4675      text-decoration: none;
4676      font-weight: 600;
4677      font-size: 15px;
4678      transition: all 0.2s;
4679      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
4680    }
4681
4682    .user-info a:hover {
4683      text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
4684      transform: translateX(2px);
4685    }
4686
4687    .note-time {
4688      margin-left: auto;
4689      font-size: 13px;
4690      color: rgba(255, 255, 255, 0.6);
4691      font-weight: 400;
4692    }
4693
4694    /* File Header */
4695    h1 {
4696      font-size: 24px;
4697      font-weight: 700;
4698      color: white;
4699      margin-bottom: 20px;
4700      word-break: break-word;
4701      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
4702    }
4703
4704    /* Action Buttons */
4705    .action-bar {
4706      display: flex;
4707      gap: 12px;
4708      margin-bottom: 24px;
4709      flex-wrap: wrap;
4710    }
4711
4712    .download {
4713      display: inline-flex;
4714      align-items: center;
4715      gap: 8px;
4716      padding: 12px 20px;
4717      background: rgba(255, 255, 255, 0.25);
4718      backdrop-filter: blur(10px);
4719      color: white;
4720      text-decoration: none;
4721      border-radius: 980px;
4722      font-weight: 600;
4723      font-size: 15px;
4724      transition: all 0.3s;
4725      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
4726      border: 1px solid rgba(255, 255, 255, 0.3);
4727      cursor: pointer;
4728      position: relative;
4729      overflow: hidden;
4730    }
4731
4732    .download::after {
4733      content: '⬇️';
4734      font-size: 16px;
4735      margin-left: 4px;
4736    }
4737
4738    .download::before {
4739      content: "";
4740      position: absolute;
4741      inset: -40%;
4742      background: radial-gradient(
4743        120% 60% at 0% 0%,
4744        rgba(255, 255, 255, 0.35),
4745        transparent 60%
4746      );
4747      opacity: 0;
4748      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4749      pointer-events: none;
4750    }
4751
4752    .download:hover {
4753      background: rgba(255, 255, 255, 0.35);
4754      transform: translateY(-2px);
4755      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
4756    }
4757
4758    .download:hover::before {
4759      opacity: 1;
4760      transform: translateX(30%) translateY(10%);
4761    }
4762
4763    .copy-url-btn {
4764      display: inline-flex;
4765      align-items: center;
4766      gap: 8px;
4767      padding: 12px 20px;
4768      background: rgba(255, 255, 255, 0.2);
4769      backdrop-filter: blur(10px);
4770      color: white;
4771      border: 1px solid rgba(255, 255, 255, 0.3);
4772      border-radius: 980px;
4773      font-weight: 600;
4774      font-size: 15px;
4775      transition: all 0.3s;
4776      cursor: pointer;
4777      position: relative;
4778      overflow: hidden;
4779    }
4780
4781    .copy-url-btn::after {
4782      content: '🔗';
4783      font-size: 16px;
4784      margin-left: 4px;
4785    }
4786
4787    .copy-url-btn::before {
4788      content: "";
4789      position: absolute;
4790      inset: -40%;
4791      background: radial-gradient(
4792        120% 60% at 0% 0%,
4793        rgba(255, 255, 255, 0.35),
4794        transparent 60%
4795      );
4796      opacity: 0;
4797      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4798      pointer-events: none;
4799    }
4800
4801    .copy-url-btn:hover {
4802      background: rgba(255, 255, 255, 0.3);
4803      transform: translateY(-2px);
4804      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
4805    }
4806
4807    .copy-url-btn:hover::before {
4808      opacity: 1;
4809      transform: translateX(30%) translateY(10%);
4810    }
4811
4812    /* Media Container */
4813    .media {
4814      margin-top: 24px;
4815      padding-top: 24px;
4816      border-top: 1px solid rgba(255, 255, 255, 0.2);
4817    }
4818
4819    .media img,
4820    .media video,
4821    .media audio {
4822      max-width: 100%;
4823      border-radius: 12px;
4824      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
4825      border: 1px solid rgba(255, 255, 255, 0.2);
4826    }
4827
4828    /* Responsive */
4829    @media (max-width: 640px) {
4830      body {
4831        padding: 16px;
4832      }
4833
4834      .file-card {
4835        padding: 24px 20px;
4836      }
4837
4838      h1 {
4839        font-size: 22px;
4840      }
4841
4842      .action-bar {
4843        flex-direction: column;
4844      }
4845
4846      .download,
4847      .copy-url-btn {
4848        padding: 12px 18px;
4849        font-size: 16px;
4850        width: 100%;
4851        justify-content: center;
4852      }
4853
4854      .back-nav {
4855        flex-direction: column;
4856      }
4857
4858      .back-link {
4859        justify-content: center;
4860      }
4861    }
4862
4863    @media (min-width: 641px) and (max-width: 1024px) {
4864      .container {
4865        max-width: 700px;
4866      }
4867    }
4868      @media (max-width: 768px) {
4869  .send-progress {
4870    animation: shimmer 1.2s infinite linear;
4871  }
4872}
4873
4874@keyframes shimmer {
4875  0% { background-position: -200px 0; }
4876  100% { background-position: 200px 0; }
4877}
4878
4879  </style>
4880</head>
4881<body>
4882  <div class="container">
4883    <!-- Back Navigation -->
4884    <div class="back-nav">
4885      <a href="${back}" class="back-link">${backText}</a>
4886    </div>
4887
4888    <!-- File Card -->
4889    <div class="file-card">
4890      <div class="user-info">
4891        ${userLink}
4892        ${note.createdAt ? `<span class="note-time">${new Date(note.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(note.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
4893      </div>
4894
4895<h1>${escapeHtml(fileName)}</h1>
4896
4897      ${fileDeleted ? "" : `<div class="action-bar">
4898        <a class="download" href="${fileUrl}" download>Download</a>
4899        <button id="copyUrlBtn" class="copy-url-btn">Share</button>
4900      </div>`}
4901
4902      <div class="media">
4903        ${fileDeleted ? `<p style="color:rgba(255,255,255,0.6); padding:40px 0;">File was deleted</p>` : mediaHtml}
4904      </div>
4905    </div>
4906  </div>
4907
4908  <script>
4909    const copyUrlBtn = document.getElementById("copyUrlBtn");
4910
4911    copyUrlBtn.addEventListener("click", () => {
4912      const url = new URL(window.location.href);
4913      url.searchParams.delete('token');
4914      if (!url.searchParams.has('html')) {
4915        url.searchParams.set('html', '');
4916      }
4917      navigator.clipboard.writeText(url.toString()).then(() => {
4918        const originalText = copyUrlBtn.textContent;
4919        copyUrlBtn.textContent = "✔️ Copied!";
4920        setTimeout(() => (copyUrlBtn.textContent = originalText), 900);
4921      });
4922    });
4923  </script>
4924</body>
4925</html>
4926`;
4927}
4928
4929export { parseBool, normalizeStatusFilters, renderBookNode };
4930
1/* ------------------------------------------------- */
2/* Public query page renderer                        */
3/* Lightweight chat UI for querying public trees      */
4/* ------------------------------------------------- */
5/* NOTE: baseStyles not imported here. The query page */
6/* uses a dark theme (gradient #0f0c29 -> #16213e)   */
7/* that diverges entirely from the purple base. Only  */
8/* the * reset overlaps, which is not worth the       */
9/* import + override cost.                            */
10
11import { escapeHtml } from "./utils.js";
12
13export function renderQueryPage({ treeName, ownerUsername, rootId, queryAvailable, isAuthenticated }) {
14  return `<!DOCTYPE html>
15<html lang="en">
16<head>
17  <meta charset="UTF-8">
18  <meta name="viewport" content="width=device-width, initial-scale=1.0">
19  <title>${escapeHtml(treeName)} - Query</title>
20  <style>
21    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
22
23    body {
24      font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25      background: linear-gradient(135deg, #0f0c29 0%, #1a1a2e 40%, #16213e 100%);
26      color: #e0e0e0;
27      min-height: 100vh;
28      display: flex;
29      flex-direction: column;
30    }
31
32    .header {
33      padding: 24px 32px;
34      border-bottom: 1px solid rgba(255,255,255,0.08);
35      display: flex;
36      align-items: center;
37      gap: 16px;
38    }
39
40    .header h1 {
41      font-size: 1.3rem;
42      font-weight: 700;
43      color: #fff;
44    }
45
46    .header .meta {
47      font-size: 0.85rem;
48      color: rgba(255,255,255,0.5);
49    }
50
51    .header .badge {
52      font-size: 0.7rem;
53      padding: 3px 10px;
54      border-radius: 12px;
55      background: rgba(72,187,120,0.15);
56      color: rgba(72,187,120,0.9);
57      border: 1px solid rgba(72,187,120,0.25);
58      font-weight: 600;
59      text-transform: uppercase;
60      letter-spacing: 0.5px;
61    }
62
63    .chat-area {
64      flex: 1;
65      overflow-y: auto;
66      padding: 24px 32px;
67      display: flex;
68      flex-direction: column;
69      gap: 16px;
70    }
71
72    .message {
73      max-width: 720px;
74      width: 100%;
75      margin: 0 auto;
76      padding: 16px 20px;
77      border-radius: 16px;
78      line-height: 1.6;
79      font-size: 0.95rem;
80    }
81
82    .message.user {
83      background: rgba(88,86,214,0.15);
84      border: 1px solid rgba(88,86,214,0.25);
85      align-self: flex-end;
86    }
87
88    .message.assistant {
89      background: rgba(255,255,255,0.05);
90      border: 1px solid rgba(255,255,255,0.1);
91    }
92
93    .message.error {
94      background: rgba(255,59,48,0.1);
95      border: 1px solid rgba(255,59,48,0.25);
96      color: rgba(255,107,107,0.9);
97    }
98
99    .message p { margin: 0 0 8px; }
100    .message p:last-child { margin-bottom: 0; }
101    .message code {
102      background: rgba(255,255,255,0.1);
103      padding: 2px 6px;
104      border-radius: 4px;
105      font-size: 0.9em;
106    }
107    .message pre {
108      background: rgba(0,0,0,0.3);
109      padding: 12px 16px;
110      border-radius: 8px;
111      overflow-x: auto;
112      margin: 8px 0;
113    }
114    .message pre code {
115      background: none;
116      padding: 0;
117    }
118
119    .empty-state {
120      text-align: center;
121      color: rgba(255,255,255,0.4);
122      padding: 48px 24px;
123      font-size: 0.95rem;
124      max-width: 480px;
125      margin: auto;
126    }
127
128    .empty-state .icon {
129      font-size: 2rem;
130      margin-bottom: 12px;
131      opacity: 0.6;
132    }
133
134    .input-area {
135      padding: 16px 32px 24px;
136      border-top: 1px solid rgba(255,255,255,0.08);
137      max-width: 784px;
138      width: 100%;
139      margin: 0 auto;
140    }
141
142    .input-row {
143      display: flex;
144      gap: 12px;
145      align-items: flex-end;
146    }
147
148    .input-row textarea {
149      flex: 1;
150      resize: none;
151      padding: 12px 16px;
152      border-radius: 16px;
153      border: 1px solid rgba(255,255,255,0.15);
154      background: rgba(255,255,255,0.06);
155      color: #fff;
156      font-size: 0.95rem;
157      font-family: inherit;
158      line-height: 1.5;
159      min-height: 48px;
160      max-height: 200px;
161      outline: none;
162      transition: border-color 0.2s;
163    }
164
165    .input-row textarea:focus {
166      border-color: rgba(88,86,214,0.5);
167    }
168
169    .input-row textarea::placeholder {
170      color: rgba(255,255,255,0.3);
171    }
172
173    .input-row button {
174      padding: 12px 20px;
175      border-radius: 16px;
176      border: 1px solid rgba(72,187,120,0.4);
177      background: rgba(72,187,120,0.15);
178      color: rgba(72,187,120,0.9);
179      font-weight: 600;
180      font-size: 0.9rem;
181      cursor: pointer;
182      transition: background 0.2s;
183      white-space: nowrap;
184    }
185
186    .input-row button:hover {
187      background: rgba(72,187,120,0.25);
188    }
189
190    .input-row button:disabled {
191      opacity: 0.4;
192      cursor: not-allowed;
193    }
194
195    .footer {
196      text-align: center;
197      padding: 12px;
198      font-size: 0.75rem;
199      color: rgba(255,255,255,0.3);
200    }
201
202    .footer a {
203      color: rgba(88,86,214,0.7);
204      text-decoration: none;
205    }
206
207    .spinner {
208      display: inline-block;
209      width: 16px;
210      height: 16px;
211      border: 2px solid rgba(255,255,255,0.2);
212      border-top-color: rgba(72,187,120,0.8);
213      border-radius: 50%;
214      animation: spin 0.8s linear infinite;
215      margin-right: 8px;
216      vertical-align: middle;
217    }
218
219    @keyframes spin { to { transform: rotate(360deg); } }
220
221    .unavailable {
222      text-align: center;
223      padding: 48px 24px;
224      color: rgba(255,255,255,0.5);
225    }
226
227    .unavailable h2 {
228      font-size: 1.1rem;
229      margin-bottom: 8px;
230      color: rgba(255,255,255,0.7);
231    }
232  </style>
233</head>
234<body>
235
236  <div class="header">
237    <div>
238      <h1>${escapeHtml(treeName)}</h1>
239      <div class="meta">by ${escapeHtml(ownerUsername)}</div>
240      <a href="https://dir.treeos.ai" target="_blank" rel="noopener"
241        style="font-size:0.75rem;color:rgba(255,255,255,0.35);text-decoration:none;margin-top:4px;display:inline-block;transition:color 0.2s;"
242        onmouseover="this.style.color='rgba(255,255,255,0.7)'"
243        onmouseout="this.style.color='rgba(255,255,255,0.35)'"
244      >Canopy Directory</a>
245    </div>
246    <span class="badge">Public</span>
247  </div>
248
249  ${queryAvailable ? `
250  <div class="chat-area" id="chatArea">
251    <div class="empty-state" id="emptyState">
252      Ask the tree anything to find knowledge. Responses are read only and will not modify the tree.
253    </div>
254  </div>
255
256  <div class="input-area">
257    <div class="input-row">
258      <textarea
259        id="queryInput"
260        placeholder="Ask a question about this tree..."
261        rows="1"
262        onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendQuery();}"
263        oninput="autoResize(this)"
264      ></textarea>
265      <button id="sendBtn" onclick="sendQuery()">Ask</button>
266    </div>
267  </div>
268  ` : `
269  <div class="unavailable">
270    <h2>Query not available</h2>
271    <p>This tree does not have AI configured for public queries.${isAuthenticated ? "" : " If you have an account on another land, you can query through the CLI or API using your own AI connection."}</p>
272  </div>
273  `}
274
275  <div class="footer">
276    Powered by <a href="https://treeos.ai" target="_blank">TreeOS</a>
277  </div>
278
279  <script>
280  var ROOT_ID = "${rootId}";
281  var sending = false;
282
283  function autoResize(el) {
284    el.style.height = "auto";
285    el.style.height = Math.min(el.scrollHeight, 200) + "px";
286  }
287
288  function addMessage(role, html) {
289    var empty = document.getElementById("emptyState");
290    if (empty) empty.remove();
291
292    var area = document.getElementById("chatArea");
293    var div = document.createElement("div");
294    div.className = "message " + role;
295    div.innerHTML = html;
296    area.appendChild(div);
297    area.scrollTop = area.scrollHeight;
298    return div;
299  }
300
301  function markdownToHtml(text) {
302    if (!text) return "";
303    var BT = String.fromCharCode(96);
304    var html = text
305      .replace(/&/g, "&amp;")
306      .replace(/</g, "&lt;")
307      .replace(/>/g, "&gt;");
308
309    // Code blocks (triple backtick fenced)
310    var codeBlockRe = new RegExp(BT + BT + BT + "(\\\\w*)?\\\\n([\\\\s\\\\S]*?)" + BT + BT + BT, "g");
311    html = html.replace(codeBlockRe, function(m, lang, code) {
312      return "<pre><code>" + code.trim() + "</code></pre>";
313    });
314
315    // Inline code
316    var inlineCodeRe = new RegExp(BT + "([^" + BT + "]+)" + BT, "g");
317    html = html.replace(inlineCodeRe, "<code>$1</code>");
318
319    // Bold
320    html = html.replace(/[*][*](.+?)[*][*]/g, "<strong>$1</strong>");
321
322    // Italic
323    html = html.replace(/[*](.+?)[*]/g, "<em>$1</em>");
324
325    // Paragraphs
326    html = html.split(new RegExp("\\\\n\\\\n+")).map(function(p) {
327      p = p.trim();
328      if (!p) return "";
329      if (p.startsWith("<pre>")) return p;
330      return "<p>" + p.replace(new RegExp("\\\\n", "g"), "<br>") + "</p>";
331    }).join("");
332
333    return html;
334  }
335
336  async function sendQuery() {
337    if (sending) return;
338    var input = document.getElementById("queryInput");
339    var btn = document.getElementById("sendBtn");
340    var msg = input.value.trim();
341    if (!msg) return;
342
343    sending = true;
344    btn.disabled = true;
345    btn.textContent = "...";
346    input.value = "";
347    input.style.height = "auto";
348
349    addMessage("user", "<p>" + msg.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/\\n/g,"<br>") + "</p>");
350
351    var loadingDiv = addMessage("assistant", '<span class="spinner"></span> Thinking...');
352
353    try {
354      var res = await fetch("/api/v1/root/" + ROOT_ID + "/query", {
355        method: "POST",
356        credentials: "include",
357        headers: { "Content-Type": "application/json" },
358        body: JSON.stringify({ message: msg }),
359      });
360
361      var text = await res.text();
362      var data;
363      try { data = JSON.parse(text); } catch (_) { data = {}; }
364
365      if (res.status === 429) {
366        loadingDiv.className = "message error";
367        loadingDiv.innerHTML = "<p>Rate limit reached. Please wait a few minutes before trying again.</p>";
368      } else if (!res.ok || !data.success) {
369        var errMsg = data.answer || data.error || data.message || "Error (HTTP " + res.status + ")";
370        loadingDiv.className = "message error";
371        loadingDiv.innerHTML = "<p>" + errMsg + "</p>";
372      } else {
373        loadingDiv.innerHTML = markdownToHtml(data.answer);
374      }
375    } catch (err) {
376      loadingDiv.className = "message error";
377      loadingDiv.innerHTML = "<p>Network error: " + err.message + "</p>";
378    }
379
380    sending = false;
381    btn.disabled = false;
382    btn.textContent = "Ask";
383    input.focus();
384  }
385  </script>
386
387</body>
388</html>`;
389}
390
1/* ------------------------------------------------- */
2/* HTML renderers for root.js pages                   */
3/* ------------------------------------------------- */
4
5import { baseStyles, backNavStyles, responsiveBase } from './baseStyles.js';
6import { escapeHtml, rainbow } from './utils.js';
7
8// ─────────────────────────────────────────────────────────────────────────
9// 1. renderRootOverview
10// ─────────────────────────────────────────────────────────────────────────
11
12export function renderRootOverview({
13  allData,
14  rootMeta,
15  ancestors,
16  isOwner,
17  isDeleted,
18  isRoot,
19  isPublicAccess,
20  queryAvailable,
21  currentUserId,
22  queryString,
23  nodeId,
24  userId,
25  token,
26  deferredItems,
27  ownerConnections,
28}) {
29  const deferredHtml = deferredItems && deferredItems.length > 0
30    ? `<ul class="deferred-list">${deferredItems.map((d) => `<li class="deferred-item"><div class="deferred-content">${escapeHtml(d.content || d.text || JSON.stringify(d.data || ""))}</div><div class="deferred-meta" style="font-size:11px;opacity:0.6;margin-top:4px;">${d.status || "pending"}${d.createdAt ? " . " + new Date(d.createdAt).toLocaleDateString() : ""}</div></li>`).join("")}</ul>`
31    : '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.5);font-size:14px;">No short-term items</div>';
32
33  let rootNameColor = "rgba(255, 255, 255, 0.4)";
34  if (isDeleted) {
35    rootNameColor = "#b00020";
36  }
37
38  const _txMeta = rootMeta?.metadata?.transactions || (rootMeta?.metadata instanceof Map ? rootMeta?.metadata?.get("transactions") : null) || {};
39  const transactionPolicy = _txMeta.policy || "OWNER_ONLY";
40
41  const renderParents = (chain) => {
42    if (!chain || chain.length === 0) return "";
43    if (chain.length === 1 && chain[0].isCurrent) return "";
44
45    let html = '<div class="breadcrumb-constellation">';
46    chain.forEach((node, idx) => {
47      const isLast = idx === chain.length - 1;
48      const color = rainbow[idx % rainbow.length];
49
50      html += `
51        <div class="breadcrumb-node ${isLast ? "current-node" : ""}" data-depth="${idx}">
52          <div class="node-connector" style="background: linear-gradient(90deg, ${rainbow[(idx - 1 + rainbow.length) % rainbow.length]}, ${color});"></div>
53          <div class="node-bubble" style="border-color: ${color}; box-shadow: 0 0 20px ${color}40;" data-node-id="${node._id}" data-is-current="${node.isCurrent ? "true" : "false"}">
54            ${
55              node.isCurrent
56                ? `<a href="/api/v1/node/${node._id}${queryString}" class="node-link current">
57                    <span class="node-icon">●</span>
58                    <span class="node-name">${escapeHtml(node.name)}</span>
59                    <span class="node-badge">YOU ARE HERE</span>
60                  </a>`
61                : `<a href="/api/v1/root/${node._id}${queryString}" class="node-link">
62                    <span class="node-icon">○</span>
63                    <span class="node-name">${escapeHtml(node.name)}</span>
64                    <span class="depth-badge">Level ${idx + 1}</span>
65                  </a>`
66            }
67          </div>
68        </div>
69      `;
70    });
71    html += "</div>";
72    return html;
73  };
74
75  const renderTree = (node, depth = 0) => {
76    const color = rainbow[depth % rainbow.length];
77    let html = `
78    <li
79      class="tree-node"
80        data-node-id="${node._id}"
81
82      style="
83        border-left: 4px solid ${color};
84        padding-left: 12px;
85        margin: 6px 0;
86      "
87    >
88
89
90      <a href="/api/v1/node/${node._id}/${0}${queryString}">
91        ${escapeHtml(node.name)}
92      </a>
93  `;
94    if (node.children && node.children.length > 0) {
95      html += `<ul>`;
96      for (const c of node.children) {
97        html += renderTree(c, depth + 1);
98      }
99      html += `</ul>`;
100    }
101    html += `</li>`;
102    return html;
103  };
104
105  const inviteFormHtml = isOwner
106    ? `
107<form
108  method="POST"
109  action="/api/v1/root/${nodeId}/invite?token=${token}&html"
110  style="display:flex; gap:8px; max-width:420px; margin-top:12px;"
111>
112  <input
113    type="text"
114    name="userReceiving"
115    placeholder="username or user@other.land.com"
116    required
117  />
118
119  <button type="submit">
120    Invite
121  </button>
122</form>
123<div style="font-size:11px; color:rgba(255,255,255,0.5); margin-top:4px;">
124  Use username@domain to invite someone from another land.
125</div>
126`
127    : ``;
128
129  const policyHtml = isOwner
130    ? `
131
132<form
133  method="POST"
134  action="/api/v1/root/${nodeId}/transaction-policy?token=${token}&html"
135  style="max-width: 420px;"
136>
137  <select
138    name="policy"
139    style="
140      width:100%;
141      padding:10px;
142      border-radius:8px;
143      border:1px solid #ccc;
144      font-size:14px;
145    "
146  >
147    <option value="OWNER_ONLY" ${
148      transactionPolicy === "OWNER_ONLY" ? "selected" : ""
149    }>
150      Owner only
151    </option>
152    <option value="ANYONE" ${transactionPolicy === "ANYONE" ? "selected" : ""}>
153      Anyone (single approval)
154    </option>
155    <option value="MAJORITY" ${
156      transactionPolicy === "MAJORITY" ? "selected" : ""
157    }>
158      Majority of root members
159    </option>
160    <option value="ALL" ${transactionPolicy === "ALL" ? "selected" : ""}>
161      All root members
162    </option>
163  </select>
164
165  <button type="submit" style="margin-top:12px;">
166    Update Policy
167  </button>
168</form>
169`
170    : ``;
171
172  const ownerHtml = rootMeta?.rootOwner
173    ? `<ul class="contributors-list">
174  <li>
175    <a href="/api/v1/user/${rootMeta.rootOwner._id}${queryString}">
176      ${escapeHtml(rootMeta.rootOwner.username)}
177    </a>
178    <span style="font-size:12px;opacity:0.7;color:white;">Owner</span>
179  </li>
180</ul>`
181    : ``;
182
183  const contributorsHtml = rootMeta?.contributors?.length
184    ? `
185<ul class="contributors-list">
186${rootMeta.contributors
187  .map((u) => {
188    const isSelf = u._id.toString() === userId?.toString();
189
190    return `
191<li>
192<a href="/api/v1/user/${u._id}${queryString}">
193  ${escapeHtml(u.username)}
194</a>
195${u.isRemote ? '<span style="font-size:11px;opacity:0.5;color:white;">(remote)</span>' : ""}
196  <div class="contributors-actions">
197    ${
198      isOwner
199        ? `
200      <form
201        method="POST"
202        action="/api/v1/root/${nodeId}/transfer-owner?token=${token}&html"
203onsubmit="return confirm('Transfer ownership to ${escapeHtml(u.username)}?')"
204      >
205        <input type="hidden" name="userReceiving" value="${u._id}" />
206        <button type="submit">Transfer</button>
207      </form>
208      `
209        : ""
210    }
211
212    ${
213      isOwner || isSelf
214        ? `
215      <form
216        method="POST"
217        action="/api/v1/root/${nodeId}/remove-user?token=${token}&html"
218        onsubmit="return confirm('${
219          isSelf ? "Leave this root?" : `Remove ${escapeHtml(u.username)} from this root?`
220        }')"
221      >
222        <input type="hidden" name="userReceiving" value="${u._id}" />
223        <button type="submit">
224          ${isSelf ? "Leave" : "Remove"}
225        </button>
226      </form>
227      `
228        : ""
229    }
230  </div>
231</li>
232`;
233  })
234  .join("")}
235</ul>
236`
237    : ``;
238
239  const retireHtml = isOwner
240    ? `
241<form
242  method="POST"
243  action="/api/v1/root/${nodeId}/retire?token=${token}&html"
244  onsubmit="return confirm('This will retire the root. Continue?')"
245  style="margin-top:12px;"
246>
247  <button
248    type="submit"
249    style="
250      padding:8px 14px;
251      border-radius:8px;
252      border:1px solid rgba(239, 68, 68, 0.5);
253      background:rgba(239, 68, 68, 0.25);
254      color:white;
255      font-weight:600;
256      cursor:pointer;
257    "
258  >
259    Retire
260  </button>
261</form>
262`
263    : "";
264
265  // Tree AI Model section
266  let treeLlmHtml = "";
267  if (isOwner && rootMeta?.rootOwner && ownerConnections) {
268    const ownerProfile = rootMeta.rootOwner;
269    const llmSlots = [
270      { key: "default", label: "Default", isDefault: true },
271      { key: "placement", label: "Placement" },
272      { key: "understanding", label: "Understanding" },
273      { key: "respond", label: "Respond" },
274      { key: "notes", label: "Notes" },
275      { key: "cleanup", label: "Cleanup" },
276      { key: "drain", label: "Drain" },
277      { key: "notification", label: "Notification" },
278    ];
279
280    function buildSlotHtml(slot) {
281      // Read from llmDefault for "default" slot, metadata.llm.slots for extension slots
282      const current = slot.key === "default"
283        ? (rootMeta.llmDefault || null)
284        : (rootMeta.metadata?.llm?.slots?.[slot.key] || (rootMeta.metadata instanceof Map ? rootMeta.metadata.get("llm")?.slots?.[slot.key] : null) || null);
285      const optHtml = ownerConnections.map(function(c) {
286        return '<div class="custom-select-option' + (current === c._id ? ' selected' : '') + '" data-value="' + c._id + '">'
287          + escapeHtml(c.name) + ' (' + escapeHtml(c.model) + ')</div>';
288      }).join('');
289      let label;
290      if (current === "none") {
291        label = 'Off (no AI)';
292      } else if (current) {
293        const m = ownerConnections.find(function(c){return c._id === current;});
294        label = m ? escapeHtml(m.name) + ' (' + escapeHtml(m.model) + ')' : 'Account default';
295      } else {
296        label = slot.isDefault ? 'Account default' : 'Use default';
297      }
298      return `<p style="font-size:0.85em;opacity:0.6;margin-bottom:4px;margin-top:10px;">${slot.label}</p>
299  <div class="custom-select" data-slot="${slot.key}" style="margin-bottom:4px;">
300    <div class="custom-select-trigger">${label}</div>
301    <div class="custom-select-options">
302      <div class="custom-select-option${!current ? ' selected' : ''}" data-value="">${slot.isDefault ? 'Account default' : 'Use default'}</div>
303      ${optHtml}
304      ${slot.isDefault ? '<div class="custom-select-option' + (current === "none" ? ' selected' : '') + '" data-value="none" style="color:rgba(255,107,107,0.8);">Off (no AI)</div>' : ''}
305    </div>
306  </div>`;
307    }
308
309    treeLlmHtml = `
310<h3>AI Models</h3>
311<p style="font-size:0.85em;opacity:0.5;margin-bottom:8px;">Set a default LLM for the tree. All modes fall back to this. Per-mode overrides below. Set default to "Off" to disable AI entirely.</p>
312${ownerConnections.length === 0
313  ? '<p style="font-size:0.85em;opacity:0.5;">No custom connections -- <a href="/api/v1/user/${ownerProfile._id}${queryString ? queryString + "&" : "?"}html" style="color:inherit;">add one on your profile</a></p>'
314  : llmSlots.map(buildSlotHtml).join('\n') + '\n  <div class="llm-assign-status" style="font-size:0.8em;margin-top:4px;display:none;"></div>'
315}`;
316  }
317
318  const parentHtml = ancestors.length
319    ? renderParents([
320        ...ancestors.slice().reverse(),
321        {
322          _id: allData._id,
323          name: allData.name,
324          isCurrent: true,
325        },
326      ])
327    : ``;
328
329  const childrenInner = allData.children?.length
330    ? `<ul>${allData.children.map((c) => renderTree(c)).join("")}</ul>`
331    : ``;
332
333  const treeHtml = `
334      <ul class="tree-root" style="padding-left:0;">
335        <li class="tree-node root-entry"
336            data-node-id="${allData._id}"
337            style="border-left: 4px solid ${rootNameColor}; padding-left: 6px; margin: 6px 0;">
338          <a href="/api/v1/node/${allData._id}/0${queryString}">
339            ${escapeHtml(allData.name)}
340          </a>
341          ${childrenInner}
342        </li>
343      </ul>`;
344
345  return `
346<!DOCTYPE html>
347<html lang="en">
348<head>
349  <meta charset="UTF-8">
350  <meta name="viewport" content="width=device-width, initial-scale=1.0">
351  <meta name="theme-color" content="#667eea">
352  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
353<title>${escapeHtml(allData.name)} - TreeOS</title>
354  <style>
355    ${baseStyles}
356    ${backNavStyles}
357
358    .current {
359    color: rgb(51, 66, 85);}
360
361    /* Glass Content Cards */
362    .content-card {
363      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
364      backdrop-filter: blur(22px) saturate(140%);
365      -webkit-backdrop-filter: blur(22px) saturate(140%);
366      border-radius: 16px;
367      padding: 28px;
368      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
369        inset 0 1px 0 rgba(255, 255, 255, 0.25);
370      border: 1px solid rgba(255, 255, 255, 0.28);
371      margin-bottom: 24px;
372      animation: fadeInUp 0.6s ease-out;
373      animation-fill-mode: both;
374       position: relative;
375    }
376
377    .content-card:nth-child(2) { animation-delay: 0.1s; }
378    .content-card:nth-child(3) { animation-delay: 0.15s; }
379    .content-card:nth-child(4) { animation-delay: 0.2s; }
380    .content-card:nth-child(5) { animation-delay: 0.25s; }
381
382
383
384  .content-card::before {
385  content: "";
386  position: absolute;
387  inset: 0;
388  border-radius: inherit;
389
390  background:
391    linear-gradient(
392      180deg,
393      rgba(255,255,255,0.18),
394      rgba(255,255,255,0.05)
395    );
396
397  pointer-events: none;
398}
399
400    /* Header Section */
401    .header-section {
402      margin-bottom: 24px;
403      padding-bottom: 20px;
404      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
405    }
406
407    .section-header {
408      margin-bottom: 20px;
409      padding-bottom: 12px;
410      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
411    }
412
413    .section-header h2 {
414      margin: 0;
415      font-size: 20px;
416      font-weight: 600;
417      color: white;
418      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
419      letter-spacing: -0.3px;
420    }
421
422    .section-header h2 a {
423      color: white;
424      text-decoration: none;
425      transition: all 0.2s;
426    }
427
428    .section-header h2 a:hover {
429      text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
430    }
431
432    /* ==========================================
433       CONSTELLATION BREADCRUMB NAVIGATION
434       ========================================== */
435
436.breadcrumb-constellation {
437  display: flex;
438  align-items: center;
439  gap: 0;
440  padding: 24px 16px;
441  overflow-x: auto;
442  overflow-y: hidden;
443  position: relative;
444  min-height: 100px;
445
446  /* Scrollbar styling */
447  scrollbar-width: thin;
448  scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
449}
450
451    .breadcrumb-constellation::-webkit-scrollbar {
452      height: 6px;
453    }
454
455    .breadcrumb-constellation::-webkit-scrollbar-track {
456      background: rgba(255, 255, 255, 0.1);
457      border-radius: 3px;
458    }
459
460    .breadcrumb-constellation::-webkit-scrollbar-thumb {
461      background: rgba(255, 255, 255, 0.3);
462      border-radius: 3px;
463    }
464
465    .breadcrumb-node {
466      display: flex;
467      align-items: center;
468      flex-shrink: 0;
469      animation: nodeSlideIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
470      animation-fill-mode: both;
471    }
472
473    .breadcrumb-node:nth-child(1) { animation-delay: 0s; }
474    .breadcrumb-node:nth-child(2) { animation-delay: 0.1s; }
475    .breadcrumb-node:nth-child(3) { animation-delay: 0.2s; }
476    .breadcrumb-node:nth-child(4) { animation-delay: 0.3s; }
477    .breadcrumb-node:nth-child(5) { animation-delay: 0.4s; }
478    .breadcrumb-node:nth-child(6) { animation-delay: 0.5s; }
479    .breadcrumb-node:nth-child(n+7) { animation-delay: 0.6s; }
480
481    @keyframes nodeSlideIn {
482      from {
483        opacity: 0;
484        transform: translateX(-30px) scale(0.8);
485      }
486      to {
487        opacity: 1;
488        transform: translateX(0) scale(1);
489      }
490    }
491
492    .node-connector {
493      width: 40px;
494      height: 3px;
495      border-radius: 2px;
496      position: relative;
497      overflow: hidden;
498    }
499
500    .node-connector::after {
501      content: '';
502      position: absolute;
503      top: 0;
504      left: -100%;
505      width: 100%;
506      height: 100%;
507      background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent);
508      animation: shimmer 2s infinite;
509    }
510
511    @keyframes shimmer {
512      to {
513        left: 100%;
514      }
515    }
516
517    .breadcrumb-node:first-child .node-connector {
518      display: none;
519    }
520
521    .node-bubble {
522      background: rgba(255, 255, 255, 0.15);
523      backdrop-filter: blur(10px);
524      border: 2px solid;
525      border-radius: 12px;
526      padding: 12px 18px;
527      position: relative;
528      transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
529      cursor: pointer;
530    }
531
532    .bubble-scroll-zone {
533      position: absolute;
534      left: 0;
535      top: 0;
536      bottom: 0;
537      width: 35%;
538      z-index: 10;
539      cursor: pointer;
540    }
541
542    .bubble-scroll-zone:hover {
543      background: rgba(255, 255, 255, 0.1);
544      border-radius: 12px 0 0 12px;
545    }
546
547    .node-bubble:hover {
548      transform: scale(1.05) translateY(-3px);
549      background: rgba(255, 255, 255, 0.25);
550      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
551    }
552
553    .current-node .node-bubble {
554      background: rgba(255, 255, 255, 0.3);
555      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2),
556                  inset 0 0 30px rgba(255, 255, 255, 0.3);
557      animation: pulse 2s infinite;
558    }
559
560    @keyframes pulse {
561      0%, 100% {
562        box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2),
563                    inset 0 0 30px rgba(255, 255, 255, 0.3);
564      }
565      50% {
566        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3),
567                    inset 0 0 40px rgba(255, 255, 255, 0.5);
568      }
569    }
570
571    .node-link {
572      display: flex;
573      align-items: center;
574      gap: 10px;
575      color: white;
576      text-decoration: none;
577      font-weight: 600;
578      font-size: 14px;
579      white-space: nowrap;
580      transition: all 0.2s;
581    }
582
583    .node-link:hover {
584      text-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
585    }
586
587    .node-link.current {
588      font-weight: 700;
589      font-size: 15px;
590    }
591
592    .node-icon {
593      font-size: 20px;
594      line-height: 1;
595      transition: transform 0.3s;
596    }
597
598    .node-link:hover .node-icon {
599      transform: scale(1.3);
600    }
601
602    .current-node .node-icon {
603      animation: glow 2s infinite;
604    }
605
606    @keyframes glow {
607      0%, 100% {
608        filter: drop-shadow(0 0 3px rgba(255, 255, 255, 0.6));
609      }
610      50% {
611        filter: drop-shadow(0 0 8px rgba(255, 255, 255, 1));
612      }
613    }
614
615    .node-name {
616      max-width: 150px;
617      overflow: hidden;
618      text-overflow: ellipsis;
619    }
620
621    .node-badge {
622      background: rgba(255, 255, 255, 0.3);
623      padding: 2px 8px;
624      border-radius: 8px;
625      font-size: 9px;
626      font-weight: 700;
627      letter-spacing: 0.5px;
628      text-transform: uppercase;
629      border: 1px solid rgba(255, 255, 255, 0.4);
630    }
631
632    .depth-badge {
633      background: rgba(0, 0, 0, 0.2);
634      padding: 2px 6px;
635      border-radius: 6px;
636      font-size: 10px;
637      font-weight: 600;
638      opacity: 0.7;
639    }
640
641    /* Mobile optimization */
642    @media (max-width: 640px) {
643      .breadcrumb-constellation {
644        padding: 16px 8px;
645        min-height: 80px;
646      }
647
648      .node-connector {
649        width: 24px;
650      }
651
652      .node-bubble {
653        padding: 8px 12px;
654      }
655
656      .node-link {
657        font-size: 12px;
658        gap: 6px;
659      }
660
661      .node-icon {
662        font-size: 16px;
663      }
664
665      .node-name {
666        max-width: 100px;
667      }
668
669      .node-badge {
670        font-size: 8px;
671        padding: 1px 6px;
672      }
673    }
674
675    /* Glass Action Buttons */
676   .action-button {
677  background: rgba(255,255,255,0.22);
678  border: 1px solid rgba(255,255,255,0.28);
679  box-shadow:
680    inset 0 1px 0 rgba(255,255,255,0.35),
681    0 4px 12px rgba(0,0,0,0.12);
682}
683
684
685    .action-button::before {
686      content: "";
687      position: absolute;
688      inset: -40%;
689      background: radial-gradient(
690        120% 60% at 0% 0%,
691        rgba(255, 255, 255, 0.35),
692        transparent 60%
693      );
694      opacity: 0;
695      transform: translateX(-30%) translateY(-10%);
696      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
697      pointer-events: none;
698    }
699
700    .action-button:hover {
701      background: rgba(255, 255, 255, 0.35);
702      transform: translateY(-2px);
703    }
704
705    .action-button:hover::before {
706      opacity: 1;
707      transform: translateX(30%) translateY(10%);
708    }
709
710    .owner-info {
711      font-size: 14px;
712      color: white;
713      font-weight: 600;
714      margin-bottom: 8px;
715      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
716    }
717
718    .owner-info a {
719      color: white;
720      text-decoration: none;
721      transition: all 0.2s;
722      border-bottom: 1px solid rgba(255, 255, 255, 0.3);
723    }
724
725    .owner-info a:hover {
726      border-bottom-color: white;
727      text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
728    }
729
730    h1 {
731      font-size: 28px;
732      margin: 12px 0;
733      font-weight: 600;
734      line-height: 1.3;
735      letter-spacing: -0.5px;
736    }
737
738    h1 a {
739      color: white;
740      text-decoration: none;
741      transition: all 0.2s;
742      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
743    }
744
745    h1 a:hover {
746      text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
747      transform: translateX(4px);
748      display: inline-block;
749    }
750
751    code {
752      background: transparent;
753      padding: 0;
754      border-radius: 0;
755      font-size: 13px;
756      font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
757      color: white;
758      word-break: break-all;
759    }
760
761    /* Section Headers */
762    h2 {
763      font-size: 18px;
764      margin: 24px 0 16px 0;
765      font-weight: 600;
766      color: white;
767      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
768      letter-spacing: -0.3px;
769    }
770
771    h3 {
772      font-size: 16px;
773      margin: 20px 0 12px 0;
774      font-weight: 600;
775      color: white;
776      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
777    }
778
779    /* Filter Buttons */
780    #filterButtons {
781      display: flex;
782      flex-wrap: wrap;
783      gap: 8px;
784      margin: 16px 0;
785    }
786
787    #filterButtons a {
788      display: inline-flex;
789      align-items: center;
790      padding: 8px 16px;
791      font-size: 13px;
792      border-radius: 980px;
793      color: white;
794      font-weight: 600;
795transition:
796  transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
797  background-color 0.3s ease,
798  opacity 0.3s ease;         text-decoration: none;
799      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
800      border: 1px solid rgba(255, 255, 255, 0.3);
801      position: relative;
802      overflow: hidden;
803    }
804
805    #filterButtons a::before {
806      content: "";
807      position: absolute;
808      inset: -40%;
809      background: radial-gradient(
810        120% 60% at 0% 0%,
811        rgba(255, 255, 255, 0.35),
812        transparent 60%
813      );
814      opacity: 0;
815      transform: translateX(-30%) translateY(-10%);
816      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
817      pointer-events: none;
818    }
819
820    #filterButtons a:hover {
821      transform: translateY(-2px);
822    }
823
824    #filterButtons a:hover::before {
825      opacity: 1;
826      transform: translateX(30%) translateY(10%);
827    }
828
829    /* Tree Structure - Keep rainbow colors */
830    ul {
831      list-style: none;
832      padding-left: 16px;
833      margin: 12px 0;
834    }
835
836    li {
837      margin: 8px 0;
838      word-wrap: break-word;
839      overflow-wrap: break-word;
840    }
841
842    li a {
843      color: white;
844      text-decoration: none;
845      font-weight: 500;
846      transition: all 0.2s;
847      position: relative;
848      display: inline-block;
849    }
850
851    li a:hover {
852      text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
853      transform: translateX(4px);
854    }
855
856    /* Parents/Children with colored borders - keep the rainbow */
857    li[style*="border-left"] {
858      padding-left: 12px !important;
859      margin: 6px 0 !important;
860      position: relative;
861      background: rgba(255, 255, 255, 0.05);
862      border-radius: 6px;
863      padding: 8px 12px !important;
864      transition: all 0.2s;
865    }
866
867    li[style*="border-left"]:hover {
868      background: rgba(255, 255, 255, 0.1);
869      transform: translateX(4px);
870    }
871
872    /* Glass Forms */
873    form {
874      margin: 16px 0;
875    }
876
877    input[type="text"],
878    select {
879      width: 100%;
880      padding: 12px 14px;
881      font-size: 15px;
882      border-radius: 10px;
883      border: 2px solid rgba(255, 255, 255, 0.3);
884      background: rgba(255, 255, 255, 0.15);
885      font-family: inherit;
886transition:
887  transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
888  background-color 0.3s ease,
889  opacity 0.3s ease;
890        color: white;
891      font-weight: 500;
892    }
893
894    input[type="text"]::placeholder {
895      color: rgba(255, 255, 255, 0.5);
896    }
897
898    input[type="text"]:focus,
899    select:focus {
900      outline: none;
901      border-color: rgba(255, 255, 255, 0.6);
902      background: rgba(255, 255, 255, 0.25);
903      box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
904      transform: translateY(-2px);
905    }
906
907    select option {
908      background: #667eea;
909      color: white;
910    }
911
912    button[type="submit"] {
913      padding: 10px 18px;
914      border-radius: 980px;
915      border: 1px solid rgba(255, 255, 255, 0.3);
916      background: rgba(255, 255, 255, 0.25);
917      color: white;
918      cursor: pointer;
919      font-weight: 600;
920      font-size: 14px;
921transition:
922  transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
923  background-color 0.3s ease,
924  opacity 0.3s ease;
925        font-family: inherit;
926      position: relative;
927      overflow: hidden;
928      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
929    }
930
931    button[type="submit"]::before {
932      content: "";
933      position: absolute;
934      inset: -40%;
935
936      opacity: 0;
937      transform: translateX(-30%) translateY(-10%);
938      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
939      pointer-events: none;
940    }
941
942    button[type="submit"]:hover {
943      background: rgba(255, 255, 255, 0.35);
944      transform: translateY(-1px);
945    }
946
947    button[type="submit"]:hover::before {
948      opacity: 1;
949      transform: translateX(30%) translateY(10%);
950    }
951
952    /* Invite Form */
953    form[action*="/invite"] {
954      display: flex;
955      flex-direction: column;
956      gap: 10px;
957      max-width: 100%;
958    }
959
960    @media (min-width: 640px) {
961      form[action*="/invite"] {
962        flex-direction: row;
963        max-width: 500px;
964      }
965
966      form[action*="/invite"] input[type="text"] {
967        flex: 1;
968      }
969
970      form[action*="/invite"] button {
971        width: auto;
972      }
973    }
974
975    /* Contributors - Glass List Items */
976    .contributors-list {
977      list-style: none;
978      padding-left: 0;
979    }
980
981    .contributors-list li {
982      display: flex;
983      flex-direction: column;
984      gap: 8px;
985      padding: 14px 16px;
986      background: rgba(255, 255, 255, 0.12);
987      border-radius: 10px;
988      margin: 8px 0;
989      border: 1px solid rgba(255, 255, 255, 0.25);
990transition:
991  transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
992  background-color 0.3s ease,
993  opacity 0.3s ease;
994      }
995
996    .contributors-list li:hover {
997      background: rgba(255, 255, 255, 0.18);
998      transform: translateX(4px);
999    }
1000
1001    @media (min-width: 640px) {
1002      .contributors-list li {
1003        flex-direction: row;
1004        align-items: center;
1005        justify-content: space-between;
1006      }
1007    }
1008
1009    .contributors-list a {
1010      font-weight: 600;
1011      color: white;
1012      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1013    }
1014
1015    .contributors-list form {
1016      display: inline-block;
1017      margin: 0;
1018    }
1019
1020    .contributors-list button {
1021      padding: 6px 12px;
1022      font-size: 13px;
1023      border-radius: 980px;
1024      border: 1px solid rgba(255, 255, 255, 0.3);
1025      cursor: pointer;
1026      transition: all 0.2s;
1027    }
1028
1029    .contributors-list button:hover {
1030      transform: translateY(-1px);
1031    }
1032
1033    .contributors-actions {
1034      display: flex;
1035      gap: 8px;
1036      flex-wrap: wrap;
1037    }
1038
1039    /* Retire Button */
1040    form[action*="/retire"] button {
1041      background: rgba(239, 68, 68, 0.3) !important;
1042      border: 1px solid rgba(239, 68, 68, 0.5) !important;
1043    }
1044
1045    form[action*="/retire"] button:hover {
1046      background: rgba(239, 68, 68, 0.5) !important;
1047      transform: translateY(-2px);
1048    }
1049
1050.glass-shadow {
1051  position: absolute;
1052  inset: 0;
1053  border-radius: inherit;
1054  box-shadow: 0 12px 32px rgba(0,0,0,0.18);
1055  opacity: 0;
1056  transition: opacity 0.3s ease;
1057  pointer-events: none;
1058}
1059  :hover > .glass-shadow {
1060  opacity: 1;
1061}
1062
1063    /* Responsive Design */
1064    @media (max-width: 640px) {
1065  ul {
1066    padding-left: 6px;
1067  }
1068}
1069  @media (max-width: 640px) {
1070  .tree-node {
1071    padding: 6px 8px;
1072  }
1073}
1074  @media (max-width: 640px) {
1075  .tree-node {
1076    margin-left: 0;
1077  }
1078
1079  li[style*="border-left"] {
1080    padding-left: 8px !important;
1081  }
1082}
1083@media (max-width: 640px) {
1084  .tree-node,
1085  .tree-node:hover,
1086  li[style*="border-left"],
1087  li[style*="border-left"]:hover {
1088    transform: none !important;
1089  }
1090}
1091@media (max-width: 640px) {
1092  li[style*="border-left"] {
1093    padding-left: 8px !important;
1094    margin-left: 0 !important;
1095  }
1096}
1097@media (hover: none) {
1098  .tree-node:hover::before {
1099    opacity: 0;
1100    transform: none;
1101  }
1102}
1103
1104    ${responsiveBase}
1105
1106    @media (max-width: 640px) {
1107      .content-card {
1108        padding: 20px;
1109      }
1110
1111      h1 {
1112        font-size: 24px;
1113      }
1114
1115      ul {
1116        padding-left: 8px;
1117      }
1118    }
1119
1120.tree-node {
1121  position: relative;
1122  padding: 8px 12px;
1123  border-radius: 8px;
1124  background: rgba(255, 255, 255, 0.12);
1125  border: 1px solid rgba(255, 255, 255, 0.22);
1126  box-shadow:
1127    inset 0 1px 0 rgba(255, 255, 255, 0.35),
1128    0 4px 12px rgba(0, 0, 0, 0.12);
1129
1130  transition:
1131    transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
1132    background-color 0.25s ease,
1133    box-shadow 0.25s ease;
1134}
1135
1136
1137
1138  /* Custom dropdown (replaces native <select> to avoid iframe glitch on mobile) */
1139  .custom-select { position: relative; width: 100%; max-width: 360px; }
1140  .custom-select-trigger {
1141    padding: 12px 14px; font-size: 15px; border-radius: 10px;
1142    border: 2px solid rgba(255, 255, 255, 0.3);
1143    background: rgba(255, 255, 255, 0.15); color: white;
1144    cursor: pointer; display: flex; align-items: center;
1145    justify-content: space-between; gap: 8px;
1146    font-weight: 500;
1147    -webkit-user-select: none; user-select: none;
1148    transition: all 0.3s ease;
1149  }
1150  .custom-select-trigger::after { content: "▾"; font-size: 11px; opacity: 0.6; flex-shrink: 0; }
1151  .custom-select.open .custom-select-trigger {
1152    border-color: rgba(255, 255, 255, 0.6);
1153    background: rgba(255, 255, 255, 0.25);
1154    box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
1155  }
1156  .custom-select.open .custom-select-trigger::after { content: "▴"; }
1157  .custom-select-options {
1158    display: none; position: absolute; left: 0; right: 0;
1159    bottom: calc(100% + 4px);
1160    background: rgba(102, 126, 234, 0.95);
1161    backdrop-filter: blur(12px);
1162    border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 10px;
1163    overflow: hidden; z-index: 100; max-height: 220px; overflow-y: auto;
1164    box-shadow: 0 -4px 16px rgba(0,0,0,0.2);
1165  }
1166  .custom-select.open .custom-select-options { display: block; }
1167  .custom-select-option {
1168    padding: 10px 14px; font-size: 14px; color: rgba(255, 255, 255, 0.85);
1169    cursor: pointer; transition: background 0.15s;
1170  }
1171  .custom-select-option:hover { background: rgba(255, 255, 255, 0.15); }
1172  .custom-select-option.selected { background: rgba(255, 255, 255, 0.2); color: white; font-weight: 600; }
1173
1174  /* Root entry in tree */
1175  .root-entry {
1176    background: rgba(255, 255, 255, 0.18) !important;
1177    border: 1px solid rgba(255, 255, 255, 0.30);
1178    border-left: 4px solid !important;
1179  }
1180  .root-entry > a {
1181    font-weight: 700;
1182    font-size: 16px;
1183  }
1184
1185  /* Settings groups inside ownership card */
1186  .settings-group {
1187    padding: 16px 0;
1188    border-bottom: 1px solid rgba(255, 255, 255, 0.15);
1189  }
1190  .settings-group:last-child {
1191    border-bottom: none;
1192    padding-bottom: 0;
1193  }
1194  .settings-group:first-child {
1195    padding-top: 0;
1196  }
1197  .settings-group h3 {
1198    margin-top: 0;
1199    margin-bottom: 12px;
1200    font-size: 15px;
1201    opacity: 0.85;
1202  }
1203  .settings-group h2 {
1204    margin-top: 0;
1205  }
1206
1207  </style>
1208</head>
1209<body>
1210  <div class="container">
1211    ${
1212      currentUserId
1213        ? `
1214    <!-- Back Navigation -->
1215    <div class="back-nav">
1216      <a href="/api/v1/user/${currentUserId}${queryString}" class="back-link">
1217        <- Back to Profile
1218      </a>
1219        <a href="/api/v1/root/${allData._id}/calendar${queryString}" class="back-link">
1220        Calendar
1221      </a>
1222      </a>
1223        <a href="/api/v1/root/${allData._id}/book${queryString}" class="back-link">
1224        Book
1225      </a>
1226       </a>
1227        <a href="/api/v1/root/${allData._id}/values${queryString}" class="back-link">
1228        Global Values
1229      </a>
1230      </a>
1231        <a href="/api/v1/root/${allData._id}/understandings${queryString}" class="back-link">
1232        Understandings
1233      </a>
1234        <a href="/api/v1/root/${allData._id}/chats${queryString}" class="back-link">
1235        AI Chats
1236      </a>
1237    </div>
1238    `
1239        : ""
1240    }
1241    <!-- Navigation Path (only if not root) -->
1242    ${
1243      ancestors.length
1244        ? `
1245    <div class="content-card">
1246      <div class="section-header">
1247        <h2>Navigation Path</h2>
1248      </div>
1249      ${parentHtml}
1250    </div>
1251    `
1252        : ""
1253    }
1254
1255    <!-- Tree Card (root + children unified) -->
1256    <div class="content-card">
1257      <div class="section-header">
1258        <h2>Tree: <a href="/api/v1/node/${allData._id}/0${queryString}">${escapeHtml(allData.name)}</a></h2>
1259      </div>
1260      <div id="filterButtons"></div>
1261      ${treeHtml}
1262    </div>
1263
1264    ${isPublicAccess ? `
1265    <div style="text-align:center;padding:16px 20px;background:rgba(72,187,120,0.1);border:1px solid rgba(72,187,120,0.25);border-radius:12px;margin:16px 0;color:rgba(255,255,255,0.8);font-size:0.9rem;">
1266      Viewing public tree${queryAvailable ? ". You can query this tree using the API." : "."}
1267    </div>
1268    ` : ""}
1269
1270    ${!isPublicAccess ? `
1271    <!-- Deferred Items (Short-Term Holdings) -->
1272    <div class="content-card">
1273      <div class="section-header">
1274        <h2>Short-Term Holdings ${deferredItems.length > 0 ? `<span style="font-size:0.7em;color:#ffb347;">(${deferredItems.length})</span>` : ""}</h2>
1275      </div>
1276      ${deferredHtml}
1277    </div>
1278    ` : ""}
1279
1280    <!-- Tree Settings Section -->
1281${
1282  !isPublicAccess && (isOwner ||
1283  rootMeta?.contributors?.some(
1284    (c) => c._id.toString() === userId?.toString(),
1285  ))
1286    ? `
1287
1288  ${isOwner ? `
1289<div class="content-card">
1290  <div class="section-header">
1291    <h2>Visibility</h2>
1292  </div>
1293  <p style="color:rgba(255,255,255,0.7);font-size:0.85rem;margin:0 0 12px">
1294    Public trees can be browsed and queried by anyone without authentication.
1295    If an LLM is assigned to the placement slot, anonymous visitors can query the tree (you pay energy).
1296  </p>
1297  <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
1298    <select id="visibilitySelect"
1299      style="padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);
1300             background:rgba(255,255,255,0.06);color:#fff;font-size:0.95rem;min-width:140px">
1301      <option value="private" ${rootMeta.visibility || "private" === "private" ? "selected" : ""}>Private</option>
1302      <option value="public" ${rootMeta.visibility === "public" ? "selected" : ""}>Public</option>
1303    </select>
1304    <button onclick="saveVisibility()" style="padding:8px 14px;border-radius:8px;
1305      border:1px solid rgba(72,187,120,0.4);background:rgba(72,187,120,0.15);
1306      color:rgba(72,187,120,0.9);font-weight:600;cursor:pointer">Save</button>
1307    <span id="visibilityStatus" style="display:none;font-size:0.85rem"></span>
1308  </div>
1309</div>
1310
1311<div class="content-card">
1312  <div class="section-header">
1313    <h2>Tree Dream</h2>
1314  </div>
1315  <p style="color:rgba(255,255,255,0.7);font-size:0.85rem;margin:0 0 12px">
1316    Schedule a daily maintenance cycle: cleanup, process deferred items,
1317    and update tree understanding.
1318  </p>
1319  <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
1320    <input type="time" id="dreamTimeInput" value="${rootMeta.metadata?.dreams?.dreamTime || ""}"
1321      style="padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);
1322             background:rgba(255,255,255,0.06);color:#fff;font-size:0.95rem" />
1323    <button onclick="saveDreamTime()" style="padding:8px 14px;border-radius:8px;
1324      border:1px solid rgba(72,187,120,0.4);background:rgba(72,187,120,0.15);
1325      color:rgba(72,187,120,0.9);font-weight:600;cursor:pointer">Save</button>
1326    <button onclick="clearDreamTime()" style="padding:8px 14px;border-radius:8px;
1327      border:1px solid rgba(255,107,107,0.4);background:rgba(255,107,107,0.1);
1328      color:rgba(255,107,107,0.8);cursor:pointer">Disable</button>
1329    <span id="dreamTimeStatus" style="display:none;font-size:0.85rem"></span>
1330  </div>
1331  ${rootMeta.metadata?.dreams?.lastDreamAt ? `<p style="color:rgba(255,255,255,0.6);font-size:0.8rem;margin:8px 0 0">Last dream: ${new Date(rootMeta.metadata?.dreams?.lastDreamAt).toLocaleString()}</p>` : ""}
1332</div>
1333  ` : ""}
1334
1335<div class="content-card">
1336  <div class="section-header">
1337    <h2>Team</h2>
1338  </div>
1339  ${ownerHtml}
1340  ${contributorsHtml}
1341  ${inviteFormHtml}
1342</div>
1343
1344  ${policyHtml ? `
1345<div class="content-card">
1346  <div class="section-header">
1347    <h2>Transaction Policy</h2>
1348  </div>
1349  ${policyHtml}
1350</div>` : ""}
1351
1352  ${treeLlmHtml ? `
1353<div class="content-card">
1354  <div class="section-header">
1355    <h2>Tree Models</h2>
1356  </div>
1357  ${treeLlmHtml}
1358</div>` : ""}
1359
1360  ${isOwner ? `
1361<div class="content-card">
1362  <div class="section-header">
1363    <h2>Gateway</h2>
1364  </div>
1365  <p style="color:rgba(255,255,255,0.7);font-size:0.85rem;margin:0 0 12px">
1366    Manage output channels for this tree -- send dream summaries and notifications to Telegram, Discord, or your browser.
1367  </p>
1368  <a href="/api/v1/root/${nodeId}/gateway${queryString}"
1369     style="display:inline-block;padding:8px 16px;border-radius:8px;
1370            border:1px solid rgba(115,111,230,0.4);background:rgba(115,111,230,0.15);
1371            color:rgba(200,200,255,0.95);font-weight:600;text-decoration:none;
1372            font-size:0.9rem;cursor:pointer">
1373    Manage Channels
1374  </a>
1375</div>
1376  ` : ""}
1377
1378  ${
1379    !isOwner && userId
1380      ? `
1381<div class="content-card">
1382  <div class="section-header">
1383    <h2>Leave Tree</h2>
1384  </div>
1385  <form
1386    method="POST"
1387    action="/api/v1/root/${nodeId}/remove-user?token=${token}&html"
1388    onsubmit="return confirm('Are you sure you want to leave this tree?')"
1389  >
1390    <input type="hidden" name="userReceiving" value="${userId}" />
1391    <button
1392      type="submit"
1393      style="
1394        padding:8px 14px;
1395        border-radius:8px;
1396        border:1px solid #900;
1397        background:rgba(239, 68, 68, 0.15);
1398        color:#ff6b6b;
1399        font-weight:600;
1400        cursor:pointer;
1401      "
1402    >
1403      Leave Tree
1404    </button>
1405  </form>
1406</div>
1407  `
1408      : ""
1409  }
1410
1411  ${
1412    retireHtml
1413      ? `
1414<div class="content-card">
1415  <div class="section-header">
1416    <h2>Retire Tree</h2>
1417  </div>
1418  ${retireHtml}
1419</div>
1420  `
1421      : ""
1422  }
1423`
1424    : ""
1425}
1426
1427  </div>
1428
1429
1430<script>
1431// VISIBILITY
1432async function saveVisibility() {
1433  var select = document.getElementById("visibilitySelect");
1434  var status = document.getElementById("visibilityStatus");
1435  if (!select) return;
1436  try {
1437    var res = await fetch("/api/v1/root/${nodeId}/visibility", {
1438      method: "POST",
1439      credentials: "include",
1440      headers: { "Content-Type": "application/json" },
1441      body: JSON.stringify({ visibility: select.value }),
1442    });
1443    if (res.ok) {
1444      if (status) {
1445        status.style.display = "inline";
1446        status.style.color = "rgba(72, 187, 120, 0.9)";
1447        status.textContent = select.value === "public" ? "Now public" : "Now private";
1448        setTimeout(function() { status.style.display = "none"; }, 3000);
1449      }
1450    } else {
1451      var data = await res.json().catch(function() { return {}; });
1452      if (status) {
1453        status.style.display = "inline";
1454        status.style.color = "rgba(255, 107, 107, 0.9)";
1455        status.textContent = data.error || "Failed";
1456      }
1457    }
1458  } catch (err) {
1459    if (status) {
1460      status.style.display = "inline";
1461      status.style.color = "rgba(255, 107, 107, 0.9)";
1462      status.textContent = "Error";
1463    }
1464  }
1465}
1466
1467// DREAM TIME
1468async function saveDreamTime() {
1469  var input = document.getElementById("dreamTimeInput");
1470  var status = document.getElementById("dreamTimeStatus");
1471  try {
1472    var res = await fetch("/api/v1/root/${nodeId}/dream-time", {
1473      method: "POST",
1474      credentials: "include",
1475      headers: { "Content-Type": "application/json" },
1476      body: JSON.stringify({ dreamTime: input.value || null }),
1477    });
1478    if (res.ok) {
1479      if (status) {
1480        status.style.display = "inline";
1481        status.style.color = "rgba(72, 187, 120, 0.9)";
1482        status.textContent = input.value ? "Saved" : "Disabled";
1483        setTimeout(function() { status.style.display = "none"; }, 3000);
1484      }
1485    } else {
1486      var data = await res.json().catch(function() { return {}; });
1487      if (status) {
1488        status.style.display = "inline";
1489        status.style.color = "rgba(255, 107, 107, 0.9)";
1490        status.textContent = data.error || "Failed";
1491      }
1492    }
1493  } catch (err) {
1494    if (status) {
1495      status.style.display = "inline";
1496      status.style.color = "rgba(255, 107, 107, 0.9)";
1497      status.textContent = "Network error";
1498    }
1499  }
1500}
1501async function clearDreamTime() {
1502  document.getElementById("dreamTimeInput").value = "";
1503  saveDreamTime();
1504}
1505
1506// ROOT LLM ASSIGNMENT
1507async function assignRootLlm(slot, connId) {
1508  var statusEl = document.querySelector(".llm-assign-status");
1509  try {
1510    var res = await fetch("/api/v1/root/${nodeId}/llm-assign", {
1511      method: "POST",
1512      credentials: "include",
1513      headers: { "Content-Type": "application/json" },
1514      body: JSON.stringify({ slot: slot, connectionId: connId || null }),
1515    });
1516    if (res.ok) {
1517      if (statusEl) {
1518        statusEl.style.display = "block";
1519        statusEl.style.color = "rgba(72, 187, 120, 0.9)";
1520        statusEl.textContent = connId ? "✓ Assigned" : "✓ Using default";
1521        setTimeout(function() { statusEl.style.display = "none"; }, 3000);
1522      }
1523    } else {
1524      var data = await res.json().catch(function() { return {}; });
1525      if (statusEl) {
1526        statusEl.style.display = "block";
1527        statusEl.style.color = "rgba(255, 107, 107, 0.9)";
1528        statusEl.textContent = "✕ " + (data.error || "Failed");
1529      }
1530    }
1531  } catch (err) {
1532    if (statusEl) {
1533      statusEl.style.display = "block";
1534      statusEl.style.color = "rgba(255, 107, 107, 0.9)";
1535      statusEl.textContent = "✕ Network error";
1536    }
1537  }
1538}
1539
1540// CUSTOM DROPDOWN HANDLER
1541(function() {
1542  document.querySelectorAll(".custom-select").forEach(function(sel) {
1543    var trigger = sel.querySelector(".custom-select-trigger");
1544    if (!trigger) return;
1545    trigger.addEventListener("click", function(e) {
1546      e.stopPropagation();
1547      var wasOpen = sel.classList.contains("open");
1548      document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
1549      if (!wasOpen) sel.classList.add("open");
1550    });
1551    sel.querySelectorAll(".custom-select-option").forEach(function(opt) {
1552      opt.addEventListener("click", function(e) {
1553        e.stopPropagation();
1554        sel.querySelectorAll(".custom-select-option").forEach(function(o) { o.classList.remove("selected"); });
1555        opt.classList.add("selected");
1556        trigger.textContent = opt.textContent;
1557        sel.classList.remove("open");
1558        assignRootLlm(sel.getAttribute("data-slot") || "placement", opt.getAttribute("data-value"));
1559      });
1560    });
1561  });
1562  document.addEventListener("click", function() {
1563    document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
1564  });
1565})();
1566
1567// AUTO-SCROLL BREADCRUMB TO RIGHT ON LOAD
1568window.addEventListener('load', () => {
1569  const breadcrumb = document.querySelector('.breadcrumb-constellation');
1570  if (breadcrumb) {
1571    breadcrumb.scrollLeft = breadcrumb.scrollWidth;
1572  }
1573});
1574
1575// HORIZONTAL SCROLL WITH MOUSE WHEEL - Breadcrumb
1576const breadcrumb = document.querySelector('.breadcrumb-constellation');
1577if (breadcrumb) {
1578  breadcrumb.addEventListener('wheel', (e) => {
1579    e.preventDefault();
1580    breadcrumb.scrollLeft += e.deltaY;
1581  });
1582}
1583
1584// Breadcrumb bubble click handling - links work normally
1585document.addEventListener('click', (e) => {
1586  const link = e.target.closest('.node-link');
1587  if (link && !e.defaultPrevented) {
1588    // Just let the link work normally
1589    return;
1590  }
1591});
1592
1593// Tree node click handling (existing)
1594document.addEventListener('click', (e) => {
1595  const node = e.target.closest('.tree-node');
1596  if (!node) return;
1597
1598  // Ignore real navigation
1599  if (e.target.closest('a, button')) return;
1600
1601  const link = node.querySelector(':scope > a');
1602  if (!link) return;
1603
1604  e.preventDefault();
1605
1606  const OFFSET = 50;
1607
1608  const rect = link.getBoundingClientRect();
1609  const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
1610
1611  const targetY = rect.top + scrollTop - OFFSET;
1612
1613  window.scrollTo({
1614    top: targetY,
1615    behavior: 'smooth'
1616  });
1617
1618  // Optional glow pulse
1619  link.animate(
1620    [
1621      { boxShadow: '0 0 0 rgba(255,255,255,0)' },
1622      { boxShadow: '0 0 24px rgba(255,255,255,0.6)' },
1623      { boxShadow: '0 0 0 rgba(255,255,255,0)' }
1624    ],
1625    { duration: 900, easing: 'ease-out' }
1626  );
1627});
1628</script>
1629
1630  <script>
1631    // Filter toggles
1632    const params = new URLSearchParams(window.location.search);
1633
1634    function paramIsOn(param, current) {
1635      if (current === "true") return true;
1636      if (current === "false") return false;
1637      if (param === "active" || param === "completed") return true;
1638      return false;
1639    }
1640
1641    function makeToggle(param) {
1642      const current = params.get(param);
1643      const isOn = paramIsOn(param, current);
1644      const nextValue = isOn ? "false" : "true";
1645
1646      const newParams = new URLSearchParams(params);
1647      newParams.set(param, nextValue);
1648
1649      const url = window.location.pathname + "?" + newParams.toString();
1650      const color = isOn ? "#4CAF50" : "#9E9E9E";
1651
1652      return (
1653        '<a href="' + url + '" ' +
1654        'style="background:' + color + ';">' +
1655          param +
1656        '</a>'
1657      );
1658    }
1659
1660    document.getElementById("filterButtons").innerHTML =
1661      makeToggle("active") +
1662      makeToggle("completed") +
1663      makeToggle("trimmed") +
1664      '<a href="#" id="copyNodeIdBtn" title="Copy Node ID" style="background:rgba(var(--glass-water-rgb),0.35);">📋</a>';
1665
1666    document.getElementById("copyNodeIdBtn").addEventListener("click", function(e) {
1667      e.preventDefault();
1668      navigator.clipboard.writeText("${allData._id}").then(function() {
1669        var b = document.getElementById("copyNodeIdBtn");
1670        b.textContent = "✔️";
1671        setTimeout(function() { b.textContent = "📋"; }, 900);
1672      });
1673    });
1674  </script>
1675
1676</body>
1677</html>
1678`;
1679}
1680
1681
1682// ─────────────────────────────────────────────────────────────────────────
1683// 2. renderCalendar
1684// ─────────────────────────────────────────────────────────────────────────
1685
1686export function renderCalendar({ rootId, queryString, month, year, byDay }) {
1687  return `
1688<!DOCTYPE html>
1689<html lang="en">
1690<head>
1691  <meta charset="UTF-8">
1692  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1693  <meta name="theme-color" content="#667eea">
1694  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1695  <title>Calendar</title>
1696  <style>
1697    ${baseStyles}
1698    body { color: white; }
1699    .container { max-width: 1200px; }
1700
1701    /* Glass Card Base */
1702    .glass-card {
1703      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1704      backdrop-filter: blur(22px) saturate(140%);
1705      -webkit-backdrop-filter: blur(22px) saturate(140%);
1706      border-radius: 16px;
1707      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1708        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1709      border: 1px solid rgba(255, 255, 255, 0.28);
1710      position: relative;
1711      overflow: hidden;
1712    }
1713
1714    .glass-card::before {
1715      content: "";
1716      position: absolute;
1717      inset: 0;
1718      border-radius: inherit;
1719      background: linear-gradient(
1720        180deg,
1721        rgba(255,255,255,0.18),
1722        rgba(255,255,255,0.05)
1723      );
1724      pointer-events: none;
1725    }
1726
1727    /* Header */
1728    .header {
1729      padding: 24px 28px;
1730      margin-bottom: 20px;
1731      animation: fadeInUp 0.5s ease-out;
1732    }
1733
1734    .header-top {
1735      display: flex;
1736      justify-content: space-between;
1737      align-items: center;
1738      gap: 16px;
1739      flex-wrap: wrap;
1740    }
1741
1742    ${backNavStyles}
1743
1744    .nav-controls {
1745      display: flex;
1746      align-items: center;
1747      gap: 16px;
1748    }
1749
1750    .nav-button {
1751      width: 40px;
1752      height: 40px;
1753      border-radius: 50%;
1754      border: 1px solid rgba(255, 255, 255, 0.3);
1755      background: rgba(255, 255, 255, 0.2);
1756      color: white;
1757      font-size: 18px;
1758      font-weight: 700;
1759      cursor: pointer;
1760      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1761      display: flex;
1762      align-items: center;
1763      justify-content: center;
1764      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15),
1765        inset 0 1px 0 rgba(255, 255, 255, 0.3);
1766    }
1767
1768    .nav-button:hover {
1769      background: rgba(255, 255, 255, 0.3);
1770      transform: scale(1.1);
1771      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
1772    }
1773
1774    .month-label {
1775      font-size: 20px;
1776      font-weight: 700;
1777      color: white;
1778      min-width: 200px;
1779      text-align: center;
1780      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1781      letter-spacing: -0.3px;
1782    }
1783
1784    .clock {
1785      font-size: 14px;
1786      color: rgba(255, 255, 255, 0.9);
1787      font-weight: 500;
1788      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1789    }
1790
1791    /* Calendar Grid - Desktop */
1792    .calendar-grid {
1793      display: grid;
1794      grid-template-columns: repeat(7, 1fr);
1795      gap: 12px;
1796      padding: 24px;
1797      animation: fadeInUp 0.6s ease-out;
1798    }
1799
1800    .day-header {
1801      background: rgba(255, 255, 255, 0.2);
1802      backdrop-filter: blur(10px);
1803      border-radius: 10px;
1804      padding: 12px;
1805      text-align: center;
1806      font-weight: 700;
1807      font-size: 14px;
1808      color: white;
1809      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
1810        inset 0 1px 0 rgba(255, 255, 255, 0.3);
1811      border: 1px solid rgba(255, 255, 255, 0.25);
1812      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1813    }
1814
1815    .day-cell {
1816      background: rgba(255, 255, 255, 0.15);
1817      backdrop-filter: blur(10px);
1818      border-radius: 12px;
1819      padding: 12px;
1820      min-height: 120px;
1821      cursor: pointer;
1822      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1823      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
1824        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1825      border: 1px solid rgba(255, 255, 255, 0.2);
1826      position: relative;
1827      overflow: hidden;
1828    }
1829
1830    .day-cell::before {
1831      content: "";
1832      position: absolute;
1833      inset: 0;
1834      border-radius: inherit;
1835      background: linear-gradient(
1836        180deg,
1837        rgba(255,255,255,0.15),
1838        rgba(255,255,255,0.05)
1839      );
1840      pointer-events: none;
1841    }
1842
1843    .day-cell:hover {
1844      transform: translateY(-4px);
1845      box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
1846      background: rgba(255, 255, 255, 0.25);
1847      border-color: rgba(255, 255, 255, 0.4);
1848    }
1849
1850    .day-cell.other-month {
1851      opacity: 0.4;
1852    }
1853
1854    .day-number {
1855      font-weight: 700;
1856      font-size: 16px;
1857      color: white;
1858      margin-bottom: 8px;
1859      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1860      position: relative;
1861      z-index: 1;
1862    }
1863
1864    .day-cell.today .day-number {
1865      background: rgba(255, 255, 255, 0.3);
1866      color: white;
1867      width: 32px;
1868      height: 32px;
1869      border-radius: 50%;
1870      display: flex;
1871      align-items: center;
1872      justify-content: center;
1873      font-size: 14px;
1874      box-shadow: 0 0 20px rgba(255, 255, 255, 0.5),
1875        inset 0 1px 0 rgba(255, 255, 255, 0.4);
1876      border: 2px solid rgba(255, 255, 255, 0.5);
1877      animation: pulse 2s infinite;
1878    }
1879
1880    @keyframes pulse {
1881      0%, 100% {
1882        box-shadow: 0 0 20px rgba(255, 255, 255, 0.5),
1883                    inset 0 1px 0 rgba(255, 255, 255, 0.4);
1884      }
1885      50% {
1886        box-shadow: 0 0 30px rgba(255, 255, 255, 0.7),
1887                    inset 0 1px 0 rgba(255, 255, 255, 0.6);
1888      }
1889    }
1890
1891    .node-item {
1892      display: block;
1893      margin: 4px 0;
1894      padding: 6px 10px;
1895      border-radius: 8px;
1896      background: rgba(255, 255, 255, 0.25);
1897      color: white;
1898      font-size: 12px;
1899      font-weight: 600;
1900      text-decoration: none;
1901      transition: all 0.2s;
1902      white-space: nowrap;
1903      overflow: hidden;
1904      text-overflow: ellipsis;
1905      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
1906      border: 1px solid rgba(255, 255, 255, 0.2);
1907      position: relative;
1908      z-index: 1;
1909    }
1910
1911    .node-item:hover {
1912      background: rgba(255, 255, 255, 0.35);
1913      transform: translateX(2px);
1914      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1915    }
1916
1917    .node-count {
1918      display: inline-block;
1919      margin-top: 4px;
1920      padding: 4px 8px;
1921      background: rgba(255, 255, 255, 0.2);
1922      color: white;
1923      font-size: 11px;
1924      font-weight: 700;
1925      border-radius: 12px;
1926      border: 1px solid rgba(255, 255, 255, 0.25);
1927      position: relative;
1928      z-index: 1;
1929    }
1930
1931    /* List View - Mobile */
1932    .calendar-list {
1933      display: none;
1934      padding: 16px;
1935      gap: 12px;
1936    }
1937
1938    .list-day {
1939      background: rgba(255, 255, 255, 0.15);
1940      backdrop-filter: blur(10px);
1941      border-radius: 12px;
1942      padding: 16px;
1943      cursor: pointer;
1944      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1945      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1),
1946        inset 0 1px 0 rgba(255, 255, 255, 0.25);
1947      border: 1px solid rgba(255, 255, 255, 0.2);
1948      position: relative;
1949      overflow: hidden;
1950    }
1951
1952    .list-day::before {
1953      content: "";
1954      position: absolute;
1955      inset: 0;
1956      border-radius: inherit;
1957      background: linear-gradient(
1958        180deg,
1959        rgba(255,255,255,0.15),
1960        rgba(255,255,255,0.05)
1961      );
1962      pointer-events: none;
1963    }
1964
1965    .list-day:hover {
1966      transform: translateY(-2px);
1967      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
1968      background: rgba(255, 255, 255, 0.2);
1969    }
1970
1971    .list-day-header {
1972      display: flex;
1973      justify-content: space-between;
1974      align-items: center;
1975      margin-bottom: 12px;
1976      position: relative;
1977      z-index: 1;
1978    }
1979
1980    .list-day-date {
1981      font-weight: 700;
1982      font-size: 16px;
1983      color: white;
1984      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
1985    }
1986
1987    .list-day-badge {
1988      padding: 4px 12px;
1989      background: rgba(255, 255, 255, 0.25);
1990      color: white;
1991      border-radius: 12px;
1992      font-size: 12px;
1993      font-weight: 700;
1994      border: 1px solid rgba(255, 255, 255, 0.3);
1995      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
1996    }
1997
1998    /* Day View */
1999    .day-view {
2000      padding: 24px;
2001      animation: fadeInUp 0.6s ease-out;
2002    }
2003
2004    .hour-row {
2005      display: flex;
2006      border-bottom: 1px solid rgba(255, 255, 255, 0.15);
2007      padding: 12px 0;
2008      min-height: 60px;
2009      transition: background 0.2s;
2010    }
2011
2012    .hour-row:hover {
2013      background: rgba(255, 255, 255, 0.08);
2014      border-radius: 8px;
2015    }
2016
2017    .hour-label {
2018      width: 80px;
2019      font-weight: 700;
2020      color: white;
2021      font-size: 14px;
2022      flex-shrink: 0;
2023      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
2024    }
2025
2026    .hour-content {
2027      flex: 1;
2028      display: flex;
2029      flex-direction: column;
2030      gap: 6px;
2031    }
2032
2033    .empty-state {
2034      text-align: center;
2035      padding: 60px 40px;
2036      color: rgba(255, 255, 255, 0.8);
2037    }
2038
2039    .empty-state-icon {
2040      font-size: 64px;
2041      margin-bottom: 16px;
2042      opacity: 0.6;
2043    }
2044
2045    /* Mobile Responsive */
2046    @media (max-width: 768px) {
2047      body {
2048        padding: 12px;
2049      }
2050
2051      .header {
2052        padding: 16px;
2053      }
2054
2055      .header-top {
2056        flex-direction: column;
2057        align-items: stretch;
2058      }
2059
2060      .nav-controls {
2061        justify-content: center;
2062      }
2063
2064      .clock {
2065        text-align: center;
2066      }
2067
2068      /* Switch to list view on mobile */
2069      .calendar-grid {
2070        display: none;
2071      }
2072
2073      .calendar-list {
2074        display: flex;
2075        flex-direction: column;
2076      }
2077
2078      .day-view {
2079        padding: 16px;
2080      }
2081
2082      .hour-label {
2083        width: 60px;
2084        font-size: 12px;
2085      }
2086
2087      .month-label {
2088        font-size: 18px;
2089      }
2090
2091      .nav-button {
2092        width: 36px;
2093        height: 36px;
2094        font-size: 16px;
2095      }
2096    }
2097  </style>
2098</head>
2099<body>
2100  <div class="container">
2101    <!-- Header -->
2102    <div class="glass-card header">
2103      <div class="header-top">
2104        <a href="/api/v1/root/${rootId}${queryString}" class="back-link" id="backLink">
2105          <- Back to Tree
2106        </a>
2107
2108        <div class="nav-controls">
2109          <button class="nav-button" id="prevMonth"><-</button>
2110          <div class="month-label" id="monthLabel"></div>
2111          <button class="nav-button" id="nextMonth">-></button>
2112        </div>
2113
2114        <div class="clock" id="clock"></div>
2115      </div>
2116    </div>
2117
2118    <!-- Calendar Container -->
2119    <div class="glass-card" id="calendarContainer"></div>
2120  </div>
2121
2122  <script>
2123    const params = new URLSearchParams(window.location.search);
2124    const dayMode = params.get("day");
2125    const calendarData = ${JSON.stringify(byDay)};
2126    const month = ${month};
2127    const year = ${year};
2128
2129    const monthNames = ["January","February","March","April","May","June","July","August","September","October","November","December"];
2130    const dayNames = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
2131
2132    const container = document.getElementById("calendarContainer");
2133    const monthLabel = document.getElementById("monthLabel");
2134    const backLink = document.getElementById("backLink");
2135
2136    // Clock
2137    function tick() {
2138      document.getElementById("clock").textContent = new Date().toLocaleString();
2139    }
2140    tick();
2141    setInterval(tick, 1000);
2142
2143    // Format hour for day view
2144    function formatHour(h) {
2145      if (h === 0) return "12 AM";
2146      if (h < 12) return h + " AM";
2147      if (h === 12) return "12 PM";
2148      return (h - 12) + " PM";
2149    }
2150
2151    // Render Day View
2152    function renderDayView(dayKey) {
2153      monthLabel.textContent = dayKey;
2154      backLink.textContent = "<- Back to Month";
2155      backLink.onclick = (e) => {
2156        e.preventDefault();
2157        const p = new URLSearchParams(window.location.search);
2158        p.delete("day");
2159        window.location.search = p.toString();
2160      };
2161
2162      const items = (calendarData[dayKey] || []).slice().sort(
2163        (a, b) => new Date(a.schedule) - new Date(b.schedule)
2164      );
2165
2166      const byHour = {};
2167      for (const item of items) {
2168        const d = new Date(item.schedule);
2169        const h = d.getHours();
2170        if (!byHour[h]) byHour[h] = [];
2171        byHour[h].push(item);
2172      }
2173
2174      let html = '<div class="day-view">';
2175
2176      if (items.length === 0) {
2177        html += '<div class="empty-state"><div class="empty-state-icon">📅</div><div>No scheduled items for this day</div></div>';
2178      } else {
2179        for (let h = 0; h < 24; h++) {
2180          html += \`
2181            <div class="hour-row">
2182              <div class="hour-label">\${formatHour(h)}</div>
2183              <div class="hour-content">
2184          \`;
2185
2186          (byHour[h] || []).forEach(item => {
2187            html += \`<a class="node-item" href="/api/v1/node/\${item.nodeId}/\0${queryString}">\${item.name}</a>\`;
2188          });
2189
2190          html += '</div></div>';
2191        }
2192      }
2193
2194      html += '</div>';
2195      container.innerHTML = html;
2196    }
2197
2198    // Render Month View
2199    function renderMonthView() {
2200      monthLabel.textContent = monthNames[month] + " " + year;
2201
2202      const firstDay = new Date(year, month, 1);
2203      const start = new Date(firstDay);
2204      start.setDate(1 - firstDay.getDay());
2205
2206      const today = new Date();
2207      const todayStr = today.toISOString().slice(0, 10);
2208
2209      const isMobile = window.innerWidth <= 768;
2210
2211      if (isMobile) {
2212        // List view for mobile
2213        let html = '<div class="calendar-list">';
2214
2215        const daysWithEvents = [];
2216        for (let i = 0; i < 42; i++) {
2217          const d = new Date(start);
2218          d.setDate(start.getDate() + i);
2219          const key = d.toISOString().slice(0, 10);
2220          const items = calendarData[key] || [];
2221
2222          if (items.length > 0 || d.getMonth() === month) {
2223            daysWithEvents.push({ date: d, key, items });
2224          }
2225        }
2226
2227        if (daysWithEvents.length === 0) {
2228          html += '<div class="empty-state"><div class="empty-state-icon">📅</div><div>No scheduled items this month</div></div>';
2229        } else {
2230          daysWithEvents.forEach(({ date, key, items }) => {
2231            const dayOfWeek = dayNames[date.getDay()];
2232            const isToday = key === todayStr;
2233
2234            html += \`
2235              <div class="list-day" onclick="goToDay('\${key}')">
2236                <div class="list-day-header">
2237                  <div class="list-day-date">
2238                    \${dayOfWeek}, \${monthNames[date.getMonth()]} \${date.getDate()}
2239                    \${isToday ? ' <span style="text-shadow: 0 0 10px rgba(255,255,255,0.8);">✨ Today</span>' : ''}
2240                  </div>
2241                  \${items.length > 0 ? \`<span class="list-day-badge">\${items.length} item\${items.length !== 1 ? 's' : ''}</span>\` : ''}
2242                </div>
2243            \`;
2244
2245            if (items.length > 0) {
2246              items.slice(0, 3).forEach(item => {
2247                html += \`<a class="node-item" href="/api/v1/node/\${item.nodeId}/\0${queryString}" onclick="event.stopPropagation()">\${item.name}</a>\`;
2248              });
2249
2250              if (items.length > 3) {
2251                html += \`<div class="node-count">+\${items.length - 3} more</div>\`;
2252              }
2253            }
2254
2255            html += '</div>';
2256          });
2257        }
2258
2259        html += '</div>';
2260        container.innerHTML = html;
2261      } else {
2262        // Grid view for desktop
2263        let html = '<div class="calendar-grid">';
2264
2265        // Day headers
2266        dayNames.forEach(day => {
2267          html += \`<div class="day-header">\${day}</div>\`;
2268        });
2269
2270        // Days
2271        for (let i = 0; i < 42; i++) {
2272          const d = new Date(start);
2273          d.setDate(start.getDate() + i);
2274          const key = d.toISOString().slice(0, 10);
2275          const items = calendarData[key] || [];
2276          const isOtherMonth = d.getMonth() !== month;
2277          const isToday = key === todayStr;
2278
2279          html += \`
2280            <div class="day-cell \${isOtherMonth ? 'other-month' : ''} \${isToday ? 'today' : ''}" onclick="goToDay('\${key}')">
2281              <div class="day-number">\${d.getDate()}</div>
2282          \`;
2283
2284          items.slice(0, 3).forEach(item => {
2285            html += \`<a class="node-item" href="/api/v1/node/\${item.nodeId}/\0${queryString}" onclick="event.stopPropagation()">\${item.name}</a>\`;
2286          });
2287
2288          if (items.length > 3) {
2289            html += \`<div class="node-count">+\${items.length - 3} more</div>\`;
2290          }
2291
2292          html += '</div>';
2293        }
2294
2295        html += '</div>';
2296        container.innerHTML = html;
2297      }
2298    }
2299
2300    // Navigate to day
2301    function goToDay(key) {
2302      const p = new URLSearchParams(window.location.search);
2303      p.set("day", key);
2304      window.location.search = p.toString();
2305    }
2306
2307    // Navigation buttons
2308    document.getElementById("prevMonth").onclick = () => {
2309      const p = new URLSearchParams(window.location.search);
2310
2311      if (dayMode) {
2312        const d = new Date(dayMode);
2313        d.setDate(d.getDate() - 1);
2314        p.set("day", d.toISOString().slice(0, 10));
2315      } else {
2316        let m = month - 1;
2317        let y = year;
2318        if (m < 0) { m = 11; y--; }
2319        p.set("month", m);
2320        p.set("year", y);
2321      }
2322
2323      window.location.search = p.toString();
2324    };
2325
2326    document.getElementById("nextMonth").onclick = () => {
2327      const p = new URLSearchParams(window.location.search);
2328
2329      if (dayMode) {
2330        const d = new Date(dayMode);
2331        d.setDate(d.getDate() + 1);
2332        p.set("day", d.toISOString().slice(0, 10));
2333      } else {
2334        let m = month + 1;
2335        let y = year;
2336        if (m > 11) { m = 0; y++; }
2337        p.set("month", m);
2338        p.set("year", y);
2339      }
2340
2341      window.location.search = p.toString();
2342    };
2343
2344    // Initial render
2345    if (dayMode) {
2346      renderDayView(dayMode);
2347    } else {
2348      renderMonthView();
2349    }
2350
2351    // Re-render on resize
2352    let resizeTimer;
2353    window.addEventListener('resize', () => {
2354      clearTimeout(resizeTimer);
2355      resizeTimer = setTimeout(() => {
2356        if (!dayMode) renderMonthView();
2357      }, 250);
2358    });
2359  </script>
2360</body>
2361</html>
2362`;
2363}
2364
2365
2366// ─────────────────────────────────────────────────────────────────────────
2367// 3. renderGateway
2368// ─────────────────────────────────────────────────────────────────────────
2369
2370export function renderGateway({ rootId, rootName, queryString, channels }) {
2371  const channelRows = channels.length === 0
2372    ? '<p style="color:rgba(255,255,255,0.5);font-size:0.9rem;">No channels configured yet. Add one below.</p>'
2373    : channels.map(function(ch) {
2374        var typeBadge = ch.type === "telegram" ? "TG"
2375          : ch.type === "discord" ? "DC"
2376          : "WEB";
2377        var typeColor = ch.type === "telegram" ? "rgba(0,136,204,0.8)"
2378          : ch.type === "discord" ? "rgba(88,101,242,0.8)"
2379          : "rgba(72,187,120,0.8)";
2380        var statusDot = ch.enabled
2381          ? '<span style="color:rgba(72,187,120,0.9);">&#9679;</span>'
2382          : '<span style="color:rgba(255,107,107,0.9);">&#9679;</span>';
2383        var notifList = (ch.notificationTypes || []).join(", ");
2384        var lastDispatch = ch.lastDispatchAt
2385          ? new Date(ch.lastDispatchAt).toLocaleString()
2386          : "Never";
2387        var lastErr = ch.lastError
2388          ? '<span style="color:rgba(255,107,107,0.8);font-size:0.75rem;">' + escapeHtml(ch.lastError) + '</span>'
2389          : '';
2390
2391        var dirLabel = ch.direction === "input-output" ? "I/O"
2392          : ch.direction === "input" ? "IN"
2393          : "OUT";
2394        var modeLabel = ch.mode === "read-write" ? "CHAT"
2395          : ch.mode === "read" ? "QUERY"
2396          : "PLACE";
2397
2398        return `
2399<div class="channel-row" data-id="${ch._id}" style="
2400  background:rgba(255,255,255,0.06);border-radius:12px;padding:16px;margin-bottom:12px;
2401  border:1px solid rgba(255,255,255,0.1);position:relative;">
2402  <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
2403    <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
2404      ${statusDot}
2405      <span style="font-weight:600;color:#fff;">${escapeHtml(ch.name)}</span>
2406      <span style="background:${typeColor};color:#fff;font-size:0.7rem;padding:2px 8px;border-radius:4px;font-weight:600;">${typeBadge}</span>
2407      <span style="background:rgba(255,255,255,0.12);color:rgba(255,255,255,0.7);font-size:0.65rem;padding:2px 6px;border-radius:4px;">${dirLabel}</span>
2408      <span style="background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.5);font-size:0.65rem;padding:2px 6px;border-radius:4px;">${modeLabel}</span>
2409    </div>
2410    <div style="display:flex;gap:8px;">
2411      <button onclick="testChannel('${ch._id}')" style="
2412        padding:4px 12px;border-radius:6px;border:1px solid rgba(115,111,230,0.4);
2413        background:rgba(115,111,230,0.15);color:rgba(200,200,255,0.9);font-size:0.8rem;cursor:pointer;">
2414        Test</button>
2415      <button onclick="toggleChannel('${ch._id}', ${!ch.enabled})" style="
2416        padding:4px 12px;border-radius:6px;border:1px solid rgba(255,179,71,0.4);
2417        background:rgba(255,179,71,0.1);color:rgba(255,179,71,0.9);font-size:0.8rem;cursor:pointer;">
2418        ${ch.enabled ? "Disable" : "Enable"}</button>
2419      <button onclick="deleteChannel('${ch._id}')" style="
2420        padding:4px 12px;border-radius:6px;border:1px solid rgba(255,107,107,0.4);
2421        background:rgba(255,107,107,0.1);color:rgba(255,107,107,0.8);font-size:0.8rem;cursor:pointer;">
2422        Delete</button>
2423    </div>
2424  </div>
2425  <div style="margin-top:8px;font-size:0.8rem;color:rgba(255,255,255,0.5);">
2426    ${ch.config?.displayIdentifier ? escapeHtml(ch.config.displayIdentifier) + ' &middot; ' : ''}
2427    ${notifList} &middot; Last sent: ${lastDispatch}
2428  </div>
2429  ${lastErr ? '<div style="margin-top:4px;">' + lastErr + '</div>' : ''}
2430</div>`;
2431      }).join('\n');
2432
2433  return `
2434<!DOCTYPE html>
2435<html lang="en">
2436<head>
2437  <meta charset="UTF-8">
2438  <meta name="viewport" content="width=device-width, initial-scale=1.0">
2439  <meta name="theme-color" content="#667eea">
2440  <title>Gateway -- ${escapeHtml(rootName)}</title>
2441  <style>
2442    ${baseStyles}
2443    body { color: #fff; }
2444    .content-card {
2445      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2446      backdrop-filter: blur(22px) saturate(140%);
2447      border-radius: 16px; padding: 28px;
2448      box-shadow: 0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.25);
2449      border: 1px solid rgba(255,255,255,0.28);
2450      margin-bottom: 24px; animation: fadeInUp 0.6s ease-out both;
2451    }
2452    .section-header h2 { color: #fff; font-size: 1.3rem; font-weight: 700; margin-bottom: 16px; }
2453    .back-nav {
2454      display: flex; gap: 12px; margin-bottom: 20px; animation: fadeInUp 0.5s ease-out;
2455    }
2456    .back-nav a {
2457      background: rgba(var(--glass-water-rgb), 0.25);
2458      backdrop-filter: blur(12px); border-radius: 10px; padding: 8px 16px;
2459      color: rgba(255,255,255,0.9); text-decoration: none; font-size: 0.85rem;
2460      border: 1px solid rgba(255,255,255,0.15); font-weight: 500;
2461    }
2462    .back-nav a:hover { background: rgba(var(--glass-water-rgb), 0.35); }
2463    label { display: block; font-size: 0.85rem; color: rgba(255,255,255,0.7); margin-bottom: 4px; margin-top: 12px; }
2464    input, select {
2465      width: 100%; padding: 10px 14px; border-radius: 8px;
2466      border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.08);
2467      color: #fff; font-size: 0.9rem; outline: none;
2468    }
2469    input::placeholder { color: rgba(255,255,255,0.7); }
2470    input:focus, select:focus { border-color: rgba(115,111,230,0.6); }
2471    select option { background: #3a3a6e; color: #fff; }
2472    .btn-primary {
2473      padding: 10px 20px; border-radius: 8px; border: 1px solid rgba(72,187,120,0.4);
2474      background: rgba(72,187,120,0.15); color: rgba(72,187,120,0.95);
2475      font-weight: 600; cursor: pointer; font-size: 0.9rem; margin-top: 16px;
2476    }
2477    .btn-primary:hover { background: rgba(72,187,120,0.25); }
2478    .checkbox-row {
2479      display: flex; align-items: center; gap: 8px; margin-top: 6px;
2480    }
2481    .checkbox-row input[type="checkbox"] { width: auto; }
2482    #gatewayStatus {
2483      display: none; font-size: 0.85rem; margin-top: 12px; padding: 8px 12px;
2484      border-radius: 8px;
2485    }
2486  </style>
2487</head>
2488<body>
2489<div class="container">
2490
2491  <div class="back-nav">
2492    <a href="/api/v1/root/${rootId}${queryString}">Back to Tree</a>
2493  </div>
2494
2495  <div class="content-card">
2496    <div class="section-header">
2497      <h2>Gateway Channels</h2>
2498    </div>
2499    <p style="color:rgba(255,255,255,0.6);font-size:0.85rem;margin-bottom:16px;">
2500      Output channels push notifications from this tree to external services.
2501    </p>
2502    <div id="channelList">
2503      ${channelRows}
2504    </div>
2505  </div>
2506
2507  <div class="content-card" style="animation-delay:0.1s;">
2508    <div class="section-header">
2509      <h2>Add Channel</h2>
2510    </div>
2511
2512    <label for="channelName">Channel Name</label>
2513    <input type="text" id="channelName" placeholder="e.g. My Discord Updates" maxlength="100" />
2514
2515    <label for="channelType">Type</label>
2516    <select id="channelType" onchange="updateFormFields()">
2517      <option value="telegram">Telegram</option>
2518      <option value="discord">Discord</option>
2519      <option value="webapp">Web Push (this browser)</option>
2520    </select>
2521
2522    <label for="channelDirection">Direction</label>
2523    <select id="channelDirection" onchange="updateFormFields()">
2524      <option value="output">Output (send notifications out)</option>
2525      <option value="input">Input (receive messages in)</option>
2526      <option value="input-output">Input/Output (bidirectional chat)</option>
2527    </select>
2528
2529    <label for="channelMode">Mode</label>
2530    <select id="channelMode">
2531      <option value="write">Place (scans tree, makes edits, no response)</option>
2532      <option value="read">Query (reads tree, responds, no edits)</option>
2533      <option value="read-write">Chat (reads tree, makes edits, responds)</option>
2534    </select>
2535
2536    <div id="telegramFields" style="margin-top:8px;">
2537      <label for="tgBotToken">Bot Token</label>
2538      <input type="password" id="tgBotToken" placeholder="123456:ABC-DEF..." />
2539      <label for="tgChatId">Chat ID</label>
2540      <input type="text" id="tgChatId" placeholder="-1001234567890" />
2541    </div>
2542
2543    <div id="discordOutputFields" style="display:none;">
2544      <label for="dcWebhookUrl">Webhook URL</label>
2545      <input type="password" id="dcWebhookUrl" placeholder="https://discord.com/api/webhooks/..." />
2546    </div>
2547
2548    <div id="discordInputFields" style="display:none;">
2549      <div style="background:rgba(255,255,255,0.05);border-radius:8px;padding:12px;margin-top:8px;margin-bottom:12px;border:1px solid rgba(255,255,255,0.1);">
2550        <div style="color:rgba(255,255,255,0.8);font-size:0.82rem;font-weight:600;margin-bottom:8px;">How to get your Discord bot details:</div>
2551        <ol style="color:rgba(255,255,255,0.6);font-size:0.8rem;margin:0;padding-left:18px;line-height:1.6;">
2552          <li>Go to <a href="https://discord.com/developers/applications" target="_blank" style="color:#1a1a1a;">Discord Developer Portal</a></li>
2553          <li>Create a New Application, then go to the <strong>Bot</strong> tab</li>
2554          <li>Click "Reset Token" to get your bot token and copy it</li>
2555          <li>Enable <strong>Message Content Intent</strong> under Privileged Gateway Intents</li>
2556          <li>Go to <strong>Installation</strong> tab, set integration type to <strong>Guild Install</strong></li>
2557          <li>Go to <strong>OAuth2</strong> tab, check <em>bot</em> scope, then under Bot Permissions check <strong>Read Message History</strong> and <strong>Send Messages</strong></li>
2558          <li>Copy the generated URL and open it to invite the bot to your server</li>
2559          <li>In Discord, right-click the channel you want, click "Copy Channel ID"<br/>(Enable Developer Mode in Discord Settings > Advanced if you don't see it)</li>
2560        </ol>
2561      </div>
2562      <label for="dcBotToken">Bot Token</label>
2563      <input type="password" id="dcBotToken" placeholder="Discord bot token..." />
2564      <label for="dcChannelId">Discord Channel ID</label>
2565      <input type="text" id="dcChannelId" placeholder="1234567890123456789" />
2566      <p style="color:rgba(255,179,71,0.7);font-size:0.8rem;margin-top:6px;">
2567        Discord input requires Standard, Premium, or God tier.
2568      </p>
2569    </div>
2570
2571    <div id="webappFields" style="display:none;">
2572      <p style="color:rgba(255,255,255,0.6);font-size:0.85rem;margin-top:12px;">
2573        Your browser will ask for notification permission when you add this channel.
2574      </p>
2575    </div>
2576
2577    <div id="outputNotifSection" style="display:none;">
2578      <label style="margin-top:16px;">Notification Types</label>
2579      <div class="checkbox-row">
2580        <input type="checkbox" id="notifSummary" checked /> <label for="notifSummary" style="margin:0;">Dream Summary</label>
2581      </div>
2582      <div class="checkbox-row">
2583        <input type="checkbox" id="notifThought" checked /> <label for="notifThought" style="margin:0;">Dream Thought</label>
2584      </div>
2585    </div>
2586
2587    <div id="inputConfigSection" style="display:none;">
2588      <label for="queueBehavior" style="margin-top:16px;">When Busy (2+ messages processing)</label>
2589      <select id="queueBehavior">
2590        <option value="respond">Respond with busy message</option>
2591        <option value="silent">Stay silent</option>
2592      </select>
2593    </div>
2594
2595    <button class="btn-primary" onclick="addChannel()">Add Channel</button>
2596    <div id="gatewayStatus"></div>
2597  </div>
2598
2599</div>
2600
2601<script>
2602var ROOT_ID = "${rootId}";
2603
2604function updateFormFields() {
2605  var type = document.getElementById("channelType").value;
2606  var direction = document.getElementById("channelDirection").value;
2607  var hasOutput = direction === "output" || direction === "input-output";
2608
2609  // Webapp can only be output
2610  var dirSelect = document.getElementById("channelDirection");
2611  var modeSelect = document.getElementById("channelMode");
2612  var modeLabel = document.querySelector('label[for="channelMode"]');
2613  if (type === "webapp") {
2614    dirSelect.value = "output";
2615    dirSelect.disabled = true;
2616    hasOutput = true;
2617  } else {
2618    dirSelect.disabled = false;
2619  }
2620
2621  // Mode only relevant for channels with input capability
2622  var hasInput = direction === "input" || direction === "input-output";
2623  modeSelect.style.display = hasInput ? "block" : "none";
2624  modeLabel.style.display = hasInput ? "block" : "none";
2625
2626  // Smart defaults per direction
2627  if (direction === "input") {
2628    modeSelect.value = "write";
2629  } else if (direction === "input-output") {
2630    modeSelect.value = "read-write";
2631  }
2632
2633  // Telegram: always show (same bot token + chat ID for input and output)
2634  document.getElementById("telegramFields").style.display = type === "telegram" ? "block" : "none";
2635
2636  // Discord: show different fields based on direction
2637  document.getElementById("discordOutputFields").style.display = (type === "discord" && !hasInput) ? "block" : "none";
2638  document.getElementById("discordInputFields").style.display = (type === "discord" && hasInput) ? "block" : "none";
2639
2640  // Webapp: only on output
2641  document.getElementById("webappFields").style.display = (type === "webapp" && hasOutput) ? "block" : "none";
2642
2643  // Notification types: only for output channels
2644  document.getElementById("outputNotifSection").style.display = hasOutput ? "block" : "none";
2645
2646  // Queue behavior: only for input channels
2647  document.getElementById("inputConfigSection").style.display = hasInput ? "block" : "none";
2648}
2649
2650function showStatus(msg, isError) {
2651  var el = document.getElementById("gatewayStatus");
2652  el.style.display = "block";
2653  el.style.background = isError ? "rgba(255,107,107,0.15)" : "rgba(72,187,120,0.15)";
2654  el.style.color = isError ? "rgba(255,107,107,0.95)" : "rgba(72,187,120,0.95)";
2655  el.textContent = msg;
2656  if (!isError) setTimeout(function() { el.style.display = "none"; }, 4000);
2657}
2658
2659async function getWebPushSubscription() {
2660  if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
2661    throw new Error("Push notifications are not supported in this browser");
2662  }
2663
2664  var permission = await Notification.requestPermission();
2665  if (permission !== "granted") {
2666    throw new Error("Notification permission denied");
2667  }
2668
2669  var reg = await navigator.serviceWorker.register("/sw.js");
2670  await navigator.serviceWorker.ready;
2671
2672  var vapidKey = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/vapid-key")
2673    .then(function(r) { return r.json(); });
2674
2675  if (!vapidKey.key) throw new Error("VAPID key not configured on server");
2676
2677  var sub = await reg.pushManager.subscribe({
2678    userVisibleOnly: true,
2679    applicationServerKey: urlBase64ToUint8Array(vapidKey.key),
2680  });
2681
2682  return sub.toJSON();
2683}
2684
2685function urlBase64ToUint8Array(base64String) {
2686  var padding = "=".repeat((4 - (base64String.length % 4)) % 4);
2687  var base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
2688  var rawData = atob(base64);
2689  var outputArray = new Uint8Array(rawData.length);
2690  for (var i = 0; i < rawData.length; i++) {
2691    outputArray[i] = rawData.charCodeAt(i);
2692  }
2693  return outputArray;
2694}
2695
2696async function addChannel() {
2697  var name = document.getElementById("channelName").value.trim();
2698  var type = document.getElementById("channelType").value;
2699  var direction = document.getElementById("channelDirection").value;
2700  var mode = document.getElementById("channelMode").value;
2701  var hasOutput = direction === "output" || direction === "input-output";
2702  var hasInput = direction === "input" || direction === "input-output";
2703
2704  if (!name) { showStatus("Please enter a channel name", true); return; }
2705
2706  var config = {};
2707
2708  try {
2709    if (type === "telegram") {
2710      // Telegram always needs bot token + chat ID
2711      var botToken = document.getElementById("tgBotToken").value.trim();
2712      var chatId = document.getElementById("tgChatId").value.trim();
2713      if (!botToken || !chatId) { showStatus("Bot token and chat ID are required", true); return; }
2714      config = { botToken: botToken, chatId: chatId };
2715    } else if (type === "discord") {
2716      if (hasInput) {
2717        // Discord input: bot token + channel ID
2718        var dcBotToken = document.getElementById("dcBotToken").value.trim();
2719        var dcChannelId = document.getElementById("dcChannelId").value.trim();
2720        if (!dcBotToken || !dcChannelId) { showStatus("Bot token and channel ID are required for Discord input", true); return; }
2721        config = { botToken: dcBotToken, discordChannelId: dcChannelId };
2722        // For input-output, optionally add webhook URL for output side
2723        if (hasOutput) {
2724          var webhookUrl = document.getElementById("dcWebhookUrl").value.trim();
2725          if (webhookUrl) config.webhookUrl = webhookUrl;
2726        }
2727      } else {
2728        // Discord output-only: webhook URL
2729        var webhookUrl = document.getElementById("dcWebhookUrl").value.trim();
2730        if (!webhookUrl) { showStatus("Webhook URL is required", true); return; }
2731        config = { webhookUrl: webhookUrl };
2732      }
2733    } else if (type === "webapp") {
2734      var subscription = await getWebPushSubscription();
2735      config = { subscription: subscription, displayIdentifier: navigator.userAgent.split(" ").pop() || "Browser" };
2736    }
2737  } catch (err) {
2738    showStatus(err.message, true);
2739    return;
2740  }
2741
2742  var notificationTypes = [];
2743  if (hasOutput) {
2744    if (document.getElementById("notifSummary").checked) notificationTypes.push("dream-summary");
2745    if (document.getElementById("notifThought").checked) notificationTypes.push("dream-thought");
2746    if (notificationTypes.length === 0 && direction === "output") { showStatus("Select at least one notification type", true); return; }
2747  }
2748
2749  var queueBehavior = hasInput ? document.getElementById("queueBehavior").value : "respond";
2750
2751  try {
2752    var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels", {
2753      method: "POST",
2754      credentials: "include",
2755      headers: { "Content-Type": "application/json" },
2756      body: JSON.stringify({ name: name, type: type, direction: direction, mode: mode, config: config, notificationTypes: notificationTypes, queueBehavior: queueBehavior }),
2757    });
2758    var data = await res.json();
2759    if (!res.ok) { showStatus(data.error || "Failed to add channel", true); return; }
2760    showStatus("Channel added successfully");
2761    setTimeout(function() { location.reload(); }, 1000);
2762  } catch (err) {
2763    showStatus("Network error: " + err.message, true);
2764  }
2765}
2766
2767async function testChannel(channelId) {
2768  try {
2769    var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels/" + channelId + "/test", {
2770      method: "POST",
2771      credentials: "include",
2772      headers: { "Content-Type": "application/json" },
2773    });
2774    var data = await res.json();
2775    if (!res.ok) { alert(data.error || "Test failed"); return; }
2776    alert("Test notification sent!");
2777  } catch (err) { alert("Network error"); }
2778}
2779
2780async function toggleChannel(channelId, enabled) {
2781  try {
2782    var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels/" + channelId, {
2783      method: "PUT",
2784      credentials: "include",
2785      headers: { "Content-Type": "application/json" },
2786      body: JSON.stringify({ enabled: enabled }),
2787    });
2788    if (res.ok) location.reload();
2789    else { var data = await res.json(); alert(data.error || "Failed"); }
2790  } catch (err) { alert("Network error"); }
2791}
2792
2793async function deleteChannel(channelId) {
2794  if (!confirm("Delete this channel?")) return;
2795  try {
2796    var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels/" + channelId, {
2797      method: "DELETE",
2798      credentials: "include",
2799      headers: { "Content-Type": "application/json" },
2800    });
2801    if (res.ok) location.reload();
2802    else { var data = await res.json(); alert(data.error || "Failed"); }
2803  } catch (err) { alert("Network error"); }
2804}
2805</script>
2806</body>
2807</html>
2808`;
2809}
2810
2811
2812// ─────────────────────────────────────────────────────────────────────────
2813// 4. renderValuesPage
2814// ─────────────────────────────────────────────────────────────────────────
2815
2816export function renderValuesPage({ nodeId, queryString, result }) {
2817  const rootNodeName = result.tree.nodeName || "Unknown";
2818
2819  const flatSummary =
2820    Object.entries(result.flat).length > 0
2821      ? Object.entries(result.flat)
2822          .sort(([, a], [, b]) => b - a)
2823          .map(
2824            ([key, value]) => `
2825            <div class="value-card">
2826              <div class="value-key">${key}</div>
2827              <div class="value-amount">${value.toLocaleString()}</div>
2828            </div>
2829          `,
2830          )
2831          .join("")
2832      : `<div class="empty-state-small">No values yet</div>`;
2833
2834  function renderTree(node, depth = 0) {
2835    const hasChildren = node.children && node.children.length > 0;
2836    const hasLocalValues =
2837      node.localValues && Object.keys(node.localValues).length > 0;
2838    const hasTotalValues =
2839      node.totalValues && Object.keys(node.totalValues).length > 0;
2840
2841    let localValuesHtml = "";
2842    if (hasLocalValues) {
2843      localValuesHtml = Object.entries(node.localValues)
2844        .map(
2845          ([k, v]) => `
2846            <div class="node-value-item" title="${k}: ${v.toLocaleString()}">
2847              <span class="value-key-small">${k}</span>
2848              <span class="value-amount-small">${v.toLocaleString()}</span>
2849            </div>
2850          `,
2851        )
2852        .join("");
2853    }
2854
2855    let totalValuesHtml = "";
2856    if (hasTotalValues) {
2857      totalValuesHtml = Object.entries(node.totalValues)
2858        .map(
2859          ([k, v]) => `
2860            <div class="node-value-item" title="${k}: ${v.toLocaleString()}">
2861              <span class="value-key-small">${k}</span>
2862              <span class="value-amount-small">${v.toLocaleString()}</span>
2863            </div>
2864          `,
2865        )
2866        .join("");
2867    }
2868
2869    const childrenHtml = hasChildren
2870      ? node.children.map((c) => renderTree(c, depth + 1)).join("")
2871      : "";
2872
2873    const valueCount = Math.max(
2874      Object.keys(node.localValues || {}).length,
2875      Object.keys(node.totalValues || {}).length,
2876    );
2877
2878    return `
2879        <div class="tree-node" data-depth="${depth}">
2880          <div class="tree-node-header ${hasChildren ? "has-children" : ""}">
2881            ${
2882              hasChildren
2883                ? `<button class="tree-toggle" onclick="toggleNode(this)" aria-label="Toggle children">▼</button>`
2884                : '<span class="tree-spacer"></span>'
2885            }
2886            <div class="tree-node-info">
2887              <a href="/api/v1/node/${
2888                node.nodeId
2889              }${queryString}" class="tree-node-name" title="${node.nodeName}">
2890                ${node.nodeName}
2891              </a>
2892              ${
2893                valueCount > 0
2894                  ? `<span class="value-count">${valueCount} value${
2895                      valueCount !== 1 ? "s" : ""
2896                    }</span>`
2897                  : ""
2898              }
2899            </div>
2900          </div>
2901
2902          ${
2903            hasLocalValues || hasTotalValues
2904              ? `
2905            <div class="tree-node-values local-values">
2906              ${
2907                localValuesHtml ||
2908                '<div class="empty-values">No local values</div>'
2909              }
2910            </div>
2911            <div class="tree-node-values total-values" style="display: none;">
2912              ${
2913                totalValuesHtml ||
2914                '<div class="empty-values">No total values</div>'
2915              }
2916            </div>
2917          `
2918              : ""
2919          }
2920
2921          ${
2922            hasChildren
2923              ? `
2924            <div class="tree-children">
2925              ${childrenHtml}
2926            </div>
2927          `
2928              : ""
2929          }
2930        </div>
2931      `;
2932  }
2933
2934  return `
2935<!DOCTYPE html>
2936<html lang="en">
2937<head>
2938  <meta charset="UTF-8">
2939  <meta name="viewport" content="width=device-width, initial-scale=1.0">
2940  <meta name="theme-color" content="#667eea">
2941  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2942  <title>Global Values - ${rootNodeName}</title>
2943  <style>
2944    ${baseStyles}
2945    ${backNavStyles}
2946    body { color: white; }
2947    .container { max-width: 1000px; }
2948
2949    /* Glass Card Base */
2950    .glass-card {
2951      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2952      backdrop-filter: blur(22px) saturate(140%);
2953      -webkit-backdrop-filter: blur(22px) saturate(140%);
2954      border-radius: 16px;
2955      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
2956        inset 0 1px 0 rgba(255, 255, 255, 0.25);
2957      border: 1px solid rgba(255, 255, 255, 0.28);
2958      position: relative;
2959      overflow: hidden;
2960    }
2961
2962    .glass-card::before {
2963      content: "";
2964      position: absolute;
2965      inset: 0;
2966      border-radius: inherit;
2967      background: linear-gradient(
2968        180deg,
2969        rgba(255,255,255,0.18),
2970        rgba(255,255,255,0.05)
2971      );
2972      pointer-events: none;
2973    }
2974
2975    /* Header */
2976    .header {
2977      padding: 28px;
2978      margin-bottom: 24px;
2979      animation: fadeInUp 0.6s ease-out;
2980      animation-delay: 0.1s;
2981      animation-fill-mode: both;
2982    }
2983
2984    .header h1 {
2985      font-size: 28px;
2986      font-weight: 700;
2987      color: white;
2988      margin-bottom: 8px;
2989      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
2990      letter-spacing: -0.5px;
2991    }
2992
2993    .header h1::before {
2994      content: '💎 ';
2995      font-size: 26px;
2996    }
2997
2998    .header-subtitle {
2999      font-size: 14px;
3000      color: rgba(255, 255, 255, 0.85);
3001      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
3002    }
3003
3004    /* Section */
3005    .section {
3006      padding: 28px;
3007      margin-bottom: 24px;
3008      animation: fadeInUp 0.6s ease-out;
3009      animation-fill-mode: both;
3010    }
3011
3012    .section:nth-child(3) { animation-delay: 0.2s; }
3013    .section:nth-child(4) { animation-delay: 0.3s; }
3014
3015    .section-title {
3016      font-size: 20px;
3017      font-weight: 600;
3018      color: white;
3019      margin-bottom: 20px;
3020      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3021      letter-spacing: -0.3px;
3022    }
3023
3024    /* Flat Summary Cards */
3025    .flat-grid {
3026      display: grid;
3027      grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
3028      gap: 16px;
3029    }
3030
3031    .value-card {
3032      background: rgba(255, 255, 255, 0.15);
3033      backdrop-filter: blur(10px);
3034      padding: 20px;
3035      border-radius: 12px;
3036      border: 1px solid rgba(255, 255, 255, 0.2);
3037      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
3038      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
3039        inset 0 1px 0 rgba(255, 255, 255, 0.25);
3040      position: relative;
3041      overflow: hidden;
3042    }
3043
3044    .value-card::before {
3045      content: "";
3046      position: absolute;
3047      inset: 0;
3048      border-radius: inherit;
3049      background: linear-gradient(
3050        180deg,
3051        rgba(255,255,255,0.15),
3052        rgba(255,255,255,0.05)
3053      );
3054      pointer-events: none;
3055    }
3056
3057    .value-card:hover {
3058      transform: translateY(-4px);
3059      box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
3060      background: rgba(255, 255, 255, 0.25);
3061      border-color: rgba(255, 255, 255, 0.4);
3062    }
3063
3064    .value-key {
3065      font-size: 14px;
3066      font-weight: 600;
3067      color: white;
3068      text-transform: uppercase;
3069      letter-spacing: 0.5px;
3070      margin-bottom: 8px;
3071      word-break: break-all;
3072      overflow-wrap: break-word;
3073      hyphens: auto;
3074      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
3075      position: relative;
3076      z-index: 1;
3077    }
3078
3079    .value-amount {
3080      font-size: 32px;
3081      font-weight: 700;
3082      color: white;
3083      font-family: 'SF Mono', Monaco, monospace;
3084      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
3085      position: relative;
3086      z-index: 1;
3087    }
3088
3089    /* Tree View */
3090    .tree-container {
3091      position: relative;
3092    }
3093
3094    .tree-node {
3095      position: relative;
3096      margin-bottom: 4px;
3097    }
3098
3099    .tree-node-header {
3100      display: flex;
3101      align-items: center;
3102      gap: 12px;
3103      padding: 12px 16px;
3104      background: rgba(255, 255, 255, 0.12);
3105      border-radius: 8px;
3106      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
3107      border: 1px solid rgba(255, 255, 255, 0.15);
3108    }
3109
3110    .tree-node-header:hover {
3111      background: rgba(255, 255, 255, 0.2);
3112      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
3113      transform: translateX(4px);
3114      border-color: rgba(255, 255, 255, 0.3);
3115    }
3116
3117    .tree-toggle {
3118      width: 24px;
3119      height: 24px;
3120      background: rgba(255, 255, 255, 0.2);
3121      border: 1px solid rgba(255, 255, 255, 0.25);
3122      border-radius: 6px;
3123      cursor: pointer;
3124      display: flex;
3125      align-items: center;
3126      justify-content: center;
3127      font-size: 12px;
3128      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
3129      flex-shrink: 0;
3130      color: white;
3131      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
3132    }
3133
3134    .tree-toggle:hover {
3135      background: rgba(255, 255, 255, 0.3);
3136      transform: scale(1.1);
3137      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
3138    }
3139
3140    .tree-toggle.collapsed {
3141      transform: rotate(-90deg);
3142    }
3143
3144    .tree-toggle.collapsed:hover {
3145      transform: rotate(-90deg) scale(1.1);
3146    }
3147
3148    .tree-spacer {
3149      width: 24px;
3150      flex-shrink: 0;
3151    }
3152
3153    .tree-node-info {
3154      flex: 1;
3155      display: flex;
3156      align-items: center;
3157      gap: 12px;
3158      flex-wrap: wrap;
3159      min-width: 0;
3160    }
3161
3162    .tree-node-name {
3163      font-size: 15px;
3164      font-weight: 600;
3165      color: white;
3166      text-decoration: none;
3167      transition: all 0.2s;
3168      overflow: hidden;
3169      text-overflow: ellipsis;
3170      white-space: nowrap;
3171      max-width: 300px;
3172      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
3173    }
3174
3175    .tree-node-name:hover {
3176      text-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
3177      transform: translateX(2px);
3178    }
3179
3180    .value-count {
3181      font-size: 12px;
3182      color: white;
3183      padding: 2px 8px;
3184      background: rgba(255, 255, 255, 0.15);
3185      border-radius: 10px;
3186      border: 1px solid rgba(255, 255, 255, 0.2);
3187      flex-shrink: 0;
3188      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
3189    }
3190
3191    .tree-node-values {
3192      display: grid;
3193      grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
3194      gap: 8px;
3195      margin: 12px 0 12px 36px;
3196      padding: 12px;
3197      background: rgba(255, 255, 255, 0.1);
3198      border-radius: 8px;
3199      border-left: 3px solid rgba(255, 255, 255, 0.4);
3200    }
3201
3202    .tree-node-values.total-values {
3203      border-left-color: rgba(16, 185, 129, 0.6);
3204    }
3205
3206    .node-value-item {
3207      display: flex;
3208      flex-direction: column;
3209      align-items: flex-start;
3210      gap: 4px;
3211      padding: 10px 12px;
3212      background: rgba(255, 255, 255, 0.15);
3213      border-radius: 6px;
3214      transition: all 0.2s;
3215      min-height: 60px;
3216      cursor: help;
3217      overflow: hidden;
3218      border: 1px solid rgba(255, 255, 255, 0.15);
3219    }
3220
3221    .node-value-item:hover {
3222      background: rgba(255, 255, 255, 0.25);
3223      transform: translateY(-2px);
3224      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
3225    }
3226
3227    .value-key-small {
3228      font-size: 11px;
3229      font-weight: 600;
3230      color: white;
3231      letter-spacing: 0.3px;
3232      overflow: hidden;
3233      text-overflow: ellipsis;
3234      white-space: nowrap;
3235      max-width: 100%;
3236      line-height: 1.3;
3237      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
3238    }
3239
3240    .value-amount-small {
3241      font-size: 16px;
3242      font-weight: 700;
3243      color: white;
3244      font-family: 'SF Mono', Monaco, monospace;
3245      overflow: hidden;
3246      text-overflow: ellipsis;
3247      white-space: nowrap;
3248      max-width: 100%;
3249      text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
3250    }
3251
3252    .empty-values {
3253      font-size: 13px;
3254      color: rgba(255, 255, 255, 0.6);
3255      font-style: italic;
3256      padding: 8px;
3257    }
3258
3259    .tree-children {
3260      margin-left: 20px;
3261      padding-left: 12px;
3262      border-left: 2px solid rgba(255, 255, 255, 0.2);
3263      margin-top: 4px;
3264      transition: all 0.3s;
3265    }
3266
3267    .tree-children.collapsed {
3268      display: none;
3269    }
3270
3271    /* Empty States */
3272    .empty-state-small {
3273      text-align: center;
3274      padding: 40px;
3275      color: rgba(255, 255, 255, 0.7);
3276      font-style: italic;
3277    }
3278
3279    /* Controls */
3280    .tree-controls {
3281      display: flex;
3282      gap: 12px;
3283      margin-bottom: 16px;
3284      flex-wrap: wrap;
3285    }
3286
3287    .btn-control {
3288      padding: 8px 16px;
3289      background: rgba(255, 255, 255, 0.15);
3290      border: 1px solid rgba(255, 255, 255, 0.2);
3291      border-radius: 980px;
3292      font-size: 14px;
3293      font-weight: 600;
3294      color: white;
3295      cursor: pointer;
3296      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
3297      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
3298        inset 0 1px 0 rgba(255, 255, 255, 0.2);
3299      position: relative;
3300      overflow: hidden;
3301    }
3302
3303    .btn-control::before {
3304      content: "";
3305      position: absolute;
3306      inset: -40%;
3307      background: radial-gradient(
3308        120% 60% at 0% 0%,
3309        rgba(255, 255, 255, 0.3),
3310        transparent 60%
3311      );
3312      opacity: 0;
3313      transform: translateX(-30%) translateY(-10%);
3314      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
3315      pointer-events: none;
3316    }
3317
3318    .btn-control:hover {
3319      background: rgba(255, 255, 255, 0.25);
3320      transform: translateY(-2px);
3321      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
3322    }
3323
3324    .btn-control:hover::before {
3325      opacity: 1;
3326      transform: translateX(30%) translateY(10%);
3327    }
3328
3329    .btn-control.active {
3330      background: rgba(255, 255, 255, 0.3);
3331      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2),
3332        inset 0 1px 0 rgba(255, 255, 255, 0.4);
3333    }
3334
3335    .controls-group {
3336      display: flex;
3337      gap: 8px;
3338      background: rgba(255, 255, 255, 0.1);
3339      padding: 4px;
3340      border-radius: 980px;
3341      border: 1px solid rgba(255, 255, 255, 0.2);
3342    }
3343
3344    .controls-group .btn-control {
3345      border: none;
3346      background: transparent;
3347      box-shadow: none;
3348    }
3349
3350    .controls-group .btn-control:hover {
3351      background: rgba(255, 255, 255, 0.2);
3352      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
3353    }
3354
3355    .controls-group .btn-control.active {
3356      background: rgba(255, 255, 255, 0.25);
3357      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
3358    }
3359
3360    /* Responsive */
3361    @media (max-width: 640px) {
3362      body {
3363        padding: 16px;
3364      }
3365
3366      .header,
3367      .section {
3368        padding: 20px;
3369      }
3370
3371      .header h1 {
3372        font-size: 24px;
3373      }
3374
3375      .flat-grid {
3376        grid-template-columns: 1fr;
3377      }
3378
3379      .tree-children {
3380        margin-left: 20px;
3381        padding-left: 12px;
3382      }
3383
3384      .tree-node-values {
3385        margin-left: 36px;
3386        grid-template-columns: 1fr;
3387      }
3388
3389      .back-nav {
3390        flex-direction: column;
3391      }
3392
3393      .back-link {
3394        justify-content: center;
3395      }
3396
3397      .value-amount {
3398        font-size: 24px;
3399      }
3400
3401      .tree-node-name {
3402        max-width: 200px;
3403      }
3404
3405      .tree-controls {
3406        flex-direction: column;
3407      }
3408
3409      .controls-group {
3410        width: 100%;
3411      }
3412
3413      .controls-group .btn-control {
3414        flex: 1;
3415        text-align: center;
3416      }
3417    }
3418
3419    @media (min-width: 641px) and (max-width: 1024px) {
3420      .container {
3421        max-width: 800px;
3422      }
3423
3424      .flat-grid {
3425        grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
3426      }
3427    }
3428  </style>
3429</head>
3430<body>
3431  <div class="container">
3432    <!-- Back Navigation -->
3433    <div class="back-nav">
3434      <a href="/api/v1/root/${nodeId}${queryString}" class="back-link">
3435        <- Back to Tree
3436      </a>
3437    </div>
3438
3439    <!-- Header -->
3440    <div class="glass-card header">
3441      <h1>Global Values</h1>
3442      <div class="header-subtitle">Cumulative values across all nodes</div>
3443    </div>
3444
3445    <!-- Flat Summary -->
3446    <div class="glass-card section">
3447      <div class="section-title">Total Summary</div>
3448      <div class="flat-grid">
3449        ${flatSummary}
3450      </div>
3451    </div>
3452
3453    <!-- Tree View -->
3454    <div class="glass-card section">
3455      <div class="tree-controls">
3456        <div class="controls-group">
3457          <button class="btn-control active" id="showLocalBtn" onclick="showLocalValues()">
3458            Local Values
3459          </button>
3460          <button class="btn-control" id="showTotalBtn" onclick="showTotalValues()">
3461            Total Values
3462          </button>
3463        </div>
3464        <button class="btn-control" onclick="expandAll()">Expand All</button>
3465        <button class="btn-control" onclick="collapseAll()">Collapse All</button>
3466      </div>
3467      <div class="tree-container">
3468        ${renderTree(result.tree)}
3469      </div>
3470    </div>
3471  </div>
3472
3473  <script>
3474    let currentView = 'local';
3475
3476    function showLocalValues() {
3477      currentView = 'local';
3478      document.getElementById('showLocalBtn').classList.add('active');
3479      document.getElementById('showTotalBtn').classList.remove('active');
3480
3481      document.querySelectorAll('.local-values').forEach(el => {
3482        el.style.display = 'grid';
3483      });
3484      document.querySelectorAll('.total-values').forEach(el => {
3485        el.style.display = 'none';
3486      });
3487    }
3488
3489    function showTotalValues() {
3490      currentView = 'total';
3491      document.getElementById('showTotalBtn').classList.add('active');
3492      document.getElementById('showLocalBtn').classList.remove('active');
3493
3494      document.querySelectorAll('.local-values').forEach(el => {
3495        el.style.display = 'none';
3496      });
3497      document.querySelectorAll('.total-values').forEach(el => {
3498        el.style.display = 'grid';
3499      });
3500    }
3501
3502    function toggleNode(button) {
3503      button.classList.toggle('collapsed');
3504      const treeNode = button.closest('.tree-node');
3505      const children = treeNode.querySelector('.tree-children');
3506      if (children) {
3507        children.classList.toggle('collapsed');
3508      }
3509    }
3510
3511    function expandAll() {
3512      document.querySelectorAll('.tree-toggle').forEach(btn => {
3513        btn.classList.remove('collapsed');
3514      });
3515      document.querySelectorAll('.tree-children').forEach(children => {
3516        children.classList.remove('collapsed');
3517      });
3518    }
3519
3520    function collapseAll() {
3521      document.querySelectorAll('.tree-toggle').forEach(btn => {
3522        btn.classList.add('collapsed');
3523      });
3524      document.querySelectorAll('.tree-children').forEach(children => {
3525        children.classList.add('collapsed');
3526      });
3527    }
3528  </script>
3529</body>
3530</html>
3531    `;
3532}
3533
1/* ─────────────────────────────────────────────── */
2/* HTML renderers for user routes                  */
3/* ─────────────────────────────────────────────── */
4
5import path from "path";
6import mime from "mime-types";
7import { getLandUrl } from "../../../canopy/identity.js";
8import { getUserMeta } from "../../../core/tree/userMetadata.js";
9import { baseStyles, backNavStyles, glassHeaderStyles, glassCardStyles, emptyStateStyles, responsiveBase } from "./baseStyles.js";
10import {
11  esc, escapeHtml, truncate, formatTime, formatDuration,
12  actionColorClass, actionColorHex, actionLabel,
13  renderMedia as _renderMedia,
14  groupIntoChains, modeLabel, sourceLabel,
15} from "./utils.js";
16
17// user.js always renders immediately (no lazy loading)
18const renderMedia = (fileUrl, mimeType) => _renderMedia(fileUrl, mimeType, { lazy: false });
19
20
21// ═══════════════════════════════════════════════════════════════════
22// 1. Profile Page - GET /user/:userId
23// ═══════════════════════════════════════════════════════════════════
24export function renderUserProfile({ userId, user, roots, profileType, energy, extraEnergy, queryString, resetTimeLabel, storageUsedKB }) {
25  const safeUsername = escapeHtml(user.username);
26    return (`
27<!DOCTYPE html>
28<html lang="en">
29<head>
30  <meta charset="UTF-8">
31  <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual">
32  <meta name="theme-color" content="#667eea">
33  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
34  <title>@${safeUsername} — Profile</title>
35  <style>
36${baseStyles}
37${responsiveBase}
38
39    html { overflow-y: auto; height: 100%; }
40
41    /* Glass Card Base */
42    .glass-card {
43      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
44      backdrop-filter: blur(22px) saturate(140%);
45      -webkit-backdrop-filter: blur(22px) saturate(140%);
46      border-radius: 16px;
47      padding: 32px;
48      margin-bottom: 24px;
49      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
50        inset 0 1px 0 rgba(255, 255, 255, 0.25);
51      border: 1px solid rgba(255, 255, 255, 0.28);
52      position: relative;
53      overflow: hidden;
54      animation: fadeInUp 0.6s ease-out both;
55    }
56
57    /* Header Section */
58    .header {
59      animation-delay: 0.1s;
60    }
61
62    .user-info h1 {
63      font-size: 32px;
64      font-weight: 700;
65      color: white;
66      margin-bottom: 16px;
67      letter-spacing: -0.5px;
68      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
69    }
70
71    .user-info h1::before {
72      content: '👤 ';
73      font-size: 28px;
74    }
75
76    /* User Meta Info */
77    .user-meta {
78      display: flex;
79      gap: 12px;
80      align-items: center;
81      margin-bottom: 16px;
82      flex-wrap: wrap;
83    }
84.send-button.loading {
85  pointer-events: none;
86  opacity: 0.9;
87}
88
89.send-progress {
90  position: absolute;
91  left: 0;
92  top: 0;
93  height: 100%;
94  width: 0%;
95  background: linear-gradient(
96    90deg,
97    rgba(255,255,255,0.25),
98    rgba(255,255,255,0.6),
99    rgba(255,255,255,0.25)
100  );
101  transition: width 0.2s ease;
102  pointer-events: none;
103}
104
105    .plan-badge {
106      padding: 8px 16px;
107      border-radius: 980px;
108      font-weight: 600;
109      font-size: 13px;
110      display: inline-flex;
111      align-items: center;
112      gap: 6px;
113      background: rgba(255, 255, 255, 0.9);
114      color: #667eea;
115      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
116    }
117.plan-basic {
118  background: rgba(255, 255, 255, 0.9);
119  color: #64748b;
120}
121
122/* STANDARD */
123.plan-standard {
124  background: linear-gradient(135deg, #60a5fa, #2563eb);
125  color: white;
126}
127
128/* PREMIUM */
129.plan-premium {
130  background: linear-gradient(135deg, #a855f7, #7c3aed);
131  color: white;
132}
133
134/* GOD ✨ */
135.plan-god {
136  background: linear-gradient(
137    135deg,
138    #facc15,
139    #f59e0b,
140    #eab308
141  );
142  color: #3a2e00;
143  text-shadow: 0 1px 1px rgba(255, 255, 255, 0.6);
144  box-shadow:
145    0 0 20px rgba(250, 204, 21, 0.6),
146    0 6px 24px rgba(234, 179, 8, 0.5);
147  border: 1px solid rgba(255, 215, 0, 0.9);
148}
149    .meta-item {
150      display: inline-flex;
151      align-items: center;
152      gap: 6px;
153      padding: 8px 14px;
154      background: rgba(255, 255, 255, 0.2);
155      backdrop-filter: blur(10px);
156      border-radius: 980px;
157      font-size: 13px;
158      font-weight: 500;
159      color: white;
160      border: 1px solid rgba(255, 255, 255, 0.3);
161    }
162
163    .storage-toggle-btn {
164      padding: 2px 8px;
165      margin-left: 4px;
166      border-radius: 6px;
167      border: 1px solid rgba(255, 255, 255, 0.4);
168      background: rgba(255, 255, 255, 0.2);
169      font-size: 11px;
170      font-weight: 600;
171      cursor: pointer;
172      transition: all 0.2s;
173      color: white;
174    }
175
176    .storage-toggle-btn:hover {
177      background: rgba(255, 255, 255, 0.3);
178      transform: scale(1.05);
179    }
180
181    .logout-btn {
182      padding: 8px 16px;
183      border-radius: 980px;
184      border: 1px solid rgba(255, 255, 255, 0.3);
185      background: rgba(239, 68, 68, 0.3);
186      backdrop-filter: blur(10px);
187      color: white;
188      font-weight: 600;
189      font-size: 13px;
190      cursor: pointer;
191      transition: all 0.3s;
192      box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
193    }
194
195    .logout-btn:hover {
196      background: rgba(239, 68, 68, 0.5);
197      transform: translateY(-2px);
198      box-shadow: 0 6px 20px rgba(239, 68, 68, 0.3);
199    }
200
201    .header { position: relative; }
202
203    .basic-btn {
204      position: absolute;
205      top: 16px;
206      right: 16px;
207      padding: 8px 16px;
208      border-radius: 980px;
209      border: 1px solid rgba(255, 255, 255, 0.3);
210      background: rgba(16, 185, 129, 0.3);
211      backdrop-filter: blur(10px);
212      color: white;
213      font-weight: 600;
214      font-size: 13px;
215      cursor: pointer;
216      transition: all 0.3s;
217      box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
218      text-decoration: none;
219    }
220
221    .basic-btn:hover {
222      background: rgba(16, 185, 129, 0.5);
223      transform: translateY(-2px);
224      box-shadow: 0 6px 20px rgba(16, 185, 129, 0.3);
225    }
226
227    /* User ID */
228    .user-id-container {
229      display: flex;
230      align-items: center;
231      gap: 8px;
232      padding: 12px 16px;
233      background: rgba(255, 255, 255, 0.15);
234      backdrop-filter: blur(10px);
235      border-radius: 10px;
236      margin-top: 12px;
237      border: 1px solid rgba(255, 255, 255, 0.2);
238    }
239
240    .user-id-container code {
241      flex: 1;
242      background: transparent;
243      padding: 0;
244      font-size: 13px;
245      font-family: 'SF Mono', Monaco, monospace;
246      color: white;
247      font-weight: 600;
248      word-break: break-all;
249    }
250
251    #copyNodeIdBtn {
252      background: rgba(255, 255, 255, 0.2);
253      border: 1px solid rgba(255, 255, 255, 0.3);
254      cursor: pointer;
255      padding: 6px 10px;
256      border-radius: 6px;
257      font-size: 16px;
258      transition: all 0.2s;
259      flex-shrink: 0;
260    }
261
262    #copyNodeIdBtn:hover {
263      background: rgba(255, 255, 255, 0.3);
264      transform: scale(1.1);
265    }
266
267    /* Raw Ideas Capture - Enhanced with glow */
268    .raw-ideas-section {
269      animation-delay: 0.2s;
270      box-shadow: 
271        0 20px 60px rgba(16, 185, 129, 0.3),
272        0 0 40px rgba(16, 185, 129, 0.2),
273        inset 0 1px 0 rgba(255, 255, 255, 0.25);
274    }
275
276    .raw-ideas-section::after {
277      content: '';
278      position: absolute;
279      top: -50%;
280      left: -50%;
281      width: 200%;
282      height: 200%;
283      background: radial-gradient(
284        circle,
285        rgba(16, 185, 129, 0.15) 0%,
286        transparent 70%
287      );
288      animation: pulse 8s ease-in-out infinite;
289      pointer-events: none;
290    }
291
292    @keyframes pulse {
293      0%, 100% {
294        transform: scale(1) rotate(0deg);
295        opacity: 0.5;
296      }
297      50% {
298        transform: scale(1.1) rotate(180deg);
299        opacity: 0.8;
300      }
301    }
302
303    .raw-ideas-section h2 {
304      font-size: 20px;
305      font-weight: 600;
306      color: white;
307      margin-bottom: 20px;
308      position: relative;
309      z-index: 1;
310      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2),
311        0 0 20px rgba(16, 185, 129, 0.4);
312    }
313
314    .raw-ideas-section h2::before {
315      content: '💡 ';
316      font-size: 20px;
317    }
318
319    .raw-idea-form {
320      display: flex;
321      flex-direction: column;
322      gap: 16px;
323      position: relative;
324      z-index: 1;
325    }
326
327    #rawIdeaInput {
328      width: 100%;
329      padding: 16px 20px;
330      font-size: 16px;
331      line-height: 1.6;
332      border-radius: 12px;
333      border: 2px solid rgba(255, 255, 255, 0.4);
334      background: rgba(255, 255, 255, 0.25);
335      backdrop-filter: blur(20px) saturate(150%);
336      -webkit-backdrop-filter: blur(20px) saturate(150%);
337      font-family: inherit;
338      resize: vertical;
339      min-height: 80px;
340      max-height: 400px;
341      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
342      box-shadow: 
343        0 4px 20px rgba(0, 0, 0, 0.15),
344        inset 0 1px 0 rgba(255, 255, 255, 0.4);
345      color: #5044c9;
346      font-weight: 600;
347      text-shadow: 
348        0 0 12px rgba(102, 126, 234, 0.7),
349        0 0 20px rgba(102, 126, 234, 0.4),
350        0 1px 3px rgba(255, 255, 255, 1),
351        0 2px 8px rgba(80, 68, 201, 0.5);
352      letter-spacing: 0.3px;
353    }
354
355    #rawIdeaInput:focus {
356      outline: none;
357      border-color: rgba(102, 126, 234, 0.6);
358      backdrop-filter: blur(28px) saturate(170%);
359      -webkit-backdrop-filter: blur(28px) saturate(170%);
360      box-shadow: 
361        0 0 0 4px rgba(102, 126, 234, 0.25),
362        0 0 40px rgba(102, 126, 234, 0.5),
363        0 8px 30px rgba(102, 126, 234, 0.3),
364        inset 0 1px 0 rgba(255, 255, 255, 0.6);
365      transform: translateY(-2px);
366    }
367    
368    #rawIdeaInput:focus::placeholder {
369      color: rgba(80, 68, 201, 0.4);
370    }
371
372    #rawIdeaInput::placeholder {
373      color: rgba(80, 68, 201, 0.4);
374      font-weight: 400;
375      text-shadow: 0 0 6px rgba(102, 126, 234, 0.25);
376    }
377
378    #rawIdeaInput:disabled {
379      opacity: 0.4;
380      cursor: not-allowed;
381      background: rgba(255, 255, 255, 0.1);
382      transform: none;
383    }
384
385    #rawIdeaInput:disabled::placeholder {
386      color: rgba(80, 68, 201, 0.25);
387    }
388
389    /* Character counter */
390    .char-counter {
391      display: flex;
392      justify-content: flex-end;
393      align-items: center;
394      margin-top: -8px;
395      margin-bottom: 8px;
396      font-size: 12px;
397      color: rgba(255, 255, 255, 0.6);
398      font-weight: 500;
399      transition: color 0.2s;
400    }
401
402    .char-counter.warning {
403      color: rgba(255, 193, 7, 0.9);
404    }
405
406    .char-counter.danger {
407      color: rgba(239, 68, 68, 0.9);
408      font-weight: 600;
409    }
410
411    .char-counter.disabled {
412      opacity: 0.4;
413    }
414
415    /* Energy display */
416    .energy-display {
417      display: inline-flex;
418      align-items: center;
419      gap: 4px;
420      margin-left: 10px;
421      padding: 2px 8px;
422      background: rgba(255, 215, 79, 0.2);
423      border: 1px solid rgba(255, 215, 79, 0.3);
424      border-radius: 10px;
425      font-size: 11px;
426      font-weight: 600;
427      color: rgba(255, 215, 79, 1);
428      transition: all 0.2s;
429    }
430
431    .energy-display:empty {
432      display: none;
433    }
434
435    .energy-display.file-energy {
436      background: rgba(255, 220, 100, 0.9);
437      border-color: rgba(255, 200, 50, 1);
438      color: #1a1a1a;
439      font-size: 13px;
440      font-weight: 700;
441      padding: 4px 12px;
442      box-shadow: 0 2px 8px rgba(255, 200, 50, 0.4);
443    }
444
445    /* File selected badge */
446    .file-selected-badge {
447      display: none;
448      align-items: center;
449      gap: 6px;
450      padding: 6px 12px;
451      background: rgba(255, 255, 255, 0.15);
452      border: 1px solid rgba(255, 255, 255, 0.25);
453      border-radius: 20px;
454      font-size: 12px;
455      font-weight: 500;
456      color: white;
457    }
458
459    .file-selected-badge.visible {
460      display: inline-flex;
461    }
462
463    .file-selected-badge .file-name {
464      max-width: 120px;
465      overflow: hidden;
466      text-overflow: ellipsis;
467      white-space: nowrap;
468    }
469
470    .file-selected-badge .clear-file {
471      background: rgba(255, 255, 255, 0.2);
472      border: none;
473      border-radius: 50%;
474      width: 18px;
475      height: 18px;
476      display: flex;
477      align-items: center;
478      justify-content: center;
479      cursor: pointer;
480      font-size: 10px;
481      color: white;
482      transition: all 0.2s;
483    }
484
485    .file-selected-badge .clear-file:hover {
486      background: rgba(239, 68, 68, 0.4);
487    }
488
489    .form-actions {
490      display: flex;
491      justify-content: space-between;
492      align-items: center;
493      gap: 12px;
494      flex-wrap: wrap;
495    }
496
497    .file-input-wrapper {
498      flex: 1;
499      min-width: 180px;
500      display: flex;
501      align-items: center;
502      gap: 8px;
503    }
504
505    input[type="file"] {
506      font-size: 13px;
507      color: rgba(255, 255, 255, 0.9);
508      cursor: pointer;
509    }
510
511    input[type="file"]::file-selector-button {
512      padding: 8px 16px;
513      border-radius: 980px;
514      border: 1px solid rgba(255, 255, 255, 0.3);
515      background: rgba(255, 255, 255, 0.2);
516      backdrop-filter: blur(10px);
517      color: white;
518      cursor: pointer;
519      font-size: 13px;
520      font-weight: 600;
521      transition: all 0.2s;
522      margin-right: 10px;
523    }
524
525    input[type="file"]::file-selector-button:hover {
526      background: rgba(255, 255, 255, 0.3);
527      transform: translateY(-1px);
528    }
529
530    input[type="file"].hidden-input {
531      display: none;
532    }
533
534    .send-button {
535      padding: 14px 32px;
536      font-size: 16px;
537      font-weight: 600;
538      border-radius: 980px;
539      border: 1px solid rgba(255, 255, 255, 0.3);
540      background: rgba(16, 185, 129, 0.9);
541      backdrop-filter: blur(10px);
542      color: white;
543      cursor: pointer;
544      transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
545      box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);
546      white-space: nowrap;
547      position: relative;
548      overflow: hidden;
549    }
550
551    .send-button::before {
552      content: "";
553      position: absolute;
554      inset: -40%;
555      background: radial-gradient(
556        120% 60% at 0% 0%,
557        rgba(255, 255, 255, 0.35),
558        transparent 60%
559      );
560      opacity: 0;
561      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
562      pointer-events: none;
563    }
564
565    .send-button:hover {
566      background: rgba(16, 185, 129, 1);
567      transform: translateY(-2px);
568      box-shadow: 0 6px 25px rgba(16, 185, 129, 0.5);
569    }
570
571    .send-button:hover::before {
572      opacity: 1;
573      transform: translateX(30%) translateY(10%);
574    }
575
576    /* Navigation Section */
577    .nav-section {
578      animation-delay: 0.3s;
579    }
580
581    .nav-section h2 {
582      font-size: 18px;
583      font-weight: 600;
584      color: white;
585      margin-bottom: 20px;
586      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
587    }
588
589    .nav-links {
590      list-style: none;
591      display: grid;
592      grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
593      gap: 12px;
594    }
595
596    .nav-links a {
597      display: block;
598      padding: 14px 18px;
599      background: rgba(255, 255, 255, 0.2);
600      backdrop-filter: blur(10px);
601      border-radius: 980px;
602      color: white;
603      text-decoration: none;
604      font-weight: 600;
605      font-size: 14px;
606      transition: all 0.3s;
607      border: 1px solid rgba(255, 255, 255, 0.3);
608      text-align: center;
609      position: relative;
610      overflow: hidden;
611    }
612
613    .nav-links a::before {
614      content: "";
615      position: absolute;
616      inset: -40%;
617      background: linear-gradient(
618        120deg,
619        transparent 40%,
620        rgba(255, 255, 255, 0.25),
621        transparent 60%
622      );
623      opacity: 0;
624      transform: translateX(-100%);
625      transition: opacity 0.3s, transform 0.6s;
626    }
627
628    .nav-links a:hover {
629      background: rgba(255, 255, 255, 0.3);
630      transform: translateY(-2px);
631      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
632    }
633
634    .nav-links a:hover::before {
635      opacity: 1;
636      transform: translateX(100%);
637    }
638
639    /* Roots Section */
640    .roots-section {
641      animation-delay: 0.4s;
642    }
643
644    .roots-section h2 {
645      font-size: 20px;
646      font-weight: 600;
647      color: white;
648      margin-bottom: 20px;
649      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
650    }
651
652    .roots-section h2::before {
653      content: '🌳 ';
654      font-size: 20px;
655    }
656
657    .roots-list {
658      list-style: none;
659      margin-bottom: 24px;
660    }
661
662    .roots-list li {
663      margin-bottom: 10px;
664    }
665
666    .roots-list a {
667      display: block;
668      padding: 14px 18px;
669      background: rgba(255, 255, 255, 0.2);
670      backdrop-filter: blur(10px);
671      border-radius: 12px;
672      color: white;
673      text-decoration: none;
674      font-weight: 500;
675      font-size: 15px;
676      transition: all 0.3s;
677      border: 1px solid rgba(255, 255, 255, 0.25);
678      position: relative;
679      overflow: hidden;
680    }
681
682    .roots-list a::before {
683      content: "";
684      position: absolute;
685      inset: -40%;
686      background: linear-gradient(
687        120deg,
688        transparent 40%,
689        rgba(255, 255, 255, 0.25),
690        transparent 60%
691      );
692      opacity: 0;
693      transform: translateX(-100%);
694      transition: opacity 0.3s, transform 0.6s;
695    }
696
697    .roots-list a:hover {
698      background: rgba(255, 255, 255, 0.3);
699      transform: translateX(4px);
700      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
701    }
702
703    .roots-list a:hover::before {
704      opacity: 1;
705      transform: translateX(100%);
706    }
707
708    .roots-list em {
709      color: rgba(255, 255, 255, 0.7);
710      font-style: italic;
711      display: block;
712      padding: 20px;
713      text-align: center;
714    }
715
716    /* Create Root Form */
717    .create-root-form {
718      display: flex;
719      gap: 12px;
720      align-items: stretch;
721    }
722
723    .create-root-form input[type="text"] {
724      flex: 1;
725      padding: 14px 18px;
726      font-size: 15px;
727      border-radius: 12px;
728      border: 2px solid rgba(255, 255, 255, 0.3);
729      background: rgba(255, 255, 255, 0.9);
730      font-family: inherit;
731      transition: all 0.2s;
732    }
733
734    .create-root-form input[type="text"]:focus {
735      outline: none;
736      border-color: white;
737      background: white;
738      box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.2),
739        0 4px 20px rgba(0, 0, 0, 0.1);
740    }
741
742    .create-root-button {
743      padding: 14px 20px;
744      font-size: 24px;
745      line-height: 1;
746      border-radius: 12px;
747      border: 1px solid rgba(255, 255, 255, 0.3);
748      background: rgba(255, 255, 255, 0.25);
749      backdrop-filter: blur(10px);
750      color: white;
751      cursor: pointer;
752      transition: all 0.3s;
753      font-weight: 300;
754      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
755      position: relative;
756      overflow: hidden;
757    }
758
759    .create-root-button::before {
760      content: "";
761      position: absolute;
762      inset: -40%;
763      background: radial-gradient(
764        120% 60% at 0% 0%,
765        rgba(255, 255, 255, 0.35),
766        transparent 60%
767      );
768      opacity: 0;
769      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
770      pointer-events: none;
771    }
772
773    .create-root-button:hover {
774      background: rgba(255, 255, 255, 0.35);
775      transform: scale(1.05) translateY(-2px);
776      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
777    }
778
779    .create-root-button:hover::before {
780      opacity: 1;
781      transform: translateX(30%) translateY(10%);
782    }
783
784    /* Responsive Design */
785
786
787      a {
788text-decoration: none;
789        color: inherit;}
790  </style>
791</head>
792<body>
793  <div class="container">
794    <!-- Header -->
795    <div class="glass-card header">
796      <a href="/chat" target="_top" class="basic-btn">Back to Basic Chat</a>
797      <div class="user-info">
798       <a href="/api/v1/user/${userId}/energy${queryString}">
799        <h1>@${safeUsername}</h1> </a>
800
801        <div class="user-meta">
802   <a href="/api/v1/user/${userId}/energy${queryString}">
803  <span class="plan-badge plan-${profileType}">
804  ${profileType === "god" ? "👑 " : ""}
805  ${profileType.charAt(0).toUpperCase() + profileType.slice(1)} Plan
806</span></a>
807
808          <span class="meta-item">
809            <a href="/api/v1/user/${userId}/energy${queryString}">⚡ ${(energy?.amount ?? 0) + (extraEnergy?.amount ?? 0)} · resets ${resetTimeLabel}
810</a>
811          </span>
812
813          <span class="meta-item">
814            💾 <span id="storageValue"></span>
815            <button
816              id="storageToggle"
817              class="storage-toggle-btn"
818              data-storage-kb="${storageUsedKB}"
819            >
820              MB
821            </button>
822            used
823          </span>
824
825          <button id="logoutBtn" class="logout-btn">
826            Log out
827          </button>
828        </div>
829
830        <div class="user-id-container">
831          <code id="nodeIdCode">${user._id}</code>
832          <button id="copyNodeIdBtn" title="Copy ID">📋</button>
833        </div>
834      </div>
835    </div>
836
837    <!-- Raw Ideas Capture -->
838    <div class="glass-card raw-ideas-section">
839      <h2>Capture a Raw Idea</h2>
840      <form
841        method="POST"
842        action="/api/v1/user/${userId}/raw-ideas${queryString}"
843        enctype="multipart/form-data"
844        class="raw-idea-form"
845        id="rawIdeaForm"
846      >
847        <textarea
848          name="content"
849          placeholder="What's on your mind?"
850          id="rawIdeaInput"
851          rows="1"
852          maxlength="5000"
853          autofocus
854        ></textarea>
855
856        <div class="char-counter" id="charCounter">
857          <span id="charCount">0</span> / 5000
858          <span class="energy-display" id="energyDisplay"></span>
859        </div>
860
861        <div class="form-actions">
862          <div class="file-input-wrapper">
863            <input type="file" name="file" id="fileInput" />
864            <div class="file-selected-badge" id="fileSelectedBadge">
865              <span>📎</span>
866              <span class="file-name" id="fileName"></span>
867              <button type="button" class="clear-file" id="clearFileBtn" title="Remove file">✕</button>
868            </div>
869          </div>
870          <button type="submit" class="send-button" title="Save raw idea" id="rawIdeaSendBtn">
871            <span class="send-label">Send</span>
872            <span class="send-progress"></span>
873          </button>
874        </div>
875      </form>
876    </div>
877
878    <!-- Navigation Links -->
879    <div class="glass-card nav-section">
880      <h2>Quick Links</h2>
881      <ul class="nav-links">
882        <li><a href="/api/v1/user/${userId}/raw-ideas${queryString}">Raw Ideas</a></li>
883                <li><a href="/api/v1/user/${userId}/chats${queryString}">AI Chats</a></li>
884
885        <li><a href="/api/v1/user/${userId}/notes${queryString}">Notes</a></li>
886        <li><a href="/api/v1/user/${userId}/tags${queryString}">Mail</a></li>
887        <li><a href="/api/v1/user/${userId}/contributions${queryString}">Contributions</a></li>
888        <li><a href="/api/v1/user/${userId}/notifications${queryString}">Notifications</a></li>
889        <li><a href="/api/v1/user/${userId}/invites${queryString}">Invites</a></li>
890        <li><a href="/api/v1/user/${userId}/deleted${queryString}">Deleted</a></li>
891        <li><a href="/api/v1/user/${userId}/api-keys${queryString}">API Keys</a></li>
892        <li><a href="/api/v1/user/${userId}/sharetoken${queryString}">Share Token</a></li>
893      </ul>
894    </div>
895
896    <!-- Roots Section -->
897    <div class="glass-card roots-section">
898      <h2>My Roots</h2>
899      ${
900        roots.length > 0
901          ? `
902        <ul class="roots-list">
903          ${roots
904            .map(
905              (r) => `
906            <li>
907              <a href="/api/v1/root/${r._id}${queryString}">
908                  ${escapeHtml(r.name || "Untitled")}
909              </a>
910            </li>
911          `,
912            )
913            .join("")}
914        </ul>
915      `
916          : `<ul class="roots-list"><li><em>No roots yet — create your first one below!</em></li></ul>`
917      }
918      
919      <form
920        method="POST"
921        action="/api/v1/user/${userId}/createRoot${queryString}"
922        class="create-root-form"
923      >
924        <input
925          type="text"
926          name="name"
927          placeholder="New root name..."
928          required
929        />
930        <button type="submit" class="create-root-button" title="Create root">
931932        </button>
933      </form>
934    </div>
935  </div>
936
937  <script>
938    // Copy ID functionality
939    document.getElementById("copyNodeIdBtn").addEventListener("click", () => {
940      const code = document.getElementById("nodeIdCode");
941      const btn = document.getElementById("copyNodeIdBtn");
942      
943      navigator.clipboard.writeText(code.textContent).then(() => {
944        btn.textContent = "✔️";
945        setTimeout(() => (btn.textContent = "📋"), 1000);
946      });
947    });
948
949    // Storage toggle
950    (() => {
951      const toggleBtn = document.getElementById("storageToggle");
952      const valueEl = document.getElementById("storageValue");
953      const storageKB = Number(toggleBtn.dataset.storageKb || 0);
954      let unit = "MB";
955
956      function render() {
957        if (unit === "MB") {
958          const mb = storageKB / 1024;
959          valueEl.textContent = mb.toFixed(mb < 10 ? 2 : 1);
960          toggleBtn.textContent = "MB";
961        } else {
962          const gb = storageKB / (1024 * 1024);
963          valueEl.textContent = gb.toFixed(gb < 1 ? 3 : 2);
964          toggleBtn.textContent = "GB";
965        }
966      }
967
968      toggleBtn.addEventListener("click", () => {
969        unit = unit === "GB" ? "MB" : "GB";
970        render();
971      });
972
973      render();
974    })();
975
976    // Logout
977    document.getElementById("logoutBtn").addEventListener("click", async () => {
978      try {
979        await fetch("/api/v1/logout", {
980          method: "POST",
981          credentials: "include",
982        });
983        window.top.location.href = "/login";
984      } catch (err) {
985        console.error("Logout failed", err);
986        alert("Logout failed. Please try again.");
987      }
988    });
989
990    // Elements
991    const form = document.getElementById('rawIdeaForm');
992    const textarea = document.getElementById('rawIdeaInput');
993    const charCounter = document.getElementById('charCounter');
994    const charCount = document.getElementById('charCount');
995    const energyDisplay = document.getElementById('energyDisplay');
996    const fileInput = document.getElementById('fileInput');
997    const fileSelectedBadge = document.getElementById('fileSelectedBadge');
998    const fileName = document.getElementById('fileName');
999    const clearFileBtn = document.getElementById('clearFileBtn');
1000    const sendBtn = document.getElementById('rawIdeaSendBtn');
1001    const progressBar = sendBtn.querySelector('.send-progress');
1002
1003    const MAX_CHARS = 5000;
1004    let hasFile = false;
1005
1006    // Auto-resize textarea
1007    function autoResize() {
1008      textarea.style.height = 'auto';
1009      const maxHeight = 400;
1010      const newHeight = Math.min(textarea.scrollHeight, maxHeight);
1011      textarea.style.height = newHeight + 'px';
1012      textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
1013      updateCharCounter();
1014    }
1015    
1016    textarea.addEventListener('input', autoResize);
1017    autoResize();
1018
1019    // Character counter with energy (1 per 1000 chars)
1020    function updateCharCounter() {
1021      const len = textarea.value.length;
1022      charCount.textContent = len;
1023      
1024      const remaining = MAX_CHARS - len;
1025      charCounter.classList.remove('warning', 'danger', 'disabled');
1026      
1027      if (hasFile) {
1028        charCounter.classList.add('disabled');
1029      } else if (remaining <= 100) {
1030        charCounter.classList.add('danger');
1031      } else if (remaining <= 500) {
1032        charCounter.classList.add('warning');
1033      }
1034      
1035      // Energy cost: 1 per 1000 chars (minimum 1 if any text)
1036      if (len > 0 && !hasFile) {
1037        const cost = Math.max(1, Math.ceil(len / 1000));
1038        energyDisplay.textContent = '⚡' + cost;
1039        energyDisplay.classList.remove('file-energy');
1040      } else if (!hasFile) {
1041        energyDisplay.textContent = '';
1042      }
1043    }
1044
1045    // File energy calculation
1046    const FILE_MIN_COST = 5;
1047    const FILE_BASE_RATE = 1.5;
1048    const FILE_MID_RATE = 3;
1049    const SOFT_LIMIT_MB = 100;
1050    const HARD_LIMIT_MB = 1024;
1051
1052    function calculateFileEnergy(sizeMB) {
1053      if (sizeMB <= SOFT_LIMIT_MB) {
1054        return Math.max(FILE_MIN_COST, Math.ceil(sizeMB * FILE_BASE_RATE));
1055      }
1056      if (sizeMB <= HARD_LIMIT_MB) {
1057        const base = SOFT_LIMIT_MB * FILE_BASE_RATE;
1058        const extra = (sizeMB - SOFT_LIMIT_MB) * FILE_MID_RATE;
1059        return Math.ceil(base + extra);
1060      }
1061      const base = SOFT_LIMIT_MB * FILE_BASE_RATE + 
1062                   (HARD_LIMIT_MB - SOFT_LIMIT_MB) * FILE_MID_RATE;
1063      const overGB = sizeMB - HARD_LIMIT_MB;
1064      return Math.ceil(base + Math.pow(overGB / 50, 2) * 50);
1065    }
1066
1067    // File selection - blocks text input
1068    fileInput.addEventListener('change', function() {
1069      if (this.files && this.files[0]) {
1070        const file = this.files[0];
1071        hasFile = true;
1072        
1073        // Disable textarea
1074        textarea.disabled = true;
1075        textarea.value = '';
1076        textarea.placeholder = 'File selected - text disabled';
1077        
1078        // Show file badge, hide file input
1079        fileInput.classList.add('hidden-input');
1080        fileSelectedBadge.classList.add('visible');
1081        
1082        // Truncate filename
1083        let displayName = file.name;
1084        if (displayName.length > 20) {
1085          displayName = displayName.substring(0, 17) + '...';
1086        }
1087        fileName.textContent = displayName;
1088        fileSelectedBadge.title = file.name;
1089        
1090        // Calculate and show energy (+1 for the note itself)
1091        const sizeMB = file.size / (1024 * 1024);
1092        const fileCost = calculateFileEnergy(sizeMB);
1093        const totalCost = fileCost + 1;
1094        energyDisplay.textContent = '~⚡' + totalCost;
1095        energyDisplay.classList.add('file-energy');
1096        
1097        updateCharCounter();
1098      }
1099    });
1100
1101    // Clear file selection
1102    clearFileBtn.addEventListener('click', function() {
1103      hasFile = false;
1104      fileInput.value = '';
1105      fileInput.classList.remove('hidden-input');
1106      fileSelectedBadge.classList.remove('visible');
1107      
1108      textarea.disabled = false;
1109      textarea.placeholder = "What's on your mind?";
1110      
1111      energyDisplay.textContent = '';
1112      energyDisplay.classList.remove('file-energy');
1113      
1114      updateCharCounter();
1115    });
1116
1117    // Submit with Enter (desktop only)
1118    textarea.addEventListener("keydown", (e) => {
1119      const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
1120      if (!isMobile && e.key === "Enter" && !e.shiftKey) {
1121        e.preventDefault();
1122        form.requestSubmit();
1123      }
1124    });
1125
1126    // Form submission with progress + cancel
1127    let activeXhr = null;
1128
1129    sendBtn.addEventListener('click', (e) => {
1130      if (activeXhr) {
1131        e.preventDefault();
1132        activeXhr.abort();
1133        activeXhr = null;
1134        sendBtn.classList.remove('loading');
1135        sendBtn.querySelector('.send-label').textContent = 'Send';
1136        progressBar.style.width = '0%';
1137        return;
1138      }
1139    });
1140
1141    form.addEventListener('submit', (e) => {
1142      e.preventDefault();
1143
1144      sendBtn.classList.add('loading');
1145      sendBtn.querySelector('.send-label').textContent = 'Cancel';
1146      progressBar.style.width = '15%';
1147
1148      const formData = new FormData(form);
1149      const xhr = new XMLHttpRequest();
1150      activeXhr = xhr;
1151
1152      xhr.open('POST', form.action, true);
1153
1154      xhr.upload.onprogress = (e) => {
1155        if (!e.lengthComputable) return;
1156        const realPercent = (e.loaded / e.total) * 100;
1157        const lagged = Math.min(90, Math.round(realPercent * 0.8));
1158        progressBar.style.width = lagged + '%';
1159      };
1160
1161      xhr.onload = () => {
1162        activeXhr = null;
1163        if (xhr.status >= 200 && xhr.status < 300) {
1164          progressBar.style.width = '100%';
1165          setTimeout(() => document.location.reload(), 150);
1166        } else {
1167          fail();
1168        }
1169      };
1170
1171      xhr.onerror = fail;
1172      xhr.onabort = () => {
1173        activeXhr = null;
1174      };
1175
1176      function fail() {
1177        activeXhr = null;
1178        var msg = 'Send failed';
1179        try {
1180          var body = JSON.parse(xhr.responseText);
1181          if (body.error) msg = body.error;
1182        } catch(e) {}
1183        alert(msg);
1184        sendBtn.classList.remove('loading');
1185        sendBtn.querySelector('.send-label').textContent = 'Send';
1186        progressBar.style.width = '0%';
1187      }
1188
1189      xhr.send(formData);
1190    });
1191
1192    // Form reset handler
1193    form.addEventListener('reset', () => {
1194      hasFile = false;
1195      fileInput.classList.remove('hidden-input');
1196      fileSelectedBadge.classList.remove('visible');
1197      textarea.disabled = false;
1198      textarea.placeholder = "What's on your mind?";
1199      energyDisplay.textContent = '';
1200      energyDisplay.classList.remove('file-energy');
1201      charCount.textContent = '0';
1202      charCounter.classList.remove('warning', 'danger', 'disabled');
1203    });
1204  </script>
1205</body>
1206</html>
1207`);
1208}
1209
1210// ═══════════════════════════════════════════════════════════════════
1211// 2. Notes Page - GET /user/:userId/notes
1212// ═══════════════════════════════════════════════════════════════════
1213export function renderUserNotes({ userId, user, notes, processedNotes, query, token }) {
1214  const tokenQS = token ? `?token=${token}&html` : `?html`;
1215    return (`
1216<!DOCTYPE html>
1217<html lang="en">
1218<head>
1219  <meta charset="UTF-8">
1220  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1221  <meta name="theme-color" content="#667eea">
1222  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1223<title>${escapeHtml(user.username)} — Notes</title>
1224  <style>
1225${baseStyles}
1226${backNavStyles}
1227${glassCardStyles}
1228${emptyStateStyles}
1229${responsiveBase}
1230
1231
1232/* Glass Header Section */
1233.header {
1234  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1235  backdrop-filter: blur(22px) saturate(140%);
1236  -webkit-backdrop-filter: blur(22px) saturate(140%);
1237  border-radius: 16px;
1238  padding: 32px;
1239  margin-bottom: 24px;
1240  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1241    inset 0 1px 0 rgba(255, 255, 255, 0.25);
1242  border: 1px solid rgba(255, 255, 255, 0.28);
1243  color: white;
1244  animation: fadeInUp 0.6s ease-out 0.1s both;
1245}
1246
1247.header h1 {
1248  font-size: 28px;
1249  font-weight: 600;
1250  color: white;
1251  margin-bottom: 8px;
1252  line-height: 1.3;
1253  letter-spacing: -0.5px;
1254  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1255}
1256
1257.header h1 a {
1258  color: white;
1259  text-decoration: none;
1260  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
1261  transition: all 0.2s;
1262}
1263
1264.header h1 a:hover {
1265  border-bottom-color: white;
1266  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
1267}
1268
1269.header-subtitle {
1270  font-size: 14px;
1271  color: rgba(255, 255, 255, 0.9);
1272  margin-bottom: 20px;
1273  font-weight: 400;
1274}
1275
1276/* Glass Search Form */
1277.search-form {
1278  display: flex;
1279  gap: 10px;
1280  flex-wrap: wrap;
1281}
1282
1283.search-form input[type="text"] {
1284  flex: 1;
1285  min-width: 200px;
1286  padding: 12px 16px;
1287  font-size: 16px;
1288  border-radius: 12px;
1289  border: 2px solid rgba(255, 255, 255, 0.3);
1290  background: rgba(255, 255, 255, 0.2);
1291  backdrop-filter: blur(20px) saturate(150%);
1292  -webkit-backdrop-filter: blur(20px) saturate(150%);
1293  font-family: inherit;
1294  color: white;
1295  font-weight: 500;
1296  transition: all 0.3s;
1297  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
1298    inset 0 1px 0 rgba(255, 255, 255, 0.25);
1299}
1300
1301.search-form input[type="text"]::placeholder {
1302  color: rgba(255, 255, 255, 0.6);
1303}
1304
1305.search-form input[type="text"]:focus {
1306  outline: none;
1307  border-color: rgba(255, 255, 255, 0.6);
1308  background: rgba(255, 255, 255, 0.3);
1309  box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15),
1310    0 8px 30px rgba(0, 0, 0, 0.15),
1311    inset 0 1px 0 rgba(255, 255, 255, 0.4);
1312  transform: translateY(-2px);
1313}
1314
1315
1316.search-form button {
1317  position: relative;
1318  overflow: hidden;
1319  padding: 12px 28px;
1320  font-size: 15px;
1321  font-weight: 600;
1322  letter-spacing: -0.2px;
1323  border-radius: 980px;
1324  border: 1px solid rgba(255, 255, 255, 0.3);
1325  background: rgba(255, 255, 255, 0.25);
1326  backdrop-filter: blur(10px);
1327  color: white;
1328  cursor: pointer;
1329  transition: all 0.3s;
1330  font-family: inherit;
1331  white-space: nowrap;
1332  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
1333}
1334
1335.search-form button::before {
1336  content: "";
1337  position: absolute;
1338  inset: -40%;
1339  background: radial-gradient(
1340    120% 60% at 0% 0%,
1341    rgba(255, 255, 255, 0.35),
1342    transparent 60%
1343  );
1344  opacity: 0;
1345  transform: translateX(-30%) translateY(-10%);
1346  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1347  pointer-events: none;
1348}
1349
1350.search-form button:hover {
1351  background: rgba(255, 255, 255, 0.35);
1352  transform: translateY(-2px);
1353  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
1354}
1355
1356.search-form button:hover::before {
1357  opacity: 1;
1358  transform: translateX(30%) translateY(10%);
1359}
1360
1361/* Card Actions (Edit + Delete buttons) */
1362.card-actions {
1363  position: absolute;
1364  top: 20px;
1365  right: 20px;
1366  display: flex;
1367  gap: 8px;
1368  z-index: 10;
1369}
1370
1371.edit-button,
1372.delete-button {
1373  background: rgba(255, 255, 255, 0.2);
1374  border: 1px solid rgba(255, 255, 255, 0.3);
1375  border-radius: 50%;
1376  width: 32px;
1377  height: 32px;
1378  display: flex;
1379  align-items: center;
1380  justify-content: center;
1381  font-size: 16px;
1382  cursor: pointer;
1383  color: white;
1384  padding: 0;
1385  line-height: 1;
1386  opacity: 0.8;
1387  transition: all 0.3s;
1388  text-decoration: none;
1389}
1390
1391.edit-button:hover {
1392  opacity: 1;
1393  background: rgba(72, 187, 178, 0.4);
1394  border-color: rgba(72, 187, 178, 0.6);
1395  transform: scale(1.1);
1396  box-shadow: 0 4px 12px rgba(72, 187, 178, 0.3);
1397}
1398
1399.delete-button:hover {
1400  opacity: 1;
1401  background: rgba(239, 68, 68, 0.4);
1402  border-color: rgba(239, 68, 68, 0.6);
1403  transform: scale(1.1);
1404  box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
1405}
1406
1407
1408.note-author {
1409  font-weight: 600;
1410  color: white;
1411  font-size: 13px;
1412  margin-bottom: 6px;
1413  opacity: 0.9;
1414  letter-spacing: -0.2px;
1415  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
1416}
1417
1418.note-link {
1419  color: white;
1420  text-decoration: none;
1421  font-size: 15px;
1422  line-height: 1.6;
1423  display: block;
1424  word-wrap: break-word;
1425  transition: all 0.2s;
1426  font-weight: 400;
1427}
1428
1429.note-link:hover {
1430  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
1431}
1432
1433.file-badge {
1434  display: inline-block;
1435  padding: 4px 10px;
1436  background: rgba(255, 255, 255, 0.25);
1437  color: white;
1438  border-radius: 6px;
1439  font-size: 11px;
1440  font-weight: 600;
1441  margin-right: 8px;
1442  border: 1px solid rgba(255, 255, 255, 0.3);
1443  text-transform: uppercase;
1444  letter-spacing: 0.5px;
1445}
1446
1447/* Responsive Design */
1448@media (max-width: 640px) {
1449  body {
1450    padding: 16px;
1451  }
1452
1453  .header {
1454    padding: 24px 20px;
1455  }
1456
1457  .header h1 {
1458    font-size: 24px;
1459  }
1460
1461  .search-form {
1462    flex-direction: column;
1463  }
1464
1465  .search-form input[type="text"] {
1466    width: 100%;
1467    min-width: 0;
1468    font-size: 16px;
1469  }
1470
1471  .search-form button {
1472    width: 100%;
1473  }
1474
1475
1476  .card-actions {
1477    top: 16px;
1478    right: 16px;
1479    gap: 6px;
1480  }
1481
1482  .edit-button,
1483  .delete-button {
1484    width: 28px;
1485    height: 28px;
1486    font-size: 14px;
1487  }
1488
1489}
1490
1491
1492  </style>
1493</head>
1494<body>
1495  <div class="container">
1496    <!-- Back Navigation -->
1497    <div class="back-nav">
1498      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
1499        ← Back to Profile
1500      </a>
1501    </div>
1502
1503    <!-- Header Section -->
1504    <div class="header">
1505      <h1>
1506        Notes by
1507<a href="/api/v1/user/${userId}${tokenQS}">${escapeHtml(user.username)}</a>
1508      </h1>
1509      <div class="header-subtitle">
1510        View and manage your last 200notes across every tree
1511      </div>
1512
1513      <!-- Search Form -->
1514      <form method="GET" action="/api/v1/user/${userId}/notes" class="search-form">
1515        <input type="hidden" name="token" value="${token}">
1516        <input type="hidden" name="html" value="">
1517        <input
1518          type="text"
1519          name="q"
1520          placeholder="Search notes..."
1521value="${escapeHtml(query)}"
1522        />
1523        <button type="submit">Search</button>
1524      </form>
1525    </div>
1526
1527    <!-- Notes List -->
1528    ${
1529      notes.length > 0
1530        ? `
1531    <ul class="notes-list">
1532      ${processedNotes.join("")}
1533    </ul>
1534    `
1535        : `
1536    <div class="empty-state">
1537      <div class="empty-state-icon">📝</div>
1538      <div class="empty-state-text">No notes yet</div>
1539      <div class="empty-state-subtext">
1540        ${
1541          query.trim() !== ""
1542            ? "Try a different search term"
1543            : "Notes will appear here as you create them"
1544        }
1545      </div>
1546    </div>
1547    `
1548    }
1549  </div>
1550
1551  <script>
1552    document.addEventListener("click", async (e) => {
1553      if (!e.target.classList.contains("delete-button")) return;
1554
1555      const card = e.target.closest(".note-card");
1556      const noteId = card.dataset.noteId;
1557      const nodeId = card.dataset.nodeId;
1558      const version = card.dataset.version;
1559
1560      // Debug: log what we're trying to delete
1561
1562      if (!noteId || !nodeId || !version) {
1563        alert("Error: Missing note data. Please refresh and try again.");
1564        return;
1565      }
1566
1567      if (!confirm("Delete this note? This cannot be undone.")) return;
1568
1569      const token = new URLSearchParams(window.location.search).get("token") || "";
1570      const qs = token ? "?token=" + encodeURIComponent(token) : "";
1571
1572      try {
1573        const url = "/api/v1/" + nodeId + "/" + version + "/notes/" + noteId + qs;
1574
1575        const res = await fetch(url, { method: "DELETE" });
1576
1577        const data = await res.json();
1578        if (!data.success) throw new Error(data.error || "Delete failed");
1579
1580        // Fade out animation
1581        card.style.transition = "all 0.3s ease";
1582        card.style.opacity = "0";
1583        card.style.transform = "translateX(-20px)";
1584        setTimeout(() => card.remove(), 300);
1585      } catch (err) {
1586        alert("Failed to delete: " + (err.message || "Unknown error"));
1587      }
1588    });
1589  </script>
1590</body>
1591</html>
1592`);
1593}
1594
1595// ═══════════════════════════════════════════════════════════════════
1596// 3. Tags/Mail Page - GET /user/:userId/tags
1597// ═══════════════════════════════════════════════════════════════════
1598export async function renderUserTags({ userId, user, notes, getNodeName, token }) {
1599  const tokenQS = token ? `?token=${token}&html` : `?html`;
1600    return (`
1601<!DOCTYPE html>
1602<html lang="en">
1603<head>
1604  <meta charset="UTF-8">
1605  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1606  <meta name="theme-color" content="#667eea">
1607  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1608  <title>${escapeHtml(user.username)} — Mail</title>
1609  <style>
1610${baseStyles}
1611${backNavStyles}
1612${glassHeaderStyles}
1613${glassCardStyles}
1614${emptyStateStyles}
1615${responsiveBase}
1616
1617.header-subtitle {
1618  margin-bottom: 0;
1619}
1620
1621
1622.note-author {
1623  font-weight: 600;
1624  color: white;
1625  font-size: 15px;
1626  margin-bottom: 8px;
1627  display: block;
1628  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
1629}
1630
1631.note-author a {
1632  color: white;
1633  text-decoration: none;
1634  transition: all 0.2s;
1635}
1636
1637.note-author a:hover {
1638  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
1639}
1640
1641.note-link {
1642  color: white;
1643  text-decoration: none;
1644  font-size: 15px;
1645  line-height: 1.6;
1646  display: block;
1647  word-wrap: break-word;
1648  transition: all 0.2s;
1649  font-weight: 400;
1650}
1651
1652.note-link:hover {
1653  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
1654}
1655
1656.file-badge {
1657  display: inline-block;
1658  padding: 4px 10px;
1659  background: rgba(255, 255, 255, 0.25);
1660  color: white;
1661  border-radius: 6px;
1662  font-size: 11px;
1663  font-weight: 600;
1664  margin-right: 8px;
1665  border: 1px solid rgba(255, 255, 255, 0.3);
1666  text-transform: uppercase;
1667  letter-spacing: 0.5px;
1668}
1669
1670/* Responsive Design */
1671
1672  </style>
1673</head>
1674<body>
1675  <div class="container">
1676    <!-- Back Navigation -->
1677    <div class="back-nav">
1678      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
1679        ← Back to Profile
1680      </a>
1681    </div>
1682
1683    <!-- Header Section -->
1684    <div class="header">
1685      <h1>
1686        Mail for
1687        <a href="/api/v1/user/${userId}${tokenQS}">@${escapeHtml(user.username)}</a>
1688        ${
1689          notes.length > 0
1690            ? `<span class="message-count">${notes.length}</span>`
1691            : ""
1692        }
1693      </h1>
1694      <div class="header-subtitle">Notes where others have mentioned you</div>
1695    </div>
1696
1697    <!-- Notes List -->
1698    ${
1699      notes.length > 0
1700        ? `
1701    <ul class="notes-list">
1702      ${await Promise.all(
1703        notes.map(async (n) => {
1704          const nodeName = await getNodeName(n.nodeId);
1705          const preview =
1706            n.contentType === "text"
1707              ? n.content.length > 120
1708                ? n.content.substring(0, 120) + "…"
1709                : n.content
1710              : n.content.split("/").pop();
1711
1712          const author = n.userId.username || n.userId._id;
1713
1714          return `
1715          <li class="note-card">
1716            <div class="note-content">
1717              <div class="note-author">
1718                <a href="/api/v1/user/${n.userId._id}${tokenQS}">
1719                  ${escapeHtml(author)}
1720                </a>
1721              </div>
1722              <a href="/api/v1/node/${n.nodeId}/${n.version}/notes/${
1723                n._id
1724              }${tokenQS}" class="note-link">
1725                ${
1726                  n.contentType === "file"
1727                    ? `<span class="file-badge">FILE</span>`
1728                    : ""
1729                }${escapeHtml(preview)}
1730              </a>
1731            </div>
1732
1733            <div class="note-meta">
1734              ${new Date(n.createdAt).toLocaleString()}
1735              <span class="meta-separator">•</span>
1736              <a href="/api/v1/node/${n.nodeId}/${n.version}${tokenQS}">
1737                ${escapeHtml(nodeName)} v${n.version}
1738              </a>
1739              <span class="meta-separator">•</span>
1740              <a href="/api/v1/node/${n.nodeId}/${n.version}/notes${tokenQS}">
1741                View Notes
1742              </a>
1743            </div>
1744          </li>
1745        `;
1746        }),
1747      ).then((results) => results.join(""))}
1748    </ul>
1749    `
1750        : `
1751    <div class="empty-state">
1752      <div class="empty-state-icon">📬</div>
1753      <div class="empty-state-text">No messages yet</div>
1754      <div class="empty-state-subtext">
1755        Notes where you're mentioned will appear here
1756      </div>
1757    </div>
1758    `
1759    }
1760  </div>
1761</body>
1762</html>
1763`);
1764}
1765
1766// ═══════════════════════════════════════════════════════════════════
1767// 4. User Contributions Page - GET /user/:userId/contributions
1768// ═══════════════════════════════════════════════════════════════════
1769export async function renderUserContributions({ userId, contributions, username, getNodeName, token }) {
1770  const tokenQS = token ? `?token=${token}&html` : `?html`;
1771
1772  const link = (id, label) =>
1773    id
1774      ? `<a href="/api/v1/node/${id}${tokenQS}">${label || `<code>${esc(id)}</code>`}</a>`
1775      : `<code>unknown</code>`;
1776
1777  const nodeLink = (id, name, version) => {
1778    if (!id) return `<code>unknown node</code>`;
1779    const v = version != null ? `/${version}` : "";
1780    const display = name || id;
1781    return `<a href="/api/v1/node/${id}${v}${tokenQS}"><code>${esc(display)}</code></a>`;
1782  };
1783
1784  const userTag = (u) => {
1785    if (!u) return `<code>unknown user</code>`;
1786    if (typeof u === "object" && u.username)
1787      return `<a href="/api/v1/user/${u._id}${tokenQS}"><code>${esc(u.username)}</code></a>`;
1788    if (typeof u === "string")
1789      return `<a href="/api/v1/user/${u}${tokenQS}"><code>${esc(u)}</code></a>`;
1790    return `<code>unknown user</code>`;
1791  };
1792
1793  const kvMap = (data) => {
1794    if (!data) return "";
1795    const entries =
1796      data instanceof Map
1797        ? [...data.entries()]
1798        : typeof data === "object"
1799          ? Object.entries(data)
1800          : [];
1801    if (entries.length === 0) return "";
1802    return entries
1803      .map(
1804        ([k, v]) =>
1805          `<span class="kv-chip"><code>${esc(k)}</code> ${esc(String(v))}</span>`,
1806      )
1807      .join(" ");
1808  };
1809
1810    /* ─────────────────────────────────────────────── */
1811    /* ACTION RENDERER                                  */
1812    /* ─────────────────────────────────────────────── */
1813
1814    const renderAction = (rawC, nodeName) => {
1815      // Merge extensionData into contribution so action renderers work
1816      const c = rawC.extensionData ? { ...rawC, ...rawC.extensionData } : rawC;
1817      const nId = c.nodeId?._id || c.nodeId;
1818      const v = Number(c.nodeVersion ?? 0);
1819      const nLink = nodeLink(nId, nodeName, v);
1820
1821      switch (c.action) {
1822        case "create":
1823          return `Created ${nLink}`;
1824
1825        case "editStatus":
1826          return `Marked ${nLink} as <code>${esc(c.statusEdited)}</code>`;
1827
1828        case "editValue":
1829          return `Adjusted values on ${nLink} ${kvMap(c.valueEdited)}`;
1830
1831        case "prestige":
1832          return `Prestiged ${nLink} to a new version`;
1833
1834        case "trade":
1835          return `Traded on ${nLink}`;
1836
1837        case "delete":
1838          return `Deleted ${nLink}`;
1839
1840        case "invite": {
1841          const ia = c.inviteAction || {};
1842          const target = userTag(ia.receivingId);
1843          const labels = {
1844            invite: `Invited ${target} to collaborate on`,
1845            acceptInvite: `Accepted an invitation on`,
1846            denyInvite: `Declined an invitation on`,
1847            removeContributor: `Removed ${target} from`,
1848            switchOwner: `Transferred ownership of`,
1849          };
1850          const suffix = ia.action === "switchOwner" ? ` to ${target}` : "";
1851          return `${labels[ia.action] || "Updated collaboration on"} ${nLink}${suffix}`;
1852        }
1853
1854        case "editSchedule": {
1855          const s = c.scheduleEdited || {};
1856          const parts = [];
1857          if (s.date)
1858            parts.push(
1859              `date to <code>${new Date(s.date).toLocaleString()}</code>`,
1860            );
1861          if (s.reeffectTime != null)
1862            parts.push(`re-effect to <code>${s.reeffectTime}</code>`);
1863          return parts.length
1864            ? `Set ${parts.join(" and ")} on ${nLink}`
1865            : `Updated the schedule on ${nLink}`;
1866        }
1867
1868        case "editGoal":
1869          return `Set new goals on ${nLink} ${kvMap(c.goalEdited)}`;
1870
1871        case "transaction": {
1872          const tm = c.transactionMeta;
1873          if (!tm) return `Recorded a transaction on ${nLink}`;
1874          const eventLabel = esc(tm.event || "unknown").replace(/_/g, " ");
1875          const counterparty = tm.counterpartyNodeId
1876            ? ` with ${link(tm.counterpartyNodeId)}`
1877            : "";
1878          const sent = kvMap(tm.valuesSent);
1879          const recv = kvMap(tm.valuesReceived);
1880          let flow = "";
1881          if (sent) flow += ` — sent ${sent}`;
1882          if (recv) flow += `${sent ? "," : " —"} received ${recv}`;
1883          return `Transaction <code>${eventLabel}</code> as ${esc(tm.role)} (side ${esc(tm.side)}) on ${nLink}${counterparty}${flow}`;
1884        }
1885
1886        case "note": {
1887          const na = c.noteAction || {};
1888
1889          let verb;
1890          switch (na.action) {
1891            case "add":
1892              verb = "Added a note to";
1893              break;
1894            case "edit":
1895              verb = "Edited a note in";
1896              break;
1897            case "remove":
1898              verb = "Removed a note from";
1899              break;
1900            default:
1901              verb = "Updated a note in";
1902          }
1903
1904          const noteRef = na.noteId
1905            ? ` <a href="/api/v1/node/${nId}/${v}/notes/${na.noteId}${tokenQS}"><code>${esc(na.noteId)}</code></a>`
1906            : "";
1907
1908          return `${verb} ${nLink}${noteRef}`;
1909        }
1910
1911        case "updateParent": {
1912          const up = c.updateParent || {};
1913          const from = up.oldParentId
1914            ? link(up.oldParentId)
1915            : `<code>none</code>`;
1916          const to = up.newParentId
1917            ? link(up.newParentId)
1918            : `<code>none</code>`;
1919          return `Moved ${nLink} from ${from} to ${to}`;
1920        }
1921
1922        case "editScript": {
1923          const es = c.editScript || {};
1924          return `Edited script <code>${esc(es.scriptName || es.scriptId)}</code> on ${nLink}`;
1925        }
1926
1927        case "executeScript": {
1928          const xs = c.executeScript || {};
1929          const icon = xs.success ? "✅" : "❌";
1930          let text = `${icon} Ran <code>${esc(xs.scriptName || xs.scriptId)}</code> on ${nLink}`;
1931          if (xs.error) text += ` — <code>${esc(xs.error)}</code>`;
1932          return text;
1933        }
1934
1935        case "updateChildNode": {
1936          const uc = c.updateChildNode || {};
1937          return uc.action === "added"
1938            ? `Added ${link(uc.childId)} as a child of ${nLink}`
1939            : `Removed child ${link(uc.childId)} from ${nLink}`;
1940        }
1941
1942        case "editNameNode": {
1943          const en = c.editNameNode || {};
1944          return `Renamed ${nLink} from <code>${esc(en.oldName)}</code> to <code>${esc(en.newName)}</code>`;
1945        }
1946
1947        case "rawIdea": {
1948          const ri = c.rawIdeaAction || {};
1949          const ideaRef = `<a href="/api/v1/user/${userId}/raw-ideas/${ri.rawIdeaId}${tokenQS}"><code>${esc(ri.rawIdeaId)}</code></a>`;
1950          if (ri.action === "add") return `Captured a raw idea ${ideaRef}`;
1951          if (ri.action === "delete")
1952            return `Discarded raw idea <code>${esc(ri.rawIdeaId)}</code>`;
1953          if (ri.action === "placed") {
1954            const target = ri.targetNodeId ? link(ri.targetNodeId) : nLink;
1955            return `Placed raw idea ${ideaRef} into ${target}`;
1956          }
1957          if (ri.action === "aiStarted")
1958            return `AI began processing raw idea ${ideaRef}`;
1959          if (ri.action === "aiFailed")
1960            return `AI failed to place raw idea ${ideaRef}`;
1961          return `Updated raw idea ${ideaRef}`;
1962        }
1963
1964        case "branchLifecycle": {
1965          const bl = c.branchLifecycle || {};
1966          if (bl.action === "retired") {
1967            let text = `Retired branch ${nLink}`;
1968            if (bl.fromParentId) text += ` from ${link(bl.fromParentId)}`;
1969            return text;
1970          }
1971          if (bl.action === "revived") {
1972            let text = `Revived branch ${nLink}`;
1973            if (bl.toParentId) text += ` under ${link(bl.toParentId)}`;
1974            return text;
1975          }
1976          return `Revived ${nLink} as a new root`;
1977        }
1978
1979        case "purchase": {
1980          const pm = c.purchaseMeta || {};
1981          const parts = [];
1982          if (pm.plan) parts.push(`the <code>${esc(pm.plan)}</code> plan`);
1983          if (pm.energyAmount)
1984            parts.push(`<code>${pm.energyAmount}</code> energy`);
1985          const price = pm.totalCents
1986            ? ` for $${(pm.totalCents / 100).toFixed(2)} ${esc(pm.currency || "usd").toUpperCase()}`
1987            : "";
1988          return parts.length
1989            ? `Purchased ${parts.join(" and ")}${price}`
1990            : `Made a purchase${price}`;
1991        }
1992
1993        case "understanding": {
1994          const um = c.understandingMeta || {};
1995          const rootNode = um.rootNodeId || nId;
1996          const runId = um.understandingRunId;
1997
1998          if (um.stage === "createRun") {
1999            const runLink =
2000              runId && rootNode
2001                ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}${tokenQS}"><code>${esc(runId)}</code></a>`
2002                : `<code>unknown run</code>`;
2003            let text = `Started understanding run ${runLink}`;
2004            if (rootNode) text += ` on ${link(rootNode)}`;
2005            if (um.nodeCount != null)
2006              text += ` spanning <code>${um.nodeCount}</code> nodes`;
2007            if (um.perspective) text += ` — "${esc(um.perspective)}"`;
2008            return text;
2009          }
2010
2011          if (um.stage === "processStep") {
2012            const uNodeId = um.understandingNodeId;
2013            const uNodeLink =
2014              uNodeId && runId && rootNode
2015                ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}/${uNodeId}${tokenQS}"><code>${esc(uNodeId)}</code></a>`
2016                : uNodeId
2017                  ? `<code>${esc(uNodeId)}</code>`
2018                  : `<code>unknown</code>`;
2019            let text = `Understanding encoded ${uNodeLink}`;
2020            if (um.mode)
2021              text += ` <span class="kv-chip">${esc(um.mode)}</span>`;
2022            if (um.layer != null) text += ` at layer <code>${um.layer}</code>`;
2023            return text;
2024          }
2025
2026          return `Understanding activity on ${nLink}`;
2027        }
2028
2029        default:
2030          return `<code>${esc(c.action)}</code> on ${nLink}`;
2031      }
2032    };
2033
2034    const items = await Promise.all(
2035      contributions.map(async (c) => {
2036        const nId = c.nodeId?._id || c.nodeId;
2037        const nodeName = nId ? await getNodeName(nId) : null;
2038        const time = new Date(c.date).toLocaleString();
2039        const actionHtml = renderAction(c, nodeName);
2040        const colorClass = actionColorClass(c.action);
2041
2042        const aiBadge = c.wasAi ? `<span class="badge badge-ai">AI</span>` : "";
2043        const energyBadge =
2044          c.energyUsed != null && c.energyUsed > 0
2045            ? `<span class="badge badge-energy">⚡ ${c.energyUsed}</span>`
2046            : "";
2047
2048        return `
2049      <li class="note-card ${colorClass}">
2050        <div class="note-content">
2051          <div class="contribution-action">${actionHtml}</div>
2052        </div>
2053        <div class="note-meta">
2054          ${time}
2055          ${aiBadge}${energyBadge}
2056          <span class="meta-separator">·</span>
2057          <code class="contribution-id">${esc(c._id)}</code>
2058        </div>
2059      </li>`;
2060      }),
2061    );
2062
2063    /* ─────────────────────────────────────────────── */
2064    /* HTML SHELL                                       */
2065    /* ─────────────────────────────────────────────── */
2066
2067    return (`
2068<!DOCTYPE html>
2069<html lang="en">
2070<head>
2071  <meta charset="UTF-8">
2072  <meta name="viewport" content="width=device-width, initial-scale=1.0">
2073  <meta name="theme-color" content="#667eea">
2074  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2075  <title>${esc(username)} — Contributions</title>
2076  <style>
2077${baseStyles}
2078${backNavStyles}
2079${glassHeaderStyles}
2080${glassCardStyles}
2081${emptyStateStyles}
2082${responsiveBase}
2083
2084.header-subtitle {
2085  margin-bottom: 16px;
2086}
2087
2088
2089.nav-links {
2090  display: flex; flex-wrap: wrap; gap: 8px;
2091}
2092
2093.nav-links a {
2094  display: inline-block;
2095  padding: 6px 14px;
2096  background: rgba(255,255,255,0.18);
2097  color: white; border-radius: 980px;
2098  font-size: 13px; font-weight: 600;
2099  text-decoration: none;
2100  border: 1px solid rgba(255,255,255,0.25);
2101  transition: all 0.2s;
2102}
2103
2104.nav-links a:hover {
2105  background: rgba(255,255,255,0.32);
2106  transform: translateY(-1px);
2107}
2108
2109.contribution-action {
2110  font-size: 15px; line-height: 1.6;
2111  color: white; font-weight: 400;
2112  word-wrap: break-word;
2113}
2114
2115.contribution-action a {
2116  color: white; text-decoration: none;
2117  border-bottom: 1px solid rgba(255,255,255,0.3);
2118  transition: all 0.2s;
2119}
2120
2121.contribution-action a:hover {
2122  border-bottom-color: white;
2123  text-shadow: 0 0 12px rgba(255,255,255,0.8);
2124}
2125
2126.contribution-action code {
2127  background: rgba(255,255,255,0.18);
2128  padding: 2px 7px; border-radius: 5px;
2129  font-size: 13px;
2130  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
2131  border: 1px solid rgba(255,255,255,0.15);
2132}
2133
2134.contribution-id {
2135  background: rgba(255,255,255,0.12);
2136  padding: 2px 6px; border-radius: 4px;
2137  font-size: 11px;
2138  font-family: 'SF Mono', 'Fira Code', monospace;
2139  color: rgba(255,255,255,0.6);
2140  border: 1px solid rgba(255,255,255,0.1);
2141}
2142
2143/* ── Badges ─────────────────────────────────────── */
2144
2145.badge {
2146  display: inline-flex; align-items: center;
2147  padding: 3px 10px; border-radius: 980px;
2148  font-size: 11px; font-weight: 700; letter-spacing: 0.3px;
2149  border: 1px solid rgba(255,255,255,0.2);
2150}
2151
2152.badge-ai {
2153  background: rgba(255,200,50,0.35);
2154  color: #fff;
2155  text-shadow: 0 1px 2px rgba(0,0,0,0.2);
2156}
2157
2158.badge-energy {
2159  background: rgba(100,220,255,0.3);
2160  color: #fff;
2161  text-shadow: 0 1px 2px rgba(0,0,0,0.2);
2162}
2163
2164/* ── KV Chips ───────────────────────────────────── */
2165
2166.kv-chip {
2167  display: inline-block;
2168  padding: 2px 8px;
2169  background: rgba(255,255,255,0.15);
2170  border-radius: 6px; font-size: 12px;
2171  margin: 2px 2px;
2172  border: 1px solid rgba(255,255,255,0.15);
2173}
2174
2175.kv-chip code {
2176  background: none !important;
2177  border: none !important;
2178  padding: 0 !important;
2179  font-weight: 600;
2180}
2181
2182/* ── Responsive ─────────────────────────────────── */
2183
2184
2185  </style>
2186</head>
2187<body>
2188  <div class="container">
2189    <div class="back-nav">
2190      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">← Back to Profile</a>
2191    </div>
2192
2193    <div class="header">
2194      <h1>
2195        Contributions by
2196        <a href="/api/v1/user/${userId}${tokenQS}">@${esc(username)}</a>
2197        ${contributions.length > 0 ? `<span class="message-count">${contributions.length}</span>` : ""}
2198      </h1>
2199      <div class="header-subtitle">Activity &amp; change history</div>
2200
2201    </div>
2202
2203    ${
2204      items.length
2205        ? `<ul class="notes-list">${items.join("")}</ul>`
2206        : `
2207    <div class="empty-state">
2208      <div class="empty-state-icon">📊</div>
2209      <div class="empty-state-text">No contributions yet</div>
2210      <div class="empty-state-subtext">Contributions and activity will appear here</div>
2211    </div>`
2212    }
2213  </div>
2214
2215 
2216</body>
2217</html>
2218`);
2219}
2220
2221// ═══════════════════════════════════════════════════════════════════
2222// 5. Reset Password Page (expired) - GET /user/reset-password/:token
2223// ═══════════════════════════════════════════════════════════════════
2224export function renderResetPasswordExpired() {
2225      return (`
2226<!DOCTYPE html>
2227<html lang="en">
2228<head>
2229  <meta charset="UTF-8">
2230  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
2231  <meta name="theme-color" content="#736fe6">
2232  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2233  <title>TreeOS - Link Expired</title>
2234  <style>
2235${baseStyles}
2236
2237body {
2238  display: flex;
2239  flex-direction: column;
2240  align-items: center;
2241  justify-content: center;
2242}
2243
2244
2245    @keyframes fadeInDown {
2246      from { opacity: 0; transform: translateY(-30px); }
2247      to { opacity: 1; transform: translateY(0); }
2248    }
2249
2250    @keyframes slideUp {
2251      from { opacity: 0; transform: translateY(30px); }
2252      to { opacity: 1; transform: translateY(0); }
2253    }
2254
2255    .brand-header {
2256      position: relative; z-index: 1;
2257      margin-bottom: 32px; text-align: center;
2258      animation: fadeInDown 0.8s ease-out;
2259    }
2260
2261    .brand-logo {
2262      font-size: 80px; margin-bottom: 16px; display: inline-block;
2263      filter: drop-shadow(0 8px 32px rgba(0,0,0,0.2));
2264      animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
2265    }
2266
2267    @keyframes grow {
2268      0%, 100% { transform: scale(1); }
2269      50% { transform: scale(1.06); }
2270    }
2271
2272    .brand-title {
2273      font-size: 56px; font-weight: 600; color: white;
2274      text-shadow: 0 2px 8px rgba(0,0,0,0.2);
2275      letter-spacing: -1.5px; margin-bottom: 8px;
2276    }
2277
2278    .container {
2279      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2280      backdrop-filter: blur(22px) saturate(140%);
2281      -webkit-backdrop-filter: blur(22px) saturate(140%);
2282      padding: 48px;
2283      border-radius: 16px;
2284      width: 100%; max-width: 460px;
2285      box-shadow: 0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.25);
2286      border: 1px solid rgba(255,255,255,0.28);
2287      text-align: center;
2288      position: relative; z-index: 1;
2289      animation: slideUp 0.6s ease-out 0.2s both;
2290    }
2291
2292    h2 {
2293      font-size: 32px; font-weight: 600; color: white;
2294      margin-bottom: 12px;
2295      text-shadow: 0 2px 8px rgba(0,0,0,0.2);
2296    }
2297
2298    .subtitle {
2299      font-size: 15px; color: rgba(255,255,255,0.85);
2300      margin-bottom: 24px; line-height: 1.5;
2301    }
2302
2303    .back-btn {
2304      display: inline-block;
2305      width: 100%;
2306      padding: 14px;
2307      margin-top: 16px;
2308      border-radius: 980px;
2309      border: 1px solid rgba(255,255,255,0.3);
2310      background: rgba(255,255,255,0.25);
2311      backdrop-filter: blur(10px);
2312      color: white;
2313      font-size: 16px; font-weight: 600;
2314      cursor: pointer;
2315      transition: all 0.3s;
2316      text-decoration: none;
2317      text-align: center;
2318      font-family: inherit;
2319    }
2320
2321    .back-btn:hover {
2322      background: rgba(255,255,255,0.35);
2323      transform: translateY(-2px);
2324      box-shadow: 0 6px 20px rgba(0,0,0,0.18);
2325    }
2326
2327    @media (max-width: 640px) {
2328      .brand-logo { font-size: 64px; }
2329      .brand-title { font-size: 42px; }
2330      .container { padding: 32px 24px; }
2331      h2 { font-size: 28px; }
2332    }
2333  </style>
2334</head>
2335<body>
2336  <div class="brand-header">
2337    <a href="/" style="text-decoration: none;">
2338      <div class="brand-logo">🌳</div>
2339      <h1 class="brand-title">TreeOS</h1>
2340    </a>
2341  </div>
2342
2343  <div class="container">
2344    <h2>Link Expired</h2>
2345    <p class="subtitle">This reset link is invalid or has expired. Please request a new password reset.</p>
2346    <a href="/login" class="back-btn">← Back to Login</a>
2347  </div>
2348</body>
2349</html>
2350      `);
2351}
2352
2353// Reset Password Form Page
2354export function renderResetPasswordForm({ token }) {
2355    return (`
2356<!DOCTYPE html>
2357<html lang="en">
2358<head>
2359  <meta charset="UTF-8">
2360  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
2361  <meta name="theme-color" content="#736fe6">
2362  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2363  <title>TreeOS - Reset Password</title>
2364  <style>
2365${baseStyles}
2366
2367body {
2368  display: flex;
2369  flex-direction: column;
2370  align-items: center;
2371  justify-content: center;
2372}
2373
2374
2375    @keyframes fadeInDown {
2376      from { opacity: 0; transform: translateY(-30px); }
2377      to { opacity: 1; transform: translateY(0); }
2378    }
2379
2380    @keyframes slideUp {
2381      from { opacity: 0; transform: translateY(30px); }
2382      to { opacity: 1; transform: translateY(0); }
2383    }
2384
2385    @keyframes spin { to { transform: rotate(360deg); } }
2386
2387    .brand-header {
2388      position: relative; z-index: 1;
2389      margin-bottom: 32px; text-align: center;
2390      animation: fadeInDown 0.8s ease-out;
2391    }
2392
2393    .brand-logo {
2394      font-size: 80px; margin-bottom: 16px; display: inline-block;
2395      filter: drop-shadow(0 8px 32px rgba(0,0,0,0.2));
2396      animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
2397    }
2398
2399    @keyframes grow {
2400      0%, 100% { transform: scale(1); }
2401      50% { transform: scale(1.06); }
2402    }
2403
2404    .brand-title {
2405      font-size: 56px; font-weight: 600; color: white;
2406      text-shadow: 0 2px 8px rgba(0,0,0,0.2);
2407      letter-spacing: -1.5px; margin-bottom: 8px;
2408    }
2409
2410    .container {
2411      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2412      backdrop-filter: blur(22px) saturate(140%);
2413      -webkit-backdrop-filter: blur(22px) saturate(140%);
2414      padding: 48px;
2415      border-radius: 16px;
2416      width: 100%; max-width: 460px;
2417      box-shadow: 0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.25);
2418      border: 1px solid rgba(255,255,255,0.28);
2419      text-align: center;
2420      position: relative; z-index: 1;
2421      animation: slideUp 0.6s ease-out 0.2s both;
2422    }
2423
2424    h2 {
2425      font-size: 32px; font-weight: 600; color: white;
2426      margin-bottom: 8px;
2427      text-shadow: 0 2px 8px rgba(0,0,0,0.2);
2428    }
2429
2430    .subtitle {
2431      font-size: 15px; color: rgba(255,255,255,0.85);
2432      margin-bottom: 32px; line-height: 1.5;
2433    }
2434
2435    .input-group {
2436      margin-bottom: 16px;
2437      text-align: left;
2438    }
2439
2440    label {
2441      display: block;
2442      font-size: 14px; font-weight: 600; color: white;
2443      margin-bottom: 8px;
2444      text-shadow: 0 1px 3px rgba(0,0,0,0.2);
2445    }
2446
2447    input {
2448      width: 100%;
2449      padding: 14px 18px;
2450      border-radius: 12px;
2451      border: 2px solid rgba(255,255,255,0.3);
2452      font-size: 16px;
2453      transition: all 0.3s cubic-bezier(0.4,0,0.2,1);
2454      background: rgba(255,255,255,0.15);
2455      backdrop-filter: blur(20px) saturate(150%);
2456      -webkit-backdrop-filter: blur(20px) saturate(150%);
2457      font-family: inherit;
2458      color: white;
2459      font-weight: 500;
2460      box-shadow: 0 4px 20px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.25);
2461    }
2462
2463    input:focus {
2464      outline: none;
2465      border-color: rgba(255,255,255,0.6);
2466      background: rgba(255,255,255,0.25);
2467      box-shadow: 0 0 0 4px rgba(255,255,255,0.15), 0 8px 30px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.4);
2468      transform: translateY(-2px);
2469    }
2470
2471    input::placeholder {
2472      color: rgba(255,255,255,0.5);
2473      font-weight: 400;
2474    }
2475
2476    input.error {
2477      border-color: rgba(239,68,68,0.6);
2478      background: rgba(239,68,68,0.1);
2479    }
2480
2481    .password-hint {
2482      font-size: 12px;
2483      color: rgba(255,255,255,0.7);
2484      margin-top: 6px;
2485      text-align: left;
2486    }
2487
2488    button {
2489      width: 100%;
2490      padding: 16px;
2491      margin-top: 8px;
2492      border-radius: 980px;
2493      border: 1px solid rgba(255,255,255,0.3);
2494      background: rgba(255,255,255,0.25);
2495      backdrop-filter: blur(10px);
2496      color: white;
2497      font-size: 16px; font-weight: 600;
2498      cursor: pointer;
2499      transition: all 0.3s;
2500      box-shadow: 0 4px 12px rgba(0,0,0,0.12);
2501      font-family: inherit;
2502      position: relative;
2503      overflow: hidden;
2504    }
2505
2506    button:hover {
2507      background: rgba(255,255,255,0.35);
2508      transform: translateY(-2px);
2509      box-shadow: 0 6px 20px rgba(0,0,0,0.18);
2510    }
2511
2512    button:active { transform: translateY(0); }
2513
2514    button.loading {
2515      color: transparent;
2516      pointer-events: none;
2517    }
2518
2519    button.loading::after {
2520      content: '';
2521      position: absolute;
2522      width: 20px; height: 20px;
2523      top: 50%; left: 50%;
2524      margin-left: -10px; margin-top: -10px;
2525      border: 3px solid rgba(255,255,255,0.3);
2526      border-radius: 50%;
2527      border-top-color: white;
2528      animation: spin 0.8s linear infinite;
2529    }
2530
2531    .message {
2532      margin-top: 16px;
2533      padding: 12px 16px;
2534      border-radius: 10px;
2535      font-size: 14px; font-weight: 600;
2536      text-align: left;
2537      display: none;
2538    }
2539
2540    .error-message {
2541      color: white;
2542      background: rgba(239,68,68,0.3);
2543      backdrop-filter: blur(10px);
2544      border: 1px solid rgba(239,68,68,0.4);
2545    }
2546
2547    .success-message {
2548      color: white;
2549      background: rgba(16,185,129,0.3);
2550      backdrop-filter: blur(10px);
2551      border: 1px solid rgba(16,185,129,0.4);
2552    }
2553
2554    .message.show { display: block; }
2555
2556    .back-btn {
2557      display: inline-block;
2558      width: 100%;
2559      padding: 12px;
2560      margin-top: 16px;
2561      border-radius: 980px;
2562      border: 1px solid rgba(255,255,255,0.3);
2563      background: rgba(255,255,255,0.15);
2564      color: white;
2565      font-size: 15px; font-weight: 600;
2566      cursor: pointer;
2567      transition: all 0.3s;
2568      text-decoration: none;
2569      text-align: center;
2570    }
2571
2572    .back-btn:hover {
2573      background: rgba(255,255,255,0.25);
2574      transform: translateY(-2px);
2575    }
2576
2577    @media (max-width: 640px) {
2578      .brand-logo { font-size: 64px; }
2579      .brand-title { font-size: 42px; }
2580      .container { padding: 32px 24px; }
2581      h2 { font-size: 28px; }
2582      input { font-size: 16px; }
2583    }
2584  </style>
2585</head>
2586<body>
2587  <div class="brand-header">
2588    <a href="/" style="text-decoration: none;">
2589      <div class="brand-logo">🌳</div>
2590      <h1 class="brand-title">TreeOS</h1>
2591    </a>
2592  </div>
2593
2594  <div class="container">
2595    <h2>Reset Password</h2>
2596    <p class="subtitle">Enter your new password below</p>
2597
2598    <form id="resetForm">
2599      <div class="input-group">
2600        <label for="password">New Password</label>
2601        <input
2602          type="password"
2603          id="password"
2604          placeholder="Enter new password"
2605          required
2606          autocomplete="new-password"
2607        />
2608        <div class="password-hint">Must be at least 8 characters</div>
2609      </div>
2610
2611      <div class="input-group">
2612        <label for="confirm">Confirm Password</label>
2613        <input
2614          type="password"
2615          id="confirm"
2616          placeholder="Confirm new password"
2617          required
2618          autocomplete="new-password"
2619        />
2620      </div>
2621
2622      <button type="submit" id="resetBtn">Reset Password</button>
2623    </form>
2624
2625    <div id="errorMessage" class="message error-message"></div>
2626    <div id="successMessage" class="message success-message">
2627      ✓ Password reset successful! Redirecting to login...
2628    </div>
2629
2630    <a href="/login" class="back-btn">← Back to Login</a>
2631  </div>
2632
2633  <script>
2634    const form = document.getElementById("resetForm");
2635    const passwordInput = document.getElementById("password");
2636    const confirmInput = document.getElementById("confirm");
2637    const errorEl = document.getElementById("errorMessage");
2638    const successEl = document.getElementById("successMessage");
2639    const btn = document.getElementById("resetBtn");
2640
2641    confirmInput.addEventListener("input", () => {
2642      if (confirmInput.value && passwordInput.value !== confirmInput.value) {
2643        confirmInput.classList.add("error");
2644      } else {
2645        confirmInput.classList.remove("error");
2646      }
2647    });
2648
2649    form.addEventListener("submit", async (e) => {
2650      e.preventDefault();
2651
2652      const password = passwordInput.value;
2653      const confirm = confirmInput.value;
2654
2655      errorEl.classList.remove("show");
2656      successEl.classList.remove("show");
2657      passwordInput.classList.remove("error");
2658      confirmInput.classList.remove("error");
2659
2660      if (password.length < 8) {
2661        errorEl.textContent = "Password must be at least 8 characters.";
2662        errorEl.classList.add("show");
2663        passwordInput.classList.add("error");
2664        passwordInput.focus();
2665        return;
2666      }
2667
2668      if (password !== confirm) {
2669        errorEl.textContent = "Passwords do not match.";
2670        errorEl.classList.add("show");
2671        confirmInput.classList.add("error");
2672        confirmInput.focus();
2673        return;
2674      }
2675
2676      btn.classList.add("loading");
2677      btn.disabled = true;
2678
2679      try {
2680        const res = await fetch("/api/v1/user/reset-password", {
2681          method: "POST",
2682          headers: { "Content-Type": "application/json" },
2683          body: JSON.stringify({ token: "${token}", password }),
2684        });
2685
2686        const data = await res.json();
2687
2688        if (!res.ok) {
2689          errorEl.textContent = data.message || "Reset failed. Please try again.";
2690          errorEl.classList.add("show");
2691          btn.classList.remove("loading");
2692          btn.disabled = false;
2693          return;
2694        }
2695
2696        successEl.classList.add("show");
2697        form.style.display = "none";
2698
2699        setTimeout(() => {
2700          window.location.href = "/login";
2701        }, 2000);
2702
2703      } catch (err) {
2704        errorEl.textContent = "An error occurred. Please try again.";
2705        errorEl.classList.add("show");
2706        btn.classList.remove("loading");
2707        btn.disabled = false;
2708      }
2709    });
2710  </script>
2711</body>
2712</html>
2713    `);
2714}
2715
2716// ═══════════════════════════════════════════════════════════════════
2717// 6. Reset Password POST responses
2718// ═══════════════════════════════════════════════════════════════════
2719export function renderResetPasswordMismatch({ token }) {
2720      return (`
2721        <html><body style="font-family:sans-serif; padding:20px;">
2722        <h2>Passwords Do Not Match</h2>
2723        <p><a href="/api/v1/user/reset-password/${token}">Try Again</a></p>
2724        </body></html>
2725      `);
2726}
2727
2728export function renderResetPasswordInvalid() {
2729      return (`
2730        <html><body style="font-family:sans-serif; padding:20px;">
2731        <h2>Reset Link Expired or Invalid</h2>
2732        <p>Please request a new password reset.</p>
2733        </body></html>
2734      `);
2735}
2736
2737export function renderResetPasswordSuccess() {
2738    return (`
2739      <html><body style="font-family:sans-serif; padding:20px;">
2740      <h2>Password Reset Successfully</h2>
2741      <p>You can now log in with your new password.</p>
2742      </body></html>
2743    `);
2744}
2745
2746// ═══════════════════════════════════════════════════════════════════
2747// 7. Raw Ideas List - GET /user/:userId/raw-ideas
2748// ═══════════════════════════════════════════════════════════════════
2749export function renderRawIdeasList({ userId, user, rawIdeas, query, statusFilter, tabs, tabUrl, token, AUTO_PLACE_ELIGIBLE }) {
2750  const tokenQS = token ? `?token=${token}&html` : `?html`;
2751    return (`
2752<!DOCTYPE html>
2753<html lang="en">
2754<head>
2755  <meta charset="UTF-8">
2756  <meta name="viewport" content="width=device-width, initial-scale=1.0">
2757  <meta name="theme-color" content="#667eea">
2758  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2759<title>${escapeHtml(user.username)} -- Raw Ideas</title>
2760  <style>
2761${baseStyles}
2762${backNavStyles}
2763${glassHeaderStyles}
2764${emptyStateStyles}
2765${responsiveBase}
2766
2767.header-subtitle {
2768  margin-bottom: 20px;
2769}
2770
2771
2772.auto-place-row {
2773  display: flex;
2774  align-items: center;
2775  justify-content: space-between;
2776  gap: 14px;
2777  margin: 16px 0 0;
2778  padding: 14px 16px;
2779  background: rgba(255, 255, 255, 0.06);
2780  border: 1px solid rgba(255, 255, 255, 0.12);
2781  border-radius: 12px;
2782}
2783.auto-place-label {
2784  font-size: 14px;
2785  font-weight: 600;
2786  color: rgba(255, 255, 255, 0.8);
2787}
2788.auto-place-hint {
2789  font-size: 11px;
2790  color: rgba(255, 255, 255, 0.45);
2791  margin-top: 2px;
2792}
2793.auto-place-toggle {
2794  position: relative;
2795  width: 54px; height: 28px;
2796  border-radius: 999px;
2797  background: rgba(255, 255, 255, 0.2);
2798  border: 1px solid rgba(255, 255, 255, 0.3);
2799  backdrop-filter: blur(18px);
2800  cursor: pointer;
2801  transition: all 0.25s ease;
2802  flex-shrink: 0;
2803}
2804.auto-place-toggle.active {
2805  background: rgba(72, 187, 178, 0.45);
2806  box-shadow: 0 0 16px rgba(72, 187, 178, 0.35);
2807}
2808.auto-place-toggle.muted {
2809  opacity: 0.4;
2810  cursor: not-allowed;
2811}
2812.auto-place-toggle-knob {
2813  position: absolute;
2814  top: 4px; left: 4px;
2815  width: 20px; height: 20px;
2816  border-radius: 50%;
2817  background: white;
2818  transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
2819}
2820.auto-place-toggle.active .auto-place-toggle-knob {
2821  left: 28px;
2822}
2823
2824@keyframes waterDrift {
2825  0% { transform: translateY(-1px); }
2826  100% { transform: translateY(1px); }
2827}
2828
2829/* Glass Search Form */
2830.search-form {
2831  display: flex;
2832  gap: 10px;
2833  flex-wrap: wrap;
2834}
2835
2836.search-form input[type="text"] {
2837  flex: 1;
2838  min-width: 200px;
2839  padding: 12px 16px;
2840  font-size: 16px;
2841  border-radius: 12px;
2842  border: 2px solid rgba(255, 255, 255, 0.3);
2843  background: rgba(255, 255, 255, 0.2);
2844  backdrop-filter: blur(20px) saturate(150%);
2845  -webkit-backdrop-filter: blur(20px) saturate(150%);
2846  font-family: inherit;
2847  color: white;
2848  font-weight: 500;
2849  transition: all 0.3s;
2850  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
2851    inset 0 1px 0 rgba(255, 255, 255, 0.25);
2852}
2853
2854.search-form input[type="text"]::placeholder {
2855  color: rgba(255, 255, 255, 0.65);
2856}
2857
2858.search-form input[type="text"]:focus {
2859  outline: none;
2860  border-color: rgba(255, 255, 255, 0.6);
2861  background: rgba(255, 255, 255, 0.3);
2862  box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15),
2863    0 8px 30px rgba(0, 0, 0, 0.15),
2864    inset 0 1px 0 rgba(255, 255, 255, 0.4);
2865  transform: translateY(-2px);
2866}
2867
2868.search-form button {
2869  position: relative;
2870  overflow: hidden;
2871  padding: 12px 28px;
2872  font-size: 15px;
2873  font-weight: 600;
2874  letter-spacing: -0.2px;
2875  border-radius: 980px;
2876  border: 1px solid rgba(255, 255, 255, 0.3);
2877  background: rgba(255, 255, 255, 0.25);
2878  backdrop-filter: blur(10px);
2879  color: white;
2880  cursor: pointer;
2881  transition: all 0.3s;
2882  font-family: inherit;
2883  white-space: nowrap;
2884  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
2885}
2886
2887.search-form button::before {
2888  content: "";
2889  position: absolute;
2890  inset: -40%;
2891  background: radial-gradient(
2892    120% 60% at 0% 0%,
2893    rgba(255, 255, 255, 0.35),
2894    transparent 60%
2895  );
2896  opacity: 0;
2897  transform: translateX(-30%) translateY(-10%);
2898  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
2899  pointer-events: none;
2900}
2901
2902.search-form button:hover {
2903  background: rgba(255, 255, 255, 0.35);
2904  transform: translateY(-2px);
2905  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
2906}
2907
2908.search-form button:hover::before {
2909  opacity: 1;
2910  transform: translateX(30%) translateY(10%);
2911}
2912
2913/* Glass Ideas List */
2914.ideas-list {
2915  list-style: none;
2916  display: flex;
2917  flex-direction: column;
2918  gap: 16px;
2919}
2920
2921.idea-card {
2922  position: relative;
2923  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
2924  backdrop-filter: blur(22px) saturate(140%);
2925  -webkit-backdrop-filter: blur(22px) saturate(140%);
2926  border-radius: 16px;
2927  padding: 24px;
2928  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
2929    inset 0 1px 0 rgba(255, 255, 255, 0.25);
2930  border: 1px solid rgba(255, 255, 255, 0.28);
2931  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
2932  color: white;
2933  overflow: hidden;
2934  animation: fadeInUp 0.5s ease-out both;
2935}
2936
2937.ideas-list {
2938  animation: fadeInUp 0.6s ease-out 0.2s both;
2939}
2940
2941.idea-card:nth-child(1) { animation-delay: 0.25s; }
2942.idea-card:nth-child(2) { animation-delay: 0.3s; }
2943.idea-card:nth-child(3) { animation-delay: 0.35s; }
2944.idea-card:nth-child(4) { animation-delay: 0.4s; }
2945.idea-card:nth-child(5) { animation-delay: 0.45s; }
2946.idea-card:nth-child(n+6) { animation-delay: 0.5s; }
2947
2948
2949.idea-card::before {
2950  content: "";
2951  position: absolute;
2952  inset: -40%;
2953  background: radial-gradient(
2954    120% 60% at 0% 0%,
2955    rgba(255, 255, 255, 0.35),
2956    transparent 60%
2957  );
2958  opacity: 0;
2959  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
2960  pointer-events: none;
2961}
2962
2963.idea-card:hover {
2964  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
2965  transform: translateY(-2px);
2966  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
2967}
2968
2969.idea-card:hover::before {
2970  opacity: 1;
2971  transform: translateX(30%) translateY(10%);
2972}
2973
2974.delete-button {
2975  position: absolute;
2976  top: 20px;
2977  right: 20px;
2978  background: rgba(255, 255, 255, 0.2);
2979  border: 1px solid rgba(255, 255, 255, 0.3);
2980  border-radius: 50%;
2981  width: 32px;
2982  height: 32px;
2983  display: flex;
2984  align-items: center;
2985  justify-content: center;
2986  font-size: 18px;
2987  cursor: pointer;
2988  color: white;
2989  padding: 0;
2990  line-height: 1;
2991  opacity: 0.8;
2992  transition: all 0.3s;
2993  z-index: 10;
2994}
2995
2996.delete-button:hover {
2997  opacity: 1;
2998  background: rgba(239, 68, 68, 0.4);
2999  border-color: rgba(239, 68, 68, 0.6);
3000  transform: scale(1.1);
3001  box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
3002}
3003
3004.idea-content {
3005  padding-right: 48px;
3006  margin-bottom: 16px;
3007}
3008
3009.idea-link {
3010  color: white;
3011  text-decoration: none;
3012  font-size: 16px;
3013  line-height: 1.6;
3014  display: block;
3015  word-wrap: break-word;
3016  transition: all 0.2s;
3017  font-weight: 400;
3018}
3019
3020.idea-link:hover {
3021  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
3022}
3023
3024.file-badge {
3025  display: inline-block;
3026  padding: 4px 10px;
3027  background: rgba(255, 255, 255, 0.25);
3028  color: white;
3029  border-radius: 6px;
3030  font-size: 11px;
3031  font-weight: 600;
3032  margin-right: 8px;
3033  border: 1px solid rgba(255, 255, 255, 0.3);
3034  text-transform: uppercase;
3035  letter-spacing: 0.5px;
3036}
3037
3038/* Subtle Transfer Form */
3039.transfer-form {
3040  display: flex;
3041  gap: 8px;
3042  margin-top: 16px;
3043  padding-top: 16px;
3044  border-top: 1px solid rgba(255, 255, 255, 0.15);
3045  flex-wrap: wrap;
3046  align-items: center;
3047}
3048
3049.transfer-form input[type="text"] {
3050  flex: 1;
3051  min-width: 180px;
3052  padding: 10px 14px;
3053  font-size: 14px;
3054  border-radius: 10px;
3055  border: 1px solid rgba(255, 255, 255, 0.25);
3056  background: rgba(255, 255, 255, 0.15);
3057  backdrop-filter: blur(10px);
3058  -webkit-backdrop-filter: blur(10px);
3059  font-family: inherit;
3060  color: white;
3061  font-weight: 500;
3062  transition: all 0.3s;
3063  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
3064}
3065
3066.transfer-form input[type="text"]::placeholder {
3067  color: rgba(255, 255, 255, 0.5);
3068  font-size: 13px;
3069}
3070
3071.transfer-form input[type="text"]:focus {
3072  outline: none;
3073  border-color: rgba(255, 255, 255, 0.4);
3074  background: rgba(255, 255, 255, 0.2);
3075  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1),
3076    inset 0 1px 0 rgba(255, 255, 255, 0.2);
3077}
3078
3079.transfer-form button {
3080  padding: 10px 18px;
3081  font-size: 13px;
3082  font-weight: 600;
3083  border-radius: 980px;
3084  border: 1px solid rgba(255, 255, 255, 0.25);
3085  background: rgba(255, 255, 255, 0.15);
3086  backdrop-filter: blur(10px);
3087  color: white;
3088  cursor: pointer;
3089  transition: all 0.3s;
3090  font-family: inherit;
3091  white-space: nowrap;
3092  opacity: 0.85;
3093  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
3094}
3095
3096.transfer-form button:hover {
3097  background: rgba(255, 255, 255, 0.25);
3098  opacity: 1;
3099  transform: translateY(-1px);
3100  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
3101    inset 0 1px 0 rgba(255, 255, 255, 0.2);
3102}
3103
3104/* Metadata */
3105.idea-meta {
3106  margin-top: 12px;
3107  font-size: 12px;
3108  color: rgba(255, 255, 255, 0.75);
3109  display: flex;
3110  align-items: center;
3111  gap: 6px;
3112}
3113
3114/* Status badges */
3115.status-badge {
3116  display: inline-block;
3117  margin-left: 10px;
3118  padding: 2px 8px;
3119  border-radius: 20px;
3120  font-size: 11px;
3121  font-weight: 600;
3122  vertical-align: middle;
3123  letter-spacing: 0.3px;
3124}
3125.status-badge--pending { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.7); }
3126.status-badge--processing { background: rgba(255,200,0,0.25); color: #ffe066; }
3127.status-badge--succeeded { background: rgba(50,220,120,0.25); color: #7effc0; }
3128.status-badge--stuck { background: rgba(255,140,0,0.25); color: #ffcf7e; }
3129
3130/* Placed / stuck notices */
3131.placed-notice .chat-link {
3132  color: #7effc0;
3133  opacity: 0.8;
3134  text-decoration: underline;
3135  white-space: nowrap;
3136}
3137.placed-notice .chat-link:hover { opacity: 1; }
3138.placed-notice {
3139  margin-top: 12px;
3140  padding: 10px 14px;
3141  background: rgba(50,220,120,0.15);
3142  border-radius: 10px;
3143  font-size: 13px;
3144  color: #7effc0;
3145  border: 1px solid rgba(50,220,120,0.25);
3146}
3147.stuck-notice {
3148  margin-top: 12px;
3149  margin-bottom: 8px;
3150  padding: 10px 14px;
3151  background: rgba(255,140,0,0.15);
3152  border-radius: 10px;
3153  font-size: 13px;
3154  color: #ffcf7e;
3155  border: 1px solid rgba(255,140,0,0.25);
3156}
3157.processing-notice {
3158  margin-top: 12px;
3159  padding: 10px 14px;
3160  background: rgba(255,200,0,0.12);
3161  border-radius: 10px;
3162  font-size: 13px;
3163  color: #ffe066;
3164  border: 1px solid rgba(255,200,0,0.2);
3165}
3166
3167/* Status filter tabs */
3168.filter-tabs {
3169  display: flex;
3170  gap: 6px;
3171  flex-wrap: wrap;
3172  margin-top: 14px;
3173}
3174.filter-tab {
3175  padding: 5px 14px;
3176  border-radius: 980px;
3177  font-size: 12px;
3178  font-weight: 600;
3179  text-decoration: none;
3180  color: rgba(255,255,255,0.6);
3181  background: rgba(255,255,255,0.08);
3182  border: 1px solid rgba(255,255,255,0.12);
3183  transition: all 0.2s;
3184  letter-spacing: 0.3px;
3185}
3186.filter-tab:hover { background: rgba(255,255,255,0.16); color: rgba(255,255,255,0.9); }
3187.filter-tab--active { background: rgba(255,255,255,0.22); color: white; border-color: rgba(255,255,255,0.35); }
3188
3189/* Auto-place button */
3190.auto-place-btn {
3191  margin-top: 14px;
3192  padding: 10px 20px;
3193  font-size: 13px;
3194  font-weight: 600;
3195  border-radius: 980px;
3196  border: 1px solid rgba(255,255,255,0.3);
3197  background: rgba(255,255,255,0.18);
3198  backdrop-filter: blur(10px);
3199  color: white;
3200  cursor: pointer;
3201  transition: all 0.3s;
3202  font-family: inherit;
3203}
3204.auto-place-btn:hover {
3205  background: rgba(255,255,255,0.28);
3206  transform: translateY(-1px);
3207}
3208.auto-place-btn:disabled {
3209  opacity: 0.5;
3210  cursor: not-allowed;
3211  transform: none;
3212}
3213
3214/* Responsive Design */
3215@media (max-width: 640px) {
3216  body {
3217    padding: 16px;
3218  }
3219
3220  .search-form {
3221    flex-direction: column;
3222  }
3223
3224  .search-form input[type="text"] {
3225    width: 100%;
3226    min-width: 0;
3227    font-size: 16px;
3228  }
3229
3230  .search-form button {
3231    width: 100%;
3232  }
3233
3234  .idea-card {
3235    padding: 20px 16px;
3236  }
3237
3238  .delete-button {
3239    top: 16px;
3240    right: 16px;
3241    width: 28px;
3242    height: 28px;
3243    font-size: 16px;
3244  }
3245
3246  .transfer-form {
3247    flex-direction: column;
3248  }
3249
3250  .transfer-form input[type="text"] {
3251    width: 100%;
3252    min-width: 0;
3253  }
3254
3255  .transfer-form button {
3256    width: 100%;
3257  }
3258
3259}
3260
3261
3262  </style>
3263</head>
3264<body>
3265  <div class="container">
3266    <!-- Back Navigation -->
3267    <div class="back-nav">
3268      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
3269        ← Back to Profile
3270      </a>
3271    </div>
3272
3273    <!-- Header Section -->
3274    <div class="header">
3275      <h1>
3276        Raw Ideas for
3277<a href="/api/v1/user/${userId}${tokenQS}">${escapeHtml(user.username)}</a>
3278      </h1>
3279      <div class="header-subtitle">
3280These will be placed onto your trees automatically while you dream (Standard+ plans)</div>
3281
3282      <div class="auto-place-row">
3283        <div>
3284          <div class="auto-place-label">Auto-place ideas</div>
3285          <div class="auto-place-hint">${
3286            AUTO_PLACE_ELIGIBLE.includes(user.profileType)
3287              ? "Pending ideas are placed automatically every 15 minutes while you're offline."
3288              : "Available on Standard, Premium, and God plans."
3289          }</div>
3290        </div>
3291        <div
3292          id="autoPlaceToggle"
3293          class="auto-place-toggle${getUserMeta(user, "rawIdeas")?.autoPlace !== false ? " active" : ""}${!AUTO_PLACE_ELIGIBLE.includes(user.profileType) ? " muted" : ""}"
3294          onclick="${AUTO_PLACE_ELIGIBLE.includes(user.profileType) ? "toggleAutoPlace()" : ""}"
3295        >
3296          <div class="auto-place-toggle-knob"></div>
3297        </div>
3298      </div>
3299
3300      <!-- Search Form -->
3301      <form method="GET" action="/api/v1/user/${userId}/raw-ideas" class="search-form">
3302        <input type="hidden" name="token" value="${token}">
3303        <input type="hidden" name="html" value="">
3304        ${statusFilter !== "pending" ? `<input type="hidden" name="status" value="${statusFilter}">` : ""}
3305        <input
3306          type="text"
3307          name="q"
3308          placeholder="Search raw ideas..."
3309          value="${query.replace(/"/g, "&quot;")}"
3310        />
3311        <button type="submit">Search</button>
3312      </form>
3313
3314      <!-- Status Filter Tabs -->
3315      <div class="filter-tabs">
3316        ${tabs.map((t) => `<a href="${tabUrl(t.key)}" class="filter-tab${statusFilter === t.key ? " filter-tab--active" : ""}">${t.label}</a>`).join("")}
3317      </div>
3318    </div>
3319
3320    <!-- Raw Ideas List -->
3321    ${
3322      rawIdeas.length > 0
3323        ? `
3324    <ul class="ideas-list">
3325      ${rawIdeas
3326        .map(
3327          (r) => `
3328        <li class="idea-card idea-card--${r.status || "pending"}" data-raw-idea-id="${r._id}" data-status="${r.status || "pending"}">
3329          ${!r.status || r.status === "pending" || r.status === "stuck" ? `<button class="delete-button" title="Delete raw idea">✕</button>` : ""}
3330
3331          <div class="idea-content">
3332            <a
3333              href="/api/v1/user/${userId}/raw-ideas/${r._id}${tokenQS}"
3334              class="idea-link"
3335            >
3336             ${
3337               r.contentType === "file"
3338                 ? `<span class="file-badge">FILE</span>${escapeHtml(r.content)}`
3339                 : escapeHtml(r.content)
3340             }
3341            </a>
3342            <span class="status-badge status-badge--${r.status || "pending"}">
3343              ${r.status === "processing" ? "⏳ processing" : r.status === "succeeded" ? "✓ placed by AI" : r.status === "stuck" ? "⚠ stuck" : r.status === "deleted" ? "deleted" : "pending"}
3344            </span>
3345          </div>
3346
3347          ${
3348            r.status === "succeeded"
3349              ? `
3350          <div class="placed-notice">Placed automatically by AI${r.placedAt ? ` on ${new Date(r.placedAt).toLocaleString()}` : ""}.${r.aiSessionId ? ` <a class="chat-link" href="/api/v1/user/${userId}/chats?sessionId=${r.aiSessionId}${token ? `&token=${token}` : ""}&html">View AI chat →</a>` : ""}</div>
3351          `
3352              : r.status === "processing"
3353                ? `
3354          <div class="processing-notice">Being processed by AI — please wait.${r.aiSessionId ? ` <a class="chat-link" href="/api/v1/user/${userId}/chats?sessionId=${r.aiSessionId}${token ? `&token=${token}` : ""}&html">View AI chat →</a>` : ""}</div>
3355          `
3356                : r.status === "deleted"
3357                  ? ``
3358                  : `
3359          ${r.status === "stuck" ? `<div class="stuck-notice">Auto-placement failed — place manually below.${r.aiSessionId ? ` <a class="chat-link" href="/api/v1/user/${userId}/chats?sessionId=${r.aiSessionId}${token ? `&token=${token}` : ""}&html">View AI chat →</a>` : ""}</div>` : ""}
3360
3361          ${
3362            (!r.status || r.status === "pending") && r.contentType !== "file"
3363              ? `
3364          <button
3365            class="auto-place-btn"
3366            data-raw-idea-id="${r._id}"
3367            data-token="${token}"
3368            data-user-id="${userId}"
3369          >✨ Auto-place</button>
3370          `
3371              : ""
3372          }
3373
3374          <form
3375            method="POST"
3376            action="/api/v1/user/${userId}/raw-ideas/${
3377              r._id
3378            }/transfer?token=${token}&html"
3379            class="transfer-form"
3380          >
3381            <input
3382              type="text"
3383              name="nodeId"
3384              placeholder="Target node ID"
3385              required
3386            />
3387            <button type="submit">Transfer to Node</button>
3388          </form>
3389          `
3390          }
3391
3392          <div class="idea-meta">
3393            ${new Date(r.createdAt).toLocaleString()}
3394          </div>
3395        </li>
3396      `,
3397        )
3398        .join("")}
3399    </ul>
3400    `
3401        : `
3402    <div class="empty-state">
3403      <div class="empty-state-icon">💭</div>
3404      <div class="empty-state-text">No ${statusFilter === "pending" ? "" : statusFilter + " "}raw ideas</div>
3405      <div class="empty-state-subtext">
3406        ${
3407          query.trim() !== ""
3408            ? "Try a different search term"
3409            : statusFilter === "pending"
3410              ? "Start capturing your ideas from the user page"
3411              : "Nothing here yet"
3412        }
3413      </div>
3414    </div>
3415    `
3416    }
3417  </div>
3418
3419 <script>
3420    const urlToken = new URLSearchParams(window.location.search).get("token") || "";
3421    const tokenQs = urlToken ? "?token=" + encodeURIComponent(urlToken) : "";
3422
3423    // Auto-refresh if any card is processing
3424    if (document.querySelector(".idea-card[data-status='processing']")) {
3425      setTimeout(() => window.location.reload(), 3000);
3426    }
3427
3428    document.addEventListener("click", async function(e) {
3429      // ── Delete ──────────────────────────────────────────────────────────
3430      const deleteBtn = e.target.closest(".delete-button");
3431      if (deleteBtn) {
3432        e.preventDefault();
3433        e.stopPropagation();
3434
3435        const card = deleteBtn.closest(".idea-card");
3436        if (!card) return;
3437        const rawIdeaId = card.dataset.rawIdeaId;
3438
3439        if (!confirm("Delete this raw idea? This cannot be undone.")) return;
3440
3441        try {
3442          const res = await fetch(
3443            "/api/v1/user/${userId}/raw-ideas/" + rawIdeaId + tokenQs,
3444            { method: "DELETE" }
3445          );
3446          const data = await res.json();
3447          if (!data.success) throw new Error(data.error || "Delete failed");
3448
3449          card.style.transition = "all 0.3s ease";
3450          card.style.opacity = "0";
3451          card.style.transform = "translateX(-20px)";
3452          setTimeout(() => card.remove(), 300);
3453        } catch (err) {
3454          alert("Failed to delete: " + (err.message || "Unknown error"));
3455        }
3456        return;
3457      }
3458
3459      // ── Auto-place ───────────────────────────────────────────────────────
3460      const autoBtn = e.target.closest(".auto-place-btn");
3461      if (autoBtn) {
3462        e.preventDefault();
3463        const rawIdeaId = autoBtn.dataset.rawIdeaId;
3464        const card = autoBtn.closest(".idea-card");
3465
3466        autoBtn.disabled = true;
3467        autoBtn.textContent = "⏳ Starting…";
3468
3469        try {
3470          const res = await fetch(
3471            "/api/v1/user/${userId}/raw-ideas/" + rawIdeaId + "/place" + tokenQs,
3472            { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: "user" }) }
3473          );
3474          if (res.status === 202) {
3475            card.dataset.status = "processing";
3476            // Update badge
3477            const badge = card.querySelector(".status-badge");
3478            if (badge) { badge.className = "status-badge status-badge--processing"; badge.textContent = "⏳ processing"; }
3479            autoBtn.textContent = "⏳ Processing…";
3480            // Reload after 4s to show result
3481            setTimeout(() => window.location.reload(), 4000);
3482          } else {
3483            const data = await res.json().catch(() => ({}));
3484            autoBtn.disabled = false;
3485            autoBtn.textContent = "✨ Auto-place";
3486            alert(data.error || "Could not start orchestration");
3487          }
3488        } catch (err) {
3489          autoBtn.disabled = false;
3490          autoBtn.textContent = "✨ Auto-place";
3491          alert("Error: " + (err.message || "Unknown"));
3492        }
3493        return;
3494      }
3495    }, true);
3496
3497    async function toggleAutoPlace() {
3498      var toggle = document.getElementById("autoPlaceToggle");
3499      if (!toggle || toggle.classList.contains("muted")) return;
3500      var isActive = toggle.classList.contains("active");
3501      var newEnabled = !isActive;
3502      toggle.classList.toggle("active");
3503      try {
3504        var res = await fetch(
3505          "/api/v1/user/${userId}/raw-ideas/auto-place" + tokenQs,
3506          { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: newEnabled }) }
3507        );
3508        var data = await res.json();
3509        if (!data.success) {
3510          toggle.classList.toggle("active");
3511          alert(data.error || "Failed to toggle");
3512        }
3513      } catch (err) {
3514        toggle.classList.toggle("active");
3515        alert("Error: " + (err.message || "Unknown"));
3516      }
3517    }
3518  </script>
3519</body>
3520</html>
3521`);
3522}
3523
3524// ═══════════════════════════════════════════════════════════════════
3525// 8a. Single Raw Idea (text) - GET /user/:userId/raw-ideas/:rawIdeaId
3526// ═══════════════════════════════════════════════════════════════════
3527export function renderRawIdeaText({ userId, rawIdea, back, backText, userLink, hasToken, token }) {
3528  const tokenQS = token ? `?token=${token}&html` : `?html`;
3529        return (`
3530<!DOCTYPE html>
3531<html lang="en">
3532<head>
3533  <meta charset="UTF-8">
3534  <meta name="viewport" content="width=device-width, initial-scale=1.0">
3535  <meta name="theme-color" content="#667eea">
3536  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
3537  <title>Raw Idea by ${escapeHtml(rawIdea.userId?.username || "User")} - TreeOS</title>
3538  <meta name="description" content="${escapeHtml((rawIdea.content || "").slice(0, 160))}" />
3539  <meta property="og:title" content="Raw Idea by ${escapeHtml(rawIdea.userId?.username || "User")} - TreeOS" />
3540  <meta property="og:description" content="${escapeHtml((rawIdea.content || "").slice(0, 160))}" />
3541  <meta property="og:type" content="article" />
3542  <meta property="og:site_name" content="TreeOS" />
3543  <meta property="og:image" content="${getLandUrl()}/tree.png" />
3544  <style>
3545${baseStyles}
3546${backNavStyles}
3547${responsiveBase}
3548
3549
3550    /* Raw Idea Card */
3551    .raw-idea-card {
3552      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3553      backdrop-filter: blur(22px) saturate(140%);
3554      -webkit-backdrop-filter: blur(22px) saturate(140%);
3555      border-radius: 16px;
3556      padding: 32px;
3557      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
3558        inset 0 1px 0 rgba(255, 255, 255, 0.25);
3559      border: 1px solid rgba(255, 255, 255, 0.28);
3560      position: relative;
3561      overflow: hidden;
3562      animation: fadeInUp 0.6s ease-out 0.1s both;
3563    }
3564
3565    /* User Info */
3566    .user-info {
3567      display: flex;
3568      align-items: center;
3569      gap: 8px;
3570      margin-bottom: 20px;
3571      padding-bottom: 16px;
3572      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
3573    }
3574
3575    .user-info::before {
3576      content: '💡';
3577      font-size: 18px;
3578    }
3579
3580    .user-info a {
3581      color: white;
3582      text-decoration: none;
3583      font-weight: 600;
3584      font-size: 15px;
3585      transition: all 0.2s;
3586      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3587    }
3588
3589    .user-info a:hover {
3590      text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
3591      transform: translateX(2px);
3592    }
3593
3594    .note-time {
3595      margin-left: auto;
3596      font-size: 13px;
3597      color: rgba(255, 255, 255, 0.6);
3598      font-weight: 400;
3599    }
3600
3601    /* Status badge */
3602    .status-row {
3603      display: flex;
3604      align-items: center;
3605      gap: 10px;
3606      margin-bottom: 16px;
3607      flex-wrap: wrap;
3608    }
3609    .status-badge {
3610      display: inline-block;
3611      padding: 4px 12px;
3612      border-radius: 20px;
3613      font-size: 12px;
3614      font-weight: 600;
3615      letter-spacing: 0.03em;
3616    }
3617    .status-badge--pending   { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.2); }
3618    .status-badge--processing{ background: rgba(255,200,0,0.25);   color: #ffe066;               border: 1px solid rgba(255,200,0,0.3); }
3619    .status-badge--succeeded { background: rgba(50,220,120,0.25);  color: #7effc0;               border: 1px solid rgba(50,220,120,0.3); }
3620    .status-badge--stuck     { background: rgba(255,140,0,0.25);   color: #ffcf7e;               border: 1px solid rgba(255,140,0,0.3); }
3621    .status-badge--deleted   { background: rgba(255,80,80,0.2);    color: #ff9ea0;               border: 1px solid rgba(255,80,80,0.25); }
3622    .ai-chat-link {
3623      display: inline-block;
3624      padding: 4px 12px;
3625      border-radius: 20px;
3626      font-size: 12px;
3627      font-weight: 600;
3628      background: rgba(255,255,255,0.15);
3629      color: rgba(255,255,255,0.9);
3630      border: 1px solid rgba(255,255,255,0.25);
3631      text-decoration: none;
3632      transition: background 0.2s;
3633    }
3634    .ai-chat-link:hover { background: rgba(255,255,255,0.25); }
3635
3636    /* Copy Button Bar */
3637    .copy-bar {
3638      display: flex;
3639      justify-content: flex-end;
3640      gap: 8px;
3641      margin-bottom: 16px;
3642    }
3643
3644    .copy-btn {
3645      background: rgba(255, 255, 255, 0.2);
3646      backdrop-filter: blur(10px);
3647      border: 1px solid rgba(255, 255, 255, 0.3);
3648      cursor: pointer;
3649      font-size: 20px;
3650      padding: 8px 12px;
3651      border-radius: 980px;
3652      transition: all 0.3s;
3653      position: relative;
3654      overflow: hidden;
3655    }
3656
3657    .copy-btn::before {
3658      content: "";
3659      position: absolute;
3660      inset: -40%;
3661      background: radial-gradient(
3662        120% 60% at 0% 0%,
3663        rgba(255, 255, 255, 0.35),
3664        transparent 60%
3665      );
3666      opacity: 0;
3667      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
3668      pointer-events: none;
3669    }
3670
3671    .copy-btn:hover {
3672      background: rgba(255, 255, 255, 0.3);
3673      transform: translateY(-2px);
3674      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
3675    }
3676
3677    .copy-btn:hover::before {
3678      opacity: 1;
3679      transform: translateX(30%) translateY(10%);
3680    }
3681
3682    .copy-btn:active {
3683      transform: translateY(0);
3684    }
3685
3686    #copyUrlBtn {
3687      background: rgba(255, 255, 255, 0.25);
3688    }
3689
3690    /* Raw Idea Content */
3691    pre {
3692      background: rgba(255, 255, 255, 0.3);
3693      backdrop-filter: blur(20px) saturate(150%);
3694      -webkit-backdrop-filter: blur(20px) saturate(150%);
3695      padding: 20px;
3696      border-radius: 12px;
3697      font-size: 16px;
3698      line-height: 1.7;
3699      white-space: pre-wrap;
3700      word-wrap: break-word;
3701      border: 1px solid rgba(255, 255, 255, 0.3);
3702      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
3703      color: #3d2f8f;
3704      font-weight: 600;
3705      text-shadow:
3706        0 0 10px rgba(102, 126, 234, 0.4),
3707        0 1px 3px rgba(255, 255, 255, 1);
3708      box-shadow:
3709        0 4px 20px rgba(0, 0, 0, 0.1),
3710        inset 0 1px 0 rgba(255, 255, 255, 0.4);
3711      position: relative;
3712      overflow: hidden;
3713      transition: all 0.3s ease;
3714    }
3715
3716    pre::before {
3717      content: "";
3718      position: absolute;
3719      inset: 0;
3720      background: linear-gradient(
3721        110deg,
3722        transparent 40%,
3723        rgba(255, 255, 255, 0.4),
3724        transparent 60%
3725      );
3726      opacity: 0;
3727      transform: translateX(-100%);
3728      pointer-events: none;
3729    }
3730
3731    pre:hover {
3732      border-color: rgba(255, 255, 255, 0.5);
3733      box-shadow:
3734        0 8px 32px rgba(102, 126, 234, 0.2),
3735        inset 0 1px 0 rgba(255, 255, 255, 0.6);
3736    }
3737
3738    pre.flash::before {
3739      opacity: 1;
3740      animation: glassShimmer 1.2s ease forwards;
3741    }
3742
3743    pre:hover::before {
3744      opacity: 1;
3745      animation: glassShimmer 1.2s ease forwards;
3746    }
3747
3748    pre.copied {
3749      animation: textGlow 0.8s ease-out;
3750    }
3751
3752    @keyframes textGlow {
3753      0% {
3754        box-shadow:
3755          0 4px 20px rgba(0, 0, 0, 0.1),
3756          inset 0 1px 0 rgba(255, 255, 255, 0.4);
3757      }
3758      50% {
3759        box-shadow:
3760          0 0 40px rgba(102, 126, 234, 0.6),
3761          0 0 60px rgba(102, 126, 234, 0.4),
3762          inset 0 1px 0 rgba(255, 255, 255, 0.8);
3763        text-shadow:
3764          0 0 20px rgba(102, 126, 234, 0.8),
3765          0 0 30px rgba(102, 126, 234, 0.6),
3766          0 1px 3px rgba(255, 255, 255, 1);
3767      }
3768      100% {
3769        box-shadow:
3770          0 4px 20px rgba(0, 0, 0, 0.1),
3771          inset 0 1px 0 rgba(255, 255, 255, 0.4);
3772      }
3773    }
3774
3775    @keyframes glassShimmer {
3776      0% {
3777        opacity: 0;
3778        transform: translateX(-120%) skewX(-15deg);
3779      }
3780      50% {
3781        opacity: 1;
3782      }
3783      100% {
3784        opacity: 0;
3785        transform: translateX(120%) skewX(-15deg);
3786      }
3787    }
3788
3789    /* Responsive */
3790
3791
3792  </style>
3793</head>
3794<body>
3795  <div class="container">
3796    <!-- Back Navigation -->
3797    <div class="back-nav">
3798<a href="${back}" class="back-link">${backText}</a>
3799      <button id="copyUrlBtn" class="copy-btn" title="Copy URL to share">🔗</button>
3800    </div>
3801
3802    <!-- Raw Idea Card -->
3803    <div class="raw-idea-card">
3804      <div class="user-info">
3805        ${userLink}
3806        ${rawIdea.createdAt ? `<span class="note-time">${new Date(rawIdea.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(rawIdea.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
3807      </div>
3808
3809      ${
3810        hasToken
3811          ? `<div class="status-row">
3812        <span class="status-badge status-badge--${rawIdea.status || "pending"}">
3813          ${rawIdea.status === "processing" ? "⏳ processing" : rawIdea.status === "succeeded" ? "✓ placed by AI" : rawIdea.status === "stuck" ? "⚠ stuck" : rawIdea.status === "deleted" ? "deleted" : "pending"}
3814        </span>
3815        ${rawIdea.aiSessionId && (rawIdea.status === "succeeded" || rawIdea.status === "stuck" || rawIdea.status === "processing") ? `<a class="ai-chat-link" href="/api/v1/user/${userId}/chats?sessionId=${rawIdea.aiSessionId}&token=${token}&html">View AI chat →</a>` : ""}
3816      </div>`
3817          : ""
3818      }
3819
3820      <div class="copy-bar">
3821        <button id="copyBtn" class="copy-btn" title="Copy raw idea">📋</button>
3822      </div>
3823
3824      <pre id="content">${escapeHtml(rawIdea.content)}</pre>
3825    </div>
3826  </div>
3827
3828  <script>
3829    const copyBtn = document.getElementById("copyBtn");
3830    const copyUrlBtn = document.getElementById("copyUrlBtn");
3831    const content = document.getElementById("content");
3832
3833    copyBtn.addEventListener("click", () => {
3834      navigator.clipboard.writeText(content.textContent).then(() => {
3835        copyBtn.textContent = "✔️";
3836        setTimeout(() => (copyBtn.textContent = "📋"), 900);
3837
3838        content.classList.add("copied");
3839        setTimeout(() => content.classList.remove("copied"), 800);
3840
3841        setTimeout(() => {
3842          content.classList.remove("flash");
3843          void content.offsetWidth;
3844          content.classList.add("flash");
3845          setTimeout(() => content.classList.remove("flash"), 1300);
3846        }, 600);
3847      });
3848    });
3849
3850    copyUrlBtn.addEventListener("click", () => {
3851      const url = new URL(window.location.href);
3852      url.searchParams.delete('token');
3853      if (!url.searchParams.has('html')) {
3854        url.searchParams.set('html', '');
3855      }
3856      navigator.clipboard.writeText(url.toString()).then(() => {
3857        copyUrlBtn.textContent = "✔️";
3858        setTimeout(() => (copyUrlBtn.textContent = "🔗"), 900);
3859      });
3860    });
3861  </script>
3862</body>
3863</html>
3864`);
3865}
3866
3867// ═══════════════════════════════════════════════════════════════════
3868// 8b. Single Raw Idea (file) - GET /user/:userId/raw-ideas/:rawIdeaId
3869// ═══════════════════════════════════════════════════════════════════
3870export function renderRawIdeaFile({ userId, rawIdea, back, backText, userLink, hasToken, token }) {
3871  const tokenQS = token ? `?token=${token}&html` : `?html`;
3872  const fileDeleted = rawIdea.content === "File was deleted";
3873  const fileUrl = fileDeleted ? "" : `/api/v1/uploads/${rawIdea.content}`;
3874  const filePath = fileDeleted
3875    ? ""
3876    : path.join(process.cwd(), "uploads", rawIdea.content);
3877  const mimeType = fileDeleted
3878    ? ""
3879    : mime.lookup(filePath) || "application/octet-stream";
3880  const mediaHtml = fileDeleted ? "" : renderMedia(fileUrl, mimeType);
3881  const fileName = fileDeleted ? "File was deleted" : rawIdea.content;
3882      return (`
3883<!DOCTYPE html>
3884<html lang="en">
3885<head>
3886  <meta charset="UTF-8">
3887  <meta name="viewport" content="width=device-width, initial-scale=1.0">
3888  <meta name="theme-color" content="#667eea">
3889  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
3890  <title>${escapeHtml(fileName)}</title>
3891  <style>
3892${baseStyles}
3893${backNavStyles}
3894${responsiveBase}
3895
3896
3897    /* File Card */
3898    .file-card {
3899      background: rgba(var(--glass-water-rgb), var(--glass-alpha));
3900      backdrop-filter: blur(22px) saturate(140%);
3901      -webkit-backdrop-filter: blur(22px) saturate(140%);
3902      border-radius: 16px;
3903      padding: 32px;
3904      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
3905        inset 0 1px 0 rgba(255, 255, 255, 0.25);
3906      border: 1px solid rgba(255, 255, 255, 0.28);
3907      position: relative;
3908      overflow: hidden;
3909      animation: fadeInUp 0.6s ease-out 0.1s both;
3910    }
3911
3912    /* User Info */
3913    .user-info {
3914      display: flex;
3915      align-items: center;
3916      gap: 8px;
3917      margin-bottom: 20px;
3918      padding-bottom: 16px;
3919      border-bottom: 1px solid rgba(255, 255, 255, 0.2);
3920    }
3921
3922    .user-info::before {
3923      content: '👤';
3924      font-size: 18px;
3925    }
3926
3927    .user-info a {
3928      color: white;
3929      text-decoration: none;
3930      font-weight: 600;
3931      font-size: 15px;
3932      transition: all 0.2s;
3933      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3934    }
3935
3936    .user-info a:hover {
3937      text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
3938      transform: translateX(2px);
3939    }
3940
3941    .note-time {
3942      margin-left: auto;
3943      font-size: 13px;
3944      color: rgba(255, 255, 255, 0.6);
3945      font-weight: 400;
3946    }
3947
3948    /* File Header */
3949    h1 {
3950      font-size: 24px;
3951      font-weight: 700;
3952      color: white;
3953      margin-bottom: 20px;
3954      word-break: break-word;
3955      text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
3956    }
3957
3958    /* Action Buttons */
3959    .action-bar {
3960      display: flex;
3961      gap: 12px;
3962      margin-bottom: 24px;
3963      flex-wrap: wrap;
3964    }
3965
3966    .download {
3967      display: inline-flex;
3968      align-items: center;
3969      gap: 8px;
3970      padding: 12px 20px;
3971      background: rgba(255, 255, 255, 0.25);
3972      backdrop-filter: blur(10px);
3973      color: white;
3974      text-decoration: none;
3975      border-radius: 980px;
3976      font-weight: 600;
3977      font-size: 15px;
3978      transition: all 0.3s;
3979      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
3980      border: 1px solid rgba(255, 255, 255, 0.3);
3981      cursor: pointer;
3982      position: relative;
3983      overflow: hidden;
3984    }
3985
3986    .download::after {
3987      content: '⬇️';
3988      font-size: 16px;
3989      margin-left: 4px;
3990    }
3991
3992    .download::before {
3993      content: "";
3994      position: absolute;
3995      inset: -40%;
3996      background: radial-gradient(
3997        120% 60% at 0% 0%,
3998        rgba(255, 255, 255, 0.35),
3999        transparent 60%
4000      );
4001      opacity: 0;
4002      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4003      pointer-events: none;
4004    }
4005
4006    .download:hover {
4007      background: rgba(255, 255, 255, 0.35);
4008      transform: translateY(-2px);
4009      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
4010    }
4011
4012    .download:hover::before {
4013      opacity: 1;
4014      transform: translateX(30%) translateY(10%);
4015    }
4016
4017    .copy-url-btn {
4018      display: inline-flex;
4019      align-items: center;
4020      gap: 8px;
4021      padding: 12px 20px;
4022      background: rgba(255, 255, 255, 0.2);
4023      backdrop-filter: blur(10px);
4024      color: white;
4025      border: 1px solid rgba(255, 255, 255, 0.3);
4026      border-radius: 980px;
4027      font-weight: 600;
4028      font-size: 15px;
4029      transition: all 0.3s;
4030      cursor: pointer;
4031      position: relative;
4032      overflow: hidden;
4033    }
4034
4035    .copy-url-btn::after {
4036      content: '🔗';
4037      font-size: 16px;
4038      margin-left: 4px;
4039    }
4040
4041    .copy-url-btn::before {
4042      content: "";
4043      position: absolute;
4044      inset: -40%;
4045      background: radial-gradient(
4046        120% 60% at 0% 0%,
4047        rgba(255, 255, 255, 0.35),
4048        transparent 60%
4049      );
4050      opacity: 0;
4051      transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4052      pointer-events: none;
4053    }
4054
4055    .copy-url-btn:hover {
4056      background: rgba(255, 255, 255, 0.3);
4057      transform: translateY(-2px);
4058      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
4059    }
4060
4061    .copy-url-btn:hover::before {
4062      opacity: 1;
4063      transform: translateX(30%) translateY(10%);
4064    }
4065
4066    /* Media Container */
4067    .media {
4068      margin-top: 24px;
4069      padding-top: 24px;
4070      border-top: 1px solid rgba(255, 255, 255, 0.2);
4071    }
4072
4073    .media img,
4074    .media video,
4075    .media audio {
4076      max-width: 100%;
4077      border-radius: 12px;
4078      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
4079      border: 1px solid rgba(255, 255, 255, 0.2);
4080    }
4081
4082    /* Responsive */
4083
4084
4085    .status-row {
4086      display: flex;
4087      align-items: center;
4088      gap: 10px;
4089      margin-bottom: 16px;
4090      flex-wrap: wrap;
4091    }
4092    .status-badge {
4093      display: inline-block;
4094      padding: 4px 12px;
4095      border-radius: 20px;
4096      font-size: 12px;
4097      font-weight: 600;
4098      letter-spacing: 0.03em;
4099    }
4100    .status-badge--pending   { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.2); }
4101    .status-badge--processing{ background: rgba(255,200,0,0.25);   color: #ffe066;               border: 1px solid rgba(255,200,0,0.3); }
4102    .status-badge--succeeded { background: rgba(50,220,120,0.25);  color: #7effc0;               border: 1px solid rgba(50,220,120,0.3); }
4103    .status-badge--stuck     { background: rgba(255,140,0,0.25);   color: #ffcf7e;               border: 1px solid rgba(255,140,0,0.3); }
4104    .status-badge--deleted   { background: rgba(255,80,80,0.2);    color: #ff9ea0;               border: 1px solid rgba(255,80,80,0.25); }
4105
4106  </style>
4107</head>
4108<body>
4109  <div class="container">
4110    <!-- Back Navigation -->
4111    <div class="back-nav">
4112<a href="${back}" class="back-link">${backText}</a>
4113    </div>
4114
4115    <!-- File Card -->
4116    <div class="file-card">
4117      <div class="user-info">
4118        ${userLink}
4119        ${rawIdea.createdAt ? `<span class="note-time">${new Date(rawIdea.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(rawIdea.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
4120      </div>
4121
4122      ${
4123        hasToken
4124          ? `<div class="status-row">
4125        <span class="status-badge status-badge--${rawIdea.status || "pending"}">
4126          ${rawIdea.status === "processing" ? "⏳ processing" : rawIdea.status === "succeeded" ? "✓ placed by AI" : rawIdea.status === "stuck" ? "⚠ stuck" : rawIdea.status === "deleted" ? "deleted" : "pending"}
4127        </span>
4128      </div>`
4129          : ""
4130      }
4131
4132      <h1>${escapeHtml(fileName)}</h1>
4133
4134      ${
4135        fileDeleted
4136          ? ""
4137          : `<div class="action-bar">
4138        <a class="download" href="${fileUrl}" download>Download</a>
4139        <button id="copyUrlBtn" class="copy-url-btn">Share</button>
4140      </div>`
4141      }
4142
4143      <div class="media">
4144        ${fileDeleted ? `<p style="color:rgba(255,255,255,0.6); padding:40px 0;">File was deleted</p>` : mediaHtml}
4145      </div>
4146    </div>
4147  </div>
4148
4149  <script>
4150    const copyUrlBtn = document.getElementById("copyUrlBtn");
4151
4152    copyUrlBtn.addEventListener("click", () => {
4153      const url = new URL(window.location.href);
4154      url.searchParams.delete('token');
4155      if (!url.searchParams.has('html')) {
4156        url.searchParams.set('html', '');
4157      }
4158      navigator.clipboard.writeText(url.toString()).then(() => {
4159        const originalText = copyUrlBtn.textContent;
4160        copyUrlBtn.textContent = "✔️ Copied!";
4161        setTimeout(() => (copyUrlBtn.textContent = originalText), 900);
4162      });
4163    });
4164  </script>
4165</body>
4166</html>
4167`);
4168}
4169
4170// ═══════════════════════════════════════════════════════════════════
4171// 9. Invites Page - GET /user/:userId/invites
4172// ═══════════════════════════════════════════════════════════════════
4173export function renderInvites({ userId, invites, token }) {
4174  const tokenQS = token ? `?token=${token}&html` : `?html`;
4175    return (`
4176<!DOCTYPE html>
4177<html lang="en">
4178<head>
4179  <meta charset="UTF-8">
4180  <meta name="viewport" content="width=device-width, initial-scale=1.0">
4181  <meta name="theme-color" content="#667eea">
4182  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
4183  <title>Invites</title>
4184  <style>
4185${baseStyles}
4186${backNavStyles}
4187${glassHeaderStyles}
4188${emptyStateStyles}
4189${responsiveBase}
4190
4191.header-subtitle {
4192  margin-bottom: 0;
4193}
4194
4195
4196@keyframes waterDrift {
4197  0% { transform: translateY(-1px); }
4198  100% { transform: translateY(1px); }
4199}
4200
4201/* Glass Invites List */
4202.invites-list {
4203  list-style: none;
4204  display: flex;
4205  flex-direction: column;
4206  gap: 16px;
4207}
4208
4209.invite-card {
4210  position: relative;
4211  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
4212  backdrop-filter: blur(22px) saturate(140%);
4213  -webkit-backdrop-filter: blur(22px) saturate(140%);
4214  border-radius: 16px;
4215  padding: 24px;
4216  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
4217    inset 0 1px 0 rgba(255, 255, 255, 0.25);
4218  border: 1px solid rgba(255, 255, 255, 0.28);
4219  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
4220  color: white;
4221  overflow: hidden;
4222  
4223  /* Start hidden for lazy loading */
4224  opacity: 0;
4225  transform: translateY(30px);
4226}
4227
4228/* When item becomes visible */
4229.invite-card.visible {
4230  animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
4231}
4232
4233.invite-card::before {
4234  content: "";
4235  position: absolute;
4236  inset: -40%;
4237  background: radial-gradient(
4238    120% 60% at 0% 0%,
4239    rgba(255, 255, 255, 0.35),
4240    transparent 60%
4241  );
4242  opacity: 0;
4243  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4244  pointer-events: none;
4245}
4246
4247.invite-card:hover {
4248  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
4249  transform: translateY(-2px);
4250  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
4251}
4252
4253.invite-card:hover::before {
4254  opacity: 1;
4255  transform: translateX(30%) translateY(10%);
4256}
4257
4258.invite-text {
4259  font-size: 16px;
4260  line-height: 1.6;
4261  color: white;
4262  margin-bottom: 16px;
4263  font-weight: 400;
4264}
4265
4266.invite-text strong {
4267  font-weight: 600;
4268  color: white;
4269  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
4270}
4271
4272.invite-actions {
4273  display: flex;
4274  gap: 10px;
4275  flex-wrap: wrap;
4276}
4277
4278.invite-actions form {
4279  margin: 0;
4280}
4281
4282.accept-button,
4283.decline-button {
4284  position: relative;
4285  overflow: hidden;
4286  padding: 10px 20px;
4287  border-radius: 980px;
4288  font-weight: 600;
4289  font-size: 14px;
4290  cursor: pointer;
4291  transition: all 0.3s;
4292  font-family: inherit;
4293  border: 1px solid rgba(255, 255, 255, 0.3);
4294}
4295
4296.accept-button {
4297  background: rgba(255, 255, 255, 0.3);
4298  backdrop-filter: blur(10px);
4299  color: white;
4300  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
4301}
4302
4303.accept-button::before {
4304  content: "";
4305  position: absolute;
4306  inset: -40%;
4307  background: radial-gradient(
4308    120% 60% at 0% 0%,
4309    rgba(255, 255, 255, 0.35),
4310    transparent 60%
4311  );
4312  opacity: 0;
4313  transform: translateX(-30%) translateY(-10%);
4314  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4315  pointer-events: none;
4316}
4317
4318.accept-button:hover {
4319  background: rgba(255, 255, 255, 0.4);
4320  transform: translateY(-2px);
4321  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
4322}
4323
4324.accept-button:hover::before {
4325  opacity: 1;
4326  transform: translateX(30%) translateY(10%);
4327}
4328
4329.decline-button {
4330  background: rgba(255, 255, 255, 0.15);
4331  backdrop-filter: blur(10px);
4332  color: white;
4333  opacity: 0.85;
4334}
4335
4336.decline-button:hover {
4337  background: rgba(239, 68, 68, 0.3);
4338  border-color: rgba(239, 68, 68, 0.5);
4339  opacity: 1;
4340  transform: translateY(-1px);
4341  box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
4342}
4343
4344/* Responsive Design */
4345@media (max-width: 640px) {
4346  body {
4347    padding: 16px;
4348  }
4349
4350  .invite-card {
4351    padding: 20px 16px;
4352  }
4353
4354  .invite-actions {
4355    flex-direction: column;
4356  }
4357
4358  .accept-button,
4359  .decline-button {
4360    width: 100%;
4361  }
4362
4363}
4364
4365
4366  </style>
4367</head>
4368<body>
4369  <div class="container">
4370    <!-- Back Navigation -->
4371    <div class="back-nav">
4372      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
4373        ← Back to Profile
4374      </a>
4375    </div>
4376
4377    <!-- Header -->
4378    <div class="header">
4379      <h1>Invites</h1>
4380      <div class="header-subtitle">Join other people's trees</div>
4381    </div>
4382
4383    <!-- Invites Section -->
4384    ${
4385      invites.length > 0
4386        ? `
4387    <ul class="invites-list">
4388      ${invites
4389        .map(
4390          (i) => `
4391        <li class="invite-card">
4392          <div class="invite-text">
4393            <strong>${i.userInviting.username}${i.userInviting.isRemote && i.userInviting.homeLand ? "@" + i.userInviting.homeLand : ""}</strong>
4394            invited you to
4395            <strong>${i.rootId.name}${i.userInviting.isRemote && i.userInviting.homeLand ? " on " + i.userInviting.homeLand : ""}</strong>
4396          </div>
4397
4398          <div class="invite-actions">
4399            <form
4400              method="POST"
4401              action="/api/v1/user/${userId}/invites/${i._id}${tokenQS}"
4402            >
4403              <input type="hidden" name="accept" value="true" />
4404              <button type="submit" class="accept-button">Accept</button>
4405            </form>
4406
4407            <form
4408              method="POST"
4409              action="/api/v1/user/${userId}/invites/${i._id}${tokenQS}"
4410            >
4411              <input type="hidden" name="accept" value="false" />
4412              <button type="submit" class="decline-button">Decline</button>
4413            </form>
4414          </div>
4415        </li>
4416      `,
4417        )
4418        .join("")}
4419    </ul>
4420    `
4421        : `
4422    <div class="empty-state">
4423      <div class="empty-state-icon">📬</div>
4424      <div class="empty-state-text">No pending invites</div>
4425    </div>
4426    `
4427    }
4428  </div>
4429
4430  <script>
4431    // Intersection Observer for lazy loading animations
4432    const observerOptions = {
4433      root: null,
4434      rootMargin: '50px',
4435      threshold: 0.1
4436    };
4437
4438    const observer = new IntersectionObserver((entries) => {
4439      entries.forEach((entry, index) => {
4440        if (entry.isIntersecting) {
4441          // Add a small stagger delay based on order
4442          setTimeout(() => {
4443            entry.target.classList.add('visible');
4444          }, index * 50); // 50ms stagger between items
4445          
4446          // Stop observing once animated
4447          observer.unobserve(entry.target);
4448        }
4449      });
4450    }, observerOptions);
4451
4452    // Observe all invite cards
4453    document.querySelectorAll('.invite-card').forEach(card => {
4454      observer.observe(card);
4455    });
4456  </script>
4457</body>
4458</html>
4459`);
4460}
4461
4462// ═══════════════════════════════════════════════════════════════════
4463// 10. Deleted Branches Page - GET /user/:userId/deleted
4464// ═══════════════════════════════════════════════════════════════════
4465export function renderDeletedBranches({ userId, user, deleted, token }) {
4466  const tokenQS = token ? `?token=${token}&html` : `?html`;
4467    return (`
4468<!DOCTYPE html>
4469<html lang="en">
4470<head>
4471  <meta charset="UTF-8">
4472  <meta name="viewport" content="width=device-width, initial-scale=1.0">
4473  <meta name="theme-color" content="#667eea">
4474  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
4475  <title>${user.username} — Deleted Branches</title>
4476  <style>
4477${baseStyles}
4478${backNavStyles}
4479${glassHeaderStyles}
4480${emptyStateStyles}
4481${responsiveBase}
4482
4483.header-subtitle {
4484  margin-bottom: 0;
4485}
4486
4487
4488@keyframes waterDrift {
4489  0% { transform: translateY(-1px); }
4490  100% { transform: translateY(1px); }
4491}
4492
4493/* Glass Deleted List */
4494.deleted-list {
4495  list-style: none;
4496  display: flex;
4497  flex-direction: column;
4498  gap: 16px;
4499}
4500
4501.deleted-card {
4502  position: relative;
4503  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
4504  backdrop-filter: blur(22px) saturate(140%);
4505  -webkit-backdrop-filter: blur(22px) saturate(140%);
4506  border-radius: 16px;
4507  padding: 24px;
4508  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
4509    inset 0 1px 0 rgba(255, 255, 255, 0.25);
4510  border: 1px solid rgba(255, 255, 255, 0.28);
4511  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
4512  color: white;
4513  overflow: hidden;
4514  
4515  /* Start hidden for lazy loading */
4516  opacity: 0;
4517  transform: translateY(30px);
4518}
4519
4520/* When item becomes visible */
4521.deleted-card.visible {
4522  animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
4523}
4524
4525.deleted-card::before {
4526  content: "";
4527  position: absolute;
4528  inset: -40%;
4529  background: radial-gradient(
4530    120% 60% at 0% 0%,
4531    rgba(255, 255, 255, 0.35),
4532    transparent 60%
4533  );
4534  opacity: 0;
4535  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4536  pointer-events: none;
4537}
4538
4539.deleted-card:hover {
4540  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
4541  transform: translateY(-2px);
4542  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
4543}
4544
4545.deleted-card:hover::before {
4546  opacity: 1;
4547  transform: translateX(30%) translateY(10%);
4548}
4549
4550.deleted-info {
4551  margin-bottom: 16px;
4552}
4553
4554.deleted-name {
4555  font-size: 18px;
4556  font-weight: 600;
4557  margin-bottom: 6px;
4558}
4559
4560.deleted-name a {
4561  color: white;
4562  text-decoration: none;
4563  transition: all 0.2s;
4564}
4565
4566.deleted-name a:hover {
4567  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
4568}
4569
4570.deleted-id {
4571  font-size: 12px;
4572  color: rgba(255, 255, 255, 0.75);
4573  font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
4574  letter-spacing: -0.3px;
4575}
4576
4577/* Revival Forms */
4578.revival-section {
4579  display: flex;
4580  flex-direction: column;
4581  gap: 10px;
4582  padding-top: 16px;
4583  border-top: 1px solid rgba(255, 255, 255, 0.15);
4584}
4585
4586.revive-as-root-form button {
4587  position: relative;
4588  overflow: hidden;
4589  padding: 12px 24px;
4590  border-radius: 980px;
4591  border: 1px solid rgba(255, 255, 255, 0.3);
4592  background: rgba(255, 255, 255, 0.3);
4593  backdrop-filter: blur(10px);
4594  color: white;
4595  cursor: pointer;
4596  font-weight: 600;
4597  font-size: 14px;
4598  transition: all 0.3s;
4599  font-family: inherit;
4600  width: 100%;
4601  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
4602}
4603
4604.revive-as-root-form button::before {
4605  content: "";
4606  position: absolute;
4607  inset: -40%;
4608  background: radial-gradient(
4609    120% 60% at 0% 0%,
4610    rgba(255, 255, 255, 0.35),
4611    transparent 60%
4612  );
4613  opacity: 0;
4614  transform: translateX(-30%) translateY(-10%);
4615  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4616  pointer-events: none;
4617}
4618
4619.revive-as-root-form button:hover {
4620  background: rgba(255, 255, 255, 0.4);
4621  transform: translateY(-2px);
4622  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
4623}
4624
4625.revive-as-root-form button:hover::before {
4626  opacity: 1;
4627  transform: translateX(30%) translateY(10%);
4628}
4629
4630.revive-into-branch-form {
4631  display: flex;
4632  gap: 8px;
4633  flex-wrap: wrap;
4634  align-items: center;
4635}
4636
4637.revive-into-branch-form input[type="text"] {
4638  flex: 1;
4639  min-width: 180px;
4640  padding: 10px 14px;
4641  font-size: 14px;
4642  border-radius: 10px;
4643  border: 1px solid rgba(255, 255, 255, 0.25);
4644  background: rgba(255, 255, 255, 0.15);
4645  backdrop-filter: blur(10px);
4646  -webkit-backdrop-filter: blur(10px);
4647  font-family: inherit;
4648  color: white;
4649  font-weight: 500;
4650  transition: all 0.3s;
4651  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
4652}
4653
4654.revive-into-branch-form input[type="text"]::placeholder {
4655  color: rgba(255, 255, 255, 0.5);
4656  font-size: 13px;
4657}
4658
4659.revive-into-branch-form input[type="text"]:focus {
4660  outline: none;
4661  border-color: rgba(255, 255, 255, 0.4);
4662  background: rgba(255, 255, 255, 0.2);
4663  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1),
4664    inset 0 1px 0 rgba(255, 255, 255, 0.2);
4665}
4666
4667.revive-into-branch-form button {
4668  padding: 10px 18px;
4669  font-size: 13px;
4670  font-weight: 600;
4671  border-radius: 980px;
4672  border: 1px solid rgba(255, 255, 255, 0.25);
4673  background: rgba(255, 255, 255, 0.15);
4674  backdrop-filter: blur(10px);
4675  color: white;
4676  cursor: pointer;
4677  transition: all 0.3s;
4678  font-family: inherit;
4679  white-space: nowrap;
4680  opacity: 0.85;
4681  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
4682}
4683
4684.revive-into-branch-form button:hover {
4685  background: rgba(255, 255, 255, 0.25);
4686  opacity: 1;
4687  transform: translateY(-1px);
4688  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1),
4689    inset 0 1px 0 rgba(255, 255, 255, 0.2);
4690}
4691
4692/* Responsive Design */
4693
4694  </style>
4695</head>
4696<body>
4697  <div class="container">
4698    <!-- Back Navigation -->
4699    <div class="back-nav">
4700      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
4701        ← Back to Profile
4702      </a>
4703    </div>
4704
4705    <!-- Header Section -->
4706    <div class="header">
4707      <h1>
4708        Deleted Branches for
4709        <a href="/api/v1/user/${userId}${tokenQS}">${user.username}</a>
4710      </h1>
4711      <div class="header-subtitle">
4712        Recover deleted trees and branches as new trees or merge them into existing ones.
4713      </div>
4714    </div>
4715
4716    <!-- Deleted Items List -->
4717    ${
4718      deleted.length > 0
4719        ? `
4720    <ul class="deleted-list">
4721      ${deleted
4722        .map(
4723          ({ _id, name }) => `
4724        <li class="deleted-card">
4725          <div class="deleted-info">
4726            <div class="deleted-name">
4727              <a href="/api/v1/root/${_id}${tokenQS}">
4728                ${name || "Untitled"}
4729              </a>
4730            </div>
4731            <div class="deleted-id">${_id}</div>
4732          </div>
4733
4734          <div class="revival-section">
4735            <!-- Revive as Root -->
4736            <form
4737              method="POST"
4738              action="/api/v1/user/${userId}/deleted/${_id}/reviveAsRoot?token=${token}&html"
4739              class="revive-as-root-form"
4740            >
4741              <button type="submit">Revive as Root</button>
4742            </form>
4743
4744            <!-- Revive into Branch -->
4745            <form
4746              method="POST"
4747              action="/api/v1/user/${userId}/deleted/${_id}/revive?token=${token}&html"
4748              class="revive-into-branch-form"
4749            >
4750              <input
4751                type="text"
4752                name="targetParentId"
4753                placeholder="Target parent node ID"
4754                required
4755              />
4756              <button type="submit">Revive into Branch</button>
4757            </form>
4758          </div>
4759        </li>
4760      `,
4761        )
4762        .join("")}
4763    </ul>
4764    `
4765        : `
4766    <div class="empty-state">
4767      <div class="empty-state-icon">🗑️</div>
4768      <div class="empty-state-text">No deleted branches</div>
4769      <div class="empty-state-subtext">
4770        Deleted branches will appear here and can be revived
4771      </div>
4772    </div>
4773    `
4774    }
4775  </div>
4776
4777  <script>
4778    // Intersection Observer for lazy loading animations
4779    const observerOptions = {
4780      root: null,
4781      rootMargin: '50px',
4782      threshold: 0.1
4783    };
4784
4785    const observer = new IntersectionObserver((entries) => {
4786      entries.forEach((entry, index) => {
4787        if (entry.isIntersecting) {
4788          // Add a small stagger delay based on order
4789          setTimeout(() => {
4790            entry.target.classList.add('visible');
4791          }, index * 50); // 50ms stagger between items
4792          
4793          // Stop observing once animated
4794          observer.unobserve(entry.target);
4795        }
4796      });
4797    }, observerOptions);
4798
4799    // Observe all deleted cards
4800    document.querySelectorAll('.deleted-card').forEach(card => {
4801      observer.observe(card);
4802    });
4803  </script>
4804</body>
4805</html>
4806`);
4807}
4808
4809// ═══════════════════════════════════════════════════════════════════
4810// 11a. API Key Created Page - POST /user/:userId/api-keys
4811// ═══════════════════════════════════════════════════════════════════
4812export function renderApiKeyCreated({ userId, safeName, rawKey, token }) {
4813  const tokenQS = token ? `?token=${token}&html` : `?html`;
4814
4815    return (`
4816<!DOCTYPE html>
4817<html lang="en">
4818<head>
4819  <meta charset="UTF-8">
4820  <meta name="viewport" content="width=device-width, initial-scale=1.0">
4821  <meta name="theme-color" content="#667eea">
4822  <title>API Key Created</title>
4823  <style>
4824${baseStyles}
4825${backNavStyles}
4826
4827.container { max-width: 600px; margin: 0 auto; position: relative; z-index: 1; }
4828
4829.card {
4830  position: relative;
4831  background: rgba(115,111,230,var(--glass-alpha));
4832  backdrop-filter: blur(22px) saturate(140%);
4833  -webkit-backdrop-filter: blur(22px) saturate(140%);
4834  border-radius: 20px; padding: 40px;
4835  box-shadow: 0 20px 60px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.25);
4836  border: 1px solid rgba(255,255,255,0.28);
4837  color: white; animation: fadeInUp 0.6s ease-out 0.1s both;
4838}
4839.card-title {
4840  font-size: 22px; font-weight: 700; margin-bottom: 6px;
4841  letter-spacing: -0.3px;
4842}
4843.card-name {
4844  font-size: 14px; color: rgba(255,255,255,0.6); margin-bottom: 20px;
4845}
4846.warning {
4847  display: flex; align-items: center; gap: 10px;
4848  padding: 12px 16px; margin-bottom: 20px;
4849  background: rgba(255,179,71,0.15); border: 1px solid rgba(255,179,71,0.3);
4850  border-radius: 10px; font-size: 13px; font-weight: 500;
4851  color: rgba(255,220,150,0.95); line-height: 1.5;
4852}
4853.key-block {
4854  position: relative;
4855  background: rgba(0,0,0,0.25); border: 1px solid rgba(255,255,255,0.15);
4856  border-radius: 10px; padding: 16px 60px 16px 16px;
4857  font-family: 'SF Mono', 'Fira Code', 'Courier New', monospace;
4858  font-size: 14px; color: rgba(255,255,255,0.95);
4859  word-break: break-all; line-height: 1.6;
4860  margin-bottom: 24px;
4861}
4862.copy-btn {
4863  position: absolute; top: 10px; right: 10px;
4864  background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.25);
4865  border-radius: 8px; padding: 8px 14px;
4866  color: white; font-size: 12px; font-weight: 600;
4867  cursor: pointer; transition: all 0.2s;
4868  backdrop-filter: blur(10px);
4869}
4870.copy-btn:hover {
4871  background: rgba(255,255,255,0.25); transform: translateY(-1px);
4872}
4873.copy-btn.copied {
4874  background: rgba(72,187,120,0.3); border-color: rgba(72,187,120,0.4);
4875}
4876@media (max-width: 640px) {
4877  body { padding: 16px; }
4878  .card { padding: 28px 20px; }
4879
4880}
4881  </style>
4882</head>
4883<body>
4884  <div class="container">
4885    <div class="back-nav">
4886      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">&lt;- Back to Profile</a>
4887      <a href="/api/v1/user/${userId}/api-keys${tokenQS}" class="back-link">API Keys</a>
4888    </div>
4889
4890    <div class="card">
4891      <div class="card-title">API Key Created</div>
4892      <div class="card-name">${esc(safeName)}</div>
4893
4894      <div class="warning">
4895        This key will only be shown once. Copy it now and store it securely.
4896      </div>
4897
4898      <div class="key-block" id="keyBlock">
4899        ${esc(rawKey)}
4900        <button class="copy-btn" id="copyBtn" onclick="copyKey()">📋</button>
4901      </div>
4902    </div>
4903  </div>
4904
4905  <script>
4906    function copyKey() {
4907      var block = document.getElementById("keyBlock");
4908      var btn = document.getElementById("copyBtn");
4909      var key = block.textContent.replace(btn.textContent, "").trim();
4910      navigator.clipboard.writeText(key).then(function() {
4911        var btn = document.getElementById("copyBtn");
4912        btn.textContent = "\u2705";
4913        btn.classList.add("copied");
4914        setTimeout(function() {
4915          btn.textContent = "\uD83D\uDCCB";
4916          btn.classList.remove("copied");
4917        }, 2000);
4918      });
4919    }
4920  </script>
4921</body>
4922</html>
4923`);
4924}
4925
4926// ═══════════════════════════════════════════════════════════════════
4927// 11b. API Keys List Page - GET /user/:userId/api-keys
4928// ═══════════════════════════════════════════════════════════════════
4929export function renderApiKeysList({ userId, user, apiKeys, token, errorParam }) {
4930  const tokenQS = token ? `?token=${token}&html` : `?html`;
4931    return (`
4932<!DOCTYPE html>
4933<html lang="en">
4934<head>
4935  <meta charset="UTF-8" />
4936  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4937  <meta name="theme-color" content="#667eea">
4938  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
4939  <title>${user.username} — API Keys</title>
4940  <style>
4941${baseStyles}
4942${backNavStyles}
4943${glassHeaderStyles}
4944${emptyStateStyles}
4945${responsiveBase}
4946
4947.header-subtitle {
4948  margin-bottom: 0;
4949}
4950
4951
4952@keyframes waterDrift {
4953  0% { transform: translateY(-1px); }
4954  100% { transform: translateY(1px); }
4955}
4956
4957/* Create Form Card */
4958.create-card {
4959  position: relative;
4960  overflow: hidden;
4961  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
4962  backdrop-filter: blur(22px) saturate(140%);
4963  -webkit-backdrop-filter: blur(22px) saturate(140%);
4964  border-radius: 16px;
4965  padding: 24px;
4966  margin-bottom: 24px;
4967  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
4968    inset 0 1px 0 rgba(255, 255, 255, 0.25);
4969  border: 1px solid rgba(255, 255, 255, 0.28);
4970  color: white;
4971}
4972
4973.create-card::before {
4974  content: "";
4975  position: absolute;
4976  inset: -40%;
4977  background: radial-gradient(
4978    120% 60% at 0% 0%,
4979    rgba(255, 255, 255, 0.35),
4980    transparent 60%
4981  );
4982  opacity: 0;
4983  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
4984  pointer-events: none;
4985}
4986
4987.create-card:hover::before {
4988  opacity: 1;
4989  transform: translateX(30%) translateY(10%);
4990}
4991
4992.create-form {
4993  display: flex;
4994  gap: 10px;
4995  flex-wrap: wrap;
4996  margin-bottom: 12px;
4997}
4998
4999.create-form input {
5000  flex: 1;
5001  min-width: 200px;
5002  padding: 12px 16px;
5003  font-size: 15px;
5004  border-radius: 12px;
5005  border: 1px solid rgba(255, 255, 255, 0.25);
5006  background: rgba(255, 255, 255, 0.15);
5007  backdrop-filter: blur(10px);
5008  -webkit-backdrop-filter: blur(10px);
5009  font-family: inherit;
5010  color: white;
5011  font-weight: 500;
5012  transition: all 0.3s;
5013  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
5014}
5015
5016.create-form input::placeholder {
5017  color: rgba(255, 255, 255, 0.5);
5018}
5019
5020.create-form input:focus {
5021  outline: none;
5022  border-color: rgba(255, 255, 255, 0.4);
5023  background: rgba(255, 255, 255, 0.2);
5024  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1),
5025    inset 0 1px 0 rgba(255, 255, 255, 0.2);
5026}
5027
5028.create-form button {
5029  position: relative;
5030  overflow: hidden;
5031  padding: 12px 24px;
5032  font-size: 15px;
5033  font-weight: 600;
5034  border-radius: 980px;
5035  border: 1px solid rgba(255, 255, 255, 0.3);
5036  background: rgba(255, 255, 255, 0.3);
5037  backdrop-filter: blur(10px);
5038  color: white;
5039  cursor: pointer;
5040  transition: all 0.3s;
5041  font-family: inherit;
5042  white-space: nowrap;
5043  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
5044}
5045
5046.create-form button::before {
5047  content: "";
5048  position: absolute;
5049  inset: -40%;
5050  background: radial-gradient(
5051    120% 60% at 0% 0%,
5052    rgba(255, 255, 255, 0.35),
5053    transparent 60%
5054  );
5055  opacity: 0;
5056  transform: translateX(-30%) translateY(-10%);
5057  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
5058  pointer-events: none;
5059}
5060
5061.create-form button:hover {
5062  background: rgba(255, 255, 255, 0.4);
5063  transform: translateY(-2px);
5064  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
5065}
5066
5067.create-form button:hover::before {
5068  opacity: 1;
5069  transform: translateX(30%) translateY(10%);
5070}
5071
5072.create-hint {
5073  font-size: 13px;
5074  color: rgba(255, 255, 255, 0.75);
5075}
5076
5077/* API Keys List */
5078.keys-list {
5079  display: flex;
5080  flex-direction: column;
5081  gap: 16px;
5082}
5083
5084.key-card {
5085  position: relative;
5086  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
5087  backdrop-filter: blur(22px) saturate(140%);
5088  -webkit-backdrop-filter: blur(22px) saturate(140%);
5089  border-radius: 16px;
5090  padding: 24px;
5091  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
5092    inset 0 1px 0 rgba(255, 255, 255, 0.25);
5093  border: 1px solid rgba(255, 255, 255, 0.28);
5094  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
5095  color: white;
5096  overflow: hidden;
5097  
5098  /* Start hidden for lazy loading */
5099  opacity: 0;
5100  transform: translateY(30px);
5101}
5102
5103/* Active keys get green glass tint */
5104.key-card.active {
5105  background: rgba(76, 175, 80, 0.2);
5106  border-color: rgba(76, 175, 80, 0.35);
5107}
5108
5109.key-card.active::after {
5110  content: "";
5111  position: absolute;
5112  inset: 0;
5113  background: radial-gradient(
5114    circle at top right,
5115    rgba(76, 175, 80, 0.15),
5116    transparent 70%
5117  );
5118  pointer-events: none;
5119}
5120
5121/* When item becomes visible */
5122.key-card.visible {
5123  animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
5124}
5125
5126.key-card::before {
5127  content: "";
5128  position: absolute;
5129  inset: -40%;
5130  background: radial-gradient(
5131    120% 60% at 0% 0%,
5132    rgba(255, 255, 255, 0.35),
5133    transparent 60%
5134  );
5135  opacity: 0;
5136  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
5137  pointer-events: none;
5138}
5139
5140.key-card:hover {
5141  background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
5142  transform: translateY(-2px);
5143  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
5144}
5145
5146.key-card.active:hover {
5147  background: rgba(76, 175, 80, 0.28);
5148  box-shadow: 0 12px 32px rgba(76, 175, 80, 0.15);
5149}
5150
5151.key-card:hover::before {
5152  opacity: 1;
5153  transform: translateX(30%) translateY(10%);
5154}
5155
5156.key-name {
5157  font-size: 18px;
5158  font-weight: 600;
5159  color: white;
5160  margin-bottom: 12px;
5161  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
5162}
5163
5164.key-meta {
5165  display: flex;
5166  flex-direction: column;
5167  gap: 6px;
5168  margin-bottom: 12px;
5169}
5170
5171.meta-item {
5172  font-size: 13px;
5173  color: rgba(255, 255, 255, 0.85);
5174  display: flex;
5175  align-items: center;
5176  gap: 8px;
5177}
5178
5179.badge {
5180  display: inline-flex;
5181  align-items: center;
5182  padding: 4px 12px;
5183  font-size: 12px;
5184  border-radius: 980px;
5185  font-weight: 600;
5186  border: 1px solid rgba(255, 255, 255, 0.3);
5187}
5188
5189.badge.active {
5190  background: rgba(76, 175, 80, 0.25);
5191  color: white;
5192  border-color: rgba(76, 175, 80, 0.4);
5193}
5194
5195.badge.revoked {
5196  background: rgba(239, 68, 68, 0.25);
5197  color: white;
5198  border-color: rgba(239, 68, 68, 0.4);
5199}
5200
5201.key-actions {
5202  margin-top: 16px;
5203  padding-top: 16px;
5204  border-top: 1px solid rgba(255, 255, 255, 0.15);
5205}
5206
5207.revoke-button {
5208  padding: 10px 20px;
5209  font-size: 14px;
5210  font-weight: 600;
5211  border-radius: 980px;
5212  border: 1px solid rgba(239, 68, 68, 0.4);
5213  background: rgba(239, 68, 68, 0.25);
5214  color: white;
5215  cursor: pointer;
5216  transition: all 0.3s;
5217  font-family: inherit;
5218}
5219
5220.revoke-button:hover {
5221  background: rgba(239, 68, 68, 0.35);
5222  border-color: rgba(239, 68, 68, 0.5);
5223  transform: translateY(-1px);
5224  box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
5225}
5226
5227/* Responsive Design */
5228@media (max-width: 640px) {
5229  body {
5230    padding: 16px;
5231  }
5232
5233  .create-form {
5234    flex-direction: column;
5235  }
5236
5237  .create-form input {
5238    width: 100%;
5239    min-width: 0;
5240  }
5241
5242  .create-form button {
5243    width: 100%;
5244  }
5245
5246  .key-card {
5247    padding: 20px 16px;
5248  }
5249
5250}
5251
5252
5253  </style>
5254</head>
5255<body>
5256  <div class="container">
5257    <!-- Back Navigation -->
5258    <div class="back-nav">
5259      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
5260        ← Back to Profile
5261      </a>
5262    </div>
5263
5264    <!-- Header -->
5265    <div class="header">
5266      <h1>API Keys</h1>
5267      <div class="header-subtitle">
5268        Manage programmatic access to your account
5269      </div>
5270    </div>
5271
5272    <!-- Create API Key -->
5273    <div class="create-card">
5274      <form class="create-form" method="POST" action="/api/v1/user/${
5275        userId
5276      }/api-keys?token=${token}&html">
5277        <input type="text" name="name" placeholder="Key name (optional)" />
5278        <button type="submit">Create Key</button>
5279      </form>
5280      <div class="create-hint">
5281        You'll only see the key once after creation.
5282      </div>
5283    </div>
5284
5285    <!-- API Keys List -->
5286    ${
5287      apiKeys.length > 0
5288        ? `
5289    <div class="keys-list">
5290      ${apiKeys
5291        .map(
5292          (k) => `
5293        <div class="key-card${!k.revoked ? " active" : ""}">
5294          <div class="key-name">${k.name || "Untitled Key"}</div>
5295          
5296          <div class="key-meta">
5297            <div class="meta-item">
5298              Created ${new Date(k.createdAt).toLocaleDateString()}
5299            </div>
5300            <div class="meta-item">
5301              Used ${k.usageCount} ${k.usageCount === 1 ? "time" : "times"}
5302            </div>
5303            <div class="meta-item">
5304              <span class="badge ${k.revoked ? "revoked" : "active"}">
5305                ${k.revoked ? "Revoked" : "Active"}
5306              </span>
5307            </div>
5308          </div>
5309
5310          ${
5311            !k.revoked
5312              ? `
5313          <div class="key-actions">
5314            <button class="revoke-button" data-key-id="${k._id}">
5315              Revoke Key
5316            </button>
5317          </div>
5318          `
5319              : ""
5320          }
5321        </div>
5322      `,
5323        )
5324        .join("")}
5325    </div>
5326    `
5327        : `
5328    <div class="empty-state">
5329      <div class="empty-state-icon">🔑</div>
5330      <div class="empty-state-text">No API keys yet</div>
5331      <div class="empty-state-subtext">
5332        Create one above to get started
5333      </div>
5334    </div>
5335    `
5336    }
5337  </div>
5338
5339  <script>
5340    // Intersection Observer for lazy loading animations
5341    const observerOptions = {
5342      root: null,
5343      rootMargin: '50px',
5344      threshold: 0.1
5345    };
5346
5347    const observer = new IntersectionObserver((entries) => {
5348      entries.forEach((entry, index) => {
5349        if (entry.isIntersecting) {
5350          setTimeout(() => {
5351            entry.target.classList.add('visible');
5352          }, index * 50);
5353          observer.unobserve(entry.target);
5354        }
5355      });
5356    }, observerOptions);
5357
5358    // Observe all key cards
5359    document.querySelectorAll('.key-card').forEach(card => {
5360      observer.observe(card);
5361    });
5362
5363    // Revoke button handler
5364    document.addEventListener("click", async (e) => {
5365      if (!e.target.classList.contains("revoke-button")) return;
5366
5367      const keyId = e.target.dataset.keyId;
5368
5369      if (!confirm("Revoke this API key? This cannot be undone.")) return;
5370
5371      const token = new URLSearchParams(window.location.search).get("token") || "";
5372      const qs = token ? "?token=" + encodeURIComponent(token) : "";
5373
5374      try {
5375        const res = await fetch(
5376          "/api/v1/user/${userId}/api-keys/" + keyId + qs,
5377          { method: "DELETE" }
5378        );
5379
5380        const data = await res.json();
5381        if (!data.message) throw new Error("Revoke failed");
5382
5383        location.reload();
5384      } catch (err) {
5385        alert("Failed to revoke API key");
5386      }
5387    });
5388  </script>
5389</body>
5390</html>
5391`);
5392}
5393
5394// ═══════════════════════════════════════════════════════════════════
5395// 12. Share Token Page - GET /user/:userId/shareToken
5396// ═══════════════════════════════════════════════════════════════════
5397export function renderShareToken({ userId, user, token, tokenQS }) {
5398    return (`
5399<!DOCTYPE html>
5400<html lang="en">
5401<head>
5402  <meta charset="UTF-8" />
5403  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5404  <meta name="theme-color" content="#667eea">
5405  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
5406  <title>Share Token — @${user.username}</title>
5407  <style>
5408${baseStyles}
5409${responsiveBase}
5410
5411body {
5412  display: flex;
5413  align-items: center;
5414  justify-content: center;
5415}
5416
5417
5418.container {
5419  max-width: 600px;
5420  width: 100%;
5421  position: relative;
5422  z-index: 1;
5423}
5424
5425/* Glass Card */
5426.card {
5427  position: relative;
5428  overflow: hidden;
5429  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
5430  backdrop-filter: blur(22px) saturate(140%);
5431  -webkit-backdrop-filter: blur(22px) saturate(140%);
5432  border-radius: 24px;
5433  padding: 48px;
5434  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2),
5435    inset 0 1px 0 rgba(255, 255, 255, 0.25);
5436  border: 1px solid rgba(255, 255, 255, 0.28);
5437  color: white;
5438  animation: fadeInUp 0.6s ease-out;
5439}
5440
5441.card::before {
5442  content: "";
5443  position: absolute;
5444  inset: -40%;
5445  background: radial-gradient(
5446    120% 60% at 0% 0%,
5447    rgba(255, 255, 255, 0.35),
5448    transparent 60%
5449  );
5450  opacity: 0;
5451  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
5452  pointer-events: none;
5453}
5454
5455.card:hover::before {
5456  opacity: 1;
5457  transform: translateX(30%) translateY(10%);
5458}
5459
5460/* Header */
5461.header {
5462  text-align: center;
5463  margin-bottom: 32px;
5464}
5465
5466.icon {
5467  font-size: 64px;
5468  margin-bottom: 20px;
5469  display: inline-block;
5470  filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
5471  animation: bounce 2s infinite;
5472}
5473
5474@keyframes bounce {
5475  0%, 100% {
5476    transform: translateY(0);
5477  }
5478  50% {
5479    transform: translateY(-10px);
5480  }
5481}
5482
5483h1 {
5484  font-size: 32px;
5485  font-weight: 600;
5486  color: white;
5487  margin-bottom: 8px;
5488  letter-spacing: -0.5px;
5489  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
5490}
5491
5492.username {
5493  font-size: 16px;
5494  color: rgba(255, 255, 255, 0.85);
5495  font-weight: 500;
5496}
5497
5498/* Description */
5499.description {
5500  color: rgba(255, 255, 255, 0.9);
5501  line-height: 1.6;
5502  margin-bottom: 28px;
5503  font-size: 15px;
5504  text-align: center;
5505}
5506
5507/* Welcome Box */
5508.welcome-box {
5509  background: rgba(255, 255, 255, 0.15);
5510  backdrop-filter: blur(10px);
5511  padding: 24px;
5512  border-radius: 16px;
5513  margin-bottom: 28px;
5514  border: 1px solid rgba(255, 255, 255, 0.25);
5515}
5516
5517.welcome-title {
5518  font-size: 18px;
5519  font-weight: 600;
5520  color: white;
5521  margin-bottom: 12px;
5522  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
5523}
5524
5525.welcome-text {
5526  color: rgba(255, 255, 255, 0.9);
5527  line-height: 1.6;
5528  font-size: 15px;
5529}
5530
5531/* Token Section */
5532.token-section {
5533  margin-bottom: 28px;
5534}
5535
5536.token-label {
5537  font-size: 13px;
5538  font-weight: 600;
5539  color: rgba(255, 255, 255, 0.85);
5540  text-transform: uppercase;
5541  letter-spacing: 0.5px;
5542  margin-bottom: 10px;
5543}
5544
5545.token-display {
5546  display: flex;
5547  align-items: center;
5548  gap: 12px;
5549  background: rgba(255, 255, 255, 0.15);
5550  backdrop-filter: blur(10px);
5551  padding: 16px 20px;
5552  border-radius: 12px;
5553  border: 1px solid rgba(255, 255, 255, 0.25);
5554  transition: all 0.3s;
5555}
5556
5557.token-display:hover {
5558  border-color: rgba(255, 255, 255, 0.4);
5559  background: rgba(255, 255, 255, 0.2);
5560}
5561
5562.token-text {
5563  flex: 1;
5564  font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
5565  font-size: 14px;
5566  color: white;
5567  word-break: break-all;
5568  font-weight: 500;
5569}
5570
5571.btn-copy {
5572  padding: 8px 16px;
5573  background: rgba(255, 255, 255, 0.25);
5574  color: white;
5575  border: 1px solid rgba(255, 255, 255, 0.3);
5576  border-radius: 980px;
5577  font-size: 13px;
5578  font-weight: 600;
5579  cursor: pointer;
5580  transition: all 0.3s;
5581  flex-shrink: 0;
5582}
5583
5584.btn-copy:hover {
5585  background: rgba(255, 255, 255, 0.35);
5586  transform: translateY(-2px);
5587  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
5588}
5589
5590/* Form Section */
5591.form-section {
5592  background: rgba(255, 255, 255, 0.1);
5593  backdrop-filter: blur(10px);
5594  padding: 24px;
5595  border-radius: 16px;
5596  border: 1px solid rgba(255, 255, 255, 0.2);
5597  margin-bottom: 24px;
5598}
5599
5600.form-title {
5601  font-size: 16px;
5602  font-weight: 600;
5603  color: white;
5604  margin-bottom: 16px;
5605  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
5606}
5607
5608.form-row {
5609  display: flex;
5610  gap: 12px;
5611}
5612
5613input {
5614  flex: 1;
5615  padding: 14px 18px;
5616  border-radius: 12px;
5617  border: 1px solid rgba(255, 255, 255, 0.25);
5618  font-size: 15px;
5619  font-family: 'SF Mono', Monaco, monospace;
5620  transition: all 0.3s;
5621  background: rgba(255, 255, 255, 0.15);
5622  backdrop-filter: blur(10px);
5623  color: white;
5624  font-weight: 500;
5625}
5626
5627input::placeholder {
5628  color: rgba(255, 255, 255, 0.5);
5629}
5630
5631input:focus {
5632  outline: none;
5633  border-color: rgba(255, 255, 255, 0.4);
5634  background: rgba(255, 255, 255, 0.2);
5635  box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1);
5636}
5637
5638.btn-submit {
5639  padding: 14px 28px;
5640  border-radius: 980px;
5641  border: 1px solid rgba(255, 255, 255, 0.3);
5642  background: rgba(255, 255, 255, 0.3);
5643  backdrop-filter: blur(10px);
5644  color: white;
5645  font-weight: 600;
5646  font-size: 15px;
5647  cursor: pointer;
5648  transition: all 0.3s;
5649  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
5650  flex-shrink: 0;
5651  position: relative;
5652  overflow: hidden;
5653}
5654
5655.btn-submit::before {
5656  content: "";
5657  position: absolute;
5658  inset: -40%;
5659  background: radial-gradient(
5660    120% 60% at 0% 0%,
5661    rgba(255, 255, 255, 0.35),
5662    transparent 60%
5663  );
5664  opacity: 0;
5665  transform: translateX(-30%) translateY(-10%);
5666  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
5667  pointer-events: none;
5668}
5669
5670.btn-submit:hover {
5671  background: rgba(255, 255, 255, 0.4);
5672  transform: translateY(-2px);
5673  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
5674}
5675
5676.btn-submit:hover::before {
5677  opacity: 1;
5678  transform: translateX(30%) translateY(10%);
5679}
5680
5681/* Info Box */
5682.info-box {
5683  background: rgba(255, 255, 255, 0.1);
5684  backdrop-filter: blur(10px);
5685  padding: 14px 18px;
5686  border-radius: 12px;
5687  border-left: 3px solid rgba(255, 255, 255, 0.5);
5688  margin-bottom: 24px;
5689}
5690
5691.info-box-content {
5692  font-size: 14px;
5693  color: rgba(255, 255, 255, 0.85);
5694  line-height: 1.6;
5695}
5696
5697/* Back Links */
5698.back-links {
5699  display: flex;
5700  flex-direction: column;
5701  gap: 10px;
5702}
5703
5704.back-link {
5705  display: inline-flex;
5706  align-items: center;
5707  justify-content: center;
5708  gap: 8px;
5709  padding: 12px 20px;
5710  text-decoration: none;
5711  color: white;
5712  font-weight: 600;
5713  font-size: 14px;
5714  background: rgba(255, 255, 255, 0.15);
5715  backdrop-filter: blur(10px);
5716  border-radius: 980px;
5717  transition: all 0.3s;
5718  border: 1px solid rgba(255, 255, 255, 0.25);
5719}
5720
5721.back-link:hover {
5722  background: rgba(255, 255, 255, 0.25);
5723  transform: translateY(-2px);
5724  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
5725}
5726
5727/* Responsive */
5728@media (max-width: 640px) {
5729  body {
5730    padding: 16px;
5731    align-items: flex-start;
5732    padding-top: 40px;
5733  }
5734
5735  .card {
5736    padding: 32px 24px;
5737  }
5738
5739  h1 {
5740    font-size: 28px;
5741  }
5742
5743  .icon {
5744    font-size: 56px;
5745  }
5746
5747  .form-row {
5748    flex-direction: column;
5749  }
5750
5751  .btn-submit {
5752    width: 100%;
5753  }
5754
5755  .token-display {
5756    flex-direction: column;
5757    align-items: stretch;
5758  }
5759
5760  .btn-copy {
5761    width: 100%;
5762  }
5763}
5764
5765  </style>
5766</head>
5767<body>
5768  <div class="container">
5769    <div class="card">
5770      <!-- Header -->
5771      <div class="header">
5772        <div class="icon">🔐</div>
5773        <h1>Share Token</h1>
5774        <div class="username">@${user.username}</div>
5775      </div>
5776
5777      ${
5778        token
5779          ? `
5780          <!-- Existing Token View -->
5781          <div class="description">
5782            Share read-only access to your content.
5783          </div>
5784
5785          <div class="token-section">
5786            <div class="token-label">Your Token</div>
5787            <div class="token-display">
5788              <div class="token-text" id="tokenText">${token}</div>
5789              <button class="btn-copy" onclick="copyToken()">Copy</button>
5790            </div>
5791          </div>
5792
5793          <div class="info-box">
5794            <div class="info-box-content">
5795              Change your token anytime to revoke shared URL access.
5796            </div>
5797          </div>
5798
5799          <div class="form-section">
5800            <div class="form-title">Update Token</div>
5801            <form method="POST" action="/api/v1/user/${userId}/shareToken${tokenQS}">
5802              <div class="form-row">
5803                <input
5804                  name="htmlShareToken"
5805                  placeholder="Enter new token"
5806                  required
5807                />
5808                <button type="submit" class="btn-submit">Update</button>
5809              </div>
5810            </form>
5811          </div>
5812        `
5813          : `
5814          <!-- First Time View -->
5815          <div class="welcome-box">
5816            <div class="welcome-title">Create a Share Token</div>
5817            <div class="welcome-text">
5818              Share read-only access to your trees and notes. Change it anytime to revoke old links.
5819            </div>
5820          </div>
5821
5822          <div class="form-section">
5823            <div class="form-title">Choose Your Token</div>
5824            <form method="POST" action="/api/v1/user/${userId}/shareToken${tokenQS}">
5825              <div class="form-row">
5826                <input
5827                  name="htmlShareToken"
5828                  placeholder="Enter a unique token"
5829                  required
5830                />
5831                <button type="submit" class="btn-submit">Create</button>
5832              </div>
5833            </form>
5834          </div>
5835        `
5836      }
5837
5838      <div class="back-links">
5839        <a class="back-link" href="/api/v1/user/${userId}${tokenQS}">
5840          ← Back to Profile
5841        </a>
5842        <a class="back-link" target="_top" href="/">
5843          ← Back to ${new URL(getLandUrl()).hostname}
5844        </a>
5845      </div>
5846    </div>
5847  </div>
5848
5849  <script>
5850    function copyToken() {
5851      const tokenText = document.getElementById('tokenText').textContent;
5852      navigator.clipboard.writeText(tokenText).then(() => {
5853        const btn = document.querySelector('.btn-copy');
5854        const originalText = btn.textContent;
5855        btn.textContent = '✓ Copied';
5856        setTimeout(() => {
5857          btn.textContent = originalText;
5858        }, 2000);
5859      });
5860    }
5861  </script>
5862</body>
5863</html>
5864      `);
5865}
5866
5867// ═══════════════════════════════════════════════════════════════════
5868// 13. Energy Page - GET /user/:userId/energy
5869// ═══════════════════════════════════════════════════════════════════
5870export function renderEnergy({ userId, user, energyAmount, additionalEnergy, profileType, planExpiresAt, llmConnections, mainAssignment, rawIdeaAssignment, activeConn, hasLlm, connectionCount, isBasic, qs }) {
5871    return (`<!DOCTYPE html>
5872<html lang="en">
5873<head>
5874<meta charset="UTF-8">
5875<meta name="viewport" content="width=device-width, initial-scale=1.0">
5876<meta name="theme-color" content="#667eea">
5877<title>Energy · @${user.username}</title>
5878<style>
5879${baseStyles}
5880${backNavStyles}
5881
5882body {
5883  color: white;
5884}
5885
5886
5887  .glass-card > * {
5888    position: relative;
5889    z-index: 1;
5890  }
5891
5892
5893  /* =========================================================
5894     GLASS CARDS
5895     ========================================================= */
5896  .glass-card {
5897    background: rgba(var(--glass-water-rgb), var(--glass-alpha));
5898    backdrop-filter: blur(22px) saturate(140%);
5899    -webkit-backdrop-filter: blur(22px) saturate(140%);
5900    border-radius: 16px;
5901    padding: 28px;
5902    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
5903      inset 0 1px 0 rgba(255, 255, 255, 0.25);
5904    border: 1px solid rgba(255, 255, 255, 0.28);
5905    margin-bottom: 24px;
5906    animation: fadeInUp 0.6s ease-out both;
5907    position: relative;
5908    overflow: visible;
5909  }
5910
5911  .glass-card::before {
5912    content: "";
5913    position: absolute;
5914    inset: 0;
5915    border-radius: inherit;
5916    background: linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05));
5917    pointer-events: none;
5918  }
5919
5920  .glass-card h2 {
5921    font-size: 18px;
5922    font-weight: 600;
5923    color: white;
5924    margin-bottom: 16px;
5925    letter-spacing: -0.3px;
5926    text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
5927  }
5928
5929
5930  /* =========================================================
5931     ENERGY STATUS
5932     ========================================================= */
5933  .energy-grid {
5934    display: grid;
5935    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
5936    gap: 14px;
5937  }
5938
5939  .energy-stat {
5940    padding: 18px 20px;
5941    background: rgba(255, 255, 255, 0.1);
5942    border: 1px solid rgba(255, 255, 255, 0.2);
5943    border-radius: 14px;
5944    text-align: center;
5945    position: relative;
5946    overflow: hidden;
5947  }
5948
5949  .energy-stat::before {
5950    content: "";
5951    position: absolute;
5952    inset: 0;
5953    border-radius: inherit;
5954    background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent);
5955    pointer-events: none;
5956  }
5957
5958  .energy-stat-label {
5959    font-size: 12px;
5960    font-weight: 600;
5961    text-transform: uppercase;
5962    letter-spacing: 0.5px;
5963    color: rgba(255, 255, 255, 0.6);
5964    margin-bottom: 6px;
5965  }
5966
5967  .energy-stat-value {
5968    font-size: 28px;
5969    font-weight: 700;
5970    color: white;
5971    text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
5972  }
5973
5974  .energy-stat-sub {
5975    font-size: 12px;
5976    color: rgba(255, 255, 255, 0.5);
5977    margin-top: 4px;
5978  }
5979
5980  .energy-stat.plan-basic {
5981    background: rgba(255, 255, 255, 0.12);
5982    border-color: rgba(255, 255, 255, 0.2);
5983  }
5984
5985  .energy-stat.plan-standard {
5986    background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(37, 99, 235, 0.2));
5987    border-color: rgba(96, 165, 250, 0.3);
5988  }
5989
5990  .energy-stat.plan-premium {
5991    background: linear-gradient(135deg, rgba(168, 85, 247, 0.2), rgba(124, 58, 237, 0.2));
5992    border-color: rgba(168, 85, 247, 0.3);
5993  }
5994
5995  .energy-stat.plan-god {
5996    background: linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(245, 158, 11, 0.2));
5997    border-color: rgba(250, 204, 21, 0.3);
5998  }
5999
6000  /* =========================================================
6001     PLAN CARDS
6002     ========================================================= */
6003  .plan-grid {
6004    display: grid;
6005    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
6006    gap: 14px;
6007  }
6008
6009  .plan-box {
6010    padding: 24px 20px;
6011    border-radius: 14px;
6012    text-align: center;
6013    cursor: pointer;
6014    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
6015    position: relative;
6016    overflow: hidden;
6017  }
6018
6019  .plan-box::before {
6020    content: "";
6021    position: absolute;
6022    inset: 0;
6023    border-radius: inherit;
6024    pointer-events: none;
6025    transition: all 0.3s;
6026  }
6027
6028  .plan-box[data-plan="basic"] {
6029    background: rgba(255, 255, 255, 0.2);
6030    border: 2px solid rgba(255, 255, 255, 0.18);
6031  }
6032  .plan-box[data-plan="basic"]::before {
6033    background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent);
6034  }
6035
6036  .plan-box[data-plan="standard"] {
6037    background: rgba(96, 165, 250, 0.08);
6038    border: 2px solid rgba(96, 165, 250, 0.25);
6039  }
6040  .plan-box[data-plan="standard"]::before {
6041    background: linear-gradient(180deg, rgba(96, 165, 250, 0.1), transparent);
6042  }
6043
6044  .plan-box[data-plan="premium"] {
6045    background: rgba(168, 85, 247, 0.08);
6046    border: 2px solid rgba(168, 85, 247, 0.25);
6047  }
6048  .plan-box[data-plan="premium"]::before {
6049    background: linear-gradient(180deg, rgba(168, 85, 247, 0.1), transparent);
6050  }
6051
6052  .plan-box:hover:not(.disabled) {
6053    transform: translateY(-4px);
6054    box-shadow: 0 8px 28px rgba(0, 0, 0, 0.15);
6055  }
6056
6057  .plan-box[data-plan="standard"]:hover:not(.disabled) {
6058    background: rgba(96, 165, 250, 0.16);
6059    border-color: rgba(96, 165, 250, 0.4);
6060  }
6061
6062  .plan-box[data-plan="premium"]:hover:not(.disabled) {
6063    background: rgba(168, 85, 247, 0.16);
6064    border-color: rgba(168, 85, 247, 0.4);
6065  }
6066
6067  .plan-box.selected {
6068    transform: translateY(-4px);
6069    box-shadow: 0 0 0 3px rgba(72, 187, 178, 0.6), 0 8px 28px rgba(0, 0, 0, 0.15), 0 0 30px rgba(72, 187, 178, 0.15);
6070  }
6071
6072  .plan-box[data-plan="standard"].selected {
6073    border-color: rgba(72, 187, 178, 0.9);
6074    background: rgba(96, 165, 250, 0.18);
6075  }
6076
6077  .plan-box[data-plan="premium"].selected {
6078    border-color: rgba(72, 187, 178, 0.9);
6079    background: rgba(168, 85, 247, 0.18);
6080  }
6081
6082  .plan-box.disabled {
6083    opacity: 0.65;
6084    cursor: not-allowed;
6085  }
6086
6087 .plan-box.current-plan {
6088    border-color: rgba(255, 255, 255, 0.6);
6089    border-width: 3px;
6090    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15), 0 0 20px rgba(255, 255, 255, 0.08);
6091  }
6092
6093  .plan-name {
6094    font-size: 20px;
6095    font-weight: 700;
6096    color: white;
6097    margin-bottom: 6px;
6098  }
6099
6100  .plan-price {
6101    font-size: 24px;
6102    font-weight: 700;
6103    color: white;
6104    margin-bottom: 4px;
6105  }
6106
6107  .plan-period {
6108    font-size: 13px;
6109    color: rgba(255, 255, 255, 0.55);
6110  }
6111
6112  .plan-current-tag {
6113    display: inline-block;
6114    margin-top: 10px;
6115    padding: 4px 12px;
6116    border-radius: 980px;
6117    font-size: 11px;
6118    font-weight: 600;
6119    text-transform: uppercase;
6120    letter-spacing: 0.5px;
6121    background: rgba(255, 255, 255, 0.2);
6122    border: 1px solid rgba(255, 255, 255, 0.25);
6123  }
6124
6125  .plan-features {
6126    margin-top: 14px;
6127    display: flex;
6128    flex-direction: column;
6129    gap: 6px;
6130  }
6131
6132  .plan-feature {
6133    font-size: 13px;
6134    font-weight: 500;
6135    color: rgba(255, 255, 255, 0.75);
6136  }
6137
6138  .plan-feature.dim { color: rgba(255, 255, 255, 0.4); }
6139
6140  .plan-feature.highlight {
6141    color: rgba(72, 187, 178, 0.95);
6142    font-weight: 600;
6143  }
6144
6145  .plan-renew-note {
6146    margin-top: 14px;
6147    text-align: center;
6148    font-size: 13px;
6149    color: rgba(255, 255, 255, 0.55);
6150    font-style: italic;
6151  }
6152
6153  /* =========================================================
6154     ENERGY BUY
6155     ========================================================= */
6156  .energy-btns {
6157    display: flex;
6158    gap: 10px;
6159    flex-wrap: wrap;
6160  }
6161
6162  .energy-buy-btn {
6163    padding: 12px 20px;
6164    border-radius: 980px;
6165    border: 1px solid rgba(255, 255, 255, 0.28);
6166    background: rgba(var(--glass-water-rgb), var(--glass-alpha));
6167    backdrop-filter: blur(22px) saturate(140%);
6168    -webkit-backdrop-filter: blur(22px) saturate(140%);
6169    color: white;
6170    font-weight: 600;
6171    font-size: 14px;
6172    font-family: inherit;
6173    cursor: pointer;
6174    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
6175    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
6176      inset 0 1px 0 rgba(255, 255, 255, 0.25);
6177    position: relative;
6178    overflow: hidden;
6179  }
6180
6181  .energy-buy-btn::before {
6182    content: "";
6183    position: absolute;
6184    inset: -40%;
6185    background:
6186      radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%),
6187      linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.25), transparent 70%);
6188    opacity: 0;
6189    transform: translateX(-30%) translateY(-10%);
6190    transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
6191    pointer-events: none;
6192  }
6193
6194  .energy-buy-btn:hover {
6195    background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
6196    transform: translateY(-2px);
6197  }
6198
6199  .energy-buy-btn:hover::before {
6200    opacity: 1;
6201    transform: translateX(30%) translateY(10%);
6202  }
6203
6204  .energy-buy-btn:active {
6205    background: rgba(var(--glass-water-rgb), 0.45);
6206    transform: translateY(0);
6207  }
6208
6209  /* =========================================================
6210     CHECKOUT
6211     ========================================================= */
6212  .checkout-summary {
6213    display: flex;
6214    flex-direction: column;
6215    gap: 10px;
6216  }
6217
6218  .checkout-row {
6219    display: flex;
6220    justify-content: space-between;
6221    align-items: center;
6222    padding: 14px 16px;
6223    background: rgba(255, 255, 255, 0.08);
6224    border: 1px solid rgba(255, 255, 255, 0.15);
6225    border-radius: 12px;
6226    transition: background 0.2s;
6227  }
6228
6229  .checkout-row:hover {
6230    background: rgba(255, 255, 255, 0.12);
6231  }
6232
6233  .checkout-row-left {
6234    display: flex;
6235    align-items: center;
6236    gap: 12px;
6237    flex: 1;
6238    min-width: 0;
6239  }
6240
6241  .checkout-row-icon {
6242    width: 36px;
6243    height: 36px;
6244    border-radius: 10px;
6245    display: flex;
6246    align-items: center;
6247    justify-content: center;
6248    font-size: 16px;
6249    flex-shrink: 0;
6250  }
6251
6252  .checkout-row-icon.plan-icon {
6253    background: rgba(168, 85, 247, 0.2);
6254    border: 1px solid rgba(168, 85, 247, 0.3);
6255  }
6256
6257  .checkout-row-icon.energy-icon {
6258    background: rgba(250, 204, 21, 0.2);
6259    border: 1px solid rgba(250, 204, 21, 0.3);
6260  }
6261
6262  .checkout-row-info {
6263    display: flex;
6264    flex-direction: column;
6265    gap: 2px;
6266    min-width: 0;
6267  }
6268
6269  .checkout-row-label {
6270    font-size: 14px;
6271    font-weight: 600;
6272    color: white;
6273  }
6274
6275  .checkout-row-desc {
6276    font-size: 12px;
6277    color: rgba(255, 255, 255, 0.5);
6278  }
6279
6280  .checkout-row-right {
6281    display: flex;
6282    align-items: center;
6283    gap: 12px;
6284    flex-shrink: 0;
6285  }
6286
6287  .checkout-row-value {
6288    font-size: 16px;
6289    font-weight: 700;
6290    color: white;
6291  }
6292
6293  .checkout-remove {
6294    width: 28px;
6295    height: 28px;
6296    border-radius: 50%;
6297    border: 1px solid rgba(255, 255, 255, 0.2);
6298    background: rgba(239, 68, 68, 0.15);
6299    color: rgba(255, 255, 255, 0.7);
6300    font-size: 14px;
6301    cursor: pointer;
6302    display: inline-flex;
6303    align-items: center;
6304    justify-content: center;
6305    transition: all 0.2s;
6306    line-height: 1;
6307  }
6308
6309  .checkout-remove:hover {
6310    background: rgba(239, 68, 68, 0.35);
6311    border-color: rgba(239, 68, 68, 0.5);
6312    color: white;
6313  }
6314
6315  .checkout-divider {
6316    height: 1px;
6317    background: rgba(255, 255, 255, 0.1);
6318    margin: 4px 0;
6319  }
6320
6321  .checkout-total {
6322    display: flex;
6323    justify-content: space-between;
6324    align-items: center;
6325    padding: 18px 20px;
6326    background: linear-gradient(135deg, rgba(72, 187, 178, 0.2), rgba(56, 163, 155, 0.15));
6327    border: 1px solid rgba(72, 187, 178, 0.35);
6328    border-radius: 14px;
6329  }
6330
6331  .checkout-total-label {
6332    font-size: 16px;
6333    font-weight: 600;
6334    color: white;
6335  }
6336
6337  .checkout-total-value {
6338    font-size: 28px;
6339    font-weight: 700;
6340    color: white;
6341    text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
6342  }
6343
6344  .checkout-btn {
6345    width: 100%;
6346    padding: 18px;
6347    border-radius: 980px;
6348    border: 1px solid rgba(72, 187, 178, 0.5);
6349    background: linear-gradient(135deg, rgba(72, 187, 178, 0.4), rgba(56, 163, 155, 0.35));
6350    backdrop-filter: blur(22px) saturate(140%);
6351    -webkit-backdrop-filter: blur(22px) saturate(140%);
6352    color: white;
6353    font-size: 17px;
6354    font-weight: 700;
6355    font-family: inherit;
6356    cursor: pointer;
6357    margin-top: 16px;
6358    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
6359    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
6360      0 0 20px rgba(72, 187, 178, 0.1),
6361      inset 0 1px 0 rgba(255, 255, 255, 0.2);
6362    position: relative;
6363    overflow: hidden;
6364    letter-spacing: -0.2px;
6365  }
6366
6367  .checkout-btn::before {
6368    content: "";
6369    position: absolute;
6370    inset: -40%;
6371    background:
6372      radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%),
6373      linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.25), transparent 70%);
6374    opacity: 0;
6375    transform: translateX(-30%) translateY(-10%);
6376    transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
6377    pointer-events: none;
6378  }
6379
6380  .checkout-btn:hover {
6381    background: linear-gradient(135deg, rgba(72, 187, 178, 0.55), rgba(56, 163, 155, 0.5));
6382    transform: translateY(-2px);
6383    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18),
6384      0 0 30px rgba(72, 187, 178, 0.2);
6385  }
6386
6387  .checkout-btn:hover::before {
6388    opacity: 1;
6389    transform: translateX(30%) translateY(10%);
6390  }
6391
6392  .checkout-btn:active { transform: translateY(0); }
6393
6394  .checkout-btn:disabled {
6395    opacity: 0.4;
6396    cursor: not-allowed;
6397    transform: none;
6398  }
6399
6400  .checkout-btn:disabled:hover {
6401    background: linear-gradient(135deg, rgba(72, 187, 178, 0.4), rgba(56, 163, 155, 0.35));
6402    transform: none;
6403    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
6404  }
6405
6406  .checkout-legal {
6407    text-align: center;
6408    margin-top: 14px;
6409    font-size: 12px;
6410    color: rgba(255, 255, 255, 0.45);
6411    line-height: 1.5;
6412  }
6413
6414  .checkout-legal-link {
6415    color: rgba(255, 255, 255, 0.7);
6416    text-decoration: underline;
6417    text-underline-offset: 2px;
6418    cursor: pointer;
6419    transition: color 0.2s;
6420  }
6421
6422  .checkout-legal-link:hover { color: white; }
6423
6424  .checkout-note {
6425    text-align: center;
6426    margin-top: 10px;
6427    font-size: 13px;
6428    color: rgba(255, 255, 255, 0.45);
6429    font-style: italic;
6430  }
6431
6432  .checkout-empty {
6433    text-align: center;
6434    padding: 28px 20px;
6435    color: rgba(255, 255, 255, 0.4);
6436    font-style: italic;
6437    font-size: 14px;
6438    border: 2px dashed rgba(255, 255, 255, 0.12);
6439    border-radius: 14px;
6440  }
6441
6442  /* =========================================================
6443     LLM SECTION
6444     ========================================================= */
6445  .llm-section-wrapper {
6446    position: relative;
6447  }
6448
6449  .llm-section-wrapper.locked .llm-section-content {
6450    opacity: 0.2;
6451    pointer-events: none;
6452    filter: blur(2px);
6453  }
6454
6455  .llm-upgrade-overlay {
6456    display: none;
6457    position: absolute;
6458    inset: 0;
6459    z-index: 5;
6460    border-radius: inherit;
6461    align-items: center;
6462    justify-content: center;
6463    flex-direction: column;
6464    gap: 8px;
6465  }
6466
6467  .llm-section-wrapper.locked .llm-upgrade-overlay {
6468    display: flex;
6469  }
6470
6471  .llm-upgrade-text {
6472    font-size: 16px;
6473    font-weight: 600;
6474    color: white;
6475    text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
6476  }
6477
6478  .llm-upgrade-sub {
6479    font-size: 13px;
6480    color: rgba(255, 255, 255, 0.7);
6481  }
6482
6483  .llm-sub {
6484    font-size: 14px;
6485    color: rgba(255, 255, 255, 0.6);
6486    line-height: 1.5;
6487    margin-bottom: 16px;
6488  }
6489
6490  .llm-toggle-row {
6491    display: flex;
6492    align-items: center;
6493    justify-content: space-between;
6494    gap: 14px;
6495    margin-bottom: 16px;
6496    padding: 14px 16px;
6497    background: rgba(255, 255, 255, 0.06);
6498    border: 1px solid rgba(255, 255, 255, 0.12);
6499    border-radius: 12px;
6500  }
6501
6502  .llm-toggle-label {
6503    font-size: 14px;
6504    font-weight: 600;
6505    color: rgba(255, 255, 255, 0.8);
6506  }
6507
6508  .glass-toggle {
6509    position: relative;
6510    width: 54px;
6511    height: 28px;
6512    border-radius: 999px;
6513    background: rgba(255, 255, 255, 0.2);
6514    border: 1px solid rgba(255, 255, 255, 0.3);
6515    backdrop-filter: blur(18px);
6516    cursor: pointer;
6517    transition: all 0.25s ease;
6518    flex-shrink: 0;
6519  }
6520
6521  .glass-toggle.active {
6522    background: rgba(72, 187, 178, 0.45);
6523    box-shadow: 0 0 16px rgba(72, 187, 178, 0.35);
6524  }
6525
6526  .glass-toggle-knob {
6527    position: absolute;
6528    top: 4px;
6529    left: 4px;
6530    width: 20px;
6531    height: 20px;
6532    border-radius: 50%;
6533    background: white;
6534    transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
6535  }
6536
6537  .glass-toggle.active .glass-toggle-knob {
6538    left: 28px;
6539  }
6540
6541  .llm-connected-badge {
6542    display: flex;
6543    align-items: center;
6544    gap: 10px;
6545    padding: 12px 16px;
6546    background: rgba(72, 187, 120, 0.15);
6547    border: 1px solid rgba(72, 187, 120, 0.3);
6548    border-radius: 10px;
6549    margin-bottom: 16px;
6550  }
6551
6552  .llm-connected-dot {
6553    width: 8px; height: 8px;
6554    border-radius: 50%;
6555    background: rgba(72, 187, 120, 0.9);
6556    box-shadow: 0 0 8px rgba(72, 187, 120, 0.5);
6557    flex-shrink: 0;
6558  }
6559
6560  .llm-connected-text {
6561    font-size: 13px;
6562    font-weight: 600;
6563    color: rgba(72, 187, 120, 0.9);
6564  }
6565
6566  .llm-connected-detail {
6567    font-size: 12px;
6568    color: rgba(255, 255, 255, 0.45);
6569    margin-left: auto;
6570    font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
6571    overflow: hidden;
6572    text-overflow: ellipsis;
6573    white-space: nowrap;
6574    max-width: 300px;
6575  }
6576
6577  .llm-fields {
6578    display: flex;
6579    flex-direction: column;
6580    gap: 12px;
6581    transition: opacity 0.3s;
6582  }
6583
6584  .llm-fields.disabled {
6585    opacity: 0.35;
6586    pointer-events: none;
6587  }
6588
6589  .llm-field-row {
6590    display: flex;
6591    flex-direction: column;
6592    gap: 4px;
6593  }
6594
6595  .llm-field-label {
6596    font-size: 12px;
6597    font-weight: 600;
6598    text-transform: uppercase;
6599    letter-spacing: 0.5px;
6600    color: rgba(255, 255, 255, 0.55);
6601  }
6602
6603  .llm-input {
6604    padding: 14px 16px;
6605    font-size: 15px;
6606    border-radius: 12px;
6607    border: 2px solid rgba(255, 255, 255, 0.3);
6608    background: rgba(255, 255, 255, 0.15);
6609    color: white;
6610    font-family: inherit;
6611    font-weight: 500;
6612    transition: all 0.2s;
6613    width: 100%;
6614  }
6615
6616  .llm-input::placeholder { color: rgba(255, 255, 255, 0.35); }
6617
6618  .llm-input:focus {
6619    outline: none;
6620    border-color: rgba(255, 255, 255, 0.6);
6621    background: rgba(255, 255, 255, 0.25);
6622    box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
6623    transform: translateY(-2px);
6624  }
6625
6626  /* Custom dropdown (replaces native <select> to avoid iframe glitch on mobile) */
6627  .custom-select {
6628    position: relative;
6629    width: 100%;
6630  }
6631  .custom-select-trigger {
6632    padding: 8px 10px;
6633    font-size: 15px;
6634    border-radius: 12px;
6635    border: 2px solid rgba(255, 255, 255, 0.3);
6636    background: rgba(255, 255, 255, 0.15);
6637    color: white;
6638    font-family: inherit;
6639    font-weight: 500;
6640    cursor: pointer;
6641    display: flex;
6642    align-items: center;
6643    justify-content: space-between;
6644    gap: 8px;
6645    transition: border-color 0.2s, background 0.2s;
6646    -webkit-user-select: none;
6647    user-select: none;
6648  }
6649  .custom-select-trigger::after {
6650    content: "▾";
6651    font-size: 12px;
6652    opacity: 0.6;
6653    flex-shrink: 0;
6654  }
6655  .custom-select.open .custom-select-trigger {
6656    border-color: rgba(255, 255, 255, 0.6);
6657    background: rgba(255, 255, 255, 0.25);
6658  }
6659  .custom-select.open .custom-select-trigger::after { content: "▴"; }
6660  .custom-select-options {
6661    display: none;
6662    position: absolute;
6663    left: 0; right: 0;
6664    bottom: calc(100% + 4px);
6665    background: rgba(30, 20, 50, 0.97);
6666    border: 1px solid rgba(255, 255, 255, 0.25);
6667    border-radius: 10px;
6668    overflow: hidden;
6669    z-index: 100;
6670    max-height: 220px;
6671    overflow-y: auto;
6672    backdrop-filter: blur(12px);
6673    -webkit-backdrop-filter: blur(12px);
6674    box-shadow: 0 -4px 20px rgba(0,0,0,0.4);
6675  }
6676  .custom-select.open .custom-select-options { display: block; }
6677  .custom-select-option {
6678    padding: 10px 12px;
6679    font-size: 14px;
6680    color: rgba(255, 255, 255, 0.8);
6681    cursor: pointer;
6682    transition: background 0.15s;
6683  }
6684  .custom-select-option:hover,
6685  .custom-select-option:focus { background: rgba(255, 255, 255, 0.12); }
6686  .custom-select-option.selected {
6687    background: rgba(72, 187, 178, 0.2);
6688    color: white;
6689    font-weight: 600;
6690  }
6691
6692  .llm-btn-row {
6693    display: flex;
6694    gap: 12px;
6695    margin-top: 4px;
6696  }
6697
6698  .llm-save-btn,
6699  .llm-disconnect-btn {
6700    padding: 14px 24px;
6701    border-radius: 980px;
6702    border: 1px solid;
6703    color: white;
6704    font-weight: 600;
6705    font-size: 15px;
6706    font-family: inherit;
6707    cursor: pointer;
6708    transition: all 0.3s;
6709    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
6710      inset 0 1px 0 rgba(255, 255, 255, 0.2);
6711    background: none;
6712  }
6713
6714  .llm-save-btn {
6715    flex: 1;
6716    border-color: rgba(72, 187, 178, 0.4);
6717    background: rgba(72, 187, 178, 0.3);
6718  }
6719
6720  .llm-save-btn:hover {
6721    background: rgba(72, 187, 178, 0.45);
6722    transform: translateY(-2px);
6723  }
6724
6725  .llm-disconnect-btn {
6726    border-color: rgba(239, 68, 68, 0.4);
6727    background: rgba(239, 68, 68, 0.25);
6728  }
6729
6730  .llm-disconnect-btn:hover {
6731    background: rgba(239, 68, 68, 0.4);
6732    transform: translateY(-2px);
6733  }
6734
6735  .llm-status {
6736    margin-top: 10px;
6737    font-size: 13px;
6738    font-weight: 600;
6739    display: none;
6740  }
6741
6742  /* =========================================================
6743     MODAL (Terms / Privacy)
6744     ========================================================= */
6745  .modal-overlay {
6746    display: none;
6747    position: fixed;
6748    inset: 0;
6749    z-index: 1000;
6750    background: rgba(0, 0, 0, 0.6);
6751    backdrop-filter: blur(8px);
6752    -webkit-backdrop-filter: blur(8px);
6753    align-items: center;
6754    justify-content: center;
6755    padding: 20px;
6756  }
6757
6758  .modal-overlay.show { display: flex; }
6759
6760  .modal-container {
6761    width: 100%;
6762    max-width: 720px;
6763    height: 85vh;
6764    height: 85dvh;
6765    background: rgba(var(--glass-water-rgb), 0.35);
6766    backdrop-filter: blur(22px) saturate(140%);
6767    -webkit-backdrop-filter: blur(22px) saturate(140%);
6768    border-radius: 20px;
6769    border: 1px solid rgba(255, 255, 255, 0.28);
6770    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
6771    display: flex;
6772    flex-direction: column;
6773    overflow: hidden;
6774  }
6775
6776  .modal-header {
6777    display: flex;
6778    align-items: center;
6779    justify-content: space-between;
6780    padding: 16px 20px;
6781    border-bottom: 1px solid rgba(255, 255, 255, 0.15);
6782    flex-shrink: 0;
6783  }
6784
6785  .modal-title {
6786    font-size: 16px;
6787    font-weight: 600;
6788    color: white;
6789  }
6790
6791  .modal-close {
6792    width: 32px;
6793    height: 32px;
6794    border-radius: 50%;
6795    border: 1px solid rgba(255, 255, 255, 0.25);
6796    background: rgba(255, 255, 255, 0.15);
6797    color: white;
6798    font-size: 18px;
6799    cursor: pointer;
6800    display: inline-flex;
6801    align-items: center;
6802    justify-content: center;
6803    transition: background 0.2s;
6804    line-height: 1;
6805  }
6806
6807  .modal-close:hover {
6808    background: rgba(255, 255, 255, 0.3);
6809  }
6810
6811  .modal-body {
6812    flex: 1;
6813    overflow: hidden;
6814  }
6815
6816  .modal-body iframe {
6817    width: 100%;
6818    height: 100%;
6819    border: none;
6820  }
6821
6822  /* =========================================================
6823     RESPONSIVE
6824     ========================================================= */
6825  @media (max-width: 640px) {
6826    body { padding: 16px; }
6827    .container { max-width: 100%; }
6828    .glass-card { padding: 20px; }
6829
6830    .energy-grid { grid-template-columns: 1fr; }
6831    .plan-grid { grid-template-columns: 1fr; }
6832    .energy-btns { flex-direction: column; }
6833    .energy-buy-btn { width: 100%; }
6834    .llm-btn-row { flex-direction: column; }
6835    .llm-save-btn, .llm-disconnect-btn { width: 100%; text-align: center; }
6836    .llm-connected-detail { max-width: 140px; }
6837    .modal-container { height: 90vh; height: 90dvh; border-radius: 16px; }
6838    .modal-overlay { padding: 10px; }
6839  }
6840</style>
6841</head>
6842<body>
6843<div class="container">
6844
6845  <div class="back-nav">
6846    <a href="/api/v1/user/${userId}${qs}" class="back-link">← Back to Profile</a>
6847  </div>
6848
6849  <!-- Energy Status -->
6850  <div class="glass-card" style="animation-delay: 0.1s;">
6851    <h2>⚡ Energy</h2>
6852    <div class="energy-grid">
6853      <div class="energy-stat">
6854        <div class="energy-stat-label">Plan Energy</div>
6855        <div class="energy-stat-value">${energyAmount}</div>
6856        <div class="energy-stat-sub">Resets every 24 hours</div>
6857      </div>
6858      <div class="energy-stat plan-${profileType}">
6859        <div class="energy-stat-label">Current Plan</div>
6860        <div class="energy-stat-value" style="font-size: 22px; text-transform: capitalize;">${profileType}</div>
6861        ${!isBasic && planExpiresAt ? '<div class="energy-stat-sub">Expires ' + new Date(planExpiresAt).toLocaleDateString() + "</div>" : ""}
6862      </div>
6863      <div class="energy-stat">
6864        <div class="energy-stat-label">Additional Energy</div>
6865        <div class="energy-stat-value">${additionalEnergy}</div>
6866        <div class="energy-stat-sub">Used after plan energy</div>
6867      </div>
6868    </div>
6869  </div>
6870
6871  <!-- Plans -->
6872  <div class="glass-card" style="animation-delay: 0.15s;">
6873    <h2>📋 Plans</h2>
6874    <div class="plan-grid">
6875      <div class="plan-box disabled" data-plan="basic">
6876        <div class="plan-name">Basic</div>
6877        <div class="plan-price">Free</div>
6878        <div class="plan-period">350 daily energy</div>
6879        <div class="plan-features">
6880          <div class="plan-feature">No file uploads</div>
6881          <div class="plan-feature dim">Limited access</div>
6882        </div>
6883        ${profileType === "basic" ? '<div class="plan-current-tag">Current Plan</div>' : ""}
6884      </div>
6885      <div class="plan-box" data-plan="standard">
6886        <div class="plan-name">Standard</div>
6887        <div class="plan-price">$20</div>
6888        <div class="plan-period">per 30 days</div>
6889        <div class="plan-features">
6890          <div class="plan-feature">1,500 daily energy</div>
6891          <div class="plan-feature">File uploads</div>
6892        </div>
6893        ${profileType === "standard" ? '<div class="plan-current-tag">Current Plan</div>' : ""}
6894      </div>
6895      <div class="plan-box" data-plan="premium">
6896        <div class="plan-name">Premium</div>
6897        <div class="plan-price">$100</div>
6898        <div class="plan-period">per 30 days</div>
6899        <div class="plan-features">
6900          <div class="plan-feature">8,000 daily energy</div>
6901          <div class="plan-feature">Full access</div>
6902          <div class="plan-feature highlight">Offline LLM processing</div>
6903        </div>
6904        ${profileType === "premium" || profileType === "god" ? '<div class="plan-current-tag">Current Plan</div>' : ""}
6905      </div>
6906    </div>
6907    <div class="plan-renew-note" id="planNote" style="display:none;"></div>
6908  </div>
6909
6910  <!-- Buy Energy -->
6911  <div class="glass-card" style="animation-delay: 0.2s;">
6912    <h2>🔥 Additional Energy</h2>
6913    <div style="font-size: 14px; color: rgba(255,255,255,0.55); margin-bottom: 16px;">Reserve energy — only used when your plan energy runs out.</div>
6914    <div class="energy-btns" id="energyBtns">
6915      <button class="energy-buy-btn" data-amount="100">+100</button>
6916      <button class="energy-buy-btn" data-amount="500">+500</button>
6917      <button class="energy-buy-btn" data-amount="1000">+1000</button>
6918      <button class="energy-buy-btn" id="customEnergyBtn">+Custom</button>
6919    </div>
6920    <div id="energyAdded" style="margin-top: 14px; font-size: 14px; color: rgba(255,255,255,0.6); display: none;">
6921      Added: <strong id="energyAddedVal" style="color: white;"></strong>
6922      <span style="margin-left: 8px; cursor: pointer; opacity: 0.6;" onclick="resetEnergy()">✕ Clear</span>
6923    </div>
6924  </div>
6925
6926  <!-- Checkout -->
6927  <div class="glass-card" style="animation-delay: 0.25s;">
6928    <h2>💳 Checkout</h2>
6929    <div id="checkoutContent">
6930      <div class="checkout-empty">Select a plan or add energy to continue</div>
6931    </div>
6932  </div>
6933
6934  <!-- Custom LLM -->
6935  <div class="glass-card" style="animation-delay: 0.3s;">
6936    <h2>🤖 Custom LLM Endpoints <span style="opacity:0.5;font-size:0.7em">(${connectionCount}/15)</span></h2>
6937    <div class="llm-section-wrapper">
6938      <div class="llm-section-content">
6939        <div class="llm-sub">Connect your own OpenAI API-compatible LLMs. Assign them to different areas below.</div>
6940
6941        ${
6942          connectionCount > 0
6943            ? '<div style="display:flex;gap:12px;margin-bottom:14px;flex-wrap:wrap;">' +
6944              '<div style="flex:1;min-width:180px;">' +
6945              '<label class="llm-field-label" style="margin-bottom:4px;display:block;">Profile (Chat)</label>' +
6946              '<div class="custom-select" id="llmAssignMain" data-slot="main">' +
6947              '<div class="custom-select-trigger">' +
6948              (mainAssignment
6949                ? llmConnections
6950                    .filter(function (c) {
6951                      return c._id === mainAssignment;
6952                    })
6953                    .map(function (c) {
6954                      return c.name + " (" + c.model + ")";
6955                    })[0] || "None selected"
6956                : "None selected") +
6957              "</div>" +
6958              '<div class="custom-select-options">' +
6959              '<div class="custom-select-option' +
6960              (!mainAssignment ? " selected" : "") +
6961              '" data-value="">None</div>' +
6962              llmConnections
6963                .map(function (c) {
6964                  return (
6965                    '<div class="custom-select-option' +
6966                    (mainAssignment === c._id ? " selected" : "") +
6967                    '" data-value="' +
6968                    c._id +
6969                    '">' +
6970                    c.name +
6971                    " (" +
6972                    c.model +
6973                    ")</div>"
6974                  );
6975                })
6976                .join("") +
6977              "</div>" +
6978              "</div>" +
6979              "</div>" +
6980              '<div style="flex:1;min-width:180px;">' +
6981              '<label class="llm-field-label" style="margin-bottom:4px;display:block;">Raw Ideas</label>' +
6982              '<div class="custom-select" id="llmAssignRawIdea" data-slot="rawIdea">' +
6983              '<div class="custom-select-trigger">' +
6984              (rawIdeaAssignment
6985                ? llmConnections
6986                    .filter(function (c) {
6987                      return c._id === rawIdeaAssignment;
6988                    })
6989                    .map(function (c) {
6990                      return c.name + " (" + c.model + ")";
6991                    })[0] || "Uses main"
6992                : "Uses main") +
6993              "</div>" +
6994              '<div class="custom-select-options">' +
6995              '<div class="custom-select-option' +
6996              (!rawIdeaAssignment ? " selected" : "") +
6997              '" data-value="">Uses main</div>' +
6998              llmConnections
6999                .map(function (c) {
7000                  return (
7001                    '<div class="custom-select-option' +
7002                    (rawIdeaAssignment === c._id ? " selected" : "") +
7003                    '" data-value="' +
7004                    c._id +
7005                    '">' +
7006                    c.name +
7007                    " (" +
7008                    c.model +
7009                    ")</div>"
7010                  );
7011                })
7012                .join("") +
7013              "</div>" +
7014              "</div>" +
7015              "</div>" +
7016              "</div>"
7017            : ""
7018        }
7019
7020        <div id="llmConnectionsList">
7021          ${
7022            connectionCount === 0
7023              ? '<div class="llm-empty-state" style="text-align:center;padding:18px 0;opacity:0.5;">No connections yet</div>'
7024              : llmConnections
7025                  .map(function (c) {
7026                    return (
7027                      '<div class="llm-conn-card" data-id="' +
7028                      c._id +
7029                      '" style="border:1px solid var(--glass-border-light);border-radius:10px;padding:12px 14px;margin-bottom:8px;background:rgba(255,255,255,0.03);">' +
7030                      '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">' +
7031                      '<div style="flex:1;min-width:0;">' +
7032                      '<div style="font-weight:600;font-size:0.95em;">' +
7033                      c.name +
7034                      "</div>" +
7035                      '<div style="font-size:0.8em;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' +
7036                      c.model +
7037                      " · " +
7038                      c.baseUrl +
7039                      "</div>" +
7040                      "</div>" +
7041                      '<div style="display:flex;gap:6px;flex-shrink:0;">' +
7042                      '<button class="llm-save-btn" style="font-size:0.75em;padding:4px 10px;" onclick="editConnection(\'' +
7043                      c._id +
7044                      "')\">Edit</button>" +
7045                      '<button class="llm-disconnect-btn" style="font-size:0.75em;padding:4px 10px;" onclick="deleteConnection(\'' +
7046                      c._id +
7047                      "')\">Delete</button>" +
7048                      "</div>" +
7049                      "</div>" +
7050                      "</div>"
7051                    );
7052                  })
7053                  .join("")
7054          }
7055        </div>
7056
7057        <div id="llmAddSection" style="margin-top:12px;">
7058          <button class="llm-save-btn" id="llmAddToggle" onclick="toggleAddForm()" style="width:100%;">+ Add Connection</button>
7059          <div class="llm-fields" id="llmAddForm" style="display:none;margin-top:10px;">
7060            <div class="llm-field-row">
7061              <label class="llm-field-label">Name</label>
7062              <input type="text" class="llm-input" id="llmName" placeholder="e.g. Groq, OpenRouter" />
7063            </div>
7064            <div class="llm-field-row">
7065              <label class="llm-field-label">Endpoint URL</label>
7066              <input type="text" class="llm-input" id="llmBaseUrl" placeholder="https://api.groq.com/openai/v1/chat/completions" />
7067            </div>
7068            <div class="llm-field-row">
7069              <label class="llm-field-label">API Key</label>
7070              <input type="password" class="llm-input" id="llmApiKey" placeholder="gsk_abc123..." />
7071            </div>
7072            <div class="llm-field-row">
7073              <label class="llm-field-label">Model</label>
7074              <input type="text" class="llm-input" id="llmModel" placeholder="openai/gpt-oss-120b" />
7075            </div>
7076            <div class="llm-btn-row">
7077              <button class="llm-save-btn" onclick="addConnection()">Save Connection</button>
7078            </div>
7079          </div>
7080        </div>
7081
7082        <div id="llmEditSection" style="display:none;margin-top:12px;">
7083          <div style="font-weight:600;margin-bottom:8px;">Edit Connection</div>
7084          <input type="hidden" id="llmEditId" />
7085          <div class="llm-fields">
7086            <div class="llm-field-row">
7087              <label class="llm-field-label">Name</label>
7088              <input type="text" class="llm-input" id="llmEditName" />
7089            </div>
7090            <div class="llm-field-row">
7091              <label class="llm-field-label">Endpoint URL</label>
7092              <input type="text" class="llm-input" id="llmEditBaseUrl" />
7093            </div>
7094            <div class="llm-field-row">
7095              <label class="llm-field-label">API Key</label>
7096              <input type="password" class="llm-input" id="llmEditApiKey" placeholder="••••••••  (leave blank to keep)" />
7097            </div>
7098            <div class="llm-field-row">
7099              <label class="llm-field-label">Model</label>
7100              <input type="text" class="llm-input" id="llmEditModel" />
7101            </div>
7102            <div class="llm-btn-row">
7103              <button class="llm-save-btn" onclick="updateConnection()">Update</button>
7104              <button class="llm-disconnect-btn" onclick="cancelEdit()">Cancel</button>
7105            </div>
7106          </div>
7107        </div>
7108
7109        <div class="llm-status" id="llmStatus"></div>
7110
7111        <!-- Failover Stack -->
7112        <div style="margin-top:20px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);">
7113          <div style="font-weight:600;margin-bottom:8px;">Failover Stack</div>
7114          <div class="llm-sub" style="margin-bottom:10px;">If your default LLM fails (rate limit, timeout), the system tries these backups in order.</div>
7115          <div id="failoverStack" style="min-height:30px;">
7116            <div style="opacity:0.4;font-size:0.85rem;">Loading...</div>
7117          </div>
7118          <div style="margin-top:8px;display:flex;gap:8px;align-items:center;">
7119            <select id="failoverSelect" class="llm-input" style="flex:1;">
7120              <option value="">Select a backup connection...</option>
7121              ${llmConnections.map(c => '<option value="' + c._id + '">' + c.name + ' (' + c.model + ')</option>').join("")}
7122            </select>
7123            <button class="llm-save-btn" onclick="pushFailover()" style="white-space:nowrap;">Add Backup</button>
7124          </div>
7125        </div>
7126
7127      </div>
7128    </div>
7129  </div>
7130
7131</div>
7132
7133<script>
7134function loadFailoverStack() {
7135  fetch("/api/v1/user/${userId}/llm-failover${qs}", { headers: { "Authorization": "Bearer " + document.cookie.replace(/.*token=([^;]*).*/, "$1") } })
7136    .then(r => r.json())
7137    .then(data => {
7138      const el = document.getElementById("failoverStack");
7139      const stack = data.stack || [];
7140      if (stack.length === 0) {
7141        el.innerHTML = '<div style="opacity:0.4;font-size:0.85rem;">No backups configured. Add connections above to build your failover stack.</div>';
7142        return;
7143      }
7144      const conns = ${JSON.stringify(llmConnections.map(c => ({ id: c._id, name: c.name, model: c.model })))};
7145      el.innerHTML = stack.map((id, i) => {
7146        const c = conns.find(x => x.id === id);
7147        const label = c ? c.name + " (" + c.model + ")" : id;
7148        return '<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.05);">' +
7149          '<span style="opacity:0.4;font-size:0.8rem;width:20px;">' + (i+1) + '.</span>' +
7150          '<span style="flex:1;">' + label + '</span>' +
7151          '<button onclick="removeFailover(\\''+id+'\\','+i+')" style="background:none;border:none;color:rgba(255,100,100,0.7);cursor:pointer;font-size:0.8rem;">remove</button>' +
7152        '</div>';
7153      }).join("");
7154    })
7155    .catch(() => {
7156      document.getElementById("failoverStack").innerHTML = '<div style="color:rgba(255,100,100,0.7);">Failed to load</div>';
7157    });
7158}
7159
7160function pushFailover() {
7161  const select = document.getElementById("failoverSelect");
7162  const connectionId = select.value;
7163  if (!connectionId) return;
7164  fetch("/api/v1/user/${userId}/llm-failover${qs}", {
7165    method: "POST",
7166    headers: { "Content-Type": "application/json", "Authorization": "Bearer " + document.cookie.replace(/.*token=([^;]*).*/, "$1") },
7167    body: JSON.stringify({ connectionId })
7168  })
7169    .then(r => r.json())
7170    .then(data => {
7171      if (data.error) return alert(data.error);
7172      select.value = "";
7173      loadFailoverStack();
7174    });
7175}
7176
7177function removeFailover(connId, index) {
7178  fetch("/api/v1/user/${userId}/llm-failover/" + encodeURIComponent(connId) + "${qs}", {
7179    method: "DELETE",
7180    headers: { "Authorization": "Bearer " + document.cookie.replace(/.*token=([^;]*).*/, "$1") },
7181  })
7182    .then(r => r.json())
7183    .then(data => {
7184      if (data.error) return alert(data.error);
7185      loadFailoverStack();
7186    });
7187}
7188
7189loadFailoverStack();
7190</script>
7191
7192<!-- Terms Modal -->
7193<div class="modal-overlay" id="termsModal">
7194  <div class="modal-container">
7195    <div class="modal-header">
7196      <span class="modal-title">Terms of Service</span>
7197      <span class="modal-close" onclick="closeModal('terms')">✕</span>
7198    </div>
7199    <div class="modal-body">
7200      <iframe src="/terms" title="Terms of Service"></iframe>
7201    </div>
7202  </div>
7203</div>
7204
7205<!-- Privacy Modal -->
7206<div class="modal-overlay" id="privacyModal">
7207  <div class="modal-container">
7208    <div class="modal-header">
7209      <span class="modal-title">Privacy Policy</span>
7210      <span class="modal-close" onclick="closeModal('privacy')">✕</span>
7211    </div>
7212    <div class="modal-body">
7213      <iframe src="/privacy" title="Privacy Policy"></iframe>
7214    </div>
7215  </div>
7216</div>
7217
7218<script>
7219var userId = "${userId}";
7220var currentPlan = "${profileType === "god" ? "premium" : profileType}";
7221var PLAN_PRICE = { basic: 0, standard: 20, premium: 100 };
7222var PLAN_ORDER = ["basic", "standard", "premium"];
7223var ENERGY_RATE = 0.01;
7224
7225var state = {
7226  energyAdded: 0,
7227  selectedPlan: null
7228};
7229
7230// =====================
7231// MODAL
7232// =====================
7233function openModal(type) {
7234  var id = type === "terms" ? "termsModal" : "privacyModal";
7235  document.getElementById(id).classList.add("show");
7236  document.body.style.overflow = "hidden";
7237}
7238
7239function closeModal(type) {
7240  var id = type === "terms" ? "termsModal" : "privacyModal";
7241  document.getElementById(id).classList.remove("show");
7242  document.body.style.overflow = "";
7243}
7244
7245document.querySelectorAll(".modal-overlay").forEach(function(overlay) {
7246  overlay.addEventListener("click", function(e) {
7247    if (e.target === overlay) {
7248      overlay.classList.remove("show");
7249      document.body.style.overflow = "";
7250    }
7251  });
7252});
7253
7254document.addEventListener("keydown", function(e) {
7255  if (e.key === "Escape") {
7256    document.querySelectorAll(".modal-overlay.show").forEach(function(m) {
7257      m.classList.remove("show");
7258    });
7259    document.body.style.overflow = "";
7260  }
7261});
7262
7263// =====================
7264// URL STATE
7265// =====================
7266function readURL() {
7267  var p = new URLSearchParams(location.search);
7268  if (p.get("energy")) state.energyAdded = parseInt(p.get("energy")) || 0;
7269  if (p.get("plan") && p.get("plan") !== currentPlan) {
7270    state.selectedPlan = p.get("plan");
7271  }
7272}
7273
7274function writeURL() {
7275  var p = new URLSearchParams(location.search);
7276  p.delete("energy");
7277  p.delete("plan");
7278  if (!p.has("html")) p.set("html", "");
7279  if (state.energyAdded > 0) p.set("energy", state.energyAdded);
7280  if (state.selectedPlan) p.set("plan", state.selectedPlan);
7281  history.replaceState(null, "", "?" + p.toString());
7282}
7283
7284// =====================
7285// PLAN LOGIC
7286// =====================
7287function canSelectPlan(plan) {
7288  if (plan === "basic") return false;
7289  var cur = PLAN_ORDER.indexOf(currentPlan);
7290  var next = PLAN_ORDER.indexOf(plan);
7291  return next >= cur;
7292}
7293
7294function renderPlans() {
7295  document.querySelectorAll(".plan-box").forEach(function(box) {
7296    var plan = box.dataset.plan;
7297    var isSelected = state.selectedPlan === plan;
7298    var isCurrent = plan === currentPlan && !state.selectedPlan;
7299
7300    box.classList.toggle("selected", isSelected);
7301    box.classList.toggle("current-plan", isCurrent);
7302    box.classList.toggle("disabled", !canSelectPlan(plan));
7303  });
7304
7305  var note = document.getElementById("planNote");
7306  if (state.selectedPlan) {
7307    if (state.selectedPlan === currentPlan) {
7308      note.textContent = "Renewing " + state.selectedPlan + " for 30 more days";
7309    } else {
7310      note.textContent = "Upgrading to " + state.selectedPlan + " for 30 days";
7311    }
7312    note.style.display = "block";
7313  } else {
7314    note.style.display = "none";
7315  }
7316}
7317
7318// =====================
7319// ENERGY
7320// =====================
7321function renderEnergy() {
7322  var el = document.getElementById("energyAdded");
7323  var val = document.getElementById("energyAddedVal");
7324  if (state.energyAdded > 0) {
7325    el.style.display = "block";
7326    val.textContent = "+" + state.energyAdded + " ($" + (state.energyAdded * ENERGY_RATE).toFixed(2) + ")";
7327  } else {
7328    el.style.display = "none";
7329  }
7330}
7331
7332function resetEnergy() {
7333  state.energyAdded = 0;
7334  writeURL();
7335  renderEnergy();
7336  renderCheckout();
7337}
7338
7339function removePlan() {
7340  state.selectedPlan = null;
7341  writeURL();
7342  renderPlans();
7343  renderCheckout();
7344}
7345
7346// =====================
7347// CHECKOUT
7348// =====================
7349function renderCheckout() {
7350  var container = document.getElementById("checkoutContent");
7351  var energyCost = state.energyAdded * ENERGY_RATE;
7352  var planCost = state.selectedPlan ? (PLAN_PRICE[state.selectedPlan] || 0) : 0;
7353  var total = energyCost + planCost;
7354
7355  if (total <= 0) {
7356    container.innerHTML = '<div class="checkout-empty">Select a plan or add energy to continue</div>';
7357    return;
7358  }
7359
7360  var rows = "";
7361
7362  if (state.selectedPlan) {
7363    var label = state.selectedPlan === currentPlan
7364      ? "Renew " + state.selectedPlan
7365      : "Upgrade to " + state.selectedPlan;
7366    var desc = state.selectedPlan === currentPlan
7367      ? "+30 days added to remaining time"
7368      : "30-day plan starts immediately";
7369
7370    rows +=
7371      '<div class="checkout-row">' +
7372        '<div class="checkout-row-left">' +
7373          '<div class="checkout-row-icon plan-icon">📋</div>' +
7374          '<div class="checkout-row-info">' +
7375            '<div class="checkout-row-label">' + label + '</div>' +
7376            '<div class="checkout-row-desc">' + desc + '</div>' +
7377          '</div>' +
7378        '</div>' +
7379        '<div class="checkout-row-right">' +
7380          '<div class="checkout-row-value">$' + planCost.toFixed(2) + '</div>' +
7381          '<span class="checkout-remove" onclick="removePlan()">✕</span>' +
7382        '</div>' +
7383      '</div>';
7384  }
7385
7386  if (state.energyAdded > 0) {
7387    rows +=
7388      '<div class="checkout-row">' +
7389        '<div class="checkout-row-left">' +
7390          '<div class="checkout-row-icon energy-icon">🔥</div>' +
7391          '<div class="checkout-row-info">' +
7392            '<div class="checkout-row-label">+' + state.energyAdded + ' Additional Energy</div>' +
7393            '<div class="checkout-row-desc">Reserve — used after plan energy</div>' +
7394          '</div>' +
7395        '</div>' +
7396        '<div class="checkout-row-right">' +
7397          '<div class="checkout-row-value">$' + energyCost.toFixed(2) + '</div>' +
7398          '<span class="checkout-remove" onclick="resetEnergy()">✕</span>' +
7399        '</div>' +
7400      '</div>';
7401  }
7402
7403  container.innerHTML =
7404    '<div class="checkout-summary">' +
7405      rows +
7406      '<div class="checkout-divider"></div>' +
7407      '<div class="checkout-total">' +
7408        '<div class="checkout-total-label">Total</div>' +
7409        '<div class="checkout-total-value">$' + total.toFixed(2) + '</div>' +
7410      '</div>' +
7411    '</div>' +
7412    '<button class="checkout-btn" onclick="handleCheckout()">Pay with Stripe</button>' +
7413    '<div class="checkout-legal">' +
7414      'By purchasing, you agree to our ' +
7415      '<span class="checkout-legal-link" onclick="openModal(&#39;terms&#39;)">Terms of Service</span>' +
7416      ' and ' +
7417      '<span class="checkout-legal-link" onclick="openModal(&#39;privacy&#39;)">Privacy Policy</span>.' +
7418    '</div>' +
7419    '<div class="checkout-note">No recurring charges · No refunds · Renew manually</div>';
7420}
7421
7422// =====================
7423// STRIPE CHECKOUT
7424// =====================
7425async function handleCheckout() {
7426  var btn = document.querySelector(".checkout-btn");
7427  btn.disabled = true;
7428  btn.textContent = "Processing…";
7429
7430  try {
7431    var body = {
7432      userId: userId,
7433      energyAmount: state.energyAdded > 0 ? state.energyAdded : 0,
7434      plan: state.selectedPlan || null,
7435      currentPlan: currentPlan,
7436    };
7437
7438    var res = await fetch("/api/v1/user/" + userId + "/purchase", {
7439      method: "POST",
7440      headers: { "Content-Type": "application/json" },
7441      body: JSON.stringify(body),
7442    });
7443
7444    var data = await res.json();
7445
7446    if (data.url) {
7447      if (window.top !== window.self) {
7448        window.top.location.href = data.url;
7449      } else {
7450        window.location.href = data.url;
7451      }
7452    } else if (data.error) {
7453      alert(data.error);
7454      btn.disabled = false;
7455      btn.textContent = "Pay with Stripe";
7456    }
7457  } catch (err) {
7458    alert("Something went wrong. Please try again.");
7459    btn.disabled = false;
7460    btn.textContent = "Pay with Stripe";
7461  }
7462}
7463
7464// =====================
7465// CUSTOM LLM
7466// =====================
7467var llmConnections = ${JSON.stringify(llmConnections)};
7468
7469function showLlmStatus(msg, ok) {
7470  var el = document.getElementById("llmStatus");
7471  el.style.display = "block";
7472  el.textContent = msg;
7473  el.style.color = ok ? "rgba(72, 187, 120, 0.9)" : "rgba(255, 107, 107, 0.9)";
7474  if (ok) setTimeout(function() { el.style.display = "none"; }, 3000);
7475}
7476
7477function toggleAddForm() {
7478  var form = document.getElementById("llmAddForm");
7479  form.style.display = form.style.display === "none" ? "block" : "none";
7480  document.getElementById("llmEditSection").style.display = "none";
7481}
7482
7483async function addConnection() {
7484  var name = document.getElementById("llmName").value.trim();
7485  var baseUrl = document.getElementById("llmBaseUrl").value.trim();
7486  var apiKey = document.getElementById("llmApiKey").value.trim();
7487  var model = document.getElementById("llmModel").value.trim();
7488
7489  if (!name || !baseUrl || !apiKey || !model) {
7490    showLlmStatus("All fields are required", false);
7491    return;
7492  }
7493
7494  try {
7495    var res = await fetch("/api/v1/user/" + userId + "/custom-llm", {
7496      method: "POST",
7497      headers: { "Content-Type": "application/json" },
7498      body: JSON.stringify({ name: name, baseUrl: baseUrl, apiKey: apiKey, model: model }),
7499    });
7500    if (res.ok) {
7501      showLlmStatus("✓ Connection added", true);
7502      setTimeout(function() { location.reload(); }, 1000);
7503    } else {
7504      var data = await res.json().catch(function() { return {}; });
7505      showLlmStatus("✕ " + (data.error || "Failed to save"), false);
7506    }
7507  } catch (err) {
7508    showLlmStatus("✕ Network error", false);
7509  }
7510}
7511
7512function editConnection(connId) {
7513  var conn = llmConnections.find(function(c) { return c._id === connId; });
7514  if (!conn) return;
7515  document.getElementById("llmEditId").value = connId;
7516  document.getElementById("llmEditName").value = conn.name;
7517  document.getElementById("llmEditBaseUrl").value = conn.baseUrl;
7518  document.getElementById("llmEditApiKey").value = "";
7519  document.getElementById("llmEditModel").value = conn.model;
7520  document.getElementById("llmEditSection").style.display = "block";
7521  document.getElementById("llmAddForm").style.display = "none";
7522}
7523
7524function cancelEdit() {
7525  document.getElementById("llmEditSection").style.display = "none";
7526}
7527
7528async function updateConnection() {
7529  var connId = document.getElementById("llmEditId").value;
7530  var name = document.getElementById("llmEditName").value.trim();
7531  var baseUrl = document.getElementById("llmEditBaseUrl").value.trim();
7532  var apiKey = document.getElementById("llmEditApiKey").value.trim();
7533  var model = document.getElementById("llmEditModel").value.trim();
7534
7535  if (!baseUrl || !model) {
7536    showLlmStatus("Endpoint URL and Model are required", false);
7537    return;
7538  }
7539
7540  var payload = { baseUrl: baseUrl, model: model };
7541  if (name) payload.name = name;
7542  if (apiKey) payload.apiKey = apiKey;
7543
7544  try {
7545    var res = await fetch("/api/v1/user/" + userId + "/custom-llm/" + connId, {
7546      method: "PUT",
7547      headers: { "Content-Type": "application/json" },
7548      body: JSON.stringify(payload),
7549    });
7550    if (res.ok) {
7551      showLlmStatus("✓ Connection updated", true);
7552      setTimeout(function() { location.reload(); }, 1000);
7553    } else {
7554      var data = await res.json().catch(function() { return {}; });
7555      showLlmStatus("✕ " + (data.error || "Failed to update"), false);
7556    }
7557  } catch (err) {
7558    showLlmStatus("✕ Network error", false);
7559  }
7560}
7561
7562async function deleteConnection(connId) {
7563  if (!confirm("Delete this connection? This cannot be undone.")) return;
7564  try {
7565    var res = await fetch("/api/v1/user/" + userId + "/custom-llm/" + connId, {
7566      method: "DELETE",
7567    });
7568    if (res.ok) {
7569      showLlmStatus("✓ Deleted", true);
7570      setTimeout(function() { location.reload(); }, 1000);
7571    } else {
7572      showLlmStatus("✕ Failed to delete", false);
7573    }
7574  } catch (err) {
7575    showLlmStatus("✕ Network error", false);
7576  }
7577}
7578
7579async function assignSlot(slot, connId) {
7580  try {
7581    var res = await fetch("/api/v1/user/" + userId + "/llm-assign", {
7582      method: "POST",
7583      headers: { "Content-Type": "application/json" },
7584      body: JSON.stringify({ slot: slot, connectionId: connId || null }),
7585    });
7586    if (res.ok) {
7587      var label = slot === "main" ? "Chat" : "Raw Ideas";
7588      showLlmStatus(connId ? "✓ Assigned to " + label : "✓ " + label + " → default LLM", true);
7589    } else {
7590      showLlmStatus("✕ Failed to assign", false);
7591    }
7592  } catch (err) {
7593    showLlmStatus("✕ Network error", false);
7594  }
7595}
7596
7597// =====================
7598// CUSTOM DROPDOWNS
7599// =====================
7600(function() {
7601  document.querySelectorAll(".custom-select").forEach(function(sel) {
7602    var trigger = sel.querySelector(".custom-select-trigger");
7603    trigger.addEventListener("click", function(e) {
7604      e.stopPropagation();
7605      var wasOpen = sel.classList.contains("open");
7606      // close all others
7607      document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
7608      if (!wasOpen) sel.classList.add("open");
7609    });
7610    sel.querySelectorAll(".custom-select-option").forEach(function(opt) {
7611      opt.addEventListener("click", function(e) {
7612        e.stopPropagation();
7613        sel.querySelectorAll(".custom-select-option").forEach(function(o) { o.classList.remove("selected"); });
7614        opt.classList.add("selected");
7615        trigger.textContent = opt.textContent;
7616        sel.classList.remove("open");
7617        var val = opt.getAttribute("data-value");
7618        var slot = sel.getAttribute("data-slot");
7619        if (slot) assignSlot(slot, val);
7620      });
7621    });
7622  });
7623  document.addEventListener("click", function() {
7624    document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
7625  });
7626})();
7627
7628// =====================
7629// EVENTS
7630// =====================
7631document.querySelectorAll(".plan-box").forEach(function(box) {
7632  box.onclick = function() {
7633    var plan = box.dataset.plan;
7634    if (!canSelectPlan(plan)) return;
7635
7636    if (state.selectedPlan === plan) {
7637      state.selectedPlan = null;
7638    } else {
7639      state.selectedPlan = plan;
7640    }
7641
7642    writeURL();
7643    renderPlans();
7644    renderCheckout();
7645  };
7646});
7647
7648document.querySelectorAll(".energy-buy-btn:not(#customEnergyBtn)").forEach(function(btn) {
7649  btn.onclick = function() {
7650    state.energyAdded += parseInt(btn.dataset.amount);
7651    writeURL();
7652    renderEnergy();
7653    renderCheckout();
7654  };
7655});
7656
7657document.getElementById("customEnergyBtn").onclick = function() {
7658  var val = parseInt(prompt("Enter energy amount:"));
7659  if (!val || val <= 0) return;
7660  state.energyAdded += val;
7661  writeURL();
7662  renderEnergy();
7663  renderCheckout();
7664};
7665
7666// =====================
7667// INIT
7668// =====================
7669readURL();
7670renderPlans();
7671renderEnergy();
7672renderCheckout();
7673</script>
7674</body>
7675</html>`);
7676}
7677
7678// ═══════════════════════════════════════════════════════════════════
7679// 14. AI Chats Page - GET /user/:userId/chats
7680// ═══════════════════════════════════════════════════════════════════
7681export function renderChats({ userId, chats, sessions, username, token, sessionId }) {
7682  const tokenQS = token ? `?token=${token}&html` : `?html`;
7683
7684  const linkifyNodeIds = (html) =>
7685    html.replace(
7686      /Placed on node ([0-9a-f-]{36})/g,
7687      (_, id) =>
7688        `Placed on node <a class="node-link" href="/api/v1/root/${id}${token ? `?token=${token}&html` : "?html"}">${id}</a>`,
7689    );
7690
7691  const formatContent = (str) => {
7692    if (!str) return "";
7693    const s = String(str).trim();
7694    if (
7695      (s.startsWith("{") && s.endsWith("}")) ||
7696      (s.startsWith("[") && s.endsWith("]"))
7697    ) {
7698      try {
7699        const parsed = JSON.parse(s);
7700        const pretty = JSON.stringify(parsed, null, 2);
7701        return `<span class="chain-json">${esc(pretty)}</span>`;
7702      } catch (_) {}
7703    }
7704    return esc(s);
7705  };
7706
7707    // ── Tree context helpers ───────────────────────────────
7708
7709    const renderTreeContext = (tc) => {
7710      if (!tc) return "";
7711      const parts = [];
7712
7713      const nodeId = tc.targetNodeId?._id || tc.targetNodeId;
7714      const nodeName = tc.targetNodeId?.name || tc.targetNodeName;
7715      if (nodeId && nodeName && typeof nodeId === "string") {
7716        parts.push(
7717          `<a href="/api/v1/node/${nodeId}${tokenQS}" class="tree-target-link">🎯 ${esc(nodeName)}</a>`,
7718        );
7719      } else if (nodeName) {
7720        parts.push(`<span class="tree-target-name">🎯 ${esc(nodeName)}</span>`);
7721      } else if (tc.targetPath) {
7722        const pathParts = tc.targetPath.split(" / ");
7723        const last = pathParts[pathParts.length - 1];
7724        parts.push(`<span class="tree-target-name">🎯 ${esc(last)}</span>`);
7725      }
7726
7727      if (tc.planStepIndex != null && tc.planTotalSteps != null) {
7728        parts.push(
7729          `<span class="badge badge-step">${tc.planStepIndex}/${tc.planTotalSteps}</span>`,
7730        );
7731      }
7732
7733      if (tc.stepResult) {
7734        const resultClasses = {
7735          success: "badge-done",
7736          failed: "badge-stopped",
7737          skipped: "badge-skipped",
7738          pending: "badge-pending",
7739        };
7740        const resultIcons = {
7741          success: "✓",
7742          failed: "✗",
7743          skipped: "⊘",
7744          pending: "⏳",
7745        };
7746        parts.push(
7747          `<span class="badge ${resultClasses[tc.stepResult] || "badge-pending"}">${resultIcons[tc.stepResult] || ""} ${tc.stepResult}</span>`,
7748        );
7749      }
7750
7751      if (parts.length === 0) return "";
7752      return `<div class="tree-context-bar">${parts.join("")}</div>`;
7753    };
7754
7755    const renderDirective = (tc) => {
7756      if (!tc?.directive) return "";
7757      return `<div class="tree-directive">${esc(tc.directive)}</div>`;
7758    };
7759
7760    const getTargetName = (tc) => {
7761      if (!tc) return null;
7762      return tc.targetNodeId?.name || tc.targetNodeName || null;
7763    };
7764
7765    const sessionGroups = sessions;
7766
7767    // ── Phase grouping ─────────────────────────────────────
7768
7769    const groupStepsIntoPhases = (steps) => {
7770      const phases = [];
7771      let currentPlan = null;
7772      for (const step of steps) {
7773        const mode = step.aiContext?.path || "";
7774        if (mode === "translator") {
7775          currentPlan = null;
7776          phases.push({ type: "translate", step });
7777        } else if (mode.startsWith("tree:orchestrator:plan:")) {
7778          currentPlan = { type: "plan", marker: step, substeps: [] };
7779          phases.push(currentPlan);
7780        } else if (mode === "tree:respond") {
7781          currentPlan = null;
7782          phases.push({ type: "respond", step });
7783        } else if (currentPlan) {
7784          currentPlan.substeps.push(step);
7785        } else {
7786          phases.push({ type: "step", step });
7787        }
7788      }
7789      return phases;
7790    };
7791
7792    // ── Model badge helper ─────────────────────────────────
7793
7794    const renderModelBadge = (chat) => {
7795      const connName = chat.llmProvider?.connectionId?.name;
7796      const model = connName || chat.llmProvider?.model;
7797      if (!model) return "";
7798      return `<span class="chain-model">${esc(model)}</span>`;
7799    };
7800
7801    // ── Render substep ─────────────────────────────────────
7802
7803    const renderSubstep = (chat) => {
7804      const duration = formatDuration(
7805        chat.startMessage?.time,
7806        chat.endMessage?.time,
7807      );
7808      const stopped = chat.endMessage?.stopped;
7809      const tc = chat.treeContext;
7810
7811      const dotClass = stopped
7812        ? "chain-dot-stopped"
7813        : tc?.stepResult === "failed"
7814          ? "chain-dot-stopped"
7815          : tc?.stepResult === "skipped"
7816            ? "chain-dot-skipped"
7817            : chat.endMessage?.time
7818              ? "chain-dot-done"
7819              : "chain-dot-pending";
7820
7821      const targetName = getTargetName(tc);
7822      const inputFull = formatContent(chat.startMessage?.content);
7823      const outputFull = formatContent(chat.endMessage?.content);
7824
7825      return `
7826      <details class="chain-substep">
7827        <summary class="chain-substep-summary">
7828          <span class="chain-dot ${dotClass}"></span>
7829          <span class="chain-step-mode">${modeLabel(chat.aiContext?.path)}</span>
7830          ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
7831          ${tc?.stepResult === "failed" ? `<span class="chain-step-failed">FAILED</span>` : ""}
7832          ${tc?.resultDetail && tc.stepResult === "failed" ? `<span class="chain-step-fail-reason">${truncate(tc.resultDetail, 60)}</span>` : ""}
7833          ${renderModelBadge(chat)}
7834          ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
7835        </summary>
7836        <div class="chain-step-body">
7837          ${renderTreeContext(tc)}
7838          ${renderDirective(tc)}
7839          <div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>
7840          ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
7841        </div>
7842      </details>`;
7843    };
7844
7845    // ── Render phases ──────────────────────────────────────
7846
7847    const renderPhases = (steps) => {
7848      const phases = groupStepsIntoPhases(steps);
7849      if (phases.length === 0) return "";
7850
7851      const phaseHtml = phases
7852        .map((phase) => {
7853          if (phase.type === "translate") {
7854            const s = phase.step;
7855            const tc = s.treeContext;
7856            const duration = formatDuration(
7857              s.startMessage?.time,
7858              s.endMessage?.time,
7859            );
7860            const outputFull = formatContent(s.endMessage?.content);
7861            return `
7862          <details class="chain-phase chain-phase-translate">
7863            <summary class="chain-phase-summary">
7864              <span class="chain-phase-icon">🔄</span>
7865              <span class="chain-phase-label">Translator</span>
7866              ${tc?.planTotalSteps ? `<span class="chain-step-counter">${tc.planTotalSteps}-step plan</span>` : ""}
7867              ${tc?.directive ? `<span class="chain-plan-summary-text">${truncate(tc.directive, 80)}</span>` : ""}
7868              ${renderModelBadge(s)}
7869              ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
7870            </summary>
7871            ${outputFull ? `<div class="chain-step-body"><div class="chain-step-output"><span class="chain-io-label chain-io-out">PLAN</span>${outputFull}</div></div>` : ""}
7872          </details>`;
7873          }
7874
7875          if (phase.type === "plan") {
7876            const m = phase.marker;
7877            const tc = m.treeContext;
7878            const targetName = getTargetName(tc);
7879            const hasSubsteps = phase.substeps.length > 0;
7880
7881            // Count results from substeps that have execution modes
7882            const counts = { success: 0, failed: 0, skipped: 0 };
7883            for (const sub of phase.substeps) {
7884              const r = sub.treeContext?.stepResult;
7885              if (r && counts[r] !== undefined) counts[r]++;
7886            }
7887            const countBadges = [
7888              counts.success > 0
7889                ? `<span class="badge badge-done">${counts.success} ✓</span>`
7890                : "",
7891              counts.failed > 0
7892                ? `<span class="badge badge-stopped">${counts.failed} ✗</span>`
7893                : "",
7894              counts.skipped > 0
7895                ? `<span class="badge badge-skipped">${counts.skipped} ⊘</span>`
7896                : "",
7897            ]
7898              .filter(Boolean)
7899              .join("");
7900
7901            // Use directive from treeContext if available, otherwise fall back to input
7902            const directiveText = tc?.directive || "";
7903            const inputFull = directiveText
7904              ? esc(directiveText)
7905              : formatContent(m.startMessage?.content);
7906
7907            return `
7908          <div class="chain-phase chain-phase-plan">
7909            <div class="chain-phase-header">
7910              <span class="chain-phase-icon">📋</span>
7911              <span class="chain-phase-label">${modeLabel(m.aiContext?.path)}</span>
7912              ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
7913              ${
7914                tc?.planStepIndex != null && tc?.planTotalSteps != null
7915                  ? `<span class="chain-step-counter">Step ${tc.planStepIndex} of ${tc.planTotalSteps}</span>`
7916                  : ""
7917              }
7918              ${countBadges}
7919              ${renderModelBadge(m)}
7920            </div>
7921            <div class="chain-plan-directive">${inputFull}</div>
7922            ${hasSubsteps ? `<div class="chain-substeps">${phase.substeps.map(renderSubstep).join("")}</div>` : ""}
7923          </div>`;
7924          }
7925
7926          if (phase.type === "respond") {
7927            const s = phase.step;
7928            const tc = s.treeContext;
7929            const duration = formatDuration(
7930              s.startMessage?.time,
7931              s.endMessage?.time,
7932            );
7933            const inputFull = formatContent(s.startMessage?.content);
7934            const outputFull = formatContent(s.endMessage?.content);
7935            return `
7936          <details class="chain-phase chain-phase-respond">
7937            <summary class="chain-phase-summary">
7938              <span class="chain-phase-icon">💬</span>
7939              <span class="chain-phase-label">${modeLabel(s.aiContext?.path)}</span>
7940              ${renderModelBadge(s)}
7941              ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
7942            </summary>
7943            <div class="chain-step-body">
7944              ${renderTreeContext(tc)}
7945              ${inputFull ? `<div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>` : ""}
7946              ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
7947            </div>
7948          </details>`;
7949          }
7950
7951          return renderSubstep(phase.step);
7952        })
7953        .join("");
7954
7955      const summaryParts = phases
7956        .map((p) => {
7957          if (p.type === "translate") {
7958            const tc = p.step.treeContext;
7959            return tc?.planTotalSteps ? `🔄 ${tc.planTotalSteps}-step` : "🔄";
7960          }
7961          if (p.type === "plan") {
7962            const tc = p.marker.treeContext;
7963            const targetName = getTargetName(tc);
7964            const sub = p.substeps
7965              .map((s) => {
7966                const stc = s.treeContext;
7967                const icon =
7968                  stc?.stepResult === "failed"
7969                    ? "❌ "
7970                    : stc?.stepResult === "skipped"
7971                      ? "⊘ "
7972                      : stc?.stepResult === "success"
7973                        ? "✓ "
7974                        : "";
7975                return `${icon}${modeLabel(s.aiContext?.path)}`;
7976              })
7977              .join(" → ");
7978            const label = targetName ? `📋 ${esc(targetName)}` : "📋";
7979            return sub ? `${label}: ${sub}` : label;
7980          }
7981          if (p.type === "respond") return "💬";
7982          return modeLabel(p.step?.aiContext?.path);
7983        })
7984        .join("  ");
7985
7986      return `
7987      <details class="chain-dropdown">
7988        <summary class="chain-summary">
7989          ${phases.length} phase${phases.length !== 1 ? "s" : ""}
7990          <span class="chain-modes">${summaryParts}</span>
7991        </summary>
7992        <div class="chain-phases">${phaseHtml}</div>
7993      </details>`;
7994    };
7995
7996    // ── Render chain ───────────────────────────────────────
7997
7998    const renderChain = (chain) => {
7999      const chat = chain.root;
8000      const steps = chain.steps;
8001      const duration = formatDuration(
8002        chat.startMessage?.time,
8003        chat.endMessage?.time,
8004      );
8005      const stopped = chat.endMessage?.stopped;
8006      const contribs = chat.contributions || [];
8007      const hasContribs = contribs.length > 0;
8008      const hasSteps = steps.length > 0;
8009
8010      const modelName =
8011        chat.llmProvider?.connectionId?.name ||
8012        chat.llmProvider?.model ||
8013        "unknown";
8014
8015      const tc = chat.treeContext;
8016      const treeNodeId = tc?.targetNodeId?._id || tc?.targetNodeId;
8017      const treeNodeName = tc?.targetNodeId?.name || tc?.targetNodeName;
8018      const treeLink =
8019        treeNodeId && treeNodeName
8020          ? `<a href="/api/v1/node/${treeNodeId}${tokenQS}" class="tree-target-link">🌳 ${esc(treeNodeName)}</a>`
8021          : treeNodeName
8022            ? `<span class="tree-target-name">🌳 ${esc(treeNodeName)}</span>`
8023            : "";
8024
8025      const statusBadge = stopped
8026        ? `<span class="badge badge-stopped">Stopped</span>`
8027        : chat.endMessage?.time
8028          ? `<span class="badge badge-done">Done</span>`
8029          : `<span class="badge badge-pending">Pending</span>`;
8030
8031      const endContent = chat.endMessage?.content || "";
8032      const wasNoLlm = endContent.startsWith("No LLM connection");
8033      const wasError = endContent.startsWith("Error:");
8034      const chatEnergyUsed =
8035        wasError || wasNoLlm || chat.llmProvider?.isCustom === false ? 2 : 0;
8036      const energyBadge =
8037        chatEnergyUsed > 0
8038          ? `<span class="badge badge-energy">⚡${chatEnergyUsed}</span>`
8039          : "";
8040
8041      const contribRows = contribs
8042        .map((c) => {
8043          const nId = c.nodeId?._id || c.nodeId;
8044          const nName = c.nodeId?.name || nId || "—";
8045          const nodeRef = nId
8046            ? `<a href="/api/v1/node/${nId}${tokenQS}">${esc(nName)}</a>`
8047            : `<span style="opacity:0.5">—</span>`;
8048          const aiBadge = c.wasAi
8049            ? `<span class="mini-badge mini-ai">AI</span>`
8050            : "";
8051          const cEnergyBadge =
8052            c.energyUsed > 0
8053              ? `<span class="mini-badge mini-energy">⚡${c.energyUsed}</span>`
8054              : "";
8055          const understandingLink =
8056            c.action === "understanding" &&
8057            c.understandingMeta?.understandingRunId &&
8058            c.understandingMeta?.rootNodeId
8059              ? ` <a class="understanding-link" href="/api/v1/root/${c.understandingMeta.rootNodeId}/understandings/run/${c.understandingMeta.understandingRunId}${tokenQS}">🧠 View run →</a>`
8060              : "";
8061          const color = actionColorHex(c.action);
8062          return `
8063        <tr class="contrib-row">
8064          <td><span class="action-dot" style="background:${color}"></span>${esc(actionLabel(c.action))}${understandingLink}</td>
8065          <td>${nodeRef}</td>
8066          <td>${aiBadge}${cEnergyBadge}</td>
8067          <td class="contrib-time">${formatTime(c.date)}</td>
8068        </tr>`;
8069        })
8070        .join("");
8071
8072      const stepsHtml = hasSteps ? renderPhases(steps) : "";
8073
8074      return `
8075      <li class="note-card">
8076        <div class="chat-header">
8077          <div class="chat-header-left">
8078            <span class="chat-mode">${modeLabel(chat.aiContext?.path)}</span>
8079            ${treeLink}
8080            <span class="chat-model">${esc(modelName)}</span>
8081          </div>
8082          <div class="chat-badges">
8083            ${energyBadge}
8084            ${statusBadge}
8085            ${duration ? `<span class="badge badge-duration">${duration}</span>` : ""}
8086            <span class="badge badge-source">${sourceLabel(chat.startMessage?.source)}</span>
8087          </div>
8088        </div>
8089
8090        <div class="note-content">
8091          <div class="chat-message chat-user">
8092            <span class="msg-label">You</span>
8093            <div class="msg-text msg-clamp">${esc(chat.startMessage?.content || "")}</div>
8094            ${(chat.startMessage?.content || "").length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
8095          </div>
8096          ${
8097            chat.endMessage?.content
8098              ? `
8099          <div class="chat-message chat-ai">
8100            <span class="msg-label">AI</span>
8101            <div class="msg-text msg-clamp">${linkifyNodeIds(esc(chat.endMessage.content))}</div>
8102            ${chat.endMessage.content.length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
8103          </div>`
8104              : ""
8105          }
8106        </div>
8107
8108        ${stepsHtml}
8109
8110        ${
8111          hasContribs
8112            ? `
8113        <details class="contrib-dropdown">
8114          <summary class="contrib-summary">
8115            ${contribs.length} contribution${contribs.length !== 1 ? "s" : ""} during this chat
8116          </summary>
8117          <div class="contrib-table-wrap">
8118            <table class="contrib-table">
8119              <thead><tr><th>Action</th><th>Node</th><th></th><th>Time</th></tr></thead>
8120              <tbody>${contribRows}</tbody>
8121            </table>
8122          </div>
8123        </details>`
8124            : ""
8125        }
8126
8127        <div class="note-meta">
8128          ${formatTime(chat.startMessage?.time)}
8129          <span class="meta-separator">·</span>
8130          <code class="contribution-id">${esc(chat._id)}</code>
8131        </div>
8132      </li>`;
8133    };
8134
8135    const renderedSections = sessionGroups
8136      .map((group) => {
8137        const chatCount = group.chatCount;
8138        const sessionTime = formatTime(group.startTime);
8139        const shortId = group.sessionId.slice(0, 8);
8140        const chains = groupIntoChains(group.chats);
8141        const chatCards = chains.map(renderChain).join("");
8142
8143        return `
8144      <div class="session-group">
8145        <div class="session-pane">
8146          <div class="session-pane-header">
8147            <div class="session-header-left">
8148              <span class="session-id">${esc(shortId)}</span>
8149              <span class="session-info">${chatCount} chat${chatCount !== 1 ? "s" : ""}</span>
8150            </div>
8151            <span class="session-time">${sessionTime}</span>
8152          </div>
8153          <ul class="notes-list">${chatCards}</ul>
8154        </div>
8155      </div>`;
8156      })
8157      .join("");
8158
8159    return (`
8160<!DOCTYPE html>
8161<html lang="en">
8162<head>
8163  <meta charset="UTF-8">
8164  <meta name="viewport" content="width=device-width, initial-scale=1.0">
8165  <meta name="theme-color" content="#667eea">
8166  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8167  <title>${esc(username)} — AI Chats</title>
8168  <style>
8169${baseStyles}
8170${backNavStyles}
8171${glassHeaderStyles}
8172${glassCardStyles}
8173${emptyStateStyles}
8174${responsiveBase}
8175
8176
8177/* ── Session Pane ───────────────────────────────── */
8178.session-group { margin-bottom: 20px; animation: fadeInUp 0.6s ease-out both; }
8179.session-pane {
8180  background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
8181  border-radius: 20px; overflow: hidden; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
8182}
8183.session-pane-header {
8184  display: flex; align-items: center; justify-content: space-between; padding: 14px 20px;
8185  background: rgba(255,255,255,0.08); border-bottom: 1px solid rgba(255,255,255,0.1);
8186}
8187.session-header-left { display: flex; align-items: center; gap: 10px; }
8188.session-id {
8189  font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; font-weight: 600;
8190  color: rgba(255,255,255,0.55); background: rgba(255,255,255,0.1); padding: 3px 8px;
8191  border-radius: 6px; border: 1px solid rgba(255,255,255,0.12);
8192}
8193.session-info { font-size: 13px; color: rgba(255,255,255,0.7); font-weight: 600; }
8194.session-time { font-size: 12px; color: rgba(255,255,255,0.4); font-weight: 500; }
8195
8196/* ── Chat Header ────────────────────────────────── */
8197.chat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
8198.chat-header-left { display: flex; align-items: center; gap: 8px; }
8199.chat-mode {
8200  font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.1);
8201  padding: 3px 10px; border-radius: 980px; border: 1px solid rgba(255,255,255,0.15);
8202}
8203.chat-model {
8204  font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.45);
8205  font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden;
8206  text-overflow: ellipsis; white-space: nowrap; max-width: 200px;
8207}
8208.chat-badges { display: flex; flex-wrap: wrap; gap: 6px; }
8209
8210.chat-message { display: flex; gap: 10px; align-items: flex-start; }
8211.msg-label {
8212  flex-shrink: 0; font-weight: 700; font-size: 10px; text-transform: uppercase;
8213  letter-spacing: 0.5px; padding: 3px 10px; border-radius: 980px; margin-top: 3px;
8214}
8215.chat-user .msg-label { background: rgba(255,255,255,0.2); color: white; }
8216.chat-ai .msg-label   { background: rgba(100,220,255,0.25); color: white; }
8217.msg-text { color: rgba(255,255,255,0.95); word-wrap: break-word; min-width: 0; font-size: 15px; line-height: 1.65; font-weight: 400; }
8218.msg-clamp {
8219  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical;
8220  overflow: hidden; max-height: calc(1.65em * 4);
8221  transition: max-height 0.3s ease;
8222}
8223.msg-clamp.expanded { -webkit-line-clamp: unset; max-height: none; overflow: visible; }
8224.expand-btn {
8225  background: none; border: none; color: rgba(100,220,255,0.9); cursor: pointer;
8226  font-size: 12px; font-weight: 600; padding: 2px 0; margin-top: 2px;
8227  transition: color 0.2s;
8228}
8229.expand-btn:hover { color: rgba(100,220,255,1); text-decoration: underline; }
8230.node-link { color: #7effc0; text-decoration: none; background: rgba(50,220,120,0.15); padding: 1px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; }
8231.node-link:hover { background: rgba(50,220,120,0.3); }
8232.understanding-link {
8233  color: rgba(100,100,210,0.9); text-decoration: none; font-size: 11px; font-weight: 500;
8234  margin-left: 4px; transition: color 0.2s;
8235}
8236.understanding-link:hover { color: rgba(130,130,255,1); text-decoration: underline; }
8237.chat-user .msg-text { font-weight: 500; }
8238
8239/* ── Chain: outer dropdown ──────────────────────── */
8240.chain-dropdown { margin-bottom: 12px; }
8241.chain-summary {
8242  cursor: pointer; font-size: 13px; font-weight: 600;
8243  color: rgba(255,255,255,0.85); padding: 8px 14px;
8244  background: rgba(255,255,255,0.1); border-radius: 10px;
8245  border: 1px solid rgba(255,255,255,0.15);
8246  transition: all 0.2s; list-style: none;
8247  display: flex; align-items: center; gap: 8px;
8248}
8249.chain-summary::-webkit-details-marker { display: none; }
8250.chain-summary::before { content: "▶"; font-size: 10px; transition: transform 0.15s; display: inline-block; }
8251details[open] > .chain-summary::before { transform: rotate(90deg); }
8252.chain-summary:hover { background: rgba(255,255,255,0.18); }
8253.chain-modes { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
8254.chain-phases { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
8255
8256/* ── Chain: phase containers ────────────────────── */
8257.chain-phase { border-radius: 10px; overflow: hidden; }
8258.chain-phase-header {
8259  display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 12px; font-weight: 600;
8260  flex-wrap: wrap;
8261}
8262.chain-phase-icon { font-size: 14px; }
8263.chain-phase-label { color: rgba(255,255,255,0.85); }
8264.chain-phase-translate { background: rgba(100,100,220,0.12); border: 1px solid rgba(100,100,220,0.2); }
8265.chain-phase-plan { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); }
8266.chain-phase-respond { background: rgba(72,187,120,0.1); border: 1px solid rgba(72,187,120,0.2); }
8267.chain-plan-directive { padding: 6px 12px 10px; font-size: 12px; color: rgba(255,255,255,0.6); line-height: 1.5; white-space: pre-wrap; }
8268
8269/* ── Chain: clickable summary for translate/substep details ── */
8270.chain-phase-summary, .chain-substep-summary {
8271  cursor: pointer; list-style: none;
8272  display: flex; align-items: center; gap: 8px;
8273  padding: 8px 12px;
8274  font-size: 12px; font-weight: 600;
8275  flex-wrap: wrap;
8276}
8277.chain-phase-summary::-webkit-details-marker,
8278.chain-substep-summary::-webkit-details-marker { display: none; }
8279.chain-phase-summary::before,
8280.chain-substep-summary::before {
8281  content: "▶"; font-size: 8px; color: rgba(255,255,255,0.35);
8282  transition: transform 0.15s; display: inline-block;
8283}
8284details[open] > .chain-phase-summary::before,
8285details[open] > .chain-substep-summary::before { transform: rotate(90deg); }
8286.chain-phase-summary:hover, .chain-substep-summary:hover { background: rgba(255,255,255,0.05); }
8287
8288/* ── Chain: substeps inside plan ────────────────── */
8289.chain-substeps { display: flex; flex-direction: column; gap: 2px; padding: 0 8px 8px; }
8290.chain-substep { border-radius: 6px; background: rgba(255,255,255,0.04); }
8291.chain-substep:hover { background: rgba(255,255,255,0.07); }
8292
8293/* ── Chain: status dot ──────────────────────────── */
8294.chain-dot {
8295  display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
8296  border: 2px solid rgba(255,255,255,0.3);
8297}
8298.chain-dot-done    { background: rgba(72,187,120,0.8); border-color: rgba(72,187,120,0.4); }
8299.chain-dot-stopped { background: rgba(200,80,80,0.8); border-color: rgba(200,80,80,0.4); }
8300.chain-dot-pending { background: rgba(255,200,50,0.8); border-color: rgba(255,200,50,0.4); }
8301.chain-dot-skipped { background: rgba(160,160,160,0.6); border-color: rgba(160,160,160,0.3); }
8302
8303.chain-step-mode {
8304  font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.8);
8305  background: rgba(255,255,255,0.12); padding: 2px 8px; border-radius: 6px;
8306}
8307.chain-step-duration { font-size: 10px; color: rgba(255,255,255,0.45); }
8308.chain-model {
8309  font-size: 10px; font-family: 'SF Mono', 'Fira Code', monospace;
8310  color: rgba(255,255,255,0.4); margin-left: auto; white-space: nowrap;
8311  overflow: hidden; text-overflow: ellipsis; max-width: 150px;
8312}
8313
8314/* ── Chain: expanded body ───────────────────────── */
8315.chain-step-body { padding: 10px 12px; border-top: 1px solid rgba(255,255,255,0.08); }
8316
8317.chain-io-label {
8318  display: inline-block; font-size: 9px; font-weight: 700; letter-spacing: 0.5px;
8319  padding: 1px 6px; border-radius: 4px; margin-right: 8px; vertical-align: middle;
8320}
8321.chain-io-in  { background: rgba(100,220,255,0.2); color: rgba(100,220,255,0.9); }
8322.chain-io-out { background: rgba(72,187,120,0.2); color: rgba(72,187,120,0.9); }
8323
8324.chain-step-input {
8325  font-size: 12px; color: rgba(255,255,255,0.8); line-height: 1.6;
8326  word-break: break-word; white-space: pre-wrap;
8327  font-family: 'SF Mono', 'Fira Code', monospace;
8328}
8329.chain-step-output {
8330  font-size: 12px; color: rgba(255,255,255,0.65); line-height: 1.6;
8331  margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1);
8332  word-break: break-word; white-space: pre-wrap;
8333  font-family: 'SF Mono', 'Fira Code', monospace;
8334}
8335.chain-json { color: rgba(255,255,255,0.8); }
8336
8337/* ── Tree Context ───────────────────────────────── */
8338.tree-context-bar {
8339  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
8340  padding: 6px 12px; margin-bottom: 6px;
8341  background: rgba(255,255,255,0.06); border-radius: 6px;
8342  font-size: 12px;
8343}
8344.tree-target-link {
8345  color: rgba(100,220,255,0.95); text-decoration: none;
8346  border-bottom: 1px solid rgba(100,220,255,0.3);
8347  font-weight: 600; font-size: 12px;
8348  transition: all 0.2s;
8349}
8350.tree-target-link:hover {
8351  border-bottom-color: rgba(100,220,255,0.8);
8352  text-shadow: 0 0 8px rgba(100,220,255,0.5);
8353}
8354.tree-target-name {
8355  color: rgba(255,255,255,0.8); font-weight: 600; font-size: 12px;
8356}
8357.tree-directive {
8358  padding: 4px 12px 8px; font-size: 11px; color: rgba(255,255,255,0.55);
8359  line-height: 1.5; font-style: italic;
8360  border-left: 2px solid rgba(255,255,255,0.15);
8361  margin: 0 12px 8px;
8362}
8363.chain-step-counter {
8364  font-size: 10px; color: rgba(255,255,255,0.5); font-weight: 500;
8365  background: rgba(255,255,255,0.08); padding: 2px 8px; border-radius: 4px;
8366}
8367.chain-step-target {
8368  font-size: 10px; color: rgba(100,220,255,0.7); font-weight: 500;
8369  max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
8370}
8371.chain-step-failed {
8372  font-size: 9px; font-weight: 700; color: rgba(200,80,80,0.9);
8373  background: rgba(200,80,80,0.15); padding: 1px 6px; border-radius: 4px;
8374  letter-spacing: 0.5px;
8375}
8376.chain-step-fail-reason {
8377  font-size: 10px; color: rgba(200,80,80,0.7); font-weight: 400;
8378  font-style: italic; max-width: 200px; overflow: hidden;
8379  text-overflow: ellipsis; white-space: nowrap;
8380}
8381.badge-step {
8382  background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7);
8383  font-family: 'SF Mono', 'Fira Code', monospace; font-size: 10px;
8384}
8385.badge-skipped {
8386  background: rgba(160,160,160,0.25); color: rgba(255,255,255,0.7);
8387}
8388.chain-plan-summary-text {
8389  font-size: 11px; color: rgba(255,255,255,0.45); font-weight: 400;
8390  font-style: italic; overflow: hidden; text-overflow: ellipsis;
8391  white-space: nowrap; max-width: 300px;
8392}
8393
8394/* ── Contribution Dropdown ──────────────────────── */
8395.contrib-dropdown { margin-bottom: 12px; }
8396.contrib-summary {
8397  cursor: pointer; font-size: 13px; font-weight: 600;
8398  color: rgba(255,255,255,0.85); padding: 8px 14px;
8399  background: rgba(255,255,255,0.1); border-radius: 10px;
8400  border: 1px solid rgba(255,255,255,0.15);
8401  transition: all 0.2s; list-style: none;
8402  display: flex; align-items: center; gap: 6px;
8403}
8404.contrib-summary::-webkit-details-marker { display: none; }
8405.contrib-summary::before { content: "▶"; font-size: 10px; transition: transform 0.2s; display: inline-block; }
8406details[open] .contrib-summary::before { transform: rotate(90deg); }
8407.contrib-summary:hover { background: rgba(255,255,255,0.18); }
8408.contrib-table-wrap { margin-top: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
8409.contrib-table { width: 100%; border-collapse: collapse; font-size: 13px; }
8410.contrib-table thead th {
8411  text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase;
8412  letter-spacing: 0.5px; color: rgba(255,255,255,0.55); padding: 6px 10px;
8413  border-bottom: 1px solid rgba(255,255,255,0.15);
8414}
8415.contrib-row td {
8416  padding: 7px 10px; border-bottom: 1px solid rgba(255,255,255,0.08);
8417  color: rgba(255,255,255,0.88); vertical-align: middle; white-space: nowrap;
8418}
8419.contrib-row:last-child td { border-bottom: none; }
8420.contrib-row a { color: white; text-decoration: none; border-bottom: 1px solid rgba(255,255,255,0.3); transition: all 0.2s; }
8421.contrib-row a:hover { border-bottom-color: white; text-shadow: 0 0 12px rgba(255,255,255,0.8); }
8422.contrib-time { font-size: 11px; color: rgba(255,255,255,0.5); }
8423.action-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
8424
8425/* ── Mini Badges ────────────────────────────────── */
8426.mini-badge {
8427  display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 980px;
8428  font-size: 10px; font-weight: 700; letter-spacing: 0.2px; margin-right: 3px;
8429}
8430.mini-ai    { background: rgba(255,200,50,0.35); color: #fff; }
8431.mini-energy { background: rgba(100,220,255,0.3); color: #fff; }
8432
8433/* ── Badges ─────────────────────────────────────── */
8434.badge {
8435  display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 980px;
8436  font-size: 11px; font-weight: 700; letter-spacing: 0.3px; border: 1px solid rgba(255,255,255,0.2);
8437}
8438.badge-done     { background: rgba(72,187,120,0.35); color: #fff; }
8439.badge-stopped  { background: rgba(200,80,80,0.35); color: #fff; }
8440.badge-pending  { background: rgba(255,200,50,0.3); color: #fff; }
8441.badge-duration { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.9); }
8442.badge-source   { background: rgba(100,100,210,0.3); color: #fff; }
8443.badge-energy   { background: rgba(100,220,255,0.25); color: #fff; border-color: rgba(100,220,255,0.3); }
8444
8445
8446.contribution-id {
8447  background: rgba(255,255,255,0.12); padding: 2px 6px; border-radius: 4px;
8448  font-size: 11px; font-family: 'SF Mono', 'Fira Code', monospace;
8449  color: rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.1);
8450}
8451
8452/* ── Responsive ─────────────────────────────────── */
8453
8454
8455  </style>
8456</head>
8457<body>
8458  <div class="container">
8459    <div class="back-nav">
8460      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">← Back to Profile</a>
8461    </div>
8462
8463    <div class="header">
8464      <h1>
8465        AI Chats for
8466        <a href="/api/v1/user/${userId}${tokenQS}">@${esc(username)}</a>
8467        ${chats.length > 0 ? `<span class="message-count">${chats.length}</span>` : ""}
8468      </h1>
8469      <div class="header-subtitle">Last 10 AI conversation sessions. Phases = thought process. Contributions = actions made on tree during conversation.</div>
8470    </div>
8471
8472    ${
8473      sessionGroups.length
8474        ? renderedSections
8475        : `
8476    <div class="empty-state">
8477      <div class="empty-state-icon">💬</div>
8478      <div class="empty-state-text">No AI chats yet</div>
8479      <div class="empty-state-subtext">AI conversations and their actions will appear here</div>
8480    </div>`
8481    }
8482  </div>
8483
8484  <script>
8485    var observer = new IntersectionObserver(function(entries) {
8486      entries.forEach(function(entry, index) {
8487        if (entry.isIntersecting) {
8488          setTimeout(function() { entry.target.classList.add('visible'); }, index * 50);
8489          observer.unobserve(entry.target);
8490        }
8491      });
8492    }, { root: null, rootMargin: '50px', threshold: 0.1 });
8493    document.querySelectorAll('.note-card').forEach(function(card) { observer.observe(card); });
8494
8495    function toggleExpand(btn) {
8496      var text = btn.previousElementSibling;
8497      if (!text) return;
8498      var expanded = text.classList.toggle('expanded');
8499      btn.textContent = expanded ? 'Show less' : 'Show more';
8500    }
8501  </script>
8502</body>
8503</html>
8504`);
8505}
8506
8507// ═══════════════════════════════════════════════════════════════════
8508// 15. Notifications Page - GET /user/:userId/notifications
8509// ═══════════════════════════════════════════════════════════════════
8510export function renderNotifications({ userId, notifications, total, username, token }) {
8511  const tokenQS = token ? `?token=${token}&html` : `?html`;
8512
8513    const items = notifications
8514      .map((n) => {
8515        const icon = n.type === "dream-thought" ? "💭" : "📋";
8516        const typeLabel = n.type === "dream-thought" ? "Thought" : "Summary";
8517        const colorClass =
8518          n.type === "dream-thought" ? "glass-purple" : "glass-indigo";
8519        const date = new Date(n.createdAt).toLocaleString();
8520
8521        return `
8522      <li class="note-card ${colorClass}">
8523        <div class="note-content">
8524          <div class="contribution-action">
8525            <span style="font-size:20px;margin-right:6px">${icon}</span>
8526            ${esc(n.title)}
8527            <span class="badge badge-type">${typeLabel}</span>
8528          </div>
8529          <div style="margin-top:10px;font-size:14px;color:rgba(255,255,255,0.9);line-height:1.6;white-space:pre-wrap">${esc(n.content)}</div>
8530        </div>
8531        <div class="note-meta">
8532          ${date}
8533        </div>
8534      </li>`;
8535      })
8536      .join("");
8537
8538    return (`
8539<!DOCTYPE html>
8540<html lang="en">
8541<head>
8542  <meta charset="UTF-8">
8543  <meta name="viewport" content="width=device-width, initial-scale=1.0">
8544  <meta name="theme-color" content="#667eea">
8545  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8546  <title>${esc(username)} - Notifications</title>
8547  <style>
8548${baseStyles}
8549${backNavStyles}
8550${glassHeaderStyles}
8551${glassCardStyles}
8552${emptyStateStyles}
8553${responsiveBase}
8554
8555.header-subtitle {
8556  margin-bottom: 16px;
8557}
8558
8559
8560/* ── Badges ─────────────────────────────────────── */
8561
8562.badge {
8563  display: inline-flex; align-items: center;
8564  padding: 3px 10px; border-radius: 980px;
8565  font-size: 11px; font-weight: 700; letter-spacing: 0.3px;
8566  border: 1px solid rgba(255,255,255,0.2);
8567}
8568
8569.badge-type {
8570  background: rgba(255,255,255,0.15);
8571  color: rgba(255,255,255,0.8);
8572  text-transform: uppercase;
8573  letter-spacing: 0.5px;
8574  font-size: 10px;
8575  margin-left: 8px;
8576}
8577
8578/* ── Responsive ─────────────────────────────────── */
8579
8580
8581  </style>
8582</head>
8583<body>
8584  <div class="container">
8585    <div class="back-nav">
8586      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">← Back to Profile</a>
8587    </div>
8588
8589    <div class="header">
8590      <h1>
8591        Notifications for
8592        <a href="/api/v1/user/${userId}${tokenQS}">@${esc(username)}</a>
8593        ${notifications.length > 0 ? `<span class="message-count">${total}</span>` : ""}
8594      </h1>
8595      <div class="header-subtitle">Dream summaries and thoughts from your trees</div>
8596    </div>
8597
8598    ${
8599      items.length
8600        ? `<ul class="notes-list">${items}</ul>`
8601        : `
8602    <div class="empty-state">
8603      <div class="empty-state-icon">🔔</div>
8604      <div class="empty-state-text">No notifications yet</div>
8605      <div class="empty-state-subtext">Dreams will generate summaries and thoughts automatically</div>
8606    </div>`
8607    }
8608  </div>
8609</body>
8610</html>`);
8611}
8612
8613// ═══════════════════════════════════════════════════════════════════
8614// Exported HTML helper functions (used by user.js for inline rendering)
8615// ═══════════════════════════════════════════════════════════════════
8616export { escapeHtml, renderMedia };
8617
1// ─────────────────────────────────────────────────
2// Shared utilities for HTML renderers
3// ─────────────────────────────────────────────────
4
5// ─── HTML escaping ───────────────────────────────
6// One definitive implementation. Handles null/undefined,
7// coerces to string, escapes all 5 dangerous characters.
8
9export function esc(str) {
10  if (str == null) return "";
11  return String(str)
12    .replace(/&/g, "&amp;")
13    .replace(/</g, "&lt;")
14    .replace(/>/g, "&gt;")
15    .replace(/"/g, "&quot;")
16    .replace(/'/g, "&#039;");
17}
18
19// Alias for files that use the longer name
20export { esc as escapeHtml };
21
22// ─── Truncation ──────────────────────────────────
23
24export function truncate(str, len = 200) {
25  if (!str) return "";
26  const clean = esc(str);
27  return clean.length > len ? clean.slice(0, len) + "..." : clean;
28}
29
30// Raw truncate (no escaping, for pre-escaped or non-HTML contexts)
31export function truncateRaw(str, len = 24) {
32  if (!str) return "";
33  return str.length > len ? str.slice(0, len) + "..." : str;
34}
35
36// ─── Time formatting ─────────────────────────────
37
38export function formatTime(d) {
39  return d ? new Date(d).toLocaleString() : "--";
40}
41
42export function formatDuration(start, end) {
43  if (!start || !end) return null;
44  const ms = new Date(end) - new Date(start);
45  if (ms < 1000) return `${ms}ms`;
46  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
47  return `${(ms / 60000).toFixed(1)}m`;
48}
49
50export function timeAgo(date) {
51  if (!date) return "never";
52  const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
53  if (seconds < 0) return "just now";
54  if (seconds < 60) return seconds + "s ago";
55  if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
56  if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
57  return Math.floor(seconds / 86400) + "d ago";
58}
59
60// ─── Rainbow depth colors ────────────────────────
61
62export const rainbow = [
63  "#ff3b30",
64  "#ff9500",
65  "#ffcc00",
66  "#34c759",
67  "#32ade6",
68  "#5856d6",
69  "#af52de",
70];
71
72// ─── Action color mappings ───────────────────────
73// Two variants: CSS class names (for glass cards) and hex (for inline styles).
74// Driven from one authoritative map so they never drift.
75
76const ACTION_COLORS = {
77  create:          { cls: "glass-green",   hex: "#48bb78" },
78  delete:          { cls: "glass-red",     hex: "#c85050" },
79  branchLifecycle: { cls: "glass-red",     hex: "#c85050" },
80  editStatus:      { cls: "glass-blue",    hex: "#5082dc" },
81  editValue:       { cls: "glass-blue",    hex: "#5082dc" },
82  editGoal:        { cls: "glass-blue",    hex: "#5082dc" },
83  editSchedule:    { cls: "glass-blue",    hex: "#5082dc" },
84  editNameNode:    { cls: "glass-blue",    hex: "#5082dc" },
85  editScript:      { cls: "glass-blue",    hex: "#5082dc" },
86  executeScript:   { cls: "glass-cyan",    hex: "#38bdd2" },
87  prestige:        { cls: "glass-gold",    hex: "#c8aa32" },
88  note:            { cls: "glass-purple",  hex: "#9b64dc" },
89  rawIdea:         { cls: "glass-purple",  hex: "#9b64dc" },
90  invite:          { cls: "glass-pink",    hex: "#d264a0" },
91  transaction:     { cls: "glass-orange",  hex: "#dc8c3c" },
92  trade:           { cls: "glass-orange",  hex: "#dc8c3c" },
93  purchase:        { cls: "glass-emerald", hex: "#34be82" },
94  updateParent:    { cls: "glass-teal",    hex: "#3caab4" },
95  updateChildNode: { cls: "glass-teal",    hex: "#3caab4" },
96  understanding:   { cls: "glass-indigo",  hex: "#6464d2" },
97};
98
99const DEFAULT_ACTION = { cls: "glass-default", hex: "#736fe6" };
100
101export function actionColorClass(action) {
102  return (ACTION_COLORS[action] || DEFAULT_ACTION).cls;
103}
104
105export function actionColorHex(action) {
106  return (ACTION_COLORS[action] || DEFAULT_ACTION).hex;
107}
108
109// ─── Action labels ───────────────────────────────
110
111const ACTION_LABELS = {
112  create: "Created",
113  editStatus: "Status",
114  editValue: "Values",
115  prestige: "Prestige",
116  trade: "Trade",
117  delete: "Deleted",
118  invite: "Invite",
119  editSchedule: "Schedule",
120  editGoal: "Goal",
121  transaction: "Transaction",
122  note: "Note",
123  updateParent: "Moved",
124  editScript: "Script",
125  executeScript: "Ran script",
126  updateChildNode: "Child",
127  editNameNode: "Renamed",
128  rawIdea: "Raw idea",
129  branchLifecycle: "Branch",
130  purchase: "Purchase",
131  understanding: "Understanding",
132};
133
134export function actionLabel(action) {
135  return ACTION_LABELS[action] || action;
136}
137
138// ─── Media rendering ─────────────────────────────
139// lazy: uses data-src + lazy-media class (needs client-side IntersectionObserver)
140// immediate: uses src directly
141
142export function renderMedia(fileUrl, mimeType, { lazy = true } = {}) {
143  const srcAttr = lazy ? "data-src" : "src";
144  const cls = lazy ? ' class="lazy-media"' : "";
145  const loading = lazy ? ' loading="lazy"' : "";
146
147  if (mimeType.startsWith("image/")) {
148    return `<img ${srcAttr}="${fileUrl}"${loading}${cls} style="max-width:100%;" alt="" />`;
149  }
150  if (mimeType.startsWith("video/")) {
151    return `<video ${srcAttr}="${fileUrl}" controls${lazy ? ' preload="none"' : ""}${cls} style="max-width:100%;"></video>`;
152  }
153  if (mimeType.startsWith("audio/")) {
154    return `<audio ${srcAttr}="${fileUrl}" controls${lazy ? ' preload="none"' : ""}${cls}></audio>`;
155  }
156  if (mimeType === "application/pdf") {
157    return `<iframe ${srcAttr}="${fileUrl}"${loading}${cls} style="width:100%; height:90vh; border:none;"></iframe>`;
158  }
159  return "";
160}
161
162// ─── Chat chain grouping ─────────────────────────
163
164export function groupIntoChains(chats) {
165  const chainMap = new Map();
166  const chainOrder = [];
167  for (const chat of chats) {
168    const key = chat.rootChatId || chat._id;
169    if (!chainMap.has(key)) {
170      chainMap.set(key, { root: null, steps: [] });
171      chainOrder.push(key);
172    }
173    const chain = chainMap.get(key);
174    if (chat.chainIndex === 0 || chat._id === key) {
175      chain.root = chat;
176    } else {
177      chain.steps.push(chat);
178    }
179  }
180  return chainOrder
181    .map((key) => {
182      const chain = chainMap.get(key);
183      chain.steps.sort((a, b) => a.chainIndex - b.chainIndex);
184      return chain;
185    })
186    .filter((c) => c.root);
187}
188
189// ─── Mode labels ─────────────────────────────────
190
191export function modeLabel(path) {
192  if (!path) return "unknown";
193  if (path === "translator") return "Translator";
194  if (path.startsWith("tree:orchestrator:plan:")) {
195    return `Plan Step ${path.split(":")[3]}`;
196  }
197  const parts = path.split(":");
198  const labels = { home: "Home", tree: "Tree", rawIdea: "Raw Idea" };
199  const subLabels = {
200    default: "Default",
201    chat: "Chat",
202    structure: "Structure",
203    edit: "Edit",
204    be: "Be",
205    reflect: "Reflect",
206    navigate: "Navigate",
207    understand: "Understand",
208    getContext: "Context",
209    respond: "Respond",
210    notes: "Notes",
211    start: "Start",
212    chooseRoot: "Choose Root",
213    complete: "Placed",
214    stuck: "Stuck",
215  };
216  const big = labels[parts[0]] || parts[0];
217  const sub = subLabels[parts[1]] || parts[1] || "";
218  return sub ? `${big} ${sub}` : big;
219}
220
221// ─── Source labels ───────────────────────────────
222
223export function sourceLabel(src) {
224  const map = {
225    user: "User",
226    api: "API",
227    orchestrator: "Chain",
228    background: "Background",
229    script: "Script",
230    system: "System",
231  };
232  return map[src] || src;
233}
234
1import crypto from "crypto";
2import router, { pageRouter } from "./routes.js";
3import appRouter from "./app/app.js";
4import chatRouter from "./app/chat.js";
5import setupRouter from "./app/setup.js";
6import { renderLoginPage, renderRegisterPage, renderForgotPasswordPage } from "./pages.js";
7import * as renderers from "./renderers.js";
8import { resolveHtmlShareAccess } from "./shareAuth.js";
9
10// Mount app page routers onto the pageRouter so the loader wires them at /
11pageRouter.use("/", appRouter);
12pageRouter.use("/", chatRouter);
13pageRouter.use("/", setupRouter);
14
15function generateShareToken() {
16  return crypto.randomBytes(16).toString("base64url");
17}
18
19/**
20 * Register an HTML page route on the page router (mounted at /, not /api/v1).
21 * Other extensions call this to add their own server-rendered pages.
22 *
23 * @param {string} method - HTTP method: "get", "post", etc.
24 * @param {string} path - Route path, e.g. "/my-dashboard"
25 * @param  {...Function} handlers - Express middleware/handler(s)
26 */
27function registerPage(method, path, ...handlers) {
28  const m = method.toLowerCase();
29  if (typeof pageRouter[m] !== "function") {
30    throw new Error(`Invalid HTTP method: ${method}`);
31  }
32  pageRouter[m](path, ...handlers);
33}
34
35export async function init(core) {
36  const User = core.models.User;
37
38  // Generate share token for new users
39  core.hooks.register("afterRegister", async ({ user }) => {
40    const freshUser = await User.findById(user._id);
41    if (!freshUser) return;
42    const { getUserMeta, setUserMeta } = await import("../../core/tree/userMetadata.js");
43    const existing = getUserMeta(freshUser, "html");
44    if (existing?.shareToken) return; // already has one
45    setUserMeta(freshUser, "html", { ...existing, shareToken: generateShareToken() });
46    await freshUser.save();
47  }, "html-rendering");
48
49  return {
50    router,
51    pageRouter,
52    exports: {
53      renderLoginPage,
54      renderRegisterPage,
55      renderForgotPasswordPage,
56      resolveHtmlShareAccess,
57      registerPage,
58      ...renderers,
59    },
60  };
61}
62
1export default {
2  name: "html-rendering",
3  version: "2.0.0",
4  description: "Server-rendered HTML pages, share token auth, and a page registration API for other extensions",
5
6  needs: {
7    models: ["User", "Node"],
8  },
9
10  optional: {},
11
12  provides: {
13    models: {},
14    routes: "./routes.js",
15    tools: false,
16    jobs: false,
17    energyActions: {},
18    sessionTypes: {},
19    env: [
20      { key: "ENABLE_FRONTEND_HTML", required: false, default: "true", description: "Enable server-rendered HTML and share token auth. Set to false to disable all ?html routes, share token access, and extension HTML renderers. API endpoints still return JSON." },
21    ],
22
23    hooks: {
24      fires: [],
25      listens: ["afterRegister"],
26    },
27
28    // Documented exports (available via getExtension("html-rendering")?.exports)
29    //
30    // Page registration:
31    //   registerPage(method, path, ...handlers)  - Add routes to the page router (mounted at /, not /api/v1).
32    //                                              Other extensions use this to add their own server-rendered pages.
33    //                                              Example: registerPage("get", "/my-dashboard", authenticate, handler)
34    //
35    // Share token auth:
36    //   resolveHtmlShareAccess({ userId, nodeId, shareToken })  - Validate a share token for URL-based auth.
37    //                                                              Returns { allowed, matchedUserId, scope, ... }
38    //
39    // Render functions (60+):
40    //   All render functions from html/user.js, html/node.js, html/notes.js, html/values.js, html/chat.js, html/notFound.js
41    //   Examples: renderValues(), renderEnergy(), renderChat(), renderUserNotes(), renderScriptDetail(),
42    //             renderBookPage(), renderSolanaWallet(), errorHtml(), parseBool(), normalizeStatusFilters()
43    //
44    // Login/register pages:
45    //   renderLoginPage(), renderRegisterPage(), renderForgotPasswordPage()
46    //
47    // Usage from other extensions:
48    //   import { getExtension } from "../loader.js";
49    //   const html = getExtension("html-rendering")?.exports || {};
50    //   if (html.renderValues) res.send(html.renderValues({ ... }));
51    //
52    // If this extension is not installed, all consuming extensions fall back to JSON responses.
53  },
54};
55
1export {
2  renderLoginPage,
3  renderRegisterPage,
4  renderForgotPasswordPage,
5} from "./html/login.js";
6
1// Barrel re-export of all HTML render functions.
2// Extensions and core routes use getExtension("html-rendering")?.exports to access these.
3
4export * from "./html/user.js";
5export * from "./html/node.js";
6export * from "./html/notes.js";
7export * from "./html/notFound.js";
8export * from "./html/chat.js";
9export * from "./html/contributions.js";
10export * from "./html/root.js";
11export * from "./html/query.js";
12
13
1import log from "../../core/log.js";
2import express from "express";
3import crypto from "crypto";
4import authenticate from "../../middleware/authenticate.js";
5import User from "../../db/models/user.js";
6import { getUserMeta, setUserMeta } from "../../core/tree/userMetadata.js";
7import {
8  renderLoginPage,
9  renderRegisterPage,
10  renderForgotPasswordPage,
11} from "./pages.js";
12import rateLimit from "express-rate-limit";
13
14const router = express.Router();
15
16const URL_SAFE_REGEX = /^[A-Za-z0-9\-_.~]+$/;
17
18const limiter = rateLimit({
19  windowMs: 15 * 60 * 1000,
20  max: 10,
21  handler: (req, res) => {
22    res.status(429).json({ message: "Too many requests. Try again later." });
23  },
24});
25
26// SET share token
27router.post("/setHTMLShareToken", authenticate, limiter, async (req, res) => {
28  try {
29    const user = await User.findById(req.userId);
30    if (!user) return res.status(404).json({ message: "User not found" });
31
32    let { htmlShareToken } = req.body;
33    if (typeof htmlShareToken !== "string") {
34      return res.status(400).json({ message: "htmlShareToken must be a string" });
35    }
36
37    htmlShareToken = htmlShareToken.trim();
38    if (htmlShareToken.length > 128 || htmlShareToken.length < 1) {
39      return res.status(400).json({ message: "htmlShareToken must be 1 to 128 characters" });
40    }
41    if (!URL_SAFE_REGEX.test(htmlShareToken)) {
42      return res.status(400).json({ message: "htmlShareToken may only contain URL-safe characters" });
43    }
44
45    setUserMeta(user, "html", { shareToken: htmlShareToken });
46    await user.save();
47
48    return res.json({ htmlShareToken });
49  } catch (err) {
50    log.error("HTML", "setHtmlShareToken error:", err.message);
51    res.status(500).json({ message: "Failed to set html share token" });
52  }
53});
54
55// VERIFY token (returns share token + user info for frontend)
56router.post("/verify-token", authenticate, async (req, res) => {
57  try {
58    const user = await User.findById(req.userId)
59      .select("metadata")
60      .lean();
61
62    const htmlMeta = getUserMeta(user, "html");
63    const HTMLShareToken = htmlMeta?.shareToken || null;
64
65    let hasLlm = false;
66    try {
67      const fullUser = await User.findById(req.userId)
68        .select("llmDefault metadata")
69        .lean();
70      if (fullUser?.llmDefault) {
71        hasLlm = true;
72      } else {
73        let CustomLlmConnection;
74        try { CustomLlmConnection = (await import("../../db/models/customLlmConnection.js")).default; } catch { }
75        if (CustomLlmConnection) {
76          const connCount = await CustomLlmConnection.countDocuments({ userId: req.userId });
77          hasLlm = connCount > 0;
78        }
79      }
80    } catch (err) {
81      log.error("HTML", "verify-token LLM check error:", err.message);
82    }
83
84    res.json({
85      userId: req.userId,
86      username: req.username,
87      HTMLShareToken,
88      hasLlm,
89    });
90  } catch (err) {
91    log.error("HTML", "verify-token error:", err.message);
92    res.status(500).json({ message: "Failed to verify token" });
93  }
94});
95
96// Page routes (mounted at / not /api/v1)
97export const pageRouter = express.Router();
98
99pageRouter.get("/login", (req, res) => {
100  if (process.env.ENABLE_FRONTEND_HTML !== "true") {
101    return res.status(404).json({ error: "Server-rendered HTML is disabled." });
102  }
103  renderLoginPage(req, res);
104});
105
106pageRouter.get("/register", (req, res) => {
107  if (process.env.ENABLE_FRONTEND_HTML !== "true") {
108    return res.status(404).json({ error: "Server-rendered HTML is disabled." });
109  }
110  renderRegisterPage(req, res);
111});
112
113pageRouter.get("/forgot-password", (req, res) => {
114  if (process.env.ENABLE_FRONTEND_HTML !== "true") {
115    return res.status(404).json({ error: "Server-rendered HTML is disabled." });
116  }
117  renderForgotPasswordPage(req, res);
118});
119
120export default router;
121
1// Share token authentication for HTML-rendered pages.
2// Moved from core/authenticate.js into the html-rendering extension.
3
4import log from "../../core/log.js";
5import User from "../../db/models/user.js";
6import { resolveRootNode } from "../../core/tree/treeFetch.js";
7
8export async function resolveHtmlShareAccess({ userId, nodeId, shareToken }) {
9  if (!shareToken) {
10    return { allowed: false, reason: "Missing share token" };
11  }
12
13  // CASE 1: userId-based access
14  if (userId && !nodeId) {
15    const user = await User.findOne({
16      _id: userId,
17      "metadata.html.shareToken": shareToken,
18    })
19      .select("_id username")
20      .lean()
21      .exec();
22
23    if (!user) {
24      return { allowed: false, reason: "Invalid share token" };
25    }
26
27    return {
28      allowed: true,
29      matchedUserId: user._id,
30      matchedUsername: user.username,
31      scope: "user",
32    };
33  }
34
35  // CASE 2: nodeId-based access
36  if (nodeId) {
37    const rootNode = await resolveRootNode(nodeId);
38
39    const userIds = [
40      rootNode.rootOwner,
41      ...(rootNode.contributors || []),
42    ].filter(Boolean);
43
44    if (userIds.length === 0) {
45      return { allowed: false, reason: "No users associated with root" };
46    }
47
48    const matchedUser = await User.findOne({
49      _id: { $in: userIds },
50      "metadata.html.shareToken": shareToken,
51    })
52      .select("_id username")
53      .lean()
54      .exec();
55
56    if (!matchedUser) {
57      log.debug("Auth", "ShareAuth: DENIED nodeId=%s userIds=%j tokenPrefix=%s", nodeId, userIds, shareToken?.slice(0, 6));
58      return { allowed: false, reason: "Invalid share token for node" };
59    }
60
61    return {
62      allowed: true,
63      rootId: rootNode._id.toString(),
64      matchedUserId: matchedUser._id,
65      matchedUsername: matchedUser.username,
66      scope: "node",
67    };
68  }
69
70  return {
71    allowed: false,
72    reason: "userId or nodeId is required",
73  };
74}
75