@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,101 @@
1
+ import { LIMITS } from "../service.js";
2
+ import { describeDevice } from "./device.js";
3
+ import { finishSession, joinReviewers, seeSession, startSession, storeAnswers } from "./server.js";
4
+ /**
5
+ * The tester's playtest on this page load, as the service records it: started once, when the tester starts, seen again
6
+ * each time the page is hidden or closed, and finished when the game calls `finish()`. Its items point at it, so the
7
+ * owner sees who played which version, for how long, whether to the end, and what they said along the way.
8
+ */
9
+ export class Session {
10
+ id = crypto.randomUUID();
11
+ /** Rejects when the service never got the session, which is logged here. */
12
+ #started;
13
+ #options;
14
+ #tester;
15
+ #signal;
16
+ /** Starts the session, retried since every item points at it. Playing through one's own reviewer link enlists. */
17
+ constructor(options, tester, version, signal) {
18
+ this.#options = options;
19
+ this.#tester = tester;
20
+ this.#signal = signal;
21
+ const { name, contact, reviewer } = tester;
22
+ const start = { id: this.id, project: options.project, tester: name, contact, version, url: location.href, device: device(), reviewer, minutes: options.minutes ?? null };
23
+ this.#started = startSession(start, signal).then(id => {
24
+ if (id)
25
+ tester.enlist(id);
26
+ });
27
+ this.#started.catch(error => {
28
+ if (!signal.aborted)
29
+ console.error("feedback: could not start the session", error);
30
+ });
31
+ document.addEventListener("visibilitychange", () => {
32
+ if (document.hidden)
33
+ this.#after(seeSession, "mark the session seen");
34
+ }, { signal });
35
+ }
36
+ finish() {
37
+ this.#after(finishSession, "finish the session");
38
+ }
39
+ /** Stores the closing screen's answers with the session. Rejects when they can't be, also when it never started. */
40
+ async answer(answers) {
41
+ await this.#started;
42
+ await storeAnswers(this.id, answers, this.#signal);
43
+ }
44
+ /** Joins the tester to the project's reviewers with `email`, from the closing screen. Rejects like `answer`. */
45
+ async join(email) {
46
+ await this.#started;
47
+ await joinReviewers(this.id, email, this.#signal);
48
+ this.#tester.leave(email);
49
+ }
50
+ /** An item of this session, from what the tester said or typed, in `xr` when given in an XR session. */
51
+ item({ xr = null, state = null, text, overall }) {
52
+ const { name, contact } = this.#tester;
53
+ return {
54
+ id: crypto.randomUUID(),
55
+ project: this.#options.project,
56
+ session: this.id,
57
+ tester: name,
58
+ contact,
59
+ url: location.href,
60
+ device: device(),
61
+ // XRSession doesn't expose its mode; an opaque blend is VR, anything see-through is AR.
62
+ xr: xr && (xr.environmentBlendMode === "opaque" ? "immersive-vr" : "immersive-ar"),
63
+ state,
64
+ ...(overall && { overall }),
65
+ ...(text && { text }),
66
+ };
67
+ }
68
+ /**
69
+ * Calls the game's `pause()` and returns its state as plain JSON within the size cap: the round trip applies toJSON
70
+ * and drops undefined and functions. Null when it threw, returned nothing JSON can hold, or too much; all logged.
71
+ */
72
+ pause() {
73
+ let value;
74
+ let text;
75
+ try {
76
+ value = this.#options.pause?.();
77
+ text = JSON.stringify(value);
78
+ }
79
+ catch (error) {
80
+ console.error("feedback: pause() threw or returned something JSON.stringify can't handle, so no state is sent", error);
81
+ return null;
82
+ }
83
+ if (text === undefined) {
84
+ if (value !== undefined)
85
+ console.warn("feedback: pause() returned something that isn't JSON, so no state is sent");
86
+ return null;
87
+ }
88
+ const bytes = new TextEncoder().encode(text).length;
89
+ if (bytes <= LIMITS.state)
90
+ return JSON.parse(text);
91
+ console.warn(`feedback: pause() returned ${bytes} bytes of state, over the ${LIMITS.state} byte cap, so none is sent`);
92
+ return null;
93
+ }
94
+ /** Sends once the session started; a session that never started has nothing to send, and its failure is logged. */
95
+ #after(request, what) {
96
+ this.#started.then(() => request(this.id).catch(error => console.error(`feedback: could not ${what}`, error)), () => { });
97
+ }
98
+ }
99
+ function device() {
100
+ return describeDevice(navigator.userAgent);
101
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Where a clip's words name the feedback key, which they say as its place on the keyboard. A heading shows the key's
3
+ * cap, and at `PLACED`, its first mention, also where it is: the cap alone reads as Ctrl or Shift+6 to many.
4
+ */
5
+ export declare const KEY = "{key}";
6
+ export declare const PLACED = "{key, left of 1}";
7
+ /** Where the feedback key is, the same on every layout. */
8
+ export declare const PLACE = "the key left of\u00A01";
9
+ /**
10
+ * Voice clips and the words each one says, kept together so `pnpm clips` can regenerate a clip whose words change:
11
+ * what every device hears in a turn and on the last page, the hint heard the first time each XR input is used, and the
12
+ * walkthrough's narration, one per step and input, whose words are also its headings. English in v1. A controller's
13
+ * gesture is spelled out: few people know the thumbstick is also a button.
14
+ */
15
+ export declare const CLIPS: {
16
+ "hold-controller": {
17
+ url: URL;
18
+ words: string;
19
+ };
20
+ "hold-hand": {
21
+ url: URL;
22
+ words: string;
23
+ };
24
+ show: {
25
+ url: URL;
26
+ words: string;
27
+ };
28
+ "show-controller": {
29
+ url: URL;
30
+ words: string;
31
+ };
32
+ "show-hand": {
33
+ url: URL;
34
+ words: string;
35
+ };
36
+ "speak-controller": {
37
+ url: URL;
38
+ words: string;
39
+ };
40
+ "speak-hand": {
41
+ url: URL;
42
+ words: string;
43
+ };
44
+ sending: {
45
+ url: URL;
46
+ words: string;
47
+ };
48
+ unheard: {
49
+ url: URL;
50
+ words: string;
51
+ };
52
+ failed: {
53
+ url: URL;
54
+ words: string;
55
+ };
56
+ nomic: {
57
+ url: URL;
58
+ words: string;
59
+ };
60
+ thanks: {
61
+ url: URL;
62
+ words: string;
63
+ };
64
+ end: {
65
+ url: URL;
66
+ words: string;
67
+ };
68
+ "a-welcome": {
69
+ url: URL;
70
+ words: string;
71
+ };
72
+ "b-key": {
73
+ url: URL;
74
+ words: string;
75
+ };
76
+ "b-touch": {
77
+ url: URL;
78
+ words: string;
79
+ };
80
+ "b-controller": {
81
+ url: URL;
82
+ words: string;
83
+ };
84
+ "b-hand": {
85
+ url: URL;
86
+ words: string;
87
+ };
88
+ "b-type-key": {
89
+ url: URL;
90
+ words: string;
91
+ };
92
+ "b-type-touch": {
93
+ url: URL;
94
+ words: string;
95
+ };
96
+ "c-key": {
97
+ url: URL;
98
+ words: string;
99
+ };
100
+ "c-touch": {
101
+ url: URL;
102
+ words: string;
103
+ };
104
+ "c-quest": {
105
+ url: URL;
106
+ words: string;
107
+ };
108
+ "d-key": {
109
+ url: URL;
110
+ words: string;
111
+ };
112
+ "d-touch": {
113
+ url: URL;
114
+ words: string;
115
+ };
116
+ "d-quest": {
117
+ url: URL;
118
+ words: string;
119
+ };
120
+ };
121
+ export type Clip = keyof typeof CLIPS;
122
+ /** The words as said aloud. */
123
+ export declare function spoken(words: string): string;
124
+ /** Tones as [frequency, delay, duration, volume, wave]. */
125
+ type Tone = [number, number, number, number?, OscillatorType?];
126
+ declare const TONES: Record<"start" | "tick" | "nothing" | "failed", Tone[]>;
127
+ /** Tones and voice clips, on one AudioContext that the microphone meter shares. */
128
+ export declare class Sounds {
129
+ #private;
130
+ readonly context: AudioContext;
131
+ constructor(signal: AbortSignal);
132
+ play(name: keyof typeof TONES): void;
133
+ /** Rises with a hold's progress. */
134
+ hold(progress: number): void;
135
+ /** Plays a clip without waiting for it; one that fails is logged. */
136
+ say(name: Clip): void;
137
+ /** Plays a clip and resolves once it has ended; aborting `signal` cuts it off. */
138
+ clip(name: Clip, signal?: AbortSignal): Promise<void>;
139
+ }
140
+ export {};
@@ -0,0 +1,118 @@
1
+ /** What the tool asks for when feedback is about something on screen. */
2
+ const SHOW = "If it's on screen, you'll be asked to show it.";
3
+ /**
4
+ * Where a clip's words name the feedback key, which they say as its place on the keyboard. A heading shows the key's
5
+ * cap, and at `PLACED`, its first mention, also where it is: the cap alone reads as Ctrl or Shift+6 to many.
6
+ */
7
+ export const KEY = "{key}";
8
+ export const PLACED = "{key, left of 1}";
9
+ /** Where the feedback key is, the same on every layout. */
10
+ export const PLACE = "the key left of 1";
11
+ // Each URL is spelled out in full, which is what lets bundlers find the file.
12
+ const clip = (url, words) => ({ url, words });
13
+ /**
14
+ * Voice clips and the words each one says, kept together so `pnpm clips` can regenerate a clip whose words change:
15
+ * what every device hears in a turn and on the last page, the hint heard the first time each XR input is used, and the
16
+ * walkthrough's narration, one per step and input, whose words are also its headings. English in v1. A controller's
17
+ * gesture is spelled out: few people know the thumbstick is also a button.
18
+ */
19
+ export const CLIPS = {
20
+ "hold-controller": clip(new URL("./sounds/hold-controller.m4a", import.meta.url), "To give feedback with your left controller, press the thumbstick straight down until it clicks, and pull the trigger under your index finger at the same time. Hold both for a second."),
21
+ "hold-hand": clip(new URL("./sounds/hold-hand.m4a", import.meta.url), "To give feedback with your hands, pinch thumb and index finger on both hands, and touch the two pinches together. Hold for a second."),
22
+ show: clip(new URL("./sounds/show.m4a", import.meta.url), "Make sure it's in view, then press again."),
23
+ "show-controller": clip(new URL("./sounds/show-controller.m4a", import.meta.url), "Make sure it's in view, then click the thumbstick and trigger together again."),
24
+ "show-hand": clip(new URL("./sounds/show-hand.m4a", import.meta.url), "Make sure it's in view, then touch your two pinches together again."),
25
+ "speak-controller": clip(new URL("./sounds/speak-controller.m4a", import.meta.url), "After the tone, describe what you noticed. To send, click the thumbstick and trigger together again."),
26
+ "speak-hand": clip(new URL("./sounds/speak-hand.m4a", import.meta.url), "After the tone, describe what you noticed. To send, touch your pinches together again."),
27
+ sending: clip(new URL("./sounds/sending.m4a", import.meta.url), "Got it. Sending, one moment."),
28
+ unheard: clip(new URL("./sounds/unheard.m4a", import.meta.url), "Didn't catch that, so nothing was sent. Try again anytime."),
29
+ failed: clip(new URL("./sounds/failed.m4a", import.meta.url), "Your feedback couldn't be sent. Try again in a moment."),
30
+ nomic: clip(new URL("./sounds/nomic.m4a", import.meta.url), "The mic is off, so nothing was recorded. Allow the mic for this site, and for the browser in the headset's privacy settings."),
31
+ thanks: clip(new URL("./sounds/thanks.m4a", import.meta.url), "Sent, thanks!"),
32
+ end: clip(new URL("./sounds/end.m4a", import.meta.url), "Thanks for taking part. You can close this tab now."),
33
+ "a-welcome": clip(new URL("./sounds/walkthrough/a-welcome.m4a", import.meta.url), "You're about to test the experience. Whenever something in it feels off, report it. Each report is sent, with a screenshot when it's about something you can see. Here's how."),
34
+ "b-key": clip(new URL("./sounds/walkthrough/b-key.m4a", import.meta.url), `Press ${PLACED} and describe what you noticed. Then press ${KEY} to send.`),
35
+ "b-touch": clip(new URL("./sounds/walkthrough/b-touch.m4a", import.meta.url), "Tap Feedback and describe what you noticed. Then tap Send."),
36
+ "b-controller": clip(new URL("./sounds/walkthrough/b-controller.m4a", import.meta.url), "With controllers, use the left one. Press the thumbstick straight down until it clicks, it's also a button, and pull the trigger under your index finger at the same time. Hold both for a second, then describe what you noticed. To send, click both together again."),
37
+ "b-hand": clip(new URL("./sounds/walkthrough/b-hand.m4a", import.meta.url), "With your hands, pinch thumb and index finger on both hands, and touch the two pinches together. Hold for a second, then describe what you noticed. To send, touch them together again."),
38
+ "b-type-key": clip(new URL("./sounds/walkthrough/b-type-key.m4a", import.meta.url), `Press ${PLACED}, type what you noticed and press Enter.`),
39
+ "b-type-touch": clip(new URL("./sounds/walkthrough/b-type-touch.m4a", import.meta.url), "Tap Feedback, type what you noticed and tap Send."),
40
+ "c-key": clip(new URL("./sounds/walkthrough/c-key.m4a", import.meta.url), `${SHOW} Your next press takes a screenshot.`),
41
+ "c-touch": clip(new URL("./sounds/walkthrough/c-touch.m4a", import.meta.url), `${SHOW} Your next tap takes a screenshot.`),
42
+ "c-quest": clip(new URL("./sounds/walkthrough/c-quest.m4a", import.meta.url), `${SHOW} Look at it, then click both again, or touch your pinches together again, to take a screenshot.`),
43
+ "d-key": clip(new URL("./sounds/walkthrough/d-key.m4a", import.meta.url), `You're ready. Press ${KEY} to close this and start.`),
44
+ "d-touch": clip(new URL("./sounds/walkthrough/d-touch.m4a", import.meta.url), "You're ready. Tap Feedback to close this and start."),
45
+ "d-quest": clip(new URL("./sounds/walkthrough/d-quest.m4a", import.meta.url), "You're ready. Enter the experience to start."),
46
+ };
47
+ /** The words as said aloud. */
48
+ export function spoken(words) {
49
+ return words.replaceAll(PLACED, KEY).replaceAll(KEY, PLACE);
50
+ }
51
+ const TONES = {
52
+ start: [[880, 0, 0.12]],
53
+ /** The seconds before a recording stops itself. */
54
+ tick: [[1200, 0, 0.03, 0.08]],
55
+ nothing: [[660, 0, 0.15], [440, 0.15, 0.25]],
56
+ failed: [[180, 0, 0.4, 0.25, "triangle"]],
57
+ };
58
+ /** Tones and voice clips, on one AudioContext that the microphone meter shares. */
59
+ export class Sounds {
60
+ context = new AudioContext();
61
+ #clips = new Map();
62
+ constructor(signal) {
63
+ // Browsers keep a new context suspended until a user activation, which pointerdown doesn't count as.
64
+ const wake = () => void this.context.resume();
65
+ for (const type of ["pointerup", "click", "touchend", "keydown"])
66
+ addEventListener(type, wake, { signal, capture: true });
67
+ signal.addEventListener("abort", () => void this.context.close(), { once: true });
68
+ }
69
+ play(name) {
70
+ for (const tone of TONES[name])
71
+ this.#tone(...tone);
72
+ }
73
+ /** Rises with a hold's progress. */
74
+ hold(progress) {
75
+ this.#tone(300 + 600 * progress, 0, 0.05, 0.08);
76
+ }
77
+ /** Plays a clip without waiting for it; one that fails is logged. */
78
+ say(name) {
79
+ this.clip(name).catch(error => console.error(`feedback: could not play "${CLIPS[name].words}"`, error));
80
+ }
81
+ /** Plays a clip and resolves once it has ended; aborting `signal` cuts it off. */
82
+ async clip(name, signal) {
83
+ let buffer = this.#clips.get(name);
84
+ if (!buffer) {
85
+ // A failed load leaves the cache, so the next play tries again.
86
+ buffer = load(this.context, CLIPS[name].url).catch(error => {
87
+ this.#clips.delete(name);
88
+ throw error;
89
+ });
90
+ this.#clips.set(name, buffer);
91
+ }
92
+ const source = new AudioBufferSourceNode(this.context, { buffer: await buffer });
93
+ if (signal?.aborted)
94
+ return;
95
+ source.connect(this.context.destination);
96
+ const ended = new Promise(resolve => source.addEventListener("ended", resolve, { once: true }));
97
+ source.start();
98
+ signal?.addEventListener("abort", () => source.stop(), { once: true });
99
+ await ended;
100
+ }
101
+ #tone(frequency, delay, duration, volume = 0.2, type = "sine") {
102
+ const at = this.context.currentTime + delay;
103
+ const oscillator = new OscillatorNode(this.context, { frequency, type });
104
+ const gain = new GainNode(this.context, { gain: 0 });
105
+ gain.gain.setValueAtTime(0, at);
106
+ gain.gain.linearRampToValueAtTime(volume, at + 0.01);
107
+ gain.gain.exponentialRampToValueAtTime(0.001, at + duration);
108
+ oscillator.connect(gain).connect(this.context.destination);
109
+ oscillator.start(at);
110
+ oscillator.stop(at + duration + 0.02);
111
+ }
112
+ }
113
+ async function load(context, url) {
114
+ const response = await fetch(url);
115
+ if (!response.ok)
116
+ throw new Error(`feedback: could not load ${url} (${response.status})`);
117
+ return context.decodeAudioData(await response.arrayBuffer());
118
+ }
@@ -0,0 +1,10 @@
1
+ import type { Theme } from "../index.ts";
2
+ /** Sets the game's `theme` on the shadow host, whose own style outranks the `:host` defaults. */
3
+ export declare function paint(host: HTMLElement, theme?: Theme): void;
4
+ /**
5
+ * Everything the SDK shows, kept flat: surfaces told apart by fill alone, one radius, one accent, and red only for
6
+ * recording. Dark by default; every quieter shade is mixed from `--text`, so any theme keeps its contrast steps. Three
7
+ * layers: the cover (the intro's and the closing screen's page), feedback mode over the game (a frame, a caption and
8
+ * the typing sheet), and the corner button on top.
9
+ */
10
+ export declare const STYLE = "\n:host { all: initial;\n --font: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif; --mono: ui-monospace, \"SF Mono\", Menlo, monospace;\n --text: #f5f5f7; --background: #121214; --glass: rgba(24, 24, 26, 0.92); --radius: 8px;\n --accent: #fff; --on-accent: #111113; --record: #ff453a;\n --muted: color-mix(in srgb, var(--text) 62%, transparent); --faint: color-mix(in srgb, var(--text) 50%, transparent);\n --fill: color-mix(in srgb, var(--text) 8%, transparent); --raised: color-mix(in srgb, var(--text) 14%, transparent);\n --ease: cubic-bezier(0.2, 0.8, 0.2, 1); }\n* { box-sizing: border-box; }\n[hidden] { display: none !important; }\np { margin: 0; }\n:focus { outline: none; }\n:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\nh1:focus-visible { outline: none; }\nkbd { display: inline-block; min-width: 1.6em; height: 1.6em; padding: 0 0.4em; margin: 0 0.1em; border-radius: 4px;\n background: var(--raised); vertical-align: 0.05em; font: 500 0.85em/1.5em var(--mono); text-align: center; color: var(--text); }\nh1 kbd, .lines kbd { font-size: 0.65em; vertical-align: 0.2em; }\ninput, textarea { display: block; width: 100%; margin: 0; padding: 0 12px; border: 0; border-radius: var(--radius);\n background: var(--fill); color: var(--text); font: 400 16px/24px var(--font); }\ninput { height: 48px; }\ntextarea { padding: 12px; resize: none; }\ninput:focus-visible, textarea:focus-visible { outline: 1.5px solid var(--muted); outline-offset: -1.5px; }\n::placeholder { color: var(--faint); }\nbutton { border: 0; font: 500 16px var(--font); color: var(--text); cursor: pointer; }\nbutton:disabled { opacity: 0.6; cursor: default; }\n.primary { height: 48px; padding: 0 28px; border-radius: var(--radius); background: var(--accent); color: var(--on-accent); font-weight: 600; }\n.secondary { height: 48px; padding: 0 20px; border-radius: var(--radius); background: var(--fill); }\n.secondary:hover { background: var(--raised); }\n/* A quiet text button; on Quest the one control on the cover its clicks reach. */\n.quiet { padding: 0; background: none; color: var(--muted); font: inherit; text-decoration: underline;\n text-underline-offset: 3px; pointer-events: auto; }\n.quiet:hover { color: var(--text); }\n.dot { display: inline-block; width: 0.4em; height: 0.4em; margin-right: 0.35em; border-radius: 50%; background: var(--record);\n vertical-align: 0.12em; animation: pulse 1.6s ease-in-out infinite; }\n@keyframes pulse { 50% { opacity: 0.55; } }\n@keyframes fade { from { opacity: 0; } }\n@keyframes slide { from { translate: -100%; } to { translate: 250%; } }\n@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }\n\n/* The cover: one calm, opaque page with a wide centred column of large type, while the game renders behind it. */\n.cover { position: fixed; inset: 0; z-index: 2147483646; display: flex; overflow-y: auto; overscroll-behavior: contain;\n padding: max(48px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right)) max(48px, env(safe-area-inset-bottom))\n max(24px, env(safe-area-inset-left));\n background: var(--background); font: 400 16px/24px var(--font); color: var(--text); -webkit-font-smoothing: antialiased;\n animation: fade 200ms var(--ease); }\n/* On Quest, clicks reach the game's Enter VR button, and its last step lets the game show through, dimmed. */\n.cover.through { pointer-events: none; }\n.cover.clear { background: color-mix(in srgb, var(--background) 72%, transparent); }\n.card { width: 100%; max-width: 680px; margin: auto; }\n.logo { display: block; max-width: 200px; max-height: 40px; margin-bottom: 32px; object-fit: contain; object-position: left; }\nh1 { margin: 0; font-size: 36px; line-height: 44px; font-weight: 600; letter-spacing: -0.02em; text-wrap: balance; }\nh2 { margin: 0 0 4px; font-size: 14px; line-height: 20px; font-weight: 500; color: var(--muted); }\n.lead { margin-top: 24px; font-size: 20px; line-height: 28px; text-wrap: pretty; }\n.meta { margin-top: 8px; color: var(--muted); }\n.brief, form { margin-top: 32px; }\n.cover .primary { min-width: 240px; }\nfieldset { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; min-width: 0; margin: 0 0 28px; padding: 0; border: 0; }\nlegend { float: left; width: 100%; padding: 0; }\nlabel { display: grid; gap: 6px; min-width: 0; font-size: 14px; line-height: 20px; color: var(--muted); }\n.hint { grid-column: 1 / -1; margin: -12px 0 4px; }\nform > .quiet { display: block; margin-top: 16px; }\n.fine { margin-top: 16px; font-size: 14px; line-height: 20px; color: var(--faint); text-wrap: pretty; }\n.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 28px; }\n/* A link to open on another device, in full so it can be read off and typed. */\n.url { display: flex; align-items: center; gap: 12px; margin-top: 28px; padding: 8px 8px 8px 16px; border-radius: var(--radius);\n background: var(--fill); }\n.url .secondary { flex: none; height: 40px; }\n.url code { flex: 1; min-width: 0; overflow-wrap: anywhere; font: 400 14px/20px var(--mono); user-select: all; }\n\n/* The walkthrough: one miniature of the screen per step, the same size on every step, so the controls stay put. */\n.walk { display: grid; grid-template-rows: auto auto minmax(124px, auto) auto; gap: 24px; }\n.walk h1 { font-size: 28px; line-height: 36px; font-weight: 500; letter-spacing: -0.01em; text-wrap: pretty; }\n.progress, .controls { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 24px; font-size: 14px;\n line-height: 20px; color: var(--muted); }\n.controls { justify-content: start; font-size: 16px; }\n.example { font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }\n.note { margin-top: 8px; color: var(--muted); }\n.screen { position: relative; overflow: hidden; height: 168px; border-radius: var(--radius); background: var(--fill); }\n.screen.short { height: 84px; }\n.screen.framed { outline: 2px solid var(--faint); outline-offset: -2px; }\n.screen .lines { position: absolute; top: 16px; left: 50%; width: max-content; max-width: 160%; transform: translateX(-50%) scale(0.58);\n transform-origin: top center; }\n.screen .lines p + p { font-size: 19px; }\n.screen .dock { position: absolute; right: 20px; bottom: 20px; transform: scale(1.5); transform-origin: bottom right; }\n.screen .sheet { position: absolute; inset: 16px 20%; display: grid; grid-template-rows: auto 1fr; gap: 8px; padding: 8px;\n border-radius: var(--radius); background: var(--background); font: 400 11px/16px var(--font); color: var(--muted); }\n.screen .sheet div { display: flex; justify-content: space-between; }\n.screen .sheet b { font-size: 12px; color: var(--text); }\n.screen .sheet i { padding: 0 8px; border-radius: 4px; background: var(--accent); color: var(--on-accent); font-style: normal; }\n.screen .sheet span { border-radius: 4px; background: var(--fill); }\n.screen .keys ~ .sheet { left: 188px; right: 20px; }\n.keys { position: absolute; top: 20px; left: 20px; }\n.keys svg { display: block; height: 96px; }\n.keys rect { fill: var(--raised); }\n.keys text { fill: var(--muted); font: 500 10px var(--font); text-anchor: middle; }\n.keys .lit rect { fill: var(--accent); }\n.keys .lit text { fill: var(--on-accent); font: 600 13px var(--mono); }\n.screen.hands { display: flex; align-items: center; justify-content: center; gap: 32px; }\n.screen.hands img { height: 136px; }\n\n/* The closing screen: the game's questions, where a slider shows no value until touched, and the last word. */\n.questions { display: grid; gap: 28px; margin-bottom: 28px; }\n.question { display: grid; grid-template-columns: 1fr; gap: 10px; margin: 0; }\n.ask { display: flex; justify-content: space-between; gap: 16px; padding: 0; font-size: 16px; line-height: 24px; font-weight: 500;\n color: var(--text); }\n.value { color: var(--accent); font-variant-numeric: tabular-nums; }\ninput[type=\"range\"] { height: 32px; padding: 0; background: none; accent-color: var(--accent); }\ninput[type=\"range\"].unset { opacity: 0.5; }\n.ends, .options { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 16px; font-size: 14px; color: var(--muted); }\n.options { justify-content: start; gap: 8px; }\n.option { position: relative; display: flex; align-items: center; height: 44px; padding: 0 16px; border-radius: var(--radius);\n background: var(--fill); color: var(--text); font-size: 16px; cursor: pointer; }\n.option:has(:checked) { background: var(--accent); color: var(--on-accent); }\n.option:has(:focus-visible) { outline: 1.5px solid var(--muted); outline-offset: 2px; }\n.option input { position: absolute; inset: 0; height: 100%; opacity: 0; pointer-events: none; }\n.reply { border-radius: var(--radius); background: var(--fill); }\n.reply textarea { min-height: 112px; background: none; resize: vertical; }\n.voice { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; min-height: 112px; padding: 12px 16px;\n font-variant-numeric: tabular-nums; }\n.voice > span { flex: 1; }\n.voice .secondary, .reply .record { height: 40px; background: var(--raised); }\n.reply .record { margin: 0 8px 8px auto; border-radius: var(--radius); display: flex; align-items: center; gap: 6px; padding: 0 12px; font-size: 14px; }\n.join { margin-top: 28px; max-width: 360px; }\n.status { margin-top: 12px; font-size: 14px; line-height: 20px; color: var(--muted); }\n\n/* Feedback mode: a frame round the viewport, light with a thin dark line inside to show over any scene, and a caption. */\n.mode { position: fixed; inset: 0; z-index: 2147483646; display: flex; flex-direction: column; align-items: center; gap: 8px;\n padding: max(40px, env(safe-area-inset-top)) 48px 0; pointer-events: none; text-align: center; font: 400 16px/24px var(--font); color: var(--text);\n transition: opacity 300ms var(--ease); animation: fade 180ms var(--ease); }\n.mode::before { content: \"\"; position: absolute; inset: 0; z-index: 1; border: 3px solid rgba(255, 255, 255, 0.85);\n outline: 2px solid rgba(0, 0, 0, 0.6); outline-offset: -5px; }\n.mode.leaving { opacity: 0; }\n.lines { display: grid; justify-items: center; gap: 4px; max-width: 100%; padding: 12px 20px; border-radius: var(--radius);\n background: var(--glass); -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px); text-align: center; }\n.lines p:first-child { max-width: 800px; font: 600 28px/36px var(--font); letter-spacing: -0.01em; text-wrap: pretty; }\n.lines p + p { font: 400 16px/24px var(--font); color: var(--muted); }\n.tick { font-variant-numeric: tabular-nums; white-space: nowrap; }\n.lines p:first-child .tick { font-weight: 400; color: var(--muted); }\n.bar { position: relative; overflow: hidden; width: 120px; height: 3px; margin: 8px 0 4px; border-radius: 2px;\n background: color-mix(in srgb, var(--text) 20%, transparent); }\n.bar::after { content: \"\"; position: absolute; inset: 0; width: 40%; border-radius: 2px; background: var(--text);\n animation: slide 1.4s ease-in-out infinite; }\n.mode > .quiet { padding: 4px 8px; color: var(--text); font: 500 14px/20px var(--font); }\n/* The typing sheet: a card with a large field over the dimmed, paused game; it has the caption's words. */\n.mode:has(.sheet) > :not(.sheet) { display: none; }\n.sheet { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px;\n background: color-mix(in srgb, var(--background) 60%, transparent); pointer-events: auto; text-align: left; }\n.mode .sheet > div { display: grid; gap: 12px; width: 100%; max-width: 640px; padding: 16px; border-radius: calc(var(--radius) * 1.5);\n background: var(--background); }\n.head { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; }\n.head h2 { margin: 0; font: 600 18px/24px var(--font); color: var(--text); }\n.head .quiet { justify-self: start; text-decoration: none; }\n.head .primary { justify-self: end; height: 40px; padding: 0 20px; }\n.sheet textarea { min-height: 40vh; }\n.foot { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 16px; font: 400 14px/20px var(--font); color: var(--muted); }\n/* A phone's sheet is edge to edge over the space the on-screen keyboard leaves. */\n.sheet.phone { bottom: auto; height: 100%; padding: 0; align-items: stretch; }\n.mode .sheet.phone > div { max-width: none; grid-template-rows: auto 1fr auto; border-radius: 0; padding: max(12px, env(safe-area-inset-top)) 16px 12px; }\n.sheet.phone textarea { min-height: 0; height: 100%; }\n\n/* The corner button, 16 px from the edges, over everything. */\n.dock { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 2147483647; }\n.corner { position: relative; overflow: hidden; display: inline-flex; align-items: center; gap: 6px; height: 40px; padding: 0 8px 0 10px;\n border-radius: var(--radius); background: var(--glass); -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px);\n font: 500 14px var(--font); white-space: nowrap; transition: transform 120ms var(--ease), background-color 120ms var(--ease); }\n.corner.touch { height: 48px; padding: 0 16px 0 12px; }\n.corner svg { flex: none; width: 20px; height: 20px; }\n.corner kbd { margin: 0 0 0 2px; }\n.corner:hover { background: color-mix(in srgb, var(--glass), var(--text) 8%); }\n.corner:active { transform: scale(0.96); }\n/* Over the cover's backdrop the glass would vanish; the walkthrough's last step lights it. */\n.corner.raised { background: var(--raised); }\n.corner.lit, .corner.lit:hover { background: var(--accent); color: var(--on-accent); }\n.corner.lit kbd { background: color-mix(in srgb, var(--on-accent) 10%, transparent); color: var(--on-accent); }\n.corner[aria-disabled=\"true\"] > * { opacity: 0.35; }\n.corner[aria-label=\"Send recording\"] svg { animation: pulse 1.6s ease-in-out infinite; }\n/* A hold fills the button from the left. */\n.corner::before { content: \"\"; position: absolute; inset: 0; background: color-mix(in srgb, var(--text) 20%, transparent);\n transform-origin: left; transform: scaleX(var(--hold, 0)); pointer-events: none; }\n\n/* A phone gets the same pages in one column, with room at the bottom for the corner button. */\n@media (max-width: 560px) {\n .cover { padding: max(32px, env(safe-area-inset-top)) 20px calc(max(16px, env(safe-area-inset-bottom)) + 72px); }\n h1 { font-size: 28px; line-height: 36px; }\n .walk h1, .lines p:first-child { font-size: 24px; line-height: 32px; }\n .cover .primary { width: 100%; }\n fieldset { grid-template-columns: 1fr; }\n .walk { grid-template-rows: auto auto minmax(160px, auto) auto; }\n .screen { height: 156px; }\n .screen .dock { right: 12px; bottom: 12px; transform: scale(1.25); }\n .mode { padding: max(64px, env(safe-area-inset-top)) 24px 0; }\n}\n";