@jbarket/loomai 0.7.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.
@@ -0,0 +1,242 @@
1
+ import { splitPage } from './vocab.js';
2
+ export function esc(s) {
3
+ return s
4
+ .replace(/&/g, '&')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;')
7
+ .replace(/"/g, '&quot;')
8
+ .replace(/'/g, '&#39;');
9
+ }
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) {
71
+ switch (b.type) {
72
+ case 'stats':
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>`;
79
+ case 'list':
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>`;
84
+ case 'table':
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>`;
88
+ case 'text':
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>`;
92
+ case 'link':
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>`;
109
+ }
110
+ }
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>`;
118
+ }
119
+ const STYLE = `
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}
178
+ `;
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);
239
+ }
240
+ export function renderLogin(error) {
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);
242
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The page vocabulary (docs/extensions.md, "The page").
3
+ *
4
+ * loom's page is built from these shapes, both loom's own pages and the
5
+ * installed pieces'. A piece answers `ui --json` (a manifest), `ui page --json`
6
+ * (a page) and `ui act --json` (an action's result) in this vocabulary. Pieces
7
+ * send data, never markup: loom renders every block itself and escapes every
8
+ * string, so a piece can't put HTML or script on loom's page. Anything that
9
+ * doesn't validate is dropped whole (a piece that sends garbage just isn't
10
+ * shown), never half-rendered.
11
+ */
12
+ export type Tone = 'ok' | 'warn' | 'bad' | 'muted';
13
+ /** Where a link goes: a page of the same source, one of loom's (`loom:` prefix), or outside. */
14
+ export interface Target {
15
+ page?: string;
16
+ href?: string;
17
+ }
18
+ export type Cell = string | {
19
+ text: string;
20
+ page?: string;
21
+ };
22
+ export interface Field {
23
+ name: string;
24
+ label: string;
25
+ kind: 'text' | 'textarea' | 'choice' | 'secret';
26
+ hint?: string;
27
+ value?: string;
28
+ required?: boolean;
29
+ options?: Array<{
30
+ value: string;
31
+ label: string;
32
+ detail?: string;
33
+ }>;
34
+ }
35
+ export type Block = {
36
+ type: 'stats';
37
+ items: Array<{
38
+ label: string;
39
+ value: string;
40
+ detail?: string;
41
+ tone?: Tone;
42
+ page?: string;
43
+ }>;
44
+ } | {
45
+ type: 'list';
46
+ items: Array<{
47
+ text: string;
48
+ meta?: string;
49
+ badge?: string;
50
+ tone?: Tone;
51
+ } & Target>;
52
+ } | {
53
+ type: 'table';
54
+ columns: string[];
55
+ rows: Cell[][];
56
+ } | {
57
+ type: 'kv';
58
+ items: Array<{
59
+ label: string;
60
+ value: string;
61
+ page?: string;
62
+ }>;
63
+ } | {
64
+ type: 'text';
65
+ text: string;
66
+ tone?: Tone;
67
+ } | {
68
+ type: 'callout';
69
+ text: string;
70
+ title?: string;
71
+ tone?: Tone;
72
+ } | ({
73
+ type: 'link';
74
+ text: string;
75
+ } & Target) | {
76
+ type: 'filters';
77
+ param: string;
78
+ options: Array<{
79
+ value: string;
80
+ label: string;
81
+ }>;
82
+ } | {
83
+ type: 'details';
84
+ summary: string;
85
+ meta?: string;
86
+ blocks: Block[];
87
+ } | {
88
+ type: 'form';
89
+ action: string;
90
+ submit: string;
91
+ fields: Field[];
92
+ title?: string;
93
+ note?: string;
94
+ hidden?: Record<string, string>;
95
+ } | {
96
+ type: 'buttons';
97
+ items: Array<{
98
+ action: string;
99
+ label: string;
100
+ tone?: 'primary' | 'danger';
101
+ fields?: Record<string, string>;
102
+ }>;
103
+ };
104
+ export interface Section {
105
+ id: string;
106
+ title?: string;
107
+ side?: boolean;
108
+ tone?: Tone;
109
+ blocks: Block[];
110
+ }
111
+ export interface Page {
112
+ title: string;
113
+ intro?: string;
114
+ sections: Section[];
115
+ }
116
+ export interface Tile {
117
+ label: string;
118
+ value: string;
119
+ detail?: string;
120
+ tone?: Tone;
121
+ page?: string;
122
+ }
123
+ export interface Ask {
124
+ text: string;
125
+ meta?: string;
126
+ page?: string;
127
+ label?: string;
128
+ }
129
+ export interface Manifest {
130
+ tab?: {
131
+ label: string;
132
+ };
133
+ tile?: Tile;
134
+ asks: Ask[];
135
+ /** A first-version answer: these sections become the piece's page. */
136
+ legacy?: Section[];
137
+ }
138
+ export interface ActResult {
139
+ ok: boolean;
140
+ message: string;
141
+ page?: string;
142
+ }
143
+ export declare const LIMITS: {
144
+ readonly sections: 10;
145
+ readonly blocks: 20;
146
+ readonly items: 200;
147
+ readonly columns: 12;
148
+ readonly string: 2000;
149
+ readonly asks: 10;
150
+ readonly fields: 30;
151
+ readonly hidden: 20;
152
+ readonly options: 50;
153
+ readonly query: 10;
154
+ readonly queryValue: 200;
155
+ readonly path: 200;
156
+ };
157
+ export declare const ID: RegExp;
158
+ export declare const QUERY_KEY: RegExp;
159
+ /** A page path: `""` (the main page) or lowercase segments, never `..`, never absolute. */
160
+ export declare function validPath(p: string): boolean;
161
+ /**
162
+ * A page link: `[loom:]<path>[?<query>]`. Split into the source (loom's own or
163
+ * this piece's), the path and the query, or null when it isn't valid.
164
+ */
165
+ export declare function splitPage(s: string): {
166
+ core: boolean;
167
+ path: string;
168
+ query: Record<string, string>;
169
+ } | null;
170
+ /** Validate a piece's `ui --json` answer (v2, or a first-version `{sections}`). */
171
+ export declare function parseManifest(value: unknown): Manifest | string;
172
+ /** Validate a piece's `ui page --json` answer. */
173
+ export declare function parsePage(value: unknown): Page | string;
174
+ /** Validate a piece's `ui act --json` answer. */
175
+ export declare function parseActResult(value: unknown): ActResult | string;