@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +513 -0
  2. package/LICENSE +21 -0
  3. package/README.md +188 -3
  4. package/SECURITY.md +299 -0
  5. package/docs/workflow.md +1325 -0
  6. package/package.json +52 -6
  7. package/scripts/agent.mjs +40 -0
  8. package/scripts/export.mjs +20 -0
  9. package/scripts/mcp.mjs +175 -0
  10. package/scripts/run-debate.mjs +70 -0
  11. package/scripts/server.mjs +28 -0
  12. package/scripts/setup.mjs +182 -0
  13. package/skills/roundtable/SKILL.md +55 -0
  14. package/src/adapters/agy.js +7 -0
  15. package/src/adapters/catalogue.js +49 -0
  16. package/src/adapters/claude.js +6 -0
  17. package/src/adapters/codex.js +6 -0
  18. package/src/adapters/common.js +304 -0
  19. package/src/adapters/config.js +432 -0
  20. package/src/adapters/endpoint.js +178 -0
  21. package/src/adapters/errors.js +69 -0
  22. package/src/adapters/index.js +11 -0
  23. package/src/adapters/models.js +106 -0
  24. package/src/adapters/quota.js +305 -0
  25. package/src/adapters/spawn.js +178 -0
  26. package/src/agent-client.js +108 -0
  27. package/src/cli.js +44 -0
  28. package/src/contract.js +256 -0
  29. package/src/diagnostics.js +133 -0
  30. package/src/doctor.js +96 -0
  31. package/src/engine.js +902 -0
  32. package/src/estimate.js +331 -0
  33. package/src/export.js +307 -0
  34. package/src/fake.js +75 -0
  35. package/src/folder-picker.js +46 -0
  36. package/src/history.js +220 -0
  37. package/src/local-http.js +39 -0
  38. package/src/local-server.js +82 -0
  39. package/src/notes.js +16 -0
  40. package/src/personas.js +49 -0
  41. package/src/preflight.js +126 -0
  42. package/src/progress.js +30 -0
  43. package/src/prompts.js +278 -0
  44. package/src/research.js +113 -0
  45. package/src/review-view.js +157 -0
  46. package/src/seats.js +272 -0
  47. package/src/server.js +913 -0
  48. package/src/settings.js +27 -0
  49. package/src/setup-toml.js +124 -0
  50. package/src/snapshot.js +93 -0
  51. package/src/store.js +303 -0
  52. package/src/terminal.js +53 -0
  53. package/src/terms.js +52 -0
  54. package/src/ui/app.js +1967 -0
  55. package/src/ui/avatars.js +132 -0
  56. package/src/ui/ceremony.js +87 -0
  57. package/src/ui/design/app.css +714 -0
  58. package/src/ui/design/preset-dusk.css +91 -0
  59. package/src/ui/design/tokens.css +129 -0
  60. package/src/ui/diagnostics.js +61 -0
  61. package/src/ui/help.js +122 -0
  62. package/src/ui/icons.js +64 -0
  63. package/src/ui/index.html +272 -0
  64. package/src/ui/loader.js +73 -0
  65. package/src/ui/logo.svg +4 -0
  66. package/src/ui/scan.js +57 -0
  67. package/src/ui/scribe.js +64 -0
  68. package/src/ui/sound.js +80 -0
  69. package/src/validate.js +112 -0
  70. package/src/version.js +5 -0
@@ -0,0 +1,91 @@
1
+ /* Signature preset: Dusk, version 1.
2
+ Load after tokens.css. The Ink & paper structure (warm paper, IBM Plex, square tags, LED
3
+ status lights, hard shadows) with purple where Ink & paper has blue: aubergine chrome, a plum
4
+ action colour, orchid (the Dusk accent) for brand marks. Green appears only for success.
5
+ Light theme "Plum paper", dark theme "Night plum". First used by Roundtable. All text pairs
6
+ checked with design_lint.py --contrast at 4.5:1 or better; border-strong at 3:1 (marks). */
7
+
8
+ :root {
9
+ --knob-accent: #c79ad0;
10
+ --knob-accent-manual: #7d4a8a;
11
+ }
12
+
13
+ :root,
14
+ :root[data-theme="paper"],
15
+ :root[data-theme="manual"] {
16
+ color-scheme: light;
17
+ --color-bg: #f6f2ea;
18
+ --color-surface: #fdfbf7;
19
+ --color-surface-raised: #fffdfa;
20
+ --color-border: #ddd3d6;
21
+ --color-border-strong: #8c7f8f;
22
+ --color-text: #241c2b;
23
+ --color-text-muted: #645a6c;
24
+ --color-accent: var(--knob-accent-manual);
25
+ --color-logo-ink: #2b1d38;
26
+ --color-action: #5b3478;
27
+ --color-on-action: #ffffff;
28
+ --color-selection: #ece3f0;
29
+ --color-ok: #3f6b3a;
30
+ --color-warn: #8f5a00;
31
+ --color-danger: #a8402d;
32
+ --color-info: #5b3478;
33
+ --color-focus: #5b3478;
34
+ --color-chrome: #2b1d38;
35
+ --color-chrome-text: #f1eaf4;
36
+ --color-chrome-muted: #c3b3cc;
37
+ --color-chrome-accent: #c79ad0;
38
+ }
39
+
40
+ :root[data-theme="night"],
41
+ :root[data-theme="terminal"] {
42
+ color-scheme: dark;
43
+ --color-bg: #16111c;
44
+ --color-surface: #1d1724;
45
+ --color-surface-raised: #251d2e;
46
+ --color-border: #352a40;
47
+ --color-border-strong: #6e5d7d;
48
+ --color-text: #ece5f1;
49
+ --color-text-muted: #a597b0;
50
+ --color-accent: var(--knob-accent);
51
+ --color-logo-ink: #0f0b14;
52
+ --color-action: #c9a3e6;
53
+ --color-on-action: #16111c;
54
+ --color-selection: #2a2035;
55
+ --color-ok: #8fbf86;
56
+ --color-warn: #d9a45a;
57
+ --color-danger: #e58a78;
58
+ --color-info: #c9a3e6;
59
+ --color-focus: #c9a3e6;
60
+ --color-chrome: #0f0b14;
61
+ --color-chrome-text: #ece5f1;
62
+ --color-chrome-muted: #a597b0;
63
+ --color-chrome-accent: #c79ad0;
64
+ }
65
+
66
+ @media (prefers-color-scheme: dark) {
67
+ :root:not([data-theme]) {
68
+ color-scheme: dark;
69
+ --color-bg: #16111c;
70
+ --color-surface: #1d1724;
71
+ --color-surface-raised: #251d2e;
72
+ --color-border: #352a40;
73
+ --color-border-strong: #6e5d7d;
74
+ --color-text: #ece5f1;
75
+ --color-text-muted: #a597b0;
76
+ --color-accent: var(--knob-accent);
77
+ --color-logo-ink: #0f0b14;
78
+ --color-action: #c9a3e6;
79
+ --color-on-action: #16111c;
80
+ --color-selection: #2a2035;
81
+ --color-ok: #8fbf86;
82
+ --color-warn: #d9a45a;
83
+ --color-danger: #e58a78;
84
+ --color-info: #c9a3e6;
85
+ --color-focus: #c9a3e6;
86
+ --color-chrome: #0f0b14;
87
+ --color-chrome-text: #ece5f1;
88
+ --color-chrome-muted: #a597b0;
89
+ --color-chrome-accent: #c79ad0;
90
+ }
91
+ }
@@ -0,0 +1,129 @@
1
+ /* Signature design tokens, version 2 ("used future").
2
+ Copy unchanged into each app. Override only the --knob-* variables, in a
3
+ separate app.css loaded after this file. */
4
+
5
+ :root {
6
+ /* ---- Per-app knobs ---------------------------------------------------- */
7
+ --knob-accent: #e8a33d;
8
+ --knob-accent-manual: #b4561e;
9
+ --knob-density: 1;
10
+
11
+ /* ---- Type ------------------------------------------------------------- */
12
+ --font-sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
13
+ --font-mono: "IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
14
+ --text-xs: 11px;
15
+ --text-sm: 12px;
16
+ --text-base: 14px;
17
+ --text-md: 16px;
18
+ --text-lg: 20px;
19
+ --text-xl: 28px;
20
+ --leading-tight: 1.25;
21
+ --leading-base: 1.5;
22
+ --weight-regular: 400;
23
+ --weight-strong: 600;
24
+ --tracking-label: 0.08em;
25
+
26
+ /* ---- Space (4px grid, scaled by density) ------------------------------ */
27
+ --space-1: calc(4px * var(--knob-density));
28
+ --space-2: calc(8px * var(--knob-density));
29
+ --space-3: calc(12px * var(--knob-density));
30
+ --space-4: calc(16px * var(--knob-density));
31
+ --space-5: calc(24px * var(--knob-density));
32
+ --space-6: calc(32px * var(--knob-density));
33
+
34
+ /* ---- Shape ------------------------------------------------------------ */
35
+ --radius-sm: 2px;
36
+ --radius: 4px;
37
+ --radius-lg: 6px;
38
+ --border-width: 1px;
39
+ --shadow-hard: 3px 3px 0 var(--color-border);
40
+ --shadow-hard-action: 2px 2px 0 var(--color-border-strong);
41
+ --shadow-overlay: 6px 6px 0 var(--color-border-strong);
42
+
43
+ /* ---- Industrial details (brand layer; use these, never hand-roll) ------ */
44
+ --led-size: 7px;
45
+ --pattern-ticks: repeating-linear-gradient(90deg, var(--color-border) 0 1px, transparent 1px 8px);
46
+ --pattern-hazard: repeating-linear-gradient(-45deg, var(--color-danger) 0 3px, transparent 3px 6px);
47
+
48
+ /* ---- Motion ----------------------------------------------------------- */
49
+ --motion-fast: 120ms;
50
+ --motion-base: 180ms;
51
+ --ease: cubic-bezier(0.2, 0, 0, 1);
52
+ --cursor-blink: 1.1s steps(1) infinite;
53
+ }
54
+
55
+ /* ---- Terminal (dark, warm) ---------------------------------------------- */
56
+ :root,
57
+ :root[data-theme="terminal"] {
58
+ color-scheme: dark;
59
+ --color-bg: #13110e;
60
+ --color-surface: #1b1814;
61
+ --color-surface-raised: #221e19;
62
+ --color-border: #3a332b;
63
+ --color-border-strong: #554b3f;
64
+ --color-text: #e8dfd0;
65
+ --color-text-muted: #9e9282;
66
+ --color-accent: var(--knob-accent);
67
+ --color-logo-ink: #13110e;
68
+ --color-action: #6fb3a9;
69
+ --color-on-action: #13110e;
70
+ --color-selection: #2a241e;
71
+ --color-ok: #9cbc6a;
72
+ --color-warn: #d9824a;
73
+ --color-danger: #df6b55;
74
+ --color-info: #86a9c4;
75
+ --color-focus: #6fb3a9;
76
+ }
77
+
78
+ /* ---- Printed manual (light, warm paper) --------------------------------- */
79
+ :root[data-theme="manual"] {
80
+ color-scheme: light;
81
+ --color-bg: #eee6d6;
82
+ --color-surface: #f7f1e6;
83
+ --color-surface-raised: #fdfaf3;
84
+ --color-border: #cbbfa8;
85
+ --color-border-strong: #a3967f;
86
+ --color-text: #2a241d;
87
+ --color-text-muted: #685e50;
88
+ --color-accent: var(--knob-accent-manual);
89
+ --color-logo-ink: #eee6d6;
90
+ --color-action: #2d6a63;
91
+ --color-on-action: #ffffff;
92
+ --color-selection: #e2d8c4;
93
+ --color-ok: #556b2f;
94
+ --color-warn: #8f5a00;
95
+ --color-danger: #a8402d;
96
+ --color-info: #3d6684;
97
+ --color-focus: #2d6a63;
98
+ }
99
+
100
+ @media (prefers-color-scheme: light) {
101
+ :root:not([data-theme]) {
102
+ color-scheme: light;
103
+ --color-bg: #eee6d6;
104
+ --color-surface: #f7f1e6;
105
+ --color-surface-raised: #fdfaf3;
106
+ --color-border: #cbbfa8;
107
+ --color-border-strong: #a3967f;
108
+ --color-text: #2a241d;
109
+ --color-text-muted: #685e50;
110
+ --color-accent: var(--knob-accent-manual);
111
+ --color-logo-ink: #eee6d6;
112
+ --color-action: #2d6a63;
113
+ --color-on-action: #ffffff;
114
+ --color-selection: #e2d8c4;
115
+ --color-ok: #556b2f;
116
+ --color-warn: #8f5a00;
117
+ --color-danger: #a8402d;
118
+ --color-info: #3d6684;
119
+ --color-focus: #2d6a63;
120
+ }
121
+ }
122
+
123
+ @media (prefers-reduced-motion: reduce) {
124
+ :root {
125
+ --motion-fast: 0ms;
126
+ --motion-base: 0ms;
127
+ --cursor-blink: none;
128
+ }
129
+ }
@@ -0,0 +1,61 @@
1
+ const CODES = new Set(['browser-network', 'browser-timeout', 'browser-response', 'browser-error', 'browser-rejection', 'browser-stream', 'browser-validation']);
2
+ const ACTIONS = new Set(['setup', 'history', 'delete', 'clear', 'clear-finished', 'preflight', 'create', 'events', 'continue', 'finish', 'stop', 'hold', 'resume', 'export', 'synthesize', 'accept', 'pools', 'terms', 'diagnostics', 'pick-folder', 'unknown']);
3
+ const KEY = 'roundtable.diagnostics';
4
+
5
+ export function requestAction(url, write = false) {
6
+ if (url === '/api/seats') return 'setup';
7
+ if (url === '/api/debates') return write ? 'create' : 'history';
8
+ if (url === '/api/preflight') return 'preflight';
9
+ if (url.startsWith('/api/diagnostics')) return 'diagnostics';
10
+ if (url.startsWith('/api/pools/')) return 'pools';
11
+ if (url.startsWith('/api/terms/')) return 'terms';
12
+ const action = url.split('/').at(-1);
13
+ return ACTIONS.has(action) ? action : 'unknown';
14
+ }
15
+
16
+ function safe(event) {
17
+ if (!/^RT-[a-f0-9]{16}$/.test(event?.id) || !CODES.has(event?.code) || !ACTIONS.has(event?.action)) return null;
18
+ const out = { id: event.id, code: event.code, action: event.action };
19
+ if (['lens', 'moodText'].includes(event.field)) out.field = event.field;
20
+ for (const [key, max] of Object.entries({ seatIndex: 7, status: 599, line: 1_000_000, column: 1_000_000 })) {
21
+ if (Number.isInteger(event[key]) && event[key] >= 0 && event[key] <= max) out[key] = event[key];
22
+ }
23
+ return out;
24
+ }
25
+
26
+ // Diagnostic writes alone may retry. Review actions never do. Keep a small per-tab queue
27
+ // through reloads so an offline server or a rotated launch token does not erase the evidence.
28
+ export function createBrowserDiagnostics({ send, storage, onPending = () => {} }) {
29
+ let queue = [], sending = null;
30
+ try { const old = JSON.parse(storage?.getItem(KEY) || '[]'); if (Array.isArray(old)) queue = old.map(safe).filter(Boolean).slice(-20); } catch { /* storage disabled */ }
31
+ const persist = () => { try { storage?.setItem(KEY, JSON.stringify(queue)); } catch { /* keep the in-memory copy */ } };
32
+ const pending = () => queue.map((e) => ({ ...e }));
33
+ async function flush() {
34
+ if (sending) return sending;
35
+ if (!queue.length) return;
36
+ const batch = pending();
37
+ sending = (async () => {
38
+ try {
39
+ const reply = await send(batch);
40
+ if (Array.isArray(reply?.saved)) {
41
+ const sent = new Set(batch.map((e) => e.id));
42
+ const saved = new Set(reply.saved.filter((id) => sent.has(id)));
43
+ queue = queue.filter((e) => !saved.has(e.id));
44
+ persist();
45
+ }
46
+ } catch { /* a diagnostics failure must not recursively log itself */ }
47
+ onPending(queue.length);
48
+ })();
49
+ try { await sending; } finally { sending = null; }
50
+ }
51
+ function report(code, action = 'unknown', details = {}) {
52
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
53
+ const id = `RT-${[...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')}`;
54
+ const entry = safe({ ...details, id, code, action });
55
+ if (!entry) return null;
56
+ queue.push(entry); queue = queue.slice(-20); persist();
57
+ void flush();
58
+ return id;
59
+ }
60
+ return { report, flush, pending };
61
+ }
package/src/ui/help.js ADDED
@@ -0,0 +1,122 @@
1
+ export const MOOD_HELP = {
2
+ grill: 'Challenge assumptions and ask for evidence before agreeing.',
3
+ steelman: 'Make the strongest case for each option before judging it.',
4
+ builder: 'Focus on practical steps, effort and what could break.',
5
+ contrarian: 'Explore the less popular position and defend it with evidence.',
6
+ custom: 'Write your own sentence describing how this seat should argue.',
7
+ };
8
+ const HELP = {
9
+ lens: { title: 'Lens', text: 'The perspective this seat focuses on, such as risk, evidence or long-term effects. Give each seat a different lens so the review covers more than one angle.' },
10
+ mood: { title: 'Mood', text: 'How this seat argues. Its lens sets the topic of attention; its mood sets the approach.' },
11
+ critique: { title: 'Critique rounds', text: 'After the first independent replies, each seat reads and responds to the others. Set 0 for initial replies and a verdict only; 1 allows one extra exchange. The review can finish early. Extra rounds use more quota.' },
12
+ auto: { title: 'Review window', text: 'A brief chance to correct assumptions between rounds, including the first blind round. With no question, the table continues after this many seconds. Start writing a note to request more time.' },
13
+ questionWait: { title: 'Answer window', text: 'How long the table waits when a seat asks you a question. If nobody answers, it continues with the missing facts still unknown. Writing an answer or choosing More time extends this window, within the overall time limit.' },
14
+ wall: { title: 'Wall clock', text: 'The total time limit, including pauses while you read or type. When it runs out, the review stops. Give yourself enough time to respond.' },
15
+ unattended: { title: 'Unattended', text: 'Off: the table gives you timed windows to read and answer questions, then continues automatically. On: it never waits; the seats state missing facts as assumptions. Use it when nobody can answer, whether the review is launched from the browser or an agent.' },
16
+ memory: { title: 'Memory', text: 'Stateless starts a fresh call each round with the relevant notes and replies. Resume continues a growing conversation and may use more tokens. Stateless is a good starting point.' },
17
+ isolation: { title: 'Limited isolation', text: 'Antigravity cannot be fully sealed off: it still loads your shared Antigravity customizations and discovers your MCP servers, and its web search cannot be switched off. Roundtable denies its file, command, URL and MCP tools and stops the call at the first tool it reports. Choosing Antigravity on this card accepts that; choose another provider if it does not suit you.' },
18
+ searcher: { title: 'Searcher', text: 'Optional. One extra call before the first round, on the provider you pick: it searches the web for your query and saves up to eight cited sources that every seat and the chairman then read. It sees only the query, never your notes, and never argues or votes.' },
19
+ searchLimits: { title: 'Search limits', text: 'Searches: how many the CLI may report before the call is stopped. One search can cover several queries or pages, and the one over the limit may already have started. Time: when the searcher is stopped. Sources come from the provider and are not checked by Roundtable.' },
20
+ effort: { title: 'Effort', text: 'How much reasoning the model should use. Higher effort can take longer and use more quota. No filled squares means the provider’s default.' },
21
+ };
22
+
23
+ // Full text is present from the start for selection and assistive technology. Only its visual
24
+ // presentation changes; this does not pretend that a completed provider reply is still running.
25
+ export function revealText(root, { startedAt = performance.now(), duration = 700 } = {}) {
26
+ const win = root.ownerDocument.defaultView;
27
+ if (win.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
28
+ const elapsed = Math.max(0, performance.now() - startedAt);
29
+ if (elapsed >= duration + 260) return;
30
+ const walker = root.ownerDocument.createTreeWalker(root, win.NodeFilter.SHOW_TEXT);
31
+ const nodes = [];
32
+ while (walker.nextNode()) {
33
+ const node = walker.currentNode;
34
+ if (node.textContent.trim() && !node.parentElement.closest('.label, .sr-only, .text-reveal-word, button, select, pre, code, .verdict-meta')) nodes.push(node);
35
+ }
36
+ const total = nodes.reduce((n, node) => n + node.textContent.split(/\s+/u).filter(Boolean).length, 0);
37
+ let i = 0;
38
+ for (const node of nodes) {
39
+ const fragment = root.ownerDocument.createDocumentFragment();
40
+ for (const word of node.textContent.split(/(\s+)/u)) {
41
+ if (!word || /^\s+$/u.test(word)) { fragment.append(word); continue; }
42
+ const span = root.ownerDocument.createElement('span');
43
+ span.className = 'text-reveal-word'; span.textContent = word;
44
+ span.style.animationDelay = `${Math.round(i++ * duration / Math.max(1, total) - elapsed)}ms`;
45
+ fragment.append(span);
46
+ }
47
+ node.replaceWith(fragment);
48
+ }
49
+ }
50
+
51
+ export function installHelp() {
52
+ for (const [key, value] of Object.entries(HELP)) {
53
+ const description = document.createElement('span');
54
+ description.id = `help-${key}`; description.className = 'sr-only'; description.textContent = value.text;
55
+ document.body.append(description);
56
+ }
57
+ const bubble = document.createElement('div');
58
+ bubble.className = 'help-popover'; bubble.setAttribute('role', 'tooltip');
59
+ bubble.setAttribute('aria-hidden', 'true'); // controls describe the complete, non-animated text above
60
+ document.body.append(bubble);
61
+ let active = null, openTimer = null, closeTimer = null;
62
+ function close() {
63
+ clearTimeout(openTimer); clearTimeout(closeTimer);
64
+ bubble.classList.remove('is-open'); active = null;
65
+ }
66
+ function position() {
67
+ if (!active?.isConnected) { close(); return; }
68
+ const anchor = active.getBoundingClientRect(), rect = bubble.getBoundingClientRect();
69
+ if (anchor.bottom < 0 || anchor.top > innerHeight) { close(); return; }
70
+ const left = Math.max(12, Math.min(anchor.left, innerWidth - rect.width - 12));
71
+ const below = anchor.bottom + 10;
72
+ const top = below + rect.height <= innerHeight - 12 ? below : Math.max(12, anchor.top - rect.height - 10);
73
+ bubble.style.left = `${left}px`; bubble.style.top = `${top}px`;
74
+ }
75
+ function show(target) {
76
+ clearTimeout(openTimer); clearTimeout(closeTimer);
77
+ const help = HELP[target?.dataset.help]; if (!help || !target.isConnected) return;
78
+ active = target;
79
+ const title = document.createElement('strong'), text = document.createElement('p');
80
+ title.textContent = help.title; text.textContent = help.text;
81
+ if (target.dataset.help === 'mood') {
82
+ const mood = target.closest('.seat')?.querySelector('.mood')?.value;
83
+ if (MOOD_HELP[mood]) text.textContent += ` ${mood === 'custom' ? 'Custom' : mood[0].toUpperCase() + mood.slice(1)}: ${MOOD_HELP[mood]}`;
84
+ }
85
+ bubble.replaceChildren(title, text); position();
86
+ bubble.classList.add('is-open'); revealText(text, { duration: 480 });
87
+ }
88
+ function schedule(target, delay = 900) {
89
+ if (!HELP[target?.dataset.help]) return;
90
+ clearTimeout(closeTimer);
91
+ if (active === target && bubble.classList.contains('is-open')) return;
92
+ clearTimeout(openTimer); openTimer = setTimeout(() => show(target), delay);
93
+ }
94
+ const leave = () => { clearTimeout(openTimer); closeTimer = setTimeout(() => { if (!active?.contains(document.activeElement)) close(); }, 160); };
95
+ document.addEventListener('pointerover', (e) => { const target = e.target.closest('[data-help]'); if (target) schedule(target); });
96
+ document.addEventListener('pointerout', (e) => {
97
+ const target = e.target.closest('[data-help]');
98
+ if (target && !target.contains(e.relatedTarget) && !bubble.contains(e.relatedTarget)) leave();
99
+ });
100
+ document.addEventListener('focusin', (e) => { if (e.target.matches('[data-help]')) schedule(e.target, 900); });
101
+ document.addEventListener('focusout', (e) => { if (e.target.matches('[data-help]')) leave(); });
102
+ document.addEventListener('click', (e) => {
103
+ const trigger = e.target.closest('[data-help]');
104
+ if (trigger) {
105
+ e.preventDefault(); // A label's forwarded input click would immediately dismiss its hint.
106
+ const target = trigger.closest('[data-help]');
107
+ if (active === target && bubble.classList.contains('is-open')) close(); else show(target);
108
+ } else if (!bubble.contains(e.target) && !e.target.closest('[data-help]')) close();
109
+ });
110
+ document.addEventListener('keydown', (e) => {
111
+ if (e.key === 'Escape') close();
112
+ else if (['Enter', ' '].includes(e.key) && e.target.matches('[data-help]')) {
113
+ e.preventDefault();
114
+ if (active === e.target && bubble.classList.contains('is-open')) close(); else show(e.target);
115
+ }
116
+ });
117
+ bubble.addEventListener('pointerenter', () => clearTimeout(closeTimer));
118
+ bubble.addEventListener('pointerleave', leave);
119
+ window.addEventListener('resize', () => { if (active) position(); });
120
+ document.addEventListener('scroll', () => { if (active) position(); }, true);
121
+ return { close };
122
+ }
@@ -0,0 +1,64 @@
1
+ // Header glyphs: 12x12 pixel icons for the controls that need no word. They share the avatars'
2
+ // drawing helper so a header icon, a seat avatar and the session gavel read as one family.
3
+ import { pixelRects } from '/ui/avatars.js';
4
+
5
+ const ART = {
6
+ sun: [
7
+ '......X.....',
8
+ '..X...X...X.',
9
+ '...X.....X..',
10
+ '.....XXX....',
11
+ '....XXXXX...',
12
+ '.XX.XXXXX.XX',
13
+ '....XXXXX...',
14
+ '.....XXX....',
15
+ '...X.....X..',
16
+ '..X...X...X.',
17
+ '......X.....',
18
+ '............',
19
+ ],
20
+ moon: [
21
+ '.....XXXXX..',
22
+ '...XXXXX....',
23
+ '..XXXX......',
24
+ '.XXXX.......',
25
+ '.XXXX.......',
26
+ '.XXXX.......',
27
+ '.XXXX.......',
28
+ '.XXXX.......',
29
+ '..XXXX......',
30
+ '...XXXXX....',
31
+ '.....XXXXX..',
32
+ '............',
33
+ ],
34
+ sound: [
35
+ '.....X......',
36
+ '....XX..X...',
37
+ '...XXX...X..',
38
+ 'XXXXXX.X..X.',
39
+ 'XXXXXX.X.X.X',
40
+ 'XXXXXX.X.X.X',
41
+ 'XXXXXX.X.X.X',
42
+ 'XXXXXX.X..X.',
43
+ '...XXX...X..',
44
+ '....XX..X...',
45
+ '.....X......',
46
+ '............',
47
+ ],
48
+ muted: [
49
+ '.....X......',
50
+ '....XX......',
51
+ '...XXX..X.X.',
52
+ 'XXXXXX...X..',
53
+ 'XXXXXX..X.X.',
54
+ 'XXXXXX......',
55
+ 'XXXXXX......',
56
+ 'XXXXXX......',
57
+ '...XXX......',
58
+ '....XX......',
59
+ '.....X......',
60
+ '............',
61
+ ],
62
+ };
63
+
64
+ export const icon = (name) => `<svg viewBox="0 0 12 12" shape-rendering="crispEdges" fill="currentColor" aria-hidden="true" focusable="false">${pixelRects(ART[name])}</svg>`;