mirror-editor-block-styles
Skill527 starsChanged 4 months agoMITView on GitHub
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.
In this repository12 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- blueprint-generateSkill
- blueprintSkill
- domain-settingsSkill
- leaflet-ai-apiSkill
- lexiconsSkill
- notificationsSkill
- publication-settingsSkill
- specSkill
- tests-postsSkill
- tests-subscribeSkill
- update-default-themeSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 2 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 59
Before mirroring, compare the editor version against the existing published and email implementations.
- Distinctive: line 193
Prefer self-contained inline styles / full class strings there.
- Very specific: line 200
Global CSS may already cover it: Before duplicating a style, check whether a app/globals.css rule (e.g.
- Very specific: line 219
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 …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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 atleaflet.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.tsxfalls through toBlockNotSupported, orPostContent.tsxhas 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.HeadingStylemap (per-level classes: weight,leading-*,pb-*, color, heading font-family).headingFontSizemap →blockTextSize(src/utils/blockTextSize.ts).BaseTextBlocktextStyle(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).RenderedTextBlockis the non-editing fallback in the editor; if it andBaseTextBlockdisagree, trustBaseTextBlock.
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. TheBlockcomponent'sswitchrenders each block type (text→<p>,header→<h1/h2/h3/h6>,blockquote→<blockquote>, lists, image, …) and builds the block-wrapperclassName(margins) + inlinestyle(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 exhaustiveBlockHandlersmaps (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 fromsrc/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—BlockRendereris the email counterpart ofPostContent.tsx's block switch: onePubLeafletBlocksX.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 resolvedEmailTheme. Unhandled types fall through toBlockNotSupported/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 viaactions/publications/sendPostPreview.tsx("send test email"). - Email constraints: no Tailwind, no global CSS, no
emscaling, no CSS variables — everything is inlinestylewith absolute px values, and many clients strip anything fancy. Mirror the visual intent (relative size, weight, spacing, color role), not the mechanism. E.g. editortextSizeSmall(0.875em of a 16px-ish base) →fontSize: 14in email.
Copy/paste (editor clipboard)
The clipboard must round-trip whatever the block renders:
- Copy:
src/utils/copySelection.ts→src/utils/getBlocksAsHTML.tsx, whoserenderBlockswitch serializes each block type to HTML (plaintext falls out viahtmlToMarkdown). A new or changed block field that isn't serialized here is silently dropped on copy. - Paste:
components/Blocks/TextBlock/useHandlePaste.tsdrives the pipeline insrc/utils/paste/(normalizePastedHTML.ts→htmlToBlocks.tsbuildBlockFromHTML), which turns clipboard HTML back into block facts. Seesrc/utils/paste/README.mdfor 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
renderBlockcase and a matching parse case, plus a test insrc/utils/paste/htmlToBlocks.test.ts. Run the tests withnpm 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
emclasses (.textSizeSmall=0.875em,.textSizeLarge=1.125em) so they scale with the publication's custom base font (--theme-font-base-sizeon.pageScrollWrapper). Do not mirror with Tailwindtext-sm/text-lg— those are fixedremand ignore the custom base. In the published post use an inline emfontSize(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
stylebeatsclassName. The published<p>/<hN>setfontSizeinline (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..h4only 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 globalh1..h4 { font-bold }and.pageScrollWrapper h1..h4font-family rules do not target. A level-4 heading needs its weight, heading font-family, and color spelled out (this is whyHeadingStyle[4]is applied directly inPostContent.tsx). Email clamps heading levels to h1–h3 (Math.min(3, …)inBlockRenderer). - Global CSS may already cover it. Before duplicating a style, check whether a
app/globals.cssrule (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.tsxandemails/post.tsx(andTextBlockCore.tsxfor inline marks;feedHtml.tsfor 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.tsxserializer case +src/utils/paste/htmlToBlocks.tsparse case + test coverage. - Verify:
npx tscfor types,npm testif paste/copy code changed, and if you can, eyeball parity with thetests-postsharness or by running the app (/run). Email can be eyeballed viasendPostPreview. - 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.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

