@1agh/maude 0.58.2 → 0.59.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/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -22,10 +22,22 @@ export type SyncPhase =
|
|
|
22
22
|
| 'connecting'
|
|
23
23
|
| 'syncing'
|
|
24
24
|
| 'synced'
|
|
25
|
+
| 'stalled'
|
|
25
26
|
| 'refused'
|
|
26
27
|
| 'offline'
|
|
27
28
|
| 'nothing-syncable';
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How long `connecting…` may honestly stay on screen with ZERO documents
|
|
32
|
+
* synced before it becomes a different sentence. A real handshake settles in
|
|
33
|
+
* seconds; five minutes of nothing is not a connection in progress, it is a
|
|
34
|
+
* link that needs a person (the alligators incident sat in `connecting…` for
|
|
35
|
+
* DAYS while every document was being refused). Matches the auth re-probe
|
|
36
|
+
* cadence so the claim "nothing is moving" has had at least one full retry
|
|
37
|
+
* cycle behind it.
|
|
38
|
+
*/
|
|
39
|
+
export const STALL_AFTER_MS = 5 * 60 * 1000;
|
|
40
|
+
|
|
29
41
|
export interface SyncPresentation {
|
|
30
42
|
phase: SyncPhase;
|
|
31
43
|
/** Green dot — reserved for a link that is genuinely carrying edits. */
|
|
@@ -119,10 +131,39 @@ function readCounts(
|
|
|
119
131
|
*/
|
|
120
132
|
export function syncPresentation(
|
|
121
133
|
status: SyncStatusLike | null | undefined,
|
|
122
|
-
opts: { project?: string | null } = {}
|
|
134
|
+
opts: { project?: string | null; now?: number } = {}
|
|
123
135
|
): SyncPresentation | null {
|
|
124
136
|
if (!status || status.linked === false) return null;
|
|
125
137
|
const project = safeName(opts.project, 'the workspace');
|
|
138
|
+
const now = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
139
|
+
|
|
140
|
+
// ZERO PROGRESS HAS A DEADLINE. Every `connecting…` below is honest for a
|
|
141
|
+
// handshake and a lie after five minutes of it — the phase reads as "in
|
|
142
|
+
// progress" while nothing is moving and nothing will. Once the link has
|
|
143
|
+
// been trying since `startedAt` with not one document synced, the sentence
|
|
144
|
+
// changes to the one that names the move. Validated like everything else
|
|
145
|
+
// read off disk; an absent/garbage stamp simply never stalls (old payloads).
|
|
146
|
+
const startedAt =
|
|
147
|
+
typeof status.startedAt === 'number' &&
|
|
148
|
+
Number.isFinite(status.startedAt) &&
|
|
149
|
+
status.startedAt > 0
|
|
150
|
+
? status.startedAt
|
|
151
|
+
: null;
|
|
152
|
+
const stalled = (): SyncPresentation => {
|
|
153
|
+
// `?? now` rather than a cast: every call site is behind `isStalled`, which
|
|
154
|
+
// has already proved `startedAt` is a number — and a future one that isn't
|
|
155
|
+
// reads "1 minute" instead of "NaN minutes".
|
|
156
|
+
const min = Math.max(1, Math.round((now - (startedAt ?? now)) / 60_000));
|
|
157
|
+
return {
|
|
158
|
+
phase: 'stalled',
|
|
159
|
+
online: false,
|
|
160
|
+
label: 'stalled',
|
|
161
|
+
title: `Nothing has synced with ${project} in the ${min} minute${min === 1 ? '' : 's'} since Maude started trying.`,
|
|
162
|
+
next: 'Reconnect the workspace — the sign-in may have expired.',
|
|
163
|
+
names: [],
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
const isStalled = startedAt !== null && now - startedAt > STALL_AFTER_MS;
|
|
126
167
|
|
|
127
168
|
if (status.notSyncable) {
|
|
128
169
|
const tsx = status.tsxCount ?? 0;
|
|
@@ -203,6 +244,7 @@ export function syncPresentation(
|
|
|
203
244
|
// Pre-DDR-102 payload: `state` is all there is. Report it as the weaker
|
|
204
245
|
// evidence it is, rather than promoting it to a document-level claim.
|
|
205
246
|
const online = status.state === 'online' || status.flash === 'synced';
|
|
247
|
+
if (!online && isStalled) return stalled();
|
|
206
248
|
return {
|
|
207
249
|
phase: online ? 'synced' : 'connecting',
|
|
208
250
|
online,
|
|
@@ -218,6 +260,7 @@ export function syncPresentation(
|
|
|
218
260
|
const total = docs.synced + docs.pending + docs.rejected;
|
|
219
261
|
|
|
220
262
|
if (total === 0) {
|
|
263
|
+
if (isStalled) return stalled();
|
|
221
264
|
return {
|
|
222
265
|
phase: 'connecting',
|
|
223
266
|
online: false,
|
|
@@ -232,6 +275,7 @@ export function syncPresentation(
|
|
|
232
275
|
// Zero settled yet is a different fact from some settled: one is a
|
|
233
276
|
// handshake in flight, the other is visible progress.
|
|
234
277
|
const started = docs.synced > 0;
|
|
278
|
+
if (!started && isStalled) return stalled();
|
|
235
279
|
return {
|
|
236
280
|
phase: started ? 'syncing' : 'connecting',
|
|
237
281
|
online: false,
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// git-pull divergence). Writes are best-effort + atomic-ish (tmp + rename via
|
|
13
13
|
// the injected writer); a failed write never throws into the sync hot path.
|
|
14
14
|
|
|
15
|
+
import type { AssetPushProgress } from './asset-push.ts';
|
|
15
16
|
import type { SyncStatusSnapshot } from './connection-state.ts';
|
|
16
17
|
|
|
17
18
|
// `cold-start-hub-wins` stays in the union for OLD payload readers (additive
|
|
@@ -46,6 +47,12 @@ export interface SyncStatusPayload extends SyncStatusSnapshot {
|
|
|
46
47
|
* show which collaboration model is running. Absent/false = the two-doc path.
|
|
47
48
|
*/
|
|
48
49
|
sharedDoc?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* feature-sync-progress-modal — the DDR-217 asset push's live progress
|
|
52
|
+
* (additive; absent until the first push emit of a boot). Rides the same
|
|
53
|
+
* payload as the doc counts so the Sync panel has one source, not two.
|
|
54
|
+
*/
|
|
55
|
+
assets?: AssetPushProgress;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
export interface SyncStatusStoreOptions {
|
|
@@ -67,6 +74,10 @@ export interface SyncStatusStore {
|
|
|
67
74
|
update(snapshot: SyncStatusSnapshot): void;
|
|
68
75
|
/** Record a conflict notification + persist + broadcast. */
|
|
69
76
|
addConflict(conflict: Omit<SyncConflict, 'at'>): void;
|
|
77
|
+
/** feature-sync-progress-modal — merge asset-push progress + persist +
|
|
78
|
+
* broadcast. Kept in the store (not the monitor): assets are a push lane,
|
|
79
|
+
* not a connection, and the monitor's state machine must not learn them. */
|
|
80
|
+
updateAssets(progress: AssetPushProgress): void;
|
|
70
81
|
/** Current payload (defensive copy). */
|
|
71
82
|
get(): SyncStatusPayload;
|
|
72
83
|
}
|
|
@@ -90,6 +101,8 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
90
101
|
updatedAt: now(),
|
|
91
102
|
};
|
|
92
103
|
|
|
104
|
+
let assets: AssetPushProgress | undefined;
|
|
105
|
+
|
|
93
106
|
function payload(): SyncStatusPayload {
|
|
94
107
|
return {
|
|
95
108
|
...snapshot,
|
|
@@ -97,6 +110,7 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
97
110
|
canvases: opts.canvases,
|
|
98
111
|
conflicts: conflicts.slice(),
|
|
99
112
|
...(opts.sharedDoc ? { sharedDoc: true } : {}),
|
|
113
|
+
...(assets ? { assets } : {}),
|
|
100
114
|
};
|
|
101
115
|
}
|
|
102
116
|
|
|
@@ -124,6 +138,10 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
124
138
|
if (conflicts.length > maxConflicts) conflicts.splice(0, conflicts.length - maxConflicts);
|
|
125
139
|
flush();
|
|
126
140
|
},
|
|
141
|
+
updateAssets(progress) {
|
|
142
|
+
assets = progress;
|
|
143
|
+
flush();
|
|
144
|
+
},
|
|
127
145
|
get: payload,
|
|
128
146
|
};
|
|
129
147
|
}
|
|
@@ -52,8 +52,11 @@ export interface SyncSupervisor {
|
|
|
52
52
|
/**
|
|
53
53
|
* Adopt a just-authorized link and cycle the runtime. `linkedHub` is passed
|
|
54
54
|
* BY VALUE from the attach lane on purpose — never re-read from disk.
|
|
55
|
+
* `null` is the detach lane: clear the in-memory link (the config write on
|
|
56
|
+
* disk is invisible to a cfg captured at boot) and cycle back to solo.
|
|
57
|
+
* `undefined` keeps whatever the config already holds.
|
|
55
58
|
*/
|
|
56
|
-
restart(linkedHub?: LinkedHub): Promise<SyncStartOutcome>;
|
|
59
|
+
restart(linkedHub?: LinkedHub | null): Promise<SyncStartOutcome>;
|
|
57
60
|
stop(): Promise<void>;
|
|
58
61
|
/** The live runtime, or null in solo mode. Test/inspection surface. */
|
|
59
62
|
current(): SyncRuntime | null;
|
|
@@ -166,6 +169,7 @@ export function createSyncSupervisor(
|
|
|
166
169
|
}
|
|
167
170
|
runtime = null;
|
|
168
171
|
if (linkedHub) adoptLinkedHub(ctx, linkedHub);
|
|
172
|
+
else if (linkedHub === null) delete ctx.cfg.linkedHub;
|
|
169
173
|
return boot();
|
|
170
174
|
}),
|
|
171
175
|
stop: () =>
|
|
@@ -67,7 +67,7 @@ export interface SignInInput {
|
|
|
67
67
|
/** Injected in tests. */
|
|
68
68
|
export interface SignInDeps {
|
|
69
69
|
fetchImpl?: typeof fetch;
|
|
70
|
-
save?: (normUrl: string, token: string, role?: string) => void;
|
|
70
|
+
save?: (normUrl: string, token: string, role?: string, expiresAt?: number) => void;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
@@ -186,8 +186,12 @@ export async function signInToWorkspace(
|
|
|
186
186
|
// viewer is known to be one at BOOT. Learning it from the first refusal
|
|
187
187
|
// instead means showing somebody an editor and then taking it away.
|
|
188
188
|
const vouchedRole = typeof body.user?.role === 'string' ? body.user.role : 'member';
|
|
189
|
+
// …and WHEN it dies. The expiry was already read (it is echoed to the UI
|
|
190
|
+
// below) — discarding it from the store is what left the sync runtime
|
|
191
|
+
// with no deadline to renew against.
|
|
192
|
+
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : undefined;
|
|
189
193
|
try {
|
|
190
|
-
save(norm, body.token, vouchedRole);
|
|
194
|
+
save(norm, body.token, vouchedRole, expiresAt);
|
|
191
195
|
} catch {
|
|
192
196
|
return fail(500, "Couldn't save the workspace connection on this computer.", 'save-failed');
|
|
193
197
|
}
|
|
@@ -201,7 +205,7 @@ export async function signInToWorkspace(
|
|
|
201
205
|
email: typeof body.user?.email === 'string' ? body.user.email : String(input.email).trim(),
|
|
202
206
|
role: vouchedRole,
|
|
203
207
|
},
|
|
204
|
-
expiresAt:
|
|
208
|
+
expiresAt: expiresAt ?? null,
|
|
205
209
|
version: probe.version,
|
|
206
210
|
},
|
|
207
211
|
};
|
|
@@ -46,19 +46,124 @@ describe('bindCandidate', () => {
|
|
|
46
46
|
);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
test('
|
|
50
|
-
const label: TextStroke = {
|
|
51
|
-
id: 't',
|
|
52
|
-
tool: 'text',
|
|
53
|
-
color: '#1a1a1a',
|
|
54
|
-
fontSize: 14,
|
|
55
|
-
text: 'x',
|
|
56
|
-
x: 0,
|
|
57
|
-
y: 0,
|
|
58
|
-
};
|
|
59
|
-
expect(isBindable(label)).toBe(false);
|
|
60
|
-
expect(bindCandidate(0, 0, [label], BIND_THRESHOLD_PX)).toBeNull();
|
|
49
|
+
test('pen / arrow / link never host', () => {
|
|
61
50
|
expect(isBindable(rect('r'))).toBe(true);
|
|
51
|
+
expect(
|
|
52
|
+
isBindable({ id: 'p', tool: 'pen', color: '#000', width: 2, points: [[0, 0]] } as Stroke)
|
|
53
|
+
).toBe(false);
|
|
54
|
+
expect(
|
|
55
|
+
isBindable({
|
|
56
|
+
id: 'l',
|
|
57
|
+
tool: 'link',
|
|
58
|
+
x: 0,
|
|
59
|
+
y: 0,
|
|
60
|
+
w: 10,
|
|
61
|
+
h: 10,
|
|
62
|
+
url: 'https://x.test',
|
|
63
|
+
title: 't',
|
|
64
|
+
domain: 'x.test',
|
|
65
|
+
} as Stroke)
|
|
66
|
+
).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// DDR-216 D9 — the widening, and the three exclusions that keep it from
|
|
71
|
+
// regressing the NATIVE whiteboard. FigJam binds connectors to anything; on the
|
|
72
|
+
// only real board measured, 2/2 connectors had an endpoint Maude could not bind,
|
|
73
|
+
// so an imported connector degraded to a frozen line. Widening also closes a
|
|
74
|
+
// plain native gap (you could not attach an arrow to a text label or a section).
|
|
75
|
+
describe('isBindable widening — text + section (DDR-216 D9)', () => {
|
|
76
|
+
const standaloneText = (over: Partial<TextStroke> = {}): TextStroke => ({
|
|
77
|
+
id: 't',
|
|
78
|
+
tool: 'text',
|
|
79
|
+
color: '#1a1a1a',
|
|
80
|
+
fontSize: 14,
|
|
81
|
+
text: 'Archetyp',
|
|
82
|
+
x: 0,
|
|
83
|
+
y: 0,
|
|
84
|
+
...over,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const section = (id: string, x = 0, y = 0, w = 400, h = 300): Stroke =>
|
|
88
|
+
({ id, tool: 'section', x, y, w, h, label: 'Sekce', color: '#8b8b94' }) as Stroke;
|
|
89
|
+
|
|
90
|
+
test('standalone text IS bindable, and hosts a real bind', () => {
|
|
91
|
+
const label = standaloneText();
|
|
92
|
+
expect(isBindable(label)).toBe(true);
|
|
93
|
+
const bind = bindCandidate(1, 1, [label], BIND_THRESHOLD_PX);
|
|
94
|
+
expect(bind?.hostId).toBe('t');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('ANCHORED text is NOT bindable — its bbox is unresolvable here', () => {
|
|
98
|
+
// `strokeBBox` returns null for anchored text without the anchors map, which
|
|
99
|
+
// every caller in this module omits. Admitting it would mint a bind that can
|
|
100
|
+
// never resolve — worse than not binding, because it never self-corrects.
|
|
101
|
+
const anchored = standaloneText({ anchorId: 'some-shape' });
|
|
102
|
+
expect(isBindable(anchored)).toBe(false);
|
|
103
|
+
expect(bindCandidate(0, 0, [anchored], BIND_THRESHOLD_PX)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('oversized text is NOT offered — no board-wide bind magnet', () => {
|
|
107
|
+
// A TextStroke has no stored w/h; strokeBBox synthesizes one from content,
|
|
108
|
+
// so 5 000 chars on one line projects a ~38 500 px-wide invisible strip.
|
|
109
|
+
const huge = standaloneText({ text: 'x'.repeat(5000) });
|
|
110
|
+
expect(isBindable(huge)).toBe(false);
|
|
111
|
+
// A rect under the cursor still wins, rather than the giant text stealing it.
|
|
112
|
+
const r = rect('r', 0, 0, 100, 100);
|
|
113
|
+
expect(bindCandidate(50, 50, [r, huge], BIND_THRESHOLD_PX)?.hostId).toBe('r');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a section binds on its BORDER, matching strokeHitTest', () => {
|
|
117
|
+
const s = section('sec');
|
|
118
|
+
expect(isBindable(s)).toBe(true);
|
|
119
|
+
expect(bindCandidate(0, 150, [s], BIND_THRESHOLD_PX)?.hostId).toBe('sec');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a section does NOT swallow binds through its click-through interior', () => {
|
|
123
|
+
// The natural gesture is to draw a section AROUND existing notes, which puts
|
|
124
|
+
// it later in the array = topmost. Testing its raw bbox would make every
|
|
125
|
+
// arrow drawn between two stickies inside it bind to the section instead.
|
|
126
|
+
const inner = rect('inner', 100, 100, 60, 60);
|
|
127
|
+
const s = section('sec', 0, 0, 400, 300);
|
|
128
|
+
const strokes: Stroke[] = [inner, s]; // section topmost
|
|
129
|
+
expect(bindCandidate(130, 130, strokes, BIND_THRESHOLD_PX)?.hostId).toBe('inner');
|
|
130
|
+
// Deep interior with nothing under it binds to nothing, not to the section.
|
|
131
|
+
expect(bindCandidate(300, 200, [s], BIND_THRESHOLD_PX)).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('a bind whose anchorPoint cannot resolve is STRIPPED, not frozen-but-claimed', () => {
|
|
135
|
+
// Fix 2: `applyEnd` used to `return` silently when anchorPoint was null,
|
|
136
|
+
// which kept a bind that could never be honoured. Inert before the widening,
|
|
137
|
+
// load-bearing after it.
|
|
138
|
+
const ghost = standaloneText({ id: 'ghost', anchorId: 'gone' });
|
|
139
|
+
const ar = arrow('ar', {
|
|
140
|
+
x1: 0,
|
|
141
|
+
y1: 0,
|
|
142
|
+
x2: 100,
|
|
143
|
+
y2: 100,
|
|
144
|
+
startBind: { hostId: 'ghost', nx: 0.5, ny: 0.5 },
|
|
145
|
+
});
|
|
146
|
+
const out = recomputeBoundArrows([ghost, ar]);
|
|
147
|
+
const a = out.find((s) => s.id === 'ar') as ArrowStroke;
|
|
148
|
+
expect(a.startBind).toBeUndefined();
|
|
149
|
+
expect([a.x1, a.y1]).toEqual([0, 0]); // endpoint frozen in place, honestly
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a bound arrow follows a text host as it moves', () => {
|
|
153
|
+
const label = standaloneText({ x: 0, y: 0 });
|
|
154
|
+
const ar = arrow('ar', {
|
|
155
|
+
x1: 0,
|
|
156
|
+
y1: 0,
|
|
157
|
+
x2: 300,
|
|
158
|
+
y2: 300,
|
|
159
|
+
startBind: { hostId: 't', nx: 0, ny: 0 },
|
|
160
|
+
});
|
|
161
|
+
const moved: Stroke[] = [{ ...label, x: 40, y: 60 }, ar];
|
|
162
|
+
const out = recomputeBoundArrows(moved);
|
|
163
|
+
const a = out.find((s) => s.id === 'ar') as ArrowStroke;
|
|
164
|
+
// The exact bbox is content-derived; what matters is that it TRACKED.
|
|
165
|
+
expect(a.startBind?.hostId).toBe('t');
|
|
166
|
+
expect(a.x1).not.toBe(0);
|
|
62
167
|
});
|
|
63
168
|
});
|
|
64
169
|
|
|
@@ -122,3 +227,36 @@ describe('facingAnchor (AI annotate / quick-create side pick)', () => {
|
|
|
122
227
|
expect(facingAnchor(host, 50, -900)).toEqual({ hostId: 'h', nx: 0.5, ny: 0 });
|
|
123
228
|
});
|
|
124
229
|
});
|
|
230
|
+
|
|
231
|
+
describe('the text bind cap is PER AXIS, not an area product (review F8)', () => {
|
|
232
|
+
test('a long single line at the font-size floor is NOT a bind host', () => {
|
|
233
|
+
// The area cap it replaced was satisfiable by exactly this shape: 4 000
|
|
234
|
+
// chars at fontSize 8 gives w ≈ 17 600 × h ≈ 9.6 — area 168 960, under a
|
|
235
|
+
// 640×480 product — i.e. a 17 600 px-wide invisible magnet.
|
|
236
|
+
const strip: TextStroke = {
|
|
237
|
+
id: 'strip',
|
|
238
|
+
tool: 'text',
|
|
239
|
+
color: '#1a1a1a',
|
|
240
|
+
fontSize: 8,
|
|
241
|
+
text: 'x'.repeat(4000),
|
|
242
|
+
x: 0,
|
|
243
|
+
y: 0,
|
|
244
|
+
};
|
|
245
|
+
expect(isBindable(strip)).toBe(false);
|
|
246
|
+
const r = rect('r', 100, 0, 100, 100);
|
|
247
|
+
expect(bindCandidate(150, 50, [r, strip], BIND_THRESHOLD_PX)?.hostId).toBe('r');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('an ordinary label is still a bind host', () => {
|
|
251
|
+
const label: TextStroke = {
|
|
252
|
+
id: 't',
|
|
253
|
+
tool: 'text',
|
|
254
|
+
color: '#1a1a1a',
|
|
255
|
+
fontSize: 14,
|
|
256
|
+
text: 'Archetyp',
|
|
257
|
+
x: 0,
|
|
258
|
+
y: 0,
|
|
259
|
+
};
|
|
260
|
+
expect(isBindable(label)).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -153,7 +153,10 @@ describe('/_api/canvas — POST round-trip', () => {
|
|
|
153
153
|
expect(j.ok).toBe(true);
|
|
154
154
|
const tsx = readFileSync(join(designRoot, 'ui', 'My Reel.tsx'), 'utf8');
|
|
155
155
|
expect(tsx).toContain('<VideoComp component={Comp}');
|
|
156
|
-
|
|
156
|
+
// <Video> from @remotion/media, not <OffthreadVideo> — the renderer
|
|
157
|
+
// rejects the latter and the export loses its audio (RCA
|
|
158
|
+
// issue-mp4-audio-export-html5audio-silent-degrade).
|
|
159
|
+
expect(tsx).toContain('<Video src="assets/a.mp4" />');
|
|
157
160
|
expect(tsx).toContain('<Audio src="assets/music.mp3" />');
|
|
158
161
|
expect(tsx).toContain('name="clip-1"');
|
|
159
162
|
const m = JSON.parse(readFileSync(join(designRoot, 'ui', 'My Reel.meta.json'), 'utf8'));
|
|
@@ -214,6 +214,23 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
214
214
|
'/_api/github/invite',
|
|
215
215
|
'/_api/github/clone',
|
|
216
216
|
'/_api/github/create-project',
|
|
217
|
+
// DDR-216 D3 — every /_api/figma/* route is MAIN-ORIGIN ONLY (absent from
|
|
218
|
+
// CANVAS_SAFE_API + startCanvasServer's `routes` map). A canvas-reachable
|
|
219
|
+
// Figma route would be a token-exfiltration primitive AND an SSRF
|
|
220
|
+
// primitive at once: `connect` stores the user's PAT, `probe` spends it,
|
|
221
|
+
// and `status` would leak whether one exists. A request from this origin
|
|
222
|
+
// 403s at the gate rather than reaching a handler. (The complementary
|
|
223
|
+
// cross-SITE guard — `isTrustedRequestHost` + `sameOriginWrite` on the
|
|
224
|
+
// MAIN origin, which allowlist omission does nothing about — lives in
|
|
225
|
+
// figma-routes.test.ts; both halves are required, neither is sufficient.)
|
|
226
|
+
'/_api/figma/status',
|
|
227
|
+
'/_api/figma/connect',
|
|
228
|
+
'/_api/figma/probe',
|
|
229
|
+
'/_api/figma/import',
|
|
230
|
+
// DDR-219 D2 — the codegen route is privileged-origin only. From the
|
|
231
|
+
// canvas it would be a read primitive over the user's OPEN Figma
|
|
232
|
+
// document plus a write into the design root.
|
|
233
|
+
'/_api/figma/explode',
|
|
217
234
|
// Local-only project create (mkdir + git init + scaffold) — writes to disk,
|
|
218
235
|
// no token; MAIN-ORIGIN ONLY, so the canvas origin must 403 at the gate.
|
|
219
236
|
'/_api/project/create-local',
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Source-shape gates for capture determinism.
|
|
2
|
+
//
|
|
3
|
+
// These guard properties that only manifest in a browser under load, which CI
|
|
4
|
+
// has no way to run — `test/exporters/pw-launch.test.ts` says as much ("the
|
|
5
|
+
// launch path itself is integration-shape (needs a real browser)"), and
|
|
6
|
+
// `test/export-shim-multi-capture.test.ts` exists precisely because of it. So,
|
|
7
|
+
// in that same house style, the FIX SHAPE is asserted at the source level.
|
|
8
|
+
//
|
|
9
|
+
// Why these particular shapes: the plan's original premise was that frame-step
|
|
10
|
+
// capture is "deterministic by construction" — a doc comment in video-comp.tsx
|
|
11
|
+
// with nothing enforcing it. It was not true. A seek could fail silently, and
|
|
12
|
+
// the capture wait could give up on a wall-clock timer and screenshot a stale
|
|
13
|
+
// frame. Serially both were survivable; under any parallel capture neither is.
|
|
14
|
+
// If someone deletes these guards, the resulting bug is a valid-looking video
|
|
15
|
+
// with wrong frames — invisible to every other test we have.
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const STUDIO = join(import.meta.dir, '..');
|
|
22
|
+
const read = (rel: string) => readFileSync(join(STUDIO, rel), 'utf8');
|
|
23
|
+
|
|
24
|
+
describe('the seek bridge must not report a seek that did not happen', () => {
|
|
25
|
+
const src = () => read('video-comp.tsx');
|
|
26
|
+
|
|
27
|
+
test('no empty catch swallows a failed seekTo', () => {
|
|
28
|
+
// The exact regression: `catch { /* comment only */ }` around the seek.
|
|
29
|
+
const emptyCatch = /catch\s*\{\s*(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)?\s*\}/g;
|
|
30
|
+
const body = src();
|
|
31
|
+
const seekRegion = body.slice(
|
|
32
|
+
body.indexOf('__maude_seek__ = async'),
|
|
33
|
+
body.indexOf('Ordinary-artboard fallback')
|
|
34
|
+
);
|
|
35
|
+
expect(seekRegion.length).toBeGreaterThan(0);
|
|
36
|
+
expect(seekRegion.match(emptyCatch)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('an unlanded seek throws rather than resolving', () => {
|
|
40
|
+
expect(src()).toContain('refusing to report a seek that did not happen');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('failures are counted where the shim can read them', () => {
|
|
44
|
+
expect(src()).toContain('__maude_seek_failures__');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a cold Player still gets bounded retries — the fix is not "fail faster"', () => {
|
|
48
|
+
expect(src()).toContain('SEEK_ATTEMPTS');
|
|
49
|
+
expect(src()).toContain('seekEntryWithRetry');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('the capture shim must refuse a capture built on stale frames', () => {
|
|
54
|
+
test('it reads the in-page failure count and throws on any', () => {
|
|
55
|
+
const shim = read('bin/_video-playwright.mjs');
|
|
56
|
+
expect(shim).toContain('__maude_seek_failures__');
|
|
57
|
+
expect(shim).toMatch(/seekFailures\s*>\s*0/);
|
|
58
|
+
expect(shim).toContain('Refusing to encode');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the refusal happens BEFORE the encoded output is written', () => {
|
|
62
|
+
const shim = read('bin/_video-playwright.mjs');
|
|
63
|
+
const guardAt = shim.indexOf('seekFailures');
|
|
64
|
+
const writeAt = shim.indexOf('writeFileSync(out, Buffer.from(enc.b64');
|
|
65
|
+
expect(guardAt).toBeGreaterThan(0);
|
|
66
|
+
expect(writeAt).toBeGreaterThan(0);
|
|
67
|
+
// Ordering is the whole point — a check after the write ships the bad file.
|
|
68
|
+
expect(guardAt).toBeLessThan(writeAt);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('encoder guards (unit-shape — these run without a browser)', () => {
|
|
73
|
+
const enc = () => read('exporters/video-encode-lib.ts');
|
|
74
|
+
|
|
75
|
+
test('a mismatched frame is rejected, never silently resampled', () => {
|
|
76
|
+
const src = enc();
|
|
77
|
+
expect(src).toContain('refusing to resample');
|
|
78
|
+
// The check must precede the draw, or the resample already happened.
|
|
79
|
+
const guardAt = src.indexOf('refusing to resample');
|
|
80
|
+
const drawAt = src.indexOf('vstate.ctx.drawImage(bmp, 0, 0');
|
|
81
|
+
expect(guardAt).toBeGreaterThan(0);
|
|
82
|
+
expect(guardAt).toBeLessThan(drawAt);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("latencyMode is pinned to 'quality' — 'realtime' may drop frames", () => {
|
|
86
|
+
expect(enc()).toContain("latencyMode: 'quality'");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('the encoder canvas is NOT cleared between frames — pinned deliberately', () => {
|
|
90
|
+
// Current behaviour: a transparent artboard ghosts the previous frame,
|
|
91
|
+
// because nothing clears the canvas before drawImage. That is a real defect
|
|
92
|
+
// for transparent comps, but changing it is a visual change to every export
|
|
93
|
+
// and belongs in its own change with its own before/after. Pinned here so
|
|
94
|
+
// the next person changes it ON PURPOSE rather than as a side effect.
|
|
95
|
+
expect(enc()).not.toContain('clearRect');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('bundle cache paths must be content-addressed', () => {
|
|
100
|
+
test('no fixed-name temp bundle two servers could race on', () => {
|
|
101
|
+
const src = read('exporters/_browser-bundles.ts');
|
|
102
|
+
expect(src).toContain('writeHashedBundle');
|
|
103
|
+
expect(src).not.toContain("'maude-video-encode-lib.mjs'");
|
|
104
|
+
expect(src).not.toContain("'maude-video-render-lib.mjs'");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('the video shim must guard its output size', () => {
|
|
109
|
+
test('it calls assertRenderOutputSizeOk like the pdf shim does', () => {
|
|
110
|
+
const shim = read('bin/_video-playwright.mjs');
|
|
111
|
+
expect(shim).toContain(
|
|
112
|
+
"assertRenderOutputSizeOk(clip.width, clip.height, deviceScaleFactor, '_video-playwright')"
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('timing telemetry must not break the summary contract', () => {
|
|
118
|
+
test('MAUDE_TIMING is filtered in _runtime.ts, not in an adapter', () => {
|
|
119
|
+
const rt = read('exporters/_runtime.ts');
|
|
120
|
+
expect(rt).toContain('TIMING_LINE');
|
|
121
|
+
expect(rt).toContain('onTiming');
|
|
122
|
+
// video.ts parses stdoutLines.at(-1) as its summary JSON — an unfiltered
|
|
123
|
+
// trailing diagnostic line would break EVERY video export.
|
|
124
|
+
const video = read('exporters/video.ts');
|
|
125
|
+
expect(video).toContain('stdoutLines.at(-1)');
|
|
126
|
+
expect(video).not.toContain('MAUDE_TIMING');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('both capture paths emit it, so they are comparable', () => {
|
|
130
|
+
const shim = read('bin/_video-playwright.mjs');
|
|
131
|
+
expect(shim).toContain("path: 'frame-step'");
|
|
132
|
+
expect(shim).toContain("path: 'renderer'");
|
|
133
|
+
expect(shim).toContain('stageMs');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -708,7 +708,12 @@ describe('assembleCompSource — refs → comp (DDR-150 P4 Task 12)', () => {
|
|
|
708
708
|
]);
|
|
709
709
|
expect(tsx).toContain('<Audio src="assets/music.mp3" />');
|
|
710
710
|
expect(tsx).toContain(`import { TransitionSeries } from '@remotion/transitions';`);
|
|
711
|
-
|
|
711
|
+
// Media elements MUST come from @remotion/media — importing them from
|
|
712
|
+
// 'remotion' is what made every assembled reel export muted (RCA
|
|
713
|
+
// issue-mp4-audio-export-html5audio-silent-degrade).
|
|
714
|
+
expect(tsx).toContain(`import { Video, Audio } from '@remotion/media';`);
|
|
715
|
+
expect(tsx).toContain(`import { AbsoluteFill } from 'remotion';`);
|
|
716
|
+
expect(tsx).not.toContain(`Audio } from 'remotion'`);
|
|
712
717
|
// total driven by the video clip's duration (audio doesn't extend it).
|
|
713
718
|
expect(enumerateClips('/abs/R.tsx', tsx, 'reel').durationInFrames).toBe(90);
|
|
714
719
|
});
|
|
@@ -755,7 +755,11 @@ describe('applyEnsureVideoComp — kind="video" artboard upgrade', () => {
|
|
|
755
755
|
expect(r.source).toContain('<VideoComp component={VideoCut}');
|
|
756
756
|
expect(r.source).toContain('width={1280} height={800}');
|
|
757
757
|
expect(r.source).toMatch(/import \{[^}]*AbsoluteFill[^}]*\} from 'remotion';/);
|
|
758
|
-
|
|
758
|
+
// The canvas declares `export default function Video()`, so the inserted
|
|
759
|
+
// media element is ALIASED to dodge the collision — it is no longer
|
|
760
|
+
// rewritten to <OffthreadVideo>, which silently cost the audio track.
|
|
761
|
+
expect(r.source).toContain('<MaudeVideo src="assets/x.mp4" />');
|
|
762
|
+
expect(r.source).toContain(`import { Video as MaudeVideo } from '@remotion/media';`);
|
|
759
763
|
const cc = enumerateClips('/abs/Video.tsx', r.source, 'brief');
|
|
760
764
|
expect(cc.compName).toBe('VideoCut');
|
|
761
765
|
expect(cc.clips.length).toBe(1);
|