@1agh/maude 0.26.0 → 0.28.0
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/cli/commands/design-link.test.mjs +46 -0
- package/cli/commands/doctor.mjs +110 -5
- package/cli/commands/doctor.test.mjs +52 -0
- package/cli/lib/design-link.mjs +38 -8
- package/package.json +8 -8
- package/plugins/design/dev-server/activity.ts +256 -0
- package/plugins/design/dev-server/ai-banner.tsx +4 -0
- package/plugins/design/dev-server/artboard-activity-overlay.tsx +67 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +164 -9
- package/plugins/design/dev-server/canvas-lib.tsx +62 -15
- package/plugins/design/dev-server/config.schema.json +2 -1
- package/plugins/design/dev-server/dist/client.bundle.js +18 -18
- package/plugins/design/dev-server/dist/comment-mount.js +82 -4
- package/plugins/design/dev-server/inspect.ts +31 -1
- package/plugins/design/dev-server/participants-chrome.tsx +86 -1
- package/plugins/design/dev-server/server.ts +10 -1
- package/plugins/design/dev-server/sync/agent.ts +23 -8
- package/plugins/design/dev-server/sync/index.ts +24 -19
- package/plugins/design/dev-server/test/activity.test.ts +195 -0
- package/plugins/design/dev-server/test/artboard-activity-overlay.test.tsx +56 -0
- package/plugins/design/dev-server/test/canvas-hmr-runtime.test.tsx +114 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +28 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +34 -6
- package/plugins/design/dev-server/test/use-agent-presence.test.tsx +114 -0
- package/plugins/design/dev-server/test/use-canvas-activity.test.tsx +206 -0
- package/plugins/design/dev-server/use-agent-presence.tsx +244 -0
- package/plugins/design/dev-server/use-canvas-activity.tsx +252 -0
- package/plugins/design/dev-server/ws.ts +21 -2
- package/plugins/design/templates/_shell.html +108 -3
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file artboard-activity-overlay.tsx — Phase 13 / DDR-029 overlay.
|
|
3
|
+
* @scope plugins/design/dev-server/artboard-activity-overlay.tsx
|
|
4
|
+
* @purpose The "agent works here" chrome for a single artboard: an animated
|
|
5
|
+
* rim + a top-right `editing — <label>` badge. Rendered by
|
|
6
|
+
* `DCArtboard` as a world-coord sibling of the `<article>`, so it
|
|
7
|
+
* pans/zooms with the artboard for free.
|
|
8
|
+
*
|
|
9
|
+
* Decorative — `aria-hidden` + `pointer-events:none`. All visual styling
|
|
10
|
+
* (border, pulse keyframes, reduced-motion fallback) lives in the inspector
|
|
11
|
+
* style block injected by `inspect.ts` (single injection point — DDR-029), so
|
|
12
|
+
* this component only places the box and toggles `data-fading` for the 200 ms
|
|
13
|
+
* cross-fade out when the file goes idle.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { CSSProperties } from 'react';
|
|
17
|
+
|
|
18
|
+
export interface OverlayRect {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
w: number;
|
|
22
|
+
h: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ArtboardActivityOverlay({
|
|
26
|
+
rect,
|
|
27
|
+
label,
|
|
28
|
+
active,
|
|
29
|
+
color,
|
|
30
|
+
}: {
|
|
31
|
+
rect: OverlayRect;
|
|
32
|
+
label: string;
|
|
33
|
+
/** true = editing now (pulse, opacity 1); false = idle, cross-fading out. */
|
|
34
|
+
active: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Per-agent presence color (Phase 13.2 / DDR-078). Overrides the default
|
|
37
|
+
* `--mdcc-activity` hue so each agent's rim + badge carry its own color.
|
|
38
|
+
* Omitted for manual / non-agent edits (default inspector blue).
|
|
39
|
+
*/
|
|
40
|
+
color?: string;
|
|
41
|
+
}) {
|
|
42
|
+
const style: CSSProperties = {
|
|
43
|
+
position: 'absolute',
|
|
44
|
+
left: rect.x,
|
|
45
|
+
top: rect.y,
|
|
46
|
+
width: rect.w,
|
|
47
|
+
height: rect.h,
|
|
48
|
+
pointerEvents: 'none',
|
|
49
|
+
// The injected CSS reads `var(--mdcc-activity)`; setting it here recolors the
|
|
50
|
+
// border, the ::after glow, and the badge background in one shot.
|
|
51
|
+
...(color ? ({ '--mdcc-activity': color } as CSSProperties) : {}),
|
|
52
|
+
};
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
className="dc-activity-rim"
|
|
56
|
+
data-fading={active ? undefined : 'true'}
|
|
57
|
+
aria-hidden
|
|
58
|
+
style={style}
|
|
59
|
+
>
|
|
60
|
+
{/* Phase 13.3 — full-artboard scan beam (top→bottom sweep). Styled +
|
|
61
|
+
animated by the injected inspector CSS; this just mounts the cover. */}
|
|
62
|
+
<div className="dc-activity-scan" />
|
|
63
|
+
<span className="dc-activity-badge">editing — {label}</span>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
ArtboardActivityOverlay.displayName = 'ArtboardActivityOverlay';
|
|
@@ -27,9 +27,12 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import {
|
|
30
|
+
Component,
|
|
30
31
|
type ComponentType,
|
|
32
|
+
Fragment,
|
|
31
33
|
type ReactNode,
|
|
32
34
|
createElement,
|
|
35
|
+
useCallback,
|
|
33
36
|
useEffect,
|
|
34
37
|
useMemo,
|
|
35
38
|
useRef,
|
|
@@ -347,6 +350,27 @@ export interface MountCanvasOptions {
|
|
|
347
350
|
commentsEnabled: boolean;
|
|
348
351
|
}
|
|
349
352
|
|
|
353
|
+
// The lite provider tree wrapping a canvas component (tool + selection +
|
|
354
|
+
// comment layer). Pulled out so both the live render and the error-fallback
|
|
355
|
+
// render build the identical envelope.
|
|
356
|
+
//
|
|
357
|
+
// NB: the CanvasActivityProvider (Phase 13 / DDR-029) is NOT mounted here — it
|
|
358
|
+
// lives inside `DesignCanvas` (canvas-lib). comment-mount.js and canvas-lib are
|
|
359
|
+
// SEPARATE bundles, so a context provided here would be a different instance
|
|
360
|
+
// from the one DCArtboard (canvas-lib) consumes. Same reasoning the real
|
|
361
|
+
// ToolProvider lives in DesignCanvas, not in this layer's MaybeToolProvider.
|
|
362
|
+
function buildCanvasTree(Canvas: ComponentType, file: string | undefined): ReactNode {
|
|
363
|
+
return createElement(
|
|
364
|
+
MaybeToolProvider,
|
|
365
|
+
null,
|
|
366
|
+
createElement(
|
|
367
|
+
MaybeSelectionSetProvider,
|
|
368
|
+
null,
|
|
369
|
+
createElement(CommentHost, { file }, createElement(Canvas))
|
|
370
|
+
)
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
350
374
|
export function mountCanvas(Canvas: ComponentType, opts: MountCanvasOptions): void {
|
|
351
375
|
const root = createRoot(opts.rootEl);
|
|
352
376
|
if (!opts.commentsEnabled) {
|
|
@@ -354,15 +378,146 @@ export function mountCanvas(Canvas: ComponentType, opts: MountCanvasOptions): vo
|
|
|
354
378
|
return;
|
|
355
379
|
}
|
|
356
380
|
const file = opts.file ?? deriveFile();
|
|
357
|
-
root.render(
|
|
381
|
+
root.render(createElement(CanvasHmrRuntime, { initialCanvas: Canvas, file }));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
385
|
+
// Phase 13.1 / DDR-077 — HMR error resilience during agent editing.
|
|
386
|
+
//
|
|
387
|
+
// When an agent (`/design:edit` / `/design:new`) live-edits a canvas, a
|
|
388
|
+
// half-saved file (missing import, undefined symbol, transpile error) used to
|
|
389
|
+
// blank the canvas to white: the shell did `location.reload()` straight into the
|
|
390
|
+
// broken module. The shell now soft-reloads (import-before-swap, see
|
|
391
|
+
// `templates/_shell.html`) so a build/import error never tears down the good
|
|
392
|
+
// render. This runtime closes the remaining gap — a *render-time* throw — by
|
|
393
|
+
// keeping the last good canvas mounted via an error boundary and surfacing a
|
|
394
|
+
// "holding last good" toast instead of a white screen. Strictly gated by the
|
|
395
|
+
// shell on agent-active; manual edits keep the plain reload (so this is inert
|
|
396
|
+
// for solo hand-editing). The runtime exposes its swap/hold API on
|
|
397
|
+
// `window.__maudeCanvasRuntime` (the same window-handshake style the shell
|
|
398
|
+
// already uses for `__canvas_rel__` etc.).
|
|
399
|
+
|
|
400
|
+
export interface CanvasRuntimeApi {
|
|
401
|
+
/** Swap in a freshly-imported canvas module's default export (success path). */
|
|
402
|
+
remount: (next: ComponentType) => void;
|
|
403
|
+
/** Show/hide the "holding last good" toast (build-error path from the shell). */
|
|
404
|
+
setHolding: (on: boolean, message?: string) => void;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const RUNTIME_KEY = '__maudeCanvasRuntime';
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Fires `onOk` only when its subtree COMMITS — i.e. renders without throwing. A
|
|
411
|
+
* render-time throw in the canvas unwinds to the boundary before this commits,
|
|
412
|
+
* so `onOk` never runs and `lastGood` is not advanced to a broken module.
|
|
413
|
+
*/
|
|
414
|
+
function OkSignal({
|
|
415
|
+
Canvas,
|
|
416
|
+
file,
|
|
417
|
+
onOk,
|
|
418
|
+
}: {
|
|
419
|
+
Canvas: ComponentType;
|
|
420
|
+
file: string | undefined;
|
|
421
|
+
onOk: () => void;
|
|
422
|
+
}) {
|
|
423
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: fire once per mount (key=attempt remounts this).
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
onOk();
|
|
426
|
+
}, []);
|
|
427
|
+
return buildCanvasTree(Canvas, file);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export class CanvasErrorBoundary extends Component<
|
|
431
|
+
{
|
|
432
|
+
attempt: number;
|
|
433
|
+
onError: () => void;
|
|
434
|
+
fallback: () => ReactNode;
|
|
435
|
+
children: ReactNode;
|
|
436
|
+
},
|
|
437
|
+
{ hasError: boolean }
|
|
438
|
+
> {
|
|
439
|
+
state = { hasError: false };
|
|
440
|
+
static getDerivedStateFromError(): { hasError: boolean } {
|
|
441
|
+
return { hasError: true };
|
|
442
|
+
}
|
|
443
|
+
componentDidCatch(): void {
|
|
444
|
+
this.props.onError();
|
|
445
|
+
}
|
|
446
|
+
componentDidUpdate(prev: { attempt: number }): void {
|
|
447
|
+
// A new canvas (new attempt) arrived → clear the error and try rendering it.
|
|
448
|
+
if (prev.attempt !== this.props.attempt && this.state.hasError) {
|
|
449
|
+
this.setState({ hasError: false });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
render(): ReactNode {
|
|
453
|
+
if (this.state.hasError) return this.props.fallback();
|
|
454
|
+
return this.props.children;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function HmrHoldingToast({ message }: { message?: string }): ReactNode {
|
|
459
|
+
return createElement(
|
|
460
|
+
'div',
|
|
461
|
+
{
|
|
462
|
+
className: 'dc-hmr-holding',
|
|
463
|
+
role: 'status',
|
|
464
|
+
'aria-live': 'polite',
|
|
465
|
+
title: message ? `Holding last working render — ${message}` : 'Holding last working render',
|
|
466
|
+
},
|
|
467
|
+
'⏸ build error — držím poslední funkční verzi'
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function CanvasHmrRuntime({
|
|
472
|
+
initialCanvas,
|
|
473
|
+
file,
|
|
474
|
+
}: {
|
|
475
|
+
initialCanvas: ComponentType;
|
|
476
|
+
file: string | undefined;
|
|
477
|
+
}): ReactNode {
|
|
478
|
+
const [{ canvas, attempt }, setCanvasState] = useState({ canvas: initialCanvas, attempt: 0 });
|
|
479
|
+
const [holding, setHoldingState] = useState<{ on: boolean; message?: string }>({ on: false });
|
|
480
|
+
const lastGood = useRef<ComponentType | null>(null);
|
|
481
|
+
const canvasRef = useRef(canvas);
|
|
482
|
+
canvasRef.current = canvas;
|
|
483
|
+
|
|
484
|
+
// Publish the runtime API for the shell HMR client.
|
|
485
|
+
useEffect(() => {
|
|
486
|
+
const api: CanvasRuntimeApi = {
|
|
487
|
+
remount: (next) => setCanvasState((s) => ({ canvas: next, attempt: s.attempt + 1 })),
|
|
488
|
+
setHolding: (on, message) => setHoldingState(on ? { on: true, message } : { on: false }),
|
|
489
|
+
};
|
|
490
|
+
(window as unknown as Record<string, unknown>)[RUNTIME_KEY] = api;
|
|
491
|
+
return () => {
|
|
492
|
+
(window as unknown as Record<string, unknown>)[RUNTIME_KEY] = undefined;
|
|
493
|
+
};
|
|
494
|
+
}, []);
|
|
495
|
+
|
|
496
|
+
const handleOk = useCallback(() => {
|
|
497
|
+
lastGood.current = canvasRef.current;
|
|
498
|
+
// The new canvas rendered clean → drop any holding state.
|
|
499
|
+
setHoldingState((h) => (h.on ? { on: false } : h));
|
|
500
|
+
}, []);
|
|
501
|
+
|
|
502
|
+
const handleError = useCallback(() => {
|
|
503
|
+
setHoldingState({ on: true, message: 'render error' });
|
|
504
|
+
}, []);
|
|
505
|
+
|
|
506
|
+
const fallback = useCallback((): ReactNode => {
|
|
507
|
+
const LG = lastGood.current;
|
|
508
|
+
return LG ? buildCanvasTree(LG, file) : null;
|
|
509
|
+
}, [file]);
|
|
510
|
+
|
|
511
|
+
return createElement(
|
|
512
|
+
Fragment,
|
|
513
|
+
null,
|
|
358
514
|
createElement(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
)
|
|
515
|
+
CanvasErrorBoundary,
|
|
516
|
+
{ attempt, onError: handleError, fallback },
|
|
517
|
+
// key=attempt → each soft-reload remounts a fresh subtree (and resets the
|
|
518
|
+
// boundary's child), matching the clean-slate semantics of a full reload.
|
|
519
|
+
createElement(OkSignal, { key: attempt, Canvas: canvas, file, onOk: handleOk })
|
|
520
|
+
),
|
|
521
|
+
holding.on ? createElement(HmrHoldingToast, { message: holding.message }) : null
|
|
367
522
|
);
|
|
368
523
|
}
|
|
@@ -90,12 +90,19 @@ import {
|
|
|
90
90
|
useReducedMotion as _useReducedMotion,
|
|
91
91
|
} from 'motion/react';
|
|
92
92
|
|
|
93
|
+
import { ArtboardActivityOverlay } from './artboard-activity-overlay.tsx';
|
|
93
94
|
import { CanvasShell } from './canvas-shell.tsx';
|
|
94
95
|
import {
|
|
95
96
|
buildMoveArtboardsRecord,
|
|
96
97
|
diffLayoutPositions,
|
|
97
98
|
} from './commands/move-artboards-command.ts';
|
|
99
|
+
import { AgentPresenceProvider, useAgentPresence } from './use-agent-presence.tsx';
|
|
98
100
|
import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
|
|
101
|
+
import {
|
|
102
|
+
CanvasActivityProvider,
|
|
103
|
+
matchesArtboard,
|
|
104
|
+
useCanvasActivity,
|
|
105
|
+
} from './use-canvas-activity.tsx';
|
|
99
106
|
import { CollabProvider, canvasSlugFromPath } from './use-collab.tsx';
|
|
100
107
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
101
108
|
import { MaybeToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
|
|
@@ -1192,9 +1199,22 @@ export function DesignCanvas(props: DesignCanvasProps) {
|
|
|
1192
1199
|
</MaybeToolProvider>
|
|
1193
1200
|
);
|
|
1194
1201
|
return (
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1202
|
+
// Phase 13 / DDR-029 — the activity context MUST be provided here (canvas-lib
|
|
1203
|
+
// bundle) so DCArtboard's `useCanvasActivity()` reads the SAME context
|
|
1204
|
+
// instance. Providing it from comment-mount.js (a separate bundle) would
|
|
1205
|
+
// create a second ActivityContext the consumer never sees. `canvasFile` is
|
|
1206
|
+
// normalized to the server's design-root-relative activity key inside the
|
|
1207
|
+
// provider (falls back to window.__canvas_rel__).
|
|
1208
|
+
<CanvasActivityProvider file={canvasFile}>
|
|
1209
|
+
{/* Phase 13.2 / DDR-078 — agent presence. Same canvas-lib-bundle rule as
|
|
1210
|
+
the activity context: provided here so DCArtboard + ParticipantsChrome
|
|
1211
|
+
(both canvas-lib) read one context instance. */}
|
|
1212
|
+
<AgentPresenceProvider file={canvasFile}>
|
|
1213
|
+
<UndoStackProvider canvasFile={canvasFile}>
|
|
1214
|
+
{collabSlug ? <CollabProvider slug={collabSlug}>{inner}</CollabProvider> : inner}
|
|
1215
|
+
</UndoStackProvider>
|
|
1216
|
+
</AgentPresenceProvider>
|
|
1217
|
+
</CanvasActivityProvider>
|
|
1198
1218
|
);
|
|
1199
1219
|
}
|
|
1200
1220
|
DesignCanvas.displayName = 'DesignCanvas';
|
|
@@ -1510,6 +1530,12 @@ export function DCArtboard({
|
|
|
1510
1530
|
const toolMode = useToolModeOptional();
|
|
1511
1531
|
const selSet = useSelectionSetOptional();
|
|
1512
1532
|
const dragBus = useDragStateContext();
|
|
1533
|
+
// Phase 13 / DDR-029 — live "agent works here" overlay. Inert (present=false)
|
|
1534
|
+
// outside CanvasActivityProvider, so specimens / legacy mounts never show it.
|
|
1535
|
+
const activity = useCanvasActivity();
|
|
1536
|
+
// Phase 13.2 / DDR-078 — when an agent is the editor, tint the overlay with
|
|
1537
|
+
// its presence color + show its funny name instead of the generic file label.
|
|
1538
|
+
const agent = useAgentPresence();
|
|
1513
1539
|
const rect = ctx ? ctx.rectFor(id) : null;
|
|
1514
1540
|
|
|
1515
1541
|
// Drag hook — always called (hook rules). Inert outside DesignCanvas
|
|
@@ -1583,19 +1609,40 @@ export function DCArtboard({
|
|
|
1583
1609
|
|
|
1584
1610
|
const handleProps = dragHook.bindHandle();
|
|
1585
1611
|
|
|
1612
|
+
// Phase 13 — overlay when THIS canvas is active and the change scope includes
|
|
1613
|
+
// this artboard (null scope = file-level = every artboard). Rendered as a
|
|
1614
|
+
// world-coord sibling of the <article> so it pans/zooms with the artboard.
|
|
1615
|
+
const showActivity = activity.present && matchesArtboard(activity.artboardIds, id);
|
|
1616
|
+
// Agent-driven → funny name + agent color; manual edit → file label + default hue.
|
|
1617
|
+
const activityLabel = agent
|
|
1618
|
+
? agent.name
|
|
1619
|
+
: activity.artboardIds
|
|
1620
|
+
? `${activity.fileLabel}:${label}`
|
|
1621
|
+
: activity.fileLabel;
|
|
1622
|
+
|
|
1586
1623
|
return (
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
{label}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1624
|
+
<>
|
|
1625
|
+
<article
|
|
1626
|
+
className={`dc-artboard dc-positioned${isInDrag ? ' dc-dragging' : ''}`}
|
|
1627
|
+
data-dc-screen={id}
|
|
1628
|
+
aria-current={isActive ? 'true' : undefined}
|
|
1629
|
+
style={{ left: liveX, top: liveY, width: rect.w, height: rect.h }}
|
|
1630
|
+
{...handleProps}
|
|
1631
|
+
>
|
|
1632
|
+
<button type="button" className="dc-artboard-label sku" aria-label={`Artboard ${label}`}>
|
|
1633
|
+
{label}
|
|
1634
|
+
</button>
|
|
1635
|
+
<div className="dc-artboard-body">{children}</div>
|
|
1636
|
+
</article>
|
|
1637
|
+
{showActivity ? (
|
|
1638
|
+
<ArtboardActivityOverlay
|
|
1639
|
+
rect={{ x: liveX, y: liveY, w: rect.w, h: rect.h }}
|
|
1640
|
+
label={activityLabel}
|
|
1641
|
+
active={activity.active}
|
|
1642
|
+
color={agent?.color}
|
|
1643
|
+
/>
|
|
1644
|
+
) : null}
|
|
1645
|
+
</>
|
|
1599
1646
|
);
|
|
1600
1647
|
}
|
|
1601
1648
|
DCArtboard.displayName = 'DCArtboard';
|
|
@@ -206,7 +206,8 @@
|
|
|
206
206
|
},
|
|
207
207
|
"syncTsx": {
|
|
208
208
|
"type": "boolean",
|
|
209
|
-
"
|
|
209
|
+
"default": true,
|
|
210
|
+
"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."
|
|
210
211
|
}
|
|
211
212
|
},
|
|
212
213
|
"additionalProperties": false
|