KQ-1753 · BULLETIN/COMPOSER
Bulletin Composer
An email layout editor: a palette of header, text, image, button and divider blocks feeds a one-column letter drawn in a device frame, and each block opens an edit panel for its content. Toggling Desktop and Phone changes the frame's width in one render and every block FLIPs to its new box on springs.glide — corners round into a phone bezel, pictures go full bleed, the call to action stretches and text slides to its new wrap without being squashed — while a ruler over the letter marks where the phone width will cut. Blocks and palette tiles drag 1:1: an accent drop line opens a gap between the blocks under it on glide and, on release, every block glides from where it is drawn to its new slot with the throw's velocity; the letter is a listbox where Up and Down move, Alt with Up or Down moves the block, Enter edits it and Delete removes it, and missing alt text is flagged on the picture, in its field and in a count that jumps to it.
October letter
Fernworks · 7 blocks
Up and Down move between blocks, Alt with Up or Down moves the block, Enter edits it, Delete removes it, Escape clears the selection.
desktop · 7 blocks · 1 to fix
Install
One command — the source lands in your repo. Or copy it from the Code tab.
$ pnpm dlx shadcn@latest add @kinetiq/bulletin-composer
Props
- previewWidth
- number
- default 380
- Width the phone preview narrows the letter to, in px; the ruler over the desktop letter marks it.
- blockStyle
- "seamless" | "framed" | "stacked"
- default "seamless"
- How blocks sit on the canvas: edge to edge like the sent letter, each in a labelled frame, or as separate cards with grips.
- accent
- string
- default "var(--accent-bright)"
- The letter's brand colour (header wash, button, sender mark) and the editor's selection, drop line and ruler. Fills are taken at a fixed lightness so they read in both themes.
- blocks / defaultBlocks
- BulletinBlock[]
- default — / defaultBulletinBlocks
- The letter top to bottom: header { kicker, title, align }, text { body }, image { art, alt, caption }, button { label, href, fullWidthOnPhone }, divider { style }.
- onChange
- (blocks) => void
- default —
- Fires with every block after an edit, insert, move or removal.
- preview / defaultPreview
- "desktop" | "phone"
- default — / "desktop"
- Which device the letter is previewed on.
- onPreviewChange
- (preview) => void
- default —
- Fires from the toggle or arrow key that changed the preview.
- selected / defaultSelected
- string | null
- default — / null
- The selected block's id, whose edit panel the rail shows.
- onSelectedChange
- (id) => void
- default —
- Fires when a click, key, drag or insert selects a block, or the selection clears.
- title
- string
- default "October letter"
- The editor's heading and the letter list's accessible name.
- subject
- string
- default "The fern house reopens"
- The subject line shown in the frame's inbox bar.
- preheader
- string
- default "Misting at nine, new panes, Thursday spore prints."
- The line an inbox shows after the subject.
- sender
- string
- default "Fernworks"
- Who the letter is from; also its avatar initial and the footer.
- onSendTest
- (address, blocks) => void | Promise
- default —
- Send test was pressed with a valid address. Return a promise to show it sending; a rejection says it failed and keeps the dialog open.
- testAddress
- string
- default "ines@fernworks.example"
- The address the test dialog starts with.
- onSchedule
- (at, blocks) => void | Promise
- default —
- A send slot was confirmed, in ms since the epoch. Return a promise to show it saving.
- now
- number | Date
- default defaultBulletinNow
- Today, for the three schedule slots and the phone's clock — never the wall clock, so server and browser agree.
- status
- "ready" | "loading" | "error"
- default "ready"
- Whether the letter has arrived: loading draws skeleton blocks, error offers Try again.
- onRetry
- () => void
- default —
- Try again was pressed after the letter failed to load.
- height
- number
- default 540
- The editor's height in px; the letter scrolls inside it.
- disabled
- boolean
- default false
- Read only: blocks can be browsed and previewed but not edited, moved, sent or scheduled.
- className
- string
- default —
- Classes for the editor's outer box.
| Prop | Type | Default | Description |
|---|---|---|---|
| previewWidth | number | 380 | Width the phone preview narrows the letter to, in px; the ruler over the desktop letter marks it. |
| blockStyle | "seamless" | "framed" | "stacked" | "seamless" | How blocks sit on the canvas: edge to edge like the sent letter, each in a labelled frame, or as separate cards with grips. |
| accent | string | "var(--accent-bright)" | The letter's brand colour (header wash, button, sender mark) and the editor's selection, drop line and ruler. Fills are taken at a fixed lightness so they read in both themes. |
| blocks / defaultBlocks | BulletinBlock[] | — / defaultBulletinBlocks | The letter top to bottom: header { kicker, title, align }, text { body }, image { art, alt, caption }, button { label, href, fullWidthOnPhone }, divider { style }. |
| onChange | (blocks) => void | — | Fires with every block after an edit, insert, move or removal. |
| preview / defaultPreview | "desktop" | "phone" | — / "desktop" | Which device the letter is previewed on. |
| onPreviewChange | (preview) => void | — | Fires from the toggle or arrow key that changed the preview. |
| selected / defaultSelected | string | null | — / null | The selected block's id, whose edit panel the rail shows. |
| onSelectedChange | (id) => void | — | Fires when a click, key, drag or insert selects a block, or the selection clears. |
| title | string | "October letter" | The editor's heading and the letter list's accessible name. |
| subject | string | "The fern house reopens" | The subject line shown in the frame's inbox bar. |
| preheader | string | "Misting at nine, new panes, Thursday spore prints." | The line an inbox shows after the subject. |
| sender | string | "Fernworks" | Who the letter is from; also its avatar initial and the footer. |
| onSendTest | (address, blocks) => void | Promise | — | Send test was pressed with a valid address. Return a promise to show it sending; a rejection says it failed and keeps the dialog open. |
| testAddress | string | "ines@fernworks.example" | The address the test dialog starts with. |
| onSchedule | (at, blocks) => void | Promise | — | A send slot was confirmed, in ms since the epoch. Return a promise to show it saving. |
| now | number | Date | defaultBulletinNow | Today, for the three schedule slots and the phone's clock — never the wall clock, so server and browser agree. |
| status | "ready" | "loading" | "error" | "ready" | Whether the letter has arrived: loading draws skeleton blocks, error offers Try again. |
| onRetry | () => void | — | Try again was pressed after the letter failed to load. |
| height | number | 540 | The editor's height in px; the letter scrolls inside it. |
| disabled | boolean | false | Read only: blocks can be browsed and previewed but not edited, moved, sent or scheduled. |
| className | string | — | Classes for the editor's outer box. |