Tags: unlayer/elements
Tags
fix(shared): decode common named HTML entities in plaintext output (#54) htmlToPlainText (behind the public renderToPlainText, which builds the text/plain MIME part of an email) only mapped ~17 named entities, so common ones such as £, €, é, ° and × leaked into the plaintext as literal entity text (e.g. "Price £5" instead of "Price £5"). Expand the named-entity map to cover currency, typographic punctuation, common symbols, and the Latin-1 accented letters used in European names/words. Kept as a curated map (not the full HTML5 named-reference set) so the ESM bundle stays within its CI size budget; anything omitted still decodes when written as a numeric entity. Unknown entities are still left untouched. Co-authored-by: eastagiletracker <310448263+eastagiletracker@users.noreply.github.com>
renderToHtmlParts: expose granular css/js/tags (#38) * feat(react): expose granular css/js/tags on renderToHtmlParts Non-breaking: head and body are unchanged; the pieces the assembled head is built from are now also returned, for pipelines that need them separately (CSS inlining, injecting into an existing style pipeline, dropping scripts for email sends). No fonts field — Elements has no font registry, and an always-empty array would misrepresent parity with editor chunk exports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(react): position granular parts vs the documented chunk parameters The editor's documented chunks are body/css/js/fonts. Elements exposes tags additionally because component head functions can contribute them — reassembling from css + js alone would silently drop tags — and omits fonts (no font registry; renderToHtml's fonts option covers font links). Both deviations are now stated in the types and README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(react): remove stray prototype file from the branch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Custom tools: registerElementsTool renders editor custom tools from c… …ode (#37) * feat(react): registerElementsTool — render custom tools from code Accepts the same tool definition embedders already write for the editor's registerTool (docs.unlayer.com Custom Tools) and returns a React component. One definition powers both runtimes: the Builder uses the panel/canvas half (label, icon, options, Viewer, validator — all accepted and ignored here), while Elements calls renderer.exporters per mode and collects renderer.head, so code output matches editor exports by construction. renderToJson emits { type: 'custom', slug, values } with u_content_custom_<slug> meta ids — the shape custom-tool designs already use — so trees round-trip into the Builder. Details: document mode falls back to the web exporter and email-only tools render via email everywhere (matching export fallbacks); tool output passes through toSafeHtml when configured (matching editor exports, which sanitize custom tool HTML); the content wrapper, head extraction, and JSON emission honor an explicit meta base so ids match editor-saved designs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(react): custom tool edge cases from real-world tool shapes Adds option-group default seeding — the common real-world custom tool shape keeps values: {} and defines defaults on each property widget, so registerElementsTool now folds those in (disabled properties skipped, tool-level values winning, matching the editor's precedence). 22 edge-case tests modeled on realistic tools: a product card (nested object values, rich-text defaults, editor-only fields at every level) and a head-heavy tool (css keyed on values._meta.htmlID, per-mode branching, js emission, undefined heads, tag dedupe). Failure modes: throwing exporters drop only their block with a logged error, non-string returns coerce, special characters pass through raw, the sanitizer sees each mode's output. Composition: per-slug counters, containerPadding, base props excluded from values, plaintext extraction. Round-trip verified against a live editor: a renderToJson design containing a custom tool loads with the tool registered via customJS, saveDesign returns type/slug/values/_meta intact, and exportHtml renders through the tool's own exporter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(react): review fixes + link-widget normalization + custom tool stories Review feedback: exporter returns are coerced to a string before a configured sanitizer sees them (a non-string return would crash a sanitizer calling string methods), and the metaName doc now describes the suffix rather than the full prefixed id. Adding Storybook coverage surfaced a real parity gap the smoke test caught: tools written for the editor read link-widget values in render shape (values.<option>.url), but Elements only normalized fields named href/action. Options declared with widget: 'link' are now normalized to { url, target } before each render, matching what editor exporters receive. Four new stories under Custom Tools/Registered Tools: a countdown with option-widget defaults, a customized variant, a product card with nested image/link values, and both tools composed inside a full design. Visual baseline extended (130 renders; all pre-existing renders byte-identical to main). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(react): autodocs + full source snippets for custom tool stories The stories had no visible code: the meta lacked the autodocs tag the other component stories carry, and even autodocs would only show the JSX usage — for custom tools the definition object is the code worth copying. Each story now ships an explicit source snippet with the tool definition, the registerElementsTool call, and the usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(react): gallery-grade custom tool examples + per-instance meta ids Ports the accordion and tab tools from the public examples gallery as stories, with the upgrades the originals lack: head css scoped per instance via values._meta.htmlID, idempotent head js, and email exporters (panels render expanded; tabs become stacked sections — email has no JS). Stories render through renderToHtmlParts with a head injector so the interactivity works in the canvas. Building the two-instance story exposed a real bug: the value-level _meta.htmlID came from the factory's index default, so every instance in a Column rendered as _1 while head extraction numbered _1.._N — instance >= 2's scoped css targeted an id that didn't exist. Column now allocates the content id before rendering and threads it into the item, so the value meta, wrapper id, and head/json numbering all agree. Verified in-browser: two accordions with different colors style and toggle independently. All snapshots byte-identical — built-in output is unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(react): port the product, QR, and map gallery tools Three more tools from the public examples gallery, completing the set: - product_library — the gallery's flagship card, ported faithfully: nested image/link values, per-part colors, rich-text description, and the split price/CTA footer, in both div (web) and table (email) markup. The link-widget action arrives in render shape (.url/.target) exactly as editor exporters receive it. - qr_tool — in the Builder a property-editor widget generates the code client-side; from code any generator URL works. Adds the email exporter the original leaves empty (tickets and menus are the prime QR use case). - map_tool — the original builds a Google Static Maps URL around an API key, which a public example should not ship. This port computes OpenStreetMap tile coordinates from lat/lon/zoom in the exporter (slippy-map math), keyless, with a marker overlay on web and the same tile table rendering in email. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(react): name the API registerTool — same term as the Builder Per the naming discussion: terms stay the same across the Builder and Elements (that shared vocabulary is the point of the one-definition contract), and the docs bridge the mental model for Elements-first users — a custom tool is the custom component you create. registerElementsTool remains exported as an alias. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Fix/paragraph margins and full document html (#36) * fix(shared): reset paragraph margins inline in generated text HTML Generated <p> tags now carry margin: 0px plus the logical block-margin longhands inline, matching the markup the Unlayer editor exports. Browsers default <p> to 1em block margins and email clients strip <head> resets, so without the inline reset multi-paragraph text gained extra vertical spacing compared to the editor. Also brings paragraph serialization to parity for custom styles, indent, string/numeric alignment formats, and inline-tool spans. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(react): stack web-mode columns on mobile via flex wrapping The web grid's mobile rules (display: block, width/min-width overrides) are inert on flex items in a nowrap row — flex-basis governs their size — so columns never stacked on small screens and overflowed the viewport instead. Stack by letting the row wrap and growing each column's flex-basis to 100% below the breakpoint, matching the behavior of editor-exported web pages. Also aligns two related behaviors: the web breakpoint is the 480px mobile device breakpoint (email keeps contentWidth + 20), and document/print output never stacks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(react)!: renderToHtml returns a complete HTML document renderToHtml now returns a ready-to-use document from <!DOCTYPE ...> to </html>, matching what the Unlayer editor exports: an XHTML transitional shell with VML namespaces and MSO conditionals for email, an HTML5 shell for web, and a print-friendly XHTML shell for document mode. New options: title and fonts (stylesheet link URLs). Apps that own their document shell keep using renderToHtmlParts, which still returns embeddable { head, body } chunks. Also fixes display-mode resolution for the Email/Page/Document wrappers: they lock their mode internally, so head extraction previously ran in web mode for all of them — renderToHtmlParts(<Email>) returned web CSS instead of email CSS. Golden snapshots now byte-lock the full documents for all three modes. BREAKING CHANGE: renderToHtml previously returned only the body markup. Callers that wrapped it in their own document shell should switch to renderToHtmlParts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(react): browser E2E gate for rendered documents Renders full documents with the built dist for all three modes and asserts in headless Chromium: zero computed <p> margins, a single <body>, responsive column stacking at mobile width, button hover colors, RTL direction, no horizontal overflow, hidden preheader, accessibility basics (img alt, link names, presentation tables), image width pinning, CSS parseability, print media visibility, and a computed-style baseline (deterministic across platforms, unlike pixel screenshots; regenerate with UPDATE_E2E_BASELINE=1). A negative control must fail, proving the gate detects what it guards. Runs in CI after the existing Playwright Chromium install. The ESM bundle budget rises 68KB -> 75KB for the per-mode document shells (currently ~70KB). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(shared): whitelist dir and heading tag in generated text HTML Review feedback: node.direction was interpolated into the dir attribute unescaped, so untrusted Lexical JSON could inject attribute markup. Only ltr/rtl are emitted now — which is also exact parity, since the editor ignores any other direction value. The same guard applies to heading nodes, plus an h1-h6 whitelist for the heading tag name, which was likewise interpolated unvalidated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(react): scope mobile .container rule under .u_row Review feedback: the responsive rule targeted bare .container with !important, which collides with host-page classes (Bootstrap et al.) when Elements output is embedded. Every Elements .container sits inside the .u_row wrapper, so scoping keeps our rendering identical while making the rule inert outside our markup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(react): correct stale breakpoint note in browser E2E header Review feedback: the header still said 620px; the gate tests the real breakpoints (web: 480px device breakpoint, email: contentWidth + 20px). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(react): only strip the host div for Unlayer wrapper roots Review feedback: stripOuterDiv ran on every root, so a bare item root (e.g. renderToHtml(<Button className=...>)) lost its own wrapper div — including author-supplied className/style. The strip now applies only when the root is Body/Email/Page/Document, whose host div is renderer plumbing rather than the element's own markup. Also documents what HtmlParts.body actually contains per mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(react): storybook visual-drift gate Fingerprints the computed styles of every story (126 renders) at desktop and mobile widths in headless Chromium and diffs against a committed, dictionary-encoded baseline. A PR that changes any story's rendered styling fails naming the exact stories and property-level diffs — no more paging through stories by hand to spot drift. Computed styles rather than pixel screenshots keep the baseline deterministic across macOS and Linux. Regenerate intentional changes with UPDATE_VISUAL_BASELINE=1 pnpm test:visual. Runs in CI after the Storybook smoke test, reusing its static build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(react): normalize UA default serif in visual fingerprints First CI run caught the one environment-derived style: elements with no explicit font inherit the browser default serif, reported as Times on macOS and Times New Roman on Linux. Normalized to a stable token so the baseline transfers across platforms; explicit font stacks compute identically and stay covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(react): normalize BlinkMacSystemFont in visual fingerprints macOS Chromium canonicalizes BlinkMacSystemFont to system-ui in computed font-family values; Linux keeps it literal. Normalize to one spelling so story stacks using system-font fallbacks fingerprint identically on both platforms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Fix multi-column Row NaN (default cells to column count) + border wid… ( #35) * Fix multi-column Row NaN (default cells to column count) + border width px unit Two type-checks-but-renders-broken footguns found by cold-start agent testing: - A multi-column <Row> with no `layout`/`cells` defaulted `cells` to `[1]` regardless of column count, so the 2nd/3rd <Column> had no cell and rendered width="NaN". Default to one equal cell PER <Column> child, matching renderToJson. (Also makes a `layout` accidentally placed on <Column> harmless — the Row no longer NaNs.) - A number border width (`borderTopWidth: 1`) rendered `border-top: 1 solid` (invalid CSS the browser drops), even though BorderInput + its JSDoc accept a number. Normalize border *Width fields (number / unit-less numeric string → px) in the mapper, for both nested `border` objects and gathered flat side props. +6 tests (shared mapper normalization + react render-level for both). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Review: clone border before px-normalizing (keep mapSemanticProps pure) The width normalization mutated final.border in place, but final.border can alias the caller's object (the values escape hatch is a shallow clone; a nested border prop passes by reference) — so a reused const HAIRLINE would be rewritten to '1px' as a side effect. Clone before rewriting, reassign. +purity test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix/image objsrc width and dx footguns (#34) * Complete image width round-trip (object-src form) + fix DX footguns Image round-trip: a fixed width now pins whether set via the flat `width` prop OR the object-src / documented `values` full-control form (`src={{ width: 300 }}`). Previously only the flat prop pinned; the object form serialized autoWidth:true and still resized to the original on click in the Builder. Both now emit the editor's canonical pin (autoWidth:false + percent of the column slot), verified against the editor's own imageRendering functions (holds across the on-click natural-dimension reload). An explicit `autoWidth` on `src` is still honored. DX footguns surfaced by cold-start agent testing (valid, type-checking input that produced broken output): - Button: `{ name, attrs: { href } }` (the shape the canonical Href type advertises) rendered href="" — normalizeLinkValue now reads href/target from `attrs` too, and `||` lets the schema's empty default href fall through. Genuine custom attrs are still spread. - Social: `iconSize`/`spacing` as px strings ("34px") rendered max-width:NaNpx — the exporter does arithmetic on them; relax the type to number|string and coerce to a number in the mapper. - Image: a string-url image inherited the placeholder's 1600x400 aspect and emitted a wrong height attr; drop the default height so it's height:auto. Polish: - Export the input building-block types (SizeInput, BorderInput, TextStyleProps, FontFamilyInput, FontWeightInput, HeadingLevel, ImageSrcInput) — referenced by public prop types but not importable (TS2459). - Fix a JSDoc import example (@unlayer-internal/shared-elements -> the public package) and add a Button href example. Tests: new dx-footguns + object-src round-trip cases; updated the two tests that encoded the old object-src=natural behavior; type guards for the exports; refreshed two snapshots (only the wrong height attr removed). 369 pass, typecheck clean, bundle 63.6KB < 68KB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add tests: normalizeLinkValue attrs/empty-fallthrough + cross-component link consistency - shared: normalizeLinkValue reads href from attrs, an empty values.href falls through to attrs (the || vs ?? fix), custom attrs preserved. - react: the attrs-href fix works for Image action + Menu, not just Button. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Canonicalize attrs href into values.href so it round-trips into the editor The render-time fix made { name, attrs:{ href } } render a working anchor, but renderToJson preserved the storage shape (empty values.href + attrs), and the Builder reads values.href — so the link was lost on import. Move an attrs href/target into values.href/target at the mapper (both flat prop and values escape hatch), keeping genuine custom attrs. Found while loading a test design into the live editor. +4 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix CallToAction story (malformed SVG data URI) + note Html is raw passthrough The story embedded an inline-SVG data URI with double quotes (xmlns="…") inside a double-quoted style="…", so the first inner quote closed the attribute and '); opacity: 0.1; "> leaked as visible text. Replaced the grain with a valid CSS radial-gradient dot pattern and dropped the onmouseover/out inline JS (can't run in a rendered email; XSS pattern) from the affected stories. Added a guard test over all Html story HTML (no inline handlers, no url() with a raw double quote) and a security note: <Html> renders verbatim, not sanitized — pass toSafeHtml via UnlayerProvider to sanitize like the editor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Review: tighten normalizeLinkValue guard + strict Social size coercion - normalizeLinkValue: only normalize a {name} object when it actually carries values or attrs, so a bare {name} (or accidental {name:…}) falls through to undefined per the documented contract instead of becoming {url:""}. - Social coerceSizes: parse iconSize/spacing strictly (number or px string); drop a non-px unit ("50%", "1.5em") so it falls back to the schema default rather than being silently parseFloat-ed to a wrong px count. Both caught in review. +2 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pin fixed-width images so they survive the design-JSON round-trip (#33) * Pin fixed-width images so they survive the design-JSON round-trip A fixed image width (px or number) was stored in the natural-size field with autoWidth:true, so re-opening the exported design in an editor reloaded the image's intrinsic dimensions and the explicit width was lost (the image snapped back to its original size on selection). Treat a fixed px/number width as display intent: emit autoWidth:false with maxWidth as a percent of the column's content slot — the canonical fixed-size shape, kept independent of the natural src.width/height. The percent is computed from the same available-width geometry the renderers use (contentWidth x column share, minus paddings/borders) by a width-aware pass in both renderToHtml (via Column's threaded context) and renderToJson (via the tree walk). A percent width/maxWidth already pinned and is unchanged; a no-width image stays responsive (autoWidth:true). - add utils/image-sizing.ts (slot geometry + px->percent conversion) - Image propMapper: capture width/maxWidth as display intent, no longer polluting the natural src.width field - new Image.width-roundtrip tests; update stale assertions that encoded the old natural-size behavior Verified the emitted percent matches the renderer's own available-width math and that the pin no longer jumps when intrinsic dimensions refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: raise ESM bundle budget 60KB->68KB for image width-pinning The bundle was already at 58.4KB on main (97% of the 60KB budget set when it was ~49KB). The image width-pinning fix adds ~4.6KB of dependency-free local geometry, so the budget no longer fits legitimate growth. Raise to 68KB; it still flags accidental dependency bundling (any real dep is 10KB+). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Treat a bare numeric-string contentWidth as fixed px in slot geometry fixedContentWidth only accepted numbers and px strings, but the renderer (Row's toContentWidthPx parseInts any string) and the exporter's body-width math treat a bare numeric string like "600" as 600px. The slot geometry fell back to 500, producing a wrong pinned-image percent for that input. Accept a numeric string with an optional px unit; still reject "%"/"auto". Caught in review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make toPx strict + correct fixedContentWidth doc toPx() parseFloat'd any string, so a non-px maxWidth on a pinned src (e.g. the escape hatch {autoWidth:false, maxWidth:'1.5em'}) was misread as px and converted into a bogus percent. Accept only a number or numeric/px string; leave other CSS units untouched. Add a test guarding it. Also correct fixedContentWidth's doc: it mirrors the exporter's body-width math (bare numeric string = px, %/auto -> fallback), not Row's parseInt (which would misread '50%' as 50). Both caught in review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Share one strict contentWidth->px parse; default row cells to Column count - contentWidth parsing: Row's grid CSS and the image slot geometry had separate parsers that disagreed on non-px values (Row's parseInt read "50%" as 50px; the slot math fell back to 500). Extract one shared bodyContentWidthPx and use it in both, so a non-px contentWidth collapses to the same base everywhere. No change for px/number widths; also fixes a latent email-grid bug for % content widths. - renderToJson default cells: counted all children, so a stray non-Column child inflated the cells array beyond the column list and distorted the column-share math (wrong pinned-image percent) and the row layout. Count only <Column> children, matching the existing comment. Both caught in review. +4 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Keep geometry parsers on parseFloat to mirror the renderer The slot geometry must match the renderer's available-width math, and the editor's explodePaddingsOrMargins / explodeBorder both parseFloat each token (so '10%' is read as 10). edges() already did this; switch borderEdges() back from strict toPx to parseFloat so the two are consistent and both mirror the renderer. Strict px parsing (toPx) stays only for the display-pin value in pinImageSrc, never for the geometry. Documented the rationale inline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render parity: content-width image sizing, containerPadding, unique I… …Ds (#32) * fix(elements): size images against the real content width in columns Item exporters received no column/body context, so the width-aware image exporter fell back to a fixed ~500px regardless of contentWidth or column count — full-width images rendered small and images in multi-column rows could overflow their column. Column now threads its index, the row cells, and the row/column/body values to its item children, and renderComponent surfaces them on the exporter `meta`, so the exporter computes the available width (contentWidth × column fraction, minus padding) the same way the editor does. A standalone item (no Body) now defaults contentWidth to 500 to match the schema default. Result: a full-width image fills the content width, an image in a 3-column row sizes to ~1/3, and nothing overflows. Updates the golden snapshot to the corrected sizes; adds regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): expose containerPadding as a typed item prop (number → px) containerPadding (an item's content-wrapper padding) was threaded at runtime but only typed as a string and never exposed on item props — so containerPadding="10px" was a type error, and a bare number would render unitless. Type it as SizeInput on the item base props and normalize a number to px in Column, matching the other size props. Renders identically to the equivalent px string. Adds render + type tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): unique element ids in renderToHtml (match renderToJson + editor) renderToHtml generated ids per element position, so multi-row designs repeated u_row_1 / u_column_1 / u_content_*_1 — invalid HTML5 and out of step with both renderToJson (a global counter) and the editor (unique stored ids). Thread a per-render id counter on _config (reset by Body, shared by reference down the tree, SSR-safe) so every body/row/column/content id is unique. Updates the multi-element snapshots (id attributes only); adds a uniqueness test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(elements): default standalone contentWidth to the schema-shaped "500px" Use the CSS-string "500px" (matching BodyDefaults.contentWidth) for the standalone-item contentWidth default instead of a bare number, so the value is a CSS string everywhere it might be consumed. Behavior-neutral — the image exporter parseFloats it either way; addresses a review note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DX follow-ups: renderToJson wrapper parity + Menu text inputs (#31) * fix(elements): renderToJson accepts a wrapper component, like renderToHtml renderToHtml renders a custom wrapper component through React, but renderToJson walked the element tree and rejected anything whose root wasn't <Body>/<Email>/ <Page>/<Document> — so renderToJson(<MyEmail/>) threw while renderToHtml(<MyEmail/>) worked. Unwrap a plain function-component root to its returned element (bounded loop; class/forwardRef/memo still hit the clear root-type error). Adds tests for the wrapper case and the still-invalid case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): relax Menu's text inputs to match Heading/Paragraph Menu's fontFamily/fontWeight/fontSize/letterSpacing kept the canonical strict types, so a string fontFamily or a number/em size that compiles on Heading failed on Menu. Relax them to the shared agent-friendly inputs (Menu has no color/lineHeight field, so only these four). Type-only — values are normalized at render time the same way as the other text components. Guarded in the tsc contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(elements): keep extract-head's type comment to its own local concern Public-repo hygiene — the comment now describes only this file's local head type, not anything about how the rendering dependency is structured. * fix(elements): give a clear error when a renderToJson wrapper throws Invoking a wrapper component that uses React hooks throws a bare "Invalid hook call" that masked the intended guidance. Catch the invocation and rethrow an actionable error: a wrapper must synchronously return a root (Email/Page/ Document/Body) and use no hooks — pass the root element or call the component. Adds a test for the throwing-wrapper path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): drop misleading workaround from the unwrap error message The error fires only when invoking the wrapper itself threw, so calling it manually (renderToJson(MyEmail())) would fail identically — suggesting it was misleading. Point only to passing the root element directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PreviousNext