Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
d7afa62
chore(ci): raise the bundle size budget to 78KB
ivoIturrieta Oct 4, 2026
562272a
fix(shared): write rgb() colors and quoted font names the way the exp…
ivoIturrieta Oct 4, 2026
57239d3
fix(react): escape previewText
ivoIturrieta Oct 4, 2026
2942733
feat(react): accept a fonts prop on root components
ivoIturrieta Oct 4, 2026
5cd1ac6
fix(react): make Row noStackMobile take effect
ivoIturrieta Oct 4, 2026
08edf30
feat(convert-core): Elements tree, report and outputs for converters
ivoIturrieta Oct 4, 2026
f1f93ff
feat(from-react-email): convert React Email templates to Unlayer Elem…
ivoIturrieta Oct 4, 2026
73ee1d9
feat(migrate): npx @unlayer/migrate, the React Email migration CLI
ivoIturrieta Oct 4, 2026
ec2e324
docs: migrating from React Email
ivoIturrieta Oct 4, 2026
e20510f
fix: address React Email migration review findings
ivoIturrieta Oct 4, 2026
49c577b
fix: apply automatic JSX to imported migration helpers
ivoIturrieta Oct 4, 2026
8230dad
fix: preserve React Email migration behavior and outputs
ivoIturrieta Oct 4, 2026
2514b3c
fix(convert-core): decode every entity, keep number punctuation and w…
ivoIturrieta Oct 4, 2026
cb2e4cc
fix(from-react-email): convert every return, tag only text, links and…
ivoIturrieta Oct 4, 2026
1e1abfe
fix(migrate): load templates from CommonJS and ESM projects, refuse s…
ivoIturrieta Oct 4, 2026
9372109
refactor(migrate): publish one package with a React Email subpath
ivoIturrieta Oct 4, 2026
f1deddf
feat(elements): render phone settings and device visibility
ivoIturrieta Oct 5, 2026
b9d504c
fix(elements): keep hidden columns collapsed on phones
ivoIturrieta Oct 5, 2026
971fc09
feat(from-react-email): convert phone layout and content settings
ivoIturrieta Oct 5, 2026
895a76c
chore(ci): raise the Elements bundle budget to 85KB
ivoIturrieta Oct 5, 2026
1c8b7f4
chore(ci): scope Storybook smoke checks to React stories
ivoIturrieta Oct 5, 2026
d1d62af
docs: document phone settings and conversion fidelity
ivoIturrieta Oct 5, 2026
3671156
fix: use only phone settings the editor supports, so migrated designs…
ivoIturrieta Oct 5, 2026
ff763a4
fix(from-react-email): keep phone classes in a className chosen by a …
ivoIturrieta Oct 5, 2026
49e6d48
docs(from-react-email): update phone results
ivoIturrieta Oct 5, 2026
36406b8
fix: keep a card's edge and its item gaps when columns stack on phones
ivoIturrieta Oct 5, 2026
2104676
chore(ci): raise the Elements bundle budget to 86KB
ivoIturrieta Oct 5, 2026
a2d3d87
fix(from-react-email): text outside a Text inherits its box's font size
ivoIturrieta Oct 5, 2026
f00a777
fix(from-react-email): keep fixed px sizes on phones
ivoIturrieta Oct 5, 2026
5ba6721
docs(from-react-email): update phone results and known differences
ivoIturrieta Oct 5, 2026
c7f052c
fix(from-react-email): keep a full-width box's background image, in t…
ivoIturrieta Oct 5, 2026
eeb3234
fix(from-react-email): fit long words in columns side by side on phones
ivoIturrieta Oct 5, 2026
b566ed2
feat(react-elements): textDirection and lang on root components
ivoIturrieta Oct 5, 2026
f3530af
fix(migrate): installed CLI's React, hidden content, RTL, order check…
ivoIturrieta Oct 5, 2026
519fab7
fix(from-react-email): preserve values captured by JSX constants
ivoIturrieta Oct 5, 2026
13ebae5
fix(migrate): preserve shared sources until conversion completes
ivoIturrieta Oct 5, 2026
dbdd639
fix(from-react-email): preserve empty dynamic preview text
ivoIturrieta Oct 5, 2026
48f7dd6
fix(migrate): support wrapped React templates
ivoIturrieta Oct 5, 2026
fb01410
fix(migrate): reject conversions that add words
ivoIturrieta Oct 5, 2026
9afbf96
chore(react-elements): set the workspace version to 0.2.0
ivoIturrieta Oct 6, 2026
1fc389a
fix(migrate): stop when Elements is older than the supported range
ivoIturrieta Oct 6, 2026
80e2a26
ci(release): publish each package only when its code changed
ivoIturrieta Oct 6, 2026
8d3b49a
fix(from-react-email): fold spacer rows into their neighbours
ivoIturrieta Oct 7, 2026
bca2694
docs(examples): open React Email templates in the Unlayer editor
ivoIturrieta Oct 7, 2026
1d3b416
fix(from-react-email): give space-only columns an invisible divider
ivoIturrieta Oct 7, 2026
72eba1a
fix(from-react-email): keep the spacer in columns whose content code …
ivoIturrieta Oct 7, 2026
1e3504b
fix(from-react-email): give HTML blocks their font size, even 16px
ivoIturrieta Oct 7, 2026
3808ea1
feat(migrate): list each template's web fonts for the editor
ivoIturrieta Oct 7, 2026
49d1610
fix(convert-core): give images in text and HTML blocks their width in…
ivoIturrieta Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 233 additions & 11 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
# Release & Publish
#
# Gated publish flow:
# Gated publish flow, one package at a time:
# 1. PR merged to main → triggers build & test
# 2. A reviewer approves the publish in the Actions UI (environment gate)
# 3. Package is published to npm via trusted publishing (OIDC)
# 2. Each package publishes only when its code changed since its last
# release tag (Elements: v*, migrate: migrate-v*)
# 3. A reviewer approves each publish in the Actions UI (environment gate)
# 4. The package is published to npm via trusted publishing (OIDC)
#
# Version: reads the latest published version from npm and bumps from that.
# Defaults to patch. Add a `release:minor` or `release:major` PR label to
# override. Manual dispatch also supports bump selection.
# override. Manual dispatch also supports bump selection. A version
# committed ahead of npm (a planned minor or major) wins over the bump.
#
# Auth: Uses npm trusted publishing (OIDC) — no NPM_TOKEN needed.
# The `npm-publish` environment must be configured in repo Settings →
# Environments with at least one required reviewer.
#
# npm trusted publishing must also be configured on npmjs.com:
# npm trusted publishing must also be configured on npmjs.com, per package:
# Package Settings → Publishing access → Add trusted publisher →
# Repository: unlayer/elements
# Workflow: publish.yml
# Repository: unlayer/elements
# Workflow: publish.yml
# Environment: npm-publish
# npm only offers this on a package that exists, so a new package's first
# version is published by hand, with its release tag (e.g. migrate-v0.1.0).

name: Release

Expand All @@ -25,6 +31,14 @@ on:
branches: [main]
workflow_dispatch:
inputs:
package:
description: 'Package to release'
required: true
type: choice
options:
- react-elements
- migrate
default: react-elements
bump:
description: 'Version bump type'
required: true
Expand Down Expand Up @@ -75,9 +89,53 @@ jobs:
- name: Run tests
run: pnpm test

changes:
name: Detect package changes
runs-on: ubuntu-latest
outputs:
elements: ${{ steps.detect.outputs.elements }}
migrate: ${{ steps.detect.outputs.migrate }}

steps:
- name: Checkout code (with tags)
uses: actions/checkout@v4
with:
fetch-depth: 0

# Compared with each package's last release tag, so a release that
# failed or was rejected is retried on the next push. Fails open: a git
# error counts as a change.
- name: Detect changes since each package's last release
id: detect
shell: bash
run: |
changed() {
local tag
tag=$(git tag -l "$1" --sort=-v:refname | head -1)
shift
[ -z "$tag" ] && { echo true; return; }
git diff --quiet "$tag" HEAD -- "$@" && echo false || echo true
}
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
ELEMENTS=$([ "${{ inputs.package }}" = "react-elements" ] && echo true || echo false)
MIGRATE=$([ "${{ inputs.package }}" = "migrate" ] && echo true || echo false)
else
ELEMENTS=$(changed 'v*' packages/react packages/shared pnpm-workspace.yaml)
MIGRATE=$(changed 'migrate-v*' packages/migrate packages/from-react-email packages/convert-core)
fi
# The first version of a new package is published by hand (see above).
if [ "$MIGRATE" = "true" ] && ! npm view @unlayer/migrate version >/dev/null 2>&1; then
echo "::notice::@unlayer/migrate isn't on npm yet: publish its first version by hand, tag it migrate-v<version>, and add its trusted publisher."
MIGRATE=false
fi
echo "elements=$ELEMENTS" >> $GITHUB_OUTPUT
echo "migrate=$MIGRATE" >> $GITHUB_OUTPUT
echo "Elements changed: $ELEMENTS, migrate changed: $MIGRATE"

publish:
name: Publish to npm
needs: build-and-test
name: Publish @unlayer/react-elements
needs: [build-and-test, changes]
if: needs.changes.outputs.elements == 'true'
runs-on: ubuntu-latest
concurrency:
group: publish
Expand Down Expand Up @@ -151,8 +209,9 @@ jobs:
id: version
working-directory: packages/react
run: |
COMMITTED=$(node -p "require('./package.json').version")
# Read latest published version from npm (falls back to package.json for first publish)
CURRENT=$(npm view @unlayer/react-elements version 2>/dev/null || node -p "require('./package.json').version")
CURRENT=$(npm view @unlayer/react-elements version 2>/dev/null || echo "$COMMITTED")
echo "current=$CURRENT" >> $GITHUB_OUTPUT

# Write current version to package.json so npm version can bump from it
Expand All @@ -161,6 +220,12 @@ jobs:
# Bump
VERSION=$(npm version ${{ steps.bump.outputs.type }} --no-git-tag-version)
VERSION="${VERSION#v}"

# A version committed ahead of npm (a planned minor or major) wins
if [ "$COMMITTED" != "$VERSION" ] && [ "$(printf '%s\n%s\n' "$VERSION" "$COMMITTED" | sort -V | tail -1)" = "$COMMITTED" ]; then
npm version "$COMMITTED" --no-git-tag-version --allow-same-version
VERSION="$COMMITTED"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT

- name: Pre-publish summary
Expand Down Expand Up @@ -205,15 +270,172 @@ jobs:
git push origin "$TAG"
fi

# Notes start at the previous Elements tag: migrate-v* tags don't match v*.
- name: Create GitHub Release
run: |
TAG="v${{ steps.version.outputs.version }}"
PREVIOUS=$(git ls-remote --tags --refs origin | sed 's#.*refs/tags/##' | grep -E '^v[0-9]' | grep -vFx "$TAG" | sort -V | tail -1)
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists — skipping"
else
gh release create "$TAG" \
--title "$TAG" \
--generate-notes
--generate-notes \
${PREVIOUS:+--notes-start-tag "$PREVIOUS"}
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

publish-migrate:
name: Publish @unlayer/migrate
needs: [build-and-test, changes, publish]
# After Elements (when it publishes too), so the Elements range migrate requires is on npm.
if: >-
!cancelled() &&
needs.build-and-test.result == 'success' &&
needs.changes.outputs.migrate == 'true' &&
(needs.publish.result == 'success' || needs.publish.result == 'skipped')
runs-on: ubuntu-latest
concurrency:
group: publish-migrate
cancel-in-progress: false
environment: npm-publish
permissions:
contents: write
id-token: write
pull-requests: read

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.7.0

- name: Setup Node.js 24 (with npm registry for OIDC)
uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org

- name: Update npm for trusted publishing
run: npm install -g npm@12

- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build packages
run: pnpm build

- name: Determine version bump
id: bump
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "type=${{ inputs.bump }}" >> $GITHUB_OUTPUT
else
LABELS=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" --jq '.[0].labels[].name' 2>/dev/null || echo "")
if echo "$LABELS" | grep -Fxq "release:major"; then
echo "type=major" >> $GITHUB_OUTPUT
elif echo "$LABELS" | grep -Fxq "release:minor"; then
echo "type=minor" >> $GITHUB_OUTPUT
else
echo "type=patch" >> $GITHUB_OUTPUT
fi
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Determine version
id: version
working-directory: packages/migrate
run: |
COMMITTED=$(node -p "require('./package.json').version")
CURRENT=$(npm view @unlayer/migrate version 2>/dev/null || echo "$COMMITTED")
echo "current=$CURRENT" >> $GITHUB_OUTPUT
npm version "$CURRENT" --no-git-tag-version --allow-same-version
VERSION=$(npm version ${{ steps.bump.outputs.type }} --no-git-tag-version)
VERSION="${VERSION#v}"
if [ "$COMMITTED" != "$VERSION" ] && [ "$(printf '%s\n%s\n' "$VERSION" "$COMMITTED" | sort -V | tail -1)" = "$COMMITTED" ]; then
npm version "$COMMITTED" --no-git-tag-version --allow-same-version
VERSION="$COMMITTED"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT

# Packing writes `^<packages/react version>` as migrate's Elements range:
# that release must be on npm, or installs would fail (or, with an older
# range, silently use an Elements without the converter's settings).
- name: Check the Elements range is published
id: elements
working-directory: packages/migrate
run: |
RANGE="^$(node -p "require('../react/package.json').version")"
echo "range=$RANGE" >> $GITHUB_OUTPUT
for attempt in 1 2 3 4 5 6; do
if [ -n "$(npm view "@unlayer/react-elements@$RANGE" version 2>/dev/null)" ]; then exit 0; fi
sleep 10
done
echo "::error::@unlayer/react-elements@$RANGE isn't on npm: release Elements first."
exit 1

- name: Pre-publish summary
run: |
echo "## Publish Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| | |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
echo "| **Package** | \`@unlayer/migrate\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Version** | ${{ steps.version.outputs.current }} → ${{ steps.version.outputs.version }} (${{ steps.bump.outputs.type }}) |" >> $GITHUB_STEP_SUMMARY
echo "| **Elements range** | ${{ steps.elements.outputs.range }} |" >> $GITHUB_STEP_SUMMARY
echo "| **Trigger** | ${{ github.event_name }} |" >> $GITHUB_STEP_SUMMARY

- name: Publish to npm (trusted publishing)
working-directory: packages/migrate
run: |
pnpm pack --pack-destination /tmp
TARBALL=$(ls /tmp/unlayer-migrate-*.tgz)
npm publish "$TARBALL" --access public

- name: Tag release
run: |
TAG="migrate-v${{ steps.version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if git ls-remote --tags origin | awk '{print $2}' | grep -Fxq "refs/tags/$TAG"; then
echo "Tag $TAG already exists — skipping"
else
git tag "$TAG"
git push origin "$TAG"
fi

# Not marked latest: the repository's latest release stays Elements.
- name: Create GitHub Release
run: |
TAG="migrate-v${{ steps.version.outputs.version }}"
PREVIOUS=$(git ls-remote --tags --refs origin | sed 's#.*refs/tags/##' | grep -E '^migrate-v[0-9]' | grep -vFx "$TAG" | sort -V | tail -1)
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists — skipping"
else
gh release create "$TAG" \
--title "@unlayer/migrate ${{ steps.version.outputs.version }}" \
--generate-notes \
--latest=false \
${PREVIOUS:+--notes-start-tag "$PREVIOUS"}
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
8 changes: 4 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,16 @@ jobs:
run: pnpm --filter @unlayer/react-elements typecheck

# ── Bundle size budget ──────────────────────────────────────
# Fail if the react-elements ESM bundle exceeds 76KB.
# Current size: ~76KB (15 components + image width-pinning geometry
# + per-mode full-document shells for renderToHtml).
# Fail if the react-elements ESM bundle exceeds 86KB.
# Current size: 84,826 bytes, including phone device-override CSS
# and visibility settings.
# The budget tracks the unminified ESM output as a proxy for code volume;
# it still flags accidental dependency bundling (any real dep is 10KB+).
- name: Check bundle size
run: |
BUNDLE="packages/react/dist/index.js"
SIZE=$(wc -c < "$BUNDLE" | tr -d ' ')
MAX_SIZE=76000
MAX_SIZE=86000
echo "Bundle: $BUNDLE"
echo "Size: $SIZE bytes (budget: $MAX_SIZE bytes)"
if [ "$SIZE" -gt "$MAX_SIZE" ]; then
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ cd packages/react && pnpm storybook # Storybook dev server
| `@unlayer/react-elements` | `packages/react` | Yes (npm) | React components, renderers, context |
| `@unlayer-internal/shared-elements` | `packages/shared` | No (private, bundled into react) | Framework-agnostic types, config, utils |
| `@unlayer/elements-demo` | `packages/demo` | No | Demo/showcase app |
| `@unlayer/from-react-email` | `packages/from-react-email` | No (private, bundled into migrate; API at `@unlayer/migrate/react-email`) | React Email → Elements converter (codemod, runtime with merge tags, check against the original); fidelity benchmark in `bench/`, results in `FIDELITY.md` |
| `@unlayer/migrate` | `packages/migrate` | Yes (npm) | React Email converter API at `@unlayer/migrate/react-email`; `npx @unlayer/migrate` CLI: migrate React Email templates, check them, write a report; `compare` for templates migrated by hand |
| `@unlayer/convert-core` | `packages/convert-core` | No (private, bundled into the converters) | Elements tree, conversion report, TSX printer and content check that converters share |

### Component Hierarchy (strict)

Expand Down Expand Up @@ -102,13 +105,13 @@ Example: `<Button fontSize="16px">` → `{ style: { fontSize: "16px" } }` in the
- **Storybook smoke test**: `packages/react/.storybook/test-runner.ts` — opens every story in headless Chromium and asserts each component paints visible content with no console / page errors. Runs against both the dev server (`pnpm test-storybook`) and the production static build (`pnpm test-storybook:ci`). Note: Storybook bundles from `src/` via Vite — the published `dist/` artifact is covered by the Next.js integration and the CSP gate.
- **CSP safety gate**: `packages/react/scripts/csp-probe.mjs` (`pnpm test:csp`) — imports + renders the built `dist/` bundle under V8's `--disallow-code-generation-from-strings` (a Content-Security-Policy without `'unsafe-eval'`). **Hard gate**: fails if this package _or_ its pinned `@unlayer/exporters` evaluates a string (`eval` / `new Function`) at import or render. It stays red until the workspace catalog pins a precompiled / CSP-safe `@unlayer/exporters` release — a green check must mean the package is genuinely CSP-safe.
- **Storybook visual-drift gate**: `packages/react/scripts/storybook-visual.mjs` (`pnpm test:visual`, needs `pnpm build-storybook` first) — fingerprints the computed styles of every story (126 renders) at desktop + mobile widths in headless Chromium and diffs against the committed `scripts/storybook-visual-baseline.json` (dictionary-encoded; regenerate intentional changes with `UPDATE_VISUAL_BASELINE=1 pnpm test:visual`). Fails naming the exact stories and property-level diffs. Computed styles, not pixel screenshots — deterministic across macOS/Linux; blind only to pure rasterization differences.
- **Browser E2E gate**: `packages/react/scripts/browser-e2e.mjs` (`pnpm test:e2e`, needs `pnpm build` + `playwright install chromium` once) — renders full documents with the built `dist/` for all three modes and asserts in headless Chromium across eleven sections: (1) document contract — every `<p>` computes to 0px margins (the inline reset beats the UA default), exactly one `<body>`, title/styles/links applied, no console/page errors; (2) responsive — three columns side-by-side at desktop width, stacked full-width below the mobile breakpoint (480px web, contentWidth+20 email; document/print never stacks); (3) interaction — button `:hover` applies the configured hover colors; (4) RTL — `textDirection` reaches the computed direction; (5) style baseline — computed colors/fonts/radii/padding/column-widths diffed against the committed `scripts/browser-e2e-baseline.json` (regenerate intentional changes with `UPDATE_E2E_BASELINE=1 pnpm test:e2e`; computed values instead of pixel screenshots so the baseline is deterministic across macOS/Linux); (6) no horizontal overflow at mobile width; (7) preheader — `previewText` present but invisible/zero-size; (8) accessibility — img alt, link names, `role="presentation"` on layout tables; (9) image width pinning holds and stays inside its column; (10) every non-empty `<style>` parses into CSS rules; (11) document mode stays visible under print media emulation. Includes a negative control (a document with a bare `<p>`) that must fail the checks — a green run proves the gate can detect the regression it guards, not just that selectors matched nothing.
- **Browser E2E gate**: `packages/react/scripts/browser-e2e.mjs` (`pnpm test:e2e`, needs `pnpm build` + `playwright install chromium` once) — renders full documents with the built `dist/` for all three modes and asserts in headless Chromium across eleven sections: (1) document contract — every `<p>` computes to 0px margins (the inline reset beats the UA default), exactly one `<body>`, title/styles/links applied, no console/page errors; (2) responsive — three columns side-by-side at desktop width, stacked full-width below the mobile breakpoint (480px web, contentWidth+20 email; document/print never stacks), and a `noStackMobile` row kept side by side in proportion on phones even when a stacking row follows it; (3) interaction — button `:hover` applies the configured hover colors; (4) RTL — `textDirection` reaches the computed direction; (5) style baseline — computed colors/fonts/radii/padding/column-widths diffed against the committed `scripts/browser-e2e-baseline.json` (regenerate intentional changes with `UPDATE_E2E_BASELINE=1 pnpm test:e2e`; computed values instead of pixel screenshots so the baseline is deterministic across macOS/Linux); (6) no horizontal overflow at mobile width; (7) preheader — `previewText` present but invisible/zero-size; (8) accessibility — img alt, link names, `role="presentation"` on layout tables; (9) image width pinning holds and stays inside its column; (10) every non-empty `<style>` parses into CSS rules; (11) document mode stays visible under print media emulation. Includes a negative control (a document with a bare `<p>`) that must fail the checks — a green run proves the gate can detect the regression it guards, not just that selectors matched nothing.

## CI Quality Gates

- TypeScript strict compilation
- All unit tests pass
- Bundle size < 76KB (ESM)
- Bundle size < 86KB (ESM)
- Next.js integration build succeeds
- Browser E2E gate passes (rendered documents verified in Chromium: 0px `<p>` margins, single `<body>`, no errors)
- Storybook smoke test passes (every story renders, no console errors)
Expand Down
Loading
Loading