tinywebui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +586 -0
  3. package/bin/tinywebui.js +105 -0
  4. package/package.json +49 -0
  5. package/public/app.js +81 -0
  6. package/public/chat/attachments.js +304 -0
  7. package/public/chat/chat.js +282 -0
  8. package/public/chat/composer.js +289 -0
  9. package/public/chat/library.js +76 -0
  10. package/public/chat/outline.js +39 -0
  11. package/public/chat/queue.js +124 -0
  12. package/public/chat/sidebar.js +485 -0
  13. package/public/chat/stream.js +162 -0
  14. package/public/chat/tasks.js +36 -0
  15. package/public/chat/transcript.js +691 -0
  16. package/public/core/access.js +42 -0
  17. package/public/core/api.js +27 -0
  18. package/public/core/dom.js +13 -0
  19. package/public/core/id.js +7 -0
  20. package/public/core/md.js +131 -0
  21. package/public/core/state.js +31 -0
  22. package/public/favicon.svg +9 -0
  23. package/public/index.html +261 -0
  24. package/public/panels/admin.js +199 -0
  25. package/public/panels/automations.js +464 -0
  26. package/public/panels/panels.js +52 -0
  27. package/public/panels/settings.js +365 -0
  28. package/public/panels/usage.js +474 -0
  29. package/public/shared/attachment_note.js +23 -0
  30. package/public/shared/events.js +27 -0
  31. package/public/shared/features.js +21 -0
  32. package/public/shared/origins.js +8 -0
  33. package/public/shared/usage.js +59 -0
  34. package/public/styles.css +1455 -0
  35. package/public/themes/README.md +178 -0
  36. package/public/themes/cyber-grid/cyber-grid.css +59 -0
  37. package/public/themes/drivers/scene-kit.js +422 -0
  38. package/public/themes/fall-fairy/fall-fairy.css +221 -0
  39. package/public/themes/fall-fairy/fall-fairy.js +859 -0
  40. package/public/themes/manifest.json +6 -0
  41. package/public/themes/theme-loader.js +123 -0
  42. package/src/access/auth.js +228 -0
  43. package/src/access/auth_gate.js +67 -0
  44. package/src/access/policy.js +180 -0
  45. package/src/audit.js +7 -0
  46. package/src/automations/automation.js +136 -0
  47. package/src/automations/scheduler.js +147 -0
  48. package/src/chat/compact.js +242 -0
  49. package/src/chat/llm.js +448 -0
  50. package/src/chat/runs.js +180 -0
  51. package/src/chat/tool_executor.js +239 -0
  52. package/src/cli.js +478 -0
  53. package/src/config/approval.js +54 -0
  54. package/src/config/config.js +479 -0
  55. package/src/config/models.js +112 -0
  56. package/src/files/documents.js +91 -0
  57. package/src/files/images.js +62 -0
  58. package/src/harness/agent.js +115 -0
  59. package/src/harness/attribution.js +75 -0
  60. package/src/harness/checkpoint.js +96 -0
  61. package/src/harness/history.js +40 -0
  62. package/src/harness/prompt.js +93 -0
  63. package/src/harness/provider.js +430 -0
  64. package/src/http.js +95 -0
  65. package/src/log.js +24 -0
  66. package/src/mcp.js +405 -0
  67. package/src/retrieval/embedding.js +298 -0
  68. package/src/retrieval/retrieval.js +250 -0
  69. package/src/routes/admin.js +112 -0
  70. package/src/routes/auth.js +97 -0
  71. package/src/routes/automations.js +92 -0
  72. package/src/routes/chats.js +232 -0
  73. package/src/routes/documents.js +133 -0
  74. package/src/routes/library.js +51 -0
  75. package/src/routes/mcp.js +59 -0
  76. package/src/routes/settings.js +101 -0
  77. package/src/server.js +397 -0
  78. package/src/set-password.js +32 -0
  79. package/src/store/automations.js +119 -0
  80. package/src/store/chats.js +254 -0
  81. package/src/store/documents.js +256 -0
  82. package/src/store/embeddings.js +67 -0
  83. package/src/store/index.js +90 -0
  84. package/src/store/messages.js +93 -0
  85. package/src/store/schema.js +480 -0
  86. package/src/store/scope.js +43 -0
  87. package/src/store/search.js +56 -0
  88. package/src/store/turns.js +78 -0
  89. package/src/store/usage.js +327 -0
  90. package/src/store/users.js +102 -0
  91. package/src/store/wire.js +55 -0
  92. package/src/tools/ask_tool.js +93 -0
  93. package/src/tools/chat_search_tool.js +63 -0
  94. package/src/tools/context_tool.js +183 -0
  95. package/src/tools/document_tool.js +102 -0
  96. package/src/tools/task_tool.js +63 -0
@@ -0,0 +1,178 @@
1
+ # Themes
2
+
3
+ A theme is a folder drop, not a patch. Nothing in `app.js` or `styles.css`
4
+ knows any theme exists, and adding one never changes either file.
5
+
6
+ ```
7
+ public/themes/
8
+ manifest.json the list. one line per theme
9
+ theme-loader.js the only bridge to the app
10
+ drivers/
11
+ scene-kit.js the runtime a moving theme is built on
12
+ fall-fairy/
13
+ fall-fairy.css a theme
14
+ fall-fairy.js ...and its scenery
15
+ ```
16
+
17
+ ## The smallest theme
18
+
19
+ Drop a folder with a CSS file in it and add a line to `manifest.json`:
20
+
21
+ ```json
22
+ { "id": "dusk", "name": "Dusk", "file": "dusk/dusk.css" }
23
+ ```
24
+
25
+ The file reassigns the variables `styles.css` already reads. That is the whole
26
+ API:
27
+
28
+ ```css
29
+ :root[data-theme="dusk"] {
30
+ --bg: #12131a; --panel: #1a1c26; --line: #2e3142; --fg: #e9eaf2;
31
+ --muted: #a3a7bd; --faint: #878ba4; --accent: #7c6cff; --tool: #4fd6b8; --err: #ff8093;
32
+ --mono: "Space Mono", ui-monospace, monospace;
33
+ }
34
+ ```
35
+
36
+ Anything past that is yours, on one condition: **scope every rule under
37
+ `[data-theme="<id>"]`.** A theme that leaks outside its own attribute is a
38
+ fork of the stylesheet wearing a theme's clothes.
39
+
40
+ The picker in settings → appearance is built from the manifest, and the choice
41
+ is remembered in `localStorage`. Removing a theme's files is a complete
42
+ uninstall.
43
+
44
+ ## A theme that moves
45
+
46
+ Name a module and it is mounted while your theme is selected, unmounted when it
47
+ is not:
48
+
49
+ ```json
50
+ { "id": "dusk", "name": "Dusk", "file": "dusk/dusk.css", "js": "dusk/dusk.js" }
51
+ ```
52
+
53
+ ```js
54
+ export function mount() {
55
+ // ...
56
+ return () => { /* leave no trace */ };
57
+ }
58
+ ```
59
+
60
+ `mount()` returns its own teardown. If the module throws or fails to load, the
61
+ loader swallows it and your theme is still a theme — the CSS half always stands
62
+ on its own.
63
+
64
+ ## scene-kit
65
+
66
+ Don't write that module by hand. Every moving theme needs the same six
67
+ unglamorous parts — canvases at the right depth, a device-pixel-correct resize,
68
+ a frame loop that pauses itself when the tab is hidden, the reduced-motion
69
+ path, the app's current state, and a teardown that leaves nothing behind.
70
+ `drivers/scene-kit.js` is those parts, so your theme is only ever its own
71
+ artwork. It is a driver, not a theme: nothing about it is specific to any one
72
+ skin, and a new theme imports it rather than copying it.
73
+
74
+ ```js
75
+ import { createScene } from '../drivers/scene-kit.js';
76
+
77
+ export function mount() {
78
+ let dots = [];
79
+ return createScene({
80
+ layers: [{ name: 'bg', z: -1 }, { name: 'fg', z: 5 }],
81
+ vars: { glow: '--dusk-glow', ink: ['--dusk-a', '--dusk-b'] },
82
+ fallbacks: { glow: '#ffd98a', ink: ['#7c6cff', '#4fd6b8'] },
83
+
84
+ setup(ctx) { dots = seed(ctx.w, ctx.h); }, // mount, resize, palette change
85
+ update(ctx, dt) { for (const d of dots) d.y += d.v * dt; },
86
+ draw(ctx) { ctx.clear('fg'); /* ... */ },
87
+ });
88
+ }
89
+ ```
90
+
91
+ `createScene` returns the unmount function, so `return createScene({...})` is
92
+ the whole of `mount()`.
93
+
94
+ ### What `ctx` gives you
95
+
96
+ | | |
97
+ |---|---|
98
+ | `ctx.w` `ctx.h` | viewport, in CSS pixels |
99
+ | `ctx.t` `ctx.dt` | seconds since mount, seconds this frame |
100
+ | `ctx.layer(name)` | that layer's 2d context, already DPR-scaled |
101
+ | `ctx.clear(name)` | clear a layer |
102
+ | `ctx.pal` | your `vars`, resolved against the live palette |
103
+ | `fallbacks` | a value per `vars` key, used when a property reads back empty |
104
+ | `ctx.mode` | `empty` · `idle` · `busy` · `done` |
105
+ | `ctx.modeAge` | seconds in the current mode |
106
+ | `ctx.pointer` | `{ x, y, inside, vx, vy, speed, downFor }` |
107
+ | `ctx.anchor(what)` | `transcript` · `scroller` · `composer` · `viewport` → DOMRect\|null |
108
+ | `ctx.words(n)` | up to n perchable word handles in the transcript |
109
+ | `ctx.reduced` | true when the viewer asked for less motion |
110
+ | `ctx.on(evt, fn)` | `mode` · `pointerdown` · `resize` |
111
+
112
+ ### The modes
113
+
114
+ All four are read from markup the app already produces for its own reasons. No
115
+ hook was added anywhere for a theme to read, and a theme never queries the app
116
+ itself — that coupling lives in `scene-kit.js`, once, for every theme at once.
117
+
118
+ | mode | what the app is doing | where it comes from |
119
+ |---|---|---|
120
+ | `empty` | a fresh chat, nothing asked yet | `#wrap` has no children |
121
+ | `busy` | a turn is running | `#send` carries `.stop` |
122
+ | `done` | an answer just landed (transient, ~2.4s) | `.stop` came off |
123
+ | `idle` | a transcript, sitting there | anything else |
124
+
125
+ ### Words
126
+
127
+ `ctx.words(n)` returns handles onto real text in the transcript. Each keeps a
128
+ live `Range`, so ask for the box when you need it rather than caching it:
129
+
130
+ ```js
131
+ const w = pick(ctx.words(60));
132
+ const r = w.rect(); // DOMRect, or null if edited away or scrolled out
133
+ ```
134
+
135
+ Because the box is read back per frame, anything you park on a word rides it as
136
+ the transcript scrolls, and lets go by itself when the word is re-rendered. The
137
+ harvest is the expensive half, so it is sampled, capped, cached for a beat, and
138
+ thrown away whenever the transcript changes or scrolls.
139
+
140
+ ## House rules
141
+
142
+ These are not the loader's to enforce. They are what keeps a theme a theme.
143
+
144
+ - **Decorative only.** Layers are `pointer-events: none`. Nothing a theme draws
145
+ may be the only way to see or do something.
146
+ - **Read, never write.** A theme reads state; it never dispatches an event,
147
+ sets a class on app markup, or touches the transcript.
148
+ - **Stay under the app's ceiling.** Settings sits at `z-index: 10`, the mobile
149
+ drawer at `19`/`20`. A foreground layer above those covers the UI.
150
+ - **Text first.** If your scene sits behind the transcript, put a scrim on the
151
+ reading surfaces. `fall-fairy.css` does this with `color-mix` and a blur; the
152
+ forest is never allowed behind a raw paragraph.
153
+ - **Honour `prefers-reduced-motion`.** The kit already does — it draws one still
154
+ frame and keeps the observers running, so state still changes without
155
+ animating. Don't defeat it.
156
+ - **Offline is the default.** TinyWebUI runs on a laptop with no network. A
157
+ theme reaching for a CDN needs a fallback that is genuinely fine
158
+ (`fall-fairy.css` ships full local font stacks behind its `@import`).
159
+ - **Clean up.** Whatever `mount()` added, its teardown removes. The kit handles
160
+ this for everything it created; anything you add yourself is yours to undo.
161
+
162
+ ## fall-fairy
163
+
164
+ The worked example, and the stress test for all of the above: a procedural
165
+ autumn forest on two canvases, a flock with flight behaviour, cursor reactions,
166
+ and fairies that land on the words of your transcript and read them.
167
+
168
+ - `empty` — she waits in the clearing, on a pumpkin, chin on hand
169
+ - `busy` — the flock goes out with lanterns, quartering the window
170
+ - `done` — they come home to the answer and light it up
171
+ - the cursor — a still one gets orbited, a fast one scatters them, and moving
172
+ through falling leaves shoves them aside
173
+ - perching — a fairy picks a word, stands on it, dusts it with light, and rides
174
+ it while you scroll
175
+
176
+ It also re-voices the app: Grandstander for headings, Quicksand for everything
177
+ you read, Space Mono for the machine's own voice, and Caveat for the one
178
+ handwritten line in the clearing.
@@ -0,0 +1,59 @@
1
+ /*
2
+ * Cyber Grid — minimalist, utilitarian cyberpunk skin.
3
+ * Near-black terminal surface, one hot accent, hard corners, monospace
4
+ * everywhere. No scenery module: the palette and a few sharp-edge overrides
5
+ * are the whole theme.
6
+ */
7
+
8
+ :root[data-theme="cyber-grid"] {
9
+ --bg: #08090a;
10
+ --panel: #101214;
11
+ --line: #262b2e;
12
+ --fg: #d8fdf2;
13
+ --muted: #7a8a87;
14
+ --faint: #58635f;
15
+ --accent: #ff1f6d;
16
+ --tool: #00e5c7;
17
+ --err: #ff4d4d;
18
+ --mono: "JetBrains Mono", "Space Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
19
+ }
20
+
21
+ /* Hard, square edges everywhere — no rounded utilitarian hardware. */
22
+ :root[data-theme="cyber-grid"] * {
23
+ border-radius: 0 !important;
24
+ }
25
+
26
+ /* Everything reads as terminal output, not prose. */
27
+ :root[data-theme="cyber-grid"] body {
28
+ font-family: var(--mono);
29
+ }
30
+
31
+ :root[data-theme="cyber-grid"] ::selection {
32
+ background: var(--accent);
33
+ color: #08090a;
34
+ }
35
+
36
+ /* Focus ring reads as a targeting reticle, not a soft glow. */
37
+ :root[data-theme="cyber-grid"] :focus-visible {
38
+ outline: 2px solid var(--accent);
39
+ outline-offset: 1px;
40
+ }
41
+
42
+ /* Panels and inputs get a hairline-only look; no shadows, no gradients. */
43
+ :root[data-theme="cyber-grid"] .tool-group,
44
+ :root[data-theme="cyber-grid"] .steps,
45
+ :root[data-theme="cyber-grid"] pre,
46
+ :root[data-theme="cyber-grid"] textarea,
47
+ :root[data-theme="cyber-grid"] input {
48
+ background: var(--panel);
49
+ border-color: var(--line);
50
+ }
51
+
52
+ /* Accent stays functional: status dots, tool names, links — never decorative. */
53
+ :root[data-theme="cyber-grid"] a {
54
+ color: var(--tool);
55
+ }
56
+
57
+ :root[data-theme="cyber-grid"] .tool-group .dot {
58
+ background: var(--tool);
59
+ }
@@ -0,0 +1,422 @@
1
+ /* ============================================================================
2
+ scene-kit — the runtime behind a scenery skin.
3
+
4
+ A skin that only recolours things needs nothing but CSS. A skin that wants
5
+ to put something moving on the page needs the same six unglamorous parts
6
+ every time: canvases at the right depth, a device-pixel-correct resize, a
7
+ frame loop that pauses itself, the reduced-motion path, the app's current
8
+ state, and a teardown that leaves no trace. This module is those parts, so a
9
+ skin is only ever its own artwork.
10
+
11
+ It is also the single place that knows TinyWebUI's markup. A skin never
12
+ queries the app: it reads ctx.mode, ctx.anchor() and ctx.words(), and if the
13
+ app's DOM changes one day, it changes here, once, for every skin at once.
14
+
15
+ Contract, in full:
16
+
17
+ import { createScene } from '../drivers/scene-kit.js';
18
+
19
+ export function mount() {
20
+ return createScene({
21
+ layers: [{ name: 'bg', z: -1 }, { name: 'fg', z: 5 }],
22
+ vars: { glow: '--my-glow' }, // read from CSS custom props
23
+ setup(ctx) { }, // mount, resize, theme change
24
+ update(ctx, dt) { }, // once per frame
25
+ draw(ctx) { }, // once per frame, after update
26
+ });
27
+ }
28
+
29
+ createScene returns the unmount function the loader expects.
30
+
31
+ What ctx carries:
32
+ ctx.w, ctx.h viewport, in css pixels
33
+ ctx.t, ctx.dt seconds since mount, seconds this frame
34
+ ctx.layer(name) that layer's 2d context, already dpr-scaled
35
+ ctx.clear(name) clear a layer
36
+ ctx.pal `vars` resolved against the current palette
37
+ ctx.mode 'empty' | 'idle' | 'busy' | 'done'
38
+ ctx.modeAge seconds in the current mode
39
+ ctx.pointer { x, y, inside, vx, vy, speed, downFor }
40
+ ctx.anchor(what) 'transcript' | 'composer' | 'viewport' -> DOMRect|null
41
+ ctx.words(n) up to n perchable word handles in the transcript
42
+ ctx.reduced true when the viewer asked for less motion
43
+ ctx.on(evt, fn) 'mode' | 'pointerdown' | 'resize'
44
+
45
+ The modes, and where they come from — all of it markup the app already
46
+ produces for its own reasons, none of it a hook added for a skin:
47
+
48
+ empty #wrap has no children a fresh chat, nothing asked yet
49
+ busy #send carries .stop a turn is running
50
+ done .stop just came off transient, ~2.4s, then idle
51
+ idle anything else a transcript, sitting there
52
+ ========================================================================== */
53
+
54
+ const DONE_FOR = 2.4;
55
+
56
+ export function createScene(spec) {
57
+ const {
58
+ layers = [{ name: 'fg', z: 5 }],
59
+ vars = {},
60
+ fallbacks = {},
61
+ setup = () => {},
62
+ update = () => {},
63
+ draw = () => {},
64
+ } = spec;
65
+
66
+ const reducedQ = matchMedia('(prefers-reduced-motion: reduce)');
67
+ const darkQ = matchMedia('(prefers-color-scheme: dark)');
68
+
69
+ const canvases = new Map();
70
+ for (const l of layers) {
71
+ const c = document.createElement('canvas');
72
+ c.className = 'scene-layer';
73
+ c.dataset.layer = l.name;
74
+ c.setAttribute('aria-hidden', 'true');
75
+ // Inline, so a skin needs no CSS at all to get its layers placed right,
76
+ // and so the app's own stacking (settings 10, drawer 19/20) is respected
77
+ // by default rather than by each skin remembering to.
78
+ c.style.cssText =
79
+ `position:fixed;inset:0;width:100%;height:100%;pointer-events:none;` +
80
+ `user-select:none;z-index:${l.z ?? 0}`;
81
+ document.body.appendChild(c);
82
+ canvases.set(l.name, { el: c, ctx: c.getContext('2d') });
83
+ }
84
+
85
+ const listeners = { mode: [], pointerdown: [], resize: [] };
86
+ const emit = (evt, arg) => {
87
+ for (const fn of listeners[evt] || []) {
88
+ try { fn(ctx, arg); } catch { /* one bad handler must not kill the loop */ }
89
+ }
90
+ };
91
+
92
+ const pointer = { x: -1e4, y: -1e4, inside: false, vx: 0, vy: 0, speed: 0, downFor: 0 };
93
+
94
+ const ctx = {
95
+ w: 0, h: 0, t: 0, dt: 0,
96
+ pal: {},
97
+ mode: 'idle',
98
+ modeAge: 0,
99
+ pointer,
100
+ reduced: reducedQ.matches,
101
+ layer: (name) => canvases.get(name)?.ctx,
102
+ clear(name) {
103
+ const c = canvases.get(name);
104
+ if (c) c.ctx.clearRect(0, 0, this.w, this.h);
105
+ },
106
+ anchor,
107
+ words,
108
+ on(evt, fn) { (listeners[evt] ||= []).push(fn); },
109
+ };
110
+
111
+ /* ========================================================= palette ==== */
112
+
113
+ /* Custom properties are read raw — the browser does not resolve them for us
114
+ — so a skin's scene tokens should be plain values, not expressions.
115
+
116
+ A property can also read back as the empty string, which is the trap this
117
+ guards: it happens whenever the skin's stylesheet has not applied yet, and
118
+ an empty string handed to addColorStop() or a gradient throws, killing the
119
+ frame without killing the loop. The result is two blank canvases and no
120
+ error anywhere. So every value has a fallback, and a palette that came
121
+ back incomplete is re-read on the next frame until it is whole. */
122
+
123
+ let paletteStale = false;
124
+
125
+ function resolveVar(cs, prop, fb) {
126
+ const v = cs.getPropertyValue(prop).trim();
127
+ if (v) return v;
128
+ paletteStale = true;
129
+ return fb || '#8a8a8a';
130
+ }
131
+
132
+ function readPalette() {
133
+ const cs = getComputedStyle(document.documentElement);
134
+ paletteStale = false;
135
+ const out = {};
136
+ for (const [key, prop] of Object.entries(vars)) {
137
+ const fb = fallbacks[key];
138
+ if (Array.isArray(prop)) {
139
+ out[key] = prop.map((p, i) => resolveVar(cs, p, Array.isArray(fb) ? fb[i] : fb));
140
+ } else {
141
+ out[key] = resolveVar(cs, prop, Array.isArray(fb) ? fb[0] : fb);
142
+ }
143
+ }
144
+ ctx.pal = out;
145
+ }
146
+
147
+ /* ========================================================== layout ==== */
148
+
149
+ let dpr = 1;
150
+
151
+ function resize() {
152
+ dpr = Math.min(Math.max(devicePixelRatio || 1, 1), 2);
153
+ ctx.w = Math.max(innerWidth, 1);
154
+ ctx.h = Math.max(innerHeight, 1);
155
+ for (const { el, ctx: c } of canvases.values()) {
156
+ el.width = Math.round(ctx.w * dpr);
157
+ el.height = Math.round(ctx.h * dpr);
158
+ c.setTransform(dpr, 0, 0, dpr, 0, 0);
159
+ }
160
+ readPalette();
161
+ setup(ctx);
162
+ emit('resize');
163
+ if (ctx.reduced) renderOnce();
164
+ }
165
+
166
+ /** The places on screen a skin might want to aim something at. A skin asks
167
+ * for a role, not a selector, so this stays the only file coupled to the
168
+ * app's ids. */
169
+ function anchor(what) {
170
+ const el =
171
+ what === 'transcript' ? document.getElementById('wrap') :
172
+ what === 'scroller' ? document.getElementById('log') :
173
+ what === 'composer' ? document.getElementById('input') :
174
+ what === 'viewport' ? document.documentElement : null;
175
+ if (!el) return null;
176
+ const r = el.getBoundingClientRect();
177
+ return r.width > 0 || r.height > 0 ? r : null;
178
+ }
179
+
180
+ /* =========================================================== words ==== */
181
+
182
+ /* Perchable text. Each handle keeps a live Range, so a word's box is read
183
+ back at the moment it is needed rather than cached: scrolling the
184
+ transcript therefore carries whatever is sitting on that word along with
185
+ it, for free, and a word that is deleted, re-rendered or scrolled out of
186
+ the log simply stops having a box. The harvest itself is the expensive
187
+ half, so it is capped and cached for a beat. */
188
+
189
+ let wordCache = [];
190
+ let wordsAt = -1e4;
191
+
192
+ function harvest(limit) {
193
+ const root = document.getElementById('wrap');
194
+ const scroller = document.getElementById('log');
195
+ if (!root || !scroller) return [];
196
+ const clip = scroller.getBoundingClientRect();
197
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
198
+ const out = [];
199
+ let node;
200
+ while ((node = walker.nextNode())) {
201
+ if (out.length >= limit) break;
202
+ const text = node.nodeValue;
203
+ if (!text || text.length > 4000 || !/\S/.test(text)) continue;
204
+ // A quick reject before touching layout: a text node whose own parent is
205
+ // off screen has no word worth measuring.
206
+ const parent = node.parentElement;
207
+ if (!parent) continue;
208
+ const pr = parent.getBoundingClientRect();
209
+ if (pr.bottom < clip.top || pr.top > clip.bottom || pr.width < 8) continue;
210
+
211
+ const re = /[^\s]{4,}/g;
212
+ let m;
213
+ while ((m = re.exec(text)) && out.length < limit) {
214
+ // Sampling: on a long answer every word is a candidate, and a fairy
215
+ // only ever needs a few dozen to choose between.
216
+ if (Math.random() > 0.35) continue;
217
+ const range = document.createRange();
218
+ range.setStart(node, m.index);
219
+ range.setEnd(node, m.index + m[0].length);
220
+ const r = range.getBoundingClientRect();
221
+ if (r.width < 18 || r.height < 8) continue;
222
+ if (r.top < clip.top + 4 || r.bottom > clip.bottom - 4) continue;
223
+ out.push(makeWord(range, clip));
224
+ }
225
+ }
226
+ return out;
227
+ }
228
+
229
+ function makeWord(range, clip) {
230
+ return {
231
+ range,
232
+ /** The word's box right now, or null if it is gone or scrolled away. */
233
+ rect() {
234
+ const n = range.startContainer;
235
+ if (!n || !n.isConnected) return null;
236
+ let r;
237
+ try { r = range.getBoundingClientRect(); } catch { return null; }
238
+ if (!r || r.width < 2) return null;
239
+ const c = document.getElementById('log')?.getBoundingClientRect() || clip;
240
+ if (r.bottom < c.top + 2 || r.top > c.bottom - 2) return null;
241
+ return r;
242
+ },
243
+ };
244
+ }
245
+
246
+ function words(limit = 60) {
247
+ if (ctx.t - wordsAt > 1.2 || !wordCache.length) {
248
+ wordCache = harvest(limit);
249
+ wordsAt = ctx.t;
250
+ }
251
+ return wordCache;
252
+ }
253
+
254
+ /* ============================================================ mode ==== */
255
+
256
+ function readMode() {
257
+ const send = document.getElementById('send');
258
+ const wrap = document.getElementById('wrap');
259
+ if (send && send.classList.contains('stop')) return 'busy';
260
+ if (wrap && wrap.childElementCount === 0) return 'empty';
261
+ return 'idle';
262
+ }
263
+
264
+ function syncMode() {
265
+ const next = readMode();
266
+ if (ctx.mode === 'done') {
267
+ if (next === 'busy') setMode('busy');
268
+ else if (ctx.modeAge > DONE_FOR) setMode('idle');
269
+ return;
270
+ }
271
+ if (next === ctx.mode) return;
272
+ setMode(ctx.mode === 'busy' && next !== 'busy' ? 'done' : next);
273
+ }
274
+
275
+ function setMode(m) {
276
+ const prev = ctx.mode;
277
+ ctx.mode = m;
278
+ ctx.modeAge = 0;
279
+ wordCache = [];
280
+ wordsAt = -1e4;
281
+ emit('mode', prev);
282
+ if (ctx.reduced) renderOnce();
283
+ }
284
+
285
+ /* ========================================================= pointer ==== */
286
+
287
+ let lastPointer = 0;
288
+
289
+ function onPointerMove(e) {
290
+ const now = performance.now();
291
+ const dt = Math.min(Math.max((now - lastPointer) / 1000, 1 / 240), 0.1);
292
+ lastPointer = now;
293
+ pointer.vx = (e.clientX - pointer.x) / dt;
294
+ pointer.vy = (e.clientY - pointer.y) / dt;
295
+ if (!pointer.inside) { pointer.vx = pointer.vy = 0; }
296
+ pointer.speed = Math.hypot(pointer.vx, pointer.vy);
297
+ pointer.x = e.clientX;
298
+ pointer.y = e.clientY;
299
+ pointer.inside = true;
300
+ }
301
+ function onPointerDown(e) {
302
+ pointer.x = e.clientX; pointer.y = e.clientY; pointer.inside = true;
303
+ pointer.downFor = 0.0001;
304
+ emit('pointerdown', { x: e.clientX, y: e.clientY });
305
+ if (ctx.reduced) renderOnce();
306
+ }
307
+ function onPointerUp() { pointer.downFor = 0; }
308
+ function onPointerOut(e) {
309
+ if (e.relatedTarget === null) {
310
+ pointer.inside = false;
311
+ pointer.vx = pointer.vy = pointer.speed = 0;
312
+ }
313
+ }
314
+
315
+ /* ============================================================= run ==== */
316
+
317
+ let raf = 0, last = 0;
318
+
319
+ function frame(now) {
320
+ raf = requestAnimationFrame(frame);
321
+ const dt = Math.min(Math.max((now - (last || now)) / 1000, 0), 0.05);
322
+ last = now;
323
+ ctx.t += dt;
324
+ ctx.dt = dt;
325
+ ctx.modeAge += dt;
326
+ // A palette read before the skin's stylesheet applied comes back empty and
327
+ // marks itself stale; re-read until it is whole, then leave it alone.
328
+ if (paletteStale) { readPalette(); if (!paletteStale) setup(ctx); }
329
+ syncMode();
330
+
331
+ // The pointer decays toward rest rather than stopping dead, so "is the
332
+ // cursor moving" is a question a skin can ask on any frame, not only on
333
+ // the ones a pointermove happened to land in.
334
+ pointer.vx -= pointer.vx * Math.min(dt * 6, 1);
335
+ pointer.vy -= pointer.vy * Math.min(dt * 6, 1);
336
+ pointer.speed = Math.hypot(pointer.vx, pointer.vy);
337
+ if (pointer.downFor) pointer.downFor += dt;
338
+
339
+ update(ctx, dt);
340
+ draw(ctx);
341
+ }
342
+
343
+ function start() {
344
+ if (raf || ctx.reduced || document.hidden) return;
345
+ last = 0;
346
+ raf = requestAnimationFrame(frame);
347
+ }
348
+ function stop() {
349
+ if (raf) cancelAnimationFrame(raf);
350
+ raf = 0;
351
+ }
352
+ /** The still-motion path: one frame, on demand, with no time passing. */
353
+ function renderOnce() {
354
+ ctx.dt = 0;
355
+ update(ctx, 0);
356
+ draw(ctx);
357
+ }
358
+
359
+ /* ========================================================== wiring ==== */
360
+
361
+ const onResize = () => resize();
362
+ const onVisible = () => (document.hidden ? stop() : start());
363
+ const onScheme = () => { readPalette(); resize(); };
364
+ const onMotion = () => {
365
+ ctx.reduced = reducedQ.matches;
366
+ stop();
367
+ if (ctx.reduced) renderOnce(); else start();
368
+ };
369
+
370
+ addEventListener('resize', onResize, { passive: true });
371
+ addEventListener('pointermove', onPointerMove, { passive: true });
372
+ addEventListener('pointerdown', onPointerDown, { passive: true });
373
+ addEventListener('pointerup', onPointerUp, { passive: true });
374
+ addEventListener('pointercancel', onPointerUp, { passive: true });
375
+ document.addEventListener('pointerout', onPointerOut, { passive: true });
376
+ document.addEventListener('visibilitychange', onVisible);
377
+ darkQ.addEventListener('change', onScheme);
378
+ reducedQ.addEventListener('change', onMotion);
379
+
380
+ // The transcript's own box moves when the sidebar is dragged, the drawer
381
+ // opens, or the composer grows a line — all of which change where a skin's
382
+ // anchors are without the window ever resizing.
383
+ const ro = new ResizeObserver(() => { emit('resize'); if (ctx.reduced) renderOnce(); });
384
+ for (const el of [document.getElementById('wrap'), document.querySelector('main')]) {
385
+ if (el) ro.observe(el);
386
+ }
387
+
388
+ // Word boxes go stale the instant the transcript changes or scrolls; the
389
+ // handles re-measure themselves, but the harvest has to be thrown away.
390
+ const invalidate = () => { wordCache = []; wordsAt = -1e4; };
391
+ const mo = new MutationObserver(() => { invalidate(); syncMode(); if (ctx.reduced) renderOnce(); });
392
+ const wrapEl = document.getElementById('wrap');
393
+ const sendEl = document.getElementById('send');
394
+ if (wrapEl) mo.observe(wrapEl, { childList: true, subtree: true, characterData: true });
395
+ if (sendEl) mo.observe(sendEl, { attributes: true, attributeFilter: ['class'] });
396
+ const scroller = document.getElementById('log');
397
+ if (scroller) scroller.addEventListener('scroll', invalidate, { passive: true });
398
+
399
+ resize();
400
+ ctx.mode = readMode();
401
+ if (ctx.reduced) renderOnce(); else start();
402
+
403
+ /* Teardown. The loader calls this when another skin is picked, and what it
404
+ leaves behind has to be indistinguishable from never having run. */
405
+ return () => {
406
+ stop();
407
+ removeEventListener('resize', onResize);
408
+ removeEventListener('pointermove', onPointerMove);
409
+ removeEventListener('pointerdown', onPointerDown);
410
+ removeEventListener('pointerup', onPointerUp);
411
+ removeEventListener('pointercancel', onPointerUp);
412
+ document.removeEventListener('pointerout', onPointerOut);
413
+ document.removeEventListener('visibilitychange', onVisible);
414
+ darkQ.removeEventListener('change', onScheme);
415
+ reducedQ.removeEventListener('change', onMotion);
416
+ scroller?.removeEventListener('scroll', invalidate);
417
+ ro.disconnect();
418
+ mo.disconnect();
419
+ for (const { el } of canvases.values()) el.remove();
420
+ canvases.clear();
421
+ };
422
+ }