Status: build steps 1–5 built and committed (2026-09-24/25): profile, validator, CSS pipeline, emitters, shell, plain text, the @pagehub/sdk/email-renderer subpath, canvasWidth, unit tests + fixtures (pnpm test:email-render) and the /dev…
Status: build steps 1–5 built and committed (2026-09-24/25): profile, validator, CSS
pipeline, emitters, shell, plain text, the @pagehub/sdk/email-renderer subpath,
canvasWidth, unit tests + fixtures (pnpm test:email-render) and the /dev/emails
"PageHub trees" preview, plus site web fonts in the shell and email-mode toolbox gating
(preset children, saved components). Step 6 (real-client pass) done 2026-09-25; step 7 (phase 2) is pending — see
"Status" at the bottom for what differs from the text below.
Package: @pagehub/sdk, a git submodule. Commit in packages/sdk, push to PageHubApp/sdk,
then bump the pointer in the monorepo (monorepo.md).
First consumer: docs/features/site-email-templates.md.
This is the missing half that host-constraints.md points at ("Not email rendering… a separate export pipeline").
compileCSS (exists — compile-css/api.ts:29)
run on bg-primary text-primary-content py-space-md rounded-box text-lg btn btn-primary flex gap-space-sm
produces 31 KB with:
:root { --primary: oklch(14% 0 0); … } (the SDK default theme) and rules like
.bg-primary { background-color: var(--primary) }, .py-space-md { padding-block: var(--space-md) };--space-md: calc(clamp(1.5rem, 1rem + 1.5vw, 2rem) * var(--spacing-density));color-mix(in oklch, …) and DaisyUI's internal vars (--btn-color, :is(...)) inside btn;@layer (already unwrapped by unwrapLayerBlocks, api.ts:71-72) and no @property.In email clients, CSS variables, oklch, color-mix, clamp/calc, flex/grid, <style>-only
styling, <script> and inline SVG are dropped or unreliable (Outlook desktop uses Word's engine,
and Gmail keeps a limited <style>).
EMAIL_PROFILE ──► emailEditorConfig() → the editor only offers what the renderer can emit
└──► validateEmailTree() → the save endpoint + renderer reject anything else
renderEmailHTML() → tables + inline styles + literal colors
EMAIL_PROFILE + emailEditorConfig()New packages/sdk/src/render/email/profile.ts.
import type { PageHubFeatures } from "../../types/features";
export const EMAIL_WIDTH = 600;
export const EMAIL_COMPONENTS = ["Container", "Text", "Button", "Image"] as const;
// No Divider component exists (viewer resolver keys: components/resolvers/renders.ts:70-95);
// the "Divider" preset is a Container with `border-t w-full` (Container/presets/structure.tsx:82-89).
/** Base-class tokens (after stripping one `sm:|md:|lg:` prefix) the email pipeline supports. */
export const EMAIL_CLASS_ALLOWLIST: RegExp[] = [
/^(bg|text|border(-[trbl])?)-(primary|secondary|accent|neutral|info|success|warning|error|base-(100|200|300)|base-content|(primary|secondary|accent|neutral|info|success|warning|error)-content)(\/\d{1,3})?$/,
/^text-(xs|sm|base|lg|xl|[2-6]xl|left|center|right)$/,
/^font-(normal|medium|semibold|bold|extrabold|heading|body)$/,
/^(leading|tracking)-[a-z0-9\[\]\.]+$/,
/^-?(p|px|py|pt|pr|pb|pl|m|mx|my|mt|mb|gap)-[a-z0-9\[\]\.-]+$/, // spatial tokens + numbers
/^(w|max-w)-(full|\d+|\[\d+px\])$/,
/^(rounded|rounded-[a-z0-9\[\]]+)$/,
/^(border|border-[trbl]|border-[0-8])$/,
/^(flex|flex-row|flex-col|grid|grid-cols-[1-4]|items-(start|center|end)|justify-(start|center|end|between)|text-(left|center|right))$/,
/^(uppercase|lowercase|capitalize|italic|underline|no-underline)$/,
/^(hidden|block)$/,
];
/** Classes that make a tree un-sendable (error, not warning). */
export const EMAIL_UNSAFE =
/(^|:)(btn|card|badge|navbar|hero|menu|tab|modal|input)(-|$)|backdrop-blur|animate-|transform|transition|filter|fixed|sticky|absolute|shadow|bg-linear|bg-gradient|overflow-/;
// DaisyUI component classes are unsafe: they compile to internal var chains and :is() selectors
// (measured above). Buttons in email are styled with bg-/text-/px-/py-/rounded- utilities.
export const EMAIL_INSPECTOR_TABS: PageHubFeatures["inspectorTabs"] = {
Container: ["component", "layout", "design"],
Text: ["component", "design"],
Button: ["component", "design"],
Image: ["component", "design"],
};
/**
* Boot helper. Call BEFORE mounting the editor (registries are boot-time only —
* host-constraints.md "Ordering rule"). Returns the `features` to pass.
*/
export function emailEditorConfig(extraComponents: string[] = []): Partial<PageHubFeatures> {
registerComponentAllowlist([...EMAIL_COMPONENTS, ...extraComponents]);
for (const name of EMAIL_COMPONENTS) {
registerCatalogFilter(name, (entry, kind) => {
const e = entry as { classes?: string; expands?: string; name?: string; props?: { className?: string } };
const text = `${e.classes ?? ""} ${e.expands ?? ""} ${e.props?.className ?? ""}`;
return !text.split(/\s+/).some(t => EMAIL_UNSAFE.test(t));
});
}
return {
sidebar: true, toolbar: true,
aiGeneration: false, customCSS: false, importExport: false, seoPanel: false,
multiPage: false, custom404Page: false, darkModeSwitcher: false,
designSystem: false, saveButton: false, // theme + publishing belong to the host
mediaLibraryEdit: false, // the library is the host's; browse/pick/upload only
blocksPanel: { enabled: false },
inspectorTabs: EMAIL_INSPECTOR_TABS,
cssAllowlist: { classes: EMAIL_CLASS_ALLOWLIST },
canvasWidth: EMAIL_WIDTH,
};
}
registerComponentAllowlist (define/componentAllowlist.ts:25),
registerCatalogFilter (define/catalogRegistry.ts:41,
predicate (entry, kind) => boolean), and every features key above except canvasWidth exist
in types/features.ts. Export emailEditorConfig,
EMAIL_PROFILE pieces and validateEmailTree from src/index.ts (editor-side, no server deps).
Allowlist gotcha (measured): registerComponentAllowlist filters defs before the
resolver is built (editor.tsx:458-470). So a host's email-only custom component (e.g. EmailSlot)
must be passed in extraComponents, or it can't be deserialized.
canvasWidth (fixed canvas)There's no way to lock the canvas width today: responsivePreview is declared but read nowhere,
and width comes from Zedux atoms (ViewAtom, BreakpointWidthOverrideAtom,
chrome/viewport/state/atoms.ts).
/** Lock the canvas to this width in px and hide the breakpoint switcher. */ canvasWidth?: number;.
No default in config.ts (undefined = today's behavior).config is
already in scope (const { config } = useSDK(), :161). Before canvasOuterStyle (:243):
const lockedWidth = config.features?.canvasWidth;
and when set, canvasOuterStyle = { width: \${lockedWidth}px`, maxWidth: "none", flexShrink: 0, marginInline: "auto" }`
(skip the breakpoint branch).<BreakpointSwitcher /> (:168) when config.features?.canvasWidth is set
(read via useSDK()).responsivePreview flag from types/features.ts + config.ts:26 + the
host-constraints doc example. Reduce: a flag that does nothing is a trap.validateEmailTreepackages/sdk/src/render/email/validate.ts:
export interface EmailIssue {
nodeId: string;
severity: "error" | "warning";
code: "component" | "unsafe-class" | "dropped-class" | "action" | "icon" | "nesting";
message: string;
}
export function validateEmailTree(
nodes: SerializedNodes,
opts?: { extraComponents?: string[] }
): EmailIssue[];
Walk every node except ROOT:
| Check | Severity | Message (product-copy pass before shipping) |
|---|---|---|
resolvedName not in EMAIL_COMPONENTS ∪ extraComponents | error | "This element can't be sent in an email. Remove it." |
any className token matches EMAIL_UNSAFE | error | "“{token}” doesn't show in most email apps. Remove it." |
Button/Container props.action has an entry whose type !== "link" | error | "Buttons in emails can only open a link." |
props.icon set on a Button | warning | "Icons are left out of emails." (dropped by the emitter) |
a className token (prefix stripped) matches no EMAIL_CLASS_ALLOWLIST entry | warning | "“{token}” is ignored in emails." |
more than 2 nested column Containers (flex-row / grid-cols-N with N>1) | warning | "Columns nested this deep may break in Outlook." |
Used by: renderEmailHTML (errors → throw), the host's save endpoint (errors → 400 with the
list), and the host editor page (issues listed; clicking one selects nodeId).
renderEmailHTMLpackages/sdk/src/render/email/renderEmailHTML.ts, exported from a new subpath
@pagehub/sdk/email-renderer (packages/sdk/src/render/email/index.ts). Add to
packages/sdk/package.json exports: "./email-renderer": "./src/render/email/index.ts", next
to "./compile-css". It's server-only because it compiles CSS.
export interface RenderEmailOptions {
/** Serialized nodes: object, JSON string, or lz-base64 (same detection as compileTailwindCSS). */
content: string | SerializedNodes;
/** Merged into ROOT.props.variables as { key, value } entries (§3a). */
variables?: Record<string, string>;
/** Host email components (toHTML-bearing defs), e.g. the app's EmailSlotDef. */
components?: ResolvedComponentDef[];
preheader?: string;
title?: string;
lang?: string;
}
export interface RenderEmailResult { html: string; text: string; warnings: EmailIssue[] }
export async function renderEmailHTML(opts: RenderEmailOptions): Promise<RenderEmailResult>;
Email variables are entries in ROOT.props.variables ({ key, value }[]), referenced as
{{variables.<key>}}, e.g. {{variables.customer.name}}. Measured behavior (exists):
resolveVariablesVar (utils/design/variables.ts:286-299)
finds the entry with v.key === "customer.name" (dots in keys are fine) and returns value.variables.* token renders as empty (variables.ts:461-468), never the
literal {{…}}. A blank customer name just disappears. (Other root namespaces are left
literal, which is why we don't invent {{customer.*}} at ROOT level.)ROOT.props.variables entry as
variables.<key> under "Custom" (utils/editorVariableOptions.ts:35-87), so hosts get
insertable chips by writing the entries. No new registry needed.Text / Button / Image all interpolate through interpolate() → replaceVariables
with ctx.nodes.ROOT.props (staticHtml.ts:618-641; Text.toHTML.ts:28,34; Button.toHTML.ts:48,84).renderEmailHTML sets ROOT.props.variables = Object.entries(opts.variables ?? {}).map(([key, value]) => ({ key, value })),
replacing whatever the stored doc had.
validateEmailTree(nodes, { extraComponents: opts.components?.map(d => d.name) }). Errors →
throw new PageHubError({ code: "EMAIL_RENDER_INVALID", message, hint }). Warnings are returned.
resolverconst result = renderToHTML(JSON.stringify(nodes), {
compressed: false, view: "desktop", document: false, runtime: false,
components: opts.components ?? [], resolver: EMAIL_RESOLVER,
});
Measured precedence (renderToHTML.ts:216-218): { ...defaultResolver, ...processForStatic(components), ...options.resolver },
so EMAIL_RESOLVER entries override the built-in emitters, and host components still render
through their own toHTML. ToHTMLFn = (props, childrenHTML: string, ctx) => string
(staticHtml.ts:107-111). Children arrive as one "\n"-joined string (walker.ts:142-145).
packages/sdk/src/render/email/emitters.ts, using staticClasses, tag, interpolate from
utils/staticHtml (exist):
flex-row, or grid +
grid-cols-N with N > 1), emit
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>,
then for each child a <td class="ph-col" width="{round(100/N)}%" valign="{top|middle|bottom from items-*}" style="padding:0 {gap/2}px">
and the child's HTML. Children must be rendered one by one, so the emitter uses
ctx.renderChildren(node.nodes) per child instead of the joined childrenHTML. The node is
ctx.nodes[ctx.renderingNodeId]. Otherwise (stacked) emit a single-column table with one
<tr><td> per child, with the gap as padding-top on every row after the first. The
Container's own classes go on the outer <td> (background, padding, border, radius) as
class= attributes, and §3d inlines them. justify-center → align="center".
A link action (action[0].type === "link") wraps the table in <a href>.<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="{classes}"><a href="{interpolated href}" class="{text classes}" style="display:inline-block;text-decoration:none">{interpolated text}</a></td></tr></table>.
icon is dropped (validator warning).<img src alt width class style="display:block;border:0;outline:none;height:auto;max-width:100%">.
src for type: "cdn" with a bare id: getCdnUrl(id, { width: 1200, format })
(exists — utils/cdn.ts:52-73; format accepts "png" / "jpeg"). format is "png"
when the ROOT.props.pageMedia entry for that id has metadata.contentType of png/gif/svg,
otherwise "jpeg". The web emitter's format: "auto" (cdn.ts:317-337) can serve webp/avif,
which Outlook can't show.http(s) src passes through unchanged.width attribute is taken from a w-[Npx] / w-N class, else EMAIL_WIDTH.Compile. compileCSS({ classes: [...result.classes, "ph-col"], themeCSS: "", lean: true })
(exists, exported from compile-css/index.ts), plus this fixed reset, appended:
p,h1,h2,h3,h4,h5,h6{margin:0} a{color:inherit}.
Resolve custom properties. New resolveCssVars(css: string, overrides: Record<string,string>): string
(render/email/cssVars.ts, postcss):
--name: value declaration from :root rules;overrides on top. These are the site theme vars, parsed from
result.themeCSS, which renderToHTML returns from the tree's own ROOT palette/theme via
generateThemeVars (exists — render/static/themeCss.ts:4-52, :root { --<palette>: …; --radius … }).
They must win: compileCSS prepends themeCSS, but the SDK default :root from theme.css
comes later in the output and would otherwise override the site;var(--x) / var(--x, fallback) recursively (max depth 10; a cycle or miss uses
the fallback, else drops the declaration);:root rules and every --* declaration.Evaluate math. New evalStaticMath(value) (render/email/cssMath.ts): evaluates calc(),
clamp(), min(), max() over px / rem / em / vw / unitless, with 1rem = 16px and
1vw = EMAIL_WIDTH / 100 px, emitting Npx. Anything it can't fully evaluate (% mixed with
lengths) drops the declaration. This resolves spatial tokens like
calc(clamp(1.5rem, 1rem + 1.5vw, 2rem) * 1) = clamp(24, 16+9, 32)px = 25px, and the
density comes from the site theme var via step 2. (lightningcss only rewrites clamp to
max(min()), measured below, so it can't do this step.)
Lower. lightningcss transform({ code, targets: { safari: 10 << 16, chrome: 50 << 16, firefox: 50 << 16, ie: 11 << 16 } }).
Measured with 1.32.0:
color: oklch(14% 0 0) → color: #090909; color: lab(2.47% 0 0)color-mix(in oklch, oklch(60% .2 250) 20%, #0000) → rgba(0,131,227,.2) + a lab() duplicateThen drop every declaration whose value contains lab(, lch(, oklab(, oklch( or color(
(the duplicates), keeping the hex/rgba fallback. A final guard throws if oklch( or var(
survives, since that's a bug in steps 2–4.
Inline. juice(html, { extraCss: css, preserveMediaQueries: true, applyWidthAttributes: true, applyHeightAttributes: true, applyAttributesTableElements: true, removeStyleTags: false, preserveImportant: true }).
Option names are verified against juice 11.1.1 juice.d.ts. Only @media rules remain in <style>.
Append to the kept <style>:
@media (max-width:620px){.ph-col{display:block!important;width:100%!important;padding-left:0!important;padding-right:0!important}}.
Other sm:/md:/lg: variants: only hidden / block are honored (inside the same media
block, inverted for desktop-first). Everything else responsive is dropped, with a
dropped-class warning from the validator.
render/email/shell.ts: <!DOCTYPE html>, <html lang>,
<meta charset="UTF-8">, <meta name="viewport" content="width=device-width,initial-scale=1">,
<meta name="color-scheme" content="light">, <meta name="supported-color-schemes" content="light">,
<title>, the <style> from 3e, then the body:
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all">{preheader}</div>;<table width="100%"> with the ROOT background (the ROOT node's classes, inlined);<!--[if mso]><table width="600" align="center"><tr><td><![endif]--> wrapper;<table width="100%" style="max-width:600px;margin:0 auto">, with the rendered HTML inside.htmlToText(html) (render/email/text.ts) with htmlparser2 (SDK dependency ^9.1.0):
block elements → blank lines, <a href> → text (href), <img> → alt if present,
the preheader div skipped, whitespace collapsed.
packages/sdk/package.jsonMeasured: juice, lightningcss and @tailwindcss/node are not declared in the SDK, and
postcss is a devDependency only. Add to dependencies: juice (^11.1.1, matching the root),
lightningcss (^1.32.0, matching what's installed), postcss (move from dev, ^8.5.10), and
@tailwindcss/node (^4.2.2, matching the root; compile-css/compiler.ts already imports it
undeclared). Run pnpm install from the repo root.
packages/sdk/src/render/email/*.test.ts), node:test + node:assert/strict like
variables.test.ts. Add a root script "test:email-render": "tsx --test packages/sdk/src/render/email/*.test.ts"
next to test:text-static. Cover:
evalStaticMath: clamp at 600px, nested calc, unsupported → dropped.resolveCssVars: override wins over a later :root, nested var, fallback, cycle.oklch(, var(, lab(, @layer, display:flex, display:grid.validateEmailTree; htmlToText.packages/sdk/src/render/email/fixtures/*.json: one-column, two-column,
three-column, button row, image + text, with snapshots of html.renderEmailHTML.canvasWidth feature + remove responsivePreview.evalStaticMath, resolveCssVars, lower + strip, inline, all on the built-in emitters with a
single-column fixture. Proves colors / vars / spacing.email-renderer subpath + deps.emailEditorConfig(), and rewrite the host-constraints.md "Full email-mode boot" example to call it.prefers-color-scheme block, icon → PNG.Dark-mode palettes (phase 2), AMP/interactive email, sending (the host sends { html, text }).
| Step | State |
|---|---|
| 1. Profile + validator + tests | Built |
2. canvasWidth + remove responsivePreview | Built |
3. evalStaticMath, resolveCssVars, lower + strip, inline | Built |
4. Emitters, shell, text, email-renderer subpath, deps | Built |
5. emailEditorConfig() + host-constraints.md boot example | Built |
| 6. Real-client pass | Done 2026-09-25 (owner sign-off; see "Real-client results") |
| 7. Phase 2 (VML buttons, dark mode, icon → PNG) | Pending |
Files: packages/sdk/src/render/email/ (profile, validate, cssMath, cssVars,
emailCss, emitters, shell, text, renderEmailHTML, index), fixtures in
fixtures/*.json (+ fixtures/tree.ts test builder), snapshots in
renderEmailHTML.test.ts.snapshot. Preview: /dev/emails/pagehub/<fixture>.
Where the build differs from the text above:
canvasWidth default is null, not absent. DEFAULT_FEATURES is typed
Required<PageHubFeatures>, so the flag is canvasWidth?: number | null with null in
config.ts. Viewport and top bar read the live features from useSDK().
Editor class allowlist accepts one responsive prefix. filterClassName tests raw tokens,
so emailEditorConfig() passes EMAIL_CLASS_ALLOWLIST wrapped as ^(?:(sm|md|lg):)?(…)$.
Catalog filter also checks a single-class modifier's name (no classes → the class is
the name, e.g. btn-outline).
Responsive layout tokens are honored as the desktop value: flex-col md:flex-row is two
columns (stacked on phones by .ph-col). hidden / block variants become ph-d-*
(inlined, desktop) and ph-m-* (phone media query) classes. Other responsive tokens warn.
canvasWidth sets flex: 0 0 <w>px, not just width: the canvas wrapper carries flex-1,
whose 0% basis otherwise wins over width.
Hosts must allowlist Background (emailEditorConfig([..., "Background"])): the
allowlist drops ROOT's editor def, so it renders through the viewer resolver without theme vars.
CSS pipeline additions: @property initial values seed missing vars; @supports
branches are unwrapped (replacing the fallback declaration before them, or lightningcss
lowers color-mix() to oklab() only); all other at-rules are dropped from the inlined
sheet; bare rem / vw lengths become px; unitless math stays unitless; flex / grid / gap
declarations are dropped (tables carry layout). Gaps are read from the compiled gap-* rules
and applied as cell padding.
ROOT renders as the frame (Background emitter), keeping only allowlisted, non-layout ROOT
classes. Inline-SVG images are left out.
Container emitter output is memoized per node, since the walker renders children before the emitter renders each again for its own cell.
Site theme vars come from generateDesignSystemCSSVariables(resolveTheme(ROOT.props))
(what live pages use), not generateThemeVars as §3d step 2 says, since the latter carries only
the palette. That's what brings site fonts (font-heading / font-body), spacingDensity and
derived *-content colors into emails.
Site web fonts are linked in the shell <head>: renderEmailHTML passes
renderToHTML's fontUrls (the theme's Heading / Body Google families, weights 300–700) to
emailShell({ fontUrls }). Apple Mail and iOS Mail load them; Gmail drops the <link> and
uses the inlined stack, which always ends in the generic (Khand, sans-serif, from
--font-heading: var(--heading-font-family), sans-serif). With a web font present the shell
also adds <!--[if mso]><style>…font-family:Arial,Helvetica,sans-serif!important</style><![endif]-->,
because Outlook for Windows falls back to Times New Roman instead of walking the stack.
Weight 800 (font-extrabold) is synthesized from 700.
Email-mode catalog filter checks the whole preset (presetSafety.ts): every child's
component must be allowlisted, no class may match EMAIL_UNSAFE, and every action must be a
link. Checking only the preset's root class let Modal / Navbar / Social Icons / carousel
presets through, and each failed validation on save.
Saved components respect the allowlist: savedComponentsAllowed()
(define/componentAllowlist.ts) is false when an allowlist lacks SavedComponentLoader. It
gates "My Components" (toolbox + insert flyout), the "Reusable components" start card,
ph.component.createReusable, ph.node.convertToComponent and entering the components editor
(ph.canvas.toggleViewMode). No new flag: emailEditorConfig covers it.
An image's width attribute is capped at its cell width: EMAIL_WIDTH divided by every
ancestor's column count (availableWidth in emitters.ts). Outlook sizes images by the
attribute, so a w-full image in a half column gets width="300".
Known gap (accepted): Tailwind preflight is inlined on every element (box-sizing,
margin:0, padding:0, border:0 solid), about 70 bytes per element. Fixtures stay 6–10 KB.
2026-09-25: Will sent test emails from the test site and reported "verified them, they look fine". He didn't say which email apps he checked, so there are no per-app results. All 7 kinds went live on the editor pipeline the same day. Record any app-specific problem found later here, with the app, the kind and what broke.