@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,241 @@
1
+ import { LIMITS } from "../service.js";
2
+ import { EMAIL_USE } from "./intro.js";
3
+ import { record } from "./mic.js";
4
+ import { advice, Refused, submit } from "./server.js";
5
+ import { CLIPS } from "./sounds.js";
6
+ import { clock } from "./turn.js";
7
+ import { button, h } from "./ui.js";
8
+ import { wait } from "./wait.js";
9
+ const UNHEARD = "Didn't catch that. Record again, or type it.";
10
+ const MIC = `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"
11
+ stroke-linecap="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg>`;
12
+ /**
13
+ * The close of the playtest, once the game called `finish()`: a cover asking the game's questions and for an optional
14
+ * last word on the whole experience, written or spoken. Finish stores the answers with the session and sends the last
15
+ * word as an overall item; then thanks, by name when known, and stays. The cover is a page, so an XR session ends first.
16
+ */
17
+ export async function ending(ctx, session) {
18
+ ctx.ui.corner("hidden");
19
+ await ctx.options.renderer.xr.getSession()?.end().catch(error => console.error("feedback: could not leave XR", error));
20
+ const cover = ctx.ui.cover();
21
+ try {
22
+ const sent = await ask(ctx, session, cover);
23
+ await thank(ctx, cover, sent);
24
+ }
25
+ finally {
26
+ cover.dispose();
27
+ }
28
+ }
29
+ /**
30
+ * Resolves once Finish sent what the tester left, with true, or with false when they left nothing or the service refused
31
+ * it, which no retry would change. A tester who isn't a reviewer yet may leave an email to become one, when the project
32
+ * takes sign-ups.
33
+ */
34
+ async function ask(ctx, session, cover) {
35
+ const { options, tester, project, signal } = ctx;
36
+ const questions = (options.questions ?? []).map(question => (question.type === "scale" ? scale(question) : choice(question)));
37
+ const status = h("p", { className: "status", role: "alert" });
38
+ const last = new LastWord(ctx, note => (status.textContent = note));
39
+ const email = h("input", { name: "email", type: "email", autocomplete: "email", maxLength: LIMITS.contact });
40
+ const join = tester.reviewer || !(await project)?.signup ? null : h("label", { className: "join" }, "Email (optional)", email, h("span", { className: "fine" }, EMAIL_USE));
41
+ const finish = h("button", { className: "primary", type: "submit" }, "Finish");
42
+ const form = h("form", {}, questions.length ? h("div", { className: "questions" }, ...questions.map(question => question.element)) : null, h("div", { className: "question" }, h("div", { className: "ask", id: "last" }, "Anything else? What worked, what didn't, what you'd change."), last.element), join, h("div", { className: "actions" }, finish), status);
43
+ last.field.setAttribute("aria-labelledby", "last");
44
+ cover.show([
45
+ h("h1", {}, tester.name ? `You've reached the end, ${tester.name}` : "You've reached the end"),
46
+ h("p", { className: "lead" }, questions.length ? "A few last questions. All optional." : "One last thing, if you like. It's optional."),
47
+ form,
48
+ ]);
49
+ // Never a native submit, which would reload the page.
50
+ form.addEventListener("submit", event => event.preventDefault());
51
+ const submitted = () => wait(signal, (done, over) => form.addEventListener("submit", () => done(undefined), { signal: over }));
52
+ const busy = (on) => {
53
+ finish.disabled = last.disabled = email.disabled = on;
54
+ finish.textContent = on ? "Sending…" : "Finish";
55
+ };
56
+ for (;;) {
57
+ await submitted();
58
+ // Finish while recording sends the recording.
59
+ const audio = await last.audio();
60
+ const text = last.field.value.trim();
61
+ const answers = questions.map(question => question.read()).filter(answer => answer !== null);
62
+ const address = email.value.trim();
63
+ if (!audio && !text && !answers.length && !address)
64
+ return false;
65
+ busy(true);
66
+ status.textContent = "";
67
+ try {
68
+ // Each step is safe to repeat after a later one failed.
69
+ if (address)
70
+ await session.join(address);
71
+ if (answers.length)
72
+ await session.answer(answers);
73
+ if (!audio && !text)
74
+ return true;
75
+ const answer = await submit(session.item({ overall: true, text: audio ? undefined : text }), audio, signal);
76
+ if (answer.status === "stored")
77
+ return true;
78
+ last.unheard();
79
+ }
80
+ catch (error) {
81
+ signal.throwIfAborted();
82
+ console.error("feedback: could not send", error);
83
+ status.textContent = `Couldn't send. ${advice(error instanceof Refused, "press Finish again")}`;
84
+ if (error instanceof Refused) {
85
+ // Sending again would fail the same way, so the button only moves on.
86
+ finish.disabled = false;
87
+ finish.textContent = "Continue";
88
+ await submitted();
89
+ return false;
90
+ }
91
+ }
92
+ busy(false);
93
+ }
94
+ }
95
+ /**
96
+ * The last page. Going back to a finished game would only confuse, so it says the tab can be closed, and offers to play
97
+ * again: a reload, which starts a new session for the same tester.
98
+ */
99
+ async function thank({ tester, sounds, signal }, cover, sent) {
100
+ const again = button("secondary", "Start again");
101
+ cover.show([
102
+ h("h1", {}, tester.name ? `Thank you, ${tester.name}!` : "Thank you!"),
103
+ h("p", { className: "lead" }, `${sent ? "Your feedback was sent. " : ""}${CLIPS.end.words}`),
104
+ h("div", { className: "actions" }, again),
105
+ ]);
106
+ sounds.say("end");
107
+ await wait(signal, (done, over) => again.addEventListener("click", () => done(undefined), { signal: over }));
108
+ location.reload();
109
+ }
110
+ /**
111
+ * The last word, in one field: typed, or recorded with the button under it. The button shows only while nothing is
112
+ * typed, so typed text is never silently dropped for a recording; a recording takes the field's place until deleted. A
113
+ * microphone that is denied or broken removes the button.
114
+ */
115
+ class LastWord {
116
+ field = h("textarea", { name: "overall", maxLength: LIMITS.text, placeholder: "Type here, or record it instead." });
117
+ element;
118
+ #ctx;
119
+ #note;
120
+ #button = h("button", { className: "record", type: "button" });
121
+ #voice = h("div", { className: "voice", hidden: true });
122
+ #recording = null;
123
+ #audio = null;
124
+ #nomic = false;
125
+ /** `note` tells the tester what they should know, such as that nothing was heard. */
126
+ constructor(ctx, note) {
127
+ this.#ctx = ctx;
128
+ this.#note = note;
129
+ this.#button.innerHTML = `${MIC}<span>Record</span>`;
130
+ this.#button.addEventListener("click", () => void this.#record());
131
+ this.field.addEventListener("input", () => this.#show());
132
+ this.element = h("div", { className: "reply" }, this.field, this.#voice, this.#button);
133
+ this.#show();
134
+ }
135
+ set disabled(disabled) {
136
+ this.field.readOnly = disabled;
137
+ for (const button of this.element.querySelectorAll("button"))
138
+ button.disabled = disabled;
139
+ }
140
+ /** The recording, stopping it first if it still runs; null without one. */
141
+ async audio() {
142
+ const recording = this.#recording;
143
+ recording?.stop();
144
+ await recording?.audio;
145
+ return this.#audio?.blob ?? null;
146
+ }
147
+ /** Drops the recording the service heard nothing in, saying so. */
148
+ unheard() {
149
+ this.#audio = null;
150
+ this.#note(UNHEARD);
151
+ this.#show();
152
+ }
153
+ async #record() {
154
+ this.#button.disabled = true;
155
+ const discard = new AbortController();
156
+ const recording = await record(this.#ctx.sounds.context, AbortSignal.any([this.#ctx.signal, discard.signal])).catch(error => {
157
+ if (!this.#ctx.signal.aborted)
158
+ throw error;
159
+ return null;
160
+ }).finally(() => (this.#button.disabled = false));
161
+ if (!recording)
162
+ return;
163
+ if (typeof recording === "string") {
164
+ this.#nomic = true;
165
+ this.#note(recording === "denied" ? "The mic is off. Type instead, or turn it on in your browser's site settings." : "The mic isn't working, so type instead.");
166
+ return this.#show();
167
+ }
168
+ const current = { ...recording, discard };
169
+ this.#recording = current;
170
+ const seconds = () => (performance.now() - recording.since) / 1000;
171
+ // Built once and only its time updated, so a press on Stop or Cancel lands on the button it started on.
172
+ const time = h("span", {}, clock(0));
173
+ const timer = setInterval(() => (time.textContent = clock(seconds())), 250);
174
+ const stop = button("secondary", "Stop");
175
+ const cancel = button("quiet", "Cancel");
176
+ stop.addEventListener("click", recording.stop);
177
+ cancel.addEventListener("click", () => {
178
+ this.#recording = null;
179
+ discard.abort();
180
+ this.#show();
181
+ });
182
+ this.#voice.replaceChildren(h("span", {}, h("span", { className: "dot" }), "Recording ", time, ` · up to ${clock(LIMITS.seconds)}`), stop, cancel);
183
+ // It also stops by itself at its caps.
184
+ void recording.audio.then(blob => {
185
+ clearInterval(timer);
186
+ if (this.#recording !== current)
187
+ return;
188
+ this.#recording = null;
189
+ this.#audio = blob && { blob, seconds: seconds() };
190
+ this.#note(blob ? "" : UNHEARD);
191
+ this.#show();
192
+ });
193
+ this.#note("");
194
+ this.#show();
195
+ }
196
+ #show() {
197
+ const voice = this.#recording !== null || this.#audio !== null;
198
+ this.field.hidden = voice;
199
+ this.#voice.hidden = !voice;
200
+ this.#button.hidden = voice || this.#nomic || this.field.value.trim() !== "";
201
+ if (!this.#audio || this.#recording)
202
+ return;
203
+ const remove = button("secondary", "Delete");
204
+ remove.addEventListener("click", () => {
205
+ this.#audio = null;
206
+ this.#show();
207
+ });
208
+ this.#voice.replaceChildren(h("span", {}, `Voice recording ready · ${clock(this.#audio.seconds)}`), remove);
209
+ }
210
+ }
211
+ /** A slider shows no value until it is moved or clicked, so its starting position never counts as an answer. */
212
+ function scale(question) {
213
+ const { id, ask, min, max, step = 1, labels } = question;
214
+ const value = h("output", { className: "value" }, "–");
215
+ // In the middle, so the untouched slider leans neither way; set after the bounds, which would otherwise clamp it.
216
+ const middle = min + Math.round((max - min) / 2 / step) * step;
217
+ const slider = h("input", { type: "range", min: String(min), max: String(max), step: String(step), value: String(middle), className: "unset" });
218
+ slider.ariaLabel = ask;
219
+ let touched = false;
220
+ const touch = () => {
221
+ touched = true;
222
+ slider.classList.remove("unset");
223
+ value.textContent = slider.value;
224
+ };
225
+ slider.addEventListener("input", touch);
226
+ slider.addEventListener("pointerdown", touch);
227
+ const [low, high] = labels ?? [String(min), String(max)];
228
+ const element = h("div", { className: "question" }, h("div", { className: "ask" }, h("span", {}, ask), value), slider, h("div", { className: "ends" }, h("span", {}, low), h("span", {}, high)));
229
+ const range = { min, max, ...(labels && { labels }) };
230
+ return { element, read: () => (touched ? { id, ask, answer: Number(slider.value), scale: range } : null) };
231
+ }
232
+ function choice({ id, ask, options, multiple = false }) {
233
+ const boxes = options.map(option => h("input", { type: multiple ? "checkbox" : "radio", name: id, value: option }));
234
+ const labels = boxes.map(box => h("label", { className: "option" }, box, box.value));
235
+ const element = h("fieldset", { className: "question" }, h("legend", { className: "ask" }, ask), h("div", { className: "options" }, ...labels));
236
+ const read = () => {
237
+ const picked = boxes.filter(box => box.checked).map(box => box.value);
238
+ return picked.length ? { id, ask, answer: picked, options } : null;
239
+ };
240
+ return { element, read };
241
+ }
@@ -0,0 +1,27 @@
1
+ import type { Renderer } from "../index.ts";
2
+ export interface Size {
3
+ width: number;
4
+ height: number;
5
+ }
6
+ /** Scales down to a long edge of 1280 px, never up, with the width a multiple of 64: WebGPU pads rows to 256 bytes. */
7
+ export declare function fit(width: number, height: number): Size;
8
+ /** Reverses the row order of RGBA pixels, for WebGL, which reads rows bottom-up. */
9
+ export declare function flip(pixels: Uint8Array, { width, height }: Size): Uint8Array;
10
+ export declare function canvas2d({ width, height }: Size): {
11
+ canvas: HTMLCanvasElement;
12
+ context: CanvasRenderingContext2D;
13
+ };
14
+ export declare function encode(canvas: HTMLCanvasElement): Promise<Blob>;
15
+ /**
16
+ * Sees every frame the game draws: after each outermost render that reaches the screen or the XR display, so
17
+ * post-processing included, it calls `each` and feeds a pending screenshot. Unhooked on abort.
18
+ */
19
+ export declare class Frames {
20
+ #private;
21
+ constructor(renderer: Renderer, each: (xr: XRFrame | null) => void, signal: AbortSignal);
22
+ /**
23
+ * The next frame the game renders, as a canvas to `encode`; given up when `signal` aborts. Encoding is left out, as
24
+ * Quest Browser takes seconds to encode a JPEG in XR, while the frame itself comes in a fraction of one.
25
+ */
26
+ take(signal: AbortSignal): Promise<HTMLCanvasElement>;
27
+ }
@@ -0,0 +1,165 @@
1
+ /** Scales down to a long edge of 1280 px, never up, with the width a multiple of 64: WebGPU pads rows to 256 bytes. */
2
+ export function fit(width, height) {
3
+ const scale = Math.min(1, 1280 / Math.max(width, height));
4
+ const fitted = Math.max(64, Math.floor((width * scale) / 64) * 64);
5
+ return { width: fitted, height: Math.max(1, Math.round((fitted * height) / width)) };
6
+ }
7
+ /** Reverses the row order of RGBA pixels, for WebGL, which reads rows bottom-up. */
8
+ export function flip(pixels, { width, height }) {
9
+ const row = width * 4;
10
+ const flipped = new Uint8Array(pixels.length);
11
+ for (let y = 0; y < height; y++)
12
+ flipped.set(pixels.subarray(y * row, (y + 1) * row), (height - 1 - y) * row);
13
+ return flipped;
14
+ }
15
+ export function canvas2d({ width, height }) {
16
+ const canvas = Object.assign(document.createElement("canvas"), { width, height });
17
+ const context = canvas.getContext("2d");
18
+ if (!context)
19
+ throw new Error("feedback: no 2D canvas context");
20
+ return { canvas, context };
21
+ }
22
+ export function encode(canvas) {
23
+ return new Promise((resolve, reject) => {
24
+ canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error("feedback: could not encode the screenshot"))), "image/jpeg", 0.85);
25
+ });
26
+ }
27
+ /**
28
+ * Sees every frame the game draws: after each outermost render that reaches the screen or the XR display, so
29
+ * post-processing included, it calls `each` and feeds a pending screenshot. Unhooked on abort.
30
+ */
31
+ export class Frames {
32
+ #renderer;
33
+ #signal;
34
+ #request = null;
35
+ #copy = null;
36
+ /** Prepared on the first XR frame, so flat-screen games never load the output pass. */
37
+ #eye = null;
38
+ constructor(renderer, each, signal) {
39
+ this.#renderer = renderer;
40
+ this.#signal = signal;
41
+ let depth = 0;
42
+ const after = (scene, screen) => {
43
+ if (depth > 0 || !screen)
44
+ return;
45
+ each(renderer.xr.isPresenting ? renderer.xr.getFrame() : null);
46
+ this.#frame(scene);
47
+ };
48
+ hook(renderer, "render", render => (scene, camera) => {
49
+ const screen = toScreen(renderer);
50
+ depth++;
51
+ try {
52
+ render.call(renderer, scene, camera);
53
+ }
54
+ finally {
55
+ depth--;
56
+ }
57
+ after(scene, screen);
58
+ }, signal);
59
+ // WebGPU's renderAsync draws the frame once its promise settles.
60
+ if ("isWebGPURenderer" in renderer) {
61
+ hook(renderer, "renderAsync", render => async (scene, camera) => {
62
+ const screen = toScreen(renderer);
63
+ depth++;
64
+ try {
65
+ await render.call(renderer, scene, camera);
66
+ }
67
+ finally {
68
+ depth--;
69
+ }
70
+ after(scene, screen);
71
+ }, signal);
72
+ }
73
+ }
74
+ /**
75
+ * The next frame the game renders, as a canvas to `encode`; given up when `signal` aborts. Encoding is left out, as
76
+ * Quest Browser takes seconds to encode a JPEG in XR, while the frame itself comes in a fraction of one.
77
+ */
78
+ take(signal) {
79
+ return new Promise((resolve, reject) => {
80
+ const request = { resolve, reject };
81
+ this.#request = request;
82
+ signal.addEventListener("abort", () => {
83
+ if (this.#request === request)
84
+ this.#request = null;
85
+ }, { once: true });
86
+ });
87
+ }
88
+ #frame(scene) {
89
+ const { xr } = this.#renderer;
90
+ if (xr.isPresenting && !this.#eye)
91
+ this.#prepare();
92
+ const request = this.#request;
93
+ if (!request)
94
+ return;
95
+ if (!xr.isPresenting)
96
+ return this.#flat(request);
97
+ // The eye renders synchronously inside the XR frame, right after the game's, so while it loads a later frame takes it.
98
+ const eye = this.#eye;
99
+ if (!eye || eye === "loading")
100
+ return;
101
+ this.#request = null;
102
+ if ("failed" in eye)
103
+ request.reject(eye.failed);
104
+ else
105
+ eye.take(scene).then(request.resolve, request.reject);
106
+ }
107
+ #prepare() {
108
+ this.#eye = "loading";
109
+ import("./xr.js").then(({ prepareEyeShot }) => prepareEyeShot(this.#renderer)).then(eye => {
110
+ if (this.#signal.aborted)
111
+ return eye.dispose();
112
+ this.#eye = eye;
113
+ this.#signal.addEventListener("abort", () => eye.dispose(), { once: true });
114
+ }, (error) => {
115
+ console.error("feedback: could not prepare XR screenshots", error);
116
+ this.#eye = { failed: error };
117
+ });
118
+ }
119
+ /**
120
+ * Copies the canvas right after the render, while its drawing buffer is still intact, then hands over the last copy
121
+ * in the next task. A microtask is too early: async render loops would run it before their post-processing.
122
+ */
123
+ #flat(request) {
124
+ const game = this.#renderer.domElement;
125
+ try {
126
+ const copy = this.#copy ?? canvas2d(fit(game.width, game.height));
127
+ copy.context.drawImage(game, 0, 0, copy.canvas.width, copy.canvas.height);
128
+ if (this.#copy)
129
+ return;
130
+ this.#copy = copy;
131
+ setTimeout(() => {
132
+ this.#copy = null;
133
+ if (this.#request === request)
134
+ this.#request = null;
135
+ request.resolve(copy.canvas);
136
+ });
137
+ }
138
+ catch (error) {
139
+ // A failed copy fails the screenshot, not the game's render call.
140
+ this.#request = null;
141
+ request.reject(error);
142
+ }
143
+ }
144
+ }
145
+ /** Wraps the method `key`, until `signal` aborts; put back only if nothing replaced it after, by deleting an own copy. */
146
+ function hook(target, key, wrap, signal) {
147
+ const own = Object.hasOwn(target, key);
148
+ const original = target[key];
149
+ const wrapped = wrap(original);
150
+ target[key] = wrapped;
151
+ signal.addEventListener("abort", () => {
152
+ if (target[key] !== wrapped)
153
+ return;
154
+ if (own)
155
+ target[key] = original;
156
+ else
157
+ Reflect.deleteProperty(target, key);
158
+ }, { once: true });
159
+ }
160
+ function toScreen(renderer) {
161
+ if ("isWebGPURenderer" in renderer)
162
+ return renderer.isOutputTarget;
163
+ const target = renderer.getRenderTarget();
164
+ return target === null || ("isXRRenderTarget" in target && target.isXRRenderTarget === true);
165
+ }
@@ -0,0 +1,45 @@
1
+ import type { Text } from "./ui.ts";
2
+ /**
3
+ * The inputs that give feedback. `key` and `touch` start on a single press, and a click of the corner button counts as
4
+ * one of them; the others are inputs the experience also uses, so they start only after a hold. Every later press is a
5
+ * tap of the input that started.
6
+ */
7
+ export type Kind = "key" | "touch" | "gamepad" | Hand;
8
+ /** What gives feedback in an XR session: the left Touch controller, or both tracked hands. */
9
+ export type Hand = "controller" | "hand";
10
+ /** The inputs that start on a single press, each with its own corner button. */
11
+ export type Press = "key" | "touch";
12
+ /**
13
+ * The feedback key: the one left of 1, below Esc, which every layout has. Browsers name it "Backquote", except on a
14
+ * Mac with an ISO keyboard (German, UK, …), where macOS reports it as "IntlBackslash" and the key next to the left
15
+ * Shift as "Backquote". Accepting both keeps the key working there; the extra one is rarely bound by games.
16
+ */
17
+ export declare const isHotkey: (event: KeyboardEvent) => boolean;
18
+ /** What the key prints on this layout, e.g. "^" on German. Only Chromium can tell, elsewhere this stays "`". */
19
+ export declare let hotkey: string;
20
+ export declare function learnHotkey(): Promise<void>;
21
+ /** "press ` to send", "tap Send": how to act on this input, mid-sentence. A phone's only button names what it does. */
22
+ export declare function act(kind: Kind, after: string, button: string): Text;
23
+ /** Turns raw presses and holds into what a flow waits for: a start, a tap of the started input, and Escape. */
24
+ export declare class Input {
25
+ #private;
26
+ /** Called while a start hold fills, from 0 to 1, and with 0 when it is let go early. */
27
+ onHold: (kind: Kind, progress: number) => void;
28
+ constructor(signal: AbortSignal);
29
+ /** Calls `run` on a start (a press of `key` or `touch`, or a full hold of the others) or a tap of `kind`. */
30
+ on(kind: Kind | null, run: (kind: Kind) => void, signal: AbortSignal): void;
31
+ /** Calls `run` on Escape outside a text field, which is kept from the game, whose own pause menu would open. */
32
+ escape(run: () => void, signal: AbortSignal): void;
33
+ /** A click of the corner button, which counts as the input it stands in for. */
34
+ corner(kind: Press): void;
35
+ /** What the tester gives feedback with in the XR session, as of the last poll; null outside XR or while untracked. */
36
+ get hand(): Hand | null;
37
+ /**
38
+ * Polls gamepads and, in XR, the hands; called once per rendered frame. A left controller holds its thumbstick press
39
+ * and trigger together, which nothing does by accident. Tracked hands hold a pinch on both hands with the two pinches
40
+ * touching: apps often use a pinch, or a pinch held, on either hand, but not the two brought together.
41
+ */
42
+ poll(frame: XRFrame | null): void;
43
+ /** A short pulse on the controller of `kind`, where it has one. */
44
+ pulse(kind: Kind, strength: number, ms: number): void;
45
+ }
@@ -0,0 +1,173 @@
1
+ const HOLD_MS = 1000;
2
+ /** Standard-mapping View / Select; xr-standard trigger, which on a tracked hand is the pinch; thumbstick press. */
3
+ const VIEW = 8;
4
+ const TRIGGER = 0;
5
+ const STICK = 3;
6
+ /**
7
+ * The feedback key: the one left of 1, below Esc, which every layout has. Browsers name it "Backquote", except on a
8
+ * Mac with an ISO keyboard (German, UK, …), where macOS reports it as "IntlBackslash" and the key next to the left
9
+ * Shift as "Backquote". Accepting both keeps the key working there; the extra one is rarely bound by games.
10
+ */
11
+ export const isHotkey = (event) => event.code === "Backquote" || event.code === "IntlBackslash";
12
+ /** What the key prints on this layout, e.g. "^" on German. Only Chromium can tell, elsewhere this stays "`". */
13
+ export let hotkey = "`";
14
+ export async function learnHotkey() {
15
+ const layout = await navigator.keyboard?.getLayoutMap().catch((error) => {
16
+ console.warn("feedback: could not read the keyboard layout", error);
17
+ });
18
+ const char = layout?.get("Backquote");
19
+ // No layout prints "<" left of 1, so "<" means the Mac ISO swap above, and the key is "IntlBackslash".
20
+ if (char)
21
+ hotkey = (char === "<" ? layout?.get("IntlBackslash") : char) ?? hotkey;
22
+ }
23
+ /** "press ` to send", "tap Send": how to act on this input, mid-sentence. A phone's only button names what it does. */
24
+ export function act(kind, after, button) {
25
+ if (kind === "touch")
26
+ return [`tap ${button}`];
27
+ if (kind === "hand")
28
+ return [`touch both pinches together${after}`];
29
+ const key = { key: hotkey, gamepad: "View", controller: "thumbstick + trigger" }[kind];
30
+ return [kind === "controller" ? "click " : "press ", { key }, after];
31
+ }
32
+ /** Turns raw presses and holds into what a flow waits for: a start, a tap of the started input, and Escape. */
33
+ export class Input {
34
+ /** Called while a start hold fills, from 0 to 1, and with 0 when it is let go early. */
35
+ onHold = () => { };
36
+ #waiters = new Set();
37
+ #escapes = new Set();
38
+ #holds = new Map();
39
+ #xr = null;
40
+ constructor(signal) {
41
+ addEventListener("keydown", this.#key, { signal, capture: true });
42
+ }
43
+ /** Calls `run` on a start (a press of `key` or `touch`, or a full hold of the others) or a tap of `kind`. */
44
+ on(kind, run, signal) {
45
+ this.#listen(this.#waiters, { kind, run }, signal);
46
+ }
47
+ /** Calls `run` on Escape outside a text field, which is kept from the game, whose own pause menu would open. */
48
+ escape(run, signal) {
49
+ this.#listen(this.#escapes, run, signal);
50
+ }
51
+ /** A click of the corner button, which counts as the input it stands in for. */
52
+ corner(kind) {
53
+ this.#press(kind, false);
54
+ }
55
+ /** What the tester gives feedback with in the XR session, as of the last poll; null outside XR or while untracked. */
56
+ get hand() {
57
+ const left = this.#source("left");
58
+ if (!left)
59
+ return null;
60
+ return isHand(left) ? "hand" : "controller";
61
+ }
62
+ /**
63
+ * Polls gamepads and, in XR, the hands; called once per rendered frame. A left controller holds its thumbstick press
64
+ * and trigger together, which nothing does by accident. Tracked hands hold a pinch on both hands with the two pinches
65
+ * touching: apps often use a pinch, or a pinch held, on either hand, but not the two brought together.
66
+ */
67
+ poll(frame) {
68
+ this.#xr = frame?.session ?? null;
69
+ for (const pad of navigator.getGamepads()) {
70
+ if (pad?.mapping === "standard")
71
+ this.#track(`pad${pad.index}`, "gamepad", pad.buttons[VIEW]?.pressed === true);
72
+ }
73
+ const left = this.#source("left");
74
+ const pressed = (source, index) => source?.gamepad?.buttons[index]?.pressed === true;
75
+ this.#track("controller", "controller", left !== null && !isHand(left) && pressed(left, TRIGGER) && pressed(left, STICK));
76
+ const right = this.#source("right");
77
+ const joined = frame !== null && pressed(left, TRIGGER) && pressed(right, TRIGGER) && touching(frame, left?.hand, right?.hand);
78
+ this.#track("hand", "hand", joined);
79
+ }
80
+ /** A short pulse on the controller of `kind`, where it has one. */
81
+ pulse(kind, strength, ms) {
82
+ if (kind === "controller")
83
+ void this.#source("left")?.gamepad?.hapticActuators[0]?.pulse(strength, ms);
84
+ if (kind !== "gamepad")
85
+ return;
86
+ for (const pad of navigator.getGamepads()) {
87
+ void pad?.vibrationActuator?.playEffect("dual-rumble", { duration: ms, strongMagnitude: strength, weakMagnitude: strength });
88
+ }
89
+ }
90
+ #listen(set, item, signal) {
91
+ if (signal.aborted)
92
+ return;
93
+ set.add(item);
94
+ signal.addEventListener("abort", () => set.delete(item), { once: true });
95
+ }
96
+ #source(handedness) {
97
+ for (const source of this.#xr?.inputSources ?? [])
98
+ if (source.handedness === handedness && source.gamepad)
99
+ return source;
100
+ return null;
101
+ }
102
+ #key = (event) => {
103
+ if (event.repeat || typing(event))
104
+ return;
105
+ if (event.key === "Escape" && this.#escapes.size) {
106
+ event.preventDefault();
107
+ event.stopPropagation();
108
+ for (const run of [...this.#escapes])
109
+ run();
110
+ }
111
+ else if (isHotkey(event)) {
112
+ event.preventDefault();
113
+ this.#press("key", false);
114
+ }
115
+ };
116
+ #track(id, kind, pressed) {
117
+ const hold = this.#holds.get(id);
118
+ if (!pressed) {
119
+ if (hold && !hold.done)
120
+ this.onHold(kind, 0);
121
+ this.#holds.delete(id);
122
+ }
123
+ else if (!hold) {
124
+ this.#holds.set(id, { since: performance.now(), done: false });
125
+ this.#press(kind, false);
126
+ }
127
+ else if (!hold.done) {
128
+ const progress = Math.min(1, (performance.now() - hold.since) / HOLD_MS);
129
+ hold.done = progress === 1;
130
+ if ([...this.#waiters].some(waiter => waiter.kind === null))
131
+ this.onHold(kind, progress);
132
+ if (hold.done)
133
+ this.#press(kind, true);
134
+ }
135
+ }
136
+ #press(kind, held) {
137
+ const starts = held || kind === "key" || kind === "touch";
138
+ for (const waiter of [...this.#waiters]) {
139
+ if (waiter.kind === null ? starts : waiter.kind === kind && !held)
140
+ waiter.run(kind);
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * Quest Browser reports a tracked hand with a gamepad whose button 0 is the pinch, and with the generic-hand profiles;
146
+ * its joints (`source.hand`) come only in a session that asked for hand tracking, the only one with hands at all.
147
+ */
148
+ function isHand(source) {
149
+ return Boolean(source.hand) || source.profiles.some(profile => profile.startsWith("generic-hand"));
150
+ }
151
+ /** Pinch midpoints within 7 cm count as touching, allowing for finger width and tracking error. */
152
+ const TOUCH_M = 0.07;
153
+ /** Whether the pinch points of both hands, each midway between thumb tip and index tip, touch. */
154
+ function touching(frame, left, right) {
155
+ const base = left?.get("wrist");
156
+ if (!left || !right || !base || !frame.getJointPose)
157
+ return false;
158
+ const at = (hand, joint) => {
159
+ const space = hand.get(joint);
160
+ return space ? frame.getJointPose?.(space, base)?.transform.position : undefined;
161
+ };
162
+ const ends = [at(left, "thumb-tip"), at(left, "index-finger-tip"), at(right, "thumb-tip"), at(right, "index-finger-tip")];
163
+ const [lt, li, rt, ri] = ends;
164
+ if (!lt || !li || !rt || !ri)
165
+ return false;
166
+ const gap = (axis) => (lt[axis] + li[axis] - rt[axis] - ri[axis]) / 2;
167
+ return Math.hypot(gap("x"), gap("y"), gap("z")) < TOUCH_M;
168
+ }
169
+ /** Trigger keys are ignored while the tester types. The path's start sees through shadow roots, unlike activeElement. */
170
+ function typing(event) {
171
+ const element = event.composedPath()[0];
172
+ return element instanceof HTMLElement && (element.isContentEditable || element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement);
173
+ }
@@ -0,0 +1,13 @@
1
+ import type { Context } from "./run.ts";
2
+ import { Session } from "./session.ts";
3
+ /** What a tester's email is for, wherever they may leave it. A sign-up hears of new versions once they play its link. */
4
+ export declare const EMAIL_USE = "Leave an email to get your own link for new versions, and for questions about your feedback.";
5
+ /**
6
+ * The full-screen cover on every load in feedback mode. On a device the playtest isn't for, it first says which
7
+ * devices it is for, so the tester can switch or go on anyway. The invite says what the player is asked to do and why,
8
+ * and takes optional details; Start, or entering VR, starts the session, then asks for the microphone and lets sound
9
+ * play. The walkthrough then shows how feedback works on this device. A microphone that fails counts as none, so the
10
+ * walkthrough teaches typing instead. A broken intro must not cost the playtest its feedback, so it is logged, and the
11
+ * session starts anyway.
12
+ */
13
+ export declare function intro(ctx: Context, version: string | null): Promise<Session>;