Skip to content

Markdown editor chrome and plugin environment

State: open/proposed

The fleet’s markdown editor is @sksizer/obsidian-editor (CodeMirror 6 live preview), extended to the polish of Orca’s editor (RF-0010-orca-rich-markdown-editor). Tiptap, Milkdown and their ProseMirror peers are rejected: they normalize markdown on save, need a third dialect implementation, and put two engines in the fleet. One chrome layer, @sksizer/markdown-ui (a tier-1 Nuxt layer over @determined/ui), owns the toolbar, menus, overlays and the Vue-level plugin environment. Peer-content editors and validation hints ride the plugin seam (D-7VMX-markdown-ecosystem-strategy decision 7); every migration deletes the implementation it replaces (decision 10); interactive editing is a swap write with advisory validation (decision 4); the engine stays body-only — the caller splits frontmatter per D-4QGN-markdown-dialect-and-compatibility-profile.

Markdown is the fleet’s substrate: notes, skills, SDLC entities, agent transcripts, templates. There is one live-preview editor, one renderer, and six hand-rolled or marked+v-html viewers.

No surface has a formatting toolbar, slash menu, bubble menu or `SurfacePathToday
Rendererpackages/ts/obsidian-mdunified/remark Obsidian dialect to Vue VNodes, never v-html; component-override routing; rehype-sanitize allowlist; Shiki, KaTeX, Mermaid; --obsmd-* theme contract
Editorpackages/ts/obsidian-editorCodeMirror 6 live preview; every decision reads the Lezer tree; [[ and # completion; bold, italic, link and save commands; source and live modes; find; outline; useObsidianEditor
Notes experiencesolutions/determined/experiences/notesNotesEditor.vue on obsidian-editor; own find bar, outline, hover preview, mode switch and --obsmd-* CSS bridge
Agent-pants experiencesolutions/determined/experiences/agent-pantsSkillBodyEditor.vue on obsidian-editor; the correct-shaped consumer
SDLC dashboard entity and task bodiessolutions/ontological/desktop/src-nuxt/app/components/entities/MarkdownBody.vuemarked + DOMPurify + v-html; the package already depends on obsidian-md
Suspenders skill detailsolutions/ontological/experiences/suspenders/app/components/SkillDetailPanel.vueraw <pre> of frontmatter and body
Standalone Agent Pants appapps/agentpants/src-nuxt/app/components/preview/regex frontmatter + @nuxtjs/mdc; read-only CodeMirror viewer; reference-only per [[D-A27T-suspenders-skill-experience-module
Agent chat transcriptssolutions/determined/experiences/agent-chat/app/components/AgentChatMessage.vueno markdown rendering
Canvas node textpackages/ts/json-canvas-vue/src/markdown.tsmarked + dompurify behind a MarkdownRenderer seam
Backup previewsolutions/determined/apps/vault-backup/app/components/FilePreview.vueown marked
Pumice admin entity bodyapps/pumice/packages/nuxt_admin_layer/app/pages/admin/[entity]/[id]/index.vuemarked + DOMPurify, plus a Vite alias block
Mermaid previewsexperiences/flows/FlowsMermaidPreview.vue, apps/workflow-designer/MermaidPreview.vuestandalone mermaid, bypassing obsidian-md’s MermaidBlock
Augmented appssolutions/augmented/apps/{augmented_web,learn_web}own remark/MDC stacks

@determined/ui supplies DToolbar, DMenu, DContextMenu, DCommandPalette, DIconButton and DKbd; app-frame supplies the contribution registries. docs/LIBRARIES.md lists obsidian-md and not obsidian-editor. apps/ontological-legacy and apps/legacy-polish keep their own renderers and are outside this decision.

  1. Engine: @sksizer/obsidian-editor. CodeMirror 6 live preview is the one editor. The text is the document; every decision reads the Lezer tree; saves are byte-exact. Files over sourceModeThreshold (default 600 000 bytes) open in source mode. No Tiptap, Milkdown, BlockNote or Lexical.
  2. Chrome home: @sksizer/markdown-ui. A new Nuxt layer at packages/ts/markdown-ui (tier 1), extends: ['@determined/ui'], depending on @sksizer/obsidian-md and @sksizer/obsidian-editor. Components are Md*; MdSurface is the one component products mount (v-model is the body, mode is read, live or source). Not app-frame, because apps/* do not extend it. Not @determined/ui, which stays dumb. The layer enrols in scripts/guard-raw-palette.ts and consumes colors only as --d-* tokens (S-0010-design-language-consumption).
  3. Plugin environment. Engine level: EditorPlugin (id, syntax, inline, block, commands, keymap, completions, clicks, theme, extensions, prec), compiled once per configuration through pluginsFacet; built-ins are internal plugins on the same contract, one file each under src/plugins/. Vue level: MarkdownPlugin (editor, components, renderOptions, commands, toolbar) through provideMarkdownPlugins, registerMarkdownPlugin and useMarkdownPlugins; useMarkdownCommands returns { commands, states, run }. projectMarkdownCommands is structurally a CommandContribution; the layer never imports app-frame. Peer-content editors (mermaid, canvas) and the markdown-contract validation hint ride this seam (D-7VMX decision 7).
  4. Widgets are framework-free DOM. Rendered widgets in the editor (tables, math, mermaid, HTML blocks, transclusions) are built from obsidian-md’s own markup through renderToHtmlSync, never a Vue root per widget. A host that needs a Vue-rendered widget (lang:base) contributes an EditorPlugin whose widget mounts Vue itself.
  5. In-text menus are completion sources. The /, ``` and “). MdFrontmatterPanel is a chrome component above the editor with show/hide; notes keeps its store-bound properties panel through the default slot.
  6. Interactive writes are swap with advisory validation. A human save is taken as-is (D-7VMX decision 4). Inserted material uses the document’s first line ending (lineSeparatorFor); every emission uses sliceDoc(). The swap-writer corpus asserts that bytes outside the changed range are identical for every command over every fixture. Validation hints never block a save.
  7. Each migration deletes the implementation it replaces (D-7VMX decision 10). The kill-list is under Consequences. A generalized helper replaces its origin: renderMath, renderMermaid, useAnchoredPosition, chords.ts.
  8. Wave order. Notes and the agent-pants experience are wave 1, the reference integrations. Viewers follow in the order under Migration; editors for SDLC entity bodies and suspenders drafts land once their write ops exist.
DecisionRationale
1The text is the document, so saves are byte-exact and the editor is a swap writer by construction. The Obsidian dialect already exists as a Lezer extension and remark plugins; a WYSIWYG engine needs a third implementation. Every Orca behaviour except drag block handles works without a WYSIWYG document model.
2Three origins consume the markdown packages, so tier 1 is earned. @determined/ui stays dumb and apps/* do not extend app-frame; a layer between them is the one home every host can reach.
3One contract for built-ins and hosts turns the decoration code into a dispatch table instead of one large switch, and gives peer-content editors and validation a place outside the core.
4A Vue root per widget costs a mount on every viewport rebuild and breaks widget identity. obsidian-md markup gives read and live modes one visual source.
5`
7A human save is never blocked or rewritten (D-7VMX decision 4). state.doc.toString() joins with LF, so a CRLF note saves as LF; the corpus makes that a failing test.
8docs/PRINCIPLES.md: when generalizing, delete the original. Six duplicate viewers are the maintenance bill.
9Notes and agent-pants already run obsidian-editor, so wave 1 proves the layer on real consumers before any marked viewer moves.
OptionEngineMarkdown fidelity (byte-exact saves?)Dialect costEngines in fleetVerdict
Extend obsidian-editorCodeMirror 6 live preview; text is the document; Lezer treeYes. The editor is a swap writerNone. The Obsidian dialect exists as a Lezer extension and remark pluginsOneAdopted. Weaker than WYSIWYG on block drag and in-cell table editing; both accepted
Tiptap v3 + @tiptap/markdownProseMirror document; marked tokenizerNo. Normalizes markers, escapes, blank lines and HTML on save; Orca needs a round-trip check and a raw fallbackWikilinks, embeds, callouts, tags, highlights, block ids and comments re-implemented as marked tokenizers plus ProseMirror nodes: a third dialect implementationTwoRejected. Best block-editing feel and largest ecosystem; UI components are React-only
MilkdownProseMirror + remarkNo. remark-stringify normalizes on saveReuses obsidian-md’s remark plugins; still needs a ProseMirror schema and node view per constructTwoRejected. Closest WYSIWYG fit to the remark stack; small team, thin docs
BlockNoteTiptap + block modelNo. LossyHighTwoRejected. React UI only; MPL plus a paid tier
LexicalOwn modelNo. LossyHighTwoRejected. Community Vue bindings only

Orca’s editor ([[RF-0010-orca-rich-markdown-editor) sets the UX bar: slash menu on /, fixed

toolbar with active state, bubble menu, `DeletedWhereReplaced by
NotesEditor.vue, NotesFindBar.vue, NotesHoverPreview.vue, NotesOutline.vue, useNoteFind.ts, utils/noteFind.ts, useNoteComponents.tssolutions/determined/experiences/notesMdSurface, MdFindBar + useMarkdownFind, MdHoverPreview, MdOutline, MdMermaidBlock
SkillBodyEditor.vuesolutions/determined/experiences/agent-pantsMdSurface
marked, dompurify, :deep() CSS in MarkdownBody.vuesolutions/ontological/desktopMdViewer
Raw <pre> bodysuspenders SkillDetailPanel.vueMdViewer
FlowsMermaidPreview.vue and the mermaid depsolutions/determined/experiences/flowsMermaidBlock with a source prop over obsidian-md/src/mermaid.ts
workflow-designer and vault-backup dashboards (the Nuxt halves; directories, cli.ts and their .moon/workspace.yml entries stay — determined-platform’s sidecar runs both cli.ts)solutions/determined/appsexperiences/flows, experiences/backup
marked, dompurify, the Vite alias blockapps/pumice (two package.jsons, src-nuxt/nuxt.config.ts)MdViewer through a bodyRenderer injection in the admin layer
@nuxtjs/mdc, regex frontmatter, @tailwindcss/typographyapps/agentpantsMdViewer + markdown-util.split(); capped at M, skippable
@nuxt/contentapps/zoonothing; unused
markedpackages/ts/json-canvas-vueMarkdownRenderer seam with a plainTextRenderer default; obsidian adapter in markdown-ui
simpleMarkdownToHtml; @nuxtjs/mdc, gray-mattersolutions/augmented/apps/{augmented_web,learn_web}MdViewer, MdSurface
src/widgets.ts, the decorations.ts switch, the selection re-dispatch redrawpackages/ts/obsidian-editor, NotesEditor.vueper-plugin widgets, a node-name dispatch table, requestRedraw

docs/LIBRARIES.md changes:

RowChange
Tier-1 TS markdown-uiNew. Nuxt layer over @determined/ui; Md* components and the plugin environment
Tier-1 TS obsidian-editorNew. CodeMirror 6 live-preview engine; missing today
obsidian-mdNotes the ./plugins subpath once mermaid and KaTeX leave the hard deps
Known duplicatesRows for markdown render sites (the kill-list), mermaid preview, wikilink resolution (five copies), canvas node markdown; each wave flips its row to Resolved

Other effects:

  • @determined/ui gains useAnchoredPosition (measure, flip, clamp, from DContextMenu) and chords.ts (from app-frame, which re-imports it).
  • obsidian-md exports renderToHtmlSync, renderMath and renderMermaid; MathBlock.ts and MermaidBlock.ts consume them.
  • Hosts wired by file: (apps/agentpants, apps/pumice, apps/zoo, solutions/ontological/desktop) need overrides for every workspace:* transitive and a regenerated bun.lock in the same change.
  • Viewers on untrusted content ship after the obsidian-md sanitize and routing review findings are fixed.
  • Performance budgets bind from the first engine change:
BudgetMeasured in
Keystroke to paint p95 under 16 ms on a 10 000-line note; viewport decoration rebuild under 8 msengine bench, playground Playwright trace
600 KB note opens in source mode under 500 ms; 2 000-line note in live mode under 300 ms to first paintplayground Playwright
Mode switch under 100 ms on a 2 000-line noteplayground Playwright
Viewer render of a 2 000-line note under 200 ms, Shiki measured separatelyplayground, obsidian-md bench
Layer plus engines add under 150 KB gzip to a product’s main chunk; mermaid, KaTeX and Shiki are lazy chunksnuxi analyze
No long task over 50 ms while typing; no memory growth across 100 note opensPlaywright with CDP metrics
WaveSurfacesDeletesDepends on
1Notes (NotesNoteView.vue onto MdSurface); agent-pants experience (both SkillBodyEditor call sites onto MdSurface)notes editor, find, outline, hover and components files; SkillBodyEditor.vue; notes CSS bridge, editor, autocomplete and find blockslayer skeleton with MdViewer, MdSurface, MdModeToggle; engine plugin contract, line endings, command catalogue, anchors, slash menu, tables
2SDLC dashboard entity and task bodies (MdViewer, highlight: rehypeShiki() from @sksizer/obsidian-md/shiki); suspenders skill detail bodymarked, dompurify, :deep() CSS in MarkdownBody.vue; raw <pre> bodywave 1; file: plus overrides wiring for solutions/ontological/desktop; sanitize review fixes
3Agent chat answers (MdViewer); flows mermaid preview onto MermaidBlock; retire the workflow-designer and vault-backup dashboardspre-wrap text rendering; FlowsMermaidPreview.vue and the mermaid dep; both apps’ Nuxt dashboard halves (directories, cli.ts and moon entries stay — determined-platform’s sidecar runs both cli.ts)obsidian-md/src/mermaid.ts; confirmation that experiences/flows and experiences/backup cover both surfaces
4Pumice admin body (MdViewer via bodyRenderer injection); apps/agentpants viewer, capped at M and skippable; zoomarked, dompurify, Vite alias in pumice; @nuxtjs/mdc and regex frontmatter in agentpants; @nuxt/content in zoofile: plus overrides per isolated workspace; regenerated bun.lock
5SDLC entity body edit (MdSurface, swap); suspenders managed-draft body edit; inline markdown-contract validation hintnonea dashboard body-write op behind @sdlc/dashboard-client with CAS per etag; a suspenders-core draft-body write and SkillClient.updateBody; the markdown-contract browser build
6json-canvas-vue seam (MarkdownRenderer.render widened to string | Promise<string>); augmented augmented_web renderer, learn_web ContentEditor and PreviewPanel, vimit_prototypemarked in json-canvas-vue; simpleMarkdownToHtml, @nuxtjs/mdc, gray-matter in augmentedasync renderer shape; SSR render path for MdViewer

Every wave runs just ci-guards and moon run :check :test --affected. Wave 1 adds a byte-diff check: saving an unedited note leaves the file hash unchanged, and editing one line changes only that line. obsidian-astro, apps/ontological-legacy and apps/legacy-polish are outside the program.

  • json-canvas seam: MarkdownRenderer.render returns a string today and the obsidian renderer is async. Whether the seam widens to string | Promise<string> with a monotonic token in CanvasNode/index.vue, or gains a separate async path, is decided in wave 6.
  • Augmented SSR: MdSurface wraps the editor in <ClientOnly> with MdViewer as the fallback. Whether MdViewer server-renders in augmented_web and learn_web without touching window is verified in wave 6.
  • Write ops for entity bodies: no body-write op exists for SDLC entity bodies (the dashboard API is read-only) or suspenders drafts (SkillClient has no body verb). Both surfaces are viewer-only until their swap write lands; the op shape (PUT /api/entities/:id/body or sdlc <type> update) is decided in wave 5.

[[D-7VMX-markdown-ecosystem-strategy lists rich editors as out of scope; this decision covers the editor surface it left open and cites the D-7VMX decisions it depends on. The task roster is M-931L.


← Back to Decisions