@1agh/maude 0.60.4 → 0.60.6
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/annotations-layer.tsx +21 -12
- package/apps/studio/api.ts +54 -5
- package/apps/studio/build.ts +10 -0
- package/apps/studio/canvas-comment-mount.tsx +7 -1
- package/apps/studio/canvas-icons.tsx +30 -2
- package/apps/studio/canvas-lib.tsx +41 -6
- package/apps/studio/canvas-shell.tsx +39 -43
- package/apps/studio/client/app.jsx +20 -13
- package/apps/studio/dist/client.bundle.js +1 -1
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dom-selection.ts +67 -0
- package/apps/studio/hmr-broadcast.ts +40 -2
- package/apps/studio/input-router.tsx +26 -2
- package/apps/studio/sync/agent.ts +64 -13
- package/apps/studio/sync/codec.ts +47 -0
- package/apps/studio/sync/cold-start.ts +96 -0
- package/apps/studio/sync/migrate-seed.ts +52 -7
- package/apps/studio/sync/projection.ts +10 -1
- package/apps/studio/test/canvas-meta-api.test.ts +114 -3
- package/apps/studio/test/canvas-wheel-delta.test.ts +42 -0
- package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
- package/apps/studio/test/comment-mount.test.ts +10 -1
- package/apps/studio/test/dom-selection.test.ts +69 -1
- package/apps/studio/test/hmr-broadcast.test.ts +6 -4
- package/apps/studio/test/hmr-classify.test.ts +18 -1
- package/apps/studio/test/input-router.test.ts +69 -0
- package/apps/studio/test/mode-toggle.test.tsx +226 -0
- package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
- package/apps/studio/test/use-tool-mode.test.tsx +33 -3
- package/apps/studio/tool-palette.tsx +86 -24
- package/apps/studio/use-tool-mode.tsx +113 -14
- package/apps/studio/whats-new.json +45 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +31 -0
|
@@ -186,6 +186,25 @@ export function annotationsFromDoc(doc: Y.Doc): string | null {
|
|
|
186
186
|
return typeof svg === 'string' ? svg : null;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* True when an annotations value carries ZERO strokes: null, `''`, or the bare
|
|
191
|
+
* serialized wrapper `<svg …></svg>` with no child elements (what
|
|
192
|
+
* `strokesToSvg([])` emits — 72 bytes, constant across peers).
|
|
193
|
+
*
|
|
194
|
+
* This distinction is load-bearing for cold start (the 2026-08-14 annotations
|
|
195
|
+
* eraser): the wrapper is a non-empty STRING, so every `!== ''` emptiness
|
|
196
|
+
* guard let a stale hub wrapper overwrite a peer's real strokes — and with the
|
|
197
|
+
* strokes went the `assets/<sha8>` references `asset-pull` scans, so freshly
|
|
198
|
+
* dropped images never crossed machines. Live delete-all still materializes
|
|
199
|
+
* the wrapper through `writeAnnotationsIfChanged` (deletes must propagate);
|
|
200
|
+
* only COLD-START decisions treat it as emptiness.
|
|
201
|
+
*/
|
|
202
|
+
export function isEmptyAnnotationsSvg(svg: string | null): boolean {
|
|
203
|
+
if (svg === null) return true;
|
|
204
|
+
if (svg.trim() === '') return true;
|
|
205
|
+
return /^\s*<svg\b[^>]*>\s*<\/svg>\s*$/i.test(svg);
|
|
206
|
+
}
|
|
207
|
+
|
|
189
208
|
export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?: unknown): boolean {
|
|
190
209
|
if (next !== null && byteLengthUtf8(next) > MAX_ANNOTATIONS_BYTES) {
|
|
191
210
|
console.warn(
|
|
@@ -344,6 +363,34 @@ export function bodyEditAtFromDoc(doc: Y.Doc): number | null {
|
|
|
344
363
|
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
345
364
|
}
|
|
346
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Stamp `syncMeta.annotationsEditAt` — the annotations lane's own newest-wins
|
|
368
|
+
* timestamp (extends DDR-102's `bodyEditAt` per-lane). Call in the SAME
|
|
369
|
+
* transaction + origin as every local→doc annotations apply (agent applyFromFs
|
|
370
|
+
* annotations branch, cold-start local-wins seed, adopt) so peers receive ONE
|
|
371
|
+
* update. Before this stamp existed, cold start resolved annotations by the
|
|
372
|
+
* BODY winner — but annotation edits don't move the body's edit time, so a
|
|
373
|
+
* hub with a newer body and a stale (empty) annotations lane erased newer
|
|
374
|
+
* local strokes (the 2026-08-14 annotations eraser).
|
|
375
|
+
*
|
|
376
|
+
* `nowMs` override: cold-start seeding passes the local FILE's mtime so stale
|
|
377
|
+
* content can't claim apply-time freshness.
|
|
378
|
+
*/
|
|
379
|
+
export function stampAnnotationsEdit(doc: Y.Doc, origin?: unknown, nowMs?: number): void {
|
|
380
|
+
const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
|
|
381
|
+
doc.transact(() => {
|
|
382
|
+
map.set('annotationsEditAt', nowMs ?? Date.now());
|
|
383
|
+
}, origin);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The doc-side annotations-edit stamp, or null when no peer ever stamped
|
|
387
|
+
* (pre-annotationsEditAt docs → callers fall back to the body-winner
|
|
388
|
+
* coupling, interop-safe). */
|
|
389
|
+
export function annotationsEditAtFromDoc(doc: Y.Doc): number | null {
|
|
390
|
+
const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('annotationsEditAt');
|
|
391
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
347
394
|
/**
|
|
348
395
|
* Record THIS doc's clientID as the seeder in `syncMeta.seededBy` (F1). Call it
|
|
349
396
|
* in the SAME transaction + origin as a `seed-local-up` body apply. The value is
|
|
@@ -196,3 +196,99 @@ export function decideColdStart(input: ColdStartInput): ColdStartDecision {
|
|
|
196
196
|
: 'diverged — timestamps tied, falling back to hub-wins (recoverable: both sides snapshotted)',
|
|
197
197
|
};
|
|
198
198
|
}
|
|
199
|
+
|
|
200
|
+
/* ------------------------------------------------- annotations (per-lane) */
|
|
201
|
+
|
|
202
|
+
export interface AnnotationsColdStartInput {
|
|
203
|
+
/** Local `.annotations.svg` content, or null when the file doesn't exist. */
|
|
204
|
+
local: string | null;
|
|
205
|
+
/** Annotations currently held by the doc (`''` when the lane is unset). */
|
|
206
|
+
doc: string;
|
|
207
|
+
/** True when the value carries zero strokes — callers pass
|
|
208
|
+
* `isEmptyAnnotationsSvg` (codec.ts) so this table stays Y-free. */
|
|
209
|
+
isEmpty: (svg: string | null) => boolean;
|
|
210
|
+
/** Local annotations file mtime (ms epoch), or null when unavailable. */
|
|
211
|
+
localMtimeMs: number | null;
|
|
212
|
+
/** Doc-side syncMeta.annotationsEditAt stamp, or null when no peer ever
|
|
213
|
+
* stamped (pre-stamp interop). */
|
|
214
|
+
docEditAtMs: number | null;
|
|
215
|
+
/** The body lane's resolved winner — the legacy coupling, used only as the
|
|
216
|
+
* fallback when both sides are non-empty and neither is stamped. */
|
|
217
|
+
bodyWinner: 'local' | 'hub';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface AnnotationsColdStartDecision {
|
|
221
|
+
winner: 'local' | 'hub' | 'none';
|
|
222
|
+
reason: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Per-lane cold-start resolution for annotations (extends DDR-102 to the
|
|
227
|
+
* annotations lane; the 2026-08-14 annotations eraser).
|
|
228
|
+
*
|
|
229
|
+
* Annotations used to blindly follow the body winner — but annotation edits
|
|
230
|
+
* don't move the body's edit time, so a hub with a newer body and a STALE
|
|
231
|
+
* (empty-wrapper) annotations lane erased newer local strokes on every cold
|
|
232
|
+
* start, and with the strokes went the `assets/<sha8>` references the asset
|
|
233
|
+
* pull scans. The one load-bearing rule here: **unstamped emptiness never
|
|
234
|
+
* beats content.** A STAMPED emptiness that is provably newer is a deliberate
|
|
235
|
+
* delete-all and is honored; everything else prefers the side with strokes.
|
|
236
|
+
*/
|
|
237
|
+
export function decideAnnotationsColdStart(
|
|
238
|
+
input: AnnotationsColdStartInput
|
|
239
|
+
): AnnotationsColdStartDecision {
|
|
240
|
+
const { local, doc, isEmpty, localMtimeMs, docEditAtMs, bodyWinner } = input;
|
|
241
|
+
const localEmpty = isEmpty(local);
|
|
242
|
+
const docEmpty = isEmpty(doc === '' ? null : doc);
|
|
243
|
+
|
|
244
|
+
if (localEmpty && docEmpty) return { winner: 'none', reason: 'both sides empty' };
|
|
245
|
+
|
|
246
|
+
if (docEmpty && !localEmpty) {
|
|
247
|
+
// A stamped hub emptiness NEWER than the local file is a deliberate
|
|
248
|
+
// delete-all made while this peer was offline — honor it.
|
|
249
|
+
if (docEditAtMs !== null && localMtimeMs !== null && docEditAtMs > localMtimeMs) {
|
|
250
|
+
return {
|
|
251
|
+
winner: 'hub',
|
|
252
|
+
reason: `hub delete-all is newer than local strokes (annotationsEditAt ${new Date(docEditAtMs).toISOString()} > local mtime ${new Date(localMtimeMs).toISOString()})`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
winner: 'local',
|
|
257
|
+
reason:
|
|
258
|
+
'hub annotations are empty but local has strokes — keeping local + seeding it up (unstamped emptiness never beats content)',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!docEmpty && localEmpty) {
|
|
263
|
+
// Symmetric: a local delete-all (empty wrapper on disk) newer than the
|
|
264
|
+
// doc's stamp is honored; an absent/stale local file materializes the hub.
|
|
265
|
+
if (
|
|
266
|
+
local !== null &&
|
|
267
|
+
localMtimeMs !== null &&
|
|
268
|
+
docEditAtMs !== null &&
|
|
269
|
+
localMtimeMs > docEditAtMs
|
|
270
|
+
) {
|
|
271
|
+
return {
|
|
272
|
+
winner: 'local',
|
|
273
|
+
reason: `local delete-all is newer than hub strokes (local mtime ${new Date(localMtimeMs).toISOString()} > annotationsEditAt ${new Date(docEditAtMs).toISOString()})`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return { winner: 'hub', reason: 'local annotations empty/absent — materializing hub strokes' };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Both non-empty.
|
|
280
|
+
if (doc === local) return { winner: 'none', reason: 'both sides equal' };
|
|
281
|
+
if (docEditAtMs !== null && localMtimeMs !== null && docEditAtMs !== localMtimeMs) {
|
|
282
|
+
const winner = localMtimeMs > docEditAtMs ? 'local' : 'hub';
|
|
283
|
+
return {
|
|
284
|
+
winner,
|
|
285
|
+
reason: `diverged — newest wins: local mtime ${new Date(localMtimeMs).toISOString()} ${
|
|
286
|
+
winner === 'local' ? '>' : '<'
|
|
287
|
+
} doc annotationsEditAt ${new Date(docEditAtMs).toISOString()}`,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
winner: bodyWinner,
|
|
292
|
+
reason: `diverged — no per-lane stamp on one side, following the body winner (${bodyWinner})`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -36,16 +36,19 @@ import type * as Y from 'yjs';
|
|
|
36
36
|
|
|
37
37
|
import { Y_TYPES } from '../collab/persistence.ts';
|
|
38
38
|
import {
|
|
39
|
+
annotationsEditAtFromDoc,
|
|
39
40
|
applyAnnotationsToDoc,
|
|
40
41
|
applyCommentsToDoc,
|
|
41
42
|
applyCssToDoc,
|
|
42
43
|
applyHtmlToDoc,
|
|
43
44
|
applyMetaToDoc,
|
|
44
45
|
bodyEditAtFromDoc,
|
|
46
|
+
isEmptyAnnotationsSvg,
|
|
47
|
+
stampAnnotationsEdit,
|
|
45
48
|
stampBodyEdit,
|
|
46
49
|
Y_SYNC_TYPES,
|
|
47
50
|
} from './codec.ts';
|
|
48
|
-
import { decideColdStart, unionCommentsById } from './cold-start.ts';
|
|
51
|
+
import { decideAnnotationsColdStart, decideColdStart, unionCommentsById } from './cold-start.ts';
|
|
49
52
|
import { hashBytes } from './echo-guard.ts';
|
|
50
53
|
import type { SyncJournal } from './journal.ts';
|
|
51
54
|
import { ORIGINS } from './origins.ts';
|
|
@@ -133,7 +136,17 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
133
136
|
const parsed = tryParseJsonArray(localComments);
|
|
134
137
|
if (parsed) applyCommentsToDoc(doc, parsed, ORIGINS.MIGRATION);
|
|
135
138
|
}
|
|
136
|
-
if (localAnnotations)
|
|
139
|
+
if (localAnnotations) {
|
|
140
|
+
if (applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION)) {
|
|
141
|
+
// Stamp with the FILE's mtime — adopt seeds pre-existing content,
|
|
142
|
+
// which must not claim apply-time freshness over a newer peer.
|
|
143
|
+
stampAnnotationsEdit(
|
|
144
|
+
doc,
|
|
145
|
+
ORIGINS.MIGRATION,
|
|
146
|
+
localMtimeMs(paths.annotations) ?? undefined
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
137
150
|
if (paths.meta && localMeta) applyMetaToDoc(doc, localMeta, ORIGINS.MIGRATION);
|
|
138
151
|
if (paths.css && localCss) applyCssToDoc(doc, localCss, ORIGINS.MIGRATION);
|
|
139
152
|
}, ORIGINS.MIGRATION);
|
|
@@ -164,17 +177,16 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
164
177
|
|
|
165
178
|
let result: MigrateSeedResult = 'hub-wins';
|
|
166
179
|
|
|
167
|
-
/** Rebuild body (+ visually-coupled css
|
|
168
|
-
*
|
|
180
|
+
/** Rebuild body (+ visually-coupled css) from local, in ONE MIGRATION
|
|
181
|
+
* transaction — one source per type, never a two-history merge.
|
|
182
|
+
* Annotations are deliberately NOT here any more: they resolve per-lane
|
|
183
|
+
* below (decideAnnotationsColdStart), independent of the body winner. */
|
|
169
184
|
const rebuildBodyFromLocal = (): void => {
|
|
170
185
|
doc.transact(() => {
|
|
171
186
|
if (applyHtmlToDoc(doc, localHtml as string, ORIGINS.MIGRATION)) {
|
|
172
187
|
stampBodyEdit(doc, ORIGINS.MIGRATION);
|
|
173
188
|
}
|
|
174
189
|
if (paths.css && localCss !== null) applyCssToDoc(doc, localCss, ORIGINS.MIGRATION);
|
|
175
|
-
if (localAnnotations !== null) {
|
|
176
|
-
applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION);
|
|
177
|
-
}
|
|
178
190
|
}, ORIGINS.MIGRATION);
|
|
179
191
|
opts.journal?.record(slug, {
|
|
180
192
|
bodyHash: hashBytes(localHtml as string),
|
|
@@ -246,6 +258,39 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
246
258
|
}
|
|
247
259
|
}
|
|
248
260
|
|
|
261
|
+
// ---- annotations: PER-LANE newest-wins (the 2026-08-14 eraser fix; the
|
|
262
|
+
// same table as agent.ts reconcile). Under sharedDoc the collab room's
|
|
263
|
+
// persistJson materializes the doc's annotations to disk, so a stale hub
|
|
264
|
+
// EMPTY WRAPPER (non-'' but zero strokes) in the doc erased local strokes
|
|
265
|
+
// right after this seed ran. Resolve the lane here, before the room
|
|
266
|
+
// materializes: unstamped emptiness never beats content.
|
|
267
|
+
{
|
|
268
|
+
const docSvg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
|
|
269
|
+
const docAnnotations = typeof docSvg === 'string' ? docSvg : '';
|
|
270
|
+
const annDecision = decideAnnotationsColdStart({
|
|
271
|
+
local: localAnnotations,
|
|
272
|
+
doc: docAnnotations,
|
|
273
|
+
isEmpty: isEmptyAnnotationsSvg,
|
|
274
|
+
localMtimeMs: localMtimeMs(paths.annotations),
|
|
275
|
+
docEditAtMs: annotationsEditAtFromDoc(doc),
|
|
276
|
+
bodyWinner: result === 'body-seed-up' || result === 'conflict-local-wins' ? 'local' : 'hub',
|
|
277
|
+
});
|
|
278
|
+
if (annDecision.winner === 'local' && localAnnotations !== null) {
|
|
279
|
+
console.warn(`[sync/${slug}] shared-doc cold-start annotations: ${annDecision.reason}`);
|
|
280
|
+
doc.transact(() => {
|
|
281
|
+
if (applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION)) {
|
|
282
|
+
stampAnnotationsEdit(
|
|
283
|
+
doc,
|
|
284
|
+
ORIGINS.MIGRATION,
|
|
285
|
+
localMtimeMs(paths.annotations) ?? undefined
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}, ORIGINS.MIGRATION);
|
|
289
|
+
}
|
|
290
|
+
// winner 'hub' → nothing to write here: the collab room owns the
|
|
291
|
+
// annotations doc→file half and materializes it (persistJson).
|
|
292
|
+
}
|
|
293
|
+
|
|
249
294
|
// Comments id-union (DDR-102): rebuild from merged JSON via the
|
|
250
295
|
// delete-then-insert codec — same-id entries keep the doc's version, so the
|
|
251
296
|
// duplication trap stays closed; local-only comments survive.
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
htmlFromDoc,
|
|
40
40
|
mergeSharedMetaIntoLocal,
|
|
41
41
|
metaFromDoc,
|
|
42
|
+
stampAnnotationsEdit,
|
|
42
43
|
stampBodyEdit,
|
|
43
44
|
} from './codec.ts';
|
|
44
45
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
@@ -354,7 +355,15 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
354
355
|
}
|
|
355
356
|
if (evt.path === paths.annotations) {
|
|
356
357
|
clearStrike(evt.path);
|
|
357
|
-
|
|
358
|
+
// Apply + per-lane stamp in ONE transaction (mirrors agent.applyFromFs)
|
|
359
|
+
// so a deliberate delete-all (empty wrapper) carries its freshness and
|
|
360
|
+
// survives cold start on other peers (the 2026-08-14 eraser fix).
|
|
361
|
+
let changed = false;
|
|
362
|
+
doc.transact(() => {
|
|
363
|
+
changed = applyAnnotationsToDoc(doc, str, importOrigin);
|
|
364
|
+
if (changed) stampAnnotationsEdit(doc, importOrigin);
|
|
365
|
+
}, importOrigin);
|
|
366
|
+
return changed;
|
|
358
367
|
}
|
|
359
368
|
return false;
|
|
360
369
|
}
|
|
@@ -115,7 +115,7 @@ describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
|
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
test('PATCH clamps zoom to [0.
|
|
118
|
+
test('PATCH clamps zoom to [0.02, 4.0]', async () => {
|
|
119
119
|
const { root, designRoot } = makeSandbox();
|
|
120
120
|
const port = nextPort();
|
|
121
121
|
const proc = await bootServer(root, port);
|
|
@@ -137,10 +137,45 @@ describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
|
|
|
137
137
|
const r2 = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
138
138
|
method: 'PATCH',
|
|
139
139
|
headers: { 'content-type': 'application/json' },
|
|
140
|
-
body: JSON.stringify({ file, patch: { viewport: { x: 0, y: 0, zoom: 0.
|
|
140
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 0, y: 0, zoom: 0.0001 } } }),
|
|
141
141
|
});
|
|
142
142
|
const m2 = (await r2.json()) as MetaShape;
|
|
143
|
-
expect(m2.viewport?.zoom).toBe(0.
|
|
143
|
+
expect(m2.viewport?.zoom).toBe(0.02);
|
|
144
|
+
} finally {
|
|
145
|
+
await killProc(proc);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Issue #91 — a board wider than ~10x the viewport needs a zoom below the
|
|
150
|
+
// OLD 0.1 floor to ever be framed whole ("fit to screen") or dragged across
|
|
151
|
+
// in one motion. Before the fix, this value was silently re-clamped up to
|
|
152
|
+
// 0.1 on save, so even a client that could reach 0.05 lost it on reload.
|
|
153
|
+
test('PATCH preserves a deep zoom-out that the old [0.1, 4.0] floor used to clamp away (issue #91)', async () => {
|
|
154
|
+
const { root, designRoot } = makeSandbox();
|
|
155
|
+
const port = nextPort();
|
|
156
|
+
const proc = await bootServer(root, port);
|
|
157
|
+
try {
|
|
158
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
159
|
+
const tsxAbs = join(designRoot, 'ui', 'DeepZoom.tsx');
|
|
160
|
+
writeFileSync(tsxAbs, 'export default function D(){return <main/>}\n');
|
|
161
|
+
writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), '{"title":"DeepZoom"}');
|
|
162
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
163
|
+
|
|
164
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
165
|
+
method: 'PATCH',
|
|
166
|
+
headers: { 'content-type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 0, y: 0, zoom: 0.05 } } }),
|
|
168
|
+
});
|
|
169
|
+
const m = (await r.json()) as MetaShape;
|
|
170
|
+
expect(m.viewport?.zoom).toBe(0.05);
|
|
171
|
+
|
|
172
|
+
// Round-trips through a fresh GET too (the persisted view file, not just
|
|
173
|
+
// the PATCH response) — this is what a page reload would read back.
|
|
174
|
+
const g = await fetch(
|
|
175
|
+
`http://localhost:${port}/_api/canvas-meta?file=${encodeURIComponent(file)}`
|
|
176
|
+
);
|
|
177
|
+
const gm = (await g.json()) as MetaShape;
|
|
178
|
+
expect(gm.viewport?.zoom).toBe(0.05);
|
|
144
179
|
} finally {
|
|
145
180
|
await killProc(proc);
|
|
146
181
|
}
|
|
@@ -317,6 +352,82 @@ describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
|
|
|
317
352
|
}
|
|
318
353
|
});
|
|
319
354
|
|
|
355
|
+
// Security follow-up to issue #91 — `layout.artboards[]` is reachable from
|
|
356
|
+
// the untrusted canvas origin (DDR-054) and previously had no count/magnitude
|
|
357
|
+
// bound. Now that the zoom-floor fix lets `fit()` correctly frame whatever
|
|
358
|
+
// bounding box a synced layout describes, an unbounded artboard count could
|
|
359
|
+
// promote hundreds/thousands of simultaneous GPU layers on the viewer's
|
|
360
|
+
// client (a DoS). Caps mirror the existing overlays/locked lanes.
|
|
361
|
+
test('PATCH layout rejects an oversized artboards array as a silent no-op', async () => {
|
|
362
|
+
const { root, designRoot } = makeSandbox();
|
|
363
|
+
const port = nextPort();
|
|
364
|
+
const proc = await bootServer(root, port);
|
|
365
|
+
try {
|
|
366
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
367
|
+
const tsxAbs = join(designRoot, 'ui', 'Oversized.tsx');
|
|
368
|
+
writeFileSync(tsxAbs, 'export default function O(){return <main/>}\n');
|
|
369
|
+
const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
|
|
370
|
+
writeFileSync(metaAbs, JSON.stringify({ title: 'Oversized', sections: [] }));
|
|
371
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
372
|
+
|
|
373
|
+
// Seed a valid, small layout first.
|
|
374
|
+
await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
375
|
+
method: 'PATCH',
|
|
376
|
+
headers: { 'content-type': 'application/json' },
|
|
377
|
+
body: JSON.stringify({ file, patch: { layout: { artboards: [{ id: 'a', x: 0, y: 0 }] } } }),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// A crafted, oversized layout (2001 > the 2000-entry cap) must not land.
|
|
381
|
+
const huge = Array.from({ length: 2001 }, (_, i) => ({ id: `a${i}`, x: i, y: 0 }));
|
|
382
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
383
|
+
method: 'PATCH',
|
|
384
|
+
headers: { 'content-type': 'application/json' },
|
|
385
|
+
body: JSON.stringify({ file, patch: { layout: { artboards: huge } } }),
|
|
386
|
+
});
|
|
387
|
+
expect(r.status).toBe(200);
|
|
388
|
+
const merged = (await r.json()) as MetaShape;
|
|
389
|
+
// Silent no-op — the prior small, valid layout is still what's live.
|
|
390
|
+
const arts = merged.layout?.artboards as Array<Record<string, unknown>> | undefined;
|
|
391
|
+
expect(arts).toEqual([{ id: 'a', x: 0, y: 0 }]);
|
|
392
|
+
|
|
393
|
+
const onDisk = JSON.parse(readFileSync(metaAbs, 'utf8')) as MetaShape;
|
|
394
|
+
const diskArts = onDisk.layout?.artboards as Array<Record<string, unknown>> | undefined;
|
|
395
|
+
expect(diskArts).toEqual([{ id: 'a', x: 0, y: 0 }]);
|
|
396
|
+
} finally {
|
|
397
|
+
await killProc(proc);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('PATCH layout rejects an out-of-range artboard coordinate as a silent no-op', async () => {
|
|
402
|
+
const { root, designRoot } = makeSandbox();
|
|
403
|
+
const port = nextPort();
|
|
404
|
+
const proc = await bootServer(root, port);
|
|
405
|
+
try {
|
|
406
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
407
|
+
const tsxAbs = join(designRoot, 'ui', 'OutOfRange.tsx');
|
|
408
|
+
writeFileSync(tsxAbs, 'export default function R(){return <main/>}\n');
|
|
409
|
+
writeFileSync(
|
|
410
|
+
tsxAbs.replace(/\.tsx$/, '.meta.json'),
|
|
411
|
+
JSON.stringify({ title: 'OutOfRange' })
|
|
412
|
+
);
|
|
413
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
414
|
+
|
|
415
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
416
|
+
method: 'PATCH',
|
|
417
|
+
headers: { 'content-type': 'application/json' },
|
|
418
|
+
body: JSON.stringify({
|
|
419
|
+
file,
|
|
420
|
+
patch: { layout: { artboards: [{ id: 'a', x: 50_000_000, y: 0 }] } },
|
|
421
|
+
}),
|
|
422
|
+
});
|
|
423
|
+
expect(r.status).toBe(200);
|
|
424
|
+
const merged = (await r.json()) as MetaShape;
|
|
425
|
+
expect(merged.layout).toBeUndefined();
|
|
426
|
+
} finally {
|
|
427
|
+
await killProc(proc);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
320
431
|
test('PATCH viewport on a non-existent canvas is refused (404) and mints no file (DDR-115 F-A2)', async () => {
|
|
321
432
|
const { root, designRoot } = makeSandbox();
|
|
322
433
|
const port = nextPort();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// canvas-wheel-delta — issue #94 dogfood report: mouse-wheel panning in the
|
|
2
|
+
// canvas is very slow. Root cause: `onWheel` fed raw `WheelEvent.deltaX`/
|
|
3
|
+
// `deltaY` straight into `panBy` as CSS pixels, but the Wheel Events spec
|
|
4
|
+
// lets a browser report deltas in pixel/line/page units (`deltaMode`).
|
|
5
|
+
// Physical mice on Firefox (and some browser/OS combos elsewhere) report
|
|
6
|
+
// LINE-mode deltas — a single integer like 3 per notch — which, read as
|
|
7
|
+
// pixels, panned the canvas only a few px per wheel notch. Trackpads report
|
|
8
|
+
// pixel-mode deltas already, so trackpad panning felt fine while mouse-wheel
|
|
9
|
+
// panning felt frozen. See .ai/logs/rca/issue-94-wheel-pan-slow.md.
|
|
10
|
+
//
|
|
11
|
+
// `wheelDeltaToPixels` is a pure function (no DOM needed) so this test
|
|
12
|
+
// doesn't need happy-dom, unlike canvas-zoom-floor.test.ts's computeFit case.
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
|
|
16
|
+
import { wheelDeltaToPixels } from '../canvas-lib.tsx';
|
|
17
|
+
|
|
18
|
+
describe('wheelDeltaToPixels — normalizes WheelEvent units (issue #94)', () => {
|
|
19
|
+
test('pixel mode (deltaMode 0) passes the value through unchanged', () => {
|
|
20
|
+
expect(wheelDeltaToPixels(100, 0, 800)).toBe(100);
|
|
21
|
+
expect(wheelDeltaToPixels(-42.5, 0, 800)).toBe(-42.5);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('line mode (deltaMode 1) scales a small per-notch delta up to a pixel-range step', () => {
|
|
25
|
+
// The regression: before the fix, `onWheel` fed the raw delta straight
|
|
26
|
+
// into `panBy`, so a line-mode notch of 3 panned only 3 CSS pixels —
|
|
27
|
+
// reproducing the "very slow" mouse-wheel report. This asserts the FIXED
|
|
28
|
+
// behavior: scaled up to a magnitude comparable to a trackpad's
|
|
29
|
+
// pixel-mode delta (~100 per gesture step). Pre-fix, `wheelDeltaToPixels`
|
|
30
|
+
// didn't exist and `onWheel` used the raw `3` — this assertion fails
|
|
31
|
+
// against that old code path (3 !== 48).
|
|
32
|
+
const raw = 3;
|
|
33
|
+
const scaled = wheelDeltaToPixels(raw, 1, 800);
|
|
34
|
+
expect(scaled).toBe(48);
|
|
35
|
+
expect(scaled).not.toBe(raw);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('page mode (deltaMode 2) scales by the supplied page size', () => {
|
|
39
|
+
expect(wheelDeltaToPixels(1, 2, 800)).toBe(800);
|
|
40
|
+
expect(wheelDeltaToPixels(-2, 2, 600)).toBe(-1200);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// canvas-zoom-floor — issue #91 dogfood report: dragging an artboard across a
|
|
2
|
+
// large board, the user can't zoom out far enough to complete the move in one
|
|
3
|
+
// motion. Root cause: `clampZoom` applies a single hard-coded floor to every
|
|
4
|
+
// viewport write, including the programmatic `fit()` path — a board wider
|
|
5
|
+
// than ~10x the viewport could never be framed whole. See
|
|
6
|
+
// .ai/logs/rca/issue-91-canvas-zoom-floor.md.
|
|
7
|
+
//
|
|
8
|
+
// canvas-lib.tsx pulls in a React/DOM dependency graph, so we register
|
|
9
|
+
// happy-dom for this file (like camera-reveal.test.tsx) before importing.
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
12
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
GlobalRegistrator.register();
|
|
16
|
+
});
|
|
17
|
+
afterAll(() => {
|
|
18
|
+
GlobalRegistrator.unregister();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
import { clampZoom, computeFit } from '../canvas-lib.tsx';
|
|
22
|
+
|
|
23
|
+
function fakeHost(clientWidth: number, clientHeight: number): HTMLElement {
|
|
24
|
+
const el = document.createElement('div');
|
|
25
|
+
Object.defineProperty(el, 'clientWidth', { value: clientWidth });
|
|
26
|
+
Object.defineProperty(el, 'clientHeight', { value: clientHeight });
|
|
27
|
+
return el;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('clampZoom — the interactive zoom floor (issue #91)', () => {
|
|
31
|
+
test('a deep zoom-out below the OLD 0.1 floor is no longer clamped', () => {
|
|
32
|
+
expect(clampZoom(0.05)).toBe(0.05);
|
|
33
|
+
expect(clampZoom(0.02)).toBe(0.02);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('still clamps below the new floor', () => {
|
|
37
|
+
expect(clampZoom(0.0001)).toBe(0.02);
|
|
38
|
+
expect(clampZoom(-5)).toBe(0.02);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('still clamps above the max and normalizes non-finite input', () => {
|
|
42
|
+
expect(clampZoom(99)).toBe(4);
|
|
43
|
+
expect(clampZoom(Number.NaN)).toBe(1);
|
|
44
|
+
expect(clampZoom(Number.POSITIVE_INFINITY)).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('computeFit → clampZoom — fit() can now frame a wide board (issue #91)', () => {
|
|
49
|
+
test('a board ~20x the viewport width computes a zoom the old floor would have clamped up', () => {
|
|
50
|
+
// Viewport 1000x800; board 20000 wide — the exact "more than a screenful
|
|
51
|
+
// of artboards" shape from the report. Ideal fit zoom is well under 0.1.
|
|
52
|
+
const host = fakeHost(1000, 800);
|
|
53
|
+
const fit = computeFit([{ x: 0, y: 0, w: 20000, h: 800 }], host, 24);
|
|
54
|
+
|
|
55
|
+
expect(fit.zoom).toBeLessThan(0.1); // the bug: old floor would have clamped this
|
|
56
|
+
expect(fit.zoom).toBeGreaterThanOrEqual(0.02); // still within the new interactive floor
|
|
57
|
+
|
|
58
|
+
// The regression proper: applying the viewport funnel's clamp must be a
|
|
59
|
+
// no-op for this value now — before the fix (ZOOM_MIN = 0.1) this would
|
|
60
|
+
// have raised fit.zoom back up to 0.1, cropping the board.
|
|
61
|
+
expect(clampZoom(fit.zoom)).toBe(fit.zoom);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('an extremely wide board (>50x viewport) still bottoms out at the interactive floor', () => {
|
|
65
|
+
const host = fakeHost(1000, 800);
|
|
66
|
+
const fit = computeFit([{ x: 0, y: 0, w: 100000, h: 800 }], host, 24);
|
|
67
|
+
|
|
68
|
+
// The ideal fit zoom is far below even the new floor — clampZoom must
|
|
69
|
+
// still protect the interactive floor for pathological board sizes.
|
|
70
|
+
expect(fit.zoom).toBeLessThan(0.02);
|
|
71
|
+
expect(clampZoom(fit.zoom)).toBe(0.02);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -39,7 +39,16 @@ describe('comment-mount / MaybeToolProvider dedup', () => {
|
|
|
39
39
|
const html = renderToStaticMarkup(
|
|
40
40
|
createElement(MaybeToolProvider, null, createElement(ToolReader))
|
|
41
41
|
);
|
|
42
|
-
//
|
|
42
|
+
// DDR-223 — the default boot tool is `move` (edit mode).
|
|
43
|
+
expect(html).toContain('data-tool="move"');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('forwards initial="browse" — the comment-mount specimen posture (DDR-223)', () => {
|
|
47
|
+
// buildCanvasTree passes this explicitly so bare DS specimens keep the
|
|
48
|
+
// DDR-187 alive posture + native cursors.
|
|
49
|
+
const html = renderToStaticMarkup(
|
|
50
|
+
createElement(MaybeToolProvider, { initial: 'browse' }, createElement(ToolReader))
|
|
51
|
+
);
|
|
43
52
|
expect(html).toContain('data-tool="browse"');
|
|
44
53
|
});
|
|
45
54
|
});
|
|
@@ -19,7 +19,7 @@ afterAll(() => {
|
|
|
19
19
|
GlobalRegistrator.unregister();
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
import { resolveByDomPath } from '../dom-selection.ts';
|
|
22
|
+
import { buildComposeSelection, openCommentComposer, resolveByDomPath } from '../dom-selection.ts';
|
|
23
23
|
|
|
24
24
|
function setArtboard(html: string): void {
|
|
25
25
|
document.body.innerHTML = `<div data-dc-screen="art1">${html}</div>`;
|
|
@@ -128,3 +128,71 @@ describe('resolveByDomPath', () => {
|
|
|
128
128
|
expect(found?.getAttribute('data-cd-id')).toBe('aaa11111');
|
|
129
129
|
});
|
|
130
130
|
});
|
|
131
|
+
|
|
132
|
+
// issue-90 — right-click "Add comment" on empty canvas background or an
|
|
133
|
+
// artboard's chrome/border (neither carries a `data-cd-id`) used to have no
|
|
134
|
+
// menu entry at all, and even where the entry DID exist (on an element) it
|
|
135
|
+
// never actually opened the composer (see `openCommentComposer` below).
|
|
136
|
+
// `buildComposeSelection` is the shared builder canvas-shell.tsx's
|
|
137
|
+
// context-menu registry now calls for all three target kinds.
|
|
138
|
+
describe('buildComposeSelection', () => {
|
|
139
|
+
test('with an element under the cursor — delegates to hoverTargetToSelection (same anchor a comment-tool click gets)', () => {
|
|
140
|
+
setArtboard(`<button class="cta" data-cd-id="aaa11111">Buy now</button>`);
|
|
141
|
+
const el = document.querySelector('[data-cd-id="aaa11111"]');
|
|
142
|
+
const sel = buildComposeSelection({ el, cdId: 'aaa11111', artboardId: 'art1' }, 100, 200);
|
|
143
|
+
expect(sel.selector).toBe('[data-dc-screen="art1"] [data-cd-id="aaa11111"]');
|
|
144
|
+
expect(sel.id).toBe('aaa11111');
|
|
145
|
+
expect(sel.tag).toBe('button');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('with no element (empty canvas background) — a FLOATING selection anchored to the click point', () => {
|
|
149
|
+
document.body.innerHTML = '';
|
|
150
|
+
const sel = buildComposeSelection({ el: null, cdId: null, artboardId: null }, 100, 200);
|
|
151
|
+
expect(sel.selector).toBe('');
|
|
152
|
+
expect(sel.tag).toBe('');
|
|
153
|
+
expect(sel.artboardId).toBeNull();
|
|
154
|
+
expect(sel.bounds).toEqual({ x: 88, y: 188, w: 24, h: 24 });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('with no element but INSIDE an artboard (right-click on its chrome/border) — floating, but keeps the artboardId', () => {
|
|
158
|
+
document.body.innerHTML = '';
|
|
159
|
+
const sel = buildComposeSelection({ el: null, cdId: null, artboardId: 'art1' }, 50, 60);
|
|
160
|
+
expect(sel.selector).toBe('');
|
|
161
|
+
expect(sel.artboardId).toBe('art1');
|
|
162
|
+
expect(sel.bounds).toEqual({ x: 38, y: 48, w: 24, h: 24 });
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('openCommentComposer', () => {
|
|
167
|
+
// The regression: canvas-shell.tsx's context-menu "Add comment" used to
|
|
168
|
+
// ONLY postMessage the parent shell (which merely mirrors the target for
|
|
169
|
+
// the StatusBar/sidebar) and never dispatched `cm:open-composer` — the one
|
|
170
|
+
// event `comments-overlay.tsx` listens on to actually render the composer.
|
|
171
|
+
// Selecting the menu item silently did nothing.
|
|
172
|
+
test('dispatches cm:open-composer carrying the selection + click point', () => {
|
|
173
|
+
let detail: { selection?: unknown; clientX?: number; clientY?: number } | null = null;
|
|
174
|
+
const onOpen = (e: Event) => {
|
|
175
|
+
detail = (e as CustomEvent).detail;
|
|
176
|
+
};
|
|
177
|
+
document.addEventListener('cm:open-composer', onOpen);
|
|
178
|
+
const sel = {
|
|
179
|
+
selector: '',
|
|
180
|
+
artboardId: null,
|
|
181
|
+
tag: '',
|
|
182
|
+
classes: '',
|
|
183
|
+
text: '',
|
|
184
|
+
dom_path: [],
|
|
185
|
+
bounds: { x: 30, y: 40, w: 24, h: 24 },
|
|
186
|
+
html: '',
|
|
187
|
+
};
|
|
188
|
+
try {
|
|
189
|
+
openCommentComposer(sel, 42, 52);
|
|
190
|
+
} finally {
|
|
191
|
+
document.removeEventListener('cm:open-composer', onOpen);
|
|
192
|
+
}
|
|
193
|
+
expect(detail).not.toBeNull();
|
|
194
|
+
expect(detail?.selection).toBe(sel);
|
|
195
|
+
expect(detail?.clientX).toBe(42);
|
|
196
|
+
expect(detail?.clientY).toBe(52);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -60,14 +60,16 @@ describe('hmr-broadcast / classification', () => {
|
|
|
60
60
|
h.stop();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
test('unrelated extensions are dropped', async () => {
|
|
63
|
+
test('unrelated extensions are dropped; media becomes an asset heal', async () => {
|
|
64
64
|
const ctx = mkCtx();
|
|
65
65
|
const got: HmrMessage[] = [];
|
|
66
66
|
const h = createHmrBroadcaster(ctx, (m) => got.push(m));
|
|
67
|
-
ctx.bus.emit('fs:any', 'ui/screenshot.png');
|
|
68
|
-
ctx.bus.emit('fs:any', '_locator.json');
|
|
67
|
+
ctx.bus.emit('fs:any', 'ui/screenshot.png'); // media → asset heal (2026-08-15 RCA)
|
|
68
|
+
ctx.bus.emit('fs:any', '_locator.json'); // runtime state → dropped
|
|
69
69
|
await awaitNextFlush();
|
|
70
|
-
expect(got).toHaveLength(
|
|
70
|
+
expect(got).toHaveLength(1);
|
|
71
|
+
expect(got[0]?.mode).toBe('asset');
|
|
72
|
+
expect(got[0]?.file).toBe('ui/screenshot.png');
|
|
71
73
|
h.stop();
|
|
72
74
|
});
|
|
73
75
|
});
|