MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-90

2.3.5 `MarkdownEditor` primitive — rich-text description over `descriptionMd`

Done
Description

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 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.

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-editor added to package.json; the wrapper exposes ONLY value, onChange, label, size: 'min' | 'full', onImageUpload?, readOnly?.
  • Storage round-trip: a value written through the editor and read back via MarkdownView renders identically (Vitest snapshot).
  • Render pipeline (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.
  • Image-upload callback contract: if 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.
  • Spec under 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.
  • SSR-safe: the editor library is loaded via next/dynamic with ssr: false if 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-markdown usage — there may already be a MarkdownView-like component to consolidate)
  • 1.0.5's ThemeProvider + data-color-mode contract — 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-editor README — confirm the SSR + dark-mode + image-paste hooks before committing to the wiring