Public: anyone can read this; the crew can change it.

A card does the host's full handshake and wears the host's theme, so it rende… #11830

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11829-a-card-does-the-host-s-full-handshake-an → main 4 commits fork

  • Merged. scout/11829-a-card-does-the-host-s-full-handshake-an was merged into main as 1f1be71.

Review

Sign in to review this change.
4 files changed +289 −25
docs/MCP.md +7
@@ -570,6 +570,13 @@ answers
570570 words action shows one text field: **Tell <app>** (app card) sends the
571571 words to `tell` as `text` with `repo` and `context.said`.
572572 Empty words send nothing.
573 **What a card sends and expects (MCP Apps handshake).**
574 1. On load it posts `ui/initialize` as a request with an id (`protocolVersion`, `appInfo`, `appCapabilities`).
575 2. On the host's reply (matching id, `result` object) it applies `hostContext`, then posts `ui/notifications/initialized`.
576 3. `hostContext.theme` (`light`/`dark`) sets the colour scheme; without it the OS scheme decides. `styles.variables` (only `--name` keys, string values) become CSS variables, except that `--color-background-primary` and `--color-text-primary` are used only as a pair at 4.5:1 or better (else the card's own palette, `src/http/mcp/card_palette.ts`, stands, so never dark on dark); `styles.css.fonts` is added as a style. `ui/notifications/host-context-changed` does the same later, no reload. Links and buttons are 44 px tall; an empty result is one line; more than ten rows show ten and a "Show N more" button.
577 4. `ui/notifications/tool-result` draws the card, before or after the reply; a host that sends none of this still shows the card's content.
578 5. It posts `ui/notifications/size-changed` (`width`, `height` of the body box, so it can shrink) on load, on content or width change, at most once a frame.
579 6. Only messages from the parent with `jsonrpc: "2.0"` and the expected shape are read; the rest are ignored.
573580 - **The manifest's shape.** `GET /api/v1/here?path=/owner/name` (or the
574581 `here` tool with the page URL) answers `{ here, you, can, mcp, docs }`.
575582 `you.as` is `anonymous`, `reader`, `writer`, `owner` or `agent`; `can` is
src/http/mcp/card_palette.ts added +12
@@ -0,0 +1,12 @@
1 /**
2 * The card's own colours per scheme, used when the host gives none that read.
3 * Each text colour is at 4.5:1 or better on its background; `test/card-shell.test.ts`
4 * holds them to it. The card page is a host's sandboxed frame, so it cannot use
5 * the forge's design tokens; this is the one place its colours are written (a copy
6 * of the forge tokens by design, kept out of src/web, where the design lint
7 * bans colour literals).
8 */
9 export const CARD_PALETTE = {
10 light: { bg: "#ffffff", fg: "#1f1f1e", link: "#0b57d0" },
11 dark: { bg: "#1f1f1e", fg: "#f2f2f0", link: "#8ab4f8" },
12 } as const;
src/web/studio/cards.ts +96 −10
@@ -8,6 +8,7 @@
88 */
99
1010 import { html, raw } from "../html.ts";
11 import { CARD_PALETTE } from "../../http/mcp/card_palette.ts";
1112 import { timeAgo } from "../ui.ts";
1213 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1314
@@ -650,7 +651,9 @@ export function cardFor(
650651 });
651652 }
652653 rows.push(...hits.slice(0, SEARCH_ROWS));
653 status = `${hits.length} hit${hits.length === 1 ? "" : "s"}${hits.length > SEARCH_ROWS ? `, first ${SEARCH_ROWS}` : ""}`;
654 status = hits.length === 0
655 ? `No results${q === "" ? "" : ` for “${q}”`}. Try fewer or different words, or name a repository.`
656 : `${hits.length} hit${hits.length === 1 ? "" : "s"}${hits.length > SEARCH_ROWS ? `, first ${SEARCH_ROWS}` : ""}`;
654657 return { kind, title: q === "" ? CARD_TITLE[kind] : `Search: ${q}`, status, rows, actions };
655658 }
656659 if (kind === "issues") {
@@ -1008,10 +1011,12 @@ function button(a) {
10081011 };
10091012 return b;
10101013 }
1011 function draw(card) {
1014 const SHOWN = 10;
1015 function draw(card, all) {
10121016 root.replaceChildren(el("h2", card.title), el("p", card.status, "status"));
10131017 const dl = document.createElement("dl");
1014 for (const r of card.rows) {
1018 const rows = all ? card.rows : card.rows.slice(0, SHOWN);
1019 for (const r of rows) {
10151020 const dt = el("dt", r.label);
10161021 if (r.depth > 0) dt.style.paddingInlineStart = Math.min(8, r.depth) * 1.25 + "em";
10171022 if (r.link && (r.link.startsWith("https://") || r.link.startsWith("http://"))) { const a = el("a", r.label); a.href = r.link; a.target = "_blank"; a.rel = "noopener noreferrer"; a.onclick = (ev) => { ev.preventDefault(); parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "ui/open-link", params: { url: r.link } }, "*"); }; dt.replaceChildren(a); }
@@ -1019,21 +1024,102 @@ function draw(card) {
10191024 for (const a of r.actions || []) dd.append(button(a));
10201025 dl.append(dt, dd);
10211026 }
1022 root.append(dl);
1023 field = card.actions.some((a) => a.words) ? document.createElement("input") : null;
1027 if (rows.length > 0) root.append(dl);
1028 if (rows.length < card.rows.length) {
1029 const more = el("button", "Show " + (card.rows.length - rows.length) + " more", "more");
1030 more.onclick = () => { draw(card, true); report(); };
1031 root.append(more);
1032 }
1033 // Show-more redraws the rows only: the field the reader typed in is kept.
1034 field = card.actions.some((a) => a.words) ? (all && field ? field : document.createElement("input")) : null;
10241035 if (field) { field.type = "text"; field.maxLength = 2000; field.placeholder = "Your words"; field.setAttribute("aria-label", "Your words"); root.append(field); }
10251036 for (const a of card.actions) root.append(button(a));
10261037 }
1038 // The MCP Apps handshake: ui/initialize is a request (with an id); the host's
1039 // reply carries hostContext (theme, style variables); only then does the card
1040 // say ui/notifications/initialized. A tool result that comes first is drawn at
1041 // once, so a host that sends none of this still shows the content.
1042 const INIT_ID = "init-1";
1043 const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
1044 // Colours: the host's background and text are used only as a pair that reads (4.5:1);
1045 // otherwise the card's own palette for the host's theme (or the OS scheme) stands.
1046 function channels(c) {
1047 if (typeof c !== "string") return null;
1048 let m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim());
1049 if (m) { let h = m[1]; if (h.length === 3) h = h.replace(/./g, "$&$&"); return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); }
1050 m = /^rgba?\\(\\s*(\\d{1,3})[\\s,]+(\\d{1,3})[\\s,]+(\\d{1,3})\\s*(?:[,/]\\s*([\\d.]+%?)\\s*)?\\)$/i.exec(c.trim());
1051 if (m && (m[4] === undefined || parseFloat(m[4]) >= 1)) return [m[1], m[2], m[3]].map(Number).map((n) => Math.min(255, n));
1052 return null;
1053 }
1054 const lum = (c) => { const f = c.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]; };
1055 const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
1056 const OWN = ["--c-bg", "--c-fg", "--c-link"];
1057 function applyContext(ctx) {
1058 if (!isObj(ctx)) return;
1059 const html = document.documentElement;
1060 if (ctx.theme === "light" || ctx.theme === "dark") { html.setAttribute("data-theme", ctx.theme); html.style.colorScheme = ctx.theme; }
1061 const st = isObj(ctx.styles) ? ctx.styles : {};
1062 if (isObj(st.variables)) {
1063 const vars = st.variables;
1064 for (const k of Object.keys(vars)) {
1065 const v = vars[k];
1066 if (/^--(?!color-)[a-z0-9-]{1,80}$/.test(k) && typeof v === "string" && v.length <= 500) html.style.setProperty(k, v);
1067 }
1068 const bg = channels(vars["--color-background-primary"]), fg = channels(vars["--color-text-primary"]);
1069 if (bg && fg && ratio(bg, fg) >= 4.5) {
1070 const info = channels(vars["--color-text-info"]);
1071 html.style.setProperty("--c-bg", vars["--color-background-primary"].trim());
1072 html.style.setProperty("--c-fg", vars["--color-text-primary"].trim());
1073 html.style.setProperty("--c-link", info && ratio(bg, info) >= 4.5 ? vars["--color-text-info"].trim() : vars["--color-text-primary"].trim());
1074 } else for (const k of OWN) html.style.removeProperty(k);
1075 }
1076 if (isObj(st.css) && typeof st.css.fonts === "string" && st.css.fonts.length <= 20000) {
1077 let f = document.getElementById("host-fonts");
1078 if (!f) { f = document.createElement("style"); f.id = "host-fonts"; document.head.append(f); }
1079 // Only @font-face blocks: an @import or a stray rule would let a host make the frame fetch arbitrary CSS.
1080 f.textContent = (st.css.fonts.match(/@font-face\\s*\\{[^{}]*\\}/g) || []).join("\\n");
1081 }
1082 }
1083 let queued = false, lastW = -1, lastH = -1;
1084 function measure() {
1085 queued = false;
1086 const w = Math.ceil(document.documentElement.clientWidth || 0);
1087 const h = Math.ceil(document.body.getBoundingClientRect().height);
1088 if (h === lastH && w === lastW) return;
1089 lastW = w; lastH = h;
1090 parent.postMessage({ jsonrpc: "2.0", method: "ui/notifications/size-changed", params: { width: w, height: h } }, "*");
1091 }
1092 function report() {
1093 if (queued) return;
1094 queued = true;
1095 if (typeof requestAnimationFrame === "function") requestAnimationFrame(measure); else measure();
1096 }
10271097 addEventListener("message", (e) => {
10281098 if (e.source !== parent) return;
10291099 const m = e.data;
1030 if (m && m.method === "ui/notifications/tool-result") {
1031 const card = m.params && m.params.structuredContent && m.params.structuredContent.card;
1032 if (card) draw(card);
1100 if (!isObj(m) || m.jsonrpc !== "2.0") return;
1101 if (m.id === INIT_ID && !("method" in m)) {
1102 if (isObj(m.result)) {
1103 applyContext(m.result.hostContext);
1104 parent.postMessage({ jsonrpc: "2.0", method: "ui/notifications/initialized", params: {} }, "*");
1105 report();
1106 }
1107 return;
10331108 }
1109 const p = isObj(m.params) ? m.params : {};
1110 if (m.method === "ui/notifications/tool-result") {
1111 const sc = isObj(p.structuredContent) ? p.structuredContent : {};
1112 const card = sc.card;
1113 if (isObj(card) && typeof card.title === "string" && Array.isArray(card.rows) && Array.isArray(card.actions)) { draw(card); report(); }
1114 } else if (m.method === "ui/notifications/host-context-changed") applyContext(p);
10341115 });
1035 parent.postMessage({ jsonrpc: "2.0", method: "ui/notifications/initialized", params: {} }, "*");
1116 if (typeof ResizeObserver === "function") new ResizeObserver(report).observe(document.body);
1117 parent.postMessage({ jsonrpc: "2.0", id: INIT_ID, method: "ui/initialize", params: { protocolVersion: "2026-01-26", appInfo: { name: "forge-card", version: "1" }, appCapabilities: {} } }, "*");
1118 report();
10361119 `;
10371120
1121 const pal = (t: "light" | "dark"): string => `--c-bg:${CARD_PALETTE[t].bg};--c-fg:${CARD_PALETTE[t].fg};--c-link:${CARD_PALETTE[t].link};color-scheme:${t}`;
1122 export const CARD_CSS = `:root{${pal("light")}}@media(prefers-color-scheme:dark){:root{${pal("dark")}}}:root[data-theme=light]{${pal("light")}}:root[data-theme=dark]{${pal("dark")}}body{font:14px/1.4 var(--font-sans,system-ui,sans-serif);margin:0;padding:12px;background:var(--c-bg);color:var(--c-fg)}h2{margin:0 0 4px;font-size:16px}.status{margin:0 0 8px}dl{margin:0}dt{font-weight:600;margin-top:6px}dd{margin:0}a{color:var(--c-link);text-underline-offset:2px;display:inline-flex;align-items:center;min-height:44px}button,input{font:inherit;color:var(--c-fg);border:0;background:color-mix(in srgb,var(--c-fg) 12%,var(--c-bg));border-radius:var(--border-radius-sm,6px);min-height:44px;box-sizing:border-box}button{margin:8px 6px 0 0;padding:4px 12px}input{padding:4px 8px;width:100%;margin-top:8px}`;
1123
10381124 export const CARD_SHELL = (kind: CardKind): string =>
1039 html`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${CARD_TITLE[kind]}</title><style>body{font:14px system-ui;margin:0;padding:12px}h2{margin:0 0 4px;font-size:16px}.status{margin:0 0 8px;opacity:.7}dt{font-weight:600;margin-top:6px}dd{margin:0}button{margin:8px 6px 0 0;padding:4px 10px}</style><div id="card"><h2>${CARD_TITLE[kind]}</h2></div><script>${raw(SHELL_JS)}</script>`.text;
1125 html`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta name="color-scheme" content="light dark"><title>${CARD_TITLE[kind]}</title><style>${raw(CARD_CSS)}</style><div id="card"><h2>${CARD_TITLE[kind]}</h2></div><script>${raw(SHELL_JS)}</script>`.text;
test/card-shell.test.ts +174 −15
@@ -1,5 +1,6 @@
11 import { describe, expect, test } from "bun:test";
2 import { SHELL_JS, cardFor } from "../src/web/studio/cards.ts";
2 import { CARD_CSS, CARD_KINDS, CARD_SHELL, SHELL_JS, cardFor } from "../src/web/studio/cards.ts";
3 import { CARD_PALETTE } from "../src/http/mcp/card_palette.ts";
34 import { NO_MARKS, mapJson } from "../src/web/pages/map.ts";
45
56 class Node {
@@ -20,25 +21,44 @@ class Node {
2021 all(): Node[] { return [this, ...this.children.flatMap((c) => c.all())]; }
2122 }
2223
23 function shell() {
24 function shell(opts: { handshake?: boolean } = {}) {
2425 const root = new Node("div");
25 const posted: any[] = [];
26 const parent = { postMessage: (m: unknown) => posted.push(m) };
26 const all: any[] = [];
27 const posted: any[] = []; // what the card asks the host to do; lifecycle messages are in `all`
28 const life = ["ui/initialize", "ui/notifications/initialized", "ui/notifications/size-changed"];
29 const parent = { postMessage: (m: any) => { all.push(m); if (!life.includes(m.method)) posted.push(m); } };
2730 let listener: (e: any) => void = () => {};
28 const document = { getElementById: () => root, createElement: (t: string) => new Node(t) };
29 new Function("document", "parent", "addEventListener", SHELL_JS)(
30 document, parent, (_: string, f: (e: any) => void) => (listener = f),
31 const vars: Record<string, string> = {};
32 const attrs: Record<string, string> = {};
33 const documentElement = { clientWidth: 400, style: { colorScheme: "", setProperty: (k: string, v: string) => (vars[k] = v), removeProperty: (k: string) => delete vars[k] }, setAttribute: (k: string, v: string) => (attrs[k] = v) };
34 const box = { height: 100 };
35 const body = { getBoundingClientRect: () => ({ height: box.height }) };
36 const head = new Node("head");
37 const document = {
38 getElementById: (id: string) => (id === "card" ? root : null),
39 createElement: (t: string) => new Node(t), documentElement, body, head,
40 };
41 const frames: (() => void)[] = [];
42 let resized: () => void = () => {};
43 class RO { constructor(f: () => void) { resized = f; } observe() {} }
44 new Function("document", "parent", "addEventListener", "requestAnimationFrame", "ResizeObserver", SHELL_JS)(
45 document, parent, (_: string, f: (e: any) => void) => (listener = f), (f: () => void) => frames.push(f), RO,
3146 );
3247 const send = (data: unknown, source: unknown = parent) => listener({ source, data });
48 const frame = () => frames.splice(0).forEach((f) => f());
3349 const result = (card: unknown) =>
34 ({ method: "ui/notifications/tool-result", params: { structuredContent: { card } } });
35 return { root, posted, parent, send, result };
50 ({ jsonrpc: "2.0", method: "ui/notifications/tool-result", params: { structuredContent: { card } } });
51 const reply = (hostContext: unknown = {}) =>
52 send({ jsonrpc: "2.0", id: all.find((m) => m.method === "ui/initialize").id, result: { protocolVersion: "2026-01-26", hostInfo: { name: "t", version: "1" }, hostCapabilities: {}, hostContext } });
53 if (opts.handshake !== false) { reply(); frame(); }
54 const sizes = () => all.filter((m) => m.method === "ui/notifications/size-changed").map((m) => m.params.height);
55 return { root, posted, all, parent, send, result, reply, frame, vars, attrs, box, resized: () => resized(), sizes, head };
3656 }
3757
3858 describe("the card shell", () => {
3959 test("announces itself, draws with textContent, ignores other sources, posts tools/call", () => {
4060 const s = shell();
41 expect(s.posted[0].method).toBe("ui/notifications/initialized");
61 expect(s.all[0].method).toBe("ui/initialize");
4262 const card = {
4363 kind: "run", title: "<b>T</b>", status: "ok",
4464 rows: [{ label: "<img src=x onerror=1>", value: "v" }],
@@ -51,12 +71,151 @@ describe("the card shell", () => {
5171 expect(nodes.find((n) => n.tag === "h2")!.textContent).toBe("<b>T</b>");
5272 expect(nodes.find((n) => n.tag === "dt")!.textContent).toBe("<img src=x onerror=1>");
5373 nodes.find((n) => n.tag === "button")!.onclick!();
54 expect(s.posted[1]).toMatchObject({
74 expect(s.posted[0]).toMatchObject({
5575 method: "tools/call",
5676 params: { name: "stop", arguments: { repo: "a/b", run: "r1" } },
5777 });
5878 });
5979
80 test("the handshake: ui/initialize is a request with an id, initialized follows only the host's reply", () => {
81 const s = shell({ handshake: false });
82 expect(s.all.map((m) => m.method)).toEqual(["ui/initialize"]);
83 expect(s.all[0]).toMatchObject({ jsonrpc: "2.0", params: { protocolVersion: "2026-01-26", appInfo: { name: "forge-card" }, appCapabilities: {} } });
84 expect(typeof s.all[0].id).toBe("string");
85 s.send({ jsonrpc: "2.0", id: "other", result: {} });
86 s.send({ jsonrpc: "2.0", id: s.all[0].id, result: {} }, {}); // not the parent
87 s.send({ jsonrpc: "2.0", id: s.all[0].id, result: "nope" });
88 expect(s.all.length).toBe(1);
89 s.reply({});
90 expect(s.all.map((m) => m.method)).toEqual(["ui/initialize", "ui/notifications/initialized"]);
91 });
92
93 test("a host that sends no handshake still gets its card drawn", () => {
94 const s = shell({ handshake: false });
95 s.send(s.result({ kind: "run", title: "T", status: "ok", rows: [], actions: [] }));
96 expect(s.root.all().find((n) => n.tag === "h2")!.textContent).toBe("T");
97 });
98
99 test("a tool result before the reply is kept; malformed messages are ignored", () => {
100 const s = shell({ handshake: false });
101 s.send(null); s.send("x"); s.send({ method: "ui/notifications/tool-result" });
102 s.send({ jsonrpc: "2.0", method: "ui/notifications/tool-result", params: { structuredContent: { card: { title: 1 } } } });
103 expect(s.root.all().some((n) => n.tag === "h2")).toBe(false);
104 s.send(s.result({ kind: "run", title: "Early", status: "", rows: [], actions: [] }));
105 s.reply({ theme: "dark" });
106 expect(s.root.all().find((n) => n.tag === "h2")!.textContent).toBe("Early");
107 expect(s.attrs["data-theme"]).toBe("dark");
108 });
109
110 test("the host's theme and variables apply, later changes too; bad names and values do not", () => {
111 const s = shell({ handshake: false });
112 s.reply({ theme: "dark", styles: { variables: { "--color-background-primary": "#111", "color": "red", "--x": 5, "--font-sans": "Inter" }, css: { fonts: "@font-face{}" } } });
113 expect(s.attrs["data-theme"]).toBe("dark");
114 expect(s.vars).toEqual({ "--font-sans": "Inter" }); // a lone background is not a pair that reads: the card's own palette stands
115 expect(s.head.children[0]!.textContent).toBe("@font-face{}");
116 s.send({ jsonrpc: "2.0", method: "ui/notifications/host-context-changed", params: { theme: "light", styles: { variables: { "--color-background-primary": "#fff" } } } });
117 expect(s.attrs["data-theme"]).toBe("light");
118 expect(s.vars).toEqual({ "--font-sans": "Inter" });
119 s.send({ jsonrpc: "2.0", method: "ui/notifications/host-context-changed", params: { theme: "dark" } }, {});
120 expect(s.attrs["data-theme"]).toBe("light");
121 });
122
123 test("a host's colours are used when they read, and dropped when they do not (dark text on a dark surface)", () => {
124 const s = shell({ handshake: false });
125 s.reply({ theme: "dark", styles: { variables: { "--color-background-primary": "#101010", "--color-text-primary": "#202020" } } });
126 expect(s.vars["--c-bg"]).toBeUndefined();
127 s.send({ jsonrpc: "2.0", method: "ui/notifications/host-context-changed", params: { styles: { variables: { "--color-background-primary": "rgb(16, 16, 16)", "--color-text-primary": "#eee", "--color-text-info": "#222" } } } });
128 expect(s.vars).toMatchObject({ "--c-bg": "rgb(16, 16, 16)", "--c-fg": "#eee", "--c-link": "#eee" }); // an unreadable link colour falls back to the text colour
129 s.send({ jsonrpc: "2.0", method: "ui/notifications/host-context-changed", params: { styles: { variables: { "--color-background-primary": "light-dark(#fff,#000)", "--color-text-primary": "#eee" } } } });
130 expect(s.vars["--c-bg"]).toBeUndefined();
131 });
132
133 test("the card's own palette reads at 4.5:1 in both schemes, and no rule draws a colour outside it", () => {
134 const hex = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
135 const lum = (c: number[]) => { const f = c.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * f[0]! + 0.7152 * f[1]! + 0.0722 * f[2]!; };
136 const ratio = (a: string, b: string) => { const x = lum(hex(a)), y = lum(hex(b)); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
137 for (const t of ["light", "dark"] as const) {
138 expect(ratio(CARD_PALETTE[t].fg, CARD_PALETTE[t].bg)).toBeGreaterThanOrEqual(4.5);
139 expect(ratio(CARD_PALETTE[t].link, CARD_PALETTE[t].bg)).toBeGreaterThanOrEqual(4.5);
140 }
141 // Every text-bearing element takes --c-fg or --c-link on --c-bg; no literal colour is set outside the palette lines.
142 const outside = CARD_CSS.replace(/--c-(bg|fg|link):#[0-9a-f]{6}/g, "");
143 expect(outside).not.toMatch(/#[0-9a-f]{3,6}\b/i);
144 expect(outside.match(/(?<![-\w])color:[^;}]+/g)!.every((d) => d === "color:var(--c-fg)" || d === "color:var(--c-link)")).toBe(true);
145 expect(CARD_CSS).toContain("min-height:44px");
146 });
147
148 test("an empty result is one short line; more than ten rows show ten and a button for the rest", () => {
149 const s = shell();
150 const empty = cardFor("search", { repos: [], issues: [], code: [] }, "https://f.test", { q: "zzz" })!;
151 expect(empty.rows).toEqual([]);
152 expect(empty.status).toBe("No results for “zzz”. Try fewer or different words, or name a repository.");
153 s.send(s.result(empty));
154 expect(s.root.all().some((n) => n.tag === "dl")).toBe(false);
155 expect(s.root.children.length).toBe(2);
156 const rows = Array.from({ length: 17 }, (_, i) => ({ label: `r${i}`, value: "v" }));
157 s.send(s.result({ kind: "search", title: "Search: x", status: "17 hits", rows, actions: [] }));
158 expect(s.root.all().filter((n) => n.tag === "dt").length).toBe(10);
159 const more = s.root.all().find((n) => n.tag === "button")!;
160 expect(more.textContent).toBe("Show 7 more");
161 more.onclick!();
162 expect(s.root.all().filter((n) => n.tag === "dt").length).toBe(17);
163 expect(s.root.all().some((n) => n.tag === "button")).toBe(false);
164 });
165
166 test("host fonts keep only @font-face blocks: an @import or a stray rule is dropped", () => {
167 const s = shell({ handshake: false });
168 s.reply({ styles: { css: { fonts: '@import url(https://evil.test/x.css);@font-face{font-family:A;src:url(https://h.test/a.woff2)}body{background:url(https://evil.test/b)}' } } });
169 expect(s.head.children[0]!.textContent).toBe("@font-face{font-family:A;src:url(https://h.test/a.woff2)}");
170 });
171
172 test("Show more keeps the words typed in the field", () => {
173 const s = shell();
174 const rows = Array.from({ length: 12 }, (_, i) => ({ label: `r${i}`, value: "v" }));
175 s.send(s.result({ kind: "apps", title: "t", status: "s", rows, actions: [{ label: "Tell", tool: "tell", args: { repo: "a/b" }, words: true }] }));
176 const input = s.root.all().find((n) => n.tag === "input")!;
177 input.value = "keep me";
178 s.root.all().find((n) => n.textContent === "Show 2 more")!.onclick!();
179 expect(s.root.all().filter((n) => n.tag === "dt").length).toBe(12);
180 const after = s.root.all().find((n) => n.tag === "input")!;
181 expect(after).toBe(input);
182 expect(after.value).toBe("keep me");
183 });
184
185 test("height is the body box, reported on load, growth and shrink, once a frame", () => {
186 const s = shell();
187 expect(s.sizes()).toEqual([100]);
188 s.box.height = 300; s.resized(); s.resized(); s.resized();
189 s.frame();
190 expect(s.sizes()).toEqual([100, 300]);
191 s.box.height = 120; s.resized(); s.frame();
192 expect(s.sizes()).toEqual([100, 300, 120]);
193 s.resized(); s.frame(); // unchanged: nothing sent
194 expect(s.sizes().length).toBe(3);
195 });
196
197 test("after the handshake is answered, every kind is drawn and the theme applied", () => {
198 for (const kind of CARD_KINDS) {
199 const s = shell({ handshake: false });
200 const card = { kind, title: `Card ${kind}`, status: "s", rows: [{ label: "l", value: "v" }], actions: [] };
201 expect(s.all[0].method).toBe("ui/initialize");
202 s.reply({ theme: "dark" });
203 expect(s.all.some((m) => m.method === "ui/notifications/initialized")).toBe(true);
204 s.send(s.result(card));
205 expect(s.root.all().find((n) => n.tag === "h2")!.textContent).toBe(`Card ${kind}`);
206 expect(s.attrs["data-theme"]).toBe("dark");
207 }
208 });
209
210 test("the served page for every kind wears the host's variables", () => {
211 for (const kind of CARD_KINDS) {
212 const page = CARD_SHELL(kind);
213 expect(page).toContain("color:var(--c-fg)");
214 expect(page).toContain("prefers-color-scheme:dark");
215 expect(page).toContain("ui/initialize");
216 }
217 });
218
60219 test("Tell sends the typed words, and nothing when empty", () => {
61220 const s = shell();
62221 const app = cardFor("apps", [{ app: "alice/game", live: "up" }])!;
@@ -64,11 +223,11 @@ describe("the card shell", () => {
64223 const input = s.root.all().find((n) => n.tag === "input")!;
65224 const tell = s.root.all().find((n) => n.tag === "button")!;
66225 tell.onclick!();
67 expect(s.posted.length).toBe(1);
226 expect(s.posted.length).toBe(0);
68227 expect(input.focused).toBe(true);
69228 input.value = " make it blue ";
70229 tell.onclick!();
71 expect(s.posted[1].params).toEqual({
230 expect(s.posted[0].params).toEqual({
72231 name: "tell",
73232 arguments: { repo: "alice/game", context: { said: "make it blue" }, text: "make it blue" },
74233 });
@@ -147,7 +306,7 @@ describe("the card shell", () => {
147306 let prevented = false;
148307 a.onclick!({ preventDefault: () => (prevented = true) });
149308 expect(prevented).toBe(true);
150 expect(s.posted[1]).toMatchObject({ method: "ui/open-link", params: { url: "https://f.example/a/b/issues/1" } });
309 expect(s.posted[0]).toMatchObject({ method: "ui/open-link", params: { url: "https://f.example/a/b/issues/1" } });
151310 expect(dts[1]!.children.length).toBe(0);
152311 expect(dts[1]!.textContent).toBe("#2");
153312 expect(s.root.all().filter((n) => n.tag === "a").length).toBe(1);
@@ -166,7 +325,7 @@ describe("the card shell", () => {
166325 let prevented = false;
167326 anchor.onclick!({ preventDefault: () => (prevented = true) });
168327 expect(prevented).toBe(true);
169 expect(s.posted[1]).toMatchObject({ method: "ui/open-link", params: { url: "https://forge.test/a/b/pulls/1" } });
328 expect(s.posted[0]).toMatchObject({ method: "ui/open-link", params: { url: "https://forge.test/a/b/pulls/1" } });
170329 expect(dts[1]!.children).toHaveLength(0);
171330 expect(dts[2]!.children).toHaveLength(0);
172331 expect(s.root.all().filter((n) => n.tag === "a")).toHaveLength(1);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs