Skip to content

Tags: unlayer/elements

Tags

v0.1.16

Toggle v0.1.16's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v0.1.15

Toggle v0.1.15's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v0.1.14

Toggle v0.1.14's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v0.1.13

Toggle v0.1.13's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v0.1.12

Toggle v0.1.12's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v0.1.11

Toggle v0.1.11's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v0.1.10

Toggle v0.1.10's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v0.1.9

Toggle v0.1.9's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v0.1.8

Toggle v0.1.8's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Merge pull request #14 from unlayer/deps/update-unlayer

deps: update @unlayer/exporters and @unlayer/types

v0.1.7

Toggle v0.1.7's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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.