@1agh/maude 0.60.3 → 0.60.5

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 (43) hide show
  1. package/apps/studio/api.ts +68 -5
  2. package/apps/studio/build.ts +10 -0
  3. package/apps/studio/canvas-lib.tsx +16 -3
  4. package/apps/studio/canvas-shell.tsx +34 -39
  5. package/apps/studio/client/app.jsx +43 -9
  6. package/apps/studio/client/panels/SyncPanel.jsx +27 -0
  7. package/apps/studio/context.ts +9 -0
  8. package/apps/studio/dist/client.bundle.js +707 -707
  9. package/apps/studio/dist/comment-mount.js +2 -2
  10. package/apps/studio/dom-selection.ts +67 -0
  11. package/apps/studio/http.ts +12 -1
  12. package/apps/studio/input-router.tsx +26 -2
  13. package/apps/studio/sync/agent.ts +64 -13
  14. package/apps/studio/sync/asset-pull.ts +210 -0
  15. package/apps/studio/sync/asset-push.ts +111 -98
  16. package/apps/studio/sync/codec.ts +47 -0
  17. package/apps/studio/sync/cold-start.ts +96 -0
  18. package/apps/studio/sync/file-membership.ts +290 -0
  19. package/apps/studio/sync/file-pull.ts +330 -0
  20. package/apps/studio/sync/index.ts +198 -10
  21. package/apps/studio/sync/migrate-seed.ts +52 -7
  22. package/apps/studio/sync/projection.ts +10 -1
  23. package/apps/studio/sync/remote-docs.ts +98 -4
  24. package/apps/studio/sync/status.ts +25 -0
  25. package/apps/studio/sync/tombstone-apply.ts +131 -0
  26. package/apps/studio/test/canvas-meta-api.test.ts +114 -3
  27. package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
  28. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
  29. package/apps/studio/test/dom-selection.test.ts +69 -1
  30. package/apps/studio/test/exporters/jobs.test.ts +10 -4
  31. package/apps/studio/test/git-cloud-posture.test.ts +11 -2
  32. package/apps/studio/test/input-router.test.ts +69 -0
  33. package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
  34. package/apps/studio/test/sync-asset-pull.test.ts +161 -0
  35. package/apps/studio/test/sync-asset-push.test.ts +52 -2
  36. package/apps/studio/test/sync-file-membership.test.ts +331 -0
  37. package/apps/studio/test/sync-file-pull.test.ts +333 -0
  38. package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
  39. package/apps/studio/test/sync-remote-docs.test.ts +118 -8
  40. package/apps/studio/test/sync-status.test.ts +18 -0
  41. package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
  42. package/apps/studio/whats-new.json +18 -0
  43. package/package.json +8 -8
@@ -1499,11 +1499,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1499
1499
  // shared `.meta.json` document. It lives in `_canvas-state/<slug>.view.json`
1500
1500
  // ({ viewport }) — gitignored, swept on delete, export-excluded. DISTINCT from
1501
1501
  // the legacy `_canvas-state/<slug>.json` ({ sections, viewport:{x,y,scale} })
1502
- // store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.1–4, so
1502
+ // store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.02–4, so
1503
1503
  // overloading one file would let the two writers clobber each other's shape.
1504
1504
 
1505
- /** Validate a candidate viewport — finite x/y, zoom clamped [0.1, 4] (the
1506
- * Phase 4 rule). Returns the normalized viewport, or null when invalid. */
1505
+ /** Validate a candidate viewport — finite x/y, zoom clamped [0.02, 4] (the
1506
+ * Phase 4 rule). Mirrors `ZOOM_MIN` in canvas-lib.tsx (issue #91) — keep the
1507
+ * two floors in lockstep or a save/reload round-trip silently re-clamps a
1508
+ * zoom the client just let the user reach. Returns the normalized viewport,
1509
+ * or null when invalid. */
1507
1510
  function normalizeViewport(v: unknown): { x: number; y: number; zoom: number } | null {
1508
1511
  if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
1509
1512
  const vv = v as { x?: unknown; y?: unknown; zoom?: unknown };
@@ -1512,7 +1515,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1512
1515
  Number.isFinite(vv.y as number) &&
1513
1516
  Number.isFinite(vv.zoom as number)
1514
1517
  ) {
1515
- const zoom = Math.min(4, Math.max(0.1, vv.zoom as number));
1518
+ const zoom = Math.min(4, Math.max(0.02, vv.zoom as number));
1516
1519
  return { x: vv.x as number, y: vv.y as number, zoom };
1517
1520
  }
1518
1521
  return null;
@@ -1571,6 +1574,41 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1571
1574
  return [...new Set(out)];
1572
1575
  }
1573
1576
 
1577
+ // Issue #91 security follow-up — `layout.artboards[]` (DDR-027 position-only
1578
+ // entries) is reachable from the untrusted canvas origin (DDR-054) via the
1579
+ // same PATCH lane as overlays/locked above, but previously had no count or
1580
+ // magnitude bound. `fit()`/`computeFit` (canvas-lib.tsx) now correctly frames
1581
+ // whatever bounding box this describes — before the zoom-floor fix, a hard
1582
+ // 0.1 zoom clamp accidentally masked an unbounded layout into a cropped,
1583
+ // mismatched view; after it, an oversized synced layout can legitimately be
1584
+ // framed whole, promoting every artboard in it to its own GPU layer at once
1585
+ // (canvas-lib.tsx `content-visibility` comment). Cap count + coordinate
1586
+ // magnitude the same way `normalizeOverlays`/`normalizeLocked` cap theirs.
1587
+ const MAX_LAYOUT_ARTBOARDS = 2000;
1588
+ const MAX_LAYOUT_COORD = 1_000_000;
1589
+
1590
+ function normalizeLayoutArtboards(
1591
+ v: unknown
1592
+ ): Array<{ id: string; x: number; y: number }> | null {
1593
+ if (!Array.isArray(v) || v.length > MAX_LAYOUT_ARTBOARDS) return null;
1594
+ const out: Array<{ id: string; x: number; y: number }> = [];
1595
+ for (const entry of v) {
1596
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
1597
+ const e = entry as { id?: unknown; x?: unknown; y?: unknown };
1598
+ if (typeof e.id !== 'string' || e.id.length === 0 || e.id.length > 128) return null;
1599
+ if (
1600
+ !Number.isFinite(e.x as number) ||
1601
+ !Number.isFinite(e.y as number) ||
1602
+ Math.abs(e.x as number) > MAX_LAYOUT_COORD ||
1603
+ Math.abs(e.y as number) > MAX_LAYOUT_COORD
1604
+ ) {
1605
+ return null;
1606
+ }
1607
+ out.push({ id: e.id, x: e.x as number, y: e.y as number });
1608
+ }
1609
+ return out;
1610
+ }
1611
+
1574
1612
  /** Raw view-file contents, tolerant of a missing/corrupt file (→ `{}`) — the
1575
1613
  * read half of the read-modify-write both `saveCanvasView` and
1576
1614
  * `saveCanvasOverlays` need so writing one lane never clobbers the other. */
@@ -1819,7 +1857,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1819
1857
  if (patch.layout === null) {
1820
1858
  next.layout = undefined;
1821
1859
  } else if (typeof patch.layout === 'object' && !Array.isArray(patch.layout)) {
1822
- next.layout = patch.layout;
1860
+ const rawLayout = patch.layout as Record<string, unknown>;
1861
+ if ('artboards' in rawLayout) {
1862
+ // Bounded — see normalizeLayoutArtboards. An oversized/malformed
1863
+ // `artboards` array is a silent no-op (leaves the prior persisted
1864
+ // layout in place), matching the overlays/locked lanes' convention.
1865
+ const artboards = normalizeLayoutArtboards(rawLayout.artboards);
1866
+ if (artboards !== null) {
1867
+ next.layout = { ...rawLayout, artboards };
1868
+ }
1869
+ } else {
1870
+ next.layout = patch.layout;
1871
+ }
1823
1872
  }
1824
1873
  // Defensive: a stale inline viewport must never persist in the versioned
1825
1874
  // file (the camera lane owns it now). JSON.stringify drops undefined keys.
@@ -2577,6 +2626,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2577
2626
  // via git "Get latest" — the file travels through git, this event is only a
2578
2627
  // "refresh your list" nudge for online local tabs (loopback inspector WS).
2579
2628
  ctx.bus.emit('canvas-list-update', { action: 'added', rel, slug });
2629
+ // The inverse of `canvas-deleted` (see deleteCanvas for why this is its own
2630
+ // event). Creating a name the project buried earlier has to lift the
2631
+ // tombstone, or every peer would dutifully trash the new canvas for as long
2632
+ // as the gravestone lives.
2633
+ ctx.bus.emit('canvas-created', { slug });
2580
2634
  // designRel-prefixed path — matches the file-tree `file.path` shape so the
2581
2635
  // client can open it directly after reloadTree().
2582
2636
  return { ok: true, file: path.posix.join(paths.designRel, rel), rel, slug };
@@ -2690,6 +2744,15 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2690
2744
 
2691
2745
  // Phase 30 — live tree refresh for other local tabs (see createCanvas).
2692
2746
  ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
2747
+ // A SEPARATE EVENT, DELIBERATELY. `canvas-list-update` is emitted by this
2748
+ // API *and* by the filesystem watcher, so its payload can describe a file
2749
+ // an agent or a `git checkout` produced — which is why discovery treats it
2750
+ // as a nudge and re-derives the truth itself. This one is only ever emitted
2751
+ // HERE, from a privileged route the canvas origin cannot reach, so it
2752
+ // carries something the watcher's version cannot: the user meant it. Sync
2753
+ // needs exactly that, because "gone from this disk" and "deleted from the
2754
+ // project" are different claims and only the second may travel.
2755
+ ctx.bus.emit('canvas-deleted', { slug });
2693
2756
  return {
2694
2757
  ok: true,
2695
2758
  rel,
@@ -353,6 +353,16 @@ export function writeCompileEntry(target: PlatformTarget): string {
353
353
  // Side-effect module: must be imported BEFORE any oxc-parser usage.
354
354
  import bindingPath from ${JSON.stringify(bindingSpec)} with { type: 'file' };
355
355
  process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bindingPath;
356
+ // DDR-177 — a compiled binary must be self-contained: every runtime-spawned
357
+ // child (canvas-build worker, asset-sweep worker) resolves its JS runtime via
358
+ // resolveBunPath() = MAUDE_BUN_PATH || 'bun'. The desktop app launches this
359
+ // binary with a GUI PATH that has no 'bun', and NOTHING set MAUDE_BUN_PATH —
360
+ // so the asset sweep died with "Executable not found in $PATH" on machines
361
+ // without a user-installed bun. Default it to THIS binary: workerEnv() adds
362
+ // BUN_BE_BUN=1 when MAUDE_BUN_PATH === process.execPath, so children re-enter
363
+ // the sidecar as a plain JS runtime. An explicit launcher-set value (the
364
+ // cell's studio-child.mjs) still wins.
365
+ process.env.MAUDE_BUN_PATH ||= process.execPath;
356
366
  `;
357
367
  writeFileSync(initPath, initContent);
358
368
 
@@ -214,7 +214,15 @@ export function AIPlaceholder({
214
214
  // ─────────────────────────────────────────────────────────────────────────────
215
215
  // Module constants
216
216
 
217
- const ZOOM_MIN = 0.1;
217
+ // Issue #91 — was 0.1. A board spanning more world units than ~10x the
218
+ // viewport could never be framed whole: `fit()` and manual zoom-out both
219
+ // funnel through `clampZoom` (below), so the old floor blocked "fit to
220
+ // screen" from ever reaching the zoom a wide board actually needs. 0.02
221
+ // matches the interactive floor other canvas tools (Figma, tldraw) ship.
222
+ // Keep in lockstep with the mirror clamp in `api.ts`'s `normalizeViewport`
223
+ // (the per-machine camera persisted to `_canvas-state/<slug>.view.json`,
224
+ // DDR-115) — that one re-clamps on save/reload independently of this file.
225
+ const ZOOM_MIN = 0.02;
218
226
  const ZOOM_MAX = 4.0;
219
227
  const ZOOM_STEP_IN = 1.2;
220
228
  const ZOOM_STEP_OUT = 1 / 1.2;
@@ -685,7 +693,9 @@ function synthDefaultGrid(seeds: ArtboardSeed[]): ArtboardRect[] {
685
693
  });
686
694
  }
687
695
 
688
- function computeFit(rects: ArtboardRect[], hostEl: HTMLElement, pad = 24): ViewportState {
696
+ // Exported for the same reason as `clampZoom` above — lets a unit test prove
697
+ // the raw fit computation for a wide board isn't what's clamping the zoom.
698
+ export function computeFit(rects: ArtboardRect[], hostEl: HTMLElement, pad = 24): ViewportState {
689
699
  if (rects.length === 0) return { x: 0, y: 0, zoom: 1 };
690
700
  let xMin = Number.POSITIVE_INFINITY;
691
701
  let yMin = Number.POSITIVE_INFINITY;
@@ -907,7 +917,10 @@ export interface ViewportControllerHandle {
907
917
  isInteracting: boolean;
908
918
  }
909
919
 
910
- function clampZoom(z: number): number {
920
+ // Exported (like `revealAxisDelta` in canvas-shell.tsx) so a pure-logic unit
921
+ // test can pin the zoom floor without booting the DOM/host-dependent
922
+ // viewport controller.
923
+ export function clampZoom(z: number): number {
911
924
  if (!Number.isFinite(z)) return 1;
912
925
  return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
913
926
  }
@@ -67,15 +67,13 @@ import {
67
67
  import { ContextualToolbar } from './contextual-toolbar.tsx';
68
68
  import { CursorsOverlay } from './cursors-overlay.tsx';
69
69
  import {
70
+ buildComposeSelection,
70
71
  cssPath,
71
72
  deriveFile,
72
- domPath,
73
73
  hoverTargetToSelection,
74
+ openCommentComposer,
74
75
  realClasses,
75
76
  resolveSelectionEl,
76
- scopedCdSelector,
77
- selectorIndex,
78
- shortText,
79
77
  } from './dom-selection.ts';
80
78
  import { setElementDragActive } from './drag-state.ts';
81
79
  import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
@@ -1378,33 +1376,17 @@ function buildRegistry(deps: {
1378
1376
  });
1379
1377
  };
1380
1378
 
1379
+ // issue-90 — right-click "Add comment" (element / artboard-chrome / world)
1380
+ // used to only postMessage the parent shell, which merely mirrors the
1381
+ // target for the StatusBar/sidebar; it never dispatched `cm:open-composer`,
1382
+ // the event `comments-overlay.tsx` actually listens on to open the
1383
+ // composer. Selecting the menu item did nothing. `openCommentComposer`
1384
+ // sends both, matching the working comment-tool-click path
1385
+ // (canvas-comment-mount.tsx's `dropComment`).
1381
1386
  const postComposeForTarget = (target: ContextTarget): void => {
1382
1387
  if (typeof window === 'undefined') return;
1383
- const composeSelector = target.cdId
1384
- ? scopedCdSelector(target.cdId, target.artboardId)
1385
- : cssPath(target.el);
1386
- const sel: Selection | null = target.el
1387
- ? {
1388
- file: deriveFile(),
1389
- id: target.cdId ?? undefined,
1390
- selector: composeSelector,
1391
- artboardId: target.artboardId,
1392
- index: target.cdId ? selectorIndex(document, composeSelector, target.el) : 0,
1393
- tag: target.el.tagName.toLowerCase(),
1394
- classes: realClasses(target.el),
1395
- text: shortText(target.el, 240),
1396
- dom_path: domPath(target.el),
1397
- bounds: (target.el as HTMLElement).getBoundingClientRect
1398
- ? boundsOf(target.el as HTMLElement)
1399
- : null,
1400
- html: (target.el.outerHTML ?? '').slice(0, 4000),
1401
- }
1402
- : null;
1403
- try {
1404
- window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
1405
- } catch {
1406
- /* ignore */
1407
- }
1388
+ const sel = buildComposeSelection(target, target.clientX, target.clientY);
1389
+ openCommentComposer(sel, target.clientX, target.clientY);
1408
1390
  };
1409
1391
 
1410
1392
  const fitItem: MenuItem = {
@@ -2141,6 +2123,18 @@ function buildRegistry(deps: {
2141
2123
  ],
2142
2124
  ],
2143
2125
  'artboard-chrome': [
2126
+ [
2127
+ // issue-90 — right-click on an artboard's own label/border chrome
2128
+ // (outside its body, where `data-cd-id` never resolves) had no
2129
+ // "Add comment" entry at all; only a hit inside the body counted as
2130
+ // `element`. Anchors at the click point (no element under it).
2131
+ {
2132
+ id: 'add-comment',
2133
+ label: 'Add comment',
2134
+ shortcut: 'C',
2135
+ onSelect: postComposeForTarget,
2136
+ },
2137
+ ],
2144
2138
  [
2145
2139
  {
2146
2140
  id: 'fit-one',
@@ -2273,6 +2267,17 @@ function buildRegistry(deps: {
2273
2267
  [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
2274
2268
  ],
2275
2269
  world: [
2270
+ // issue-90 — right-click on empty canvas background (no artboard, no
2271
+ // element) had no "Add comment" entry at all. Anchors at the click
2272
+ // point, same as the comment tool's own floating-pin fallback.
2273
+ [
2274
+ {
2275
+ id: 'add-comment',
2276
+ label: 'Add comment',
2277
+ shortcut: 'C',
2278
+ onSelect: postComposeForTarget,
2279
+ },
2280
+ ],
2276
2281
  [fitItem, resetItem],
2277
2282
  [
2278
2283
  exportItem('export-canvas', 'Export canvas as separate…', 'canvas-as-separate'),
@@ -2320,16 +2325,6 @@ function filterRegistryForReadOnly(registry: ContextRegistry): ContextRegistry {
2320
2325
  return out;
2321
2326
  }
2322
2327
 
2323
- function boundsOf(el: HTMLElement) {
2324
- const r = el.getBoundingClientRect();
2325
- return {
2326
- x: Math.round(r.left),
2327
- y: Math.round(r.top),
2328
- w: Math.round(r.width),
2329
- h: Math.round(r.height),
2330
- };
2331
- }
2332
-
2333
2328
  // ─────────────────────────────────────────────────────────────────────────────
2334
2329
  // Task A1 (feature-element-editing-robustness) — camera-aware reveal.
2335
2330
  //
@@ -4704,6 +4704,11 @@ function StatusBar({
4704
4704
  onOpenSync,
4705
4705
  changesCount = 0,
4706
4706
  unpushed = 0,
4707
+ // Somebody else commits this project (a cloud cell, or the cell behind a
4708
+ // linked+credentialed desktop repo — DDR-218). The chip then names the
4709
+ // mechanism that IS saving rather than reporting a working-tree count the
4710
+ // user has no reason to act on. Default false = today's local-first chip.
4711
+ savingIsManaged = false,
4707
4712
  changesOpen = false,
4708
4713
  onOpenChanges,
4709
4714
  version,
@@ -4811,11 +4816,17 @@ function StatusBar({
4811
4816
  <span className="st-sb-changes-dot" aria-hidden="true" />
4812
4817
  <span className="lbl">changes</span>
4813
4818
  <span className="val">
4814
- {changesCount > 0
4815
- ? `${changesCount} unsaved`
4816
- : unpushed > 0
4817
- ? `${unpushed} to publish`
4818
- : 'all saved'}
4819
+ {savingIsManaged
4820
+ ? // Not "all saved": assets and project config are written into the
4821
+ // cell's checkout but never committed there, so asserting a clean
4822
+ // save would overstate what the cloud actually holds. Naming the
4823
+ // mechanism is both honest and the thing the user needs to know.
4824
+ 'cloud saving'
4825
+ : changesCount > 0
4826
+ ? `${changesCount} unsaved`
4827
+ : unpushed > 0
4828
+ ? `${unpushed} to publish`
4829
+ : 'all saved'}
4819
4830
  </span>
4820
4831
  </button>
4821
4832
  )}
@@ -11383,7 +11394,26 @@ function App() {
11383
11394
  for (const f of gitStatus?.files || []) m.set(f.path, KIND[f.status]);
11384
11395
  return m;
11385
11396
  }, [gitStatus]);
11386
- const unsavedCount = gitStatus?.files?.length || 0;
11397
+ // WHO IS SAVING THIS PROJECT — one expression, read by every surface that
11398
+ // would otherwise offer to save it (DDR-218 + the cell's `historyOnly`).
11399
+ //
11400
+ // These two flags were previously derived at the GitPanel call site alone, so
11401
+ // the panel withdrew correctly while the toolbar menu and the status-bar chip
11402
+ // kept rendering a raw dirty count from `gitStatus.files.length` — the very
11403
+ // "lie about work that is already saved" the withdrawal exists to delete. The
11404
+ // count is the claim, wherever it is drawn; hoisting the rule here is what
11405
+ // keeps a third surface from re-introducing it.
11406
+ //
11407
+ // PRESENTATION, NOT A CONTROL — unchanged from DDR-218. `.git` is untouched,
11408
+ // a terminal `git` behaves exactly as before, the server routes keep their own
11409
+ // gates, and Disconnect restores every surface live.
11410
+ const cellManaged = !!cfg.cloud;
11411
+ const cloudManaged = !cfg.cloud && !!cloudLinkedHub?.credentialed;
11412
+ const savingIsManaged = cellManaged || cloudManaged;
11413
+ // The raw truth stays available for surfaces that legitimately need it (the
11414
+ // panel's own file list, `dirtyByPath`); only the COUNT — the thing that reads
11415
+ // as a to-do — is withheld when this project's saving is somebody else's job.
11416
+ const unsavedCount = savingIsManaged ? 0 : gitStatus?.files?.length || 0;
11387
11417
 
11388
11418
  // Phase 28 (E3) — keep remote ahead/behind fresh so the "Get latest" nudge
11389
11419
  // surfaces on its own: probe once a repo is known, again whenever the Changes
@@ -14446,7 +14476,7 @@ function App() {
14446
14476
  // mislead, it does not remove a capability. The real gates are
14447
14477
  // server-side (`projectReadOnly`, the manifest's role matrix), and
14448
14478
  // they are unchanged by this flag.
14449
- historyOnly={!!cfg.cloud}
14479
+ historyOnly={cellManaged}
14450
14480
  // DDR-218 (fix 8) — the DESKTOP half of the same withdrawal: a repo
14451
14481
  // linked+credentialed to Maude Cloud is cloud-managed (the cell
14452
14482
  // commits every edit as it lands), so the local Changes surface is
@@ -14454,7 +14484,7 @@ function App() {
14454
14484
  // Live: CloudBar lifts every link change (resolve/attach/detach)
14455
14485
  // into `cloudLinkedHub`. Same presentation-not-a-control rule as
14456
14486
  // `historyOnly` above; Disconnect restores the panel in place.
14457
- cloudManaged={!cfg.cloud && !!cloudLinkedHub?.credentialed}
14487
+ cloudManaged={cloudManaged}
14458
14488
  resizing={resizingFor('changes')}
14459
14489
  onClose={() => setChangesOpen(false)}
14460
14490
  onCommit={gitCommit}
@@ -15521,7 +15551,11 @@ function App() {
15521
15551
  // holds (opening Sync closes whatever else the right slot shows).
15522
15552
  onOpenSync={syncStatus ? () => toggleRightPanel('sync') : undefined}
15523
15553
  changesCount={unsavedCount}
15524
- unpushed={gitStatus?.unpushed || 0}
15554
+ // `unpushed` is a LOCAL-git offer ("N to publish"), and the panel has
15555
+ // withdrawn Publish under either managed posture — so the chip must
15556
+ // not keep advertising it either.
15557
+ unpushed={savingIsManaged ? 0 : gitStatus?.unpushed || 0}
15558
+ savingIsManaged={savingIsManaged}
15525
15559
  changesOpen={changesOpen}
15526
15560
  onOpenChanges={gitStatus?.repo ? () => setChangesOpen(true) : undefined}
15527
15561
  version={cfg?.version}
@@ -89,6 +89,15 @@ function readAssets(raw) {
89
89
  return { ...raw, failures };
90
90
  }
91
91
 
92
+ /** The file plane (feature-sync-file-plane), or null when unreadable/absent —
93
+ * absent is the norm: the payload carries `files` only once a flag-on pull
94
+ * has run this boot. */
95
+ function readFiles(raw) {
96
+ if (!raw || typeof raw !== 'object') return null;
97
+ if (![raw.synced, raw.pulled, raw.conflicts].every(isCount)) return null;
98
+ return raw;
99
+ }
100
+
92
101
  export default function SyncPanel({
93
102
  status, // the live `sync:status` payload (never null while mounted)
94
103
  project, // display name for the header sentence (hub-supplied → safeName'd)
@@ -150,6 +159,7 @@ export default function SyncPanel({
150
159
  const truncated = isCount(status?.itemsTruncated) ? status.itemsTruncated : 0;
151
160
  const assets = readAssets(status?.assets);
152
161
  const assetFailures = assets?.failures || [];
162
+ const files = readFiles(status?.files);
153
163
 
154
164
  const { attention, byGroup } = useMemo(() => {
155
165
  const attention = items.filter((i) => i.state === 'auth-rejected');
@@ -329,6 +339,23 @@ export default function SyncPanel({
329
339
  )}
330
340
  </section>
331
341
  )}
342
+
343
+ {files && (
344
+ <section aria-label="Project files" data-testid="sync-files">
345
+ <div className="gp-sect-label">
346
+ project files <span className="gp-group-count">{files.synced}</span>
347
+ </div>
348
+ <div className="sp-assets-line">
349
+ {`${files.synced} synced` + (files.pulled > 0 ? ` · ${files.pulled} pulled` : '')}
350
+ </div>
351
+ {files.conflicts > 0 && (
352
+ <div className="sp-assets-retry" data-testid="sync-files-conflicts">
353
+ {files.conflicts} conflict{files.conflicts === 1 ? '' : 's'} — the older copies are
354
+ kept in _trash/.
355
+ </div>
356
+ )}
357
+ </section>
358
+ )}
332
359
  </div>
333
360
  </aside>
334
361
  );
@@ -47,6 +47,15 @@ export interface LinkedHub {
47
47
  * would split peers of the same project apart. See sync/doc-name.ts.
48
48
  */
49
49
  workspaceId?: string;
50
+ /**
51
+ * feature-sync-file-plane — opt-in for the manifest-driven file plane
52
+ * (Plane B): the downward project-file pull AND the widened upward sweep
53
+ * (stylesheets, docs, code modules — classifier membership, see
54
+ * sync/file-membership.ts). Default OFF this release; `MAUDE_SYNC_FILES=1`
55
+ * forces it on for a session. The flag gates ONLY the new plane — the
56
+ * canvas CRDT lanes and the DDR-217 asset lanes run regardless.
57
+ */
58
+ syncFiles?: boolean;
50
59
  }
51
60
 
52
61
  export interface DevServerConfig {