@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.
- package/README.md +47 -2
- package/dist/api.d.ts +328 -0
- package/dist/api.js +202 -0
- package/dist/browser/device.d.ts +13 -0
- package/dist/browser/device.js +64 -0
- package/dist/browser/ending.d.ts +8 -0
- package/dist/browser/ending.js +241 -0
- package/dist/browser/frame.d.ts +27 -0
- package/dist/browser/frame.js +165 -0
- package/dist/browser/input.d.ts +45 -0
- package/dist/browser/input.js +173 -0
- package/dist/browser/intro.d.ts +13 -0
- package/dist/browser/intro.js +172 -0
- package/dist/browser/mic.d.ts +20 -0
- package/dist/browser/mic.js +98 -0
- package/dist/browser/pictures/LICENSE +24 -0
- package/dist/browser/pictures/controller.webp +0 -0
- package/dist/browser/pictures/hand.webp +0 -0
- package/dist/browser/run.d.ts +32 -0
- package/dist/browser/run.js +78 -0
- package/dist/browser/server.d.ts +22 -0
- package/dist/browser/server.js +147 -0
- package/dist/browser/session.d.ts +31 -0
- package/dist/browser/session.js +101 -0
- package/dist/browser/sounds/end.m4a +0 -0
- package/dist/browser/sounds/failed.m4a +0 -0
- package/dist/browser/sounds/hold-controller.m4a +0 -0
- package/dist/browser/sounds/hold-hand.m4a +0 -0
- package/dist/browser/sounds/nomic.m4a +0 -0
- package/dist/browser/sounds/sending.m4a +0 -0
- package/dist/browser/sounds/show-controller.m4a +0 -0
- package/dist/browser/sounds/show-hand.m4a +0 -0
- package/dist/browser/sounds/show.m4a +0 -0
- package/dist/browser/sounds/speak-controller.m4a +0 -0
- package/dist/browser/sounds/speak-hand.m4a +0 -0
- package/dist/browser/sounds/thanks.m4a +0 -0
- package/dist/browser/sounds/unheard.m4a +0 -0
- package/dist/browser/sounds/walkthrough/a-welcome.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-controller.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-hand.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-key.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-touch.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-type-key.m4a +0 -0
- package/dist/browser/sounds/walkthrough/b-type-touch.m4a +0 -0
- package/dist/browser/sounds/walkthrough/c-key.m4a +0 -0
- package/dist/browser/sounds/walkthrough/c-quest.m4a +0 -0
- package/dist/browser/sounds/walkthrough/c-touch.m4a +0 -0
- package/dist/browser/sounds/walkthrough/d-key.m4a +0 -0
- package/dist/browser/sounds/walkthrough/d-quest.m4a +0 -0
- package/dist/browser/sounds/walkthrough/d-touch.m4a +0 -0
- package/dist/browser/sounds.d.ts +140 -0
- package/dist/browser/sounds.js +118 -0
- package/dist/browser/style.d.ts +10 -0
- package/dist/browser/style.js +214 -0
- package/dist/browser/turn.d.ts +23 -0
- package/dist/browser/turn.js +274 -0
- package/dist/browser/ui.d.ts +88 -0
- package/dist/browser/ui.js +291 -0
- package/dist/browser/wait.d.ts +10 -0
- package/dist/browser/wait.js +32 -0
- package/dist/browser/walkthrough.d.ts +12 -0
- package/dist/browser/walkthrough.js +137 -0
- package/dist/browser/xr.d.ts +10 -0
- package/dist/browser/xr.js +99 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +12 -0
- package/dist/cli/commands.d.ts +19 -0
- package/dist/cli/commands.js +153 -0
- package/dist/cli/files.d.ts +21 -0
- package/dist/cli/files.js +62 -0
- package/dist/cli/index.d.ts +14 -0
- package/dist/cli/index.js +94 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.js +75 -0
- package/dist/notes.d.ts +12 -0
- package/dist/notes.js +23 -0
- package/dist/service.d.ts +53 -0
- package/dist/service.js +55 -0
- package/dist/tester.d.ts +39 -0
- package/dist/tester.js +84 -0
- package/package.json +54 -4
- package/skills/feedback/SKILL.md +117 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/** Sets the game's `theme` on the shadow host, whose own style outranks the `:host` defaults. */
|
|
2
|
+
export function paint(host, theme = {}) {
|
|
3
|
+
const tokens = {
|
|
4
|
+
"--font": theme.font,
|
|
5
|
+
"--background": theme.background,
|
|
6
|
+
"--glass": theme.surface,
|
|
7
|
+
"--text": theme.text,
|
|
8
|
+
"--accent": theme.accent,
|
|
9
|
+
"--on-accent": theme.onAccent,
|
|
10
|
+
"--radius": theme.radius === undefined ? undefined : `${theme.radius}px`,
|
|
11
|
+
};
|
|
12
|
+
for (const [name, value] of Object.entries(tokens))
|
|
13
|
+
if (value !== undefined)
|
|
14
|
+
host.style.setProperty(name, value);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Everything the SDK shows, kept flat: surfaces told apart by fill alone, one radius, one accent, and red only for
|
|
18
|
+
* recording. Dark by default; every quieter shade is mixed from `--text`, so any theme keeps its contrast steps. Three
|
|
19
|
+
* layers: the cover (the intro's and the closing screen's page), feedback mode over the game (a frame, a caption and
|
|
20
|
+
* the typing sheet), and the corner button on top.
|
|
21
|
+
*/
|
|
22
|
+
export const STYLE = `
|
|
23
|
+
:host { all: initial;
|
|
24
|
+
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --mono: ui-monospace, "SF Mono", Menlo, monospace;
|
|
25
|
+
--text: #f5f5f7; --background: #121214; --glass: rgba(24, 24, 26, 0.92); --radius: 8px;
|
|
26
|
+
--accent: #fff; --on-accent: #111113; --record: #ff453a;
|
|
27
|
+
--muted: color-mix(in srgb, var(--text) 62%, transparent); --faint: color-mix(in srgb, var(--text) 50%, transparent);
|
|
28
|
+
--fill: color-mix(in srgb, var(--text) 8%, transparent); --raised: color-mix(in srgb, var(--text) 14%, transparent);
|
|
29
|
+
--ease: cubic-bezier(0.2, 0.8, 0.2, 1); }
|
|
30
|
+
* { box-sizing: border-box; }
|
|
31
|
+
[hidden] { display: none !important; }
|
|
32
|
+
p { margin: 0; }
|
|
33
|
+
:focus { outline: none; }
|
|
34
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
35
|
+
h1:focus-visible { outline: none; }
|
|
36
|
+
kbd { display: inline-block; min-width: 1.6em; height: 1.6em; padding: 0 0.4em; margin: 0 0.1em; border-radius: 4px;
|
|
37
|
+
background: var(--raised); vertical-align: 0.05em; font: 500 0.85em/1.5em var(--mono); text-align: center; color: var(--text); }
|
|
38
|
+
h1 kbd, .lines kbd { font-size: 0.65em; vertical-align: 0.2em; }
|
|
39
|
+
input, textarea { display: block; width: 100%; margin: 0; padding: 0 12px; border: 0; border-radius: var(--radius);
|
|
40
|
+
background: var(--fill); color: var(--text); font: 400 16px/24px var(--font); }
|
|
41
|
+
input { height: 48px; }
|
|
42
|
+
textarea { padding: 12px; resize: none; }
|
|
43
|
+
input:focus-visible, textarea:focus-visible { outline: 1.5px solid var(--muted); outline-offset: -1.5px; }
|
|
44
|
+
::placeholder { color: var(--faint); }
|
|
45
|
+
button { border: 0; font: 500 16px var(--font); color: var(--text); cursor: pointer; }
|
|
46
|
+
button:disabled { opacity: 0.6; cursor: default; }
|
|
47
|
+
.primary { height: 48px; padding: 0 28px; border-radius: var(--radius); background: var(--accent); color: var(--on-accent); font-weight: 600; }
|
|
48
|
+
.secondary { height: 48px; padding: 0 20px; border-radius: var(--radius); background: var(--fill); }
|
|
49
|
+
.secondary:hover { background: var(--raised); }
|
|
50
|
+
/* A quiet text button; on Quest the one control on the cover its clicks reach. */
|
|
51
|
+
.quiet { padding: 0; background: none; color: var(--muted); font: inherit; text-decoration: underline;
|
|
52
|
+
text-underline-offset: 3px; pointer-events: auto; }
|
|
53
|
+
.quiet:hover { color: var(--text); }
|
|
54
|
+
.dot { display: inline-block; width: 0.4em; height: 0.4em; margin-right: 0.35em; border-radius: 50%; background: var(--record);
|
|
55
|
+
vertical-align: 0.12em; animation: pulse 1.6s ease-in-out infinite; }
|
|
56
|
+
@keyframes pulse { 50% { opacity: 0.55; } }
|
|
57
|
+
@keyframes fade { from { opacity: 0; } }
|
|
58
|
+
@keyframes slide { from { translate: -100%; } to { translate: 250%; } }
|
|
59
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
|
60
|
+
|
|
61
|
+
/* The cover: one calm, opaque page with a wide centred column of large type, while the game renders behind it. */
|
|
62
|
+
.cover { position: fixed; inset: 0; z-index: 2147483646; display: flex; overflow-y: auto; overscroll-behavior: contain;
|
|
63
|
+
padding: max(48px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right)) max(48px, env(safe-area-inset-bottom))
|
|
64
|
+
max(24px, env(safe-area-inset-left));
|
|
65
|
+
background: var(--background); font: 400 16px/24px var(--font); color: var(--text); -webkit-font-smoothing: antialiased;
|
|
66
|
+
animation: fade 200ms var(--ease); }
|
|
67
|
+
/* On Quest, clicks reach the game's Enter VR button, and its last step lets the game show through, dimmed. */
|
|
68
|
+
.cover.through { pointer-events: none; }
|
|
69
|
+
.cover.clear { background: color-mix(in srgb, var(--background) 72%, transparent); }
|
|
70
|
+
.card { width: 100%; max-width: 680px; margin: auto; }
|
|
71
|
+
.logo { display: block; max-width: 200px; max-height: 40px; margin-bottom: 32px; object-fit: contain; object-position: left; }
|
|
72
|
+
h1 { margin: 0; font-size: 36px; line-height: 44px; font-weight: 600; letter-spacing: -0.02em; text-wrap: balance; }
|
|
73
|
+
h2 { margin: 0 0 4px; font-size: 14px; line-height: 20px; font-weight: 500; color: var(--muted); }
|
|
74
|
+
.lead { margin-top: 24px; font-size: 20px; line-height: 28px; text-wrap: pretty; }
|
|
75
|
+
.meta { margin-top: 8px; color: var(--muted); }
|
|
76
|
+
.brief, form { margin-top: 32px; }
|
|
77
|
+
.cover .primary { min-width: 240px; }
|
|
78
|
+
fieldset { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; min-width: 0; margin: 0 0 28px; padding: 0; border: 0; }
|
|
79
|
+
legend { float: left; width: 100%; padding: 0; }
|
|
80
|
+
label { display: grid; gap: 6px; min-width: 0; font-size: 14px; line-height: 20px; color: var(--muted); }
|
|
81
|
+
.hint { grid-column: 1 / -1; margin: -12px 0 4px; }
|
|
82
|
+
form > .quiet { display: block; margin-top: 16px; }
|
|
83
|
+
.fine { margin-top: 16px; font-size: 14px; line-height: 20px; color: var(--faint); text-wrap: pretty; }
|
|
84
|
+
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 28px; }
|
|
85
|
+
/* A link to open on another device, in full so it can be read off and typed. */
|
|
86
|
+
.url { display: flex; align-items: center; gap: 12px; margin-top: 28px; padding: 8px 8px 8px 16px; border-radius: var(--radius);
|
|
87
|
+
background: var(--fill); }
|
|
88
|
+
.url .secondary { flex: none; height: 40px; }
|
|
89
|
+
.url code { flex: 1; min-width: 0; overflow-wrap: anywhere; font: 400 14px/20px var(--mono); user-select: all; }
|
|
90
|
+
|
|
91
|
+
/* The walkthrough: one miniature of the screen per step, the same size on every step, so the controls stay put. */
|
|
92
|
+
.walk { display: grid; grid-template-rows: auto auto minmax(124px, auto) auto; gap: 24px; }
|
|
93
|
+
.walk h1 { font-size: 28px; line-height: 36px; font-weight: 500; letter-spacing: -0.01em; text-wrap: pretty; }
|
|
94
|
+
.progress, .controls { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 24px; font-size: 14px;
|
|
95
|
+
line-height: 20px; color: var(--muted); }
|
|
96
|
+
.controls { justify-content: start; font-size: 16px; }
|
|
97
|
+
.example { font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
|
|
98
|
+
.note { margin-top: 8px; color: var(--muted); }
|
|
99
|
+
.screen { position: relative; overflow: hidden; height: 168px; border-radius: var(--radius); background: var(--fill); }
|
|
100
|
+
.screen.short { height: 84px; }
|
|
101
|
+
.screen.framed { outline: 2px solid var(--faint); outline-offset: -2px; }
|
|
102
|
+
.screen .lines { position: absolute; top: 16px; left: 50%; width: max-content; max-width: 160%; transform: translateX(-50%) scale(0.58);
|
|
103
|
+
transform-origin: top center; }
|
|
104
|
+
.screen .lines p + p { font-size: 19px; }
|
|
105
|
+
.screen .dock { position: absolute; right: 20px; bottom: 20px; transform: scale(1.5); transform-origin: bottom right; }
|
|
106
|
+
.screen .sheet { position: absolute; inset: 16px 20%; display: grid; grid-template-rows: auto 1fr; gap: 8px; padding: 8px;
|
|
107
|
+
border-radius: var(--radius); background: var(--background); font: 400 11px/16px var(--font); color: var(--muted); }
|
|
108
|
+
.screen .sheet div { display: flex; justify-content: space-between; }
|
|
109
|
+
.screen .sheet b { font-size: 12px; color: var(--text); }
|
|
110
|
+
.screen .sheet i { padding: 0 8px; border-radius: 4px; background: var(--accent); color: var(--on-accent); font-style: normal; }
|
|
111
|
+
.screen .sheet span { border-radius: 4px; background: var(--fill); }
|
|
112
|
+
.screen .keys ~ .sheet { left: 188px; right: 20px; }
|
|
113
|
+
.keys { position: absolute; top: 20px; left: 20px; }
|
|
114
|
+
.keys svg { display: block; height: 96px; }
|
|
115
|
+
.keys rect { fill: var(--raised); }
|
|
116
|
+
.keys text { fill: var(--muted); font: 500 10px var(--font); text-anchor: middle; }
|
|
117
|
+
.keys .lit rect { fill: var(--accent); }
|
|
118
|
+
.keys .lit text { fill: var(--on-accent); font: 600 13px var(--mono); }
|
|
119
|
+
.screen.hands { display: flex; align-items: center; justify-content: center; gap: 32px; }
|
|
120
|
+
.screen.hands img { height: 136px; }
|
|
121
|
+
|
|
122
|
+
/* The closing screen: the game's questions, where a slider shows no value until touched, and the last word. */
|
|
123
|
+
.questions { display: grid; gap: 28px; margin-bottom: 28px; }
|
|
124
|
+
.question { display: grid; grid-template-columns: 1fr; gap: 10px; margin: 0; }
|
|
125
|
+
.ask { display: flex; justify-content: space-between; gap: 16px; padding: 0; font-size: 16px; line-height: 24px; font-weight: 500;
|
|
126
|
+
color: var(--text); }
|
|
127
|
+
.value { color: var(--accent); font-variant-numeric: tabular-nums; }
|
|
128
|
+
input[type="range"] { height: 32px; padding: 0; background: none; accent-color: var(--accent); }
|
|
129
|
+
input[type="range"].unset { opacity: 0.5; }
|
|
130
|
+
.ends, .options { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 16px; font-size: 14px; color: var(--muted); }
|
|
131
|
+
.options { justify-content: start; gap: 8px; }
|
|
132
|
+
.option { position: relative; display: flex; align-items: center; height: 44px; padding: 0 16px; border-radius: var(--radius);
|
|
133
|
+
background: var(--fill); color: var(--text); font-size: 16px; cursor: pointer; }
|
|
134
|
+
.option:has(:checked) { background: var(--accent); color: var(--on-accent); }
|
|
135
|
+
.option:has(:focus-visible) { outline: 1.5px solid var(--muted); outline-offset: 2px; }
|
|
136
|
+
.option input { position: absolute; inset: 0; height: 100%; opacity: 0; pointer-events: none; }
|
|
137
|
+
.reply { border-radius: var(--radius); background: var(--fill); }
|
|
138
|
+
.reply textarea { min-height: 112px; background: none; resize: vertical; }
|
|
139
|
+
.voice { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; min-height: 112px; padding: 12px 16px;
|
|
140
|
+
font-variant-numeric: tabular-nums; }
|
|
141
|
+
.voice > span { flex: 1; }
|
|
142
|
+
.voice .secondary, .reply .record { height: 40px; background: var(--raised); }
|
|
143
|
+
.reply .record { margin: 0 8px 8px auto; border-radius: var(--radius); display: flex; align-items: center; gap: 6px; padding: 0 12px; font-size: 14px; }
|
|
144
|
+
.join { margin-top: 28px; max-width: 360px; }
|
|
145
|
+
.status { margin-top: 12px; font-size: 14px; line-height: 20px; color: var(--muted); }
|
|
146
|
+
|
|
147
|
+
/* Feedback mode: a frame round the viewport, light with a thin dark line inside to show over any scene, and a caption. */
|
|
148
|
+
.mode { position: fixed; inset: 0; z-index: 2147483646; display: flex; flex-direction: column; align-items: center; gap: 8px;
|
|
149
|
+
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);
|
|
150
|
+
transition: opacity 300ms var(--ease); animation: fade 180ms var(--ease); }
|
|
151
|
+
.mode::before { content: ""; position: absolute; inset: 0; z-index: 1; border: 3px solid rgba(255, 255, 255, 0.85);
|
|
152
|
+
outline: 2px solid rgba(0, 0, 0, 0.6); outline-offset: -5px; }
|
|
153
|
+
.mode.leaving { opacity: 0; }
|
|
154
|
+
.lines { display: grid; justify-items: center; gap: 4px; max-width: 100%; padding: 12px 20px; border-radius: var(--radius);
|
|
155
|
+
background: var(--glass); -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px); text-align: center; }
|
|
156
|
+
.lines p:first-child { max-width: 800px; font: 600 28px/36px var(--font); letter-spacing: -0.01em; text-wrap: pretty; }
|
|
157
|
+
.lines p + p { font: 400 16px/24px var(--font); color: var(--muted); }
|
|
158
|
+
.tick { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
159
|
+
.lines p:first-child .tick { font-weight: 400; color: var(--muted); }
|
|
160
|
+
.bar { position: relative; overflow: hidden; width: 120px; height: 3px; margin: 8px 0 4px; border-radius: 2px;
|
|
161
|
+
background: color-mix(in srgb, var(--text) 20%, transparent); }
|
|
162
|
+
.bar::after { content: ""; position: absolute; inset: 0; width: 40%; border-radius: 2px; background: var(--text);
|
|
163
|
+
animation: slide 1.4s ease-in-out infinite; }
|
|
164
|
+
.mode > .quiet { padding: 4px 8px; color: var(--text); font: 500 14px/20px var(--font); }
|
|
165
|
+
/* The typing sheet: a card with a large field over the dimmed, paused game; it has the caption's words. */
|
|
166
|
+
.mode:has(.sheet) > :not(.sheet) { display: none; }
|
|
167
|
+
.sheet { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px;
|
|
168
|
+
background: color-mix(in srgb, var(--background) 60%, transparent); pointer-events: auto; text-align: left; }
|
|
169
|
+
.mode .sheet > div { display: grid; gap: 12px; width: 100%; max-width: 640px; padding: 16px; border-radius: calc(var(--radius) * 1.5);
|
|
170
|
+
background: var(--background); }
|
|
171
|
+
.head { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; }
|
|
172
|
+
.head h2 { margin: 0; font: 600 18px/24px var(--font); color: var(--text); }
|
|
173
|
+
.head .quiet { justify-self: start; text-decoration: none; }
|
|
174
|
+
.head .primary { justify-self: end; height: 40px; padding: 0 20px; }
|
|
175
|
+
.sheet textarea { min-height: 40vh; }
|
|
176
|
+
.foot { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 16px; font: 400 14px/20px var(--font); color: var(--muted); }
|
|
177
|
+
/* A phone's sheet is edge to edge over the space the on-screen keyboard leaves. */
|
|
178
|
+
.sheet.phone { bottom: auto; height: 100%; padding: 0; align-items: stretch; }
|
|
179
|
+
.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; }
|
|
180
|
+
.sheet.phone textarea { min-height: 0; height: 100%; }
|
|
181
|
+
|
|
182
|
+
/* The corner button, 16 px from the edges, over everything. */
|
|
183
|
+
.dock { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 2147483647; }
|
|
184
|
+
.corner { position: relative; overflow: hidden; display: inline-flex; align-items: center; gap: 6px; height: 40px; padding: 0 8px 0 10px;
|
|
185
|
+
border-radius: var(--radius); background: var(--glass); -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px);
|
|
186
|
+
font: 500 14px var(--font); white-space: nowrap; transition: transform 120ms var(--ease), background-color 120ms var(--ease); }
|
|
187
|
+
.corner.touch { height: 48px; padding: 0 16px 0 12px; }
|
|
188
|
+
.corner svg { flex: none; width: 20px; height: 20px; }
|
|
189
|
+
.corner kbd { margin: 0 0 0 2px; }
|
|
190
|
+
.corner:hover { background: color-mix(in srgb, var(--glass), var(--text) 8%); }
|
|
191
|
+
.corner:active { transform: scale(0.96); }
|
|
192
|
+
/* Over the cover's backdrop the glass would vanish; the walkthrough's last step lights it. */
|
|
193
|
+
.corner.raised { background: var(--raised); }
|
|
194
|
+
.corner.lit, .corner.lit:hover { background: var(--accent); color: var(--on-accent); }
|
|
195
|
+
.corner.lit kbd { background: color-mix(in srgb, var(--on-accent) 10%, transparent); color: var(--on-accent); }
|
|
196
|
+
.corner[aria-disabled="true"] > * { opacity: 0.35; }
|
|
197
|
+
.corner[aria-label="Send recording"] svg { animation: pulse 1.6s ease-in-out infinite; }
|
|
198
|
+
/* A hold fills the button from the left. */
|
|
199
|
+
.corner::before { content: ""; position: absolute; inset: 0; background: color-mix(in srgb, var(--text) 20%, transparent);
|
|
200
|
+
transform-origin: left; transform: scaleX(var(--hold, 0)); pointer-events: none; }
|
|
201
|
+
|
|
202
|
+
/* A phone gets the same pages in one column, with room at the bottom for the corner button. */
|
|
203
|
+
@media (max-width: 560px) {
|
|
204
|
+
.cover { padding: max(32px, env(safe-area-inset-top)) 20px calc(max(16px, env(safe-area-inset-bottom)) + 72px); }
|
|
205
|
+
h1 { font-size: 28px; line-height: 36px; }
|
|
206
|
+
.walk h1, .lines p:first-child { font-size: 24px; line-height: 32px; }
|
|
207
|
+
.cover .primary { width: 100%; }
|
|
208
|
+
fieldset { grid-template-columns: 1fr; }
|
|
209
|
+
.walk { grid-template-rows: auto auto minmax(160px, auto) auto; }
|
|
210
|
+
.screen { height: 156px; }
|
|
211
|
+
.screen .dock { right: 12px; bottom: 12px; transform: scale(1.25); }
|
|
212
|
+
.mode { padding: max(64px, env(safe-area-inset-top)) 24px 0; }
|
|
213
|
+
}
|
|
214
|
+
`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Kind } from "./input.ts";
|
|
2
|
+
import type { Context } from "./run.ts";
|
|
3
|
+
import type { Session } from "./session.ts";
|
|
4
|
+
import type { View } from "./ui.ts";
|
|
5
|
+
/** How long the player has to put a visual problem in view and press again. */
|
|
6
|
+
export declare const SHOW_MS = 15000;
|
|
7
|
+
/** Feedback mode's caption while recording, which the walkthrough's miniature shows word for word. */
|
|
8
|
+
export declare function talking(kind: Kind, seconds: () => number): View;
|
|
9
|
+
/** The ask to show it; `left` says how many seconds it has left. */
|
|
10
|
+
export declare function asking(kind: Kind, left: () => number): View;
|
|
11
|
+
/** Seconds as "m:ss". */
|
|
12
|
+
export declare function clock(seconds: number): string;
|
|
13
|
+
/**
|
|
14
|
+
* One piece of feedback, from the start press to its outcome, the same on every device. The start tone enters feedback
|
|
15
|
+
* mode, which stays through recording or typing, the wait for the classifier and the ask for a screenshot; a spoken
|
|
16
|
+
* "Thanks!" leaves it once feedback is sent, and a line with a low tone says why when nothing is. The game is paused
|
|
17
|
+
* until then. Uploads still running finish after `resume()`, and only a failure of theirs shows afterwards.
|
|
18
|
+
*
|
|
19
|
+
* On a flat screen a tester who chose typing gets the typing sheet, and so does one whose microphone is denied or
|
|
20
|
+
* broken; either can switch to the other way within the turn, which is remembered. In XR, without a microphone it ends
|
|
21
|
+
* at once.
|
|
22
|
+
*/
|
|
23
|
+
export declare function turn(ctx: Context, session: Session, kind: Kind): Promise<void>;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { LIMITS } from "../service.js";
|
|
2
|
+
import { act } from "./input.js";
|
|
3
|
+
import { record } from "./mic.js";
|
|
4
|
+
import { encode } from "./frame.js";
|
|
5
|
+
import { advice, attach, Refused, submit } from "./server.js";
|
|
6
|
+
import { after, sleep, wait } from "./wait.js";
|
|
7
|
+
/** How long the player has to put a visual problem in view and press again. */
|
|
8
|
+
export const SHOW_MS = 15_000;
|
|
9
|
+
/**
|
|
10
|
+
* How long the game stays paused for the classifier after the player stops. Only a visual answer asks for a
|
|
11
|
+
* screenshot; without an answer by then the turn ends without one and the upload finishes in the background.
|
|
12
|
+
*/
|
|
13
|
+
const CLASSIFY_MS = 12_000;
|
|
14
|
+
/** How long a screenshot may wait for the game's next frame. */
|
|
15
|
+
const FRAME_MS = 2000;
|
|
16
|
+
/** A stalled XR cue must not keep the app paused or prevent recording. */
|
|
17
|
+
const CUE_MS = 12_000;
|
|
18
|
+
/**
|
|
19
|
+
* What the tester sees and hears of each outcome, and for how long. XR shows nothing, so there `voice` says what the
|
|
20
|
+
* view would, in place of the tone.
|
|
21
|
+
*/
|
|
22
|
+
const OUTCOMES = {
|
|
23
|
+
stored: { view: { big: ["Thanks!"] }, ms: 1000, sound: "thanks" },
|
|
24
|
+
pending: { view: { big: ["Still sending"], small: ["You can keep playing while it finishes."] }, ms: 1000, voice: "sending" },
|
|
25
|
+
unheard: { view: { big: ["Didn't catch that"], small: ["Nothing was sent. Try again anytime."] }, ms: 2500, sound: "nothing", voice: "unheard" },
|
|
26
|
+
cancelled: { view: { big: ["Cancelled"], small: ["Nothing was sent."] }, ms: 1500, sound: "nothing" },
|
|
27
|
+
nomic: { ms: 0, sound: "failed", voice: "nomic" },
|
|
28
|
+
refused: { view: { big: ["Couldn't send"], small: [`Your feedback wasn't saved. ${advice(true, "try again")}`] }, ms: 2500, sound: "failed", voice: "failed" },
|
|
29
|
+
failed: { view: { big: ["Couldn't send"], small: [`Your feedback wasn't saved. ${advice(false, "try again")}`] }, ms: 2500, sound: "failed", voice: "failed" },
|
|
30
|
+
};
|
|
31
|
+
/** Feedback mode's caption while recording, which the walkthrough's miniature shows word for word. */
|
|
32
|
+
export function talking(kind, seconds) {
|
|
33
|
+
// Only a keyboard has an Escape key; a phone sends or runs out the clock.
|
|
34
|
+
const cancel = kind === "key" ? [" · Esc to cancel"] : [];
|
|
35
|
+
return { big: ["Recording ", { tick: () => clock(seconds()) }], small: ["Describe what you noticed · ", ...act(kind, " to send", "Send"), ...cancel], recording: true };
|
|
36
|
+
}
|
|
37
|
+
/** The ask to show it; `left` says how many seconds it has left. */
|
|
38
|
+
export function asking(kind, left) {
|
|
39
|
+
return {
|
|
40
|
+
big: ["Make sure it's in view, then ", ...act(kind, " again", "Take screenshot"), "."],
|
|
41
|
+
small: ["Sending without a screenshot in ", { tick: () => `${left()}s` }],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Seconds as "m:ss". */
|
|
45
|
+
export function clock(seconds) {
|
|
46
|
+
const whole = Math.floor(seconds);
|
|
47
|
+
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One piece of feedback, from the start press to its outcome, the same on every device. The start tone enters feedback
|
|
51
|
+
* mode, which stays through recording or typing, the wait for the classifier and the ask for a screenshot; a spoken
|
|
52
|
+
* "Thanks!" leaves it once feedback is sent, and a line with a low tone says why when nothing is. The game is paused
|
|
53
|
+
* until then. Uploads still running finish after `resume()`, and only a failure of theirs shows afterwards.
|
|
54
|
+
*
|
|
55
|
+
* On a flat screen a tester who chose typing gets the typing sheet, and so does one whose microphone is denied or
|
|
56
|
+
* broken; either can switch to the other way within the turn, which is remembered. In XR, without a microphone it ends
|
|
57
|
+
* at once.
|
|
58
|
+
*/
|
|
59
|
+
export async function turn(ctx, session, kind) {
|
|
60
|
+
const { ui, sounds, input, tester, frames, signal } = ctx;
|
|
61
|
+
const xr = ctx.options.renderer.xr.getSession();
|
|
62
|
+
/** In XR, where nothing shows, a voice line guides each step; it names the gesture of the input that started. */
|
|
63
|
+
const hand = xr && (kind === "controller" || kind === "hand") ? kind : null;
|
|
64
|
+
const state = session.pause();
|
|
65
|
+
const later = [];
|
|
66
|
+
/**
|
|
67
|
+
* Calls `run` once the XR session has ended, at once when it already has, as after a recording the end stopped. The
|
|
68
|
+
* turn then goes on without a screenshot, so the game resumes instead of staying paused for a player who left.
|
|
69
|
+
*/
|
|
70
|
+
const exit = (run, over) => {
|
|
71
|
+
if (!xr)
|
|
72
|
+
return;
|
|
73
|
+
if (ctx.options.renderer.xr.getSession() !== xr)
|
|
74
|
+
return run();
|
|
75
|
+
xr.addEventListener("end", run, { signal: over });
|
|
76
|
+
};
|
|
77
|
+
const cue = () => {
|
|
78
|
+
sounds.play("start");
|
|
79
|
+
input.pulse(kind, 0.8, 80);
|
|
80
|
+
};
|
|
81
|
+
/** Records until a tap, a hidden tab, the end of the XR session or the recording's caps. Escape cancels on a keyboard. */
|
|
82
|
+
const speak = async () => {
|
|
83
|
+
const discard = new AbortController();
|
|
84
|
+
// Said before recording starts, so the recording doesn't pick it up; the start tone then marks the start.
|
|
85
|
+
if (hand) {
|
|
86
|
+
await wait(signal, (done, over) => {
|
|
87
|
+
sounds.clip(`speak-${hand}`, over).then(() => done(), error => {
|
|
88
|
+
console.error("feedback: could not play the recording cue", error);
|
|
89
|
+
done();
|
|
90
|
+
});
|
|
91
|
+
after(CUE_MS, () => {
|
|
92
|
+
console.error("feedback: the recording cue timed out");
|
|
93
|
+
done();
|
|
94
|
+
}, over);
|
|
95
|
+
exit(() => done(), over);
|
|
96
|
+
});
|
|
97
|
+
if (ctx.options.renderer.xr.getSession() !== xr)
|
|
98
|
+
return "cancelled";
|
|
99
|
+
}
|
|
100
|
+
const recording = await record(sounds.context, AbortSignal.any([signal, discard.signal]));
|
|
101
|
+
if (typeof recording === "string")
|
|
102
|
+
return recording;
|
|
103
|
+
// On a flat screen the cue came before, covering the permission prompt.
|
|
104
|
+
if (xr)
|
|
105
|
+
cue();
|
|
106
|
+
const seconds = () => (performance.now() - recording.since) / 1000;
|
|
107
|
+
ui.show(talking(kind, seconds));
|
|
108
|
+
ui.action("Send recording");
|
|
109
|
+
const end = await wait(signal, (done, over) => {
|
|
110
|
+
input.on(kind, () => done("stop"), over);
|
|
111
|
+
void recording.audio.then(() => done("stop"));
|
|
112
|
+
document.addEventListener("visibilitychange", () => {
|
|
113
|
+
if (document.hidden)
|
|
114
|
+
done("stop");
|
|
115
|
+
}, { signal: over });
|
|
116
|
+
// With the pointer locked, the browser takes the first Escape to unlock it, and the next one reaches here.
|
|
117
|
+
if (kind === "key")
|
|
118
|
+
input.escape(() => done("cancel"), over);
|
|
119
|
+
if (!xr)
|
|
120
|
+
return ui.link("Type instead", () => done("type"), over);
|
|
121
|
+
exit(() => done("stop"), over);
|
|
122
|
+
// XR shows nothing, so ticks count down the last seconds.
|
|
123
|
+
const ticks = setInterval(() => {
|
|
124
|
+
if (LIMITS.seconds - seconds() <= 10)
|
|
125
|
+
sounds.play("tick");
|
|
126
|
+
}, 1000);
|
|
127
|
+
over.addEventListener("abort", () => clearInterval(ticks));
|
|
128
|
+
}).finally(() => ui.action("Give feedback"));
|
|
129
|
+
if (end !== "stop") {
|
|
130
|
+
discard.abort();
|
|
131
|
+
return end === "cancel" ? "cancelled" : "type";
|
|
132
|
+
}
|
|
133
|
+
recording.stop();
|
|
134
|
+
return (await recording.audio) ?? "unheard";
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Uploads what the player said or typed and waits for the classifier. Only feedback it calls visual asks for a
|
|
138
|
+
* screenshot, attached once taken; without one the Worker completes the item later.
|
|
139
|
+
*/
|
|
140
|
+
const send = async (submission, audio) => {
|
|
141
|
+
const posted = submit(submission, audio, signal).catch((error) => {
|
|
142
|
+
if (!signal.aborted)
|
|
143
|
+
console.error("feedback: could not send", error);
|
|
144
|
+
return error instanceof Refused ? "refused" : "failed";
|
|
145
|
+
});
|
|
146
|
+
ui.show({ big: ["Got it, one moment…"], small: ["Everything is paused."], waiting: true });
|
|
147
|
+
ui.dim();
|
|
148
|
+
if (hand)
|
|
149
|
+
sounds.say("sending");
|
|
150
|
+
const answer = await wait(signal, (done, over) => {
|
|
151
|
+
void posted.then(done);
|
|
152
|
+
after(CLASSIFY_MS, () => done(null), over);
|
|
153
|
+
exit(() => done(null), over);
|
|
154
|
+
});
|
|
155
|
+
if (!answer) {
|
|
156
|
+
later.push(posted.then(outcomeOf));
|
|
157
|
+
return "pending";
|
|
158
|
+
}
|
|
159
|
+
if (typeof answer === "string" || answer.status === "empty" || !answer.visual)
|
|
160
|
+
return outcomeOf(answer);
|
|
161
|
+
const picture = await show();
|
|
162
|
+
// The item is stored either way; a lost screenshot only lacks the picture. Encoding can take seconds in XR, so it
|
|
163
|
+
// and the upload finish after the turn.
|
|
164
|
+
if (picture) {
|
|
165
|
+
encode(picture).then(jpeg => attach(submission.id, jpeg, signal)).catch((error) => {
|
|
166
|
+
if (!signal.aborted)
|
|
167
|
+
console.error("feedback: could not attach the screenshot", error);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return "stored";
|
|
171
|
+
};
|
|
172
|
+
/** Asks the player to show what they mean, and takes the screenshot on their next press, or none after 15 s. */
|
|
173
|
+
const show = async () => {
|
|
174
|
+
const deadline = performance.now() + SHOW_MS;
|
|
175
|
+
ui.show(asking(kind, () => Math.max(0, Math.ceil((deadline - performance.now()) / 1000))));
|
|
176
|
+
ui.action("Take screenshot");
|
|
177
|
+
// Said on every device: in XR nothing else shows it, and on a flat screen it is easy to miss while playing.
|
|
178
|
+
sounds.say(hand ? `show-${hand}` : "show");
|
|
179
|
+
const tapped = await wait(signal, (done, over) => {
|
|
180
|
+
input.on(kind, () => done(true), over);
|
|
181
|
+
after(SHOW_MS, () => done(false), over);
|
|
182
|
+
exit(() => done(false), over);
|
|
183
|
+
}).finally(() => ui.action("Give feedback"));
|
|
184
|
+
if (!tapped)
|
|
185
|
+
return null;
|
|
186
|
+
ui.dim();
|
|
187
|
+
try {
|
|
188
|
+
const picture = await wait(signal, (done, over, fail) => {
|
|
189
|
+
frames.take(over).then(done, fail);
|
|
190
|
+
after(FRAME_MS, () => done(null), over);
|
|
191
|
+
});
|
|
192
|
+
if (!picture)
|
|
193
|
+
console.error("feedback: the experience rendered no frame to take the screenshot from");
|
|
194
|
+
return picture;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
signal.throwIfAborted();
|
|
198
|
+
console.error("feedback: could not take the screenshot", error);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const give = async () => {
|
|
203
|
+
if (!xr)
|
|
204
|
+
cue();
|
|
205
|
+
let typing = !xr && tester.typing;
|
|
206
|
+
// Whether the microphone may still work, so the sheet offers it.
|
|
207
|
+
let speakable = true;
|
|
208
|
+
for (;;) {
|
|
209
|
+
if (typing) {
|
|
210
|
+
// Only flat inputs type: a gamepad stands in for the keyboard.
|
|
211
|
+
const typed = await ui.sheet(kind === "touch" ? "touch" : "key", speakable, signal);
|
|
212
|
+
if (typed === null)
|
|
213
|
+
return "cancelled";
|
|
214
|
+
if (typed !== "speak")
|
|
215
|
+
return send(session.item({ state, text: typed }), null);
|
|
216
|
+
tester.chooseTyping(false);
|
|
217
|
+
typing = false;
|
|
218
|
+
cue();
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const spoken = await speak();
|
|
222
|
+
if (spoken === "denied" || spoken === "broken") {
|
|
223
|
+
if (xr)
|
|
224
|
+
return "nomic";
|
|
225
|
+
[typing, speakable] = [true, false];
|
|
226
|
+
}
|
|
227
|
+
else if (spoken === "type") {
|
|
228
|
+
tester.chooseTyping(true);
|
|
229
|
+
typing = true;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
return spoken instanceof Blob ? send(session.item({ xr, state }), spoken) : spoken;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
ui.busy = true;
|
|
237
|
+
try {
|
|
238
|
+
const outcome = await give();
|
|
239
|
+
tell(ctx, outcome, false, hand !== null);
|
|
240
|
+
await sleep(OUTCOMES[outcome].ms, signal);
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
ui.busy = false;
|
|
244
|
+
ui.leave();
|
|
245
|
+
ui.action("Give feedback");
|
|
246
|
+
ctx.options.resume?.();
|
|
247
|
+
}
|
|
248
|
+
// A pending upload's outcome shows unless a newer turn holds the screen; failures are also logged when sent.
|
|
249
|
+
for (const upload of later) {
|
|
250
|
+
void upload.then(outcome => {
|
|
251
|
+
if (!signal.aborted && !ui.busy)
|
|
252
|
+
tell(ctx, outcome, true, hand !== null);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Shows and sounds the outcome; `spoken` says it in words where the view isn't seen. */
|
|
257
|
+
function tell({ ui, sounds }, outcome, flash, spoken) {
|
|
258
|
+
const { view, ms, sound, voice } = OUTCOMES[outcome];
|
|
259
|
+
if (view && flash)
|
|
260
|
+
ui.flash(view, ms);
|
|
261
|
+
else if (view)
|
|
262
|
+
ui.show(view);
|
|
263
|
+
if (sound === "thanks")
|
|
264
|
+
sounds.say("thanks");
|
|
265
|
+
else if (spoken && voice)
|
|
266
|
+
sounds.say(voice);
|
|
267
|
+
else if (sound)
|
|
268
|
+
sounds.play(sound);
|
|
269
|
+
}
|
|
270
|
+
function outcomeOf(answer) {
|
|
271
|
+
if (typeof answer === "string")
|
|
272
|
+
return answer;
|
|
273
|
+
return answer.status === "empty" ? "unheard" : "stored";
|
|
274
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Theme } from "../index.ts";
|
|
2
|
+
import type { Device } from "./device.ts";
|
|
3
|
+
import { type Press } from "./input.ts";
|
|
4
|
+
/** A line of text; `{ key }` renders as a key cap, and `{ tick }` as text redrawn every 250 ms, such as a countdown. */
|
|
5
|
+
export type Text = (string | {
|
|
6
|
+
key: string;
|
|
7
|
+
} | {
|
|
8
|
+
tick: () => string;
|
|
9
|
+
})[];
|
|
10
|
+
/** What feedback mode shows: a big line and a small one, with a red dot while recording or a bar while waiting. */
|
|
11
|
+
export interface View {
|
|
12
|
+
big: Text;
|
|
13
|
+
small?: Text;
|
|
14
|
+
recording?: true;
|
|
15
|
+
waiting?: true;
|
|
16
|
+
}
|
|
17
|
+
/** What a press of the corner button does at the moment, as its label tells screen readers and its face shows. */
|
|
18
|
+
export type Action = "Give feedback" | "Send recording" | "Take screenshot";
|
|
19
|
+
/** The typing sheet's heading. */
|
|
20
|
+
export declare const PROMPT = "What feels off?";
|
|
21
|
+
type Child = Node | string | null;
|
|
22
|
+
/** An element with `props` and `children`; null children are left out. */
|
|
23
|
+
export declare function h<K extends keyof HTMLElementTagNameMap>(tag: K, props?: Partial<HTMLElementTagNameMap[K]>, ...children: Child[]): HTMLElementTagNameMap[K];
|
|
24
|
+
export declare const kbd: (key: string) => HTMLElement;
|
|
25
|
+
export declare const button: (className: string, label: string) => HTMLButtonElement;
|
|
26
|
+
/** The corner button's face: a glyph, what a press does, and on a keyboard the key that does it. */
|
|
27
|
+
export declare function face(kind: Press, action: Action): string;
|
|
28
|
+
/**
|
|
29
|
+
* Puts the lines of `view` on a plate; `ticks` collects what redraws their `{ tick }` parts, which screen readers skip
|
|
30
|
+
* since feedback mode's plate announces every change.
|
|
31
|
+
*/
|
|
32
|
+
export declare function plate({ big, small, recording, waiting }: View, ticks?: (() => void)[], lines?: HTMLDivElement): HTMLElement;
|
|
33
|
+
/**
|
|
34
|
+
* Everything the SDK shows, in one shadow root on the page: the cover, feedback mode and the corner button. DOM only,
|
|
35
|
+
* so never in the screenshot, which is drawn from the canvas; in XR none of it is visible and sounds carry the flow.
|
|
36
|
+
* 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
|
|
37
|
+
* too; the SDK's own window capture listeners still see them. Releases still reach the game, so nothing held when it
|
|
38
|
+
* appeared stays stuck down. Gone on abort.
|
|
39
|
+
*/
|
|
40
|
+
export declare class Ui {
|
|
41
|
+
#private;
|
|
42
|
+
readonly root: ShadowRoot;
|
|
43
|
+
readonly theme: Theme;
|
|
44
|
+
/** Whether a turn holds the screen, whose captions and sounds an earlier turn's late outcome must leave alone. */
|
|
45
|
+
busy: boolean;
|
|
46
|
+
/** The corner button's clicks call `press` with the input they count as. */
|
|
47
|
+
constructor(device: Device, press: (kind: Press) => void, theme: Theme | undefined, signal: AbortSignal);
|
|
48
|
+
/** Shows `view` in feedback mode, entering it if needed. */
|
|
49
|
+
show(view: View): void;
|
|
50
|
+
/** Shows `view` for `ms`, then leaves, unless something else shows first. */
|
|
51
|
+
flash(view: View, ms: number): void;
|
|
52
|
+
/** Fades out, which ends feedback mode on screen. */
|
|
53
|
+
leave(): void;
|
|
54
|
+
/** A link under the caption that switches how feedback is given, such as "Type instead", until `signal` aborts. */
|
|
55
|
+
link(label: string, run: () => void, signal: AbortSignal): void;
|
|
56
|
+
/** Hides the corner button while something else takes the screen, raises it over the cover, or lights it up. */
|
|
57
|
+
corner(look: "hidden" | "shown" | "raised" | "lit"): void;
|
|
58
|
+
action(label: Action): void;
|
|
59
|
+
/** Dims the corner button while a press does nothing, until the next `action`. */
|
|
60
|
+
dim(): void;
|
|
61
|
+
/** A hold's progress, 0 to 1, filling the corner button. Without one, only the hold's sound shows it. */
|
|
62
|
+
hold(progress: number): void;
|
|
63
|
+
/**
|
|
64
|
+
* The typing sheet, in feedback mode's frame with the corner button hidden behind it. Resolves with the text on Send,
|
|
65
|
+
* with "speak" for "Speak instead" when `speakable`, and with null on Cancel, Escape, or the feedback key in an empty
|
|
66
|
+
* field. On a keyboard Enter sends and Shift+Enter starts a new line; on a phone Enter is a new line, and the sheet
|
|
67
|
+
* fills the space above the on-screen keyboard.
|
|
68
|
+
*/
|
|
69
|
+
sheet(kind: Press, speakable: boolean, signal: AbortSignal): Promise<string | "speak" | null>;
|
|
70
|
+
/** A full-screen cover, under the corner button, until disposed. */
|
|
71
|
+
cover(): Cover;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The page of the intro and the closing screen: a modal dialog until released, while the game keeps rendering behind
|
|
75
|
+
* it. Opening it gives back the pointer a game's mouse look holds, and leaves a fullscreen canvas, which would hide it.
|
|
76
|
+
*/
|
|
77
|
+
export declare class Cover {
|
|
78
|
+
#private;
|
|
79
|
+
constructor(ui: Ui);
|
|
80
|
+
/** Replaces the page, under the game's logo unless `bare`, and focuses its heading, which names the dialog. */
|
|
81
|
+
show(nodes: Node[], bare?: boolean): void;
|
|
82
|
+
/** Stops being modal, so focus and screen readers reach what lies outside, such as the corner button. */
|
|
83
|
+
release(): void;
|
|
84
|
+
/** Lets clicks through to the game, and with `clear` lets it show through, dimmed. */
|
|
85
|
+
through(clear?: boolean): void;
|
|
86
|
+
dispose(): void;
|
|
87
|
+
}
|
|
88
|
+
export {};
|