Keep Block Surfaces in Sync with the Editor
Leaflet renders every block from three disconnected code paths, plus a
clipboard round-trip:
- Editor (
components/Blocks/…) — the interactive doc you write in.
This is the source of truth for how a block looks and behaves.
- Published post (
app/(app)/(published)/lish/[did]/[publication]/[rkey]/…) —
the read-only version served at leaflet.pub, rendered from the AT-Protocol
record (facets + block fields), not from the editor components.
- Email newsletter (
emails/post.tsx) — the version sent to subscribers,
rendered with react-email and inline pixel styles.
- Copy/paste (
src/utils/getBlocksAsHTML.tsx + src/utils/paste/) — the
editor's clipboard serializer and parser, which must round-trip the block.
There is no shared styling layer between them beyond a few global CSS
classes. A change in the editor does not automatically show up anywhere
else — it has to be mirrored by hand. This skill is the checklist.
Structural completeness is compiler-enforced (since 2026-08-11):
src/utils/blockDispatch.ts derives BlockTypeMap/KnownBlockType from
the generated block union (PubLeafletPagesLinearDocument.Block["block"]) —
nothing to maintain there — and PostContent.tsx, feedHtml.ts, and
emails/post.tsx each define an exhaustive BlockHandlers map dispatched
via matchBlock. So: lexicon addition → npm run lexgen → tsc errors in
every surface until each handles the type or explicitly opts out
(() => null / BlockNotSupported). Likewise factsToPagesRecord.ts
(publish serialization) and getBlocksAsHTML.tsx (copy) are exhaustive over
the editor union, and inline facet marks flow through
src/utils/facetFeatures.ts's extractFacetFeatures (derived keys; shared
by TextBlockCore and feedHtml). src/utils/blockDispatch.test.ts
compares the generated schemas back to lexicons/src, so editing a lexicon
without running lexgen fails vitest. Forgetting a surface entirely is a
build failure; this skill is about the part the compiler can't see —
visual parity — plus the paste pipeline.
When to use
Any time you create a block type or update an existing block — visual
styling (margin/padding, font size, weight, line-height, color, alignment, the
block wrapper) or structure (new fields, new variants) — check and mirror all
of: the published renderer, the email renderer, and copy/paste. Do this by
default, unless the user explicitly says not to (e.g. "editor only"). Also
run it as an audit when asked to "make the published post match the editor."
Purely interactive/editor-only changes do not get mirrored: focus/selection
outlines, hover affordances, drag handles, remote-cursor overlays, command-bar UI,
placeholder text, foldable-heading markers, relative/positioning added solely to
anchor an editor overlay. If a change has no read-only visual effect, skip it.
Check in with the user first when…
Before mirroring, compare the editor version against the existing published and
email implementations. Stop and ask the user before proceeding if:
- The published or email counterpart doesn't exist for this block type
(e.g.
emails/post.tsx falls through to BlockNotSupported, or
PostContent.tsx has no case for it). Building a whole new renderer for a
surface is a scope decision, not a mechanical mirror.
- The existing published or email version is significantly, deliberately
different from the editor (different layout, different content shown, not
just drifted values). It may be intentional (email client constraints,
static-context simplification) — ask rather than flattening the difference.
Small drifts (a margin or font-size that fell out of sync) are what this skill
exists for — just fix those.
The surfaces
Editor (source of truth)
components/Blocks/TextBlock/index.tsx — the most important file.
HeadingStyle map (per-level classes: weight, leading-*, pb-*, color,
heading font-family).
headingFontSize map → blockTextSize (src/utils/blockTextSize.ts).
BaseTextBlock textStyle (the editable block — this is what the author
actually sees, so it's the authoritative style): small → textSizeSmall text-secondary, large → textSizeLarge text-primary, default → text-primary.
alignmentClass (text-left/right/center/justify).
RenderedTextBlock is the non-editing fallback in the editor; if it and
BaseTextBlock disagree, trust BaseTextBlock.
components/Blocks/Block.tsx — the block wrapper spacing: top/bottom
margins per block type, the heading level→top-margin map, blockquote stacking
margins, first/last-block spacing, list indentation.
app/globals.css — shared CSS. Some styles live here (.pageScrollWrapper h1..h4, .textSizeSmall/.textSizeLarge, h1..h4 { font-bold }, CSS variables
like --list-marker-width). Changes here are already shared with the
published post if the published markup uses the same selector/class — verify,
don't assume. They are never shared with email.
Published post
app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx — the primary
target: the interactive published post. The Block component's switch
renders each block type (text → <p>, header → <h1/h2/h3/h6>,
blockquote → <blockquote>, lists, image, …) and builds the block-wrapper
className (margins) + inline style (font size). Most mirroring happens
here.
app/(app)/(published)/lish/[did]/[publication]/feedHtml.ts — the
RSS/Atom/JSON feed serializer (generateFeed.ts), not email. It emits
deliberately unstyled, portable semantic HTML (no classes, no inline styles),
so styling changes never get mirrored here. Structural changes are
tsc-enforced via its two exhaustive BlockHandlers maps (render +
plain-text); write the handler thoughtfully rather than defaulting to
() => "". It has its own vitest suite (feedHtml.test.ts), including a
no-classes/no-styles regression test.
…/Blocks/TextBlockCore.tsx — inline facet rendering (bold, italic,
underline, strikethrough, code, highlight, links, mentions, footnotes). Mirror
here if you changed an inline mark's appearance. These mostly reuse the same
global CSS classes as the editor (font-bold, italic, inline-code,
highlight, …), so inline marks usually stay in sync automatically. Facet
extraction comes from src/utils/facetFeatures.ts — a new facet feature
type is added there once, then each consumer (this file, feedHtml.ts)
decides how to render the new field.
Email newsletter
emails/post.tsx — BlockRenderer is the email counterpart of
PostContent.tsx's block switch: one PubLeafletBlocksX.isMain(block) branch
per block type, each styled with inline pixel styles on react-email
components (fontSize: 16, HEADING_FONT_SIZE_PX, BLOCK_MARGIN,
HEADING_MARGIN), with colors/fonts from the resolved EmailTheme.
Unhandled types fall through to BlockNotSupported / BlockDataNotFound.
emails/bskyPost.tsx, emails/standardSiteBlocks.tsx — embed-style
blocks split into their own files.
- Sent by
app/api/inngest/functions/send_post_broadcast.ts; previewable via
actions/publications/sendPostPreview.tsx ("send test email").
- Email constraints: no Tailwind, no global CSS, no
em scaling, no CSS
variables — everything is inline style with absolute px values, and many
clients strip anything fancy. Mirror the visual intent (relative size,
weight, spacing, color role), not the mechanism. E.g. editor textSizeSmall
(0.875em of a 16px-ish base) → fontSize: 14 in email.
Copy/paste (editor clipboard)
The clipboard must round-trip whatever the block renders:
- Copy:
src/utils/copySelection.ts → src/utils/getBlocksAsHTML.tsx,
whose renderBlock switch serializes each block type to HTML (plaintext falls
out via htmlToMarkdown). A new or changed block field that isn't serialized
here is silently dropped on copy.
- Paste:
components/Blocks/TextBlock/useHandlePaste.ts drives the pipeline
in src/utils/paste/ (normalizePastedHTML.ts → htmlToBlocks.ts
buildBlockFromHTML), which turns clipboard HTML back into block facts. See
src/utils/paste/README.md for the architecture.
- Check: copying the block in the editor and pasting it back should
reproduce it (type, content, and Leaflet-specific attributes). For a new
block type, that usually means a
renderBlock case and a matching parse
case, plus a test in src/utils/paste/htmlToBlocks.test.ts. Run the tests
with npm test (vitest).
Editor → published mapping (block wrapper)
The editor's Block.tsx wrapper uses padding + margin; the published
PostContent.tsx uses margins on the block element. Match the visual result,
not the class names verbatim. Current mapping for headers (keep in sync):
| Condition (previous block) |
Editor (Block.tsx) |
Published (PostContent.tsx) |
| first block |
mt-1 sm:mt-2 |
mt-1 sm:mt-2 |
| after a horizontal rule |
"" (rule's own margin) |
"" |
| after another heading |
mt-1 |
mt-1 |
| level 1, otherwise |
mt-5 sm:mt-6 |
mt-5 sm:mt-6 |
| level 2, otherwise |
mt-4 sm:mt-5 |
mt-4 sm:mt-5 |
| level 3 / 4, otherwise |
mt-2 sm:mt-3 |
mt-2 sm:mt-3 |
HeadingStyle is duplicated as a constant in both TextBlock/index.tsx and
PostContent.tsx — edit both (each has a comment pointing at the other).
Gotchas that cause silent drift
- em vs rem — text size. The editor sizes small/large text with
em classes
(.textSizeSmall = 0.875em, .textSizeLarge = 1.125em) so they scale with
the publication's custom base font (--theme-font-base-size on
.pageScrollWrapper). Do not mirror with Tailwind text-sm/text-lg —
those are fixed rem and ignore the custom base. In the published post use an
inline em fontSize (0.875em / 1.125em / 1em), which scales in every
context (main post, quote excerpts, page-link previews), not only inside
.pageScrollWrapper. In email, use absolute px against the 16px body base.
- Inline
style beats className. The published <p>/<hN> set
fontSize inline (blockTextSize.*). An inline font-size overrides any
Tailwind size class on the same element. If you need a size variant, change the
inline value — don't add a size class next to it and expect it to win.
.pageScrollWrapper-scoped CSS. Classes like .textSizeSmall,
.pageScrollWrapper h1..h4 only apply inside that wrapper. Published content
also renders in Quotes excerpts and page-link previews that may sit outside it.
Prefer self-contained inline styles / full class strings there.
- Level-4 heading =
<h6>. The published renderer emits <h6> for level 4,
which the global h1..h4 { font-bold } and .pageScrollWrapper h1..h4
font-family rules do not target. A level-4 heading needs its weight, heading
font-family, and color spelled out (this is why HeadingStyle[4] is applied
directly in PostContent.tsx). Email clamps heading levels to h1–h3
(Math.min(3, …) in BlockRenderer).
- Global CSS may already cover it. Before duplicating a style, check whether a
app/globals.css rule (e.g. .pageScrollWrapper h2 { font-size }) already
applies to the published markup. If so, the change is shared — but confirm the
published element actually matches that selector. Email never gets global CSS.
Process
- Diff the editor change (e.g.
git show <commit> -- components/Blocks/). List
each stylistic/structural hunk; drop editor-only/interaction hunks.
- Locate the block's counterpart in
PostContent.tsx and emails/post.tsx
(and TextBlockCore.tsx for inline marks; feedHtml.ts for structural
changes only — see above). If a counterpart is missing or deliberately very
different, check in with the user first (see above) before writing code.
- Apply the equivalent change to each surface, honoring the gotchas
(em-vs-rem, inline-vs-class, px-only email). If a value lives in a
HeadingStyle-style duplicated constant, update every copy.
- For new fields/variants or a new block type, wire up copy/paste:
getBlocksAsHTML.tsx serializer case + src/utils/paste/htmlToBlocks.ts
parse case + test coverage.
- Verify:
npx tsc for types, npm test if paste/copy code changed, and if
you can, eyeball parity with the tests-posts harness or by running the app
(/run). Email can be eyeballed via sendPostPreview.
- Report per surface. End with an explicit breakdown of what changed in
each of: editor, published post, and email (plus copy/paste if
touched). For any surface where nothing changed, say so and why — "no
changes" must be stated, never implied by omission.
1---2name: mirror-editor-block-styles3description: Keep every render surface of a block in sync with the editor. Use whenever you create a new block type or change an existing block in components/Blocks/ — styling (margins, padding, font size/weight, line-height, color, alignment) or structure — so the published post and the email newsletter keep matching the editor, and copy/paste keeps working. Unless the user says otherwise, block work isn't done until all surfaces are checked.4---56# Keep Block Surfaces in Sync with the Editor78Leaflet renders every block from **three disconnected code paths**, plus a9clipboard round-trip:10111. **Editor** (`components/Blocks/…`) — the interactive doc you write in.12 This is the **source of truth** for how a block looks and behaves.132. **Published post** (`app/(app)/(published)/lish/[did]/[publication]/[rkey]/…`) —14 the read-only version served at `leaflet.pub`, rendered from the AT-Protocol15 record (facets + block fields), not from the editor components.163. **Email newsletter** (`emails/post.tsx`) — the version sent to subscribers,17 rendered with react-email and inline pixel styles.184. **Copy/paste** (`src/utils/getBlocksAsHTML.tsx` + `src/utils/paste/`) — the19 editor's clipboard serializer and parser, which must round-trip the block.2021There is **no shared styling layer** between them beyond a few global CSS22classes. A change in the editor does **not** automatically show up anywhere23else — it has to be mirrored by hand. This skill is the checklist.2425**Structural completeness is compiler-enforced** (since 2026-08-11):26`src/utils/blockDispatch.ts` **derives** `BlockTypeMap`/`KnownBlockType` from27the generated block union (`PubLeafletPagesLinearDocument.Block["block"]`) —28nothing to maintain there — and `PostContent.tsx`, `feedHtml.ts`, and29`emails/post.tsx` each define an exhaustive `BlockHandlers` map dispatched30via `matchBlock`. So: lexicon addition → `npm run lexgen` → tsc errors in31every surface until each handles the type or explicitly opts out32(`() => null` / `BlockNotSupported`). Likewise `factsToPagesRecord.ts`33(publish serialization) and `getBlocksAsHTML.tsx` (copy) are exhaustive over34the editor union, and inline facet marks flow through35`src/utils/facetFeatures.ts`'s `extractFacetFeatures` (derived keys; shared36by `TextBlockCore` and `feedHtml`). `src/utils/blockDispatch.test.ts`37compares the generated schemas back to `lexicons/src`, so editing a lexicon38without running lexgen fails vitest. *Forgetting a surface entirely* is a39build failure; this skill is about the part the compiler can't see —40**visual parity** — plus the paste pipeline.4142## When to use4344Any time you **create a block type or update an existing block** — visual45styling (margin/padding, font size, weight, line-height, color, alignment, the46block wrapper) or structure (new fields, new variants) — check and mirror all47of: the published renderer, the email renderer, and copy/paste. Do this **by48default, unless the user explicitly says not to** (e.g. "editor only"). Also49run it as an audit when asked to "make the published post match the editor."5051Purely *interactive/editor-only* changes do **not** get mirrored: focus/selection52outlines, hover affordances, drag handles, remote-cursor overlays, command-bar UI,53placeholder text, foldable-heading markers, `relative`/positioning added solely to54anchor an editor overlay. If a change has no read-only visual effect, skip it.5556## Check in with the user first when…5758Before mirroring, compare the editor version against the existing published and59email implementations. **Stop and ask the user before proceeding** if:6061- The **published or email counterpart doesn't exist** for this block type62 (e.g. `emails/post.tsx` falls through to `BlockNotSupported`, or63 `PostContent.tsx` has no case for it). Building a whole new renderer for a64 surface is a scope decision, not a mechanical mirror.65- The existing published or email version is **significantly, deliberately66 different** from the editor (different layout, different content shown, not67 just drifted values). It may be intentional (email client constraints,68 static-context simplification) — ask rather than flattening the difference.6970Small drifts (a margin or font-size that fell out of sync) are what this skill71exists for — just fix those.7273## The surfaces7475### Editor (source of truth)7677- **`components/Blocks/TextBlock/index.tsx`** — the most important file.78 - `HeadingStyle` map (per-level classes: weight, `leading-*`, `pb-*`, color,79 heading font-family).80 - `headingFontSize` map → `blockTextSize` (`src/utils/blockTextSize.ts`).81 - `BaseTextBlock` `textStyle` (the **editable** block — this is what the author82 actually sees, so it's the authoritative style): small → `textSizeSmall83 text-secondary`, large → `textSizeLarge text-primary`, default → `text-primary`.84 - `alignmentClass` (`text-left/right/center/justify`).85 - `RenderedTextBlock` is the *non-editing* fallback in the editor; if it and86 `BaseTextBlock` disagree, trust `BaseTextBlock`.87- **`components/Blocks/Block.tsx`** — the block **wrapper** spacing: top/bottom88 margins per block type, the heading level→top-margin map, blockquote stacking89 margins, first/last-block spacing, list indentation.90- **`app/globals.css`** — shared CSS. Some styles live here (`.pageScrollWrapper91 h1..h4`, `.textSizeSmall/.textSizeLarge`, `h1..h4 { font-bold }`, CSS variables92 like `--list-marker-width`). Changes here are **already shared** with the93 published post *if* the published markup uses the same selector/class — verify,94 don't assume. They are **never** shared with email.9596### Published post9798- **`app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx`** — the primary99 target: the interactive published post. The `Block` component's `switch`100 renders each block type (`text` → `<p>`, `header` → `<h1/h2/h3/h6>`,101 `blockquote` → `<blockquote>`, lists, image, …) and builds the block-wrapper102 `className` (margins) + inline `style` (font size). **Most mirroring happens103 here.**104- **`app/(app)/(published)/lish/[did]/[publication]/feedHtml.ts`** — the105 **RSS/Atom/JSON feed** serializer (`generateFeed.ts`), *not* email. It emits106 deliberately unstyled, portable semantic HTML (no classes, no inline styles),107 so **styling changes never get mirrored here**. Structural changes are108 tsc-enforced via its two exhaustive `BlockHandlers` maps (render +109 plain-text); write the handler thoughtfully rather than defaulting to110 `() => ""`. It has its own vitest suite (`feedHtml.test.ts`), including a111 no-classes/no-styles regression test.112- **`…/Blocks/TextBlockCore.tsx`** — inline **facet** rendering (bold, italic,113 underline, strikethrough, code, highlight, links, mentions, footnotes). Mirror114 here if you changed an inline *mark's* appearance. These mostly reuse the same115 global CSS classes as the editor (`font-bold`, `italic`, `inline-code`,116 `highlight`, …), so inline marks usually stay in sync automatically. Facet117 *extraction* comes from `src/utils/facetFeatures.ts` — a new facet feature118 type is added there once, then each consumer (this file, `feedHtml.ts`)119 decides how to render the new field.120121### Email newsletter122123- **`emails/post.tsx`** — `BlockRenderer` is the email counterpart of124 `PostContent.tsx`'s block switch: one `PubLeafletBlocksX.isMain(block)` branch125 per block type, each styled with **inline pixel styles** on react-email126 components (`fontSize: 16`, `HEADING_FONT_SIZE_PX`, `BLOCK_MARGIN`,127 `HEADING_MARGIN`), with colors/fonts from the resolved `EmailTheme`.128 Unhandled types fall through to `BlockNotSupported` / `BlockDataNotFound`.129- **`emails/bskyPost.tsx`**, **`emails/standardSiteBlocks.tsx`** — embed-style130 blocks split into their own files.131- Sent by `app/api/inngest/functions/send_post_broadcast.ts`; previewable via132 `actions/publications/sendPostPreview.tsx` ("send test email").133- Email constraints: **no Tailwind, no global CSS, no `em` scaling, no CSS134 variables** — everything is inline `style` with absolute px values, and many135 clients strip anything fancy. Mirror the *visual intent* (relative size,136 weight, spacing, color role), not the mechanism. E.g. editor `textSizeSmall`137 (0.875em of a 16px-ish base) → `fontSize: 14` in email.138139### Copy/paste (editor clipboard)140141The clipboard must round-trip whatever the block renders:142143- **Copy**: `src/utils/copySelection.ts` → `src/utils/getBlocksAsHTML.tsx`,144 whose `renderBlock` switch serializes each block type to HTML (plaintext falls145 out via `htmlToMarkdown`). A new or changed block field that isn't serialized146 here is silently dropped on copy.147- **Paste**: `components/Blocks/TextBlock/useHandlePaste.ts` drives the pipeline148 in `src/utils/paste/` (`normalizePastedHTML.ts` → `htmlToBlocks.ts`149 `buildBlockFromHTML`), which turns clipboard HTML back into block facts. See150 `src/utils/paste/README.md` for the architecture.151- **Check**: copying the block in the editor and pasting it back should152 reproduce it (type, content, and Leaflet-specific attributes). For a new153 block type, that usually means a `renderBlock` case *and* a matching parse154 case, plus a test in `src/utils/paste/htmlToBlocks.test.ts`. Run the tests155 with `npm test` (vitest).156157## Editor → published mapping (block wrapper)158159The editor's `Block.tsx` wrapper uses **padding + margin**; the published160`PostContent.tsx` uses margins on the block element. Match the *visual* result,161not the class names verbatim. Current mapping for headers (keep in sync):162163| Condition (previous block) | Editor (`Block.tsx`) | Published (`PostContent.tsx`) |164|-----------------------------------|--------------------------|-------------------------------|165| first block | `mt-1 sm:mt-2` | `mt-1 sm:mt-2` |166| after a horizontal rule | `""` (rule's own margin) | `""` |167| after another heading | `mt-1` | `mt-1` |168| level 1, otherwise | `mt-5 sm:mt-6` | `mt-5 sm:mt-6` |169| level 2, otherwise | `mt-4 sm:mt-5` | `mt-4 sm:mt-5` |170| level 3 / 4, otherwise | `mt-2 sm:mt-3` | `mt-2 sm:mt-3` |171172`HeadingStyle` is duplicated as a constant in **both** `TextBlock/index.tsx` and173`PostContent.tsx` — edit both (each has a comment pointing at the other).174175## Gotchas that cause silent drift176177- **em vs rem — text size.** The editor sizes small/large text with `em` classes178 (`.textSizeSmall` = `0.875em`, `.textSizeLarge` = `1.125em`) so they scale with179 the publication's custom base font (`--theme-font-base-size` on180 `.pageScrollWrapper`). Do **not** mirror with Tailwind `text-sm`/`text-lg` —181 those are fixed `rem` and ignore the custom base. In the published post use an182 inline **em** `fontSize` (`0.875em` / `1.125em` / `1em`), which scales in every183 context (main post, quote excerpts, page-link previews), not only inside184 `.pageScrollWrapper`. In **email**, use absolute px against the 16px body base.185- **Inline `style` beats `className`.** The published `<p>`/`<hN>` set186 `fontSize` inline (`blockTextSize.*`). An inline font-size **overrides** any187 Tailwind size class on the same element. If you need a size variant, change the188 inline value — don't add a size class next to it and expect it to win.189- **`.pageScrollWrapper`-scoped CSS.** Classes like `.textSizeSmall`,190 `.pageScrollWrapper h1..h4` only apply *inside* that wrapper. Published content191 also renders in Quotes excerpts and page-link previews that may sit outside it.192 Prefer self-contained inline styles / full class strings there.193- **Level-4 heading = `<h6>`.** The published renderer emits `<h6>` for level 4,194 which the global `h1..h4 { font-bold }` and `.pageScrollWrapper h1..h4`195 font-family rules do **not** target. A level-4 heading needs its weight, heading196 font-family, and color spelled out (this is why `HeadingStyle[4]` is applied197 directly in `PostContent.tsx`). Email clamps heading levels to h1–h3198 (`Math.min(3, …)` in `BlockRenderer`).199- **Global CSS may already cover it.** Before duplicating a style, check whether a200 `app/globals.css` rule (e.g. `.pageScrollWrapper h2 { font-size }`) already201 applies to the published markup. If so, the change is shared — but confirm the202 published element actually matches that selector. Email never gets global CSS.203204## Process2052061. Diff the editor change (e.g. `git show <commit> -- components/Blocks/`). List207 each **stylistic/structural** hunk; drop editor-only/interaction hunks.2082. Locate the block's counterpart in `PostContent.tsx` and `emails/post.tsx`209 (and `TextBlockCore.tsx` for inline marks; `feedHtml.ts` for structural210 changes only — see above). If a counterpart is missing or deliberately very211 different, **check in with the user first** (see above) before writing code.2123. Apply the equivalent change to each surface, honoring the gotchas213 (em-vs-rem, inline-vs-class, px-only email). If a value lives in a214 `HeadingStyle`-style duplicated constant, update every copy.2154. For new fields/variants or a new block type, wire up copy/paste:216 `getBlocksAsHTML.tsx` serializer case + `src/utils/paste/htmlToBlocks.ts`217 parse case + test coverage.2185. Verify: `npx tsc` for types, `npm test` if paste/copy code changed, and if219 you can, eyeball parity with the `tests-posts` harness or by running the app220 (`/run`). Email can be eyeballed via `sendPostPreview`.2216. **Report per surface.** End with an explicit breakdown of what changed in222 each of: **editor**, **published post**, and **email** (plus copy/paste if223 touched). For any surface where nothing changed, say so and why — "no224 changes" must be stated, never implied by omission.