2 Achegas 7b66329bf7 ... 781191f2f4

Autor SHA1 Mensaxe Data
  Zander Hawke 781191f2f4 fix(editor): preserve single vs double newline via hard_break; add history coalescing tests hai 1 día
  Zander Hawke ae3efdca9d test(editor): add Playwright E2E suite, fix blocks.value staleness, document usage hai 1 día

+ 39 - 1
AGENTS.md

@@ -2,7 +2,7 @@
 
 ## System Intent & Context
 
-This repository is a high-performance, rule-based **block markdown editor** built with Vue 3, TypeScript, and the Lezer parsing framework. Documents are structured as distinct, interactive **Blocks** rather than a single flat string. A 3-stage parsing pipeline bridges UI state down to ASTs, enabling real-time extraction of pages, references, task state, callouts, and inline decorations.
+This repository is a high-performance, rule-based **block markdown editor** built with Vue 3, TypeScript, and the Lezer parsing framework. Documents are structured as distinct, interactive **Blocks** rather than a single flat string. A 2-stage parsing pipeline bridges UI state down to ASTs, enabling real-time extraction of pages, references, task state, callouts, and inline decorations.
 
 ---
 
@@ -491,3 +491,41 @@ This prevents CM6 from parsing fence backticks as template literals.
 > **Test coverage is required.** A rule without a mirror test in `__tests__/markdown-parser.test.ts` is not complete.
 
 > **Strict types.** Do not introduce `any`. If a type is genuinely unknown, model it explicitly with a discriminated union or `unknown` with a type guard.
+
+---
+
+## E2E Test Utilities (`__testUtils__`)
+
+Injected in `Editor.vue` when `testMode` prop is true (gated by `?e2e=true`).
+
+### `selectText` position convention
+
+`selectText(blockId, from, to)` treats `from`/`to` as **0-based character offsets** within the block's content string (what `docToContent` returns). Internally these are mapped to PM positions by iterating through all `textblock` nodes via `doc.descendants()` and accounting for `\n\n` separators between paragraphs. This correctly handles multi-paragraph blocks (e.g. `doc(paragraph("abc"), paragraph("def"))` maps char offset 5 to PM position 6 = `"d"` in the second paragraph). Position 0 is before the first paragraph node and resolves outside any textblock, causing `currentBlock()` to return `null`.
+
+### Caret initialization — `focusAtEnd` / `focusAtStart`
+
+`page.keyboard.type()` inserts at the cursor. But `focus()` on a contenteditable leaves the cursor at a browser-dependent position (often position 0), and `press("End")` only reaches line-end, not doc-end. This causes text to land at the wrong position in a block.
+
+Use `focusAtEnd(blockId)` or `focusAtStart(blockId)` instead to set cursor position via PM's selection API before typing:
+
+```typescript
+const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+const lastId = blockIds![blockIds!.length - 1]
+await page.evaluate((bid: string) => window.__testUtils__?.focusAtEnd(bid), lastId)
+await page.keyboard.type("hello world")   // reliably appends at end of block
+```
+
+Both helpers call `view.focus()` + `view.dispatch(tr.setSelection(...))` — absolute position, no browser quirk.
+
+### When to use `page.keyboard.type()` vs `__testUtils__`
+
+| Action | Method | Reason |
+|---|---|---|
+| Set exact text content | `insertText()` | Fast, bypasses plugins |
+| **Replace entire block content** | **`replaceContent()`** | **Single PM transaction — deterministic undo history** |
+| Select precise range | `selectText()` | Unreliable via Playwright in contenteditable |
+| Caret positioning before typing | `focusAtEnd()` / `focusAtStart()` | Browser `focus()` + `End` is unreliable |
+| Typing `[[`, `((`, `/`, `#` | `page.keyboard.type()` | Must go through `handleTextInput` → pattern plugin |
+| Typing `(` for auto-close | `page.keyboard.type()` | Must go through auto-close handler |
+| Typing `` ``` `` | `page.keyboard.type()` | Must go through `tripleBacktickRule` |
+| Paste | `page.keyboard.press('Control+V')` | Must go through paste plugin |

+ 40 - 25
README.md

@@ -9,30 +9,11 @@
   A high-performance <strong>block markdown editor</strong> built with Vue 3, TypeScript, ProseMirror, and the Lezer parsing framework.
 </p>
 
-Documents are structured as distinct, interactive **Blocks** rather than a single flat string. Each block is a self-contained ProseMirror editor instance that stores raw markdown as plain text — no AST conversion, no mark storage. A Lezer-based 3-stage parsing pipeline renders syntax as visual decorations in real time, enabling live-preview editing of Obsidian-style references, task states, callouts, headings, and inline formatting.
-
-The editor is designed for the philosophy that **plain text should stay plain text** — formatting is purely decorative, never structural. This makes it ideal for note-taking apps, knowledge bases, and any tool that needs to edit rich markdown with programmatic access to the raw source.
+Documents are structured as distinct, interactive **Blocks** rather than a single flat string. Each block is a self-contained ProseMirror editor instance that stores raw markdown as plain text — no AST conversion, no mark storage. A Lezer-based parsing pipeline renders syntax as visual decorations in real time, enabling live-preview editing of Obsidian-style references, task states, callouts, headings, and inline formatting.
 
 ## Vision
 
-Enesis is being built as a complete block-editing substrate:
-
-| Phase | Feature | Status |
-|---|---|---|---|
-| 1 | Clean content model — raw markdown in/out, ProseMirror never parses formatting | **Done** |
-| 2 | Keyboard & boundary behavior — Enter, Backspace, Tab, arrows, pattern triggers | **Done** |
-| 3 | Focus & cursor control — programmatic focus, expose API | **Done** |
-| 4 | Core decoration system — Lezer-based inline tokens, cursor-reveal, caching | **Done** |
-| 5 | Block-level decorations — headings, blockquotes, tasks, callouts, properties | **Done** |
-| 6 | Code & math node views — CM6 fenced code blocks, KaTeX LaTeX rendering | **Done** |
-| 6.5 | Formatting handlers — toggle bold/italic/code/strike/highlight, insertLink, setHeading, toggleTask | **Done** |
-| 7 | Reference & tag insertion handlers — `insertPageRef`, `insertBlockRef`, `insertTag` | **Done** |
-| 8 | Reference interactivity — clickable `[[page]]`, `((block))`, `#tag` chips with preview | Planned |
-| 9 | Asset handling — image drop, upload protocol, inline preview | Planned |
-| 10 | Multi-block editing — shared toolbar, insertion zones, suggestion/mention menus, undo/redo | **Done** (shell + history) |
-| 11 | WCAG 2.1 AA compliance, screen reader support, RTL, high contrast | Planned |
-| 12 | History orchestration API across blocks | Planned |
-| Post-v1 | Third-party decoration plugin system | Planned |
+Enesis is being built as a complete block-editing substrate where **plain text stays plain text** — formatting is purely decorative via Lezer-driven decorations, never structural. This makes it ideal for note-taking apps, knowledge bases, and any tool that needs to edit rich markdown with programmatic access to the raw source.
 
 ## Design Principles
 
@@ -78,9 +59,9 @@ The multi-block architecture is the other major friction point. Each `EditorBloc
 │       ├── src/
 │       │   ├── index.ts               Public API (Block + Editor + EditorToolbar components)
 │       │   ├── components/
-│   │   │   ├── EditorBlock.vue     Self-contained ProseMirror block editor with draggable bullet
-│   │   │   ├── Editor.vue          Multi-block shell with insertion zones, drag-to-reorder, undo/redo
-│   │   │   ├── EditorInsertionZone.vue   32px hit target between blocks, hover-reveal, drag target
+│       │   │   ├── EditorBlock.vue     Self-contained ProseMirror block editor with draggable bullet
+│       │   │   ├── Editor.vue          Multi-block shell with insertion zones, drag-to-reorder, undo/redo
+│       │   │   ├── EditorInsertionZone.vue   32px hit target between blocks, hover-reveal, drag target
 │       │   │   └── EditorToolbar.vue   Shared toolbar bound to active block
 │       │   ├── composables/
 │       │   │   ├── useMarkdownDecorations.ts   ProseMirror decoration plugin
@@ -117,7 +98,7 @@ The multi-block architecture is the other major friction point. Each `EditorBloc
 ├── package.json                    Workspace root
 ├── pnpm-workspace.yaml            pnpm workspace definition
-├── AGENTS.md                      Project conventions for AI coding agents
+├── AGENTS.md                      Context optimization file — strict rules and system boundaries to guide AI coding assistants safely through ProseMirror's state architecture
 └── biome.json                     Linting & formatting
 ```
 
@@ -130,6 +111,40 @@ pnpm test           # Run editor unit tests (Vitest)
 pnpm check          # Lint & format check (Biome)
 ```
 
+## Usage
+
+```bash
+pnpm install @enesis/editor
+```
+
+```vue
+<script setup lang="ts">
+import { ref } from "vue"
+import { Editor, EditorToolbar } from "@enesis/editor"
+import "@enesis/editor/dist/index.css"
+
+const content = ref(`* # Hello World
+
+  * A paragraph with **bold** and *italic* text.
+  * A TODO task with ::TODO:: status
+`)
+
+function onFocus(payload: { view: EditorView; handlers: FormattingHandlers }) {
+  // payload.handlers provides toggleBold, insertLink, setHeading, etc.
+}
+</script>
+
+<template>
+  <EditorToolbar />
+  <Editor
+    v-model:content="content"
+    @focus="onFocus"
+    @blur="() => console.log('blurred')"
+    @error="(e: any) => console.error(e)"
+  />
+</template>
+```
+
 ## Scripts
 
 | Script | Description |

+ 59 - 0
packages/editor/e2e/block-operations.spec.ts

@@ -0,0 +1,59 @@
+import { expect, test } from "@playwright/test"
+import { focusLastBlockAtEnd } from "./helpers"
+
+test.describe("Block operations", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  test("Enter splits a block and creates a new one", async ({ page }) => {
+    await focusLastBlockAtEnd(page)
+    await page.keyboard.type("hello world")
+    await page.keyboard.press("Enter")
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("hello")
+    expect(content).toContain("world")
+  })
+
+  test("Shift+Enter creates a soft break, not a new block", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const before = blockIds!.length
+
+    await focusLastBlockAtEnd(page)
+    await page.keyboard.type("line1")
+    await page.keyboard.press("Shift+Enter")
+    await page.keyboard.type("line2")
+
+    const afterIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    expect(afterIds!.length).toBe(before)
+  })
+
+  test("Backspace on new empty block removes it", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const before = blockIds!.length
+
+    await focusLastBlockAtEnd(page)
+    await page.keyboard.press("Enter")
+
+    // The new empty block at the end should be focused. Backspace removes it.
+    await page.keyboard.press("Backspace")
+
+    const afterIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    expect(afterIds!.length).toBe(before)
+  })
+
+  test("ArrowDown navigates to next block", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const firstId = blockIds![0]
+    const secondId = blockIds![1]
+    await page.evaluate((bid: string) => window.__testUtils__?.focusAtStart(bid), firstId)
+    await page.keyboard.type("First block ")
+    await page.evaluate((bid: string) => window.__testUtils__?.focusAtStart(bid), secondId)
+    await page.keyboard.type("Second block ")
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("First block")
+    expect(content).toContain("Second block")
+  })
+})

+ 107 - 0
packages/editor/e2e/code-block.spec.ts

@@ -0,0 +1,107 @@
+import { expect, test } from "@playwright/test"
+import { focusLastBlockAtEnd } from "./helpers"
+
+test.describe("Code block", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  test("triple backtick creates a code block", async ({ page }) => {
+    await focusLastBlockAtEnd(page)
+    await page.keyboard.press("Enter")
+    await page.keyboard.type("```")
+
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const lastId = blockIds![blockIds!.length - 1]
+    const codeBlock = page.locator(
+      `[role="textbox"][data-block-id="${lastId}"] [data-codeblock-editor]`,
+    )
+    await expect(codeBlock).toBeVisible({ timeout: 5000 })
+  })
+
+  test("typing in code block updates content", async ({ page }) => {
+    await focusLastBlockAtEnd(page)
+    await page.keyboard.press("Enter")
+    await page.keyboard.type("```")
+
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const lastId = blockIds![blockIds!.length - 1]
+
+    const codeBlock = page.locator(
+      `[role="textbox"][data-block-id="${lastId}"] [data-codeblock-editor]`,
+    )
+    await expect(codeBlock).toBeVisible({ timeout: 5000 })
+    await codeBlock.locator(".cm-content").click()
+    await page.keyboard.type("const x = 1;")
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("const x = 1;")
+  })
+
+  test("code block content is wrapped in fences", async ({ page }) => {
+    await focusLastBlockAtEnd(page)
+    await page.keyboard.press("Enter")
+    await page.keyboard.type("```")
+
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const lastId = blockIds![blockIds!.length - 1]
+
+    const codeBlock = page.locator(
+      `[role="textbox"][data-block-id="${lastId}"] [data-codeblock-editor]`,
+    )
+    await expect(codeBlock).toBeVisible({ timeout: 5000 })
+    await codeBlock.locator(".cm-content").click()
+    await page.keyboard.type("const x = 1;")
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("```")
+    expect(content).toContain("const x = 1;")
+  })
+
+  test("ArrowUp at code block start exits to paragraph above", async ({ page }) => {
+    await focusLastBlockAtEnd(page)
+    await page.keyboard.press("Enter")
+    await page.keyboard.type("paragraph above")
+    await page.keyboard.press("Enter")
+    await page.keyboard.type("```")
+
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const lastId = blockIds![blockIds!.length - 1]
+
+    const codeBlock = page.locator(
+      `[role="textbox"][data-block-id="${lastId}"] [data-codeblock-editor]`,
+    )
+    await expect(codeBlock).toBeVisible({ timeout: 5000 })
+    await codeBlock.locator(".cm-content").click()
+
+    // ArrowUp should exit the code block to the previous block
+    await page.keyboard.press("ArrowUp")
+    await page.waitForTimeout(100)
+
+    // Type should go to the paragraph above
+    await page.keyboard.type(" added above")
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("paragraph above added above")
+  })
+
+  test("empty code block removed by Backspace", async ({ page }) => {
+    await focusLastBlockAtEnd(page)
+    await page.keyboard.press("Enter")
+    await page.keyboard.type("```")
+
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const lastId = blockIds![blockIds!.length - 1]
+    const scoped = `[role="textbox"][data-block-id="${lastId}"]`
+
+    const codeBlock = page.locator(`${scoped} [data-codeblock-editor]`)
+    await expect(codeBlock).toBeVisible({ timeout: 5000 })
+    await codeBlock.locator(".cm-content").click()
+
+    // Backspace in empty code block should remove it
+    await page.keyboard.press("Backspace")
+    await page.waitForTimeout(100)
+
+    await expect(page.locator(`${scoped} [data-codeblock-editor]`)).not.toBeVisible()
+  })
+})

+ 7 - 0
packages/editor/e2e/helpers.ts

@@ -0,0 +1,7 @@
+import { type Page } from "@playwright/test"
+
+export async function focusLastBlockAtEnd(page: Page) {
+  const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+  const lastId = blockIds![blockIds!.length - 1]
+  await page.evaluate((bid: string) => window.__testUtils__?.focusAtEnd(bid), lastId)
+}

+ 62 - 0
packages/editor/e2e/history.spec.ts

@@ -0,0 +1,62 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("History coalescing", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  test("rapid typing coalesces into single undo step", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const firstId = blockIds![0]
+    await page.evaluate(
+      ({ bid }: { bid: string }) => window.__testUtils__?.replaceContent(bid, "hello"),
+      { bid: firstId },
+    )
+    await page.evaluate(
+      ({ bid }: { bid: string }) => window.__testUtils__?.focusAtEnd(bid),
+      { bid: firstId },
+    )
+
+    const before = await page.evaluate(() => window.__testUtils__?.getContent())
+    // Type rapidly — coalesced within same event tick (<500ms apart)
+    await page.keyboard.type("xyz")
+    const afterType = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(afterType).not.toBe(before)
+
+    // Single undo reverts all three characters
+    await page.locator('[data-toolbar-action="undo"]').click()
+    expect(await page.evaluate(() => window.__testUtils__?.getContent())).toBe(before)
+
+    // Redo restores them
+    await page.locator('[data-toolbar-action="redo"]').click()
+    expect(await page.evaluate(() => window.__testUtils__?.getContent())).toBe(afterType)
+  })
+
+  test("pause >500ms creates separate history entry", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const firstId = blockIds![0]
+    await page.evaluate(
+      ({ bid }: { bid: string }) => window.__testUtils__?.replaceContent(bid, "hello"),
+      { bid: firstId },
+    )
+    await page.evaluate(
+      ({ bid }: { bid: string }) => window.__testUtils__?.focusAtEnd(bid),
+      { bid: firstId },
+    )
+
+    const before = await page.evaluate(() => window.__testUtils__?.getContent())
+    await page.keyboard.type("a")
+    const afterA = await page.evaluate(() => window.__testUtils__?.getContent())
+    // Pause beyond 500ms coalescing window
+    await page.waitForTimeout(600)
+    await page.keyboard.type("b")
+
+    // First undo reverts only "b" (separate history entry)
+    await page.locator('[data-toolbar-action="undo"]').click()
+    expect(await page.evaluate(() => window.__testUtils__?.getContent())).toBe(afterA)
+
+    // Second undo reverts "a"
+    await page.locator('[data-toolbar-action="undo"]').click()
+    expect(await page.evaluate(() => window.__testUtils__?.getContent())).toBe(before)
+  })
+})

+ 45 - 0
packages/editor/e2e/live-preview.spec.ts

@@ -0,0 +1,45 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("Live preview markers", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  test("bold markers visible when block is focused", async ({ page }) => {
+    const pm = page.locator('[role="textbox"][data-block-id]').first()
+    await pm.focus()
+
+    const boldMarker = pm.locator(".md-strong")
+    await expect(boldMarker.first()).toBeVisible()
+  })
+
+  test("page ref chip rendered after blur", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    expect(blockIds).toBeDefined()
+    const id = blockIds![2] // block with "References" content
+
+    await page.evaluate(
+      ({ bid }) => window.__testUtils__?.insertText(bid, " [[Test Page]]"),
+      { bid: id },
+    )
+
+    // Blur triggers live-preview decoration rendering
+    await page.evaluate(() => window.__testUtils__?.toggleBlur())
+
+    // The ref chip renders within the modified block
+    const refBlock = page.locator(`[role="textbox"][data-block-id="${id}"]`)
+    await expect(refBlock.locator('.md-page-ref[data-page-name="Test Page"]')).toBeVisible()
+  })
+
+  test("bold markers persist after blur in always-visible mode", async ({ page }) => {
+    const pm = page.locator('[role="textbox"][data-block-id]').first()
+    await pm.focus()
+
+    await expect(pm.locator(".md-strong").first()).toBeVisible()
+
+    await page.evaluate(() => window.__testUtils__?.toggleBlur())
+
+    // always-visible mode keeps markers rendered regardless of focus
+    await expect(pm.locator(".md-strong").first()).toBeVisible()
+  })
+})

+ 90 - 0
packages/editor/e2e/suggestion-menu.spec.ts

@@ -0,0 +1,90 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("Suggestion menu", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  test("typing / opens the suggestion menu", async ({ page }) => {
+    const firstBlock = page.locator("[data-block-id]").first()
+    await firstBlock.focus()
+    await page.keyboard.type("/")
+
+    await expect(page.locator("[data-command-palette]")).toBeVisible({
+      timeout: 3000,
+    })
+  })
+
+  test("suggestion menu shows items with data-menu-item-index", async ({ page }) => {
+    const firstBlock = page.locator("[data-block-id]").first()
+    await firstBlock.focus()
+    await page.keyboard.type("/")
+
+    const menu = page.locator("[data-command-palette]")
+    await expect(menu).toBeVisible({ timeout: 3000 })
+
+    const items = menu.locator("[data-menu-item-index]")
+    const count = await items.count()
+    expect(count).toBeGreaterThan(0)
+  })
+
+  test("Escape closes the suggestion menu", async ({ page }) => {
+    const firstBlock = page.locator("[data-block-id]").first()
+    await firstBlock.focus()
+    await page.keyboard.type("/")
+
+    const menu = page.locator("[data-command-palette]")
+    await expect(menu).toBeVisible({ timeout: 3000 })
+
+    await page.keyboard.press("Escape")
+    await expect(menu).not.toBeVisible()
+  })
+
+  test("ArrowDown highlights next item", async ({ page }) => {
+    const firstBlock = page.locator("[data-block-id]").first()
+    await firstBlock.focus()
+    await page.keyboard.type("/")
+
+    const menu = page.locator("[data-command-palette]")
+    await expect(menu).toBeVisible({ timeout: 3000 })
+
+    // First item should be highlighted initially
+    const firstItem = menu.locator('[data-menu-item-index="0"]')
+    await expect(firstItem).toHaveAttribute("data-highlighted")
+
+    await page.keyboard.press("ArrowDown")
+
+    // Second item should now be highlighted
+    const secondItem = menu.locator('[data-menu-item-index="1"]')
+    await expect(secondItem).toHaveAttribute("data-highlighted")
+  })
+
+  test("ArrowUp wraps to last item", async ({ page }) => {
+    const firstBlock = page.locator("[data-block-id]").first()
+    await firstBlock.focus()
+    await page.keyboard.type("/")
+
+    const menu = page.locator("[data-command-palette]")
+    await expect(menu).toBeVisible({ timeout: 3000 })
+
+    await page.keyboard.press("ArrowUp")
+
+    const items = menu.locator("[data-menu-item-index]")
+    const lastIndex = (await items.count()) - 1
+    const lastItem = menu.locator(`[data-menu-item-index="${lastIndex}"]`)
+    await expect(lastItem).toHaveAttribute("data-highlighted")
+  })
+
+  test("Enter selects the highlighted item and closes menu", async ({ page }) => {
+    const firstBlock = page.locator("[data-block-id]").first()
+    await firstBlock.focus()
+    await page.keyboard.type("/")
+
+    const menu = page.locator("[data-command-palette]")
+    await expect(menu).toBeVisible({ timeout: 3000 })
+
+    // Slash commands insert content — just verify the menu closes
+    await page.keyboard.press("Enter")
+    await expect(menu).not.toBeVisible()
+  })
+})

+ 85 - 0
packages/editor/e2e/toolbar.spec.ts

@@ -0,0 +1,85 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("Toolbar", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  async function replaceFirstBlockContent(page: any, text: string) {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const firstId = blockIds![0]
+    await page.evaluate(
+      ({ bid, t }: { bid: string; t: string }) =>
+        window.__testUtils__?.replaceContent(bid, t),
+      { bid: firstId, t: text },
+    )
+  }
+
+  test("bold button wraps selected text in **", async ({ page }) => {
+    await replaceFirstBlockContent(page, "bold this")
+
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const firstId = blockIds![0]
+    await page.evaluate(
+      ({ bid, from, to }) => window.__testUtils__?.selectText(bid, from, to),
+      { bid: firstId, from: 0, to: 9 },
+    )
+
+    await page.locator('[data-toolbar-action="bold"]').click()
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("**bold this**")
+  })
+
+  test("italic button wraps selected text in *", async ({ page }) => {
+    await replaceFirstBlockContent(page, "italic this")
+
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const firstId = blockIds![0]
+    await page.evaluate(
+      ({ bid, from, to }) => window.__testUtils__?.selectText(bid, from, to),
+      { bid: firstId, from: 0, to: 11 },
+    )
+
+    await page.locator('[data-toolbar-action="italic"]').click()
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("*italic this*")
+  })
+
+  test("undo button reverts last change", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const firstId = blockIds![0]
+    await page.evaluate((bid: string) => window.__testUtils__?.focusAtEnd(bid), firstId)
+    await page.keyboard.type("X")
+
+    const before = await page.evaluate(() => window.__testUtils__?.getContent())
+
+    await page.locator('[data-toolbar-action="undo"]').click()
+
+    const after = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(after).not.toBe(before)
+  })
+
+  test("redo button restores undone change", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const firstId = blockIds![0]
+    await page.evaluate((bid: string) => window.__testUtils__?.focusAtEnd(bid), firstId)
+    await page.keyboard.type("X")
+
+    const beforeUndo = await page.evaluate(() => window.__testUtils__?.getContent())
+
+    await page.locator('[data-toolbar-action="undo"]').click()
+    await page.locator('[data-toolbar-action="redo"]').click()
+
+    const afterRedo = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(afterRedo).toBe(beforeUndo)
+  })
+
+  test("toolbar buttons are visible", async ({ page }) => {
+    await expect(page.locator('[data-toolbar-action="bold"]')).toBeVisible()
+    await expect(page.locator('[data-toolbar-action="italic"]')).toBeVisible()
+    await expect(page.locator('[data-toolbar-action="undo"]')).toBeVisible()
+    await expect(page.locator('[data-toolbar-action="redo"]')).toBeVisible()
+  })
+})

+ 6 - 0
packages/editor/src/__test-utils__/harness.d.ts

@@ -5,8 +5,14 @@ export interface EditorTestUtils {
   getBlockIds: () => string[]
   /** Dispatch a PM transaction to insert text (bypasses auto-close & pattern plugins) */
   insertText: (blockId: string, text: string) => void
+  /** Replace the block's entire content in a single PM transaction (deterministic undo history) */
+  replaceContent: (blockId: string, text: string) => void
   /** Serialize all blocks to unified markdown */
   getContent: () => string
+  /** Focus a block with cursor at end of its content (reliable caret positioning) */
+  focusAtEnd: (blockId: string) => void
+  /** Focus a block with cursor at start of its content */
+  focusAtStart: (blockId: string) => void
   /** Click outside the editor to trigger live-preview (blur) */
   toggleBlur: () => Promise<void>
   /** Return the Lezer AST tree snapshot for assertion */

+ 94 - 8
packages/editor/src/components/Editor.vue

@@ -36,6 +36,7 @@ import {
   splitBlocks as opSplitBlocks,
 } from "@/lib/editor-operations"
 import type { FormattingHandlers } from "@/lib/formatting"
+import { inlineToString } from "@/lib/content-model"
 import { createLogger } from "@/lib/logger"
 import {
   createOperationHistory,
@@ -393,7 +394,13 @@ watch(
 )
 
 // Serialize and emit when any block's content changes.
-function onBlockContentChange() {
+function onBlockContentChange(blockId?: string, newContent?: string) {
+  if (blockId && newContent) {
+    const idx = blocks.value.findIndex((b) => b.id === blockId)
+    if (idx >= 0) {
+      blocks.value[idx] = { ...blocks.value[idx], content: newContent }
+    }
+  }
   lastSerialized = serializeBlocks(blocks.value)
   content.value = lastSerialized
 }
@@ -603,8 +610,22 @@ function onArrowDownFromEnd(index: number, leftOffset?: number) {
 }
 
 function onBlockBoundaryExit(index: number, direction: "up" | "down") {
+  log.info("onBlockBoundaryExit", { index, direction })
+  if (direction === "up") {
+    const prev = blocks.value[index - 1]
+    if (prev) {
+      registry.focus(prev.id, "end")
+      return
+    }
+  } else {
+    const next = blocks.value[index + 1]
+    if (next) {
+      registry.focus(next.id, "start")
+      return
+    }
+  }
+  // Fallback to insertion zone if no adjacent block
   const zoneIndex = direction === "up" ? index : index + 1
-  log.info("onBlockBoundaryExit", { index, direction, zoneIndex })
   focusZone(zoneIndex)
 }
 
@@ -765,29 +786,94 @@ function onZoneActivate(index: number, content: string) {
 // tree-shaken since the prop is never true.
 onMounted(() => {
   if (!props.testMode) return
-  window.__testUtils__ = {
-    selectText(blockId, from, to) {
+  ;(window as any).__testUtils__ = {
+    selectText(blockId: string, from: number, to: number) {
       const api = blockRefs.get(blockId) as any
       if (!api?.view) return
       const v = api.view as EditorView
       v.focus()
+      // Map 0-based character offsets (as returned by docToContent) → PM document positions.
+      // Uses inlineToString to account for hard_break nodes (textContent omits them).
+      const doc = v.state.doc
+      let charPos = 0
+      let pmFrom: number | null = null
+      let pmTo: number | null = null
+      doc.descendants((node, pos) => {
+        if (!node.isTextblock) return true
+        const textLen = inlineToString(node).length
+        const contentStart = pos + 1
+        const charEnd = charPos + textLen
+        if (pmFrom === null && from < charEnd) {
+          pmFrom = contentStart + Math.max(0, from - charPos)
+        }
+        if (pmTo === null && to <= charEnd) {
+          pmTo = contentStart + Math.max(0, to - charPos)
+        }
+        charPos = charEnd + 2 // docToContent joins with "\n\n"
+        return pmFrom === null || pmTo === null
+      })
+      if (pmFrom === null) pmFrom = doc.content.size
+      if (pmTo === null) pmTo = doc.content.size
       v.dispatch(
-        v.state.tr.setSelection(TextSelection.create(v.state.doc, from, to)),
+        v.state.tr.setSelection(
+          TextSelection.create(doc, pmFrom, pmTo),
+        ),
       )
     },
     getBlockIds() {
       return Array.from(blockRefs.keys())
     },
-    insertText(blockId, text) {
+    insertText(blockId: string, text: string) {
       const api = blockRefs.get(blockId) as any
       if (!api?.view) return
       const v = api.view as EditorView
       v.focus()
       v.dispatch(v.state.tr.insertText(text))
     },
+    /** Replace the block's entire content in a single PM transaction */
+    replaceContent(blockId: string, text: string) {
+      const api = blockRefs.get(blockId) as any
+      if (!api?.setContent) {
+        console.warn(
+          `[Editor] replaceContent: block ${blockId} has no setContent method`,
+        )
+        return
+      }
+      api.setContent(text)
+    },
     getContent() {
       return serializeBlocks(blocks.value)
     },
+    focusAtEnd(blockId: string) {
+      const api = blockRefs.get(blockId) as any
+      if (!api?.view) return
+      const v = api.view as EditorView
+      const end = v.state.doc.content.size
+      v.focus()
+      v.dispatch(
+        v.state.tr.setSelection(
+          TextSelection.create(v.state.doc, end, end),
+        ).scrollIntoView(),
+      )
+    },
+    focusAtStart(blockId: string) {
+      const api = blockRefs.get(blockId) as any
+      if (!api?.view) return
+      const v = api.view as EditorView
+      v.focus()
+      let start = 0
+      v.state.doc.descendants((node, pos) => {
+        if (node.isTextblock) {
+          start = pos + 1
+          return false
+        }
+      })
+      v.dispatch(
+        v.state.tr.setSelection(
+          TextSelection.create(v.state.doc, start, start),
+        ).scrollIntoView(),
+      )
+    },
     toggleBlur() {
       return new Promise<void>((resolve) => {
         const btn = document.createElement("button")
@@ -807,7 +893,7 @@ onMounted(() => {
       if (!firstApi?.view) return ""
       return (firstApi.view as EditorView).state.doc.toString()
     },
-    setTimeSource(fakeMs) {
+    setTimeSource(fakeMs: number) {
       ;(window as any).__testTimeSource = fakeMs
     },
   }
@@ -898,7 +984,7 @@ defineExpose({
           :resolved-refs-version="resolvedRefsVersion"
           :resolve-asset="resolveAsset"
           class="flex-1 min-w-0"
-          @update:content="onBlockContentChange"
+          @update:content="(val: string) => onBlockContentChange(block.id, val)"
           @split="(before, after) => onSplit(i, before, after)"
           @merge-previous="onMergePrevious(i)"
           @delete-if-empty="onDeleteIfEmpty(i)"

+ 6 - 8
packages/editor/src/components/__tests__/block-expose.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest"
-import { contentToDoc, docToContent } from "@/lib/content-model"
+import { contentToDoc, docToContent, inlineToString } from "@/lib/content-model"
 
 // Unit tests for expose logic without mounting
 describe("Block expose API", () => {
@@ -12,7 +12,8 @@ describe("Block expose API", () => {
 
     it("handles multiline content", () => {
       const doc = contentToDoc("line1\nline2\nline3")
-      expect(doc.childCount).toBe(3)
+      expect(doc.childCount).toBe(1)
+      expect(inlineToString(doc.child(0))).toBe("line1\nline2\nline3")
     })
 
     it("handles empty content", () => {
@@ -50,12 +51,9 @@ describe("Block expose API", () => {
 
     it("handles multiline strings", () => {
       const doc = contentToDoc("line1\nline2\nline3")
-      expect(doc.childCount).toBe(3) // three paragraphs
-      expect(doc.child(0).textContent).toBe("line1")
-      expect(doc.child(1).type.name).toBe("paragraph")
-      expect(doc.child(1).textContent).toBe("line2")
-      expect(doc.child(2).type.name).toBe("paragraph")
-      expect(doc.child(2).textContent).toBe("line3")
+      expect(doc.childCount).toBe(1)
+      expect(doc.child(0).type.name).toBe("paragraph")
+      expect(inlineToString(doc.child(0))).toBe("line1\nline2\nline3")
     })
   })
 

+ 3 - 3
packages/editor/src/composables/__tests__/markdown-decorations.test.ts

@@ -239,9 +239,9 @@ describe("Markdown decorations plugin", () => {
       }
     }
 
-    // Combined-parsing with \n\n separator: each paragraph gets its own
-    // node wrapper, so callout + continuation = 2 md-callout decorations.
-    expect(mdCalloutCount).toBe(2)
+    // With hard_break preservation, both lines are in one paragraph.
+    // Lezer produces a single callout block decoration for the pair.
+    expect(mdCalloutCount).toBe(1)
   })
 
   it("builds decorations for inline math", () => {

+ 135 - 0
packages/editor/src/composables/__tests__/node-views.test.ts

@@ -142,4 +142,139 @@ describe("CodeBlockView", () => {
 
     nodeView.destroy()
   })
+
+  it("stopEvent returns true for keyboard events", async () => {
+    const { CodeBlockView } = await import("@/composables/useCodeBlockView")
+
+    const textNode = schema.text("```\ncode\n```")
+    const node = schema.nodes.code_block.create({ language: "" }, [textNode])
+    const doc = schema.node("doc", {}, [node])
+    const state = EditorState.create({ doc, schema })
+    const dom = document.createElement("div")
+    const view = new EditorView(dom, { state })
+    views.push(view)
+
+    const nodeView = new CodeBlockView(node, view, () => 0)
+
+    expect(nodeView.stopEvent(new Event("keydown"))).toBe(true)
+    expect(nodeView.stopEvent(new Event("keypress"))).toBe(true)
+    expect(nodeView.stopEvent(new Event("keyup"))).toBe(true)
+
+    nodeView.destroy()
+  })
+
+  it("stopEvent returns false for clipboard events", async () => {
+    const { CodeBlockView } = await import("@/composables/useCodeBlockView")
+
+    const textNode = schema.text("```\ncode\n```")
+    const node = schema.nodes.code_block.create({ language: "" }, [textNode])
+    const doc = schema.node("doc", {}, [node])
+    const state = EditorState.create({ doc, schema })
+    const dom = document.createElement("div")
+    const view = new EditorView(dom, { state })
+    views.push(view)
+
+    const nodeView = new CodeBlockView(node, view, () => 0)
+
+    expect(nodeView.stopEvent(new Event("copy"))).toBe(false)
+    expect(nodeView.stopEvent(new Event("cut"))).toBe(false)
+    expect(nodeView.stopEvent(new Event("paste"))).toBe(false)
+
+    nodeView.destroy()
+  })
+
+  it("stopEvent returns false for composition events", async () => {
+    const { CodeBlockView } = await import("@/composables/useCodeBlockView")
+
+    const textNode = schema.text("```\ncode\n```")
+    const node = schema.nodes.code_block.create({ language: "" }, [textNode])
+    const doc = schema.node("doc", {}, [node])
+    const state = EditorState.create({ doc, schema })
+    const dom = document.createElement("div")
+    const view = new EditorView(dom, { state })
+    views.push(view)
+
+    const nodeView = new CodeBlockView(node, view, () => 0)
+
+    expect(nodeView.stopEvent(new Event("compositionstart"))).toBe(false)
+    expect(nodeView.stopEvent(new Event("compositionupdate"))).toBe(false)
+    expect(nodeView.stopEvent(new Event("compositionend"))).toBe(false)
+
+    nodeView.destroy()
+  })
+})
+
+describe("stripFences / addFences", () => {
+  it("stripFences removes first and last fence lines", async () => {
+    const { stripFences } = await import("@/composables/useCodeBlockView")
+    expect(stripFences("```js\nconst x = 1\n```")).toBe("const x = 1")
+  })
+
+  it("stripFences handles empty body", async () => {
+    const { stripFences } = await import("@/composables/useCodeBlockView")
+    expect(stripFences("```js\n```")).toBe("")
+  })
+
+  it("stripFences handles multiline body", async () => {
+    const { stripFences } = await import("@/composables/useCodeBlockView")
+    expect(stripFences("```\nline1\nline2\n```")).toBe("line1\nline2")
+  })
+
+  it("stripFences handles no language", async () => {
+    const { stripFences } = await import("@/composables/useCodeBlockView")
+    expect(stripFences("```\nconst x = 1\n```")).toBe("const x = 1")
+  })
+
+  it("addFences wraps body with fence markers and language", async () => {
+    const { addFences } = await import("@/composables/useCodeBlockView")
+    expect(addFences("const x = 1", "js")).toBe("```js\nconst x = 1\n```")
+  })
+
+  it("addFences handles empty language", async () => {
+    const { addFences } = await import("@/composables/useCodeBlockView")
+    expect(addFences("const x = 1", "")).toBe("```\nconst x = 1\n```")
+  })
+
+  it("addFences normalizes trailing newline", async () => {
+    const { addFences } = await import("@/composables/useCodeBlockView")
+    expect(addFences("const x = 1\n", "js")).toBe("```js\nconst x = 1\n```")
+  })
+
+  it("addFences handles empty body", async () => {
+    const { addFences } = await import("@/composables/useCodeBlockView")
+    expect(addFences("", "js")).toBe("```js\n\n```")
+  })
+
+  it("stripFences / addFences roundtrip preserves content", async () => {
+    const { stripFences, addFences } = await import(
+      "@/composables/useCodeBlockView"
+    )
+    const original = "```ts\nconst x: number = 1\n```"
+    const body = stripFences(original)
+    const restored = addFences(body, "ts")
+    expect(restored).toBe(original)
+  })
+
+  it("addFences adds newline after empty body", async () => {
+    // addFences always appends a trailing newline after the body to ensure
+    // the closing ``` is on its own line. This means stripFences→addFences
+    // is not idempotent for the edge case of an empty code block
+    // ("```\n```" → "```\n\n```"). The fence format is normalized on write.
+    const { stripFences, addFences } = await import(
+      "@/composables/useCodeBlockView"
+    )
+    const original = "```\n```"
+    const body = stripFences(original)
+    const restored = addFences(body, "")
+    expect(restored).toBe("```\n\n```")
+  })
+
+  it("addFences preserves trailing whitespace in body", async () => {
+    const { addFences } = await import("@/composables/useCodeBlockView")
+    const body = "line with trailing   "
+    const result = addFences(body, "")
+    expect(result).toContain("line with trailing   ")
+    expect(result.startsWith("```")).toBe(true)
+    expect(result).toBe("```\nline with trailing   \n```")
+  })
 })

+ 15 - 10
packages/editor/src/composables/__tests__/pattern-plugin.test.ts

@@ -91,11 +91,12 @@ describe("Pattern plugin", () => {
     expect(session).toBeNull()
   })
 
-  // ── Multi-line patterns (multiple paragraphs) ──────────────────────
-  // With multiple paragraphs per block, each pattern is detected within
-  // its own paragraph. Cursor is placed at the end of the document.
+  // ── Multi-line patterns (with hard_break) ──────────────────────────
+  // Single \n becomes a hard_break within the same paragraph. Patterns
+  // are still detected because the inlineToString walker emits "\n" for
+  // each hard_break, so the text before cursor is correct.
 
-  it("detects [[ pattern in second paragraph", () => {
+  it("detects [[ pattern after hard_break", () => {
     const state = createTestState("line1\n[[query")
     const session = getSession(state)
 
@@ -104,7 +105,7 @@ describe("Pattern plugin", () => {
     expect(session?.query).toBeTruthy()
   })
 
-  it("detects (( pattern in second paragraph", () => {
+  it("detects (( pattern after hard_break", () => {
     const state = createTestState("prefix\n((block-id")
     const session = getSession(state)
 
@@ -113,7 +114,7 @@ describe("Pattern plugin", () => {
     expect(session?.query).toBeTruthy()
   })
 
-  it("detects / command in second paragraph", () => {
+  it("detects / command after hard_break", () => {
     const state = createTestState("text\n/cmd")
     const session = getSession(state)
 
@@ -122,7 +123,7 @@ describe("Pattern plugin", () => {
     expect(session?.kind).toBe("command")
   })
 
-  it("detects # tag in second paragraph", () => {
+  it("detects # tag after hard_break", () => {
     const state = createTestState("text\n#tag")
     const session = getSession(state)
 
@@ -131,12 +132,16 @@ describe("Pattern plugin", () => {
     expect(session?.kind).toBe("tag")
   })
 
-  it("does not detect pattern from previous paragraph", () => {
+  it("detects [[ pattern across hard_break within same paragraph", () => {
     const state = createTestState("[[Page\nhello")
     const session = getSession(state)
 
-    // Should NOT detect [[ from paragraph 1 — each paragraph is isolated
-    expect(session).toBeNull()
+    // With hard_break preservation, both lines are in one paragraph,
+    // so [[ at the start is detected even after a hard_break.
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("[[")
+    expect(session?.kind).toBe("reference")
+    expect(session?.query).toBe("Page\nhello")
   })
 
   // ── Spaces in delimiter pattern queries ──────────────────────────────

+ 2 - 1
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -20,6 +20,7 @@ import { Plugin, PluginKey, TextSelection } from "prosemirror-state"
 import { Decoration, DecorationSet } from "prosemirror-view"
 import { renderKatexSync } from "@/lib/katex"
 import { createLogger } from "@/lib/logger"
+import { inlineToString } from "@/lib/content-model"
 import {
   type DecorationRange,
   MarkdownRuleEngine,
@@ -118,7 +119,7 @@ function collectParagraphs(doc: ProsemirrorNode): ParagraphInfo[] {
   doc.descendants((node: ProsemirrorNode, pos: number) => {
     if (!node.isTextblock) return
     if (node.type.spec.code) return
-    paragraphs.push({ node, pos, text: node.textContent })
+    paragraphs.push({ node, pos, text: inlineToString(node) })
   })
   return paragraphs
 }

+ 3 - 1
packages/editor/src/composables/usePatternPlugin.ts

@@ -18,6 +18,7 @@ import {
   type Transaction,
 } from "prosemirror-state"
 import type { EditorView } from "prosemirror-view"
+import { inlineToString } from "@/lib/content-model"
 import { createLogger } from "@/lib/logger"
 
 export type PatternKind = "reference" | "embed" | "command" | "tag"
@@ -121,7 +122,8 @@ function getTextBeforeCursor(
   const parentNode = $head.parent
   if (!parentNode?.isTextblock) return null
 
-  const textBefore = parentNode.textContent.slice(0, $head.parentOffset)
+  // Use inlineToString to account for hard_break nodes (textContent omits them)
+  const textBefore = inlineToString(parentNode).slice(0, $head.parentOffset)
 
   return { textBefore }
 }

+ 31 - 8
packages/editor/src/lib/__tests__/content-model.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest"
-import { contentToDoc, docToContent } from "@/lib/content-model"
+import { contentToDoc, docToContent, inlineToString } from "@/lib/content-model"
 import { schema } from "@/lib/schema"
 
 describe("contentToDoc", () => {
@@ -9,13 +9,11 @@ describe("contentToDoc", () => {
     expect(doc.child(0).textContent).toBe("hello world")
   })
 
-  it("handles newlines as separate paragraphs", () => {
+  it("handles newlines as hard_break within a single paragraph", () => {
     const doc = contentToDoc("line1\nline2")
-    expect(doc.childCount).toBe(2)
-    expect(doc.child(0).textContent).toBe("line1")
-    expect(doc.child(1).textContent).toBe("line2")
+    expect(doc.childCount).toBe(1)
     expect(doc.child(0).type.name).toBe("paragraph")
-    expect(doc.child(1).type.name).toBe("paragraph")
+    expect(inlineToString(doc.child(0))).toBe("line1\nline2")
   })
 
   it("handles fenced code block with backticks", () => {
@@ -146,10 +144,11 @@ describe("roundtrip", () => {
     expect(docToContent(doc)).toBe(original)
   })
 
-  it("multiline roundtrips", () => {
+  it("multiline roundtrips as single paragraph with hard_breaks", () => {
     const original = "line1\nline2\nline3"
     const doc = contentToDoc(original)
-    expect(doc.childCount).toBe(3)
+    expect(doc.childCount).toBe(1)
+    expect(inlineToString(doc.child(0))).toBe("line1\nline2\nline3")
   })
 
   it("unicode roundtrips", () => {
@@ -240,3 +239,27 @@ describe("math block (content model)", () => {
     expect(doc.child(0).textContent).toBe("$$\n$$")
   })
 })
+
+describe("roundtrip (hard_break preservation)", () => {
+  const ROUNDTRIP_CASES: [string, string][] = [
+    ["plain text", "hello world"],
+    ["single newline", "line1\nline2"],
+    ["double newline (paragraph break)", "para1\n\npara2"],
+    ["mixed newlines", "section1\nline1\nline2\n\nsection2"],
+    ["triple line", "line1\nline2\nline3"],
+    ["multiple paragraphs", "para1\n\npara2\n\npara3"],
+    ["code block", "```js\nconst x = 1\n```"],
+    ["code block with internal blank lines", "text\n\n```\nline1\n\nline2\n```\n\nafter"],
+    ["math block", "$$\nE = mc^2\n$$"],
+    ["wrapped math", "text\n\n$$\na^2 + b^2 = c^2\n$$\n\nmore"],
+    ["properties (motivating case)", "## Properties\n\nauthor:: Enesis Editor\nversion:: 0.1.0"],
+    ["single line only", "line1\nline2\nline3"],
+    ["empty string", ""],
+  ]
+
+  for (const [label, input] of ROUNDTRIP_CASES) {
+    it(`roundtrips: ${label}`, () => {
+      expect(docToContent(contentToDoc(input))).toBe(input)
+    })
+  }
+})

+ 104 - 0
packages/editor/src/lib/__tests__/editor-operations.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it, vi } from "vitest"
 import { stableId } from "@/__test-utils__"
 import type { EditorBlockData } from "@/lib/block-parser"
+import { classifyBlock } from "@/lib/markdown-rules/block-classifier"
 import {
   deleteIfEmpty,
   indentBlock,
@@ -326,3 +327,106 @@ describe("moveBlock", () => {
     expect(result[1].id).toBe("b")
   })
 })
+
+describe("classifyBlock", () => {
+  it("classifies heading at various levels", () => {
+    expect(classifyBlock("# H1").kind).toBe("heading")
+    expect(classifyBlock("## H2").kind).toBe("heading")
+    expect(classifyBlock("###### H6").kind).toBe("heading")
+  })
+
+  it("classifies heading with correct level", () => {
+    const result = classifyBlock("### H3")
+    if (result.kind === "heading") {
+      expect(result.level).toBe(3)
+    }
+  })
+
+  it("classifies code fence with language", () => {
+    const result = classifyBlock("```python")
+    expect(result.kind).toBe("code")
+    if (result.kind === "code") {
+      expect(result.language).toBe("python")
+    }
+  })
+
+  it("classifies code fence without language", () => {
+    const result = classifyBlock("```")
+    expect(result.kind).toBe("code")
+    if (result.kind === "code") {
+      expect(result.language).toBe("")
+    }
+  })
+
+  it("classifies tilde code fence", () => {
+    const result = classifyBlock("~~~")
+    expect(result.kind).toBe("code")
+  })
+
+  it("classifies lowercase task state", () => {
+    const result = classifyBlock("todo buy milk")
+    expect(result.kind).toBe("task")
+    if (result.kind === "task") {
+      expect(result.taskState).toBe("TODO")
+    }
+  })
+
+  it("classifies all task states", () => {
+    const states = ["TODO", "DOING", "DONE", "LATER", "NOW", "WAITING", "CANCELLED"]
+    for (const s of states) {
+      const result = classifyBlock(`${s} task`)
+      expect(result.kind).toBe("task")
+      if (result.kind === "task") {
+        expect(result.taskState).toBe(s)
+      }
+    }
+  })
+
+  it("classifies empty callout as callout", () => {
+    const result = classifyBlock("> [!NOTE]")
+    expect(result.kind).toBe("callout")
+    if (result.kind === "callout") {
+      expect(result.calloutType).toBe("NOTE")
+    }
+  })
+
+  it("classifies callout with arbitrary type", () => {
+    const result = classifyBlock("> [!CUSTOM] text")
+    expect(result.kind).toBe("callout")
+    if (result.kind === "callout") {
+      expect(result.calloutType).toBe("CUSTOM")
+    }
+  })
+
+  it("classifies callout with extra whitespace", () => {
+    const result = classifyBlock(">  [!NOTE]  text")
+    expect(result.kind).toBe("callout")
+  })
+
+  it("classifies blockquote", () => {
+    const result = classifyBlock("> quoted text")
+    expect(result.kind).toBe("blockquote")
+  })
+
+  it("classifies plain text as paragraph", () => {
+    expect(classifyBlock("some text").kind).toBe("paragraph")
+  })
+
+  it("classifies empty string as paragraph", () => {
+    expect(classifyBlock("").kind).toBe("paragraph")
+  })
+
+  it("classifies string with only whitespace as paragraph", () => {
+    expect(classifyBlock("   ").kind).toBe("paragraph")
+  })
+
+  it("classifies heading with leading spaces as paragraph (classifyBlock is regex-based)", () => {
+    const result = classifyBlock("  # Title")
+    expect(result.kind).toBe("paragraph")
+  })
+
+  it("classifies blockquote with leading space as paragraph (classifyBlock is regex-based)", () => {
+    const result = classifyBlock(" > text")
+    expect(result.kind).toBe("paragraph")
+  })
+})

+ 186 - 0
packages/editor/src/lib/__tests__/logger.test.ts

@@ -0,0 +1,186 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+beforeEach(() => {
+  localStorage.clear()
+})
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
+
+async function freshLogger() {
+  vi.resetModules()
+  return import("@/lib/logger")
+}
+
+describe("createLogger", () => {
+  it("logs debug when global filter is wildcard", async () => {
+    localStorage.setItem("editor:debug", "*")
+    const { createLogger } = await freshLogger()
+    const log = createLogger("MyFeature")
+    const spy = vi.spyOn(console, "debug").mockImplementation(() => {})
+    log.debug("test message")
+    expect(spy).toHaveBeenCalledWith("[MyFeature]", "test message")
+  })
+
+  it("logs info when global filter is wildcard", async () => {
+    localStorage.setItem("editor:debug", "*")
+    const { createLogger } = await freshLogger()
+    const log = createLogger("MyFeature")
+    const spy = vi.spyOn(console, "info").mockImplementation(() => {})
+    log.info("info message")
+    expect(spy).toHaveBeenCalledWith("[MyFeature]", "info message")
+  })
+
+  it("does not log debug when no global filter matches", async () => {
+    localStorage.setItem("editor:debug", "OtherNamespace")
+    const { createLogger } = await freshLogger()
+    const log = createLogger("MyFeature")
+    const spy = vi.spyOn(console, "debug").mockImplementation(() => {})
+    log.debug("should not appear")
+    expect(spy).not.toHaveBeenCalled()
+  })
+
+  it("logs when namespace matches specific global filter", async () => {
+    localStorage.setItem("editor:debug", "Block,MyFeature,Parse")
+    const { createLogger } = await freshLogger()
+    const log = createLogger("MyFeature")
+    const spy = vi.spyOn(console, "info").mockImplementation(() => {})
+    log.info("visible")
+    expect(spy).toHaveBeenCalled()
+  })
+
+  it("does not log for non-matching namespace in comma-separated filter", async () => {
+    localStorage.setItem("editor:debug", "Block,Parse")
+    const { createLogger } = await freshLogger()
+    const log = createLogger("Other")
+    const spy = vi.spyOn(console, "info").mockImplementation(() => {})
+    log.info("invisible")
+    expect(spy).not.toHaveBeenCalled()
+  })
+})
+
+describe("component filter", () => {
+  it("component filter wildcard logs regardless of global", async () => {
+    localStorage.setItem("editor:debug", "Unrelated")
+    const { createLogger } = await freshLogger()
+    const log = createLogger("MyFeature", "*")
+    const spy = vi.spyOn(console, "debug").mockImplementation(() => {})
+    log.debug("visible via component filter")
+    expect(spy).toHaveBeenCalled()
+  })
+
+  it("component filter with matching namespace logs", async () => {
+    localStorage.setItem("editor:debug", "Unrelated")
+    const { createLogger } = await freshLogger()
+    const log = createLogger("MyFeature", "MyFeature,Other")
+    const spy = vi.spyOn(console, "info").mockImplementation(() => {})
+    log.info("visible")
+    expect(spy).toHaveBeenCalled()
+  })
+
+  it("component filter with non-matching namespace suppresses logs", async () => {
+    localStorage.setItem("editor:debug", "*")
+    const { createLogger } = await freshLogger()
+    const log = createLogger("MyFeature", "Other")
+    const spy = vi.spyOn(console, "debug").mockImplementation(() => {})
+    log.debug("suppressed by component filter")
+    expect(spy).not.toHaveBeenCalled()
+  })
+})
+
+describe("level filtering", () => {
+  it("does not log debug when minLevel is info", async () => {
+    const { createLogger } = await freshLogger()
+    const log = createLogger("MyFeature")
+    const spy = vi.spyOn(console, "debug").mockImplementation(() => {})
+    log.debug("should not appear")
+    expect(spy).not.toHaveBeenCalled()
+  })
+
+  it("logs warn when namespace matches global filter", async () => {
+    localStorage.setItem("editor:debug", "MyFeature")
+    const { createLogger } = await freshLogger()
+    const log = createLogger("MyFeature")
+    const spy = vi.spyOn(console, "warn").mockImplementation(() => {})
+    log.warn("warning is visible")
+    expect(spy).toHaveBeenCalled()
+  })
+
+  it("logs error when namespace matches global filter", async () => {
+    localStorage.setItem("editor:debug", "MyFeature")
+    const { createLogger } = await freshLogger()
+    const log = createLogger("MyFeature")
+    const spy = vi.spyOn(console, "error").mockImplementation(() => {})
+    log.error("error is always visible")
+    expect(spy).toHaveBeenCalled()
+  })
+})
+
+describe("configureDebug", () => {
+  it("updates namespace at runtime", async () => {
+    const { configureDebug, createLogger } = await freshLogger()
+    configureDebug({ namespaces: "RuntimeNS", minLevel: "debug" })
+
+    const matchingLog = createLogger("RuntimeNS")
+    const spyMatch = vi.spyOn(console, "debug").mockImplementation(() => {})
+    matchingLog.debug("matches runtime config")
+    expect(spyMatch).toHaveBeenCalled()
+    spyMatch.mockRestore()
+
+    const nonMatchingLog = createLogger("Other")
+    const spyNoMatch = vi.spyOn(console, "debug").mockImplementation(() => {})
+    nonMatchingLog.debug("does not match")
+    expect(spyNoMatch).not.toHaveBeenCalled()
+  })
+
+  it("updates minLevel at runtime to allow debug", async () => {
+    const { configureDebug, createLogger } = await freshLogger()
+    configureDebug({ namespaces: "*", minLevel: "debug" })
+
+    const log = createLogger("Any")
+    const spy = vi.spyOn(console, "debug").mockImplementation(() => {})
+    log.debug("now visible")
+    expect(spy).toHaveBeenCalled()
+  })
+
+  it("updates minLevel at runtime to suppress info", async () => {
+    const { configureDebug, createLogger } = await freshLogger()
+    configureDebug({ namespaces: "*", minLevel: "warn" })
+
+    const log = createLogger("Any")
+    const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {})
+    log.info("suppressed by minLevel")
+    expect(infoSpy).not.toHaveBeenCalled()
+
+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+    log.warn("warn is above minLevel")
+    expect(warnSpy).toHaveBeenCalled()
+  })
+})
+
+describe("default config", () => {
+  it("uses default config when localStorage is empty", async () => {
+    const { createLogger } = await freshLogger()
+    const log = createLogger("Any")
+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+    log.warn("warn visible by default")
+    expect(warnSpy).toHaveBeenCalled()
+
+    const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {})
+    log.debug("debug not visible by default")
+    expect(debugSpy).not.toHaveBeenCalled()
+  })
+
+  it("handles localStorage unavailable gracefully", async () => {
+    const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+      throw new Error("storage unavailable")
+    })
+    const { createLogger } = await freshLogger()
+    const log = createLogger("Any")
+    const spy = vi.spyOn(console, "info").mockImplementation(() => {})
+    log.info("still works")
+    expect(spy).toHaveBeenCalled()
+    getItem.mockRestore()
+  })
+})

+ 137 - 0
packages/editor/src/lib/__tests__/markdown-parser.test.ts

@@ -825,6 +825,76 @@ describe("horizontal-rule", () => {
   })
 })
 
+describe("error recovery", () => {
+  it("handles empty callout > [!NOTE]", () => {
+    const result = parseMarkdown("> [!NOTE]")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("callout")
+    expect(result.blocks[0].metadata?.calloutType).toBe("NOTE")
+  })
+
+  it("handles callout with unrecognized type as blockquote", () => {
+    const result = parseMarkdown("> [!INVALID] text")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("blockquote")
+  })
+
+  it("handles callout with no space after type", () => {
+    const result = parseMarkdown("> [!NOTE]text")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("callout")
+  })
+
+  it("handles heading with no content", () => {
+    const result = parseMarkdown("# ")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("heading")
+    expect(result.blocks[0].metadata?.level).toBe(1)
+  })
+
+  it("handles task with no content after keyword", () => {
+    const result = parseMarkdown("TODO ")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("task")
+    expect(result.blocks[0].metadata?.taskState).toBe("TODO")
+  })
+
+  it("handles extra closing bold delimiters", () => {
+    const result = parseMarkdown("**bold**text**")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("strong")
+  })
+
+  it("handles unclosed link URL", () => {
+    const result = parseMarkdown("[link](url")
+    expect(Array.isArray(result.content)).toBe(true)
+  })
+
+  it("handles triple nested brackets [[[Page]]]", () => {
+    const result = parseMarkdown("[[[Page]]]")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("page-ref")
+  })
+
+  it("handles trailing text after blockquote", () => {
+    const result = parseMarkdown("> Quote")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("blockquote")
+  })
+
+  it("handles empty heading marker only", () => {
+    const result = parseMarkdown("#")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("heading")
+  })
+
+  it("handles multiple blockquotes sequentially in same block", () => {
+    const result = parseMarkdown("> Line 1")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("blockquote")
+  })
+})
+
 describe("table", () => {
   it("parses a simple table as block decoration", () => {
     const result = parseMarkdown("| A | B |\n| --- | --- |\n| C | D |")
@@ -891,4 +961,71 @@ describe("table", () => {
     expect(result.blocks[0].type).toBe("heading")
     expect(result.blocks[1].type).toBe("table")
   })
+
+  it("parses table with inline markdown in header cells", () => {
+    const result = parseMarkdown("| `code` | **bold** |\n| --- | --- |\n| a | b |")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("table")
+  })
+
+  it("parses table with pipes inside cell content (escaped)", () => {
+    const result = parseMarkdown("| A \\| B | C |\n| --- | --- |\n| D | E |")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("table")
+  })
+
+  it("handles table with uneven columns as non-table (column count mismatch)", () => {
+    const result = parseMarkdown("| A | B | C |\n| --- | --- |\n| D | E | F |")
+    expect(result.blocks).toHaveLength(0)
+  })
+
+  it("handles table with missing separator row", () => {
+    const result = parseMarkdown("| A | B |\n| C | D |")
+    expect(result.blocks).toHaveLength(0)
+  })
+
+  it("handles table with empty cells", () => {
+    const result = parseMarkdown("| A || B |\n| --- | --- | --- |\n| C | | D |")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("table")
+  })
+
+  it("handles table with inline markdown in cells", () => {
+    const result = parseMarkdown("| **bold** | *italic* |\n| --- | --- |\n| `code` | text |")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("table")
+  })
+})
+
+describe("tokenization depth exhaustion", () => {
+  it("handles deeply nested bold delimiters without crashing", () => {
+    const result = parseMarkdown("******deep******")
+    expect(Array.isArray(result.content)).toBe(true)
+  })
+
+  it("handles 10+ layers of emphasis nesting without crashing", () => {
+    const input = "*".repeat(20) + "text" + "*".repeat(20)
+    const result = parseMarkdown(input)
+    expect(Array.isArray(result.content)).toBe(true)
+  })
+
+  it("handles extreme nested brackets for page refs", () => {
+    const input = "[[" + "[[" + "Page" + "]]" + "]]"
+    const result = parseMarkdown(input)
+    expect(Array.isArray(result.content)).toBe(true)
+  })
+
+  it("handles very long unbroken inline syntax", () => {
+    const input = "**" + "x".repeat(500) + "**"
+    const result = parseMarkdown(input)
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("strong")
+  })
+
+  it("handles many inline tokens in a single paragraph", () => {
+    const tokens = Array.from({ length: 50 }, (_, i) => `**bold${i}**`)
+    const input = tokens.join(" ")
+    const result = parseMarkdown(input)
+    expect(result.content.length).toBeGreaterThan(0)
+  })
 })

+ 5 - 5
packages/editor/src/lib/__tests__/schema.test.ts

@@ -16,8 +16,8 @@ describe("schema", () => {
       expect(schema.nodes.text).toBeDefined()
     })
 
-    it("does not have hard_break node (removed in re-architecture)", () => {
-      expect(schema.nodes.hard_break).toBeUndefined()
+    it("has hard_break node (re-added for newline fidelity)", () => {
+      expect(schema.nodes.hard_break).toBeDefined()
     })
   })
 
@@ -74,13 +74,13 @@ describe("schema", () => {
   })
 
   describe("roundtrip with content-model", () => {
-    it("preserves multi-paragraph structure", () => {
+    it("preserves single newline as hard_break", () => {
       const original = "line1\nline2"
       const doc = contentToDoc(original)
       const result = docToContent(doc)
 
-      expect(result).toBe("line1\n\nline2")
-      expect(doc.childCount).toBe(2)
+      expect(result).toBe("line1\nline2")
+      expect(doc.childCount).toBe(1)
     })
   })
 })

+ 45 - 8
packages/editor/src/lib/content-model.ts

@@ -2,7 +2,8 @@
  * Content model conversion functions.
  *
  * Converts between raw markdown string and ProseMirror document nodes.
- * Supports paragraphs (one per line) and fenced code blocks.
+ * Single \n within block content becomes hard_break; \n\n separates paragraphs.
+ * Fenced code blocks and math blocks are detected first.
  */
 
 import type { Node as ProsemirrorNode } from "prosemirror-model"
@@ -11,10 +12,27 @@ import { schema } from "@/lib/schema"
 
 const log = createLogger("ContentModel")
 
+/**
+ * Serialize a node's inline content to a string.
+ * hard_break nodes emit "\n"; text nodes emit their text.
+ */
+function inlineToString(node: ProsemirrorNode): string {
+  let out = ""
+  node.forEach((child: ProsemirrorNode) => {
+    if (child.isText) {
+      out += child.text ?? ""
+    } else if (child.type.name === "hard_break") {
+      out += "\n"
+    }
+  })
+  return out
+}
+
 /**
  * Convert markdown string to ProseMirror document.
  * Detects fenced code blocks (``` / ~~~) and creates code_block nodes.
- * Everything else becomes one paragraph per line.
+ * Blank lines (\n\n) separate paragraphs; single \n within a group of
+ * consecutive non-empty lines becomes a hard_break.
  */
 export function contentToDoc(content: string): ProsemirrorNode {
   const nodes: ProsemirrorNode[] = []
@@ -82,15 +100,29 @@ export function contentToDoc(content: string): ProsemirrorNode {
         ),
       )
     } else {
-      const line = lines[i]!
-      if (line.length === 0) {
-        // Blank lines separate paragraphs — skip, don't create empty <p>
+      // Non-empty line: collect consecutive non-empty lines into one paragraph
+      // with hard_break nodes between them. Blank lines separate paragraphs.
+      if (lines[i]!.length === 0) {
         i++
         continue
       }
+      const paraLines: string[] = []
+      while (i < lines.length && lines[i]!.length > 0) {
+        // Guard: if we hit a potential fence, stop grouping
+        const l = lines[i]!
+        if (l.match(/^(```|~~~)/) || l.trim() === "$$") break
+        paraLines.push(l)
+        i++
+      }
       paraCount++
-      nodes.push(schema.nodes.paragraph.create(null, [schema.text(line)]))
-      i++
+      const inline: ProsemirrorNode[] = []
+      for (let j = 0; j < paraLines.length; j++) {
+        if (j > 0) {
+          inline.push(schema.nodes.hard_break.create())
+        }
+        inline.push(schema.text(paraLines[j]!))
+      }
+      nodes.push(schema.nodes.paragraph.create(null, inline))
     }
   }
 
@@ -111,6 +143,7 @@ export function contentToDoc(content: string): ProsemirrorNode {
 /**
  * Convert ProseMirror document to markdown string.
  * code_block nodes are serialized as fenced code blocks.
+ * Paragraphs use inlineToString which emits "\n" for hard_break nodes.
  */
 export function docToContent(doc: ProsemirrorNode): string {
   const lines: string[] = []
@@ -119,8 +152,10 @@ export function docToContent(doc: ProsemirrorNode): string {
   doc.forEach((node: ProsemirrorNode) => {
     if (node.type.name === "code_block") {
       codeBlockCount++
+      lines.push(node.textContent)
+    } else {
+      lines.push(inlineToString(node))
     }
-    lines.push(node.textContent)
   })
 
   const result = lines.join("\n\n")
@@ -131,3 +166,5 @@ export function docToContent(doc: ProsemirrorNode): string {
   })
   return result
 }
+
+export { inlineToString }

+ 11 - 12
packages/editor/src/lib/markdown-rules/block-rules.ts

@@ -115,19 +115,18 @@ function createCalloutDecoration(
     const leadingOffset = line.length - trimmed.length
 
     if (i === 0) {
-      // First line: hide "> [!NOTE]" marker
+      // First line: hide "[!NOTE]" marker only (keep ">" visible so it
+      // matches the ">" on continuation lines after a hard_break).
+      const bracketStart = trimmed.indexOf("[!")
       const bracketEnd = trimmed.indexOf("]")
-      const markerStart = offset + leadingOffset
-      const markerEnd =
-        bracketEnd >= 0
-          ? markerStart + (bracketEnd + 1)
-          : markerStart + trimmed.length
-      decorationRanges.push({
-        type: "hidden",
-        from: markerStart,
-        to: markerEnd,
-        className: "md-marker",
-      })
+      if (bracketStart >= 0 && bracketEnd > bracketStart) {
+        decorationRanges.push({
+          type: "hidden",
+          from: offset + leadingOffset + bracketStart,
+          to: offset + leadingOffset + bracketEnd + 1,
+          className: "md-marker",
+        })
+      }
     } else {
       // Continuation lines: hide just the ">" marker
       const gtPos = trimmed.indexOf(">")

+ 10 - 2
packages/editor/src/lib/schema.ts

@@ -3,11 +3,11 @@
  *
  * Only contains structural nodes - no semantic marks (bold, italic, etc).
  * Markdown syntax is preserved as plain text and rendered via decorations.
- * Each Block.vue instance is one or more <p> paragraphs; no hard_breaks.
+ * hard_break preserves the \n vs \n\n distinction within block content.
  */
 import { type NodeSpec, Schema } from "prosemirror-model"
 
-type DOMNode = "doc" | "paragraph" | "code_block" | "math_block" | "text"
+type DOMNode = "doc" | "paragraph" | "hard_break" | "code_block" | "math_block" | "text"
 
 /** Node definitions */
 const nodes: Record<DOMNode, NodeSpec> = {
@@ -22,6 +22,14 @@ const nodes: Record<DOMNode, NodeSpec> = {
     parseDOM: [{ tag: "p" }],
     toDOM: () => ["p", 0],
   },
+  /** Hard line break — preserves single \n within a paragraph */
+  hard_break: {
+    inline: true,
+    group: "inline",
+    selectable: false,
+    parseDOM: [{ tag: "br" }],
+    toDOM: () => ["br"],
+  },
   /** Fenced code block — rendered via CodeMirror 6 node view */
   code_block: {
     group: "block",