gentle-pi 4.3.0 → 4.4.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/README.md +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import vm from "node:vm";
|
|
7
|
+
|
|
8
|
+
// The canvas scripts run in the browser; these tests evaluate them in a minimal
|
|
9
|
+
// fake DOM so the focus-mode message contract is checked without a browser.
|
|
10
|
+
|
|
11
|
+
const ASSET_DIR = fileURLToPath(new URL("../assets/design-canvas/", import.meta.url));
|
|
12
|
+
const read = (name: string) => readFileSync(join(ASSET_DIR, name), "utf8");
|
|
13
|
+
|
|
14
|
+
type Listener = (event: any) => void;
|
|
15
|
+
// postMessage clones its message; the copy also leaves the vm realm's prototypes behind.
|
|
16
|
+
const structuredCopy = (message: unknown) => JSON.parse(JSON.stringify(message));
|
|
17
|
+
type Rect = { left: number; top: number; width: number; height: number };
|
|
18
|
+
|
|
19
|
+
class FakeElement {
|
|
20
|
+
localName: string;
|
|
21
|
+
id = "";
|
|
22
|
+
children: FakeElement[] = [];
|
|
23
|
+
parentElement: FakeElement | null = null;
|
|
24
|
+
attributes = new Map<string, string>();
|
|
25
|
+
classes = new Set<string>();
|
|
26
|
+
listeners = new Map<string, Listener[]>();
|
|
27
|
+
style: Record<string, any> = { setProperty(name: string, value: string) { this[name] = value; } };
|
|
28
|
+
dataset: Record<string, string> = {};
|
|
29
|
+
textContent = "";
|
|
30
|
+
title = "";
|
|
31
|
+
hidden = false;
|
|
32
|
+
disabled = false;
|
|
33
|
+
inert = false;
|
|
34
|
+
value = "";
|
|
35
|
+
// Set by the canvas on iframes, the Open in new tab link and the Scale to fit checkbox.
|
|
36
|
+
src = "";
|
|
37
|
+
href = "";
|
|
38
|
+
checked = false;
|
|
39
|
+
rect: Rect = { left: 0, top: 0, width: 0, height: 0 };
|
|
40
|
+
contentWindow: { posted: any[]; postMessage(message: any): void } | null = null;
|
|
41
|
+
doc: FakeDocument;
|
|
42
|
+
|
|
43
|
+
constructor(tag: string, doc: FakeDocument) {
|
|
44
|
+
this.localName = tag;
|
|
45
|
+
this.doc = doc;
|
|
46
|
+
if (tag === "iframe") {
|
|
47
|
+
const posted: any[] = [];
|
|
48
|
+
this.contentWindow = { posted, postMessage: (message) => posted.push(structuredCopy(message)) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get className() { return [...this.classes].join(" "); }
|
|
53
|
+
set className(value: string) { this.classes = new Set(value.split(/\s+/).filter(Boolean)); }
|
|
54
|
+
get classList() {
|
|
55
|
+
const classes = this.classes;
|
|
56
|
+
return {
|
|
57
|
+
add: (name: string) => classes.add(name),
|
|
58
|
+
remove: (name: string) => classes.delete(name),
|
|
59
|
+
contains: (name: string) => classes.has(name),
|
|
60
|
+
toggle: (name: string, force?: boolean) => {
|
|
61
|
+
const on = force === undefined ? !classes.has(name) : force;
|
|
62
|
+
if (on) classes.add(name);
|
|
63
|
+
else classes.delete(name);
|
|
64
|
+
return on;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Layout offsets inside the offset parent; unlike rects, the world's transform never changes them.
|
|
70
|
+
offsetLeft = 0;
|
|
71
|
+
offsetTop = 0;
|
|
72
|
+
get offsetWidth() { return 0; }
|
|
73
|
+
get offsetHeight() { return 0; }
|
|
74
|
+
setAttribute(name: string, value: unknown) { this.attributes.set(name, String(value)); }
|
|
75
|
+
getAttribute(name: string) { return this.attributes.get(name) ?? null; }
|
|
76
|
+
hasAttribute(name: string) { return this.attributes.has(name); }
|
|
77
|
+
removeAttribute(name: string) { this.attributes.delete(name); }
|
|
78
|
+
append(...nodes: FakeElement[]) {
|
|
79
|
+
for (const node of nodes) {
|
|
80
|
+
node.parentElement = this;
|
|
81
|
+
this.children.push(node);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
replaceChildren(...nodes: FakeElement[]) {
|
|
85
|
+
for (const child of this.children) child.parentElement = null;
|
|
86
|
+
this.children = [];
|
|
87
|
+
this.append(...nodes);
|
|
88
|
+
}
|
|
89
|
+
contains(node: FakeElement | null) {
|
|
90
|
+
for (let current = node; current; current = current.parentElement) if (current === this) return true;
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
matches(selector: string) {
|
|
94
|
+
return selector.startsWith(".") ? this.classes.has(selector.slice(1)) : this.localName === selector;
|
|
95
|
+
}
|
|
96
|
+
closest(selector: string) {
|
|
97
|
+
for (let current: FakeElement | null = this; current; current = current.parentElement) if (current.matches(selector)) return current;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
querySelector() { return null; }
|
|
101
|
+
addEventListener(type: string, listener: Listener) {
|
|
102
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
|
103
|
+
}
|
|
104
|
+
/** Bubbles from this element through its ancestors, like a DOM event. */
|
|
105
|
+
dispatch(type: string, init: Record<string, unknown> = {}) {
|
|
106
|
+
const event: any = { type, target: this, defaultPrevented: false, ...init, preventDefault() { event.defaultPrevented = true; }, stopPropagation() {}, stopImmediatePropagation() {} };
|
|
107
|
+
for (let current: FakeElement | null = this; current; current = current.parentElement) {
|
|
108
|
+
for (const listener of current.listeners.get(type) ?? []) listener(event);
|
|
109
|
+
}
|
|
110
|
+
return event;
|
|
111
|
+
}
|
|
112
|
+
focus() { this.doc.activeElement = this; }
|
|
113
|
+
getBoundingClientRect() { return this.rect; }
|
|
114
|
+
setPointerCapture() {}
|
|
115
|
+
scrollIntoView() {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class FakeDocument {
|
|
119
|
+
activeElement: FakeElement | null = null;
|
|
120
|
+
body: FakeElement;
|
|
121
|
+
documentElement: FakeElement;
|
|
122
|
+
title = "";
|
|
123
|
+
byId = new Map<string, FakeElement>();
|
|
124
|
+
listeners = new Map<string, Listener[]>();
|
|
125
|
+
|
|
126
|
+
constructor() {
|
|
127
|
+
this.documentElement = new FakeElement("html", this);
|
|
128
|
+
this.body = new FakeElement("body", this);
|
|
129
|
+
this.documentElement.append(this.body);
|
|
130
|
+
this.activeElement = this.body;
|
|
131
|
+
}
|
|
132
|
+
createElement(tag: string) { return new FakeElement(tag, this); }
|
|
133
|
+
getElementById(id: string) {
|
|
134
|
+
let element = this.byId.get(id);
|
|
135
|
+
if (!element) {
|
|
136
|
+
element = new FakeElement(id === "gd-back" ? "button" : "div", this);
|
|
137
|
+
element.id = id;
|
|
138
|
+
this.byId.set(id, element);
|
|
139
|
+
}
|
|
140
|
+
return element;
|
|
141
|
+
}
|
|
142
|
+
addEventListener(type: string, listener: Listener) {
|
|
143
|
+
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
|
144
|
+
}
|
|
145
|
+
querySelectorAll() { return []; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
149
|
+
|
|
150
|
+
async function loadCanvas(firstHeight = 300, extraReplies: Record<string, unknown> = {}) {
|
|
151
|
+
const document = new FakeDocument();
|
|
152
|
+
document.getElementById("gd-config").textContent = JSON.stringify({ base: "/t/", slug: "alpha" });
|
|
153
|
+
const viewport = document.getElementById("gd-viewport");
|
|
154
|
+
const world = document.getElementById("gd-world");
|
|
155
|
+
viewport.append(world);
|
|
156
|
+
viewport.rect = { left: 0, top: 50, width: 1000, height: 700 };
|
|
157
|
+
const windowListeners = new Map<string, Listener[]>();
|
|
158
|
+
const replies: Record<string, unknown> = {
|
|
159
|
+
"/t/api/system": { exists: false },
|
|
160
|
+
"/t/api/designs": { designs: [{ slug: "alpha", title: "Alpha", valid: true }] },
|
|
161
|
+
"/t/api/designs/alpha": {
|
|
162
|
+
slug: "alpha",
|
|
163
|
+
title: "Alpha",
|
|
164
|
+
brief: "",
|
|
165
|
+
artboards: [
|
|
166
|
+
{ file: "01-a.html", title: "First", tradeoff: "", width: 400, height: firstHeight },
|
|
167
|
+
{ file: "02-b.html", title: "Second", tradeoff: "", width: 400, height: 300 },
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
"/t/api/designs/alpha/comments": { comments: [] },
|
|
171
|
+
"/t/api/designs/alpha/handoffs": { version: 1, chosen: null, handoffs: {} },
|
|
172
|
+
"/t/api/designs/beta": {
|
|
173
|
+
slug: "beta",
|
|
174
|
+
title: "Beta",
|
|
175
|
+
brief: "",
|
|
176
|
+
artboards: [{ file: "01-only.html", title: "Only", tradeoff: "", width: 500, height: 400 }],
|
|
177
|
+
},
|
|
178
|
+
"/t/api/designs/beta/comments": { comments: [] },
|
|
179
|
+
"/t/api/designs/beta/handoffs": { version: 1, chosen: null, handoffs: {} },
|
|
180
|
+
...extraReplies,
|
|
181
|
+
};
|
|
182
|
+
const fetched: string[] = [];
|
|
183
|
+
const posts: Array<{ url: string; body: unknown }> = [];
|
|
184
|
+
const urls: string[] = [];
|
|
185
|
+
const sourceListeners = new Map<string, Listener[]>();
|
|
186
|
+
const context: Record<string, any> = {
|
|
187
|
+
document,
|
|
188
|
+
console,
|
|
189
|
+
// Canvas timers (toasts, the preview load timeout) must not keep the test process alive.
|
|
190
|
+
setTimeout: (callback: () => void, ms?: number) => { const timer = setTimeout(callback, ms); timer.unref(); return timer; },
|
|
191
|
+
clearTimeout,
|
|
192
|
+
location: { port: "4100", origin: "http://127.0.0.1:4100" },
|
|
193
|
+
URL,
|
|
194
|
+
innerWidth: 1200,
|
|
195
|
+
innerHeight: 800,
|
|
196
|
+
CSS: { escape: (value: string) => value },
|
|
197
|
+
history: { replaceState(_state: unknown, _title: string, url: string) { urls.push(url); } },
|
|
198
|
+
localStorage: { getItem: () => null, setItem() {} },
|
|
199
|
+
requestAnimationFrame: (callback: () => void) => { callback(); return 0; },
|
|
200
|
+
fetch: async (url: string, init?: { method?: string; body?: string }) => {
|
|
201
|
+
if (init?.method === "POST") posts.push({ url, body: JSON.parse(init.body ?? "null") });
|
|
202
|
+
fetched.push(url);
|
|
203
|
+
return { ok: url in replies, status: url in replies ? 200 : 404, json: async () => replies[url] ?? {} };
|
|
204
|
+
},
|
|
205
|
+
EventSource: class {
|
|
206
|
+
addEventListener(type: string, listener: Listener) {
|
|
207
|
+
sourceListeners.set(type, [...(sourceListeners.get(type) ?? []), listener]);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
addEventListener(type: string, listener: Listener) {
|
|
211
|
+
windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]);
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
context.window = context;
|
|
215
|
+
vm.runInNewContext(read("canvas.js"), context);
|
|
216
|
+
for (let i = 0; i < 10; i++) await flush();
|
|
217
|
+
const boards = world.children.map((wrapper) => {
|
|
218
|
+
const box = wrapper.children.find((child) => child.classes.has("gd-frame-box"))!;
|
|
219
|
+
const frame = box.children.find((child) => child.localName === "iframe")!;
|
|
220
|
+
const shield = box.children.find((child) => child.classes.has("gd-shield"));
|
|
221
|
+
return { wrapper, frame, shield };
|
|
222
|
+
});
|
|
223
|
+
const message = (source: unknown, data: unknown) => {
|
|
224
|
+
for (const listener of windowListeners.get("message") ?? []) listener({ source, data });
|
|
225
|
+
};
|
|
226
|
+
const windowEvent = (type: string) => {
|
|
227
|
+
for (const listener of windowListeners.get(type) ?? []) listener({ type });
|
|
228
|
+
};
|
|
229
|
+
/** A server-sent event on the canvas's live-reload stream, then the async work it starts. */
|
|
230
|
+
const serverEvent = async (type: string, data: unknown) => {
|
|
231
|
+
for (const listener of sourceListeners.get(type) ?? []) listener({ type, data: JSON.stringify(data) });
|
|
232
|
+
for (let i = 0; i < 10; i++) await flush();
|
|
233
|
+
};
|
|
234
|
+
const frames = () => world.children.map((wrapper) => wrapper.children.find((child) => child.classes.has("gd-frame-box"))!.children.find((child) => child.localName === "iframe")!);
|
|
235
|
+
return { document, viewport, world, boards, message, windowEvent, serverEvent, frames, fetched, posts, urls, replies, zoomLevel: document.getElementById("gd-zoom-level"), navMode: document.getElementById("gd-nav-mode") };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function click(target: FakeElement, x: number, y: number) {
|
|
239
|
+
target.dispatch("pointerdown", { button: 0, pointerId: 1, clientX: x, clientY: y });
|
|
240
|
+
target.dispatch("pointerup", { button: 0, pointerId: 1, clientX: x, clientY: y });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
test("a click on an artboard's shield enters focus mode at 100% and only that artboard can end it", async () => {
|
|
244
|
+
const canvas = await loadCanvas();
|
|
245
|
+
assert.equal(canvas.boards.length, 2);
|
|
246
|
+
const [first, second] = canvas.boards;
|
|
247
|
+
assert.ok(first.shield && second.shield, "every artboard has an interaction shield");
|
|
248
|
+
assert.equal(canvas.navMode.textContent, "Navigating");
|
|
249
|
+
|
|
250
|
+
// Start from a non-default view so restoring it is observable.
|
|
251
|
+
canvas.viewport.dispatch("wheel", { deltaX: 0, deltaY: 40, deltaMode: 0, ctrlKey: true, metaKey: false, shiftKey: false, clientX: 300, clientY: 300 });
|
|
252
|
+
canvas.viewport.dispatch("wheel", { deltaX: 30, deltaY: 20, deltaMode: 0, ctrlKey: false, metaKey: false, shiftKey: false, clientX: 300, clientY: 300 });
|
|
253
|
+
const before = canvas.world.style.transform;
|
|
254
|
+
const zoomBefore = canvas.zoomLevel.textContent;
|
|
255
|
+
assert.notEqual(zoomBefore, "100%");
|
|
256
|
+
|
|
257
|
+
first.frame.rect = { left: 120, top: 140, width: 268, height: 201 };
|
|
258
|
+
click(first.shield!, 200, 200);
|
|
259
|
+
assert.equal(canvas.zoomLevel.textContent, "100%");
|
|
260
|
+
assert.match(canvas.world.style.transform, /scale\(1\)$/);
|
|
261
|
+
assert.equal(first.shield!.hidden, true, "the focused artboard loses its shield");
|
|
262
|
+
assert.equal(second.shield!.hidden, false);
|
|
263
|
+
assert.equal(second.wrapper.inert, true, "the other artboards are inert");
|
|
264
|
+
assert.equal(canvas.document.activeElement, first.frame, "keyboard focus moves into the artboard");
|
|
265
|
+
assert.match(canvas.navMode.textContent, /^Using: First · Shift\+Esc to exit$/);
|
|
266
|
+
assert.deepEqual(first.frame.contentWindow!.posted.at(-1), { type: "gd:focus", enabled: true });
|
|
267
|
+
|
|
268
|
+
// Canvas pan and zoom are off while focused.
|
|
269
|
+
const focusedTransform = canvas.world.style.transform;
|
|
270
|
+
canvas.viewport.dispatch("wheel", { deltaX: 0, deltaY: 80, deltaMode: 0, ctrlKey: false, metaKey: false, shiftKey: false, clientX: 10, clientY: 10 });
|
|
271
|
+
canvas.message(first.frame.contentWindow, { type: "gd:wheel", deltaX: 0, deltaY: 80, deltaMode: 0, zoom: true, shift: false, x: 5, y: 5 });
|
|
272
|
+
assert.equal(canvas.world.style.transform, focusedTransform);
|
|
273
|
+
|
|
274
|
+
// gd:exit-focus is accepted only from the focused artboard's own window.
|
|
275
|
+
canvas.message(second.frame.contentWindow, { type: "gd:exit-focus" });
|
|
276
|
+
canvas.message({}, { type: "gd:exit-focus" });
|
|
277
|
+
assert.equal(canvas.zoomLevel.textContent, "100%");
|
|
278
|
+
canvas.message(first.frame.contentWindow, { type: "gd:exit-focus" });
|
|
279
|
+
assert.equal(canvas.world.style.transform, before, "the previous zoom and pan return exactly");
|
|
280
|
+
assert.equal(canvas.zoomLevel.textContent, zoomBefore);
|
|
281
|
+
assert.equal(first.shield!.hidden, false);
|
|
282
|
+
assert.equal(second.wrapper.inert, false);
|
|
283
|
+
assert.equal(canvas.navMode.textContent, "Navigating");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("a taller artboard is top-aligned and clipped to the viewport while focused, then restored", async () => {
|
|
287
|
+
const canvas = await loadCanvas(1600);
|
|
288
|
+
const [first] = canvas.boards;
|
|
289
|
+
assert.equal(first.frame.style.height, "1600px");
|
|
290
|
+
// In world coordinates the frame box sits at (480, 20), under a 20 px label.
|
|
291
|
+
first.frame.parentElement!.offsetLeft = 480;
|
|
292
|
+
first.frame.parentElement!.offsetTop = 20;
|
|
293
|
+
click(first.shield!, 200, 200);
|
|
294
|
+
// The 700 px viewport keeps a 16 px margin, the 20 px label and a 64 px band for the Back button.
|
|
295
|
+
assert.equal(first.frame.style.height, "600px");
|
|
296
|
+
// x centers the 400 px board in 1000 px: 300 - 480; y puts its top 16 + 20 px down: 36 - 20.
|
|
297
|
+
assert.equal(canvas.world.style.transform, "translate(-180px, 16px) scale(1)");
|
|
298
|
+
// Re-centering (window resize, panel toggle) is idempotent.
|
|
299
|
+
canvas.windowEvent("resize");
|
|
300
|
+
assert.equal(canvas.world.style.transform, "translate(-180px, 16px) scale(1)");
|
|
301
|
+
canvas.document.getElementById("gd-back").dispatch("click");
|
|
302
|
+
assert.equal(first.frame.style.height, "1600px");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("entering focus hides an open panel and exiting restores it; a closed panel stays closed", async () => {
|
|
306
|
+
const canvas = await loadCanvas();
|
|
307
|
+
const [first] = canvas.boards;
|
|
308
|
+
const panel = canvas.document.getElementById("gd-panel");
|
|
309
|
+
const toggle = canvas.document.getElementById("gd-panel-toggle");
|
|
310
|
+
assert.equal(panel.hidden, false);
|
|
311
|
+
click(first.shield!, 200, 200);
|
|
312
|
+
assert.equal(panel.hidden, true, "focus mode hides the open panel to gain width");
|
|
313
|
+
assert.equal(toggle.getAttribute("aria-pressed"), "false");
|
|
314
|
+
canvas.message(first.frame.contentWindow, { type: "gd:exit-focus" });
|
|
315
|
+
assert.equal(panel.hidden, false, "exiting reopens it");
|
|
316
|
+
assert.equal(toggle.getAttribute("aria-pressed"), "true");
|
|
317
|
+
|
|
318
|
+
toggle.dispatch("click");
|
|
319
|
+
assert.equal(panel.hidden, true);
|
|
320
|
+
click(first.shield!, 200, 200);
|
|
321
|
+
assert.equal(panel.hidden, true);
|
|
322
|
+
canvas.document.getElementById("gd-back").dispatch("click");
|
|
323
|
+
assert.equal(panel.hidden, true, "a closed panel stays closed");
|
|
324
|
+
assert.equal(toggle.getAttribute("aria-pressed"), "false");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("the Back to canvas button and Shift+Esc in the canvas both leave focus mode", async () => {
|
|
328
|
+
const canvas = await loadCanvas();
|
|
329
|
+
const [first] = canvas.boards;
|
|
330
|
+
const back = canvas.document.getElementById("gd-back");
|
|
331
|
+
assert.equal(back.hidden, true);
|
|
332
|
+
click(first.shield!, 200, 200);
|
|
333
|
+
assert.equal(back.hidden, false);
|
|
334
|
+
back.dispatch("click");
|
|
335
|
+
assert.equal(canvas.navMode.textContent, "Navigating");
|
|
336
|
+
assert.equal(back.hidden, true);
|
|
337
|
+
|
|
338
|
+
click(first.shield!, 200, 200);
|
|
339
|
+
const keydown = canvas.document.listeners.get("keydown") ?? [];
|
|
340
|
+
const plain: any = { key: "Escape", shiftKey: false, target: back, defaultPrevented: false, preventDefault() { plain.defaultPrevented = true; } };
|
|
341
|
+
keydown.forEach((listener) => listener(plain));
|
|
342
|
+
assert.match(canvas.navMode.textContent, /^Using:/, "plain Esc does not leave focus mode");
|
|
343
|
+
const chord: any = { key: "Escape", shiftKey: true, target: back, defaultPrevented: false, preventDefault() { chord.defaultPrevented = true; } };
|
|
344
|
+
keydown.forEach((listener) => listener(chord));
|
|
345
|
+
assert.equal(canvas.navMode.textContent, "Navigating");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("a drag over an artboard pans the canvas, and in comment mode a click is routed to the inspector as a pick", async () => {
|
|
349
|
+
const canvas = await loadCanvas();
|
|
350
|
+
const [first] = canvas.boards;
|
|
351
|
+
first.frame.rect = { left: 100, top: 100, width: 200, height: 150 };
|
|
352
|
+
const before = canvas.world.style.transform;
|
|
353
|
+
first.shield!.dispatch("pointerdown", { button: 0, pointerId: 1, clientX: 150, clientY: 150 });
|
|
354
|
+
first.shield!.dispatch("pointermove", { pointerId: 1, clientX: 190, clientY: 170 });
|
|
355
|
+
first.shield!.dispatch("pointerup", { button: 0, pointerId: 1, clientX: 190, clientY: 170 });
|
|
356
|
+
assert.notEqual(canvas.world.style.transform, before, "the drag panned");
|
|
357
|
+
assert.equal(canvas.navMode.textContent, "Navigating", "a drag never enters focus mode");
|
|
358
|
+
|
|
359
|
+
canvas.document.getElementById("gd-comment").dispatch("click");
|
|
360
|
+
click(first.shield!, 150, 130);
|
|
361
|
+
assert.equal(canvas.navMode.textContent, "Navigating", "comment mode picks instead of focusing");
|
|
362
|
+
// The frame shows a 400x300 artboard at half size: (150-100)/0.5, (130-100)/0.5.
|
|
363
|
+
assert.deepEqual(first.frame.contentWindow!.posted.at(-1), { type: "gd:pick-at", x: 100, y: 60 });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// S14: `open` while a canvas page is connected sends it a show event instead of opening a tab.
|
|
367
|
+
|
|
368
|
+
test("a show event for the same design re-fetches its manifest and reloads its artboards, keeping zoom, pan and focus", async () => {
|
|
369
|
+
const canvas = await loadCanvas();
|
|
370
|
+
// A laid-out world, so a fit() during the refresh would visibly change the view.
|
|
371
|
+
Object.defineProperty(canvas.world, "offsetWidth", { get: () => 2000 });
|
|
372
|
+
Object.defineProperty(canvas.world, "offsetHeight", { get: () => 900 });
|
|
373
|
+
canvas.viewport.dispatch("wheel", { deltaX: 0, deltaY: 40, deltaMode: 0, ctrlKey: true, metaKey: false, shiftKey: false, clientX: 300, clientY: 300 });
|
|
374
|
+
const view = canvas.world.style.transform;
|
|
375
|
+
const zoom = canvas.zoomLevel.textContent;
|
|
376
|
+
const before = canvas.frames();
|
|
377
|
+
const manifestFetches = canvas.fetched.filter((url) => url === "/t/api/designs/alpha").length;
|
|
378
|
+
// A file written while the page missed the change event: the refresh must still pick it up.
|
|
379
|
+
(canvas.replies["/t/api/designs/alpha"] as { artboards: unknown[] }).artboards.push({ file: "03-c.html", title: "Third", tradeoff: "", width: 400, height: 300 });
|
|
380
|
+
await canvas.serverEvent("show", { slug: "alpha" });
|
|
381
|
+
assert.equal(canvas.fetched.filter((url) => url === "/t/api/designs/alpha").length, manifestFetches + 1, "the manifest is fetched again");
|
|
382
|
+
const after = canvas.frames();
|
|
383
|
+
assert.equal(after.length, 3, "the new artboard appears");
|
|
384
|
+
assert.ok(after.every((frame) => !before.includes(frame)), "every artboard is reloaded");
|
|
385
|
+
assert.equal(canvas.world.style.transform, view, "pan and zoom stay");
|
|
386
|
+
assert.equal(canvas.zoomLevel.textContent, zoom);
|
|
387
|
+
assert.equal(canvas.urls.at(-1), "/t/d/alpha/");
|
|
388
|
+
|
|
389
|
+
// Focus mode survives a refresh of the same design, on the same artboard.
|
|
390
|
+
const shield = canvas.frames()[0].parentElement!.children.find((child) => child.classes.has("gd-shield"))!;
|
|
391
|
+
click(shield, 200, 200);
|
|
392
|
+
assert.match(canvas.navMode.textContent, /^Using: First/);
|
|
393
|
+
await canvas.serverEvent("show", { slug: "alpha" });
|
|
394
|
+
assert.match(canvas.navMode.textContent, /^Using: First/, "focus stays on the same artboard");
|
|
395
|
+
assert.equal(canvas.zoomLevel.textContent, "100%");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("a show event for another design leaves focus, loads that design and replaces the URL", async () => {
|
|
399
|
+
const canvas = await loadCanvas();
|
|
400
|
+
const [first] = canvas.boards;
|
|
401
|
+
click(first.shield!, 200, 200);
|
|
402
|
+
assert.match(canvas.navMode.textContent, /^Using:/);
|
|
403
|
+
await canvas.serverEvent("show", { slug: "beta" });
|
|
404
|
+
assert.ok(canvas.fetched.includes("/t/api/designs/beta"), "the other design's manifest is loaded");
|
|
405
|
+
assert.ok(canvas.fetched.includes("/t/api/designs/beta/comments"), "and its comments");
|
|
406
|
+
assert.equal(canvas.urls.at(-1), "/t/d/beta/");
|
|
407
|
+
assert.equal(canvas.document.getElementById("gd-title").textContent, "Beta");
|
|
408
|
+
assert.equal(canvas.document.getElementById("gd-design").value, "beta", "the design selector follows");
|
|
409
|
+
assert.equal(canvas.navMode.textContent, "Navigating", "focus mode ends when the design changes");
|
|
410
|
+
assert.deepEqual(canvas.frames().map((frame) => frame.title), ["Only artboard"]);
|
|
411
|
+
|
|
412
|
+
await canvas.serverEvent("show", { slug: null });
|
|
413
|
+
assert.equal(canvas.urls.at(-1), "/t/", "null shows the system-only view at the root URL");
|
|
414
|
+
assert.equal(canvas.document.getElementById("gd-title").textContent, "Gentle Design");
|
|
415
|
+
assert.deepEqual(canvas.frames(), []);
|
|
416
|
+
|
|
417
|
+
const fetches = canvas.fetched.length;
|
|
418
|
+
for (const data of [{ slug: 3 }, {}, "beta", { slug: "../x" }]) await canvas.serverEvent("show", data);
|
|
419
|
+
assert.equal(canvas.fetched.length, fetches, "a malformed show event is ignored");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
function loadInspector() {
|
|
423
|
+
const posted: any[] = [];
|
|
424
|
+
const listeners = new Map<string, Listener[]>();
|
|
425
|
+
class Element {}
|
|
426
|
+
const style = () => ({ cursor: "" });
|
|
427
|
+
const makeNode = () => Object.assign(new Element(), {
|
|
428
|
+
style: style(),
|
|
429
|
+
isConnected: false,
|
|
430
|
+
setAttribute() {},
|
|
431
|
+
hasAttribute: () => false,
|
|
432
|
+
getBoundingClientRect: () => ({ left: 0, top: 0, width: 10, height: 10 }),
|
|
433
|
+
});
|
|
434
|
+
const button = Object.assign(new Element(), {
|
|
435
|
+
id: "cta",
|
|
436
|
+
localName: "button",
|
|
437
|
+
innerText: "Buy now",
|
|
438
|
+
parentElement: null,
|
|
439
|
+
hasAttribute: () => false,
|
|
440
|
+
getAttribute: () => null,
|
|
441
|
+
getBoundingClientRect: () => ({ left: 4, top: 8, width: 60, height: 20 }),
|
|
442
|
+
});
|
|
443
|
+
const scrolling = { scrollTop: 0, scrollLeft: 0, clientHeight: 100, scrollHeight: 100, clientWidth: 100, scrollWidth: 100, parentElement: null };
|
|
444
|
+
const documentElement = Object.assign(makeNode(), { append() {} });
|
|
445
|
+
const document = {
|
|
446
|
+
documentElement,
|
|
447
|
+
body: makeNode(),
|
|
448
|
+
scrollingElement: scrolling,
|
|
449
|
+
createElement: makeNode,
|
|
450
|
+
elementFromPoint: () => button,
|
|
451
|
+
querySelectorAll: (selector: string) => (selector === "#cta" ? [button] : []),
|
|
452
|
+
querySelector: () => null,
|
|
453
|
+
};
|
|
454
|
+
const parent = { postMessage: (message: any) => posted.push(structuredCopy(message)) };
|
|
455
|
+
const context: Record<string, any> = {
|
|
456
|
+
document,
|
|
457
|
+
Element,
|
|
458
|
+
CSS: { escape: (value: string) => value },
|
|
459
|
+
getComputedStyle: () => ({ overflowX: "hidden", overflowY: "hidden" }),
|
|
460
|
+
requestAnimationFrame: () => 0,
|
|
461
|
+
MutationObserver: class { observe() {} },
|
|
462
|
+
parent,
|
|
463
|
+
addEventListener(type: string, listener: Listener) {
|
|
464
|
+
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
context.window = context;
|
|
468
|
+
vm.runInNewContext(read("inspector.js"), context);
|
|
469
|
+
const fire = (type: string, init: Record<string, unknown>) => {
|
|
470
|
+
const event: any = { defaultPrevented: false, propagationStopped: false, ...init, preventDefault() { event.defaultPrevented = true; }, stopPropagation() { event.propagationStopped = true; }, stopImmediatePropagation() { event.propagationStopped = true; } };
|
|
471
|
+
for (const listener of listeners.get(type) ?? []) listener(event);
|
|
472
|
+
return event;
|
|
473
|
+
};
|
|
474
|
+
const fromParent = (data: unknown) => fire("message", { source: parent, data });
|
|
475
|
+
return { posted, fire, fromParent };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
test("the inspector posts gd:exit-focus on Shift+Esc and keeps plain Esc for the design", () => {
|
|
479
|
+
const inspector = loadInspector();
|
|
480
|
+
const plain = inspector.fire("keydown", { key: "Escape", shiftKey: false });
|
|
481
|
+
assert.deepEqual(inspector.posted, []);
|
|
482
|
+
assert.equal(plain.defaultPrevented, false);
|
|
483
|
+
const chord = inspector.fire("keydown", { key: "Escape", shiftKey: true });
|
|
484
|
+
assert.deepEqual(inspector.posted, [{ type: "gd:exit-focus" }]);
|
|
485
|
+
assert.equal(chord.defaultPrevented, true);
|
|
486
|
+
inspector.fire("keydown", { key: "Enter", shiftKey: true });
|
|
487
|
+
assert.equal(inspector.posted.length, 1);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("the inspector picks the element under a routed point only in comment mode", () => {
|
|
491
|
+
const inspector = loadInspector();
|
|
492
|
+
inspector.fromParent({ type: "gd:pick-at", x: 10, y: 12 });
|
|
493
|
+
assert.deepEqual(inspector.posted, [], "no pick outside comment mode");
|
|
494
|
+
inspector.fromParent({ type: "gd:mode", enabled: true });
|
|
495
|
+
inspector.fromParent({ type: "gd:pick-at", x: 10, y: 12 });
|
|
496
|
+
assert.deepEqual(inspector.posted, [{ type: "gd:pick", selector: "#cta", excerpt: "Buy now", rect: { x: 4, y: 8, width: 60, height: 20 } }]);
|
|
497
|
+
inspector.fromParent({ type: "gd:pick-at", x: "10", y: 12 });
|
|
498
|
+
assert.equal(inspector.posted.length, 1, "non-numeric points are ignored");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("in focus mode the inspector leaves the wheel to the artboard and never pans the canvas", () => {
|
|
502
|
+
const inspector = loadInspector();
|
|
503
|
+
const wheel = { deltaX: 0, deltaY: 50, deltaMode: 0, shiftKey: false, clientX: 1, clientY: 1, target: null };
|
|
504
|
+
const unfocused = inspector.fire("wheel", { ...wheel, ctrlKey: false, metaKey: false });
|
|
505
|
+
assert.equal(unfocused.defaultPrevented, true);
|
|
506
|
+
assert.equal(inspector.posted.at(-1).type, "gd:wheel");
|
|
507
|
+
inspector.posted.length = 0;
|
|
508
|
+
inspector.fromParent({ type: "gd:focus", enabled: true });
|
|
509
|
+
const plain = inspector.fire("wheel", { ...wheel, ctrlKey: false, metaKey: false });
|
|
510
|
+
assert.equal(plain.defaultPrevented, false, "the artboard scrolls natively");
|
|
511
|
+
const zoom = inspector.fire("wheel", { ...wheel, ctrlKey: true, metaKey: false });
|
|
512
|
+
assert.equal(zoom.defaultPrevented, true, "Ctrl/Cmd+wheel never zooms the browser page");
|
|
513
|
+
assert.deepEqual(inspector.posted, []);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("canvas assets keep the sandbox, the source check and no remote URLs", () => {
|
|
517
|
+
const canvas = read("canvas.js");
|
|
518
|
+
assert.match(canvas, /frame\.setAttribute\("sandbox", "allow-scripts"\)/);
|
|
519
|
+
assert.match(canvas, /candidate\.frame\.contentWindow === event\.source/);
|
|
520
|
+
// Only the Compare preview of the running app (another loopback origin) may keep its own origin.
|
|
521
|
+
assert.equal(canvas.split("allow-same-origin").length - 1, 1);
|
|
522
|
+
assert.match(canvas, /const PREVIEW_SANDBOX = "allow-scripts allow-same-origin allow-forms allow-popups";/);
|
|
523
|
+
for (const name of ["index.html", "canvas.css", "canvas.js", "inspector.js"]) {
|
|
524
|
+
const source = read(name);
|
|
525
|
+
assert.ok(!source.includes("allow-top-navigation"), `${name} must not grant top navigation`);
|
|
526
|
+
if (name !== "canvas.js") assert.ok(!source.includes("allow-same-origin"), `${name} must not grant allow-same-origin`);
|
|
527
|
+
assert.ok(!/https?:\/\//i.test(source), `${name} must not reference remote URLs`);
|
|
528
|
+
assert.ok(!/innerHTML|outerHTML|insertAdjacentHTML/.test(source), `${name} must not parse HTML strings`);
|
|
529
|
+
}
|
|
530
|
+
const html = read("index.html");
|
|
531
|
+
assert.match(html, /id="gd-back"[^>]*>Back to canvas</);
|
|
532
|
+
assert.match(html, /id="gd-nav-mode"[^>]*>Navigating</);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// S15: handoff badges, the chosen artboard, and Compare.
|
|
536
|
+
|
|
537
|
+
/** Every element under `root` (itself included) that matches `test`. */
|
|
538
|
+
function findAll(root: FakeElement, test: (element: FakeElement) => boolean): FakeElement[] {
|
|
539
|
+
const found: FakeElement[] = test(root) ? [root] : [];
|
|
540
|
+
for (const child of root.children) found.push(...findAll(child, test));
|
|
541
|
+
return found;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const withClass = (root: FakeElement, name: string) => findAll(root, (element) => element.classes.has(name));
|
|
545
|
+
const texts = (root: FakeElement, name: string) => withClass(root, name).map((element) => element.textContent);
|
|
546
|
+
|
|
547
|
+
function handoffDoc(chosen: string | null, handoffs: Record<string, Record<string, unknown>>) {
|
|
548
|
+
const entries = Object.fromEntries(Object.entries(handoffs).map(([file, entry]) => [file, { status: "sent", queued: false, sentAt: "2026-10-26T10:00:00.000Z", startedAt: null, implementedAt: null, ...entry }]));
|
|
549
|
+
return { version: 1, chosen, handoffs: entries };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function setHandoffs(canvas: Awaited<ReturnType<typeof loadCanvas>>, document: unknown) {
|
|
553
|
+
canvas.replies["/t/api/designs/alpha/handoffs"] = document;
|
|
554
|
+
await canvas.serverEvent("change", { slug: "alpha", file: "handoffs.json" });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const boardsOf = (canvas: Awaited<ReturnType<typeof loadCanvas>>) => canvas.world.children;
|
|
558
|
+
|
|
559
|
+
test("the handoff badge follows the state live, and shows the linked feature document", async () => {
|
|
560
|
+
const canvas = await loadCanvas(300, { "/t/api/designs/alpha/handoffs": handoffDoc("01-a.html", { "01-a.html": { queued: true, feature: "odd/tasks/login.md" } }) });
|
|
561
|
+
const [first, second] = boardsOf(canvas);
|
|
562
|
+
assert.deepEqual(texts(first, "gd-handoff-badge"), ["Queued"]);
|
|
563
|
+
assert.deepEqual(texts(first, "gd-handoff-feature"), ["odd/tasks/login.md"]);
|
|
564
|
+
assert.deepEqual(texts(second, "gd-handoff-badge"), [], "an artboard never handed off has no badge");
|
|
565
|
+
const states: Array<[Record<string, unknown>, string]> = [
|
|
566
|
+
[{ queued: false }, "Implementing…"],
|
|
567
|
+
[{ status: "in_progress", startedAt: "2026-10-26T10:01:00.000Z" }, "In progress"],
|
|
568
|
+
[{ status: "implemented", implementedAt: "2026-10-26T10:02:00.000Z", note: "Done with the header" }, "Implemented ✓"],
|
|
569
|
+
];
|
|
570
|
+
for (const [entry, label] of states) {
|
|
571
|
+
await setHandoffs(canvas, handoffDoc("01-a.html", { "01-a.html": { feature: "odd/tasks/login.md", ...entry } }));
|
|
572
|
+
assert.deepEqual(texts(boardsOf(canvas)[0], "gd-handoff-badge"), [label], label);
|
|
573
|
+
}
|
|
574
|
+
assert.deepEqual(texts(boardsOf(canvas)[0], "gd-handoff-note"), ["Done with the header"]);
|
|
575
|
+
assert.ok(canvas.fetched.filter((url) => url === "/t/api/designs/alpha/handoffs").length >= 4, "every handoffs.json change is fetched");
|
|
576
|
+
assert.equal(boardsOf(canvas)[0], first, "a handoffs.json change does not reload the artboards");
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("the chosen artboard gets a Chosen badge and an accent frame; the others are labeled Alternative", async () => {
|
|
580
|
+
const canvas = await loadCanvas(300, { "/t/api/designs/alpha/handoffs": handoffDoc(null, {}) });
|
|
581
|
+
for (const board of boardsOf(canvas)) {
|
|
582
|
+
assert.deepEqual(texts(board, "gd-choice"), [], "nothing is chosen yet");
|
|
583
|
+
assert.equal(board.classes.has("gd-chosen"), false);
|
|
584
|
+
}
|
|
585
|
+
await setHandoffs(canvas, handoffDoc("02-b.html", { "02-b.html": {} }));
|
|
586
|
+
const [first, second] = boardsOf(canvas);
|
|
587
|
+
assert.deepEqual(texts(second, "gd-choice"), ["Chosen"]);
|
|
588
|
+
assert.equal(second.classes.has("gd-chosen"), true);
|
|
589
|
+
assert.deepEqual(texts(first, "gd-choice"), ["Alternative"]);
|
|
590
|
+
assert.equal(first.classes.has("gd-chosen"), false);
|
|
591
|
+
await setHandoffs(canvas, handoffDoc("01-a.html", { "02-b.html": {}, "01-a.html": {} }));
|
|
592
|
+
assert.deepEqual(boardsOf(canvas).map((board) => texts(board, "gd-choice")[0]), ["Chosen", "Alternative"], "a new handoff moves Chosen");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("Compare appears only on an implemented artboard with a loopback previewUrl other than the canvas", async () => {
|
|
596
|
+
const canvas = await loadCanvas();
|
|
597
|
+
const compareButtons = () => boardsOf(canvas).map((board) => withClass(board, "gd-compare-start").length);
|
|
598
|
+
const cases: Array<[Record<string, unknown>, number]> = [
|
|
599
|
+
[{ status: "implemented" }, 0],
|
|
600
|
+
[{ status: "in_progress", previewUrl: "http://localhost:3000/" }, 0],
|
|
601
|
+
[{ status: "implemented", previewUrl: "http://example.com/" }, 0],
|
|
602
|
+
[{ status: "implemented", previewUrl: "http://localhost:4100/" }, 0],
|
|
603
|
+
[{ status: "implemented", previewUrl: "javascript:alert(1)" }, 0],
|
|
604
|
+
[{ status: "implemented", previewUrl: "http://localhost:3000/" }, 1],
|
|
605
|
+
[{ status: "implemented", previewUrl: "http://[::1]:3000/" }, 1],
|
|
606
|
+
];
|
|
607
|
+
for (const [entry, count] of cases) {
|
|
608
|
+
await setHandoffs(canvas, handoffDoc("01-a.html", { "01-a.html": entry }));
|
|
609
|
+
assert.deepEqual(compareButtons(), [count, 0], JSON.stringify(entry));
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("Compare shows the artboard and the sandboxed app side by side at the artboard size, and Shift+Esc or Back exits", async () => {
|
|
614
|
+
const canvas = await loadCanvas(300, { "/t/api/designs/alpha/handoffs": handoffDoc("01-a.html", { "01-a.html": { status: "implemented", previewUrl: "http://localhost:5173/login" } }) });
|
|
615
|
+
const overlay = canvas.document.getElementById("gd-compare");
|
|
616
|
+
const panes = canvas.document.getElementById("gd-compare-panes");
|
|
617
|
+
const open = () => withClass(boardsOf(canvas)[0], "gd-compare-start")[0].dispatch("click");
|
|
618
|
+
open();
|
|
619
|
+
assert.equal(overlay.hidden, false);
|
|
620
|
+
const frames = findAll(panes, (element) => element.localName === "iframe");
|
|
621
|
+
assert.equal(frames.length, 2);
|
|
622
|
+
const [artboard, preview] = frames;
|
|
623
|
+
assert.equal(artboard.getAttribute("sandbox"), "allow-scripts", "the artboard stays sandboxed as on the canvas");
|
|
624
|
+
assert.equal(artboard.src, "/t/files/alpha/01-a.html");
|
|
625
|
+
assert.equal(preview.getAttribute("sandbox"), "allow-scripts allow-same-origin allow-forms allow-popups");
|
|
626
|
+
assert.equal(preview.getAttribute("referrerpolicy"), "no-referrer");
|
|
627
|
+
assert.equal(preview.src, "http://localhost:5173/login");
|
|
628
|
+
for (const frame of frames) assert.deepEqual([frame.style.width, frame.style.height], ["400px", "300px"]);
|
|
629
|
+
const link = canvas.document.getElementById("gd-compare-open");
|
|
630
|
+
assert.equal(link.href, "http://localhost:5173/login");
|
|
631
|
+
assert.equal(link.getAttribute("rel"), "noopener noreferrer");
|
|
632
|
+
assert.equal(link.getAttribute("target"), "_blank");
|
|
633
|
+
assert.equal(canvas.viewport.inert, true, "the canvas underneath is inert");
|
|
634
|
+
|
|
635
|
+
const keydown = canvas.document.listeners.get("keydown") ?? [];
|
|
636
|
+
const press = (shiftKey: boolean) => {
|
|
637
|
+
const event: any = { key: "Escape", shiftKey, target: canvas.document.body, defaultPrevented: false, preventDefault() { event.defaultPrevented = true; } };
|
|
638
|
+
keydown.forEach((listener) => listener(event));
|
|
639
|
+
};
|
|
640
|
+
press(false);
|
|
641
|
+
assert.equal(overlay.hidden, false, "plain Esc stays");
|
|
642
|
+
press(true);
|
|
643
|
+
assert.equal(overlay.hidden, true, "Shift+Esc exits Compare");
|
|
644
|
+
assert.deepEqual(panes.children, [], "the app frame is removed");
|
|
645
|
+
assert.equal(canvas.viewport.inert, false);
|
|
646
|
+
|
|
647
|
+
open();
|
|
648
|
+
canvas.document.getElementById("gd-compare-back").dispatch("click");
|
|
649
|
+
assert.equal(overlay.hidden, true, "Back to canvas exits Compare");
|
|
650
|
+
|
|
651
|
+
open();
|
|
652
|
+
const [shownArtboard] = findAll(panes, (element) => element.localName === "iframe");
|
|
653
|
+
canvas.message({}, { type: "gd:exit-focus" });
|
|
654
|
+
assert.equal(overlay.hidden, false, "only the compared artboard may ask to exit");
|
|
655
|
+
canvas.message(shownArtboard.contentWindow, { type: "gd:exit-focus" });
|
|
656
|
+
assert.equal(overlay.hidden, true, "Shift+Esc inside the artboard exits Compare");
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test("Compare offers scale to fit when the pair is wider than the window", async () => {
|
|
660
|
+
const canvas = await loadCanvas(300, { "/t/api/designs/alpha/handoffs": handoffDoc("01-a.html", { "01-a.html": { status: "implemented", previewUrl: "http://localhost:3000/" } }) });
|
|
661
|
+
const stage = canvas.document.getElementById("gd-compare-stage");
|
|
662
|
+
stage.rect = { left: 0, top: 0, width: 500, height: 600 };
|
|
663
|
+
withClass(boardsOf(canvas)[0], "gd-compare-start")[0].dispatch("click");
|
|
664
|
+
const fit = canvas.document.getElementById("gd-compare-fit");
|
|
665
|
+
const field = canvas.document.getElementById("gd-compare-fit-field");
|
|
666
|
+
const panes = canvas.document.getElementById("gd-compare-panes");
|
|
667
|
+
assert.equal(field.hidden, false, "two 400 px boards do not fit in 500 px");
|
|
668
|
+
assert.equal(panes.style.transform, "none", "Compare starts at 100%");
|
|
669
|
+
fit.checked = true;
|
|
670
|
+
fit.dispatch("change");
|
|
671
|
+
assert.match(panes.style.transform, /^scale\(0\.\d+\)$/);
|
|
672
|
+
canvas.document.getElementById("gd-compare-back").dispatch("click");
|
|
673
|
+
stage.rect = { left: 0, top: 0, width: 2000, height: 600 };
|
|
674
|
+
withClass(boardsOf(canvas)[0], "gd-compare-start")[0].dispatch("click");
|
|
675
|
+
assert.equal(field.hidden, true, "no toggle when the pair fits");
|
|
676
|
+
assert.equal(panes.style.transform, "none");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("Implement this shows a toast and the badge at once: Queued when busy, Implementing… when idle", async () => {
|
|
680
|
+
const canvas = await loadCanvas(300, { "/t/api/designs/alpha/implement": { ok: true, slug: "alpha", file: "01-a.html", queued: true } });
|
|
681
|
+
const toast = canvas.document.getElementById("gd-toast");
|
|
682
|
+
assert.match(read("index.html"), /<div id="gd-toast" class="gd-toast" role="status" aria-live="polite" hidden><\/div>/, "the toast starts hidden");
|
|
683
|
+
const send = async () => {
|
|
684
|
+
const [control] = withClass(boardsOf(canvas)[0], "gd-implement");
|
|
685
|
+
withClass(control, "gd-implement-start")[0].dispatch("click");
|
|
686
|
+
const yes = withClass(control, "gd-primary")[0];
|
|
687
|
+
yes.dispatch("click");
|
|
688
|
+
for (let i = 0; i < 10; i++) await flush();
|
|
689
|
+
};
|
|
690
|
+
await send();
|
|
691
|
+
assert.deepEqual(canvas.posts.at(-1), { url: "/t/api/designs/alpha/implement", body: { file: "01-a.html" } });
|
|
692
|
+
assert.equal(toast.hidden, false);
|
|
693
|
+
assert.equal(toast.textContent, "Queued: the session will implement First after its current task.");
|
|
694
|
+
assert.deepEqual(texts(boardsOf(canvas)[0], "gd-handoff-badge"), ["Queued"]);
|
|
695
|
+
assert.deepEqual(texts(boardsOf(canvas)[0], "gd-choice"), ["Chosen"]);
|
|
696
|
+
assert.deepEqual(texts(boardsOf(canvas)[1], "gd-choice"), ["Alternative"]);
|
|
697
|
+
|
|
698
|
+
canvas.replies["/t/api/designs/alpha/implement"] = { ok: true, slug: "alpha", file: "01-a.html", queued: false };
|
|
699
|
+
await send();
|
|
700
|
+
assert.equal(toast.textContent, "Sent: the session is implementing First now.");
|
|
701
|
+
assert.deepEqual(texts(boardsOf(canvas)[0], "gd-handoff-badge"), ["Implementing…"]);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("an [::1] preview says browsers refuse to frame it, and keeps Open in new tab", async () => {
|
|
705
|
+
const canvas = await loadCanvas(300, { "/t/api/designs/alpha/handoffs": handoffDoc("01-a.html", { "01-a.html": { status: "implemented", previewUrl: "http://[::1]:3000/" } }) });
|
|
706
|
+
withClass(boardsOf(canvas)[0], "gd-compare-start")[0].dispatch("click");
|
|
707
|
+
const message = canvas.document.getElementById("gd-compare-message");
|
|
708
|
+
assert.equal(message.textContent, "Browsers do not frame [::1] under the canvas policy (CSP has no IPv6 sources): use Open in new tab, or report the preview on localhost.");
|
|
709
|
+
assert.equal(canvas.document.getElementById("gd-compare-open").href, "http://[::1]:3000/");
|
|
710
|
+
});
|