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):
@uiw/react-md-editor — most-downloaded
React Markdown editor (MIT), pairs natively with our existing
react-markdown+remark-gfm render 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.
components/ui/; both consumed by 2.3.3 (modal, min)
and 2.3.6 (edit form, full).@uiw/react-md-editor added to package.json; the wrapper exposes ONLY value, onChange, label, size: 'min' | 'full', onImageUpload?, readOnly?.MarkdownView renders identically (Vitest snapshot).react-markdown + GFM + sanitize + highlight) is exported from ONE module (lib/markdown/renderer.tsx); both editor preview and MarkdownView import it. A grep guard in the test suite asserts no other file imports react-markdown directly.onImageUpload is 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.tests/components/MarkdownEditor.test.tsx covers: controlled value/onChange; size variants render the expected toolbar set; paste-image-without-handler shows the notice; readOnly hides the toolbar + tabs./tokens route (or a new /tokens/markdown-editor sub-route) renders both components in a specimen state for visual review — added to the STRICT axe sweep, zero violations.next/dynamic with ssr: false if it touches the DOM at module load; the wrapper handles the loading state gracefully.react-markdown usage — there may already be a MarkdownView-like component to consolidate)ThemeProvider + data-color-mode contract — how the editor picks up light/darkmotir-core/CLAUDE.md — DTOs stay shipped reality (no leaked @uiw/* types in the wrapper's public surface)@uiw/react-md-editor README — confirm the SSR + dark-mode + image-paste hooks before committing to the wiring