@prplkane/roundtable 0.0.0-stage → 0.6.3
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/CHANGELOG.md +513 -0
- package/LICENSE +21 -0
- package/README.md +188 -3
- package/SECURITY.md +299 -0
- package/docs/workflow.md +1325 -0
- package/package.json +52 -6
- package/scripts/agent.mjs +40 -0
- package/scripts/export.mjs +20 -0
- package/scripts/mcp.mjs +175 -0
- package/scripts/run-debate.mjs +70 -0
- package/scripts/server.mjs +28 -0
- package/scripts/setup.mjs +182 -0
- package/skills/roundtable/SKILL.md +55 -0
- package/src/adapters/agy.js +7 -0
- package/src/adapters/catalogue.js +49 -0
- package/src/adapters/claude.js +6 -0
- package/src/adapters/codex.js +6 -0
- package/src/adapters/common.js +304 -0
- package/src/adapters/config.js +432 -0
- package/src/adapters/endpoint.js +178 -0
- package/src/adapters/errors.js +69 -0
- package/src/adapters/index.js +11 -0
- package/src/adapters/models.js +106 -0
- package/src/adapters/quota.js +305 -0
- package/src/adapters/spawn.js +178 -0
- package/src/agent-client.js +108 -0
- package/src/cli.js +44 -0
- package/src/contract.js +256 -0
- package/src/diagnostics.js +133 -0
- package/src/doctor.js +96 -0
- package/src/engine.js +902 -0
- package/src/estimate.js +331 -0
- package/src/export.js +307 -0
- package/src/fake.js +75 -0
- package/src/folder-picker.js +46 -0
- package/src/history.js +220 -0
- package/src/local-http.js +39 -0
- package/src/local-server.js +82 -0
- package/src/notes.js +16 -0
- package/src/personas.js +49 -0
- package/src/preflight.js +126 -0
- package/src/progress.js +30 -0
- package/src/prompts.js +278 -0
- package/src/research.js +113 -0
- package/src/review-view.js +157 -0
- package/src/seats.js +272 -0
- package/src/server.js +913 -0
- package/src/settings.js +27 -0
- package/src/setup-toml.js +124 -0
- package/src/snapshot.js +93 -0
- package/src/store.js +303 -0
- package/src/terminal.js +53 -0
- package/src/terms.js +52 -0
- package/src/ui/app.js +1967 -0
- package/src/ui/avatars.js +132 -0
- package/src/ui/ceremony.js +87 -0
- package/src/ui/design/app.css +714 -0
- package/src/ui/design/preset-dusk.css +91 -0
- package/src/ui/design/tokens.css +129 -0
- package/src/ui/diagnostics.js +61 -0
- package/src/ui/help.js +122 -0
- package/src/ui/icons.js +64 -0
- package/src/ui/index.html +272 -0
- package/src/ui/loader.js +73 -0
- package/src/ui/logo.svg +4 -0
- package/src/ui/scan.js +57 -0
- package/src/ui/scribe.js +64 -0
- package/src/ui/sound.js +80 -0
- package/src/validate.js +112 -0
- package/src/version.js +5 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="paper">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="roundtable-token" content="">
|
|
7
|
+
<title>Roundtable</title>
|
|
8
|
+
<link rel="icon" href="/ui/logo.svg" type="image/svg+xml">
|
|
9
|
+
<link rel="stylesheet" href="/ui/design/tokens.css">
|
|
10
|
+
<link rel="stylesheet" href="/ui/design/preset-dusk.css">
|
|
11
|
+
<link rel="stylesheet" href="/ui/design/app.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div class="app">
|
|
15
|
+
<header class="header">
|
|
16
|
+
<svg class="logo" viewBox="0 0 16 16" shape-rendering="crispEdges" role="img" aria-label="Roundtable logo">
|
|
17
|
+
<rect class="tile" width="16" height="16"/>
|
|
18
|
+
<g class="ink">
|
|
19
|
+
<rect x="6" y="2" width="4" height="1"/><rect x="4" y="3" width="2" height="1"/><rect x="10" y="3" width="2" height="1"/>
|
|
20
|
+
<rect x="3" y="4" width="1" height="2"/><rect x="12" y="4" width="1" height="2"/><rect x="6" y="5" width="4" height="1"/>
|
|
21
|
+
<rect x="2" y="6" width="1" height="3"/><rect x="13" y="6" width="1" height="3"/><rect x="5" y="6" width="6" height="3"/>
|
|
22
|
+
<rect x="3" y="9" width="1" height="2"/><rect x="12" y="9" width="1" height="2"/><rect x="6" y="9" width="4" height="1"/>
|
|
23
|
+
<rect x="4" y="11" width="2" height="1"/><rect x="10" y="11" width="2" height="1"/><rect x="6" y="12" width="4" height="1"/>
|
|
24
|
+
</g>
|
|
25
|
+
</svg>
|
|
26
|
+
<span class="wordmark">ROUNDTABLE<span class="cursor" aria-hidden="true"></span></span>
|
|
27
|
+
<span class="run-mode" id="run-mode">Connecting</span>
|
|
28
|
+
<span class="meta">
|
|
29
|
+
<a class="btn btn-small" id="nav-debates" href="#" hidden>Debates</a>
|
|
30
|
+
<button class="btn btn-small btn-icon" id="sound" type="button" aria-pressed="true" aria-label="Sound on"></button>
|
|
31
|
+
<button class="btn btn-small btn-icon" id="theme" type="button" aria-label="Switch to the night theme"></button>
|
|
32
|
+
</span>
|
|
33
|
+
</header>
|
|
34
|
+
|
|
35
|
+
<main id="main">
|
|
36
|
+
<p class="mode-note" id="mode-note" role="status">Reading the server mode…</p>
|
|
37
|
+
<p class="mode-note" id="diagnostics-status" role="status" hidden></p>
|
|
38
|
+
<p class="error" id="page-error" role="alert" hidden></p>
|
|
39
|
+
<!-- Setup screen -->
|
|
40
|
+
<section id="setup" class="screen">
|
|
41
|
+
<details class="panel" id="recent" aria-label="Debates">
|
|
42
|
+
<summary class="panel-title fold"><span>Debates</span><span class="hint" id="recent-count"></span></summary>
|
|
43
|
+
<div id="recent-list" class="debates"></div>
|
|
44
|
+
<div class="debates-foot" id="recent-foot" hidden><span id="debates-more-slot"></span><button class="text-btn" id="clear-history" type="button" disabled>Clear</button></div>
|
|
45
|
+
</details>
|
|
46
|
+
<details class="local-data" id="local-data">
|
|
47
|
+
<summary>Local data and backups</summary>
|
|
48
|
+
<p>Records for this server are saved in <code id="data-dir"></code>.</p>
|
|
49
|
+
<p id="legacy-data" hidden></p>
|
|
50
|
+
<p>Delete removes a session's local transcript and working files. Exported decision records, numeric usage statistics and quota calibration are kept. Running sessions cannot be deleted.</p>
|
|
51
|
+
<p>Usage statistics describe your own agents' calls in Roundtable and stay on this machine. Roundtable has no telemetry or analytics service and sends no usage data to its developers.</p>
|
|
52
|
+
<p>Stop the server before copying this folder. Keep the complete copy, including transcripts and recovery backups. Browser preferences and provider logins are stored separately.</p>
|
|
53
|
+
<p>Error logs stay in <code>_diagnostics/</code> inside this folder (up to 512 KiB). They contain error categories, reference IDs and code locations, not review text, credentials or personal paths. <b>Save error log</b> downloads a copy for review; nothing is uploaded automatically.</p>
|
|
54
|
+
<a href="/docs/workflow.md" target="_blank" rel="noopener">Full user guide, including startup and backups (text)</a>
|
|
55
|
+
</details>
|
|
56
|
+
<div>
|
|
57
|
+
<h1>New debate</h1>
|
|
58
|
+
<p class="lede">Round 0 is blind: the seats see only these notes.</p>
|
|
59
|
+
</div>
|
|
60
|
+
<form id="notes" class="panel form-grid" autocomplete="off">
|
|
61
|
+
<div class="panel-title"><span>Field notes</span></div>
|
|
62
|
+
<div class="fields">
|
|
63
|
+
<div class="field wide">
|
|
64
|
+
<label class="label" for="decision">Decision</label>
|
|
65
|
+
<input id="decision" name="decision" required placeholder="What has to be decided, as one question">
|
|
66
|
+
</div>
|
|
67
|
+
<div class="field wide">
|
|
68
|
+
<span class="label" id="options-label">Options</span>
|
|
69
|
+
<div id="options-editor" class="options" role="group" aria-labelledby="options-label"></div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="field wide"><label class="label" for="constraints">Constraints</label><input id="constraints" name="constraints" placeholder="Team, budget, platform: what any answer must live within"></div>
|
|
72
|
+
<details class="more wide" id="more-context">
|
|
73
|
+
<summary><span class="label">More context</span> <span class="more-filled" id="more-filled"></span></summary>
|
|
74
|
+
<div class="fields">
|
|
75
|
+
<div class="field"><label class="label" for="known">Known</label><textarea id="known" name="known" rows="2" placeholder="Measured facts"></textarea></div>
|
|
76
|
+
<div class="field"><label class="label" for="assumed">Assumed</label><textarea id="assumed" name="assumed" rows="2" placeholder="Believed, not measured"></textarea></div>
|
|
77
|
+
<div class="field"><label class="label" for="reversibility">Reversibility</label><input id="reversibility" name="reversibility" placeholder="Can it be undone, at what cost"></div>
|
|
78
|
+
<div class="field"><label class="label" for="deadline">Deadline</label><input id="deadline" name="deadline"></div>
|
|
79
|
+
<div class="field wide"><label class="label" for="exportTo">Export to</label><span class="pick-row"><input id="exportTo" name="exportTo" placeholder="A decisions folder in a codebase; the record is written there when the verdict lands"><button class="btn btn-small pick-folder" type="button" data-for="exportTo">Choose…</button></span><span id="export-folder-status" class="error" role="status"></span></div>
|
|
80
|
+
</div>
|
|
81
|
+
</details>
|
|
82
|
+
</div>
|
|
83
|
+
</form>
|
|
84
|
+
|
|
85
|
+
<section class="panel">
|
|
86
|
+
<div class="panel-title"><span>Seats</span><span class="hint">Seats know each other by lens only, never by vendor.</span></div>
|
|
87
|
+
<p class="hint panel-note" id="seat-setup-note" role="status" hidden></p>
|
|
88
|
+
<div id="seats" class="seats" role="list"></div>
|
|
89
|
+
<div id="datalists"></div>
|
|
90
|
+
<div class="row seats-actions">
|
|
91
|
+
<span class="error" id="seats-error" role="alert"></span>
|
|
92
|
+
<span class="hint" id="preflight-status" role="status"></span>
|
|
93
|
+
<button class="btn btn-small" id="refresh-status" type="button">Refresh status</button>
|
|
94
|
+
<button class="btn btn-small" id="add-seat" type="button">+ Add seat</button>
|
|
95
|
+
<button class="btn btn-small" id="add-searcher" type="button" title="Optional: one extra call that searches the web for your query before the first round, so every seat and the chairman read the same cited sources">+ Add searcher</button>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="chair-wrap">
|
|
98
|
+
<article class="seat-card chair-card" id="chair-card">
|
|
99
|
+
<div class="seat-hd">
|
|
100
|
+
<span class="avatar avatar-lg" id="chair-avatar"></span>
|
|
101
|
+
<div class="seat-id">
|
|
102
|
+
<span class="chair-title">Chairman <select id="chairman" class="flat vendor" aria-label="Chairman vendor"></select><span class="kind-tag" id="chair-kind"></span></span>
|
|
103
|
+
<span id="chair-model-slot"></span><input id="chairman-model" class="flat model" placeholder="model id" aria-label="Chairman model id" hidden>
|
|
104
|
+
</div>
|
|
105
|
+
<span class="lamp idle" id="chair-lamp" title="not checked"></span>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="seat-bd">
|
|
108
|
+
<p class="chair-role">A fresh session that took no part. It reads every round and writes the verdict, sceptically: agreement between seats is not evidence. You decide.</p>
|
|
109
|
+
<div id="chair-api" hidden>
|
|
110
|
+
<label class="f"><span class="label">Endpoint</span><input id="chairman-endpoint" class="flat" list="endpoints" placeholder="http://localhost:11434/v1" aria-label="Chairman endpoint base URL" title="The base URL of an OpenAI-compatible server, ending in /v1. Plain http only on this machine or a private network."></label>
|
|
111
|
+
<label class="f"><span class="label">Key var</span><input id="chairman-keyenv" class="flat" placeholder="none (local server)" aria-label="Chairman key environment variable" title="The NAME of the environment variable that holds the key, set in the shell roundtable was started from. Never the key itself."></label>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="f"><span class="label help-label" data-help="effort" tabindex="0" aria-describedby="help-effort">Effort</span><span class="pips" id="chair-pips"></span><select id="chairman-effort" class="effort" hidden><option value="">default</option></select></div>
|
|
114
|
+
<span class="seat-note lamp-note" id="chair-note" tabindex="0"></span>
|
|
115
|
+
<span class="iso-slot" id="chair-iso"></span>
|
|
116
|
+
<details class="seat-more" id="chair-profile"><summary>Login profile</summary><input id="chairman-account" class="flat" placeholder="none: your usual login" aria-label="Chairman login profile"></details>
|
|
117
|
+
</div>
|
|
118
|
+
</article>
|
|
119
|
+
</div>
|
|
120
|
+
<!-- The searcher: not a seat. It gathers cited web sources once, before round 0, from its query
|
|
121
|
+
alone; every seat and the chairman read the same sources. Its own model, effort, login. -->
|
|
122
|
+
<div class="chair-wrap searcher-wrap" id="searcher-wrap" hidden>
|
|
123
|
+
<article class="seat-card searcher-card" id="searcher-card" aria-labelledby="searcher-name">
|
|
124
|
+
<button class="remove" id="remove-searcher" type="button" aria-label="Remove the searcher" title="Remove the searcher">×</button>
|
|
125
|
+
<div class="seat-hd">
|
|
126
|
+
<span class="avatar avatar-lg" id="searcher-avatar"></span>
|
|
127
|
+
<div class="seat-id">
|
|
128
|
+
<span class="chair-title"><span id="searcher-name" class="help-label" data-help="searcher" tabindex="0" aria-describedby="help-searcher">Searcher</span> <select id="searcher-vendor" class="flat vendor" aria-label="Searcher vendor" title="The CLI that searches. Only CLIs that can search with every other tool kept off are offered."></select><span class="kind-tag">CLI · subscription</span></span>
|
|
129
|
+
<span id="searcher-model-slot"></span><input id="searcher-model" class="flat model" placeholder="model id" aria-label="Searcher model id" hidden>
|
|
130
|
+
</div>
|
|
131
|
+
<span class="lamp idle" id="searcher-lamp" title="not checked"></span>
|
|
132
|
+
</div>
|
|
133
|
+
<div class="seat-bd">
|
|
134
|
+
<p class="chair-role">Gathers web evidence before the first round and shares the same cited sources with every seat and the chairman. It investigates the question and options, without arguing or voting.</p>
|
|
135
|
+
<label class="f"><span class="label">Search</span><select id="research-query-mode" class="flat" aria-label="Search query source"><option value="decision">Use question and options</option><option value="custom">Custom query</option></select></label>
|
|
136
|
+
<label class="f f-top"><span class="label">Query</span><textarea id="research-query" class="flat" maxlength="2000" rows="2" placeholder="What should be checked on the web?" aria-describedby="research-query-note"></textarea></label>
|
|
137
|
+
<span class="seat-note" id="research-query-note">Review the query before starting. Other context and answers stay out of web research.</span>
|
|
138
|
+
<div class="f"><span class="label help-label" data-help="effort" tabindex="0" aria-describedby="help-effort">Effort</span><span class="pips" id="searcher-pips"></span><input id="searcher-effort" type="hidden"></div>
|
|
139
|
+
<div class="f"><span class="label help-label" data-help="searchLimits" tabindex="0" aria-describedby="help-searchLimits">Limits</span><span class="searcher-limits"><input id="research-searches" class="flat" type="number" min="1" max="8" value="4" aria-label="Search limit"> searches · <input id="research-time" class="flat" type="number" min="30" max="600" value="120" aria-label="Time limit in seconds"> s</span></div>
|
|
140
|
+
<span class="seat-note lamp-note" id="searcher-note" tabindex="0"></span>
|
|
141
|
+
<span class="iso-slot" id="searcher-iso"></span>
|
|
142
|
+
<details class="seat-more" id="searcher-profile"><summary>Login profile</summary><input id="searcher-account" class="flat" placeholder="none: your usual login" aria-label="Searcher login profile"></details>
|
|
143
|
+
</div>
|
|
144
|
+
</article>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="fields fields-row rules">
|
|
147
|
+
<div class="field"><label class="label help-label" data-help="critique" tabindex="0" for="critique-rounds" aria-describedby="help-critique">Critique rounds</label><input id="critique-rounds" type="number" min="0" max="8" value="1" aria-describedby="help-critique"></div>
|
|
148
|
+
<div class="field"><label class="label help-label" data-help="auto" tabindex="0" for="auto" aria-describedby="help-auto">Review window (s)</label><input id="auto" type="number" min="0.1" step="0.1" max="600" value="15" aria-describedby="help-auto"></div>
|
|
149
|
+
<div class="field"><label class="label help-label" data-help="questionWait" tabindex="0" for="question-wait" aria-describedby="help-questionWait">Answer window (s)</label><input id="question-wait" type="number" min="0.1" step="0.1" max="600" value="120" aria-describedby="help-questionWait"></div>
|
|
150
|
+
<div class="field"><label class="label help-label" data-help="wall" tabindex="0" for="wall" aria-describedby="help-wall">Wall clock (min)</label><input id="wall" type="number" min="1" max="180" value="30" aria-describedby="help-wall"></div>
|
|
151
|
+
<div class="field"><label class="label help-label" data-help="memory" tabindex="0" for="memory" aria-describedby="help-memory">Memory</label><select id="memory" aria-describedby="help-memory"><option value="stateless">stateless: fresh call per round</option><option value="resume">resume: re-send the thread</option></select></div>
|
|
152
|
+
<div class="field field-check"><label class="label help-label" data-help="unattended" tabindex="0" for="unattended" aria-describedby="help-unattended">Unattended</label><label class="check"><input id="unattended" type="checkbox" aria-describedby="help-unattended"><span class="switch" aria-hidden="true"></span><span class="switch-text" data-off="off" data-on="armed"></span></label></div>
|
|
153
|
+
</div>
|
|
154
|
+
</section>
|
|
155
|
+
<details class="panel" id="projection-panel">
|
|
156
|
+
<summary class="panel-title fold"><span>Quota picture</span><span class="hint" id="projection-summary"></span></summary>
|
|
157
|
+
<p class="hint panel-note">One pool per account: every seat on it draws from the same limit. Figures are estimates from past debates on this machine; other use of the same account moves the windows too.</p>
|
|
158
|
+
<div id="projection" class="pools"></div>
|
|
159
|
+
</details>
|
|
160
|
+
|
|
161
|
+
<div class="startbar">
|
|
162
|
+
<span class="startbar-summary" id="start-summary"></span>
|
|
163
|
+
<span class="error" id="setup-error" role="alert"></span>
|
|
164
|
+
<button class="btn btn-primary" id="start" type="button">Open the table</button>
|
|
165
|
+
</div>
|
|
166
|
+
</section>
|
|
167
|
+
|
|
168
|
+
<!-- Board screen -->
|
|
169
|
+
<section id="board" class="screen" hidden>
|
|
170
|
+
<div id="banners" class="banners" role="status" aria-live="polite"></div>
|
|
171
|
+
<!-- Quota first, as one quiet line; its table opens beneath the line, so the toggle never
|
|
172
|
+
moves. Then the question heads the state, the conclusion and the rounds. -->
|
|
173
|
+
<div class="usage-line" aria-label="Usage">
|
|
174
|
+
<div id="usage-summary" class="usage-summary" aria-label="Reported quota remaining"></div>
|
|
175
|
+
<button class="fold-toggle" id="usage-toggle" type="button" aria-expanded="false" aria-controls="usage-details">Usage details</button>
|
|
176
|
+
</div>
|
|
177
|
+
<div id="usage-details" class="panel usage-details" hidden>
|
|
178
|
+
<table id="usage"><thead><tr><th class="label">Pool and seat</th><th class="label num" title="Fresh input tokens">Input</th><th class="label num" title="Input tokens read from or written to the provider's cache">Cached</th><th class="label num">Output</th><th class="label num" title="A + marks a sum where some calls reported no cost">Cost</th><th class="label num">Time</th><th class="label num" title="Input tokens, cache included, that this seat sent in its latest round">Last round</th></tr></thead><tbody></tbody></table>
|
|
179
|
+
</div>
|
|
180
|
+
<header class="review-head" aria-label="Review">
|
|
181
|
+
<h2 id="board-title" class="review-question"></h2>
|
|
182
|
+
<span id="review-state" class="review-state" role="status"></span>
|
|
183
|
+
</header>
|
|
184
|
+
|
|
185
|
+
<section id="research-panel" class="panel" hidden aria-labelledby="research-heading"><div class="panel-title"><span id="research-heading">Shared web evidence</span></div><div id="research-body"></div></section>
|
|
186
|
+
|
|
187
|
+
<section id="questions-panel" class="panel questions-panel" hidden aria-labelledby="questions-heading">
|
|
188
|
+
<div class="panel-title"><span id="questions-heading">Questions for you</span><span id="questions-count" class="hint"></span></div>
|
|
189
|
+
<p class="hint">Your answers go to every seat. Leave anything you do not know blank. When the timer ends, the review continues with unanswered questions still unknown.</p>
|
|
190
|
+
<p id="question-timing" class="hint" aria-live="off"></p>
|
|
191
|
+
<div id="question-inputs"></div>
|
|
192
|
+
<div class="row question-actions"><span id="answer-count" class="hint" role="status"></span><button class="btn" id="more-time" type="button">More time</button><button class="btn btn-primary" id="send-answers" type="button">Send answers and continue</button></div>
|
|
193
|
+
</section>
|
|
194
|
+
|
|
195
|
+
<section id="verdict" class="panel verdict" hidden>
|
|
196
|
+
<div class="panel-title verdict-title"><span>Conclusion</span><button class="btn btn-small" id="copy-result" type="button" disabled>Copy result</button></div>
|
|
197
|
+
<div class="result-copy"><span id="copy-result-status" class="hint" role="status"></span><textarea id="copy-result-text" aria-label="Result text to copy" aria-describedby="copy-result-status" readonly hidden></textarea></div>
|
|
198
|
+
<div class="verdict-body" id="verdict-body"></div>
|
|
199
|
+
<details id="decision-actions" class="decision-actions"><summary>Record your decision</summary>
|
|
200
|
+
<p class="hint">The discussion is finished. This note records your decision; it is not sent to the agents.</p>
|
|
201
|
+
<form class="export-bar accept-bar" id="accept-form">
|
|
202
|
+
<label class="label" for="accept-option">Your decision</label>
|
|
203
|
+
<select id="accept-option"></select>
|
|
204
|
+
<input id="accept-note" placeholder="Why, in one line (optional)" aria-label="Note on your decision">
|
|
205
|
+
<button class="btn btn-small" id="accept" type="submit">Accept</button>
|
|
206
|
+
<span id="accept-status" class="hint" role="status"></span>
|
|
207
|
+
</form>
|
|
208
|
+
</details>
|
|
209
|
+
<form class="export-bar" id="export-form">
|
|
210
|
+
<input id="export-target" placeholder="Export to: an existing directory in a codebase" aria-label="Export target directory">
|
|
211
|
+
<button class="btn btn-small pick-folder" type="button" data-for="export-target">Choose…</button>
|
|
212
|
+
<button class="btn btn-small" id="export" type="submit">Export record</button>
|
|
213
|
+
<span id="export-status" class="hint" role="status"></span>
|
|
214
|
+
</form>
|
|
215
|
+
</section>
|
|
216
|
+
|
|
217
|
+
<div id="rounds" class="rounds"></div>
|
|
218
|
+
|
|
219
|
+
<div class="empty" id="board-empty">
|
|
220
|
+
<span><span class="prompt">></span> waiting for round 0<br> blind positions arrive together_</span>
|
|
221
|
+
</div>
|
|
222
|
+
</section>
|
|
223
|
+
</main>
|
|
224
|
+
|
|
225
|
+
<div id="humanbar" class="humanbar" hidden>
|
|
226
|
+
<div class="humanbar-status" id="gate-status"></div>
|
|
227
|
+
<div class="error" id="human-error" role="alert" hidden></div>
|
|
228
|
+
<form id="human-form" class="humanbar-form">
|
|
229
|
+
<input id="human-msg" aria-label="Note to every seat" placeholder="Say something to every seat, or /stop" autocomplete="off" maxlength="4000">
|
|
230
|
+
<button class="btn" id="answer-questions" type="button" hidden>Answer questions</button>
|
|
231
|
+
<button class="btn btn-primary" id="continue" type="submit">Continue</button>
|
|
232
|
+
<button class="btn" id="finish" type="button" hidden>Finish with verdict</button>
|
|
233
|
+
<button class="btn btn-danger" id="stop" type="button">Stop</button>
|
|
234
|
+
</form>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div id="boot" class="boot" hidden aria-hidden="true">
|
|
238
|
+
<div class="boot-card" id="boot-card"><span><span class="prompt">></span> loading the table<span class="cursor"></span></span></div>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<footer class="statusline">
|
|
242
|
+
<span id="sys" class="status ok">idle</span>
|
|
243
|
+
<span id="st-round"></span>
|
|
244
|
+
<span id="st-seats"></span>
|
|
245
|
+
<span id="st-elapsed"></span>
|
|
246
|
+
<span class="end" id="st-version"></span>
|
|
247
|
+
<button class="statusline-link" id="save-diagnostics" type="button">Save error log</button>
|
|
248
|
+
</footer>
|
|
249
|
+
</div>
|
|
250
|
+
<dialog id="delete-history" aria-labelledby="delete-history-title" aria-describedby="delete-history-detail">
|
|
251
|
+
<form method="dialog">
|
|
252
|
+
<h2 id="delete-history-title">Delete session?</h2>
|
|
253
|
+
<p id="delete-history-detail"></p>
|
|
254
|
+
<p>Local transcripts and working files will be removed permanently. Exported files and usage statistics are kept.</p>
|
|
255
|
+
<p class="error" id="delete-history-error" role="alert" hidden></p>
|
|
256
|
+
<div class="row"><button class="btn" id="delete-history-cancel" value="cancel" autofocus>Cancel</button><button class="btn btn-danger" id="delete-history-confirm" type="button">Delete</button></div>
|
|
257
|
+
</form>
|
|
258
|
+
</dialog>
|
|
259
|
+
<dialog id="vendor-terms" aria-labelledby="vendor-terms-title" aria-describedby="vendor-terms-notice">
|
|
260
|
+
<form method="dialog">
|
|
261
|
+
<h2 id="vendor-terms-title">Third-party app notice</h2>
|
|
262
|
+
<p id="vendor-terms-notice"></p>
|
|
263
|
+
<p id="vendor-terms-what"></p>
|
|
264
|
+
<p><a id="vendor-terms-link" href="#" target="_blank" rel="noopener noreferrer">Read the FAQ</a></p>
|
|
265
|
+
<p class="seat-note" id="vendor-terms-state" hidden></p>
|
|
266
|
+
<p class="error" id="vendor-terms-error" role="alert" hidden></p>
|
|
267
|
+
<div class="row"><button class="btn" id="vendor-terms-cancel" value="cancel" autofocus>Not now</button><button class="btn btn-danger" id="vendor-terms-withdraw" type="button" hidden>Withdraw</button><button class="btn btn-primary" id="vendor-terms-confirm" type="button">I understand, use it</button></div>
|
|
268
|
+
</form>
|
|
269
|
+
</dialog>
|
|
270
|
+
<script type="module" src="/ui/app.js"></script>
|
|
271
|
+
</body>
|
|
272
|
+
</html>
|
package/src/ui/loader.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The loading mark: the Roundtable logo drawn straight on the page, no tile or frame. The table at
|
|
2
|
+
// the centre stays lit. The ring of seats breathes slowly, one pixel out and back, one pixel in
|
|
3
|
+
// and back, while a light runs round it clockwise, a bright head with a short trail.
|
|
4
|
+
//
|
|
5
|
+
// Every pixel sits on the logo's own 16x16 grid, so it stays crisp at any size. The middle ring
|
|
6
|
+
// is the logo's own; the inner and outer rings are drawn the same way (a flat top, a two-pixel
|
|
7
|
+
// step, straight sides), one pixel smaller and larger on every side. Each seat carries its angle
|
|
8
|
+
// as a fraction of a lap, so the running light keeps its place when the ring changes size. The
|
|
9
|
+
// timing is CSS (app.css, "Loading"); reduced motion shows the logo, lit.
|
|
10
|
+
|
|
11
|
+
const TABLE = [
|
|
12
|
+
'................', '................', '................', '................', '................',
|
|
13
|
+
'......####......',
|
|
14
|
+
'.....######.....',
|
|
15
|
+
'.....######.....',
|
|
16
|
+
'.....######.....',
|
|
17
|
+
'......####......',
|
|
18
|
+
];
|
|
19
|
+
const RINGS = {
|
|
20
|
+
in: [
|
|
21
|
+
'................', '................', '................',
|
|
22
|
+
'......####......',
|
|
23
|
+
'....##....##....',
|
|
24
|
+
'...#........#...',
|
|
25
|
+
'...#........#...',
|
|
26
|
+
'...#........#...',
|
|
27
|
+
'...#........#...',
|
|
28
|
+
'...#........#...',
|
|
29
|
+
'....##....##....',
|
|
30
|
+
'......####......',
|
|
31
|
+
],
|
|
32
|
+
mid: [
|
|
33
|
+
'................', '................',
|
|
34
|
+
'......####......',
|
|
35
|
+
'....##....##....',
|
|
36
|
+
'...#........#...',
|
|
37
|
+
'...#........#...',
|
|
38
|
+
'..#..........#..',
|
|
39
|
+
'..#..........#..',
|
|
40
|
+
'..#..........#..',
|
|
41
|
+
'...#........#...',
|
|
42
|
+
'...#........#...',
|
|
43
|
+
'....##....##....',
|
|
44
|
+
'......####......',
|
|
45
|
+
],
|
|
46
|
+
out: [
|
|
47
|
+
'................',
|
|
48
|
+
'.....######.....',
|
|
49
|
+
'...##......##...',
|
|
50
|
+
'..#..........#..',
|
|
51
|
+
'..#..........#..',
|
|
52
|
+
'.#............#.',
|
|
53
|
+
'.#............#.',
|
|
54
|
+
'.#............#.',
|
|
55
|
+
'.#............#.',
|
|
56
|
+
'.#............#.',
|
|
57
|
+
'..#..........#..',
|
|
58
|
+
'..#..........#..',
|
|
59
|
+
'...##......##...',
|
|
60
|
+
'.....######.....',
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const cells = (rows) => rows.flatMap((row, y) => [...row].map((c, x) => (c === '#' ? [x, y] : null)).filter(Boolean));
|
|
65
|
+
// The logo's middle is (8, 7.5) in pixel-centre terms: twelve columns wide, eleven rows tall.
|
|
66
|
+
const turn = (x, y) => { const a = Math.atan2(x + 0.5 - 8, -(y + 0.5 - 7.5)); return (a < 0 ? a + 2 * Math.PI : a) / (2 * Math.PI); };
|
|
67
|
+
|
|
68
|
+
export function loaderMarkup() {
|
|
69
|
+
const rect = ([x, y], extra = '') => `<rect x="${x}" y="${y}" width="1" height="1"${extra}/>`;
|
|
70
|
+
const ring = (name) => `<g class="loader-ring loader-${name}">${cells(RINGS[name]).map((p) => rect(p, ` class="loader-seat" style="--a:${turn(...p).toFixed(3)}"`)).join('')}</g>`;
|
|
71
|
+
return `<svg class="loader" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true" focusable="false">
|
|
72
|
+
<g class="loader-table">${cells(TABLE).map((p) => rect(p)).join('')}</g>${ring('in')}${ring('mid')}${ring('out')}</svg>`;
|
|
73
|
+
}
|
package/src/ui/logo.svg
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 16 16" shape-rendering="crispEdges" role="img" aria-label="roundtable logo">
|
|
2
|
+
<rect width="16" height="16" fill="#7d4a8a"/>
|
|
3
|
+
<g fill="#f6f2ea"><rect x="6" y="2" width="1" height="1"/><rect x="7" y="2" width="1" height="1"/><rect x="8" y="2" width="1" height="1"/><rect x="9" y="2" width="1" height="1"/><rect x="4" y="3" width="1" height="1"/><rect x="5" y="3" width="1" height="1"/><rect x="10" y="3" width="1" height="1"/><rect x="11" y="3" width="1" height="1"/><rect x="3" y="4" width="1" height="1"/><rect x="12" y="4" width="1" height="1"/><rect x="3" y="5" width="1" height="1"/><rect x="6" y="5" width="1" height="1"/><rect x="7" y="5" width="1" height="1"/><rect x="8" y="5" width="1" height="1"/><rect x="9" y="5" width="1" height="1"/><rect x="12" y="5" width="1" height="1"/><rect x="2" y="6" width="1" height="1"/><rect x="5" y="6" width="1" height="1"/><rect x="6" y="6" width="1" height="1"/><rect x="7" y="6" width="1" height="1"/><rect x="8" y="6" width="1" height="1"/><rect x="9" y="6" width="1" height="1"/><rect x="10" y="6" width="1" height="1"/><rect x="13" y="6" width="1" height="1"/><rect x="2" y="7" width="1" height="1"/><rect x="5" y="7" width="1" height="1"/><rect x="6" y="7" width="1" height="1"/><rect x="7" y="7" width="1" height="1"/><rect x="8" y="7" width="1" height="1"/><rect x="9" y="7" width="1" height="1"/><rect x="10" y="7" width="1" height="1"/><rect x="13" y="7" width="1" height="1"/><rect x="2" y="8" width="1" height="1"/><rect x="5" y="8" width="1" height="1"/><rect x="6" y="8" width="1" height="1"/><rect x="7" y="8" width="1" height="1"/><rect x="8" y="8" width="1" height="1"/><rect x="9" y="8" width="1" height="1"/><rect x="10" y="8" width="1" height="1"/><rect x="13" y="8" width="1" height="1"/><rect x="3" y="9" width="1" height="1"/><rect x="6" y="9" width="1" height="1"/><rect x="7" y="9" width="1" height="1"/><rect x="8" y="9" width="1" height="1"/><rect x="9" y="9" width="1" height="1"/><rect x="12" y="9" width="1" height="1"/><rect x="3" y="10" width="1" height="1"/><rect x="12" y="10" width="1" height="1"/><rect x="4" y="11" width="1" height="1"/><rect x="5" y="11" width="1" height="1"/><rect x="10" y="11" width="1" height="1"/><rect x="11" y="11" width="1" height="1"/><rect x="6" y="12" width="1" height="1"/><rect x="7" y="12" width="1" height="1"/><rect x="8" y="12" width="1" height="1"/><rect x="9" y="12" width="1" height="1"/></g>
|
|
4
|
+
</svg>
|
package/src/ui/scan.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The searcher at work: its eye moves down a column of search results, and the results it keeps
|
|
2
|
+
// light up. Shown in the sources panel while the research call runs. Frames are swapped by a
|
|
3
|
+
// timer, never tweened, so every pixel stays crisp; with reduced motion the column is shown read,
|
|
4
|
+
// the eye at rest beside the last result. It draws nothing but the picture: the panel text, the
|
|
5
|
+
// status line and the review state carry the words.
|
|
6
|
+
import { pixelRects, sprite } from '/ui/avatars.js';
|
|
7
|
+
|
|
8
|
+
const EYE = sprite('searcher'); // 12x12; its iris is rows 4 to 7
|
|
9
|
+
const IRIS = 5; // the eye's sprite row that sits level with the result it reads
|
|
10
|
+
const SIZE = 36; // art pixels, square
|
|
11
|
+
const ROWS = [[16, 7, 14, true], [16, 12, 10, false], [16, 17, 16, true], [16, 22, 12, true], [16, 27, 9, false]]; // x, y, length, kept
|
|
12
|
+
const TOP = ROWS[0][1] - IRIS, BOTTOM = ROWS.at(-1)[1] - IRIS;
|
|
13
|
+
const TICK_MS = 120, REST_TICKS = 10; // a pause with every result read before a fresh search
|
|
14
|
+
|
|
15
|
+
export function scanMarkup() {
|
|
16
|
+
return `<div class="scan" role="img" aria-label="The searcher is reading search results">
|
|
17
|
+
<svg class="scan-art" viewBox="0 0 ${SIZE} ${SIZE}" shape-rendering="crispEdges" focusable="false" aria-hidden="true">
|
|
18
|
+
<g class="scan-results"></g>
|
|
19
|
+
<g class="scan-eye">${pixelRects(EYE)}</g>
|
|
20
|
+
</svg>
|
|
21
|
+
</div>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Draw the column with the eye's top at art row `y`: results above its iris are read, and the
|
|
25
|
+
// kept ones are lit. Each result is a square mark and a line, like a link and its title.
|
|
26
|
+
function frame(root, y) {
|
|
27
|
+
const level = y + IRIS;
|
|
28
|
+
root.querySelector('.scan-results').innerHTML = ROWS.map(([x, ry, len, kept]) => {
|
|
29
|
+
const cls = ry > level ? 'scan-unread' : kept ? 'scan-kept' : 'scan-read';
|
|
30
|
+
return `<g class="${cls}"><rect x="${x}" y="${ry - 1}" width="2" height="2"/><rect x="${x + 4}" y="${ry}" width="${len}" height="1"/></g>`;
|
|
31
|
+
}).join('');
|
|
32
|
+
root.querySelector('.scan-eye').setAttribute('transform', `translate(1 ${y})`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let timer = null, playing = null;
|
|
36
|
+
|
|
37
|
+
// Start the drawing inside `root` (an element holding the markup above). Idempotent for the
|
|
38
|
+
// same root, so a redraw of the panel on every event does not restart the eye.
|
|
39
|
+
export function startScan(root) {
|
|
40
|
+
if (playing === root) return;
|
|
41
|
+
stopScan();
|
|
42
|
+
playing = root;
|
|
43
|
+
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { frame(root, BOTTOM); return; }
|
|
44
|
+
let y = TOP, rest = 0;
|
|
45
|
+
frame(root, y);
|
|
46
|
+
timer = setInterval(() => {
|
|
47
|
+
if (!root.isConnected) return stopScan();
|
|
48
|
+
if (y >= BOTTOM) { if (++rest > REST_TICKS) { y = TOP; rest = 0; } else return; }
|
|
49
|
+
else y += 1;
|
|
50
|
+
frame(root, y);
|
|
51
|
+
}, TICK_MS);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function stopScan() {
|
|
55
|
+
if (timer) clearInterval(timer);
|
|
56
|
+
timer = null; playing = null;
|
|
57
|
+
}
|
package/src/ui/scribe.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The chairman at work: a pixel sheet on which lines of text appear under a quill, shown in the
|
|
2
|
+
// Conclusion panel while the verdict call runs. Frames are swapped by a timer, never tweened, so
|
|
3
|
+
// every pixel stays crisp; with reduced motion the sheet is shown written, the quill at rest.
|
|
4
|
+
// It draws nothing but the picture: the status line and the review state carry the words.
|
|
5
|
+
import { pixelRects, sprite } from '/ui/avatars.js';
|
|
6
|
+
|
|
7
|
+
const QUILL = sprite('custom'); // the custom seat's 12x12 quill; its nib is the bottom-left pixel
|
|
8
|
+
const NIB = [0, 11];
|
|
9
|
+
const SIZE = 36; // art pixels, square
|
|
10
|
+
const PAGE = { x: 2, y: 9, w: 20, h: 24 };
|
|
11
|
+
const LINES = [[4, 13, 13], [4, 16, 15], [4, 19, 11], [4, 22, 14], [4, 25, 9], [4, 28, 12]]; // x, y, full length
|
|
12
|
+
const TOTAL = LINES.reduce((n, [, , len]) => n + len, 0);
|
|
13
|
+
const TICK_MS = 110, REST_TICKS = 9; // a pause with the page full before a fresh sheet
|
|
14
|
+
|
|
15
|
+
export function scribeMarkup() {
|
|
16
|
+
return `<div class="scribe" role="img" aria-label="The chairman is writing the verdict">
|
|
17
|
+
<svg class="scribe-art" viewBox="0 0 ${SIZE} ${SIZE}" shape-rendering="crispEdges" focusable="false" aria-hidden="true">
|
|
18
|
+
<rect class="scribe-shadow" x="${PAGE.x + 1}" y="${PAGE.y + 1}" width="${PAGE.w}" height="${PAGE.h}"/>
|
|
19
|
+
<rect class="scribe-shadow" x="${PAGE.x}" y="${PAGE.y}" width="${PAGE.w}" height="${PAGE.h}"/>
|
|
20
|
+
<rect class="scribe-page" x="${PAGE.x + 1}" y="${PAGE.y + 1}" width="${PAGE.w - 2}" height="${PAGE.h - 2}"/>
|
|
21
|
+
<g class="scribe-ink"></g>
|
|
22
|
+
<g class="scribe-quill">${pixelRects(QUILL)}</g>
|
|
23
|
+
</svg>
|
|
24
|
+
</div>`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Draw the sheet with `ink` pixels written and the quill's nib on the next one.
|
|
28
|
+
function frame(root, ink) {
|
|
29
|
+
let left = ink, nib = null;
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (const [x, y, len] of LINES) {
|
|
32
|
+
const w = Math.min(len, left);
|
|
33
|
+
if (w > 0) rows.push(`<rect x="${x}" y="${y}" width="${w}" height="1"/>`);
|
|
34
|
+
if (left < len) { nib = [x + Math.max(w, 0), y]; break; }
|
|
35
|
+
left -= len;
|
|
36
|
+
}
|
|
37
|
+
if (!nib) { const [x, y, len] = LINES.at(-1); nib = [x + len, y]; }
|
|
38
|
+
root.querySelector('.scribe-ink').innerHTML = rows.join('');
|
|
39
|
+
root.querySelector('.scribe-quill').setAttribute('transform', `translate(${nib[0] - NIB[0]} ${nib[1] - NIB[1]})`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let timer = null, playing = null;
|
|
43
|
+
|
|
44
|
+
// Start the drawing inside `root` (an element holding the markup above). Idempotent for the
|
|
45
|
+
// same root, so a redraw of the panel on every event does not restart the hand.
|
|
46
|
+
export function startScribe(root) {
|
|
47
|
+
if (playing === root) return;
|
|
48
|
+
stopScribe();
|
|
49
|
+
playing = root;
|
|
50
|
+
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { frame(root, TOTAL); return; }
|
|
51
|
+
let ink = 0, rest = 0;
|
|
52
|
+
frame(root, ink);
|
|
53
|
+
timer = setInterval(() => {
|
|
54
|
+
if (!root.isConnected) return stopScribe();
|
|
55
|
+
if (ink >= TOTAL) { if (++rest > REST_TICKS) { ink = 0; rest = 0; } else return; }
|
|
56
|
+
else ink += ink % 5 === 4 ? 2 : 1; // an uneven hand
|
|
57
|
+
frame(root, ink);
|
|
58
|
+
}, TICK_MS);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function stopScribe() {
|
|
62
|
+
if (timer) clearInterval(timer);
|
|
63
|
+
timer = null; playing = null;
|
|
64
|
+
}
|
package/src/ui/sound.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Short chiptune cues, synthesized on the spot: no audio files, nothing fetched. A triangle
|
|
2
|
+
// voice keeps the notes soft, a filtered noise burst is the gavel's knock, and everything runs
|
|
3
|
+
// through one quiet master gain. Browsers allow audio only after a gesture on the page, so
|
|
4
|
+
// unlock() is called from clicks; without one a cue stays silent instead of failing.
|
|
5
|
+
const KEY = 'roundtable.sound';
|
|
6
|
+
let ctx = null, master = null;
|
|
7
|
+
let enabled = true;
|
|
8
|
+
try { enabled = localStorage.getItem(KEY) !== 'off'; } catch {}
|
|
9
|
+
|
|
10
|
+
export const soundEnabled = () => enabled;
|
|
11
|
+
export function setSound(on) {
|
|
12
|
+
enabled = !!on;
|
|
13
|
+
try { localStorage.setItem(KEY, enabled ? 'on' : 'off'); } catch {}
|
|
14
|
+
if (enabled) unlock();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function unlock() {
|
|
18
|
+
if (!enabled) return null;
|
|
19
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
20
|
+
if (!AC) return null;
|
|
21
|
+
if (!ctx) {
|
|
22
|
+
ctx = new AC();
|
|
23
|
+
master = ctx.createGain(); master.gain.value = 0.55; master.connect(ctx.destination);
|
|
24
|
+
}
|
|
25
|
+
if (ctx.state === 'suspended') ctx.resume().catch(() => {});
|
|
26
|
+
return ctx;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tone(at, freq, dur, { to = freq, type = 'triangle', gain = 0.05, lowpass = 0 } = {}) {
|
|
30
|
+
const osc = ctx.createOscillator(), env = ctx.createGain();
|
|
31
|
+
osc.type = type;
|
|
32
|
+
osc.frequency.setValueAtTime(freq, at);
|
|
33
|
+
if (to !== freq) osc.frequency.exponentialRampToValueAtTime(to, at + dur);
|
|
34
|
+
env.gain.setValueAtTime(0.0001, at);
|
|
35
|
+
env.gain.exponentialRampToValueAtTime(gain, at + 0.008);
|
|
36
|
+
env.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
|
37
|
+
let out = osc;
|
|
38
|
+
if (lowpass) { const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = lowpass; osc.connect(f); out = f; }
|
|
39
|
+
out.connect(env); env.connect(master);
|
|
40
|
+
osc.start(at); osc.stop(at + dur + 0.02);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let noiseBuffer = null;
|
|
44
|
+
function knock(at, { gain = 0.1, freq = 1700, body = 200 } = {}) {
|
|
45
|
+
if (!noiseBuffer) {
|
|
46
|
+
noiseBuffer = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * 0.05), ctx.sampleRate);
|
|
47
|
+
const data = noiseBuffer.getChannelData(0);
|
|
48
|
+
for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
|
|
49
|
+
}
|
|
50
|
+
const src = ctx.createBufferSource(), band = ctx.createBiquadFilter(), env = ctx.createGain();
|
|
51
|
+
src.buffer = noiseBuffer;
|
|
52
|
+
band.type = 'bandpass'; band.frequency.value = freq; band.Q.value = 1.4;
|
|
53
|
+
env.gain.setValueAtTime(gain, at); env.gain.exponentialRampToValueAtTime(0.0001, at + 0.045);
|
|
54
|
+
src.connect(band); band.connect(env); env.connect(master);
|
|
55
|
+
src.start(at); src.stop(at + 0.05);
|
|
56
|
+
tone(at, body, 0.07, { to: body / 2, gain: gain * 0.7 }); // the wooden body under the click
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// hit: seconds after now when the gavel lands, matched to the animation's strike frame.
|
|
60
|
+
const CUES = {
|
|
61
|
+
open(t) { knock(t, { gain: 0.06 }); tone(t + 0.05, 523.25, 0.09, { gain: 0.035 }); tone(t + 0.14, 783.99, 0.14, { gain: 0.035 }); },
|
|
62
|
+
verdict(t) { knock(t); [523.25, 659.25, 783.99].forEach((f, i) => tone(t + 0.08 + i * 0.075, f, 0.09, { gain: 0.035 })); tone(t + 0.31, 1046.5, 0.26, { gain: 0.03 }); },
|
|
63
|
+
ended(t) { knock(t, { gain: 0.08 }); tone(t + 0.08, 783.99, 0.1, { gain: 0.032 }); tone(t + 0.19, 523.25, 0.22, { gain: 0.032 }); },
|
|
64
|
+
cancelled(t) { tone(t, 392, 0.1, { gain: 0.025 }); tone(t + 0.12, 261.63, 0.16, { gain: 0.025 }); },
|
|
65
|
+
// The table waits for the person: three rising notes that end on a question, no gavel.
|
|
66
|
+
waiting(t) { tone(t, 659.25, 0.1, { gain: 0.03 }); tone(t + 0.13, 880, 0.12, { gain: 0.03 }); tone(t + 0.3, 1046.5, 0.3, { gain: 0.028, to: 1108.73 }); },
|
|
67
|
+
failed(t) { knock(t, { gain: 0.07, freq: 700, body: 140 }); tone(t + 0.07, 311.13, 0.16, { type: 'square', gain: 0.022, lowpass: 1100 }); tone(t + 0.25, 233.08, 0.3, { type: 'square', gain: 0.022, lowpass: 900, to: 220 }); },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function playCue(kind, hit = 0) {
|
|
71
|
+
if (!enabled || !CUES[kind]) return;
|
|
72
|
+
const ac = unlock();
|
|
73
|
+
if (!ac) return;
|
|
74
|
+
const schedule = () => { try { CUES[kind](ac.currentTime + hit); } catch {} }; // a cue never breaks the board
|
|
75
|
+
if (ac.state === 'running') return schedule();
|
|
76
|
+
// Still waking up: play if it resumes at once. A late resume (the next click, minutes on) would
|
|
77
|
+
// be a stale cue, so it is dropped.
|
|
78
|
+
const asked = performance.now();
|
|
79
|
+
ac.resume().then(() => { if (ac.state === 'running' && performance.now() - asked < 300) schedule(); }, () => {});
|
|
80
|
+
}
|