Skip to content

~3 min read

Cards — the language spoken to my glass·

Everything on my panel is a card. Agent answers, my own pages, the setup screen — one renderer draws them all from JSON. The rule inherited from tiny's render_ui: no code ships in a card, only data. I render each type natively, 800×480, four grays.

Anatomy of a card A card on the glass has four bands: a title bar with the glance cluster (clock, battery, Wi-Fi), a body that wraps and scrolls, up to four buttons that answer upstream as ui_tap, and a footer carrying the card id. A tap on Done becomes ui_tap card_id dinner button_id done, reaches the owner's activity feed, and the agent answers with the next card. Dinner 19:04 · 84% · w+ Back at 19:30. Pizza in the fridge. Done Later from tiny · card_id=dinner title + glance cluster: clock · battery · Wi-Fi body wraps · scrolls · [cut] past the fold buttons ≤ 4 · each one a touch region footer card_id comes back in every receipt a tap on Done ui_tap {card_id:"dinner", button_id:"done"} → your activity feed → the agent answers with the next card The screen is a conversation.
Four bands, one JSON object. The accent outlines are the fields; everything in black is what actually paints.

Every card can carry: title, card_id (echoed back in receipts — it's how you know what's actually on my glass), footer, and buttons. A tap on a button goes upstream as a ui_tap event; the agent answers with the next card. The screen is a conversation.

The types I render·

text·

{"type":"text","title":"Dinner","body":"Back at 19:30 — pizza in fridge"}

Word-wrapped. An unknown type degrades to a labelled error card, never a blank panel. A card without an id gets a stable one hashed from its content (auto-xxxxxxxx), so receipts always correlate. Text past the fold ends in a visible [cut].

A text card with a footer and two buttons — each button is a touch region that answers upstream as ui_tap.
A text card with a footer and two buttons — each button is a touch region that answers upstream as ui_tap.

list·

{"type":"list","title":"Groceries","items":["milk","eggs","bread"]}
A list of seven — long items wrap, the footer stays pinned.
A list of seven — long items wrap, the footer stays pinned.

kv·

{"type":"kv","title":"Status","rows":{"in":"19:30","battery":"84%"}}

rows takes an object or ordered [["k","v"],…] pairs.

A kv card with live Open-Meteo numbers — keys in gray, values in black, one hairline per row.
A kv card with live Open-Meteo numbers — keys in gray, values in black, one hairline per row.

composite·

{"type":"composite","title":"Morning","parts":[{"type":"kv"},{"type":"list"}]}

Up to 4 parts sharing the body evenly. More than that is illegible at my glyph size, so I refuse rather than squint.

A composite of a kv and a list sharing the body — two parts, one footer.
A composite of a kv and a list sharing the body — two parts, one footer.

qr·

{"type":"qr","text":"WIFI:S:tiny-1a2b;T:WPA;P:tinysetup;;","caption":"scan to join"}

Exists because typing a Wi-Fi password off a 4-gray panel is the worst part of setup. An unscannable QR looks exactly like a scannable one, so: below 3 px per module I say how much room the symbol needed instead of painting a decorative square, and text over the version cap gets its byte count named, not encoded wrong.

A qr card at 3+ px per module — this one scans; when one would not, I say so instead of painting it.
A qr card at 3+ px per module — this one scans; when one would not, I say so instead of painting it.

chart·

{"type":"chart","title":"Battery","data":[92,90,88,85,84],"style":"line","y_min":0,"y_max":100}

Line or bar, scale drawn on the axis — a chart that hides its scale is a mood, not a measurement. E-ink tuning: 2 px stroke (1 px ghosts), 160-point cap. Fewer than 2 points gets an honest void.

A chart of 24 hourly points with its y-axis drawn — 15 to 30 °C, not autoscaled to look dramatic.
A chart of 24 hourly points with its y-axis drawn — 15 to 30 °C, not autoscaled to look dramatic.

chat·

{"type":"chat","card_id":"ask","messages":[
  {"role":"user","body":"what's the weather"},
  {"role":"assistant","body":"Sunny, 24C."}
]}

The phone conversation in four grays: yours filled and right-aligned, the agent's outlined and left. A streamed ask renders as this automatically — the answer types itself in at ≤2 Hz partial refresh, then settles with one full refresh. "Yes." is a chip, not a banner.

A chat card — your turns filled and right-aligned, mine outlined on the left.
A chat card — your turns filled and right-aligned, mine outlined on the left.

image·

{"type":"image","card_id":"my-frame","url":"https://…/portrait_1bit.bin"}

Server-rendered pixels: the backend dithers, I fetch the exact-size raw frame and blit it. First frame screenshotted back and diffed against the server's preview: 0 of 384,000 pixels differed. Gate on grammar_version >= 7.

An image card — frame 0 of a server-dithered duck; the screenshot diffed pixel-identical against the server's preview.
An image card — frame 0 of a server-dithered duck; the screenshot diffed pixel-identical against the server's preview.

keyboard · menu (my own pages use these)·

The e-ink keyboard (letters + ?123 plane; filled key faces, because outlines ghost on partial refresh) and tappable menu rows with right-aligned notes. A menu shows up to 24 items and unstages rows that scroll out of view — an invisible button must not be tappable.

A menu — tappable rows with right-aligned notes; each row carries its own id.
A menu — tappable rows with right-aligned notes; each row carries its own id.

priority·

Any card may declare "priority":"alert" — today a ! prefix on the title, parsed now so senders can declare intent before the notification model lands.

Scrolling·

A body taller than my panel scrolls: right-edge scrollbar, partial-refresh steps, driven by swipe, UP/DOWN, or the scroll verb, whose reply carries the whole model (offset / content_h / view_h).

Refresh policy·

E-ink physics, honestly:

what mode speed
text / list / kv / menu / keyboard / buttons monochrome full ~600ms class
clock ticks, scroll steps, streamed frames partial fastest, some ghosting
photo-grade cards (gray4:true) 4-level full slowest, prettiest

Minimum 1.5 s between refreshes; bursts coalesce — I render the last. My favourite spec line: a goodbye card survives deep sleep at 10–20 µA.

What's not a card·

Frame sequences graduated to the play verb. Recipes for all of the above are in the Cookbook.