@1agh/maude 0.60.7 → 1.0.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.
- package/apps/studio/acp/index.ts +1 -0
- package/apps/studio/ai-banner.tsx +1 -0
- package/apps/studio/annotations-context-toolbar.tsx +3 -1
- package/apps/studio/annotations-layer.tsx +33 -16
- package/apps/studio/api.ts +108 -18
- package/apps/studio/artboard-guides-overlay.tsx +5 -1
- package/apps/studio/assets-s3.ts +6 -1
- package/apps/studio/bin/_import-figma.mjs +8 -3
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-artifacts.ts +21 -0
- package/apps/studio/canvas-build.ts +14 -9
- package/apps/studio/canvas-comment-mount.tsx +27 -30
- package/apps/studio/canvas-icons.tsx +1 -1
- package/apps/studio/canvas-lib.tsx +94 -5
- package/apps/studio/canvas-list-watch.ts +14 -1
- package/apps/studio/canvas-shell.tsx +8 -0
- package/apps/studio/client/app.jsx +224 -45
- package/apps/studio/client/panels/GitPanel.jsx +121 -13
- package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
- package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
- package/apps/studio/client/panels/SyncPanel.jsx +595 -1
- package/apps/studio/client/styles/3-shell-maude.css +38 -0
- package/apps/studio/clip-ops.ts +8 -1
- package/apps/studio/cloud/endpoints.ts +265 -3
- package/apps/studio/collab/origins.ts +3 -1
- package/apps/studio/comments-overlay.tsx +5 -0
- package/apps/studio/config.schema.json +24 -0
- package/apps/studio/context-menu.tsx +40 -27
- package/apps/studio/context.ts +57 -8
- package/apps/studio/cursors-overlay.tsx +25 -13
- package/apps/studio/dist/client.bundle.js +686 -686
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/jobs.ts +77 -15
- package/apps/studio/exporters/remote.ts +201 -0
- package/apps/studio/exporters/video-encode-lib.ts +10 -4
- package/apps/studio/figma/to-strokes.ts +11 -9
- package/apps/studio/gifenc.d.ts +51 -0
- package/apps/studio/git/log-format.ts +88 -0
- package/apps/studio/git/safe-rel.ts +96 -0
- package/apps/studio/git/service.ts +46 -27
- package/apps/studio/hmr-broadcast.ts +10 -0
- package/apps/studio/http.ts +384 -6
- package/apps/studio/participants-chrome.tsx +1 -0
- package/apps/studio/photo-store.ts +7 -0
- package/apps/studio/react-augment.d.ts +17 -0
- package/apps/studio/runtime-bundle.ts +6 -1
- package/apps/studio/server.ts +43 -8
- package/apps/studio/sync/agent.ts +70 -82
- package/apps/studio/sync/asset-push.ts +28 -71
- package/apps/studio/sync/autocommit.ts +106 -5
- package/apps/studio/sync/cell-file-events.ts +117 -0
- package/apps/studio/sync/cell-pairing.ts +20 -5
- package/apps/studio/sync/cell-write-nudge.ts +244 -0
- package/apps/studio/sync/codec.ts +155 -3
- package/apps/studio/sync/cold-start-apply.ts +211 -0
- package/apps/studio/sync/ctl-heal.ts +253 -0
- package/apps/studio/sync/ctl-provider.ts +217 -0
- package/apps/studio/sync/decide-file.ts +335 -0
- package/apps/studio/sync/file-ledger.ts +581 -0
- package/apps/studio/sync/file-membership.ts +32 -0
- package/apps/studio/sync/file-plane.ts +1400 -0
- package/apps/studio/sync/file-pull.ts +41 -4
- package/apps/studio/sync/hub-link.ts +16 -1
- package/apps/studio/sync/hub-listing.ts +46 -0
- package/apps/studio/sync/hubs-config.ts +16 -0
- package/apps/studio/sync/index.ts +915 -308
- package/apps/studio/sync/journal-client.ts +200 -0
- package/apps/studio/sync/migrate-seed.ts +99 -67
- package/apps/studio/sync/poke.ts +50 -0
- package/apps/studio/sync/projection.ts +13 -0
- package/apps/studio/sync/pull-budget.ts +86 -0
- package/apps/studio/sync/settings.ts +110 -0
- package/apps/studio/sync/status.ts +68 -0
- package/apps/studio/sync/trash.ts +243 -0
- package/apps/studio/sync/untrusted.ts +30 -10
- package/apps/studio/test/_helpers.ts +8 -0
- package/apps/studio/test/canvas-build.test.ts +63 -0
- package/apps/studio/test/canvas-list-watch.test.ts +17 -0
- package/apps/studio/test/canvas-move-api.test.ts +31 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
- package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
- package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
- package/apps/studio/test/cloud-history-posture.test.ts +230 -0
- package/apps/studio/test/cloud-session-role.test.ts +30 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
- package/apps/studio/test/cold-start-apply.test.ts +303 -0
- package/apps/studio/test/collab-stress.test.ts +9 -1
- package/apps/studio/test/export-lane.test.ts +245 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
- package/apps/studio/test/git-log-format.test.ts +95 -0
- package/apps/studio/test/git-safe-rel.test.ts +132 -0
- package/apps/studio/test/hmr-broadcast.test.ts +26 -0
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
- package/apps/studio/test/sync-agent.test.ts +78 -0
- package/apps/studio/test/sync-asset-push.test.ts +71 -108
- package/apps/studio/test/sync-autocommit.test.ts +80 -0
- package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
- package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
- package/apps/studio/test/sync-decide-file.test.ts +420 -0
- package/apps/studio/test/sync-file-ledger.test.ts +334 -0
- package/apps/studio/test/sync-file-membership.test.ts +17 -1
- package/apps/studio/test/sync-file-plane.test.ts +976 -0
- package/apps/studio/test/sync-hub-listing.test.ts +46 -0
- package/apps/studio/test/sync-meta-codec.test.ts +76 -0
- package/apps/studio/test/sync-move-retirement.test.ts +231 -0
- package/apps/studio/test/sync-panel-surface.test.ts +20 -0
- package/apps/studio/test/sync-path-pull.test.ts +67 -1
- package/apps/studio/test/sync-pull-budget.test.ts +169 -0
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
- package/apps/studio/test/sync-settings-routes.test.ts +195 -0
- package/apps/studio/test/sync-settings.test.ts +151 -0
- package/apps/studio/test/sync-status.test.ts +69 -0
- package/apps/studio/test/sync-trash.test.ts +132 -0
- package/apps/studio/test/workspace-containment.test.ts +45 -9
- package/apps/studio/tsconfig.json +9 -10
- package/apps/studio/use-annotation-resize.tsx +14 -3
- package/apps/studio/use-collab.tsx +3 -1
- package/apps/studio/whats-new.json +90 -0
- package/apps/studio/workspace-mode.ts +110 -62
- package/apps/studio/ws.ts +22 -1
- package/cli/bin/claude-design-server.mjs +19 -0
- package/cli/commands/design.mjs +25 -6
- package/cli/commands/hub-workspace.mjs +243 -22
- package/cli/commands/hub-workspace.test.mjs +171 -0
- package/cli/commands/hub.mjs +71 -1
- package/cli/lib/design-link.mjs +186 -1
- package/cli/lib/design-ownership.mjs +330 -0
- package/cli/lib/design-ownership.test.mjs +329 -0
- package/cli/lib/hubs-config.mjs +21 -0
- package/cli/lib/hubs-config.test.mjs +47 -1
- package/cli/lib/workspace-plan.mjs +298 -5
- package/cli/lib/workspace-plan.test.mjs +215 -1
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +43 -2
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/apps/studio/server.mjs +0 -1312
- package/apps/studio/sync/asset-pull.ts +0 -210
- package/apps/studio/sync/asset-push-worker.ts +0 -84
- package/apps/studio/sync/asset-sweep.ts +0 -262
- package/apps/studio/test/sync-asset-pull.test.ts +0 -161
- package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
- package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
package/apps/studio/clip-ops.ts
CHANGED
|
@@ -394,6 +394,11 @@ export function applyInsertClipAt(
|
|
|
394
394
|
}
|
|
395
395
|
const indent = `${lineStartInfo(source, rootClose).indent} `;
|
|
396
396
|
const s2 = new MagicString(source);
|
|
397
|
+
// Placeholder wins over media (same precedence as the append path below),
|
|
398
|
+
// then media, then nothing. The old two-arm form read `media.tag` in the
|
|
399
|
+
// else, where only the `needs media` throw above ruled out null — a
|
|
400
|
+
// disjunction the checker cannot carry. Spelled as three arms it needs no
|
|
401
|
+
// assertion, and the unreachable third arm is honest rather than absent.
|
|
397
402
|
const gfChild = opts.placeholder
|
|
398
403
|
? placeholderChildText(
|
|
399
404
|
canvasAbsPath,
|
|
@@ -402,7 +407,9 @@ export function applyInsertClipAt(
|
|
|
402
407
|
`${indent} `,
|
|
403
408
|
dur
|
|
404
409
|
).trimEnd()
|
|
405
|
-
:
|
|
410
|
+
: media
|
|
411
|
+
? `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />`
|
|
412
|
+
: '';
|
|
406
413
|
s2.appendLeft(
|
|
407
414
|
rootClose,
|
|
408
415
|
` <TransitionSeries>\n${indent} <TransitionSeries.Sequence durationInFrames={${dur}}>` +
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
writeFileSync,
|
|
26
26
|
} from 'node:fs';
|
|
27
27
|
import { homedir } from 'node:os';
|
|
28
|
-
import { basename, dirname, join } from 'node:path';
|
|
28
|
+
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
29
29
|
|
|
30
30
|
import { deleteHubCredential, saveHubCredential } from '../sync/hub-link.ts';
|
|
31
31
|
import { getHubToken, normalizeUrl } from '../sync/hubs-config.ts';
|
|
@@ -48,6 +48,26 @@ export interface CloudEndpointResult {
|
|
|
48
48
|
json: unknown;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Why a historical blob could not be produced — or the blob.
|
|
53
|
+
*
|
|
54
|
+
* `not-found` is the only AUTHORITATIVE negative: a commit's content never
|
|
55
|
+
* changes, so the answer is stable and a caller may cache it. `unreachable`,
|
|
56
|
+
* `not-linked` and the two argument refusals are all transient or caller-side,
|
|
57
|
+
* and caching any of them turns an outage into a permanent 404.
|
|
58
|
+
*/
|
|
59
|
+
export type HistoryFileResult =
|
|
60
|
+
| { ok: true; source: string }
|
|
61
|
+
| { ok: false; reason: 'not-found' | 'unreachable' | 'not-linked' | 'bad-sha' | 'bad-path' };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ceiling on ONE response from a linked cell.
|
|
65
|
+
*
|
|
66
|
+
* Comfortably above the hub's own 2 MiB blob cap, so a legitimate answer is
|
|
67
|
+
* never refused, and far below "buffer whatever arrives for eight seconds".
|
|
68
|
+
*/
|
|
69
|
+
const MAX_HUB_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
70
|
+
|
|
51
71
|
/** Whatever the control plane answered. Read defensively at every use. */
|
|
52
72
|
type CloudBody = Record<string, unknown> & {
|
|
53
73
|
token?: string;
|
|
@@ -123,6 +143,13 @@ interface Ctx {
|
|
|
123
143
|
* missing supervisor degrades to exactly the old behaviour — linked on disk,
|
|
124
144
|
* syncing after the next start.
|
|
125
145
|
*/
|
|
146
|
+
/**
|
|
147
|
+
* The link changed (attach / detach). Lets the server drop anything it
|
|
148
|
+
* cached about "what a past version of this file looked like" — those
|
|
149
|
+
* answers were produced under the OLD link and are keyed by nothing that
|
|
150
|
+
* would distinguish them.
|
|
151
|
+
*/
|
|
152
|
+
onLinkChanged?: () => void;
|
|
126
153
|
syncControl?: {
|
|
127
154
|
/** `null` = unlink: clear the runtime's in-memory link, cycle to solo. */
|
|
128
155
|
restart(
|
|
@@ -266,7 +293,10 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
266
293
|
headers: { authorization: `Bearer ${file.token}`, 'content-type': 'application/json' },
|
|
267
294
|
body: JSON.stringify({ project: projectId }),
|
|
268
295
|
});
|
|
269
|
-
|
|
296
|
+
// `url` is checked alongside `token`: linkToWorkspace requires a
|
|
297
|
+
// workspace URL, and a 200 body missing it would have linked against
|
|
298
|
+
// `undefined` rather than refusing.
|
|
299
|
+
if (opened.status !== 200 || !opened.body?.token || !opened.body.url) {
|
|
270
300
|
return {
|
|
271
301
|
status: 502,
|
|
272
302
|
json: { ok: false, error: 'The project could not be opened with this account.' },
|
|
@@ -295,7 +325,7 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
295
325
|
headers: { 'content-type': 'application/json' },
|
|
296
326
|
body: JSON.stringify({ code }),
|
|
297
327
|
});
|
|
298
|
-
if (exchanged.status !== 200 || !exchanged.body?.token) {
|
|
328
|
+
if (exchanged.status !== 200 || !exchanged.body?.token || !exchanged.body.url) {
|
|
299
329
|
return {
|
|
300
330
|
status: 410,
|
|
301
331
|
json: {
|
|
@@ -331,6 +361,93 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
331
361
|
});
|
|
332
362
|
},
|
|
333
363
|
|
|
364
|
+
/**
|
|
365
|
+
* THE HISTORY THAT IS ACTUALLY BEING WRITTEN — feature-cloud-managed-git-
|
|
366
|
+
* posture.
|
|
367
|
+
*
|
|
368
|
+
* In cloud-managed posture the cell is the sole committer (DDR-198/209/213)
|
|
369
|
+
* and the local repo has no commits at all, so the desktop's History tab
|
|
370
|
+
* read a repo nobody writes and reported "No saved versions yet" directly
|
|
371
|
+
* under "Cloud is saving". This asks the cell instead.
|
|
372
|
+
*
|
|
373
|
+
* SERVER-SIDE, ALWAYS. The hub credential resolved here never reaches the
|
|
374
|
+
* browser — the client talks only to the loopback `/_api/cloud/history`,
|
|
375
|
+
* exactly as it does for every other call in this module.
|
|
376
|
+
*/
|
|
377
|
+
async history(pathRaw?: string | null, limitRaw?: string | null): Promise<CloudEndpointResult> {
|
|
378
|
+
const hub = credentialedHub();
|
|
379
|
+
if (!hub) return { status: 200, json: { ok: false, reason: 'not-linked' } };
|
|
380
|
+
|
|
381
|
+
const qs = new URLSearchParams();
|
|
382
|
+
const rel = designRelative(pathRaw);
|
|
383
|
+
if (rel) qs.set('path', rel);
|
|
384
|
+
const limit = Number(limitRaw);
|
|
385
|
+
if (Number.isFinite(limit) && limit > 0)
|
|
386
|
+
qs.set('limit', String(Math.min(100, Math.trunc(limit))));
|
|
387
|
+
|
|
388
|
+
const r = await hubFetch(hub, `/api/history?${qs.toString()}`);
|
|
389
|
+
if (!r.ok) return { status: 200, json: { ok: false, reason: 'unreachable' } };
|
|
390
|
+
// Clamp what the cell says before it reaches a renderer. The hub caps its
|
|
391
|
+
// own page at 100 rows, but a compromised cell does not run that code.
|
|
392
|
+
const clamp = (v: unknown, n: number) => (typeof v === 'string' ? v.slice(0, n) : '');
|
|
393
|
+
const body = (r.body ?? {}) as { entries?: unknown; branch?: unknown; project?: unknown };
|
|
394
|
+
const rows = Array.isArray(body.entries) ? body.entries.slice(0, 100) : [];
|
|
395
|
+
return {
|
|
396
|
+
status: 200,
|
|
397
|
+
json: {
|
|
398
|
+
ok: true,
|
|
399
|
+
entries: rows.map((e) => {
|
|
400
|
+
const r0 = (e ?? {}) as Record<string, unknown>;
|
|
401
|
+
return {
|
|
402
|
+
sha: clamp(r0.sha, 40),
|
|
403
|
+
message: clamp(r0.message, 500),
|
|
404
|
+
author: clamp(r0.author, 120),
|
|
405
|
+
date: clamp(r0.date, 40),
|
|
406
|
+
};
|
|
407
|
+
}),
|
|
408
|
+
branch: typeof body.branch === 'string' ? body.branch.slice(0, 200) : null,
|
|
409
|
+
project: typeof body.project === 'string' ? body.project.slice(0, 200) : null,
|
|
410
|
+
hubHost: hubHost(hub.url),
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* One file's source at one cloud commit — what the version preview builds
|
|
417
|
+
* from when the sha exists only on the cell.
|
|
418
|
+
*
|
|
419
|
+
* `sha` is validated HERE as well as on the hub. It reaches the preview
|
|
420
|
+
* route from the UNTRUSTED canvas origin (DDR-054), and "the other side
|
|
421
|
+
* checks it" is how a guard ends up existing on neither side.
|
|
422
|
+
*/
|
|
423
|
+
async historyFile(sha: string, pathRaw: string): Promise<HistoryFileResult> {
|
|
424
|
+
// WHY THIS REPORTS A REASON INSTEAD OF JUST `null`.
|
|
425
|
+
//
|
|
426
|
+
// The caller caches a miss so a repeated bad sha costs nothing. If every
|
|
427
|
+
// failure looked alike, an UNREACHABLE cell (or an unlinked project)
|
|
428
|
+
// would be remembered as "this version does not exist" — and the entry
|
|
429
|
+
// outlives the outage, so a version stays permanently unpreviewable. That
|
|
430
|
+
// is the same collapse of "could not reach" into "is not there" this
|
|
431
|
+
// whole feature exists to undo; the panel is careful about it one layer
|
|
432
|
+
// up, and it would be silly to re-introduce it one layer down.
|
|
433
|
+
//
|
|
434
|
+
// Only `not-found` is authoritative. Everything else is transient.
|
|
435
|
+
if (!/^[0-9a-f]{7,40}$/.test(String(sha ?? ''))) return { ok: false, reason: 'bad-sha' };
|
|
436
|
+
const rel = designRelative(pathRaw);
|
|
437
|
+
if (!rel) return { ok: false, reason: 'bad-path' };
|
|
438
|
+
const hub = credentialedHub();
|
|
439
|
+
if (!hub) return { ok: false, reason: 'not-linked' };
|
|
440
|
+
|
|
441
|
+
const qs = new URLSearchParams({ sha, path: rel });
|
|
442
|
+
const r = await hubFetch(hub, `/api/history/file?${qs.toString()}`, { text: true });
|
|
443
|
+
if (r.ok && typeof r.text === 'string') return { ok: true, source: r.text };
|
|
444
|
+
// A 404 is the cell's ONE indistinguishable refusal (out-of-scope,
|
|
445
|
+
// out-of-tree, wrong class, absent). Treating it as authoritative is
|
|
446
|
+
// correct for caching: none of those become true later for the same
|
|
447
|
+
// (sha, path), because a commit's content never changes.
|
|
448
|
+
return { ok: false, reason: r.status === 404 ? 'not-found' : 'unreachable' };
|
|
449
|
+
},
|
|
450
|
+
|
|
334
451
|
/**
|
|
335
452
|
* Detach THIS project from its workspace — the in-app `maude design
|
|
336
453
|
* unlink` (fix 7's Disconnect, sync RCA 2026-08-10). Drops the committed
|
|
@@ -372,6 +489,7 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
372
489
|
} catch {
|
|
373
490
|
/* best-effort — the link is gone on disk either way */
|
|
374
491
|
}
|
|
492
|
+
ctx.onLinkChanged?.();
|
|
375
493
|
return { status: 200, json: { ok: true, detached: !!linked } };
|
|
376
494
|
},
|
|
377
495
|
};
|
|
@@ -404,6 +522,148 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
404
522
|
}
|
|
405
523
|
}
|
|
406
524
|
|
|
525
|
+
/**
|
|
526
|
+
* The linked hub AND its credential, or null.
|
|
527
|
+
*
|
|
528
|
+
* "Linked" alone is not enough to make a network call on: `config.json` is
|
|
529
|
+
* COMMITTED and travels with the repo, so a `linkedHub` in it is
|
|
530
|
+
* attacker-authorable (B2). A stored credential is the corroboration — and
|
|
531
|
+
* it is also the thing we would be sending, so resolving both together is
|
|
532
|
+
* what keeps a no-credential case from becoming an unauthenticated request
|
|
533
|
+
* to an address a repo named.
|
|
534
|
+
*/
|
|
535
|
+
function credentialedHub(): { url: string; token: string } | null {
|
|
536
|
+
const linked = readLinkedHub();
|
|
537
|
+
if (!linked?.credentialed) return null;
|
|
538
|
+
try {
|
|
539
|
+
const url = normalizeUrl(linked.url);
|
|
540
|
+
const token = getHubToken(url);
|
|
541
|
+
return token ? { url, token } : null;
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* One authenticated GET against the linked cell.
|
|
549
|
+
*
|
|
550
|
+
* NEITHER THE TOKEN NOR THE HUB'S OWN ERROR BODY EVER LEAVES THIS FUNCTION.
|
|
551
|
+
* Callers get `{ ok }` plus the parsed payload on success, so a hub message
|
|
552
|
+
* cannot be relayed into the browser (or into a log) by accident.
|
|
553
|
+
*
|
|
554
|
+
* A 401 IS A PLAIN READ FAILURE HERE. `/_api/cloud/status`'s 401 path
|
|
555
|
+
* DELETES the stored credential (confused-deputy F6, and correct there: the
|
|
556
|
+
* control plane is the authority on whether this device is still trusted).
|
|
557
|
+
* A History poll is not that authority — a cell restarting mid-token-renewal
|
|
558
|
+
* would otherwise silently unlink a working project. Read fails; credential
|
|
559
|
+
* stands.
|
|
560
|
+
*/
|
|
561
|
+
async function hubFetch(
|
|
562
|
+
hub: { url: string; token: string },
|
|
563
|
+
path: string,
|
|
564
|
+
{ text = false, timeoutMs = 8_000, maxBytes = MAX_HUB_RESPONSE_BYTES } = {}
|
|
565
|
+
): Promise<{ ok: boolean; status: number; body?: unknown; text?: string }> {
|
|
566
|
+
const ctl = new AbortController();
|
|
567
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
568
|
+
try {
|
|
569
|
+
const res = await fetch(`${hub.url}${path}`, {
|
|
570
|
+
headers: { authorization: `Bearer ${hub.token}` },
|
|
571
|
+
signal: ctl.signal,
|
|
572
|
+
});
|
|
573
|
+
if (!res.ok) return { ok: false, status: res.status };
|
|
574
|
+
// THE CELL IS NOT TRUSTED TO RESPECT ITS OWN LIMITS. The hub caps a blob
|
|
575
|
+
// at 2 MiB and checks `cat-file -s` before reading — but a compromised
|
|
576
|
+
// cell, or anything MITM-ing a self-hosted `http://` hub, simply does
|
|
577
|
+
// not run that code. Without a cap here the desktop buffers an endless
|
|
578
|
+
// stream into one JS string for the whole timeout, and the trigger is
|
|
579
|
+
// reachable from the UNTRUSTED canvas origin (a canvas picks the `?sha=`
|
|
580
|
+
// on the version-preview route). Read through a counted stream and abort
|
|
581
|
+
// past the cap. (Security re-review of 8134ca8f, finding F2.)
|
|
582
|
+
const body = await readCapped(res, ctl, maxBytes);
|
|
583
|
+
if (body == null) return { ok: false, status: 0 };
|
|
584
|
+
if (text) return { ok: true, status: res.status, text: body };
|
|
585
|
+
try {
|
|
586
|
+
return { ok: true, status: res.status, body: JSON.parse(body) };
|
|
587
|
+
} catch {
|
|
588
|
+
return { ok: true, status: res.status, body: {} };
|
|
589
|
+
}
|
|
590
|
+
} catch {
|
|
591
|
+
return { ok: false, status: 0 };
|
|
592
|
+
} finally {
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Read a response body, aborting past `maxBytes`. Null = refused/failed. */
|
|
598
|
+
async function readCapped(
|
|
599
|
+
res: Response,
|
|
600
|
+
ctl: AbortController,
|
|
601
|
+
maxBytes: number
|
|
602
|
+
): Promise<string | null> {
|
|
603
|
+
const declared = Number(res.headers.get('content-length'));
|
|
604
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
605
|
+
ctl.abort();
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
if (!res.body) {
|
|
609
|
+
const t = await res.text();
|
|
610
|
+
return t.length > maxBytes ? null : t;
|
|
611
|
+
}
|
|
612
|
+
const reader = res.body.getReader();
|
|
613
|
+
const dec = new TextDecoder('utf8');
|
|
614
|
+
let out = '';
|
|
615
|
+
let seen = 0;
|
|
616
|
+
for (;;) {
|
|
617
|
+
const { done, value } = await reader.read();
|
|
618
|
+
if (done) break;
|
|
619
|
+
seen += value?.byteLength ?? 0;
|
|
620
|
+
if (seen > maxBytes) {
|
|
621
|
+
// Content-Length is a claim; this is the measurement.
|
|
622
|
+
try {
|
|
623
|
+
await reader.cancel();
|
|
624
|
+
} catch {
|
|
625
|
+
/* already gone */
|
|
626
|
+
}
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
out += dec.decode(value, { stream: true });
|
|
630
|
+
}
|
|
631
|
+
return out + dec.decode();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* repo-relative (`.design/ui/Card.tsx`) → design-root-relative (`ui/Card.tsx`).
|
|
636
|
+
*
|
|
637
|
+
* The hub speaks design-root-relative on every file surface it has, and the
|
|
638
|
+
* cell's design root need not share this machine's folder name — so the
|
|
639
|
+
* translation belongs on this side, where both roots are known. A path
|
|
640
|
+
* outside the design tree returns null rather than being passed through: the
|
|
641
|
+
* cell would refuse it anyway, and sending it would make this a probe.
|
|
642
|
+
*/
|
|
643
|
+
function designRelative(raw?: string | null): string | null {
|
|
644
|
+
if (typeof raw !== 'string' || raw === '') return null;
|
|
645
|
+
const p = raw.replace(/\\/g, '/');
|
|
646
|
+
if (p.split('/').includes('..')) return null;
|
|
647
|
+
const designRel = relative(ctx.paths.repoRoot, ctx.paths.designRoot).split(sep).join('/');
|
|
648
|
+
if (!designRel || designRel.startsWith('..')) return null;
|
|
649
|
+
if (p === designRel) return null;
|
|
650
|
+
if (p.startsWith(`${designRel}/`)) return p.slice(designRel.length + 1) || null;
|
|
651
|
+
// Already design-root-relative (the History scope the panel holds for a
|
|
652
|
+
// canvas opened from the cell) — accept it as-is rather than refusing a
|
|
653
|
+
// caller for using the hub's own vocabulary.
|
|
654
|
+
return p.startsWith('/') ? null : p;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** The cell's host, for a header that must name SOMETHING when the project
|
|
658
|
+
* name is unknown. Address only — already-public, and never the token. */
|
|
659
|
+
function hubHost(url: string): string | null {
|
|
660
|
+
try {
|
|
661
|
+
return new URL(url).host;
|
|
662
|
+
} catch {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
407
667
|
/** The shared tail of every attach: cell exchange → credential + linkedHub. */
|
|
408
668
|
async function linkToWorkspace({
|
|
409
669
|
workspaceUrl,
|
|
@@ -499,6 +759,8 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
499
759
|
};
|
|
500
760
|
}
|
|
501
761
|
|
|
762
|
+
ctx.onLinkChanged?.();
|
|
763
|
+
|
|
502
764
|
return {
|
|
503
765
|
status: 200,
|
|
504
766
|
json: {
|
|
@@ -129,7 +129,9 @@ export const SYNC_AGENT_ORIGIN: object = markTrustedOrigin(
|
|
|
129
129
|
export function rootTypesTouched(transaction: Y.Transaction): Set<string> {
|
|
130
130
|
const roots = new Set<string>();
|
|
131
131
|
for (const type of transaction.changed.keys()) {
|
|
132
|
-
|
|
132
|
+
// `transaction.changed` keys are AbstractType<YEvent<any>>; the parent walk
|
|
133
|
+
// below is event-type agnostic, so widen once here rather than at each step.
|
|
134
|
+
let t: Y.AbstractType<unknown> = type as unknown as Y.AbstractType<unknown>;
|
|
133
135
|
// biome-ignore lint/suspicious/noExplicitAny: `_item` is yjs-internal but stable API surface for parent walks.
|
|
134
136
|
while ((t as any)._item != null) t = (t as any)._item.parent;
|
|
135
137
|
try {
|
|
@@ -73,6 +73,11 @@ interface ComposeSelection {
|
|
|
73
73
|
id?: string;
|
|
74
74
|
selector: string;
|
|
75
75
|
artboardId?: string | null;
|
|
76
|
+
/** Occurrence index within `selector` — always set by buildComposeSelection,
|
|
77
|
+
* read into the comment payload below. Missing from this local mirror of
|
|
78
|
+
* dom-selection's `Selection`, so the payload was silently sending
|
|
79
|
+
* `undefined` to the checker's eyes and the real value at runtime. */
|
|
80
|
+
index: number;
|
|
76
81
|
tag: string;
|
|
77
82
|
classes: string;
|
|
78
83
|
text: string;
|
|
@@ -228,6 +228,30 @@
|
|
|
228
228
|
"type": "boolean",
|
|
229
229
|
"default": true,
|
|
230
230
|
"description": "DDR-079 (supersedes DDR-072) — project-level TSX sync, ON BY DEFAULT. Omit the field to sync ALL .tsx canvases to this hub (the common case: a linked teammate sees the project's TSX without a hidden opt-in). Set false to opt the whole project OUT. A per-canvas .meta.json \"syncable\": false still excludes one canvas (the sidecar always wins). Inert unless the cross-origin sandbox is active (MAUDE_CANVAS_ORIGIN_SPLIT != 0) — the DDR-060 Lock-2 coupling is preserved. TSX bodies execute, so syncing broadens a WebRTC/self-nav exfil residual to every synced canvas — link only hubs you operate or trust; the dev-server prints a loud boot banner against non-loopback hubs."
|
|
231
|
+
},
|
|
232
|
+
"workspaceId": {
|
|
233
|
+
"type": "string",
|
|
234
|
+
"description": "DDR-192 §5 — stable workspace identity for the hub document namespace (ws/<workspace-id>/<branch>/<slug>). Authoritative when present; the cloud control plane sets it at provisioning time. Absent, the sync runtime derives it from the git origin remote."
|
|
235
|
+
},
|
|
236
|
+
"syncFiles": {
|
|
237
|
+
"type": "boolean",
|
|
238
|
+
"default": true,
|
|
239
|
+
"description": "Sync v2 — the journal-driven file plane (Plane B): the downward project-file pull AND the widened upward sweep. Default ON; set false to fall back to the pre-Sync-v2 reach (binary media under assets/ only). Settable from the Sync panel's Settings section."
|
|
240
|
+
},
|
|
241
|
+
"propagateDeletes": {
|
|
242
|
+
"type": "boolean",
|
|
243
|
+
"default": true,
|
|
244
|
+
"description": "Sync v2 Increment 6 — a deletion here becomes a deletion everywhere (losers quarantined into _trash/, never unlinked; cumulative breakers hold bulk removals for confirmation). Default ON; set false to hold every absence instead. Settable from the Sync panel's Settings section."
|
|
245
|
+
},
|
|
246
|
+
"resolveFirstAnchor": {
|
|
247
|
+
"type": "string",
|
|
248
|
+
"enum": ["keep-local", "keep-cloud"],
|
|
249
|
+
"description": "How to settle a FIRST-ANCHOR hold for the whole set at once: keep-local pushes this machine's copies up; keep-cloud takes the project's and parks yours beside them. ABSENT means keep asking — the hold is not an error. Settable from the Sync panel's Settings section."
|
|
250
|
+
},
|
|
251
|
+
"fileEvents": {
|
|
252
|
+
"type": "boolean",
|
|
253
|
+
"default": true,
|
|
254
|
+
"description": "Sync v2 Increment 2 (DDR-226 §4) — the file-event control channel (poke). Default ON where the hub advertises ledger support; set false to fall back to the 20 s poll cadence. The documented rollback for the poke — a config key because the target user has no terminal (DDR-177)."
|
|
231
255
|
}
|
|
232
256
|
},
|
|
233
257
|
"additionalProperties": false
|
|
@@ -371,7 +371,11 @@ export function ContextMenuView({
|
|
|
371
371
|
el.querySelectorAll<HTMLButtonElement>('button.dc-menu-item:not([disabled])')
|
|
372
372
|
);
|
|
373
373
|
if (items.length === 0) return;
|
|
374
|
-
|
|
374
|
+
// `document.activeElement` is Element | null; anything that is not one
|
|
375
|
+
// of these buttons behaves as "no current item" (-1), which is what
|
|
376
|
+
// indexOf returned before and what the wrap-around arithmetic expects.
|
|
377
|
+
const active = document.activeElement;
|
|
378
|
+
const idx = active instanceof HTMLButtonElement ? items.indexOf(active) : -1;
|
|
375
379
|
const nextIdx =
|
|
376
380
|
e.key === 'ArrowDown'
|
|
377
381
|
? (idx + 1) % items.length
|
|
@@ -526,32 +530,41 @@ function MenuItemRow({
|
|
|
526
530
|
onMouseEnter={cancelClose}
|
|
527
531
|
onMouseLeave={scheduleClose}
|
|
528
532
|
>
|
|
529
|
-
{submenuItems.map((sub) =>
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
sub.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
533
|
+
{submenuItems.map((sub) => {
|
|
534
|
+
// Same per-click resolution the leaf branch does at `disabled`
|
|
535
|
+
// above. Passing the raw value here meant a FUNCTION-form
|
|
536
|
+
// `disabled` — always truthy — greyed the item out permanently and
|
|
537
|
+
// swallowed every click, whatever the predicate said about this
|
|
538
|
+
// target. Submenus simply never got the resolution leaves had.
|
|
539
|
+
const subDisabled =
|
|
540
|
+
typeof sub.disabled === 'function' ? sub.disabled(target) : sub.disabled;
|
|
541
|
+
return (
|
|
542
|
+
<button
|
|
543
|
+
key={sub.id}
|
|
544
|
+
type="button"
|
|
545
|
+
role="menuitem"
|
|
546
|
+
disabled={subDisabled}
|
|
547
|
+
title={subDisabled ? sub.disabledHint : undefined}
|
|
548
|
+
className={`dc-menu-item${sub.destructive ? ' is-destructive' : ''}`}
|
|
549
|
+
onClick={() => {
|
|
550
|
+
if (subDisabled) return;
|
|
551
|
+
sub.onSelect(target);
|
|
552
|
+
onClose();
|
|
553
|
+
}}
|
|
554
|
+
onKeyDown={(e) => {
|
|
555
|
+
if (e.key === 'ArrowLeft' || e.key === 'Escape') {
|
|
556
|
+
e.preventDefault();
|
|
557
|
+
e.stopPropagation();
|
|
558
|
+
setSubOpen(false);
|
|
559
|
+
btnRef.current?.focus();
|
|
560
|
+
}
|
|
561
|
+
}}
|
|
562
|
+
>
|
|
563
|
+
<span>{sub.label}</span>
|
|
564
|
+
{sub.shortcut ? <span className="dc-menu-shortcut">{sub.shortcut}</span> : null}
|
|
565
|
+
</button>
|
|
566
|
+
);
|
|
567
|
+
})}
|
|
555
568
|
</div>
|
|
556
569
|
) : null}
|
|
557
570
|
</div>
|
package/apps/studio/context.ts
CHANGED
|
@@ -48,14 +48,59 @@ export interface LinkedHub {
|
|
|
48
48
|
*/
|
|
49
49
|
workspaceId?: string;
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
51
|
+
* Sync v2 — the journal-driven file plane (Plane B): the downward
|
|
52
|
+
* project-file pull AND the widened upward sweep (stylesheets, docs, code
|
|
53
|
+
* modules — classifier membership, see sync/file-membership.ts).
|
|
54
|
+
*
|
|
55
|
+
* **Default ON from Increment 4**, once its security gate closed. Set to
|
|
56
|
+
* `false` per project to fall back to the pre-Sync-v2 reach (binary media
|
|
57
|
+
* under `assets/` only) — a config key rather than a terminal command,
|
|
58
|
+
* because the target user has no terminal (DDR-177). `MAUDE_SYNC_FILES=0`
|
|
59
|
+
* forces it off for one session.
|
|
60
|
+
*
|
|
61
|
+
* The flag gates ONLY this plane — the canvas CRDT lanes and the DDR-217
|
|
62
|
+
* asset lanes run regardless.
|
|
57
63
|
*/
|
|
58
64
|
syncFiles?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Sync v2 Increment 6 — does a deletion here become a deletion everywhere?
|
|
67
|
+
*
|
|
68
|
+
* **Default ON.** A hub-owned mirror that ignores deletes is not a mirror:
|
|
69
|
+
* you remove a file, it comes back on the next pass, and the model the
|
|
70
|
+
* product describes stops being true.
|
|
71
|
+
*
|
|
72
|
+
* What makes that safe is not caution but the breakers: past ten files, or a
|
|
73
|
+
* quarter of what this machine tracks, in one pass, nothing is removed in
|
|
74
|
+
* EITHER direction and the pass says what it was about to do — a branch
|
|
75
|
+
* switch and a deliberate purge look identical until somebody confirms which
|
|
76
|
+
* one it was. Losers are quarantined into `_trash/`, never unlinked, on both
|
|
77
|
+
* ends. Set `false` per project to hold every absence instead.
|
|
78
|
+
*/
|
|
79
|
+
propagateDeletes?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* How to settle a FIRST-ANCHOR hold, for the whole set at once.
|
|
82
|
+
*
|
|
83
|
+
* When a project is linked for the first time both sides usually have
|
|
84
|
+
* content and neither has been reconciled here, so sync holds rather than
|
|
85
|
+
* writing a conflict copy per file. `keep-local` pushes this machine's
|
|
86
|
+
* copies up; `keep-cloud` takes the project's and parks yours beside them.
|
|
87
|
+
* Absent means keep asking — the hold is not an error and waiting costs
|
|
88
|
+
* nothing but time.
|
|
89
|
+
*/
|
|
90
|
+
resolveFirstAnchor?: 'keep-local' | 'keep-cloud';
|
|
91
|
+
/**
|
|
92
|
+
* Sync v2 Increment 2 (DDR-226 §4) — the file-event control channel.
|
|
93
|
+
*
|
|
94
|
+
* Default ON where the hub advertises `ledger`; set to `false` to fall back
|
|
95
|
+
* to exactly today's 20 s poll cadence. A CONFIG KEY rather than an env var
|
|
96
|
+
* on purpose: this is the documented rollback for the poke, and the target
|
|
97
|
+
* user has no terminal to set an env var in (DDR-177).
|
|
98
|
+
*
|
|
99
|
+
* Absence means ON. A committed `false` can only ever make sync SLOWER, so
|
|
100
|
+
* unlike `syncTsx` it carries no trust weight and needs no restore-proof
|
|
101
|
+
* absence-means-on gymnastics.
|
|
102
|
+
*/
|
|
103
|
+
fileEvents?: boolean;
|
|
59
104
|
}
|
|
60
105
|
|
|
61
106
|
export interface DevServerConfig {
|
|
@@ -209,8 +254,12 @@ export interface Context {
|
|
|
209
254
|
}>;
|
|
210
255
|
/** A cycle is in flight — Resync refuses early rather than queueing. */
|
|
211
256
|
busy?(): boolean;
|
|
212
|
-
/** The live runtime, for the sweep-scoped cancel
|
|
213
|
-
|
|
257
|
+
/** The live runtime, for the sweep-scoped cancel + the move protocol's
|
|
258
|
+
* retire step. Null in solo mode. Structural on purpose — see above. */
|
|
259
|
+
current?(): {
|
|
260
|
+
cancelAssetSweep(): boolean;
|
|
261
|
+
retireForMove?(fromSlug: string, toRel: string): Promise<boolean>;
|
|
262
|
+
} | null;
|
|
214
263
|
};
|
|
215
264
|
}
|
|
216
265
|
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* 30 Hz publish cadence keeps it smooth enough for the multiplayer demo.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import type { JSX } from 'react';
|
|
20
21
|
import { memo, useEffect, useState } from 'react';
|
|
21
22
|
|
|
22
|
-
import {
|
|
23
|
+
import { useLiveViewport } from './canvas-lib.tsx';
|
|
23
24
|
import { type ForeignAwareness, useCollab, useForeignAwareness } from './use-collab.tsx';
|
|
24
25
|
|
|
25
26
|
const CURSOR_CSS = `
|
|
@@ -164,9 +165,17 @@ const Cursor = memo(function Cursor({ peer, viewport }: CursorProps): JSX.Elemen
|
|
|
164
165
|
*/
|
|
165
166
|
interface PeerAnnotationSelectionProps {
|
|
166
167
|
peer: ForeignAwareness;
|
|
168
|
+
/**
|
|
169
|
+
* Unused in the math below — the halo is measured straight off the DOM — but
|
|
170
|
+
* load-bearing as a PROP: `memo` compares props, and through a pan/zoom the
|
|
171
|
+
* peer never changes, so without a value that ticks with the camera this
|
|
172
|
+
* component would bail out of every re-render and leave the halo frozen in
|
|
173
|
+
* screen space while the stroke it outlines moves away underneath it.
|
|
174
|
+
*/
|
|
175
|
+
viewport: ViewportSnapshot;
|
|
167
176
|
}
|
|
168
177
|
|
|
169
|
-
const PeerAnnotationSelection = memo(function PeerAnnotationSelection({
|
|
178
|
+
export const PeerAnnotationSelection = memo(function PeerAnnotationSelection({
|
|
170
179
|
peer,
|
|
171
180
|
}: PeerAnnotationSelectionProps): JSX.Element | null {
|
|
172
181
|
if (!peer.annotationSelection || peer.annotationSelection.length === 0) return null;
|
|
@@ -218,9 +227,11 @@ const PeerAnnotationSelection = memo(function PeerAnnotationSelection({
|
|
|
218
227
|
*/
|
|
219
228
|
interface PeerSelectionProps {
|
|
220
229
|
peer: ForeignAwareness;
|
|
230
|
+
/** See PeerAnnotationSelectionProps.viewport — the memo-invalidation tick. */
|
|
231
|
+
viewport: ViewportSnapshot;
|
|
221
232
|
}
|
|
222
233
|
|
|
223
|
-
const PeerSelection = memo(function PeerSelection({
|
|
234
|
+
export const PeerSelection = memo(function PeerSelection({
|
|
224
235
|
peer,
|
|
225
236
|
}: PeerSelectionProps): JSX.Element | null {
|
|
226
237
|
if (!peer.selection) return null;
|
|
@@ -267,14 +278,15 @@ const PeerSelection = memo(function PeerSelection({
|
|
|
267
278
|
export function CursorsOverlay(): JSX.Element {
|
|
268
279
|
ensureCursorStyles();
|
|
269
280
|
const peers = useForeignAwareness();
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
281
|
+
// LIVE, not the published viewport. `controller.viewport` republishes only on
|
|
282
|
+
// the settle (SETTLE_MS), so through a pan/zoom it is one frozen value: every
|
|
283
|
+
// peer cursor and every peer selection frame sat at the screen position the
|
|
284
|
+
// camera had when the gesture STARTED and only snapped onto the content half
|
|
285
|
+
// a second after it ended. Same root cause, same fix as the annotation resize
|
|
286
|
+
// handles — read the camera that is written on every applyViewport. Settled,
|
|
287
|
+
// `useLiveViewport()` IS the published value, and it only subscribes to the
|
|
288
|
+
// shared rAF ticker while a gesture is in flight.
|
|
289
|
+
const vp = useLiveViewport();
|
|
278
290
|
|
|
279
291
|
// Bump a render tick whenever annotations change so PeerAnnotationSelection
|
|
280
292
|
// re-resolves [data-id] bounds. Without this, a peer resizing a stroke
|
|
@@ -299,10 +311,10 @@ export function CursorsOverlay(): JSX.Element {
|
|
|
299
311
|
return (
|
|
300
312
|
<div className="dc-cursor-overlay" aria-hidden="true">
|
|
301
313
|
{peers.map((peer) => (
|
|
302
|
-
<PeerSelection key={`sel-${peer.clientID}`} peer={peer} />
|
|
314
|
+
<PeerSelection key={`sel-${peer.clientID}`} peer={peer} viewport={vp} />
|
|
303
315
|
))}
|
|
304
316
|
{peers.map((peer) => (
|
|
305
|
-
<PeerAnnotationSelection key={`asel-${peer.clientID}`} peer={peer} />
|
|
317
|
+
<PeerAnnotationSelection key={`asel-${peer.clientID}`} peer={peer} viewport={vp} />
|
|
306
318
|
))}
|
|
307
319
|
{peers.map((peer) => (
|
|
308
320
|
<Cursor key={peer.clientID} peer={peer} viewport={vp} />
|