Markdown editor chrome and plugin environment
State: open/proposed
Summary
Section titled “Summary”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.
Context
Section titled “Context”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 `Surface | Path | Today |
|---|---|---|
| Renderer | packages/ts/obsidian-md | unified/remark Obsidian dialect to Vue VNodes, never v-html; component-override routing; rehype-sanitize allowlist; Shiki, KaTeX, Mermaid; --obsmd-* theme contract |
| Editor | packages/ts/obsidian-editor | CodeMirror 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 experience | solutions/determined/experiences/notes | NotesEditor.vue on obsidian-editor; own find bar, outline, hover preview, mode switch and --obsmd-* CSS bridge |
| Agent-pants experience | solutions/determined/experiences/agent-pants | SkillBodyEditor.vue on obsidian-editor; the correct-shaped consumer |
| SDLC dashboard entity and task bodies | solutions/ontological/desktop/src-nuxt/app/components/entities/MarkdownBody.vue | marked + DOMPurify + v-html; the package already depends on obsidian-md |
| Suspenders skill detail | solutions/ontological/experiences/suspenders/app/components/SkillDetailPanel.vue | raw <pre> of frontmatter and body |
| Standalone Agent Pants app | apps/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 transcripts | solutions/determined/experiences/agent-chat/app/components/AgentChatMessage.vue | no markdown rendering |
| Canvas node text | packages/ts/json-canvas-vue/src/markdown.ts | marked + dompurify behind a MarkdownRenderer seam |
| Backup preview | solutions/determined/apps/vault-backup/app/components/FilePreview.vue | own marked |
| Pumice admin entity body | apps/pumice/packages/nuxt_admin_layer/app/pages/admin/[entity]/[id]/index.vue | marked + DOMPurify, plus a Vite alias block |
| Mermaid previews | experiences/flows/FlowsMermaidPreview.vue, apps/workflow-designer/MermaidPreview.vue | standalone mermaid, bypassing obsidian-md’s MermaidBlock |
| Augmented apps | solutions/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.
Decision
Section titled “Decision”- 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 oversourceModeThreshold(default 600 000 bytes) open in source mode. No Tiptap, Milkdown, BlockNote or Lexical. - Chrome home:
@sksizer/markdown-ui. A new Nuxt layer atpackages/ts/markdown-ui(tier 1),extends: ['@determined/ui'], depending on@sksizer/obsidian-mdand@sksizer/obsidian-editor. Components areMd*;MdSurfaceis the one component products mount (v-modelis the body,modeisread,liveorsource). Not app-frame, becauseapps/*do not extend it. Not@determined/ui, which stays dumb. The layer enrols inscripts/guard-raw-palette.tsand consumes colors only as--d-*tokens (S-0010-design-language-consumption). - Plugin environment. Engine level:
EditorPlugin(id,syntax,inline,block,commands,keymap,completions,clicks,theme,extensions,prec), compiled once per configuration throughpluginsFacet; built-ins are internal plugins on the same contract, one file each undersrc/plugins/. Vue level:MarkdownPlugin(editor,components,renderOptions,commands,toolbar) throughprovideMarkdownPlugins,registerMarkdownPluginanduseMarkdownPlugins;useMarkdownCommandsreturns{ commands, states, run }.projectMarkdownCommandsis structurally aCommandContribution; the layer never imports app-frame. Peer-content editors (mermaid, canvas) and the markdown-contract validation hint ride this seam (D-7VMX decision 7). - Widgets are framework-free DOM. Rendered widgets in the editor (tables, math, mermaid,
HTML blocks, transclusions) are built from
obsidian-md’s own markup throughrenderToHtmlSync, never a Vue root per widget. A host that needs a Vue-rendered widget (lang:base) contributes anEditorPluginwhose widget mounts Vue itself. - In-text menus are completion sources. The
/, ``` and “).MdFrontmatterPanelis a chrome component above the editor with show/hide; notes keeps its store-bound properties panel through the default slot. - Interactive writes are
swapwith 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 usessliceDoc(). 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. - 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. - 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.
| Decision | Rationale |
|---|---|
| 1 | The 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. |
| 2 | Three 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. |
| 3 | One 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. |
| 4 | A 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 | ` |
| 7 | A 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. |
| 8 | docs/PRINCIPLES.md: when generalizing, delete the original. Six duplicate viewers are the maintenance bill. |
| 9 | Notes and agent-pants already run obsidian-editor, so wave 1 proves the layer on real consumers before any marked viewer moves. |
Options considered
Section titled “Options considered”| Option | Engine | Markdown fidelity (byte-exact saves?) | Dialect cost | Engines in fleet | Verdict |
|---|---|---|---|---|---|
Extend obsidian-editor | CodeMirror 6 live preview; text is the document; Lezer tree | Yes. The editor is a swap writer | None. The Obsidian dialect exists as a Lezer extension and remark plugins | One | Adopted. Weaker than WYSIWYG on block drag and in-cell table editing; both accepted |
Tiptap v3 + @tiptap/markdown | ProseMirror document; marked tokenizer | No. Normalizes markers, escapes, blank lines and HTML on save; Orca needs a round-trip check and a raw fallback | Wikilinks, embeds, callouts, tags, highlights, block ids and comments re-implemented as marked tokenizers plus ProseMirror nodes: a third dialect implementation | Two | Rejected. Best block-editing feel and largest ecosystem; UI components are React-only |
| Milkdown | ProseMirror + remark | No. remark-stringify normalizes on save | Reuses obsidian-md’s remark plugins; still needs a ProseMirror schema and node view per construct | Two | Rejected. Closest WYSIWYG fit to the remark stack; small team, thin docs |
| BlockNote | Tiptap + block model | No. Lossy | High | Two | Rejected. React UI only; MPL plus a paid tier |
| Lexical | Own model | No. Lossy | High | Two | Rejected. 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, `Deleted | Where | Replaced by |
|---|---|---|
NotesEditor.vue, NotesFindBar.vue, NotesHoverPreview.vue, NotesOutline.vue, useNoteFind.ts, utils/noteFind.ts, useNoteComponents.ts | solutions/determined/experiences/notes | MdSurface, MdFindBar + useMarkdownFind, MdHoverPreview, MdOutline, MdMermaidBlock |
SkillBodyEditor.vue | solutions/determined/experiences/agent-pants | MdSurface |
marked, dompurify, :deep() CSS in MarkdownBody.vue | solutions/ontological/desktop | MdViewer |
Raw <pre> body | suspenders SkillDetailPanel.vue | MdViewer |
FlowsMermaidPreview.vue and the mermaid dep | solutions/determined/experiences/flows | MermaidBlock 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/apps | experiences/flows, experiences/backup |
marked, dompurify, the Vite alias block | apps/pumice (two package.jsons, src-nuxt/nuxt.config.ts) | MdViewer through a bodyRenderer injection in the admin layer |
@nuxtjs/mdc, regex frontmatter, @tailwindcss/typography | apps/agentpants | MdViewer + markdown-util.split(); capped at M, skippable |
@nuxt/content | apps/zoo | nothing; unused |
marked | packages/ts/json-canvas-vue | MarkdownRenderer seam with a plainTextRenderer default; obsidian adapter in markdown-ui |
simpleMarkdownToHtml; @nuxtjs/mdc, gray-matter | solutions/augmented/apps/{augmented_web,learn_web} | MdViewer, MdSurface |
src/widgets.ts, the decorations.ts switch, the selection re-dispatch redraw | packages/ts/obsidian-editor, NotesEditor.vue | per-plugin widgets, a node-name dispatch table, requestRedraw |
docs/LIBRARIES.md changes:
| Row | Change |
|---|---|
Tier-1 TS markdown-ui | New. Nuxt layer over @determined/ui; Md* components and the plugin environment |
Tier-1 TS obsidian-editor | New. CodeMirror 6 live-preview engine; missing today |
obsidian-md | Notes the ./plugins subpath once mermaid and KaTeX leave the hard deps |
| Known duplicates | Rows 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/uigainsuseAnchoredPosition(measure, flip, clamp, fromDContextMenu) andchords.ts(from app-frame, which re-imports it).obsidian-mdexportsrenderToHtmlSync,renderMathandrenderMermaid;MathBlock.tsandMermaidBlock.tsconsume them.- Hosts wired by
file:(apps/agentpants,apps/pumice,apps/zoo,solutions/ontological/desktop) needoverridesfor everyworkspace:*transitive and a regeneratedbun.lockin the same change. - Viewers on untrusted content ship after the
obsidian-mdsanitize and routing review findings are fixed. - Performance budgets bind from the first engine change:
| Budget | Measured in |
|---|---|
| Keystroke to paint p95 under 16 ms on a 10 000-line note; viewport decoration rebuild under 8 ms | engine 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 paint | playground Playwright |
| Mode switch under 100 ms on a 2 000-line note | playground Playwright |
| Viewer render of a 2 000-line note under 200 ms, Shiki measured separately | playground, obsidian-md bench |
| Layer plus engines add under 150 KB gzip to a product’s main chunk; mermaid, KaTeX and Shiki are lazy chunks | nuxi analyze |
| No long task over 50 ms while typing; no memory growth across 100 note opens | Playwright with CDP metrics |
Migration
Section titled “Migration”| Wave | Surfaces | Deletes | Depends on |
|---|---|---|---|
| 1 | Notes (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 blocks | layer skeleton with MdViewer, MdSurface, MdModeToggle; engine plugin contract, line endings, command catalogue, anchors, slash menu, tables |
| 2 | SDLC dashboard entity and task bodies (MdViewer, highlight: rehypeShiki() from @sksizer/obsidian-md/shiki); suspenders skill detail body | marked, dompurify, :deep() CSS in MarkdownBody.vue; raw <pre> body | wave 1; file: plus overrides wiring for solutions/ontological/desktop; sanitize review fixes |
| 3 | Agent chat answers (MdViewer); flows mermaid preview onto MermaidBlock; retire the workflow-designer and vault-backup dashboards | pre-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 |
| 4 | Pumice admin body (MdViewer via bodyRenderer injection); apps/agentpants viewer, capped at M and skippable; zoo | marked, dompurify, Vite alias in pumice; @nuxtjs/mdc and regex frontmatter in agentpants; @nuxt/content in zoo | file: plus overrides per isolated workspace; regenerated bun.lock |
| 5 | SDLC entity body edit (MdSurface, swap); suspenders managed-draft body edit; inline markdown-contract validation hint | none | a 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 |
| 6 | json-canvas-vue seam (MarkdownRenderer.render widened to string | Promise<string>); augmented augmented_web renderer, learn_web ContentEditor and PreviewPanel, vimit_prototype | marked in json-canvas-vue; simpleMarkdownToHtml, @nuxtjs/mdc, gray-matter in augmented | async 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.
Open questions
Section titled “Open questions”- json-canvas seam:
MarkdownRenderer.renderreturns a string today and the obsidian renderer is async. Whether the seam widens tostring | Promise<string>with a monotonic token inCanvasNode/index.vue, or gains a separate async path, is decided in wave 6. - Augmented SSR:
MdSurfacewraps the editor in<ClientOnly>withMdVieweras the fallback. WhetherMdViewerserver-renders inaugmented_webandlearn_webwithout touchingwindowis 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 (
SkillClienthas no body verb). Both surfaces are viewer-only until theirswapwrite lands; the op shape (PUT /api/entities/:id/bodyorsdlc <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.