A Markdown rendering app built for the Frontend Developer assignment. Drop in a .md file and it's
typeset on a paper page against a dark studio canvas, with an outline sidebar and a one-click copy
that carries HTML, plain text, and the original Markdown to the clipboard at once.
npm install
npm run dev # http://localhost:5173
npm run build # production build to dist/
npm run preview # serve the production build locallyNo environment variables, no backend, no API keys. Everything — parsing, rendering, and the copy pipeline — runs in the browser.
Try it with the files in samples/: technical-design-review.md exercises the full GFM feature set
(tables, nested lists, code blocks with a language, blockquotes, strikethrough, footnote-style
references), malformed-edge-cases.md exercises the failure paths (a broken table, an unclosed
code fence, a dangling link, mixed whitespace), and gfm-edge-cases.md sweeps trickier-but-valid GFM
(task lists, table alignment, nested/loose/tight lists, hard line breaks, long unbroken strings) — the
last one is what caught the three bugs described below.
Concept. The brief is explicitly about rendering AI-generated documents, so I leaned into a "manuscript desk" idea: a dark studio canvas with a single warm parchment page under a spotlight. The page is the one thing that's supposed to hold your attention — everything else (toolbar, outline, canvas) stays quiet and out of the way. Fonts: Fraunces for document headings (a display serif with some personality), Source Serif 4 for document body text, IBM Plex Sans for the app chrome, and JetBrains Mono for code.
Rendering. react-markdown + remark-gfm for the required GFM feature set (tables, strikethrough,
autolinks, task-list-adjacent list handling), with rehype-highlight for fenced code block syntax
highlighting. Raw HTML in the source (stray <div>s, HTML comments some AI models leave in) is
stripped via skipHtml rather than shown as literal text or executed — that's the graceful-degradation
choice for "malformed" content that isn't in the required feature list.
Copy button. This was the trickiest requirement. One click writes three representations to the
clipboard in a single ClipboardItem:
text/html— built with a separateunified/remark/rehypepipeline (not by reading the live DOM) that inlines CSS directly onto each element (style="..."on every heading, table cell, code block, etc.), because Word, Google Docs, and most rich-text editors ignore external/Tailwind classes on paste and only respect inline styles or a handful of semantic tags.text/plain— derived by mounting that HTML in an offscreen div and readinginnerText, which handles block-level line breaks and table cell spacing better than a hand-rolled regex strip.text/markdown— the original source, attempted where the browser'sClipboardItemaccepts a non-standard MIME type. Chrome currently rejects rawtext/markdownin the constructor in my testing, so this silently drops out and the button's tooltip/status line reports exactly which formats actually made it onto the clipboard (e.g. "as HTML · Plain text") rather than claiming success it didn't deliver.
The button never throws on an unsupported browser: it retries with fewer formats, and falls back to
writeText as a last resort, surfacing a "Couldn't copy" state only if every path fails.
Resilience. A RenderErrorBoundary wraps the document body so a pathological parse tree can't
blank the whole app — it degrades to a message instead. Malformed constructs (an unclosed code fence,
a broken table, a dangling [link() don't throw; CommonMark's own error-tolerant grammar handles most
of it by treating the input literally, which I verified against a dedicated edge-case file rather than
assuming.
A real bug this caught. While sweeping a wider set of GFM edge cases (task lists, deeply nested
lists, long unbroken strings, table alignment) I found that remark-gfm injects its own className
onto certain nodes — task-list-item on checkbox list items, contains-task-list on their parent
<ul> — and my custom renderers were spreading {...props} after setting className, so that
incoming className silently replaced mine instead of merging with it. The visible symptom: checked/
unchecked task items rendered with no styling at all, falling back to inherited white text on the
parchment background — nearly invisible. Every custom component now explicitly merges classNames. I
also found and fixed a related layout bug (a nested list inside a task item rendering on the same line
as its parent) and a text-overflow bug (long unbroken strings breaking out of the page). All three are
the kind of thing that looks fine on a "normal" test document and only shows up once you write markdown
specifically trying to break each feature — which is what samples/gfm-edge-cases.md is for.
Accessibility, checked rather than assumed. Beyond the usual (focus rings, landmarks, reduced motion), I ran automated checks rather than eyeballing it: WCAG contrast ratios for every text/ background pairing (one, the "faint" hint-text color, failed at 2.94:1 and was retuned to 4.75:1), full keyboard tab order through both the empty and loaded states, heading-level sequencing (no skipped levels, which matters for screen-reader users navigating by heading), and that image alt text survives the render pipeline.
Accessibility & responsiveness. Visible focus rings everywhere, prefers-reduced-motion respected,
semantic landmarks (header, main, aside with aria-label), the outline collapses into a toggled
drawer under lg, and the toolbar/paper padding scale down for mobile rather than just shrinking text.
Built with Claude (Sonnet) as a pair programmer: scaffolding the Vite/Tailwind/react-markdown setup,
writing the inline-style clipboard pipeline, and iterating on the visual design. I used it to generate
a real test document (samples/technical-design-review.md) and a deliberately broken one
(samples/malformed-edge-cases.md) to verify rendering and the copy pipeline end-to-end — including
actually driving a headless browser to click the copy button and read back the clipboard contents,
which caught that Chrome silently rejects the text/markdown MIME type in ClipboardItem, and that
raw HTML comments in source markdown were rendering as visible garbage text before skipHtml was
added.
- Bundle size.
highlight.js's full language grammar set pulls the JS bundle over Vite's 500KB warning threshold. I'd switch tolowlight'scommonsubset or lazy-load languages on demand. - Chrome's custom clipboard formats. Chrome 104+ supports arbitrary MIME types on the clipboard
under a
"web <type>"prefix (a different mechanism than the plaintext/markdownI attempt first). I'd add that as a second attempt so Chrome-to-Chrome copy/paste between two instances of this app could round-trip the original Markdown exactly. - GitHub-style alert callouts (
> [!NOTE],> [!WARNING]) aren't in the required feature list, so they currently render as plain blockquotes with the literal[!NOTE]text — a smallremarkplugin would turn these into proper styled callouts. - Large file handling. Documents in the tens of thousands of words would benefit from virtualized rendering; right now the whole document mounts at once.
- Automated tests. Given more runway I'd add a small Vitest + Testing Library suite around the clipboard pipeline and the outline extraction, since those are the two places with the most "correct-looking but subtly wrong" failure modes.