@tensorix/metahub 0.5.1 → 0.5.2

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.
package/dist/index.js CHANGED
@@ -352,6 +352,22 @@ CREATE TABLE IF NOT EXISTS blob_policy (
352
352
  redundancy TEXT, -- 'all' | 'any'
353
353
  __deleted INTEGER NOT NULL DEFAULT 0
354
354
  );
355
+
356
+ -- Synced device roster (system tier, see tables.ts): one row per node id, written
357
+ -- by the device itself on open (describeSelf) and by anyone who renames it.
358
+ -- Replicates so every device shows the same name and icon for a peer \u2014 the only
359
+ -- record a purely bucket-joined device leaves besides its oplog stream. Each
360
+ -- column is its own LWW register, so a rename and a platform refresh never
361
+ -- clobber each other. meta.node_label survives as a node-local read fallback.
362
+ CREATE TABLE IF NOT EXISTS nodes (
363
+ id TEXT PRIMARY KEY, -- node_id
364
+ label TEXT, -- shared display name; any device may set it
365
+ platform TEXT, -- macos | windows | linux | ios | android | web
366
+ form TEXT, -- laptop | desktop | phone | server | browser
367
+ app TEXT, -- cli | server | desktop | web
368
+ first_seen INTEGER, -- epoch ms of the device's first self-description
369
+ __deleted INTEGER NOT NULL DEFAULT 0
370
+ );
355
371
  `, FTS_SCHEMA = `
356
372
  CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
357
373
  kind UNINDEXED,
@@ -410,27 +426,133 @@ function getNodeId(db) {
410
426
  db.query("INSERT INTO meta (key, value) VALUES ('node_id', ?)").run(id);
411
427
  return id;
412
428
  }
413
- function getNodeLabel(db) {
429
+ function readNodeMeta(db, nodeId) {
430
+ const r = db.query(`SELECT ${NODE_COLS} FROM nodes WHERE id = ? AND __deleted = 0`).get(nodeId);
431
+ return r ? rowToMeta(r) : null;
432
+ }
433
+ function listNodeMeta(db) {
434
+ return db.query(`SELECT ${NODE_COLS} FROM nodes WHERE __deleted = 0`).all().map(rowToMeta);
435
+ }
436
+ function legacySelfLabel(db) {
414
437
  const row = db.query("SELECT value FROM meta WHERE key = 'node_label'").get();
415
438
  return row?.value ?? null;
416
439
  }
417
- function setNodeLabel(db, label) {
418
- if (label == null || label.trim() === "") {
440
+ function mirrorLegacySelfLabel(db, label) {
441
+ if (label == null) {
419
442
  db.query("DELETE FROM meta WHERE key = 'node_label'").run();
420
443
  return;
421
444
  }
422
- db.query("INSERT INTO meta (key, value) VALUES ('node_label', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(label.trim());
445
+ db.query("INSERT INTO meta (key, value) VALUES ('node_label', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(label);
446
+ }
447
+ function nodeLabelOf(db, nodeId) {
448
+ const synced = readNodeMeta(db, nodeId)?.label;
449
+ if (synced)
450
+ return synced;
451
+ const peer = db.query("SELECT label FROM peers WHERE node_id = ? AND label IS NOT NULL AND label <> '' LIMIT 1").get(nodeId);
452
+ if (peer?.label)
453
+ return peer.label;
454
+ return nodeId === getNodeId(db) ? legacySelfLabel(db) : null;
455
+ }
456
+ function getNodeLabel(db) {
457
+ return nodeLabelOf(db, getNodeId(db));
458
+ }
459
+ function setNodeLabel(db, label, nodeId) {
460
+ const id = nodeId ?? getNodeId(db);
461
+ const next = label == null || label.trim() === "" ? null : label.trim();
462
+ const cur = readNodeMeta(db, id);
463
+ if (id === getNodeId(db))
464
+ mirrorLegacySelfLabel(db, next);
465
+ if (cur && cur.label === next)
466
+ return;
467
+ if (!cur && next == null)
468
+ return;
469
+ withChangeGroup("node", () => {
470
+ emit(db, "nodes", id, "label", next);
471
+ });
472
+ }
473
+ function describeSelf(db, info = {}) {
474
+ const id = getNodeId(db);
475
+ const cur = readNodeMeta(db, id);
476
+ const fields = {};
477
+ if (info.platform !== undefined && (cur?.platform ?? null) !== info.platform)
478
+ fields.platform = info.platform;
479
+ if (info.form !== undefined && (cur?.form ?? null) !== info.form)
480
+ fields.form = info.form;
481
+ if (info.app !== undefined && (cur?.app ?? null) !== info.app)
482
+ fields.app = info.app;
483
+ if (cur?.first_seen == null)
484
+ fields.first_seen = info.now ?? Date.now();
485
+ if (!cur?.label) {
486
+ const seed = legacySelfLabel(db) ?? info.defaultLabel ?? null;
487
+ if (seed)
488
+ fields.label = seed;
489
+ }
490
+ if (Object.keys(fields).length > 0) {
491
+ withChangeGroup("node", () => {
492
+ for (const [col, v] of Object.entries(fields))
493
+ emit(db, "nodes", id, col, v);
494
+ });
495
+ }
496
+ return readNodeMeta(db, id);
497
+ }
498
+ function platformFromProcess(p) {
499
+ switch (p) {
500
+ case "darwin":
501
+ return "macos";
502
+ case "win32":
503
+ return "windows";
504
+ case "linux":
505
+ return "linux";
506
+ case "android":
507
+ return "android";
508
+ default:
509
+ return null;
510
+ }
511
+ }
512
+ function defaultForm(platform, app) {
513
+ if (app === "web")
514
+ return "browser";
515
+ if (platform === "ios" || platform === "android")
516
+ return "phone";
517
+ if (platform === "linux")
518
+ return "server";
519
+ if (platform === "macos" || platform === "windows")
520
+ return "laptop";
521
+ return null;
423
522
  }
424
523
  function displayNodes(db) {
425
524
  const self = getNodeId(db);
426
- const peers = db.query("SELECT node_id, label FROM peers WHERE node_id IS NOT NULL AND node_id <> '' GROUP BY node_id").all();
427
- return [
428
- { node_id: self, label: getNodeLabel(db), self: true },
429
- ...peers.filter((p) => p.node_id !== self).map((p) => ({ ...p, self: false }))
430
- ];
525
+ const ids = new Set([self]);
526
+ for (const r of listNodeMeta(db))
527
+ ids.add(r.node_id);
528
+ const peers = db.query("SELECT node_id FROM peers WHERE node_id IS NOT NULL AND node_id <> '' GROUP BY node_id").all();
529
+ for (const p of peers)
530
+ ids.add(p.node_id);
531
+ const out = [...ids].map((node_id) => {
532
+ const m = readNodeMeta(db, node_id);
533
+ return {
534
+ node_id,
535
+ label: nodeLabelOf(db, node_id),
536
+ self: node_id === self,
537
+ platform: m?.platform ?? null,
538
+ form: m?.form ?? null,
539
+ app: m?.app ?? null
540
+ };
541
+ });
542
+ out.sort((a, b) => Number(b.self) - Number(a.self));
543
+ return out;
431
544
  }
545
+ var NODE_COLS = "id, label, platform, form, app, first_seen", rowToMeta = (r) => ({
546
+ node_id: r.id,
547
+ label: r.label,
548
+ platform: r.platform ?? null,
549
+ form: r.form ?? null,
550
+ app: r.app ?? null,
551
+ first_seen: r.first_seen
552
+ });
432
553
  var init_node = __esm(() => {
433
554
  init_ids();
555
+ init_crdt();
434
556
  });
435
557
 
436
558
  // src/core/hlc.ts
@@ -870,6 +992,10 @@ var init_crdt = __esm(() => {
870
992
  blob_policy: {
871
993
  table: "blob_policy",
872
994
  cols: new Set(["full_nodes", "redundancy", "__deleted"])
995
+ },
996
+ nodes: {
997
+ table: "nodes",
998
+ cols: new Set(["label", "platform", "form", "app", "first_seen", "__deleted"])
873
999
  }
874
1000
  };
875
1001
  RECORD_META = new Set(["database_id", "created_hlc", "order_key", "__deleted"]);
@@ -887,7 +1013,8 @@ var init_crdt = __esm(() => {
887
1013
  "sites:spa",
888
1014
  "site_channels:__deleted",
889
1015
  "site_files:__deleted",
890
- "blob_policy:__deleted"
1016
+ "blob_policy:__deleted",
1017
+ "nodes:__deleted"
891
1018
  ]);
892
1019
  });
893
1020
 
@@ -2205,17 +2332,14 @@ function cacheStats(db) {
2205
2332
  }
2206
2333
  function knownNodes(db) {
2207
2334
  const self = getNodeId(db);
2208
- const byId = new Map;
2209
- byId.set(self, getNodeLabel(db));
2210
- const peers = db.query("SELECT node_id, label FROM peers WHERE node_id IS NOT NULL").all();
2211
- for (const p of peers)
2212
- if (!byId.get(p.node_id))
2213
- byId.set(p.node_id, p.label ?? byId.get(p.node_id) ?? null);
2214
- const grants = db.query("SELECT DISTINCT node_id FROM peer_grants WHERE node_id IS NOT NULL").all();
2215
- for (const g of grants)
2216
- if (!byId.has(g.node_id))
2217
- byId.set(g.node_id, null);
2218
- return [...byId.entries()].map(([nodeId, label]) => ({ nodeId, label, self: nodeId === self }));
2335
+ const ids = new Set([self]);
2336
+ for (const r of db.query("SELECT id FROM nodes WHERE __deleted = 0").all())
2337
+ ids.add(r.id);
2338
+ for (const p of db.query("SELECT DISTINCT node_id FROM peers WHERE node_id IS NOT NULL").all())
2339
+ ids.add(p.node_id);
2340
+ for (const g of db.query("SELECT DISTINCT node_id FROM peer_grants WHERE node_id IS NOT NULL").all())
2341
+ ids.add(g.node_id);
2342
+ return [...ids].map((nodeId) => ({ nodeId, label: nodeLabelOf(db, nodeId), self: nodeId === self }));
2219
2343
  }
2220
2344
  var POLICY_ID = "default", blobBytesResolver = null, setFullNodes = (db, nodeIds) => {
2221
2345
  withChangeGroup(null, () => {
@@ -2468,21 +2592,28 @@ function migrateSitesAccess(db) {
2468
2592
  backfill("public_grants", "NULL");
2469
2593
  }
2470
2594
  }
2471
- function migrateSiteChannels(db) {
2472
- if (!tableExists3(db, "site_channels"))
2595
+ function replayDataset(db, dataset) {
2596
+ if (!tableExists3(db, dataset))
2473
2597
  return;
2474
- const wm = db.query("SELECT value FROM meta WHERE key = ?").get(SITE_CHANNELS_REPLAY_KEY);
2598
+ const key = `${dataset}_replay_seq`;
2599
+ const wm = db.query("SELECT value FROM meta WHERE key = ?").get(key);
2475
2600
  const since = wm ? Number(wm.value) : 0;
2476
2601
  const changes = db.query(`SELECT seq, ${CHANGE_SELECT} FROM crdt_changes
2477
- WHERE dataset = 'site_channels' AND seq > ? ORDER BY seq`).all(since);
2602
+ WHERE dataset = ? AND seq > ? ORDER BY seq`).all(dataset, since);
2478
2603
  if (changes.length === 0)
2479
2604
  return;
2480
2605
  db.transaction((rows) => {
2481
2606
  for (const change of rows)
2482
2607
  applyChange(db, change);
2483
- db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(SITE_CHANNELS_REPLAY_KEY, String(rows[rows.length - 1].seq));
2608
+ db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, String(rows[rows.length - 1].seq));
2484
2609
  })(changes);
2485
2610
  }
2611
+ function migrateSiteChannels(db) {
2612
+ replayDataset(db, "site_channels");
2613
+ }
2614
+ function migrateNodes(db) {
2615
+ replayDataset(db, "nodes");
2616
+ }
2486
2617
  function initSchema(db) {
2487
2618
  runSchema(db);
2488
2619
  migrateOplog(db);
@@ -2497,8 +2628,8 @@ function initSchema(db) {
2497
2628
  migrateShares(db);
2498
2629
  migrateSitesAccess(db);
2499
2630
  migrateSiteChannels(db);
2631
+ migrateNodes(db);
2500
2632
  }
2501
- var SITE_CHANNELS_REPLAY_KEY = "site_channels_replay_seq";
2502
2633
  var init_schema_init = __esm(() => {
2503
2634
  init_records();
2504
2635
  init_documents();
@@ -9025,7 +9156,7 @@ var package_default;
9025
9156
  var init_package = __esm(() => {
9026
9157
  package_default = {
9027
9158
  name: "@tensorix/metahub",
9028
- version: "0.5.1",
9159
+ version: "0.5.2",
9029
9160
  description: "Metahub core library and CLI",
9030
9161
  license: "AGPL-3.0-only",
9031
9162
  type: "module",
@@ -10527,6 +10658,8 @@ async function addAndSyncStoragePeer(db, spec) {
10527
10658
  publish: spec.publish,
10528
10659
  priority: spec.priority
10529
10660
  };
10661
+ if (spec.provider)
10662
+ config.provider = spec.provider;
10530
10663
  if (encrypt) {
10531
10664
  if (spec.recoveryCode) {
10532
10665
  const rawKey = await decodeRecoveryCode(spec.recoveryCode);
@@ -12163,10 +12296,14 @@ function listDevices(db) {
12163
12296
  const ensure = (nodeId) => {
12164
12297
  let v = byNode.get(nodeId);
12165
12298
  if (!v) {
12299
+ const meta = readNodeMeta(db, nodeId);
12166
12300
  v = {
12167
12301
  nodeId,
12168
- label: null,
12302
+ label: nodeLabelOf(db, nodeId),
12169
12303
  self: nodeId === self,
12304
+ platform: meta?.platform ?? null,
12305
+ form: meta?.form ?? null,
12306
+ app: meta?.app ?? null,
12170
12307
  channels: [],
12171
12308
  lastActivityAt: null,
12172
12309
  revocable: "none",
@@ -12177,7 +12314,9 @@ function listDevices(db) {
12177
12314
  }
12178
12315
  return v;
12179
12316
  };
12180
- ensure(self).label = getNodeLabel(db);
12317
+ ensure(self);
12318
+ for (const r of db.query("SELECT id FROM nodes WHERE __deleted = 0").all())
12319
+ ensure(r.id);
12181
12320
  const oplog = db.query("SELECT node_id, MAX(hlc) AS h FROM crdt_changes GROUP BY node_id").all();
12182
12321
  for (const r of oplog)
12183
12322
  ensure(r.node_id).channels.push({ kind: "oplog", ref: "", lastSeenAt: parseHlc(r.h).millis });
@@ -12186,8 +12325,6 @@ function listDevices(db) {
12186
12325
  if (!p.node_id)
12187
12326
  continue;
12188
12327
  const v = ensure(p.node_id);
12189
- if (!v.label)
12190
- v.label = p.label;
12191
12328
  v.channels.push({
12192
12329
  kind: "paired_out",
12193
12330
  ref: p.url,
@@ -12205,6 +12342,9 @@ function listDevices(db) {
12205
12342
  nodeId: null,
12206
12343
  label: null,
12207
12344
  self: false,
12345
+ platform: null,
12346
+ form: null,
12347
+ app: null,
12208
12348
  channels: [ch],
12209
12349
  lastActivityAt: g.created_at,
12210
12350
  revocable: "yes",
@@ -12324,7 +12464,8 @@ function s3PeerViews(db) {
12324
12464
  prefix: c.prefix ?? null,
12325
12465
  accessKeyId: c.accessKeyId ?? null,
12326
12466
  encrypt: c.encrypt !== false,
12327
- virtualHostedStyle: c.virtualHostedStyle ?? null
12467
+ virtualHostedStyle: c.virtualHostedStyle ?? null,
12468
+ provider: c.provider ?? null
12328
12469
  };
12329
12470
  });
12330
12471
  }
@@ -12401,7 +12542,8 @@ var init_peers_routes = __esm(() => {
12401
12542
  prefix: string2().optional(),
12402
12543
  encrypt: boolean2().optional(),
12403
12544
  passphrase: string2().optional(),
12404
- corsOrigins: array(string2()).optional()
12545
+ corsOrigins: array(string2()).optional(),
12546
+ provider: string2().optional()
12405
12547
  });
12406
12548
  S3PeerSchema = object({
12407
12549
  url: string2(),
@@ -12418,7 +12560,8 @@ var init_peers_routes = __esm(() => {
12418
12560
  prefix: string2().nullable(),
12419
12561
  accessKeyId: string2().nullable(),
12420
12562
  encrypt: boolean2(),
12421
- virtualHostedStyle: boolean2().nullable()
12563
+ virtualHostedStyle: boolean2().nullable(),
12564
+ provider: string2().nullable()
12422
12565
  });
12423
12566
  DataPlaceSchema = object({
12424
12567
  kind: _enum(["self", "device", "bucket"]),
@@ -15373,20 +15516,42 @@ init_paths();
15373
15516
  // src/core/db.ts
15374
15517
  init_paths();
15375
15518
  init_schema_init();
15519
+ init_node();
15376
15520
  init_schema_init();
15377
15521
  import { Database } from "bun:sqlite";
15378
15522
  import { mkdirSync } from "fs";
15523
+ import { hostname } from "os";
15379
15524
  function ensureDirs() {
15380
15525
  mkdirSync(metahubHome(), { recursive: true });
15381
15526
  mkdirSync(cacheDir(), { recursive: true });
15382
15527
  }
15528
+ function appKind() {
15529
+ const v = process.env.METAHUB_APP;
15530
+ return v === "server" || v === "desktop" || v === "web" ? v : "cli";
15531
+ }
15383
15532
  function openMetahub() {
15384
15533
  ensureDirs();
15385
15534
  const db = new Database(dbPath(), { create: true });
15386
15535
  db.exec("PRAGMA journal_mode = WAL;");
15387
15536
  initSchema(db);
15537
+ const app = appKind();
15538
+ const platform = platformFromProcess(process.platform);
15539
+ describeSelf(db, {
15540
+ app,
15541
+ platform,
15542
+ form: defaultForm(platform, app),
15543
+ defaultLabel: safeHostname()
15544
+ });
15388
15545
  return db;
15389
15546
  }
15547
+ function safeHostname() {
15548
+ try {
15549
+ const h = hostname();
15550
+ return h ? h.replace(/\.local$/i, "") : null;
15551
+ } catch {
15552
+ return null;
15553
+ }
15554
+ }
15390
15555
 
15391
15556
  // src/core/index.ts
15392
15557
  init_ids();
@@ -15677,6 +15842,7 @@ function scalarHtml(specUrl) {
15677
15842
  }
15678
15843
  function startServer(opts = {}) {
15679
15844
  registerRoomBlobResolver(resolveBlob);
15845
+ process.env.METAHUB_APP ??= "server";
15680
15846
  const tDb = Date.now();
15681
15847
  const db = openMetahub();
15682
15848
  const dbMs = Date.now() - tDb;
@@ -16171,9 +16337,34 @@ var styles_default = ` :root {
16171
16337
  .set-row-line { display:flex; align-items:center; gap:14px; min-height:44px; padding:5px 0; }
16172
16338
  .set-row.clickable > .set-row-line { cursor:pointer; }
16173
16339
  .set-row-text { flex:1 1 auto; min-width:0; }
16174
- .set-row-title { font-size:14px; font-weight:550; color:var(--fg); }
16175
- .set-row-caption { font-size:12px; color:var(--muted); margin-top:1px;
16340
+ .set-row-title { font-size:14px; font-weight:550; color:var(--fg); display:flex; align-items:center; gap:8px; min-width:0; }
16341
+ .set-row-title > .set-row-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16342
+ .set-row-caption { font-size:12px; color:var(--muted); margin-top:1px; display:flex; align-items:center; gap:6px;
16176
16343
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
16344
+ /* leading glyph (device form / bucket) \u2014 a soft tile so rows scan by shape */
16345
+ .set-row-lead { flex:none; width:28px; height:28px; display:grid; place-items:center; color:var(--muted);
16346
+ border-radius:7px; background:var(--surface-2); }
16347
+ .set-row-lead .ico { width:15px; height:15px; }
16348
+ .set-row.dim > .set-row-line { opacity:.62; }
16349
+ /* status dot in a caption: the only colour a row carries */
16350
+ .cap-dot { display:inline-block; flex:none; width:7px; height:7px; border-radius:50%; background:var(--muted); }
16351
+ .cap-dot.ok { background:var(--success); } .cap-dot.warn { background:var(--warning,#9a6700); } .cap-dot.err { background:var(--danger); }
16352
+ .cap-err { color:var(--danger); }
16353
+ /* tag variants next to a title: neutral outline (default), accent (roles), self (soft fill) */
16354
+ .set-row-title .set-row-tag { margin-left:0; vertical-align:0; flex:none; }
16355
+ .set-row-tag.accent { color:var(--accent); background:var(--accent-soft); border-color:transparent; }
16356
+ .set-row-tag.self { color:var(--fg-soft); background:var(--surface-2); border-color:transparent; }
16357
+ .set-row-tag.mono { font-family:var(--mono); letter-spacing:-.01em; }
16358
+ /* quiet "+ add" row closing a list, and a "N more\u2026" fold */
16359
+ .add-row { display:flex; align-items:center; gap:8px; width:100%; min-height:40px; margin-top:2px; padding:0 2px;
16360
+ color:var(--muted); font-size:var(--fs-ui); border-radius:var(--radius-sm); text-align:left; }
16361
+ .add-row:hover { color:var(--fg); background:var(--hover); }
16362
+ .add-row .ico { width:15px; height:15px; }
16363
+ .more-row { display:inline-flex; align-items:center; gap:6px; padding:8px 2px; font-size:var(--fs-sm); color:var(--muted); }
16364
+ .more-row:hover { color:var(--fg); }
16365
+ .set-section-head { display:flex; align-items:baseline; gap:10px; margin:0 0 4px 2px; }
16366
+ .set-section-head .set-section-label { margin:0; }
16367
+ .set-section-count { font-size:12px; color:var(--muted); font-variant-numeric:tabular-nums; }
16177
16368
  .set-row-control { flex:none; display:flex; align-items:center; gap:8px; }
16178
16369
  .set-row-detail { padding:2px 0 12px; }
16179
16370
  .set-row.disabled > .set-row-line { opacity:.55; pointer-events:none; }
@@ -16238,8 +16429,12 @@ var styles_default = ` :root {
16238
16429
  .audit-detail { padding:0 4px 10px 86px; }
16239
16430
  .audit-detail-ent-name { font-size:var(--fs-sm); font-weight:600; margin-top:6px; }
16240
16431
  .audit-blocks { font-size:var(--fs-sm); margin-top:2px; }
16241
- .audit-more { display:block; margin:14px auto 0; }
16242
- .audit-foot { font-size:var(--fs-xs); margin-top:18px; line-height:1.6; }
16432
+ /* quiet tail: left-aligned text button while more exists; a rule once exhausted */
16433
+ .audit-more { display:inline-flex; align-items:center; gap:6px; margin:14px 0 0 2px; font-size:var(--fs-sm); color:var(--muted); }
16434
+ .audit-more:hover { color:var(--fg); }
16435
+ .audit-more.loading { opacity:.6; pointer-events:none; }
16436
+ .audit-end { display:flex; align-items:center; gap:12px; margin:18px 0 0; color:var(--muted); font-size:var(--fs-xs); }
16437
+ .audit-end::before, .audit-end::after { content:""; flex:1; height:1px; background:var(--line); }
16243
16438
  @media (hover:none) { .audit-revert { opacity:1; } }
16244
16439
  .set-callout.warn { border-color:color-mix(in srgb,var(--warning,#9a6700) 32%,var(--line)); }
16245
16440
  /* two-fact meta line under a block title (who publishes \xB7 where creds live) \u2014
@@ -16354,28 +16549,31 @@ var styles_default = ` :root {
16354
16549
  /* concurrent problems under the headline (only when >1, else redundant) */
16355
16550
  .sync-health-issues { padding:2px 0 4px; }
16356
16551
  .sync-health-issue { font-size:12px; color:var(--muted); padding:2px 0 2px 18px; }
16357
- .sync-health-places { padding:4px 0 2px; }
16358
- .sync-health-place { display:flex; align-items:baseline; gap:8px; padding:8px 0; min-width:0; }
16552
+ /* \u67E5\u770B\u5404\u5904: a bordered place list (kind \xB7 name \xB7 freshness \xB7 inline retry) */
16553
+ .sync-health-places { margin-top:6px; border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; }
16554
+ .sync-health-place { display:flex; align-items:center; gap:10px; padding:9px 12px; min-width:0; font-size:var(--fs-sm); }
16359
16555
  .sync-health-place + .sync-health-place { border-top:1px solid var(--line); }
16360
- .sync-health-place-kind { flex:none; font-size:11px; color:var(--muted); border:1px solid var(--line);
16361
- border-radius:999px; padding:1px 7px; }
16362
- .sync-health-place-label { font-size:var(--fs-body); font-weight:550; overflow:hidden;
16363
- text-overflow:ellipsis; white-space:nowrap; }
16364
- .sync-health-place-cap { flex:1; min-width:0; font-size:12px; color:var(--muted);
16365
- overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16556
+ .sync-health-place-kind { flex:none; width:64px; font-size:var(--fs-xs); color:var(--muted); }
16557
+ .sync-health-place-label { flex:none; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:40%; }
16558
+ .sync-health-place-cap { flex:1; min-width:0; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16559
+ .sync-health-place .btn { margin-left:auto; }
16366
16560
  .sync-health-role { flex:none; font-size:11px; color:var(--accent); background:var(--accent-soft);
16367
16561
  border-radius:999px; padding:2px 7px; }
16368
16562
 
16369
- /* unified device roster (DevicesPanel) */
16370
- .device-detail { padding:2px 12px 10px 28px; display:flex; flex-direction:column; gap:4px; }
16371
- .device-detail-line { display:flex; align-items:center; gap:8px; font-size:12px; min-width:0; }
16372
- .device-ch-label { flex:none; color:var(--muted); border:1px solid var(--line); border-radius:999px; padding:1px 7px; font-size:11px; }
16373
- .device-ch-ref { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--fg); }
16374
- .device-ch-actions { margin-left:auto; display:inline-flex; gap:2px; flex:none; }
16375
- .device-ch-actions .btn { padding:2px 8px; font-size:12px; }
16376
- .device-danger { color:var(--danger); }
16377
- .device-detail-note { font-size:12px; color:var(--muted); background:var(--surface-2);
16378
- border-radius:8px; padding:8px 10px; margin-top:4px; line-height:1.6; }
16563
+ /* device roster (settings/devices-page.tsx): expanded detail = key/value
16564
+ table + one action row + one plain sentence \u2014 no gray callout box. */
16565
+ .device-kv { display:grid; grid-template-columns:max-content 1fr; gap:6px 18px; padding:4px 0 10px 42px; font-size:var(--fs-sm); margin:0; }
16566
+ .device-kv dt { color:var(--muted); margin:0; }
16567
+ .device-kv dd { margin:0; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
16568
+ .device-kv .mono { font-family:var(--mono); font-size:var(--fs-xs); letter-spacing:-.01em; }
16569
+ .device-actions { display:flex; align-items:center; gap:4px; padding-left:42px; flex-wrap:wrap; }
16570
+ .device-actions .btn { padding:3px 8px; font-size:12px; }
16571
+ .device-actions .btn.danger { color:var(--danger); }
16572
+ .device-actions .btn.danger:hover { background:var(--danger-soft); color:var(--danger); }
16573
+ .device-note { font-size:12px; color:var(--muted); line-height:1.6; padding:4px 0 6px 42px; max-width:60ch; }
16574
+ .device-note b { color:var(--fg-soft); font-weight:600; }
16575
+ .device-note a { color:var(--accent); text-decoration:none; }
16576
+ .device-note a:hover { text-decoration:underline; }
16379
16577
 
16380
16578
  /* cloud accessories panel (CloudPanel: bucket + edge in one glance) */
16381
16579
  .cloud-cta { border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin:4px 0 12px;
@@ -16450,6 +16648,7 @@ var styles_default = ` :root {
16450
16648
  .btn-primary { background:var(--accent); color:var(--accent-fg); box-shadow:var(--shadow-sm); }
16451
16649
  .btn-primary:hover { filter:brightness(1.07); }
16452
16650
  .btn-danger { background:var(--danger); color:#fff; } .btn-danger:hover { filter:brightness(1.05); }
16651
+ .btn.sm { padding:3px 8px; font-size:12px; }
16453
16652
  /* Notion-style ambient sync indicator: a quiet ghost control living in the
16454
16653
  topbar icon row. State reads through the *text/icon colour* \u2014 no filled pill
16455
16654
  or drop shadow \u2014 and only the error state escalates to a visible danger tone.
@@ -16479,6 +16678,26 @@ var styles_default = ` :root {
16479
16678
  @keyframes save-pop { 0% { transform:scale(.7); } 100% { transform:scale(1); } }
16480
16679
  @keyframes save-shake { 10%,90% { transform:translateX(-1px); } 20%,80% { transform:translateX(1.5px); } 30%,50%,70% { transform:translateX(-2px); } 40%,60% { transform:translateX(2px); } }
16481
16680
  @keyframes save-kbd-pulse { 0% { transform:scale(1); } 45% { transform:scale(1.05); } 100% { transform:scale(1); } }
16681
+ /* Ambient replica-sync indicator (sync-indicator.tsx): same ghost-control
16682
+ idiom as .share-save \u2014 zero footprint when idle, colour carries state,
16683
+ only error escalates to danger. Lives in .sb-footer (desktop), .sb-head
16684
+ (mobile home) and the topbar (sidebar collapsed). */
16685
+ .sync-ind { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 8px; border-radius:var(--radius-sm);
16686
+ font-size:var(--fs-xs); color:var(--fg-soft); white-space:nowrap; font-variant-numeric:tabular-nums; flex:none;
16687
+ animation:save-rise .22s ease both; }
16688
+ .sync-ind:hover { background:var(--hover-2); color:var(--fg); }
16689
+ .sync-ind-ico { display:inline-flex; align-items:center; }
16690
+ .sync-ring { width:12px; height:12px; border-radius:50%; border:1.5px solid var(--line-strong);
16691
+ border-top-color:var(--accent); animation:mh-spin .8s linear infinite; }
16692
+ .sync-ind-done { color:var(--success); }
16693
+ .sync-ind-done .sync-ind-ico { animation:save-pop .24s cubic-bezier(.18,.9,.24,1.22) both; }
16694
+ .sync-ind-error { color:var(--danger); }
16695
+ .sync-ind-error:hover { background:var(--danger-soft); color:var(--danger); }
16696
+ /* footer: push right, version stays flush right after it */
16697
+ .sb-footer .sync-ind { margin-left:auto; }
16698
+ .sb-footer .sync-ind + .sbf-ver { margin-left:8px; }
16699
+ /* head slot is mobile-only (mirrors the .sb-head .iconbtn gating) */
16700
+ .sb-head .sync-ind { display:none; }
16482
16701
 
16483
16702
  /* macOS desktop (hiddenInset frameless): drop the sidebar logo and reserve a
16484
16703
  draggable strip so the inset traffic lights never overlap our controls.
@@ -17168,55 +17387,148 @@ var styles_default = ` :root {
17168
17387
  .site-empty .et { font-size:15px; color:var(--fg); margin-bottom:5px; font-weight:650; letter-spacing:-.01em; }
17169
17388
  .site-empty .ed { font-size:var(--fs-sm); margin-bottom:18px; max-width:42ch; line-height:1.55; }
17170
17389
 
17171
- /* \u2500\u2500 \u5206\u4EAB (global ShareView) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
17172
- .shares-page .db-head { align-items:flex-start; gap:16px; margin-bottom:18px; }
17173
- .shv-tools { margin-left:auto; display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
17390
+ /* \u2500\u2500 \u5206\u4EAB (global ShareView) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
17391
+ One row = object \xB7 status \xB7 one primary action \xB7 overflow. State is the
17392
+ organizing idea: colour lives only in the status dot and the \u53EF\u7F16\u8F91 word;
17393
+ expiry is a share's normal ending and reads muted, never danger. */
17394
+ .shares-page .db-head { align-items:flex-start; gap:16px; margin-bottom:6px; }
17395
+ .shares-page .db-desc { max-width:56ch; }
17396
+ .shv-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:nowrap; padding:10px 0; margin-bottom:8px; }
17397
+ .shv-tools-right { display:flex; align-items:center; gap:10px; flex:none; margin-left:auto; }
17174
17398
  .shv-seg { display:inline-flex; background:var(--surface-2); border:1px solid var(--line); border-radius:var(--radius-sm); padding:2px; }
17175
- .shv-seg-btn { padding:5px 11px; border-radius:calc(var(--radius-sm) - 2px); font-size:13px; color:var(--fg-soft); }
17399
+ .shv-seg-btn { display:inline-flex; align-items:baseline; padding:5px 11px; border-radius:calc(var(--radius-sm) - 2px); font-size:13px; color:var(--fg-soft); }
17176
17400
  .shv-seg-btn.active { background:var(--bg); color:var(--fg); box-shadow:var(--shadow-sm); }
17177
- .shv-search { display:flex; align-items:center; gap:6px; background:var(--bg); border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:5px 10px; color:var(--muted); }
17178
- .shv-search input { border:0; background:none; outline:none; color:var(--fg); font-size:13px; width:180px; }
17401
+ .shv-seg-btn .n { display:inline-flex; justify-content:flex-end; align-items:center; min-width:2ch; margin-left:5px; font-family:var(--mono); font-size:var(--fs-micro); color:var(--muted); font-variant-numeric:tabular-nums; }
17402
+ .shv-seg-btn .skel-n { width:2ch; height:.85em; border-radius:3px; animation:skel-in .25s ease .12s both; }
17403
+ .shv-seg-btn.active .n { color:var(--fg-soft); }
17404
+ .shv-src { gap:5px; color:var(--fg-soft); }
17405
+ .shv-src.on { color:var(--fg); }
17406
+ .shv-src .ico { width:14px; height:14px; opacity:.7; }
17407
+ .shv-search { display:flex; align-items:center; gap:6px; flex:1 1 220px; min-width:140px; max-width:360px; background:var(--bg); border:1px solid var(--line-strong); border-radius:var(--radius-sm); padding:5px 10px; color:var(--muted); }
17408
+ .shv-search:focus-within { border-color:var(--accent); }
17409
+ .shv-search input { border:0; background:none; outline:none; color:var(--fg); font-size:13px; width:100%; min-width:0; }
17179
17410
 
17180
17411
  .shv-list { display:flex; flex-direction:column; gap:8px; list-style:none; padding:0; margin:0; }
17181
- .shv-row { display:flex; align-items:center; gap:13px; padding:13px 15px; background:var(--bg); border:1px solid var(--line);
17412
+ .shv-row { display:grid; grid-template-columns:38px minmax(0,1fr) auto auto; gap:6px 13px; align-items:center;
17413
+ padding:12px 15px; background:var(--bg); border:1px solid var(--line);
17182
17414
  border-radius:var(--radius-lg); transition:border-color .12s, box-shadow .12s; }
17183
17415
  .shv-row:hover { border-color:var(--line-strong); box-shadow:var(--shadow-sm); }
17184
17416
  .shv-ico { width:38px; height:38px; flex:none; display:grid; place-items:center; border-radius:var(--radius-md, 10px);
17185
17417
  background:var(--surface-2); color:var(--fg-soft); }
17186
17418
  .shv-ico svg { width:19px; height:19px; }
17187
17419
  .shv-ico.k-doc { color:var(--accent); background:var(--accent-soft); }
17420
+ .shv-ico.k-database { color:var(--fg-soft); }
17188
17421
  .shv-ico.k-site { color:#1a7f5a; }
17189
- .shv-main { flex:1; min-width:0; }
17190
- .shv-titleline { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
17191
- .shv-title { font-weight:600; color:var(--fg); letter-spacing:-.01em; max-width:34ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17422
+ .shv-main { min-width:0; }
17423
+ .shv-title { display:block; max-width:100%; font-weight:600; color:var(--fg); letter-spacing:-.01em;
17424
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; }
17192
17425
  .shv-title:hover { color:var(--accent); }
17193
- .shv-pill { font-size:11px; line-height:1.6; padding:0 8px; border-radius:999px; border:1px solid var(--line-strong); color:var(--fg-soft); }
17194
- .shv-pill.t-s3 { background:var(--accent-soft); border-color:transparent; color:var(--accent); }
17195
- .shv-pill.p-edit { background:var(--danger-soft); border-color:transparent; color:var(--danger); }
17196
- .shv-lock { font-size:12px; }
17197
- .shv-meta { margin-top:3px; font-size:var(--fs-sm); color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17198
- .shv-exp.warn { color:var(--danger); }
17199
- .shv-exp.dead { text-decoration:line-through; }
17426
+ .shv-sub { display:flex; align-items:center; gap:6px; margin-top:3px; min-width:0; font-size:var(--fs-sm); color:var(--muted);
17427
+ white-space:nowrap; overflow:hidden; }
17428
+ .shv-sub > * { flex:none; }
17429
+ .shv-sub > .shv-link { flex:0 1 auto; }
17430
+ .shv-link-text { min-width:0; overflow:hidden; text-overflow:ellipsis; }
17431
+ .shv-sep { color:var(--line-strong); }
17432
+ /* the link itself: mono, copy on click, copy glyph slides in on hover */
17433
+ .shv-link { display:inline-flex; align-items:center; gap:4px; min-width:0; max-width:46ch; font-family:var(--mono); font-size:var(--fs-xs);
17434
+ color:var(--fg-soft); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; border-radius:4px; }
17435
+ .shv-link .ico { width:12px; height:12px; opacity:0; transition:opacity .12s; flex:none; }
17436
+ .shv-link:not(:disabled):hover { color:var(--accent); }
17437
+ .shv-link:not(:disabled):hover .ico { opacity:1; }
17438
+ .shv-link:disabled { cursor:default; text-decoration:line-through; text-decoration-color:var(--line-strong); }
17439
+ .shv-snap { font-variant-numeric:tabular-nums; }
17440
+ .shv-perm.edit { color:var(--danger); font-weight:500; }
17441
+ .shv-lock { display:inline-flex; color:var(--fg-soft); }
17442
+ .shv-lock .ico { width:12px; height:12px; }
17443
+ /* status column: dot + short text, fixed floor so rows never jitter */
17444
+ .shv-status { display:inline-flex; align-items:center; gap:7px; min-width:9ch; font-size:var(--fs-sm); color:var(--fg-soft);
17445
+ font-variant-numeric:tabular-nums; white-space:nowrap; }
17446
+ .shv-dot { width:8px; height:8px; border-radius:50%; background:var(--muted); flex:none; }
17447
+ .shv-status.tone-ok .shv-dot { background:var(--success); }
17448
+ .shv-status.tone-warn { color:var(--warning,#9a6700); }
17449
+ .shv-status.tone-warn .shv-dot { background:var(--warning,#9a6700); }
17450
+ .shv-status.tone-muted { color:var(--muted); }
17451
+ .shv-status.tone-muted .shv-dot { background:transparent; border:1.5px solid var(--line-strong); }
17452
+ .shv-status .sync-ring { width:10px; height:10px; }
17200
17453
  .shv-actions { display:flex; align-items:center; gap:6px; flex:none; }
17201
- .shv-actions .btn { padding:5px 10px; font-size:12px; }
17454
+ .shv-actions .btn { padding:5px 10px; font-size:12px; min-width:76px; justify-content:center; }
17455
+ .shv-actions .btn.txt-danger { color:var(--danger); }
17456
+ .shv-row.st-expired .shv-title { color:var(--fg-soft); }
17457
+ /* expired group sinks below the live list */
17458
+ .shv-group-head { display:flex; align-items:center; gap:8px; margin:22px 2px 8px; font-size:var(--fs-sm); font-weight:600; color:var(--muted); }
17459
+ .shv-group-head .n { font-family:var(--mono); font-size:var(--fs-micro); font-weight:400; }
17460
+ .shv-group-head .btn { margin-left:auto; padding:4px 10px; font-size:12px; }
17461
+ /* list swap: data arriving over the skeleton fades in rather than hard-cutting */
17462
+ .shv-list { animation:skel-in .18s ease both; }
17463
+
17464
+ /* \u2500\u2500 skeletons (skeleton.tsx, primitives.tsx SetRowSkeleton, shares-view
17465
+ ShareListSkeleton). Rows are the REAL row markup; only content is swapped
17466
+ for sheen blocks. .skel-list fades in after 120ms so an instant load
17467
+ never flashes it; --i offsets each row's sheen into one rolling wave. */
17468
+ .skel-list { animation:skel-in .25s ease .12s both; }
17469
+ .skel-lines { display:flex; flex-direction:column; gap:8px; }
17470
+ .skel-lines .skel-s { width:calc(58% + var(--i, 0) * 9%); max-width:100%; }
17471
+ .page-header-sub .skel-s { display:block; width:22ch; }
17472
+ .set-row.skel { pointer-events:none; }
17473
+ .set-row.skel .set-row-text { display:flex; flex-direction:column; gap:5px; padding:3px 0; }
17474
+ .set-row.skel .skel-t { height:14px; width:calc(30% + var(--i, 0) * 8%); border-radius:5px; }
17475
+ .set-row.skel .skel-s { height:11px; width:calc(44% + var(--i, 0) * 7%); border-radius:4px; }
17476
+ .set-row.skel .set-row-lead.skel-b { border-radius:7px; }
17477
+ .skel-switch { width:38px; height:22px; border-radius:999px; }
17478
+ .skel-chevron { width:14px; height:14px; border-radius:4px; }
17479
+ .skel-menu { width:28px; height:28px; border-radius:var(--radius-sm); }
17480
+ .skel-button { width:76px; height:29px; border-radius:var(--radius-sm); }
17481
+ .audit-item.skel .audit-item-line { cursor:default; pointer-events:none; }
17482
+ .audit-item.skel .skel-when { width:56px; height:12px; border-radius:4px; flex:none; }
17483
+ .audit-item.skel .skel-t { height:14px; width:calc(34% + var(--i, 0) * 9%); border-radius:5px; }
17484
+ .site-card.skel { pointer-events:none; }
17485
+ .site-card.skel:hover { transform:none; box-shadow:none; border-color:var(--line); }
17486
+ .site-card.skel .si.skel-b { background:var(--surface-2); }
17487
+ .site-card.skel .skel-t { height:14px; width:calc(40% + var(--i, 0) * 8%); border-radius:5px; margin-bottom:6px; }
17488
+ .site-card.skel .skel-s { height:11px; width:calc(55% + var(--i, 0) * 6%); border-radius:4px; }
17489
+ .site-card.skel .skel-addr { height:30px; width:80%; border-radius:var(--radius-sm); }
17490
+ .filerow.skel { pointer-events:none; }
17491
+ .filerow.skel .fi.skel-b { background:var(--surface-2); }
17492
+ .filerow.skel .skel-t { height:13px; width:calc(36% + var(--i, 0) * 10%); border-radius:4px; }
17493
+ .blob-hero.skel .blob-legend .skel-s { height:12px; width:calc(70% + var(--i, 0) * 8%); }
17494
+ .blob-hero.skel .blob-total .skel-s { display:block; height:11px; width:14ch; }
17495
+ .skel-b { display:block; position:relative; overflow:hidden; background:var(--surface-2); border-radius:6px; }
17496
+ .skel-b::after { content:""; position:absolute; inset:0;
17497
+ background:linear-gradient(90deg, transparent 20%, color-mix(in srgb, var(--fg) 5%, transparent) 50%, transparent 80%);
17498
+ background-size:200% 100%; animation:skel-sweep 1.6s ease-in-out infinite;
17499
+ animation-delay:calc(var(--i, 0) * 120ms); }
17500
+ .shv-row.skel { pointer-events:none; }
17501
+ .shv-row.skel:hover { border-color:var(--line); box-shadow:none; }
17502
+ .skel-t { height:1.55em; height:1lh; width:calc(52% + var(--i, 0) * 9%); border-radius:5px; }
17503
+ .skel-s { height:1.55em; height:1lh; width:min(44ch, 60%); border-radius:4px; }
17504
+ .shv-status .skel-st { height:1.55em; height:1lh; width:7ch; border-radius:4px; }
17505
+ .skel-btn { width:76px; height:29px; border-radius:var(--radius-sm); }
17506
+ .skel-ib { width:30px; height:30px; border-radius:var(--radius-sm); }
17507
+ @keyframes skel-in { from { opacity:0; } to { opacity:1; } }
17508
+ @keyframes skel-sweep { from { background-position:100% 0; } to { background-position:-100% 0; } }
17202
17509
  @media (max-width:640px){
17203
- /* head stacks: title/desc full row, then segmented tabs, then search */
17510
+ .shv-actions .skel-btn { flex:1; width:auto; height:36px; }
17511
+ .shv-actions .skel-ib { width:40px; height:36px; }
17512
+ /* head stacks: title/desc full row, then segmented tabs, then [source][search] */
17204
17513
  .shares-page .db-head { flex-direction:column; align-items:stretch; gap:12px; }
17205
- .shv-tools { margin-left:0; width:100%; }
17206
- .shv-seg { display:flex; width:100%; }
17207
- .shv-seg-btn { flex:1; text-align:center; }
17208
- .shv-search { flex:1; }
17209
- .shv-search input { width:100%; flex:1; }
17210
-
17211
- /* cards: title clamps to two lines, meta wraps naturally */
17212
- .shv-row { flex-wrap:wrap; }
17213
- .shv-title { max-width:100%; white-space:normal; display:-webkit-box;
17214
- -webkit-line-clamp:2; -webkit-box-orient:vertical; text-align:left; }
17215
- .shv-meta { white-space:normal; overflow:visible; text-overflow:clip; }
17216
-
17217
- /* actions: full row of evenly stretched touch targets */
17218
- .shv-actions { width:100%; }
17219
- .shv-actions .btn { flex:1; justify-content:center; padding:7px 10px; }
17514
+ .shv-toolbar { flex-wrap:wrap; }
17515
+ .shv-tools-right { display:contents; }
17516
+ .shv-seg { display:flex; width:100%; order:-1; }
17517
+ .shv-seg-btn { flex:1; justify-content:center; padding:6px 4px; }
17518
+ .shv-src { flex:1; justify-content:center; }
17519
+ .shv-search { flex:1; min-width:0; max-width:none; }
17520
+
17521
+ /* rows: ico + main on top; status, then the action row, each full width */
17522
+ .shv-row { grid-template-columns:38px minmax(0,1fr); gap:8px 13px; align-items:start; }
17523
+ .shv-title { white-space:normal; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
17524
+ .shv-sub { white-space:normal; flex-wrap:wrap; row-gap:2px; }
17525
+ .shv-link { max-width:100%; }
17526
+ .shv-status { grid-column:1 / -1; min-width:0; }
17527
+ .shv-actions { grid-column:1 / -1; }
17528
+ .shv-actions .btn { flex:1; padding:8px 10px; }
17529
+ .shv-actions .iconbtn { width:40px; height:36px; flex:none; }
17530
+ .shv-group-head { flex-wrap:wrap; }
17531
+ .shv-group-head .btn { margin-left:0; width:100%; justify-content:center; padding:7px 10px; }
17220
17532
  }
17221
17533
 
17222
17534
  /* in-context "\u5DF2\u5206\u4EAB" badge on docs / tables / site cards */
@@ -17901,6 +18213,7 @@ var styles_default = ` :root {
17901
18213
  html:has(body.mobile), body.mobile { background:var(--sidebar); }
17902
18214
  html:has(body.mobile-content), body.mobile-content { background:var(--bg); }
17903
18215
  .sb-head .iconbtn { display:none; } /* no collapse on mobile */
18216
+ .sb-head .sync-ind { display:inline-flex; height:32px; } /* .brand's flex:1 already pushes it right */
17904
18217
  .sb-resizer { display:none; }
17905
18218
  .topbar .hamburger { display:grid; } /* serves as the back button */
17906
18219
  /* site config topbar: icon-only publish button so the crumb keeps its last segment */
@@ -18358,6 +18671,12 @@ var styles_default = ` :root {
18358
18671
  .blob-dot.keep { background:var(--line-strong); }
18359
18672
  .blob-dot.pin { background:var(--muted); opacity:.6; }
18360
18673
  .blob-total { margin:13px 0 14px; font-size:var(--fs-xs); color:var(--muted); }
18674
+ /* headline number over the legend (\u6570\u636E\u4E0E\u5907\u4EFD \xB7 \u9644\u4EF6) */
18675
+ .blob-stat { font-size:20px; font-weight:700; letter-spacing:-.02em; font-variant-numeric:tabular-nums; margin-bottom:10px; }
18676
+ .blob-stat small { font-size:var(--fs-sm); font-weight:500; color:var(--muted); margin-left:8px; letter-spacing:0; }
18677
+ /* a status sentence in the action row (never a disabled button) */
18678
+ .blob-actions .blob-status { display:inline-flex; align-items:center; gap:6px; font-size:var(--fs-sm); color:var(--muted); }
18679
+ .blob-actions .blob-status .ico { width:14px; height:14px; color:var(--success); }
18361
18680
 
18362
18681
  .blob-ring { position:relative; width:152px; height:152px; flex:none; }
18363
18682
  .blob-ring-svg { width:100%; height:100%; display:block; }
@@ -18392,7 +18711,7 @@ var styles_default = ` :root {
18392
18711
  }
18393
18712
 
18394
18713
  @media (prefers-reduced-motion: reduce) {
18395
- .switch, .switch-knob, .ico.spin, .blob-seg, .blob-ring-track { transition:none; animation:none; }
18714
+ .switch, .switch-knob, .ico.spin, .sync-ring, .blob-seg, .blob-ring-track { transition:none; animation:none; }
18396
18715
  }
18397
18716
 
18398
18717
  /* Blob manager popup (Settings storage \u2192 Blob \u7BA1\u7406): a sortable/filterable list
@@ -19270,15 +19589,49 @@ function recordFieldHistory(db, id, propId) {
19270
19589
  // src/core/audit.ts
19271
19590
  init_crdt();
19272
19591
  init_errors();
19592
+
19593
+ // src/core/tables.ts
19594
+ var TABLES = {
19595
+ databases: { tier: "content", synced: true },
19596
+ properties: { tier: "content", synced: true },
19597
+ records: { tier: "content", synced: true },
19598
+ documents: { tier: "content", synced: true },
19599
+ doc_blocks: { tier: "content", synced: true },
19600
+ sites: { tier: "content", synced: true },
19601
+ site_files: { tier: "content", synced: true },
19602
+ site_channels: { tier: "system", synced: true },
19603
+ blob_policy: { tier: "system", synced: true },
19604
+ nodes: { tier: "system", synced: true },
19605
+ meta: { tier: "local", synced: false },
19606
+ crdt_changes: { tier: "local", synced: false },
19607
+ peers: { tier: "local", synced: false },
19608
+ storage_cursors: { tier: "local", synced: false },
19609
+ room_rows: { tier: "local", synced: false },
19610
+ peer_grants: { tier: "local", synced: false },
19611
+ pairing_codes: { tier: "local", synced: false },
19612
+ shares: { tier: "local", synced: false },
19613
+ site_channel_observations: { tier: "local", synced: false },
19614
+ blob_cache: { tier: "local", synced: false },
19615
+ drop_rejects: { tier: "local", synced: false },
19616
+ search_fts: { tier: "local", synced: false }
19617
+ };
19618
+ var names = (pred) => Object.entries(TABLES).filter(([, s]) => pred(s)).map(([n]) => n);
19619
+ var SYNCED_TABLES = names((s) => s.synced);
19620
+ var CONTENT_TABLES = names((s) => s.tier === "content");
19621
+ var SYSTEM_DATASETS = names((s) => s.tier === "system");
19622
+ var LOCAL_TABLES = names((s) => s.tier === "local");
19623
+
19624
+ // src/core/audit.ts
19273
19625
  function isMetaCol(dataset, col) {
19274
19626
  if (dataset === "records")
19275
19627
  return RECORD_META.has(col);
19276
19628
  return col === "created_hlc" || col === "__deleted";
19277
19629
  }
19278
19630
  function oplogExclude() {
19631
+ const excluded = [...OPLOG_ONLY_DATASETS, ...SYSTEM_DATASETS];
19279
19632
  return {
19280
- sql: [...OPLOG_ONLY_DATASETS].map(() => "dataset != ?"),
19281
- params: [...OPLOG_ONLY_DATASETS]
19633
+ sql: excluded.map(() => "dataset != ?"),
19634
+ params: excluded
19282
19635
  };
19283
19636
  }
19284
19637
  function changesOfTxn(db, txn, beforeHlc) {
@@ -22468,8 +22821,11 @@ var FieldHistoryEntrySchema = object({
22468
22821
  });
22469
22822
  var NodeInfoSchema = object({
22470
22823
  node_id: string2(),
22471
- label: string2().nullable().describe("Peer label from pairing; null when unnamed"),
22472
- self: boolean2().describe("True for the node serving this request")
22824
+ label: string2().nullable().describe("Synced roster name \u2192 pairing label \u2192 null when unnamed"),
22825
+ self: boolean2().describe("True for the node serving this request"),
22826
+ platform: _enum(["macos", "windows", "linux", "ios", "android", "web"]).nullable(),
22827
+ form: _enum(["laptop", "desktop", "phone", "server", "browser"]).nullable(),
22828
+ app: _enum(["cli", "server", "desktop", "web"]).nullable()
22473
22829
  });
22474
22830
  var CacheStatsSchema = object({
22475
22831
  totalBytes: number2(),
@@ -22979,13 +23335,17 @@ var webuiRoutes = [
22979
23335
  {
22980
23336
  method: "PATCH",
22981
23337
  path: "/api/node",
22982
- summary: "Set this device's display label (empty/null clears it)",
22983
- request: object({ label: string2().nullable().optional() }),
23338
+ summary: "Rename a device (default: this one). Writes the synced roster; empty/null clears",
23339
+ request: object({
23340
+ label: string2().nullable().optional(),
23341
+ node_id: string2().optional().describe("Target node; omitted = this device")
23342
+ }),
22984
23343
  response: NodeInfoSchema,
22985
23344
  handler: handle3(async (req, { db }) => {
22986
23345
  const body = await req.json();
22987
- setNodeLabel(db, body.label ?? null);
22988
- return { node_id: getNodeId(db), label: getNodeLabel(db), self: true };
23346
+ const id = body.node_id ?? getNodeId(db);
23347
+ setNodeLabel(db, body.label ?? null, id);
23348
+ return displayNodes(db).find((n) => n.node_id === id);
22989
23349
  })
22990
23350
  },
22991
23351
  {
@@ -23139,12 +23499,14 @@ export {
23139
23499
  createSite,
23140
23500
  dbPath,
23141
23501
  decodeFileRow,
23502
+ defaultForm,
23142
23503
  deleteBlob,
23143
23504
  deleteDatabase,
23144
23505
  deleteDocument,
23145
23506
  deleteFile,
23146
23507
  deleteRecord,
23147
23508
  deleteSite,
23509
+ describeSelf,
23148
23510
  displayNodes,
23149
23511
  documentTitleMap,
23150
23512
  documentVersion,
@@ -23190,6 +23552,7 @@ export {
23190
23552
  listDatabases,
23191
23553
  listDocuments,
23192
23554
  listFiles,
23555
+ listNodeMeta,
23193
23556
  listProperties,
23194
23557
  listRecords,
23195
23558
  listSites,
@@ -23200,6 +23563,7 @@ export {
23200
23563
  metahubHome,
23201
23564
  migrateDocBlocks,
23202
23565
  migrateDocuments,
23566
+ migrateNodes,
23203
23567
  migrateOplog,
23204
23568
  migratePeers,
23205
23569
  migrateRecords,
@@ -23208,12 +23572,14 @@ export {
23208
23572
  moveRecord,
23209
23573
  newId,
23210
23574
  nextHlc,
23575
+ nodeLabelOf,
23211
23576
  normalizeSiteName,
23212
23577
  normalizeSitePath,
23213
23578
  observeHlc,
23214
23579
  openMetahub,
23215
23580
  parseDocBlocks,
23216
23581
  parseHlc,
23582
+ platformFromProcess,
23217
23583
  prepareRecordCells,
23218
23584
  prependDocument,
23219
23585
  publishDirectory,
@@ -23222,6 +23588,7 @@ export {
23222
23588
  putFile,
23223
23589
  putFileInline,
23224
23590
  randomSuffix,
23591
+ readNodeMeta,
23225
23592
  rebuildSearchIndex,
23226
23593
  reconcile,
23227
23594
  recordTitleMap,
@@ -23267,4 +23634,4 @@ export {
23267
23634
  writeFileRow
23268
23635
  };
23269
23636
 
23270
- //# debugId=CA4BC603573EC3ED64756E2164756E21
23637
+ //# debugId=189D146DD6185A7B64756E2164756E21