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.
- package/LICENSE +661 -0
- package/README.md +586 -0
- package/bin/tinywebui.js +105 -0
- package/package.json +49 -0
- package/public/app.js +81 -0
- package/public/chat/attachments.js +304 -0
- package/public/chat/chat.js +282 -0
- package/public/chat/composer.js +289 -0
- package/public/chat/library.js +76 -0
- package/public/chat/outline.js +39 -0
- package/public/chat/queue.js +124 -0
- package/public/chat/sidebar.js +485 -0
- package/public/chat/stream.js +162 -0
- package/public/chat/tasks.js +36 -0
- package/public/chat/transcript.js +691 -0
- package/public/core/access.js +42 -0
- package/public/core/api.js +27 -0
- package/public/core/dom.js +13 -0
- package/public/core/id.js +7 -0
- package/public/core/md.js +131 -0
- package/public/core/state.js +31 -0
- package/public/favicon.svg +9 -0
- package/public/index.html +261 -0
- package/public/panels/admin.js +199 -0
- package/public/panels/automations.js +464 -0
- package/public/panels/panels.js +52 -0
- package/public/panels/settings.js +365 -0
- package/public/panels/usage.js +474 -0
- package/public/shared/attachment_note.js +23 -0
- package/public/shared/events.js +27 -0
- package/public/shared/features.js +21 -0
- package/public/shared/origins.js +8 -0
- package/public/shared/usage.js +59 -0
- package/public/styles.css +1455 -0
- package/public/themes/README.md +178 -0
- package/public/themes/cyber-grid/cyber-grid.css +59 -0
- package/public/themes/drivers/scene-kit.js +422 -0
- package/public/themes/fall-fairy/fall-fairy.css +221 -0
- package/public/themes/fall-fairy/fall-fairy.js +859 -0
- package/public/themes/manifest.json +6 -0
- package/public/themes/theme-loader.js +123 -0
- package/src/access/auth.js +228 -0
- package/src/access/auth_gate.js +67 -0
- package/src/access/policy.js +180 -0
- package/src/audit.js +7 -0
- package/src/automations/automation.js +136 -0
- package/src/automations/scheduler.js +147 -0
- package/src/chat/compact.js +242 -0
- package/src/chat/llm.js +448 -0
- package/src/chat/runs.js +180 -0
- package/src/chat/tool_executor.js +239 -0
- package/src/cli.js +478 -0
- package/src/config/approval.js +54 -0
- package/src/config/config.js +479 -0
- package/src/config/models.js +112 -0
- package/src/files/documents.js +91 -0
- package/src/files/images.js +62 -0
- package/src/harness/agent.js +115 -0
- package/src/harness/attribution.js +75 -0
- package/src/harness/checkpoint.js +96 -0
- package/src/harness/history.js +40 -0
- package/src/harness/prompt.js +93 -0
- package/src/harness/provider.js +430 -0
- package/src/http.js +95 -0
- package/src/log.js +24 -0
- package/src/mcp.js +405 -0
- package/src/retrieval/embedding.js +298 -0
- package/src/retrieval/retrieval.js +250 -0
- package/src/routes/admin.js +112 -0
- package/src/routes/auth.js +97 -0
- package/src/routes/automations.js +92 -0
- package/src/routes/chats.js +232 -0
- package/src/routes/documents.js +133 -0
- package/src/routes/library.js +51 -0
- package/src/routes/mcp.js +59 -0
- package/src/routes/settings.js +101 -0
- package/src/server.js +397 -0
- package/src/set-password.js +32 -0
- package/src/store/automations.js +119 -0
- package/src/store/chats.js +254 -0
- package/src/store/documents.js +256 -0
- package/src/store/embeddings.js +67 -0
- package/src/store/index.js +90 -0
- package/src/store/messages.js +93 -0
- package/src/store/schema.js +480 -0
- package/src/store/scope.js +43 -0
- package/src/store/search.js +56 -0
- package/src/store/turns.js +78 -0
- package/src/store/usage.js +327 -0
- package/src/store/users.js +102 -0
- package/src/store/wire.js +55 -0
- package/src/tools/ask_tool.js +93 -0
- package/src/tools/chat_search_tool.js +63 -0
- package/src/tools/context_tool.js +183 -0
- package/src/tools/document_tool.js +102 -0
- 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
|
+
}
|