T-A0HT-md-ui-layer-skeleton
State: closed/done · Impact: high · Complexity: medium
Every product that renders or edits markdown today rebuilds the same chrome: the token bridge, the
editor CSS, the mode switch, the doc-sync guard, the mermaid theme binding. This task creates the
@sksizer/markdown-ui Nuxt layer at packages/ts/markdown-ui/ with the one component products
mount, MdSurface, plus MdViewer, MdModeToggle, MdMermaidBlock and the two composables that
own sync and plugin registration. It runs against today’s engine and needs nothing new from it.
| Location | Role today |
|---|---|
packages/ts/ui/nuxt.config.ts | Layer shape to copy: absolute-path CSS, typescript.tsConfig include and exclude, no ssr key |
packages/ts/ui/moon.yml | Layer check (nuxi typecheck) and test tasks |
packages/ts/ui/.storybook/main.ts | Storybook over @nuxt/ui/vite with auto-import dirs; port 4890 |
packages/ts/ui/tests/layerConfig.test.ts | Regression test that a layer declares no ssr |
solutions/determined/experiences/notes/nuxt.config.ts | Resolves @sksizer/obsidian-md/theme.css and katex/dist/katex.min.css via createRequire; transpiles both engines |
solutions/determined/experiences/notes/app/assets/notes.css | :root block of 25 --obsmd-*-override variables mapped to --d-*; .notes-reading prose rules; .notes-editor chrome rules |
solutions/determined/experiences/notes/app/components/NotesEditor.vue | applying guard between the store and the editor doc ref; redraw() selection re-dispatch |
solutions/determined/experiences/agent-pants/app/components/SkillBodyEditor.vue | Second copy of the applying guard |
solutions/determined/experiences/notes/app/components/NotesNoteView.vue | Segmented read/live/source control (data-testid="notes-mode-switch") |
solutions/determined/experiences/notes/app/composables/useNoteComponents.ts | ThemedMermaidBlock binds MermaidBlock’s theme to useDTheme().isDark |
solutions/determined/experiences/agent-pants/app/composables/useAgentPantsEditorProvider.ts | An EditorIndexProvider that already satisfies the MarkdownProvider shape below |
scripts/guard-raw-palette.ts#RINGS | Palette guard enrolment table; the packages/ts/ui components ring is the all-enforce() precedent |
package.json | Root workspaces globs packages/ts/* |
docs/LIBRARIES.md | Package roster; no markdown-ui row |
Proposed
Section titled “Proposed”packages/ts/markdown-ui/ package.json name @sksizer/markdown-ui, main ./nuxt.config.ts, exports ./components/* ./composables/* ./utils/* ./css/* deps: @determined/ui, @sksizer/obsidian-md, @sksizer/obsidian-editor (workspace:*), @iconify-json/lucide, @nuxt/ui + tailwindcss for resolution only (never in `modules`, never imported) nuxt.config.ts extends ['@determined/ui']; no `ssr` key; build.transpile both engines; css: obsidian-md theme.css, katex.min.css (createRequire from the layer), app/assets/css/markdown-ui.css; typescript.tsConfig allowImportingTsExtensions; vite.optimizeDeps.include ['unified','extend','debug'] moon.yml layer library, stack frontend; check = nuxi typecheck, test = bun test tsconfig.json extends ./.nuxt/tsconfig.json .storybook/ ported from packages/ts/ui/.storybook (port 4892); auto-import dirs include @determined/ui's app/utils and app/composables; fixtures/demoProvider.ts, fixtures/sample.md app/assets/css/markdown-ui.css app/components/Md*.vue (+ .stories.ts) app/composables/use*.ts app/utils/md*.ts tests/*.test.tsComponents (prefix Md*; every auto-imported util is md/MD_-prefixed):
| Component | Role | Engine exports used |
|---|---|---|
MdSurface | The one component products mount. v-model = note body; frontmatter?: string; mode: 'read'|'live'|'source' (v-model:mode), modes, readonly, provider: MarkdownProvider, plugins, components, renderOptions, foldable, foldedHeadings, foldedLists, measure, keys.cycleMode (default mod+shift+m). Emits update:modelValue, save, open-link, tag-click, task-toggle, fold-toggle, outline, rendered, ready. Exposes view, scroller, focus(), cycleMode(), scrollToLine(n), scrollToHeading(id), redraw(). read renders MdViewer; live/source render the editor inside <ClientOnly>. Provides NoteSourceKey built from provider by mdNoteSource; subscribes provider.onInvalidate to redraw(); registers in useActiveMarkdownSurface() | useObsidianEditor (view, focus, cursorLine), setMode, scrollLineToTop, extractOutline |
MdViewer | Read-only wrapper over ObsidianMarkdown; merges plugin component maps; root class .md-reading | — |
MdModeToggle | Segmented read/live/source control lifted from NotesNoteView.vue; data-testid="md-mode-toggle"; disabledModes prop with tooltip text | — |
MdMermaidBlock | ThemedMermaidBlock promoted to a layer built-in bound to useDTheme().isDark; registered as lang:mermaid in MdViewer’s default map | — |
Composables and utils:
interface MarkdownProvider extends EditorIndexProvider { loadNote?(path: string): Promise<LoadedNote | null> saveAttachment?(file: File, notePath?: string): Promise<string> searchHeadings?(path: string, query: string): Promise<OutlineEntry[]> onInvalidate?(cb: () => void): () => void}mdNoteSource(provider): NoteSource // app/utils/mdProvider.tsuseMarkdownEditorSync({ model, view, doc }) // owns the `applying` guard and `redraw()` onceinterface MarkdownPlugin { id; editor?: EditorPlugin | EditorPlugin[]; components?: ComponentMap; renderOptions?; commands?: MdChromeCommand[]; toolbar?: {group, ids}[] }provideMarkdownPlugins(plugins) // component-scopedregisterMarkdownPlugin(plugin) // module-level reactive registry for Nuxt pluginsuseMarkdownPlugins() // injected ∪ registered, id-dedupeduseActiveMarkdownSurface() // shallowRef of the focused surface's exposed APIredraw() re-dispatches the current selection, the mechanism NotesEditor.vue uses today;
requestRedraw from T-0GV4-md-engine-line-endings-corpus replaces that one call later.
The layer stylesheet markdown-ui.css, sections 1–4: the --obsmd-*-override ← --d-* bridge moved
verbatim from notes.css onto :root (its two undeclared tokens --d-shadow-md and
--d-bg-input replaced with --d-shadow-popover and --d-bg-control from the ui tokens
stylesheet); layer variables
--md-measure: 46rem, --md-font-size, --md-bottom-pad; reading prose rules for .md-reading
(the generic parts of the .notes-reading rules); editor chrome for .md-editor (the
.notes-editor rules). Dark mode needs no block. Enrol packages/ts/markdown-ui/app in RINGS
with all five rules enforce().
Approach
Section titled “Approach”- Scaffold
packages/ts/markdown-ui/per the tree above;bun install; add themarkdown-uirow todocs/LIBRARIES.md. - Port the layer-config regression test from ui as
layer-config.test.ts(nossr;extendscontains@determined/ui). - Write
mdProvider.ts(MarkdownProvider,mdNoteSource) anduseMarkdownPlugins.tswith its test (dedupe by id, injected wins over registered, registry is reactive). - Write
useMarkdownEditorSync.tswith its test under happy-dom: external write does not emit; user edit emits once;redraw()dispatches. - Write
MdMermaidBlock.vue(fromThemedMermaidBlock),MdViewer.vue,MdModeToggle.vue. - Write
MdSurface.vuecomposing the above;useActiveMarkdownSurface.tsregisters on focus. - Write the stylesheet sections 1–4; add the guard ring; run
just ci-guards. - Port
.storybook/(port 4892) withfixtures/demoProvider.ts(in-memory two-note vault) andfixtures/sample.md; one story per component. moon run markdown-ui:check markdown-ui:test;bun run storybook:build.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
packages/ts/markdown-ui/package.json | new | Layer manifest |
packages/ts/markdown-ui/nuxt.config.ts | new | Layer config |
packages/ts/markdown-ui/moon.yml | new | check, test |
packages/ts/markdown-ui/tsconfig.json | new | Extends .nuxt/tsconfig.json |
packages/ts/markdown-ui/README.md | new | What the layer is, how a product extends it, the MarkdownProvider contract |
packages/ts/markdown-ui/.storybook/ | new | Ported from ui (main, preview, stubs) at port 4892, plus fixtures/demoProvider.ts and fixtures/sample.md |
packages/ts/markdown-ui/app/assets/css/markdown-ui.css | new | Bridge, layer variables, reading prose, editor chrome |
packages/ts/markdown-ui/app/components/MdSurface.vue | new | The mount point |
packages/ts/markdown-ui/app/components/MdSurface.stories.ts | new | Read, live, source stories |
packages/ts/markdown-ui/app/components/MdViewer.vue | new | Read-only renderer wrapper |
packages/ts/markdown-ui/app/components/MdViewer.stories.ts | new | Story |
packages/ts/markdown-ui/app/components/MdModeToggle.vue | new | Segmented control |
packages/ts/markdown-ui/app/components/MdModeToggle.stories.ts | new | Story |
packages/ts/markdown-ui/app/components/MdMermaidBlock.vue | new | Theme-bound mermaid block |
packages/ts/markdown-ui/app/composables/useMarkdownEditorSync.ts | new | applying guard, redraw() |
packages/ts/markdown-ui/app/composables/useMarkdownPlugins.ts | new | provideMarkdownPlugins, registerMarkdownPlugin, useMarkdownPlugins |
packages/ts/markdown-ui/app/composables/useActiveMarkdownSurface.ts | new | Focused-surface registry |
packages/ts/markdown-ui/app/utils/mdProvider.ts | new | MarkdownProvider, mdNoteSource |
packages/ts/markdown-ui/tests/layer-config.test.ts | new | No ssr; extends ui |
packages/ts/markdown-ui/tests/useMarkdownPlugins.test.ts | new | Registry tests |
packages/ts/markdown-ui/tests/useMarkdownEditorSync.test.ts | new | Sync guard tests |
packages/ts/markdown-ui/tests/mdSurface.test.ts | new | Mount, v-model, mode cycle under happy-dom |
scripts/guard-raw-palette.ts | modify | markdown-ui ring, all rules enforce() |
docs/LIBRARIES.md | modify | markdown-ui row in the TypeScript table |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
moon run markdown-ui:check markdown-ui:testpasses. - AC-2: the layer-config test asserts the config has no
ssrkey andextendsincludes@determined/ui. - AC-3: the surface test: an external
modelValuewrite does not emitupdate:modelValue; one user edit emits exactly once;cycleMode()fromlivetosourceand back keeps the document and the cursor position. - AC-4:
grep -rn "from '@nuxt/ui'\|from 'tailwindcss'\|from '@codemirror/" packages/ts/markdown-ui/appreturns nothing, and the layer manifest lists no@codemirror/*dependency. - AC-5:
just ci-guardsexits 0 with amarkdown-uiring inscripts/guard-raw-palette.tswhose five rules areenforce(). - AC-6: the layer stylesheet declares every
--obsmd-*-overridename thatsolutions/determined/experiences/notes/app/assets/notes.cssdeclares today (25 names), and references no token absent frompackages/ts/ui/app/assets/css/tokens.css. - AC-7:
bun run storybook:buildin the package succeeds with stories forMdSurface,MdViewer,MdModeToggle. - AC-8:
useMarkdownPlugins()returns injected and registered plugins deduplicated byid(asserted in the registry test). - AC-9:
docs/LIBRARIES.mdhas amarkdown-uirow.
Out of scope
Section titled “Out of scope”MdToolbar,MdInsertMenu,MdFrontmatterPanel, palette projection (T-98EU-md-ui-toolbar-insert-frontmatter).MdBubbleMenu,MdLinkPopover,MdTableMenu, slash tooltip CSS (T-8ZHI-md-ui-overlays-bubble-link-table).MdFindBar,MdOutline,MdHoverPreview, find CSS (T-RJVA-md-ui-find-outline-hover-lifts).- The playground app (T-J92O-md-test-apps).
- Notes and agent-pants adopting the layer, removing their CSS and tightening the notes ring baseline (T-3DFJ-notes-adopts-markdown-ui, T-FQ5M-agent-pants-adopts-markdown-ui).
Dependencies
Section titled “Dependencies”- No task dependency; runs against today’s engine exports.
- S-0010 / D-DLNG: no
@nuxt/uior Tailwind import; colours are--d-*tokens; the package enrols in the palette guard. D-4QGN: the surface takes the body and frontmatter separately. - D-0001 placement: tier 1
packages/ts/,main: ./nuxt.config.ts, consumed viaextends.
Discovery context
Section titled “Discovery context”Member of milestone M-931L; chrome step C1 of decision D-K3EM-markdown-editor-chrome-and-plugin-environment.