Output

Preview is the export

Most builders show you a React approximation and call it a preview. Ours renders the compiled HTML (the same string the export writes) in a sandboxed iframe. Even the editing canvas is that HTML. A test asserts they are byte-identical.

The contract

ts
// Preview
<iframe srcDoc={previewSrcDoc(doc)} sandbox="" />

// Export
exportHtml(doc)

// Canvas: the same HTML, annotated with data-hm-* attributes
canvasSrcDoc(doc)                       // compile(doc, { annotate: true })

// Tests
expect(previewSrcDoc(doc)).toBe(exportHtml(doc))              // byte-for-byte
expect(stripAnnotations(canvasSrcDoc(doc))).toBe(exportHtml(doc))

The canvas is the compiled HTML

Each block’s first element carries a data-hm-id, and a repeated block a data-hm-instance ({{@index}} once Handlebars runs). Stripping them gives exportHtml() byte for byte, and a test proves it across the regression corpus; preview and export force annotations off whatever they are passed.

@repo/editor, hosted at /editor in apps/web, renders that HTML in an iframe that is sandboxed same-origin with no scripts:

  • Canvas. Each edit is swapped into the live document rather than reloading the iframe. A click selects the closest data-hm-id ancestor, and a test holds the canvas DOM, minus annotations, equal to the export DOM.
  • Overlay. It draws the IR’s geometry over the real output, with handles for padding, gap and row-child width (fixed px, or fill shares traded between siblings). Insertion lines for palette and move drags are checked by running the real tree op, and a refused drop shows the IR’s reason. Each handle drag is one undo step.
  • Inline editing. Double-click a heading, text or button to edit it in place. The surface matches the rendered text, holds variables as chips, toggles bold, italic and underline on the runs themselves, and commits back into runs, never HTML.
  • Inspector. Generated from the selected block’s own schemas. Safe styles sit in sections; the keys that degrade or are restricted are grouped apart, with badges naming the clients they affect. Mobile and dark tabs edit the overrides and dark twins the type allows. Every value is validated, and a refused value shows why.
  • Variables. The variables UI and a “preview with sample data” mode that executes the Handlebars canvas compile.
  • Layers and history. A layers tree with drag-to-reorder, undo/redo, keyboard shortcuts (which also work with the canvas focused), and copy, cut, paste and duplicate. Every paste gets fresh ids.
Preview is never a second renderer

The pre-rewrite app had switched preview mode over to a React canvas renderer, which meant the one screen whose entire job was “show me what will actually send” had stopped doing it. Three rendering paths, quietly diverging. Now there is one: the compiler.

The compatibility lint

Testing is the bottleneck in email: you find out something breaks after you send it to a rendering-test service. lintDocument(), a deterministic lint over the IR, moves a useful slice of that check to compile time: no model call, no network, runs on every keystroke if you want it to.

RuleSeverityWhat it catches
style-tierwarning / infoA restricted style key (warning) or a progressive one (info), with the clients where it degrades.
background-image-fallbackerrorOutlook drops the image; with no background-color behind it the band renders empty.
unitswarningrem/em are ignored by Outlook desktop. The IR is px-only for this reason.
image-src · image-alterror · warningAn image with no source renders broken everywhere; alt is shown when images are blocked and required for accessibility.
link-unsafe · link-https · link-placeholder · link-texterror · warning · info · infoUnsafe schemes, non-https links, links still at # or empty, and vague link or button text.
font-size · tap-targetwarningText below 14px, and buttons smaller than a 44px tap target.
contrast · dark-survivalwarning · infoWCAG AA contrast against the effective background, and pure black/white pairs that invert harshly under Gmail's forced dark mode.
row-fill-sum · row-columnserror/warning · infoA row's fill shares exceed or don't total 100% (±1); four columns in a stacking row.
empty-containerinfoA container with no children. It compiles to empty space.
heading-orderwarningHeading levels that skip.
orphan-alias · unused-repeat-alias · raw-runwarning · info · warningA variable reads a repeat alias that does not enclose it; a repeat whose alias nothing uses; an unescaped variable run.
compile · size-budget · head-csserror · warning/error · warning/errorThe document does not compile; the HTML passes 80KB (warn) or 100KB (error); a <style> block over Gmail's ~8KB limit or nesting @font-face in @media.

Support data is distilled from the caniemail dataset, vendored as JSON so there is no runtime dependency. Every issue carries a path, the block id, a hint and the affected clients (the shape the MCP check_ir tool returns), and lint never throws: a document that does not compile is itself an issue. styleSupport(key) exposes the per-key tier data (message, remedy, clients) that the tier issues use, so the editor’s badges and the lint cannot disagree.

Rendering checks (Phase F, planned)

Phase F will render the corpus with Playwright in three engines (an approximation, never ground truth for Outlook) plus an Outlook-engine adapter.