Skip to content

feat(frontend): inline-preview markdown editor for encounter notes#161

Merged
enko merged 12 commits into
mainfrom
feat/markdown-encounter-notes
May 25, 2026
Merged

feat(frontend): inline-preview markdown editor for encounter notes#161
enko merged 12 commits into
mainfrom
feat/markdown-encounter-notes

Conversation

@enko

@enko enko commented May 25, 2026

Copy link
Copy Markdown
Member

What

Replaces the plain <textarea> for encounter notes with a CodeMirror 6 editor that renders markdown inline, Obsidian-style — typing ## makes the line a heading in place, while the stored value stays plain markdown. Adds a sanitized read-only viewer for the encounter detail page.

Raw markdown is the source of truth (stored byte-for-byte); rendering is view-only and sanitized. No DB or backend changeencounters.description is already free-text TEXT, and the ArkType validator passes it through untouched.

Editor engine: vendored, not a dependency

The inline-preview machinery comes from atomic-editor (MIT). Rather than depend on @atomic-editor/editor, the React-free CodeMirror 6 extensions are vendored into apps/frontend/src/lib/editor/inline-preview/ at a pinned commit (26f75e76), because the package is pre-1.0, single-maintainer, and declares react/react-dom as required peers (only its React component uses them; the engine does not).

Result: React is not installed at all — it cannot end up in the bundle. VENDORED.md records the source commit + local modifications for re-syncing; the dir is excluded from Biome to stay verbatim.

Changes

  • src/lib/editor/inline-preview/ — vendored CM6 engine (+ LICENSE, VENDORED.md, local barrel)
  • MarkdownEditor.svelte — Svelte 5 wrapper (onMount, $bindable, cursor-safe reconcile), themed to design tokens (forest accent, Merriweather body, Yanone headings), forest focus-ring, placeholder + disabled parity
  • MarkdownView.sveltemarkdown-it (html:false) + existing isomorphic-dompurify, link hardening (target=_blank rel=noopener noreferrer nofollow)
  • Wired into encounter-form.svelte (editor) and encounter-detail.svelte (viewer — the only display surface)
  • Deps via aube, exact-pinned; svelteTesting() enables Svelte 5 component tests

Verification

  • ✅ 27/27 frontend unit tests (9 new: XSS strip, inert javascript: links, link hardening, heading-decoration render, value reconcile)
  • svelte-check: 0 errors (new files clean)
  • ✅ Biome: clean on authored files
  • ✅ Production build succeeds; React absent, CM6 present

Not covered

  • No Playwright e2e / live browser pass. The interactive create→save→reload flow is auth-gated and the e2e harness has no authenticated fixture yet. The inline render is covered by a unit asserting the .cm-atomic-h2 decoration, but a manual pass on the running stack is still wanted.
  • Existing plain-text notes starting with #/- /> will now render as markdown (low blast radius; short free-text).

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR upgrades encounter notes in the frontend from plain text to a markdown-aware experience by introducing a CodeMirror 6 “inline preview” editor (Obsidian-style live rendering while storing raw markdown) and a sanitized, read-only markdown renderer for the encounter detail page.

Changes:

  • Replaced the encounter notes <textarea> with a new MarkdownEditor (CM6 + inline-preview extensions).
  • Added MarkdownView for sanitized markdown rendering in encounter detail.
  • Vendored the inline-preview engine (from atomic-editor) and updated tooling/config (Biome ignore + Vitest/Svelte 5 testing plugin + deps).

Reviewed changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
biome.json Excludes vendored inline-preview from Biome; adjusts test lint rule.
apps/frontend/vite.config.ts Adds svelteTesting() Vite plugin to support Svelte 5 component tests.
apps/frontend/src/lib/editor/MarkdownView.svelte New sanitized markdown renderer (markdown-it + DOMPurify + link hardening).
apps/frontend/src/lib/editor/MarkdownEditor.svelte New CM6-based markdown editor wrapper using vendored inline-preview extensions.
apps/frontend/src/lib/editor/markdown-view.test.ts Unit tests for markdown rendering + sanitization/link hardening.
apps/frontend/src/lib/editor/markdown-editor.test.ts Unit tests for editor decoration + external value reconciliation.
apps/frontend/src/lib/editor/inline-preview/VENDORED.md Documents upstream source commit and local modifications for vendored engine.
apps/frontend/src/lib/editor/inline-preview/tree-progress.ts Vendored parse-progress plugin supporting incremental decoration rebuilds.
apps/frontend/src/lib/editor/inline-preview/LICENSE MIT license for vendored atomic-editor code.
apps/frontend/src/lib/editor/inline-preview/inline-preview.ts Vendored core inline-preview decorations + interaction behaviors.
apps/frontend/src/lib/editor/inline-preview/inline-preview.css Vendored styles for inline-preview rendering.
apps/frontend/src/lib/editor/inline-preview/index.ts Local barrel exporting the React-free CM6 extensions.
apps/frontend/src/lib/editor/inline-preview/edit-helpers.ts Vendored input handlers (emphasis pairing, code-fence helper).
apps/frontend/src/lib/editor/inline-preview/atomic-theme.ts Vendored theme/syntax highlighting; locally set dark:false.
apps/frontend/src/lib/components/encounters/encounter-form.svelte Wires the new MarkdownEditor into the encounter form.
apps/frontend/src/lib/components/encounters/encounter-detail.svelte Wires the new MarkdownView into the encounter detail view.
apps/frontend/package.json Adds CodeMirror/Lezer + markdown-it deps and related typings.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +558 to +575
const imageLine = doc.lineAt(node.from);
const lineNum = imageLine.number;
if (!activeLines.has(lineNum)) {
// Hide the raw `![alt](url)` on inactive lines so only the
// rendered image block (emitted by the image-blocks state
// field below the line) shows. We deliberately keep the
// now-empty source `.cm-line` at its default line-height
// rather than collapsing it via `display: none`: on iOS
// Safari, toggling a line from its text-measured height
// to zero mid-scroll shifts every subsequent line up by
// that amount, which the scroll engine reads as an
// anchor conflict and halts kinetic momentum — visible
// as "scroll stops right before an image when you scroll
// back up." The tradeoff is one line of empty space
// above each rendered image, which actually reads a bit
// cleaner as visual separation anyway.
pushReplace(ranges, doc, node.from, node.to);
}
Comment on lines +5 to +6
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });

Comment on lines 219 to 221
<label for="description" class="block text-sm font-body font-medium text-gray-700 mb-1">
{$i18n.t('encounters.form.notesLabel')} <span class="text-gray-400">{$i18n.t('encounters.form.optional')}</span>
</label>
Comment on lines +60 to +64
EditorView.lineWrapping,
...(placeholder ? [cmPlaceholder(placeholder)] : []),
EditorView.editable.of(!disabled),
EditorView.contentAttributes.of({ 'aria-label': ariaLabel }),
EditorView.updateListener.of((u) => {
enko and others added 6 commits May 25, 2026 14:27
Adds the CodeMirror 6 packages (state, view, commands, language,
lang-markdown), lezer common/highlight, and markdown-it (+types) used by
the new encounter-notes markdown editor and viewer. Exact-pinned per repo
convention. No @atomic-editor/editor or react: the editor engine is
vendored (React-free) instead, so React is not installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vendors the React-free CodeMirror 6 extensions from atomic-editor
(github.com/kenforthewin/atomic-editor @ 26f75e76, MIT) that power
Obsidian-style inline markdown live-preview. We vendor rather than depend
on @atomic-editor/editor because it is pre-1.0, single-maintainer, and
declares react/react-dom as required peers — only its React component uses
them, the engine does not.

Includes inline-preview, tree-progress, atomic-theme (FB: dark->light),
edit-helpers, the CSS, LICENSE, and a local barrel (no React re-exports).
vendored.md records the source commit and local modifications. Image
markdown is left as visible source (FB: the image-block widget is not
vendored and remote images are blocked for privacy). The vendored dir is
excluded from Biome to stay verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
markdown-editor.svelte wraps the vendored CM6 engine in a Svelte 5
component: instantiates the EditorView in onMount (client-only), two-way
binds the raw markdown via $bindable, and reconciles external value
changes without clobbering the cursor. Editability is held in a
Compartment so the disabled prop toggles reactively (blurs on disable),
mirroring a disabled textarea. Labelled via aria-labelledby (or
aria-label). Themed to the design system through the engine's
--atomic-editor-* variables (forest accent, Merriweather body, Yanone
headings) with a focus ring matching the surrounding form fields.

markdown-view.svelte renders stored markdown read-only: markdown-it with
html:false, sanitized through the existing isomorphic-dompurify, with link
hardening (target=_blank, rel=noopener noreferrer nofollow). Images are
disabled at the parser and forbidden at sanitize time so viewing a note
never triggers an external request. Carries its own typography since
Tailwind preflight strips defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swaps the encounter notes textarea for the markdown editor (associated to
its label via aria-labelledby) and the read-only notes div for the
markdown viewer. The bound `description` string and the create/update path
are unchanged — stored value stays byte-for-byte what the user typed;
markdown is rendered, never stored as HTML. No backend or schema change:
the column is already free-text TEXT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Viewer: headings/lists/emphasis render, raw <script> stripped, javascript:
links never produce a live anchor, external links hardened, images never
render as <img>. Editor: a heading line gets the inline-preview decoration
class, external value reconciles, empty mounts stay empty.

Enables Svelte 5 component testing under jsdom via the svelteTesting() Vite
plugin (browser resolve condition + auto-cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The repo migrated husky -> hk, but clones that previously ran `husky
install` still carry `core.hooksPath=.husky/_` in their git config, which
shadows the working hooks hk installs into `.git/hooks/`. Result: commit-msg
(commitlint) and pre-push (danger) silently never run locally. The prepare
script now unsets core.hooksPath before `hk install`, so any clone
self-heals on the next install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@enko enko force-pushed the feat/markdown-encounter-notes branch from 6047099 to 27f5238 Compare May 25, 2026 12:28
@enko enko requested a review from Copilot May 25, 2026 12:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 19 changed files in this pull request and generated 2 comments.

Comment on lines +219 to +227
<label for="description" class="block text-sm font-body font-medium text-gray-700 mb-1">
{$i18n.t('encounters.form.notesLabel')} <span class="text-gray-400">{$i18n.t('encounters.form.optional')}</span>
</label>
<textarea
id="description"
<MarkdownEditor
bind:value={description}
rows="3"
ariaLabel={$i18n.t('encounters.form.notesLabel')}
placeholder={$i18n.t('encounters.form.notesPlaceholder')}
disabled={isSubmitting}
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-forest focus:border-transparent font-body text-sm resize-none disabled:opacity-50"
></textarea>
/>
</script>

<div class="fb-md-view">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized above -->
enko and others added 2 commits May 25, 2026 14:52
The biome hook step invoked bare `biome`, which isn't on PATH under
`mise x` (mise doesn't add node_modules/.bin), so pre-commit failed with
"biome: command not found" once the hooks actually ran. Switch to `npx
biome`, matching the danger and commitlint steps, so the pinned
node_modules biome resolves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the encounter notes label to the markdown editor with
aria-labelledby (label id + labelledBy prop) instead of a dangling
`for="description"` that pointed at no labelable control. Also drops a
non-functional eslint-disable comment in the viewer (the repo uses Biome,
not ESLint) in favor of a plain note that the html is sanitized.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 20 changed files in this pull request and generated 6 comments.

Comment thread package.json Outdated
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"prepare": "command -v hk >/dev/null && hk install --mise; ! command -v aube >/dev/null || aube --filter @freundebuch/frontend run sync --no-install || true",
"prepare": "git config --unset-all core.hooksPath 2>/dev/null || true; command -v hk >/dev/null && hk install --mise; ! command -v aube >/dev/null || aube --filter @freundebuch/frontend run sync --no-install || true",
Comment on lines +123 to +125
.fb-md-view :global(a) {
overflow-wrap: anywhere;
}
Comment on lines +866 to +870
if (!url) return false;

event.preventDefault();
event.stopPropagation();
onLinkClick(url);
Comment thread apps/frontend/vite.config.ts Outdated
sveltekit(),
// Enables Svelte 5 component testing under jsdom (browser resolve
// condition + auto-cleanup between tests).
svelteTesting(),
Comment on lines +92 to +96
$effect(() => {
if (view && value !== view.state.doc.toString()) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
Comment on lines 219 to 222
<!-- svelte-ignore a11y_label_has_associated_control -->
<label id="notes-label" class="block text-sm font-body font-medium text-gray-700 mb-1">
{$i18n.t('encounters.form.notesLabel')} <span class="text-gray-400">{$i18n.t('encounters.form.optional')}</span>
</label>
enko and others added 2 commits May 25, 2026 18:41
- Editor opens link-icon clicks through a scheme allowlist
  (http/https/mailto/tel), rejecting javascript:/data: URLs that come
  straight from note markdown.
- External value swaps reconcile with addToHistory:false so Undo can't
  jump back into a previous document's content.
- Notes field header is a <span> (matching the type field) referenced by
  aria-labelledby, dropping the label/svelte-ignore workaround.
- Merge the duplicate .fb-md-view a rule in the viewer.
- Gate svelteTesting() on VITEST so it never affects dev/preview/prod builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Guards the prepare-script cleanup so it touches core.hooksPath only when
it still points at the stale `.husky/_` path, leaving any intentional
custom hooks path untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 20 changed files in this pull request and generated 3 comments.

Comment on lines +11 to +31
// Harden every rendered link: open in a new tab and sever the opener so
// note content can't reach back into the app. Runs after sanitization, so
// it also covers autolinked bare URLs (linkify) and survives `html:false`.
let hooked = false;
function ensureHook() {
if (hooked) return;
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
hooked = true;
}

export function renderMarkdown(source: string): string {
ensureHook();
// html:false already neutralizes raw HTML; DOMPurify is the belt to that
// suspenders, and the only thing standing between note text and the DOM.
// FORBID_TAGS img backstops the parser-level image disable above.
return DOMPurify.sanitize(md.render(source ?? ''), { FORBID_TAGS: ['img'] });

:global(.fb-md-editor--disabled) {
opacity: 0.5;
pointer-events: none;
Comment on lines +219 to 225
<span id="notes-label" class="block text-sm font-body font-medium text-gray-700 mb-1">
{$i18n.t('encounters.form.notesLabel')} <span class="text-gray-400">{$i18n.t('encounters.form.optional')}</span>
</label>
<textarea
id="description"
</span>
<MarkdownEditor
bind:value={description}
rows="3"
labelledBy="notes-label"
placeholder={$i18n.t('encounters.form.notesPlaceholder')}
- Viewer hardens links via a module-local markdown-it renderer rule
  (target/rel on link_open) instead of a global DOMPurify.addHook, so it no
  longer mutates other sanitize() callers (e.g. search headlines). DOMPurify
  keeps the attributes via ADD_ATTR; images stay forbidden.
- Disabled editor no longer sets pointer-events:none — editing is already
  blocked by EditorView.editable(false), and dropping it keeps long notes
  scrollable/selectable like a disabled textarea.
- Notes field label stays a <span> tied via aria-labelledby; documented why
  there's no static-span click-to-focus handler (the editor box is itself
  click/tab focusable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 20 changed files in this pull request and generated 2 comments.

Comment on lines +74 to +86
markdown(),
inlinePreview({ onLinkClick: openSafeLink }),
extendEmphasisPair,
atomicMarkdownSyntax,
atomicEditorTheme,
inlineFieldTheme,
EditorView.lineWrapping,
...(placeholder ? [cmPlaceholder(placeholder)] : []),
editableConf.of(EditorView.editable.of(!disabled)),
EditorView.contentAttributes.of(
labelledBy ? { 'aria-labelledby': labelledBy } : { 'aria-label': ariaLabel },
),
EditorView.updateListener.of((u) => {
Comment on lines +79 to +80
.fb-md-view :global(:first-child) { margin-top: 0; }
.fb-md-view :global(:last-child) { margin-bottom: 0; }
- Editor holds placeholder and content attributes (aria-label/labelledby)
  in Compartments and reconfigures them in $effects, so a live language
  switch updates the translated placeholder/label instead of keeping the
  values frozen from mount.
- Viewer trims leading/trailing margin only on the rendered block's direct
  children (`> :first-child` / `> :last-child`), so nested first/last items
  (e.g. a list's first <li>) keep their margins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 20 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 20 changed files in this pull request and generated 1 comment.

// Enables Svelte 5 component testing under jsdom (browser resolve
// condition + auto-cleanup). Test-only — excluded from dev/preview/prod
// builds so it can't affect production module resolution.
...(process.env.VITEST ? [svelteTesting()] : []),
@enko enko merged commit 8c63d69 into main May 25, 2026
9 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.74.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants