@jbarket/loomai 0.8.0 → 0.9.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/CHANGELOG.md +41 -1
- package/README.md +28 -14
- package/dist/backends/migrations.js +18 -0
- package/dist/backends/salience.d.ts +38 -4
- package/dist/backends/salience.js +82 -30
- package/dist/backends/sqlite-vec.js +14 -5
- package/dist/ui/core.d.ts +15 -3
- package/dist/ui/core.js +414 -90
- package/dist/ui/data.d.ts +53 -0
- package/dist/ui/data.js +129 -0
- package/dist/ui/handler.js +295 -55
- package/dist/ui/pieces.d.ts +37 -0
- package/dist/ui/pieces.js +191 -0
- package/dist/ui/render.d.ts +47 -10
- package/dist/ui/render.js +220 -48
- package/dist/ui/vocab.d.ts +175 -0
- package/dist/ui/vocab.js +360 -0
- package/package.json +1 -1
- package/dist/ui/piece-sections.d.ts +0 -20
- package/dist/ui/piece-sections.js +0 -112
- package/dist/ui/sections.d.ts +0 -51
- package/dist/ui/sections.js +0 -121
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talking to installed pieces for the page: each `loom-<piece>` on PATH is
|
|
3
|
+
* asked `ui --json` for its manifest, `ui page --json` for a page and
|
|
4
|
+
* `ui act --json` to run an action (docs/extensions.md, "The page"). A piece
|
|
5
|
+
* that answers shows up with no configuration; one that doesn't (no `ui`
|
|
6
|
+
* verb, a timeout, bad JSON) simply isn't there. Composes without requiring.
|
|
7
|
+
*
|
|
8
|
+
* Bounded on every axis: one manifest run per piece per cache window however
|
|
9
|
+
* many pages load at once (the in-flight promise is what's cached), a time
|
|
10
|
+
* limit and 512KB per answer, and on a timeout the piece's whole process group
|
|
11
|
+
* is killed (after every run, not just a timeout), and at most MAX_RUNNING
|
|
12
|
+
* pieces run at once. Requests travel on stdin, never argv, so an action's
|
|
13
|
+
* field values (a secret typed into a form) stay out of `ps`.
|
|
14
|
+
*
|
|
15
|
+
* A piece is trusted code running as the same user as the daemon. It gets the
|
|
16
|
+
* daemon's env minus loom's own bearer tokens, but that's hygiene, not a
|
|
17
|
+
* boundary: a same-user process can read the daemon's environment from /proc.
|
|
18
|
+
* Install only pieces you'd trust with the daemon itself.
|
|
19
|
+
*/
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { listPieces } from '../cli/pieces.js';
|
|
22
|
+
import { parseActResult, parseManifest, parsePage } from './vocab.js';
|
|
23
|
+
export const PIECE_TIMEOUT_MS = 3_000;
|
|
24
|
+
export const ACT_TIMEOUT_MS = 15_000;
|
|
25
|
+
export const PIECE_MAX_BYTES = 512 * 1024;
|
|
26
|
+
/** Longer than the page's 30s refresh, so an open tab doesn't re-run every piece each time. */
|
|
27
|
+
export const CACHE_MS = 45_000;
|
|
28
|
+
/** After the piece exits, how long to wait for its stdout to close (a background helper may hold it). */
|
|
29
|
+
const DRAIN_MS = 200;
|
|
30
|
+
/** Env loom never hands to a piece. */
|
|
31
|
+
const WITHHELD = ['LOOM_BEARER_TOKEN', 'LOOM_KNOWLEDGE_BEARER_TOKENS'];
|
|
32
|
+
const cache = new Map();
|
|
33
|
+
export function clearPieceCache(path) {
|
|
34
|
+
if (path === undefined)
|
|
35
|
+
cache.clear();
|
|
36
|
+
else
|
|
37
|
+
cache.delete(path);
|
|
38
|
+
}
|
|
39
|
+
export function pieceEnv(env) {
|
|
40
|
+
const out = { ...env };
|
|
41
|
+
for (const k of WITHHELD)
|
|
42
|
+
delete out[k];
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function killGroup(pid) {
|
|
46
|
+
if (pid === undefined)
|
|
47
|
+
return;
|
|
48
|
+
try {
|
|
49
|
+
process.kill(-pid, 'SIGKILL');
|
|
50
|
+
}
|
|
51
|
+
catch { /* already gone */ }
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* At most this many piece processes at once, across every page load: a burst
|
|
55
|
+
* of requests queues instead of forking a process each. The time limit starts
|
|
56
|
+
* when a piece actually runs.
|
|
57
|
+
*/
|
|
58
|
+
export const MAX_RUNNING = 6;
|
|
59
|
+
let running = 0;
|
|
60
|
+
const waiting = [];
|
|
61
|
+
async function slot(f) {
|
|
62
|
+
if (running >= MAX_RUNNING)
|
|
63
|
+
await new Promise((r) => waiting.push(r));
|
|
64
|
+
running++;
|
|
65
|
+
try {
|
|
66
|
+
return await f();
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
running--;
|
|
70
|
+
waiting.shift()?.();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function ask(path, args, env, opts = {}) {
|
|
74
|
+
return slot(() => run(path, args, env, opts));
|
|
75
|
+
}
|
|
76
|
+
function run(path, args, env, opts) {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
// Its own process group, so a timeout takes the piece's children with it.
|
|
79
|
+
const child = spawn(path, args, {
|
|
80
|
+
env: pieceEnv(env),
|
|
81
|
+
stdio: [opts.stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'],
|
|
82
|
+
detached: true,
|
|
83
|
+
});
|
|
84
|
+
let out = '';
|
|
85
|
+
let bytes = 0;
|
|
86
|
+
let settled = false;
|
|
87
|
+
let drain;
|
|
88
|
+
const finish = (r) => {
|
|
89
|
+
if (settled)
|
|
90
|
+
return;
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
if (drain)
|
|
94
|
+
clearTimeout(drain);
|
|
95
|
+
child.stdout?.destroy();
|
|
96
|
+
// Every time, not only on a timeout: whatever the piece left running in
|
|
97
|
+
// its group goes with it. (A piece that calls setsid on purpose escapes.)
|
|
98
|
+
killGroup(child.pid);
|
|
99
|
+
resolve(r);
|
|
100
|
+
};
|
|
101
|
+
const timer = setTimeout(() => { killGroup(child.pid); finish({ ok: false, reason: 'timed out' }); }, opts.timeoutMs ?? PIECE_TIMEOUT_MS);
|
|
102
|
+
child.stdout?.on('data', (d) => {
|
|
103
|
+
bytes += d.length;
|
|
104
|
+
if (bytes > PIECE_MAX_BYTES) {
|
|
105
|
+
killGroup(child.pid);
|
|
106
|
+
finish({ ok: false, reason: 'answer too large' });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
out += d.toString('utf-8');
|
|
110
|
+
});
|
|
111
|
+
child.on('error', (e) => finish({ ok: false, reason: e.message }));
|
|
112
|
+
if (opts.stdin !== undefined && child.stdin) {
|
|
113
|
+
child.stdin.on('error', () => { });
|
|
114
|
+
child.stdin.end(opts.stdin);
|
|
115
|
+
}
|
|
116
|
+
// Resolve on the piece's own exit (plus a short drain), not on 'close':
|
|
117
|
+
// 'close' waits for every holder of the pipe, and a helper the piece left
|
|
118
|
+
// running would hold it open past the timeout.
|
|
119
|
+
child.on('exit', (code) => {
|
|
120
|
+
const done = () => finish(code === 0 ? { ok: true, stdout: out } : { ok: false, reason: 'no ui' });
|
|
121
|
+
if (child.stdout?.readableEnded)
|
|
122
|
+
return done();
|
|
123
|
+
child.stdout?.once('end', done);
|
|
124
|
+
drain = setTimeout(done, DRAIN_MS);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function json(stdout) {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(stdout);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function fetchManifest(piece, path, env) {
|
|
137
|
+
const r = await ask(path, ['ui', '--json'], env);
|
|
138
|
+
if (!r.ok)
|
|
139
|
+
return { piece, reason: r.reason };
|
|
140
|
+
const parsed = json(r.stdout);
|
|
141
|
+
if (parsed === undefined)
|
|
142
|
+
return { piece, reason: 'invalid: not JSON' };
|
|
143
|
+
const m = parseManifest(parsed);
|
|
144
|
+
return typeof m === 'string' ? { piece, reason: `invalid: ${m}` } : { piece, path, manifest: m };
|
|
145
|
+
}
|
|
146
|
+
function manifestFor(piece, path, env, nowMs) {
|
|
147
|
+
const hit = cache.get(path);
|
|
148
|
+
if (hit && nowMs - hit.at < CACHE_MS)
|
|
149
|
+
return hit.result;
|
|
150
|
+
// Cache the promise itself: concurrent page loads share one run.
|
|
151
|
+
const result = fetchManifest(piece, path, env);
|
|
152
|
+
cache.set(path, { at: nowMs, result });
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
/** Every installed piece's manifest, plus the ones that had nothing to show and why. */
|
|
156
|
+
export async function pieceManifests(env, nowMs = Date.now()) {
|
|
157
|
+
// "loom" is loom's own name on the page; a piece can't take it.
|
|
158
|
+
const installed = listPieces(env).filter((p) => p.name !== 'loom');
|
|
159
|
+
const results = await Promise.all(installed.map((p) => manifestFor(p.name, p.path, env, nowMs)));
|
|
160
|
+
const pieces = results.filter((r) => 'manifest' in r);
|
|
161
|
+
const empty = pieces.filter((p) => !p.manifest.tab && !p.manifest.tile && !p.manifest.asks.length && !p.manifest.legacy?.length);
|
|
162
|
+
return {
|
|
163
|
+
pieces: pieces.filter((p) => !empty.includes(p)),
|
|
164
|
+
skipped: [...results.filter((r) => 'reason' in r), ...empty.map((p) => ({ piece: p.piece, reason: 'nothing to show' }))],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** One page from a piece, or why it couldn't be had. */
|
|
168
|
+
export async function piecePage(info, path, query, env) {
|
|
169
|
+
// A first-version piece has one page: its sections.
|
|
170
|
+
if (info.manifest.legacy) {
|
|
171
|
+
return path === '' ? { title: info.piece, sections: info.manifest.legacy } : { title: 'Not found', sections: [] };
|
|
172
|
+
}
|
|
173
|
+
const r = await ask(info.path, ['ui', 'page', '--json'], env, { stdin: JSON.stringify({ path, query }) });
|
|
174
|
+
if (!r.ok)
|
|
175
|
+
return r.reason;
|
|
176
|
+
const parsed = json(r.stdout);
|
|
177
|
+
if (parsed === undefined)
|
|
178
|
+
return 'not JSON';
|
|
179
|
+
return parsePage(parsed);
|
|
180
|
+
}
|
|
181
|
+
/** Run an action on a piece. Field values go on stdin only. */
|
|
182
|
+
export async function pieceAct(info, request, env) {
|
|
183
|
+
const r = await ask(info.path, ['ui', 'act', '--json'], env, { stdin: JSON.stringify(request), timeoutMs: ACT_TIMEOUT_MS });
|
|
184
|
+
// Whatever happened, the piece's state may have changed.
|
|
185
|
+
clearPieceCache(info.path);
|
|
186
|
+
if (!r.ok)
|
|
187
|
+
return { ok: false, message: `${info.piece} didn't answer (${r.reason}).` };
|
|
188
|
+
const parsed = json(r.stdout);
|
|
189
|
+
const result = parsed === undefined ? 'not JSON' : parseActResult(parsed);
|
|
190
|
+
return typeof result === 'string' ? { ok: false, message: `${info.piece} sent an answer loom couldn't read (${result}).` } : result;
|
|
191
|
+
}
|
package/dist/ui/render.d.ts
CHANGED
|
@@ -1,16 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Server-side HTML for loom's page. Every string that reaches the page goes
|
|
3
|
-
* through esc(): pieces send data in the
|
|
4
|
-
*
|
|
3
|
+
* through esc(): pieces send data in the page vocabulary, never markup. No
|
|
4
|
+
* client script anywhere; folding is <details>, filters are links, and every
|
|
5
|
+
* action is a plain form post carrying the anti-forgery token.
|
|
5
6
|
*/
|
|
6
|
-
import type {
|
|
7
|
-
import type { PieceSections, PieceSkip } from './piece-sections.js';
|
|
7
|
+
import type { Ask, Page, Tile } from './vocab.js';
|
|
8
8
|
export declare function esc(s: string): string;
|
|
9
|
-
|
|
9
|
+
/** Whose page this is: loom's own (`loom`) or a piece's. */
|
|
10
|
+
export interface Source {
|
|
11
|
+
id: string;
|
|
12
|
+
base: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const CORE: Source;
|
|
15
|
+
export declare const pieceSource: (piece: string) => Source;
|
|
16
|
+
export interface RenderContext {
|
|
17
|
+
source: Source;
|
|
18
|
+
/** This page's path within its source, and its query, for filters and for returning after an action. */
|
|
19
|
+
path: string;
|
|
20
|
+
query: Record<string, string>;
|
|
21
|
+
csrf: string;
|
|
22
|
+
}
|
|
23
|
+
/** Where a page link goes. Pieces' links stay inside their own base unless they say `loom:`. */
|
|
24
|
+
export declare function pageHref(link: string, source: Source): string;
|
|
25
|
+
export interface Chrome {
|
|
10
26
|
agent: string;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
27
|
+
nav: Array<{
|
|
28
|
+
label: string;
|
|
29
|
+
href: string;
|
|
30
|
+
current: boolean;
|
|
31
|
+
piece: boolean;
|
|
32
|
+
}>;
|
|
33
|
+
clock: string;
|
|
34
|
+
csrf: string;
|
|
35
|
+
/** The one-time result of an action; `from` names the piece that sent it. */
|
|
36
|
+
flash?: {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
message: string;
|
|
39
|
+
from?: string;
|
|
40
|
+
};
|
|
41
|
+
skipped: string[];
|
|
42
|
+
}
|
|
43
|
+
/** One page, loom's or a piece's. */
|
|
44
|
+
export declare function renderPage(page: Page, ctx: RenderContext, chrome: Chrome): string;
|
|
45
|
+
export interface HomeTile extends Tile {
|
|
46
|
+
source: Source;
|
|
47
|
+
}
|
|
48
|
+
export interface HomeAsk extends Ask {
|
|
49
|
+
source: Source;
|
|
50
|
+
}
|
|
51
|
+
/** Home: tiles across the top, the tape as the page, "Your call" leading the side column. */
|
|
52
|
+
export declare function renderHome(page: Page, ctx: RenderContext, chrome: Chrome, tiles: HomeTile[], asks: HomeAsk[]): string;
|
|
16
53
|
export declare function renderLogin(error?: string): string;
|
package/dist/ui/render.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { splitPage } from './vocab.js';
|
|
1
2
|
export function esc(s) {
|
|
2
3
|
return s
|
|
3
4
|
.replace(/&/g, '&')
|
|
@@ -6,65 +7,236 @@ export function esc(s) {
|
|
|
6
7
|
.replace(/"/g, '"')
|
|
7
8
|
.replace(/'/g, ''');
|
|
8
9
|
}
|
|
9
|
-
const
|
|
10
|
-
|
|
10
|
+
export const CORE = { id: 'loom', base: '/ui/' };
|
|
11
|
+
export const pieceSource = (piece) => ({ id: piece, base: `/ui/p/${piece}/` });
|
|
12
|
+
function qs(query) {
|
|
13
|
+
const p = new URLSearchParams();
|
|
14
|
+
for (const [k, v] of Object.entries(query))
|
|
15
|
+
if (v !== '')
|
|
16
|
+
p.set(k, v);
|
|
17
|
+
const s = p.toString();
|
|
18
|
+
return s ? `?${s}` : '';
|
|
19
|
+
}
|
|
20
|
+
/** Where a page link goes. Pieces' links stay inside their own base unless they say `loom:`. */
|
|
21
|
+
export function pageHref(link, source) {
|
|
22
|
+
const s = splitPage(link);
|
|
23
|
+
if (!s)
|
|
24
|
+
return '#';
|
|
25
|
+
const base = s.core ? CORE.base : source.base;
|
|
26
|
+
const url = s.path === '' ? (base === CORE.base ? '/ui' : base.replace(/\/$/, '')) : base + s.path;
|
|
27
|
+
return url + qs(s.query);
|
|
28
|
+
}
|
|
29
|
+
const tone = (t) => (t ? ` tone-${t}` : '');
|
|
30
|
+
function link(text, t, ctx, cls = '') {
|
|
31
|
+
const c = cls ? ` class="${cls}"` : '';
|
|
32
|
+
if (t.href)
|
|
33
|
+
return `<a${c} href="${esc(t.href)}" rel="noopener noreferrer" target="_blank">${text}</a>`;
|
|
34
|
+
if (t.page !== undefined)
|
|
35
|
+
return `<a${c} href="${esc(pageHref(t.page, ctx.source))}">${text}</a>`;
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
function hiddenInputs(fields) {
|
|
39
|
+
return Object.entries(fields).map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
|
|
40
|
+
}
|
|
41
|
+
function actionForm(ctx, action, inner, cls) {
|
|
42
|
+
const back = ctx.path + qs(ctx.query);
|
|
43
|
+
return `<form class="${cls}" method="post" action="/ui/act/${esc(ctx.source.id)}">${hiddenInputs({ _csrf: ctx.csrf, _action: action, _path: back })}${inner}</form>`;
|
|
44
|
+
}
|
|
45
|
+
let fieldSeq = 0;
|
|
46
|
+
function renderField(f) {
|
|
47
|
+
const id = `f${++fieldSeq}-${f.name}`;
|
|
48
|
+
const req = f.required ? ' required' : '';
|
|
49
|
+
const hint = f.hint ? `<span class="hint" id="${id}-h">${esc(f.hint)}</span>` : '';
|
|
50
|
+
const desc = f.hint ? ` aria-describedby="${id}-h"` : '';
|
|
51
|
+
switch (f.kind) {
|
|
52
|
+
case 'text':
|
|
53
|
+
return `<div class="field"><label for="${id}">${esc(f.label)}</label><input id="${id}" name="${esc(f.name)}" type="text" value="${esc(f.value ?? '')}" autocomplete="off" spellcheck="false"${req}${desc}>${hint}</div>`;
|
|
54
|
+
case 'secret':
|
|
55
|
+
// Never prefilled. Asks the browser and the common password managers
|
|
56
|
+
// not to offer to save it (or to fill it with loom's own token); no
|
|
57
|
+
// browser promises to honour that, which the contract says.
|
|
58
|
+
return `<div class="field"><label for="${id}">${esc(f.label)}</label><input id="${id}" name="${esc(f.name)}" type="password" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other" spellcheck="false"${req}${desc}>${hint}</div>`;
|
|
59
|
+
case 'textarea':
|
|
60
|
+
return `<div class="field"><label for="${id}">${esc(f.label)}</label><textarea id="${id}" name="${esc(f.name)}" rows="4"${req}${desc}>${esc(f.value ?? '')}</textarea>${hint}</div>`;
|
|
61
|
+
case 'choice':
|
|
62
|
+
return `<fieldset class="field choice"><legend>${esc(f.label)}</legend>${(f.options ?? []).map((o, i) => `<label class="option"><input type="radio" name="${esc(f.name)}" value="${esc(o.value)}"${f.value === o.value ? ' checked' : ''}${req && i === 0 ? ' required' : ''}><span><strong>${esc(o.label)}</strong>${o.detail ? `<span class="hint">${esc(o.detail)}</span>` : ''}</span></label>`).join('')}${hint}</fieldset>`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function cell(c, ctx) {
|
|
66
|
+
if (typeof c === 'string')
|
|
67
|
+
return esc(c);
|
|
68
|
+
return c.page !== undefined ? link(esc(c.text), { page: c.page }, ctx) : esc(c.text);
|
|
69
|
+
}
|
|
70
|
+
function renderBlock(b, ctx) {
|
|
11
71
|
switch (b.type) {
|
|
12
72
|
case 'stats':
|
|
13
|
-
return `<dl class="stats">${b.items
|
|
14
|
-
|
|
15
|
-
.
|
|
73
|
+
return `<dl class="stats">${b.items.map((i) => {
|
|
74
|
+
const inner = `<dt>${esc(i.label)}</dt><dd>${esc(i.value)}</dd>${i.detail ? `<dd class="detail">${esc(i.detail)}</dd>` : ''}`;
|
|
75
|
+
return i.page !== undefined
|
|
76
|
+
? `<div class="stat${tone(i.tone)}"><a class="stat-link" href="${esc(pageHref(i.page, ctx.source))}">${inner}</a></div>`
|
|
77
|
+
: `<div class="stat${tone(i.tone)}">${inner}</div>`;
|
|
78
|
+
}).join('')}</dl>`;
|
|
16
79
|
case 'list':
|
|
17
|
-
return `<ul class="list">${b.items
|
|
18
|
-
|
|
19
|
-
.
|
|
80
|
+
return `<ul class="list">${b.items.map((i) => {
|
|
81
|
+
const text = `<span class="text">${esc(i.text)}</span>`;
|
|
82
|
+
return `<li class="${tone(i.tone).trim()}"><div class="row">${link(text, i, ctx, 'row-link')}${i.badge ? `<span class="badge">${esc(i.badge)}</span>` : ''}</div>${i.meta ? `<span class="meta">${esc(i.meta)}</span>` : ''}</li>`;
|
|
83
|
+
}).join('')}</ul>`;
|
|
20
84
|
case 'table':
|
|
21
|
-
return `<div class="table-wrap"><table><thead><tr>${b.columns.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${b.rows
|
|
22
|
-
|
|
23
|
-
|
|
85
|
+
return `<div class="table-wrap"><table><thead><tr>${b.columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${b.rows.map((r) => `<tr>${r.map((c) => `<td>${cell(c, ctx)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
|
86
|
+
case 'kv':
|
|
87
|
+
return `<dl class="kv">${b.items.map((i) => `<div><dt>${esc(i.label)}</dt><dd>${i.page !== undefined ? link(esc(i.value), { page: i.page }, ctx) : esc(i.value)}</dd></div>`).join('')}</dl>`;
|
|
24
88
|
case 'text':
|
|
25
|
-
return `<p class="text-block">${esc(b.text)}</p>`;
|
|
89
|
+
return `<p class="text-block${tone(b.tone)}">${esc(b.text)}</p>`;
|
|
90
|
+
case 'callout':
|
|
91
|
+
return `<div class="callout${tone(b.tone)}">${b.title ? `<strong>${esc(b.title)}</strong>` : ''}<p>${esc(b.text)}</p></div>`;
|
|
26
92
|
case 'link':
|
|
27
|
-
return `<p
|
|
93
|
+
return `<p class="link-block">${link(esc(b.text), b, ctx)}</p>`;
|
|
94
|
+
case 'filters': {
|
|
95
|
+
const current = ctx.query[b.param] ?? '';
|
|
96
|
+
return `<nav class="filters" aria-label="Filter">${b.options.map((o) => {
|
|
97
|
+
const href = ctx.source.base === CORE.base && ctx.path === '' ? '/ui' : pageHref(ctx.path, ctx.source);
|
|
98
|
+
const target = href + qs({ ...ctx.query, [b.param]: o.value });
|
|
99
|
+
const on = o.value === current;
|
|
100
|
+
return `<a class="pill${on ? ' on' : ''}" href="${esc(target)}"${on ? ' aria-current="true"' : ''}>${esc(o.label)}</a>`;
|
|
101
|
+
}).join('')}</nav>`;
|
|
102
|
+
}
|
|
103
|
+
case 'details':
|
|
104
|
+
return `<details class="details"><summary><span class="text">${esc(b.summary)}</span>${b.meta ? `<span class="meta">${esc(b.meta)}</span>` : ''}</summary><div class="details-body">${b.blocks.map((x) => renderBlock(x, ctx)).join('')}</div></details>`;
|
|
105
|
+
case 'form':
|
|
106
|
+
return actionForm(ctx, b.action, `${b.title ? `<h3>${esc(b.title)}</h3>` : ''}${hiddenInputs(b.hidden ?? {})}${b.fields.map(renderField).join('')}${b.note ? `<p class="note">${esc(b.note)}</p>` : ''}<div class="actions"><button class="btn primary" type="submit">${esc(b.submit)}</button></div>`, 'piece-form');
|
|
107
|
+
case 'buttons':
|
|
108
|
+
return `<div class="buttons">${b.items.map((i) => actionForm(ctx, i.action, `${hiddenInputs(i.fields ?? {})}<button class="btn${i.tone ? ` ${i.tone}` : ''}" type="submit">${esc(i.label)}</button>`, 'button-form')).join('')}</div>`;
|
|
28
109
|
}
|
|
29
110
|
}
|
|
30
|
-
// Ids are namespaced (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return `<section class="card" id="s-${esc(source)}--${esc(s.id)}"
|
|
111
|
+
// Ids are namespaced (s-<source>--<id>) so no piece's anchors can land on another's or on loom's.
|
|
112
|
+
function renderSection(s, ctx, headingLevel = 2) {
|
|
113
|
+
const h = s.title ? `<h${headingLevel}>${esc(s.title)}</h${headingLevel}>` : '';
|
|
114
|
+
return `<section class="card${tone(s.tone)}" id="s-${esc(ctx.source.id)}--${esc(s.id)}">${h}${s.blocks.map((b) => renderBlock(b, ctx)).join('')}</section>`;
|
|
115
|
+
}
|
|
116
|
+
function columns(main, side) {
|
|
117
|
+
return side ? `<div class="cols"><div class="main-col">${main}</div><aside class="side-col">${side}</aside></div>` : `<div class="main-col solo">${main}</div>`;
|
|
34
118
|
}
|
|
35
119
|
const STYLE = `
|
|
36
|
-
:root{--bg:#
|
|
37
|
-
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#
|
|
38
|
-
*{box-sizing:border-box}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.
|
|
44
|
-
.
|
|
45
|
-
.
|
|
46
|
-
.
|
|
47
|
-
.
|
|
48
|
-
.
|
|
49
|
-
.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
120
|
+
:root{--bg:#f4f5f7;--card:#fff;--fg:#15171c;--muted:#596171;--line:#e1e4ea;--soft:#eef0f3;--accent:#3442c2;--accent-fg:#fff;--ok:#1d6b45;--ok-bg:#e7f4ec;--warn:#8a4b0a;--warn-line:#e9c98f;--warn-bg:#fffbf3;--bad:#b42318;--bad-line:#f1c4bf;--bad-bg:#fff7f6;--pill:#15171c;--pill-fg:#fff;color-scheme:light}
|
|
121
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#121317;--card:#1b1d23;--fg:#e9ebf0;--muted:#a0a7b4;--line:#2c3039;--soft:#23262e;--accent:#9aa6ff;--accent-fg:#121317;--ok:#6fd39b;--ok-bg:#16291f;--warn:#f0b35c;--warn-line:#5a4520;--warn-bg:#231d12;--bad:#ff8f84;--bad-line:#5c2a25;--bad-bg:#2a1714;--pill:#e9ebf0;--pill-fg:#121317;color-scheme:dark}}
|
|
122
|
+
*{box-sizing:border-box}
|
|
123
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
124
|
+
.mono,.meta,.badge,.eyebrow,.kv dt,.stat dt,code{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace}
|
|
125
|
+
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
|
126
|
+
header.top{background:var(--card);border-bottom:1px solid var(--line)}
|
|
127
|
+
.top-in{max-width:1200px;margin:0 auto;padding:0 16px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 24px}
|
|
128
|
+
.brand{display:flex;align-items:center;gap:8px;color:var(--fg);padding:12px 0;font-weight:600}.brand:hover{text-decoration:none}.brand .who{color:var(--muted);font-weight:400}
|
|
129
|
+
nav.tabs{display:flex;flex-wrap:wrap;gap:0 18px;flex:1 1 auto;align-items:center}
|
|
130
|
+
nav.tabs a{color:var(--muted);padding:12px 2px;border-bottom:2px solid transparent;font-size:14px}nav.tabs a:hover{color:var(--fg);text-decoration:none}
|
|
131
|
+
nav.tabs a[aria-current]{color:var(--fg);border-bottom-color:var(--accent);font-weight:500}
|
|
132
|
+
nav.tabs .sep{width:1px;height:18px;background:var(--line)}
|
|
133
|
+
.top-right{display:flex;align-items:center;gap:12px;font-size:13px;color:var(--muted)}
|
|
134
|
+
main{max-width:1200px;margin:0 auto;padding:24px 16px 48px;display:flex;flex-direction:column;gap:18px}
|
|
135
|
+
h1{margin:0;font-size:26px;font-weight:600;letter-spacing:-.01em;line-height:1.25}
|
|
136
|
+
.intro{margin:4px 0 0;color:var(--muted);max-width:720px}
|
|
137
|
+
.flash{padding:12px 16px;border-radius:10px;border:1px solid var(--line);background:var(--card)}.flash.ok{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}.flash.bad{border-color:var(--bad-line);background:var(--bad-bg);color:var(--bad)}
|
|
138
|
+
.cols{display:flex;flex-wrap:wrap;gap:20px;align-items:flex-start}.main-col{flex:999 1 560px;min-width:0;display:flex;flex-direction:column;gap:16px}.side-col{flex:1 1 300px;min-width:0;display:flex;flex-direction:column;gap:16px}
|
|
139
|
+
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}
|
|
140
|
+
.tile{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;color:var(--fg);display:flex;flex-direction:column;gap:2px}a.tile:hover{text-decoration:none;border-color:var(--muted)}
|
|
141
|
+
.tile .eyebrow{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}.tile .value{font-size:20px;font-weight:600}.tile .detail{font-size:13px;color:var(--muted)}
|
|
142
|
+
.tile.tone-ok .detail{color:var(--ok)}.tile.tone-warn .detail{color:var(--warn)}.tile.tone-bad .detail{color:var(--bad)}
|
|
143
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px 18px;min-width:0}
|
|
144
|
+
.card h2{margin:0 0 8px;font-size:16px;font-weight:600}.card h3{margin:0 0 8px;font-size:15px}
|
|
145
|
+
.card.tone-warn{border-color:var(--warn-line);background:var(--warn-bg)}.card.tone-bad{border-color:var(--bad-line);background:var(--bad-bg)}.card.tone-ok{border-color:var(--ok);background:var(--ok-bg)}
|
|
146
|
+
.card>*+*{margin-top:10px}
|
|
147
|
+
.stats{display:flex;flex-wrap:wrap;gap:12px 28px;margin:0}.stat dt{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.05em}.stat dd{margin:0;font-size:22px;font-weight:600;font-variant-numeric:tabular-nums}.stat dd.detail,.stat.tone-ok dd.detail,.stat.tone-warn dd.detail,.stat.tone-bad dd.detail{font-size:13px;font-weight:400;color:var(--muted)}
|
|
148
|
+
.stat-link{color:inherit;display:block}.stat-link:hover{text-decoration:none}.stat-link:hover dd:first-of-type{color:var(--accent)}
|
|
149
|
+
.stat.tone-ok>dd,.stat.tone-ok a>dd,.list li.tone-ok .text{color:var(--ok)}.stat.tone-warn>dd,.stat.tone-warn a>dd,.list li.tone-warn .text{color:var(--warn)}.stat.tone-bad>dd,.stat.tone-bad a>dd,.list li.tone-bad .text{color:var(--bad)}.stat.tone-muted>dd,.stat.tone-muted a>dd,.list li.tone-muted .text{color:var(--muted)}
|
|
150
|
+
.ask .text{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
|
151
|
+
.list{list-style:none;margin:0;padding:0}.list li{padding:9px 0;border-top:1px solid var(--soft);display:flex;flex-direction:column;gap:2px}.list li:first-child{border-top:0}
|
|
152
|
+
.list .row{display:flex;align-items:baseline;gap:10px}.list .row-link{color:inherit;flex:1;min-width:0}.list .row-link:hover{color:var(--accent);text-decoration:none}
|
|
153
|
+
.list .text{overflow-wrap:anywhere}.meta{color:var(--muted);font-size:12px}
|
|
154
|
+
.badge{font-size:11px;color:var(--warn);background:var(--warn-bg);border:1px solid var(--warn-line);border-radius:4px;padding:1px 6px;white-space:nowrap}
|
|
155
|
+
.text-block{margin:0;white-space:pre-line;overflow-wrap:anywhere}.text-block.tone-muted{color:var(--muted);font-size:13px}.text-block.tone-warn{color:var(--warn)}.text-block.tone-bad{color:var(--bad)}.text-block.tone-ok{color:var(--ok)}
|
|
156
|
+
.callout{padding:12px 14px;border-radius:8px;background:var(--soft)}.callout p{margin:2px 0 0}.callout.tone-warn{background:var(--warn-bg);border:1px solid var(--warn-line)}.callout.tone-bad{background:var(--bad-bg);border:1px solid var(--bad-line);color:var(--bad)}.callout.tone-ok{background:var(--ok-bg);color:var(--ok)}
|
|
157
|
+
.link-block{margin:0;font-size:14px}
|
|
158
|
+
.kv{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px 18px;margin:0}.kv dt{color:var(--muted);font-size:12px}.kv dd{margin:2px 0 0;overflow-wrap:anywhere;font-size:14px}
|
|
159
|
+
.side-col .kv{grid-template-columns:1fr 1fr}
|
|
160
|
+
.table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:14px}th,td{text-align:left;padding:8px 10px 8px 0;border-top:1px solid var(--soft);vertical-align:top}th{color:var(--muted);font-weight:500;font-size:12px;text-transform:uppercase;letter-spacing:.05em;border-top:0}
|
|
161
|
+
.filters{display:flex;flex-wrap:wrap;gap:8px}.pill{font-size:13px;min-height:32px;display:inline-flex;align-items:center;padding:0 12px;border-radius:16px;border:1px solid var(--line);background:var(--card);color:var(--fg)}.pill:hover{text-decoration:none;border-color:var(--muted)}.pill.on{background:var(--pill);border-color:var(--pill);color:var(--pill-fg)}
|
|
162
|
+
details.details{border-top:1px solid var(--soft)}details.details:first-of-type{border-top:0}
|
|
163
|
+
details.details>summary{cursor:pointer;padding:10px 0;display:flex;flex-direction:column;gap:2px;list-style:none}details.details>summary::-webkit-details-marker{display:none}
|
|
164
|
+
details.details>summary .text::before{content:"▸ ";color:var(--muted)}details.details[open]>summary .text::before{content:"▾ "}
|
|
165
|
+
.details-body{margin:0 0 12px 14px;padding:12px 14px;border-radius:8px;background:var(--soft);display:flex;flex-direction:column;gap:10px}
|
|
166
|
+
.btn{font:inherit;font-size:14px;font-weight:500;min-height:36px;padding:0 14px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--fg);cursor:pointer}.btn:hover{border-color:var(--muted)}
|
|
167
|
+
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn.danger{color:var(--bad)}
|
|
168
|
+
.buttons{display:flex;flex-wrap:wrap;gap:8px}.button-form{margin:0}
|
|
169
|
+
.piece-form{display:flex;flex-direction:column;gap:14px;margin:0}.field{display:flex;flex-direction:column;gap:5px;border:0;padding:0;margin:0}.field label,.field legend{font-size:14px;font-weight:500}
|
|
170
|
+
.field input[type=text],.field input[type=password],.field textarea{font:inherit;font-size:15px;min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);width:100%}
|
|
171
|
+
.field input:focus,.field textarea:focus{outline:2px solid var(--accent);outline-offset:1px}
|
|
172
|
+
.hint{font-size:12px;color:var(--muted)}.note{margin:0;font-size:13px;color:var(--muted)}.actions{display:flex;gap:10px}
|
|
173
|
+
.choice .option{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);margin-top:6px;cursor:pointer}.choice .option span{display:flex;flex-direction:column;gap:2px}
|
|
174
|
+
footer{max-width:1200px;margin:0 auto;padding:0 16px 32px;color:var(--muted);font-size:12px;display:flex;flex-wrap:wrap;gap:6px 14px}
|
|
175
|
+
form.inline{display:inline;margin:0}form.inline button{background:none;border:1px solid var(--line);border-radius:6px;padding:4px 10px;color:var(--fg);font:inherit;font-size:13px;cursor:pointer}
|
|
176
|
+
form.login{max-width:380px;margin:64px auto;padding:20px;display:flex;flex-direction:column;gap:10px}form.login input{width:100%;min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);font:inherit}
|
|
177
|
+
form.login button{min-height:44px;border:0;border-radius:8px;background:var(--accent);color:var(--accent-fg);font:inherit;font-weight:500}.err{color:var(--bad)}.sub{color:var(--muted);margin:0}
|
|
54
178
|
`;
|
|
55
|
-
function
|
|
56
|
-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${refresh ? '<meta http-equiv="refresh" content="
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
.join('')
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
179
|
+
function document(title, body, refresh) {
|
|
180
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${refresh ? '<meta http-equiv="refresh" content="60">' : ''}<title>${esc(title)}</title><style>${STYLE}</style></head><body>${body}</body></html>`;
|
|
181
|
+
}
|
|
182
|
+
function chromeTop(c) {
|
|
183
|
+
const core = c.nav.filter((n) => !n.piece);
|
|
184
|
+
const pieces = c.nav.filter((n) => n.piece);
|
|
185
|
+
const tab = (n) => `<a href="${esc(n.href)}"${n.current ? ' aria-current="page"' : ''}>${esc(n.label)}</a>`;
|
|
186
|
+
return `<header class="top"><div class="top-in"><a class="brand" href="/ui"><svg width="20" height="20" viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3 5h16M3 11h16M3 17h16"></path><path d="M7 2v18M15 2v18" opacity=".55"></path></svg>loom <span class="who">· ${esc(c.agent)}</span></a>` +
|
|
187
|
+
`<nav class="tabs" aria-label="Sections">${core.map(tab).join('')}${pieces.length ? `<span class="sep" aria-hidden="true"></span>${pieces.map(tab).join('')}` : ''}</nav>` +
|
|
188
|
+
`<div class="top-right"><span class="mono">${esc(c.clock)}</span><form class="inline" method="post" action="/ui/logout"><input type="hidden" name="_csrf" value="${esc(c.csrf)}"><button type="submit">Sign out</button></form></div></div></header>`;
|
|
189
|
+
}
|
|
190
|
+
function chromeFoot(c, refresh) {
|
|
191
|
+
return `<footer>${refresh ? '<span>Refreshes every minute.</span>' : ''}${c.skipped.length ? `<span>Pieces with no page: ${c.skipped.map(esc).join(', ')}</span>` : ''}</footer>`;
|
|
192
|
+
}
|
|
193
|
+
function flash(c) {
|
|
194
|
+
if (!c.flash)
|
|
195
|
+
return '';
|
|
196
|
+
const from = c.flash.from ? `<strong>${esc(c.flash.from)}</strong> · ` : '';
|
|
197
|
+
return `<div class="flash ${c.flash.ok ? 'ok' : 'bad'}" role="status">${from}${esc(c.flash.message)}</div>`;
|
|
198
|
+
}
|
|
199
|
+
/** One page, loom's or a piece's. */
|
|
200
|
+
export function renderPage(page, ctx, chrome) {
|
|
201
|
+
const main = page.sections.filter((s) => !s.side).map((s) => renderSection(s, ctx)).join('');
|
|
202
|
+
const side = page.sections.filter((s) => s.side).map((s) => renderSection(s, ctx)).join('');
|
|
203
|
+
const empty = page.sections.length ? '' : '<p class="intro">There\'s nothing here.</p>';
|
|
204
|
+
// Pages with forms don't auto-refresh: a refresh would throw away what's being typed.
|
|
205
|
+
const hasForm = page.sections.some((s) => s.blocks.some((b) => b.type === 'form' || (b.type === 'details' && b.blocks.some((x) => x.type === 'form'))));
|
|
206
|
+
const body = `${chromeTop(chrome)}<main>${flash(chrome)}<div><h1>${esc(page.title)}</h1>${page.intro ? `<p class="intro">${esc(page.intro)}</p>` : ''}</div>${empty}${columns(main, side)}</main>${chromeFoot(chrome, !hasForm)}`;
|
|
207
|
+
return document(`${page.title} · ${chrome.agent}`, body, !hasForm);
|
|
208
|
+
}
|
|
209
|
+
/** "Your call" shows this many a piece on Home; the rest are a link away. */
|
|
210
|
+
const ASKS_SHOWN = 5;
|
|
211
|
+
/** Home: tiles across the top, the tape as the page, "Your call" leading the side column. */
|
|
212
|
+
export function renderHome(page, ctx, chrome, tiles, asks) {
|
|
213
|
+
const tileHtml = tiles.length
|
|
214
|
+
? `<section class="tiles" aria-label="Status">${tiles.map((t) => {
|
|
215
|
+
const inner = `<span class="eyebrow">${esc(t.label)}</span><span class="value">${esc(t.value)}</span>${t.detail ? `<span class="detail">${esc(t.detail)}</span>` : ''}`;
|
|
216
|
+
return t.page !== undefined
|
|
217
|
+
? `<a class="tile${tone(t.tone)}" href="${esc(pageHref(t.page, t.source))}">${inner}</a>`
|
|
218
|
+
: `<div class="tile${tone(t.tone)}">${inner}</div>`;
|
|
219
|
+
}).join('')}</section>`
|
|
220
|
+
: '';
|
|
221
|
+
const bySource = new Map();
|
|
222
|
+
for (const a of asks)
|
|
223
|
+
bySource.set(a.source.id, [...(bySource.get(a.source.id) ?? []), a]);
|
|
224
|
+
const askItems = [...bySource.values()].flatMap((list) => {
|
|
225
|
+
const shown = list.slice(0, ASKS_SHOWN).map((a) => {
|
|
226
|
+
const go = a.page !== undefined ? `<a href="${esc(pageHref(a.page, a.source))}">${esc(a.label ?? 'Open')}</a>` : '';
|
|
227
|
+
return `<li class="ask"><span class="text">${esc(a.text)}</span><div class="row"><span class="meta" style="flex:1">${esc(a.meta ?? a.source.id)}</span>${go}</div></li>`;
|
|
228
|
+
});
|
|
229
|
+
const rest = list.length - ASKS_SHOWN;
|
|
230
|
+
if (rest > 0)
|
|
231
|
+
shown.push(`<li><a href="${esc(pageHref('', list[0].source))}">${rest} more from ${esc(list[0].source.id)}</a></li>`);
|
|
232
|
+
return shown;
|
|
233
|
+
});
|
|
234
|
+
const yours = askItems.length ? `<section class="card tone-warn" id="your-call"><h2>Your call</h2><ul class="list">${askItems.join('')}</ul></section>` : '';
|
|
235
|
+
const main = page.sections.filter((s) => !s.side).map((s) => renderSection(s, ctx)).join('');
|
|
236
|
+
const side = yours + page.sections.filter((s) => s.side).map((s) => renderSection(s, ctx)).join('');
|
|
237
|
+
const body = `${chromeTop(chrome)}<main>${flash(chrome)}${tileHtml}${columns(main, side)}</main>${chromeFoot(chrome, true)}`;
|
|
238
|
+
return document(chrome.agent, body, true);
|
|
67
239
|
}
|
|
68
240
|
export function renderLogin(error) {
|
|
69
|
-
return
|
|
241
|
+
return document('loom', `<form class="login" method="post" action="/ui/login"><h1>loom</h1><p class="sub">Paste the bearer token once. This browser keeps a cookie (until the daemon restarts, or 30 days).</p>${error ? `<p class="err">${esc(error)}</p>` : ''}<input type="password" name="token" autocomplete="current-password" autofocus aria-label="token"><button type="submit">Open</button></form>`, false);
|
|
70
242
|
}
|