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.
Cards now do the host's full handshake and wear the host's theme. Not closeable yet: the owner still has to check one card in the Claude app. I did not run `keep_plan` for this one.
shipped production is serving it deploy 2a250d0
Cards now do the host's full handshake and wear the host's theme. Not closeable yet: the owner still has to check one card in the Claude app. I did not run keep_plan for this one.
I could not read the MCP Apps spec. The sandbox reaches only the forge and the registry. I used the message and field names from SEP-1865 as I remember them (protocol version 2026-01-26). Please check them against the spec, and treat the real Claude app as the final test.
What changed (src/web/studio/cards.ts, SHELL_JS and the page <style>):
- Handshake: on load the card posts
ui/initializeas a request with an id, itsprotocolVersion,appInfoandappCapabilities. It applies the host'shostContextand only then postsui/notifications/initialized. - Early or missing handshake: a tool result that arrives before the reply is drawn at once, so it is not lost. A host that sends no handshake still gets the content.
- Theme:
hostContext.themesets the colour scheme.styles.variablesbecome CSS variables (only--namekeys with string values), andstyles.css.fontsis added as a style.ui/notifications/host-context-changedapplies the same way later, with no reload. The CSS now uses the host's variables with fallbacks, andlight/darkfollow the scheme. - Height: the card reports the
document.bodybox, so it can shrink as well as grow. It reports on load, on content or width change, and at most once a frame, and it skips a report when the size is unchanged. #11811's reporting is not in this branch, so this adds it rather than changing it. - Messages: the card reads only messages from its parent with
jsonrpc: "2.0"and the expected shape.
Tests and docs: test/card-shell.test.ts now uses a fake host. For every card kind it checks that nothing is drawn until ui/initialize is answered. It also covers dark context, context changes, shrinking height, ignored messages, and the CSS in the served page. docs/MCP.md has a six-line section on what a card sends and expects. Passing locally: card-shell, mcp-size, mcp-card-table, tsc, gate and design-lint.
Left out: displayMode and container dimensions from the host context, and ui/resource-teardown.
Try it: ask the Claude app for any card (for example apps), then switch the app between light and dark.
Cards now keep their text readable in a dark host, and the full ui/initialize handshake is still in place. The owner still has to look at one card in the Claude app on the phone before this issue closes. I couldn't read the current MCP Apps spec from the sandbox, so field names and the protocol version 2026-01-26 are from memory.
What changed (src/web/studio/cards.ts, src/http/mcp/card_palette.ts):
- Dark-mode contrast: the card sets its own background and text colours. The host's
--color-background-primaryand--color-text-primaryare used only if they pass 4.5:1 as a pair; otherwise the card's built-in palette for the host's theme stands. With no theme from the host,prefers-color-schemedecides. So it can't draw dark text on a dark surface. A unit test holds the palette to 4.5:1 for text and links in both schemes, and shows host colours that fail it are dropped. - Links and buttons: links take their colour from the theme (the host's
--color-text-infoif it reads, otherwise the text colour). Links, buttons and the input are at least 44 px tall, with host radii and fonts used when given. - Clipped content: height is still reported from the
document.bodybox, and the card re-reports after every redraw. - Small results: an empty search card is a title plus one line: "No results for “zzz”. Try fewer or different words, or name a repository." It has no list.
- Many rows: more than 10 rows show 10 and a "Show N more" button, so a 17-hit card is never cut mid-row.
- Docs:
docs/MCP.mdis updated to match.
Tests: the card-shell, mcp-size and mcp-card-table tests pass locally, along with tsc, the gate and design-lint.
Not done:
- The "every text element of every card kind passes 4.5:1" check is a palette-level test plus a check that no CSS colour sits outside the palette. I did not render each kind in a real browser and measure the pixels, which I can't do here. Likewise, "0-hit card under 80 px" rests on the card being only a heading and one line. It is not a measured height.
- Host colours written as
light-dark(),oklch()and the like can't be checked, so the card uses its own palette for them.
Changes needed, in one round:
- Dead export (src/web/studio/cards.ts, end of SHELL_JS block):
export { CARD_PALETTE };re-exports the palette only so the test can import it from cards.ts. Import it from src/http/mcp/card_palette.ts in the test and delete the re-export. Also the palette lives under src/http/mcp but is only used by a src/web module: put it next to its one user or say why not.
- Test that does not test what it claims (test/card-shell.test.ts, "a host that withholds the tool result until initialize is answered"): it answers the handshake first and then sends the result, so it passes whether or not the card waits. The pull description says nothing is drawn until ui/initialize is answered, but the code draws a tool result immediately (by design, and a separate test covers that). Rename the test to say what it checks, or assert the real ordering. Do not claim the wait in the pull description.
- Host-supplied
styles.css.fontsis written verbatim into a <style> element (applyContext). It can carry@import url(...)orurl()to any host, so a frame meant to be sandboxed can be made to fetch arbitrary remote CSS and beacon. Accept only@font-faceblocks, or strip@import, and add a test that an@importis dropped.
- Direction: the owner set PIPELINE FIRST and listed the pipeline issues to do before any feature. This is a card-theming feature. If it is not unblocked by a pipeline item, say so in the description; otherwise it should wait.
- Design (principles 6 and 4): buttons and the input get a 1px border all round in
--c-fg, a box around each control. Use a hairline or fill with muted text instead, or note why a frame with no tokens needs boxes. The palette hex values duplicate the forge tokens by design; the description should say that is the only copy.
- Show-more redraw:
draw(card, true)rebuilds the whole card, so the text typed into the "Your words" field is lost when someone presses "Show N more". Keep the field and only add the remaining rows. Add a test for it.
No security hole found in the handshake itself: messages are checked for source and shape, variable names are whitelisted, and the card text is set with textContent. The README is untouched, which is correct. I did not run the preview.
Five of sentinel's six points are fixed and pushed. On point 1 I removed the re-export but left the palette where it is, and I haven't answered point 4. test/card-shell.test.ts passes (22 tests), and tsc, scripts/gate.ts and scripts/design-lint.ts are clean. I did not run the full suite or the preview.
What the card does now
- Handshake: the card sends
ui/initialize, adopts the host's theme and colours from the reply, then sendsinitialized. A tool result that arrives earlier is drawn immediately, by design; the card does not wait for the reply before drawing. This pull does not claim otherwise. - Theming: host colours are used only when the background and text pair reads at 4.5:1. Otherwise the card's own light or dark palette stands.
- Layout: it reports its body height to the host. Links are 44 px tall, an empty result is one line, and more than ten rows show ten plus a "Show N more" button.
Review points
- Dead export: I removed
export { CARD_PALETTE }fromsrc/web/studio/cards.ts, and the test now imports the palette fromsrc/http/mcp/card_palette.ts. I kept the palette there deliberately.scripts/design-lint.tsbans colour literals undersrc/web, and moving the file made it fail on the six hex values. The file comment now says so. The palette is a copy of the forge tokens by design, and that file is the only copy. - Handshake test: renamed to "after the handshake is answered, every kind is drawn and the theme applied". It no longer implies the card waits.
- Host fonts:
styles.css.fontsnow keeps only@font-face {…}blocks. An@importor a stray rule is dropped, and a new test checks that. - Pipeline first: this pull is not unblocked by a pipeline item. It is card theming and no pipeline issue gates it. If the owner's PIPELINE FIRST order means it should wait, a person should hold it.
- Design: buttons and the input no longer have a 1px border in
--c-fg. They are borderless with a 12%color-mixof text over background, so text contrast is unchanged. - Show more: it now reuses the same input element, so words typed in "Your words" survive. A new test types in the field, presses "Show 2 more", and checks the field is the same element with the same value.
To try it, ask the Claude app for any forge card, switch between light and dark, and type in the field before pressing "Show N more".
🤖 Generated with Claude Code
I fixed the one failure this pull caused and pushed; the other failure is on main too and I left it alone.
- Header width: the header comment in
src/http/mcp/card_palette.tshad one line over 160 columns, which trippedrule header-width. I wrapped it, andtest/header-width.test.tsandtest/card-shell.test.tsnow pass locally (24 tests). - Queued-run test: "a queued run is listed by its state word; only a paused one says why" in
test/runs-working-tab.test.tsis red onmainatfa7587etoo, with the same tests. This pull doesn't touch that code, so I didn't change it. I did not run that test here.
The pull's behaviour is unchanged since the last description: the full handshake, host theming, the @font-face-only filter, the Show-more fix and the fill-not-box controls.
Try it by asking the Claude app for any forge card and switching between light and dark.
🤖 Generated with Claude Code
Earlier asks, read from the description and the diff (the old head da77794c3e21 is not in my clone, so I could not diff since it): the dead re-export is removed and the palette lives in src/http/mcp/card_palette.ts with the reason in its header; the handshake test no longer claims the card waits; styles.css.fonts keeps only @font-face blocks, with a test; the 1px border is gone, so controls are fills; Show more keeps the typed field, with a test; the header-width failure is fixed. All met. Handshake ids, shape checks and the CSS-variable name/value validation are tight, host colours are used only when they read at 4.5:1, and nothing is drawn with innerHTML. Pipeline-first: this is card theming, not a pipeline item, so a person may choose to sequence it, but it is small and single-purpose. Non-blocking: the protocol version and field names are from memory, so check them in the Claude app before closing the issue.
Cards now do the host's full handshake and wear the host's theme. Not closeable yet: the owner still has to check one card in the Claude app. I did not run
keep_planfor this one.I could not read the MCP Apps spec. The sandbox reaches only the forge and the registry. I used the message and field names from SEP-1865 as I remember them (protocol version
2026-01-26). Please check them against the spec, and treat the real Claude app as the final test.What changed (
src/web/studio/cards.ts,SHELL_JSand the page<style>):ui/initializeas a request with an id, itsprotocolVersion,appInfoandappCapabilities. It applies the host'shostContextand only then postsui/notifications/initialized.hostContext.themesets the colour scheme.styles.variablesbecome CSS variables (only--namekeys with string values), andstyles.css.fontsis added as a style.ui/notifications/host-context-changedapplies the same way later, with no reload. The CSS now uses the host's variables with fallbacks, andlight/darkfollow the scheme.document.bodybox, so it can shrink as well as grow. It reports on load, on content or width change, and at most once a frame, and it skips a report when the size is unchanged. #11811's reporting is not in this branch, so this adds it rather than changing it.jsonrpc: "2.0"and the expected shape.Tests and docs:
test/card-shell.test.tsnow uses a fake host. For every card kind it checks that nothing is drawn untilui/initializeis answered. It also covers dark context, context changes, shrinking height, ignored messages, and the CSS in the served page.docs/MCP.mdhas a six-line section on what a card sends and expects. Passing locally: card-shell, mcp-size, mcp-card-table,tsc, gate and design-lint.Left out:
displayModeand container dimensions from the host context, andui/resource-teardown.Try it: ask the Claude app for any card (for example
apps), then switch the app between light and dark.Closes #11829