Three business documents. One typed object each. Nine renders. Zero external requests.
Built with Unlayer Elements for the Build with Elements Challenge.
Open the live studio · Browse the static build
The three cards on the front page are not screenshots. Each one is the real document, rendered by the same server action the studio uses, so the page cannot advertise something the kit does not actually produce.
Each document here is a pure function of a data object. Elements renders it three ways from the same component tree: an email, a responsive web page, and a print document that Chromium turns into a real PDF.
The part that is not just plumbing: the charts, QR codes, quota bars and status stamps are custom Elements tools drawn entirely in HTML tables. No images. No web fonts. No JavaScript. No CDN. They render in Outlook, they survive a client with images switched off, and they print.
One row list, three renderers. The only thing that differs between those panels is which wrapper the tree is mounted in:
<Email>,<Page>or<Document>.
Nothing to install: the studio is live at asterix-elements-kit.vercel.app, and every generated artifact is browsable at /gallery.
To run it locally:
npm install
npm run build # writes the nine documents, plain-text parts and design JSON
open dist/index.htmldist/index.html is a single self-contained page with all nine documents embedded live, plus the five
custom tools shown close up. It has no scripts in it at all: the renderer switcher is CSS.
Then, for the fun part:
npm run preview # the bench on :3000, studio at /studioChange the data on the left and every output follows. Mark the invoice paid and the stamp, the hero figure and the totals ladder all rewrite themselves. Drag retention storage past its allowance and the quota bar goes amber, then red. Switch currency and every figure in the document reformats.
Each render round-trips through the server, so the bytes in the preview pane are the bytes the build would write. The stat strip is produced by the same audit that can fail the build.
Every asset a document loads is another way for it to arrive broken.
Images are blocked by default in plenty of corporate mail setups. Web fonts never reach Outlook. And a chart served as a PNG is invisible in exactly the situation where the numbers matter most.
So this kit loads nothing. The wordmark is a text glyph. The charts are table cells. The QR codes are
run-length-encoded table rows. Every typeface is a system stack. The build fails if a single
src, <link href> or CSS url() shows up in any of the nine outputs.
9 documents · 3 templates × 3 modes · 0 external assets
That rule is also why <Image>, <Social> and <Video> go unused. All three need a remote asset.
Using them to raise the component count would have cost the one property that makes this kit worth
having.
Three different kinds of document on purpose, so the design system has to hold up three ways instead of repeating one layout.
A part-paid B2B invoice. The totals ladder is derived from the line items, so a fixture can never
carry an arithmetic error that the document then repeats. The status stamp promotes itself from
due to overdue once the date has passed. The QR code is a real pay-by-phone link.
Four metric tiles, a bar chart, a sparkline and three quota bars.
Note the polarity. Latency and failed deliveries carry positiveIsGood: false, so a falling number
shows green. Templates that hard-code "up is good" get this backwards and congratulate customers on
rising error rates.
A statement of work: numbered objectives, a phased scope table with a derived contract total, three comparison tiers, terms, and a signature block with sign-by-QR.
Each one is registered with registerTool(), using the same definition object the editor's
unlayer.registerTool takes. So these are not
Elements-only forks. One definition drives both runtimes, and renderToJson writes each usage out as
{ type: "custom", slug, values }, which is the shape the Builder stores.
| Tool | What it draws | How |
|---|---|---|
asterix-bar-chart |
Bar chart, plus a sparkline variant |
One table column per point. The bar is a colour-filled cell of an exact pixel height, pushed down to the baseline by a spacer cell above it. |
asterix-kpi |
Metric tile with a signed delta | Coloured by meaning rather than direction, and changes inside the noise floor read neutral. Direction is carried by a glyph and a sign, so it survives greyscale. |
asterix-progress |
Usage and quota bar | Two cells with percentage widths, which is the only bar technique Outlook renders reliably. |
asterix-qr |
A genuinely scannable QR code | The payload is encoded at build time and the module matrix is painted as run-length-encoded table cells, with the 4-module quiet zone the spec requires. |
asterix-stamp |
PAID, DUE, OVERDUE, DRAFT | The status is in the word as well as the colour. |
A table-drawn QR code can be geometrically perfect and still fail to read. One seam between cells, a missing quiet zone, or one stretched row is enough.
So the test suite renders the real document in Chromium, screenshots the code, and decodes the pixels with an independent QR reader:
✓ invoice / email scans back to https://astx.link/p/0431
✓ invoice / document scans back to https://astx.link/p/0431
✓ proposal / email scans back to https://astx.link/s/0117
✓ proposal / document scans back to https://astx.link/s/0117
Node 20 or newer.
| Command | What it does |
|---|---|
npm run build |
Renders every template in every mode, writes dist/, audits each output |
npm run pdf |
Turns the print documents into real PDFs with Chromium, into dist/pdf/ |
npm run shots |
Regenerates the README screenshots from dist/ |
npm test |
242 tests, including the QR pixel-decode round-trip |
npm run preview |
The bench on :3000, front page at /, studio at /studio |
npm run shots:studio |
Front page and studio screenshots (needs preview running) |
npm run typecheck |
tsc --noEmit |
pdf, shots and the QR scan tests need a Chromium. Run npx playwright install chromium once if
you do not have one. The scan tests skip themselves rather than fail when no browser is present.
What npm run build prints:
template mode size rows assets budget
──────────────────────────────────────────────────────────────
invoice Email 95.8kB 11 ✓ none · tight 94%
invoice Web page 62.8kB 11 ✓ none
invoice Print / PDF 62.5kB 11 ✓ none
report Email 98.8kB 14 ✓ none · tight 97%
...
9 documents · 3 templates × 3 modes · 0 external assets
largest email 98.8kB against a 102.0kB Gmail clip budget
src/
├─ tokens.ts palette, type scale, spacing, system font stacks
├─ modes.ts the whole mode layer, about 60 lines
├─ blocks.tsx letterhead, band, tables, totals ladder, footer
├─ tools/ the five custom tools
├─ templates/ invoice, report, proposal, each a list of rows
├─ data/ typed fixtures and the data contracts
├─ overrides.ts the studio's controls, as pure data transforms
├─ render.tsx html, parts, text, json
├─ audit.ts the zero-external-assets check
└─ minify.ts the email size pass
app/ Asterix Studio, the live preview (Next.js, at the repo root so
it deploys with no configuration)
scripts/ build, gallery, block library, PDFs, screenshots
Every template is:
({ data, mode }) => <Wrapper {...bodyProps(mode)}>{ rows(data) }</Wrapper>rows(data) is written once. Everything that differs between the inbox, the browser and the printer
lives in src/modes.ts: body width, preview text, print rhythm. You can check the
claim by reading one short file instead of taking it on trust.
Two places change content rather than presentation, and both are marked in the source. The proposal's email leaves out the legal clauses, the signature lines and the postal addresses. A four-page statement of work does not belong inline in an inbox. The email carries the commercial summary, and the link and the PDF carry the agreement. Everything you need in order to decide is in all three.
npm run build writes dist/json/<template>.<mode>.json, which is Unlayer design JSON
(schemaVersion: 24). Built-in components serialise to their own types and the Asterix tools
serialise as custom tools, so a design round-trips into the Builder as long as the same five
definitions are registered there:
import { ASTERIX_TOOLS } from './src/tools';
ASTERIX_TOOLS.forEach((tool) => unlayer.registerTool(tool));Cream paper and warm ink, kept low in chroma so it is comfortable to sit in front of. Newsreader sets the editorial voice, IBM Plex Mono sets every number so the readouts scan like a spec sheet, and the single slate accent is reserved for links and small marks. Dark mode is a neutral dark with bone text rather than white, so it does not glare.
Three things were wrong with the first attempt at this, and they are worth recording because they are easy mistakes to repeat:
- The primary action did not look like one. It was a full-width tinted bar, styled exactly like the three ruled links beneath it, so nothing said "start here". It is now a solid ink button, filled and high-contrast, with the secondary route as a quiet text link beside it.
- The page did the explaining. Six blocks of copy above the fold, including a five-line paragraph and a roster table. It now carries one sentence, then shows the documents instead of describing them.
- The colour was tiring. A warm brown-black paired with an orange spot colour vibrates, especially in dark mode. The orange is what had to go; the cream ground itself was liked, and is back, now carrying warm greys and a single slate accent, with contrast doing the work that saturation was doing badly.
Two rules keep it honest:
- Web fonts are chrome only. The studio loads Newsreader and Plex Mono. The documents never do, and neither does the static gallery, which is why the gallery's display face is a system serif. A page that claims zero external requests cannot go fetching a typeface.
- The documents share the palette. They began on their own violet accent, on the theory that the chrome frames the work and should not repaint it. In practice the two palettes argued in every screenshot, so the documents were re-graded onto the same warm ink and slate accent as the bench. They keep their system fonts: web fonts stay chrome-only.
One detail worth recording, because it is the same trap twice: the proof iframe on the front page is 664px wide, not 600px. Elements stacks its columns at 600px and below, so a 600px frame showed the narrow-width layout, with the letterhead's wordmark and reference broken onto separate lines. Rendering into 664px and scaling down shows what a desktop inbox actually displays, and leaves a trim margin, which a proof sheet should have anyway.
Elements has a second JSON exporter that is easy to miss. renderRowToJson takes a single <Row> and
returns the shape Unlayer's Block Editor works with, rather than a whole design. That makes every
block in this kit portable on its own, so someone who is not writing React at all can still use them.
npm run build writes dist/blocks/: thirteen ready-made rows plus an index.json manifest.
Download one, drop it into a Block Editor, and you have a working letterhead, totals ladder, KPI row
or quota set. The manifest records which Asterix tools each block carries, because a custom slug the
consumer has not registered renders as an empty gap.
dist/blocks/
├─ index.json manifest: slug, title, description, tools carried
├─ letterhead.json ├─ totals-ladder.json ├─ quota-bars.json
├─ meta-grid.json ├─ status-stamp.json ├─ pay-cta-qr.json
├─ party-pair.json ├─ kpi-row.json ├─ notes.json
├─ line-items-table.json ├─ bar-chart.json └─ footer.json
└─ trend-sparkline.json
Every block is generated from the same block functions the three documents use, so the library cannot drift from the templates. Eighty-one tests assert the published row shape, since it is a public contract the moment someone pastes it into their own editor.
These each cost real debugging time. Writing them down in case they save someone else's.
1. renderToJson only sees literal <Row> elements. renderToHtml walks the rendered output, so
a <Fragment>, or a <Letterhead /> component that resolves to rows, gives you perfect HTML. But
renderToJson walks the tree statically and accepts only a literal <Row>, or an array of them.
Anything else warns on stderr and then exports a design with zero rows. It fails quietly and the
HTML looks fine, so you find out much later when a template will not open in the editor. Every block
in this kit is therefore a function that returns a Row, called as letterhead(mode, …), never
<Letterhead />.
2. A key in a tool's registered values is always handed to the exporter. registerTool merges
its values over your props, so a listed key is never absent, which makes "absent" useless as a
signal. zeroBaseline needed a default that depends on variant: true for a chart, false for a
sparkline. Listing it in values pinned it to true and quietly flattened every sparkline back into a
zero-anchored chart.
3. A coloured row background is exactly as tall as its row. Elements paints a row's background
full-bleed across the body while keeping its content inside contentWidth. Colouring the letterhead's
own background to get a 4px accent line gave a 4px strip at the top and two full-height accent bands
down either side of it. A 4px line needs a 4px row. There is a test for this now, because I made the
mistake twice.
4. Per-item cost dominates email size. Every Elements item carries its own <table><tr><td>,
its own pair of MSO conditional comments, and its own <style> block. That is roughly 3 to 5kB of
packaging no matter how little it holds. Six label and value pairs as six Paragraphs cost about 25kB
more than two Paragraphs whose html holds a nested table, for output that looks identical.
5. Paragraph's html is a verbatim passthrough, and that is the escape hatch. htmlToTextJson
wraps HTML in a format the renderer hands straight back, and the result is wrapped in a <div> rather
than a <p>. So a nested <table> inside html is valid and renders. That is how the totals ladder,
the tier cards and the terms list collapse from many items into one.
6. <Table>'s shorthand is not its ceiling. headers and data cannot express per-column
alignment, a monospaced money column, or a second detail line inside a cell. The values escape hatch
can, one cell at a time. Two traps in there: headerFontSize and contentFontSize must be px
strings, since a bare number is ignored, and border applies to every side, so the vertical rules
need explicit zeroing.
7. document mode falls back to the web exporter silently. A custom tool with no document
exporter still renders, through web, which is easy to miss. Print also needs
print-color-adjust: exact in renderer.head.css, or Chromium drops every background colour and
every chart bar prints white.
The first working version ran 204 to 274kB per email. That is well past the roughly 102kB where Gmail clips the body and hides your footer behind a "View entire message" link.
Getting under it without cutting the design. Each row is the measured email size after that change:
| Invoice | Review | Proposal | |
|---|---|---|---|
| First working version | 203.6kB | 178.9kB | 273.6kB |
| Multi-item blocks became one nested table; slimmer QR cells | 147.1kB | 168.1kB | 190.8kB |
Chrome folded into band(), rows 20 to 11, 28 to 14, 30 to 15 |
113.9kB | 117.8kB | 134.6kB |
| Shorter font stacks, CSS dedupe, whitespace collapse | 101.5kB | 100.2kB | 119.9kB |
| Short-link QR payloads; proposal email scoped to the summary | 96.3kB | 100.2kB | 102.7kB |
| Now, after re-adding the 4px accent row as its own row | 95.8kB | 98.8kB | 96.7kB |
Most of that was packaging rather than content. The single biggest win was realising that a hairline
rule and a 24px gap were each costing a whole row: see the note on band() in
src/blocks.tsx. The 30kB or so of MSO conditional comments left in each email is
Elements doing its job for Outlook, and is not reducible.
The size check is reported, not enforced, as ok, tight or over. It is a quality target rather
than a correctness one, and failing the build on it would make an ordinary content edit look like a
broken build. The zero-external-assets check is enforced, because that is the promise the kit makes.
tests/blocks-library.test.ts 81 the published row shape, one suite per block: cell and
column agreement, only registered tool slugs, no
external assets, house style
tests/render.test.ts 67 all 9 outputs: zero external assets, links survive,
design JSON has rows, the accent strip stays a strip,
mode differences, totals arithmetic
tests/tools.test.ts 27 each tool's promises: QR geometry against the encoder,
chart scaling, delta polarity, the noise floor
tests/content.test.ts 28 house style over source and output, no placeholder
text, no unreplaced tokens
tests/overrides.test.ts 17 the studio's controls, and that prose can never
contradict a derived figure
tests/minify.test.ts 18 the size pass is lossless and idempotent
tests/qr-scan.test.ts 4 render in Chromium, screenshot, decode the pixels
One rule earned its own tests. Prose may not restate a figure that is derived from the data, and may not assert a condition the data can contradict. The review's summary says what the reader will find rather than quoting numbers, the overage note pulls its figures from the same quota the bar uses, and that note disappears entirely once usage drops back inside the allowance. A template that congratulates you on an overage you no longer have is worse than one that says less.
House style is worth a note. This project uses no em dashes, en dashes or Unicode minus signs, and
tests/content.test.ts enforces that across the source, the docs and all nine rendered documents.
Rules that are not checked drift back within a week.
Built with Unlayer Elements
(@unlayer/react-elements). QR encoding by
qrcode-generator, and decoding in the tests by
jsqr.
Asterix Labs and Verano Commerce are fictional. Every address, tax ID, URL and figure is invented, and all links use reserved example domains.
MIT, see LICENSE.




