// 🤖 Cards — a small, bounded, declarative document a bot can attach to a chat // message ({ ...payload, card }), rendered natively per platform (this module // on the web; CardView in LChatCore). Think Slack Block Kit, deliberately NOT // a mini-app format: a card is a document with links, and interactivity beyond // links rides the existing payload `actions` whitelist, not the card. // // The contract lives in test/event-vectors/cards.json — normalizeCard here and // the Swift parser must agree on every vector, so the two renderers can't // drift. Evolution is additive within a schema major; anything unknown or // malformed FAILS CLOSED: normalizeCard returns null and the client shows the // message's plain text instead (which is why `text` is a mandatory fallback on // any card-carrying message). // // Pure module: normalizeCard/… have no DOM or network so Node can test them; // renderCard builds DOM with textContent only (never innerHTML — cards are // bot-authored content). export const CARD_SCHEMA = 1; // Bounds — part of the schema contract (the Swift parser enforces the same). const MAX_BLOCKS = 32; // top-level blocks, and blocks within one section const MAX_LIST_ITEMS = 32; const MAX_KV_ROWS = 16; const MAX_CHIPS = 4; const MAX_TEXT = 4000; // any body text const MAX_LABEL = 200; // headings, titles, sources, chip labels const MAX_URL = 2048; function str(v, max) { return typeof v === "string" && v.length > 0 && v.length <= max ? v : null; } function optStr(v, max) { if (v === undefined || v === null) return ""; return typeof v === "string" && v.length <= max ? v : null; } function httpsURL(v) { const s = str(v, MAX_URL); if (!s || !/^https?:\/\//i.test(s)) return null; return s; } // normalizeCard validates a decrypted card into its canonical shape, or // returns null — the fail-closed signal to render the plain-text fallback. // Nothing about a card is trusted: it came from another member (a bot). export function normalizeCard(card) { if (!card || typeof card !== "object" || Array.isArray(card)) return null; if (card.schema !== CARD_SCHEMA) return null; // unknown major → fallback const blocks = normalizeBlocks(card.blocks, true); if (!blocks || !blocks.length) return null; return { schema: CARD_SCHEMA, blocks }; } function normalizeBlocks(list, allowSection) { if (!Array.isArray(list) || list.length === 0 || list.length > MAX_BLOCKS) return null; const out = []; for (const b of list) { const n = normalizeBlock(b, allowSection); if (!n) return null; // one bad block fails the whole card out.push(n); } return out; } function normalizeBlock(b, allowSection) { if (!b || typeof b !== "object" || Array.isArray(b)) return null; switch (b.b) { case "heading": { const text = str(b.text, MAX_LABEL); const sub = optStr(b.sub, MAX_LABEL); if (!text || sub === null) return null; return sub ? { b: "heading", text, sub } : { b: "heading", text }; } case "text": { const text = str(b.text, MAX_TEXT); return text ? { b: "text", text } : null; } case "item": { const text = str(b.text, MAX_TEXT); const source = optStr(b.source, MAX_LABEL); if (!text || source === null) return null; const out = { b: "item", text }; if (source) out.source = source; if (b.url !== undefined) { const url = httpsURL(b.url); if (!url) return null; out.url = url; } return out; } case "list": { if (!Array.isArray(b.items) || !b.items.length || b.items.length > MAX_LIST_ITEMS) return null; const items = []; for (const it of b.items) { const s = str(it, MAX_TEXT); if (!s) return null; items.push(s); } return { b: "list", items }; } case "kv": { if (!Array.isArray(b.rows) || !b.rows.length || b.rows.length > MAX_KV_ROWS) return null; const rows = []; for (const r of b.rows) { if (!Array.isArray(r) || r.length !== 2) return null; const k = str(r[0], MAX_LABEL); const v = str(r[1], MAX_TEXT); if (!k || !v) return null; rows.push([k, v]); } return { b: "kv", rows }; } case "divider": return { b: "divider" }; case "section": { if (!allowSection) return null; // one level of nesting, no more const title = optStr(b.title, MAX_LABEL); if (title === null) return null; const blocks = normalizeBlocks(b.blocks, false); if (!blocks) return null; const out = { b: "section", blocks }; if (title) out.title = title; return out; } case "chips": { if (!Array.isArray(b.chips) || !b.chips.length || b.chips.length > MAX_CHIPS) return null; const chips = []; for (const c of b.chips) { if (!c || typeof c !== "object") return null; const label = str(c.label, MAX_LABEL); const url = httpsURL(c.url); if (!label || !url) return null; // v1 chips are links only chips.push({ label, url }); } return { b: "chips", chips }; } default: return null; // unknown block type → the whole card fails closed } } // renderCard builds the card's DOM, or returns null when the card doesn't // normalize (caller falls back to the message text, which it renders anyway). // All content lands via textContent; links open in a new tab like message // links do. export function renderCard(card) { const n = normalizeCard(card); if (!n) return null; const root = document.createElement("div"); root.className = "card"; for (const b of n.blocks) root.appendChild(renderBlock(b)); return root; } function renderBlock(b) { switch (b.b) { case "heading": { const el = document.createElement("div"); el.className = "card-heading"; const h = document.createElement("div"); h.className = "card-heading-text"; h.textContent = b.text; el.appendChild(h); if (b.sub) { const s = document.createElement("div"); s.className = "card-heading-sub"; s.textContent = b.sub; el.appendChild(s); } return el; } case "text": { const el = document.createElement("div"); el.className = "card-text"; el.textContent = b.text; return el; } case "item": { const el = document.createElement("div"); el.className = "card-item"; const t = document.createElement(b.url ? "a" : "span"); t.className = "card-item-text"; t.textContent = b.text; if (b.url) { t.href = b.url; t.target = "_blank"; t.rel = "noopener noreferrer"; } el.appendChild(t); if (b.source) { const s = document.createElement("span"); s.className = "card-item-source"; s.textContent = b.source; el.appendChild(s); } return el; } case "list": { const el = document.createElement("ul"); el.className = "card-list"; for (const it of b.items) { const li = document.createElement("li"); li.textContent = it; el.appendChild(li); } return el; } case "kv": { const el = document.createElement("table"); el.className = "card-kv"; for (const [k, v] of b.rows) { const tr = document.createElement("tr"); const th = document.createElement("td"); th.className = "card-kv-key"; th.textContent = k; const td = document.createElement("td"); td.textContent = v; tr.append(th, td); el.appendChild(tr); } return el; } case "divider": { const el = document.createElement("hr"); el.className = "card-divider"; return el; } case "section": { const el = document.createElement("div"); el.className = "card-section"; if (b.title) { const t = document.createElement("div"); t.className = "card-section-title"; t.textContent = b.title; el.appendChild(t); } for (const inner of b.blocks) el.appendChild(renderBlock(inner)); return el; } case "chips": { const el = document.createElement("div"); el.className = "card-chips"; for (const c of b.chips) { const a = document.createElement("a"); a.className = "card-chip"; a.textContent = c.label; a.href = c.url; a.target = "_blank"; a.rel = "noopener noreferrer"; el.appendChild(a); } return el; } } // Unreachable after normalize, but never let a render throw take the feed down. return document.createElement("span"); } // CARD_CSS is injected by the chat app alongside its own styles — themed via // the standard variables so cards follow light/dark and the chosen theme. export const CARD_CSS = ` .card { margin-top: 6px; padding: 12px 14px; max-width: 480px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); display: flex; flex-direction: column; gap: 8px; } .card-heading-text { font-weight: 700; font-size: 1.05em; text-wrap: balance; } .card-heading-sub { color: var(--muted); font-size: 0.85em; margin-top: 2px; } .card-text { overflow-wrap: anywhere; white-space: pre-wrap; } .card-item { display: flex; align-items: baseline; gap: 8px; } .card-item-text { flex: 1 1 auto; overflow-wrap: anywhere; } a.card-item-text { color: var(--link); text-decoration: none; } a.card-item-text:hover { text-decoration: underline; } .card-item-source { flex: 0 0 auto; color: var(--muted); font-size: 0.78em; text-transform: uppercase; letter-spacing: 0.04em; } .card-list { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 4px; } .card-kv { border-collapse: collapse; font-variant-numeric: tabular-nums; } .card-kv td { padding: 2px 0; } .card-kv-key { color: var(--muted); padding-right: 14px; white-space: nowrap; } .card-divider { border: none; border-top: 1px solid var(--border); margin: 2px 0; } .card-section { display: flex; flex-direction: column; gap: 6px; } .card-section-title { font-weight: 600; font-size: 0.8em; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } .card-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 2px; } .card-chip { padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--bubble-them); color: var(--ink); text-decoration: none; font-size: 0.9em; cursor: var(--ui-cursor, pointer); } .card-chip:hover { border-color: var(--accent); } `;