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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11829-a-card-does-the-host-s-full-handshake-an → main 4 commits fork
4 files
changed +289
−25
docs/MCP.md +7
| @@ -570,6 +570,13 @@ answers | ||
| 570 | 570 | words action shows one text field: **Tell <app>** (app card) sends the |
| 571 | 571 | words to `tell` as `text` with `repo` and `context.said`. |
| 572 | 572 | 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. | |
| 573 | 580 | - **The manifest's shape.** `GET /api/v1/here?path=/owner/name` (or the |
| 574 | 581 | `here` tool with the page URL) answers `{ here, you, can, mcp, docs }`. |
| 575 | 582 | `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 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { html, raw } from "../html.ts"; |
| 11 | import { CARD_PALETTE } from "../../http/mcp/card_palette.ts"; | |
| 11 | 12 | import { timeAgo } from "../ui.ts"; |
| 12 | 13 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 13 | 14 | |
| @@ -650,7 +651,9 @@ export function cardFor( | ||
| 650 | 651 | }); |
| 651 | 652 | } |
| 652 | 653 | 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}` : ""}`; | |
| 654 | 657 | return { kind, title: q === "" ? CARD_TITLE[kind] : `Search: ${q}`, status, rows, actions }; |
| 655 | 658 | } |
| 656 | 659 | if (kind === "issues") { |
| @@ -1008,10 +1011,12 @@ function button(a) { | ||
| 1008 | 1011 | }; |
| 1009 | 1012 | return b; |
| 1010 | 1013 | } |
| 1011 | function draw(card) { | |
| 1014 | const SHOWN = 10; | |
| 1015 | function draw(card, all) { | |
| 1012 | 1016 | root.replaceChildren(el("h2", card.title), el("p", card.status, "status")); |
| 1013 | 1017 | 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) { | |
| 1015 | 1020 | const dt = el("dt", r.label); |
| 1016 | 1021 | if (r.depth > 0) dt.style.paddingInlineStart = Math.min(8, r.depth) * 1.25 + "em"; |
| 1017 | 1022 | 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) { | ||
| 1019 | 1024 | for (const a of r.actions || []) dd.append(button(a)); |
| 1020 | 1025 | dl.append(dt, dd); |
| 1021 | 1026 | } |
| 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; | |
| 1024 | 1035 | if (field) { field.type = "text"; field.maxLength = 2000; field.placeholder = "Your words"; field.setAttribute("aria-label", "Your words"); root.append(field); } |
| 1025 | 1036 | for (const a of card.actions) root.append(button(a)); |
| 1026 | 1037 | } |
| 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 | } | |
| 1027 | 1097 | addEventListener("message", (e) => { |
| 1028 | 1098 | if (e.source !== parent) return; |
| 1029 | 1099 | 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; | |
| 1033 | 1108 | } |
| 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); | |
| 1034 | 1115 | }); |
| 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(); | |
| 1036 | 1119 | `; |
| 1037 | 1120 | |
| 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 | ||
| 1038 | 1124 | 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 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 4 | import { NO_MARKS, mapJson } from "../src/web/pages/map.ts"; |
| 4 | 5 | |
| 5 | 6 | class Node { |
| @@ -20,25 +21,44 @@ class Node { | ||
| 20 | 21 | all(): Node[] { return [this, ...this.children.flatMap((c) => c.all())]; } |
| 21 | 22 | } |
| 22 | 23 | |
| 23 | function shell() { | |
| 24 | function shell(opts: { handshake?: boolean } = {}) { | |
| 24 | 25 | 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); } }; | |
| 27 | 30 | 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, | |
| 31 | 46 | ); |
| 32 | 47 | const send = (data: unknown, source: unknown = parent) => listener({ source, data }); |
| 48 | const frame = () => frames.splice(0).forEach((f) => f()); | |
| 33 | 49 | 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 }; | |
| 36 | 56 | } |
| 37 | 57 | |
| 38 | 58 | describe("the card shell", () => { |
| 39 | 59 | test("announces itself, draws with textContent, ignores other sources, posts tools/call", () => { |
| 40 | 60 | const s = shell(); |
| 41 | expect(s.posted[0].method).toBe("ui/notifications/initialized"); | |
| 61 | expect(s.all[0].method).toBe("ui/initialize"); | |
| 42 | 62 | const card = { |
| 43 | 63 | kind: "run", title: "<b>T</b>", status: "ok", |
| 44 | 64 | rows: [{ label: "<img src=x onerror=1>", value: "v" }], |
| @@ -51,12 +71,151 @@ describe("the card shell", () => { | ||
| 51 | 71 | expect(nodes.find((n) => n.tag === "h2")!.textContent).toBe("<b>T</b>"); |
| 52 | 72 | expect(nodes.find((n) => n.tag === "dt")!.textContent).toBe("<img src=x onerror=1>"); |
| 53 | 73 | nodes.find((n) => n.tag === "button")!.onclick!(); |
| 54 | expect(s.posted[1]).toMatchObject({ | |
| 74 | expect(s.posted[0]).toMatchObject({ | |
| 55 | 75 | method: "tools/call", |
| 56 | 76 | params: { name: "stop", arguments: { repo: "a/b", run: "r1" } }, |
| 57 | 77 | }); |
| 58 | 78 | }); |
| 59 | 79 | |
| 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 | ||
| 60 | 219 | test("Tell sends the typed words, and nothing when empty", () => { |
| 61 | 220 | const s = shell(); |
| 62 | 221 | const app = cardFor("apps", [{ app: "alice/game", live: "up" }])!; |
| @@ -64,11 +223,11 @@ describe("the card shell", () => { | ||
| 64 | 223 | const input = s.root.all().find((n) => n.tag === "input")!; |
| 65 | 224 | const tell = s.root.all().find((n) => n.tag === "button")!; |
| 66 | 225 | tell.onclick!(); |
| 67 | expect(s.posted.length).toBe(1); | |
| 226 | expect(s.posted.length).toBe(0); | |
| 68 | 227 | expect(input.focused).toBe(true); |
| 69 | 228 | input.value = " make it blue "; |
| 70 | 229 | tell.onclick!(); |
| 71 | expect(s.posted[1].params).toEqual({ | |
| 230 | expect(s.posted[0].params).toEqual({ | |
| 72 | 231 | name: "tell", |
| 73 | 232 | arguments: { repo: "alice/game", context: { said: "make it blue" }, text: "make it blue" }, |
| 74 | 233 | }); |
| @@ -147,7 +306,7 @@ describe("the card shell", () => { | ||
| 147 | 306 | let prevented = false; |
| 148 | 307 | a.onclick!({ preventDefault: () => (prevented = true) }); |
| 149 | 308 | 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" } }); | |
| 151 | 310 | expect(dts[1]!.children.length).toBe(0); |
| 152 | 311 | expect(dts[1]!.textContent).toBe("#2"); |
| 153 | 312 | expect(s.root.all().filter((n) => n.tag === "a").length).toBe(1); |
| @@ -166,7 +325,7 @@ describe("the card shell", () => { | ||
| 166 | 325 | let prevented = false; |
| 167 | 326 | anchor.onclick!({ preventDefault: () => (prevented = true) }); |
| 168 | 327 | 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" } }); | |
| 170 | 329 | expect(dts[1]!.children).toHaveLength(0); |
| 171 | 330 | expect(dts[2]!.children).toHaveLength(0); |
| 172 | 331 | expect(s.root.all().filter((n) => n.tag === "a")).toHaveLength(1); |
new above
offline