@echomem/echo-memory-cloud-openclaw-plugin 0.2.1 → 0.2.3

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.
@@ -3,10 +3,15 @@ import { spawn } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import fs from "node:fs/promises";
5
5
  import fsSync from "node:fs";
6
- import { fileURLToPath } from "node:url";
7
- import { getLocalUiSetupState, saveLocalUiSetup } from "./config.js";
6
+ import { fileURLToPath } from "node:url";
7
+ import { getLocalUiSetupState, saveLocalUiSetup } from "./config.js";
8
8
  import { scanFullWorkspace, scanWorkspaceMarkdownFile } from "./openclaw-memory-scan.js";
9
- import { readLastSyncState } from "./state.js";
9
+ import {
10
+ readLastSyncState,
11
+ readLocalUiPresence,
12
+ resolveUiPresencePath,
13
+ writeLocalUiPresence,
14
+ } from "./state.js";
10
15
 
11
16
  const BASE_PORT = 17823;
12
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -15,9 +20,11 @@ const UI_WORKDIR = path.join(__dirname, "local-ui");
15
20
  const UI_DIST_DIR = path.join(__dirname, "local-ui", "dist");
16
21
  const UI_NODE_MODULES_DIR = path.join(UI_WORKDIR, "node_modules");
17
22
 
18
- let _instance = null;
19
- let _bootstrapPromise = null;
20
- let _lastOpenedUrl = null;
23
+ let _instance = null;
24
+ let _bootstrapPromise = null;
25
+ let _lastOpenedUrl = null;
26
+ const BACKEND_SOURCE_LOOKUP_TIMEOUT_MS = 4000;
27
+ const LOCAL_UI_PRESENCE_STALE_MS = 75000;
21
28
 
22
29
  /* ── File Watcher + SSE ────────────────────────────────── */
23
30
 
@@ -261,17 +268,17 @@ function getBrowserOpenCommand(url) {
261
268
  return null;
262
269
  }
263
270
 
264
- export async function openUrlInDefaultBrowser(url, opts = {}) {
265
- const { logger, force = false } = opts;
266
- if (!force) {
267
- const skipReason = detectBrowserOpenSkipReason();
268
- if (skipReason) {
269
- return { opened: false, reason: skipReason };
270
- }
271
- }
272
- if (_lastOpenedUrl === url) {
273
- return { opened: false, reason: "already_opened" };
274
- }
271
+ export async function openUrlInDefaultBrowser(url, opts = {}) {
272
+ const { logger, force = false } = opts;
273
+ if (!force) {
274
+ const skipReason = detectBrowserOpenSkipReason();
275
+ if (skipReason) {
276
+ return { opened: false, reason: skipReason };
277
+ }
278
+ }
279
+ if (!force && _lastOpenedUrl === url) {
280
+ return { opened: false, reason: "already_opened" };
281
+ }
275
282
  const command = getBrowserOpenCommand(url);
276
283
  if (!command) {
277
284
  return { opened: false, reason: "unsupported_platform" };
@@ -327,34 +334,143 @@ function readBody(req) {
327
334
  });
328
335
  }
329
336
 
330
- function resolveStoredFilePath(workspaceDir, entry) {
331
- const rawPath = entry?.filePath || entry?.file_path || entry?.path || null;
332
- if (!rawPath) {
333
- return null;
334
- }
335
- return path.isAbsolute(rawPath) ? path.normalize(rawPath) : path.resolve(workspaceDir, rawPath);
336
- }
337
-
338
- async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }) {
339
- const [lastState, files] = await Promise.all([
340
- statePath ? readLastSyncState(statePath) : Promise.resolve(null),
341
- scanFullWorkspace(workspaceDir),
342
- ]);
343
-
344
- const storedResults = Array.isArray(lastState?.results) ? lastState.results : [];
345
- const resultMap = new Map();
346
- for (const entry of storedResults) {
347
- const storedPath = resolveStoredFilePath(workspaceDir, entry);
348
- if (!storedPath) continue;
349
- resultMap.set(storedPath, entry);
350
- }
337
+ function resolveStoredFilePath(workspaceDir, entry) {
338
+ const rawPath = entry?.filePath || entry?.file_path || entry?.path || null;
339
+ if (!rawPath) {
340
+ return null;
341
+ }
342
+ return path.isAbsolute(rawPath) ? path.normalize(rawPath) : path.resolve(workspaceDir, rawPath);
343
+ }
344
+
345
+ function toPathKey(targetPath) {
346
+ const normalized = path.normalize(String(targetPath || ""));
347
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
348
+ }
349
+
350
+ function resolveBackendFilePath(workspaceDir, rawPath) {
351
+ if (!rawPath) {
352
+ return null;
353
+ }
354
+ return path.isAbsolute(rawPath)
355
+ ? path.normalize(rawPath)
356
+ : path.resolve(workspaceDir, String(rawPath));
357
+ }
358
+
359
+ async function readBackendSourceMap(apiClient, workspaceDir) {
360
+ const sources = new Map();
361
+ if (!apiClient) {
362
+ return sources;
363
+ }
364
+
365
+ const addSource = (sourcePath, latestAt = null) => {
366
+ const resolvedPath = resolveBackendFilePath(workspaceDir, sourcePath);
367
+ if (!resolvedPath) {
368
+ return;
369
+ }
370
+ const pathKey = toPathKey(resolvedPath);
371
+ const existing = sources.get(pathKey);
372
+ if (!existing || (latestAt && (!existing.latestAt || latestAt > existing.latestAt))) {
373
+ sources.set(pathKey, {
374
+ latestAt: latestAt || existing?.latestAt || null,
375
+ });
376
+ }
377
+ };
378
+
379
+ try {
380
+ const data = await apiClient.listAllSources();
381
+ for (const source of data?.paths || []) {
382
+ addSource(source?.file_path, source?.latest_at ?? null);
383
+ }
384
+ return sources;
385
+ } catch {
386
+ // Fall through to import-status fallback.
387
+ }
388
+
389
+ try {
390
+ const status = await apiClient.getImportStatus();
391
+ for (const source of status?.recent_sources || []) {
392
+ addSource(source?.file_path, source?.created_at ?? null);
393
+ }
394
+ } catch {
395
+ // Ignore backend status fallback failures in the local UI.
396
+ }
397
+
398
+ return sources;
399
+ }
400
+
401
+ function resolveWithin(timeoutMs, value) {
402
+ return new Promise((resolve) => {
403
+ setTimeout(() => resolve(value), timeoutMs);
404
+ });
405
+ }
406
+
407
+ function getLocalUiPresencePath(syncRunner) {
408
+ try {
409
+ const statePath = syncRunner?.getStatePath?.();
410
+ return statePath ? resolveUiPresencePath(path.dirname(statePath)) : null;
411
+ } catch {
412
+ return null;
413
+ }
414
+ }
415
+
416
+ async function updateLocalUiPresence(syncRunner, payload = {}) {
417
+ const presencePath = getLocalUiPresencePath(syncRunner);
418
+ if (!presencePath) {
419
+ return null;
420
+ }
421
+
422
+ const previous = await readLocalUiPresence(presencePath);
423
+ const next = {
424
+ clientId: payload.clientId || previous?.clientId || null,
425
+ serverInstanceId: payload.serverInstanceId || previous?.serverInstanceId || null,
426
+ active: payload.active !== undefined ? Boolean(payload.active) : true,
427
+ lastSeenAt: payload.lastSeenAt || new Date().toISOString(),
428
+ };
429
+ await writeLocalUiPresence(presencePath, next);
430
+ return next;
431
+ }
432
+
433
+ export async function hasRecentLocalUiPresence(syncRunner, { maxAgeMs = LOCAL_UI_PRESENCE_STALE_MS } = {}) {
434
+ const presencePath = getLocalUiPresencePath(syncRunner);
435
+ if (!presencePath) {
436
+ return false;
437
+ }
438
+
439
+ const presence = await readLocalUiPresence(presencePath);
440
+ if (!presence?.lastSeenAt || presence.active === false) {
441
+ return false;
442
+ }
443
+
444
+ const ageMs = Date.now() - new Date(presence.lastSeenAt).getTime();
445
+ return Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= maxAgeMs;
446
+ }
447
+
448
+ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath, apiClient }) {
449
+ const backendSourcesPromise = readBackendSourceMap(apiClient, workspaceDir).catch(() => new Map());
450
+ const [lastState, files, backendSources] = await Promise.all([
451
+ statePath ? readLastSyncState(statePath) : Promise.resolve(null),
452
+ scanFullWorkspace(workspaceDir),
453
+ Promise.race([
454
+ backendSourcesPromise,
455
+ resolveWithin(BACKEND_SOURCE_LOOKUP_TIMEOUT_MS, new Map()),
456
+ ]),
457
+ ]);
458
+
459
+ const storedResults = Array.isArray(lastState?.results) ? lastState.results : [];
460
+ const resultMap = new Map();
461
+ for (const entry of storedResults) {
462
+ const storedPath = resolveStoredFilePath(workspaceDir, entry);
463
+ if (!storedPath) continue;
464
+ resultMap.set(toPathKey(storedPath), entry);
465
+ }
351
466
 
352
467
  const DATE_RE = /^\d{4}-\d{2}-\d{2}/;
353
468
  const eligibleAbsolutePaths = new Set();
354
469
  const eligibleRelativePaths = [];
355
470
 
356
471
  const fileStatuses = files.map((f) => {
357
- const absPath = path.resolve(workspaceDir, f.relativePath);
472
+ const absPath = path.resolve(workspaceDir, f.relativePath);
473
+ const pathKey = toPathKey(absPath);
358
474
  const isPrivate =
359
475
  f.relativePath.startsWith("memory/private/") ||
360
476
  f.privacyLevel === "private";
@@ -362,16 +478,22 @@ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }
362
478
  Boolean(syncMemoryDir) && path.dirname(absPath) === syncMemoryDir;
363
479
  const isDaily =
364
480
  f.relativePath.startsWith("memory/") && DATE_RE.test(f.fileName);
365
- const stored = resultMap.get(absPath) ?? null;
481
+ const stored = resultMap.get(pathKey) ?? null;
366
482
  const storedStatus = String(stored?.status || "").trim().toLowerCase() || null;
367
483
  const attemptedHash = stored?.contentHash || stored?.content_hash || null;
368
- const successfulHash =
369
- stored?.lastSuccessfulContentHash
370
- || stored?.last_successful_content_hash
371
- || (storedStatus && storedStatus !== "failed" ? attemptedHash : null);
372
- const lastError = stored?.lastError || stored?.last_error || stored?.error || null;
373
- const lastAttemptAt = stored?.lastAttemptAt || stored?.last_attempt_at || null;
374
- const lastSuccessAt = stored?.lastSuccessAt || stored?.last_success_at || lastState?.finished_at || null;
484
+ const successfulHash =
485
+ stored?.lastSuccessfulContentHash
486
+ || stored?.last_successful_content_hash
487
+ || (storedStatus && storedStatus !== "failed" ? attemptedHash : null);
488
+ const lastError = stored?.lastError || stored?.last_error || stored?.error || null;
489
+ const lastAttemptAt = stored?.lastAttemptAt || stored?.last_attempt_at || null;
490
+ const backendSource = backendSources.get(pathKey) ?? null;
491
+ const lastSuccessAt =
492
+ stored?.lastSuccessAt
493
+ || stored?.last_success_at
494
+ || backendSource?.latestAt
495
+ || lastState?.finished_at
496
+ || null;
375
497
 
376
498
  if (isPrivate) {
377
499
  return {
@@ -396,14 +518,16 @@ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }
396
518
  eligibleAbsolutePaths.add(absPath);
397
519
  eligibleRelativePaths.push(f.relativePath);
398
520
 
399
- let status = "new";
400
- if (storedStatus === "failed" && attemptedHash && attemptedHash === f.contentHash) {
401
- status = "failed";
402
- } else if (successfulHash) {
403
- status = isDaily || successfulHash === f.contentHash ? "synced" : "modified";
404
- } else if (storedStatus === "failed") {
405
- status = "modified";
406
- }
521
+ let status = "new";
522
+ if (storedStatus === "failed" && attemptedHash && attemptedHash === f.contentHash) {
523
+ status = "failed";
524
+ } else if (successfulHash) {
525
+ status = isDaily || successfulHash === f.contentHash ? "synced" : "modified";
526
+ } else if (backendSource) {
527
+ status = "synced";
528
+ } else if (storedStatus === "failed") {
529
+ status = "modified";
530
+ }
407
531
 
408
532
  return {
409
533
  fileName: f.fileName,
@@ -451,7 +575,7 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
451
575
  return;
452
576
  }
453
577
 
454
- // SSE endpoint — push file-change events to the frontend
578
+ // SSE endpoint — push file-change events to the frontend
455
579
  if (url.pathname === "/api/events") {
456
580
  res.writeHead(200, {
457
581
  "Content-Type": "text/event-stream",
@@ -470,8 +594,28 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
470
594
  }
471
595
  return;
472
596
  }
473
-
474
- if (url.pathname === "/") {
597
+
598
+ if (url.pathname === "/api/ui-presence" && req.method === "POST") {
599
+ try {
600
+ const bodyResult = await readBody(req);
601
+ if (!bodyResult.ok) {
602
+ sendJson(res, { ok: false, error: "invalid_json" });
603
+ return;
604
+ }
605
+ await updateLocalUiPresence(syncRunner, {
606
+ clientId: typeof bodyResult.body?.clientId === "string" ? bodyResult.body.clientId : null,
607
+ serverInstanceId: typeof bodyResult.body?.serverInstanceId === "string" ? bodyResult.body.serverInstanceId : serverInstanceId,
608
+ active: bodyResult.body?.active,
609
+ });
610
+ sendJson(res, { ok: true });
611
+ } catch (error) {
612
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
613
+ res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }));
614
+ }
615
+ return;
616
+ }
617
+
618
+ if (url.pathname === "/") {
475
619
  // Always serve the built React app from dist/
476
620
  let content;
477
621
  try {
@@ -548,21 +692,16 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
548
692
  return;
549
693
  }
550
694
 
551
- if (fileScan.privacyLevel === "private") {
552
- sendJson(res, {
553
- fileName: fileScan.fileName,
554
- blocked: true,
555
- privacyLevel: fileScan.privacyLevel,
556
- privacyAutoUpgraded: fileScan.privacyAutoUpgraded,
557
- hasSensitiveContent: fileScan.hasSensitiveContent,
558
- hasHighRiskSensitiveContent: fileScan.hasHighRiskSensitiveContent,
559
- sensitiveSummary: fileScan.sensitiveSummary,
560
- sensitiveFindings: fileScan.sensitiveFindings,
561
- });
562
- return;
563
- }
564
-
565
- sendJson(res, { fileName: fileScan.fileName, content: fileScan.content });
695
+ sendJson(res, {
696
+ fileName: fileScan.fileName,
697
+ content: fileScan.content,
698
+ privacyLevel: fileScan.privacyLevel,
699
+ privacyAutoUpgraded: fileScan.privacyAutoUpgraded,
700
+ hasSensitiveContent: fileScan.hasSensitiveContent,
701
+ hasHighRiskSensitiveContent: fileScan.hasHighRiskSensitiveContent,
702
+ sensitiveSummary: fileScan.sensitiveSummary,
703
+ sensitiveFindings: fileScan.sensitiveFindings,
704
+ });
566
705
  return;
567
706
  }
568
707
 
@@ -679,11 +818,12 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
679
818
  if (url.pathname === "/api/sync-status") {
680
819
  try {
681
820
  const statePath = syncRunner?.getStatePath() ?? null;
682
- const syncView = await buildWorkspaceSyncView({
683
- workspaceDir,
684
- syncMemoryDir,
685
- statePath,
686
- });
821
+ const syncView = await buildWorkspaceSyncView({
822
+ workspaceDir,
823
+ syncMemoryDir,
824
+ statePath,
825
+ apiClient,
826
+ });
687
827
  sendJson(res, {
688
828
  lastSyncAt: syncView.lastState?.finished_at ?? null,
689
829
  syncedFileCount: syncView.fileStatuses.filter((status) => status.status === 'synced').length,
@@ -770,11 +910,12 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
770
910
  }
771
911
 
772
912
  const statePath = syncRunner?.getStatePath() ?? null;
773
- const syncView = await buildWorkspaceSyncView({
774
- workspaceDir,
775
- syncMemoryDir,
776
- statePath,
777
- });
913
+ const syncView = await buildWorkspaceSyncView({
914
+ workspaceDir,
915
+ syncMemoryDir,
916
+ statePath,
917
+ apiClient,
918
+ });
778
919
  const statusMap = new Map(syncView.fileStatuses.map((status) => [status.relativePath, status]));
779
920
  const requestedFilterPaths = new Set();
780
921
  const requestedInvalidPaths = [];
@@ -0,0 +1 @@
1
+ .card{position:absolute;border-radius:4px;overflow:hidden;box-shadow:0 1px 3px #0000002e;cursor:pointer;display:flex;flex-direction:column;padding:8px 10px;contain:layout style paint;animation:cardFadeIn .5s ease-out both}@keyframes cardFadeIn{0%{opacity:0}to{opacity:1}}.card:hover{box-shadow:0 4px 16px #0000004d;z-index:10}.card-selected{transform:scale(1.08);transform-origin:center center;box-shadow:0 8px 32px #a78bfa59,0 0 0 2px #a78bfa99;z-index:50!important;transition:transform .25s cubic-bezier(.34,1.56,.64,1),box-shadow .25s ease}.card-dimmed{opacity:.35;transition:opacity .3s ease}.card-dimmed:hover{opacity:.7}.card-expand-btn{flex-shrink:0;width:20px;height:20px;border:none;border-radius:4px;background:#a78bfa33;color:#a78bfa;font-size:12px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,transform .15s;line-height:1}.card-expand-btn:hover{background:#a78bfa66;transform:scale(1.15)}.card-checkbox{flex-shrink:0;font-size:14px;cursor:pointer;color:#999;line-height:1;transition:color .15s}.card-checkbox-on{color:#a78bfa}.card-lod0{position:absolute;border-radius:3px;contain:strict;pointer-events:none}.card-header{display:flex;align-items:center;gap:6px;flex-shrink:0;margin-bottom:4px}.card-name{font:600 11px/1.3 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.card-warning-toggle{flex-shrink:0;max-width:48%;display:inline-flex;align-items:center;gap:4px;border:1px solid rgba(160,112,48,.35);border-radius:999px;background:#a070301a;color:#8b6128;padding:2px 6px;font:600 8px/1.1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;cursor:pointer;min-width:0}.card-warning-toggle-high{border-color:#b040406b;background:#b040401a;color:#9c3d3d}.card-warning-toggle__icon{flex-shrink:0}.card-warning-toggle__text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.card-private-badge{flex-shrink:0;border:1px solid rgba(156,61,61,.35);border-radius:999px;background:#9c3d3d14;color:#9c3d3d;padding:2px 6px;font:700 8px/1.1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.card-cluster-badge{flex-shrink:0;border-radius:999px;padding:2px 6px;font:700 8px/1.1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;letter-spacing:.3px;border:1px solid rgba(255,255,255,.18);background:#ffffff1f;color:#d6d0c6;max-width:38%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.card-cluster-badge-identity{color:#fca5a5;border-color:#fca5a547;background:#fca5a514}.card-cluster-badge-long-term{color:#fdba74;border-color:#fdba7447;background:#fdba7414}.card-cluster-badge-journal{color:#fde68a;border-color:#fde68a47;background:#fde68a14}.card-cluster-badge-goals{color:#d6b388;border-color:#d6b38847;background:#d6b38814}.card-cluster-badge-technical{color:#93c5fd;border-color:#93c5fd47;background:#93c5fd14}.card-cluster-badge-thematic{color:#d8b4fe;border-color:#d8b4fe47;background:#d8b4fe14}.card-cluster-badge-knowledge{color:#86efac;border-color:#86efac47;background:#86efac14}.card-cluster-badge-system{color:#cbd5e1;border-color:#cbd5e147;background:#cbd5e114}.card-warning-panel{margin-bottom:6px;border:1px solid rgba(176,64,64,.2);border-radius:4px;background:#fff8f4eb;padding:6px 8px;font:500 9px/1.35 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#6b443d;pointer-events:none}.card-warning-panel__header,.card-warning-panel__row{display:flex;align-items:center;justify-content:space-between;gap:8px}.card-warning-panel__header{font-weight:700;margin-bottom:4px}.card-warning-panel__privacy{color:#9c3d3d}.card-warning-panel__row+.card-warning-panel__row{margin-top:2px}.card-content{flex:1;overflow:hidden;font:400 9.5px/1.45 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;letter-spacing:.01em;white-space:pre-wrap;word-break:break-word;-webkit-mask-image:linear-gradient(to bottom,#000 60%,transparent 100%);mask-image:linear-gradient(to bottom,#000 60%,transparent 100%);opacity:.72}.card-session-log{animation:cardFadeInLog .5s ease-out both}.card-session-log:hover{opacity:.75}@keyframes cardFadeInLog{0%{opacity:0}to{opacity:.5}}.session-badge{font-size:9px;flex-shrink:0}.card-content-log{flex:1;overflow:hidden;font:400 8px/1.35 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#aaa;white-space:pre-wrap;word-break:break-word;-webkit-mask-image:linear-gradient(to bottom,#000 40%,transparent 90%);mask-image:linear-gradient(to bottom,#000 40%,transparent 90%);opacity:.5}.stamp{flex-shrink:0;font:700 7px/1 -apple-system,sans-serif;letter-spacing:.5px;padding:2px 5px;border-radius:1px;pointer-events:none;border:1.5px solid;text-transform:uppercase;transform:rotate(-2deg);opacity:.75}.stamp-new,.stamp-mod{color:#a07030;border-color:#a07030;background:#a0703014}.stamp-synced{color:#9090a0;border-color:#9090a0;background:#9090a00f;opacity:.5}.stamp-local{color:#8d7e67;border-color:#8d7e67;background:#8d7e6714}.stamp-transient{opacity:.92}.stamp-queued{color:#7da2d6;border-color:#7da2d6;background:#7da2d61f}.stamp-syncing{color:#67d5c1;border-color:#67d5c1;background:#67d5c124}.stamp-done{color:#74c98a;border-color:#74c98a;background:#74c98a1f}.stamp-failed{color:#e58a8a;border-color:#e58a8a;background:#e58a8a24}.card-unselectable{opacity:.88}.card-checkbox-disabled{opacity:.35}.stamp-overlay{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) rotate(-8deg);font:800 14px/1 -apple-system,Songti SC,"Noto Serif SC",serif;letter-spacing:2px;color:#b04040;border:2.5px solid #b04040;padding:4px 10px;border-radius:2px;opacity:.55;pointer-events:none;white-space:nowrap;text-transform:uppercase}.stamp-overlay.stamp-sealed{text-shadow:0 0 2px rgba(176,64,64,.3);box-shadow:inset 0 0 4px #b0404026}.card-sealed .card-content{filter:blur(2px);opacity:.4}.minimap{position:absolute;bottom:40px;right:12px;border-radius:6px;overflow:hidden;border:1px solid #2a2a36;box-shadow:0 4px 16px #0006;z-index:80;cursor:pointer;opacity:.85;transition:opacity .15s}.minimap:hover{opacity:1}.minimap-canvas{display:block;width:180px;height:120px}.viewport-root{flex:1;position:relative;overflow:hidden}.viewport{width:100%;height:100%;cursor:grab;-webkit-user-select:none;user-select:none;overflow:hidden}.viewport:active{cursor:grabbing}.canvas{position:absolute;top:0;left:0;transform-origin:0 0;will-change:transform}.section-label{position:absolute;font-size:13px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;opacity:.3;pointer-events:none;white-space:nowrap;display:flex;align-items:baseline;gap:8px}.section-count{font-size:11px;font-weight:500;opacity:.6}.zoom-indicator{position:absolute;bottom:8px;right:8px;font-size:11px;color:#555;background:#0a0a0fcc;padding:3px 8px;border-radius:4px;pointer-events:none;z-index:50}.viewport-controls{position:absolute;right:12px;top:12px;display:flex;flex-direction:column;gap:8px;z-index:60}.viewport-control-pad{display:flex;flex-direction:column;gap:6px}.viewport-control-pad__row{display:flex;gap:6px}.viewport-control{min-width:64px;height:34px;border:1px solid rgba(90,86,78,.24);border-radius:8px;background:#faf8f3eb;color:#3a342d;font:600 12px/1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;cursor:pointer;box-shadow:0 8px 20px #2d28201f;transition:transform .12s ease,background .12s ease,border-color .12s ease}.viewport-control:hover{background:#fffcf5fa;border-color:#7a623066;transform:translateY(-1px)}.viewport-control:active{transform:translateY(0)}.viewport-control-fit{min-width:70px}.reading-panel-wrapper{flex:1;display:flex;overflow:hidden;background:#faf9f6;animation:rpFadeIn .3s ease-out}@keyframes rpFadeIn{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.reading-panel{flex:1;display:flex;flex-direction:column;max-width:800px;margin:0 auto;width:100%}.rp-header{flex-shrink:0;padding:24px 32px 16px;border-bottom:1px solid #e0ddd8;display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px}.rp-back{flex-shrink:0;width:32px;height:32px;border:none;border-radius:6px;background:#0000000f;color:#666;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,color .15s;margin-right:8px}.rp-back:hover{background:#0000001f;color:#333}.rp-title{font:700 24px/1.2 Georgia,"Noto Serif",serif;color:#2a2520;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rp-path{font:400 11px/1 -apple-system,sans-serif;color:#a09888;word-break:break-all;width:100%;padding-left:40px}.rp-body{flex:1;overflow-y:auto;padding:32px 32px 64px;font:400 16px/1.8 Georgia,"Noto Serif",serif;color:#3a3530;-webkit-font-smoothing:antialiased}.rp-h1{font-size:28px;font-weight:800;margin:36px 0 14px;color:#1a1510;line-height:1.3}.rp-h2{font-size:22px;font-weight:700;margin:32px 0 12px;color:#2a2520;line-height:1.3;border-bottom:1px solid #e8e4df;padding-bottom:8px}.rp-h3{font-size:18px;font-weight:700;margin:24px 0 8px;color:#3a3530;line-height:1.3}.rp-h4{font-size:16px;font-weight:700;margin:20px 0 6px;color:#4a4540;line-height:1.4}.rp-h5{font-size:15px;font-weight:700;margin:16px 0 4px;color:#5a5550}.rp-h6{font-size:13px;font-weight:600;margin:14px 0 4px;color:#6a6560;text-transform:uppercase;letter-spacing:.5px}.rp-p{margin:0 0 4px}.rp-spacer{height:14px}.rp-list{margin:4px 0 8px 24px;padding:0}.rp-list li{margin:4px 0}.rp-quote{margin:14px 0;padding:10px 20px;border-left:3px solid #c8b898;background:#c8b89814;color:#6a6050;font-style:italic}.rp-code-block{margin:14px 0;padding:16px 18px;background:#f0ede8;border-radius:6px;overflow-x:auto;font:400 13.5px/1.55 SF Mono,Fira Code,Consolas,monospace;color:#4a4540}.rp-inline-code{background:#ede9e3;padding:2px 6px;border-radius:3px;font:400 13.5px/1 SF Mono,Fira Code,Consolas,monospace;color:#6a5540}.rp-body a{color:#7c6a4f;text-decoration:underline;text-decoration-color:#7c6a4f4d;transition:text-decoration-color .15s}.rp-body a:hover{text-decoration-color:#7c6a4fcc}.rp-hr{border:none;border-top:1px solid #ddd8d0;margin:28px 0}.rp-body strong{font-weight:700;color:#2a2520}.rp-body em{font-style:italic}.rp-body del{text-decoration:line-through;opacity:.5}.rp-warning{max-width:640px;margin:0 auto 24px;border:1px solid #d7c49a;border-radius:10px;background:#fff9eb;padding:24px 26px;box-shadow:0 10px 30px #82641e14}.rp-warning__title{font:700 22px/1.25 Georgia,"Noto Serif",serif;color:#7a5a1f;margin-bottom:10px}.rp-warning__copy{margin:0 0 14px}.rp-warning__privacy{font:600 13px/1.4 -apple-system,sans-serif;color:#8a6421;margin-bottom:12px}.rp-warning__row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 0;border-top:1px solid rgba(138,100,33,.14);font:600 13px/1.4 -apple-system,sans-serif}.rp-body::-webkit-scrollbar{width:6px}.rp-body::-webkit-scrollbar-track{background:transparent}.rp-body::-webkit-scrollbar-thumb{background:#d0ccc4;border-radius:3px}.rp-body::-webkit-scrollbar-thumb:hover{background:#b0aaa0}:root{--canvas-bg: #1a1a22;--chrome-bg: rgba(10, 10, 15, .92);--chrome-border: #2a2a36;--accent: #a78bfa;--setup-width: 340px}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}body{background:var(--canvas-bg);color:#e0e0f0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;height:100vh;overflow:hidden}#root{display:flex;flex-direction:column;height:100vh}.setup-sidebar{position:fixed;top:44px;left:0;bottom:32px;width:22px;z-index:120;transition:width .22s ease}.setup-sidebar:hover{width:var(--setup-width)}.setup-sidebar__rail{position:absolute;inset:0 auto 0 0;width:22px;display:flex;align-items:center;justify-content:center;writing-mode:vertical-rl;transform:rotate(180deg);background:#16161feb;border-right:1px solid var(--chrome-border);color:#9389c9;font-size:10px;letter-spacing:.18em;text-transform:uppercase}.setup-sidebar__panel{position:absolute;inset:0 auto 0 22px;width:calc(var(--setup-width) - 22px);padding:18px 16px 16px;background:linear-gradient(180deg,#101019fa,#0b0b12fa),radial-gradient(circle at top,rgba(167,139,250,.18),transparent 42%);border-right:1px solid var(--chrome-border);overflow-y:auto;transform:translate(calc(-100% - 22px));transition:transform .22s ease}.setup-sidebar:hover .setup-sidebar__panel{transform:translate(0)}.setup-sidebar__header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}.setup-sidebar__header h2{font-size:16px;color:#f3efff;margin-bottom:4px}.setup-sidebar__header p{color:#aaa2c8;font-size:12px;line-height:1.45}.setup-pill{border:1px solid #50476a;border-radius:999px;color:#d0c7f4;font-size:10px;padding:5px 9px;white-space:nowrap}.setup-pill--ok{border-color:#2d6f4b;color:#9ff0be}.setup-card{background:#ffffff0a;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px;margin-bottom:12px;box-shadow:0 10px 30px #0000002e}.setup-card__title{color:#f1eaff;font-size:12px;font-weight:700;margin-bottom:8px;text-transform:uppercase;letter-spacing:.08em}.setup-copy{color:#c4bedc;font-size:12px;line-height:1.55}.setup-copy code{color:#fff;font-size:11px;word-break:break-all}.setup-steps{margin-left:16px;margin-bottom:8px;color:#d7d0ee;font-size:12px;line-height:1.6}.setup-field{display:flex;flex-direction:column;gap:5px;margin-bottom:10px}.setup-field span{color:#efe9ff;font-size:11px;font-weight:600}.setup-field input{background:#09090fbf;border:1px solid #3a3450;border-radius:10px;color:#f4f0ff;padding:10px 12px;font-size:12px;outline:none}.setup-field input:focus{border-color:var(--accent)}.setup-field small{color:#9087af;font-size:10px}.setup-save-btn{width:100%;border:1px solid #3d2f69;border-radius:10px;background:linear-gradient(135deg,#34255f,#5b43a0);color:#fff;font-weight:700;font-size:12px;padding:10px 12px;cursor:pointer}.setup-save-btn:disabled{opacity:.65;cursor:default}.setup-msg{margin-top:10px;font-size:11px;line-height:1.4}.setup-msg--ok{color:#8df0b2}.setup-msg--error{color:#ff9898}.hdr{height:44px;background:var(--chrome-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--chrome-border);display:flex;align-items:center;padding:0 16px;gap:12px;z-index:100;flex-shrink:0}.hdr-icon{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#8d84b8}.hdr-title{font-size:14px;font-weight:600;color:var(--accent);letter-spacing:.3px}.hdr-title-system{color:#9ca3af}.hdr-back{font-size:12px;color:#888;cursor:pointer;padding:3px 8px;border-radius:4px;transition:background .15s,color .15s}.hdr-back:hover{background:#ffffff14;color:var(--accent)}.hdr-search{background:#16161f;border:1px solid var(--chrome-border);border-radius:6px;color:#8f8fb0;font-size:12px;padding:5px 10px;width:180px;outline:none}.hdr-spacer{flex:1}.hdr-meta{font-size:11px;color:#555}.hdr-meta b{color:#777;font-weight:500}.hdr-conn{font-size:11px}.conn-ok{color:#4ade80}.conn-off{color:#f59e0b}.ftr{height:32px;background:var(--chrome-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-top:1px solid var(--chrome-border);display:flex;align-items:center;padding:0 16px;gap:16px;font-size:11px;color:#555;z-index:100;flex-shrink:0}.ftr b{color:#777;font-weight:500}.ftr-spacer{flex:1}.sync-btn{background:#2d1b69;color:var(--accent);border:1px solid #3b2a7a;border-radius:4px;padding:3px 12px;font-size:11px;font-weight:600;cursor:pointer}.sync-btn:hover{background:#3b2a7a}.sync-btn:disabled{opacity:.4;cursor:default}.ftr-system{color:#666;font-size:10px;cursor:pointer;padding:2px 8px;border-radius:4px;background:#ffffff0a;border:1px solid #2a2a36}.ftr-system:hover{background:#ffffff14;color:#888}.ftr-select-toggle{background:none;border:1px solid #333;border-radius:4px;color:#888;font-size:11px;padding:2px 8px;cursor:pointer}.ftr-select-toggle:hover{background:#ffffff0f;color:#bbb}.explore-btn{background:linear-gradient(135deg,#6d28d9,#a855f7);color:#fff;border:none;border-radius:4px;padding:3px 12px;font-size:11px;font-weight:600;cursor:pointer;text-decoration:none}.explore-btn:hover{opacity:.88;transform:translateY(-1px)}.explore-btn[aria-disabled=true]{opacity:.5;cursor:default;transform:none}.sync-result{color:#4ade80}.sync-error{color:#f87171}.sync-progress-dock{height:56px;background:#0d0d13f2;border-top:1px solid var(--chrome-border);border-bottom:1px solid var(--chrome-border);padding:10px 16px 8px;display:flex;flex-direction:column;gap:6px;z-index:100;flex-shrink:0}.sync-progress-top{display:flex;align-items:center;gap:12px;font-size:11px;color:#b8b8c8;flex-wrap:wrap}.sync-progress-title{color:#f0eff8;font-weight:700}.sync-progress-meta{color:#9a98ad}.sync-progress-track{width:100%;height:8px;border-radius:999px;background:#ffffff14;overflow:hidden}.sync-progress-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#4d6cff,#68dfc4);transition:width .18s ease}.sync-progress-detail{color:#f3b2b2;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty-state{flex:1;display:flex;align-items:center;justify-content:center;color:#666;font-size:14px}.selection-copy{color:#a78bfa}@media(max-width:900px){.setup-sidebar{width:18px}.setup-sidebar:hover{width:min(92vw,var(--setup-width))}.hdr-search{width:120px}.hdr-meta{display:none}}