@fangorn-network/westmarch 0.0.1-dev
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.md +20 -0
- package/README.md +280 -0
- package/consume/apps.js +111 -0
- package/consume/corpora.js +297 -0
- package/consume/directory.js +262 -0
- package/consume/embed.js +251 -0
- package/consume/embed.worker.js +26 -0
- package/consume/lint.js +500 -0
- package/consume/mcp.js +381 -0
- package/consume/rank.js +218 -0
- package/consume/roles.js +556 -0
- package/consume/shard.js +726 -0
- package/consume/steam.js +341 -0
- package/consume/taste-doc.js +342 -0
- package/consume/taste.js +350 -0
- package/consume/tools.js +358 -0
- package/consume/ui.js +724 -0
- package/docs/app-to-agent.md +351 -0
- package/package.json +70 -0
- package/publish/agent-card.js +134 -0
- package/publish/cli.js +336 -0
- package/publish/cohort.js +349 -0
- package/publish/demand.js +537 -0
- package/publish/enrich.js +211 -0
- package/publish/envelope.js +185 -0
- package/publish/graph.js +307 -0
- package/publish/manifest.js +142 -0
- package/publish/reactions.js +343 -0
- package/publish/settle.js +165 -0
- package/publish/terms.js +200 -0
- package/publish/vault.js +162 -0
package/consume/ui.js
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
// A UI for data nobody wrote a UI for.
|
|
2
|
+
//
|
|
3
|
+
// MCP Apps (formerly mcp-ui) lets a tool return an HTML view alongside its JSON:
|
|
4
|
+
// an `EmbeddedResource` content block with a `ui://` uri, which a host renders in
|
|
5
|
+
// a sandboxed iframe and a host that has never heard of it ignores. `resource` is
|
|
6
|
+
// a standard MCP content type, so emitting one is safe on any client — the worst
|
|
7
|
+
// case is that the text block is shown instead, which is what happens today.
|
|
8
|
+
//
|
|
9
|
+
// The reason it is interesting HERE rather than in general: this page does not
|
|
10
|
+
// know what its rows are. It reads whatever a stranger baked, and `roles` is the
|
|
11
|
+
// publisher's own statement of which field is the title, which is the subtitle,
|
|
12
|
+
// which are the tags, which are the measures. That is exactly the input a table
|
|
13
|
+
// renderer needs. So the interface is generated per corpus at the moment of the
|
|
14
|
+
// call, from a declaration that shipped with the data — not designed for films
|
|
15
|
+
// and then reused for games.
|
|
16
|
+
//
|
|
17
|
+
// `rankedList`, `record` and `bars` are PREVIEWS — enough to decide, and then a
|
|
18
|
+
// link out to whoever owns the thing. `stage` is not, and the comment at its own
|
|
19
|
+
// definition argues the reversal: the links went to an app that does not exist,
|
|
20
|
+
// and the presentation belongs to whoever holds the taste.
|
|
21
|
+
//
|
|
22
|
+
// ponytail: four layouts, because the verbs return four shapes — a ranked
|
|
23
|
+
// list, one record, a distribution, and a queue to consume. Not a component
|
|
24
|
+
// library: each one is
|
|
25
|
+
// ASSEMBLED from the role_map at the moment of the call, so the same function
|
|
26
|
+
// previews a video game, a public-domain film and a restaurant differently
|
|
27
|
+
// because their publishers declared different things, not because anyone wrote
|
|
28
|
+
// three templates.
|
|
29
|
+
|
|
30
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
31
|
+
|
|
32
|
+
/** http(s) only. A link comes out of a row someone else published, and this HTML
|
|
33
|
+
* runs in an iframe the host trusts enough to show. `javascript:` never lands
|
|
34
|
+
* in an href from here. */
|
|
35
|
+
function safeHref(url) {
|
|
36
|
+
try {
|
|
37
|
+
const u = new URL(String(url));
|
|
38
|
+
return u.protocol === "https:" || u.protocol === "http:" ? u.toString() : null;
|
|
39
|
+
} catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CSS = `
|
|
43
|
+
:root{color-scheme:light dark;--fg:#111;--dim:#6b7280;--line:#e5e7eb;--bg:#fff;--accent:#2563eb}
|
|
44
|
+
@media (prefers-color-scheme:dark){:root{--fg:#e5e7eb;--dim:#9ca3af;--line:#27272a;--bg:#0b0b0d;--accent:#60a5fa}}
|
|
45
|
+
*{box-sizing:border-box}
|
|
46
|
+
body{margin:0;padding:12px;background:var(--bg);color:var(--fg);font:13px/1.45 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
|
47
|
+
h1{font-size:13px;margin:0 0 2px;font-weight:600}
|
|
48
|
+
.sub{color:var(--dim);font-size:11px;margin-bottom:10px}
|
|
49
|
+
ol{list-style:none;margin:0;padding:0}
|
|
50
|
+
li{display:grid;grid-template-columns:34px 1fr;gap:10px;padding:7px 0;border-top:1px solid var(--line)}
|
|
51
|
+
li:first-child{border-top:0}
|
|
52
|
+
.s{font-variant-numeric:tabular-nums;color:var(--dim);font-size:11px;padding-top:2px}
|
|
53
|
+
.s i{display:block;height:3px;background:var(--accent);border-radius:2px;margin-top:3px;opacity:.65}
|
|
54
|
+
.t{font-weight:600}
|
|
55
|
+
.t a{color:inherit;text-decoration:none;border-bottom:1px solid var(--line)}
|
|
56
|
+
.m{color:var(--dim);font-size:11px;margin-top:1px}
|
|
57
|
+
.c{display:inline-block;padding:0 5px;margin-right:6px;border:1px solid var(--line);border-radius:3px;font-size:10px;color:var(--dim)}
|
|
58
|
+
.x{font-size:11px;margin-top:3px}
|
|
59
|
+
button{font:inherit;font-size:10px;color:var(--dim);background:none;border:1px solid var(--line);border-radius:3px;padding:1px 6px;cursor:pointer}
|
|
60
|
+
button:hover{color:var(--fg);border-color:var(--accent)}
|
|
61
|
+
.empty{color:var(--dim);padding:14px 0}
|
|
62
|
+
/* record */
|
|
63
|
+
.hero{width:100%;max-height:190px;object-fit:cover;border-radius:5px;margin-bottom:10px;display:block}
|
|
64
|
+
.head{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}
|
|
65
|
+
h2{font-size:17px;margin:0;font-weight:650;letter-spacing:-.01em}
|
|
66
|
+
h2 a{color:inherit;text-decoration:none}
|
|
67
|
+
.stats{display:flex;gap:14px;flex-wrap:wrap;margin:10px 0}
|
|
68
|
+
.stat b{display:block;font-size:16px;font-weight:650;font-variant-numeric:tabular-nums}
|
|
69
|
+
.stat span{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.06em}
|
|
70
|
+
.prose{margin:10px 0;line-height:1.55;max-width:62ch}
|
|
71
|
+
dl{display:grid;grid-template-columns:auto 1fr;gap:2px 12px;margin:10px 0 0;font-size:12px}
|
|
72
|
+
dt{color:var(--dim)}
|
|
73
|
+
dd{margin:0}
|
|
74
|
+
.lock{color:var(--dim);border-top:1px solid var(--line);margin-top:12px;padding-top:8px;font-size:11px}
|
|
75
|
+
.acts{margin-top:12px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
|
76
|
+
.acts a{font:inherit;font-size:11px;color:var(--fg);background:var(--accent);border-radius:4px;padding:5px 11px;text-decoration:none;font-weight:600}
|
|
77
|
+
.acts a.alt{background:none;border:1px solid var(--line);color:var(--dim);font-weight:400}
|
|
78
|
+
/* bars */
|
|
79
|
+
.b{display:grid;grid-template-columns:minmax(90px,30%) 1fr 58px;gap:9px;align-items:center;padding:2px 0}
|
|
80
|
+
.b .k{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
81
|
+
.b .g{background:var(--line);height:7px;border-radius:2px;overflow:hidden}
|
|
82
|
+
.b .g i{display:block;height:100%;background:var(--accent)}
|
|
83
|
+
.b .v{text-align:right;color:var(--dim);font-variant-numeric:tabular-nums}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
// The one place this view talks back. `tool` and `prompt` are MCP Apps' own
|
|
87
|
+
// intents; a host that does not implement them drops the message and the buttons
|
|
88
|
+
// are inert, which is why nothing here depends on a reply.
|
|
89
|
+
const JS = `
|
|
90
|
+
document.addEventListener("click", (e) => {
|
|
91
|
+
const b = e.target.closest("button[data-id]"); if (!b) return;
|
|
92
|
+
if (b.dataset.act === "back") return parent.postMessage({ type: "intent", payload: { intent: "back" } }, "*");
|
|
93
|
+
if (b.dataset.act === "like") { b.textContent = "♥ noted"; return parent.postMessage({ type: "tool", payload: { toolName: "note-taste", params: { like: [b.dataset.id], corpus: b.dataset.corpus || undefined } } }, "*"); }
|
|
94
|
+
const msg = b.dataset.act === "similar"
|
|
95
|
+
? { type: "tool", payload: { toolName: "similar-rows", params: { id: b.dataset.id, corpus: b.dataset.corpus || undefined } } }
|
|
96
|
+
: { type: "tool", payload: { toolName: "get-row", params: { id: b.dataset.id, corpus: b.dataset.corpus || undefined } } };
|
|
97
|
+
parent.postMessage(msg, "*");
|
|
98
|
+
});
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Render a ranked list the way its publisher said to.
|
|
103
|
+
*
|
|
104
|
+
* `hits` are what the verbs already return — `brief()` output, so title/subtitle/
|
|
105
|
+
* tags are resolved and `score` is present. `roles` is consulted for the EXTRA
|
|
106
|
+
* columns: measures the publisher declared, and the link field, neither of which
|
|
107
|
+
* brief() flattens. Hits carrying `corpus` (a spanning call) are chipped with it.
|
|
108
|
+
*/
|
|
109
|
+
export function rankedList({ heading = "", note = "", hits = [], roles = null, link = null } = {}) {
|
|
110
|
+
const top = Math.max(...hits.map((h) => Number(h.score) || 0), 0) || 1;
|
|
111
|
+
const measures = (roles?.measures ?? []).slice(0, 3);
|
|
112
|
+
// A ranked list has scores; a set someone MADE has an order. Same layout,
|
|
113
|
+
// and the left gutter says which one you are looking at — an empty score
|
|
114
|
+
// column above a curated playlist is a rank that was never computed.
|
|
115
|
+
const scored = hits.some((h) => h.score != null);
|
|
116
|
+
const items = hits.map((h, i) => {
|
|
117
|
+
const href = safeHref(link ? h[link] : (h.url ?? h.link ?? null));
|
|
118
|
+
const tags = [].concat(h.tags ?? []).filter(Boolean).slice(0, 5);
|
|
119
|
+
const extra = measures.map((m) => (h[m] == null || h[m] === "" ? null : `${esc(m)} ${esc(h[m])}`)).filter(Boolean);
|
|
120
|
+
const pct = Math.round((100 * (Number(h.score) || 0)) / top);
|
|
121
|
+
return `<li>
|
|
122
|
+
<span class="s">${scored ? `${h.score == null ? "" : Number(h.score).toFixed(3)}<i style="width:${pct}%"></i>` : `${i + 1}.`}</span>
|
|
123
|
+
<div>
|
|
124
|
+
<div class="t">${href ? `<a href="${esc(href)}" target="_blank" rel="noreferrer noopener">${esc(h.title)}</a>` : esc(h.title)}</div>
|
|
125
|
+
<div class="m">${h.corpus ? `<span class="c">${esc(h.corpus)}</span>` : ""}${[h.subtitle, ...tags].filter(Boolean).map(esc).join(" · ")}</div>
|
|
126
|
+
${extra.length ? `<div class="m">${extra.join(" · ")}</div>` : ""}
|
|
127
|
+
${h.id ? `<div class="x"><button data-act="row" data-id="${esc(h.id)}" data-corpus="${esc(h.corpus ?? "")}">open</button>
|
|
128
|
+
<button data-act="similar" data-id="${esc(h.id)}" data-corpus="${esc(h.corpus ?? "")}">more like this</button></div>` : ""}
|
|
129
|
+
</div></li>`;
|
|
130
|
+
}).join("");
|
|
131
|
+
return `<style>${CSS}</style><h1>${esc(heading)}</h1><div class="sub">${esc(note)}</div>`
|
|
132
|
+
+ (items ? `<ol>${items}</ol>` : `<div class="empty">nothing matched</div>`)
|
|
133
|
+
+ `<script>${JS}<\/script>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Does this look like something an <img> can show? `media` is a declared role,
|
|
137
|
+
* not a promise of an image — places declares `googleMapsUri` as its media, and
|
|
138
|
+
* a broken <img> is worse than a link. */
|
|
139
|
+
const imageish = (u) => /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i.test(String(u ?? ""));
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The doors out.
|
|
143
|
+
*
|
|
144
|
+
* This file used to mount `<video>` for an action declared `kind: "video"`, and
|
|
145
|
+
* that was the moment the index started turning into a portal. A switchboard
|
|
146
|
+
* does not play the film — it tells you the film exists, roughly what it is, and
|
|
147
|
+
* where to go. So every resolved action becomes a LINK regardless of kind, and
|
|
148
|
+
* the app on the other side owns the player, the wallet and the seventeen verbs
|
|
149
|
+
* that go with it.
|
|
150
|
+
*
|
|
151
|
+
* `kind` is still carried through from the publisher's declaration, because the
|
|
152
|
+
* app on the other side needs it and dropping data at the boundary is how the
|
|
153
|
+
* next person ends up re-baking a corpus.
|
|
154
|
+
*/
|
|
155
|
+
function links(actions) {
|
|
156
|
+
return actions.filter((a) => !a.locked.length && safeHref(a.href))
|
|
157
|
+
.map((a, i) => `<a class="${i ? "alt" : ""}" href="${esc(safeHref(a.href))}" target="_blank" rel="noreferrer noopener">${esc(a.label)}</a>`)
|
|
158
|
+
.join("");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const NOISE = new Set(["vector", "norm", "hasVector", "vectorDim", "text", "id", "owner", "entityType"]);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* One record, laid out by what its publisher declared.
|
|
165
|
+
*
|
|
166
|
+
* This is the JIT part, and the reason it is not a template engine: nothing here
|
|
167
|
+
* knows what a game or a film or a restaurant is. `roles.media` decides whether
|
|
168
|
+
* there is a hero image, `roles.measures` decides whether there is a stat row,
|
|
169
|
+
* `roles.text` decides what the prose is, `roles.labels` decides what to call
|
|
170
|
+
* any of it — all published with the data. A bundle that declared none of it
|
|
171
|
+
* still renders, as a title and a field list, which is what it actually is.
|
|
172
|
+
*
|
|
173
|
+
* `locked` is rendered rather than hidden. A record with fields missing and no
|
|
174
|
+
* sign of it is how a person concludes the data is bad instead of unpaid.
|
|
175
|
+
*/
|
|
176
|
+
export function record({ row = {}, roles = {}, corpus = null, heading = "", locked = [], price = "", back = false, actions = [] } = {}) {
|
|
177
|
+
const label = (f) => esc(roles.fieldLabels?.[f] ?? f);
|
|
178
|
+
const val = (v) => (Array.isArray(v) ? v.join(", ") : String(v));
|
|
179
|
+
// Undeclared fields are whatever the publisher happened to ship, and one of
|
|
180
|
+
// them is a 6 KB wall of scraped event listings. Prose the publisher DECLARED
|
|
181
|
+
// is shown whole; a field nobody claimed was prose is clipped.
|
|
182
|
+
const clip = (t) => (t.length > 320 ? `${t.slice(0, 320)}…` : t);
|
|
183
|
+
const has = (f) => row[f] != null && row[f] !== "" && !(Array.isArray(row[f]) && !row[f].length);
|
|
184
|
+
|
|
185
|
+
const title = roles.title?.find(has) ? val(row[roles.title.find(has)]) : (row.title ?? row.id ?? "untitled");
|
|
186
|
+
const sub = roles.subtitle?.find(has) ? val(row[roles.subtitle.find(has)]) : null;
|
|
187
|
+
const icon = roles.types?.[row.entityType]?.icon ?? "";
|
|
188
|
+
const href = safeHref(row.url ?? row.link ?? null);
|
|
189
|
+
const media = roles.media?.map((f) => row[f]).find(Boolean) ?? null;
|
|
190
|
+
const measures = (roles.measures ?? []).filter(has);
|
|
191
|
+
const tags = (roles.tags ?? []).filter(has).flatMap((f) => [].concat(row[f])).filter(Boolean).slice(0, 12);
|
|
192
|
+
const prose = (roles.text ?? []).filter(has).map((f) => val(row[f]));
|
|
193
|
+
const place = (roles.spatial ?? []).filter(has).map((f) => val(row[f])).join(", ");
|
|
194
|
+
|
|
195
|
+
// Whatever is left that the publisher named — shown, but only once. A
|
|
196
|
+
// detail view that repeats the title as a field reads as a dump.
|
|
197
|
+
const shown = new Set([...(roles.title ?? []), ...(roles.subtitle ?? []), ...(roles.tags ?? []),
|
|
198
|
+
...(roles.text ?? []), ...(roles.measures ?? []), ...(roles.spatial ?? []),
|
|
199
|
+
...(roles.media ?? []), "url", "link"]);
|
|
200
|
+
const rest = Object.keys(row).filter((f) => !shown.has(f) && !NOISE.has(f) && has(f));
|
|
201
|
+
|
|
202
|
+
return `<style>${CSS}</style>
|
|
203
|
+
${heading ? `<div class="sub">${esc(heading)}</div>` : ""}
|
|
204
|
+
${media && imageish(media) ? `<img class="hero" src="${esc(safeHref(media) ?? "")}" alt="">` : ""}
|
|
205
|
+
<div class="head">
|
|
206
|
+
<h2>${href ? `<a href="${esc(href)}" target="_blank" rel="noreferrer noopener">${icon ? `${esc(icon)} ` : ""}${esc(title)}</a>`
|
|
207
|
+
: `${icon ? `${esc(icon)} ` : ""}${esc(title)}`}</h2>
|
|
208
|
+
${corpus ? `<span class="c">${esc(corpus)}</span>` : ""}
|
|
209
|
+
</div>
|
|
210
|
+
${sub || place ? `<div class="m">${[sub, place].filter(Boolean).map(esc).join(" · ")}</div>` : ""}
|
|
211
|
+
${measures.length ? `<div class="stats">${measures.map((f) =>
|
|
212
|
+
`<span class="stat"><b>${esc(val(row[f]))}</b><span>${label(f)}</span></span>`).join("")}</div>` : ""}
|
|
213
|
+
${tags.length ? `<div class="m" style="margin-top:8px">${tags.map((t) => `<span class="c">${esc(t)}</span>`).join("")}</div>` : ""}
|
|
214
|
+
${prose.map((t) => `<p class="prose">${esc(t)}</p>`).join("")}
|
|
215
|
+
${rest.length ? `<dl>${rest.map((f) => `<dt>${label(f)}</dt><dd>${esc(clip(val(row[f])))}</dd>`).join("")}</dl>` : ""}
|
|
216
|
+
${media && !imageish(media) && safeHref(media) && safeHref(media) !== href ? `<div class="m" style="margin-top:8px"><a href="${esc(safeHref(media))}" target="_blank" rel="noreferrer noopener">${esc(label(roles.media.find((f) => row[f] === media)))}</a></div>` : ""}
|
|
217
|
+
${(() => {
|
|
218
|
+
// What you cannot have yet, named. Ranking is complete on the free tier —
|
|
219
|
+
// this row is in the right place — so the only thing missing is fields, and
|
|
220
|
+
// saying which ones is the difference between a purchase and a refund.
|
|
221
|
+
const shut = actions.filter((a) => a.locked.length);
|
|
222
|
+
const fields = [...new Set([...locked, ...shut.flatMap((a) => a.locked)])];
|
|
223
|
+
if (!fields.length) return "";
|
|
224
|
+
return `<div class="lock">${shut.length ? `${shut.map((a) => esc(a.label)).join(", ")} and ` : ""}`
|
|
225
|
+
+ `${fields.length} field${fields.length === 1 ? "" : "s"} withheld by the publisher${price ? ` · ${esc(price)}` : ""}`
|
|
226
|
+
+ ` — ${fields.slice(0, 6).map(esc).join(", ")}${fields.length > 6 ? " …" : ""}</div>`;
|
|
227
|
+
})()}
|
|
228
|
+
<div class="acts">
|
|
229
|
+
${links(actions)}
|
|
230
|
+
${row.id ? `<button data-act="like" data-id="${esc(row.id)}" data-corpus="${esc(corpus ?? "")}">♥ more of this</button>` : ""}
|
|
231
|
+
${row.id ? `<button data-act="similar" data-id="${esc(row.id)}" data-corpus="${esc(corpus ?? "")}">similar</button>` : ""}
|
|
232
|
+
${back ? `<button data-act="back" data-id="">← results</button>` : ""}
|
|
233
|
+
</div>
|
|
234
|
+
<script>${JS}<\/script>`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A distribution: field coverage, facet counts, collection sizes. One shape, so
|
|
239
|
+
* one layout — `[{ label, count }]` with bars relative to the largest.
|
|
240
|
+
*/
|
|
241
|
+
export function bars({ heading = "", note = "", items = [], suffix = "" } = {}) {
|
|
242
|
+
const top = Math.max(...items.map((i) => Number(i.count) || 0), 1);
|
|
243
|
+
return `<style>${CSS}</style><h1>${esc(heading)}</h1><div class="sub">${esc(note)}</div>`
|
|
244
|
+
+ (items.length
|
|
245
|
+
? items.map((i) => `<div class="b"><span class="k">${esc(i.label)}</span>`
|
|
246
|
+
+ `<span class="g"><i style="width:${Math.round((100 * (Number(i.count) || 0)) / top)}%"></i></span>`
|
|
247
|
+
+ `<span class="v">${esc(Number(i.count).toLocaleString())}${esc(suffix)}</span></div>`).join("")
|
|
248
|
+
: `<div class="empty">nothing to count</div>`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Wrap HTML as the content block a host renders.
|
|
253
|
+
*
|
|
254
|
+
* `ui://` is what marks it as a view rather than an attachment, and the uri is
|
|
255
|
+
* per call — the same tool called twice is two views, and a host that caches by
|
|
256
|
+
* uri would otherwise show the first one forever.
|
|
257
|
+
*/
|
|
258
|
+
let seq = 0;
|
|
259
|
+
export const uiResource = (html, name = "view") => ({
|
|
260
|
+
type: "resource",
|
|
261
|
+
resource: { uri: `ui://westmarch/${name}/${++seq}`, mimeType: "text/html", text: html },
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
// ── the stage ───────────────────────────────────────────────────────────────
|
|
266
|
+
//
|
|
267
|
+
// The three exports above DESCRIBE rows. This one plays them, and that is a
|
|
268
|
+
// reversal of a rule this file used to hold.
|
|
269
|
+
//
|
|
270
|
+
// The rule was: the index is a switchboard, an app owns the player, so a record
|
|
271
|
+
// here shows you enough to decide and hands you a link. It was the right rule
|
|
272
|
+
// while there was an app on the other end of the link. There is not. `launch`
|
|
273
|
+
// points at `http://localhost:5173`, which is nobody's television, and every
|
|
274
|
+
// hand-off ends at a page whose only advantage over this one is that somebody
|
|
275
|
+
// wrote a player for exactly one content type.
|
|
276
|
+
//
|
|
277
|
+
// So the presentation moves here, and the argument for it is the same argument
|
|
278
|
+
// as everywhere else in this library: nothing here knows what a film is. A row
|
|
279
|
+
// is playable because the publisher declared a `media` role and the row carries
|
|
280
|
+
// a `mime` — the same two facts an agent reads. `shapeOf` is the whole of the
|
|
281
|
+
// content-type half, five branches, and a publisher who ships audio gets an
|
|
282
|
+
// audio player without telling anyone.
|
|
283
|
+
//
|
|
284
|
+
// The other half is order. The queue arrives ranked by the taste kernel and is
|
|
285
|
+
// re-ranked in place as you use it: finishing something counts as a like,
|
|
286
|
+
// skipping counts against, and the host pushes the new order back into this
|
|
287
|
+
// frame WITHOUT touching the stage — which is why the media keeps playing while
|
|
288
|
+
// the list under it moves. That visible movement is the product. It is the only
|
|
289
|
+
// screen in the system where a person can watch their own preferences act on
|
|
290
|
+
// something.
|
|
291
|
+
|
|
292
|
+
const MEDIA_EXT = {
|
|
293
|
+
video: ["mp4", "ogv", "webm", "m4v", "mov", "mkv"],
|
|
294
|
+
audio: ["mp3", "ogg", "oga", "flac", "wav", "m4a", "opus", "aac"],
|
|
295
|
+
image: ["png", "jpg", "jpeg", "gif", "webp", "avif", "svg"],
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/** The publisher's own media field, resolved to something an element can load.
|
|
299
|
+
* `roles.media` is a declaration, not a promise — places declares a Google Maps
|
|
300
|
+
* link as its media — so the caller still has to ask what SHAPE came back. */
|
|
301
|
+
function sourceOf(row, roles = {}) {
|
|
302
|
+
for (const f of roles.media ?? []) {
|
|
303
|
+
const u = safeHref(row?.[f]);
|
|
304
|
+
if (u) return u;
|
|
305
|
+
}
|
|
306
|
+
return safeHref(row?.url ?? row?.link ?? null);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* What kind of thing is this, as far as presenting it goes.
|
|
311
|
+
*
|
|
312
|
+
* From the row and the role_map only. No corpus is named anywhere in here: the
|
|
313
|
+
* archive bundle gets a video player because its rows say `video/mp4`, not
|
|
314
|
+
* because anyone wrote a branch for archive.org, and the first publisher to bake
|
|
315
|
+
* a podcast gets an audio player on the day they bake it.
|
|
316
|
+
*
|
|
317
|
+
* `mime` wins over the extension — `.ogg` is audio and `video/ogg` is not — and
|
|
318
|
+
* a row with neither falls through to the two shapes that need no media at all.
|
|
319
|
+
*/
|
|
320
|
+
export function shapeOf(row, roles = {}) {
|
|
321
|
+
const mime = String(row?.mime ?? row?.contentType ?? "");
|
|
322
|
+
const src = sourceOf(row, roles);
|
|
323
|
+
const ext = src ? (src.split(/[?#]/)[0].split(".").pop() ?? "").toLowerCase() : "";
|
|
324
|
+
for (const kind of ["video", "audio", "image"]) {
|
|
325
|
+
if (mime.startsWith(`${kind}/`) || (!mime && MEDIA_EXT[kind].includes(ext))) return kind;
|
|
326
|
+
}
|
|
327
|
+
if (row?.coordinates || (roles.spatial ?? []).some((f) => row?.[f])) return "place";
|
|
328
|
+
return "read";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const GLYPH = { video: "▶", audio: "♪", image: "▣", place: "⌖", read: "¶" };
|
|
332
|
+
|
|
333
|
+
/** Why this is on screen when what matched was something else. Naming the corpus
|
|
334
|
+
* that scored is not a footnote: a person who typed a line of dialogue and got
|
|
335
|
+
* a film has to be able to tell that the film is the ANSWER and not a guess. */
|
|
336
|
+
function viaLine(via) {
|
|
337
|
+
const at = Number(via.at);
|
|
338
|
+
const when = Number.isFinite(at) && at > 0
|
|
339
|
+
? ` at ${Math.floor(at / 60)}:${String(Math.floor(at % 60)).padStart(2, "0")}`
|
|
340
|
+
: "";
|
|
341
|
+
const more = via.also ? ` · ${via.also + 1} passages matched` : "";
|
|
342
|
+
return `found by ${via.corpus ?? "a passage"}${when}${more}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Seconds as a publisher happens to have written them. A measure called
|
|
346
|
+
* `duration` is 399 in one bundle and "6:39" in another; only the first is
|
|
347
|
+
* worth reformatting, and the second is already what we would produce. */
|
|
348
|
+
const clock = (v) => {
|
|
349
|
+
const s = Number(v);
|
|
350
|
+
if (!Number.isFinite(s) || s <= 0) return null;
|
|
351
|
+
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = Math.round(s % 60);
|
|
352
|
+
return h ? `${h}:${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}` : `${m}:${String(r).padStart(2, "0")}`;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const STAGE_CSS = `
|
|
356
|
+
#stage{margin:0 0 14px}
|
|
357
|
+
.player{width:100%;max-height:56vh;background:#000;border-radius:6px;display:block}
|
|
358
|
+
audio.player{max-height:none;height:44px;background:none}
|
|
359
|
+
.cover{width:100%;max-height:34vh;object-fit:cover;border-radius:6px;display:block;margin-bottom:9px}
|
|
360
|
+
.why{color:var(--dim);font-size:11px;margin-top:8px}
|
|
361
|
+
.via{color:var(--dim);font-size:11px;margin-top:6px;border-left:2px solid var(--accent);padding-left:9px}
|
|
362
|
+
.via q{display:block;color:var(--fg);font-style:italic;margin-top:3px;quotes:none;max-width:60ch}
|
|
363
|
+
.acts button{font-size:12px;padding:5px 12px}
|
|
364
|
+
.acts button.key{border-color:var(--accent);color:var(--fg)}
|
|
365
|
+
.qhead{color:var(--dim);font-size:10px;letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--line);padding-top:9px;margin-top:16px}
|
|
366
|
+
#queue li{grid-template-columns:16px 1fr auto;cursor:pointer;align-items:baseline}
|
|
367
|
+
#queue li:hover .t{color:var(--accent)}
|
|
368
|
+
#queue .g{color:var(--dim);font-size:11px}
|
|
369
|
+
#queue .d{color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
370
|
+
.moved{animation:flash 1.2s ease-out}
|
|
371
|
+
@keyframes flash{from{background:color-mix(in srgb,var(--accent) 18%,transparent)}to{background:transparent}}
|
|
372
|
+
`;
|
|
373
|
+
|
|
374
|
+
// The frame drives itself. Everything below runs with no host attached — the
|
|
375
|
+
// queue advances, the media plays, `ended` moves on — and a host that IS
|
|
376
|
+
// attached gets told about every like and skip and may push a new order back.
|
|
377
|
+
// Splitting `drawStage` from `drawQueue` is the whole reason that push is
|
|
378
|
+
// possible: re-rendering the document to reorder a list would restart the film.
|
|
379
|
+
const STAGE_JS = `
|
|
380
|
+
let ITEMS = window.__ITEMS__, i = 0;
|
|
381
|
+
const $s = document.getElementById("stage"), $q = document.getElementById("queue"), $qn = document.getElementById("qnote");
|
|
382
|
+
const send = (m) => parent.postMessage(m, "*");
|
|
383
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&":"&","<":"<",">":">",'"':""" }[c]));
|
|
384
|
+
const taste = (act, it) => it && send({ type:"tool", payload:{ toolName:"note-taste", params:{ [act]:[it.id], corpus: it.corpus || undefined } } });
|
|
385
|
+
|
|
386
|
+
function drawStage() {
|
|
387
|
+
const it = ITEMS[i];
|
|
388
|
+
if (!it) { $s.innerHTML = '<div class="empty">queue finished — ask something else, or let the taste pick again</div>'; return drawQueue(); }
|
|
389
|
+
$s.innerHTML = it.html;
|
|
390
|
+
const m = $s.querySelector("video,audio");
|
|
391
|
+
// Watched to the end is the strongest signal this surface can collect, and it
|
|
392
|
+
// costs the person nothing to give. Skipping is the other half of it.
|
|
393
|
+
if (m) m.addEventListener("ended", () => { taste("like", it); next(); }, { once: true });
|
|
394
|
+
drawQueue();
|
|
395
|
+
}
|
|
396
|
+
function drawQueue(flash) {
|
|
397
|
+
const rest = ITEMS.slice(i + 1);
|
|
398
|
+
$qn.textContent = rest.length ? rest.length + " queued" : "";
|
|
399
|
+
$q.innerHTML = rest.length ? rest.map((q, n) =>
|
|
400
|
+
'<li data-act="jump" data-n="' + n + '"><span class="g">' + esc(q.glyph) + '</span>'
|
|
401
|
+
+ '<div><div class="t">' + esc(q.title) + '</div><div class="m">'
|
|
402
|
+
+ (q.corpus ? '<span class="c">' + esc(q.corpus) + '</span>' : "") + esc(q.why ?? "") + '</div></div>'
|
|
403
|
+
+ '<span class="d">' + esc(q.length ?? "") + '</span></li>').join("")
|
|
404
|
+
: '<li class="empty">nothing else queued</li>';
|
|
405
|
+
if (flash) for (const li of $q.children) li.classList.add("moved");
|
|
406
|
+
}
|
|
407
|
+
function next() { i++; drawStage(); }
|
|
408
|
+
|
|
409
|
+
document.addEventListener("click", (e) => {
|
|
410
|
+
const el = e.target.closest("[data-act]"); if (!el) return;
|
|
411
|
+
const it = ITEMS[i], act = el.dataset.act;
|
|
412
|
+
if (act === "like") { el.textContent = "♥ noted"; return taste("like", it); }
|
|
413
|
+
if (act === "skip") { taste("dislike", it); return next(); }
|
|
414
|
+
if (act === "next") { return next(); }
|
|
415
|
+
if (act === "jump") { i += 1 + Number(el.dataset.n); return drawStage(); }
|
|
416
|
+
if (act === "similar") { return it && send({ type:"tool", payload:{ toolName:"similar-rows", params:{ id: it.id, corpus: it.corpus || undefined } } }); }
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// The host answering a like or a skip with a new order. Only the queue is
|
|
420
|
+
// redrawn; the stage — and whatever is playing in it — is left alone.
|
|
421
|
+
addEventListener("message", (e) => {
|
|
422
|
+
if (e.data?.type !== "queue") return;
|
|
423
|
+
const seen = new Set(ITEMS.slice(0, i + 1).map((p) => p.id));
|
|
424
|
+
ITEMS = [...ITEMS.slice(0, i + 1), ...(e.data.payload?.items ?? []).filter((r) => !seen.has(r.id))];
|
|
425
|
+
drawQueue(true);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
drawStage();
|
|
429
|
+
`;
|
|
430
|
+
|
|
431
|
+
/** One item, laid out for its shape. Everything it needs comes from the roles
|
|
432
|
+
* the publisher declared plus the row itself, which is why a corpus nobody
|
|
433
|
+
* wrote code for still lands on a page rather than a link. */
|
|
434
|
+
function stageItem(row, roles = {}, corpus = null, via = null) {
|
|
435
|
+
const has = (f) => row[f] != null && row[f] !== "" && !(Array.isArray(row[f]) && !row[f].length);
|
|
436
|
+
const val = (v) => (Array.isArray(v) ? v.join(", ") : String(v));
|
|
437
|
+
const shape = shapeOf(row, roles);
|
|
438
|
+
const src = sourceOf(row, roles);
|
|
439
|
+
const title = roles.title?.find(has) ? val(row[roles.title.find(has)]) : (row.title ?? row.id ?? "untitled");
|
|
440
|
+
const sub = roles.subtitle?.find(has) ? val(row[roles.subtitle.find(has)]) : null;
|
|
441
|
+
const icon = roles.types?.[row.entityType]?.icon ?? "";
|
|
442
|
+
const place = (roles.spatial ?? []).filter(has).map((f) => val(row[f])).join(", ");
|
|
443
|
+
const tags = (roles.tags ?? []).filter(has).flatMap((f) => [].concat(row[f])).filter(Boolean).slice(0, 8);
|
|
444
|
+
const prose = (roles.text ?? []).filter(has).map((f) => val(row[f])).join(" ").slice(0, 700);
|
|
445
|
+
const poster = safeHref(row.thumb ?? row.imageUrl ?? row.image ?? null);
|
|
446
|
+
const length = clock((roles.measures ?? []).map((f) => row[f]).find((v) => Number(v) > 0) ?? row.duration);
|
|
447
|
+
const out = safeHref(row.url ?? row.link ?? null);
|
|
448
|
+
|
|
449
|
+
// A media fragment, when a subtitle brought us here and its publisher gave
|
|
450
|
+
// the timecode away. `#t=` is what makes "search what was said, arrive at
|
|
451
|
+
// what was filmed" land on the SECOND rather than on the film — and it costs
|
|
452
|
+
// nothing: the range request the player already makes just starts later.
|
|
453
|
+
const seek = Number(via?.at);
|
|
454
|
+
const play = src && Number.isFinite(seek) && seek > 0 ? `${src}#t=${Math.floor(seek)}` : src;
|
|
455
|
+
|
|
456
|
+
const media =
|
|
457
|
+
shape === "video" && src ? `<video class="player" controls preload="metadata"${poster ? ` poster="${esc(poster)}"` : ""} src="${esc(play)}"></video>`
|
|
458
|
+
: shape === "audio" && src ? `${poster ? `<img class="cover" src="${esc(poster)}" alt="">` : ""}<audio class="player" controls preload="metadata" src="${esc(play)}"></audio>`
|
|
459
|
+
: shape === "image" && src ? `<img class="cover" style="max-height:52vh" src="${esc(src)}" alt="">`
|
|
460
|
+
: poster ? `<img class="cover" src="${esc(poster)}" alt="">`
|
|
461
|
+
: "";
|
|
462
|
+
|
|
463
|
+
return { shape, title, length, html: `${media}
|
|
464
|
+
<div class="head"><h2>${icon ? `${esc(icon)} ` : ""}${esc(title)}</h2>${corpus ? `<span class="c">${esc(corpus)}</span>` : ""}</div>
|
|
465
|
+
${sub || place || length ? `<div class="m">${[sub, place, length].filter(Boolean).map(esc).join(" · ")}</div>` : ""}
|
|
466
|
+
${via ? `<div class="via">${esc(viaLine(via))}${via.line ? `<q>${esc(via.line)}</q>` : ""}</div>` : ""}
|
|
467
|
+
${tags.length ? `<div class="m" style="margin-top:6px">${tags.map((t) => `<span class="c">${esc(t)}</span>`).join("")}</div>` : ""}
|
|
468
|
+
${prose ? `<p class="prose">${esc(prose)}</p>` : ""}
|
|
469
|
+
<div class="acts">
|
|
470
|
+
<button class="key" data-act="like">♥ more of this</button>
|
|
471
|
+
<button data-act="skip">✕ not this</button>
|
|
472
|
+
<button data-act="next">skip ⏭</button>
|
|
473
|
+
<button data-act="similar">similar</button>
|
|
474
|
+
${out ? `<a class="alt" href="${esc(out)}" target="_blank" rel="noreferrer noopener">source</a>` : ""}
|
|
475
|
+
</div>` };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* A queue, presented.
|
|
480
|
+
*
|
|
481
|
+
* `picks` is `[{ row, roles, corpus, why }]` — rows rather than briefs, because
|
|
482
|
+
* `brief()` resolves the publisher's LINK and a player needs their FILE, and the
|
|
483
|
+
* two are different fields on the same row. Mixed corpora are the interesting
|
|
484
|
+
* case and are handled by each pick carrying its own roles: a film, then a place,
|
|
485
|
+
* then a game, ordered by one taste and rendered by three publishers' own
|
|
486
|
+
* declarations.
|
|
487
|
+
*/
|
|
488
|
+
export function stageItems(picks = []) {
|
|
489
|
+
return picks.map(({ row, roles = {}, corpus = null, why = "", via = null }) => {
|
|
490
|
+
const it = stageItem(row, roles, corpus, via);
|
|
491
|
+
return { id: row.id, corpus, why, title: it.title, glyph: GLYPH[it.shape] ?? "·", length: it.length, html: it.html };
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function stage({ picks = [], heading = "", note = "" } = {}) {
|
|
496
|
+
const items = stageItems(picks);
|
|
497
|
+
// `</script>` inside a row someone else published would end this script tag.
|
|
498
|
+
const json = JSON.stringify(items).replace(/</g, "\\u003c");
|
|
499
|
+
return `<style>${CSS}${STAGE_CSS}</style>
|
|
500
|
+
<h1>${esc(heading)}</h1><div class="sub">${esc(note)}</div>
|
|
501
|
+
<div id="stage"></div>
|
|
502
|
+
<div class="qhead">up next <span id="qnote"></span></div>
|
|
503
|
+
<ol id="queue"></ol>
|
|
504
|
+
<script>window.__ITEMS__ = ${json};${STAGE_JS}<\/script>`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── self-check: `node consume/ui.js` ────────────────────────────────────────
|
|
508
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
509
|
+
const roles = { measures: ["rating", "year", "runtime", "votes"] };
|
|
510
|
+
const html = rankedList({
|
|
511
|
+
heading: 'search "frozen"', note: "2 corpora",
|
|
512
|
+
roles,
|
|
513
|
+
hits: [
|
|
514
|
+
{ id: "g1", corpus: "games", title: "Frostpunk", subtitle: "2018", tags: ["survival", "strategy"], score: 0.663, rating: 9, url: "https://en.wikipedia.org/wiki/Frostpunk" },
|
|
515
|
+
{ id: "f1", corpus: "archive-films", title: "<script>alert(1)</script>", subtitle: null, tags: [], score: 0.4, url: "javascript:alert(1)" },
|
|
516
|
+
],
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// The trust boundary: titles and links come from a stranger's shard.
|
|
520
|
+
if (html.includes("<script>alert(1)")) throw new Error("a row title must not reach the DOM unescaped");
|
|
521
|
+
if (html.includes("javascript:alert")) throw new Error("a javascript: url must never become an href");
|
|
522
|
+
if (!html.includes('href="https://en.wikipedia.org/wiki/Frostpunk"')) throw new Error("an http link must survive");
|
|
523
|
+
|
|
524
|
+
// Role-driven, not hardcoded: `rating` is declared and present, so it shows;
|
|
525
|
+
// `runtime` is declared and absent, so it does not become an empty column.
|
|
526
|
+
if (!html.includes("rating 9")) throw new Error("a declared measure present on the row must render");
|
|
527
|
+
if (html.includes("runtime")) throw new Error("a declared measure absent from every row must not render");
|
|
528
|
+
// …and a corpus that declares no measures renders the same list with no extras.
|
|
529
|
+
const bare = rankedList({ hits: [{ id: "x", title: "T", score: 1 }] });
|
|
530
|
+
if (bare.slice(bare.indexOf("<ol>"), bare.indexOf("</ol>")).includes("undefined")) throw new Error("no roles must not leak undefined into a row");
|
|
531
|
+
|
|
532
|
+
// A spanning call chips each hit with where it came from.
|
|
533
|
+
if (!html.includes(">games<") || !html.includes(">archive-films<")) throw new Error("a merged hit must name its corpus");
|
|
534
|
+
|
|
535
|
+
// Empty is a rendered state, not a blank page.
|
|
536
|
+
if (!rankedList({ heading: "x", hits: [] }).includes("nothing matched")) throw new Error("no hits must still render something");
|
|
537
|
+
|
|
538
|
+
const a = uiResource(html, "search"), b = uiResource(html, "search");
|
|
539
|
+
if (a.resource.uri === b.resource.uri) throw new Error("two calls must not share a uri, or a host caches the first view forever");
|
|
540
|
+
if (a.type !== "resource" || a.resource.mimeType !== "text/html") throw new Error("must be an MCP EmbeddedResource");
|
|
541
|
+
if (!a.resource.uri.startsWith("ui://")) throw new Error("ui:// is what marks it renderable");
|
|
542
|
+
|
|
543
|
+
// A curated set is numbered, not scored.
|
|
544
|
+
{
|
|
545
|
+
const made = rankedList({ heading: "playlist", hits: [{ title: "one" }, { title: "two" }] });
|
|
546
|
+
if (!made.includes(">1.<") || !made.includes(">2.<")) throw new Error("an unscored list must number its rows");
|
|
547
|
+
if (made.includes("width:0%")) throw new Error("…and must not draw an empty score bar");
|
|
548
|
+
const ranked = rankedList({ heading: "search", hits: [{ title: "one", score: 0.5 }] });
|
|
549
|
+
if (!ranked.includes("0.500")) throw new Error("a ranked list still shows its scores");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── record: the layout is the publisher's declaration, not a template ───
|
|
553
|
+
const gameRoles = { title: ["title"], subtitle: ["year"], text: ["desc"], tags: ["genre"],
|
|
554
|
+
measures: [], media: ["thumb"], spatial: [], types: { Game: { icon: "🎮" } },
|
|
555
|
+
fieldLabels: { platform: "Plays on" } };
|
|
556
|
+
const placeRoles = { title: ["title"], subtitle: ["primaryType"], text: ["editorialSummary"],
|
|
557
|
+
tags: ["categories"], measures: ["rating", "userRatingCount"], media: ["googleMapsUri"],
|
|
558
|
+
spatial: ["locality"], types: {}, fieldLabels: { rating: "Rating" } };
|
|
559
|
+
|
|
560
|
+
let r = record({
|
|
561
|
+
row: { id: "Frostpunk", entityType: "Game", title: "Frostpunk", year: "2018", desc: "A city-building survival game.",
|
|
562
|
+
genre: ["survival", "strategy"], platform: ["windows"], thumb: "https://x.test/f.jpg",
|
|
563
|
+
url: "https://en.wikipedia.org/wiki/Frostpunk", vector: [1, 2], norm: 1 },
|
|
564
|
+
roles: gameRoles, corpus: "games", back: true,
|
|
565
|
+
});
|
|
566
|
+
if (!r.includes('class="hero" src="https://x.test/f.jpg"')) throw new Error("a declared image media must be the hero");
|
|
567
|
+
if (!r.includes("🎮 Frostpunk")) throw new Error("the publisher's own icon must be used");
|
|
568
|
+
if (!r.includes("A city-building survival game.")) throw new Error("the declared text role is the prose");
|
|
569
|
+
if (!r.includes("<dt>Plays on</dt>")) throw new Error("undeclared-but-named fields use the publisher's label");
|
|
570
|
+
if (r.includes("vector") || r.includes("norm</dt>")) throw new Error("internals must never render as fields");
|
|
571
|
+
if ((r.match(/Frostpunk<\/a>|>Frostpunk</g) ?? []).length > 1) throw new Error("the title must not repeat as a field");
|
|
572
|
+
if (!r.includes("← results")) throw new Error("back must be offered when the caller asked for it");
|
|
573
|
+
if (!r.includes('class="stats"') === false) throw new Error("a corpus with no measures must draw no stat row");
|
|
574
|
+
|
|
575
|
+
// The SAME function, a publisher that declared other things. No branch here
|
|
576
|
+
// knows what a restaurant is.
|
|
577
|
+
r = record({
|
|
578
|
+
row: { id: "p1", title: "The Wharf", primaryType: "Bar", locality: "Eagle River, WI",
|
|
579
|
+
rating: "4.7", userRatingCount: "218", categories: ["bar", "grill"],
|
|
580
|
+
editorialSummary: "Lakeside bar.", googleMapsUri: "https://maps.test/p1" },
|
|
581
|
+
roles: placeRoles, corpus: "places", locked: ["phone", "website"], price: "0.05 USDC",
|
|
582
|
+
});
|
|
583
|
+
if (r.includes("<img")) throw new Error("a media role that is not an image must not become a broken <img>");
|
|
584
|
+
if (!r.includes('href="https://maps.test/p1"')) throw new Error("…it must still be reachable as a link");
|
|
585
|
+
if (!r.includes("<b>4.7</b>") || !r.includes("<span>Rating</span>")) throw new Error("declared measures render as stats with their label");
|
|
586
|
+
if (!r.includes("2 fields withheld") || !r.includes("0.05 USDC")) throw new Error("withheld fields must be named and priced, not hidden");
|
|
587
|
+
if (r.includes("← results")) throw new Error("back must not appear unasked");
|
|
588
|
+
|
|
589
|
+
// A bundle that declared nothing still renders.
|
|
590
|
+
r = record({ row: { id: "x", name: "loose row", n: 3 }, roles: {} });
|
|
591
|
+
if (!r.includes("loose row") && !r.includes("untitled")) throw new Error("an undeclared row must still render");
|
|
592
|
+
|
|
593
|
+
// ── the doors out, not the thing itself ─────────────────────────────────
|
|
594
|
+
r = record({
|
|
595
|
+
row: { id: "f1", title: "The Temp" }, roles: { title: ["title"] }, price: "0.05 USDC",
|
|
596
|
+
actions: [
|
|
597
|
+
{ kind: "video", label: "Play", href: "https://cdn.test/a.mp4", locked: [], missing: [] },
|
|
598
|
+
{ kind: "link", label: "On archive.org", href: "https://archive.org/details/x", locked: [], missing: [] },
|
|
599
|
+
],
|
|
600
|
+
});
|
|
601
|
+
if (r.includes("<video")) throw new Error("a preview must not mount a player — that is the app's job");
|
|
602
|
+
if (!r.includes('href="https://cdn.test/a.mp4"') || !r.includes(">Play<")) throw new Error("…it must still be a door to it");
|
|
603
|
+
if (!r.includes('href="https://archive.org/details/x"')) throw new Error("a declared link action must be a button");
|
|
604
|
+
if (!r.includes("♥ more of this")) throw new Error("a record must be able to teach the taste");
|
|
605
|
+
|
|
606
|
+
// What you cannot have yet is NAMED. Ranking is complete on the free tier,
|
|
607
|
+
// so the only question left is what a purchase would actually get you.
|
|
608
|
+
r = record({
|
|
609
|
+
row: { id: "f1", title: "The Temp" }, roles: { title: ["title"] }, price: "0.05 USDC",
|
|
610
|
+
actions: [{ kind: "video", label: "Play", href: "https://cdn.test/", locked: ["path"], missing: [] }],
|
|
611
|
+
});
|
|
612
|
+
if (r.includes('href="https://cdn.test/"')) throw new Error("a locked action must not render a door that does not open");
|
|
613
|
+
if (!r.includes("0.05 USDC") || !r.includes("path") || !r.includes("Play")) {
|
|
614
|
+
throw new Error("a withheld action must name itself, its fields and its price");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// A hostile action url is still just a url.
|
|
618
|
+
r = record({ row: { id: "x", title: "T" }, roles: { title: ["title"] },
|
|
619
|
+
actions: [{ kind: "link", label: "P", href: "javascript:alert(1)", locked: [], missing: [] }] });
|
|
620
|
+
if (r.includes("javascript:")) throw new Error("an action url gets the same gate as every other url");
|
|
621
|
+
|
|
622
|
+
// An undeclared field holding 6 KB of scraped text must not become the page.
|
|
623
|
+
r = record({ row: { id: "g", title: "T", blob: "x".repeat(5000) }, roles: { title: ["title"] } });
|
|
624
|
+
if (!r.includes("…") || r.length > 6000) throw new Error(`an undeclared field must be clipped: ${r.length}`);
|
|
625
|
+
|
|
626
|
+
// A publisher whose media role IS the link the title already carries must
|
|
627
|
+
// not print the same url twice under a field name.
|
|
628
|
+
r = record({ row: { id: "g", title: "T", url: "https://x.test/a", link2: null }, roles: { title: ["title"], media: ["url"] } });
|
|
629
|
+
if ((r.match(/https:\/\/x\.test\/a/g) ?? []).length !== 1) throw new Error("the media link must not repeat the title's href");
|
|
630
|
+
|
|
631
|
+
// Hostile input, same gate as the list.
|
|
632
|
+
r = record({ row: { id: "x", title: "<img onerror=alert(1)>", url: "javascript:alert(1)", thumb: "javascript:x.png" }, roles: { title: ["title"], media: ["thumb"] } });
|
|
633
|
+
if (r.includes("<img onerror")) throw new Error("a hostile title must be escaped");
|
|
634
|
+
if (r.includes("javascript:")) throw new Error("javascript: must never reach an href or a src");
|
|
635
|
+
|
|
636
|
+
// ── bars ────────────────────────────────────────────────────────────────
|
|
637
|
+
const bb = bars({ heading: "genre", items: [{ label: "indie", count: 2485 }, { label: "action", count: 843 }] });
|
|
638
|
+
if (!bb.includes("2,485")) throw new Error("counts must be grouped for reading");
|
|
639
|
+
if (!bb.includes('style="width:100%"')) throw new Error("the largest bar is full width");
|
|
640
|
+
if (!bb.includes('style="width:34%"')) throw new Error(`bars are relative to the largest: ${bb.match(/width:\d+%/g)}`);
|
|
641
|
+
if (!bars({ items: [] }).includes("nothing to count")) throw new Error("an empty distribution must say so");
|
|
642
|
+
|
|
643
|
+
// ── the stage: shape from the row, order from the taste ─────────────────
|
|
644
|
+
const filmRoles = { title: ["name"], subtitle: ["creator"], text: ["desc"], tags: ["subject"],
|
|
645
|
+
measures: ["duration", "size"], media: ["url"], spatial: ["country"], types: { video: { icon: "🎞" } } };
|
|
646
|
+
const film = { id: "f1", entityType: "video", name: "The Temp", creator: "Lieberman Bros.", mime: "video/ogg",
|
|
647
|
+
url: "https://archive.org/download/x/x.ogv", thumb: "https://archive.org/services/img/x",
|
|
648
|
+
desc: "Superman gets injured.", subject: ["lego"], duration: 399,
|
|
649
|
+
identifier: "x", vector: [1, 2] };
|
|
650
|
+
|
|
651
|
+
// Content type comes off the row, never off the corpus name.
|
|
652
|
+
if (shapeOf(film, filmRoles) !== "video") throw new Error("a video/* mime is a video");
|
|
653
|
+
if (shapeOf({ url: "https://x.test/a.ogv" }, { media: ["url"] }) !== "video") throw new Error("…and an extension alone is enough");
|
|
654
|
+
if (shapeOf({ mime: "video/ogg", url: "https://x.test/a.ogg" }, { media: ["url"] }) !== "video") throw new Error("mime must beat the extension: .ogg is audio, video/ogg is not");
|
|
655
|
+
if (shapeOf({ mime: "audio/mpeg", url: "https://x.test/a.mp3" }, { media: ["url"] }) !== "audio") throw new Error("an audio publisher gets an audio player without asking");
|
|
656
|
+
if (shapeOf({ googleMapsUri: "https://maps.test/p", locality: "Eagle River" }, placeRoles) !== "place") throw new Error("a maps link is not media — a row with a place is a place");
|
|
657
|
+
if (shapeOf({ title: "T", desc: "words" }, { title: ["title"], text: ["desc"] }) !== "read") throw new Error("a row with no media at all still has a shape");
|
|
658
|
+
|
|
659
|
+
let st = stage({ heading: "for you", note: "1 corpus",
|
|
660
|
+
picks: [{ row: film, roles: filmRoles, corpus: "archive-films", why: "0.71 · your taste" }] });
|
|
661
|
+
|
|
662
|
+
// The reversal, asserted: the stage mounts the publisher's FILE. `record`
|
|
663
|
+
// would have linked to `presentation.externalUrl` — the details page — and a
|
|
664
|
+
// player cannot play that.
|
|
665
|
+
if (!st.includes("https://archive.org/download/x/x.ogv")) throw new Error("the stage plays the media role, not the link role");
|
|
666
|
+
if (!st.includes("poster=")) throw new Error("a thumb the publisher shipped is the poster");
|
|
667
|
+
if (!st.includes("6:39")) throw new Error("a duration measure in seconds must be readable as a clock");
|
|
668
|
+
if (!st.includes("🎞 The Temp")) throw new Error("the publisher's own icon still wins");
|
|
669
|
+
|
|
670
|
+
// The whole point of splitting the two draws: a re-rank arriving mid-play
|
|
671
|
+
// must not touch the element that is playing.
|
|
672
|
+
if (!st.includes('e.data?.type !== "queue"')) throw new Error("the frame must accept a re-ranked queue from the host");
|
|
673
|
+
const handler = st.match(/type !== "queue"[\s\S]*?drawQueue\(true\)/)?.[0];
|
|
674
|
+
if (!handler) throw new Error("a queue push must end in a queue redraw");
|
|
675
|
+
if (handler.includes("drawStage(")) throw new Error("a queue push must redraw the queue only — redrawing the stage restarts the film");
|
|
676
|
+
if (!st.includes('toolName:"note-taste"') && !st.includes('toolName: "note-taste"')) throw new Error("likes and skips must reach the host as the same verb an agent calls");
|
|
677
|
+
|
|
678
|
+
// ── arriving at a moment, from a line ───────────────────────────────────
|
|
679
|
+
// The subtitle corpus scores; the film plays. What must survive the swap is
|
|
680
|
+
// the evidence: which corpus matched, where in the film, and — when its
|
|
681
|
+
// publisher gives the words away — the words.
|
|
682
|
+
st = stage({ picks: [{
|
|
683
|
+
row: film, roles: filmRoles, corpus: "archive-films",
|
|
684
|
+
via: { corpus: "archive-dialogue", at: 41.5, also: 2, line: "we are cut off, and the wire is dead" },
|
|
685
|
+
}] });
|
|
686
|
+
if (!st.includes("x.ogv#t=41")) throw new Error("a timecode the publisher gave away must reach the player as a media fragment");
|
|
687
|
+
if (!st.includes("found by archive-dialogue at 0:41")) throw new Error("a person handed a film for a line they typed must be told why");
|
|
688
|
+
if (!st.includes("3 passages matched")) throw new Error("the other lines into the same film are the evidence, not noise");
|
|
689
|
+
if (!st.includes("we are cut off")) throw new Error("a free line must be quoted — it is the thing that matched");
|
|
690
|
+
|
|
691
|
+
// …and the archive bundle as it actually ships today, which sells `start`.
|
|
692
|
+
// No timecode is not a reason to withhold the film, and it is not a reason
|
|
693
|
+
// to invent second zero either.
|
|
694
|
+
st = stage({ picks: [{ row: film, roles: filmRoles, corpus: "archive-films", via: { corpus: "archive-dialogue", at: null } }] });
|
|
695
|
+
if (st.includes("#t=")) throw new Error("a withheld timecode must not become a fabricated one");
|
|
696
|
+
if (!st.includes("https://archive.org/download/x/x.ogv")) throw new Error("…the film still plays");
|
|
697
|
+
if (!st.includes("found by archive-dialogue")) throw new Error("…and still says what found it");
|
|
698
|
+
if (/found by archive-dialogue at/.test(st)) throw new Error("…without claiming a moment nobody paid for");
|
|
699
|
+
|
|
700
|
+
// Mixed corpora, each rendered by its own publisher's declaration. This is
|
|
701
|
+
// the case that has no ad-tech equivalent and it must not need a branch.
|
|
702
|
+
st = stage({ picks: [
|
|
703
|
+
{ row: film, roles: filmRoles, corpus: "archive-films" },
|
|
704
|
+
{ row: { id: "p1", title: "The Wharf", primaryType: "Bar", locality: "Eagle River, WI", rating: "4.7",
|
|
705
|
+
categories: ["bar"], editorialSummary: "Lakeside bar.", googleMapsUri: "https://maps.test/p1" },
|
|
706
|
+
roles: placeRoles, corpus: "places" },
|
|
707
|
+
] });
|
|
708
|
+
if (!st.includes("The Wharf") || !st.includes("The Temp")) throw new Error("one queue must carry two publishers");
|
|
709
|
+
if (!st.includes("\\u2318") && !st.includes("⌖")) throw new Error("the queue must say what shape each item is");
|
|
710
|
+
|
|
711
|
+
// Same trust boundary as everywhere else, plus one that is specific to
|
|
712
|
+
// shipping rows inside a <script>.
|
|
713
|
+
st = stage({ picks: [{ row: { id: "x", name: "</script><script>alert(1)</script>", mime: "video/mp4",
|
|
714
|
+
url: "javascript:alert(1)", thumb: "javascript:x.png" }, roles: filmRoles }] });
|
|
715
|
+
if (st.includes("</script><script>")) throw new Error("a title must not be able to close the tag its own JSON ships in");
|
|
716
|
+
if (st.includes("javascript:")) throw new Error("javascript: must never become a src, a poster or an href");
|
|
717
|
+
if (!stage({ picks: [] }).includes("queue")) throw new Error("an empty queue must still render a surface");
|
|
718
|
+
|
|
719
|
+
console.log("ui.js self-check ok — list/record/bars all assembled from the publisher's role_map, one record function "
|
|
720
|
+
+ "lays out a game and a restaurant differently, withheld fields named not hidden, internals never rendered, "
|
|
721
|
+
+ "declared actions become doors out and never a player, a withheld one names its price instead, "
|
|
722
|
+
+ "hostile titles and urls neutralised, unique ui:// per call; the stage plays the media role by a shape read "
|
|
723
|
+
+ "off the row, mixes publishers in one queue, seeks to the moment a matched line names without inventing one when the timecode is withheld, and takes a re-ranked queue without restarting what is playing");
|
|
724
|
+
}
|