@drawcall/feedback 0.0.0-stage → 0.1.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.
Files changed (82) hide show
  1. package/README.md +47 -2
  2. package/dist/api.d.ts +328 -0
  3. package/dist/api.js +202 -0
  4. package/dist/browser/device.d.ts +13 -0
  5. package/dist/browser/device.js +64 -0
  6. package/dist/browser/ending.d.ts +8 -0
  7. package/dist/browser/ending.js +241 -0
  8. package/dist/browser/frame.d.ts +27 -0
  9. package/dist/browser/frame.js +165 -0
  10. package/dist/browser/input.d.ts +45 -0
  11. package/dist/browser/input.js +173 -0
  12. package/dist/browser/intro.d.ts +13 -0
  13. package/dist/browser/intro.js +172 -0
  14. package/dist/browser/mic.d.ts +20 -0
  15. package/dist/browser/mic.js +98 -0
  16. package/dist/browser/pictures/LICENSE +24 -0
  17. package/dist/browser/pictures/controller.webp +0 -0
  18. package/dist/browser/pictures/hand.webp +0 -0
  19. package/dist/browser/run.d.ts +32 -0
  20. package/dist/browser/run.js +78 -0
  21. package/dist/browser/server.d.ts +22 -0
  22. package/dist/browser/server.js +147 -0
  23. package/dist/browser/session.d.ts +31 -0
  24. package/dist/browser/session.js +101 -0
  25. package/dist/browser/sounds/end.m4a +0 -0
  26. package/dist/browser/sounds/failed.m4a +0 -0
  27. package/dist/browser/sounds/hold-controller.m4a +0 -0
  28. package/dist/browser/sounds/hold-hand.m4a +0 -0
  29. package/dist/browser/sounds/nomic.m4a +0 -0
  30. package/dist/browser/sounds/sending.m4a +0 -0
  31. package/dist/browser/sounds/show-controller.m4a +0 -0
  32. package/dist/browser/sounds/show-hand.m4a +0 -0
  33. package/dist/browser/sounds/show.m4a +0 -0
  34. package/dist/browser/sounds/speak-controller.m4a +0 -0
  35. package/dist/browser/sounds/speak-hand.m4a +0 -0
  36. package/dist/browser/sounds/thanks.m4a +0 -0
  37. package/dist/browser/sounds/unheard.m4a +0 -0
  38. package/dist/browser/sounds/walkthrough/a-welcome.m4a +0 -0
  39. package/dist/browser/sounds/walkthrough/b-controller.m4a +0 -0
  40. package/dist/browser/sounds/walkthrough/b-hand.m4a +0 -0
  41. package/dist/browser/sounds/walkthrough/b-key.m4a +0 -0
  42. package/dist/browser/sounds/walkthrough/b-touch.m4a +0 -0
  43. package/dist/browser/sounds/walkthrough/b-type-key.m4a +0 -0
  44. package/dist/browser/sounds/walkthrough/b-type-touch.m4a +0 -0
  45. package/dist/browser/sounds/walkthrough/c-key.m4a +0 -0
  46. package/dist/browser/sounds/walkthrough/c-quest.m4a +0 -0
  47. package/dist/browser/sounds/walkthrough/c-touch.m4a +0 -0
  48. package/dist/browser/sounds/walkthrough/d-key.m4a +0 -0
  49. package/dist/browser/sounds/walkthrough/d-quest.m4a +0 -0
  50. package/dist/browser/sounds/walkthrough/d-touch.m4a +0 -0
  51. package/dist/browser/sounds.d.ts +140 -0
  52. package/dist/browser/sounds.js +118 -0
  53. package/dist/browser/style.d.ts +10 -0
  54. package/dist/browser/style.js +214 -0
  55. package/dist/browser/turn.d.ts +23 -0
  56. package/dist/browser/turn.js +274 -0
  57. package/dist/browser/ui.d.ts +88 -0
  58. package/dist/browser/ui.js +291 -0
  59. package/dist/browser/wait.d.ts +10 -0
  60. package/dist/browser/wait.js +32 -0
  61. package/dist/browser/walkthrough.d.ts +12 -0
  62. package/dist/browser/walkthrough.js +137 -0
  63. package/dist/browser/xr.d.ts +10 -0
  64. package/dist/browser/xr.js +99 -0
  65. package/dist/cli/bin.d.ts +2 -0
  66. package/dist/cli/bin.js +12 -0
  67. package/dist/cli/commands.d.ts +19 -0
  68. package/dist/cli/commands.js +153 -0
  69. package/dist/cli/files.d.ts +21 -0
  70. package/dist/cli/files.js +62 -0
  71. package/dist/cli/index.d.ts +14 -0
  72. package/dist/cli/index.js +94 -0
  73. package/dist/index.d.ts +109 -0
  74. package/dist/index.js +75 -0
  75. package/dist/notes.d.ts +12 -0
  76. package/dist/notes.js +23 -0
  77. package/dist/service.d.ts +53 -0
  78. package/dist/service.js +55 -0
  79. package/dist/tester.d.ts +39 -0
  80. package/dist/tester.js +84 -0
  81. package/package.json +54 -4
  82. package/skills/feedback/SKILL.md +117 -0
@@ -0,0 +1,291 @@
1
+ import { LIMITS } from "../service.js";
2
+ import { hotkey, isHotkey } from "./input.js";
3
+ import { paint, STYLE } from "./style.js";
4
+ import { wait } from "./wait.js";
5
+ /** The typing sheet's heading. */
6
+ export const PROMPT = "What feels off?";
7
+ /**
8
+ * What a click of the corner button counts as. On desktop it is the key, for players whose key below Esc is unusable
9
+ * (a Japanese IME toggle) or whose browser can't name it. Quest Browser shows none, since its feedback starts in VR.
10
+ */
11
+ const CORNER = { desktop: "key", phone: "touch", quest: null };
12
+ const FADE_MS = 300;
13
+ /** Each action's glyph and short label on the corner button. */
14
+ const FACES = {
15
+ "Give feedback": [`<circle cx="12" cy="12" r="5" style="fill: var(--record)"/>`, "Feedback"],
16
+ // A stop square, breathing while it records.
17
+ "Send recording": [`<rect x="7.5" y="7.5" width="9" height="9" rx="2" style="fill: var(--record)"/>`, "Send"],
18
+ "Take screenshot": [
19
+ `<path d="M5 9V7a2 2 0 0 1 2-2h2m6 0h2a2 2 0 0 1 2 2v2m0 6v2a2 2 0 0 1-2 2h-2m-6 0H7a2 2 0 0 1-2-2v-2" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/><circle cx="12" cy="12" r="2.25" fill="currentColor"/>`,
20
+ "Take screenshot",
21
+ ],
22
+ };
23
+ /** An element with `props` and `children`; null children are left out. */
24
+ export function h(tag, props = {}, ...children) {
25
+ const element = Object.assign(document.createElement(tag), props);
26
+ element.append(...children.filter(child => child !== null));
27
+ return element;
28
+ }
29
+ export const kbd = (key) => h("kbd", { textContent: key });
30
+ export const button = (className, label) => h("button", { className, type: "button" }, label);
31
+ /** The corner button's face: a glyph, what a press does, and on a keyboard the key that does it. */
32
+ export function face(kind, action) {
33
+ const [glyph, label] = FACES[action];
34
+ return `<svg viewBox="0 0 24 24" aria-hidden="true">${glyph}</svg><span>${label}</span>${kind === "key" ? kbd(hotkey).outerHTML : ""}`;
35
+ }
36
+ /**
37
+ * Puts the lines of `view` on a plate; `ticks` collects what redraws their `{ tick }` parts, which screen readers skip
38
+ * since feedback mode's plate announces every change.
39
+ */
40
+ export function plate({ big, small, recording, waiting }, ticks = [], lines = h("div", { className: "lines" })) {
41
+ const paragraph = (line) => h("p", {}, ...line.map(part => {
42
+ if (typeof part === "string")
43
+ return part;
44
+ if ("key" in part)
45
+ return kbd(part.key);
46
+ const span = h("span", { className: "tick", textContent: part.tick() });
47
+ span.setAttribute("aria-hidden", "true");
48
+ ticks.push(() => (span.textContent = part.tick()));
49
+ return span;
50
+ }));
51
+ lines.replaceChildren(paragraph(big), ...(small ? [paragraph(small)] : []), ...(waiting ? [h("div", { className: "bar" })] : []));
52
+ if (recording)
53
+ lines.firstElementChild?.prepend(h("span", { className: "dot" }));
54
+ return lines;
55
+ }
56
+ /**
57
+ * Everything the SDK shows, in one shadow root on the page: the cover, feedback mode and the corner button. DOM only,
58
+ * so never in the screenshot, which is drawn from the canvas; in XR none of it is visible and sounds carry the flow.
59
+ * Keys and presses inside it don't reach the game, so one that locks the pointer or shoots on a click doesn't take them
60
+ * too; the SDK's own window capture listeners still see them. Releases still reach the game, so nothing held when it
61
+ * appeared stays stuck down. Gone on abort.
62
+ */
63
+ export class Ui {
64
+ root;
65
+ theme;
66
+ /** Whether a turn holds the screen, whose captions and sounds an earlier turn's late outcome must leave alone. */
67
+ busy = false;
68
+ #mode = h("div", { className: "mode", hidden: true });
69
+ #lines = h("div", { className: "lines", role: "status" });
70
+ #corner = null;
71
+ #timer;
72
+ #ticker;
73
+ /** What the tester typed into a sheet they cancelled, kept for the next one. */
74
+ #draft = "";
75
+ /** The corner button's clicks call `press` with the input they count as. */
76
+ constructor(device, press, theme = {}, signal) {
77
+ this.theme = theme;
78
+ const host = h("div");
79
+ paint(host, theme);
80
+ this.root = host.attachShadow({ mode: "open" });
81
+ this.root.append(h("style", { textContent: STYLE }), this.#mode);
82
+ const kind = CORNER[device];
83
+ if (kind) {
84
+ // It never takes focus from the game, whose Space or Enter would press it again.
85
+ const corner = h("button", { className: `corner ${kind}` });
86
+ corner.addEventListener("mousedown", event => event.preventDefault());
87
+ corner.addEventListener("click", () => press(kind));
88
+ this.#corner = { kind, button: corner };
89
+ this.root.append(h("div", { className: "dock" }, corner));
90
+ this.action("Give feedback");
91
+ }
92
+ for (const type of ["keydown", "keypress", "pointerdown", "mousedown", "click"])
93
+ host.addEventListener(type, event => event.stopPropagation());
94
+ document.body.append(host);
95
+ signal.addEventListener("abort", () => host.remove(), { once: true });
96
+ }
97
+ /** Shows `view` in feedback mode, entering it if needed. */
98
+ show(view) {
99
+ clearTimeout(this.#timer);
100
+ clearInterval(this.#ticker);
101
+ const ticks = [];
102
+ this.#mode.replaceChildren(plate(view, ticks, this.#lines));
103
+ if (ticks.length)
104
+ this.#ticker = setInterval(() => ticks.forEach(tick => tick()), 250);
105
+ this.#mode.className = "mode";
106
+ this.#mode.hidden = false;
107
+ }
108
+ /** Shows `view` for `ms`, then leaves, unless something else shows first. */
109
+ flash(view, ms) {
110
+ this.show(view);
111
+ this.#timer = setTimeout(() => this.leave(), ms);
112
+ }
113
+ /** Fades out, which ends feedback mode on screen. */
114
+ leave() {
115
+ clearInterval(this.#ticker);
116
+ this.#mode.classList.add("leaving");
117
+ this.#timer = setTimeout(() => (this.#mode.hidden = true), FADE_MS);
118
+ }
119
+ /** A link under the caption that switches how feedback is given, such as "Type instead", until `signal` aborts. */
120
+ link(label, run, signal) {
121
+ if (signal.aborted)
122
+ return;
123
+ const link = button("quiet", label);
124
+ link.addEventListener("click", run, { signal });
125
+ this.#mode.append(link);
126
+ signal.addEventListener("abort", () => link.remove(), { once: true });
127
+ }
128
+ /** Hides the corner button while something else takes the screen, raises it over the cover, or lights it up. */
129
+ corner(look) {
130
+ const corner = this.#corner?.button;
131
+ if (!corner)
132
+ return;
133
+ corner.hidden = look === "hidden";
134
+ corner.classList.toggle("raised", look === "raised");
135
+ corner.classList.toggle("lit", look === "lit");
136
+ }
137
+ action(label) {
138
+ if (!this.#corner)
139
+ return;
140
+ const { kind, button } = this.#corner;
141
+ button.removeAttribute("aria-disabled");
142
+ button.setAttribute("aria-label", label);
143
+ button.innerHTML = face(kind, label);
144
+ }
145
+ /** Dims the corner button while a press does nothing, until the next `action`. */
146
+ dim() {
147
+ this.#corner?.button.setAttribute("aria-disabled", "true");
148
+ }
149
+ /** A hold's progress, 0 to 1, filling the corner button. Without one, only the hold's sound shows it. */
150
+ hold(progress) {
151
+ this.#corner?.button.style.setProperty("--hold", String(progress < 1 ? progress : 0));
152
+ }
153
+ /**
154
+ * The typing sheet, in feedback mode's frame with the corner button hidden behind it. Resolves with the text on Send,
155
+ * with "speak" for "Speak instead" when `speakable`, and with null on Cancel, Escape, or the feedback key in an empty
156
+ * field. On a keyboard Enter sends and Shift+Enter starts a new line; on a phone Enter is a new line, and the sheet
157
+ * fills the space above the on-screen keyboard.
158
+ */
159
+ sheet(kind, speakable, signal) {
160
+ return wait(signal, (done, over) => {
161
+ const phone = kind === "touch";
162
+ const field = h("textarea", { maxLength: LIMITS.text, value: this.#draft, placeholder: "Type here" });
163
+ field.setAttribute("aria-label", PROMPT);
164
+ const cancel = button("quiet", "Cancel");
165
+ const send = button("primary", "Send");
166
+ const speak = speakable ? button("quiet", "Speak instead") : null;
167
+ const hints = phone ? null : h("span", {}, "Enter to send · Shift+Enter for a new line · Esc to cancel");
168
+ const card = h("div", {}, h("div", { className: "head" }, cancel, h("h2", {}, PROMPT), send), field, hints || speak ? h("div", { className: "foot" }, hints, speak) : null);
169
+ card.setAttribute("role", "dialog");
170
+ card.setAttribute("aria-label", PROMPT);
171
+ const sheet = h("div", { className: phone ? "sheet phone" : "sheet" }, card);
172
+ // The on-screen keyboard shrinks the visual viewport, not the layout one, so the sheet follows it to keep Send in view.
173
+ const view = window.visualViewport;
174
+ const fit = () => {
175
+ if (!view)
176
+ return;
177
+ sheet.style.top = `${view.offsetTop}px`;
178
+ sheet.style.height = `${view.height}px`;
179
+ };
180
+ if (phone)
181
+ for (const type of ["resize", "scroll"])
182
+ view?.addEventListener(type, fit, { signal: over });
183
+ if (phone)
184
+ fit();
185
+ // The sheet needs the pointer that a game's mouse look holds.
186
+ if (document.pointerLockElement)
187
+ document.exitPointerLock();
188
+ this.show({ big: [PROMPT] });
189
+ this.#mode.append(sheet);
190
+ this.corner("hidden");
191
+ over.addEventListener("abort", () => {
192
+ field.blur();
193
+ sheet.remove();
194
+ this.corner("shown");
195
+ });
196
+ const submit = () => {
197
+ const text = field.value.trim();
198
+ if (!text)
199
+ return;
200
+ this.#draft = "";
201
+ done(text);
202
+ };
203
+ const update = () => {
204
+ this.#draft = field.value;
205
+ send.disabled = !field.value.trim();
206
+ };
207
+ update();
208
+ field.addEventListener("input", update, { signal: over });
209
+ field.addEventListener("keydown", event => {
210
+ if (event.isComposing)
211
+ return;
212
+ if (event.key === "Escape" || (isHotkey(event) && !field.value)) {
213
+ event.preventDefault();
214
+ done(null);
215
+ }
216
+ else if (!phone && event.key === "Enter" && !event.shiftKey) {
217
+ event.preventDefault();
218
+ submit();
219
+ }
220
+ }, { signal: over });
221
+ cancel.addEventListener("click", () => done(null), { signal: over });
222
+ send.addEventListener("click", submit, { signal: over });
223
+ speak?.addEventListener("click", () => done("speak"), { signal: over });
224
+ field.focus();
225
+ });
226
+ }
227
+ /** A full-screen cover, under the corner button, until disposed. */
228
+ cover() {
229
+ return new Cover(this);
230
+ }
231
+ }
232
+ /**
233
+ * The page of the intro and the closing screen: a modal dialog until released, while the game keeps rendering behind
234
+ * it. Opening it gives back the pointer a game's mouse look holds, and leaves a fullscreen canvas, which would hide it.
235
+ */
236
+ export class Cover {
237
+ #ui;
238
+ #cover = h("div", { className: "cover" });
239
+ #card = h("div", { className: "card" });
240
+ constructor(ui) {
241
+ this.#ui = ui;
242
+ if (document.pointerLockElement)
243
+ document.exitPointerLock();
244
+ if (document.fullscreenElement && !document.fullscreenElement.contains(document.body)) {
245
+ document.exitFullscreen().catch(error => console.error("feedback: could not leave fullscreen", error));
246
+ }
247
+ this.#cover.setAttribute("role", "dialog");
248
+ this.#cover.setAttribute("aria-modal", "true");
249
+ this.#cover.setAttribute("aria-labelledby", "title");
250
+ this.#cover.addEventListener("keydown", this.#trap);
251
+ this.#cover.append(this.#card);
252
+ ui.root.append(this.#cover);
253
+ }
254
+ /** Replaces the page, under the game's logo unless `bare`, and focuses its heading, which names the dialog. */
255
+ show(nodes, bare = false) {
256
+ const { logo } = this.#ui.theme;
257
+ this.#card.replaceChildren(...(logo && !bare ? [h("img", { className: "logo", src: logo, alt: "" })] : []), ...nodes);
258
+ const heading = this.#card.querySelector("h1");
259
+ if (!heading)
260
+ return;
261
+ heading.id = "title";
262
+ heading.tabIndex = -1;
263
+ heading.focus({ preventScroll: true });
264
+ }
265
+ /** Stops being modal, so focus and screen readers reach what lies outside, such as the corner button. */
266
+ release() {
267
+ this.#cover.removeAttribute("aria-modal");
268
+ this.#cover.removeEventListener("keydown", this.#trap);
269
+ }
270
+ /** Lets clicks through to the game, and with `clear` lets it show through, dimmed. */
271
+ through(clear = false) {
272
+ this.release();
273
+ this.#cover.classList.add("through");
274
+ this.#cover.classList.toggle("clear", clear);
275
+ }
276
+ dispose() {
277
+ this.#cover.remove();
278
+ }
279
+ /** Keeps Tab inside the dialog. */
280
+ #trap = (event) => {
281
+ if (event.key !== "Tab")
282
+ return;
283
+ const stops = [...this.#card.querySelectorAll("input:not(:disabled), textarea:not([hidden]), button:not(:disabled, [hidden], [hidden] *)")];
284
+ const active = this.#ui.root.activeElement;
285
+ const edge = event.shiftKey ? stops[0] : stops.at(-1);
286
+ if (active instanceof HTMLElement && stops.includes(active) && active !== edge)
287
+ return;
288
+ event.preventDefault();
289
+ (event.shiftKey ? stops.at(-1) : stops[0])?.focus();
290
+ };
291
+ }
@@ -0,0 +1,10 @@
1
+ /** What `wait` hands its setup: `done` ends the wait with a value, `fail` with an error, and `over` aborts once it ends. */
2
+ export type Arm<T> = (done: (value: T) => void, over: AbortSignal, fail: (error: unknown) => void) => void;
3
+ /**
4
+ * Resolves with the first value `arm`'s listeners report. Everything it sets up listens on `over`, so nothing outlives
5
+ * the wait. Rejects with the reason when `signal` aborts first, as on dispose.
6
+ */
7
+ export declare function wait<T>(signal: AbortSignal, arm: Arm<T>): Promise<T>;
8
+ /** Calls `run` after `ms` unless `signal` aborts first. */
9
+ export declare function after(ms: number, run: () => void, signal: AbortSignal): void;
10
+ export declare function sleep(ms: number, signal: AbortSignal): Promise<void>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Resolves with the first value `arm`'s listeners report. Everything it sets up listens on `over`, so nothing outlives
3
+ * the wait. Rejects with the reason when `signal` aborts first, as on dispose.
4
+ */
5
+ export function wait(signal, arm) {
6
+ const over = new AbortController();
7
+ return new Promise((resolve, reject) => {
8
+ const end = (settle) => (value) => {
9
+ over.abort();
10
+ settle(value);
11
+ };
12
+ if (signal.aborted)
13
+ return reject(signal.reason);
14
+ signal.addEventListener("abort", () => end(reject)(signal.reason), { signal: over.signal });
15
+ try {
16
+ arm(end(resolve), over.signal, end(reject));
17
+ }
18
+ catch (error) {
19
+ end(reject)(error);
20
+ }
21
+ });
22
+ }
23
+ /** Calls `run` after `ms` unless `signal` aborts first. */
24
+ export function after(ms, run, signal) {
25
+ if (signal.aborted)
26
+ return;
27
+ const timer = setTimeout(run, ms);
28
+ signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
29
+ }
30
+ export function sleep(ms, signal) {
31
+ return wait(signal, (done, over) => after(ms, () => done(undefined), over));
32
+ }
@@ -0,0 +1,12 @@
1
+ import type { Context } from "./run.ts";
2
+ import { type Cover } from "./ui.ts";
3
+ /** How the walkthrough teaches feedback: speaking, typing by choice, or typing because there is no microphone. */
4
+ export type Means = "speak" | "type" | "nomic";
5
+ /**
6
+ * The narrated walkthrough on the cover: each step shows while its clip plays, and the next follows once it has ended.
7
+ * Each press of the real input goes to the next step at once, cutting the clip short; a press on the last step, which
8
+ * lights the real corner button, or "Skip intro" closes the cover, and the press after that starts feedback. On Quest,
9
+ * whose 2D page has no input to press, the cover lets clicks through to the experience's enter button, its last step
10
+ * clears the backdrop over it, and the walkthrough ends with that step's clip. Entering XR ends it on any device.
11
+ */
12
+ export declare function walkthrough(ctx: Context, cover: Cover, means: Means, entered: (run: () => void, signal: AbortSignal) => void): Promise<void>;
@@ -0,0 +1,137 @@
1
+ import { hotkey } from "./input.js";
2
+ import { CLIPS, KEY, PLACE, PLACED } from "./sounds.js";
3
+ import { asking, SHOW_MS, talking } from "./turn.js";
4
+ import { button, face, h, kbd, plate, PROMPT } from "./ui.js";
5
+ import { after, wait } from "./wait.js";
6
+ /** How long a step stays without its clip, to read it instead: at least this, and more for longer words. */
7
+ const READ_MS = 4000;
8
+ const READ_MS_PER_WORD = 300;
9
+ /** A clip that doesn't end by then, as in a context the browser keeps suspended, is taken as done. */
10
+ const CLIP_MS = 12_000;
11
+ /** The breath between steps. */
12
+ const PAUSE_MS = 500;
13
+ /** The mic can be off for this site or for the whole browser app (an OS or headset setting); both are named. */
14
+ const MIC_OFF = "The mic is off, so you'll type instead. To speak, allow the mic for this site and for your browser in your device's settings.";
15
+ const MIC_OFF_XR = "The mic is off, so feedback won't record in the experience. Allow the mic for this site, and for the browser in the headset's privacy settings.";
16
+ /**
17
+ * The narrated walkthrough on the cover: each step shows while its clip plays, and the next follows once it has ended.
18
+ * Each press of the real input goes to the next step at once, cutting the clip short; a press on the last step, which
19
+ * lights the real corner button, or "Skip intro" closes the cover, and the press after that starts feedback. On Quest,
20
+ * whose 2D page has no input to press, the cover lets clicks through to the experience's enter button, its last step
21
+ * clears the backdrop over it, and the walkthrough ends with that step's clip. Entering XR ends it on any device.
22
+ */
23
+ export async function walkthrough(ctx, cover, means, entered) {
24
+ const { device, input, ui, sounds, signal } = ctx;
25
+ const press = device === "desktop" ? "key" : device === "phone" ? "touch" : null;
26
+ const steps = press ? flat(press, means) : quest(means);
27
+ if (press)
28
+ cover.release();
29
+ else
30
+ cover.through();
31
+ for (const [i, step] of steps.entries()) {
32
+ const last = i === steps.length - 1;
33
+ if (last && !press)
34
+ cover.through(true);
35
+ if (last)
36
+ ui.corner("lit");
37
+ const skip = button("quiet", "Skip intro");
38
+ const next = press && h("span", {}, ...(press === "key" ? ["Press ", kbd(hotkey), " for next"] : ["Tap Feedback for next"]));
39
+ // The last step has nothing to skip, but keeps the row's space, so the page keeps its height.
40
+ const controls = h("div", { className: "controls" }, next || null, skip);
41
+ controls.style.visibility = last ? "hidden" : "";
42
+ const view = h("div", { className: "walk" },
43
+ // The tag sits over the miniature, so it doesn't read as the real thing.
44
+ h("p", { className: "progress" }, h("span", {}, `Step ${i + 1} of ${steps.length}`), h("span", { className: "example" }, "Example")), step.screen, h("div", {}, h("h1", {}, ...heading(CLIPS[step.clip].words)), step.note ? h("p", { className: "note" }, step.note) : null), controls);
45
+ view.dataset.step = "abcde"[i];
46
+ cover.show([view], true);
47
+ const end = await wait(signal, (done, over) => {
48
+ // A flat screen holds the last step for the press; before any other step comes a breath.
49
+ narrate(sounds, step.clip, over, () => {
50
+ if (!last)
51
+ after(PAUSE_MS, () => done("next"), over);
52
+ else if (!press)
53
+ done("next");
54
+ });
55
+ if (press)
56
+ input.on(null, () => done("next"), over);
57
+ skip.addEventListener("click", () => done("skip"), { signal: over });
58
+ entered(() => done("skip"), over);
59
+ });
60
+ if (end === "skip")
61
+ return;
62
+ }
63
+ }
64
+ /** Plays the clip, then calls `then`; without sound, after the time it takes to read. */
65
+ function narrate(sounds, clip, signal, then) {
66
+ const { words } = CLIPS[clip];
67
+ const read = Math.max(READ_MS, words.split(" ").length * READ_MS_PER_WORD);
68
+ sounds.clip(clip, signal).then(then, error => {
69
+ console.error(`feedback: could not play "${words}"`, error);
70
+ after(read, then, signal);
71
+ });
72
+ after(Math.max(CLIP_MS, 2 * read), then, signal);
73
+ }
74
+ /** The clip's words as its heading, with the key named as a cap. */
75
+ function heading(words) {
76
+ const keys = (part) => part.split(KEY).flatMap((piece, i) => (i ? [kbd(hotkey), piece] : [piece]));
77
+ return words.split(PLACED).flatMap((part, i) => (i ? [kbd(hotkey), ` (${PLACE})`, ...keys(part)] : keys(part)));
78
+ }
79
+ function flat(kind, means) {
80
+ const keys = kind === "key";
81
+ return [
82
+ { clip: "a-welcome", screen: screen(kind, {}) },
83
+ means === "speak"
84
+ ? { clip: `b-${kind}`, screen: screen(kind, { action: "Send recording", keys, view: talking(kind, () => 3) }) }
85
+ : { clip: `b-type-${kind}`, screen: screen(kind, { keys, sheet: true }), note: means === "nomic" ? MIC_OFF : undefined },
86
+ { clip: `c-${kind}`, screen: screen(kind, { action: "Take screenshot", view: asking(kind, () => SHOW_MS / 1000) }) },
87
+ { clip: `d-${kind}`, screen: screen(kind, { lit: true, short: true }) },
88
+ ];
89
+ }
90
+ /**
91
+ * Which of controllers and hands the tester will use shows only once in XR, so each gets its own step. XR has no typing:
92
+ * without a microphone each turn explains why it cannot record, so the first speaking step says so too.
93
+ */
94
+ function quest(means) {
95
+ const both = hands("controller", "hand");
96
+ return [
97
+ { clip: "a-welcome", screen: both },
98
+ { clip: "b-controller", screen: hands("controller"), note: means === "nomic" ? MIC_OFF_XR : undefined },
99
+ { clip: "b-hand", screen: hands("hand") },
100
+ { clip: "c-quest", screen: both.cloneNode(true) },
101
+ { clip: "d-quest", screen: both.cloneNode(true) },
102
+ ];
103
+ }
104
+ /** A miniature of the screen, drawn with the real caption and corner button, framed wherever feedback mode shows. */
105
+ function screen(kind, { action = "Give feedback", view, sheet = false, lit = false, keys = false, short = false }) {
106
+ const corner = h("span", { className: lit ? `corner ${kind} lit` : `corner ${kind}` });
107
+ corner.innerHTML = face(kind, action);
108
+ const card = h("div", { className: "sheet" }, h("div", {}, "Cancel", h("b", {}, PROMPT), h("i", {}, "Send")), h("span"));
109
+ const shown = h("div", { className: "screen" }, keys ? picture() : null, view ? plate(view) : null, sheet ? card : h("div", { className: "dock" }, corner));
110
+ shown.classList.toggle("framed", Boolean(view) || sheet);
111
+ shown.classList.toggle("short", short);
112
+ shown.ariaHidden = "true";
113
+ return shown;
114
+ }
115
+ /**
116
+ * The left controller, rendered from Meta's Quest Touch Plus model in @webxr-input-profiles/assets (MIT, see
117
+ * pictures/LICENSE), and the pinching left hand, standing in for the screen on Quest. The URLs are spelled out in full
118
+ * so bundlers find the files.
119
+ */
120
+ const PICTURES = {
121
+ controller: new URL("./pictures/controller.webp", import.meta.url),
122
+ hand: new URL("./pictures/hand.webp", import.meta.url),
123
+ };
124
+ function hands(...shown) {
125
+ const view = h("div", { className: "screen hands" }, ...shown.map(hand => h("img", { src: PICTURES[hand].href, alt: "" })));
126
+ view.ariaHidden = "true";
127
+ return view;
128
+ }
129
+ /** The feedback key among its neighbours, lit. */
130
+ function picture() {
131
+ const cap = (x, y, w, label, opacity = 1) => `<g opacity="${opacity}"><rect x="${x}" y="${y}" width="${w}" height="24" rx="4"/><text x="${x + w / 2}" y="${y + 15}">${label}</text></g>`;
132
+ const keys = h("span", { className: "keys" });
133
+ keys.innerHTML = `<svg viewBox="0 0 88 56">${cap(2, 2, 32, "esc")}${cap(38, 2, 22, "F1", 0.5)}${cap(64, 2, 22, "F2", 0.25)}
134
+ <g class="lit"><rect x="2" y="30" width="32" height="24" rx="4"/><text x="18" y="47"></text></g>${cap(38, 30, 22, "1")}${cap(64, 30, 22, "2", 0.5)}</svg>`;
135
+ keys.querySelector(".lit text")?.append(hotkey);
136
+ return keys;
137
+ }
@@ -0,0 +1,10 @@
1
+ import { type Object3D } from "three";
2
+ import type { Renderer } from "../index.ts";
3
+ /** Renders the left eye of the current XR frame. */
4
+ export interface EyeShot {
5
+ /** Must run synchronously inside the XR frame, right after the game's render. */
6
+ take(scene: Object3D): Promise<HTMLCanvasElement>;
7
+ dispose(): void;
8
+ }
9
+ /** Loads the output pass for this renderer ahead of the shot, which can't wait for an import. */
10
+ export declare function prepareEyeShot(renderer: Renderer): Promise<EyeShot>;
@@ -0,0 +1,99 @@
1
+ import { HalfFloatType, RenderTarget, WebGLRenderTarget, } from "three";
2
+ import { canvas2d, fit, flip } from "./frame.js";
3
+ /** Loads the output pass for this renderer ahead of the shot, which can't wait for an import. */
4
+ export async function prepareEyeShot(renderer) {
5
+ return "isWebGPURenderer" in renderer ? webgpu(renderer) : webgl(renderer);
6
+ }
7
+ async function webgl(renderer) {
8
+ const { OutputPass } = await import("three/examples/jsm/postprocessing/OutputPass.js");
9
+ const pass = new OutputPass();
10
+ const linear = new WebGLRenderTarget(1, 1, { type: HalfFloatType });
11
+ const output = new WebGLRenderTarget(1, 1);
12
+ return {
13
+ async take(scene) {
14
+ // Tone mapping and sRGB, which the renderer applies only on its way to the screen.
15
+ const size = offscreen(renderer, scene, linear, output, () => pass.render(renderer, output, linear, 0, false));
16
+ const pixels = new Uint8Array(size.width * size.height * 4);
17
+ await renderer.readRenderTargetPixelsAsync(output, 0, 0, size.width, size.height, pixels);
18
+ return paint(flip(pixels, size), size);
19
+ },
20
+ dispose() {
21
+ pass.dispose();
22
+ linear.dispose();
23
+ output.dispose();
24
+ },
25
+ };
26
+ }
27
+ async function webgpu(renderer) {
28
+ const { NodeMaterial, QuadMesh } = await import("three/webgpu");
29
+ const { renderOutput, texture } = await import("three/tsl");
30
+ const linear = new RenderTarget(1, 1, { type: HalfFloatType });
31
+ const output = new RenderTarget(1, 1);
32
+ const material = new NodeMaterial();
33
+ // QuadMesh shares one geometry across all instances, so only its material is ours to dispose.
34
+ const quad = new QuadMesh(material);
35
+ let toneMapping = null;
36
+ let colorSpace = null;
37
+ return {
38
+ async take(scene) {
39
+ // Tone mapping and sRGB, which the renderer applies only on its way to the screen, so they are passed as
40
+ // RenderPipeline does, and rebuilt when the game changes them.
41
+ if (renderer.toneMapping !== toneMapping || renderer.outputColorSpace !== colorSpace) {
42
+ toneMapping = renderer.toneMapping;
43
+ colorSpace = renderer.outputColorSpace;
44
+ material.fragmentNode = renderOutput(texture(linear.texture), renderer.toneMapping, renderer.outputColorSpace);
45
+ material.needsUpdate = true;
46
+ }
47
+ const size = offscreen(renderer, scene, linear, output, () => {
48
+ renderer.setRenderTarget(output);
49
+ quad.render(renderer);
50
+ });
51
+ const pixels = await renderer.readRenderTargetPixelsAsync(output, 0, 0, size.width, size.height);
52
+ if (!(pixels instanceof Uint8Array))
53
+ throw new Error("feedback: expected 8-bit pixels from the output target");
54
+ // XR runs WebGPURenderer on its WebGL 2 backend, which reads rows bottom-up like WebGL.
55
+ return paint("isWebGLBackend" in renderer.backend ? flip(pixels, size) : pixels, size);
56
+ },
57
+ dispose() {
58
+ linear.dispose();
59
+ output.dispose();
60
+ material.dispose();
61
+ },
62
+ };
63
+ }
64
+ /**
65
+ * Draws `scene` from the left eye into `linear` with XR off, then `post` turns it into display colours in `output`,
66
+ * both sized to the eye's size, which it returns. The game's render target and XR are restored either way.
67
+ */
68
+ function offscreen(renderer, scene, linear, output, post) {
69
+ const { camera, size } = eye(renderer.xr.getCamera());
70
+ linear.setSize(size.width, size.height);
71
+ output.setSize(size.width, size.height);
72
+ const previous = renderer.getRenderTarget();
73
+ renderer.xr.enabled = false;
74
+ try {
75
+ renderer.setRenderTarget(linear);
76
+ renderer.render(scene, camera);
77
+ post();
78
+ }
79
+ finally {
80
+ renderer.setRenderTarget(previous);
81
+ renderer.xr.enabled = true;
82
+ }
83
+ return size;
84
+ }
85
+ /** A standalone copy of the left eye's camera, with the rig's transform baked into its world matrix. */
86
+ function eye(rig) {
87
+ const left = rig.cameras[0];
88
+ if (!left?.viewport)
89
+ throw new Error("feedback: the XR camera has no left eye");
90
+ const camera = left.clone();
91
+ camera.matrixWorld.decompose(camera.position, camera.quaternion, camera.scale);
92
+ return { camera, size: fit(left.viewport.z, left.viewport.w) };
93
+ }
94
+ /** A canvas of top-down RGBA pixels. */
95
+ function paint(pixels, size) {
96
+ const { canvas, context } = canvas2d(size);
97
+ context.putImageData(new ImageData(new Uint8ClampedArray(pixels), size.width, size.height), 0, 0);
98
+ return canvas;
99
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { getAuthToken } from "@drawcall/auth";
3
+ import { run } from "./index.js";
4
+ process.exitCode = await run(process.argv.slice(2), {
5
+ fetch: globalThis.fetch,
6
+ token: () => getAuthToken(),
7
+ stdout: text => process.stdout.write(`${text}\n`),
8
+ stderr: text => process.stderr.write(`${text}\n`),
9
+ baseUrl: process.env.DRAWCALL_FEEDBACK_URL || undefined,
10
+ // The drawcall CLI forwards `drawcall feedback` here and names itself in this variable.
11
+ invocation: process.env.DRAWCALL_INVOCATION || undefined,
12
+ });
@@ -0,0 +1,19 @@
1
+ /** A mistake in the command line, found before any request. */
2
+ export declare class UsageError extends Error {
3
+ }
4
+ export interface Api {
5
+ call(method: string, path: string, body?: unknown): Promise<Response>;
6
+ json(method: string, path: string, body?: unknown): Promise<unknown>;
7
+ /** `projects/<key>` of the project `key` names, else of the account's only one. */
8
+ project(key: string | undefined): Promise<string>;
9
+ }
10
+ export interface Command {
11
+ /** Its arguments and flags after the words naming it, besides --project. */
12
+ usage: string;
13
+ about: string;
14
+ /** Whether it takes --project. */
15
+ scoped: boolean;
16
+ /** What it prints: JSON, or text for `skill`. */
17
+ run(args: string[], api: Api): Promise<unknown>;
18
+ }
19
+ export declare const commands: Record<string, Command>;