Resolved: light

Rich text editor

A lean, single-user WYSIWYG built on Tiptap — the STD answer to editing a description, a doc body, or an announcement without the weight of the design system's Yjs-backed CollabEditor (real-time, presence, multi-cursor). It's a controlled input: the host owns the value string and the editor emits the next string on every edit. It speaks markdown by default (parsed in, serialized back out via the tiptap-markdown extension) or HTML, carries a compact formatting toolbar (bold, italic, strike, H2/H3, bullet + ordered lists, link add/remove, undo/redo), and flips to a clean read-only render with editable={false}. Themed entirely from the semantic tokens and accessible — the surface is a labelled role="textbox", the toolbar a role="toolbar", and each control reports its toggle state via aria-pressed.

import
import { RichTextEditor } from "@/components/ds/editor" — the CLIENT barrel. It is deliberately NOT in the server-safe components/ds/index.ts, because Tiptap is client-only.
value / onChange
Controlled. value is the content string (markdown or HTML per format); onChange(next) fires on every edit with the next string in the same format. Changing value from outside re-seeds the editor without moving the caret on your own echoes.
format?: "html" | "markdown"
Default "markdown": seeds from a markdown string and emits editor.storage.markdown.getMarkdown(). "html" seeds from / emits editor.getHTML(). The wire format is symmetric — value in and onChange out are always the same shape.
editable?: boolean
Default true. When false the toolbar is hidden and the surface is read-only (aria-readonly) — a clean prose render of the same content.
placeholder? / minHeight? / ariaLabel?
placeholder shows muted hint text while empty (Tiptap Placeholder extension); minHeight (px, default 160) sets the minimum surface height; ariaLabel names the editable region for assistive tech (default "Rich text editor").
extensions
StarterKit (paragraphs, headings, lists, bold/italic/strike, undo/redo — its bundled Link disabled) + the standalone @tiptap/extension-link + tiptap-markdown's Markdown + Placeholder. Single-user only; no collaboration layer.
client-only
The editor touches the DOM, so it's a 'use client' component initialized with immediatelyRender:false (SSR-safe under the App Router). This page is a server component; each interactive editor lives in the colocated ./EditorDemo island.

Controlled markdown (with live output)

The default format. The editor is seeded from a markdown string and, on every keystroke or toolbar action, emits the re-serialized markdown via editor.storage.markdown.getMarkdown(). The panel below mirrors the live onChange value — type, bold a word, toggle a heading, and watch the markdown update in lockstep. This is the round-trip that makes the editor safe to persist: what you store is plain markdown, not editor-specific JSON.

editor.storage.markdown.getMarkdown() — live output

## Adoption day checklist

A **rescue runs on logistics**. Before any transport leaves, confirm:

- Health certificate signed and *in the folder*
- Microchip scanned and ~~unregistered~~ registered to the new owner
- Foster contact reachable

See the [transport SOP](https://savethedogs.io/sop) for the full sequence.
format="markdown" — controlled value; live getMarkdown() output below
markdown round-trip
value (markdown) → parsed into the document on mount → edited → editor.storage.markdown.getMarkdown() serializes back to markdown → onChange. Store the markdown, not the editor state.
toolbar ↔ markdown
Every toolbar control maps to a markdown construct: bold → **…**, H2 → ##, bullet list → -, link → [text](url). The output panel makes the mapping visible.

HTML format

Set format="html" and the same control surface reads and writes HTML instead: it seeds from an HTML string and emits editor.getHTML() on every edit. Reach for this when the consumer is an HTML sink — a richtext column, an email body, a dangerouslySetInnerHTML render — rather than a markdown store. The toolbar and prose styling are identical; only the wire format changes.

editor.getHTML() — live output

<h2>Volunteer welcome</h2><p>You're <strong>officially</strong> part of the pack. Here's what happens next:</p><ol><li>Watch for your <em>orientation email</em>.</li><li>Join your regional WhatsApp group.</li><li>Pick a first shift.</li></ol><p>Questions? <a href="mailto:hello@savethedogs.io">Email the team</a>.</p>
format="html" — controlled value; live getHTML() output below
getHTML()
When format="html", onChange emits editor.getHTML() — sanitized prosemirror HTML (the editor never round-trips raw arbitrary markup; links carry rel="noopener noreferrer nofollow").
when to choose HTML
Use HTML when the storage/render target is HTML; prefer markdown (the default) for human-readable, diff-friendly, portable content.

Read-only render

With editable={false} the editor becomes a faithful, non-interactive render of the same content: the toolbar is gone, there's no caret, and the surface reports aria-readonly. It uses the exact same prose typography as the editable state, so a doc looks identical whether you're reading or writing it — no separate viewer component to drift.

editable={false} — no toolbar, no caret, prose render
editable={false}
Hides the toolbar and disables editing (the underlying editor is set non-editable). The content renders with the same .prose styles as the editable surface.
one component, two modes
Read and write share the same render path, so there's no viewer/editor visual drift. Flip editable to toggle between them in place.

Empty & placeholder

A fresh, empty editor isn't a blank box: pass placeholder and the empty document shows muted hint text (via Tiptap's Placeholder extension) until the first character lands. The minHeight prop keeps the surface a comfortable target size even when empty, so the layout doesn't jump as content grows.

placeholder="Write the team announcement…" · minHeight={140}
placeholder
Muted hint text shown on the empty first paragraph; disappears on the first keystroke. Purely presentational — not part of the value.
minHeight
Minimum height (px, default 160) of the editable surface, so an empty or short document still presents a comfortable click target without layout shift.