diff --git a/client/src/blocks/BlockRenderer.jsx b/client/src/blocks/BlockRenderer.jsx
new file mode 100644
index 0000000..e78e94f
--- /dev/null
+++ b/client/src/blocks/BlockRenderer.jsx
@@ -0,0 +1,26 @@
+// Renders stored blocks via their registry component. Used by the public page
+// route, the draft preview, and (recursively) the two_column block. Kept
+// separate from the registry so both the renderer and the builder can import it.
+// Import the lookup from the registry directly (not ./index) to avoid a cycle:
+// index → types/twoColumn → BlockRenderer. The page route/builder import ./index,
+// which registers every block before anything renders.
+import { getBlock } from './registry.js'
+
+/**
+ * Render one block. A block with `visible === false` renders nothing (admins
+ * hide blocks without deleting them). An unknown type also renders nothing —
+ * server validation prevents storing one, so this only guards a client/server
+ * registry skew rather than crashing the whole page.
+ */
+export default function BlockRenderer({ block }) {
+ if (!block || block.visible === false) return null
+ const def = getBlock(block.type)
+ if (!def || !def.component) return null
+ const Component = def.component
+ return
+}
+
+/** Render an ordered array of blocks (array position = display order). */
+export function BlockList({ blocks }) {
+ return (blocks || []).map((block) => )
+}
diff --git a/client/src/blocks/editorKit.jsx b/client/src/blocks/editorKit.jsx
new file mode 100644
index 0000000..481fb21
--- /dev/null
+++ b/client/src/blocks/editorKit.jsx
@@ -0,0 +1,64 @@
+// Shared form controls for block editors, styled with the existing admin design
+// system (.field-label / .input / .select). Every block's editor is a
+// ({ props, onChange }) component; these keep the seven of them consistent and
+// short. onChange always receives the full next props object.
+
+export function Field({ label, hint, children }) {
+ return (
+
+ )
+}
+
+export function TextField({ label, hint, value, onChange, placeholder, maxLength }) {
+ return (
+
+ onChange(e.target.value)}
+ />
+
+ )
+}
+
+export function TextAreaField({ label, hint, value, onChange, placeholder, rows = 4, maxLength }) {
+ return (
+
+
+ )
+}
+
+// options: array of [value, label] tuples.
+export function SelectField({ label, hint, value, onChange, options }) {
+ return (
+
+
+
+ )
+}
diff --git a/client/src/blocks/index.js b/client/src/blocks/index.js
index 6d7f95d..67f504f 100644
--- a/client/src/blocks/index.js
+++ b/client/src/blocks/index.js
@@ -9,11 +9,11 @@
export * from './registry'
-// ── Wave 1 block definitions ──────────────────────────────────────────
-// import './types/heading' // added in step 3
-// import './types/richText'
-// import './types/image'
-// import './types/twoColumn'
-// import './types/cta'
-// import './types/divider'
-// import './types/quote'
+// ── Wave 1 block definitions (self-register on import) ─────────────────
+import './types/heading.jsx'
+import './types/richText.jsx'
+import './types/image.jsx'
+import './types/twoColumn.jsx'
+import './types/cta.jsx'
+import './types/divider.jsx'
+import './types/quote.jsx'
diff --git a/client/src/blocks/types/cta.jsx b/client/src/blocks/types/cta.jsx
new file mode 100644
index 0000000..9511ff0
--- /dev/null
+++ b/client/src/blocks/types/cta.jsx
@@ -0,0 +1,63 @@
+// cta block — a call-to-action button/link. Renders as an anchor styled with the
+// existing button system (primary / secondary).
+import { registerBlock } from '../registry'
+import { SelectField, TextField } from '../editorKit.jsx'
+
+const STYLES = [
+ ['primary', 'Primary'],
+ ['secondary', 'Secondary'],
+]
+
+function CtaBlock({ props }) {
+ if (!props.url || !props.text) return null
+ const style = props.style === 'secondary' ? 'secondary' : 'primary'
+ // External links get a safe rel; same-origin relative links don't need it.
+ const external = /^https?:\/\//i.test(props.url)
+ return (
+
+ )
+}
+
+function CtaEditor({ props, onChange }) {
+ return (
+
+ onChange({ ...props, text })}
+ />
+ onChange({ ...props, url })}
+ />
+ onChange({ ...props, style })}
+ options={STYLES}
+ />
+
+ )
+}
+
+registerBlock({
+ type: 'cta',
+ version: 1,
+ label: 'Button',
+ icon: '⇥',
+ component: CtaBlock,
+ editor: CtaEditor,
+ defaults: () => ({ text: '', url: '', style: 'primary' }),
+})
diff --git a/client/src/blocks/types/divider.jsx b/client/src/blocks/types/divider.jsx
new file mode 100644
index 0000000..fd1f9d6
--- /dev/null
+++ b/client/src/blocks/types/divider.jsx
@@ -0,0 +1,25 @@
+// divider block — a pure spacer / horizontal rule. No props, so its editor is
+// just a note.
+import { registerBlock } from '../registry'
+
+function DividerBlock() {
+ return
+}
+
+function DividerEditor() {
+ return (
+
+ A divider has no options — it adds a horizontal rule and spacing.
+
+ )
+}
+
+registerBlock({
+ type: 'divider',
+ version: 1,
+ label: 'Divider',
+ icon: '—',
+ component: DividerBlock,
+ editor: DividerEditor,
+ defaults: () => ({}),
+})
diff --git a/client/src/blocks/types/heading.jsx b/client/src/blocks/types/heading.jsx
new file mode 100644
index 0000000..81be8b4
--- /dev/null
+++ b/client/src/blocks/types/heading.jsx
@@ -0,0 +1,46 @@
+// heading block — plain-text section heading (h1–h4). Text is rendered as text
+// (React escapes it); use rich_text for inline markup.
+import { registerBlock } from '../registry'
+import { SelectField, TextField } from '../editorKit.jsx'
+
+const LEVELS = [
+ ['h1', 'Heading 1'],
+ ['h2', 'Heading 2'],
+ ['h3', 'Heading 3'],
+ ['h4', 'Heading 4'],
+]
+const VALID = ['h1', 'h2', 'h3', 'h4']
+
+function HeadingBlock({ props }) {
+ const Tag = VALID.includes(props.level) ? props.level : 'h2'
+ return {props.text}
+}
+
+function HeadingEditor({ props, onChange }) {
+ return (
+
+ onChange({ ...props, level })}
+ options={LEVELS}
+ />
+ onChange({ ...props, text })}
+ />
+
+ )
+}
+
+registerBlock({
+ type: 'heading',
+ version: 1,
+ label: 'Heading',
+ icon: 'H',
+ component: HeadingBlock,
+ editor: HeadingEditor,
+ defaults: () => ({ level: 'h2', text: '' }),
+})
diff --git a/client/src/blocks/types/image.jsx b/client/src/blocks/types/image.jsx
new file mode 100644
index 0000000..217ee14
--- /dev/null
+++ b/client/src/blocks/types/image.jsx
@@ -0,0 +1,100 @@
+// image block — a single image with optional caption and alignment. Upload
+// reuses the shared admin uploader (returns { url }); the block stays URL-based
+// until the Wave 3 asset picker lands.
+import { useState } from 'react'
+import { registerBlock } from '../registry'
+import { api } from '../../api/client.js'
+import { SelectField, TextField } from '../editorKit.jsx'
+
+const ALIGN = [
+ ['left', 'Left'],
+ ['center', 'Center'],
+ ['right', 'Right'],
+ ['full', 'Full width'],
+]
+const VALID = ['left', 'center', 'right', 'full']
+
+function ImageBlock({ props }) {
+ if (!props.src) return null
+ const align = VALID.includes(props.alignment) ? props.alignment : 'center'
+ return (
+
+
+ {props.caption && {props.caption}}
+
+ )
+}
+
+function ImageEditor({ props, onChange }) {
+ const [uploading, setUploading] = useState(false)
+ const [error, setError] = useState('')
+
+ async function onUpload(e) {
+ const file = e.target.files?.[0]
+ e.target.value = ''
+ if (!file) return
+ setUploading(true)
+ setError('')
+ try {
+ const { url } = await api.admin.upload(file)
+ onChange({ ...props, src: url })
+ } catch (err) {
+ setError(err.message || 'Upload failed')
+ } finally {
+ setUploading(false)
+ }
+ }
+
+ return (
+
+
+
Image
+
+ {uploading &&
uploading…}
+ {error &&
{error}}
+ {props.src && (
+

+ )}
+
+
onChange({ ...props, alt })}
+ />
+ onChange({ ...props, caption })}
+ />
+ onChange({ ...props, alignment })}
+ options={ALIGN}
+ />
+
+ )
+}
+
+registerBlock({
+ type: 'image',
+ version: 1,
+ label: 'Image',
+ icon: '🖼',
+ component: ImageBlock,
+ editor: ImageEditor,
+ defaults: () => ({ src: '', alt: '', caption: '', alignment: 'center' }),
+})
diff --git a/client/src/blocks/types/quote.jsx b/client/src/blocks/types/quote.jsx
new file mode 100644
index 0000000..46cc657
--- /dev/null
+++ b/client/src/blocks/types/quote.jsx
@@ -0,0 +1,43 @@
+// quote block — a pull quote with optional attribution.
+import { registerBlock } from '../registry'
+import { TextAreaField, TextField } from '../editorKit.jsx'
+
+function QuoteBlock({ props }) {
+ if (!props.text) return null
+ return (
+
+ {props.text}
+ {props.attribution && — {props.attribution}}
+
+ )
+}
+
+function QuoteEditor({ props, onChange }) {
+ return (
+
+ onChange({ ...props, text })}
+ />
+ onChange({ ...props, attribution })}
+ />
+
+ )
+}
+
+registerBlock({
+ type: 'quote',
+ version: 1,
+ label: 'Quote',
+ icon: '❝',
+ component: QuoteBlock,
+ editor: QuoteEditor,
+ defaults: () => ({ text: '', attribution: '' }),
+})
diff --git a/client/src/blocks/types/richText.jsx b/client/src/blocks/types/richText.jsx
new file mode 100644
index 0000000..78b6e05
--- /dev/null
+++ b/client/src/blocks/types/richText.jsx
@@ -0,0 +1,39 @@
+// rich_text block — HTML from the shared rich-text editor. Rendered inside the
+// same `.prose` styling as wiki/news bodies, sanitized on render as defense in
+// depth (the server also sanitizes on save).
+import { lazy, Suspense } from 'react'
+import DOMPurify from 'dompurify'
+import { registerBlock } from '../registry'
+
+const RichTextEditor = lazy(() => import('../../components/RichTextEditor.jsx'))
+
+function RichTextBlock({ props }) {
+ return (
+
+ )
+}
+
+function RichTextEditorForm({ props, onChange }) {
+ return (
+ }>
+ onChange({ ...props, html })}
+ variant="post"
+ />
+
+ )
+}
+
+registerBlock({
+ type: 'rich_text',
+ version: 1,
+ label: 'Rich text',
+ icon: '¶',
+ component: RichTextBlock,
+ editor: RichTextEditorForm,
+ defaults: () => ({ html: '' }),
+})
diff --git a/client/src/blocks/types/twoColumn.jsx b/client/src/blocks/types/twoColumn.jsx
new file mode 100644
index 0000000..cf5a998
--- /dev/null
+++ b/client/src/blocks/types/twoColumn.jsx
@@ -0,0 +1,120 @@
+// two_column block — the only container. Holds two ordered arrays of sub-blocks
+// (`left`, `right`). Sub-blocks are leaf blocks only (no nested containers — the
+// one-level cap the server also enforces), so the column editor's palette is the
+// set of non-container registered blocks.
+import { registerBlock, getBlock, listBlocks, makeBlockId } from '../registry'
+import BlockRenderer from '../BlockRenderer.jsx'
+
+// ── Renderer ──────────────────────────────────────────────────────────
+function TwoColumnBlock({ props }) {
+ const left = Array.isArray(props.left) ? props.left : []
+ const right = Array.isArray(props.right) ? props.right : []
+ return (
+
+
+ {left.map((b) => (
+
+ ))}
+
+
+ {right.map((b) => (
+
+ ))}
+
+
+ )
+}
+
+// ── Column editor ─────────────────────────────────────────────────────
+// Manages one side's array: add (from the leaf palette), edit each via its own
+// registry editor, reorder, remove.
+function ColumnEditor({ title, items, onChange }) {
+ const list = Array.isArray(items) ? items : []
+ const palette = listBlocks().filter((b) => !b.container)
+
+ function addBlock(type) {
+ const def = getBlock(type)
+ if (!def) return
+ const block = { id: makeBlockId(), type, version: def.version, visible: true, props: def.defaults() }
+ onChange([...list, block])
+ }
+ function updateAt(i, nextProps) {
+ onChange(list.map((b, j) => (j === i ? { ...b, props: nextProps } : b)))
+ }
+ function removeAt(i) {
+ onChange(list.filter((_, j) => j !== i))
+ }
+ function move(i, dir) {
+ const j = i + dir
+ if (j < 0 || j >= list.length) return
+ const next = [...list]
+ ;[next[i], next[j]] = [next[j], next[i]]
+ onChange(next)
+ }
+
+ return (
+
+
+ {title}
+
+
+
+ {list.length === 0 && (
+
Empty column.
+ )}
+
+ {list.map((block, i) => {
+ const def = getBlock(block.type)
+ const Editor = def?.editor
+ return (
+
+
+
{def?.label || block.type}
+
+
+
+
+
+
+ {Editor &&
updateAt(i, p)} />}
+
+ )
+ })}
+
+ )
+}
+
+function TwoColumnEditor({ props, onChange }) {
+ return (
+
+ onChange({ ...props, left })} />
+ onChange({ ...props, right })} />
+
+ )
+}
+
+registerBlock({
+ type: 'two_column',
+ version: 1,
+ label: 'Two columns',
+ icon: '▥',
+ component: TwoColumnBlock,
+ editor: TwoColumnEditor,
+ defaults: () => ({ left: [], right: [] }),
+ container: true,
+ containerSlots: ['left', 'right'],
+})
diff --git a/client/src/styles/theme.css b/client/src/styles/theme.css
index 23d9331..55dac7a 100644
--- a/client/src/styles/theme.css
+++ b/client/src/styles/theme.css
@@ -740,3 +740,147 @@ button[disabled] {
grid-template-columns: 1fr;
}
}
+
+/* ===== CMS page builder — public block rendering ===== */
+/* Vertical rhythm between top-level blocks on a rendered page. */
+.page-blocks > * + * {
+ margin-top: 26px;
+}
+.page-heading {
+ font-family: var(--serif, Georgia, serif);
+ color: var(--ink);
+ line-height: 1.2;
+}
+.page-divider {
+ border: none;
+ border-top: 1px solid var(--line);
+ margin: 8px 0;
+}
+/* image block */
+.page-image {
+ margin: 0;
+}
+.page-image img {
+ max-width: 100%;
+ border-radius: 8px;
+ border: 1px solid var(--line);
+ display: block;
+}
+.page-image figcaption {
+ margin-top: 8px;
+ color: var(--muted);
+ font-family: var(--sans);
+ font-size: 0.82rem;
+}
+.page-image--center {
+ text-align: center;
+}
+.page-image--center img,
+.page-image--center figcaption {
+ margin-left: auto;
+ margin-right: auto;
+}
+.page-image--right {
+ text-align: right;
+}
+.page-image--right img,
+.page-image--right figcaption {
+ margin-left: auto;
+}
+.page-image--full img {
+ width: 100%;
+}
+/* cta block */
+.page-cta-wrap {
+ display: flex;
+}
+.page-cta--secondary {
+ background: transparent;
+ color: var(--ink);
+}
+/* quote block */
+.page-quote {
+ margin: 0;
+ border-left: 3px solid var(--accent);
+ padding: 4px 0 4px 20px;
+}
+.page-quote blockquote {
+ margin: 0;
+ font-size: 1.15rem;
+ line-height: 1.5;
+ color: var(--ink);
+ font-style: italic;
+}
+.page-quote figcaption {
+ margin-top: 8px;
+ color: var(--muted);
+ font-family: var(--sans);
+ font-size: 0.85rem;
+}
+/* two-column block */
+.page-two-column {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 32px;
+}
+.page-column > * + * {
+ margin-top: 18px;
+}
+@media (max-width: 720px) {
+ .page-two-column {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* ===== CMS page builder — column sub-block editor ===== */
+.pb-two-column-editor {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+@media (max-width: 720px) {
+ .pb-two-column-editor {
+ grid-template-columns: 1fr;
+ }
+}
+.pb-column-editor {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 12px;
+ background: var(--panel-flat, transparent);
+}
+.pb-column-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+.pb-add-select {
+ width: auto;
+ padding: 6px 10px;
+ font-size: 0.82rem;
+}
+.pb-subblock {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 10px;
+ margin-top: 10px;
+ background: var(--bg);
+}
+.pb-subblock-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+.pb-subblock-actions {
+ display: flex;
+ gap: 4px;
+}
+.pb-mini {
+ min-width: 28px;
+ padding: 3px 8px;
+ font-size: 0.8rem;
+ line-height: 1;
+}