Tags: unlayer/elements
Tags
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>
Make the agent-facing prop types airtight (#30) * fix(elements): accept a factored-out border object on Column without `as const` The canonical ColumnValues.border pins each per-side *Width to `${number}px`, so an inline `border={{ borderBottomWidth: "1px" }}` type-checks but the recommended DRY hairline pattern — a reusable `const HAIRLINE = { ... }` applied across many columns — widens "1px" to `string` and fails strict tsc, even though the runtime already accepts it. The types were stricter than the runtime, the opposite of the DX layer's intent. Add a BorderInput type (derived from the canonical border shape so it tracks the schema instead of duplicating it) that relaxes the per-side *Width fields to SizeInput, and Omit+redeclare `border` on ColumnProps — mirroring the existing FontFamilyInput / SizeInput widenings. Type-only change; build green, 329 tests pass. Button/Table/Divider carry the same canonical border and can reuse BorderInput in a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(elements): enforce the DX prop-type contract with a tsc gate The build only type-checks the index import graph, so the agent-friendly prop types had no CI guard — a relaxed input type could be reverted (like the Column `border` regression) and nothing would catch it; vitest doesn't type-check. Add src/dx-types.test-d.tsx: compile-time assertions that the natural authoring forms type-check (factored-out border hairline incl. numeric width, numeric/ string fontSize+fontWeight, string/object fontFamily, numeric lineHeight, full- width button, percent image width) and that garbage is rejected (@ts-expect-error on a string border, a bogus fontWeight). It imports only TYPES, so it stays out of the render graph and checks in isolation — no storybook/exporters noise. Wire it up with a scoped tsconfig.typecheck.json, a `typecheck` script, and a "Type contract" CI step. Verified the gate goes red when BorderInput is reverted to the strict canonical type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): relax box-model dimension types across all components A type stress test of every component showed the Column `border` fix was one instance of a broader hazard: the canonical @unlayer/types pins every scalar dimension field to a `${number}px`-style template, so natural forms an agent writes — a bare number (`borderRadius={8}`, `padding={14}`), a factored-out border object, a computed/widened string — fail strict tsc even though the runtime already normalizes them to px (verified: no unitless output, no [object Object], no NaN). The types were stricter than the runtime. Relax to SizeInput / BorderInput, mirroring the existing FontFamilyInput / SizeInput widenings, at BOTH layers: - component-local *SemanticProps (Button, Menu, Table, Divider) — these are what the factory types the components with (what JSX checks); - the exported *Props aliases in types.ts (Button/Menu/Table/Divider) and the container props (Column/Body borderRadius). Fields: borderRadius (Button/Column/Body), padding (Button/Menu/Table), border object (Button/Table/Divider). Divider previously passed the raw strict SemanticProps to the factory; it now has a relaxed DividerSemanticProps. Body and Divider gained the `as SemanticProps<X>` cast at the mapSemanticProps call that Column/Row already use. Type-only; build green, 329 tests pass, render output unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(elements): extend the type-contract gate to the box-model relaxations Guard the broader relaxation against the ACTUAL component prop types (imported from the component files, not just the exported aliases): borderRadius as a number on Column/Button, item padding as a number on Button/Menu/Table, and a factored-out border object on Button/Table/Divider. Runs in the existing `typecheck` CI gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(elements): one source of truth for component prop types The item prop types (ButtonProps, HeadingProps, …) were defined twice — a parallel set in types.ts (the exported ones) and the real ones next to each component (what the components are actually typed with). The two could drift, so the type you imported didn't always match what the component accepted. Drop the types.ts duplicates and export each component's own prop type from its file, matching how RowProps / EmailProps already work. types.ts is now just the shared value-type re-exports plus the agent-friendly input building blocks (SizeInput, BorderInput, TextStyleProps, …) that components import, and the unused ItemProps helper is gone. No behavior change; build green, 329 tests pass, and the exported type now equals the component's accepted props. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): type extract-head's head shape locally; gate the full contract extract-head.ts imported `type ComponentHead` from @unlayer/exporters, but that package only exports `heads` — an untyped Record<string, any> registry — and neither @unlayer/exporters nor @unlayer/types defines a head type. The phantom import was a latent type error the build happened to tolerate. Replace it with a local ComponentHead type describing the css/js/tags builders this file calls. The index import graph is now type-clean, so add dx-contract.test.tsx to the typecheck gate — its @ts-expect-error garbage-rejection assertions are now enforced in CI alongside dx-types.test-d.tsx. Type-only; build green, 329 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>
Fix/image button width roundtrip (#28) * fix(elements): pin image display width for Builder round-trip An explicit image width was emitted with autoWidth:true, so the Builder auto-sized the image to its natural width and dropped the intended width when the image was selected. Now an explicit width — passed inside `src`, as a flat `width=` prop, or via the `values.src` escape hatch — emits autoWidth:false + maxWidth:"<w>px", pinning the display width independent of the natural size. All user-provided src fields are merged (defensively; base.src may be a string), flat src props are no longer clobbered, an explicit maxWidth alone also pins, and an explicit autoWidth is honored. A string `values.src` is normalized to { url } before mapping so it is not character-spread when a flat src prop is also present. No explicit sizing stays responsive. Adds regression tests for the flat-prop and escape-hatch paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): pin button width for Builder round-trip Same class as the image fix: an explicit button width set size.width but left size.autoWidth:true, so the Builder auto-sized the button to its content and dropped the width on selection. The pin now reads the mapped size (covering the flat prop, nested prop, and `values` escape hatch) and sets size.autoWidth:false when a width is present, unless the user set autoWidth themselves. The size is guarded as a plain object before mutation so a malformed escape-hatch value is ignored rather than throwing. Adds regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): let an explicit maxWidth win over a natural width src={{ url, width: 1600, maxWidth: "50%" }} serialized to maxWidth:"1600px", clobbering the caller's display width. width is the natural size and maxWidth is the display size, so an explicit maxWidth must take precedence — derive maxWidth from the numeric width only when no maxWidth was provided. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(elements): normalize CSS-idiom prop values so natural forms render Authors (human and AI) reach for CSS habits that type-check but render blank or invalid because flat props are loosely typed. mapSemanticProps now coerces the natural form to the shape the exporters expect: - fontFamily: "Arial" -> { label, value } (a bare string was char-spread into the fontFamily group, dropping the font entirely) - fontWeight: "700" -> 700 (keyword strings like "bold" are left as-is) - fontSize / padding / borderRadius: bare number or unit-less string -> "<n>px" - lineHeight: 1.4 -> "1.4" Image sizing now accepts CSS-style width: a px string ("300px") and bare number pin the display width (autoWidth:false + maxWidth), and a percent ("50%") routes to the display maxWidth. Previously only a numeric width pinned, so width="300px" silently rendered full-width and re-triggered the select-resize round-trip. Runtime only; the types are tightened separately. Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(elements): agent-friendly prop types for the natural authoring forms The public prop types were generated from the canonical model, so the flattened semantic props were typed `any` (wrong forms type-checked and rendered broken) while the strict object forms rejected the documented/working code. Replace the `any` flat keys via Omit + re-declare with the real accepted unions: - Image `src` accepts a plain URL string or the value object; `width`/`maxWidth` accept a number, px, or percent. - Text components accept fontFamily as a string or { label, value }, fontWeight as a number/numeric-string/keyword, fontSize/lineHeight as number or string. - Heading gains the `level` alias and h1–h6 (was h1–h4); Paragraph types `text`. These pair with the runtime normalization so the natural form type-checks AND renders correctly, and clear garbage is now rejected. Adds dx-contract.test.tsx, a tsc-enforced guard (@ts-expect-error) over the should-pass / should-fail forms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): make Table columns/rows render an empty grid instead of crashing The documented numeric API (columns/rows) type-checked but crashed: the flat `rows` prop collided with the nested `table.rows` data key, so the exporter iterated a number. Intercept `columns`/`rows` in the propMapper (like headers/data), set the top-level counts, and build an empty columns x rows grid when no data is given. headers/data shorthand is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(elements): document the accepted natural prop forms; add Spotify example The README previously listed the CSS-idiom forms (string fontFamily, string fontWeight, numeric fontSize, padding "0", Paragraph text) as mistakes — they now work, so the guidance was actively steering authors away from working code. Updated the Critical Rules and Common Mistakes, and noted the recommended forms. Adds a Spotify Premium welcome story written in the natural form (string src + width="300px") as a living example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): treat Image width/maxWidth "100%" as responsive, not pinned A "100%" display width means fill-the-container (the default responsive image behavior), so it should map to autoWidth:true rather than being pinned with autoWidth:false. Smaller percents and px widths still pin (autoWidth:false). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(elements): loosen container padding type; add four agent example stories Reconstructing the fresh-agent example templates surfaced one more footgun: an idiomatic multi-value padding with a bare 0 token ("0 48px", "8px 40px 0 40px") was type-rejected because the canonical padding type requires px on every token, even though it is valid CSS and renders fine. Loosen padding/containerPadding on Row/Column/Body to SizeInput (string | number) via Omit + re-declare. Adds four example stories under src/examples/ (Airbnb, Stripe, Nike, Linear) written in the natural authoring forms, as living proof the DX holds end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): handle JSX children, flat Column borders, and previewText round-trip Three medium-severity gaps from the DX audit: - JSX/element children on text components stringified to "[object Object]". Flatten the children tree to its text content (framework-free duck-typing) so e.g. <Heading>Hi <b>x</b></Heading> renders "Hi x" instead of garbage. Use the `html` prop for inline formatting. - Flat Column border props (borderTopWidth, …) were dropped because the Column default ships an empty `border: {}` the nested-group detector can't read. Gather flat border-side props into `border` when the component declares that field. - previewText was excluded from renderToJson, so it never round-tripped to the editor. Map it to the schema's preheaderText in the body JSON. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(elements): accept CSS letterSpacing; document Button width; 5 more examples A second blind round of agent-built emails (Warby order, Luma event, Mercury receipt, Morning Brew digest, Notion welcome) compiled first-try; two small gaps surfaced and are fixed: - letterSpacing was px/number only, so an em value ("-0.01em") was type-rejected. Loosen it to a CSS string or number (bare number normalized to px), matching fontSize/lineHeight/padding. - Button `width` works but was undocumented — added to the README so full-width CTAs (width="100%") are discoverable. Adds the five templates as example stories. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(elements): permanent regression coverage for the agent-friendly DX layer The DX runtime behaviors were verified ad-hoc; codify them so a regression is caught by CI (pnpm test). Adds dx-behaviors.test.tsx (22 tests): image sizing (numeric/px/percent/100%/no-width/maxWidth precedence/string src), font + CSS normalization (fontFamily/fontSize/fontWeight/lineHeight/letterSpacing), text components (level alias, h5, Paragraph text, JSX children flatten), and the shorthands/round-trip (Table columns/rows grid, headers/data, Column flat border, previewText->preheaderText, multi-value padding). Also makes the dx-contract runtime assertion actually render instead of a typeof no-op. Verified the suite catches regressions (disabling a fix turns its test red). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * polish(elements): redesign table-heavy examples with clean label/value rows The Airbnb, Stripe, Mercury, and Warby examples used the Table headers/data shorthand for details/line-items, which renders a bordered, zero-padding spreadsheet. Replace those with label/value rows — muted label left, bold value right, separated by hairline Column-border dividers — plus brand-accurate palettes, eyebrow labels (letterSpacing), Heading for amounts, and tighter spacing. Renders cleanly; no behavior/runtime change (stories only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): normalize numeric Button width / Image maxWidth; doc + type cleanups Addresses code-review findings: - Button width accepts a number (SizeInput) but the exporter wants a CSS string; coerce numeric/unit-less size.width to px (200 -> "200px"). Previously rendered "width: 200" (invalid). - Image maxWidth accepts a number but the sizing logic only handled strings, so a numeric maxWidth was dropped and the pin didn't fire. Coerce number/unit-less to px (percent/px strings pass through). - README: lineHeight is kept unitless (not px); JSX children are now flattened to text, not "corrupted" — corrected both. - Drop the no-op `containerPadding` from Row/Column/Body types (it is not a field on any of them — only content blocks have it; normalization still applies there) and the unnecessary `as any` on the Table columns/rows test (they type-check). Adds regression tests for numeric Button width and Image maxWidth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): image sizing follows Unlayer's autoWidth/maxWidth model The image mapper invented a px-based display "pin" (autoWidth:false + maxWidth:"<w>px" derived from src.width). That isn't how Unlayer sizes images and it broke responsive layouts: an object src.width — which is the image's NATURAL size, not a display width — was forced to a fixed display width, overflowing multi-column rows. Mirror the actual model instead: src.width/height are the natural size and never pin the display; the default is responsive (autoWidth:true, capped at the natural size); a fixed display size is autoWidth:false + maxWidth as a PERCENT of the container. A px/number width is the natural size, which gives "up to <w>px, responsive" for free. Button width was already correct (size.width is the display width when autoWidth:false) and is unchanged. Tests and README updated to the faithful behavior; verified the affected layouts render byte-identical to main again. Also scrubbed comments that described editor internals (this is a public repo). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * polish(elements): make the Spotify Premium example feel premium Was flat text on black. Add a hero image for visual energy, switch to Spotify's real palette (#121212 dark, brighter #1ED760 green), add a green "SPOTIFY PREMIUM" eyebrow with letter-spacing and a bolder headline, plus a "WHAT'S INCLUDED" section label and green-check benefit titles. Type-clean; renders fine. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(elements): guard a dimensioned image stays responsive in a multi-column row Closes the coverage gap behind the column-overflow regression: assert that an object src.width (the natural size) inside a multi-column row stays responsive (autoWidth:true, maxWidth:"100%") rather than being forced to its natural width. * fix(elements): remove stray space in Warby example tracking URL The USPS track href in the WarbyOrderShipped story had a literal space in the tLabels tracking number, producing an invalid URL. Drop the space so the example link is well-formed. (Encoding it as %20 would keep a space inside the tracking number and still not resolve.) 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(elements): honor each block's containerPadding in Column render (#26 ) * fix(elements): honor each block's containerPadding in Column render Column rendered every content block's `u_content_*` wrapper with the column's padding instead of the block's own `containerPadding`. It read `child.props.values?.containerPadding`, which is always undefined for the flat-prop API (`<Heading containerPadding="...">`), so every block collapsed to `COLUMN_DEFAULTS.padding` ("0px"). The renderToJson path correctly defaulted to "10px", so designs looked right in the builder but rendered jammed-to-edges through the React elements package. Fix: - Resolve each block's containerPadding via the item's own pipeline (`mergeValues(defaultValues, propMapper(props))`), default "10px". - Delegate the wrapper to the canonical (previously unused) exported `ContentExporters[mode]`, so padding, the `v-container-padding` class, and the per-mode div/table structure match the editor exactly. Adds regression tests to Column.test.tsx (proven to fail on the old code) and re-baselines the two full-tree snapshots. Email blocks now render the canonical <table> wrapper instead of a hand-rolled <div> (snapshot churn). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): thread Body contentWidth to rows (web column widths) Body cloned its Row children with only `_config`, never passing the body-level values down. Row therefore fell back to BODY_DEFAULTS.contentWidth ("500px"), so `<Body contentWidth="…">` was silently ignored for layout: in web mode every column computed its width as a percentage of a 500px container regardless of the declared contentWidth. Visible symptom: the Stats Bar example rendered its 4 columns at 125px (25% of 500) instead of 240px (25% of 960), so a 44px stat like "68.4%" overflowed and `overflow-wrap: break-word` snapped the "%" onto its own line, misaligning the label row. Fix: Body now clones children with `bodyValues: values`, so Row/Column inherit contentWidth (and other body context). The row exporter already parses both "960px" and 960, so no parsing change is needed. This also corrects the golden templates, which declared contentWidth 600px (email) / 960px (web) but were rendering at 500px. Adds Body.test.tsx regression coverage and re-baselines the two golden snapshots. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(elements): resolve containerPadding from props directly in Column Addresses a PR review note: Column resolved each child's containerPadding via mergeValues(defaultValues, propMapper(child.props)), which re-ran the item's propMapper a second time per child (the item's own renderFn already runs it). For Paragraph that duplicated the children→Lexical textJson conversion on every render. containerPadding is a universal base-content prop: mapSemanticProps passes it through untouched and it isn't present in any item's defaultValues, so reading it straight from props (flat prop + `values` escape hatch, default 10px) is equivalent to the full pipeline — and drops the redundant propMapper/mergeValues call. Output is byte-identical (all snapshots unchanged). Removes the now-unused mergeValues / UNLAYER_CONFIG_KEY imports and adds an escape-hatch regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(elements): right-size SaaS Landing & Magazine example widths These two Body examples declared contentWidth 800px / 960px, which the prior bug ignored (clamping to 500px) — so they were never seen at their true width. Now that contentWidth is honored, they nearly filled the ~1000px Storybook docs preview and read as left-shifted/uncentered. Narrow both to 680px so they center with comfortable margins, and reduce Magazine's overlay/intro paddings (sized for 960px) to match. Other examples (Receipt 560, Newsletter/Ecommerce 600, and the Row/Column showcases whose multi-column content fills its width) already frame well. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): balance EditorialWithStats — Heading for stat numbers The right-column stats (73% / 2.4x / 18 mo.) were <Paragraph>, which renders a <p>. A <p>'s default margin is 1em, which scales with font-size — so at 52px each stat carried ~52px phantom top/bottom margin, inflating each block to ~114px and ballooning the column to 731px. That forced the wide editorial column to match (flex stretch), leaving a large dead zone and spreading the stats with huge gaps. Use <Heading> for the display numbers (renders <h_> with margin:0), tighten the label rhythm, so both columns are balanced and the stats group cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): use Heading for stat numbers in StatsBar & SaaS Landing Same phantom-margin issue as EditorialWithStats: large display numbers rendered as <Paragraph> pick up the <p> default 1em margin, which scales with font-size (44px / 40px here), adding unwanted vertical space below each stat. Convert the stat numbers to <Heading> (margin:0) so the number→label spacing is tight. Labels stay <Paragraph>. Purely visual; stories only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): replace two dead Unsplash URLs in Image stories Image/Showcase 3rd avatar (photo-1494790108755...) and ThumbnailImage (photo-1486312338219...) both 404'd, rendering as broken-image alt text. Swapped for live portrait / code-on-screen photos (verified 200). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(elements): correct email width handling across clients Three issues made email output inconsistent across clients: 1. Outlook width: the email body exporter reads contentWidth from the `bodyValues` field of its 3rd argument. We passed `values` directly, so the MSO/Outlook table fell back to 600px for any contentWidth. Now pass `{ bodyValues: values }`. 2. Responsive grid CSS hardcoded 600px instead of the body contentWidth. Row now passes the resolved contentWidth to generateGridCSS (default 500px). 3. Body skipped its default-value merge (item components already merge theirs), so contentWidth/textColor defaults were absent and the exporters hit their own internal fallbacks. Body now merges BODY_DEFAULTS. Result: the Outlook table, container max-width, and responsive grid CSS all resolve the same body contentWidth (default 500px or explicit). Re-baselines the email snapshots to the now-consistent output (+color:#000000, default width 500). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(elements): add output-contract tests for email rendering Snapshot tests lock the exact HTML, so a regression in key invariants (column widths, defaults) surfaces as a noisy diff rather than a clear failure. These assert the invariants directly: email Outlook/container/grid widths must all equal the body contentWidth; unset contentWidth uses the 500px default (not the exporter's 600 fallback); the body carries the textColor default; content defaults to 10px padding. Verified each fails on the pre-fix code. 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: render link/action values to the shape exporters expect (#15) Button.href, Image.action, Menu items, Video.href and Timer.action all rendered with href="" (or no anchor at all) regardless of the shape the caller passed. The schema stores links as `{ name, values: { href, target } }` but exporters read `e.url`, so the value never reached the rendered HTML. The editor avoids this by running the link property-editor's `renderValue()` between storage and exporter; React-elements skipped that step. Add `normalizeLinkValue` (string / `{ url }` / `{ name, values }` -> render shape) and `normalizeValuesForExporter` which walks known link-bearing paths (top-level `href`/`action`, `menu.items[].link`). Call it from `createItemComponent` between `mergeValues` and the exporter handoff. The `renderToJson` path uses the propMapper directly and is untouched, so JSON output still round-trips into the editor with the storage shape intact. Also extend the existing string shorthand in `mapSemanticProps` to cover `action` (was only `href`), so a string `<Image action="..." />` is wrapped to the same storage shape before merge. Tests: - 11 new unit tests for the helpers in semantic-props.test.ts - 4 regression tests in Button.test.tsx (string / storage / values escape hatch / email mode) - 3 regression tests in Image.test.tsx (string / storage / email mode) - snapshots regenerated; only diff is `target="_blank"` now correctly emitted on default-href anchors, matching what the editor produces Verified end-to-end: 275 unit tests pass, packed tarball smoke tests pass, live Next.js RSC dev server returns correct hrefs.
PreviousNext