@tensorix/metahub 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/cli.js +3755 -2524
  4. package/dist/cli.js.map +35 -32
  5. package/dist/core/databases.d.ts.map +1 -1
  6. package/dist/core/db.d.ts +7 -0
  7. package/dist/core/db.d.ts.map +1 -1
  8. package/dist/core/documents.d.ts +11 -0
  9. package/dist/core/documents.d.ts.map +1 -1
  10. package/dist/core/errors.d.ts +32 -0
  11. package/dist/core/errors.d.ts.map +1 -0
  12. package/dist/core/history.d.ts +86 -0
  13. package/dist/core/history.d.ts.map +1 -0
  14. package/dist/core/index.d.ts +1 -0
  15. package/dist/core/index.d.ts.map +1 -1
  16. package/dist/core/properties.d.ts.map +1 -1
  17. package/dist/core/records.d.ts.map +1 -1
  18. package/dist/core/resolve.d.ts.map +1 -1
  19. package/dist/core/schema.d.ts +1 -1
  20. package/dist/core/schema.d.ts.map +1 -1
  21. package/dist/core/sites.d.ts +21 -0
  22. package/dist/core/sites.d.ts.map +1 -1
  23. package/dist/core/snapshot.d.ts.map +1 -1
  24. package/dist/core/sync/client.d.ts.map +1 -1
  25. package/dist/core/sync/files.d.ts.map +1 -1
  26. package/dist/core/sync/openapi.d.ts +5 -2
  27. package/dist/core/sync/openapi.d.ts.map +1 -1
  28. package/dist/core/sync/pairing.d.ts.map +1 -1
  29. package/dist/core/sync/peers-routes.d.ts +1 -1
  30. package/dist/core/sync/peers-routes.d.ts.map +1 -1
  31. package/dist/core/sync/routes.d.ts +4 -0
  32. package/dist/core/sync/routes.d.ts.map +1 -1
  33. package/dist/core/sync/server.d.ts +14 -1
  34. package/dist/core/sync/server.d.ts.map +1 -1
  35. package/dist/core/sync/sites-routes.d.ts +1 -1
  36. package/dist/core/sync/sites-routes.d.ts.map +1 -1
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +2547 -1574
  40. package/dist/index.js.map +25 -23
  41. package/dist/{core/sync/webui.d.ts → webui/server/assets.d.ts} +1 -5
  42. package/dist/webui/server/assets.d.ts.map +1 -0
  43. package/dist/webui/server/routes.d.ts +3 -0
  44. package/dist/webui/server/routes.d.ts.map +1 -0
  45. package/dist/webui.js +51 -51
  46. package/package.json +1 -1
  47. package/dist/core/sync/webui-routes.d.ts +0 -3
  48. package/dist/core/sync/webui-routes.d.ts.map +0 -1
  49. package/dist/core/sync/webui.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -15,6 +15,22 @@ var __export = (target, all) => {
15
15
  };
16
16
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
 
18
+ // src/core/errors.ts
19
+ function errorCode(e) {
20
+ return e instanceof MhError ? e.code : undefined;
21
+ }
22
+ var MhError;
23
+ var init_errors = __esm(() => {
24
+ MhError = class MhError extends Error {
25
+ code;
26
+ constructor(code, message) {
27
+ super(message);
28
+ this.code = code;
29
+ this.name = "MhError";
30
+ }
31
+ };
32
+ });
33
+
18
34
  // src/core/paths.ts
19
35
  import { homedir } from "os";
20
36
  import { join } from "path";
@@ -342,7 +358,7 @@ var init_crdt = __esm(() => {
342
358
  },
343
359
  documents: {
344
360
  table: "documents",
345
- cols: new Set(["title", "body", "database_id", "parent_id", "created_hlc", "__deleted"])
361
+ cols: new Set(["title", "body", "database_id", "parent_id", "created_hlc", "order_key", "__deleted"])
346
362
  },
347
363
  doc_blocks: {
348
364
  table: "doc_blocks",
@@ -398,6 +414,24 @@ function inferContentType(path) {
398
414
  const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
399
415
  return MIME[ext] ?? "application/octet-stream";
400
416
  }
417
+ function normalizeSiteName(raw) {
418
+ const slug = raw.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
419
+ if (!slug)
420
+ throw new MhError("invalid_input", `invalid site name: ${JSON.stringify(raw)}`);
421
+ return slug;
422
+ }
423
+ function normalizeSitePath(raw) {
424
+ const segs = [];
425
+ for (const seg of raw.split("/")) {
426
+ if (seg === "" || seg === ".")
427
+ continue;
428
+ if (seg === "..")
429
+ segs.pop();
430
+ else
431
+ segs.push(seg);
432
+ }
433
+ return segs.join("/");
434
+ }
401
435
  function isTextType(contentType) {
402
436
  const ct = contentType.toLowerCase();
403
437
  return ct.startsWith("text/") || ct.startsWith("application/json") || ct.startsWith("application/manifest+json") || ct.startsWith("application/xml") || ct.startsWith("text/javascript") || ct.startsWith("application/javascript") || ct.startsWith("image/svg+xml");
@@ -408,10 +442,11 @@ function toBytes(data) {
408
442
  return data instanceof Uint8Array ? data : new Uint8Array(data);
409
443
  }
410
444
  function createSite(db, opts) {
411
- if (getSiteByName(db, opts.name))
412
- throw new Error(`site name already exists: ${opts.name}`);
413
- const id = newId("site", opts.name);
414
- const first = emit(db, "sites", id, "name", opts.name);
445
+ const name = normalizeSiteName(opts.name);
446
+ if (getSiteByName(db, name))
447
+ throw new MhError("conflict", `site name already exists: ${name}`);
448
+ const id = newId("site", name);
449
+ const first = emit(db, "sites", id, "name", name);
415
450
  emit(db, "sites", id, "created_hlc", first.hlc);
416
451
  if (opts.title !== undefined)
417
452
  emit(db, "sites", id, "title", opts.title);
@@ -421,7 +456,13 @@ function getSite(db, id) {
421
456
  return db.query("SELECT id, name, title, created_hlc FROM sites WHERE id = ? AND __deleted = 0").get(id);
422
457
  }
423
458
  function getSiteByName(db, name) {
424
- return db.query("SELECT id, name, title, created_hlc FROM sites WHERE name = ? AND __deleted = 0 ORDER BY created_hlc DESC LIMIT 1").get(name);
459
+ let slug;
460
+ try {
461
+ slug = normalizeSiteName(name);
462
+ } catch {
463
+ return null;
464
+ }
465
+ return db.query("SELECT id, name, title, created_hlc FROM sites WHERE name = ? AND __deleted = 0 ORDER BY created_hlc DESC LIMIT 1").get(slug);
425
466
  }
426
467
  function listSites(db) {
427
468
  return db.query("SELECT id, name, title, created_hlc FROM sites WHERE __deleted = 0 ORDER BY created_hlc").all();
@@ -430,9 +471,23 @@ function resolveSite(db, ref) {
430
471
  const byId = ref.startsWith("site_") ? getSite(db, ref) : null;
431
472
  const site = byId ?? getSiteByName(db, ref);
432
473
  if (!site)
433
- throw new Error(`no such site: ${ref}`);
474
+ throw new MhError("not_found", `no such site: ${ref}`);
434
475
  return site;
435
476
  }
477
+ function updateSite(db, id, opts) {
478
+ if (!getSite(db, id))
479
+ throw new MhError("not_found", `no such site: ${id}`);
480
+ if (opts.name !== undefined) {
481
+ const name = normalizeSiteName(opts.name);
482
+ const dup = getSiteByName(db, name);
483
+ if (dup && dup.id !== id)
484
+ throw new MhError("conflict", `site name already exists: ${name}`);
485
+ emit(db, "sites", id, "name", name);
486
+ }
487
+ if (opts.title !== undefined)
488
+ emit(db, "sites", id, "title", opts.title);
489
+ return getSite(db, id);
490
+ }
436
491
  function deleteSite(db, id) {
437
492
  if (!getSite(db, id))
438
493
  return false;
@@ -447,7 +502,10 @@ function fileIdFor(db, siteId, path) {
447
502
  return row?.id ?? null;
448
503
  }
449
504
  async function putFile(db, siteId, path, opts) {
450
- const contentType = opts.contentType ?? inferContentType(path);
505
+ const cleanPath = normalizeSitePath(path);
506
+ if (!cleanPath)
507
+ throw new MhError("invalid_input", `invalid file path: ${JSON.stringify(path)}`);
508
+ const contentType = opts.contentType ?? inferContentType(cleanPath);
451
509
  const bytes = toBytes(opts.data);
452
510
  let encoding;
453
511
  let content;
@@ -461,11 +519,11 @@ async function putFile(db, siteId, path, opts) {
461
519
  encoding = "blob";
462
520
  content = (await putBlob(bytes)).hash;
463
521
  }
464
- const existing = fileIdFor(db, siteId, path);
465
- const id = existing ?? newId("sf", path);
522
+ const existing = fileIdFor(db, siteId, cleanPath);
523
+ const id = existing ?? newId("sf", cleanPath);
466
524
  if (!existing) {
467
525
  const first = emit(db, "site_files", id, "site_id", siteId);
468
- emit(db, "site_files", id, "path", path);
526
+ emit(db, "site_files", id, "path", cleanPath);
469
527
  emit(db, "site_files", id, "created_hlc", first.hlc);
470
528
  }
471
529
  emit(db, "site_files", id, "content_type", contentType);
@@ -478,7 +536,7 @@ function listFiles(db, siteId) {
478
536
  return db.query("SELECT id, site_id, path, content_type, encoding FROM site_files WHERE site_id = ? AND __deleted = 0 ORDER BY path").all(siteId);
479
537
  }
480
538
  function deleteFile(db, siteId, path) {
481
- const id = fileIdFor(db, siteId, path);
539
+ const id = fileIdFor(db, siteId, normalizeSitePath(path));
482
540
  if (!id)
483
541
  return false;
484
542
  const live = db.query("SELECT __deleted AS d FROM site_files WHERE id = ?").get(id);
@@ -488,7 +546,10 @@ function deleteFile(db, siteId, path) {
488
546
  return true;
489
547
  }
490
548
  async function getFileForServe(db, siteId, path) {
491
- const p = path === "" || path.endsWith("/") ? `${path}index.html` : path;
549
+ const withIndex = path === "" || path.endsWith("/") ? `${path}index.html` : path;
550
+ const p = normalizeSitePath(withIndex);
551
+ if (!p)
552
+ return null;
492
553
  const row = db.query("SELECT content_type, encoding, content FROM site_files WHERE site_id = ? AND path = ? AND __deleted = 0 ORDER BY created_hlc LIMIT 1").get(siteId, p);
493
554
  if (!row)
494
555
  return null;
@@ -509,6 +570,7 @@ var init_sites = __esm(() => {
509
570
  init_ids();
510
571
  init_crdt();
511
572
  init_cache();
573
+ init_errors();
512
574
  INLINE_LIMIT = 256 * 1024;
513
575
  MIME = {
514
576
  html: "text/html; charset=utf-8",
@@ -538,1099 +600,299 @@ var init_sites = __esm(() => {
538
600
  };
539
601
  });
540
602
 
541
- // src/core/sync/webui.ts
542
- var exports_webui = {};
543
- __export(exports_webui, {
544
- warmWebui: () => warmWebui,
545
- setWebuiBundle: () => setWebuiBundle,
546
- serveWebui: () => serveWebui
603
+ // src/core/sync/sites-serve.ts
604
+ var exports_sites_serve = {};
605
+ __export(exports_sites_serve, {
606
+ serveSite: () => serveSite
547
607
  });
548
- import { fileURLToPath } from "url";
549
- function setWebuiBundle(js) {
550
- cachedJs = js;
551
- }
552
- async function getJs() {
553
- if (cachedJs != null)
554
- return cachedJs;
555
- const prebuilt = Bun.file(fileURLToPath(new URL("./webui.js", import.meta.url)));
556
- if (await prebuilt.exists()) {
557
- cachedJs = await prebuilt.text();
558
- return cachedJs;
559
- }
560
- const entry = fileURLToPath(new URL("../../webui/app.tsx", import.meta.url));
561
- const res = await Bun.build({ entrypoints: [entry], target: "browser", minify: true });
562
- if (!res.success)
563
- throw new AggregateError(res.logs, "webui build failed");
564
- cachedJs = await res.outputs[0].text();
565
- return cachedJs;
566
- }
567
- async function warmWebui() {
568
- try {
569
- await getJs();
570
- } catch {}
571
- }
572
- async function serveWebui(req) {
573
- if (req.method !== "GET")
608
+ async function serveSite(req, ctx) {
609
+ const url2 = new URL(req.url);
610
+ const rest = url2.pathname.slice("/sites/".length);
611
+ if (rest === "")
574
612
  return null;
575
- const { pathname } = new URL(req.url);
576
- if (pathname === "/") {
577
- return new Response(HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
578
- }
579
- if (pathname === "/webui.js") {
580
- try {
581
- return new Response(await getJs(), {
582
- headers: { "content-type": "text/javascript; charset=utf-8" }
583
- });
584
- } catch (e) {
585
- console.error("[webui] failed to serve /webui.js \u2014", e);
586
- return new Response(`webui build failed: ${e}`, { status: 500 });
587
- }
613
+ const slash = rest.indexOf("/");
614
+ const name = decodeURIComponent(slash === -1 ? rest : rest.slice(0, slash));
615
+ if (slash === -1)
616
+ return Response.redirect(`${url2.origin}/sites/${name}/`, 301);
617
+ const filePath = decodeURIComponent(rest.slice(slash + 1));
618
+ let siteId;
619
+ try {
620
+ siteId = resolveSite(ctx.db, name).id;
621
+ } catch {
622
+ return new Response("site not found", { status: 404 });
588
623
  }
589
- return null;
624
+ const file2 = await getFileForServe(ctx.db, siteId, filePath);
625
+ if (!file2)
626
+ return new Response("not found", { status: 404 });
627
+ return new Response(file2.bytes, { headers: { "content-type": file2.contentType } });
590
628
  }
591
- var HTML = `<!doctype html>
592
- <html lang="zh">
593
- <head>
594
- <meta charset="utf-8" />
595
- <meta name="viewport" content="width=device-width, initial-scale=1" />
596
- <title>Metahub</title>
597
- <link rel="preconnect" href="https://fonts.googleapis.com">
598
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
599
- <!-- Load web fonts without blocking first paint: media="print" keeps the
600
- stylesheet non-render-blocking, then onload flips it to "all". The page
601
- paints instantly with the system-font fallbacks baked into --ui/--mono and
602
- swaps to the web fonts when they arrive (or stays on system fonts offline).
603
- This is what kept the desktop cold-start white for seconds on slow networks. -->
604
- <link rel="stylesheet" media="print" onload="this.media='all'" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
605
- <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"></noscript>
606
- <script>try{document.documentElement.dataset.theme=localStorage.getItem('mh-theme')||'system'}catch(e){}</script>
607
- <style>
608
- :root {
609
- --bg:#ffffff; --surface:#fbfbfa; --surface-2:#f1f1ef; --sidebar:#f7f7f5;
610
- --fg:#2c2c30; --fg-soft:#5b5b62; --muted:#8a8a93; --line:#ebebe8; --line-strong:#dededb;
611
- --accent:#4a55d6; --accent-fg:#ffffff; --accent-soft:#eef0ff; --danger:#d6473b; --danger-soft:#fdeceb;
612
- --hover:rgba(45,45,55,.045); --hover-2:rgba(45,45,55,.08);
613
- --shadow-sm:0 1px 2px rgba(20,20,40,.06),0 1px 1px rgba(20,20,40,.04);
614
- --shadow-md:0 4px 12px rgba(20,20,40,.08),0 2px 4px rgba(20,20,40,.05);
615
- --shadow-lg:0 16px 48px rgba(20,20,40,.16),0 4px 12px rgba(20,20,40,.08);
616
- --radius:8px; --radius-sm:6px;
617
- --ui:"Hanken Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
618
- --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,monospace;
619
- --note-lh:1.6;
620
- --code-bg:#f8f8f6;
621
- --hl-cmt:#9a9aa2; --hl-key:#a626a4; --hl-str:#28823f; --hl-num:#b76a00;
622
- --hl-fn:#3f51d6; --hl-type:#0a7ea4; --hl-var:#c2410c;
623
- }
624
- /* Dark palette. Applied (a) when explicitly chosen via [data-theme="dark"],
625
- * or (b) when following the system and the OS reports dark. Light is the
626
- * default :root above, so [data-theme="light"] needs no extra rule. */
627
- :root[data-theme="dark"] {
628
- --bg:#1a1a1c; --surface:#202022; --surface-2:#2a2a2d; --sidebar:#171719;
629
- --fg:#e6e6e9; --fg-soft:#b4b4bb; --muted:#7d7d86; --line:#2c2c30; --line-strong:#3a3a40;
630
- --accent:#7b86ff; --accent-fg:#14141a; --accent-soft:#23243a; --danger:#f87168; --danger-soft:#341e1c;
631
- --hover:rgba(255,255,255,.05); --hover-2:rgba(255,255,255,.09);
632
- --shadow-sm:0 1px 2px rgba(0,0,0,.4); --shadow-md:0 4px 14px rgba(0,0,0,.45); --shadow-lg:0 20px 56px rgba(0,0,0,.6);
633
- --code-bg:#1d1d20;
634
- --hl-cmt:#7d7d86; --hl-key:#d291e4; --hl-str:#7ed492; --hl-num:#e0a566;
635
- --hl-fn:#86a8ff; --hl-type:#5fc6e0; --hl-var:#f0936b;
636
- }
637
- @media (prefers-color-scheme: dark) {
638
- :root[data-theme="system"], :root:not([data-theme]) {
639
- --bg:#1a1a1c; --surface:#202022; --surface-2:#2a2a2d; --sidebar:#171719;
640
- --fg:#e6e6e9; --fg-soft:#b4b4bb; --muted:#7d7d86; --line:#2c2c30; --line-strong:#3a3a40;
641
- --accent:#7b86ff; --accent-fg:#14141a; --accent-soft:#23243a; --danger:#f87168; --danger-soft:#341e1c;
642
- --hover:rgba(255,255,255,.05); --hover-2:rgba(255,255,255,.09);
643
- --shadow-sm:0 1px 2px rgba(0,0,0,.4); --shadow-md:0 4px 14px rgba(0,0,0,.45); --shadow-lg:0 20px 56px rgba(0,0,0,.6);
644
- --code-bg:#1d1d20;
645
- --hl-cmt:#7d7d86; --hl-key:#d291e4; --hl-str:#7ed492; --hl-num:#e0a566;
646
- --hl-fn:#86a8ff; --hl-type:#5fc6e0; --hl-var:#f0936b;
647
- }
648
- }
649
- * { box-sizing: border-box; }
650
- html, body { margin: 0; height: 100%; }
651
- body { font-family: var(--ui); font-size: 14px; line-height: 1.55; color: var(--fg); background: var(--bg);
652
- overflow: hidden; text-rendering: auto; }
653
- button { font: inherit; color: inherit; cursor: pointer; background: none; border: 0; padding: 0; }
654
- input, textarea { font: inherit; color: inherit; }
655
- .muted { color: var(--muted); }
656
- svg { display: block; }
657
- .ico { width:16px; height:16px; stroke:currentColor; fill:none; stroke-width:1.75; stroke-linecap:round; stroke-linejoin:round; flex:none; }
658
- .ico.sm { width:14px; height:14px; }
659
- ::-webkit-scrollbar { width:10px; height:10px; }
660
- ::-webkit-scrollbar-thumb { background:var(--line-strong); border-radius:8px; border:3px solid transparent; background-clip:padding-box; }
661
- ::-webkit-scrollbar-thumb:hover { background:var(--muted); background-clip:padding-box; border:3px solid transparent; }
662
-
663
- #app { display: flex; height: 100vh; }
629
+ var init_sites_serve = __esm(() => {
630
+ init_sites();
631
+ });
664
632
 
665
- /* sidebar */
666
- .sidebar { width:268px; flex:none; background:var(--sidebar); border-right:1px solid var(--line);
667
- display:flex; flex-direction:column; position:relative; transition:margin-left .22s cubic-bezier(.4,0,.2,1); }
668
- .sb-resizer { position:absolute; top:0; right:-3px; width:6px; height:100%; cursor:col-resize; z-index:5; }
669
- .sb-head { display:flex; align-items:center; gap:8px; padding:14px 14px 10px; }
670
- .brand { display:flex; align-items:center; gap:8px; flex:1; font-weight:700; font-size:15px; letter-spacing:-.01em; }
671
- .brand .mark { width:24px; height:24px; border-radius:7px; background:linear-gradient(135deg,var(--accent),#8b94ff);
672
- display:grid; place-items:center; color:#fff; box-shadow:var(--shadow-sm); }
673
- .brand .mark svg { width:15px; height:15px; stroke:#fff; }
674
- .sb-search { margin:0 12px 8px; display:flex; align-items:center; gap:7px; cursor:text;
675
- background:var(--bg); border:1px solid var(--line); border-radius:var(--radius-sm); padding:6px 9px; color:var(--muted);
676
- box-shadow:var(--shadow-sm); transition:border-color .15s; }
677
- .sb-search:focus-within { border-color:var(--accent); }
678
- .sb-search input { border:0; background:none; outline:none; color:var(--fg); width:100%; }
679
- .sb-search kbd { font-family:var(--mono); font-size:10px; color:var(--muted); border:1px solid var(--line-strong); border-radius:4px; padding:1px 4px; }
680
- .sb-scroll { flex:1; overflow-y:auto; padding:4px 8px 24px; }
681
- .sb-section { margin-top:12px; }
682
- .sb-section-head { display:flex; align-items:center; justify-content:space-between;
683
- font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); padding:4px 8px; }
684
- .sb-section-head .add { opacity:0; padding:3px; border-radius:5px; color:var(--muted); transition:opacity .12s; }
685
- .sb-section:hover .add { opacity:1; }
686
- .sb-section-head .add:hover { background:var(--hover-2); color:var(--fg); }
687
- .navitem { display:flex; align-items:center; gap:3px; width:100%; text-align:left; padding:5px 6px; border-radius:var(--radius-sm);
688
- color:var(--fg-soft); font-size:13.5px; position:relative; white-space:nowrap; transition:background .1s; }
689
- .navitem:hover { background:var(--hover-2); }
690
- .navitem.active { background:var(--bg); color:var(--fg); font-weight:600; box-shadow:var(--shadow-sm); }
691
- .navitem .tw { width:18px; height:18px; display:grid; place-items:center; color:var(--muted); border-radius:4px; flex:none; }
692
- .navitem .tw:hover { background:var(--hover-2); }
693
- .navitem .tw svg { width:13px; height:13px; transition:transform .12s; }
694
- .navitem .tw.open svg { transform:rotate(90deg); }
695
- .navitem .emoji { width:20px; text-align:center; flex:none; font-size:14px; }
696
- .navitem .label { flex:1; overflow:hidden; text-overflow:ellipsis; }
697
- .navitem .acts { display:flex; gap:1px; opacity:0; }
698
- .navitem:hover .acts { opacity:1; }
699
- .navitem .acts button { width:22px; height:22px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
700
- .navitem .acts button:hover { background:var(--hover-2); color:var(--fg); }
701
- .navchildren { margin-left:16px; border-left:1px solid var(--line-strong); padding-left:2px; }
702
- .navitem.drop-into { box-shadow:inset 0 0 0 2px var(--accent); }
703
- .navitem.drop-before { box-shadow:inset 0 2px 0 0 var(--accent); }
704
- .navitem.drop-after { box-shadow:inset 0 -2px 0 0 var(--accent); }
705
- .dragging { opacity:.35; }
706
- .sb-footer { flex:none; padding:6px 8px; border-top:1px solid var(--line); }
707
- .sb-footer .navitem { font-size:13.5px; }
708
- .nav-dot { flex:none; width:7px; height:7px; border-radius:50%; background:var(--danger);
709
- box-shadow:0 0 0 2px var(--surface); margin-right:2px; }
633
+ // src/core/index.ts
634
+ init_errors();
635
+ init_paths();
710
636
 
711
- /* settings page */
712
- .set-page { max-width:680px; margin:0 auto; padding:40px 28px 64px; }
713
- .set-title { font-size:26px; font-weight:700; letter-spacing:-.02em; color:var(--fg); }
714
- .set-sub { color:var(--muted); margin-top:4px; }
715
- .set-section { margin-top:34px; }
716
- .set-section-head { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:4px; }
717
- .set-section-desc { color:var(--fg-soft); margin-bottom:16px; }
718
- .set-footer { margin-top:40px; padding-top:20px; border-top:1px solid var(--line); display:flex;
719
- flex-wrap:wrap; justify-content:center; align-items:center; gap:7px; color:var(--muted); font-size:12px; }
720
- .set-footer-sep { opacity:.45; }
721
- .ver-num { font-family:var(--mono); font-size:11px; color:var(--fg-soft); letter-spacing:-.01em; }
722
- .ver-up { display:inline-flex; align-items:center; gap:6px; }
723
- .ver-up.err .ver-msg { color:var(--danger); }
724
- .ver-act { background:none; border:0; padding:0; margin:0; font:inherit; font-size:12px; line-height:1;
725
- color:var(--muted); cursor:pointer; transition:color .12s; }
726
- .ver-act:hover { color:var(--accent); }
727
- .ver-act.accent { color:var(--accent); font-weight:600; }
728
- .ver-act[aria-disabled="true"] { opacity:.6; cursor:default; pointer-events:none; }
729
- .ver-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); flex:none; }
730
- .ver-dot.pulse { animation:verpulse 1.8s ease-in-out infinite; }
731
- @keyframes verpulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
732
- .theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
733
- .theme-card { position:relative; display:flex; flex-direction:column; align-items:flex-start; gap:10px;
734
- text-align:left; padding:16px; border:1px solid var(--line-strong); border-radius:var(--radius);
735
- background:var(--surface); color:var(--fg-soft); box-shadow:var(--shadow-sm);
736
- transition:border-color .14s, background .14s, transform .14s, box-shadow .14s; }
737
- .theme-card:hover { border-color:var(--muted); transform:translateY(-1px); }
738
- .theme-card .tc-ico { width:34px; height:34px; display:grid; place-items:center; border-radius:9px;
739
- background:var(--surface-2); color:var(--fg); }
740
- .theme-card .tc-ico svg { width:18px; height:18px; }
741
- .theme-card .tc-name { font-weight:600; font-size:14px; color:var(--fg); }
742
- .theme-card .tc-desc { font-size:12px; color:var(--muted); line-height:1.45; }
743
- .theme-card .tc-check { position:absolute; top:10px; right:10px; width:18px; height:18px; border-radius:50%;
744
- background:var(--accent); color:var(--accent-fg); display:grid; place-items:center; opacity:0; transform:scale(.6); transition:opacity .14s, transform .14s; }
745
- .theme-card .tc-check svg { width:12px; height:12px; stroke-width:2.6; }
746
- .theme-card.sel { border-color:var(--accent); background:var(--accent-soft); box-shadow:0 0 0 1px var(--accent) inset, var(--shadow-sm); }
747
- .theme-card.sel .tc-ico { background:var(--accent); color:var(--accent-fg); }
748
- .theme-card.sel .tc-check { opacity:1; transform:scale(1); }
749
- @media (max-width:560px) { .theme-grid { grid-template-columns:1fr; } }
637
+ // src/core/db.ts
638
+ init_paths();
639
+ import { Database } from "bun:sqlite";
640
+ import { mkdirSync } from "fs";
750
641
 
751
- /* sync devices */
752
- .peer-actions { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; }
753
- .peer-actions .btn { display:inline-flex; align-items:center; gap:6px; }
754
- .peer-list { display:flex; flex-direction:column; gap:8px; }
755
- .peer-row { display:flex; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--line);
756
- border-radius:10px; background:var(--surface); }
757
- .peer-dot { width:9px; height:9px; border-radius:50%; flex:none; background:var(--muted); }
758
- .peer-dot.on { background:#3fb950; } .peer-dot.err { background:#f85149; } .peer-dot.off { background:var(--muted); }
759
- .peer-main { flex:1; min-width:0; }
760
- .peer-url { font-weight:600; font-size:14px; color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
761
- .peer-sub { font-size:12px; color:var(--muted); margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
762
- .peer-menu { flex:none; padding:6px; }
763
- .pair-code { font-family:var(--mono); font-size:26px; font-weight:700; letter-spacing:.12em;
764
- text-align:center; padding:16px; border:1px dashed var(--line); border-radius:10px; background:var(--surface); color:var(--fg); }
642
+ // src/core/schema.ts
643
+ var CORE_SCHEMA = `
644
+ CREATE TABLE IF NOT EXISTS meta (
645
+ key TEXT PRIMARY KEY,
646
+ value TEXT
647
+ );
765
648
 
766
- /* main */
767
- .main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
768
- .topbar { display:flex; align-items:center; gap:6px; padding:10px 18px; border-bottom:1px solid var(--line); min-height:49px; }
769
- .topbar.bare { border-bottom:0; }
770
- .topbar .hamburger { display:none; }
771
- .topbar .hamburger.show-collapsed { display:grid; }
772
- .sidebar.collapsed { overflow:hidden; }
773
- .sidebar.collapsed .sb-resizer { pointer-events:none; }
774
- .crumb { display:flex; align-items:center; gap:7px; flex:1; min-width:0; font-size:13.5px; color:var(--fg); font-weight:500; }
775
- .crumb .emoji { font-size:15px; } .crumb .sub { color:var(--muted); font-weight:400; }
776
- .iconbtn { width:30px; height:30px; display:grid; place-items:center; border-radius:var(--radius-sm); color:var(--fg-soft); transition:background .12s; }
777
- .iconbtn:hover { background:var(--hover-2); color:var(--fg); }
778
- .content { flex:1; overflow:auto; }
779
- .btn { display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:var(--radius-sm); font-size:13px; font-weight:500; transition:all .12s; white-space:nowrap; }
780
- .btn-ghost { color:var(--fg-soft); } .btn-ghost:hover { background:var(--hover-2); color:var(--fg); }
781
- .btn-secondary { background:var(--bg); border:1px solid var(--line-strong); color:var(--fg); box-shadow:var(--shadow-sm); }
782
- .btn-secondary:hover { background:var(--surface-2); }
783
- .btn-primary { background:var(--accent); color:var(--accent-fg); box-shadow:var(--shadow-sm); }
784
- .btn-primary:hover { filter:brightness(1.07); }
785
- .btn-danger { background:var(--danger); color:#fff; } .btn-danger:hover { filter:brightness(1.05); }
649
+ -- CRDT oplog: one row per field assignment. Source of truth for sync.
650
+ CREATE TABLE IF NOT EXISTS crdt_changes (
651
+ hlc TEXT NOT NULL,
652
+ node_id TEXT NOT NULL,
653
+ dataset TEXT NOT NULL,
654
+ row_id TEXT NOT NULL,
655
+ col TEXT NOT NULL,
656
+ value TEXT,
657
+ PRIMARY KEY (dataset, row_id, col, hlc)
658
+ );
659
+ CREATE INDEX IF NOT EXISTS idx_changes_hlc ON crdt_changes(hlc);
786
660
 
787
- /* desktop app (frameless): drop the sidebar logo; on macOS reserve a draggable
788
- strip for the inset traffic lights so they never overlap our controls */
789
- body.desktop .brand { display:none; }
790
- /* logo gone \u2192 keep the collapse button on the right (where .brand's flex used to push it) */
791
- body.desktop .sb-head .iconbtn { margin-left:auto; }
792
- body.desktop-mac .sb-head { -webkit-app-region:drag; }
793
- body.desktop-mac .sb-head .iconbtn { -webkit-app-region:no-drag; }
794
- body.desktop-mac .topbar { -webkit-app-region:drag; }
795
- body.desktop-mac .topbar .iconbtn, body.desktop-mac .topbar .btn { -webkit-app-region:no-drag; }
796
- /* sidebar collapsed \u2192 traffic lights sit over the main header; clear the hamburger */
797
- body.desktop-mac .sidebar.collapsed ~ .main .topbar { padding-left:80px; }
661
+ -- Per-peer replication cursors (SQLite rowids: monotonic in insertion order,
662
+ -- so no change is ever skipped regardless of HLC/clock skew). Outbound side of
663
+ -- a pairing: "token" is the credential the *remote* issued to us (sent as a
664
+ -- Bearer header when we sync to them). See migratePeers in db.ts for the columns
665
+ -- added to legacy databases.
666
+ CREATE TABLE IF NOT EXISTS peers (
667
+ url TEXT PRIMARY KEY,
668
+ pull_cursor INTEGER NOT NULL DEFAULT 0,
669
+ push_cursor INTEGER NOT NULL DEFAULT 0,
670
+ token TEXT,
671
+ label TEXT,
672
+ node_id TEXT,
673
+ enabled INTEGER NOT NULL DEFAULT 1,
674
+ last_sync_at INTEGER,
675
+ last_status TEXT,
676
+ last_error TEXT
677
+ );
798
678
 
799
- /* document editor */
800
- .doc { max-width:740px; margin:0 auto; padding:60px 60px 36vh; }
801
- .doc-title { font-size:38px; font-weight:700; letter-spacing:-.02em; outline:none; line-height:1.15; margin-bottom:4px; }
802
- .doc-title:empty::before { content:"\u65E0\u6807\u9898"; color:var(--muted); }
803
- .doc-meta { display:flex; gap:14px; color:var(--muted); font-size:12.5px; margin-bottom:22px; }
804
- .doc-meta span { display:inline-flex; align-items:center; gap:5px; }
805
- .doc.source-mode { min-height:100%; }
806
- .doc-source { display:block; width:100%; min-height:calc(100vh - 210px); resize:none; border:0;
807
- background:transparent; color:var(--fg); outline:none; overflow:hidden; padding:3px 2px 36vh;
808
- font-family:var(--ui); font-size:15px; line-height:1.7; tab-size:2; }
809
- .block-wrap { position:relative; }
810
- .block-wrap.nested { padding-left:24px; }
811
- /* Subtle indent guide only for true sub-lists; suppressed for code/other
812
- nested content so code-in-list items don't stack a thicket of lines. */
813
- .block-wrap.nested:has(> .block.b-bullet, > .block.b-numbered, > .block.b-todo)::before {
814
- content:""; position:absolute; left:8px; top:2px; bottom:2px; width:1px; background:var(--line); }
815
- .block { position:relative; display:flex; align-items:flex-start; padding:1px 0; border-radius:4px; }
816
- .block .gutter { position:absolute; left:-52px; top:2px; display:flex; gap:1px; opacity:0; transition:opacity .12s; }
817
- .block:hover .gutter, .block.drop-before .gutter, .block.drop-after .gutter, .block.dragging .gutter { opacity:1; }
818
- .gutter button { width:22px; height:24px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
819
- .gutter button:hover { background:var(--hover-2); color:var(--fg); }
820
- .gutter .grip { cursor:grab; } .gutter .grip:active { cursor:grabbing; }
821
- .block.dragging { opacity:.42; background:var(--hover); }
822
- .block.drop-before::before, .block.drop-after::after { content:""; position:absolute; left:0; right:0; height:2px; background:var(--accent);
823
- border-radius:2px; pointer-events:none; box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 18%,transparent); }
824
- .block.drop-before::before { top:-1px; }
825
- .block.drop-after::after { bottom:-1px; }
826
- /* multi-block selection: whole-block tint + suppress stray native text highlight while dragging */
827
- .block.selected { background:var(--accent-soft); }
828
- .block.selected .editable::selection, .block.selected .editable *::selection { background:transparent; }
829
- .doc.selecting, .doc.selecting * { user-select:none; -webkit-user-select:none; cursor:default; }
830
- .block.list-code-host { height:0; min-height:0; padding:0; overflow:visible; }
831
- .block-wrap:hover > .block.list-code-host .gutter { opacity:1; }
832
- .block.list-code-host .marker { position:absolute; left:0; top:0; padding-top:4px; z-index:1; }
833
- /* The list item's host gutter controls the whole unit, so suppress the nested
834
- code block's own gutter (it would overlap the list marker on the same row). */
835
- .block.list-code-host + .block-wrap.nested .gutter { display:none; }
836
- .editable { outline:none; flex:1; min-width:0; padding:3px 2px; line-height:var(--note-lh); }
837
- .editable:empty::before { content:attr(data-ph); color:var(--muted); pointer-events:none; }
838
- /* The generic "/" prompt only shows on the focused line; idle blank lines (paragraphs and list items, any depth) stay blank. */
839
- .editable[data-ph-hint="slash"]:empty:not(:focus)::before { content:""; }
840
- .b-h1 .editable { font-size:28px; font-weight:700; letter-spacing:-.02em; padding-top:12px; }
841
- .b-h2 .editable { font-size:22px; font-weight:650; letter-spacing:-.01em; padding-top:8px; }
842
- .b-h3 .editable { font-size:18px; font-weight:600; padding-top:4px; }
843
- .b-h1 .gutter { top:22px; } .b-h2 .gutter { top:14px; } .b-h3 .gutter { top:7px; }
844
- .b-quote .editable { border-left:3px solid var(--line-strong); padding:1px 0 1px 16px; color:var(--fg-soft); font-style:italic; }
679
+ -- Inbound side of a pairing: credentials *we* issued to remote devices. A peer
680
+ -- presents one of these on /sync; acceptsSyncToken() checks it alongside the
681
+ -- managed master token. Revoke by deleting the row.
682
+ CREATE TABLE IF NOT EXISTS peer_grants (
683
+ token TEXT PRIMARY KEY,
684
+ peer_url TEXT,
685
+ node_id TEXT,
686
+ created_at INTEGER
687
+ );
845
688
 
846
- /* code block: transparent textarea over a highlight.js mirror */
847
- .b-code .codeblock { flex:1; min-width:0; }
848
- .codeblock { position:relative; --code-fs:12.5px; --code-lh:1.55; --code-pad:8px; margin:2px 0;
849
- background:var(--code-bg); border:1px solid var(--line); border-radius:8px; overflow:hidden; box-shadow:var(--shadow-sm); }
850
- /* lang + copy float bottom-right, revealed on hover */
851
- .code-tools { position:absolute; right:6px; bottom:6px; z-index:2; display:flex; align-items:center; gap:1px;
852
- padding:2px; border-radius:7px; border:1px solid var(--line); box-shadow:var(--shadow-sm);
853
- background:color-mix(in srgb,var(--code-bg) 82%,transparent); backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px);
854
- opacity:0; transform:translateY(3px); pointer-events:none; transition:opacity .12s,transform .12s; }
855
- .codeblock:hover .code-tools, .code-tools:focus-within { opacity:1; transform:none; pointer-events:auto; }
856
- .code-lang { position:relative; display:inline-flex; align-items:center; }
857
- .code-lang select { appearance:none; -webkit-appearance:none; border:0; outline:none; background:none; cursor:pointer;
858
- font-family:var(--mono); font-size:10.5px; font-weight:500; color:var(--fg-soft); padding:3px 17px 3px 7px; border-radius:5px; }
859
- .code-lang select:hover { background:var(--hover-2); }
860
- .code-lang svg { position:absolute; right:3px; width:12px; height:12px; pointer-events:none; color:var(--muted); }
861
- .code-copy { display:inline-flex; align-items:center; gap:4px; font-size:10.5px; font-weight:500; color:var(--muted);
862
- padding:3px 7px; border-radius:5px; transition:background .12s,color .12s; }
863
- .code-copy:hover { background:var(--hover-2); color:var(--fg-soft); }
864
- .code-copy.ok { color:var(--accent); }
865
- .code-body { display:flex; align-items:stretch; font-family:var(--mono); font-size:var(--code-fs); line-height:var(--code-lh); }
866
- .code-gutter { flex:none; text-align:right; white-space:pre; user-select:none; color:var(--muted); opacity:.55;
867
- padding:var(--code-pad) 8px var(--code-pad) 12px; border-right:1px solid var(--line); font-variant-numeric:tabular-nums; }
868
- .code-scroll { position:relative; flex:1; min-width:0; }
869
- .code-hl, .code-input { margin:0; font-family:var(--mono); font-size:var(--code-fs); line-height:var(--code-lh);
870
- padding:var(--code-pad) 16px; tab-size:2; -moz-tab-size:2; white-space:pre; letter-spacing:0; }
871
- .code-hl { position:absolute; inset:0; z-index:0; overflow:hidden; pointer-events:none; background:none; color:var(--fg); }
872
- .code-hl code { display:block; font:inherit; background:none; padding:0; }
873
- .code-input { display:block; position:relative; z-index:1; width:100%; border:0; outline:none; resize:none;
874
- overflow-x:auto; overflow-y:hidden; background:transparent; color:transparent; caret-color:var(--fg);
875
- min-height:calc(var(--code-fs) * var(--code-lh) + var(--code-pad) * 2); }
876
- .code-input::placeholder { color:var(--muted); }
689
+ -- One-time pairing codes minted by "mh config peer code" / POST /api/pair/new.
690
+ -- Short-lived and single-use: redeemed during the pairing handshake, then the
691
+ -- two devices exchange durable peer_grants. used/exp gate redemption.
692
+ CREATE TABLE IF NOT EXISTS pairing_codes (
693
+ code TEXT PRIMARY KEY,
694
+ exp INTEGER NOT NULL,
695
+ used INTEGER NOT NULL DEFAULT 0,
696
+ created_at INTEGER
697
+ );
877
698
 
878
- /* highlight.js token theme (cohesive with the app palette) */
879
- .hljs-comment, .hljs-quote { color:var(--hl-cmt); font-style:italic; }
880
- .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-doctag { color:var(--hl-key); }
881
- .hljs-string, .hljs-regexp, .hljs-addition, .hljs-meta .hljs-string { color:var(--hl-str); }
882
- .hljs-number, .hljs-bullet { color:var(--hl-num); }
883
- .hljs-title, .hljs-title.function_, .hljs-name, .hljs-selector-id, .hljs-selector-class { color:var(--hl-fn); }
884
- .hljs-type, .hljs-class .hljs-title, .hljs-built_in, .hljs-attr, .hljs-attribute, .hljs-property { color:var(--hl-type); }
885
- .hljs-variable, .hljs-template-variable, .hljs-symbol, .hljs-tag, .hljs-meta, .hljs-link { color:var(--hl-var); }
886
- .hljs-deletion { color:var(--danger); }
887
- .hljs-params, .hljs-subst { color:var(--fg); }
888
- .hljs-emphasis { font-style:italic; }
889
- .hljs-strong { font-weight:700; }
890
- .marker { width:24px; flex:none; text-align:center; color:var(--fg-soft); user-select:none; padding-top:4px; }
891
- .b-todo .marker { box-sizing:content-box; padding-top:3px; height:calc(1em * var(--note-lh));
892
- display:flex; align-items:center; justify-content:center; }
893
- .b-todo .marker input { width:15px; height:15px; accent-color:var(--accent); cursor:pointer; }
894
- .b-divider { padding:10px 0; }
895
- .b-divider hr { border:0; border-top:1px solid var(--line-strong); margin:0; width:100%; }
896
- .b-done .editable { color:var(--muted); text-decoration:line-through; }
897
- .editable b, .editable strong { font-weight:700; }
898
- .editable code { font-family:var(--mono); background:var(--surface-2); padding:1px 5px; border-radius:4px; font-size:.9em; }
899
- .editable a { color:var(--accent); }
699
+ CREATE TABLE IF NOT EXISTS databases (
700
+ id TEXT PRIMARY KEY,
701
+ name TEXT,
702
+ icon TEXT,
703
+ created_hlc TEXT,
704
+ __deleted INTEGER NOT NULL DEFAULT 0
705
+ );
900
706
 
901
- /* document table (GFM pipe table block) */
902
- .b-table .doc-table-wrap { flex:1; min-width:0; margin:4px 0; }
903
- .doc-table-scroll { overflow-x:auto; padding:2px 0; }
904
- .doc-table-inner { width:max-content; }
905
- .doc-table-row { display:flex; align-items:stretch; }
906
- table.doc-table { border-collapse:separate; border-spacing:0; table-layout:fixed; width:max-content;
907
- border:1px solid var(--line); border-radius:var(--radius-sm); overflow:hidden; box-shadow:var(--shadow-sm); }
908
- table.doc-table td { position:relative; vertical-align:top; padding:0;
909
- border-right:1px solid var(--line); border-bottom:1px solid var(--line); }
910
- table.doc-table tr td:last-child { border-right:0; }
911
- table.doc-table tr:last-child td { border-bottom:0; }
912
- table.doc-table .doc-th { background:var(--surface); }
913
- .doc-td { outline:none; padding:7px 22px 7px 10px; min-height:20px; line-height:1.55; word-break:break-word; white-space:pre-wrap; }
914
- .doc-th .doc-td { font-weight:650; color:var(--fg); }
915
- .doc-td:empty::before { content:attr(data-ph); color:var(--muted); pointer-events:none; }
916
- .doc-td b, .doc-td strong { font-weight:700; }
917
- .doc-td code { font-family:var(--mono); background:var(--surface-2); padding:1px 5px; border-radius:4px; font-size:.9em; }
918
- .doc-td a { color:var(--accent); }
919
- /* header cell: column menu + resize handle */
920
- .doc-col-menu { position:absolute; top:4px; right:4px; width:18px; height:18px; display:grid; place-items:center;
921
- color:var(--muted); border-radius:4px; opacity:0; transition:opacity .12s,background .12s; }
922
- .doc-th:hover .doc-col-menu { opacity:1; }
923
- .doc-col-menu:hover { background:var(--hover-2); color:var(--fg); }
924
- .doc-col-resizer { position:absolute; top:0; right:-3px; width:7px; height:100%; cursor:col-resize; z-index:3; touch-action:none; }
925
- .doc-col-resizer:hover, .doc-col-resizer.dragging { background:var(--accent); opacity:.35; }
926
- /* per-row delete: small handle floating at the first cell on row hover */
927
- .doc-row-del { position:absolute; top:3px; left:2px; width:18px; height:18px; display:grid; place-items:center;
928
- color:var(--muted); background:var(--bg); border:1px solid var(--line); border-radius:5px; box-shadow:var(--shadow-sm);
929
- opacity:0; transition:opacity .12s; }
930
- table.doc-table tr:hover .doc-row-del { opacity:1; }
931
- .doc-row-del:hover { background:var(--danger-soft); color:var(--danger); border-color:var(--danger-soft); }
932
- /* add-column (right edge) and add-row (bottom edge) buttons */
933
- .doc-table-addcol { width:22px; margin-left:4px; flex:none; display:grid; place-items:center; color:var(--muted);
934
- border:1px dashed var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); transition:background .12s,color .12s,border-color .12s; }
935
- .doc-table-addrow { width:100%; height:22px; margin-top:4px; display:grid; place-items:center; color:var(--muted);
936
- border:1px dashed var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); transition:background .12s,color .12s,border-color .12s; }
937
- .doc-table-addcol:hover, .doc-table-addrow:hover { background:var(--accent-soft); color:var(--accent); border-color:var(--accent); }
707
+ CREATE TABLE IF NOT EXISTS properties (
708
+ id TEXT PRIMARY KEY,
709
+ database_id TEXT,
710
+ name TEXT,
711
+ type TEXT,
712
+ config TEXT,
713
+ position REAL,
714
+ __deleted INTEGER NOT NULL DEFAULT 0
715
+ );
716
+ CREATE INDEX IF NOT EXISTS idx_properties_db ON properties(database_id);
938
717
 
939
- /* database / table */
940
- .db { padding:22px 36px 90px; min-width:0; }
941
- .db-head { display:flex; align-items:center; gap:12px; }
942
- .db-icon { width:38px; height:38px; border-radius:9px; background:var(--surface-2); display:grid; place-items:center; font-size:20px; }
943
- .db-title { font-size:27px; font-weight:700; letter-spacing:-.02em; outline:none; }
944
- .db-desc { color:var(--muted); font-size:13px; margin-top:2px; }
945
- .views { display:flex; align-items:center; gap:2px; border-bottom:1px solid var(--line); margin:16px 0 0; }
946
- .view-tab { display:flex; align-items:center; gap:6px; padding:8px 11px; font-size:13.5px; font-weight:500; color:var(--muted);
947
- border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .12s; }
948
- .view-tab.active { color:var(--fg); border-bottom-color:var(--accent); }
949
- .view-tab:hover { color:var(--fg); } .view-tab svg { width:14px; height:14px; }
950
- .toolbar { display:flex; align-items:center; gap:2px; padding:10px 0; }
951
- .toolbar .spacer { flex:1; }
952
- .tbtn { display:flex; align-items:center; gap:6px; padding:5px 9px; border-radius:var(--radius-sm); color:var(--fg-soft); font-size:13px; font-weight:500; }
953
- .tbtn:hover { background:var(--hover-2); color:var(--fg); }
954
- .tbtn.on { color:var(--accent); }
955
- .tablewrap { border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; box-shadow:var(--shadow-sm); }
956
- .tablescroll { overflow-x:auto; }
957
- /* fixed layout + max-content: each <col> width is authoritative, so the table
958
- is exactly the sum of its columns and resizing one column never redistributes
959
- width to its neighbours. min-width:100% fills the container when columns are
960
- narrow, with the slack absorbed only by the trailing auto (filler) column. */
961
- table.grid { border-collapse:collapse; table-layout:fixed; width:max-content; min-width:100%; }
962
- table.grid th, table.grid td { border-right:1px solid var(--line); border-bottom:1px solid var(--line); padding:0; text-align:left; }
963
- table.grid th:last-child, table.grid td:last-child { border-right:0; }
964
- table.grid tbody tr:last-child td { border-bottom:0; }
965
- thead th { background:var(--surface); position:relative; user-select:none; font-weight:600; color:var(--fg-soft); }
966
- .colhead { display:flex; align-items:center; gap:7px; padding:9px 11px; font-size:13px; cursor:pointer; }
967
- .colhead:hover { background:var(--hover); }
968
- .colhead .ti { color:var(--muted); } .colhead .ti svg { width:14px; height:14px; }
969
- .colhead .nm { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
970
- .col-resizer { position:absolute; top:0; right:-3px; width:7px; height:100%; cursor:col-resize; z-index:2; touch-action:none; }
971
- .col-resizer:hover, .col-resizer.dragging { background:var(--accent); opacity:.35; }
972
- .addcol { min-width:46px; } .addcol .colhead { justify-content:flex-start; color:var(--muted); }
973
- /* trailing filler column: empty canvas to the right of the data, not a real cell */
974
- table.grid td.filler { border-right:0; border-bottom:0; background:transparent; }
975
- body.col-resizing { user-select:none; cursor:col-resize; }
976
- table.grid .selcell { width:38px; min-width:38px; max-width:38px; text-align:center; }
977
- table.grid .gripcol, table.grid .rowgrip { width:26px; min-width:26px; max-width:26px; text-align:center; }
978
- td.cell-td { vertical-align:top; }
979
- .cell { padding:8px 11px; min-height:37px; cursor:text; word-break:break-word; display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
980
- /* hover/active tint goes on the <td> (always full row height) not the inner
981
- .cell (only as tall as its content), so a taller sibling row never leaves a
982
- gap of un-tinted space at the bottom of the hovered cell. */
983
- td.cell-td { transition:background .1s; }
984
- td.cell-td:hover { background:var(--hover); } .cell.center { justify-content:center; }
985
- tbody tr { transition:background .08s; }
986
- tbody tr:hover { background:var(--hover); }
987
- tbody tr.sel { background:var(--accent-soft); }
988
- /* cell range selection: per-cell fill; the accent rectangle outline is drawn via inline box-shadow on edge cells. Placed after :hover so it wins on equal specificity. */
989
- td.cell-td.cellsel, td.cell-td.cellsel:hover { background:var(--accent-soft); }
990
- body.cell-selecting { user-select:none; cursor:cell; }
991
- body.cell-selecting .cell { cursor:cell; }
992
- table.grid tr.drop-before td { box-shadow:inset 0 2px 0 0 var(--accent); }
993
- table.grid tr.drop-after td { box-shadow:inset 0 -2px 0 0 var(--accent); }
994
- table.grid th.drop-before { box-shadow:inset 2px 0 0 0 var(--accent); }
995
- table.grid th.drop-after { box-shadow:inset -2px 0 0 0 var(--accent); }
996
- .rowgrip { color:transparent; cursor:grab; vertical-align:middle; touch-action:none; }
997
- .rowgrip[aria-disabled="true"] { cursor:not-allowed; }
998
- tbody tr:hover .rowgrip { color:var(--muted); }
999
- .colhead { touch-action:none; }
1000
- .drag-source { opacity:.35; }
1001
- body.table-dragging { user-select:none; cursor:grabbing; }
1002
- .drag-ghost { position:fixed; left:0; top:0; z-index:120; pointer-events:none; display:flex; align-items:center;
1003
- padding:7px 11px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; border:1px solid var(--line-strong);
1004
- border-radius:7px; background:var(--bg); color:var(--fg); box-shadow:var(--shadow-lg); opacity:.94; font-size:13px; }
1005
- .row-ghost { min-height:34px; }
1006
- .col-ghost { justify-content:center; font-weight:600; color:var(--fg-soft); background:var(--surface); }
1007
- .selcell { vertical-align:middle; }
1008
- .selcell input { width:15px; height:15px; accent-color:var(--accent); opacity:0; cursor:pointer; vertical-align:middle; }
1009
- tbody tr:hover .selcell input, tbody tr.sel .selcell input, .selcell input:checked, thead .selcell input { opacity:1; }
1010
- .firstcell { position:relative; width:100%; min-width:0; }
1011
- /* row actions float over the cell on hover, never reserving column width */
1012
- /* right:-5px pulls the button out over the cell's 11px right padding so it
1013
- sits ~6px from the column border instead of ~15px inside it */
1014
- .rowactions { position:absolute; top:0; bottom:0; right:-5px; display:flex; align-items:flex-start;
1015
- padding:1px 0 0; opacity:0; pointer-events:none; }
1016
- tbody tr:hover .rowactions { opacity:1; pointer-events:auto; }
1017
- /* square icon button \u2014 "open as page" (Notion-style) */
1018
- .rowopen { width:23px; height:23px; flex:none; display:inline-flex; align-items:center; justify-content:center;
1019
- border:1px solid var(--line); border-radius:6px; background:var(--bg); color:var(--fg-soft); box-shadow:var(--shadow-sm); }
1020
- .rowopen:hover { background:var(--surface-2); color:var(--fg); border-color:var(--line-strong); }
1021
- .rowopen svg { width:14px; height:14px; }
1022
- .chip { display:inline-flex; align-items:center; gap:4px; border-radius:5px; padding:2px 8px; font-size:12.5px; font-weight:500; white-space:nowrap;
1023
- background:color-mix(in srgb,var(--c) 15%,transparent); color:var(--c); }
1024
- @media (prefers-color-scheme: dark) { .chip { background:color-mix(in srgb,var(--c) 22%,transparent); color:color-mix(in srgb,var(--c) 75%,#fff); } }
1025
- .addrow { display:flex; align-items:center; gap:7px; padding:9px 12px; color:var(--muted); font-size:13px; cursor:pointer; border-top:1px solid var(--line); }
1026
- .addrow:hover { background:var(--hover); color:var(--fg-soft); }
1027
- .gridfoot { display:flex; gap:18px; color:var(--muted); font-size:12px; padding:8px 2px; }
1028
- input.inlineedit { width:100%; border:0; outline:2px solid var(--accent); border-radius:4px; padding:7px 9px; background:var(--bg); color:var(--fg); font:inherit; }
718
+ -- One row per record. Field cells live in the data JSON object, keyed by
719
+ -- property id (rename-safe). Each (record,property) is still an independent
720
+ -- CRDT register in the oplog; materialize() folds the winner into data via
721
+ -- json_set/json_remove. Hot fields get expression indexes on data->>'propid'
722
+ -- (see indexing.ts) so filter/sort/limit push down to SQL.
723
+ CREATE TABLE IF NOT EXISTS records (
724
+ id TEXT PRIMARY KEY,
725
+ database_id TEXT,
726
+ created_hlc TEXT,
727
+ order_key TEXT,
728
+ data TEXT NOT NULL DEFAULT '{}',
729
+ __deleted INTEGER NOT NULL DEFAULT 0
730
+ );
731
+ CREATE INDEX IF NOT EXISTS idx_records_db_hlc ON records(database_id, created_hlc);
1029
732
 
1030
- .selbar { position:fixed; bottom:24px; left:50%; transform:translateX(-50%); z-index:65; display:flex; align-items:center; gap:4px;
1031
- background:var(--fg); color:var(--bg); padding:7px 8px 7px 14px; border-radius:11px; box-shadow:var(--shadow-lg); }
1032
- .selbar .cnt { font-size:13px; font-weight:600; margin-right:6px; }
1033
- .selbar button { display:flex; align-items:center; gap:6px; color:var(--bg); padding:6px 10px; border-radius:7px; font-size:13px; font-weight:500; }
1034
- .selbar button:hover { background:rgba(127,127,127,.25); }
1035
- .selbar .del:hover { background:var(--danger); color:#fff; }
733
+ CREATE TABLE IF NOT EXISTS documents (
734
+ id TEXT PRIMARY KEY,
735
+ title TEXT,
736
+ body TEXT, -- materialized cache: serialized from doc_blocks
737
+ database_id TEXT,
738
+ parent_id TEXT,
739
+ created_hlc TEXT,
740
+ order_key TEXT, -- fractional index among siblings (same parent_id); ties broken by created_hlc,id
741
+ __deleted INTEGER NOT NULL DEFAULT 0
742
+ );
1036
743
 
1037
- /* record peek */
1038
- .scrim { position:fixed; inset:0; background:rgba(15,15,25,.32); z-index:70; opacity:0; pointer-events:none; transition:opacity .22s; }
1039
- .scrim.open { opacity:1; pointer-events:auto; }
1040
- .peek { position:fixed; top:0; right:0; width:580px; max-width:94vw; height:100%; background:var(--bg); border-left:1px solid var(--line);
1041
- box-shadow:var(--shadow-lg); z-index:71; transform:translateX(100%); transition:transform .24s cubic-bezier(.32,.72,0,1); display:flex; flex-direction:column; }
1042
- .peek.open { transform:none; }
1043
- .peek-head { display:flex; align-items:center; gap:4px; padding:12px 16px; border-bottom:1px solid var(--line); }
1044
- .peek-body { overflow:auto; padding:28px 32px; }
1045
- .peek h2 { font-size:27px; font-weight:700; letter-spacing:-.02em; margin:0 0 20px; outline:none; }
1046
- .proprow { display:flex; gap:8px; align-items:flex-start; margin:1px 0; }
1047
- .proprow .k { width:160px; flex:none; color:var(--muted); display:flex; align-items:center; gap:7px; font-size:13px; padding:7px 8px; border-radius:var(--radius-sm); }
1048
- .proprow .k:hover { background:var(--hover); }
1049
- .proprow .v { flex:1; padding:7px 8px; border-radius:var(--radius-sm); cursor:text; min-height:33px; display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
1050
- .proprow .v:hover { background:var(--hover); }
1051
- .peek-divider { height:1px; background:var(--line); margin:22px 0 18px; }
744
+ -- Document body as an ordered list of blocks; each (block,field) is an
745
+ -- independent CRDT register so concurrent edits to different blocks merge.
746
+ -- order_key is a fractional index (ties broken by id) giving display order.
747
+ CREATE TABLE IF NOT EXISTS doc_blocks (
748
+ id TEXT PRIMARY KEY,
749
+ doc_id TEXT,
750
+ text TEXT,
751
+ order_key TEXT,
752
+ blank_after INTEGER NOT NULL DEFAULT 0, -- extra blank lines kept after this block (user spacing)
753
+ __deleted INTEGER NOT NULL DEFAULT 0
754
+ );
755
+ CREATE INDEX IF NOT EXISTS idx_doc_blocks_doc ON doc_blocks(doc_id);
1052
756
 
1053
- /* menu / popover */
1054
- .menu-layer { position:fixed; inset:0; z-index:90; }
1055
- .pop { position:fixed; background:var(--bg); border:1px solid var(--line); border-radius:11px; box-shadow:var(--shadow-lg);
1056
- padding:6px; min-width:232px; max-height:70vh; overflow:auto; animation:pop .12s ease; }
1057
- @keyframes pop { from { opacity:0; transform:translateY(-4px); } }
1058
- .pop .lbl { font-size:11px; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; padding:6px 8px 3px; }
1059
- .pop .item { display:flex; align-items:center; gap:10px; padding:7px 8px; border-radius:var(--radius-sm); width:100%; text-align:left; color:var(--fg); font-size:13.5px; }
1060
- .pop .item:hover, .pop .item.sel { background:var(--hover-2); }
1061
- .pop .item .lico { width:28px; height:28px; display:grid; place-items:center; border:1px solid var(--line); border-radius:6px; color:var(--fg-soft); flex:none; }
1062
- .pop .item .lico.plain { border:0; width:22px; height:22px; }
1063
- .pop .item .meta { flex:1; min-width:0; }
1064
- .pop .item .t { font-size:13.5px; } .pop .item .d { font-size:11.5px; color:var(--muted); display:block; }
1065
- .pop .item .chk { color:var(--accent); margin-left:auto; }
1066
- .pop .item.danger { color:var(--danger); } .pop .item.danger .lico { color:var(--danger); }
1067
- .pop .sep { height:1px; background:var(--line); margin:5px 4px; }
1068
- .pop .typegrid { display:grid; grid-template-columns:repeat(4,1fr); gap:4px; padding:2px 2px 4px; }
1069
- .pop .typegrid .item { flex-direction:column; gap:5px; padding:9px 4px; text-align:center; }
1070
- .pop .typegrid .item .lico { margin:0 auto; }
1071
- .pop input.field { width:100%; border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:7px 9px; outline:none; background:var(--surface); margin:2px; }
1072
- .pop input.field:focus { border-color:var(--accent); }
1073
- .optrow { display:flex; align-items:center; gap:6px; padding:3px 4px; }
1074
- .optrow .chip { flex:1; }
1075
- .optrow .x { width:22px; height:22px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
1076
- .optrow .x:hover { background:var(--hover-2); color:var(--danger); }
757
+ -- A named static site (Supabase-Storage-style bucket). Served at /sites/<name>/.
758
+ CREATE TABLE IF NOT EXISTS sites (
759
+ id TEXT PRIMARY KEY,
760
+ name TEXT, -- URL slug; resolved by getSiteByName
761
+ title TEXT,
762
+ created_hlc TEXT,
763
+ __deleted INTEGER NOT NULL DEFAULT 0
764
+ );
1077
765
 
1078
- /* modal */
1079
- .modal-scrim { position:fixed; inset:0; background:rgba(15,15,25,.4); z-index:100; display:flex; align-items:center; justify-content:center;
1080
- opacity:0; pointer-events:none; transition:opacity .15s; padding:20px; }
1081
- .modal-scrim.open { opacity:1; pointer-events:auto; }
1082
- .modal { background:var(--bg); border-radius:16px; box-shadow:var(--shadow-lg); width:440px; max-width:100%; max-height:100%; display:flex; flex-direction:column; overflow:hidden; }
1083
- .modal-head { padding:20px 22px 0; }
1084
- .modal-head h3 { margin:0; font-size:18px; font-weight:700; letter-spacing:-.01em; }
1085
- .modal-head p { margin:6px 0 0; color:var(--muted); font-size:13.5px; }
1086
- .modal-body { padding:18px 22px; overflow-y:auto; }
1087
- .modal-foot { display:flex; justify-content:flex-end; gap:8px; padding:14px 22px; background:var(--surface); border-top:1px solid var(--line); }
1088
- .field-label { font-size:12px; font-weight:600; color:var(--fg-soft); margin:12px 0 5px; }
1089
- .field-label:first-child { margin-top:0; }
1090
- .text-input { width:100%; border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:9px 11px; outline:none; background:var(--surface); font-size:14px; }
1091
- .text-input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); }
1092
- .icon-pick { display:flex; gap:6px; flex-wrap:wrap; }
1093
- .icon-pick button { width:38px; height:38px; border-radius:9px; border:1px solid var(--line); font-size:19px; display:grid; place-items:center; }
1094
- .icon-pick button:hover { background:var(--hover-2); }
1095
- .icon-pick button.sel { border-color:var(--accent); background:var(--accent-soft); }
1096
- .tmpl { display:flex; gap:8px; }
1097
- .tmpl button { flex:1; border:1px solid var(--line); border-radius:var(--radius); padding:12px; text-align:left; }
1098
- .tmpl button:hover { border-color:var(--accent); background:var(--surface); }
1099
- .tmpl button.sel { border-color:var(--accent); background:var(--accent-soft); }
1100
- .tmpl .tt { font-weight:600; font-size:13.5px; } .tmpl .td { font-size:11.5px; color:var(--muted); margin-top:2px; }
766
+ -- One row per file in a site. content holds inline utf8/base64 text, or a blob
767
+ -- hash (see cache.ts) when encoding = 'blob'. (site_id, path) maps to a stable
768
+ -- id so re-uploads merge as a CRDT register instead of duplicating.
769
+ CREATE TABLE IF NOT EXISTS site_files (
770
+ id TEXT PRIMARY KEY,
771
+ site_id TEXT,
772
+ path TEXT,
773
+ content_type TEXT,
774
+ encoding TEXT, -- 'utf8' | 'base64' | 'blob'
775
+ content TEXT,
776
+ created_hlc TEXT,
777
+ __deleted INTEGER NOT NULL DEFAULT 0
778
+ );
779
+ CREATE INDEX IF NOT EXISTS idx_site_files_site ON site_files(site_id);
780
+ `;
781
+ var FTS_SCHEMA = `
782
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
783
+ kind UNINDEXED,
784
+ id UNINDEXED,
785
+ database_id UNINDEXED,
786
+ title,
787
+ body
788
+ );
789
+ `;
1101
790
 
1102
- /* toast */
1103
- .toasts { position:fixed; bottom:20px; left:20px; z-index:110; display:flex; flex-direction:column; gap:8px; }
1104
- .toast { background:var(--fg); color:var(--bg); padding:10px 14px; border-radius:9px; font-size:13px; box-shadow:var(--shadow-lg);
1105
- display:flex; align-items:center; gap:8px; animation:slidein .2s ease; }
1106
- @keyframes slidein { from { opacity:0; transform:translateX(-12px); } }
791
+ // src/core/records.ts
792
+ init_ids();
793
+ init_crdt();
1107
794
 
1108
- /* misc */
1109
- .empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;
1110
- color:var(--muted); text-align:center; padding:40px; }
795
+ // src/core/databases.ts
796
+ init_ids();
797
+ init_crdt();
798
+ init_errors();
799
+ function createDatabase(db, opts) {
800
+ const id = newId("db", opts.name);
801
+ const first = emit(db, "databases", id, "name", opts.name);
802
+ emit(db, "databases", id, "created_hlc", first.hlc);
803
+ if (opts.icon !== undefined)
804
+ emit(db, "databases", id, "icon", opts.icon);
805
+ return getDatabase(db, id);
806
+ }
807
+ function updateDatabase(db, id, fields) {
808
+ if (!getDatabase(db, id))
809
+ throw new MhError("not_found", `no such database: ${id}`);
810
+ if (fields.name !== undefined)
811
+ emit(db, "databases", id, "name", fields.name);
812
+ if (fields.icon !== undefined)
813
+ emit(db, "databases", id, "icon", fields.icon);
814
+ return getDatabase(db, id);
815
+ }
816
+ function getDatabase(db, id) {
817
+ return db.query("SELECT id, name, icon, created_hlc FROM databases WHERE id = ? AND __deleted = 0").get(id);
818
+ }
819
+ function listDatabases(db) {
820
+ return db.query("SELECT id, name, icon, created_hlc FROM databases WHERE __deleted = 0 ORDER BY created_hlc").all();
821
+ }
822
+ function deleteDatabase(db, id) {
823
+ if (!getDatabase(db, id))
824
+ return false;
825
+ emit(db, "databases", id, "__deleted", 1);
826
+ const tombstone = (table) => {
827
+ for (const r of liveChildren(db, table, "database_id", id))
828
+ emit(db, table, r, "__deleted", 1);
829
+ };
830
+ tombstone("properties");
831
+ tombstone("records");
832
+ for (const d of liveChildren(db, "documents", "database_id", id))
833
+ emit(db, "documents", d, "database_id", null);
834
+ db.query("DELETE FROM meta WHERE key = 'current_db' AND value = ?").run(id);
835
+ return true;
836
+ }
837
+ function liveChildren(db, table, col, parentId) {
838
+ return db.query(`SELECT id FROM ${table} WHERE ${col} = ? AND __deleted = 0`).all(parentId).map((r) => r.id);
839
+ }
1111
840
 
1112
- /* ---- empty state: \u77E5\u8BC6\u751F\u957F\u63D2\u753B ---- */
1113
- .estate-art { width:240px; height:208px; overflow:visible; animation:idleFloat 5s ease-in-out 1.6s infinite; }
1114
- .estate-art .stroke-a { fill:none; stroke:var(--accent); stroke-width:2.4; stroke-linecap:round; stroke-linejoin:round; }
1115
- .estate-art .stroke { fill:none; stroke:var(--line-strong); stroke-width:2.4; stroke-linecap:round; stroke-linejoin:round; }
1116
- .estate-art .fill-a { fill:var(--accent); }
1117
- .estate-art .fill-soft { fill:var(--accent-soft); }
1118
- .estate-art .fill-surface { fill:var(--bg); }
1119
- .estate-art .ground { fill:var(--accent); }
1120
- .estate-title { margin:28px 0 0; font-size:18px; font-weight:600; letter-spacing:.2px; color:var(--fg);
1121
- opacity:0; animation:fadeUp .5s ease forwards 1.0s; }
1122
- .estate-hint { margin:9px 0 0; font-size:13.5px; line-height:1.6; color:var(--muted); max-width:300px;
1123
- opacity:0; animation:fadeUp .5s ease forwards 1.15s; }
1124
- .estate-link { margin:18px 0 0; font-size:14px; font-weight:600; color:var(--accent);
1125
- display:inline-flex; align-items:center; gap:6px; padding:9px 8px; border-radius:var(--radius-sm);
1126
- opacity:0; animation:fadeUp .5s ease forwards 1.3s; transition:opacity .15s; }
1127
- .estate-link:hover { opacity:.7; }
1128
- /* intro */
1129
- .draw { stroke-dasharray:100; stroke-dashoffset:100; }
1130
- @keyframes draw { to { stroke-dashoffset:0; } }
1131
- @keyframes leafGrow { from { opacity:0; transform:scale(.2) rotate(-10deg); } to { opacity:1; transform:scale(1) rotate(0); } }
1132
- @keyframes cardUp { from { opacity:0; transform:translateY(16px) scale(.96); } to { opacity:1; transform:translateY(0) scale(1); } }
1133
- @keyframes groundIn { to { opacity:.1; } }
1134
- @keyframes glowIn { to { opacity:.6; } }
1135
- @keyframes fadeUp { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
1136
- /* idle loops \u2014 each 0% equals the settled rest state to avoid a jump */
1137
- @keyframes idleFloat { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-5px); } }
1138
- @keyframes plantSway { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(-1.3deg); } 75% { transform:rotate(1.3deg); } }
1139
- @keyframes leafSwayA { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(-2.6deg); } 75% { transform:rotate(2.6deg); } }
1140
- @keyframes leafSwayB { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(2.6deg); } 75% { transform:rotate(-2.6deg); } }
1141
- @keyframes groundBreathe { 0%,100% { transform:scale(1); opacity:.10; } 50% { transform:scale(1.07); opacity:.15; } }
1142
- @keyframes glowPulse { 0%,100% { transform:scale(1); opacity:.6; } 50% { transform:scale(1.1); opacity:.85; } }
1143
- @keyframes particle { 0% { opacity:0; transform:translate(0,0) scale(.4); } 35% { opacity:.85; transform:translate(2px,-4px) scale(1); } 70% { opacity:0; transform:translate(4px,-9px) scale(.5); } 100% { opacity:0; transform:translate(0,0) scale(.4); } }
1144
- /* wiring */
1145
- .kg-ground { transform-box:fill-box; transform-origin:center; opacity:0;
1146
- animation:groundIn .5s ease forwards .1s, groundBreathe 6s ease-in-out 1.6s infinite; }
1147
- .kg-glow { filter:blur(9px); transform-box:fill-box; transform-origin:center; opacity:0;
1148
- animation:glowIn .6s ease forwards .1s, glowPulse 4.6s ease-in-out 1.6s infinite; }
1149
- .kg-cardB { transform-box:fill-box; transform-origin:center; opacity:0; animation:cardUp .55s cubic-bezier(.2,.8,.2,1) forwards .2s; }
1150
- .kg-cardA { transform-box:fill-box; transform-origin:center; opacity:0; animation:cardUp .55s cubic-bezier(.2,.8,.2,1) forwards .32s; }
1151
- .kg-plant { transform-box:fill-box; transform-origin:50% 100%; animation:plantSway 6s ease-in-out 1.7s infinite; }
1152
- .kg-stem { animation:draw .9s ease-out forwards .4s; }
1153
- .kg-leaf { transform-box:fill-box; }
1154
- .kg-grow { transform-box:fill-box; opacity:0; }
1155
- .kg-leaf.l1 { transform-origin:90% 100%; animation:leafSwayA 4.6s ease-in-out 1.7s infinite; }
1156
- .kg-leaf.l2 { transform-origin:0% 80%; animation:leafSwayB 5.2s ease-in-out 1.8s infinite; }
1157
- .kg-leaf.l3 { transform-origin:0% 100%; animation:leafSwayA 5.0s ease-in-out 1.9s infinite; }
1158
- .kg-leaf.l1 .kg-grow { transform-origin:90% 100%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards .85s; }
1159
- .kg-leaf.l2 .kg-grow { transform-origin:0% 80%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards 1.0s; }
1160
- .kg-leaf.l3 .kg-grow { transform-origin:0% 100%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards 1.15s; }
1161
- .kg-particle { transform-box:fill-box; transform-origin:center; opacity:0; }
1162
- .kg-particle.p1 { animation:particle 4.5s ease-in-out 1.6s infinite; }
1163
- .kg-particle.p2 { animation:particle 5.2s ease-in-out 2.0s infinite; }
1164
- .kg-particle.p3 { animation:particle 4.8s ease-in-out 2.6s infinite; }
1165
- .kg-particle.p4 { animation:particle 5.6s ease-in-out 3.1s infinite; }
1166
- .kg-particle.p5 { animation:particle 4.2s ease-in-out 2.3s infinite; }
1167
- @media (prefers-reduced-motion: reduce) {
1168
- .estate-art, .estate-art *, .estate-title, .estate-hint, .estate-link { animation:none !important; opacity:1 !important; transform:none !important; }
1169
- .draw { stroke-dashoffset:0 !important; }
1170
- .kg-ground { opacity:.1 !important; }
1171
- .kg-glow { opacity:.6 !important; }
1172
- .kg-particle { opacity:0 !important; }
1173
- }
1174
- .error-bar { margin:12px 24px; padding:10px 14px; background:var(--danger-soft); color:var(--danger); border-radius:var(--radius-sm); font-size:13px; cursor:pointer; }
1175
- .view-placeholder { padding:40px; text-align:center; border:1px dashed var(--line-strong); border-radius:var(--radius); color:var(--muted); margin-top:8px; }
1176
- .ico.flip { transform:scaleX(-1); }
1177
-
1178
- /* ---- board (kanban) view ---- */
1179
- .board-wrap { margin-top:4px; }
1180
- .board-bar { display:flex; align-items:center; gap:10px; padding:2px 0 12px; }
1181
- .board { display:flex; align-items:flex-start; gap:12px; overflow-x:auto; padding-bottom:14px; min-height:240px; }
1182
- .board-col { flex:0 0 272px; width:272px; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); display:flex; flex-direction:column; max-height:calc(100vh - 240px); transition:box-shadow .12s,border-color .12s; }
1183
- .board-col.drop-into { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft); }
1184
- .board-col-head { display:flex; align-items:center; gap:8px; padding:10px 10px 8px; }
1185
- .board-col-head .spacer { flex:1; }
1186
- .board-count { color:var(--muted); font-size:12px; font-weight:600; }
1187
- .board-col-head .iconbtn.sm { width:24px; height:24px; }
1188
- .board-col-body { display:flex; flex-direction:column; gap:7px; padding:0 8px 8px; overflow-y:auto; }
1189
- .kcard { background:var(--bg); border:1px solid var(--line); border-radius:var(--radius-sm); padding:9px 10px; box-shadow:var(--shadow-sm);
1190
- cursor:grab; display:flex; flex-direction:column; gap:5px; touch-action:none; transition:border-color .1s,box-shadow .1s; }
1191
- .kcard:hover { border-color:var(--line-strong); }
1192
- .kcard.drag-source { opacity:.4; }
1193
- .kcard.dragging-ghost { position:fixed; left:0; top:0; z-index:120; pointer-events:none; cursor:grabbing;
1194
- box-shadow:var(--shadow-lg); opacity:.95; transform:translate3d(-9999px,0,0); }
1195
- .kcard-title { font-size:13.5px; font-weight:600; line-height:1.35; word-break:break-word; }
1196
- .kcard-field { display:flex; align-items:center; gap:6px; font-size:12.5px; color:var(--fg-soft); min-width:0; }
1197
- .kcard-field svg { width:13px; height:13px; color:var(--muted); flex:none; }
1198
- .board-add { display:flex; align-items:center; gap:6px; padding:7px 8px; color:var(--muted); font-size:12.5px; border-radius:var(--radius-sm); }
1199
- .board-add:hover { background:var(--hover-2); color:var(--fg-soft); }
1200
-
1201
- /* ---- calendar (month) view ---- */
1202
- .cal { margin-top:4px; display:flex; flex-direction:column; }
1203
- .cal-nav { display:flex; align-items:center; gap:8px; padding:2px 0 12px; }
1204
- .cal-nav .spacer { flex:1; }
1205
- .cal-title { font-size:15px; font-weight:650; min-width:120px; }
1206
- .cal-grid { display:grid; grid-template-columns:repeat(7,1fr); }
1207
- .cal-head { border:1px solid var(--line); border-bottom:0; border-radius:var(--radius) var(--radius) 0 0; background:var(--surface); }
1208
- .cal-wd { padding:8px 10px; font-size:12px; font-weight:600; color:var(--muted); text-align:left; border-right:1px solid var(--line); }
1209
- .cal-wd:last-child { border-right:0; }
1210
- .cal-body { border:1px solid var(--line); border-radius:0 0 var(--radius) var(--radius); overflow:hidden; }
1211
- .cal-week { border-bottom:1px solid var(--line); }
1212
- .cal-week:last-child { border-bottom:0; }
1213
- .cal-day { min-height:108px; border-right:1px solid var(--line); padding:5px 6px; display:flex; flex-direction:column; gap:4px; transition:background .1s; cursor:pointer; }
1214
- .cal-day:last-child { border-right:0; }
1215
- .cal-day:hover { background:var(--hover); }
1216
- .cal-day.dim { background:var(--surface); color:var(--muted); }
1217
- .cal-day.dim:hover { background:var(--hover); }
1218
- .cal-day.drop-into { background:var(--accent-soft); }
1219
- .cal-daynum { display:flex; align-items:center; justify-content:space-between; font-size:12.5px; font-weight:600; color:var(--fg-soft); }
1220
- .cal-day.dim .cal-daynum { color:var(--muted); }
1221
- .cal-day.today .cal-daynum span:first-child { background:var(--accent); color:var(--accent-fg); width:21px; height:21px; border-radius:50%; display:grid; place-items:center; }
1222
- .cal-add { opacity:0; width:20px; height:20px; display:grid; place-items:center; color:var(--muted); border-radius:5px; transition:opacity .12s; }
1223
- .cal-day:hover .cal-add { opacity:1; }
1224
- .cal-add:hover { background:var(--hover-2); color:var(--fg); }
1225
- .cal-events { display:flex; flex-direction:column; gap:3px; }
1226
- .cal-ev { font-size:12px; padding:2px 7px; border-radius:5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:grab; touch-action:none;
1227
- background:color-mix(in srgb,var(--c) 16%,transparent); color:var(--c); border-left:2px solid var(--c); }
1228
- .cal-ev:hover { filter:brightness(1.04); box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--c) 40%,transparent); }
1229
- @media (prefers-color-scheme: dark) { .cal-ev { background:color-mix(in srgb,var(--c) 24%,transparent); color:color-mix(in srgb,var(--c) 78%,#fff); } }
1230
- .cal-more { display:block; width:100%; text-align:left; font-size:11.5px; color:var(--muted); padding:2px 7px; border-radius:5px; }
1231
- .cal-more:hover { background:var(--hover-2); color:var(--fg-soft); }
1232
-
1233
- /* ---- timeline (gantt) view ---- */
1234
- .tl { margin-top:4px; }
1235
- .tl-toolbar { display:flex; align-items:center; gap:8px; padding:2px 0 12px; }
1236
- .tl-scroll { overflow:auto; border:1px solid var(--line); border-radius:var(--radius); max-height:calc(100vh - 230px); }
1237
- .tl-canvas { position:relative; }
1238
- .tl-axis { position:sticky; top:0; z-index:4; background:var(--surface); border-bottom:1px solid var(--line); }
1239
- .tl-corner { position:sticky; left:0; z-index:5; height:100%; display:flex; align-items:center; padding:0 12px; font-size:12px; font-weight:600; color:var(--muted);
1240
- background:var(--surface); border-right:1px solid var(--line); }
1241
- .tl-tick { position:absolute; top:0; height:100%; box-sizing:border-box; border-right:1px solid var(--line); display:flex; flex-direction:column; justify-content:flex-end; padding:0 0 4px 4px; }
1242
- .tl-tick.mstart { border-right-color:var(--line-strong); }
1243
- .tl-tick .tl-month { position:absolute; top:5px; left:4px; font-size:10.5px; font-weight:700; color:var(--fg-soft); white-space:nowrap; }
1244
- .tl-tick .tl-dnum { font-size:11px; color:var(--muted); }
1245
- .tl-tick.today .tl-dnum { color:var(--accent); font-weight:700; }
1246
- .tl-row { position:absolute; left:0; right:0; border-bottom:1px solid var(--line); }
1247
- .tl-rowlabel { position:sticky; left:0; z-index:3; height:100%; display:flex; align-items:center; padding:0 12px; font-size:13px; font-weight:500;
1248
- background:var(--bg); border-right:1px solid var(--line); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1249
- .tl-item { position:absolute; top:7px; height:22px; box-sizing:border-box; background:var(--accent); color:var(--accent-fg); border-radius:6px;
1250
- display:flex; align-items:center; gap:2px; padding:0 4px; cursor:grab; touch-action:none; box-shadow:var(--shadow-sm); overflow:hidden; }
1251
- .tl-item .tl-label { flex:1; font-size:12px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; pointer-events:none; }
1252
- .tl-row:hover .tl-item { box-shadow:0 0 0 1px var(--accent), var(--shadow-sm); }
1253
- .tl-item .tl-h { width:8px; align-self:stretch; flex:none; cursor:col-resize; border-radius:6px; opacity:0; transition:opacity .1s; }
1254
- .tl-item:hover .tl-h { opacity:1; background:rgba(255,255,255,.35); }
1255
- .tl-item .tl-h:hover { background:rgba(255,255,255,.6); }
1256
- .tl-milestone { position:absolute; top:9px; width:18px; height:18px; transform:rotate(45deg); background:var(--accent); border-radius:3px; cursor:grab; touch-action:none; box-shadow:var(--shadow-sm); margin-left:-9px; }
1257
- .tl-milestone:hover { box-shadow:0 0 0 2px var(--accent-soft), var(--shadow-sm); }
1258
- .tl-track-empty { position:absolute; top:0; right:0; height:100%; cursor:copy; }
1259
- .tl-track-empty:hover { background:var(--hover); }
1260
- .tl-schedule { position:sticky; top:7px; height:22px; display:inline-flex; align-items:center; padding:0 10px; border-radius:6px; font-size:12px;
1261
- color:var(--muted); border:1px dashed var(--line-strong); background:var(--bg); white-space:nowrap; }
1262
- .tl-schedule:hover { color:var(--accent); border-color:var(--accent); }
1263
- .tl-today { position:absolute; top:0; width:2px; background:var(--accent); opacity:.55; z-index:2; pointer-events:none; }
1264
- .tl-guide { position:absolute; top:0; width:0; border-left:1px dashed var(--accent); z-index:6; pointer-events:none; }
1265
- .tl-guide-label { position:absolute; top:2px; left:4px; font-size:11px; font-weight:600; color:var(--accent-fg); background:var(--accent);
1266
- padding:1px 6px; border-radius:5px; white-space:nowrap; box-shadow:var(--shadow-sm); }
841
+ // src/core/properties.ts
842
+ init_ids();
843
+ init_crdt();
1267
844
 
1268
- /* mobile */
1269
- .backdrop { display:none; }
1270
- @media (max-width: 768px) {
1271
- .sidebar { position:fixed; z-index:80; height:100%; margin-left:-300px; }
1272
- .sidebar.open { margin-left:0; box-shadow:var(--shadow-lg); }
1273
- .backdrop { position:fixed; inset:0; background:rgba(0,0,0,.4); z-index:79; }
1274
- .backdrop.show { display:block; }
1275
- .topbar .hamburger { display:grid; }
1276
- .sb-resizer { display:none; }
1277
- .doc { padding:28px 20px 36vh; } .doc-title { font-size:30px; }
1278
- .block .gutter { display:none; }
1279
- .db { padding:16px 14px 90px; }
1280
- .peek { width:100%; }
845
+ // src/core/indexing.ts
846
+ var AUTO_INDEX_ROW_THRESHOLD = 2000;
847
+ function sanitize(s) {
848
+ return s.replace(/[^a-zA-Z0-9_]/g, "_");
849
+ }
850
+ function lit(s) {
851
+ return s.replace(/'/g, "''");
852
+ }
853
+ function indexName(databaseId, propId) {
854
+ return `idx_rec_${sanitize(databaseId)}_${sanitize(propId)}`;
855
+ }
856
+ function hasIndex(db, databaseId, propId) {
857
+ const row = db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'index' AND name = ?").get(indexName(databaseId, propId));
858
+ return row != null;
859
+ }
860
+ function ensurePropIndex(db, databaseId, propId) {
861
+ const name = indexName(databaseId, propId);
862
+ db.exec(`CREATE INDEX IF NOT EXISTS "${name}" ON records (data ->> '${lit(propId)}', created_hlc) ` + `WHERE database_id = '${lit(databaseId)}' AND __deleted = 0`);
863
+ }
864
+ function maybeAutoIndex(db, databaseId, prop) {
865
+ if (hasIndex(db, databaseId, prop.id))
866
+ return;
867
+ if (prop.type !== "relation") {
868
+ const row = db.query("SELECT COUNT(*) AS c FROM records WHERE database_id = ? AND __deleted = 0").get(databaseId);
869
+ if (row.c < AUTO_INDEX_ROW_THRESHOLD)
870
+ return;
1281
871
  }
872
+ ensurePropIndex(db, databaseId, prop.id);
873
+ }
1282
874
 
1283
- /* Quick Notes window (desktop, loaded at #quick). A transparent body lets the
1284
- macOS vibrancy blur show through; a translucent panel keeps text readable.
1285
- The top bar is the window drag region; its buttons opt back out. */
1286
- body.quicknote { background:transparent; }
1287
- /* The main app lays #app out as a flex row (sidebar + main); the quick-note
1288
- route has a single child, so override to a full-bleed block or the panel
1289
- shrinks to its content width and the window's vibrancy shows through. */
1290
- body.quicknote #app { display:block; width:100vw; height:100vh; }
1291
- .qn { display:flex; flex-direction:column; width:100%; height:100vh; overflow:hidden;
1292
- background:color-mix(in srgb, var(--bg) 20%, transparent); }
1293
- .qn-bar { display:flex; align-items:center; gap:6px; padding:4px 10px 4px 76px;
1294
- user-select:none; -webkit-app-region:drag;
1295
- background:color-mix(in srgb, var(--surface) 30%, transparent); }
1296
- .qn-brand { flex:1; min-width:0; font-size:12px; font-weight:600; color:var(--fg-soft);
1297
- white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1298
- .qn-actions { display:flex; gap:2px; -webkit-app-region:no-drag; }
1299
- .qn-actions .iconbtn { width:26px; height:26px; }
1300
- .qn-actions .iconbtn.active { color:var(--accent); background:var(--hover-2); }
1301
- .qn-body { flex:1; overflow:auto; -webkit-app-region:no-drag; }
1302
- .qn-body .doc { max-width:none; margin:0; padding:14px 22px 30vh; }
1303
- .qn-body .doc-title { font-size:24px; margin-bottom:2px; }
1304
- .qn-body .doc-meta { display:none; }
1305
- /* A native checkbox paints an opaque white box when unchecked, which breaks
1306
- the window's vibrancy blur. Draw a custom box so the unchecked background
1307
- stays transparent; checked falls back to the accent fill + checkmark. */
1308
- body.quicknote .b-todo .marker input {
1309
- appearance:none; -webkit-appearance:none; box-sizing:border-box;
1310
- border:1.5px solid color-mix(in srgb, var(--fg-soft) 55%, transparent);
1311
- border-radius:4px; background:transparent; }
1312
- body.quicknote .b-todo .marker input:checked {
1313
- border-color:var(--accent);
1314
- background:var(--accent) center/10px no-repeat
1315
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5L20 7'/%3E%3C/svg%3E"); }
1316
- /* Compact menus inside the quick-note window only (dropdown + slash command
1317
- share .pop/.item/.lico with the main app; scope under body.quicknote so the
1318
- main app keeps its roomier density). */
1319
- body.quicknote .pop { padding:5px; border-radius:9px; }
1320
- body.quicknote .pop .lbl { padding:4px 8px 2px; font-size:10px; }
1321
- body.quicknote .pop .item { padding:5px 9px; gap:8px; font-size:12.5px; }
1322
- body.quicknote .pop .item .t { font-size:12.5px; }
1323
- body.quicknote .pop .item .d { font-size:10.5px; }
1324
- body.quicknote .pop .item .lico { width:22px; height:22px; }
1325
- body.quicknote .pop .item .lico.plain { width:20px; height:20px; }
1326
- body.quicknote .pop .sep { margin:4px 4px; }
1327
-
1328
- /* Quick Notes settings rows (desktop only). */
1329
- .qn-set-row { display:flex; align-items:center; gap:14px; padding:12px 0; border-top:1px solid var(--line); }
1330
- .qn-set-row:first-of-type { border-top:0; }
1331
- .qn-set-main { flex:1; min-width:0; }
1332
- .qn-set-name { font-weight:600; }
1333
- .qn-set-desc { color:var(--muted); font-size:13px; margin-top:2px; }
1334
- .qn-shortcut { min-width:140px; justify-content:center; font-family:var(--mono); }
1335
- .qn-shortcut.capturing { outline:2px solid var(--accent); color:var(--accent); }
1336
- </style>
1337
- </head>
1338
- <body>
1339
- <div id="app"></div>
1340
- <script type="module" src="/webui.js"></script>
1341
- </body>
1342
- </html>`, cachedJs = null;
1343
- var init_webui = () => {};
1344
-
1345
- // src/core/sync/sites-serve.ts
1346
- var exports_sites_serve = {};
1347
- __export(exports_sites_serve, {
1348
- serveSite: () => serveSite
1349
- });
1350
- async function serveSite(req, ctx) {
1351
- const url2 = new URL(req.url);
1352
- const rest = url2.pathname.slice("/sites/".length);
1353
- if (rest === "")
1354
- return null;
1355
- const slash = rest.indexOf("/");
1356
- const name = decodeURIComponent(slash === -1 ? rest : rest.slice(0, slash));
1357
- if (slash === -1)
1358
- return Response.redirect(`${url2.origin}/sites/${name}/`, 301);
1359
- const filePath = decodeURIComponent(rest.slice(slash + 1));
1360
- let siteId;
1361
- try {
1362
- siteId = resolveSite(ctx.db, name).id;
1363
- } catch {
1364
- return new Response("site not found", { status: 404 });
1365
- }
1366
- const file2 = await getFileForServe(ctx.db, siteId, filePath);
1367
- if (!file2)
1368
- return new Response("not found", { status: 404 });
1369
- return new Response(file2.bytes, { headers: { "content-type": file2.contentType } });
1370
- }
1371
- var init_sites_serve = __esm(() => {
1372
- init_sites();
1373
- });
1374
-
1375
- // src/core/index.ts
1376
- init_paths();
1377
-
1378
- // src/core/db.ts
1379
- init_paths();
1380
- import { Database } from "bun:sqlite";
1381
- import { mkdirSync } from "fs";
1382
-
1383
- // src/core/schema.ts
1384
- var CORE_SCHEMA = `
1385
- CREATE TABLE IF NOT EXISTS meta (
1386
- key TEXT PRIMARY KEY,
1387
- value TEXT
1388
- );
1389
-
1390
- -- CRDT oplog: one row per field assignment. Source of truth for sync.
1391
- CREATE TABLE IF NOT EXISTS crdt_changes (
1392
- hlc TEXT NOT NULL,
1393
- node_id TEXT NOT NULL,
1394
- dataset TEXT NOT NULL,
1395
- row_id TEXT NOT NULL,
1396
- col TEXT NOT NULL,
1397
- value TEXT,
1398
- PRIMARY KEY (dataset, row_id, col, hlc)
1399
- );
1400
- CREATE INDEX IF NOT EXISTS idx_changes_hlc ON crdt_changes(hlc);
1401
-
1402
- -- Per-peer replication cursors (SQLite rowids: monotonic in insertion order,
1403
- -- so no change is ever skipped regardless of HLC/clock skew). Outbound side of
1404
- -- a pairing: "token" is the credential the *remote* issued to us (sent as a
1405
- -- Bearer header when we sync to them). See migratePeers in db.ts for the columns
1406
- -- added to legacy databases.
1407
- CREATE TABLE IF NOT EXISTS peers (
1408
- url TEXT PRIMARY KEY,
1409
- pull_cursor INTEGER NOT NULL DEFAULT 0,
1410
- push_cursor INTEGER NOT NULL DEFAULT 0,
1411
- token TEXT,
1412
- label TEXT,
1413
- node_id TEXT,
1414
- enabled INTEGER NOT NULL DEFAULT 1,
1415
- last_sync_at INTEGER,
1416
- last_status TEXT,
1417
- last_error TEXT
1418
- );
1419
-
1420
- -- Inbound side of a pairing: credentials *we* issued to remote devices. A peer
1421
- -- presents one of these on /sync; acceptsSyncToken() checks it alongside the
1422
- -- managed master token. Revoke by deleting the row.
1423
- CREATE TABLE IF NOT EXISTS peer_grants (
1424
- token TEXT PRIMARY KEY,
1425
- peer_url TEXT,
1426
- node_id TEXT,
1427
- created_at INTEGER
1428
- );
1429
-
1430
- -- One-time pairing codes minted by "mh config peer code" / POST /api/pair/new.
1431
- -- Short-lived and single-use: redeemed during the pairing handshake, then the
1432
- -- two devices exchange durable peer_grants. used/exp gate redemption.
1433
- CREATE TABLE IF NOT EXISTS pairing_codes (
1434
- code TEXT PRIMARY KEY,
1435
- exp INTEGER NOT NULL,
1436
- used INTEGER NOT NULL DEFAULT 0,
1437
- created_at INTEGER
1438
- );
1439
-
1440
- CREATE TABLE IF NOT EXISTS databases (
1441
- id TEXT PRIMARY KEY,
1442
- name TEXT,
1443
- icon TEXT,
1444
- created_hlc TEXT,
1445
- __deleted INTEGER NOT NULL DEFAULT 0
1446
- );
1447
-
1448
- CREATE TABLE IF NOT EXISTS properties (
1449
- id TEXT PRIMARY KEY,
1450
- database_id TEXT,
1451
- name TEXT,
1452
- type TEXT,
1453
- config TEXT,
1454
- position REAL,
1455
- __deleted INTEGER NOT NULL DEFAULT 0
1456
- );
1457
- CREATE INDEX IF NOT EXISTS idx_properties_db ON properties(database_id);
1458
-
1459
- -- One row per record. Field cells live in the data JSON object, keyed by
1460
- -- property id (rename-safe). Each (record,property) is still an independent
1461
- -- CRDT register in the oplog; materialize() folds the winner into data via
1462
- -- json_set/json_remove. Hot fields get expression indexes on data->>'propid'
1463
- -- (see indexing.ts) so filter/sort/limit push down to SQL.
1464
- CREATE TABLE IF NOT EXISTS records (
1465
- id TEXT PRIMARY KEY,
1466
- database_id TEXT,
1467
- created_hlc TEXT,
1468
- order_key TEXT,
1469
- data TEXT NOT NULL DEFAULT '{}',
1470
- __deleted INTEGER NOT NULL DEFAULT 0
1471
- );
1472
- CREATE INDEX IF NOT EXISTS idx_records_db_hlc ON records(database_id, created_hlc);
1473
-
1474
- CREATE TABLE IF NOT EXISTS documents (
1475
- id TEXT PRIMARY KEY,
1476
- title TEXT,
1477
- body TEXT, -- materialized cache: serialized from doc_blocks
1478
- database_id TEXT,
1479
- parent_id TEXT,
1480
- created_hlc TEXT,
1481
- __deleted INTEGER NOT NULL DEFAULT 0
1482
- );
1483
-
1484
- -- Document body as an ordered list of blocks; each (block,field) is an
1485
- -- independent CRDT register so concurrent edits to different blocks merge.
1486
- -- order_key is a fractional index (ties broken by id) giving display order.
1487
- CREATE TABLE IF NOT EXISTS doc_blocks (
1488
- id TEXT PRIMARY KEY,
1489
- doc_id TEXT,
1490
- text TEXT,
1491
- order_key TEXT,
1492
- blank_after INTEGER NOT NULL DEFAULT 0, -- extra blank lines kept after this block (user spacing)
1493
- __deleted INTEGER NOT NULL DEFAULT 0
1494
- );
1495
- CREATE INDEX IF NOT EXISTS idx_doc_blocks_doc ON doc_blocks(doc_id);
1496
-
1497
- -- A named static site (Supabase-Storage-style bucket). Served at /sites/<name>/.
1498
- CREATE TABLE IF NOT EXISTS sites (
1499
- id TEXT PRIMARY KEY,
1500
- name TEXT, -- URL slug; resolved by getSiteByName
1501
- title TEXT,
1502
- created_hlc TEXT,
1503
- __deleted INTEGER NOT NULL DEFAULT 0
1504
- );
1505
-
1506
- -- One row per file in a site. content holds inline utf8/base64 text, or a blob
1507
- -- hash (see cache.ts) when encoding = 'blob'. (site_id, path) maps to a stable
1508
- -- id so re-uploads merge as a CRDT register instead of duplicating.
1509
- CREATE TABLE IF NOT EXISTS site_files (
1510
- id TEXT PRIMARY KEY,
1511
- site_id TEXT,
1512
- path TEXT,
1513
- content_type TEXT,
1514
- encoding TEXT, -- 'utf8' | 'base64' | 'blob'
1515
- content TEXT,
1516
- created_hlc TEXT,
1517
- __deleted INTEGER NOT NULL DEFAULT 0
1518
- );
1519
- CREATE INDEX IF NOT EXISTS idx_site_files_site ON site_files(site_id);
1520
- `;
1521
- var FTS_SCHEMA = `
1522
- CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
1523
- kind UNINDEXED,
1524
- id UNINDEXED,
1525
- database_id UNINDEXED,
1526
- title,
1527
- body
1528
- );
1529
- `;
1530
-
1531
- // src/core/records.ts
1532
- init_ids();
1533
- init_crdt();
1534
-
1535
- // src/core/databases.ts
1536
- init_ids();
1537
- init_crdt();
1538
- function createDatabase(db, opts) {
1539
- const id = newId("db", opts.name);
1540
- const first = emit(db, "databases", id, "name", opts.name);
1541
- emit(db, "databases", id, "created_hlc", first.hlc);
1542
- if (opts.icon !== undefined)
1543
- emit(db, "databases", id, "icon", opts.icon);
1544
- return getDatabase(db, id);
1545
- }
1546
- function updateDatabase(db, id, fields) {
1547
- if (!getDatabase(db, id))
1548
- throw new Error(`no such database: ${id}`);
1549
- if (fields.name !== undefined)
1550
- emit(db, "databases", id, "name", fields.name);
1551
- if (fields.icon !== undefined)
1552
- emit(db, "databases", id, "icon", fields.icon);
1553
- return getDatabase(db, id);
1554
- }
1555
- function getDatabase(db, id) {
1556
- return db.query("SELECT id, name, icon, created_hlc FROM databases WHERE id = ? AND __deleted = 0").get(id);
1557
- }
1558
- function listDatabases(db) {
1559
- return db.query("SELECT id, name, icon, created_hlc FROM databases WHERE __deleted = 0 ORDER BY created_hlc").all();
1560
- }
1561
- function deleteDatabase(db, id) {
1562
- if (!getDatabase(db, id))
1563
- return false;
1564
- emit(db, "databases", id, "__deleted", 1);
1565
- const tombstone = (table) => {
1566
- for (const r of liveChildren(db, table, "database_id", id))
1567
- emit(db, table, r, "__deleted", 1);
1568
- };
1569
- tombstone("properties");
1570
- tombstone("records");
1571
- for (const d of liveChildren(db, "documents", "database_id", id))
1572
- emit(db, "documents", d, "database_id", null);
1573
- db.query("DELETE FROM meta WHERE key = 'current_db' AND value = ?").run(id);
1574
- return true;
1575
- }
1576
- function liveChildren(db, table, col, parentId) {
1577
- return db.query(`SELECT id FROM ${table} WHERE ${col} = ? AND __deleted = 0`).all(parentId).map((r) => r.id);
1578
- }
1579
-
1580
- // src/core/properties.ts
1581
- init_ids();
1582
- init_crdt();
1583
-
1584
- // src/core/indexing.ts
1585
- var AUTO_INDEX_ROW_THRESHOLD = 2000;
1586
- function sanitize(s) {
1587
- return s.replace(/[^a-zA-Z0-9_]/g, "_");
1588
- }
1589
- function lit(s) {
1590
- return s.replace(/'/g, "''");
1591
- }
1592
- function indexName(databaseId, propId) {
1593
- return `idx_rec_${sanitize(databaseId)}_${sanitize(propId)}`;
1594
- }
1595
- function hasIndex(db, databaseId, propId) {
1596
- const row = db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'index' AND name = ?").get(indexName(databaseId, propId));
1597
- return row != null;
1598
- }
1599
- function ensurePropIndex(db, databaseId, propId) {
1600
- const name = indexName(databaseId, propId);
1601
- db.exec(`CREATE INDEX IF NOT EXISTS "${name}" ON records (data ->> '${lit(propId)}', created_hlc) ` + `WHERE database_id = '${lit(databaseId)}' AND __deleted = 0`);
1602
- }
1603
- function maybeAutoIndex(db, databaseId, prop) {
1604
- if (hasIndex(db, databaseId, prop.id))
1605
- return;
1606
- if (prop.type !== "relation") {
1607
- const row = db.query("SELECT COUNT(*) AS c FROM records WHERE database_id = ? AND __deleted = 0").get(databaseId);
1608
- if (row.c < AUTO_INDEX_ROW_THRESHOLD)
1609
- return;
1610
- }
1611
- ensurePropIndex(db, databaseId, prop.id);
1612
- }
1613
-
1614
- // src/core/properties.ts
1615
- var PROP_TYPES = new Set([
1616
- "text",
1617
- "number",
1618
- "checkbox",
1619
- "select",
1620
- "multi_select",
1621
- "date",
1622
- "relation",
1623
- "url"
1624
- ]);
1625
- function validateConfig(type, config) {
1626
- if (type === "select" || type === "multi_select") {
1627
- const opts = config?.options;
1628
- if (!Array.isArray(opts) || opts.length === 0 || !opts.every((o) => typeof o === "string"))
1629
- throw new Error(`${type} requires config.options: string[]`);
1630
- }
1631
- if (type === "relation") {
1632
- if (typeof config?.database !== "string")
1633
- throw new Error("relation requires config.database (target database id)");
875
+ // src/core/properties.ts
876
+ init_errors();
877
+ var PROP_TYPES = new Set([
878
+ "text",
879
+ "number",
880
+ "checkbox",
881
+ "select",
882
+ "multi_select",
883
+ "date",
884
+ "relation",
885
+ "url"
886
+ ]);
887
+ function validateConfig(type, config) {
888
+ if (type === "select" || type === "multi_select") {
889
+ const opts = config?.options;
890
+ if (!Array.isArray(opts) || opts.length === 0 || !opts.every((o) => typeof o === "string"))
891
+ throw new MhError("invalid_input", `${type} requires config.options: string[]`);
892
+ }
893
+ if (type === "relation") {
894
+ if (typeof config?.database !== "string")
895
+ throw new MhError("invalid_input", "relation requires config.database (target database id)");
1634
896
  }
1635
897
  }
1636
898
  function nextPosition(db, databaseId) {
@@ -1639,9 +901,9 @@ function nextPosition(db, databaseId) {
1639
901
  }
1640
902
  function addProperty(db, databaseId, opts) {
1641
903
  if (!getDatabase(db, databaseId))
1642
- throw new Error(`no such database: ${databaseId}`);
904
+ throw new MhError("not_found", `no such database: ${databaseId}`);
1643
905
  if (!PROP_TYPES.has(opts.type))
1644
- throw new Error(`unknown property type: ${opts.type}`);
906
+ throw new MhError("invalid_input", `unknown property type: ${opts.type}`);
1645
907
  validateConfig(opts.type, opts.config);
1646
908
  const config = opts.indexed ? { ...opts.config ?? {}, indexed: true } : opts.config ?? null;
1647
909
  const id = newId("prop", opts.name);
@@ -1670,10 +932,10 @@ function listProperties(db, databaseId) {
1670
932
  function updateProperty(db, id, fields) {
1671
933
  const cur = getProperty(db, id);
1672
934
  if (!cur)
1673
- throw new Error(`no such property: ${id}`);
935
+ throw new MhError("not_found", `no such property: ${id}`);
1674
936
  const typeChanged = fields.type !== undefined && fields.type !== cur.type;
1675
937
  if (fields.type !== undefined && !PROP_TYPES.has(fields.type))
1676
- throw new Error(`unknown property type: ${fields.type}`);
938
+ throw new MhError("invalid_input", `unknown property type: ${fields.type}`);
1677
939
  const effectiveType = fields.type ?? cur.type;
1678
940
  if (fields.config !== undefined)
1679
941
  validateConfig(effectiveType, fields.config);
@@ -1697,9 +959,9 @@ function updateProperty(db, id, fields) {
1697
959
  function setPropertyWidth(db, id, width) {
1698
960
  const cur = getProperty(db, id);
1699
961
  if (!cur)
1700
- throw new Error(`no such property: ${id}`);
962
+ throw new MhError("not_found", `no such property: ${id}`);
1701
963
  if (!Number.isFinite(width))
1702
- throw new Error("width must be a finite number");
964
+ throw new MhError("invalid_input", "width must be a finite number");
1703
965
  const w = Math.max(80, Math.min(2000, Math.round(width)));
1704
966
  return updateProperty(db, id, { config: { ...cur.config ?? {}, width: w } });
1705
967
  }
@@ -1716,6 +978,7 @@ function removeProperty(db, id) {
1716
978
 
1717
979
  // src/core/resolve.ts
1718
980
  init_ids();
981
+ init_errors();
1719
982
  var SPECS = {
1720
983
  db: { table: "databases", nameCol: "name", scoped: false, noun: "database" },
1721
984
  prop: { table: "properties", nameCol: "name", scoped: true, noun: "property" },
@@ -1786,10 +1049,10 @@ function resolveEntity(db, ref, opts = {}) {
1786
1049
  return cands[0];
1787
1050
  if (cands.length === 0) {
1788
1051
  const noun = opts.kind ? SPECS[opts.kind].noun : "item";
1789
- throw new Error(`no such ${noun}: ${ref}`);
1052
+ throw new MhError("not_found", `no such ${noun}: ${ref}`);
1790
1053
  }
1791
1054
  const shown = exact.length > 1 ? exact : cands;
1792
- throw new Error(`ambiguous "${ref}" \u2014 ${shown.length} matches:
1055
+ throw new MhError("ambiguous", `ambiguous "${ref}" \u2014 ${shown.length} matches:
1793
1056
  ${fmt(shown)}
1794
1057
  add more characters.`);
1795
1058
  }
@@ -1854,6 +1117,7 @@ function keysBetween(a, b, n) {
1854
1117
  }
1855
1118
 
1856
1119
  // src/core/records.ts
1120
+ init_errors();
1857
1121
  function isSqlScalar(v) {
1858
1122
  return typeof v === "string" || typeof v === "number" || typeof v === "boolean";
1859
1123
  }
@@ -1873,9 +1137,9 @@ function resolveRelation(db, prop, value) {
1873
1137
  if (cands.length === 0) {
1874
1138
  if (idKind(value) === "rec")
1875
1139
  return value;
1876
- throw new Error(`${prop.name}: no such record in target database: ${value}`);
1140
+ throw new MhError("not_found", `${prop.name}: no such record in target database: ${value}`);
1877
1141
  }
1878
- throw new Error(`${prop.name}: ambiguous relation "${value}" (${cands.length} matches); use a full id`);
1142
+ throw new MhError("ambiguous", `${prop.name}: ambiguous relation "${value}" (${cands.length} matches); use a full id`);
1879
1143
  }
1880
1144
  function coerce(db, prop, value) {
1881
1145
  switch (prop.type) {
@@ -1885,7 +1149,7 @@ function coerce(db, prop, value) {
1885
1149
  if (value === null)
1886
1150
  return null;
1887
1151
  if (typeof value !== "string")
1888
- throw new Error(`${prop.name} expects a string`);
1152
+ throw new MhError("invalid_input", `${prop.name} expects a string`);
1889
1153
  return value;
1890
1154
  }
1891
1155
  case "number": {
@@ -1893,7 +1157,7 @@ function coerce(db, prop, value) {
1893
1157
  return null;
1894
1158
  const n = typeof value === "number" ? value : Number(value);
1895
1159
  if (!Number.isFinite(n))
1896
- throw new Error(`${prop.name} expects a number`);
1160
+ throw new MhError("invalid_input", `${prop.name} expects a number`);
1897
1161
  return n;
1898
1162
  }
1899
1163
  case "checkbox": {
@@ -1903,14 +1167,14 @@ function coerce(db, prop, value) {
1903
1167
  return true;
1904
1168
  if (value === "false" || value === 0 || value === null)
1905
1169
  return false;
1906
- throw new Error(`${prop.name} expects a boolean`);
1170
+ throw new MhError("invalid_input", `${prop.name} expects a boolean`);
1907
1171
  }
1908
1172
  case "select": {
1909
1173
  if (value === null)
1910
1174
  return null;
1911
1175
  const v = String(value);
1912
1176
  if (!prop.config?.options?.includes(v))
1913
- throw new Error(`${prop.name}: '${v}' is not an allowed option`);
1177
+ throw new MhError("invalid_input", `${prop.name}: '${v}' is not an allowed option`);
1914
1178
  return v;
1915
1179
  }
1916
1180
  case "multi_select": {
@@ -1918,7 +1182,7 @@ function coerce(db, prop, value) {
1918
1182
  const opts = prop.config?.options ?? [];
1919
1183
  for (const item of arr)
1920
1184
  if (!opts.includes(String(item)))
1921
- throw new Error(`${prop.name}: '${item}' is not an allowed option`);
1185
+ throw new MhError("invalid_input", `${prop.name}: '${item}' is not an allowed option`);
1922
1186
  return arr.map(String);
1923
1187
  }
1924
1188
  case "relation": {
@@ -1934,7 +1198,7 @@ function resolveData(props, data) {
1934
1198
  for (const [key, value] of Object.entries(data)) {
1935
1199
  const prop = byId.get(key) ?? byName.get(key.toLowerCase());
1936
1200
  if (!prop)
1937
- throw new Error(`unknown property: ${key}`);
1201
+ throw new MhError("not_found", `unknown property: ${key}`);
1938
1202
  out.push({ prop, value });
1939
1203
  }
1940
1204
  return out;
@@ -2003,7 +1267,7 @@ function backfillRecordOrderKeys(db, databaseId) {
2003
1267
  function createRecord(db, databaseId, data) {
2004
1268
  const dbRow = getDatabase(db, databaseId);
2005
1269
  if (!dbRow)
2006
- throw new Error(`no such database: ${databaseId}`);
1270
+ throw new MhError("not_found", `no such database: ${databaseId}`);
2007
1271
  const props = listProperties(db, databaseId);
2008
1272
  const resolved = resolveData(props, data);
2009
1273
  const id = deriveTitle(props, resolved, slugify(dbRow.name, "rec"));
@@ -2043,7 +1307,7 @@ function listRecords(db, databaseId, opts = {}) {
2043
1307
  if (key !== "created" && key !== "created_hlc") {
2044
1308
  const p = props.find((p2) => p2.name.toLowerCase() === key.toLowerCase() || p2.id === key);
2045
1309
  if (!p)
2046
- throw new Error(`unknown sort field: ${key}`);
1310
+ throw new MhError("not_found", `unknown sort field: ${key}`);
2047
1311
  sortProp = p;
2048
1312
  }
2049
1313
  }
@@ -2075,22 +1339,22 @@ function listRecords(db, databaseId, opts = {}) {
2075
1339
  }
2076
1340
  function moveRecord(db, id, targetId, where) {
2077
1341
  if (where !== "before" && where !== "after")
2078
- throw new Error(`unknown move position: ${where}`);
1342
+ throw new MhError("invalid_input", `unknown move position: ${where}`);
2079
1343
  const src = db.query("SELECT id, database_id FROM records WHERE id = ? AND __deleted = 0").get(id);
2080
1344
  if (!src)
2081
- throw new Error(`no such record: ${id}`);
1345
+ throw new MhError("not_found", `no such record: ${id}`);
2082
1346
  const target = db.query("SELECT id, database_id FROM records WHERE id = ? AND __deleted = 0").get(targetId);
2083
1347
  if (!target)
2084
- throw new Error(`no such record: ${targetId}`);
1348
+ throw new MhError("not_found", `no such record: ${targetId}`);
2085
1349
  if (src.database_id !== target.database_id)
2086
- throw new Error("cannot move a record across databases");
1350
+ throw new MhError("invalid_input", "cannot move a record across databases");
2087
1351
  if (id === targetId)
2088
1352
  return getRecord(db, id);
2089
1353
  backfillRecordOrderKeys(db, src.database_id);
2090
1354
  let rows = orderedRecordRows(db, src.database_id).filter((r) => r.id !== id);
2091
1355
  let to = rows.findIndex((r) => r.id === targetId);
2092
1356
  if (to < 0)
2093
- throw new Error(`no such target record: ${targetId}`);
1357
+ throw new MhError("not_found", `no such target record: ${targetId}`);
2094
1358
  let left = where === "before" ? rows[to - 1]?.order_key ?? null : rows[to].order_key;
2095
1359
  let right = where === "before" ? rows[to].order_key : rows[to + 1]?.order_key ?? null;
2096
1360
  if (left !== null && right !== null && left >= right) {
@@ -2106,7 +1370,7 @@ function moveRecord(db, id, targetId, where) {
2106
1370
  function updateRecord(db, id, data) {
2107
1371
  const rec = db.query("SELECT id, database_id FROM records WHERE id = ? AND __deleted = 0").get(id);
2108
1372
  if (!rec)
2109
- throw new Error(`no such record: ${id}`);
1373
+ throw new MhError("not_found", `no such record: ${id}`);
2110
1374
  const resolved = resolveData(listProperties(db, rec.database_id), data);
2111
1375
  for (const { prop, value } of resolved)
2112
1376
  emit(db, "records", id, prop.id, coerce(db, prop, value));
@@ -2120,120 +1384,24 @@ function deleteRecord(db, id) {
2120
1384
  return true;
2121
1385
  }
2122
1386
 
2123
- // src/core/db.ts
2124
- function ensureDirs() {
2125
- mkdirSync(metahubHome(), { recursive: true });
2126
- mkdirSync(cacheDir(), { recursive: true });
1387
+ // src/core/documents.ts
1388
+ init_ids();
1389
+ init_crdt();
1390
+ init_errors();
1391
+ function liveBlocks(db, docId) {
1392
+ return db.query("SELECT id, text, order_key, blank_after FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
2127
1393
  }
2128
- function runSchema(db) {
2129
- db.exec(CORE_SCHEMA);
2130
- try {
2131
- db.exec(FTS_SCHEMA);
2132
- } catch {}
1394
+ function makeBlockId(text) {
1395
+ return newId("blk", text.split(`
1396
+ `, 1)[0] ?? "");
2133
1397
  }
2134
- function ftsAvailable(db) {
2135
- const row = db.query("SELECT 1 AS ok FROM sqlite_master WHERE name = 'search_fts'").get();
2136
- return row != null;
2137
- }
2138
- function hasColumn(db, table, column) {
2139
- const cols = db.query(`PRAGMA table_info(${table})`).all();
2140
- return cols.some((c) => c.name === column);
2141
- }
2142
- function tableExists2(db, table) {
2143
- return db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
2144
- }
2145
- function migrateRecords(db) {
2146
- if (!hasColumn(db, "records", "data"))
2147
- db.exec("ALTER TABLE records ADD COLUMN data TEXT NOT NULL DEFAULT '{}'");
2148
- if (!hasColumn(db, "records", "order_key"))
2149
- db.exec("ALTER TABLE records ADD COLUMN order_key TEXT");
2150
- if (!tableExists2(db, "record_values"))
2151
- return;
2152
- const tx = db.transaction(() => {
2153
- db.exec(`
2154
- UPDATE records SET data = coalesce((
2155
- SELECT json_group_object(rv.property_id, json(rv.value))
2156
- FROM record_values rv WHERE rv.record_id = records.id
2157
- ), '{}')
2158
- WHERE id IN (SELECT DISTINCT record_id FROM record_values);
2159
- `);
2160
- db.exec("DROP TABLE record_values");
2161
- backfillRecordOrderKeys(db);
2162
- });
2163
- tx();
2164
- backfillRecordOrderKeys(db);
2165
- }
2166
- function migratePeers(db) {
2167
- const add = [
2168
- ["token", "TEXT"],
2169
- ["label", "TEXT"],
2170
- ["node_id", "TEXT"],
2171
- ["enabled", "INTEGER NOT NULL DEFAULT 1"],
2172
- ["last_sync_at", "INTEGER"],
2173
- ["last_status", "TEXT"],
2174
- ["last_error", "TEXT"]
2175
- ];
2176
- for (const [col, decl] of add) {
2177
- if (!hasColumn(db, "peers", col))
2178
- db.exec(`ALTER TABLE peers ADD COLUMN ${col} ${decl}`);
2179
- }
2180
- }
2181
- function migrateDocBlocks(db) {
2182
- if (!hasColumn(db, "doc_blocks", "blank_after"))
2183
- db.exec("ALTER TABLE doc_blocks ADD COLUMN blank_after INTEGER NOT NULL DEFAULT 0");
2184
- }
2185
- function openMetahub() {
2186
- ensureDirs();
2187
- const db = new Database(dbPath(), { create: true });
2188
- db.exec("PRAGMA journal_mode = WAL;");
2189
- runSchema(db);
2190
- migrateRecords(db);
2191
- migratePeers(db);
2192
- migrateDocBlocks(db);
2193
- return db;
2194
- }
2195
-
2196
- // src/core/index.ts
2197
- init_ids();
2198
- init_crdt();
2199
- init_node();
2200
-
2201
- // src/core/context.ts
2202
- var KEY = "current_db";
2203
- function getCurrentDatabase(db) {
2204
- const row = db.query("SELECT value FROM meta WHERE key = ?").get(KEY);
2205
- if (!row)
2206
- return null;
2207
- const found = getDatabase(db, row.value);
2208
- if (!found) {
2209
- clearCurrentDatabase(db);
2210
- return null;
2211
- }
2212
- return found;
2213
- }
2214
- function setCurrentDatabase(db, id) {
2215
- db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(KEY, id);
2216
- }
2217
- function clearCurrentDatabase(db) {
2218
- db.query("DELETE FROM meta WHERE key = ?").run(KEY);
2219
- }
2220
- // src/core/documents.ts
2221
- init_ids();
2222
- init_crdt();
2223
- function liveBlocks(db, docId) {
2224
- return db.query("SELECT id, text, order_key, blank_after FROM doc_blocks WHERE doc_id = ? AND __deleted = 0 ORDER BY order_key, id").all(docId);
2225
- }
2226
- function makeBlockId(text) {
2227
- return newId("blk", text.split(`
2228
- `, 1)[0] ?? "");
2229
- }
2230
- function emitBlock(db, docId, fields) {
2231
- const id = makeBlockId(fields.text);
2232
- emit(db, "doc_blocks", id, "doc_id", docId);
2233
- emit(db, "doc_blocks", id, "order_key", fields.order_key);
2234
- if (fields.blankAfter)
2235
- emit(db, "doc_blocks", id, "blank_after", fields.blankAfter);
2236
- emit(db, "doc_blocks", id, "text", fields.text);
1398
+ function emitBlock(db, docId, fields) {
1399
+ const id = makeBlockId(fields.text);
1400
+ emit(db, "doc_blocks", id, "doc_id", docId);
1401
+ emit(db, "doc_blocks", id, "order_key", fields.order_key);
1402
+ if (fields.blankAfter)
1403
+ emit(db, "doc_blocks", id, "blank_after", fields.blankAfter);
1404
+ emit(db, "doc_blocks", id, "text", fields.text);
2237
1405
  }
2238
1406
  function insertBlocks(db, docId, blocks, after, before) {
2239
1407
  const keys = keysBetween(after, before, blocks.length);
@@ -2288,6 +1456,7 @@ function createDocument(db, opts) {
2288
1456
  emit(db, "documents", id, "database_id", opts.database_id);
2289
1457
  if (opts.parent_id !== undefined)
2290
1458
  emit(db, "documents", id, "parent_id", opts.parent_id);
1459
+ placeInSiblings(db, id, opts.parent_id ?? null);
2291
1460
  if (opts.body !== undefined) {
2292
1461
  const blocks = parseDocBlocks(opts.body);
2293
1462
  if (blocks.length)
@@ -2296,31 +1465,125 @@ function createDocument(db, opts) {
2296
1465
  return getDocument(db, id);
2297
1466
  }
2298
1467
  function getDocument(db, id) {
2299
- return db.query("SELECT id, title, body, database_id, parent_id, created_hlc FROM documents WHERE id = ? AND __deleted = 0").get(id);
1468
+ return db.query("SELECT id, title, body, database_id, parent_id, created_hlc, order_key FROM documents WHERE id = ? AND __deleted = 0").get(id);
2300
1469
  }
1470
+ var ORDER_BY = "ORDER BY order_key IS NULL, order_key, created_hlc, id";
2301
1471
  function listDocuments(db, opts = {}) {
2302
- const cols = "SELECT id, title, database_id, parent_id, created_hlc FROM documents WHERE __deleted = 0";
1472
+ const cols = "SELECT id, title, database_id, parent_id, created_hlc, order_key FROM documents WHERE __deleted = 0";
2303
1473
  if (opts.parent_id !== undefined)
2304
- return db.query(`${cols} AND parent_id = ? ORDER BY created_hlc`).all(opts.parent_id);
1474
+ return db.query(`${cols} AND parent_id = ? ${ORDER_BY}`).all(opts.parent_id);
2305
1475
  if (opts.database_id)
2306
- return db.query(`${cols} AND database_id = ? ORDER BY created_hlc`).all(opts.database_id);
2307
- return db.query(`${cols} ORDER BY created_hlc`).all();
1476
+ return db.query(`${cols} AND database_id = ? ${ORDER_BY}`).all(opts.database_id);
1477
+ return db.query(`${cols} ${ORDER_BY}`).all();
1478
+ }
1479
+ function siblingWhere(parentId) {
1480
+ return parentId === null ? { clause: "parent_id IS NULL", args: [] } : { clause: "parent_id = ?", args: [parentId] };
1481
+ }
1482
+ function canEmitOrderKeys2(db) {
1483
+ return tableExists2(db, "meta") && tableExists2(db, "crdt_changes");
1484
+ }
1485
+ function tableExists2(db, table) {
1486
+ return db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
1487
+ }
1488
+ function lastSiblingOrderKey(db, parentId, excludeId) {
1489
+ const w = siblingWhere(parentId);
1490
+ const exclude = excludeId ? " AND id <> ?" : "";
1491
+ const row = db.query(`SELECT order_key FROM documents WHERE ${w.clause}${exclude}
1492
+ AND __deleted = 0 AND order_key IS NOT NULL
1493
+ ORDER BY order_key DESC, id DESC LIMIT 1`).get(...w.args, ...excludeId ? [excludeId] : []);
1494
+ return row?.order_key ?? null;
1495
+ }
1496
+ function orderedSiblings(db, parentId) {
1497
+ const w = siblingWhere(parentId);
1498
+ return db.query(`SELECT id, order_key FROM documents WHERE ${w.clause} AND __deleted = 0 ${ORDER_BY}`).all(...w.args);
1499
+ }
1500
+ function rebalanceSiblings(db, parentId) {
1501
+ const rows = orderedSiblings(db, parentId);
1502
+ const keys = keysBetween(null, null, rows.length);
1503
+ rows.forEach((row, i) => {
1504
+ if (row.order_key !== keys[i])
1505
+ emit(db, "documents", row.id, "order_key", keys[i]);
1506
+ });
2308
1507
  }
2309
- function updateDocument(db, id, fields) {
1508
+ function backfillSiblings(db, parentId) {
1509
+ const emitChange = canEmitOrderKeys2(db);
1510
+ const w = siblingWhere(parentId);
1511
+ const missing = db.query(`SELECT id FROM documents WHERE ${w.clause} AND __deleted = 0 AND order_key IS NULL
1512
+ ORDER BY created_hlc, id`).all(...w.args);
1513
+ if (!missing.length)
1514
+ return;
1515
+ const keys = keysBetween(lastSiblingOrderKey(db, parentId), null, missing.length);
1516
+ missing.forEach((row, i) => {
1517
+ if (emitChange)
1518
+ emit(db, "documents", row.id, "order_key", keys[i]);
1519
+ else
1520
+ db.query("UPDATE documents SET order_key = ? WHERE id = ?").run(keys[i], row.id);
1521
+ });
1522
+ }
1523
+ function backfillDocumentOrderKeys(db) {
1524
+ const parents = db.query("SELECT DISTINCT parent_id FROM documents WHERE __deleted = 0 AND order_key IS NULL").all();
1525
+ for (const p of parents)
1526
+ backfillSiblings(db, p.parent_id);
1527
+ }
1528
+ function placeInSiblings(db, id, parentId, anchor) {
1529
+ let cur = parentId;
1530
+ while (cur) {
1531
+ if (cur === id)
1532
+ throw new MhError("invalid_input", `cannot set parent_id: would create a cycle (${id})`);
1533
+ cur = getDocument(db, cur)?.parent_id ?? null;
1534
+ }
1535
+ const current = getDocument(db, id);
1536
+ if (current && (current.parent_id ?? null) !== parentId)
1537
+ emit(db, "documents", id, "parent_id", parentId);
1538
+ let key;
1539
+ if (!anchor) {
1540
+ key = keyBetween(lastSiblingOrderKey(db, parentId, id), null);
1541
+ } else {
1542
+ backfillSiblings(db, parentId);
1543
+ const neighbors = () => {
1544
+ const rows = orderedSiblings(db, parentId).filter((r) => r.id !== id);
1545
+ const to = rows.findIndex((r) => r.id === anchor.targetId);
1546
+ if (to < 0)
1547
+ throw new MhError("not_found", `no such target document: ${anchor.targetId}`);
1548
+ return anchor.where === "before" ? [rows[to - 1]?.order_key ?? null, rows[to].order_key] : [rows[to].order_key, rows[to + 1]?.order_key ?? null];
1549
+ };
1550
+ let [left, right] = neighbors();
1551
+ if (left !== null && right !== null && left >= right) {
1552
+ rebalanceSiblings(db, parentId);
1553
+ [left, right] = neighbors();
1554
+ }
1555
+ key = keyBetween(left, right);
1556
+ }
1557
+ emit(db, "documents", id, "order_key", key);
1558
+ }
1559
+ function moveDocument(db, id, targetId, where) {
1560
+ const src = getDocument(db, id);
1561
+ if (!src)
1562
+ throw new MhError("not_found", `no such document: ${id}`);
1563
+ if (id === targetId)
1564
+ return src;
1565
+ const target = getDocument(db, targetId);
1566
+ if (!target)
1567
+ throw new MhError("not_found", `no such target document: ${targetId}`);
1568
+ if (where === "into")
1569
+ placeInSiblings(db, id, targetId);
1570
+ else
1571
+ placeInSiblings(db, id, target.parent_id, { targetId, where });
1572
+ return getDocument(db, id);
1573
+ }
1574
+ function updateDocument(db, id, fields, opts = {}) {
2310
1575
  if (!getDocument(db, id))
2311
- throw new Error(`no such document: ${id}`);
1576
+ throw new MhError("not_found", `no such document: ${id}`);
1577
+ if (opts.ifMatch !== undefined && documentVersion(db, id) !== opts.ifMatch)
1578
+ throw new MhError("stale", `stale: document changed since ${opts.ifMatch}; re-read first`);
2312
1579
  if (fields.title !== undefined)
2313
1580
  emit(db, "documents", id, "title", fields.title);
2314
1581
  if (fields.database_id !== undefined)
2315
1582
  emit(db, "documents", id, "database_id", fields.database_id);
2316
1583
  if (fields.parent_id !== undefined) {
2317
- let cur = fields.parent_id;
2318
- while (cur) {
2319
- if (cur === id)
2320
- throw new Error(`cannot set parent_id: would create a cycle (${id})`);
2321
- cur = getDocument(db, cur)?.parent_id ?? null;
2322
- }
2323
- emit(db, "documents", id, "parent_id", fields.parent_id);
1584
+ const next = fields.parent_id ?? null;
1585
+ if ((getDocument(db, id).parent_id ?? null) !== next)
1586
+ placeInSiblings(db, id, next);
2324
1587
  }
2325
1588
  if (fields.body !== undefined) {
2326
1589
  ensureBlocks(db, id);
@@ -2330,19 +1593,19 @@ function updateDocument(db, id, fields) {
2330
1593
  }
2331
1594
  function editDocument(db, id, opts) {
2332
1595
  if (!getDocument(db, id))
2333
- throw new Error(`no such document: ${id}`);
1596
+ throw new MhError("not_found", `no such document: ${id}`);
2334
1597
  if (opts.ifMatch !== undefined && documentVersion(db, id) !== opts.ifMatch)
2335
- throw new Error(`stale: document changed since ${opts.ifMatch}; re-read first`);
1598
+ throw new MhError("stale", `stale: document changed since ${opts.ifMatch}; re-read first`);
2336
1599
  if (opts.old === "")
2337
- throw new Error("--old must not be empty");
1600
+ throw new MhError("invalid_input", "--old must not be empty");
2338
1601
  ensureBlocks(db, id);
2339
1602
  const blocks = liveBlocks(db, id);
2340
1603
  const body = serializeDocBlocks(blocks.map((b) => ({ text: b.text, blankAfter: b.blank_after })));
2341
1604
  const count = countOccurrences(body, opts.old);
2342
1605
  if (count === 0)
2343
- throw new Error(`anchor not found: no match for --old`);
1606
+ throw new MhError("invalid_input", `anchor not found: no match for --old`);
2344
1607
  if (count > 1 && !opts.replaceAll)
2345
- throw new Error(`${count} matches for --old; add surrounding context or use --replace-all`);
1608
+ throw new MhError("ambiguous", `${count} matches for --old; add surrounding context or use --replace-all`);
2346
1609
  if (!opts.old.includes(`
2347
1610
 
2348
1611
  `)) {
@@ -2362,7 +1625,7 @@ function editDocument(db, id, opts) {
2362
1625
  }
2363
1626
  function appendDocument(db, id, body) {
2364
1627
  if (!getDocument(db, id))
2365
- throw new Error(`no such document: ${id}`);
1628
+ throw new MhError("not_found", `no such document: ${id}`);
2366
1629
  ensureBlocks(db, id);
2367
1630
  const next = parseDocBlocks(body);
2368
1631
  if (next.length) {
@@ -2374,7 +1637,7 @@ function appendDocument(db, id, body) {
2374
1637
  }
2375
1638
  function prependDocument(db, id, body) {
2376
1639
  if (!getDocument(db, id))
2377
- throw new Error(`no such document: ${id}`);
1640
+ throw new MhError("not_found", `no such document: ${id}`);
2378
1641
  ensureBlocks(db, id);
2379
1642
  const next = parseDocBlocks(body);
2380
1643
  if (next.length) {
@@ -2408,6 +1671,110 @@ function countOccurrences(haystack, needle) {
2408
1671
  }
2409
1672
  return n;
2410
1673
  }
1674
+
1675
+ // src/core/db.ts
1676
+ function ensureDirs() {
1677
+ mkdirSync(metahubHome(), { recursive: true });
1678
+ mkdirSync(cacheDir(), { recursive: true });
1679
+ }
1680
+ function runSchema(db) {
1681
+ db.exec(CORE_SCHEMA);
1682
+ try {
1683
+ db.exec(FTS_SCHEMA);
1684
+ } catch {}
1685
+ }
1686
+ function ftsAvailable(db) {
1687
+ const row = db.query("SELECT 1 AS ok FROM sqlite_master WHERE name = 'search_fts'").get();
1688
+ return row != null;
1689
+ }
1690
+ function hasColumn(db, table, column) {
1691
+ const cols = db.query(`PRAGMA table_info(${table})`).all();
1692
+ return cols.some((c) => c.name === column);
1693
+ }
1694
+ function tableExists3(db, table) {
1695
+ return db.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) != null;
1696
+ }
1697
+ function migrateRecords(db) {
1698
+ if (!hasColumn(db, "records", "data"))
1699
+ db.exec("ALTER TABLE records ADD COLUMN data TEXT NOT NULL DEFAULT '{}'");
1700
+ if (!hasColumn(db, "records", "order_key"))
1701
+ db.exec("ALTER TABLE records ADD COLUMN order_key TEXT");
1702
+ if (!tableExists3(db, "record_values"))
1703
+ return;
1704
+ const tx = db.transaction(() => {
1705
+ db.exec(`
1706
+ UPDATE records SET data = coalesce((
1707
+ SELECT json_group_object(rv.property_id, json(rv.value))
1708
+ FROM record_values rv WHERE rv.record_id = records.id
1709
+ ), '{}')
1710
+ WHERE id IN (SELECT DISTINCT record_id FROM record_values);
1711
+ `);
1712
+ db.exec("DROP TABLE record_values");
1713
+ backfillRecordOrderKeys(db);
1714
+ });
1715
+ tx();
1716
+ backfillRecordOrderKeys(db);
1717
+ }
1718
+ function migratePeers(db) {
1719
+ const add = [
1720
+ ["token", "TEXT"],
1721
+ ["label", "TEXT"],
1722
+ ["node_id", "TEXT"],
1723
+ ["enabled", "INTEGER NOT NULL DEFAULT 1"],
1724
+ ["last_sync_at", "INTEGER"],
1725
+ ["last_status", "TEXT"],
1726
+ ["last_error", "TEXT"]
1727
+ ];
1728
+ for (const [col, decl] of add) {
1729
+ if (!hasColumn(db, "peers", col))
1730
+ db.exec(`ALTER TABLE peers ADD COLUMN ${col} ${decl}`);
1731
+ }
1732
+ }
1733
+ function migrateDocBlocks(db) {
1734
+ if (!hasColumn(db, "doc_blocks", "blank_after"))
1735
+ db.exec("ALTER TABLE doc_blocks ADD COLUMN blank_after INTEGER NOT NULL DEFAULT 0");
1736
+ }
1737
+ function migrateDocuments(db) {
1738
+ if (!hasColumn(db, "documents", "order_key"))
1739
+ db.exec("ALTER TABLE documents ADD COLUMN order_key TEXT");
1740
+ backfillDocumentOrderKeys(db);
1741
+ }
1742
+ function openMetahub() {
1743
+ ensureDirs();
1744
+ const db = new Database(dbPath(), { create: true });
1745
+ db.exec("PRAGMA journal_mode = WAL;");
1746
+ runSchema(db);
1747
+ migrateRecords(db);
1748
+ migratePeers(db);
1749
+ migrateDocBlocks(db);
1750
+ migrateDocuments(db);
1751
+ return db;
1752
+ }
1753
+
1754
+ // src/core/index.ts
1755
+ init_ids();
1756
+ init_crdt();
1757
+ init_node();
1758
+
1759
+ // src/core/context.ts
1760
+ var KEY = "current_db";
1761
+ function getCurrentDatabase(db) {
1762
+ const row = db.query("SELECT value FROM meta WHERE key = ?").get(KEY);
1763
+ if (!row)
1764
+ return null;
1765
+ const found = getDatabase(db, row.value);
1766
+ if (!found) {
1767
+ clearCurrentDatabase(db);
1768
+ return null;
1769
+ }
1770
+ return found;
1771
+ }
1772
+ function setCurrentDatabase(db, id) {
1773
+ db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(KEY, id);
1774
+ }
1775
+ function clearCurrentDatabase(db) {
1776
+ db.query("DELETE FROM meta WHERE key = ?").run(KEY);
1777
+ }
2411
1778
  // src/core/search.ts
2412
1779
  init_crdt();
2413
1780
  var TEXT_TYPES = "('text','url','select','multi_select','date')";
@@ -16897,6 +16264,7 @@ var PAIR_PATH = "/api/pair";
16897
16264
  // src/core/sync/client.ts
16898
16265
  init_node();
16899
16266
  init_crdt();
16267
+ init_errors();
16900
16268
  function getPeer(db, url2) {
16901
16269
  const row = db.query("SELECT pull_cursor, push_cursor, token FROM peers WHERE url = ?").get(url2);
16902
16270
  return row ?? { pull_cursor: 0, push_cursor: 0, token: null };
@@ -16918,7 +16286,7 @@ async function syncWithPeer(db, url2, token) {
16918
16286
  body: JSON.stringify({ node_id: node, since: peer.pull_cursor, changes: toPush.changes })
16919
16287
  });
16920
16288
  if (!res.ok)
16921
- throw new Error(`sync failed: ${res.status} ${await res.text()}`);
16289
+ throw new MhError(res.status === 401 || res.status === 403 ? "auth" : "network", `sync failed: ${res.status} ${await res.text()}`);
16922
16290
  const data = await res.json();
16923
16291
  const pulled = data.changes?.length ?? 0;
16924
16292
  ingest(db, data.changes ?? []);
@@ -16926,6 +16294,7 @@ async function syncWithPeer(db, url2, token) {
16926
16294
  return { pushed: toPush.changes.length, pulled };
16927
16295
  }
16928
16296
  // src/core/sync/server.ts
16297
+ init_errors();
16929
16298
  init_node();
16930
16299
 
16931
16300
  // src/core/sync/token.ts
@@ -17028,7 +16397,7 @@ function getServerConfig(db) {
17028
16397
  // package.json
17029
16398
  var package_default = {
17030
16399
  name: "@tensorix/metahub",
17031
- version: "0.1.8",
16400
+ version: "0.1.10",
17032
16401
  description: "Metahub core library and CLI",
17033
16402
  license: "AGPL-3.0-only",
17034
16403
  type: "module",
@@ -17079,356 +16448,113 @@ var package_default = {
17079
16448
  // src/core/sync/routes.ts
17080
16449
  init_crdt();
17081
16450
 
17082
- // src/core/sync/webui-routes.ts
17083
- var DatabaseSchema = exports_external.object({
16451
+ // src/core/sync/sites-routes.ts
16452
+ init_errors();
16453
+ init_sites();
16454
+ var SiteSchema = exports_external.object({
17084
16455
  id: exports_external.string(),
17085
16456
  name: exports_external.string(),
17086
- icon: exports_external.string().nullable(),
16457
+ title: exports_external.string().nullable(),
17087
16458
  created_hlc: exports_external.string()
17088
16459
  });
17089
- var PropertySchema = exports_external.object({
17090
- id: exports_external.string(),
17091
- database_id: exports_external.string(),
17092
- name: exports_external.string(),
17093
- type: exports_external.string(),
17094
- config: exports_external.any().nullable(),
17095
- position: exports_external.number()
17096
- });
17097
- var RecordSchema = exports_external.object({
16460
+ var SiteWithCountSchema = SiteSchema.extend({ file_count: exports_external.number() });
16461
+ var SiteFileSchema = exports_external.object({
17098
16462
  id: exports_external.string(),
17099
- database_id: exports_external.string(),
17100
- values: exports_external.record(exports_external.string(), exports_external.any())
17101
- });
17102
- var MoveRecordReq = exports_external.object({
17103
- target: exports_external.string(),
17104
- where: exports_external.enum(["before", "after"])
17105
- });
17106
- var DocumentSummarySchema = exports_external.object({
17107
- id: exports_external.string(),
17108
- title: exports_external.string(),
17109
- database_id: exports_external.string().nullable(),
17110
- parent_id: exports_external.string().nullable(),
17111
- created_hlc: exports_external.string()
17112
- });
17113
- var DocumentSchema = DocumentSummarySchema.extend({ body: exports_external.string().nullable() });
17114
- var SearchHitSchema = exports_external.object({
17115
- type: exports_external.string(),
17116
- id: exports_external.string(),
17117
- database_id: exports_external.string().nullable(),
17118
- title: exports_external.string().optional(),
17119
- snippet: exports_external.string()
17120
- });
17121
- var OkSchema = exports_external.object({ ok: exports_external.boolean() });
17122
- var CreateDatabaseReq = exports_external.object({ name: exports_external.string(), icon: exports_external.string().optional() });
17123
- var UpdateDatabaseReq = exports_external.object({
17124
- name: exports_external.string().optional(),
17125
- icon: exports_external.string().nullable().optional()
17126
- });
17127
- var CreatePropertyReq = exports_external.object({
17128
- db: exports_external.string(),
17129
- name: exports_external.string(),
17130
- type: exports_external.string(),
17131
- config: exports_external.any().optional()
17132
- });
17133
- var UpdatePropertyReq = exports_external.object({
17134
- name: exports_external.string().optional(),
17135
- type: exports_external.string().optional(),
17136
- config: exports_external.any().optional(),
17137
- position: exports_external.number().optional()
17138
- });
17139
- var SetWidthReq = exports_external.object({ width: exports_external.number() });
17140
- var RecordValuesReq = exports_external.record(exports_external.string(), exports_external.any());
17141
- var CreateDocumentReq = exports_external.object({
17142
- title: exports_external.string(),
17143
- body: exports_external.string().optional(),
17144
- database_id: exports_external.string().optional(),
17145
- parent_id: exports_external.string().optional()
17146
- });
17147
- var UpdateDocumentReq = exports_external.object({
17148
- title: exports_external.string().optional(),
17149
- body: exports_external.string().optional(),
17150
- parent_id: exports_external.string().nullable().optional()
16463
+ site_id: exports_external.string(),
16464
+ path: exports_external.string(),
16465
+ content_type: exports_external.string(),
16466
+ encoding: exports_external.string()
17151
16467
  });
16468
+ var CreateSiteBody = exports_external.object({ name: exports_external.string(), title: exports_external.string().optional() });
16469
+ var UpdateSiteBody = exports_external.object({ name: exports_external.string().optional(), title: exports_external.string().optional() });
17152
16470
  function need(req, key) {
17153
16471
  const v = new URL(req.url).searchParams.get(key);
17154
16472
  if (!v)
17155
- throw new Error(`missing query param: ${key}`);
16473
+ throw new MhError("invalid_input", `missing query param: ${key}`);
17156
16474
  return v;
17157
16475
  }
17158
- function opt(req, key) {
17159
- return new URL(req.url).searchParams.get(key) ?? undefined;
17160
- }
17161
16476
  function handle(fn) {
17162
16477
  return async (req, ctx) => {
17163
16478
  try {
17164
- const out = await fn(req, ctx);
17165
- if (out instanceof Response)
17166
- return out;
17167
- return Response.json(out ?? null);
16479
+ return Response.json(await fn(req, ctx) ?? null);
17168
16480
  } catch (e) {
17169
- return Response.json({ error: e.message }, { status: 400 });
16481
+ return errorResponse(e);
17170
16482
  }
17171
16483
  };
17172
16484
  }
17173
- var VersionSchema = exports_external.object({ version: exports_external.string() });
17174
- var webuiRoutes = [
16485
+ var sitesRoutes = [
17175
16486
  {
17176
16487
  method: "GET",
17177
- path: "/api/version",
17178
- summary: "Version of the running core (sidecar). Used by the desktop app's settings footer.",
17179
- response: VersionSchema,
17180
- handler: () => Response.json({ version: package_default.version })
16488
+ path: "/api/sites",
16489
+ summary: "List published sites (with file counts)",
16490
+ response: exports_external.array(SiteWithCountSchema),
16491
+ handler: handle((_req, { db }) => listSites(db).map((s) => ({ ...s, file_count: listFiles(db, s.id).length })))
17181
16492
  },
17182
16493
  {
17183
16494
  method: "GET",
17184
- path: "/api/databases",
17185
- summary: "List all databases",
17186
- response: exports_external.array(DatabaseSchema),
17187
- handler: handle((_req, { db }) => listDatabases(db))
16495
+ path: "/api/site/files",
16496
+ summary: "List a site's files (manifest). Query: ?site=<id|name>",
16497
+ response: exports_external.array(SiteFileSchema),
16498
+ handler: handle((req, { db }) => listFiles(db, resolveSite(db, need(req, "site")).id))
17188
16499
  },
17189
16500
  {
17190
16501
  method: "POST",
17191
- path: "/api/databases",
17192
- summary: "Create a database",
17193
- request: CreateDatabaseReq,
17194
- response: DatabaseSchema,
16502
+ path: "/api/sites",
16503
+ summary: "Create a site",
16504
+ request: CreateSiteBody,
16505
+ response: SiteSchema,
17195
16506
  handler: handle(async (req, { db }) => {
17196
- const body = await req.json();
17197
- return createDatabase(db, body);
16507
+ const body = CreateSiteBody.parse(await req.json());
16508
+ return createSite(db, body);
17198
16509
  })
17199
16510
  },
17200
16511
  {
17201
16512
  method: "PATCH",
17202
- path: "/api/database",
17203
- summary: "Rename a database or change its icon. Query: ?id=<id>",
17204
- request: UpdateDatabaseReq,
17205
- response: DatabaseSchema,
16513
+ path: "/api/site",
16514
+ summary: "Rename a site or change its title. Query: ?id=<id>",
16515
+ request: UpdateSiteBody,
16516
+ response: SiteSchema,
17206
16517
  handler: handle(async (req, { db }) => {
17207
- const body = await req.json();
17208
- return updateDatabase(db, need(req, "id"), body);
16518
+ const body = UpdateSiteBody.parse(await req.json());
16519
+ return updateSite(db, need(req, "id"), body);
17209
16520
  })
17210
16521
  },
17211
16522
  {
17212
16523
  method: "DELETE",
17213
- path: "/api/database",
17214
- summary: "Delete a database (and stop it being the current one). Query: ?id=<id>",
17215
- response: OkSchema,
17216
- handler: handle((req, { db }) => ({ ok: deleteDatabase(db, need(req, "id")) }))
17217
- },
17218
- {
17219
- method: "GET",
17220
- path: "/api/properties",
17221
- summary: "List a database's properties (columns). Query: ?db=<id>",
17222
- response: exports_external.array(PropertySchema),
17223
- handler: handle((req, { db }) => listProperties(db, need(req, "db")))
16524
+ path: "/api/site",
16525
+ summary: "Delete a site and its files. Query: ?id=<id>",
16526
+ response: exports_external.object({ ok: exports_external.boolean() }),
16527
+ handler: handle((req, { db }) => ({ ok: deleteSite(db, need(req, "id")) }))
17224
16528
  },
17225
16529
  {
17226
16530
  method: "POST",
17227
- path: "/api/properties",
17228
- summary: "Add a property to a database",
17229
- request: CreatePropertyReq,
17230
- response: PropertySchema,
17231
- handler: handle(async (req, { db }) => {
17232
- const body = await req.json();
17233
- return addProperty(db, body.db, { name: body.name, type: body.type, config: body.config });
17234
- })
17235
- },
17236
- {
17237
- method: "PATCH",
17238
- path: "/api/property",
17239
- summary: "Update a property: rename, change type, edit config/options, reorder. Query: ?id=<id>",
17240
- request: UpdatePropertyReq,
17241
- response: PropertySchema,
17242
- handler: handle(async (req, { db }) => {
17243
- const body = await req.json();
17244
- return updateProperty(db, need(req, "id"), body);
17245
- })
17246
- },
17247
- {
17248
- method: "PATCH",
17249
- path: "/api/property/width",
17250
- summary: "Set a property's table column width (px). Query: ?id=<id>",
17251
- request: SetWidthReq,
17252
- response: PropertySchema,
16531
+ path: "/api/site/file",
16532
+ summary: "Upload/replace a file. Query: ?site=<id|name>&path=<path>; body is raw bytes",
16533
+ response: SiteFileSchema,
17253
16534
  handler: handle(async (req, { db }) => {
17254
- const body = await req.json();
17255
- return setPropertyWidth(db, need(req, "id"), body.width);
17256
- })
17257
- },
17258
- {
17259
- method: "DELETE",
17260
- path: "/api/property",
17261
- summary: "Delete a property (column) and its cells. Query: ?id=<id>",
17262
- response: OkSchema,
17263
- handler: handle((req, { db }) => ({ ok: removeProperty(db, need(req, "id")) }))
17264
- },
17265
- {
17266
- method: "GET",
17267
- path: "/api/records",
17268
- summary: "List records in a database. Query: ?db=<id>&sort=<field>&limit=<n>",
17269
- response: exports_external.array(RecordSchema),
17270
- handler: handle((req, { db }) => {
17271
- const limit = opt(req, "limit");
17272
- return listRecords(db, need(req, "db"), {
17273
- sort: opt(req, "sort"),
17274
- limit: limit ? Number(limit) : undefined
16535
+ const site = resolveSite(db, need(req, "site"));
16536
+ const ct = req.headers.get("content-type");
16537
+ const file2 = await putFile(db, site.id, need(req, "path"), {
16538
+ data: await req.arrayBuffer(),
16539
+ contentType: ct && ct !== "application/octet-stream" ? ct : undefined
17275
16540
  });
17276
- })
17277
- },
17278
- {
17279
- method: "POST",
17280
- path: "/api/records",
17281
- summary: "Create a record. Query: ?db=<id>; body is a {field: value} map",
17282
- request: RecordValuesReq,
17283
- response: RecordSchema,
17284
- handler: handle(async (req, { db }) => {
17285
- const values = await req.json();
17286
- return createRecord(db, need(req, "db"), values);
17287
- })
17288
- },
17289
- {
17290
- method: "GET",
17291
- path: "/api/record",
17292
- summary: "Get one record. Query: ?id=<id>",
17293
- response: RecordSchema,
17294
- handler: handle((req, { db }) => {
17295
- const rec = getRecord(db, need(req, "id"));
17296
- return rec ?? new Response("not found", { status: 404 });
17297
- })
17298
- },
17299
- {
17300
- method: "PATCH",
17301
- path: "/api/record",
17302
- summary: "Update record fields. Query: ?id=<id>; body is a {field: value} map",
17303
- request: RecordValuesReq,
17304
- response: RecordSchema,
17305
- handler: handle(async (req, { db }) => {
17306
- const values = await req.json();
17307
- return updateRecord(db, need(req, "id"), values);
17308
- })
17309
- },
17310
- {
17311
- method: "PATCH",
17312
- path: "/api/record/order",
17313
- summary: "Move a record before or after another record. Query: ?id=<id>",
17314
- request: MoveRecordReq,
17315
- response: RecordSchema,
17316
- handler: handle(async (req, { db }) => {
17317
- const body = await req.json();
17318
- return moveRecord(db, need(req, "id"), body.target, body.where);
17319
- })
17320
- },
17321
- {
17322
- method: "DELETE",
17323
- path: "/api/record",
17324
- summary: "Delete a record. Query: ?id=<id>",
17325
- response: OkSchema,
17326
- handler: handle((req, { db }) => ({ ok: deleteRecord(db, need(req, "id")) }))
17327
- },
17328
- {
17329
- method: "GET",
17330
- path: "/api/documents",
17331
- summary: "List documents. Optional query: ?db=<id> or ?parent=<id>",
17332
- response: exports_external.array(DocumentSummarySchema),
17333
- handler: handle((req, { db }) => listDocuments(db, { database_id: opt(req, "db"), parent_id: opt(req, "parent") }))
17334
- },
17335
- {
17336
- method: "POST",
17337
- path: "/api/documents",
17338
- summary: "Create a document",
17339
- request: CreateDocumentReq,
17340
- response: DocumentSchema,
17341
- handler: handle(async (req, { db }) => {
17342
- const body = await req.json();
17343
- return createDocument(db, body);
17344
- })
17345
- },
17346
- {
17347
- method: "GET",
17348
- path: "/api/document",
17349
- summary: "Get one document with full body. Query: ?id=<id>",
17350
- response: DocumentSchema,
17351
- handler: handle((req, { db }) => {
17352
- const doc2 = getDocument(db, need(req, "id"));
17353
- return doc2 ?? new Response("not found", { status: 404 });
17354
- })
17355
- },
17356
- {
17357
- method: "PATCH",
17358
- path: "/api/document",
17359
- summary: "Update a document's title/body/parent_id (null = top level). Query: ?id=<id>",
17360
- request: UpdateDocumentReq,
17361
- response: DocumentSchema,
17362
- handler: handle(async (req, { db }) => {
17363
- const body = await req.json();
17364
- return updateDocument(db, need(req, "id"), body);
16541
+ const { content: _content, ...summary } = file2;
16542
+ return summary;
17365
16543
  })
17366
16544
  },
17367
16545
  {
17368
16546
  method: "DELETE",
17369
- path: "/api/document",
17370
- summary: "Delete a document. Query: ?id=<id>",
17371
- response: OkSchema,
17372
- handler: handle((req, { db }) => ({ ok: deleteDocument(db, need(req, "id")) }))
17373
- },
17374
- {
17375
- method: "GET",
17376
- path: "/api/search",
17377
- summary: "Full-text search across documents and records. Query: ?q=<text>&limit=<n>",
17378
- response: exports_external.array(SearchHitSchema),
17379
- handler: handle((req, { db }) => {
17380
- const limit = opt(req, "limit");
17381
- return search(db, need(req, "q"), { limit: limit ? Number(limit) : undefined });
17382
- })
16547
+ path: "/api/site/file",
16548
+ summary: "Delete a file. Query: ?site=<id|name>&path=<path>",
16549
+ response: exports_external.object({ ok: exports_external.boolean() }),
16550
+ handler: handle((req, { db }) => ({
16551
+ ok: deleteFile(db, resolveSite(db, need(req, "site")).id, need(req, "path"))
16552
+ }))
17383
16553
  }
17384
16554
  ];
17385
16555
 
17386
- // src/core/sync/sites-routes.ts
17387
- init_sites();
17388
- var SiteSchema = exports_external.object({
17389
- id: exports_external.string(),
17390
- name: exports_external.string(),
17391
- title: exports_external.string().nullable(),
17392
- created_hlc: exports_external.string()
17393
- });
17394
- var SiteFileSchema = exports_external.object({
17395
- id: exports_external.string(),
17396
- site_id: exports_external.string(),
17397
- path: exports_external.string(),
17398
- content_type: exports_external.string(),
17399
- encoding: exports_external.string()
17400
- });
17401
- function need2(req, key) {
17402
- const v = new URL(req.url).searchParams.get(key);
17403
- if (!v)
17404
- throw new Error(`missing query param: ${key}`);
17405
- return v;
17406
- }
17407
- function handle2(fn) {
17408
- return async (req, ctx) => {
17409
- try {
17410
- return Response.json(await fn(req, ctx) ?? null);
17411
- } catch (e) {
17412
- return Response.json({ error: e.message }, { status: 400 });
17413
- }
17414
- };
17415
- }
17416
- var sitesRoutes = [
17417
- {
17418
- method: "GET",
17419
- path: "/api/sites",
17420
- summary: "List published sites",
17421
- response: exports_external.array(SiteSchema),
17422
- handler: handle2((_req, { db }) => listSites(db))
17423
- },
17424
- {
17425
- method: "GET",
17426
- path: "/api/site/files",
17427
- summary: "List a site's files (manifest). Query: ?site=<id|name>",
17428
- response: exports_external.array(SiteFileSchema),
17429
- handler: handle2((req, { db }) => listFiles(db, resolveSite(db, need2(req, "site")).id))
17430
- }
17431
- ];
16556
+ // src/core/sync/peers-routes.ts
16557
+ init_errors();
17432
16558
 
17433
16559
  // src/core/sync/pairing.ts
17434
16560
  init_ids();
@@ -17483,6 +16609,7 @@ async function syncAllPeers(db) {
17483
16609
  }
17484
16610
 
17485
16611
  // src/core/sync/pairing.ts
16612
+ init_errors();
17486
16613
  var DEFAULT_PAIR_TTL_MS = parseDuration(process.env.METAHUB_PAIR_TTL, 10 * 60000);
17487
16614
  function generatePairingCode(db, ttlMs = DEFAULT_PAIR_TTL_MS) {
17488
16615
  const now = Date.now();
@@ -17511,7 +16638,7 @@ function revokeGrant(db, tokenOrPrefix) {
17511
16638
  }
17512
16639
  function handlePairRequest(db, node, body) {
17513
16640
  if (!redeemPairingCode(db, body.code)) {
17514
- throw new Error("invalid or expired pairing code");
16641
+ throw new MhError("auth", "invalid or expired pairing code");
17515
16642
  }
17516
16643
  if (body.self_url) {
17517
16644
  addPeer(db, { url: body.self_url, token: body.grant, node_id: body.node_id });
@@ -17528,7 +16655,7 @@ async function performPairing(db, node, remoteUrl, code, selfUrl) {
17528
16655
  body: JSON.stringify(reqBody)
17529
16656
  });
17530
16657
  if (!res.ok)
17531
- throw new Error(`pairing failed: ${res.status} ${await res.text()}`);
16658
+ throw new MhError(res.status === 401 || res.status === 403 ? "auth" : "network", `pairing failed: ${res.status} ${await res.text()}`);
17532
16659
  const data = await res.json();
17533
16660
  addPeer(db, { url: remoteUrl, token: data.grant, node_id: data.node_id });
17534
16661
  return { node_id: data.node_id, url: remoteUrl };
@@ -17572,14 +16699,14 @@ var GrantSchema = exports_external.object({
17572
16699
  created_at: exports_external.number().nullable()
17573
16700
  });
17574
16701
  var RevokeRes = exports_external.object({ revoked: exports_external.number() });
17575
- var OkSchema2 = exports_external.object({ ok: exports_external.boolean() });
17576
- function need3(req, key) {
16702
+ var OkSchema = exports_external.object({ ok: exports_external.boolean() });
16703
+ function need2(req, key) {
17577
16704
  const v = new URL(req.url).searchParams.get(key);
17578
16705
  if (!v)
17579
- throw new Error(`missing query param: ${key}`);
16706
+ throw new MhError("invalid_input", `missing query param: ${key}`);
17580
16707
  return v;
17581
16708
  }
17582
- function handle3(fn) {
16709
+ function handle2(fn) {
17583
16710
  return async (req, ctx) => {
17584
16711
  try {
17585
16712
  const out = await fn(req, ctx);
@@ -17587,7 +16714,7 @@ function handle3(fn) {
17587
16714
  return out;
17588
16715
  return Response.json(out ?? null);
17589
16716
  } catch (e) {
17590
- return Response.json({ error: e.message }, { status: 400 });
16717
+ return errorResponse(e);
17591
16718
  }
17592
16719
  };
17593
16720
  }
@@ -17598,7 +16725,7 @@ var peersRoutes = [
17598
16725
  summary: "Pairing handshake: redeem a one-time code and exchange credentials",
17599
16726
  request: PairRequestSchema,
17600
16727
  response: PairResponseSchema,
17601
- handler: handle3(async (req, { db, node }) => {
16728
+ handler: handle2(async (req, { db, node }) => {
17602
16729
  const body = await req.json();
17603
16730
  return handlePairRequest(db, node, body);
17604
16731
  })
@@ -17608,7 +16735,7 @@ var peersRoutes = [
17608
16735
  path: "/api/pair/new",
17609
16736
  summary: "Mint a one-time pairing code for another device to redeem",
17610
16737
  response: PairingCodeSchema,
17611
- handler: handle3((_req, { db }) => generatePairingCode(db))
16738
+ handler: handle2((_req, { db }) => generatePairingCode(db))
17612
16739
  },
17613
16740
  {
17614
16741
  method: "POST",
@@ -17616,7 +16743,7 @@ var peersRoutes = [
17616
16743
  summary: "Pair this server with a remote one using its one-time code",
17617
16744
  request: AddPeerReq,
17618
16745
  response: AddPeerRes,
17619
- handler: handle3(async (req, { db, node }) => {
16746
+ handler: handle2(async (req, { db, node }) => {
17620
16747
  const body = await req.json();
17621
16748
  return performPairing(db, node, body.url, body.code, body.self_url);
17622
16749
  })
@@ -17626,16 +16753,16 @@ var peersRoutes = [
17626
16753
  path: "/api/peers",
17627
16754
  summary: "List configured sync peers with status",
17628
16755
  response: exports_external.array(PeerSchema),
17629
- handler: handle3((_req, { db }) => listPeers(db))
16756
+ handler: handle2((_req, { db }) => listPeers(db))
17630
16757
  },
17631
16758
  {
17632
16759
  method: "PATCH",
17633
16760
  path: "/api/peer",
17634
16761
  summary: "Update a peer (enable/disable or label). Query: ?url=<url>",
17635
16762
  request: UpdatePeerReq,
17636
- response: OkSchema2,
17637
- handler: handle3(async (req, { db }) => {
17638
- const url2 = need3(req, "url");
16763
+ response: OkSchema,
16764
+ handler: handle2(async (req, { db }) => {
16765
+ const url2 = need2(req, "url");
17639
16766
  const body = await req.json();
17640
16767
  if (body.enabled != null)
17641
16768
  setPeerEnabled(db, url2, body.enabled);
@@ -17648,33 +16775,51 @@ var peersRoutes = [
17648
16775
  method: "DELETE",
17649
16776
  path: "/api/peer",
17650
16777
  summary: "Remove a sync peer. Query: ?url=<url>",
17651
- response: OkSchema2,
17652
- handler: handle3((req, { db }) => ({ ok: removePeer(db, need3(req, "url")) }))
16778
+ response: OkSchema,
16779
+ handler: handle2((req, { db }) => ({ ok: removePeer(db, need2(req, "url")) }))
17653
16780
  },
17654
16781
  {
17655
16782
  method: "POST",
17656
16783
  path: "/api/peer/sync",
17657
16784
  summary: "Manually sync once with a peer. Query: ?url=<url>",
17658
16785
  response: PeerSyncRes,
17659
- handler: handle3((req, { db }) => syncPeer(db, need3(req, "url")))
16786
+ handler: handle2((req, { db }) => syncPeer(db, need2(req, "url")))
17660
16787
  },
17661
16788
  {
17662
16789
  method: "GET",
17663
16790
  path: "/api/grants",
17664
16791
  summary: "List credentials this server issued and accepts on /sync (inbound)",
17665
16792
  response: exports_external.array(GrantSchema),
17666
- handler: handle3((_req, { db }) => listGrants(db))
16793
+ handler: handle2((_req, { db }) => listGrants(db))
17667
16794
  },
17668
16795
  {
17669
16796
  method: "DELETE",
17670
16797
  path: "/api/grant",
17671
16798
  summary: "Revoke an issued credential by token or prefix. Query: ?token=<token>",
17672
16799
  response: RevokeRes,
17673
- handler: handle3((req, { db }) => ({ revoked: revokeGrant(db, need3(req, "token")) }))
16800
+ handler: handle2((req, { db }) => ({ revoked: revokeGrant(db, need2(req, "token")) }))
17674
16801
  }
17675
16802
  ];
17676
16803
 
17677
16804
  // src/core/sync/routes.ts
16805
+ init_errors();
16806
+ var HTTP_STATUS = {
16807
+ invalid_input: 400,
16808
+ not_found: 404,
16809
+ ambiguous: 400,
16810
+ stale: 409,
16811
+ conflict: 409,
16812
+ auth: 401,
16813
+ network: 502,
16814
+ port_in_use: 500
16815
+ };
16816
+ function errorResponse(e) {
16817
+ const message = e instanceof Error ? e.message : String(e);
16818
+ const code = errorCode(e);
16819
+ return Response.json(code ? { error: message, code } : { error: message }, {
16820
+ status: code ? HTTP_STATUS[code] : 400
16821
+ });
16822
+ }
17678
16823
  var syncRoutes = [
17679
16824
  {
17680
16825
  method: "POST",
@@ -17699,12 +16844,12 @@ var syncRoutes = [
17699
16844
  }
17700
16845
  }
17701
16846
  ];
17702
- var routes = [...syncRoutes, ...webuiRoutes, ...sitesRoutes, ...peersRoutes];
16847
+ var routes = [...syncRoutes, ...sitesRoutes, ...peersRoutes];
17703
16848
 
17704
16849
  // src/core/sync/openapi.ts
17705
- function buildOpenApi(version2) {
16850
+ function buildOpenApi(version2, routes2 = routes) {
17706
16851
  const paths = {};
17707
- for (const route of routes) {
16852
+ for (const route of routes2) {
17708
16853
  const op = {
17709
16854
  summary: route.summary,
17710
16855
  responses: {
@@ -17906,11 +17051,11 @@ function unauthorized() {
17906
17051
  }
17907
17052
 
17908
17053
  // src/core/sync/server.ts
17909
- class PortInUseError extends Error {
17054
+ class PortInUseError extends MhError {
17910
17055
  port;
17911
17056
  host;
17912
17057
  constructor(port, host) {
17913
- super(`port ${port} is already in use \u2014 another mh server may be running. ` + `Stop it, or start on a free port with --port <n>.`);
17058
+ super("port_in_use", `port ${port} is already in use \u2014 another mh server may be running. ` + `Stop it, or start on a free port with --port <n>.`);
17914
17059
  this.port = port;
17915
17060
  this.host = host;
17916
17061
  this.name = "PortInUseError";
@@ -17928,6 +17073,7 @@ function startServer(opts = {}) {
17928
17073
  const db = openMetahub();
17929
17074
  const node = getNodeId(db);
17930
17075
  const ctx = { db, node };
17076
+ const allRoutes = opts.ui ? [...routes, ...opts.ui.routes] : routes;
17931
17077
  const cfg = getServerConfig(db);
17932
17078
  const port = opts.port ?? cfg.port;
17933
17079
  const host = opts.host ?? cfg.host;
@@ -17961,14 +17107,13 @@ function startServer(opts = {}) {
17961
17107
  return r ? Response.json(r) : unauthorized();
17962
17108
  }
17963
17109
  if (req.method === "GET" && url2.pathname === "/docs.json") {
17964
- return Response.json(buildOpenApi(package_default.version));
17110
+ return Response.json(buildOpenApi(package_default.version, allRoutes));
17965
17111
  }
17966
17112
  if (req.method === "GET" && url2.pathname === "/docs") {
17967
17113
  return withShim(new Response(scalarHtml("/docs.json"), { headers: HTML_HEADERS }), auth);
17968
17114
  }
17969
- if (req.method === "GET" && (url2.pathname === "/" || url2.pathname === "/webui.js")) {
17970
- const { serveWebui: serveWebui2 } = await Promise.resolve().then(() => (init_webui(), exports_webui));
17971
- const res = await serveWebui2(req);
17115
+ if (req.method === "GET" && opts.ui) {
17116
+ const res = await opts.ui.serveAssets(req);
17972
17117
  if (res)
17973
17118
  return withShim(res, auth);
17974
17119
  }
@@ -17978,7 +17123,7 @@ function startServer(opts = {}) {
17978
17123
  if (res)
17979
17124
  return withShim(res, auth);
17980
17125
  }
17981
- const route = routes.find((r) => r.method === req.method && r.path === url2.pathname);
17126
+ const route = allRoutes.find((r) => r.method === req.method && r.path === url2.pathname);
17982
17127
  if (route)
17983
17128
  return route.handler(req, ctx);
17984
17129
  return new Response("not found", { status: 404 });
@@ -17990,7 +17135,7 @@ function startServer(opts = {}) {
17990
17135
  throw new PortInUseError(port, host);
17991
17136
  }
17992
17137
  if (code === "EACCES") {
17993
- throw new Error(`permission denied binding ${host}:${port} \u2014 ports below 1024 need elevated privileges; pick a higher port with --port`);
17138
+ throw new MhError("invalid_input", `permission denied binding ${host}:${port} \u2014 ports below 1024 need elevated privileges; pick a higher port with --port`);
17994
17139
  }
17995
17140
  throw e;
17996
17141
  }
@@ -18020,7 +17165,1826 @@ function startServer(opts = {}) {
18020
17165
  }
18021
17166
  };
18022
17167
  }
17168
+ // src/webui/server/assets.ts
17169
+ import { fileURLToPath } from "url";
17170
+ import { readdirSync, statSync } from "fs";
17171
+ import { join as join3 } from "path";
17172
+
17173
+ // src/webui/styles.css
17174
+ var styles_default = ` :root {
17175
+ /* Tells browser chrome (status-bar text, form controls, soft keyboard,
17176
+ overscroll canvas) which appearance the page renders. Keyed off
17177
+ data-resolved, so a manual theme toggle retunes it too. */
17178
+ color-scheme: light;
17179
+ --bg:#ffffff; --surface:#fbfbfa; --surface-2:#f1f1ef; --sidebar:#f7f7f5;
17180
+ --fg:#2c2c30; --fg-soft:#5b5b62; --muted:#75757f; --line:#ebebe8; --line-strong:#dededb;
17181
+ --accent:#4a55d6; --accent-fg:#ffffff; --accent-soft:#eef0ff; --danger:#d6473b; --danger-soft:#fdeceb;
17182
+ --success:#1f8f3e;
17183
+ --hover:rgba(45,45,55,.045); --hover-2:rgba(45,45,55,.08);
17184
+ --shadow-sm:0 1px 2px rgba(20,20,40,.06),0 1px 1px rgba(20,20,40,.04);
17185
+ --shadow-md:0 4px 12px rgba(20,20,40,.08),0 2px 4px rgba(20,20,40,.05);
17186
+ --shadow-lg:0 16px 48px rgba(20,20,40,.16),0 4px 12px rgba(20,20,40,.08);
17187
+ --radius:8px; --radius-sm:6px; --radius-lg:14px; --radius-xl:16px;
17188
+ --fs-body:14px; --fs-ui:13.5px; --fs-sm:12.5px; --fs-xs:11.5px; --fs-micro:11px;
17189
+ --ui:"Hanken Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
17190
+ --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,monospace;
17191
+ --note-lh:1.6;
17192
+ --code-bg:#f8f8f6;
17193
+ --hl-cmt:#9a9aa2; --hl-key:#a626a4; --hl-str:#28823f; --hl-num:#b76a00;
17194
+ --hl-fn:#3f51d6; --hl-type:#0a7ea4; --hl-var:#c2410c;
17195
+ }
17196
+ /* Dark palette \u2014 exactly once. JS resolves the user's choice (including
17197
+ * "system" \u2192 the OS preference) to <html data-resolved="light|dark"> before
17198
+ * first paint (FOUC script in the shell) and on every change (theme.ts), so
17199
+ * CSS never needs a prefers-color-scheme duplicate of this block. */
17200
+ :root[data-resolved="dark"] {
17201
+ color-scheme: dark;
17202
+ --bg:#1a1a1c; --surface:#202022; --surface-2:#2a2a2d; --sidebar:#171719;
17203
+ --fg:#e6e6e9; --fg-soft:#b4b4bb; --muted:#8b8b95; --line:#2c2c30; --line-strong:#3a3a40;
17204
+ --accent:#7b86ff; --accent-fg:#14141a; --accent-soft:#23243a; --danger:#f87168; --danger-soft:#341e1c;
17205
+ --success:#3fb950;
17206
+ --hover:rgba(255,255,255,.05); --hover-2:rgba(255,255,255,.09);
17207
+ --shadow-sm:0 1px 2px rgba(0,0,0,.4); --shadow-md:0 4px 14px rgba(0,0,0,.45); --shadow-lg:0 20px 56px rgba(0,0,0,.6);
17208
+ --code-bg:#1d1d20;
17209
+ --hl-cmt:#7d7d86; --hl-key:#d291e4; --hl-str:#7ed492; --hl-num:#e0a566;
17210
+ --hl-fn:#86a8ff; --hl-type:#5fc6e0; --hl-var:#f0936b;
17211
+ }
17212
+ * { box-sizing: border-box; }
17213
+ html, body { margin: 0; height: 100%; }
17214
+ /* Explicit backgrounds on BOTH html and body: iOS 26 Safari tints its liquid
17215
+ glass chrome by sampling page CSS (theme-color is ignored there), and a
17216
+ transparent root is a known path to white toolbars on dark pages. */
17217
+ html { background: var(--bg); scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent; }
17218
+ /* Honor reduced motion everywhere; near-zero (not none) so animationend /
17219
+ final keyframe states still apply. The empty-state illustration adds its
17220
+ own targeted overrides below. */
17221
+ @media (prefers-reduced-motion: reduce) {
17222
+ *, ::before, ::after {
17223
+ animation-duration:.01ms !important; animation-iteration-count:1 !important;
17224
+ transition-duration:.01ms !important;
17225
+ }
17226
+ }
17227
+ body { font-family: var(--ui); font-size: var(--fs-body); line-height: 1.55; color: var(--fg); background: var(--bg);
17228
+ overflow: hidden; text-rendering: auto; }
17229
+ button { font: inherit; color: inherit; cursor: pointer; background: none; border: 0; padding: 0; }
17230
+ input, textarea { font: inherit; color: inherit; }
17231
+ /* Keyboard focus must be visible everywhere (mouse/touch :focus stays clean
17232
+ via :focus-visible). :where() keeps specificity at zero so any component
17233
+ can override; contentEditable surfaces keep their caret as the indicator. */
17234
+ :where(button, [role="button"], select, [tabindex]):focus-visible {
17235
+ outline: 2px solid var(--accent); outline-offset: 2px;
17236
+ }
17237
+ .muted { color: var(--muted); }
17238
+ svg { display: block; }
17239
+ .ico { width:16px; height:16px; stroke:currentColor; fill:none; stroke-width:1.75; stroke-linecap:round; stroke-linejoin:round; flex:none; }
17240
+ .ico.sm { width:14px; height:14px; }
17241
+ ::-webkit-scrollbar { width:10px; height:10px; }
17242
+ ::-webkit-scrollbar-thumb { background:var(--line-strong); border-radius:8px; border:3px solid transparent; background-clip:padding-box; }
17243
+ ::-webkit-scrollbar-thumb:hover { background:var(--muted); background-clip:padding-box; border:3px solid transparent; }
17244
+ /* Scroll containers swallow their own overscroll: no rubber-banding the whole
17245
+ page / accidental pull-to-refresh when an inner list hits its end. */
17246
+ .content, .sb-scroll, .peek-body, .modal-body, .pop, .qn-body { overscroll-behavior:contain; }
17247
+ .tablescroll, .tl-scroll, .board { overscroll-behavior-x:contain; }
17248
+
17249
+ /* dvh tracks the *visible* viewport on mobile (iOS Safari's collapsing
17250
+ address bar); the vh line before it is the fallback older engines keep. */
17251
+ #app { display: flex; height: 100vh; height: 100dvh; }
17252
+
17253
+ /* sidebar */
17254
+ .sidebar { width:268px; flex:none; background:var(--sidebar); border-right:1px solid var(--line);
17255
+ display:flex; flex-direction:column; position:relative; transition:margin-left .22s cubic-bezier(.4,0,.2,1); }
17256
+ .sb-resizer { position:absolute; top:0; right:-3px; width:6px; height:100%; cursor:col-resize; z-index:5; }
17257
+ .sb-head { display:flex; align-items:center; gap:8px; padding:14px 14px 10px; }
17258
+ .brand { display:flex; align-items:center; gap:8px; flex:1; font-weight:700; font-size:15px; letter-spacing:-.01em; }
17259
+ .brand .mark { width:24px; height:24px; border-radius:7px;
17260
+ background:linear-gradient(135deg,var(--accent),color-mix(in srgb,var(--accent) 60%,#fff));
17261
+ display:grid; place-items:center; color:#fff; box-shadow:var(--shadow-sm); }
17262
+ .brand .mark svg { width:15px; height:15px; stroke:#fff; }
17263
+ .sb-search { margin:0 12px 8px; display:flex; align-items:center; gap:7px; cursor:text;
17264
+ background:var(--bg); border:1px solid var(--line); border-radius:var(--radius-sm); padding:6px 9px; color:var(--muted);
17265
+ box-shadow:var(--shadow-sm); transition:border-color .15s; }
17266
+ .sb-search:focus-within { border-color:var(--accent); }
17267
+ .sb-search input { border:0; background:none; outline:none; color:var(--fg); width:100%; }
17268
+ .sb-search kbd { font-family:var(--mono); font-size:10px; color:var(--muted); border:1px solid var(--line-strong); border-radius:4px; padding:1px 4px; }
17269
+ .sb-scroll { flex:1; overflow-y:auto; padding:4px 8px 24px; }
17270
+ .sb-section { margin-top:12px; }
17271
+ .sb-section-head { display:flex; align-items:center; justify-content:space-between;
17272
+ font-size:var(--fs-micro); font-weight:600; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); padding:4px 8px; }
17273
+ .sb-section-head .add { opacity:0; padding:3px; border-radius:5px; color:var(--muted); transition:opacity .12s; }
17274
+ .sb-section:hover .add { opacity:1; }
17275
+ .sb-section-head .add:hover { background:var(--hover-2); color:var(--fg); }
17276
+ .navitem { display:flex; align-items:center; gap:3px; width:100%; text-align:left; padding:5px 6px; margin-bottom:2px; border-radius:var(--radius-sm);
17277
+ color:var(--fg-soft); font-size:var(--fs-ui); position:relative; white-space:nowrap; transition:background .1s; }
17278
+ .navitem:hover { background:var(--hover-2); }
17279
+ .navitem.active { background:var(--bg); color:var(--fg); font-weight:600; box-shadow:var(--shadow-sm); }
17280
+ .navitem .tw { width:18px; height:18px; display:grid; place-items:center; color:var(--muted); border-radius:4px; flex:none; }
17281
+ .navitem .tw:hover { background:var(--hover-2); }
17282
+ .navitem .tw svg { width:13px; height:13px; transition:transform .12s; }
17283
+ .navitem .tw.open svg { transform:rotate(90deg); }
17284
+ .navitem .emoji { width:20px; text-align:center; flex:none; font-size:var(--fs-body); }
17285
+ .navitem .label { flex:1; overflow:hidden; text-overflow:ellipsis; }
17286
+ .navitem .acts { display:flex; gap:1px; opacity:0; }
17287
+ .navitem:hover .acts { opacity:1; }
17288
+ .navitem .acts button { width:22px; height:22px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
17289
+ .navitem .acts button:hover { background:var(--hover-2); color:var(--fg); }
17290
+ .navchildren { margin-left:16px; border-left:1px solid var(--line-strong); padding-left:2px; }
17291
+ .navitem.drop-into { box-shadow:inset 0 0 0 2px var(--accent); }
17292
+ .navitem.drop-before { box-shadow:inset 0 2px 0 0 var(--accent); }
17293
+ .navitem.drop-after { box-shadow:inset 0 -2px 0 0 var(--accent); }
17294
+ .dragging { opacity:.35; }
17295
+ /* Slim status row: sites/settings icon buttons left, version right. */
17296
+ .sb-footer { flex:none; display:flex; align-items:center; gap:2px; padding:5px 10px; border-top:1px solid var(--line); }
17297
+ .sb-footer .sb-act { display:grid; place-items:center; width:28px; height:28px; flex:none;
17298
+ border-radius:var(--radius-sm); color:var(--muted); position:relative; }
17299
+ .sb-footer .sb-act:hover { background:var(--hover-2); color:var(--fg); }
17300
+ .sb-footer .sb-act.active { background:var(--bg); color:var(--fg); box-shadow:var(--shadow-sm); }
17301
+ .sb-footer .sb-act .nav-dot { position:absolute; top:4px; right:4px; box-shadow:0 0 0 2px var(--sidebar); }
17302
+ .sbf-ver { margin-left:auto; font-family:var(--mono); font-size:var(--fs-micro); color:var(--muted);
17303
+ letter-spacing:-.01em; user-select:text; }
17304
+ /* Sites/settings as sb-head icon buttons \u2014 the mobile home's replacement for
17305
+ the .sb-footer row (swapped in the mobile media block below). */
17306
+ .sb-act { display:none; }
17307
+ .nav-dot { flex:none; width:7px; height:7px; border-radius:50%; background:var(--danger);
17308
+ box-shadow:0 0 0 2px var(--surface); margin-right:2px; }
17309
+
17310
+ /* settings page */
17311
+ .set-page { max-width:680px; margin:0 auto; padding:40px 28px 64px; }
17312
+ .set-title { font-size:26px; font-weight:700; letter-spacing:-.02em; color:var(--fg); }
17313
+ .set-sub { color:var(--muted); margin-top:4px; }
17314
+ .set-section { margin-top:34px; }
17315
+ .set-section-head { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:4px; }
17316
+ .set-section-desc { color:var(--fg-soft); margin-bottom:16px; }
17317
+ .set-footer { margin-top:40px; padding-top:20px; border-top:1px solid var(--line); display:flex;
17318
+ flex-wrap:wrap; justify-content:center; align-items:center; gap:7px; color:var(--muted); font-size:12px; }
17319
+ .set-footer-sep { opacity:.45; }
17320
+ .ver-num { font-family:var(--mono); font-size:var(--fs-micro); color:var(--fg-soft); letter-spacing:-.01em; }
17321
+ .ver-up { display:inline-flex; align-items:center; gap:6px; }
17322
+ .ver-up.err .ver-msg { color:var(--danger); }
17323
+ .ver-act { background:none; border:0; padding:0; margin:0; font:inherit; font-size:12px; line-height:1;
17324
+ color:var(--muted); cursor:pointer; transition:color .12s; }
17325
+ .ver-act:hover { color:var(--accent); }
17326
+ .ver-act.accent { color:var(--accent); font-weight:600; }
17327
+ .ver-act[aria-disabled="true"] { opacity:.6; cursor:default; pointer-events:none; }
17328
+ .ver-dot { width:6px; height:6px; border-radius:50%; background:var(--accent); flex:none; }
17329
+ .ver-dot.pulse { animation:verpulse 1.8s ease-in-out infinite; }
17330
+ @keyframes verpulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
17331
+ .theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
17332
+ .theme-card { position:relative; display:flex; flex-direction:column; align-items:flex-start; gap:10px;
17333
+ text-align:left; padding:16px; border:1px solid var(--line-strong); border-radius:var(--radius);
17334
+ background:var(--surface); color:var(--fg-soft); box-shadow:var(--shadow-sm);
17335
+ transition:border-color .14s, background .14s, transform .14s, box-shadow .14s; }
17336
+ .theme-card:hover { border-color:var(--muted); transform:translateY(-1px); }
17337
+ .theme-card .tc-ico { width:34px; height:34px; display:grid; place-items:center; border-radius:9px;
17338
+ background:var(--surface-2); color:var(--fg); }
17339
+ .theme-card .tc-ico svg { width:18px; height:18px; }
17340
+ .theme-card .tc-name { font-weight:600; font-size:var(--fs-body); color:var(--fg); }
17341
+ .theme-card .tc-desc { font-size:12px; color:var(--muted); line-height:1.45; }
17342
+ .theme-card .tc-check { position:absolute; top:10px; right:10px; width:18px; height:18px; border-radius:50%;
17343
+ background:var(--accent); color:var(--accent-fg); display:grid; place-items:center; opacity:0; transform:scale(.6); transition:opacity .14s, transform .14s; }
17344
+ .theme-card .tc-check svg { width:12px; height:12px; stroke-width:2.6; }
17345
+ .theme-card.sel { border-color:var(--accent); background:var(--accent-soft); box-shadow:0 0 0 1px var(--accent) inset, var(--shadow-sm); }
17346
+ .theme-card.sel .tc-ico { background:var(--accent); color:var(--accent-fg); }
17347
+ .theme-card.sel .tc-check { opacity:1; transform:scale(1); }
17348
+ @media (max-width:560px) { .theme-grid { grid-template-columns:1fr; } }
17349
+
17350
+ /* sync devices */
17351
+ .peer-actions { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; }
17352
+ .peer-actions .btn { display:inline-flex; align-items:center; gap:6px; }
17353
+ .peer-list { display:flex; flex-direction:column; gap:8px; }
17354
+ .peer-row { display:flex; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--line);
17355
+ border-radius:var(--radius); background:var(--surface); }
17356
+ .peer-dot { width:9px; height:9px; border-radius:50%; flex:none; background:var(--muted); }
17357
+ .peer-dot.on { background:var(--success); } .peer-dot.err { background:var(--danger); } .peer-dot.off { background:var(--muted); }
17358
+ .peer-main { flex:1; min-width:0; }
17359
+ .peer-url { font-weight:600; font-size:var(--fs-body); color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17360
+ .peer-sub { font-size:12px; color:var(--muted); margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17361
+ .peer-menu { flex:none; padding:6px; }
17362
+ .pair-code { font-family:var(--mono); font-size:26px; font-weight:700; letter-spacing:.12em;
17363
+ text-align:center; padding:16px; border:1px dashed var(--line); border-radius:var(--radius); background:var(--surface); color:var(--fg); }
17364
+
17365
+ /* main */
17366
+ /* Opaque background matters: on mobile this is a full-screen fixed pane that
17367
+ iOS 26 Safari samples for its toolbar tint \u2014 transparent would read white. */
17368
+ .main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; background:var(--bg); }
17369
+ .topbar { display:flex; align-items:center; gap:6px; padding:10px 18px; border-bottom:1px solid var(--line); min-height:49px; }
17370
+ .topbar.bare { border-bottom:0; }
17371
+ .topbar .hamburger { display:none; }
17372
+ .topbar .hamburger.show-collapsed { display:grid; }
17373
+ .sidebar.collapsed { overflow:hidden; }
17374
+ .sidebar.collapsed .sb-resizer { pointer-events:none; }
17375
+ .crumb { display:flex; align-items:center; gap:7px; flex:1; min-width:0; font-size:var(--fs-ui); color:var(--fg); font-weight:500; }
17376
+ .crumb .emoji { font-size:15px; } .crumb .sub { color:var(--muted); font-weight:400; }
17377
+ .iconbtn { width:30px; height:30px; display:grid; place-items:center; border-radius:var(--radius-sm); color:var(--fg-soft); transition:background .12s; }
17378
+ .iconbtn:hover { background:var(--hover-2); color:var(--fg); }
17379
+ .content { flex:1; overflow:auto; }
17380
+ .btn { display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:var(--radius-sm); font-size:13px; font-weight:500; transition:all .12s; white-space:nowrap; }
17381
+ .btn-ghost { color:var(--fg-soft); } .btn-ghost:hover { background:var(--hover-2); color:var(--fg); }
17382
+ .btn-secondary { background:var(--bg); border:1px solid var(--line-strong); color:var(--fg); box-shadow:var(--shadow-sm); }
17383
+ .btn-secondary:hover { background:var(--surface-2); }
17384
+ .btn-primary { background:var(--accent); color:var(--accent-fg); box-shadow:var(--shadow-sm); }
17385
+ .btn-primary:hover { filter:brightness(1.07); }
17386
+ .btn-danger { background:var(--danger); color:#fff; } .btn-danger:hover { filter:brightness(1.05); }
17387
+
17388
+ /* desktop app (frameless): drop the sidebar logo; on macOS reserve a draggable
17389
+ strip for the inset traffic lights so they never overlap our controls */
17390
+ body.desktop .brand { display:none; }
17391
+ /* logo gone \u2192 keep the collapse button on the right (where .brand's flex used to push it) */
17392
+ body.desktop .sb-head .iconbtn { margin-left:auto; }
17393
+ body.desktop-mac .sb-head { -webkit-app-region:drag; }
17394
+ body.desktop-mac .sb-head .iconbtn { -webkit-app-region:no-drag; }
17395
+ body.desktop-mac .topbar { -webkit-app-region:drag; }
17396
+ body.desktop-mac .topbar .iconbtn, body.desktop-mac .topbar .btn { -webkit-app-region:no-drag; }
17397
+ /* peek drawer header overlaps the topbar drag region \u2192 clear its buttons so they stay clickable */
17398
+ body.desktop-mac .peek-head { -webkit-app-region:drag; }
17399
+ body.desktop-mac .peek-head .iconbtn, body.desktop-mac .peek-head .btn { -webkit-app-region:no-drag; }
17400
+ /* sidebar collapsed \u2192 traffic lights sit over the main header; clear the hamburger */
17401
+ body.desktop-mac .sidebar.collapsed ~ .main .topbar { padding-left:80px; }
17402
+
17403
+ /* document editor */
17404
+ .doc { max-width:740px; margin:0 auto; padding:60px 60px 36vh; }
17405
+ .doc-title { font-size:38px; font-weight:700; letter-spacing:-.02em; outline:none; line-height:1.15; margin-bottom:4px; }
17406
+ .doc-title:empty::before { content:"\u65E0\u6807\u9898"; color:var(--muted); }
17407
+ .doc-meta { display:flex; gap:14px; color:var(--muted); font-size:var(--fs-sm); margin-bottom:22px; }
17408
+ .doc-meta span { display:inline-flex; align-items:center; gap:5px; }
17409
+ /* Save-conflict banner: the doc changed elsewhere; auto-save is paused until
17410
+ the user picks a side, so it must read as blocking, not as a toast. */
17411
+ .doc-conflict { display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:10px 14px; margin-bottom:16px; border:1px solid var(--danger); border-radius:var(--radius-sm); background:var(--danger-soft); font-size:13px; }
17412
+ .doc-conflict-msg { color:var(--danger); font-weight:600; flex:1; min-width:180px; }
17413
+ .doc.source-mode { min-height:100%; }
17414
+ .doc-source { display:block; width:100%; min-height:calc(100vh - 210px); min-height:calc(100dvh - 210px); resize:none; border:0;
17415
+ background:transparent; color:var(--fg); outline:none; overflow:hidden; padding:3px 2px 36vh;
17416
+ font-family:var(--ui); font-size:15px; line-height:1.7; tab-size:2; }
17417
+ /* floating table of contents: a column of indented tick marks in the right
17418
+ gutter that expands to a labelled list on hover (Notion / Vercel-docs style).
17419
+ Fixed to the viewport so it never disturbs the centred .doc column. */
17420
+ .doc-toc { position:fixed; top:72px; right:18px; z-index:40; display:flex; flex-direction:column; gap:2px;
17421
+ width:24px; max-height:calc(100vh - 120px); max-height:calc(100dvh - 120px); padding:6px; box-sizing:content-box; overflow:hidden;
17422
+ border:1px solid transparent; border-radius:var(--radius); background:transparent;
17423
+ transition:width .22s ease, background .22s ease, border-color .22s ease, box-shadow .22s ease; }
17424
+ .doc-toc:hover { width:228px; overflow-y:auto; background:var(--surface); border-color:var(--line);
17425
+ box-shadow:var(--shadow-md); }
17426
+ .toc-row { display:flex; align-items:center; gap:8px; width:100%; height:24px; padding:0 2px; border-radius:var(--radius-sm);
17427
+ color:var(--muted); text-align:left; cursor:pointer; transition:color .12s, background .12s; }
17428
+ .doc-toc:hover .toc-row { padding:3px 8px; height:auto; min-height:26px; }
17429
+ .doc-toc:hover .toc-row:hover { background:var(--hover); color:var(--fg); }
17430
+ .toc-row.active { color:var(--accent); }
17431
+ /* collapsed: right-aligned ticks, length by heading level; active is accented */
17432
+ .toc-tick { flex:none; margin-left:auto; height:2px; border-radius:2px; background:var(--line-strong);
17433
+ transition:background .12s, width .22s ease, margin .22s ease; }
17434
+ .toc-row.lvl-1 .toc-tick { width:16px; }
17435
+ .toc-row.lvl-2 .toc-tick { width:11px; }
17436
+ .toc-row.lvl-3 .toc-tick { width:7px; }
17437
+ .toc-row.active .toc-tick { background:var(--accent); }
17438
+ .doc-toc:hover .toc-tick { width:0; margin:0; }
17439
+ /* expanded: labels, indented by level; hidden until hover */
17440
+ .toc-label { flex:1; min-width:0; font-size:13px; line-height:1.4; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
17441
+ opacity:0; transition:opacity .18s ease; }
17442
+ .doc-toc:hover .toc-label { opacity:1; }
17443
+ .toc-row.lvl-2 .toc-label { padding-left:12px; font-size:var(--fs-sm); }
17444
+ .toc-row.lvl-3 .toc-label { padding-left:24px; font-size:var(--fs-sm); color:var(--fg-soft); }
17445
+ .toc-row.active .toc-label { color:var(--accent); font-weight:550; }
17446
+ /* not enough room for the gutter \u2192 hide entirely (also covers mobile) */
17447
+ @media (max-width:1100px) { .doc-toc { display:none; } }
17448
+
17449
+ .block-wrap { position:relative; }
17450
+ .block-wrap.nested { padding-left:24px; }
17451
+ /* Subtle indent guide only for true sub-lists; suppressed for code/other
17452
+ nested content so code-in-list items don't stack a thicket of lines. */
17453
+ .block-wrap.nested:has(> .block.b-bullet, > .block.b-numbered, > .block.b-todo)::before {
17454
+ content:""; position:absolute; left:8px; top:2px; bottom:2px; width:1px; background:var(--line); }
17455
+ .block { position:relative; display:flex; align-items:flex-start; padding:1px 0; border-radius:4px; scroll-margin-top:60px; }
17456
+ .block .gutter { position:absolute; left:-52px; top:2px; display:flex; gap:1px; opacity:0; transition:opacity .12s; }
17457
+ .block:hover .gutter, .block.drop-before .gutter, .block.drop-after .gutter, .block.dragging .gutter { opacity:1; }
17458
+ .gutter button { width:22px; height:24px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
17459
+ .gutter button:hover { background:var(--hover-2); color:var(--fg); }
17460
+ .gutter .grip { cursor:grab; } .gutter .grip:active { cursor:grabbing; }
17461
+ .block.dragging { opacity:.42; background:var(--hover); }
17462
+ .block.drop-before::before, .block.drop-after::after { content:""; position:absolute; left:0; right:0; height:2px; background:var(--accent);
17463
+ border-radius:2px; pointer-events:none; box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 18%,transparent); }
17464
+ .block.drop-before::before { top:-1px; }
17465
+ .block.drop-after::after { bottom:-1px; }
17466
+ /* multi-block selection: whole-block tint + suppress stray native text highlight while dragging */
17467
+ .block.selected { background:var(--accent-soft); }
17468
+ .block.selected .editable::selection, .block.selected .editable *::selection { background:transparent; }
17469
+ .doc.selecting, .doc.selecting * { user-select:none; -webkit-user-select:none; cursor:default; }
17470
+ .block.list-code-host { height:0; min-height:0; padding:0; overflow:visible; }
17471
+ .block-wrap:hover > .block.list-code-host .gutter { opacity:1; }
17472
+ .block.list-code-host .marker { position:absolute; left:0; top:0; padding-top:4px; z-index:1; }
17473
+ /* The list item's host gutter controls the whole unit, so suppress the nested
17474
+ code block's own gutter (it would overlap the list marker on the same row). */
17475
+ .block.list-code-host + .block-wrap.nested .gutter { display:none; }
17476
+ .editable { outline:none; flex:1; min-width:0; padding:3px 2px; line-height:var(--note-lh); }
17477
+ .editable:empty::before { content:attr(data-ph); color:var(--muted); pointer-events:none; }
17478
+ /* The generic "/" prompt only shows on the focused line; idle blank lines (paragraphs and list items, any depth) stay blank. */
17479
+ .editable[data-ph-hint="slash"]:empty:not(:focus)::before { content:""; }
17480
+ .b-h1 .editable { font-size:28px; font-weight:700; letter-spacing:-.02em; padding-top:12px; }
17481
+ .b-h2 .editable { font-size:22px; font-weight:650; letter-spacing:-.01em; padding-top:8px; }
17482
+ .b-h3 .editable { font-size:18px; font-weight:600; padding-top:4px; }
17483
+ .b-h1 .gutter { top:22px; } .b-h2 .gutter { top:14px; } .b-h3 .gutter { top:7px; }
17484
+ .b-quote .editable { border-left:3px solid var(--line-strong); padding:1px 0 1px 16px; color:var(--fg-soft); font-style:italic; }
17485
+
17486
+ /* code block: transparent textarea over a highlight.js mirror */
17487
+ .b-code .codeblock { flex:1; min-width:0; }
17488
+ .codeblock { position:relative; --code-fs:12.5px; --code-lh:1.55; --code-pad:8px; margin:2px 0;
17489
+ background:var(--code-bg); border:1px solid var(--line); border-radius:8px; overflow:hidden; box-shadow:var(--shadow-sm); }
17490
+ /* lang + copy float bottom-right, revealed on hover */
17491
+ .code-tools { position:absolute; right:6px; bottom:6px; z-index:2; display:flex; align-items:center; gap:1px;
17492
+ padding:2px; border-radius:7px; border:1px solid var(--line); box-shadow:var(--shadow-sm);
17493
+ background:color-mix(in srgb,var(--code-bg) 82%,transparent); backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px);
17494
+ opacity:0; transform:translateY(3px); pointer-events:none; transition:opacity .12s,transform .12s; }
17495
+ .codeblock:hover .code-tools, .code-tools:focus-within { opacity:1; transform:none; pointer-events:auto; }
17496
+ .code-lang { position:relative; display:inline-flex; align-items:center; }
17497
+ .code-lang select { appearance:none; -webkit-appearance:none; border:0; outline:none; background:none; cursor:pointer;
17498
+ font-family:var(--mono); font-size:10.5px; font-weight:500; color:var(--fg-soft); padding:3px 17px 3px 7px; border-radius:5px; }
17499
+ .code-lang select:hover { background:var(--hover-2); }
17500
+ .code-lang svg { position:absolute; right:3px; width:12px; height:12px; pointer-events:none; color:var(--muted); }
17501
+ .code-copy { display:inline-flex; align-items:center; gap:4px; font-size:10.5px; font-weight:500; color:var(--muted);
17502
+ padding:3px 7px; border-radius:5px; transition:background .12s,color .12s; }
17503
+ .code-copy:hover { background:var(--hover-2); color:var(--fg-soft); }
17504
+ .code-copy.ok { color:var(--accent); }
17505
+ .code-body { display:flex; align-items:stretch; font-family:var(--mono); font-size:var(--code-fs); line-height:var(--code-lh); }
17506
+ .code-gutter { flex:none; text-align:right; white-space:pre; user-select:none; color:var(--muted); opacity:.55;
17507
+ padding:var(--code-pad) 8px var(--code-pad) 12px; border-right:1px solid var(--line); font-variant-numeric:tabular-nums; }
17508
+ .code-scroll { position:relative; flex:1; min-width:0; }
17509
+ .code-hl, .code-input { margin:0; font-family:var(--mono); font-size:var(--code-fs); line-height:var(--code-lh);
17510
+ padding:var(--code-pad) 16px; tab-size:2; -moz-tab-size:2; white-space:pre; letter-spacing:0; }
17511
+ .code-hl { position:absolute; inset:0; z-index:0; overflow:hidden; pointer-events:none; background:none; color:var(--fg); }
17512
+ .code-hl code { display:block; font:inherit; background:none; padding:0; }
17513
+ .code-input { display:block; position:relative; z-index:1; width:100%; border:0; outline:none; resize:none;
17514
+ overflow-x:auto; overflow-y:hidden; background:transparent; color:transparent; caret-color:var(--fg);
17515
+ min-height:calc(var(--code-fs) * var(--code-lh) + var(--code-pad) * 2); }
17516
+ .code-input::placeholder { color:var(--muted); }
17517
+
17518
+ /* highlight.js token theme (cohesive with the app palette) */
17519
+ .hljs-comment, .hljs-quote { color:var(--hl-cmt); font-style:italic; }
17520
+ .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-doctag { color:var(--hl-key); }
17521
+ .hljs-string, .hljs-regexp, .hljs-addition, .hljs-meta .hljs-string { color:var(--hl-str); }
17522
+ .hljs-number, .hljs-bullet { color:var(--hl-num); }
17523
+ .hljs-title, .hljs-title.function_, .hljs-name, .hljs-selector-id, .hljs-selector-class { color:var(--hl-fn); }
17524
+ .hljs-type, .hljs-class .hljs-title, .hljs-built_in, .hljs-attr, .hljs-attribute, .hljs-property { color:var(--hl-type); }
17525
+ .hljs-variable, .hljs-template-variable, .hljs-symbol, .hljs-tag, .hljs-meta, .hljs-link { color:var(--hl-var); }
17526
+ .hljs-deletion { color:var(--danger); }
17527
+ .hljs-params, .hljs-subst { color:var(--fg); }
17528
+ .hljs-emphasis { font-style:italic; }
17529
+ .hljs-strong { font-weight:700; }
17530
+ .marker { width:24px; flex:none; text-align:center; color:var(--fg-soft); user-select:none; padding-top:4px; }
17531
+ .b-todo .marker { box-sizing:content-box; padding-top:3px; height:calc(1em * var(--note-lh));
17532
+ display:flex; align-items:center; justify-content:center; }
17533
+ .b-todo .marker input { width:15px; height:15px; accent-color:var(--accent); cursor:pointer; }
17534
+ .b-divider { padding:10px 0; }
17535
+ .b-divider hr { border:0; border-top:1px solid var(--line-strong); margin:0; width:100%; }
17536
+ .b-done .editable { color:var(--muted); text-decoration:line-through; }
17537
+ .editable b, .editable strong { font-weight:700; }
17538
+ .editable code { font-family:var(--mono); background:var(--surface-2); padding:1px 5px; border-radius:4px; font-size:.9em; }
17539
+ .editable a { color:var(--accent); }
17540
+
17541
+ /* document table (GFM pipe table block) */
17542
+ .b-table .doc-table-wrap { flex:1; min-width:0; margin:4px 0; }
17543
+ .doc-table-scroll { overflow-x:auto; padding:2px 0; }
17544
+ .doc-table-inner { width:max-content; }
17545
+ .doc-table-row { display:flex; align-items:stretch; }
17546
+ table.doc-table { border-collapse:separate; border-spacing:0; table-layout:fixed; width:max-content;
17547
+ border:1px solid var(--line); border-radius:var(--radius-sm); overflow:hidden; box-shadow:var(--shadow-sm); }
17548
+ table.doc-table td { position:relative; vertical-align:top; padding:0;
17549
+ border-right:1px solid var(--line); border-bottom:1px solid var(--line); }
17550
+ table.doc-table tr td:last-child { border-right:0; }
17551
+ table.doc-table tr:last-child td { border-bottom:0; }
17552
+ table.doc-table .doc-th { background:var(--surface); }
17553
+ .doc-td { outline:none; padding:7px 22px 7px 10px; min-height:20px; line-height:1.55; word-break:break-word; white-space:pre-wrap; }
17554
+ .doc-th .doc-td { font-weight:650; color:var(--fg); }
17555
+ .doc-td:empty::before { content:attr(data-ph); color:var(--muted); pointer-events:none; }
17556
+ .doc-td b, .doc-td strong { font-weight:700; }
17557
+ .doc-td code { font-family:var(--mono); background:var(--surface-2); padding:1px 5px; border-radius:4px; font-size:.9em; }
17558
+ .doc-td a { color:var(--accent); }
17559
+ /* header cell: column menu + resize handle */
17560
+ .doc-col-menu { position:absolute; top:4px; right:4px; width:18px; height:18px; display:grid; place-items:center;
17561
+ color:var(--muted); border-radius:4px; opacity:0; transition:opacity .12s,background .12s; }
17562
+ .doc-th:hover .doc-col-menu { opacity:1; }
17563
+ .doc-col-menu:hover { background:var(--hover-2); color:var(--fg); }
17564
+ .doc-col-resizer { position:absolute; top:0; right:-3px; width:7px; height:100%; cursor:col-resize; z-index:3; touch-action:none; }
17565
+ .doc-col-resizer:hover, .doc-col-resizer.dragging { background:var(--accent); opacity:.35; }
17566
+ /* per-row delete: small handle floating at the first cell on row hover */
17567
+ .doc-row-del { position:absolute; top:3px; left:2px; width:18px; height:18px; display:grid; place-items:center;
17568
+ color:var(--muted); background:var(--bg); border:1px solid var(--line); border-radius:5px; box-shadow:var(--shadow-sm);
17569
+ opacity:0; transition:opacity .12s; }
17570
+ table.doc-table tr:hover .doc-row-del { opacity:1; }
17571
+ .doc-row-del:hover { background:var(--danger-soft); color:var(--danger); border-color:var(--danger-soft); }
17572
+ /* add-column (right edge) and add-row (bottom edge) buttons */
17573
+ .doc-table-addcol { width:22px; margin-left:4px; flex:none; display:grid; place-items:center; color:var(--muted);
17574
+ border:1px dashed var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); transition:background .12s,color .12s,border-color .12s; }
17575
+ .doc-table-addrow { width:100%; height:22px; margin-top:4px; display:grid; place-items:center; color:var(--muted);
17576
+ border:1px dashed var(--line-strong); border-radius:var(--radius-sm); background:var(--surface); transition:background .12s,color .12s,border-color .12s; }
17577
+ .doc-table-addcol:hover, .doc-table-addrow:hover { background:var(--accent-soft); color:var(--accent); border-color:var(--accent); }
17578
+
17579
+ /* database / table */
17580
+ .db { padding:22px 36px 90px; min-width:0; }
17581
+ .db-head { display:flex; align-items:center; gap:12px; }
17582
+ .db-icon { width:38px; height:38px; border-radius:9px; background:var(--surface-2); display:grid; place-items:center; font-size:20px; }
17583
+ .db-title { font-size:27px; font-weight:700; letter-spacing:-.02em; outline:none; }
17584
+ .db-desc { color:var(--muted); font-size:13px; margin-top:2px; }
17585
+ .views { display:flex; align-items:center; gap:2px; border-bottom:1px solid var(--line); margin:16px 0 0; }
17586
+ .view-tab { display:flex; align-items:center; gap:6px; padding:8px 11px; font-size:var(--fs-ui); font-weight:500; color:var(--muted);
17587
+ border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .12s; }
17588
+ .view-tab.active { color:var(--fg); border-bottom-color:var(--accent); }
17589
+ .view-tab:hover { color:var(--fg); } .view-tab svg { width:14px; height:14px; }
17590
+ .toolbar { display:flex; align-items:center; gap:2px; padding:10px 0; }
17591
+ .toolbar .spacer { flex:1; }
17592
+ .tbtn { display:flex; align-items:center; gap:6px; padding:5px 9px; border-radius:var(--radius-sm); color:var(--fg-soft); font-size:13px; font-weight:500; }
17593
+ .tbtn:hover { background:var(--hover-2); color:var(--fg); }
17594
+ .tbtn.on { color:var(--accent); }
17595
+ .tablewrap { border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; box-shadow:var(--shadow-sm); }
17596
+ .tablescroll { overflow-x:auto; }
17597
+ /* fixed layout + max-content: each <col> width is authoritative, so the table
17598
+ is exactly the sum of its columns and resizing one column never redistributes
17599
+ width to its neighbours. min-width:100% fills the container when columns are
17600
+ narrow, with the slack absorbed only by the trailing auto (filler) column. */
17601
+ table.grid { border-collapse:collapse; table-layout:fixed; width:max-content; min-width:100%; }
17602
+ table.grid th, table.grid td { border-right:1px solid var(--line); border-bottom:1px solid var(--line); padding:0; text-align:left; }
17603
+ table.grid th:last-child, table.grid td:last-child { border-right:0; }
17604
+ table.grid tbody tr:last-child td { border-bottom:0; }
17605
+ thead th { background:var(--surface); position:relative; user-select:none; font-weight:600; color:var(--fg-soft); }
17606
+ .colhead { display:flex; align-items:center; gap:7px; padding:9px 11px; font-size:13px; cursor:pointer; }
17607
+ .colhead:hover { background:var(--hover); }
17608
+ .colhead .ti { color:var(--muted); } .colhead .ti svg { width:14px; height:14px; }
17609
+ .colhead .nm { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17610
+ .col-resizer { position:absolute; top:0; right:-3px; width:7px; height:100%; cursor:col-resize; z-index:2; touch-action:none; }
17611
+ .col-resizer:hover, .col-resizer.dragging { background:var(--accent); opacity:.35; }
17612
+ .addcol { min-width:46px; } .addcol .colhead { justify-content:flex-start; color:var(--muted); }
17613
+ /* trailing filler column: empty canvas to the right of the data, not a real cell */
17614
+ table.grid td.filler { border-right:0; border-bottom:0; background:transparent; }
17615
+ body.col-resizing { user-select:none; cursor:col-resize; }
17616
+ table.grid .selcell { width:38px; min-width:38px; max-width:38px; text-align:center; }
17617
+ table.grid .gripcol, table.grid .rowgrip { width:26px; min-width:26px; max-width:26px; text-align:center; }
17618
+ td.cell-td { vertical-align:top; }
17619
+ .cell { padding:8px 11px; min-height:37px; cursor:text; word-break:break-word; display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
17620
+ /* hover/active tint goes on the <td> (always full row height) not the inner
17621
+ .cell (only as tall as its content), so a taller sibling row never leaves a
17622
+ gap of un-tinted space at the bottom of the hovered cell. */
17623
+ td.cell-td { transition:background .1s; }
17624
+ td.cell-td:hover { background:var(--hover); } .cell.center { justify-content:center; }
17625
+ tbody tr { transition:background .08s; }
17626
+ tbody tr:hover { background:var(--hover); }
17627
+ tbody tr.sel { background:var(--accent-soft); }
17628
+ /* cell range selection: per-cell fill; the accent rectangle outline is drawn via inline box-shadow on edge cells. Placed after :hover so it wins on equal specificity. */
17629
+ td.cell-td.cellsel, td.cell-td.cellsel:hover { background:var(--accent-soft); }
17630
+ body.cell-selecting { user-select:none; cursor:cell; }
17631
+ body.cell-selecting .cell { cursor:cell; }
17632
+ table.grid tr.drop-before td { box-shadow:inset 0 2px 0 0 var(--accent); }
17633
+ table.grid tr.drop-after td { box-shadow:inset 0 -2px 0 0 var(--accent); }
17634
+ table.grid th.drop-before { box-shadow:inset 2px 0 0 0 var(--accent); }
17635
+ table.grid th.drop-after { box-shadow:inset -2px 0 0 0 var(--accent); }
17636
+ .rowgrip { color:transparent; cursor:grab; vertical-align:middle; touch-action:none; }
17637
+ .rowgrip[aria-disabled="true"] { cursor:not-allowed; }
17638
+ tbody tr:hover .rowgrip { color:var(--muted); }
17639
+ .colhead { touch-action:none; }
17640
+ .drag-source { opacity:.35; }
17641
+ body.table-dragging { user-select:none; cursor:grabbing; }
17642
+ .drag-ghost { position:fixed; left:0; top:0; z-index:120; pointer-events:none; display:flex; align-items:center;
17643
+ padding:7px 11px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; border:1px solid var(--line-strong);
17644
+ border-radius:7px; background:var(--bg); color:var(--fg); box-shadow:var(--shadow-lg); opacity:.94; font-size:13px; }
17645
+ .row-ghost { min-height:34px; }
17646
+ .col-ghost { justify-content:center; font-weight:600; color:var(--fg-soft); background:var(--surface); }
17647
+ .selcell { vertical-align:middle; }
17648
+ .selcell input { width:15px; height:15px; accent-color:var(--accent); opacity:0; cursor:pointer; vertical-align:middle; }
17649
+ tbody tr:hover .selcell input, tbody tr.sel .selcell input, .selcell input:checked, thead .selcell input { opacity:1; }
17650
+ .firstcell { position:relative; width:100%; min-width:0; }
17651
+ /* row actions float over the cell on hover, never reserving column width */
17652
+ /* right:-5px pulls the button out over the cell's 11px right padding so it
17653
+ sits ~6px from the column border instead of ~15px inside it */
17654
+ .rowactions { position:absolute; top:0; bottom:0; right:-5px; display:flex; align-items:flex-start;
17655
+ padding:1px 0 0; opacity:0; pointer-events:none; }
17656
+ tbody tr:hover .rowactions { opacity:1; pointer-events:auto; }
17657
+ /* square icon button \u2014 "open as page" (Notion-style) */
17658
+ .rowopen { width:23px; height:23px; flex:none; display:inline-flex; align-items:center; justify-content:center;
17659
+ border:1px solid var(--line); border-radius:6px; background:var(--bg); color:var(--fg-soft); box-shadow:var(--shadow-sm); }
17660
+ .rowopen:hover { background:var(--surface-2); color:var(--fg); border-color:var(--line-strong); }
17661
+ .rowopen svg { width:14px; height:14px; }
17662
+ .chip { display:inline-flex; align-items:center; gap:4px; border-radius:5px; padding:2px 8px; font-size:var(--fs-sm); font-weight:500; white-space:nowrap;
17663
+ background:color-mix(in srgb,var(--c) 15%,transparent); color:var(--c); }
17664
+ /* data-resolved (not prefers-color-scheme): tracks a manual dark choice too. */
17665
+ :root[data-resolved="dark"] .chip { background:color-mix(in srgb,var(--c) 22%,transparent); color:color-mix(in srgb,var(--c) 75%,#fff); }
17666
+ .addrow { display:flex; align-items:center; gap:7px; padding:9px 12px; color:var(--muted); font-size:13px; cursor:pointer; border-top:1px solid var(--line); }
17667
+ .addrow:hover { background:var(--hover); color:var(--fg-soft); }
17668
+ .gridfoot { display:flex; gap:18px; color:var(--muted); font-size:12px; padding:8px 2px; }
17669
+ input.inlineedit { width:100%; border:0; outline:2px solid var(--accent); border-radius:4px; padding:7px 9px; background:var(--bg); color:var(--fg); font:inherit; }
17670
+
17671
+ /* ---- sites (\u7AD9\u70B9\u7BA1\u7406) ---- */
17672
+ .sites-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(264px, 1fr)); gap:14px; margin-top:6px; }
17673
+ .site-card { display:flex; flex-direction:column; gap:13px; padding:16px 16px 14px; background:var(--bg); border:1px solid var(--line);
17674
+ border-radius:var(--radius); box-shadow:var(--shadow-sm); cursor:pointer; transition:box-shadow .16s, border-color .16s, transform .16s; }
17675
+ .site-card:hover { box-shadow:var(--shadow-md); border-color:var(--line-strong); transform:translateY(-1px); }
17676
+ .site-card-head { display:flex; align-items:center; gap:11px; min-width:0; }
17677
+ .site-card .si { width:40px; height:40px; border-radius:11px; background:var(--accent-soft); color:var(--accent); display:grid; place-items:center; flex:none; }
17678
+ .site-card .si svg { width:20px; height:20px; }
17679
+ .site-card-id { min-width:0; }
17680
+ .site-card .slug { font-family:var(--mono); font-weight:600; font-size:14.5px; color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17681
+ .site-card .ttl { font-size:12px; color:var(--fg-soft); margin-top:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17682
+ .site-card .ttl.muted { color:var(--muted); font-style:italic; }
17683
+ .site-addr { display:flex; align-items:center; gap:6px; width:100%; text-align:left; background:var(--surface); border:1px solid var(--line);
17684
+ border-radius:var(--radius-sm); padding:6px 9px; color:var(--muted); font-family:var(--mono); font-size:var(--fs-xs); transition:border-color .12s, color .12s; }
17685
+ .site-addr svg { flex:none; }
17686
+ .site-addr span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17687
+ .site-addr:hover { border-color:var(--accent); color:var(--accent); }
17688
+ .site-card-meta { display:flex; align-items:center; gap:7px; font-size:12px; color:var(--muted); }
17689
+ .site-card-meta b { color:var(--fg-soft); font-weight:600; font-variant-numeric:tabular-nums; }
17690
+ .site-card-meta .dot { color:var(--line-strong); }
17691
+ .site-card-foot { display:flex; align-items:center; gap:6px; padding-top:12px; margin-top:1px; border-top:1px solid var(--line); }
17692
+ .site-card-foot .btn-secondary { padding:6px 12px; justify-content:center; }
17693
+
17694
+ .site-empty { text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--radius); padding:48px 24px; margin-top:8px; }
17695
+ .site-empty .ei { font-size:30px; margin-bottom:10px; }
17696
+ .site-empty .et { font-size:var(--fs-body); color:var(--fg-soft); margin-bottom:4px; font-weight:600; }
17697
+ .site-empty .ed { font-size:var(--fs-sm); margin-bottom:16px; }
17698
+
17699
+ /* site peek: access bar + file manager */
17700
+ .acc-link { display:flex; align-items:center; gap:6px; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius-sm); padding:7px 8px 7px 11px; margin-bottom:16px; }
17701
+ .acc-link .url { flex:1; font-family:var(--mono); font-size:var(--fs-sm); color:var(--fg-soft); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17702
+ .acc-link button { width:28px; height:28px; display:grid; place-items:center; border-radius:6px; color:var(--muted); flex:none; }
17703
+ .acc-link button:hover { background:var(--hover-2); color:var(--fg); }
17704
+ .acc-link button.accent { color:var(--accent); }
17705
+ .site-meta { display:flex; gap:18px; color:var(--muted); font-size:12px; margin-bottom:20px; }
17706
+ .site-meta b { color:var(--fg); font-weight:600; }
17707
+ .files-head { display:flex; align-items:center; justify-content:space-between; font-size:var(--fs-micro); font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); margin:0 0 4px; }
17708
+ .filerow { display:flex; align-items:center; gap:10px; padding:9px 8px; border-radius:var(--radius-sm); cursor:pointer; border-bottom:1px solid var(--line); }
17709
+ .filerow:last-child { border-bottom:0; }
17710
+ .filerow:hover { background:var(--hover); }
17711
+ .filerow .fi { color:var(--muted); display:grid; place-items:center; flex:none; }
17712
+ .filerow .fpath { flex:1; font-family:var(--mono); font-size:var(--fs-sm); color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17713
+ .filerow .facts { display:flex; gap:1px; opacity:0; flex:none; }
17714
+ .filerow:hover .facts { opacity:1; }
17715
+ .filerow .facts button { width:27px; height:27px; display:grid; place-items:center; border-radius:6px; color:var(--muted); }
17716
+ .filerow .facts button:hover { background:var(--hover-2); color:var(--fg); }
17717
+ .filerow .facts button.del:hover { color:var(--danger); }
17718
+ .enc-badge { display:inline-flex; align-items:center; font-family:var(--mono); font-size:10.5px; font-weight:500; letter-spacing:.02em;
17719
+ padding:2px 6px; border-radius:5px; background:var(--surface-2); color:var(--fg-soft); border:1px solid var(--line); }
17720
+ .enc-badge.blob { color:var(--accent); background:var(--accent-soft); border-color:transparent; }
17721
+
17722
+ /* file preview (inside modal) */
17723
+ .preview-box { font-family:var(--mono); font-size:var(--fs-sm); background:var(--surface-2); border:1px solid var(--line); border-radius:var(--radius);
17724
+ padding:14px 16px; max-height:52vh; overflow:auto; white-space:pre-wrap; line-height:1.6; color:var(--fg); }
17725
+ .preview-bin { text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--radius); padding:40px 20px; }
17726
+ .preview-bin .pi { font-size:28px; margin-bottom:8px; }
17727
+ .preview-bin .pt { color:var(--fg-soft); font-size:13px; }
17728
+
17729
+ /* in-app site preview (browser-framed iframe) */
17730
+ .spv-scrim { position:fixed; inset:0; background:rgba(15,15,25,.5); z-index:85; display:grid; place-items:center; padding:28px;
17731
+ opacity:0; pointer-events:none; transition:opacity .16s; animation:spv-in .16s forwards; }
17732
+ @keyframes spv-in { to { opacity:1; pointer-events:auto; } }
17733
+ .spv-scrim.open { opacity:1; pointer-events:auto; }
17734
+ .spv { width:min(1100px, 92vw); height:88vh; background:var(--bg); border-radius:var(--radius-lg); box-shadow:var(--shadow-lg);
17735
+ display:flex; flex-direction:column; overflow:hidden; }
17736
+ .spv-chrome { display:flex; align-items:center; gap:10px; padding:9px 12px; border-bottom:1px solid var(--line); background:var(--surface); }
17737
+ .spv-dots { display:flex; gap:6px; flex:none; }
17738
+ .spv-dots i { width:11px; height:11px; border-radius:50%; background:var(--line-strong); }
17739
+ .spv-url { flex:1; display:flex; align-items:center; gap:6px; min-width:0; background:var(--bg); border:1px solid var(--line);
17740
+ border-radius:7px; padding:5px 10px; color:var(--fg-soft); font-family:var(--mono); font-size:12px; }
17741
+ .spv-url svg { color:var(--muted); flex:none; }
17742
+ .spv-url span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17743
+ .spv-btn { display:inline-flex; align-items:center; gap:5px; flex:none; padding:6px 10px; border-radius:7px; color:var(--fg-soft); font-size:var(--fs-sm); font-weight:500; }
17744
+ .spv-btn:hover { background:var(--hover-2); color:var(--fg); }
17745
+ .spv-btn.icon { padding:6px; }
17746
+ /* #fff on purpose: published site content assumes a white canvas, independent
17747
+ of the app theme. */
17748
+ .spv-frame { flex:1; width:100%; border:0; background:#fff; }
17749
+
17750
+ .selbar { position:fixed; bottom:24px; left:50%; transform:translateX(-50%); z-index:65; display:flex; align-items:center; gap:4px;
17751
+ background:var(--fg); color:var(--bg); padding:7px 8px 7px 14px; border-radius:11px; box-shadow:var(--shadow-lg); }
17752
+ .selbar .cnt { font-size:13px; font-weight:600; margin-right:6px; }
17753
+ .selbar button { display:flex; align-items:center; gap:6px; color:var(--bg); padding:6px 10px; border-radius:7px; font-size:13px; font-weight:500; }
17754
+ .selbar button:hover { background:color-mix(in srgb,var(--bg) 25%,transparent); }
17755
+ .selbar .del:hover { background:var(--danger); color:#fff; }
17756
+
17757
+ /* record peek */
17758
+ .scrim { position:fixed; inset:0; background:rgba(15,15,25,.32); z-index:70; opacity:0; pointer-events:none; transition:opacity .22s; }
17759
+ .scrim.open { opacity:1; pointer-events:auto; }
17760
+ .peek { position:fixed; top:0; right:0; width:580px; max-width:94vw; height:100%; background:var(--bg); border-left:1px solid var(--line);
17761
+ box-shadow:var(--shadow-lg); z-index:71; transform:translateX(100%); transition:transform .24s cubic-bezier(.32,.72,0,1); display:flex; flex-direction:column; }
17762
+ .peek.open { transform:none; }
17763
+ .peek-head { display:flex; align-items:center; gap:4px; padding:12px 16px; border-bottom:1px solid var(--line); }
17764
+ .peek-body { overflow:auto; padding:28px 32px; }
17765
+ .peek h2 { font-size:27px; font-weight:700; letter-spacing:-.02em; margin:0 0 20px; outline:none; }
17766
+ .proprow { display:flex; gap:8px; align-items:flex-start; margin:1px 0; }
17767
+ .proprow .k { width:160px; flex:none; color:var(--muted); display:flex; align-items:center; gap:7px; font-size:13px; padding:7px 8px; border-radius:var(--radius-sm); }
17768
+ .proprow .k:hover { background:var(--hover); }
17769
+ .proprow .v { flex:1; padding:7px 8px; border-radius:var(--radius-sm); cursor:text; min-height:33px; display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
17770
+ .proprow .v:hover { background:var(--hover); }
17771
+ .peek-divider { height:1px; background:var(--line); margin:22px 0 18px; }
17772
+
17773
+ /* menu / popover */
17774
+ .menu-layer { position:fixed; inset:0; z-index:90; }
17775
+ .pop { position:fixed; background:var(--bg); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow-lg);
17776
+ padding:6px; min-width:232px; max-height:70vh; overflow:auto; animation:pop .12s ease; }
17777
+ @keyframes pop { from { opacity:0; transform:translateY(-4px); } }
17778
+ /* Action-sheet variant: MenuHost (ui.tsx) adds .sheet on phones, where an
17779
+ anchored popover clips at the edges and sits under the finger. */
17780
+ .pop.sheet { left:8px; right:8px; top:auto; bottom:calc(8px + env(safe-area-inset-bottom));
17781
+ min-width:0; max-height:60vh; max-height:60dvh; border-radius:var(--radius-lg);
17782
+ animation:sheet-in .18s ease; }
17783
+ @keyframes sheet-in { from { opacity:0; transform:translateY(12px); } }
17784
+ .pop .lbl { font-size:var(--fs-micro); font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; padding:6px 8px 3px; }
17785
+ .pop .item { display:flex; align-items:center; gap:10px; padding:7px 8px; border-radius:var(--radius-sm); width:100%; text-align:left; color:var(--fg); font-size:var(--fs-ui); }
17786
+ .pop .item:hover, .pop .item.sel { background:var(--hover-2); }
17787
+ .pop .item .lico { width:28px; height:28px; display:grid; place-items:center; border:1px solid var(--line); border-radius:6px; color:var(--fg-soft); flex:none; }
17788
+ .pop .item .lico.plain { border:0; width:22px; height:22px; }
17789
+ .pop .item .meta { flex:1; min-width:0; }
17790
+ .pop .item .t { font-size:var(--fs-ui); } .pop .item .d { font-size:var(--fs-xs); color:var(--muted); display:block; }
17791
+ .pop .item .chk { color:var(--accent); margin-left:auto; }
17792
+ .pop .item.danger { color:var(--danger); } .pop .item.danger .lico { color:var(--danger); }
17793
+ .pop .sep { height:1px; background:var(--line); margin:5px 4px; }
17794
+ .pop .typegrid { display:grid; grid-template-columns:repeat(4,1fr); gap:4px; padding:2px 2px 4px; }
17795
+ .pop .typegrid .item { flex-direction:column; gap:5px; padding:9px 4px; text-align:center; }
17796
+ .pop .typegrid .item .lico { margin:0 auto; }
17797
+ .pop input.field { width:100%; border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:7px 9px; outline:none; background:var(--surface); margin:2px; }
17798
+ .pop input.field:focus { border-color:var(--accent); }
17799
+ .optrow { display:flex; align-items:center; gap:6px; padding:3px 4px; }
17800
+ .optrow .chip { flex:1; }
17801
+ .optrow .x { width:22px; height:22px; display:grid; place-items:center; color:var(--muted); border-radius:5px; }
17802
+ .optrow .x:hover { background:var(--hover-2); color:var(--danger); }
17803
+
17804
+ /* modal */
17805
+ .modal-scrim { position:fixed; inset:0; background:rgba(15,15,25,.4); z-index:100; display:flex; align-items:center; justify-content:center;
17806
+ opacity:0; pointer-events:none; transition:opacity .15s; padding:20px; }
17807
+ .modal-scrim.open { opacity:1; pointer-events:auto; }
17808
+ .modal { background:var(--bg); border-radius:var(--radius-xl); box-shadow:var(--shadow-lg); width:440px; max-width:100%; max-height:100%; display:flex; flex-direction:column; overflow:hidden; }
17809
+ .modal-head { padding:20px 22px 0; }
17810
+ .modal-head h3 { margin:0; font-size:18px; font-weight:700; letter-spacing:-.01em; }
17811
+ .modal-head p { margin:6px 0 0; color:var(--muted); font-size:var(--fs-ui); }
17812
+ .modal-body { padding:18px 22px; overflow-y:auto; }
17813
+ .modal-foot { display:flex; justify-content:flex-end; gap:8px; padding:14px 22px; background:var(--surface); border-top:1px solid var(--line); }
17814
+ .field-label { font-size:12px; font-weight:600; color:var(--fg-soft); margin:12px 0 5px; }
17815
+ .field-label:first-child { margin-top:0; }
17816
+ .text-input { width:100%; border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:9px 11px; outline:none; background:var(--surface); font-size:var(--fs-body); }
17817
+ .text-input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); }
17818
+ .icon-pick { display:flex; gap:6px; flex-wrap:wrap; }
17819
+ .icon-pick button { width:38px; height:38px; border-radius:9px; border:1px solid var(--line); font-size:19px; display:grid; place-items:center; }
17820
+ .icon-pick button:hover { background:var(--hover-2); }
17821
+ .icon-pick button.sel { border-color:var(--accent); background:var(--accent-soft); }
17822
+ .tmpl { display:flex; gap:8px; }
17823
+ .tmpl button { flex:1; border:1px solid var(--line); border-radius:var(--radius); padding:12px; text-align:left; }
17824
+ .tmpl button:hover { border-color:var(--accent); background:var(--surface); }
17825
+ .tmpl button.sel { border-color:var(--accent); background:var(--accent-soft); }
17826
+ .tmpl .tt { font-weight:600; font-size:var(--fs-ui); } .tmpl .td { font-size:var(--fs-xs); color:var(--muted); margin-top:2px; }
17827
+
17828
+ /* toast */
17829
+ .toasts { position:fixed; bottom:20px; left:20px; z-index:110; display:flex; flex-direction:column; gap:8px; }
17830
+ .toast { background:var(--fg); color:var(--bg); padding:10px 14px; border-radius:9px; font-size:13px; box-shadow:var(--shadow-lg);
17831
+ display:flex; align-items:center; gap:8px; animation:slidein .2s ease; }
17832
+ @keyframes slidein { from { opacity:0; transform:translateX(-12px); } }
17833
+
17834
+ /* misc */
17835
+ .empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;
17836
+ color:var(--muted); text-align:center; padding:40px; }
17837
+
17838
+ /* ---- empty state: \u77E5\u8BC6\u751F\u957F\u63D2\u753B ---- */
17839
+ .estate-art { width:240px; height:208px; overflow:visible; animation:idleFloat 5s ease-in-out 1.6s infinite; }
17840
+ .estate-art .stroke-a { fill:none; stroke:var(--accent); stroke-width:2.4; stroke-linecap:round; stroke-linejoin:round; }
17841
+ .estate-art .stroke { fill:none; stroke:var(--line-strong); stroke-width:2.4; stroke-linecap:round; stroke-linejoin:round; }
17842
+ .estate-art .fill-a { fill:var(--accent); }
17843
+ .estate-art .fill-soft { fill:var(--accent-soft); }
17844
+ .estate-art .fill-surface { fill:var(--bg); }
17845
+ .estate-art .ground { fill:var(--accent); }
17846
+ .estate-title { margin:28px 0 0; font-size:18px; font-weight:600; letter-spacing:.2px; color:var(--fg);
17847
+ opacity:0; animation:fadeUp .5s ease forwards 1.0s; }
17848
+ .estate-hint { margin:9px 0 0; font-size:var(--fs-ui); line-height:1.6; color:var(--muted); max-width:300px;
17849
+ opacity:0; animation:fadeUp .5s ease forwards 1.15s; }
17850
+ .estate-link { margin:18px 0 0; font-size:var(--fs-body); font-weight:600; color:var(--accent);
17851
+ display:inline-flex; align-items:center; gap:6px; padding:9px 8px; border-radius:var(--radius-sm);
17852
+ opacity:0; animation:fadeUp .5s ease forwards 1.3s; transition:opacity .15s; }
17853
+ .estate-link:hover { opacity:.7; }
17854
+ /* intro */
17855
+ .draw { stroke-dasharray:100; stroke-dashoffset:100; }
17856
+ @keyframes draw { to { stroke-dashoffset:0; } }
17857
+ @keyframes leafGrow { from { opacity:0; transform:scale(.2) rotate(-10deg); } to { opacity:1; transform:scale(1) rotate(0); } }
17858
+ @keyframes cardUp { from { opacity:0; transform:translateY(16px) scale(.96); } to { opacity:1; transform:translateY(0) scale(1); } }
17859
+ @keyframes groundIn { to { opacity:.1; } }
17860
+ @keyframes glowIn { to { opacity:.6; } }
17861
+ @keyframes fadeUp { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
17862
+ /* idle loops \u2014 each 0% equals the settled rest state to avoid a jump */
17863
+ @keyframes idleFloat { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-5px); } }
17864
+ @keyframes plantSway { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(-1.3deg); } 75% { transform:rotate(1.3deg); } }
17865
+ @keyframes leafSwayA { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(-2.6deg); } 75% { transform:rotate(2.6deg); } }
17866
+ @keyframes leafSwayB { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(2.6deg); } 75% { transform:rotate(-2.6deg); } }
17867
+ @keyframes groundBreathe { 0%,100% { transform:scale(1); opacity:.10; } 50% { transform:scale(1.07); opacity:.15; } }
17868
+ @keyframes glowPulse { 0%,100% { transform:scale(1); opacity:.6; } 50% { transform:scale(1.1); opacity:.85; } }
17869
+ @keyframes particle { 0% { opacity:0; transform:translate(0,0) scale(.4); } 35% { opacity:.85; transform:translate(2px,-4px) scale(1); } 70% { opacity:0; transform:translate(4px,-9px) scale(.5); } 100% { opacity:0; transform:translate(0,0) scale(.4); } }
17870
+ /* wiring */
17871
+ .kg-ground { transform-box:fill-box; transform-origin:center; opacity:0;
17872
+ animation:groundIn .5s ease forwards .1s, groundBreathe 6s ease-in-out 1.6s infinite; }
17873
+ .kg-glow { filter:blur(9px); transform-box:fill-box; transform-origin:center; opacity:0;
17874
+ animation:glowIn .6s ease forwards .1s, glowPulse 4.6s ease-in-out 1.6s infinite; }
17875
+ .kg-cardB { transform-box:fill-box; transform-origin:center; opacity:0; animation:cardUp .55s cubic-bezier(.2,.8,.2,1) forwards .2s; }
17876
+ .kg-cardA { transform-box:fill-box; transform-origin:center; opacity:0; animation:cardUp .55s cubic-bezier(.2,.8,.2,1) forwards .32s; }
17877
+ .kg-plant { transform-box:fill-box; transform-origin:50% 100%; animation:plantSway 6s ease-in-out 1.7s infinite; }
17878
+ .kg-stem { animation:draw .9s ease-out forwards .4s; }
17879
+ .kg-leaf { transform-box:fill-box; }
17880
+ .kg-grow { transform-box:fill-box; opacity:0; }
17881
+ .kg-leaf.l1 { transform-origin:90% 100%; animation:leafSwayA 4.6s ease-in-out 1.7s infinite; }
17882
+ .kg-leaf.l2 { transform-origin:0% 80%; animation:leafSwayB 5.2s ease-in-out 1.8s infinite; }
17883
+ .kg-leaf.l3 { transform-origin:0% 100%; animation:leafSwayA 5.0s ease-in-out 1.9s infinite; }
17884
+ .kg-leaf.l1 .kg-grow { transform-origin:90% 100%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards .85s; }
17885
+ .kg-leaf.l2 .kg-grow { transform-origin:0% 80%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards 1.0s; }
17886
+ .kg-leaf.l3 .kg-grow { transform-origin:0% 100%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards 1.15s; }
17887
+ .kg-particle { transform-box:fill-box; transform-origin:center; opacity:0; }
17888
+ .kg-particle.p1 { animation:particle 4.5s ease-in-out 1.6s infinite; }
17889
+ .kg-particle.p2 { animation:particle 5.2s ease-in-out 2.0s infinite; }
17890
+ .kg-particle.p3 { animation:particle 4.8s ease-in-out 2.6s infinite; }
17891
+ .kg-particle.p4 { animation:particle 5.6s ease-in-out 3.1s infinite; }
17892
+ .kg-particle.p5 { animation:particle 4.2s ease-in-out 2.3s infinite; }
17893
+ @media (prefers-reduced-motion: reduce) {
17894
+ .estate-art, .estate-art *, .estate-title, .estate-hint, .estate-link { animation:none !important; opacity:1 !important; transform:none !important; }
17895
+ .draw { stroke-dashoffset:0 !important; }
17896
+ .kg-ground { opacity:.1 !important; }
17897
+ .kg-glow { opacity:.6 !important; }
17898
+ .kg-particle { opacity:0 !important; }
17899
+ }
17900
+ .error-bar { margin:12px 24px; padding:10px 14px; background:var(--danger-soft); color:var(--danger); border-radius:var(--radius-sm); font-size:13px; cursor:pointer; }
17901
+ .search-hit { margin:0 0 8px; padding:10px 14px; background:var(--surface); border-radius:var(--radius-sm); font-size:13px; cursor:pointer; }
17902
+ .search-hit:hover { background:var(--surface-2); }
17903
+ .search-hit mark { background:transparent; color:var(--accent); font-weight:600; }
17904
+ .view-placeholder { padding:40px; text-align:center; border:1px dashed var(--line-strong); border-radius:var(--radius); color:var(--muted); margin-top:8px; }
17905
+ .ico.flip { transform:scaleX(-1); }
17906
+
17907
+ /* ---- board (kanban) view ---- */
17908
+ .board-wrap { margin-top:4px; }
17909
+ .board-bar { display:flex; align-items:center; gap:10px; padding:2px 0 12px; }
17910
+ .board { display:flex; align-items:flex-start; gap:12px; overflow-x:auto; padding-bottom:14px; min-height:240px; }
17911
+ .board-col { flex:0 0 272px; width:272px; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); display:flex; flex-direction:column; max-height:calc(100vh - 240px); max-height:calc(100dvh - 240px); transition:box-shadow .12s,border-color .12s; }
17912
+ .board-col.drop-into { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft); }
17913
+ .board-col-head { display:flex; align-items:center; gap:8px; padding:10px 10px 8px; }
17914
+ .board-col-head .spacer { flex:1; }
17915
+ .board-count { color:var(--muted); font-size:12px; font-weight:600; }
17916
+ .board-col-head .iconbtn.sm { width:24px; height:24px; }
17917
+ .board-col-body { display:flex; flex-direction:column; gap:7px; padding:0 8px 8px; overflow-y:auto; }
17918
+ .kcard { background:var(--bg); border:1px solid var(--line); border-radius:var(--radius-sm); padding:9px 10px; box-shadow:var(--shadow-sm);
17919
+ cursor:grab; display:flex; flex-direction:column; gap:5px; touch-action:none; transition:border-color .1s,box-shadow .1s; }
17920
+ .kcard:hover { border-color:var(--line-strong); }
17921
+ .kcard.drag-source { opacity:.4; }
17922
+ .kcard.dragging-ghost { position:fixed; left:0; top:0; z-index:120; pointer-events:none; cursor:grabbing;
17923
+ box-shadow:var(--shadow-lg); opacity:.95; transform:translate3d(-9999px,0,0); }
17924
+ .kcard-title { font-size:var(--fs-ui); font-weight:600; line-height:1.35; word-break:break-word; }
17925
+ .kcard-field { display:flex; align-items:center; gap:6px; font-size:var(--fs-sm); color:var(--fg-soft); min-width:0; }
17926
+ .kcard-field svg { width:13px; height:13px; color:var(--muted); flex:none; }
17927
+ .board-add { display:flex; align-items:center; gap:6px; padding:7px 8px; color:var(--muted); font-size:var(--fs-sm); border-radius:var(--radius-sm); }
17928
+ .board-add:hover { background:var(--hover-2); color:var(--fg-soft); }
17929
+
17930
+ /* ---- calendar (month) view ---- */
17931
+ .cal { margin-top:4px; display:flex; flex-direction:column; }
17932
+ .cal-nav { display:flex; align-items:center; gap:8px; padding:2px 0 12px; }
17933
+ .cal-nav .spacer { flex:1; }
17934
+ .cal-title { font-size:15px; font-weight:650; min-width:120px; }
17935
+ .cal-grid { display:grid; grid-template-columns:repeat(7,1fr); }
17936
+ .cal-head { border:1px solid var(--line); border-bottom:0; border-radius:var(--radius) var(--radius) 0 0; background:var(--surface); }
17937
+ .cal-wd { padding:8px 10px; font-size:12px; font-weight:600; color:var(--muted); text-align:left; border-right:1px solid var(--line); }
17938
+ .cal-wd:last-child { border-right:0; }
17939
+ .cal-body { border:1px solid var(--line); border-radius:0 0 var(--radius) var(--radius); overflow:hidden; }
17940
+ .cal-week { border-bottom:1px solid var(--line); }
17941
+ .cal-week:last-child { border-bottom:0; }
17942
+ .cal-day { min-height:108px; border-right:1px solid var(--line); padding:5px 6px; display:flex; flex-direction:column; gap:4px; transition:background .1s; cursor:pointer; }
17943
+ .cal-day:last-child { border-right:0; }
17944
+ .cal-day:hover { background:var(--hover); }
17945
+ .cal-day.dim { background:var(--surface); color:var(--muted); }
17946
+ .cal-day.dim:hover { background:var(--hover); }
17947
+ .cal-day.drop-into { background:var(--accent-soft); }
17948
+ .cal-daynum { display:flex; align-items:center; justify-content:space-between; font-size:var(--fs-sm); font-weight:600; color:var(--fg-soft); }
17949
+ .cal-day.dim .cal-daynum { color:var(--muted); }
17950
+ .cal-day.today .cal-daynum span:first-child { background:var(--accent); color:var(--accent-fg); width:21px; height:21px; border-radius:50%; display:grid; place-items:center; }
17951
+ .cal-add { opacity:0; width:20px; height:20px; display:grid; place-items:center; color:var(--muted); border-radius:5px; transition:opacity .12s; }
17952
+ .cal-day:hover .cal-add { opacity:1; }
17953
+ .cal-add:hover { background:var(--hover-2); color:var(--fg); }
17954
+ .cal-events { display:flex; flex-direction:column; gap:3px; }
17955
+ .cal-ev { font-size:12px; padding:2px 7px; border-radius:5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:grab; touch-action:none;
17956
+ background:color-mix(in srgb,var(--c) 16%,transparent); color:var(--c); border-left:2px solid var(--c); }
17957
+ .cal-ev:hover { filter:brightness(1.04); box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--c) 40%,transparent); }
17958
+ :root[data-resolved="dark"] .cal-ev { background:color-mix(in srgb,var(--c) 24%,transparent); color:color-mix(in srgb,var(--c) 78%,#fff); }
17959
+ .cal-more { display:block; width:100%; text-align:left; font-size:var(--fs-xs); color:var(--muted); padding:2px 7px; border-radius:5px; }
17960
+ .cal-more:hover { background:var(--hover-2); color:var(--fg-soft); }
17961
+
17962
+ /* ---- timeline (gantt) view ---- */
17963
+ .tl { margin-top:4px; }
17964
+ .tl-toolbar { display:flex; align-items:center; gap:8px; padding:2px 0 12px; }
17965
+ .tl-scroll { overflow:auto; border:1px solid var(--line); border-radius:var(--radius); max-height:calc(100vh - 230px); max-height:calc(100dvh - 230px); }
17966
+ .tl-canvas { position:relative; }
17967
+ .tl-axis { position:sticky; top:0; z-index:4; background:var(--surface); border-bottom:1px solid var(--line); }
17968
+ .tl-corner { position:sticky; left:0; z-index:5; height:100%; display:flex; align-items:center; padding:0 12px; font-size:12px; font-weight:600; color:var(--muted);
17969
+ background:var(--surface); border-right:1px solid var(--line); }
17970
+ .tl-tick { position:absolute; top:0; height:100%; box-sizing:border-box; border-right:1px solid var(--line); display:flex; flex-direction:column; justify-content:flex-end; padding:0 0 4px 4px; }
17971
+ .tl-tick.mstart { border-right-color:var(--line-strong); }
17972
+ .tl-tick .tl-month { position:absolute; top:5px; left:4px; font-size:10.5px; font-weight:700; color:var(--fg-soft); white-space:nowrap; }
17973
+ .tl-tick .tl-dnum { font-size:var(--fs-micro); color:var(--muted); }
17974
+ .tl-tick.today .tl-dnum { color:var(--accent); font-weight:700; }
17975
+ .tl-row { position:absolute; left:0; right:0; border-bottom:1px solid var(--line); }
17976
+ .tl-rowlabel { position:sticky; left:0; z-index:3; height:100%; display:flex; align-items:center; padding:0 12px; font-size:13px; font-weight:500;
17977
+ background:var(--bg); border-right:1px solid var(--line); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
17978
+ .tl-item { position:absolute; top:7px; height:22px; box-sizing:border-box; background:var(--accent); color:var(--accent-fg); border-radius:6px;
17979
+ display:flex; align-items:center; gap:2px; padding:0 4px; cursor:grab; touch-action:none; box-shadow:var(--shadow-sm); overflow:hidden; }
17980
+ .tl-item .tl-label { flex:1; font-size:12px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; pointer-events:none; }
17981
+ .tl-row:hover .tl-item { box-shadow:0 0 0 1px var(--accent), var(--shadow-sm); }
17982
+ .tl-item .tl-h { width:8px; align-self:stretch; flex:none; cursor:col-resize; border-radius:6px; opacity:0; transition:opacity .1s; }
17983
+ .tl-item:hover .tl-h { opacity:1; background:rgba(255,255,255,.35); }
17984
+ .tl-item .tl-h:hover { background:rgba(255,255,255,.6); }
17985
+ .tl-milestone { position:absolute; top:9px; width:18px; height:18px; transform:rotate(45deg); background:var(--accent); border-radius:3px; cursor:grab; touch-action:none; box-shadow:var(--shadow-sm); margin-left:-9px; }
17986
+ .tl-milestone:hover { box-shadow:0 0 0 2px var(--accent-soft), var(--shadow-sm); }
17987
+ .tl-track-empty { position:absolute; top:0; right:0; height:100%; cursor:copy; }
17988
+ .tl-track-empty:hover { background:var(--hover); }
17989
+ .tl-schedule { position:sticky; top:7px; height:22px; display:inline-flex; align-items:center; padding:0 10px; border-radius:6px; font-size:12px;
17990
+ color:var(--muted); border:1px dashed var(--line-strong); background:var(--bg); white-space:nowrap; }
17991
+ .tl-schedule:hover { color:var(--accent); border-color:var(--accent); }
17992
+ .tl-today { position:absolute; top:0; width:2px; background:var(--accent); opacity:.55; z-index:2; pointer-events:none; }
17993
+ .tl-guide { position:absolute; top:0; width:0; border-left:1px dashed var(--accent); z-index:6; pointer-events:none; }
17994
+ .tl-guide-label { position:absolute; top:2px; left:4px; font-size:var(--fs-micro); font-weight:600; color:var(--accent-fg); background:var(--accent);
17995
+ padding:1px 6px; border-radius:5px; white-space:nowrap; box-shadow:var(--shadow-sm); }
17996
+
17997
+ /* mobile */
17998
+ /* Gate on a coarse pointer, not width alone: a narrow *desktop* window keeps
17999
+ the desktop layout/sizes (no jarring snap to 16px fonts + 40px buttons when
18000
+ it crosses 768px), while phones/tablets (and DevTools device mode, which
18001
+ emulates coarse) get the touch treatment. JS useIsMobile mirrors this query. */
18002
+ @media (max-width: 768px) and (pointer: coarse) {
18003
+ /* Full-page navigation, document-scrolled: the active view (home sidebar
18004
+ or the picked content view, via body.mobile-content from app.tsx) sits
18005
+ in normal flow and the *document* scrolls; the inactive view is
18006
+ display:none. This is structural for iOS 26 Safari: its liquid-glass
18007
+ bars only composite pixels from the document's scrolled canvas \u2014 a
18008
+ fixed pane with an inner scroller never paints under the bottom bar,
18009
+ so the chrome degrades to a tint sampled once and frozen (opaque pane
18010
+ bg) or fallback white (transparent pane); no see-through, and theme
18011
+ switches never propagate. In-flow content gets both for free: glass
18012
+ blurs live pixels, colors follow theme/navigation in real time.
18013
+ (app.tsx owns the scroll handoff between the two views.) */
18014
+ body { overflow:auto; }
18015
+ #app { display:block; height:auto; }
18016
+ .sidebar { width:100% !important; min-height:100dvh; margin-left:0 !important;
18017
+ border-right:0; transition:none; }
18018
+ body.mobile-content .sidebar { display:none; }
18019
+ .main { display:none; }
18020
+ body.mobile-content .main { display:flex; min-height:100dvh; overflow:visible;
18021
+ animation:page-in .22s cubic-bezier(.4,0,.2,1); }
18022
+ .sb-scroll { flex:none; overflow:visible; }
18023
+ .content { flex:none; overflow:visible; }
18024
+ /* Keep the back-button header pinned while the document scrolls. Its
18025
+ background must sit on the sticky box itself: the status-bar tint is
18026
+ sampled from edge-touching sticky elements' own background-color \u2014 a
18027
+ transparent box samples as a fallback white. Each home\u2194content switch
18028
+ toggles the pane's display, a real render-tree entry, so the tint
18029
+ re-samples on navigation. */
18030
+ .topbar { position:sticky; top:0; z-index:30; background:var(--bg); }
18031
+ /* With no sticky element at an edge (home top, all bottoms) the chrome
18032
+ derives from the html/body backgrounds and the scrolled canvas \u2014 keep
18033
+ all of them tracking the active surface so bars and overscroll blend. */
18034
+ html:has(body.mobile), body.mobile { background:var(--sidebar); }
18035
+ html:has(body.mobile-content), body.mobile-content { background:var(--bg); }
18036
+ .sb-head .iconbtn { display:none; } /* no collapse on mobile */
18037
+ .sb-resizer { display:none; }
18038
+ .topbar .hamburger { display:grid; } /* serves as the back button */
18039
+
18040
+ /* Sites/settings move from the bottom footer rows up into the home header
18041
+ as icon buttons \u2014 no full-width strip eating into the list + safe area. */
18042
+ .sb-footer { display:none; }
18043
+ .sb-act { display:grid; place-items:center; width:40px; height:40px; flex:none;
18044
+ border-radius:var(--radius-sm); color:var(--fg-soft); position:relative; }
18045
+ .sb-act:active, .sb-act.active { background:var(--hover-2); color:var(--fg); }
18046
+ .sb-act .ico { width:20px; height:20px; }
18047
+ .sb-act .nav-dot { position:absolute; top:7px; right:7px; box-shadow:0 0 0 2px var(--sidebar); }
18048
+
18049
+ /* Phone typography: bigger text for a small screen held close. Tap-target
18050
+ sizing and the 16px iOS anti-zoom inputs live in the width-independent
18051
+ (pointer:coarse) block below \u2014 tablets need those too. */
18052
+ .navitem { padding:9px 8px; font-size:16px; }
18053
+ .navitem .emoji { font-size:18px; }
18054
+ .navitem .tw { width:26px; height:26px; }
18055
+ .sb-section-head { font-size:12px; }
18056
+ .sb-search kbd { display:none; } /* no hardware keyboard, no shortcut hint */
18057
+ /* Search box is hidden until the header search button reveals it; it then
18058
+ slides+fades down in. Desktop base (.sb-search{display:flex}) is untouched,
18059
+ so search stays always-on there. reduced-motion is handled globally. */
18060
+ .sb-search { display:none; }
18061
+ .sb-search.open { display:flex; animation:search-reveal .22s cubic-bezier(.32,.72,0,1); }
18062
+ @keyframes search-reveal { from { opacity:0; transform:translateY(-8px); } }
18063
+ .btn { font-size:15px; }
18064
+ .crumb { font-size:16px; }
18065
+ .pop .item { font-size:16px; }
18066
+ .pop .item .t { font-size:16px; }
18067
+
18068
+ /* Document: bigger body text, tighter side padding for narrow screens. */
18069
+ .doc { padding:24px 18px calc(36vh + env(safe-area-inset-bottom)); }
18070
+ .doc-title { font-size:30px; }
18071
+ .doc .editable { font-size:16px; }
18072
+ /* 16px (not 14): a focused code textarea below 16px makes iOS auto-zoom. */
18073
+ .codeblock { --code-fs:16px; }
18074
+ .block .gutter { display:none; }
18075
+ /* Anchor/TOC jumps: clear the mobile topbar (49px min + notch inset);
18076
+ the desktop 60px estimate leaves blocks half-hidden behind it. */
18077
+ .block { scroll-margin-top:calc(49px + env(safe-area-inset-top) + 8px); }
18078
+ .db { padding:16px 14px 90px; }
18079
+ .peek { width:100%; }
18080
+
18081
+ /* Safe areas (viewport-fit=cover): keep chrome clear of the notch / home bar. */
18082
+ .sb-head { padding-top:calc(14px + env(safe-area-inset-top)); }
18083
+ .topbar { padding-top:calc(10px + env(safe-area-inset-top)); }
18084
+ /* no footer on mobile, so the list itself clears the home indicator */
18085
+ .sb-scroll { padding-bottom:calc(24px + env(safe-area-inset-bottom)); }
18086
+
18087
+ /* Overlays: dialogs become bottom sheets (centered cards fight the soft
18088
+ keyboard), and fixed-bottom chrome stays clear of the home indicator. */
18089
+ .modal-scrim { align-items:flex-end; padding:0; }
18090
+ .modal { width:100%; max-height:92vh; max-height:92dvh;
18091
+ border-radius:var(--radius-xl) var(--radius-xl) 0 0;
18092
+ padding-bottom:env(safe-area-inset-bottom);
18093
+ animation:sheet-up .22s cubic-bezier(.32,.72,0,1); }
18094
+ .toasts { left:12px; right:12px; bottom:calc(12px + env(safe-area-inset-bottom)); }
18095
+ .toast { justify-content:center; }
18096
+ .selbar { bottom:calc(16px + env(safe-area-inset-bottom)); max-width:calc(100vw - 24px); }
18097
+ }
18098
+ @keyframes sheet-up { from { transform:translateY(100%); } }
18099
+ /* In-flow stand-in for the old two-pane slide: only the entering view
18100
+ animates (the other is display:none, document-scroll layout above). */
18101
+ @keyframes page-in { from { transform:translateX(28px); opacity:0; } }
18102
+
18103
+ /* Touch ergonomics, decoupled from width: tablets (iPad portrait is 820px+)
18104
+ keep the desktop layout above but still tap with fingers, so target sizes,
18105
+ drag handles and the iOS anti-zoom input size key off the pointer alone. */
18106
+ @media (pointer: coarse) {
18107
+ /* iOS Safari auto-zooms the page when a focused field is below 16px; the
18108
+ class selectors out-specify the element-level sizes set on these. */
18109
+ input, textarea, .text-input, .doc-source, input.inlineedit { font-size:16px; }
18110
+ .navitem .acts button { width:34px; height:34px; }
18111
+ .iconbtn { width:40px; height:40px; }
18112
+ .btn { padding:9px 14px; min-height:40px; }
18113
+ .pop .item { padding:11px 10px; }
18114
+ /* Column/sidebar resize handles: 7px is a mouse affordance, not a finger's. */
18115
+ .col-resizer, .doc-col-resizer, .sb-resizer { width:16px; right:-8px; }
18116
+ }
18117
+
18118
+ /* No hover available (touch): controls that only materialize on :hover are
18119
+ unreachable, so pin them visible. Width-independent on purpose \u2014 this is
18120
+ the tablet's lifeline too. Desktops with a mouse are untouched. */
18121
+ @media (hover: none) {
18122
+ .code-tools { opacity:1; transform:none; pointer-events:auto; }
18123
+ .rowactions { opacity:1; pointer-events:auto; }
18124
+ .doc-th .doc-col-menu, .cal-add, .filerow .facts,
18125
+ .navitem .acts, .sb-section-head .add { opacity:1; }
18126
+ table.doc-table .doc-row-del { opacity:1; }
18127
+ .selcell input { opacity:1; }
18128
+ .rowgrip { color:var(--muted); }
18129
+ .tl-item .tl-h { opacity:1; background:rgba(255,255,255,.35); }
18130
+ /* Neutralize the stickiest tap-residue tints (a tap "hovers" until the
18131
+ next tap elsewhere); transient menus/buttons are left alone. */
18132
+ .navitem:hover { background:none; }
18133
+ .iconbtn:hover { background:none; }
18134
+ tbody tr:hover { background:none; }
18135
+ td.cell-td:hover { background:none; }
18136
+ .filerow:hover { background:none; }
18137
+ .cal-day:hover { background:none; }
18138
+ .cal-day.dim:hover { background:var(--surface); }
18139
+ }
18140
+
18141
+ /* Quick Notes window (desktop, loaded at #quick). A transparent body lets the
18142
+ macOS vibrancy blur show through; a translucent panel keeps text readable.
18143
+ The top bar is the window drag region; its buttons opt back out. */
18144
+ body.quicknote { background:transparent; }
18145
+ /* The main app lays #app out as a flex row (sidebar + main); the quick-note
18146
+ route has a single child, so override to a full-bleed block or the panel
18147
+ shrinks to its content width and the window's vibrancy shows through. */
18148
+ body.quicknote #app { display:block; width:100vw; height:100vh; height:100dvh; }
18149
+ .qn { display:flex; flex-direction:column; width:100%; height:100vh; height:100dvh; overflow:hidden;
18150
+ background:color-mix(in srgb, var(--bg) 20%, transparent); }
18151
+ .qn-bar { display:flex; align-items:center; gap:6px; padding:4px 10px 4px 76px;
18152
+ user-select:none; -webkit-app-region:drag;
18153
+ background:color-mix(in srgb, var(--surface) 30%, transparent); }
18154
+ .qn-brand { flex:1; min-width:0; font-size:12px; font-weight:600; color:var(--fg-soft);
18155
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18156
+ .qn-actions { display:flex; gap:2px; -webkit-app-region:no-drag; }
18157
+ .qn-actions .iconbtn { width:26px; height:26px; }
18158
+ .qn-actions .iconbtn.active { color:var(--accent); background:var(--hover-2); }
18159
+ .qn-body { flex:1; overflow:auto; -webkit-app-region:no-drag; }
18160
+ .qn-body .doc { max-width:none; margin:0; padding:14px 22px 30vh; }
18161
+ .qn-body .doc-title { font-size:24px; margin-bottom:2px; }
18162
+ .qn-body .doc-meta { display:none; }
18163
+ /* A native checkbox paints an opaque white box when unchecked, which breaks
18164
+ the window's vibrancy blur. Draw a custom box so the unchecked background
18165
+ stays transparent; checked falls back to the accent fill + checkmark. */
18166
+ body.quicknote .b-todo .marker input {
18167
+ appearance:none; -webkit-appearance:none; box-sizing:border-box;
18168
+ border:1.5px solid color-mix(in srgb, var(--fg-soft) 55%, transparent);
18169
+ border-radius:4px; background:transparent; }
18170
+ body.quicknote .b-todo .marker input:checked {
18171
+ border-color:var(--accent);
18172
+ background:var(--accent) center/10px no-repeat
18173
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12l5 5L20 7'/%3E%3C/svg%3E"); }
18174
+ /* Compact menus inside the quick-note window only (dropdown + slash command
18175
+ share .pop/.item/.lico with the main app; scope under body.quicknote so the
18176
+ main app keeps its roomier density). */
18177
+ body.quicknote .pop { padding:5px; border-radius:9px; }
18178
+ body.quicknote .pop .lbl { padding:4px 8px 2px; font-size:10px; }
18179
+ body.quicknote .pop .item { padding:5px 9px; gap:8px; font-size:var(--fs-sm); }
18180
+ body.quicknote .pop .item .t { font-size:var(--fs-sm); }
18181
+ body.quicknote .pop .item .d { font-size:10.5px; }
18182
+ body.quicknote .pop .item .lico { width:22px; height:22px; }
18183
+ body.quicknote .pop .item .lico.plain { width:20px; height:20px; }
18184
+ body.quicknote .pop .sep { margin:4px 4px; }
18185
+
18186
+ /* Quick Notes settings rows (desktop only). */
18187
+ .qn-set-row { display:flex; align-items:center; gap:14px; padding:12px 0; border-top:1px solid var(--line); }
18188
+ .qn-set-row:first-of-type { border-top:0; }
18189
+ .qn-set-main { flex:1; min-width:0; }
18190
+ .qn-set-name { font-weight:600; }
18191
+ .qn-set-desc { color:var(--muted); font-size:13px; margin-top:2px; }
18192
+ .qn-shortcut { min-width:140px; justify-content:center; font-family:var(--mono); }
18193
+ .qn-shortcut.capturing { outline:2px solid var(--accent); color:var(--accent); }
18194
+ `;
18195
+
18196
+ // src/webui/server/assets.ts
18197
+ var RUNNING_FROM_SOURCE = import.meta.url.includes("/src/webui/");
18198
+ var HTML = `<!doctype html>
18199
+ <html lang="zh">
18200
+ <head>
18201
+ <meta charset="utf-8" />
18202
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
18203
+ <!-- Mobile browser chrome (status/address bar) tints to theme-color; app.tsx
18204
+ keeps #theme-color-meta in sync with the active theme + visible surface. -->
18205
+ <meta name="theme-color" id="theme-color-meta" content="#ffffff" />
18206
+ <meta name="apple-mobile-web-app-capable" content="yes" />
18207
+ <meta name="apple-mobile-web-app-status-bar-style" content="default" />
18208
+ <title>Metahub</title>
18209
+ <link rel="preconnect" href="https://fonts.googleapis.com">
18210
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
18211
+ <!-- Load web fonts without blocking first paint: media="print" keeps the
18212
+ stylesheet non-render-blocking, then onload flips it to "all". The page
18213
+ paints instantly with the system-font fallbacks baked into --ui/--mono and
18214
+ swaps to the web fonts when they arrive (or stays on system fonts offline).
18215
+ This is what kept the desktop cold-start white for seconds on slow networks. -->
18216
+ <link rel="stylesheet" media="print" onload="this.media='all'" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
18217
+ <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"></noscript>
18218
+ <!-- FOUC guard: resolve the stored choice (system \u2192 OS preference) to an
18219
+ explicit data-resolved before first paint. CSS keys off data-resolved
18220
+ alone, so the dark palette exists exactly once \u2014 see styles.css. Dark also
18221
+ pre-tints the status bar (hex mirrors --bg) so the first frame isn't white. -->
18222
+ <script>try{var t=localStorage.getItem('mh-theme'),d=t==='dark'||(t!=='light'&&matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.dataset.resolved=d?'dark':'light';if(d)document.getElementById('theme-color-meta').content='#1a1a1c'}catch(e){}</script>
18223
+ <link rel="stylesheet" href="/webui.css">
18224
+ </head>
18225
+ <body>
18226
+ <div id="app"></div>
18227
+ <script type="module" src="/webui.js"></script>
18228
+ </body>
18229
+ </html>`;
18230
+ var injectedJs = null;
18231
+ function setWebuiBundle(js) {
18232
+ injectedJs = js;
18233
+ }
18234
+ var cachedJs = null;
18235
+ var cachedJsMtime = 0;
18236
+ function newestSourceMtime(dir) {
18237
+ let newest = 0;
18238
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
18239
+ if (e.name === "server" || e.name.startsWith("."))
18240
+ continue;
18241
+ const p = join3(dir, e.name);
18242
+ if (e.isDirectory())
18243
+ newest = Math.max(newest, newestSourceMtime(p));
18244
+ else if (/\.tsx?$/.test(e.name))
18245
+ newest = Math.max(newest, statSync(p).mtimeMs);
18246
+ }
18247
+ return newest;
18248
+ }
18249
+ async function getJs() {
18250
+ if (injectedJs != null)
18251
+ return injectedJs;
18252
+ if (RUNNING_FROM_SOURCE) {
18253
+ const srcDir = fileURLToPath(new URL("..", import.meta.url));
18254
+ const newest = newestSourceMtime(srcDir);
18255
+ if (cachedJs == null || newest > cachedJsMtime) {
18256
+ const entry = fileURLToPath(new URL("../app.tsx", import.meta.url));
18257
+ const res = await Bun.build({ entrypoints: [entry], target: "browser" });
18258
+ if (!res.success)
18259
+ throw new AggregateError(res.logs, "webui build failed");
18260
+ cachedJs = await res.outputs[0].text();
18261
+ cachedJsMtime = newest;
18262
+ }
18263
+ return cachedJs;
18264
+ }
18265
+ if (cachedJs == null) {
18266
+ const prebuilt = Bun.file(fileURLToPath(new URL("./webui.js", import.meta.url)));
18267
+ if (!await prebuilt.exists()) {
18268
+ throw new Error("webui bundle missing: dist/webui.js was not built \u2014 run `bun run build`");
18269
+ }
18270
+ cachedJs = await prebuilt.text();
18271
+ }
18272
+ return cachedJs;
18273
+ }
18274
+ async function getCss() {
18275
+ if (RUNNING_FROM_SOURCE) {
18276
+ return Bun.file(fileURLToPath(new URL("../styles.css", import.meta.url))).text();
18277
+ }
18278
+ return styles_default;
18279
+ }
18280
+ async function warmWebui() {
18281
+ try {
18282
+ await getJs();
18283
+ } catch {}
18284
+ }
18285
+ function asset(body, contentType) {
18286
+ return new Response(body, {
18287
+ headers: { "content-type": contentType, "cache-control": "no-store" }
18288
+ });
18289
+ }
18290
+ async function serveWebui(req) {
18291
+ if (req.method !== "GET")
18292
+ return null;
18293
+ const { pathname } = new URL(req.url);
18294
+ if (pathname === "/")
18295
+ return asset(HTML, "text/html; charset=utf-8");
18296
+ if (pathname === "/webui.css")
18297
+ return asset(await getCss(), "text/css; charset=utf-8");
18298
+ if (pathname === "/webui.js") {
18299
+ try {
18300
+ return asset(await getJs(), "text/javascript; charset=utf-8");
18301
+ } catch (e) {
18302
+ console.error("[webui] failed to serve /webui.js \u2014", e);
18303
+ return new Response(`webui build failed: ${e}`, { status: 500 });
18304
+ }
18305
+ }
18306
+ return null;
18307
+ }
18308
+ // src/webui/server/routes.ts
18309
+ init_errors();
18310
+
18311
+ // src/core/history.ts
18312
+ init_crdt();
18313
+ init_errors();
18314
+ var REVISION_GAP_MS = 1500;
18315
+ function hlcMillis(hlc2) {
18316
+ return Number(hlc2.slice(0, 15));
18317
+ }
18318
+ function hlcIso(hlc2) {
18319
+ return new Date(hlcMillis(hlc2)).toISOString();
18320
+ }
18321
+ function clusterRevisions(changes) {
18322
+ const groups = [];
18323
+ let cur = [];
18324
+ for (const c of changes) {
18325
+ const prev = cur[cur.length - 1];
18326
+ if (prev && (prev.node_id !== c.node_id || hlcMillis(c.hlc) - hlcMillis(prev.hlc) > REVISION_GAP_MS)) {
18327
+ groups.push(cur);
18328
+ cur = [];
18329
+ }
18330
+ cur.push(c);
18331
+ }
18332
+ if (cur.length)
18333
+ groups.push(cur);
18334
+ return groups;
18335
+ }
18336
+ function flagSet(value) {
18337
+ return value !== null && value !== "0" && value !== "false" && value !== "null";
18338
+ }
18339
+ function registersAt(changes, at) {
18340
+ const out = new Map;
18341
+ for (const c of changes) {
18342
+ if (c.hlc > at)
18343
+ continue;
18344
+ const cur = out.get(c.col);
18345
+ if (!cur || c.hlc > cur.hlc)
18346
+ out.set(c.col, { value: c.value, hlc: c.hlc });
18347
+ }
18348
+ return out;
18349
+ }
18350
+ function rowChanges(db2, dataset, rowId) {
18351
+ return db2.query("SELECT hlc, node_id, dataset, row_id, col, value FROM crdt_changes WHERE dataset = ? AND row_id = ? ORDER BY hlc").all(dataset, rowId);
18352
+ }
18353
+ function everBlockIds(db2, docId) {
18354
+ return db2.query("SELECT DISTINCT row_id FROM crdt_changes WHERE dataset = 'doc_blocks' AND col = 'doc_id' AND value = ?").all(JSON.stringify(docId)).map((r) => r.row_id);
18355
+ }
18356
+ function docChanges(db2, docId) {
18357
+ const blocks2 = everBlockIds(db2, docId);
18358
+ const placeholders = blocks2.map(() => "?").join(",");
18359
+ const blockClause = blocks2.length ? ` OR (dataset = 'doc_blocks' AND row_id IN (${placeholders}))` : "";
18360
+ return db2.query(`SELECT hlc, node_id, dataset, row_id, col, value FROM crdt_changes
18361
+ WHERE (dataset = 'documents' AND row_id = ?)${blockClause}
18362
+ ORDER BY hlc`).all(docId, ...blocks2);
18363
+ }
18364
+ function listDocumentRevisions(db2, id) {
18365
+ const changes = docChanges(db2, id);
18366
+ if (!changes.length)
18367
+ throw new MhError("not_found", `no such document: ${id}`);
18368
+ return clusterRevisions(changes).map((group) => {
18369
+ const changedBlocks = new Set;
18370
+ const deletedBlocks = new Set;
18371
+ let created = false;
18372
+ let deleted = false;
18373
+ let titleChanged = false;
18374
+ for (const c of group) {
18375
+ if (c.dataset === "documents") {
18376
+ if (c.col === "created_hlc")
18377
+ created = true;
18378
+ else if (c.col === "title")
18379
+ titleChanged = true;
18380
+ else if (c.col === "__deleted")
18381
+ deleted = flagSet(c.value);
18382
+ } else if (c.col === "__deleted" && flagSet(c.value)) {
18383
+ deletedBlocks.add(c.row_id);
18384
+ } else if (c.col === "text") {
18385
+ changedBlocks.add(c.row_id);
18386
+ }
18387
+ }
18388
+ const last = group[group.length - 1];
18389
+ return {
18390
+ version: last.hlc,
18391
+ at: hlcIso(last.hlc),
18392
+ node_id: last.node_id,
18393
+ changes: group.length,
18394
+ created,
18395
+ deleted,
18396
+ title_changed: titleChanged,
18397
+ blocks_changed: changedBlocks.size,
18398
+ blocks_deleted: deletedBlocks.size
18399
+ };
18400
+ }).reverse();
18401
+ }
18402
+ function documentAtVersion(db2, id, at) {
18403
+ if (!at)
18404
+ throw new MhError("invalid_input", "missing version cutoff");
18405
+ const changes = docChanges(db2, id).filter((c) => c.hlc <= at);
18406
+ if (!changes.length)
18407
+ throw new MhError("not_found", `no version of document ${id} at or before ${at}`);
18408
+ const docRegs = registersAt(changes.filter((c) => c.dataset === "documents"), at);
18409
+ const parse5 = (col) => {
18410
+ const r = docRegs.get(col);
18411
+ return r?.value == null ? undefined : JSON.parse(r.value);
18412
+ };
18413
+ const byBlock = new Map;
18414
+ for (const c of changes) {
18415
+ if (c.dataset !== "doc_blocks")
18416
+ continue;
18417
+ (byBlock.get(c.row_id) ?? byBlock.set(c.row_id, []).get(c.row_id)).push(c);
18418
+ }
18419
+ const blocks2 = [];
18420
+ for (const [blockId, blockChanges] of byBlock) {
18421
+ const regs = registersAt(blockChanges, at);
18422
+ const reg = (col) => {
18423
+ const r = regs.get(col);
18424
+ return r?.value == null ? undefined : JSON.parse(r.value);
18425
+ };
18426
+ if (reg("doc_id") !== id)
18427
+ continue;
18428
+ if (regs.get("__deleted") && flagSet(regs.get("__deleted").value))
18429
+ continue;
18430
+ const text = reg("text");
18431
+ if (typeof text !== "string" || !text)
18432
+ continue;
18433
+ blocks2.push({
18434
+ id: blockId,
18435
+ text,
18436
+ order_key: reg("order_key") ?? null,
18437
+ blankAfter: reg("blank_after") ?? 0
18438
+ });
18439
+ }
18440
+ blocks2.sort((a, b) => {
18441
+ if (a.order_key !== b.order_key) {
18442
+ if (a.order_key === null)
18443
+ return -1;
18444
+ if (b.order_key === null)
18445
+ return 1;
18446
+ return a.order_key < b.order_key ? -1 : 1;
18447
+ }
18448
+ return a.id < b.id ? -1 : 1;
18449
+ });
18450
+ const body = byBlock.size ? serializeDocBlocks(blocks2) : parse5("body") ?? "";
18451
+ return {
18452
+ id,
18453
+ title: parse5("title") ?? "",
18454
+ body,
18455
+ deleted: docRegs.get("__deleted") ? flagSet(docRegs.get("__deleted").value) : false,
18456
+ version: changes[changes.length - 1].hlc
18457
+ };
18458
+ }
18459
+ function revertDocument(db2, id, to, opts = {}) {
18460
+ const cur = getDocument(db2, id);
18461
+ if (!cur)
18462
+ throw new MhError("not_found", `no such document: ${id}`);
18463
+ if (opts.ifMatch !== undefined && documentVersion(db2, id) !== opts.ifMatch)
18464
+ throw new MhError("stale", `stale: document changed since ${opts.ifMatch}; re-read first`);
18465
+ const past = documentAtVersion(db2, id, to);
18466
+ if (past.deleted)
18467
+ throw new MhError("invalid_input", "target version is a deleted state; use doc delete instead");
18468
+ const fields = {};
18469
+ if (past.title !== cur.title)
18470
+ fields.title = past.title;
18471
+ if (past.body !== (cur.body ?? ""))
18472
+ fields.body = past.body;
18473
+ if (Object.keys(fields).length)
18474
+ updateDocument(db2, id, fields);
18475
+ return {
18476
+ id,
18477
+ changed: Object.keys(fields).length > 0,
18478
+ restored: past.version,
18479
+ version: documentVersion(db2, id)
18480
+ };
18481
+ }
18482
+ var RECORD_META2 = new Set(["database_id", "created_hlc", "order_key", "__deleted"]);
18483
+ function listRecordRevisions(db2, id) {
18484
+ const changes = rowChanges(db2, "records", id);
18485
+ if (!changes.length)
18486
+ throw new MhError("not_found", `no such record: ${id}`);
18487
+ return clusterRevisions(changes).map((group) => {
18488
+ const fields = new Set;
18489
+ let created = false;
18490
+ let deleted = false;
18491
+ let moved = false;
18492
+ for (const c of group) {
18493
+ if (c.col === "created_hlc")
18494
+ created = true;
18495
+ else if (c.col === "__deleted")
18496
+ deleted = flagSet(c.value);
18497
+ else if (c.col === "order_key")
18498
+ moved = true;
18499
+ else if (!RECORD_META2.has(c.col))
18500
+ fields.add(c.col);
18501
+ }
18502
+ const last = group[group.length - 1];
18503
+ return {
18504
+ version: last.hlc,
18505
+ at: hlcIso(last.hlc),
18506
+ node_id: last.node_id,
18507
+ changes: group.length,
18508
+ created,
18509
+ deleted,
18510
+ fields: [...fields],
18511
+ moved
18512
+ };
18513
+ }).reverse();
18514
+ }
18515
+ function recordAtVersion(db2, id, at) {
18516
+ if (!at)
18517
+ throw new MhError("invalid_input", "missing version cutoff");
18518
+ const changes = rowChanges(db2, "records", id).filter((c) => c.hlc <= at);
18519
+ if (!changes.length)
18520
+ throw new MhError("not_found", `no version of record ${id} at or before ${at}`);
18521
+ const regs = registersAt(changes, at);
18522
+ const data = {};
18523
+ for (const [col, reg] of regs) {
18524
+ if (RECORD_META2.has(col))
18525
+ continue;
18526
+ if (reg.value === null)
18527
+ continue;
18528
+ data[col] = JSON.parse(reg.value);
18529
+ }
18530
+ const dbReg = regs.get("database_id");
18531
+ return {
18532
+ id,
18533
+ database_id: dbReg?.value == null ? null : JSON.parse(dbReg.value),
18534
+ deleted: regs.get("__deleted") ? flagSet(regs.get("__deleted").value) : false,
18535
+ data,
18536
+ version: changes[changes.length - 1].hlc
18537
+ };
18538
+ }
18539
+ function revertRecord(db2, id, to) {
18540
+ const cur = db2.query("SELECT database_id, data, __deleted FROM records WHERE id = ?").get(id);
18541
+ if (!cur)
18542
+ throw new MhError("not_found", `no such record: ${id}`);
18543
+ const past = recordAtVersion(db2, id, to);
18544
+ if (past.deleted)
18545
+ throw new MhError("invalid_input", "target version is a deleted state; use record delete instead");
18546
+ const liveProps = new Set(cur.database_id ? listProperties(db2, cur.database_id).map((p) => p.id) : []);
18547
+ const curData = JSON.parse(cur.data || "{}");
18548
+ const fields = [];
18549
+ for (const propId of liveProps) {
18550
+ const inPast = Object.hasOwn(past.data, propId);
18551
+ const inCur = Object.hasOwn(curData, propId);
18552
+ if (inPast && (!inCur || JSON.stringify(past.data[propId]) !== JSON.stringify(curData[propId]))) {
18553
+ emit(db2, "records", id, propId, past.data[propId]);
18554
+ fields.push(propId);
18555
+ } else if (!inPast && inCur) {
18556
+ emit(db2, "records", id, propId, undefined);
18557
+ fields.push(propId);
18558
+ }
18559
+ }
18560
+ const undeleted = cur.__deleted !== 0;
18561
+ if (undeleted)
18562
+ emit(db2, "records", id, "__deleted", 0);
18563
+ return { id, changed: fields.length > 0 || undeleted, fields, undeleted, restored: past.version };
18564
+ }
18565
+
18566
+ // src/webui/server/routes.ts
18567
+ var DatabaseSchema = exports_external.object({
18568
+ id: exports_external.string(),
18569
+ name: exports_external.string(),
18570
+ icon: exports_external.string().nullable(),
18571
+ created_hlc: exports_external.string()
18572
+ });
18573
+ var PropertySchema = exports_external.object({
18574
+ id: exports_external.string(),
18575
+ database_id: exports_external.string(),
18576
+ name: exports_external.string(),
18577
+ type: exports_external.string(),
18578
+ config: exports_external.any().nullable(),
18579
+ position: exports_external.number()
18580
+ });
18581
+ var RecordSchema = exports_external.object({
18582
+ id: exports_external.string(),
18583
+ database_id: exports_external.string(),
18584
+ values: exports_external.record(exports_external.string(), exports_external.any()).describe('Field values keyed by property name (e.g. {"Amount":35}). Reads nest cells here; writes take a flat {column: value} map.')
18585
+ });
18586
+ var MoveRecordReq = exports_external.object({
18587
+ target: exports_external.string(),
18588
+ where: exports_external.enum(["before", "after"])
18589
+ });
18590
+ var DocumentSummarySchema = exports_external.object({
18591
+ id: exports_external.string(),
18592
+ title: exports_external.string(),
18593
+ database_id: exports_external.string().nullable(),
18594
+ parent_id: exports_external.string().nullable(),
18595
+ created_hlc: exports_external.string(),
18596
+ order_key: exports_external.string().nullable()
18597
+ });
18598
+ var MoveDocumentReq = exports_external.object({
18599
+ target: exports_external.string(),
18600
+ where: exports_external.enum(["before", "after", "into"])
18601
+ });
18602
+ var DocumentSchema = DocumentSummarySchema.extend({
18603
+ body: exports_external.string().nullable(),
18604
+ version: exports_external.string().optional()
18605
+ });
18606
+ var SearchHitSchema = exports_external.object({
18607
+ type: exports_external.string(),
18608
+ id: exports_external.string(),
18609
+ database_id: exports_external.string().nullable(),
18610
+ title: exports_external.string().optional(),
18611
+ snippet: exports_external.string()
18612
+ });
18613
+ var OkSchema2 = exports_external.object({ ok: exports_external.boolean() });
18614
+ var CreateDatabaseReq = exports_external.object({ name: exports_external.string(), icon: exports_external.string().optional() });
18615
+ var UpdateDatabaseReq = exports_external.object({
18616
+ name: exports_external.string().optional(),
18617
+ icon: exports_external.string().nullable().optional()
18618
+ });
18619
+ var CreatePropertyReq = exports_external.object({
18620
+ db: exports_external.string(),
18621
+ name: exports_external.string(),
18622
+ type: exports_external.string(),
18623
+ config: exports_external.any().optional()
18624
+ });
18625
+ var UpdatePropertyReq = exports_external.object({
18626
+ name: exports_external.string().optional(),
18627
+ type: exports_external.string().optional(),
18628
+ config: exports_external.any().optional(),
18629
+ position: exports_external.number().optional()
18630
+ });
18631
+ var SetWidthReq = exports_external.object({ width: exports_external.number() });
18632
+ var RecordValuesReq = exports_external.record(exports_external.string(), exports_external.any()).describe("Flat { column: value } cell patch (keyed by property name).");
18633
+ var CreateDocumentReq = exports_external.object({
18634
+ title: exports_external.string(),
18635
+ body: exports_external.string().optional(),
18636
+ database_id: exports_external.string().optional(),
18637
+ parent_id: exports_external.string().optional()
18638
+ });
18639
+ var UpdateDocumentReq = exports_external.object({
18640
+ title: exports_external.string().optional(),
18641
+ body: exports_external.string().optional(),
18642
+ parent_id: exports_external.string().nullable().optional(),
18643
+ if_match: exports_external.string().optional()
18644
+ });
18645
+ var RevisionBase = exports_external.object({
18646
+ version: exports_external.string(),
18647
+ at: exports_external.string().describe("Wall-clock time of the revision (ISO 8601)"),
18648
+ node_id: exports_external.string(),
18649
+ changes: exports_external.number(),
18650
+ created: exports_external.boolean(),
18651
+ deleted: exports_external.boolean()
18652
+ });
18653
+ var DocRevisionSchema = RevisionBase.extend({
18654
+ title_changed: exports_external.boolean(),
18655
+ blocks_changed: exports_external.number(),
18656
+ blocks_deleted: exports_external.number()
18657
+ });
18658
+ var RecordRevisionSchema = RevisionBase.extend({
18659
+ fields: exports_external.array(exports_external.string()).describe("Property ids of the cells written"),
18660
+ moved: exports_external.boolean()
18661
+ });
18662
+ var DocumentVersionStateSchema = exports_external.object({
18663
+ id: exports_external.string(),
18664
+ title: exports_external.string(),
18665
+ body: exports_external.string(),
18666
+ deleted: exports_external.boolean(),
18667
+ version: exports_external.string()
18668
+ });
18669
+ var RevertDocumentReq = exports_external.object({
18670
+ to: exports_external.string().describe("Version token from /api/document/history"),
18671
+ if_match: exports_external.string().optional()
18672
+ });
18673
+ var RevertRecordReq = exports_external.object({
18674
+ to: exports_external.string().describe("Version token from /api/record/history")
18675
+ });
18676
+ var RevertDocResultSchema = exports_external.object({
18677
+ id: exports_external.string(),
18678
+ changed: exports_external.boolean(),
18679
+ restored: exports_external.string(),
18680
+ version: exports_external.string()
18681
+ });
18682
+ var RevertRecordResultSchema = exports_external.object({
18683
+ id: exports_external.string(),
18684
+ changed: exports_external.boolean(),
18685
+ fields: exports_external.array(exports_external.string()),
18686
+ undeleted: exports_external.boolean(),
18687
+ restored: exports_external.string()
18688
+ });
18689
+ function need3(req, key) {
18690
+ const v = new URL(req.url).searchParams.get(key);
18691
+ if (!v)
18692
+ throw new Error(`missing query param: ${key}`);
18693
+ return v;
18694
+ }
18695
+ function opt(req, key) {
18696
+ return new URL(req.url).searchParams.get(key) ?? undefined;
18697
+ }
18698
+ function handle3(fn) {
18699
+ return async (req, ctx) => {
18700
+ try {
18701
+ const out = await fn(req, ctx);
18702
+ if (out instanceof Response)
18703
+ return out;
18704
+ return Response.json(out ?? null);
18705
+ } catch (e) {
18706
+ return errorResponse(e);
18707
+ }
18708
+ };
18709
+ }
18710
+ var VersionSchema = exports_external.object({ version: exports_external.string() });
18711
+ var webuiRoutes = [
18712
+ {
18713
+ method: "GET",
18714
+ path: "/api/version",
18715
+ summary: "Version of the running core (sidecar). Used by the desktop app's settings footer.",
18716
+ response: VersionSchema,
18717
+ handler: () => Response.json({ version: package_default.version })
18718
+ },
18719
+ {
18720
+ method: "GET",
18721
+ path: "/api/databases",
18722
+ summary: "List all databases",
18723
+ response: exports_external.array(DatabaseSchema),
18724
+ handler: handle3((_req, { db: db2 }) => listDatabases(db2))
18725
+ },
18726
+ {
18727
+ method: "POST",
18728
+ path: "/api/databases",
18729
+ summary: "Create a database",
18730
+ request: CreateDatabaseReq,
18731
+ response: DatabaseSchema,
18732
+ handler: handle3(async (req, { db: db2 }) => {
18733
+ const body = await req.json();
18734
+ return createDatabase(db2, body);
18735
+ })
18736
+ },
18737
+ {
18738
+ method: "PATCH",
18739
+ path: "/api/database",
18740
+ summary: "Rename a database or change its icon. Query: ?id=<id>",
18741
+ request: UpdateDatabaseReq,
18742
+ response: DatabaseSchema,
18743
+ handler: handle3(async (req, { db: db2 }) => {
18744
+ const body = await req.json();
18745
+ return updateDatabase(db2, need3(req, "id"), body);
18746
+ })
18747
+ },
18748
+ {
18749
+ method: "DELETE",
18750
+ path: "/api/database",
18751
+ summary: "Delete a database (and stop it being the current one). Query: ?id=<id>",
18752
+ response: OkSchema2,
18753
+ handler: handle3((req, { db: db2 }) => ({ ok: deleteDatabase(db2, need3(req, "id")) }))
18754
+ },
18755
+ {
18756
+ method: "GET",
18757
+ path: "/api/properties",
18758
+ summary: "List a database's properties (columns). Query: ?db=<id>",
18759
+ response: exports_external.array(PropertySchema),
18760
+ handler: handle3((req, { db: db2 }) => listProperties(db2, need3(req, "db")))
18761
+ },
18762
+ {
18763
+ method: "POST",
18764
+ path: "/api/properties",
18765
+ summary: "Add a property to a database",
18766
+ request: CreatePropertyReq,
18767
+ response: PropertySchema,
18768
+ handler: handle3(async (req, { db: db2 }) => {
18769
+ const body = await req.json();
18770
+ return addProperty(db2, body.db, { name: body.name, type: body.type, config: body.config });
18771
+ })
18772
+ },
18773
+ {
18774
+ method: "PATCH",
18775
+ path: "/api/property",
18776
+ summary: "Update a property: rename, change type, edit config/options, reorder. Query: ?id=<id>",
18777
+ request: UpdatePropertyReq,
18778
+ response: PropertySchema,
18779
+ handler: handle3(async (req, { db: db2 }) => {
18780
+ const body = await req.json();
18781
+ return updateProperty(db2, need3(req, "id"), body);
18782
+ })
18783
+ },
18784
+ {
18785
+ method: "PATCH",
18786
+ path: "/api/property/width",
18787
+ summary: "Set a property's table column width (px). Query: ?id=<id>",
18788
+ request: SetWidthReq,
18789
+ response: PropertySchema,
18790
+ handler: handle3(async (req, { db: db2 }) => {
18791
+ const body = await req.json();
18792
+ return setPropertyWidth(db2, need3(req, "id"), body.width);
18793
+ })
18794
+ },
18795
+ {
18796
+ method: "DELETE",
18797
+ path: "/api/property",
18798
+ summary: "Delete a property (column) and its cells. Query: ?id=<id>",
18799
+ response: OkSchema2,
18800
+ handler: handle3((req, { db: db2 }) => ({ ok: removeProperty(db2, need3(req, "id")) }))
18801
+ },
18802
+ {
18803
+ method: "GET",
18804
+ path: "/api/records",
18805
+ summary: "List records in a database. Query: ?db=<id>&sort=<field>&limit=<n>",
18806
+ response: exports_external.array(RecordSchema),
18807
+ handler: handle3((req, { db: db2 }) => {
18808
+ const limit = opt(req, "limit");
18809
+ return listRecords(db2, need3(req, "db"), {
18810
+ sort: opt(req, "sort"),
18811
+ limit: limit ? Number(limit) : undefined
18812
+ });
18813
+ })
18814
+ },
18815
+ {
18816
+ method: "POST",
18817
+ path: "/api/records",
18818
+ summary: "Create a record. Query: ?db=<id>; body is a {field: value} map",
18819
+ request: RecordValuesReq,
18820
+ response: RecordSchema,
18821
+ handler: handle3(async (req, { db: db2 }) => {
18822
+ const values = await req.json();
18823
+ return createRecord(db2, need3(req, "db"), values);
18824
+ })
18825
+ },
18826
+ {
18827
+ method: "GET",
18828
+ path: "/api/record",
18829
+ summary: "Get one record. Query: ?id=<id>",
18830
+ response: RecordSchema,
18831
+ handler: handle3((req, { db: db2 }) => {
18832
+ const id = need3(req, "id");
18833
+ const rec = getRecord(db2, id);
18834
+ if (!rec)
18835
+ throw new MhError("not_found", `no such record: ${id}`);
18836
+ return rec;
18837
+ })
18838
+ },
18839
+ {
18840
+ method: "PATCH",
18841
+ path: "/api/record",
18842
+ summary: "Update record fields. Query: ?id=<id>; body is a {field: value} map",
18843
+ request: RecordValuesReq,
18844
+ response: RecordSchema,
18845
+ handler: handle3(async (req, { db: db2 }) => {
18846
+ const values = await req.json();
18847
+ return updateRecord(db2, need3(req, "id"), values);
18848
+ })
18849
+ },
18850
+ {
18851
+ method: "PATCH",
18852
+ path: "/api/record/order",
18853
+ summary: "Move a record before or after another record. Query: ?id=<id>",
18854
+ request: MoveRecordReq,
18855
+ response: RecordSchema,
18856
+ handler: handle3(async (req, { db: db2 }) => {
18857
+ const body = await req.json();
18858
+ return moveRecord(db2, need3(req, "id"), body.target, body.where);
18859
+ })
18860
+ },
18861
+ {
18862
+ method: "GET",
18863
+ path: "/api/record/history",
18864
+ summary: "List a record's revisions, newest first. Query: ?id=<id>",
18865
+ response: exports_external.array(RecordRevisionSchema),
18866
+ handler: handle3((req, { db: db2 }) => listRecordRevisions(db2, need3(req, "id")))
18867
+ },
18868
+ {
18869
+ method: "POST",
18870
+ path: "/api/record/revert",
18871
+ summary: "Restore a record's cells to a past version, recorded as a new revision. Query: ?id=<id>",
18872
+ request: RevertRecordReq,
18873
+ response: RevertRecordResultSchema,
18874
+ handler: handle3(async (req, { db: db2 }) => {
18875
+ const body = await req.json();
18876
+ return revertRecord(db2, need3(req, "id"), body.to);
18877
+ })
18878
+ },
18879
+ {
18880
+ method: "DELETE",
18881
+ path: "/api/record",
18882
+ summary: "Delete a record. Query: ?id=<id>",
18883
+ response: OkSchema2,
18884
+ handler: handle3((req, { db: db2 }) => ({ ok: deleteRecord(db2, need3(req, "id")) }))
18885
+ },
18886
+ {
18887
+ method: "GET",
18888
+ path: "/api/documents",
18889
+ summary: "List documents. Optional query: ?db=<id> or ?parent=<id>",
18890
+ response: exports_external.array(DocumentSummarySchema),
18891
+ handler: handle3((req, { db: db2 }) => listDocuments(db2, { database_id: opt(req, "db"), parent_id: opt(req, "parent") }))
18892
+ },
18893
+ {
18894
+ method: "POST",
18895
+ path: "/api/documents",
18896
+ summary: "Create a document",
18897
+ request: CreateDocumentReq,
18898
+ response: DocumentSchema,
18899
+ handler: handle3(async (req, { db: db2 }) => {
18900
+ const body = await req.json();
18901
+ return createDocument(db2, body);
18902
+ })
18903
+ },
18904
+ {
18905
+ method: "GET",
18906
+ path: "/api/document",
18907
+ summary: "Get one document with full body. Query: ?id=<id>",
18908
+ response: DocumentSchema,
18909
+ handler: handle3((req, { db: db2 }) => {
18910
+ const id = need3(req, "id");
18911
+ const doc2 = getDocument(db2, id);
18912
+ if (!doc2)
18913
+ throw new MhError("not_found", `no such document: ${id}`);
18914
+ return { ...doc2, version: documentVersion(db2, doc2.id) };
18915
+ })
18916
+ },
18917
+ {
18918
+ method: "PATCH",
18919
+ path: "/api/document",
18920
+ summary: "Update a document's title/body/parent_id (null = top level). Query: ?id=<id>",
18921
+ request: UpdateDocumentReq,
18922
+ response: DocumentSchema,
18923
+ handler: handle3(async (req, { db: db2 }) => {
18924
+ const body = await req.json();
18925
+ const { if_match, ...fields } = body;
18926
+ const doc2 = updateDocument(db2, need3(req, "id"), fields, { ifMatch: if_match });
18927
+ return { ...doc2, version: documentVersion(db2, doc2.id) };
18928
+ })
18929
+ },
18930
+ {
18931
+ method: "PATCH",
18932
+ path: "/api/document/move",
18933
+ summary: "Move a document before/after another, or into it as a child. Query: ?id=<id>",
18934
+ request: MoveDocumentReq,
18935
+ response: DocumentSchema,
18936
+ handler: handle3(async (req, { db: db2 }) => {
18937
+ const body = await req.json();
18938
+ return moveDocument(db2, need3(req, "id"), body.target, body.where);
18939
+ })
18940
+ },
18941
+ {
18942
+ method: "GET",
18943
+ path: "/api/document/history",
18944
+ summary: "List a document's revisions, newest first. Query: ?id=<id>",
18945
+ response: exports_external.array(DocRevisionSchema),
18946
+ handler: handle3((req, { db: db2 }) => listDocumentRevisions(db2, need3(req, "id")))
18947
+ },
18948
+ {
18949
+ method: "GET",
18950
+ path: "/api/document/at",
18951
+ summary: "A document's title/body as of a past version. Query: ?id=<id>&version=<token>",
18952
+ response: DocumentVersionStateSchema,
18953
+ handler: handle3((req, { db: db2 }) => documentAtVersion(db2, need3(req, "id"), need3(req, "version")))
18954
+ },
18955
+ {
18956
+ method: "POST",
18957
+ path: "/api/document/revert",
18958
+ summary: "Restore a document's title/body to a past version, recorded as a new revision. Query: ?id=<id>",
18959
+ request: RevertDocumentReq,
18960
+ response: RevertDocResultSchema,
18961
+ handler: handle3(async (req, { db: db2 }) => {
18962
+ const body = await req.json();
18963
+ return revertDocument(db2, need3(req, "id"), body.to, { ifMatch: body.if_match });
18964
+ })
18965
+ },
18966
+ {
18967
+ method: "DELETE",
18968
+ path: "/api/document",
18969
+ summary: "Delete a document. Query: ?id=<id>",
18970
+ response: OkSchema2,
18971
+ handler: handle3((req, { db: db2 }) => ({ ok: deleteDocument(db2, need3(req, "id")) }))
18972
+ },
18973
+ {
18974
+ method: "GET",
18975
+ path: "/api/search",
18976
+ summary: "Full-text search across documents and records. Query: ?q=<text>&limit=<n>",
18977
+ response: exports_external.array(SearchHitSchema),
18978
+ handler: handle3((req, { db: db2 }) => {
18979
+ const limit = opt(req, "limit");
18980
+ return search(db2, need3(req, "q"), { limit: limit ? Number(limit) : undefined });
18981
+ })
18982
+ }
18983
+ ];
18023
18984
  export {
18985
+ webuiRoutes,
18986
+ warmWebui,
18987
+ updateSite,
18024
18988
  updateRecord,
18025
18989
  updateProperty,
18026
18990
  updateDocument,
@@ -18028,8 +18992,10 @@ export {
18028
18992
  syncWithPeer,
18029
18993
  startServer,
18030
18994
  slugify,
18995
+ setWebuiBundle,
18031
18996
  setPropertyWidth,
18032
18997
  setCurrentDatabase,
18998
+ serveWebui,
18033
18999
  serializeDocBlocks,
18034
19000
  search,
18035
19001
  runSchema,
@@ -18048,11 +19014,15 @@ export {
18048
19014
  parseDocBlocks,
18049
19015
  openMetahub,
18050
19016
  observeHlc,
19017
+ normalizeSitePath,
19018
+ normalizeSiteName,
18051
19019
  nextHlc,
18052
19020
  newId,
18053
19021
  moveRecord,
19022
+ moveDocument,
18054
19023
  migrateRecords,
18055
19024
  migratePeers,
19025
+ migrateDocuments,
18056
19026
  migrateDocBlocks,
18057
19027
  metahubHome,
18058
19028
  maybeAutoIndex,
@@ -18081,6 +19051,7 @@ export {
18081
19051
  getBlob,
18082
19052
  ftsAvailable,
18083
19053
  formatHlc,
19054
+ errorCode,
18084
19055
  ensurePropIndex,
18085
19056
  ensureDirs,
18086
19057
  emitFields,
@@ -18103,6 +19074,7 @@ export {
18103
19074
  cacheDir,
18104
19075
  blobPath,
18105
19076
  backfillRecordOrderKeys,
19077
+ backfillDocumentOrderKeys,
18106
19078
  applyChange,
18107
19079
  appendDocument,
18108
19080
  addProperty,
@@ -18115,9 +19087,10 @@ export {
18115
19087
  PairRequestSchema,
18116
19088
  PROP_TYPES,
18117
19089
  PAIR_PATH,
19090
+ MhError,
18118
19091
  HealthResponseSchema,
18119
19092
  HEALTH_PATH,
18120
19093
  ChangeSchema
18121
19094
  };
18122
19095
 
18123
- //# debugId=D4C8ECDF76A598A964756E2164756E21
19096
+ //# debugId=3270CE1250D22E6A64756E2164756E21