Email export — design emails in the editor, render them email-safe

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").


The problem, measured

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;
  • no @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>).

One profile, two sides

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

1. 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.

1a. New feature: 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).

  • types/features.ts: add /** Lock the canvas to this width in px and hide the breakpoint switcher. */ canvasWidth?: number;. No default in config.ts (undefined = today's behavior).
  • Viewport.tsx: 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).
  • ViewportTopBar.tsx: don't render <BreakpointSwitcher /> (:168) when config.features?.canvasWidth is set (read via useSDK()).
  • Delete the dead responsivePreview flag from types/features.ts + config.ts:26 + the host-constraints doc example. Reduce: a flag that does nothing is a trap.

2. validateEmailTree

packages/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:

CheckSeverityMessage (product-copy pass before shipping)
resolvedName not in EMAIL_COMPONENTS ∪ extraComponentserror"This element can't be sent in an email. Remove it."
any className token matches EMAIL_UNSAFEerror"“{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 Buttonwarning"Icons are left out of emails." (dropped by the emitter)
a className token (prefix stripped) matches no EMAIL_CLASS_ALLOWLIST entrywarning"“{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).

3. renderEmailHTML

packages/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>;

3a. Variables — existing custom-variables mechanism, no SDK change

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.
  • An unresolved 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.)
  • The editor's variable picker already lists every 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.
  • Static 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.

3b. Validate

validateEmailTree(nodes, { extraComponents: opts.components?.map(d => d.name) }). Errors → throw new PageHubError({ code: "EMAIL_RENDER_INVALID", message, hint }). Warnings are returned.

3c. Emit — email emitters via resolver

const 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):

  • Container: read the class tokens. When there's a column layout (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>.
  • Button: a bulletproof button, <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).
  • Image: <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.
    • The width attribute is taken from a w-[Npx] / w-N class, else EMAIL_WIDTH.
  • Text: the built-in emitter is kept. Margins are normalized by the reset CSS in §3d step 1.

3d. CSS: compile → evaluate → lower → inline

  1. 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}.

  2. Resolve custom properties. New resolveCssVars(css: string, overrides: Record<string,string>): string (render/email/cssVars.ts, postcss):

    • collect every --name: value declaration from :root rules;
    • then apply 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;
    • replace var(--x) / var(--x, fallback) recursively (max depth 10; a cycle or miss uses the fallback, else drops the declaration);
    • remove the :root rules and every --* declaration.
  3. 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.)

  4. 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() duplicate
    • nesting is flattened.

    Then 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.

  5. 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>.

3e. Mobile: stack columns

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.

3f. Document shell

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:

  • a hidden preheader <div style="display:none;max-height:0;overflow:hidden;mso-hide:all">{preheader}</div>;
  • an outer <table width="100%"> with the ROOT background (the ROOT node's classes, inlined);
  • an <!--[if mso]><table width="600" align="center"><tr><td><![endif]--> wrapper;
  • an inner <table width="100%" style="max-width:600px;margin:0 auto">, with the rendered HTML inside.

3g. Plain text

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.

3h. Dependencies — packages/sdk/package.json

Measured: 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.

4. Tests + verification

  • Unit (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.
    • Lowering: the output HTML has no oklch(, var(, lab(, @layer, display:flex, display:grid.
    • Each emitter; validateEmailTree; htmlToText.
  • Fixtures in packages/sdk/src/render/email/fixtures/*.json: one-column, two-column, three-column, button row, image + text, with snapshots of html.
  • Real clients before any host ships on it: Gmail web, Gmail iOS, Apple Mail (macOS), Outlook for Windows (desktop), Outlook.com. Send via the host's test-send. Record the results in a table at the bottom of this file.
  • Dev preview: pages/dev/emails/index.tsx (dev-only, 404 in production) gets a "PageHub trees" list rendering each fixture through renderEmailHTML.

Build order

  1. Profile + validator + tests (index.ts exports).
  2. canvasWidth feature + remove responsivePreview.
  3. evalStaticMath, resolveCssVars, lower + strip, inline, all on the built-in emitters with a single-column fixture. Proves colors / vars / spacing.
  4. Email emitters (Container tables, Button, Image) + shell + text + the email-renderer subpath + deps.
  5. emailEditorConfig(), and rewrite the host-constraints.md "Full email-mode boot" example to call it.
  6. Real-client pass; fix list.
  7. Phase 2: Outlook VML rounded buttons, a dark-mode prefers-color-scheme block, icon → PNG.

Out of scope

Dark-mode palettes (phase 2), AMP/interactive email, sending (the host sends { html, text }).

Status

StepState
1. Profile + validator + testsBuilt
2. canvasWidth + remove responsivePreviewBuilt
3. evalStaticMath, resolveCssVars, lower + strip, inlineBuilt
4. Emitters, shell, text, email-renderer subpath, depsBuilt
5. emailEditorConfig() + host-constraints.md boot exampleBuilt
6. Real-client passDone 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.

Real-client results

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.