2.3.5 `MarkdownEditor` primitive — rich-text description over `descriptionMd`
Estimate: 14m
Story 1.4 fixed the durable storage shape — Markdown source in
descriptionMd, rendered via react-markdown +
remark-gfm + rehype-sanitize + rehype-highlight. This Subtask ships the EDITOR
over that source. The shape decision is made and recorded
(decision-authority ladder rung 2 — storage is shipped reality):
- Source of truth: Markdown text. NOT a CRDT, NOT ProseMirror JSON, NOT HTML. Concurrent multi-user editing is out of v1 scope (last-write-wins per Linear's v1, surfaced with optimistic-concurrency rejection in 2.3.6's edit form).
- Editor library:
@uiw/react-md-editor— most-downloaded React Markdown editor (MIT), pairs natively with our existingreact-markdown+remark-gfmrender path, dark-theme aware, customizable toolbar. Why not Tiptap-with-Markdown-mode: Tiptap stores ProseMirror JSON and serializes-to-Markdown lossily, which breaks the "source = Markdown" invariant. Why not raw textarea + preview only: we ship a real PM tool; users expect a toolbar (bold, italic, headings, lists, code, link, image), keyboard shortcuts (⌘B, ⌘I, ⌘K-link), and slash-style insertions.
Component shape. components/ui/MarkdownEditor.tsx wraps
@uiw/react-md-editor with two size variants — min (textarea +
preview tab, ~6 lines, for the create modal) and full (live split-pane,
toolbar + edit tab + preview tab, ~16+ lines, for the edit form). Both expose a controlled
value: string / onChange(string) over the raw Markdown.
Image upload is wired as a callback prop onImageUpload?: (file: File) => Promise<string> (returns the URL to splice into the markdown) — this Subtask
ships the prop wired to a placeholder that throws "image upload not yet enabled"; 2.3.7
plugs in the real Vercel Blob handler. Paste/drop handlers exist; if no
onImageUpload handler is provided OR the handler throws, the editor surfaces
a polite inline notice and ignores the image (NEVER silently drops without telling the
user — finding #46-style "no silent failures" rule).
Theming + a11y. The editor picks up the 1.0.5 theme tokens via CSS-vars
override (the library accepts data-color-mode from the existing ThemeProvider
— the wrapper sets it). All buttons get visible focus rings; the preview tab is reachable
via Tab + Enter; the editor's contenteditable surface has aria-label from a
required label prop on the wrapper.
Render side. A sibling components/ui/MarkdownView.tsx ships
the read-only render path used by the edit form's preview, the future detail page, and any
list-view description preview. Uses the same render chain Story 1.4 already configured.
The two components share the renderer config — there is exactly ONE
remark/rehype pipeline in the codebase after this Subtask.
Acceptance criteria
- Both components exported from
components/ui/; both consumed by 2.3.3 (modal,min) and 2.3.6 (edit form,full). @uiw/react-md-editoradded topackage.json; the wrapper exposes ONLYvalue,onChange,label,size: 'min' | 'full',onImageUpload?,readOnly?.- Storage round-trip: a value written through the editor and read back via
MarkdownViewrenders identically (Vitest snapshot). - Render pipeline (
react-markdown+ GFM + sanitize + highlight) is exported from ONE module (lib/markdown/renderer.tsx); both editor preview andMarkdownViewimport it. A grep guard in the test suite asserts no other file importsreact-markdowndirectly. - Image-upload callback contract: if
onImageUploadis absent, paste/drop shows the inline notice "Image uploads aren't enabled here" and the image is NOT inserted. If the handler throws, the same notice surfaces and the editor reverts. NEVER silently drops. - Spec under
tests/components/MarkdownEditor.test.tsxcovers: controlledvalue/onChange; size variants render the expected toolbar set; paste-image-without-handler shows the notice;readOnlyhides the toolbar + tabs. /tokensroute (or a new/tokens/markdown-editorsub-route) renders both components in a specimen state for visual review — added to the STRICT axe sweep, zero violations.- SSR-safe: the editor library is loaded via
next/dynamicwithssr: falseif it touches the DOM at module load; the wrapper handles the loading state gracefully.
Context refs
- Existing Story-1.4 description render path (look for current
react-markdownusage — there may already be aMarkdownView-like component to consolidate) - 1.0.5's
ThemeProvider+data-color-modecontract — how the editor picks up light/dark - 1.5.5's STRICT axe sweep + Pill-tone fixes — the editor's buttons must use AA-safe tones
motir-core/CLAUDE.md— DTOs stay shipped reality (no leaked@uiw/*types in the wrapper's public surface)@uiw/react-md-editorREADME — confirm the SSR + dark-mode + image-paste hooks before committing to the wiring