浏览代码

feat(desktop): Hearthside theme, empty day state, ephemeral drag handle

- Hearthside visual identity: Lora serif, clay primary, stone neutral,
  orange secondary, warm semantics (rose/emerald/amber/sky), ghost button
  default, 0.375rem radius
- Update favicon and app icons from green to clay (#00a155 → #c25f34)
- Drag handle is invisible at rest, fades in on block hover
- Empty day state shows "A quiet page, waiting for words." invitation;
  no file is created on mount — only when user types
- Replace crypto.randomUUID() with generateBlockId() (nanoid helper)
- Switch icon set from Phosphor to Lucide (grip-vertical, size-4)
- Drop zone uses 10% primary opacity instead of full
- Blur isolation uses data-editor-overlay instead of data-slot="content"
- Type-safe emit forwarding in JournalDay.vue
Zander Hawke 15 小时之前
父节点
当前提交
ba3addc98b
共有 84 个文件被更改,包括 1405 次插入756 次删除
  1. 7 1
      apps/dev/src/pages/editor-block.vue
  2. 6 5
      apps/dev/src/pages/editor.vue
  3. 2 1
      apps/dev/src/pages/suggestion-menu.vue
  4. 3 2
      apps/dev/src/pages/toolbar.vue
  5. 1 1
      apps/dev/vite.config.ts
  6. 1 4
      apps/tauri/nuxt.config.ts
  7. 0 1
      apps/tauri/package.json
  8. 7 7
      apps/tauri/public/favicon.svg
  9. 0 6
      apps/tauri/public/tauri.svg
  10. 0 1
      apps/tauri/public/vite.svg
  11. 二进制
      apps/tauri/src-tauri/icons/128x128.png
  12. 二进制
      apps/tauri/src-tauri/icons/[email protected]
  13. 二进制
      apps/tauri/src-tauri/icons/32x32.png
  14. 二进制
      apps/tauri/src-tauri/icons/icon.png
  15. 18 0
      apps/tauri/src/app.config.ts
  16. 23 1
      apps/tauri/src/assets/main.css
  17. 0 45
      apps/tauri/src/components/AppEditor.vue
  18. 0 62
      apps/tauri/src/components/AppLeftSidebar.vue
  19. 18 42
      apps/tauri/src/components/AppRightSidebar.vue
  20. 129 0
      apps/tauri/src/components/JournalDay.vue
  21. 86 0
      apps/tauri/src/components/JournalView.vue
  22. 68 0
      apps/tauri/src/components/TimelineRail.vue
  23. 23 0
      apps/tauri/src/composables/useDates.ts
  24. 52 7
      apps/tauri/src/layouts/default.vue
  25. 19 7
      apps/tauri/src/lib/indexer.ts
  26. 18 6
      apps/tauri/src/lib/parse.test.ts
  27. 23 12
      apps/tauri/src/lib/parse.ts
  28. 32 18
      apps/tauri/src/lib/schema.test.ts
  29. 2 51
      apps/tauri/src/pages/index.vue
  30. 23 0
      apps/tauri/src/stores/ui.ts
  31. 27 10
      apps/tauri/src/stores/workspace.ts
  32. 38 16
      packages/editor/e2e/block-operations.spec.ts
  33. 35 15
      packages/editor/e2e/code-block.spec.ts
  34. 24 10
      packages/editor/e2e/cursor-boundary.spec.ts
  35. 23 13
      packages/editor/e2e/drag-drop.spec.ts
  36. 17 7
      packages/editor/e2e/focus.spec.ts
  37. 9 4
      packages/editor/e2e/helpers.ts
  38. 27 11
      packages/editor/e2e/history.spec.ts
  39. 4 4
      packages/editor/e2e/ime.spec.ts
  40. 10 4
      packages/editor/e2e/live-preview.spec.ts
  41. 38 15
      packages/editor/e2e/paste.spec.ts
  42. 7 3
      packages/editor/e2e/smoke.spec.ts
  43. 6 2
      packages/editor/e2e/suggestion-menu.spec.ts
  44. 40 16
      packages/editor/e2e/toolbar.spec.ts
  45. 20 10
      packages/editor/e2e/visual/regression.spec.ts
  46. 二进制
      packages/editor/e2e/visual/regression.spec.ts-snapshots/inline-formatting-blurred-chromium-darwin.png
  47. 二进制
      packages/editor/e2e/visual/regression.spec.ts-snapshots/table-focused-chromium-darwin.png
  48. 1 2
      packages/editor/playwright.config.ts
  49. 1 1
      packages/editor/src/__test-utils__/index.ts
  50. 41 18
      packages/editor/src/assets/style.css
  51. 82 22
      packages/editor/src/components/Editor.vue
  52. 2 1
      packages/editor/src/components/EditorBlock.vue
  53. 51 118
      packages/editor/src/components/EditorInsertionZone.vue
  54. 1 1
      packages/editor/src/components/EditorSuggestionMenu.vue
  55. 8 5
      packages/editor/src/components/EditorToolbar.vue
  56. 42 7
      packages/editor/src/components/EditorToolbarContent.vue
  57. 16 6
      packages/editor/src/components/__tests__/define-model-repro.test.ts
  58. 83 29
      packages/editor/src/components/__tests__/editor-integration.test.ts
  59. 12 7
      packages/editor/src/components/__tests__/editor-toolbar.test.ts
  60. 10 4
      packages/editor/src/composables/__tests__/node-views.test.ts
  61. 2 2
      packages/editor/src/composables/__tests__/pattern-plugin.test.ts
  62. 1 1
      packages/editor/src/composables/useKeyboard.ts
  63. 3 5
      packages/editor/src/composables/useMarkdownDecorations.ts
  64. 2 2
      packages/editor/src/composables/usePasteHandler.ts
  65. 5 1
      packages/editor/src/composables/usePatternPlugin.ts
  66. 37 28
      packages/editor/src/index.ts
  67. 8 2
      packages/editor/src/lib/__tests__/content-model.test.ts
  68. 4 5
      packages/editor/src/lib/__tests__/debug-tag.test.ts
  69. 47 14
      packages/editor/src/lib/__tests__/editor-operations.test.ts
  70. 3 1
      packages/editor/src/lib/__tests__/formatting.test.ts
  71. 2 3
      packages/editor/src/lib/__tests__/lezer-debug.test.ts
  72. 1 2
      packages/editor/src/lib/__tests__/lezer-structure.test.ts
  73. 5 3
      packages/editor/src/lib/__tests__/logger.test.ts
  74. 8 4
      packages/editor/src/lib/__tests__/markdown-parser.test.ts
  75. 2 2
      packages/editor/src/lib/__tests__/operation-history.test.ts
  76. 1 1
      packages/editor/src/lib/content-model.ts
  77. 2 2
      packages/editor/src/lib/katex.ts
  78. 2 5
      packages/editor/src/lib/markdown-extensions.ts
  79. 4 1
      packages/editor/src/lib/node-view-registry.ts
  80. 21 21
      packages/editor/src/lib/operation-history.ts
  81. 7 1
      packages/editor/src/lib/schema.ts
  82. 1 3
      packages/editor/vite.config.ts
  83. 1 3
      packages/editor/vitest.config.ts
  84. 0 3
      pnpm-lock.yaml

+ 7 - 1
apps/dev/src/pages/editor-block.vue

@@ -34,7 +34,13 @@ author:: Editor Block Demo
 
 const resolvedRefs = new Set(["Resolved Page", "known-block-id"])
 
-function onPageRefClick({ pageName, alias }: { pageName: string; alias?: string }) {
+function onPageRefClick({
+  pageName,
+  alias,
+}: {
+  pageName: string
+  alias?: string
+}) {
   console.log("page-ref-clicked", pageName, alias)
 }
 

+ 6 - 5
apps/dev/src/pages/editor.vue

@@ -1,11 +1,12 @@
 <script setup lang="ts">
-import { Editor, EditorToolbar } from "@enesis/editor"
 import type { ToolbarMode } from "@enesis/editor"
+import { Editor, EditorToolbar } from "@enesis/editor"
 import { computed, ref } from "vue"
 
-const isE2E = computed(() =>
-  typeof window !== "undefined" &&
-  new URLSearchParams(window.location.search).get("e2e") === "true",
+const isE2E = computed(
+  () =>
+    typeof window !== "undefined" &&
+    new URLSearchParams(window.location.search).get("e2e") === "true",
 )
 const themeOverride = computed(() => {
   if (typeof window === "undefined") return undefined
@@ -32,7 +33,7 @@ const content = ref(`
 
   * ## Text Formatting
 
-    Try **bold**, *italic*, \`code\`, ~~strikethrough~~, ^^highlight^^, and $\LaTeX$ math.
+    Try **bold**, *italic*, \`code\`, ~~strikethrough~~, ^^highlight^^, and $LaTeX$ math.
 
   * ## References
 

+ 2 - 1
apps/dev/src/pages/suggestion-menu.vue

@@ -6,7 +6,8 @@ const slashContent = ref(`Start typing / to see the slash command menu.
 
 You can insert headings, formatting, blocks, and references through the menu.`)
 
-const mentionContent = ref(`Type double square brackets to search for page references.
+const mentionContent =
+  ref(`Type double square brackets to search for page references.
 
 Type double parentheses to search for block references.
 

+ 3 - 2
apps/dev/src/pages/toolbar.vue

@@ -1,9 +1,10 @@
 <script setup lang="ts">
-import { Editor, EditorToolbar } from "@enesis/editor"
 import type { ToolbarMode } from "@enesis/editor"
+import { Editor, EditorToolbar } from "@enesis/editor"
 import { ref } from "vue"
 
-const content = ref(`* **Bold**, *italic*, \`code\`, ~~strikethrough~~, ^^highlight^^, and $math$ work inline
+const content =
+  ref(`* **Bold**, *italic*, \`code\`, ~~strikethrough~~, ^^highlight^^, and $math$ work inline
 * ## Heading 2
 * TODO This task can be toggled from the toolbar
 * [[Page refs]] and #tags are supported inline`)

+ 1 - 1
apps/dev/vite.config.ts

@@ -1,6 +1,6 @@
 import { resolve } from "node:path"
-import tailwindcss from "@tailwindcss/vite"
 import ui from "@nuxt/ui/vite"
+import tailwindcss from "@tailwindcss/vite"
 import vue from "@vitejs/plugin-vue"
 import { defineConfig } from "vite"
 

+ 1 - 4
apps/tauri/nuxt.config.ts

@@ -7,10 +7,7 @@ export default defineNuxtConfig({
 
   modules: ["@nuxt/ui", "@pinia/nuxt"],
 
-  css: [
-    "~/assets/main.css",
-    `${EDITOR_ROOT}/src/assets/style.css`,
-  ],
+  css: ["~/assets/main.css", `${EDITOR_ROOT}/src/assets/style.css`],
 
   colorMode: {
     classSuffix: "",

+ 0 - 1
apps/tauri/package.json

@@ -13,7 +13,6 @@
   },
   "dependencies": {
     "@enesis/editor": "workspace:*",
-    "@fontsource-variable/geist": "^5.2.9",
     "@nuxt/ui": "^4.7.1",
     "@nuxtjs/color-mode": "^4.0.1",
     "@pinia/nuxt": "^0.11.3",

+ 7 - 7
apps/tauri/public/favicon.svg

@@ -1,16 +1,16 @@
 <svg width="160" height="140" viewBox="0 0 160 140" fill="none" xmlns="http://www.w3.org/2000/svg">
   <!-- Top block -->
-  <polygon points="80,10 140,30 80,50 20,30" fill="#00a155" opacity="0.85" />
+  <polygon points="80,10 140,30 80,50 20,30"   fill="#c25f34" opacity="0.85" />
   <!-- Second block left -->
-  <polygon points="20,30 80,50 80,80 20,60" fill="#00a155" opacity="0.65" />
+  <polygon points="20,30 80,50 80,80 20,60"   fill="#c25f34" opacity="0.65" />
   <!-- Second block right -->
-  <polygon points="80,50 140,30 140,60 80,80" fill="#00a155" opacity="0.55" />
+  <polygon points="80,50 140,30 140,60 80,80"   fill="#c25f34" opacity="0.55" />
   <!-- Third block left -->
-  <polygon points="20,60 80,80 80,110 20,90" fill="#00a155" opacity="0.45" />
+  <polygon points="20,60 80,80 80,110 20,90"   fill="#c25f34" opacity="0.45" />
   <!-- Third block right -->
-  <polygon points="80,80 140,60 140,90 80,110" fill="#00a155" opacity="0.35" />
+  <polygon points="80,80 140,60 140,90 80,110"   fill="#c25f34" opacity="0.35" />
   <!-- Fourth block left -->
-  <polygon points="20,90 80,110 80,140 20,120" fill="#00a155" opacity="0.25" />
+  <polygon points="20,90 80,110 80,140 20,120"   fill="#c25f34" opacity="0.25" />
   <!-- Fourth block right -->
-  <polygon points="80,110 140,90 140,120 80,140" fill="#00a155" opacity="0.15" />
+  <polygon points="80,110 140,90 140,120 80,140"   fill="#c25f34" opacity="0.15" />
 </svg>

+ 0 - 6
apps/tauri/public/tauri.svg

@@ -1,6 +0,0 @@
-<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
-<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
-</svg>

+ 0 - 1
apps/tauri/public/vite.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

二进制
apps/tauri/src-tauri/icons/128x128.png


二进制
apps/tauri/src-tauri/icons/[email protected]


二进制
apps/tauri/src-tauri/icons/32x32.png


二进制
apps/tauri/src-tauri/icons/icon.png


+ 18 - 0
apps/tauri/src/app.config.ts

@@ -0,0 +1,18 @@
+export default defineAppConfig({
+  ui: {
+    colors: {
+      primary: "clay",
+      secondary: "orange",
+      neutral: "stone",
+      success: "emerald",
+      warning: "amber",
+      error: "rose",
+      info: "sky",
+    },
+    button: {
+      defaultVariants: {
+        variant: "ghost",
+      },
+    },
+  },
+})

+ 23 - 1
apps/tauri/src/assets/main.css

@@ -3,5 +3,27 @@
 @source "../../../../packages/editor/src";
 
 @theme {
-  --font-sans: 'Geist', sans-serif;
+  --font-sans: 'Lora', serif;
+}
+
+@theme static {
+  --color-clay-50: #fdf6f1;
+  --color-clay-100: #fae8d8;
+  --color-clay-200: #f5cfb0;
+  --color-clay-300: #ecae7c;
+  --color-clay-400: #e08347;
+  --color-clay-500: #c9621f;
+  --color-clay-600: #a84e18;
+  --color-clay-700: #873c14;
+  --color-clay-800: #6d3112;
+  --color-clay-900: #592a12;
+  --color-clay-950: #301309;
+}
+
+:root {
+  --ui-radius: 0.375rem;
+}
+
+html {
+  font-family: var(--font-sans);
 }

+ 0 - 45
apps/tauri/src/components/AppEditor.vue

@@ -1,45 +0,0 @@
-<script setup lang="ts">
-import { Editor, EditorToolbar } from "@enesis/editor"
-
-const content = defineModel<string>("content", { required: true })
-const resolvedRefs = new Set(["Known Page", "Documentation"])
-
-function onPageRefClick({ pageName }: { pageName: string }) {
-  console.log("page-ref-clicked", pageName)
-}
-
-function onBlockRefClick({ blockId }: { blockId: string }) {
-  console.log("block-ref-clicked", blockId)
-}
-
-function onTagClick({ tag }: { tag: string }) {
-  console.log("tag-clicked", tag)
-}
-</script>
-
-<template>
-  <div class="flex flex-col flex-1 min-h-0 p-4 sm:p-6 dark:bg-neutral-950/50">
-    <Editor
-      v-model:content="content"
-      marker-mode="live-preview"
-      :focused="true"
-      :resolved-refs="resolvedRefs"
-      :resolved-refs-version="resolvedRefs.size"
-      @page-ref-clicked="onPageRefClick"
-      @block-ref-clicked="onBlockRefClick"
-      @tag-clicked="onTagClick"
-    >
-      <template #toolbar="{ handlers, selectionVersion, canUndo, canRedo, undo, redo }">
-        <EditorToolbar
-          mode="bubble"
-          :handlers="handlers"
-          :selection-version="selectionVersion"
-          :can-undo="canUndo"
-          :can-redo="canRedo"
-          :undo="undo"
-          :redo="redo"
-        />
-      </template>
-    </Editor>
-  </div>
-</template>

+ 0 - 62
apps/tauri/src/components/AppLeftSidebar.vue

@@ -1,62 +0,0 @@
-<script setup lang="ts">
-import { ref } from "vue"
-
-const open = defineModel<boolean>("open", { required: true })
-
-const pages = ref([
-  { id: "1", label: "Getting Started", icon: "i-lucide-book-open" },
-  { id: "2", label: "Architecture", icon: "i-lucide-layers" },
-  { id: "3", label: "API Reference", icon: "i-lucide-code" },
-  { id: "4", label: "Changelog", icon: "i-lucide-history" },
-])
-</script>
-
-<template>
-  <USidebar
-    v-model:open="open"
-    collapsible="icon"
-    :ui="{ header: 'border-b border-default' }"
-  >
-    <template #header="{ state }">
-      <div class="flex items-center gap-2 px-2 py-1 min-w-0">
-        <UButton
-          icon="i-lucide-panels-top-left"
-          color="neutral"
-          variant="ghost"
-          size="sm"
-          @click="open = !open"
-        />
-        <span v-if="state !== 'collapsed'" class="font-semibold text-sm truncate">
-          My Workspace
-        </span>
-      </div>
-    </template>
-
-    <!-- Navigation items -->
-    <div class="flex flex-col gap-0.5 p-2">
-      <UButton
-        v-for="page in pages"
-        :key="page.id"
-        :icon="page.icon"
-        :label="page.label"
-        color="neutral"
-        variant="ghost"
-        size="sm"
-        class="justify-start w-full"
-      />
-    </div>
-
-    <template #footer="{ state }">
-      <div class="p-2 border-t border-default">
-        <UButton
-          icon="i-lucide-plus"
-          :label="state !== 'collapsed' ? 'New Page' : undefined"
-          color="neutral"
-          variant="soft"
-          size="sm"
-          class="w-full justify-start"
-        />
-      </div>
-    </template>
-  </USidebar>
-</template>

+ 18 - 42
apps/tauri/src/components/AppRightSidebar.vue

@@ -4,28 +4,19 @@ import { ref } from "vue"
 const open = defineModel<boolean>("open", { required: true })
 
 const tabs = ref([
-  { label: "Outline", icon: "i-lucide-list", slot: "outline" },
-  { label: "Properties", icon: "i-lucide-sliders-horizontal", slot: "properties" },
-])
-const activeTab = ref("outline")
-
-// Driven by the active editor block's headings
-const headings = ref([
-  { label: "Introduction", level: 1 },
-  { label: "Architecture", level: 2 },
-  { label: "Block model", level: 3 },
-  { label: "Parsing pipeline", level: 3 },
-  { label: "API", level: 2 },
+  { label: "References", icon: "i-lucide-link", value: "refs" },
+  { label: "Search", icon: "i-lucide-search", value: "search" },
 ])
+const activeTab = ref("refs")
 </script>
 
 <template>
   <USidebar
     v-model:open="open"
-    side="right"
     collapsible="offcanvas"
-    :ui="{ header: 'border-b border-default' }"
-    style="--sidebar-width: 14rem"
+    side="right"
+    :ui="{ width: 'w-80' }"
+    rail
   >
     <template #header>
       <UTabs
@@ -38,35 +29,20 @@ const headings = ref([
       />
     </template>
 
-    <!-- Outline tab -->
-    <div v-if="activeTab === 'outline'" class="p-2 flex flex-col gap-0.5">
-      <button
-        v-for="(heading, i) in headings"
-        :key="i"
-        class="text-left text-sm text-muted hover:text-highlighted hover:bg-elevated px-2 py-1 rounded-[--ui-radius] transition-colors truncate w-full"
-        :style="{ paddingLeft: `${(heading.level - 1) * 12 + 8}px` }"
-      >
-        {{ heading.label }}
-      </button>
+    <div v-if="activeTab === 'refs'" class="p-3 text-sm text-muted">
+      <p>References will appear here once you start linking pages.</p>
     </div>
 
-    <!-- Properties tab -->
-    <div v-if="activeTab === 'properties'" class="p-3 flex flex-col gap-3">
-      <div class="flex flex-col gap-1">
-        <span class="text-xs font-medium text-muted uppercase tracking-wide">Created</span>
-        <span class="text-sm text-highlighted">Jan 15, 2025</span>
-      </div>
-      <div class="flex flex-col gap-1">
-        <span class="text-xs font-medium text-muted uppercase tracking-wide">Tags</span>
-        <div class="flex flex-wrap gap-1">
-          <UBadge label="editor" color="neutral" variant="soft" size="sm" />
-          <UBadge label="vue" color="primary" variant="soft" size="sm" />
-        </div>
-      </div>
-      <div class="flex flex-col gap-1">
-        <span class="text-xs font-medium text-muted uppercase tracking-wide">Word count</span>
-        <span class="text-sm text-highlighted">1,248 words</span>
-      </div>
+    <div v-if="activeTab === 'search'" class="p-3">
+      <UInput
+        placeholder="Search pages..."
+        size="sm"
+        color="neutral"
+        variant="outline"
+      />
+      <p class="mt-3 text-sm text-muted">
+        Search results appear here.
+      </p>
     </div>
   </USidebar>
 </template>

+ 129 - 0
apps/tauri/src/components/JournalDay.vue

@@ -0,0 +1,129 @@
+<script setup lang="ts">
+import { Editor, EditorToolbar } from "@enesis/editor"
+import { computed, onMounted, ref, watch } from "vue"
+import { useFileSystem } from "~/composables/useFileSystem"
+import { markInFlight, useWorkspaceStore } from "~/stores/workspace"
+
+const props = defineProps<{
+  date: string
+  isToday: boolean
+}>()
+
+const emit = defineEmits<{
+  "page-ref-clicked": [payload: { pageName: string; alias?: string }]
+  "block-ref-clicked": [payload: { blockId: string }]
+  "tag-clicked": [payload: { tag: string }]
+}>()
+
+const content = ref("")
+const loading = ref(false)
+const opened = ref(false)
+
+const fs = useFileSystem()
+const ws = useWorkspaceStore()
+
+const dayLabel = computed(() => {
+  const d = new Date(`${props.date}T12:00:00`)
+  const weekday = d.toLocaleDateString("en-US", { weekday: "long" })
+  const month = d.toLocaleDateString("en-US", { month: "long" })
+  const day = d.getDate()
+  return props.isToday ? "Today" : `${weekday}, ${month} ${day}`
+})
+
+const fullPath = computed(() => {
+  if (!ws.workspacePath) return ""
+  return `${ws.workspacePath}/journals/${props.date}.md`
+})
+
+let saveTimer: ReturnType<typeof setTimeout> | null = null
+
+watch(content, (val) => {
+  if (!fullPath.value) return
+
+  if (saveTimer) clearTimeout(saveTimer)
+  saveTimer = setTimeout(async () => {
+    try {
+      markInFlight(fullPath.value)
+      await fs.writeFile(fullPath.value, val)
+    } catch (e) {
+      console.warn("[JournalDay] save failed:", e)
+    }
+  }, 500)
+})
+
+onMounted(async () => {
+  if (!fullPath.value) return
+  loading.value = true
+  try {
+    try {
+      content.value = await fs.readFile(fullPath.value)
+    } catch {
+      // File doesn't exist — start empty; the insertion zone or
+      // empty state will invite the user to write.
+      content.value = ""
+    }
+  } finally {
+    loading.value = false
+  }
+})
+</script>
+
+<template>
+  <div class="border-b border-default last:border-b-0 min-h-[80vh]">
+    <!-- Day header -->
+    <div class="flex items-baseline gap-3 pt-12 pb-4 px-6 max-w-4xl mx-auto w-full">
+      <h2
+        class="text-3xl font-normal tracking-normal text-highlighted"
+        :class="{ 'text-(--ui-primary)': isToday }"
+      >
+        {{ dayLabel }}
+      </h2>
+      <span class="text-sm text-muted">{{ props.date }}</span>
+    </div>
+
+    <div class="pb-12 px-6 max-w-4xl mx-auto w-full">
+      <!-- Loading -->
+      <div v-if="loading" class="py-12 text-center text-sm text-muted">
+        Loading...
+      </div>
+
+      <!-- Editor — shown when the user opened the page or content exists -->
+      <div v-else-if="opened || content" class="min-h-[3rem]">
+        <Editor
+          v-model:content="content"
+          marker-mode="live-preview"
+          :focused="opened ? 'last-zone' : 'last-block'"
+          @page-ref-clicked="({ pageName, alias }: { pageName: string; alias?: string }) => emit('page-ref-clicked', { pageName, alias })"
+          @block-ref-clicked="({ blockId }: { blockId: string }) => emit('block-ref-clicked', { blockId })"
+          @tag-clicked="({ tag }: { tag: string }) => emit('tag-clicked', { tag })"
+        >
+          <template #toolbar="{ handlers, selectionVersion, canUndo, canRedo, undo, redo }">
+            <EditorToolbar
+              mode="bubble"
+              :handlers="handlers"
+              :selection-version="selectionVersion"
+              :can-undo="canUndo"
+              :can-redo="canRedo"
+              :undo="undo"
+              :redo="redo"
+            />
+          </template>
+        </Editor>
+      </div>
+
+      <!-- Empty state — warm invitation for days without entries -->
+      <div v-else
+        class="py-16 text-center cursor-text select-none"
+        @click="opened = true"
+      >
+        <div class="w-12 h-px bg-muted mx-auto mb-8" />
+        <p class="text-xl italic text-muted/50">
+          A quiet page, waiting for words.
+        </p>
+        <p class="text-sm text-muted/30 mt-4">
+          click to begin writing
+        </p>
+      </div>
+    </div>
+  </div>
+</template>

+ 86 - 0
apps/tauri/src/components/JournalView.vue

@@ -0,0 +1,86 @@
+<script setup lang="ts">
+import { nextTick, onMounted, ref, watch } from "vue"
+import { useUiStore } from "~/stores/ui"
+import { useDates } from "~/composables/useDates"
+import JournalDay from "./JournalDay.vue"
+
+const ui = useUiStore()
+const { getToday } = useDates()
+
+const today = getToday()
+const days = ref<string[]>([today])
+const loadingMore = ref(false)
+
+function loadOlderDays() {
+  if (loadingMore.value) return
+  loadingMore.value = true
+
+  const last = days.value[days.value.length - 1]
+  if (!last) return
+
+  const d = new Date(`${last}T12:00:00`)
+  for (let i = 0; i < 7; i++) {
+    d.setDate(d.getDate() - 1)
+    const y = d.getFullYear()
+    const m = String(d.getMonth() + 1).padStart(2, "0")
+    const day = String(d.getDate()).padStart(2, "0")
+    days.value.push(`${y}-${m}-${day}`)
+  }
+
+  loadingMore.value = false
+}
+
+onMounted(() => {
+  loadOlderDays()
+})
+
+function onScroll(event: Event) {
+  const el = event.target as HTMLElement
+  if (el.scrollTop + el.clientHeight >= el.scrollHeight - 400 && !loadingMore.value) {
+    loadOlderDays()
+  }
+}
+
+/** Scroll to a journal day when the store's activeDate changes. */
+watch(() => ui.activeDate, (date) => {
+  if (!date) return
+  nextTick(() => {
+    document.getElementById(`day-${date}`)?.scrollIntoView({ behavior: "smooth" })
+  })
+})
+
+function onPageRefClick(payload: { pageName: string; alias?: string }) {
+  console.log("page-ref-clicked", payload)
+}
+
+function onBlockRefClick(payload: { blockId: string }) {
+  console.log("block-ref-clicked", payload)
+}
+
+function onTagClick(payload: { tag: string }) {
+  console.log("tag-clicked", payload)
+}
+</script>
+
+<template>
+  <div class="flex-1 overflow-y-auto bg-default" @scroll="onScroll">
+    <JournalDay
+      v-for="(date, i) in days"
+      :id="`day-${date}`"
+      :key="date"
+      :date="date"
+      :is-today="i === 0"
+      @page-ref-clicked="onPageRefClick"
+      @block-ref-clicked="onBlockRefClick"
+      @tag-clicked="onTagClick"
+    />
+
+    <div
+      v-if="loadingMore"
+      class="flex items-center justify-center gap-2 py-8 text-xs text-muted"
+    >
+      <UIcon name="i-lucide-loader" class="size-4 animate-spin" />
+      Loading earlier days...
+    </div>
+  </div>
+</template>

+ 68 - 0
apps/tauri/src/components/TimelineRail.vue

@@ -0,0 +1,68 @@
+<script setup lang="ts">
+import { computed } from "vue"
+
+function formatDate(d: Date): string {
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, "0")
+  const day = String(d.getDate()).padStart(2, "0")
+  return `${y}-${m}-${day}`
+}
+
+const props = defineProps<{
+  days: string[]
+  activeDate?: string
+}>()
+
+const emit = defineEmits<{
+  select: [date: string]
+}>()
+
+/** Stable density per day — computed once, reused across renders. */
+const densityMap = computed(() => {
+  const map = new Map<string, number>()
+  for (const date of props.days) {
+    map.set(date, Math.floor(Math.random() * 3) + 1)
+  }
+  return map
+})
+
+function density(date: string): number {
+  return densityMap.value.get(date) ?? 1
+}
+
+function label(date: string): string {
+  const d = new Date(`${date}T12:00:00`)
+  const today = formatDate(new Date())
+  const yesterday = formatDate(new Date(Date.now() - 86400000))
+  if (date === today) return "Today"
+  if (date === yesterday) return "Yesterday"
+  return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
+}
+</script>
+
+<template>
+  <div class="flex flex-col gap-0.5 py-4">
+    <button
+      v-for="date in days"
+      :key="date"
+      class="flex items-center gap-2 px-3 py-1.5 text-left rounded-md transition-colors hover:bg-elevated"
+      :class="date === activeDate ? 'bg-elevated' : ''"
+      @click="emit('select', date)"
+    >
+      <span class="flex gap-0.5">
+        <span
+          v-for="i in density(date)"
+          :key="i"
+          class="size-1 rounded-full transition-colors"
+          :class="date === activeDate ? 'bg-(--ui-primary)' : 'bg-(--ui-text-muted)/40'"
+        />
+      </span>
+      <span
+        class="text-xs font-medium transition-colors"
+        :class="date === activeDate ? 'text-(--ui-primary)' : 'text-(--ui-text-muted)'"
+      >
+        {{ label(date) }}
+      </span>
+    </button>
+  </div>
+</template>

+ 23 - 0
apps/tauri/src/composables/useDates.ts

@@ -0,0 +1,23 @@
+/** Generate an array of date strings (YYYY-MM-DD) for the last N days. */
+export function useDates() {
+  function formatDate(d: Date): string {
+    const y = d.getFullYear()
+    const m = String(d.getMonth() + 1).padStart(2, "0")
+    const day = String(d.getDate()).padStart(2, "0")
+    return `${y}-${m}-${day}`
+  }
+
+  function getToday(): string {
+    return formatDate(new Date())
+  }
+
+  function lastDays(count: number): string[] {
+    return Array.from({ length: count }, (_, i) => {
+      const d = new Date()
+      d.setDate(d.getDate() - i)
+      return formatDate(d)
+    })
+  }
+
+  return { formatDate, getToday, lastDays }
+}

+ 52 - 7
apps/tauri/src/layouts/default.vue

@@ -1,18 +1,63 @@
 <script setup lang="ts">
-import { useLocalStorage } from "@vueuse/core"
+import { useUiStore } from "~/stores/ui"
+import { useDates } from "~/composables/useDates"
+import TimelineRail from "~/components/TimelineRail.vue"
 
-const leftOpen = useLocalStorage("editor-left-sidebar", true)
-const rightOpen = useLocalStorage("editor-right-sidebar", true)
+const ui = useUiStore()
+const { lastDays, getToday } = useDates()
+
+const dateKeys = lastDays(14)
+const today = getToday()
 </script>
 
 <template>
-  <div class="flex h-screen overflow-hidden">
-    <!-- <AppLeftSidebar v-model:open="leftOpen" /> -->
+  <div class="flex h-screen overflow-hidden bg-default">
+    <USidebar
+      v-model:open="ui.leftSidebarOpen"
+      collapsible="offcanvas"
+      side="left"
+      title="Timeline"
+      close
+      rail
+    >
+      <div class="flex flex-col gap-0.5 p-2">
+        <UButton
+          icon="i-lucide-calendar"
+          label="Today"
+          color="neutral"
+          variant="ghost"
+          size="sm"
+          class="justify-start w-full"
+          @click="ui.setActiveDate(today)"
+        />
+      </div>
+
+      <TimelineRail
+        :days="dateKeys"
+        :active-date="ui.activeDate"
+        @select="ui.setActiveDate"
+      />
+
+      <template #footer>
+        <div class="p-2 border-t border-default">
+          <UTooltip text="Settings (coming soon)">
+            <UButton
+              icon="i-lucide-settings"
+              color="neutral"
+              variant="ghost"
+              size="sm"
+              class="w-full justify-start"
+              disabled
+            />
+          </UTooltip>
+        </div>
+      </template>
+    </USidebar>
 
-    <UMain class="flex flex-col flex-1 min-w-0 overflow-y-auto">
+    <UMain class="flex flex-col flex-1 min-w-0">
       <slot />
     </UMain>
 
-    <!-- <AppRightSidebar v-model:open="rightOpen" /> -->
+    <AppRightSidebar v-model:open="ui.rightSidebarOpen" />
   </div>
 </template>

+ 19 - 7
apps/tauri/src/lib/indexer.ts

@@ -11,10 +11,15 @@
  * incremental re-index fast and preserving rowids for in-memory references.
  */
 
-import Database from "@tauri-apps/plugin-sql"
 import { splitMarkdownIntoBlocks } from "@enesis/editor"
+import Database from "@tauri-apps/plugin-sql"
+import {
+  contentHash,
+  detectBlockType,
+  extractLinks,
+  extractTitle,
+} from "./parse"
 import { SCHEMA_SQL } from "./schema"
-import { contentHash, detectBlockType, extractTitle, extractLinks, type ExtractedLink } from "./parse"
 
 export interface IndexerOptions {
   /** Tauri invoke wrapper for listing directory contents. */
@@ -30,9 +35,7 @@ export interface IndexerOptions {
  * Creates the database file if it doesn't exist and runs CREATE TABLE IF NOT EXISTS.
  * Tables, indices, FTS5 virtual table, and sync triggers are all idempotent.
  */
-export async function initIndex(
-  dbPath: string,
-): Promise<Database> {
+export async function initIndex(dbPath: string): Promise<Database> {
   const db = await Database.load(`sqlite:${dbPath}`)
   await db.execute(SCHEMA_SQL)
   return db
@@ -53,7 +56,7 @@ export async function indexFile(
   content: string,
 ): Promise<void> {
   const now = Math.floor(Date.now() / 1000)
-  const hash = contentHash(content)
+  const _hash = contentHash(content)
   const title = extractTitle(content)
 
   await db.execute(
@@ -92,7 +95,16 @@ export async function indexFile(
          block_type = excluded.block_type,
          content_hash = excluded.content_hash,
          modified_at = excluded.modified_at`,
-      [block.id, filePath, block.content, block.depth, blockType, 0, blockHash, now],
+      [
+        block.id,
+        filePath,
+        block.content,
+        block.depth,
+        blockType,
+        0,
+        blockHash,
+        now,
+      ],
     )
 
     // Re-index links for this block

+ 18 - 6
apps/tauri/src/lib/parse.test.ts

@@ -1,5 +1,10 @@
-import { describe, it, expect } from "vitest"
-import { contentHash, detectBlockType, extractTitle, extractLinks } from "./parse"
+import { describe, expect, it } from "vitest"
+import {
+  contentHash,
+  detectBlockType,
+  extractLinks,
+  extractTitle,
+} from "./parse"
 
 describe("contentHash", () => {
   it("returns a deterministic hash for the same content", () => {
@@ -84,7 +89,10 @@ describe("extractLinks", () => {
   it("extracts [[Page Name]] references", () => {
     const links = extractLinks("see [[Other Page]] for details")
     expect(links).toHaveLength(1)
-    expect(links[0]).toMatchObject({ target: "Other Page", linkType: "page-ref" })
+    expect(links[0]).toMatchObject({
+      target: "Other Page",
+      linkType: "page-ref",
+    })
   })
 
   it("extracts [[Page|Alias]] references", () => {
@@ -107,20 +115,24 @@ describe("extractLinks", () => {
 
   it("lowercases tag targets", () => {
     const links = extractLinks("#Project-Alpha")
-    expect(links[0]!.target).toBe("project-alpha")
+    expect(links[0]?.target).toBe("project-alpha")
   })
 
   it("extracts multiple link types from mixed content", () => {
     const content = "Page [[Foo]] and ((id-123)) and #tag"
     const links = extractLinks(content)
     expect(links).toHaveLength(3)
-    expect(links.map((l) => l.linkType).sort()).toEqual(["block-ref", "page-ref", "tag"])
+    expect(links.map((l) => l.linkType).sort()).toEqual([
+      "block-ref",
+      "page-ref",
+      "tag",
+    ])
   })
 
   it("records correct offset positions", () => {
     const content = "text [[Foo]] text"
     const links = extractLinks(content)
-    expect(links[0]!.offset).toBe(5) // [[Foo]] starts at position 5
+    expect(links[0]?.offset).toBe(5) // [[Foo]] starts at position 5
   })
 
   it("handles content with no links", () => {

+ 23 - 12
apps/tauri/src/lib/parse.ts

@@ -3,7 +3,7 @@ export function contentHash(content: string): string {
   let hash = 0
   for (let i = 0; i < content.length; i++) {
     const chr = content.charCodeAt(i)
-    hash = ((hash << 5) - hash) + chr
+    hash = (hash << 5) - hash + chr
     hash |= 0
   }
   return hash.toString(36)
@@ -16,7 +16,12 @@ export function contentHash(content: string): string {
 export function detectBlockType(content: string): string {
   const first = content.trimStart()
   if (/^#{1,6}\s/.test(first)) return "heading"
-  if (/^TODO\s|^DOING\s|^DONE\s|^LATER\s|^NOW\s|^WAITING\s|^CANCELLED\s/i.test(first)) return "task"
+  if (
+    /^TODO\s|^DOING\s|^DONE\s|^LATER\s|^NOW\s|^WAITING\s|^CANCELLED\s/i.test(
+      first,
+    )
+  )
+    return "task"
   if (/^>\s*\[!/.test(first)) return "callout"
   if (/^>\s/.test(first)) return "blockquote"
   if (/^```/.test(first)) return "code"
@@ -29,7 +34,7 @@ export function detectBlockType(content: string): string {
 /** Extract the first `# Heading 1` from markdown content. */
 export function extractTitle(content: string): string {
   const match = content.match(/^# (.+)/m)
-  return match ? match[1]!.trim() : ""
+  return match ? match[1]?.trim() : ""
 }
 
 export interface ExtractedLink {
@@ -44,18 +49,24 @@ export interface ExtractedLink {
  */
 export function extractLinks(content: string): ExtractedLink[] {
   const links: ExtractedLink[] = []
-  const pageRefRe = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g
-  let m: RegExpExecArray | null
-  while ((m = pageRefRe.exec(content)) !== null) {
-    links.push({ target: m[1]!, linkType: "page-ref", offset: m.index })
+
+  for (const m of content.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)) {
+    links.push({
+      target: m[1]!, // biome-ignore lint/style/noNonNullAssertion: matchAll guarantees group 1
+      linkType: "page-ref",
+      offset: m.index,
+    })
   }
-  const blockRefRe = /\(\(([a-zA-Z0-9_-]+)\)\)/g
-  while ((m = blockRefRe.exec(content)) !== null) {
+  for (const m of content.matchAll(/\(\(([a-zA-Z0-9_-]+)\)\)/g)) {
     links.push({ target: m[1]!, linkType: "block-ref", offset: m.index })
   }
-  const tagRe = /(?:\s|^)#([a-zA-Z0-9_-]+)/g
-  while ((m = tagRe.exec(content)) !== null) {
-    links.push({ target: m[1]!.toLowerCase(), linkType: "tag", offset: m.index + 1 })
+  for (const m of content.matchAll(/(?:\s|^)#([a-zA-Z0-9_-]+)/g)) {
+    links.push({
+      target: m[1]!,
+      linkType: "tag",
+      offset: m.index + 1,
+    })
   }
+
   return links
 }

+ 32 - 18
apps/tauri/src/lib/schema.test.ts

@@ -1,5 +1,5 @@
-import { describe, it, expect, beforeAll } from "vitest"
 import Database from "better-sqlite3"
+import { beforeAll, describe, expect, it } from "vitest"
 import { SCHEMA_SQL } from "./schema"
 
 let db: Database.Database
@@ -11,44 +11,58 @@ beforeAll(() => {
 
 describe("schema integrity", () => {
   it("creates pages table", () => {
-    const result = db.prepare(
-      "SELECT name FROM sqlite_master WHERE type='table' AND name='pages'",
-    ).get() as { name: string } | undefined
+    const result = db
+      .prepare(
+        "SELECT name FROM sqlite_master WHERE type='table' AND name='pages'",
+      )
+      .get() as { name: string } | undefined
     expect(result?.name).toBe("pages")
   })
 
   it("creates blocks table", () => {
-    const result = db.prepare(
-      "SELECT name FROM sqlite_master WHERE type='table' AND name='blocks'",
-    ).get() as { name: string } | undefined
+    const result = db
+      .prepare(
+        "SELECT name FROM sqlite_master WHERE type='table' AND name='blocks'",
+      )
+      .get() as { name: string } | undefined
     expect(result?.name).toBe("blocks")
   })
 
   it("creates links table", () => {
-    const result = db.prepare(
-      "SELECT name FROM sqlite_master WHERE type='table' AND name='links'",
-    ).get() as { name: string } | undefined
+    const result = db
+      .prepare(
+        "SELECT name FROM sqlite_master WHERE type='table' AND name='links'",
+      )
+      .get() as { name: string } | undefined
     expect(result?.name).toBe("links")
   })
 
   it("creates blocks_fts virtual table", () => {
-    const result = db.prepare(
-      "SELECT name FROM sqlite_master WHERE type='table' AND name='blocks_fts'",
-    ).get() as { name: string } | undefined
+    const result = db
+      .prepare(
+        "SELECT name FROM sqlite_master WHERE type='table' AND name='blocks_fts'",
+      )
+      .get() as { name: string } | undefined
     expect(result?.name).toBe("blocks_fts")
   })
 
   it("enforces pages.path primary key", () => {
-    db.prepare("INSERT INTO pages (path, title, modified_at) VALUES ('a.md', 'A', 1000)").run()
+    db.prepare(
+      "INSERT INTO pages (path, title, modified_at) VALUES ('a.md', 'A', 1000)",
+    ).run()
     expect(() => {
-      db.prepare("INSERT INTO pages (path, title, modified_at) VALUES ('a.md', 'A2', 2000)").run()
+      db.prepare(
+        "INSERT INTO pages (path, title, modified_at) VALUES ('a.md', 'A2', 2000)",
+      ).run()
     }).toThrow()
   })
 
   it("creates all expected indices", () => {
-    const indices = db.prepare(
-      "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'",
-    ).all() as { name: string }[]
+    const indices = db
+      .prepare(
+        "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'",
+      )
+      .all() as { name: string }[]
     const names = indices.map((i) => i.name).sort()
     expect(names).toContain("idx_blocks_page")
     expect(names).toContain("idx_links_target")

+ 2 - 51
apps/tauri/src/pages/index.vue

@@ -1,56 +1,7 @@
 <script setup lang="ts">
-import { ref } from "vue"
-const content = ref(`
-* # Welcome to Enesis Editor
-
-  Each **block** between blank lines is its own independent editor — click anywhere to start typing.
-
-  * ## Text Formatting
-
-    Try **bold**, *italic*, \`code\`, ~~strikethrough~~, ^^highlight^^, and $\LaTeX$ math.
-
-  * ## References
-
-    [[Known Page]] resolves. [[Missing Page]] shows via dashed border.
-
-    ((unknown-block-id))
-
-    Use #tags for categorization.
-
-  * ## Tasks
-
-    * TODO Write the documentation
-    * DOING Implement the parser
-    * DONE Set up the build pipeline
-
-  * ## Table
-
-    | Feature | Status |
-    | --- | --- |
-    | Blocks | Done |
-    | Tables | Done |
-
-
-  * ## Code
-
-    \`\`\`typescript
-    function greet(name: string): string {
-      return \`Hello, \${name}!\`
-    }
-    \`\`\`
-
-  * ## Callout
-
-    > [!NOTE] Useful info for the reader
-    > Plus multi-line functionality
-
-  * ## Properties
-
-    author:: Enesis Editor
-    version:: 0.1.0
-`)
+import JournalView from "~/components/JournalView.vue"
 </script>
 
 <template>
-  <AppEditor v-model:content="content" />
+  <JournalView />
 </template>

+ 23 - 0
apps/tauri/src/stores/ui.ts

@@ -0,0 +1,23 @@
+/**
+ * UI state for the desktop app — sidebar open/closed, active date.
+ * Not persisted (sidebar state resets on reload).
+ */
+import { defineStore } from "pinia"
+
+export const useUiStore = defineStore("ui", () => {
+  const leftSidebarOpen = ref(true)
+  const rightSidebarOpen = ref(false)
+  const activeDate = ref("")
+
+  /** Set the active journal date. JournalView watches this and scrolls. */
+  function setActiveDate(date: string) {
+    activeDate.value = date
+  }
+
+  return {
+    leftSidebarOpen,
+    rightSidebarOpen,
+    activeDate,
+    setActiveDate,
+  }
+})

+ 27 - 10
apps/tauri/src/stores/workspace.ts

@@ -2,14 +2,15 @@
  * Workspace Pinia store.
  * Single source of truth for workspace state across all components.
  */
-import { defineStore } from "pinia"
-import { open } from "@tauri-apps/plugin-dialog"
-import { listen } from "@tauri-apps/api/event"
+
 import { invoke } from "@tauri-apps/api/core"
+import { listen } from "@tauri-apps/api/event"
+import { open } from "@tauri-apps/plugin-dialog"
 import type Database from "@tauri-apps/plugin-sql"
+import { defineStore } from "pinia"
 import { useFileSystem } from "~/composables/useFileSystem"
 import { useSettings } from "~/composables/useSettings"
-import { initIndex, fullReindex, indexFile, removeFile } from "~/lib/indexer"
+import { fullReindex, indexFile, initIndex, removeFile } from "~/lib/indexer"
 
 // Track files the app wrote itself, to avoid re-indexing on our own writes
 const inFlightWrites = new Set<string>()
@@ -33,7 +34,8 @@ export const useWorkspaceStore = defineStore("workspace", () => {
   const loading = ref(false)
   const db = ref<Database | null>(null)
 
-  const { readFile, writeFile, listDirectory, createDirectory } = useFileSystem()
+  const { readFile, writeFile, listDirectory, createDirectory } =
+    useFileSystem()
   const settings = useSettings()
 
   const journals = computed(() => files.value.filter((f) => f.isJournal))
@@ -54,13 +56,16 @@ export const useWorkspaceStore = defineStore("workspace", () => {
     }
 
     // Listen for file changes from the watcher
-    const unlisten = await listen<{ path: string; kind: string }>(
+    const _unlisten = await listen<{ path: string; kind: string }>(
       "fs-watch:change",
       async (event) => {
         const { path: changedPath, kind } = event.payload
 
         // Ignore events from files we wrote ourselves
-        if (inFlightWrites.has(changedPath) || inFlightWrites.has(changedPath.replace("/./", "/"))) {
+        if (
+          inFlightWrites.has(changedPath) ||
+          inFlightWrites.has(changedPath.replace("/./", "/"))
+        ) {
           return
         }
 
@@ -75,7 +80,11 @@ export const useWorkspaceStore = defineStore("workspace", () => {
           }
           await scanFiles()
         } catch (e) {
-          console.warn("[workspace] watcher: failed to re-index", changedPath, e)
+          console.warn(
+            "[workspace] watcher: failed to re-index",
+            changedPath,
+            e,
+          )
         }
       },
     )
@@ -126,7 +135,12 @@ export const useWorkspaceStore = defineStore("workspace", () => {
       files.value = markdownFiles.map((p) => {
         const name = p.split("/").pop() ?? p
         const isJournal = !!name.match(/^\d{4}-\d{2}-\d{2}\.md$/)
-        return { path: p, name, isJournal, date: isJournal ? name.replace(".md", "") : undefined }
+        return {
+          path: p,
+          name,
+          isJournal,
+          date: isJournal ? name.replace(".md", "") : undefined,
+        }
       })
     } finally {
       loading.value = false
@@ -138,7 +152,10 @@ export const useWorkspaceStore = defineStore("workspace", () => {
     return await readFile(`${workspacePath.value}/${relativePath}`)
   }
 
-  async function saveFile(relativePath: string, content: string): Promise<void> {
+  async function saveFile(
+    relativePath: string,
+    content: string,
+  ): Promise<void> {
     if (!workspacePath.value) return
     await writeFile(`${workspacePath.value}/${relativePath}`, content)
   }

+ 38 - 16
packages/editor/e2e/block-operations.spec.ts

@@ -11,27 +11,37 @@ test.describe("Block operations", () => {
     await page.keyboard.type("hello world")
     await page.keyboard.press("Enter")
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    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
+  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)
+    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
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
+    const before = blockIds?.length
 
     await focusLastBlockAtEnd(page)
     await page.keyboard.press("Enter")
@@ -39,20 +49,32 @@ test.describe("Block operations", () => {
     // 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)
+    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)
+    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.evaluate(
+      (bid: string) => window.__testUtils__?.focusAtStart(bid),
+      secondId,
+    )
     await page.keyboard.type("Second block ")
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    const content = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(content).toContain("First block")
     expect(content).toContain("Second block")
   })

+ 35 - 15
packages/editor/e2e/code-block.spec.ts

@@ -11,8 +11,10 @@ test.describe("Code block", () => {
     await page.keyboard.press("Enter")
     await page.keyboard.type("```")
 
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    const lastId = blockIds![blockIds!.length - 1]
+    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]`,
     )
@@ -24,8 +26,10 @@ test.describe("Code block", () => {
     await page.keyboard.press("Enter")
     await page.keyboard.type("```")
 
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    const lastId = blockIds![blockIds!.length - 1]
+    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]`,
@@ -34,7 +38,9 @@ test.describe("Code block", () => {
     await codeBlock.locator(".cm-content").click()
     await page.keyboard.type("const x = 1;")
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    const content = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(content).toContain("const x = 1;")
   })
 
@@ -43,8 +49,10 @@ test.describe("Code block", () => {
     await page.keyboard.press("Enter")
     await page.keyboard.type("```")
 
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    const lastId = blockIds![blockIds!.length - 1]
+    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]`,
@@ -53,20 +61,26 @@ test.describe("Code block", () => {
     await codeBlock.locator(".cm-content").click()
     await page.keyboard.type("const x = 1;")
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    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 }) => {
+  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 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]`,
@@ -81,7 +95,9 @@ test.describe("Code block", () => {
     // Type should go to the paragraph above
     await page.keyboard.type(" added above")
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    const content = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(content).toContain("paragraph above added above")
   })
 
@@ -90,8 +106,10 @@ test.describe("Code block", () => {
     await page.keyboard.press("Enter")
     await page.keyboard.type("```")
 
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    const lastId = blockIds![blockIds!.length - 1]
+    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]`)
@@ -102,6 +120,8 @@ test.describe("Code block", () => {
     await page.keyboard.press("Backspace")
     await page.waitForTimeout(100)
 
-    await expect(page.locator(`${scoped} [data-codeblock-editor]`)).not.toBeVisible()
+    await expect(
+      page.locator(`${scoped} [data-codeblock-editor]`),
+    ).not.toBeVisible()
   })
 })

+ 24 - 10
packages/editor/e2e/cursor-boundary.spec.ts

@@ -6,9 +6,11 @@ test.describe("Cursor boundary behavior", () => {
   })
 
   test("typing after bold delimiter does not trap cursor", async ({ page }) => {
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
     // Second block contains "**block**" in its content
-    const id = blockIds![1]
+    const id = blockIds?.[1]
     // Position cursor after "**block**" (14 chars into the paragraph text)
     await page.evaluate(
       ({ bid, from, to }: { bid: string; from: number; to: number }) =>
@@ -18,13 +20,17 @@ test.describe("Cursor boundary behavior", () => {
 
     await page.keyboard.type("XX")
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    const content = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(content).toContain("XX")
   })
 
   test("typing before bold delimiter flows continuously", async ({ page }) => {
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    const id = blockIds![1]
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
+    const id = blockIds?.[1]
     // Position cursor right before "**block**" — "Each **" = 5 chars
     await page.evaluate(
       ({ bid, from, to }: { bid: string; from: number; to: number }) =>
@@ -34,14 +40,20 @@ test.describe("Cursor boundary behavior", () => {
 
     await page.keyboard.type("YY")
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    const content = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(content).toContain("YY")
   })
 
-  test("typing after strikethrough delimiter does not trap cursor", async ({ page }) => {
+  test("typing after strikethrough delimiter does not trap cursor", async ({
+    page,
+  }) => {
     // The initial content doesn't have ~~strikethrough~~, so use replaceContent
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    const id = blockIds![0]
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
+    const id = blockIds?.[0]
     await page.evaluate(
       ({ bid }: { bid: string }) =>
         window.__testUtils__?.replaceContent(bid, "hello ~~world~~ test"),
@@ -55,7 +67,9 @@ test.describe("Cursor boundary behavior", () => {
 
     await page.keyboard.type("ZZ")
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    const content = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(content).toContain("ZZ")
   })
 })

+ 23 - 13
packages/editor/e2e/drag-drop.spec.ts

@@ -6,8 +6,10 @@ test.describe("Drag-and-drop block reorder", () => {
   })
 
   test("drag block 0 to after block 2 reorders correctly", async ({ page }) => {
-    const beforeIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    expect(beforeIds!.length).toBeGreaterThan(2)
+    const beforeIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
+    expect(beforeIds?.length).toBeGreaterThan(2)
 
     // Use programmatic reorderBlocks instead of HTML5 DnD, since Playwright
     // cannot reliably trigger the browser's drag-and-drop engine.
@@ -18,24 +20,32 @@ test.describe("Drag-and-drop block reorder", () => {
     // Block 0 should now be at index 2 (after blocks 1 and 2)
 
     // Block 0 should now be at index 2 (after blocks 1 and 2)
-    const afterIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    expect(afterIds![0]).toBe(beforeIds![1])
-    expect(afterIds![1]).toBe(beforeIds![2])
-    expect(afterIds![2]).toBe(beforeIds![0])
+    const afterIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
+    expect(afterIds?.[0]).toBe(beforeIds?.[1])
+    expect(afterIds?.[1]).toBe(beforeIds?.[2])
+    expect(afterIds?.[2]).toBe(beforeIds?.[0])
   })
 
-  test("drag block 2 to before block 0 reorders correctly", async ({ page }) => {
-    const beforeIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    expect(beforeIds!.length).toBeGreaterThan(2)
+  test("drag block 2 to before block 0 reorders correctly", async ({
+    page,
+  }) => {
+    const beforeIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
+    expect(beforeIds?.length).toBeGreaterThan(2)
 
     // Move block at index 2 to before block 0
     await page.evaluate(() => {
       window.__testUtils__?.reorderBlocks(2, 0)
     })
 
-    const afterIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    expect(afterIds![0]).toBe(beforeIds![2])
-    expect(afterIds![1]).toBe(beforeIds![0])
-    expect(afterIds![2]).toBe(beforeIds![1])
+    const afterIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
+    expect(afterIds?.[0]).toBe(beforeIds?.[2])
+    expect(afterIds?.[1]).toBe(beforeIds?.[0])
+    expect(afterIds?.[2]).toBe(beforeIds?.[1])
   })
 })

+ 17 - 7
packages/editor/e2e/focus.spec.ts

@@ -11,25 +11,35 @@ test.describe("Focus management", () => {
     await expect(pm).toBeFocused()
   })
 
-  test("typing after focus lands text in the correct block", async ({ page }) => {
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    const firstId = blockIds![0]
-    const secondId = blockIds![1]
+  test("typing after focus lands text in the correct block", async ({
+    page,
+  }) => {
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
+    const firstId = blockIds?.[0]
+    const secondId = blockIds?.[1]
 
     // Focus and type in first block
     const firstPm = page.locator(`[role="textbox"][data-block-id="${firstId}"]`)
     await firstPm.click()
     await expect(firstPm).toBeFocused()
     await page.keyboard.type("AAA")
-    const afterFirstType = await page.evaluate(() => window.__testUtils__?.getContent())
+    const afterFirstType = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(afterFirstType).toContain("AAA")
 
     // Click and type in second block
-    const secondPm = page.locator(`[role="textbox"][data-block-id="${secondId}"]`)
+    const secondPm = page.locator(
+      `[role="textbox"][data-block-id="${secondId}"]`,
+    )
     await secondPm.click()
     await expect(secondPm).toBeFocused()
     await page.keyboard.type("BBB")
-    const afterSecondType = await page.evaluate(() => window.__testUtils__?.getContent())
+    const afterSecondType = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(afterSecondType).toContain("BBB")
   })
 

+ 9 - 4
packages/editor/e2e/helpers.ts

@@ -1,7 +1,12 @@
-import { type Page } from "@playwright/test"
+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)
+  const blockIds = await page.evaluate(() =>
+    window.__testUtils__?.getBlockIds(),
+  )
+  const lastId = blockIds?.[blockIds?.length - 1]
+  await page.evaluate(
+    (bid: string) => window.__testUtils__?.focusAtEnd(bid),
+    lastId,
+  )
 }

+ 27 - 11
packages/editor/e2e/history.spec.ts

@@ -6,10 +6,13 @@ test.describe("History coalescing", () => {
   })
 
   test("rapid typing coalesces into single undo step", async ({ page }) => {
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    const firstId = blockIds![0]
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
+    const firstId = blockIds?.[0]
     await page.evaluate(
-      ({ bid }: { bid: string }) => window.__testUtils__?.replaceContent(bid, "hello"),
+      ({ bid }: { bid: string }) =>
+        window.__testUtils__?.replaceContent(bid, "hello"),
       { bid: firstId },
     )
     await page.evaluate(
@@ -20,23 +23,32 @@ test.describe("History coalescing", () => {
     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())
+    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)
+    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)
+    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]
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
+    const firstId = blockIds?.[0]
     await page.evaluate(
-      ({ bid }: { bid: string }) => window.__testUtils__?.replaceContent(bid, "hello"),
+      ({ bid }: { bid: string }) =>
+        window.__testUtils__?.replaceContent(bid, "hello"),
       { bid: firstId },
     )
     await page.evaluate(
@@ -53,10 +65,14 @@ test.describe("History coalescing", () => {
 
     // 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)
+    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)
+    expect(await page.evaluate(() => window.__testUtils__?.getContent())).toBe(
+      before,
+    )
   })
 })

+ 4 - 4
packages/editor/e2e/ime.spec.ts

@@ -5,7 +5,9 @@ test.describe("IME composition", () => {
     await page.goto("/editor?e2e=true")
   })
 
-  test("typing / during composition does not open suggestion menu", async ({ page }) => {
+  test("typing / during composition does not open suggestion menu", async ({
+    page,
+  }) => {
     // Focus the PM editor
     const pm = page.locator('[role="textbox"][data-block-id]').first()
     await pm.click()
@@ -35,9 +37,7 @@ test.describe("IME composition", () => {
       }
 
       // End composition (commit the text)
-      el.dispatchEvent(
-        new CompositionEvent("compositionend", { data: "/" }),
-      )
+      el.dispatchEvent(new CompositionEvent("compositionend", { data: "/" }))
     })
 
     await page.waitForTimeout(200)

+ 10 - 4
packages/editor/e2e/live-preview.spec.ts

@@ -14,9 +14,11 @@ test.describe("Live preview markers", () => {
   })
 
   test("page ref chip rendered after blur", async ({ page }) => {
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
     expect(blockIds).toBeDefined()
-    const id = blockIds![2] // block with "References" content
+    const id = blockIds?.[2] // block with "References" content
 
     await page.evaluate(
       ({ bid }) => window.__testUtils__?.insertText(bid, " [[Test Page]]"),
@@ -28,10 +30,14 @@ test.describe("Live preview markers", () => {
 
     // 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()
+    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 }) => {
+  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()
 

+ 38 - 15
packages/editor/e2e/paste.spec.ts

@@ -27,36 +27,59 @@ test.describe("Paste interception", () => {
   }
 
   test("pastes single line into focused block", async ({ 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)
+    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 pasteIntoFocused(page, "pasted text")
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    const content = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(content).toContain("pasted text")
   })
 
   test("pastes multi-line uniform text into same block", async ({ 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)
+    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 pasteIntoFocused(page, "line1\nline2\nline3")
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    const content = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(content).toContain("line1")
     expect(content).toContain("line2")
     expect(content).toContain("line3")
   })
 
-  test("pastes multi-line with mixed types into new block", async ({ 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)
+  test("pastes multi-line with mixed types into new block", async ({
+    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,
+    )
 
-    const beforeCount = blockIds!.length
+    const beforeCount = blockIds?.length
     await pasteIntoFocused(page, "text\n> blockquote")
 
-    const afterIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    expect(afterIds!.length).toBe(beforeCount + 1)
+    const afterIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
+    expect(afterIds?.length).toBe(beforeCount + 1)
   })
 })

+ 7 - 3
packages/editor/e2e/smoke.spec.ts

@@ -26,13 +26,17 @@ test.describe("Editor smoke", () => {
     const firstBlock = page.locator("[data-block-id]").first()
     await firstBlock.focus()
     await page.keyboard.type("Hello Playwright")
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    const content = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(content).toContain("Hello Playwright")
   })
 
   test("__testUtils__ selectText works", async ({ page }) => {
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
     expect(blockIds).toBeDefined()
-    expect(blockIds!.length).toBeGreaterThan(0)
+    expect(blockIds?.length).toBeGreaterThan(0)
   })
 })

+ 6 - 2
packages/editor/e2e/suggestion-menu.spec.ts

@@ -15,7 +15,9 @@ test.describe("Suggestion menu", () => {
     })
   })
 
-  test("suggestion menu shows items with data-menu-item-index", async ({ page }) => {
+  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("/")
@@ -75,7 +77,9 @@ test.describe("Suggestion menu", () => {
     await expect(lastItem).toHaveAttribute("data-highlighted")
   })
 
-  test("Enter selects the highlighted item and closes menu", async ({ page }) => {
+  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("/")

+ 40 - 16
packages/editor/e2e/toolbar.spec.ts

@@ -6,8 +6,10 @@ test.describe("Toolbar", () => {
   })
 
   async function replaceFirstBlockContent(page: any, text: string) {
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
-    const firstId = blockIds![0]
+    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),
@@ -18,8 +20,10 @@ test.describe("Toolbar", () => {
   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]
+    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 },
@@ -27,15 +31,19 @@ test.describe("Toolbar", () => {
 
     await page.locator('[data-toolbar-action="bold"]').click()
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    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]
+    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 },
@@ -43,14 +51,21 @@ test.describe("Toolbar", () => {
 
     await page.locator('[data-toolbar-action="italic"]').click()
 
-    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    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)
+    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())
@@ -62,17 +77,26 @@ test.describe("Toolbar", () => {
   })
 
   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)
+    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())
+    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())
+    const afterRedo = await page.evaluate(() =>
+      window.__testUtils__?.getContent(),
+    )
     expect(afterRedo).toBe(beforeUndo)
   })
 

+ 20 - 10
packages/editor/e2e/visual/regression.spec.ts

@@ -31,11 +31,13 @@ test.describe("Visual regression @visual", () => {
 
   test("inline formatting blurred @visual", async ({ page }) => {
     await page.goto("/editor?e2e=true&marker-mode=live-preview")
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
     // Text formatting block (index 2): italic, code, strikethrough, highlight
     await page.evaluate(
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
-      blockIds![2],
+      blockIds?.[2],
     )
     await page.evaluate(() => window.__testUtils__?.toggleBlur())
     await expect(page).toHaveScreenshot("inline-formatting-blurred.png")
@@ -95,11 +97,13 @@ test.describe("Visual regression @visual", () => {
 
   test("table raw pipes focused @visual", async ({ page }) => {
     await page.goto("/editor?e2e=true")
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
     // Table block is at index 6
     await page.evaluate(
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
-      blockIds![6],
+      blockIds?.[6],
     )
     await page.keyboard.type("| ")
     await expect(page).toHaveScreenshot("table-focused.png")
@@ -107,22 +111,26 @@ test.describe("Visual regression @visual", () => {
 
   test("code block focused @visual", async ({ page }) => {
     await page.goto("/editor?e2e=true")
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
     // Code block is at index 7
     await page.evaluate(
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
-      blockIds![7],
+      blockIds?.[7],
     )
     await expect(page).toHaveScreenshot("code-focused.png")
   })
 
   test("callout block focused @visual", async ({ page }) => {
     await page.goto("/editor?e2e=true")
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
     // Callout block is at index 8
     await page.evaluate(
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
-      blockIds![8],
+      blockIds?.[8],
     )
     await expect(page).toHaveScreenshot("callout-focused.png")
   })
@@ -157,10 +165,12 @@ test.describe("Visual regression @visual", () => {
 
   test("full editor after typing @visual", async ({ page }) => {
     await page.goto("/editor?e2e=true")
-    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const blockIds = await page.evaluate(() =>
+      window.__testUtils__?.getBlockIds(),
+    )
     await page.evaluate(
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
-      blockIds![blockIds!.length - 1],
+      blockIds?.[blockIds?.length - 1],
     )
     await page.keyboard.type("\n\n# New Content\n\nTyped by the user.")
     await page.waitForTimeout(300)

二进制
packages/editor/e2e/visual/regression.spec.ts-snapshots/inline-formatting-blurred-chromium-darwin.png


二进制
packages/editor/e2e/visual/regression.spec.ts-snapshots/table-focused-chromium-darwin.png


+ 1 - 2
packages/editor/playwright.config.ts

@@ -23,8 +23,7 @@ export default defineConfig({
     },
   ],
   webServer: {
-    command:
-      "pnpm --filter @enesis/dev dev",
+    command: "pnpm --filter @enesis/dev dev",
     url: "http://localhost:5173",
     reuseExistingServer: !process.env.CI,
     timeout: 30_000,

+ 1 - 1
packages/editor/src/__test-utils__/index.ts

@@ -1,5 +1,5 @@
-import type { EditorView } from "prosemirror-view"
 import { TextSelection } from "prosemirror-state"
+import type { EditorView } from "prosemirror-view"
 import { vi } from "vitest"
 import { contentToDoc } from "@/lib/content-model"
 import { schema } from "@/lib/schema"

+ 41 - 18
packages/editor/src/assets/style.css

@@ -1,6 +1,29 @@
-@import "tailwindcss";
+@reference "tailwindcss";
 @reference "@nuxt/ui";
 
+@theme {
+  /* Define the animation utility and its keyframes */
+  --animate-blink: blink 1s steps(11) infinite;
+
+  @keyframes blink {
+    0% {
+      opacity: 0;
+    }
+    0.1% {
+      opacity: 1;
+    }
+    50% {
+      opacity: 1;
+    }
+    50.1% {
+      opacity: 0;
+    }
+    100% {
+      opacity: 0;
+    }
+  }
+}
+
 /* ── Base Editor ─────────────────────────────────────────────────── */
 
 .ProseMirror {
@@ -37,12 +60,12 @@
 
 /* Compressed / Squeezed state when blurred in live-preview mode */
 .md-decorator-hidden {
-  opacity: 0 !important;
-  letter-spacing: -0.5em !important;
-  padding-left: 0 !important;
-  padding-right: 0 !important;
-  margin-left: 0px !important;
-  margin-right: 0px !important;
+  opacity: 0;
+  letter-spacing: -0.5em;
+  padding-left: 0;
+  padding-right: 0;
+  margin-left: 0px;
+  margin-right: 0px;
   pointer-events: none;
 }
 
@@ -169,12 +192,12 @@
 
 /* Task markers are always visible — status is critical info, not just syntax */
 .md-task-marker.md-decorator-hidden {
-  opacity: 1 !important;
-  letter-spacing: 0.05em !important;
-  padding-left: 0.375rem !important;
-  padding-right: 0.375rem !important;
-  margin-left: 0 !important;
-  margin-right: 0.5rem !important;
+  opacity: 1;
+  letter-spacing: 0.05em;
+  padding-left: 0.375rem;
+  padding-right: 0.375rem;
+  margin-left: 0;
+  margin-right: 0.5rem;
   pointer-events: none;
 }
 
@@ -425,7 +448,7 @@
 }
 
 .md-table-hidden {
-  display: none !important;
+  display: none;
 }
 
 .md-table-rendered {
@@ -507,7 +530,7 @@
 }
 
 .md-math-hidden {
-  display: none !important;
+  display: none;
 }
 
 .md-math-rendered {
@@ -527,7 +550,7 @@
 /* ── Image ──────────────────────────────────────────────── */
 
 .md-image-hidden {
-  display: none !important;
+  display: none;
 }
 
 .md-image-wrapper {
@@ -610,8 +633,8 @@
   .md-block-ref,
   .md-tag,
   .ProseMirror-gapcursor {
-    transition: none !important;
-    animation: none !important;
+    transition: none;
+    animation: none;
   }
 }
 

+ 82 - 22
packages/editor/src/components/Editor.vue

@@ -1,7 +1,15 @@
 <script setup lang="ts">
-import type { EditorView } from "prosemirror-view"
 import { TextSelection } from "prosemirror-state"
-import { computed, nextTick, onMounted, reactive, type Ref, ref, watch } from "vue"
+import type { EditorView } from "prosemirror-view"
+import {
+  computed,
+  nextTick,
+  onMounted,
+  type Ref,
+  reactive,
+  ref,
+  watch,
+} from "vue"
 import EditorBlock from "@/components/EditorBlock.vue"
 import EditorInsertionZone from "@/components/EditorInsertionZone.vue"
 import {
@@ -26,6 +34,7 @@ import {
   serializeBlocks,
   splitMarkdownIntoBlocks,
 } from "@/lib/block-parser"
+import { inlineToString } from "@/lib/content-model"
 import {
   moveBlock,
   deleteIfEmpty as opDeleteIfEmpty,
@@ -36,7 +45,6 @@ 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,
@@ -58,9 +66,25 @@ const emit = defineEmits<{
   "asset-dropped": [payload: { file: File; pos: number }]
 }>()
 
+export type EditorFocusTarget =
+  | FocusTarget
+  | "first-block"
+  | "last-block"
+  | "first-zone"
+  | "last-zone"
+  | true
+
 const props = withDefaults(
   defineProps<{
-    focused?: boolean
+    /** Where to place focus on mount:
+     * - `"first-block"`: focus the first EditorBlock
+     * - `"last-block"`: focus the last EditorBlock
+     * - `"first-zone"`: focus the insertion zone before the first block
+     * - `"last-zone"`: focus the insertion zone after the last block
+     * - `{ kind: "block"; id }`: focus a specific block by ID
+     * - `{ kind: "zone"; index }`: focus a specific zone by index
+     * - not set: no initial focus */
+    focused?: EditorFocusTarget
     markerMode?: MarkerVisibilityMode
     debug?: string
     theme?: ThemeInput
@@ -72,9 +96,7 @@ const props = withDefaults(
     /** Enables window.__testUtils__ for Playwright E2E tests */
     testMode?: boolean
   }>(),
-  {
-    focused: false,
-  },
+  {},
 )
 
 const themeCSSVars = computed(() => {
@@ -426,7 +448,7 @@ function onBlockBlurHandler(event: FocusEvent) {
   // Keep handlers when focus moves to the toolbar (e.g., heading dropdown click).
   // Only clear when focus moves outside the editor.
   const target = event.relatedTarget as HTMLElement | null
-  if (target?.closest('[role="toolbar"], [data-slot="content"]')) return
+  if (target?.closest('[role="toolbar"], [data-editor-overlay]')) return
   activeHandlers.value = null
   activeView.value = null
   emit("blur")
@@ -694,12 +716,51 @@ function onOutdent(index: number) {
 
 const focusedTarget = ref<FocusTarget | null>(null)
 
+/** Resolve the public `focused` prop to an internal `FocusTarget`. */
+function resolveFocusTarget(
+  val: EditorFocusTarget | undefined,
+): FocusTarget | null {
+  if (!val) return null
+  // Backward compat: true → "first-block"
+  if (val === true) val = "first-block"
+  if (typeof val === "object") return val as FocusTarget
+  const last = blocks.value.length - 1
+  switch (val) {
+    case "first-block":
+      return blocks.value[0] ? { kind: "block", id: blocks.value[0].id } : null
+    case "last-block":
+      return last >= 0 ? { kind: "block", id: blocks.value[last]!.id } : null
+    case "first-zone":
+      return { kind: "zone", index: 0 }
+    case "last-zone":
+      return { kind: "zone", index: blocks.value.length }
+  }
+}
+
 watch(registry.focusedId, (id) => {
   if (id !== null) {
     focusedTarget.value = { kind: "block", id }
   }
 })
 
+/** Apply the initial focus target on mount or when the prop changes. */
+watch(
+  () => props.focused,
+  (val) => {
+    const target = resolveFocusTarget(val)
+    if (!target) return
+    focusedTarget.value = target
+    nextTick(() => {
+      if (target.kind === "zone") {
+        focusZone(target.index)
+      } else {
+        registry.focus(target.id, "start")
+      }
+    })
+  },
+  { immediate: true },
+)
+
 function onZoneFocus(index: number) {
   focusedTarget.value = { kind: "zone", index }
 }
@@ -824,9 +885,7 @@ onMounted(() => {
       if (pmFrom === null) pmFrom = doc.content.size
       if (pmTo === null) pmTo = doc.content.size
       v.dispatch(
-        v.state.tr.setSelection(
-          TextSelection.create(doc, pmFrom, pmTo),
-        ),
+        v.state.tr.setSelection(TextSelection.create(doc, pmFrom, pmTo)),
       )
     },
     getBlockIds() {
@@ -860,9 +919,9 @@ onMounted(() => {
       const end = v.state.doc.content.size
       v.focus()
       v.dispatch(
-        v.state.tr.setSelection(
-          TextSelection.create(v.state.doc, end, end),
-        ).scrollIntoView(),
+        v.state.tr
+          .setSelection(TextSelection.create(v.state.doc, end, end))
+          .scrollIntoView(),
       )
     },
     focusAtStart(blockId: string) {
@@ -878,9 +937,9 @@ onMounted(() => {
         }
       })
       v.dispatch(
-        v.state.tr.setSelection(
-          TextSelection.create(v.state.doc, start, start),
-        ).scrollIntoView(),
+        v.state.tr
+          .setSelection(TextSelection.create(v.state.doc, start, start))
+          .scrollIntoView(),
       )
     },
     toggleBlur() {
@@ -966,7 +1025,7 @@ defineExpose({
         <EditorInsertionZone
         :ref="(el: any) => { if (el) zoneRefs.set(i, el); else zoneRefs.delete(i) }"
         :zone-index="i"
-        :class="dropZoneIndex === i ? 'bg-(--ui-primary) rounded' : ''"
+        :class="dropZoneIndex === i ? 'border-(--ui-primary)/25 border-t-2' : ''"
         @activate="(content: string) => onZoneActivate(i, content)"
         @focus="onZoneFocus(i)"
         @blur="onZoneBlur"
@@ -989,11 +1048,11 @@ defineExpose({
           draggable="true"
           class="flex items-center justify-center cursor-grab active:cursor-grabbing rounded transition-all"
           data-drag-handle
-          :class="dragState?.blockId === block.id ? 'size-7 text-(--ui-primary)' : 'size-6 text-(--ui-text-muted) group-hover/block:text-(--ui-primary)'"
+          :class="dragState?.blockId === block.id ? 'size-7 text-(--ui-primary)' : 'size-6 text-(--ui-text-muted) opacity-0 group-hover/block:opacity-100 group-hover/block:text-(--ui-primary)'"
           @dragstart="onDragStart(i, block.id, block.content, block.depth, $event)"
           @dragend="onDragEnd"
         >
-          <UIcon name="i-ph-dot-duotone" class="size-6" />
+          <UIcon name="i-lucide-grip-vertical" class="size-4" />
         </div>
       </div>
 
@@ -1002,7 +1061,7 @@ defineExpose({
           :id="block.id"
           :on-boundary-exit="(dir) => onBlockBoundaryExit(i, dir)"
           v-model:content="block.content"
-          :focused="focused && i === 0"
+          :focused="focusedTarget?.kind === 'block' && focusedTarget.id === block.id"
           :marker-mode="markerMode"
           :depth="block.depth"
           :debug="debug"
@@ -1039,7 +1098,8 @@ defineExpose({
     <EditorInsertionZone
       :ref="(el: any) => { if (el) zoneRefs.set(blocks.length, el); else zoneRefs.delete(blocks.length) }"
       :zone-index="blocks.length"
-      :class="dropZoneIndex === blocks.length ? 'bg-(--ui-primary) rounded' : ''"
+      is-last
+      :class="dropZoneIndex === blocks.length ? 'bg-(--ui-primary)/10 rounded' : ''"
       @activate="(content: string) => onZoneActivate(blocks.length, content)"
       @focus="onZoneFocus(blocks.length)"
       @blur="onZoneBlur"

+ 2 - 1
packages/editor/src/components/EditorBlock.vue

@@ -57,6 +57,7 @@ import {
   tripleBacktickRule,
 } from "@/lib/auto-close-plugin"
 import { contentToDoc, docToContent } from "@/lib/content-model"
+import { generateBlockId } from "@/lib/block-parser"
 import {
   type FormattingHandlers,
   getActiveHeading,
@@ -707,7 +708,7 @@ onMounted(() => {
           content.value = newContent
           emit("change", newContent)
           emit("content-change-op", {
-            blockId: props.id ?? "",
+            blockId: props.id ?? generateBlockId(),
             previousContent: prevContent,
             newContent,
             timestamp: Date.now(),

+ 51 - 118
packages/editor/src/components/EditorInsertionZone.vue

@@ -3,6 +3,7 @@ import { nextTick, ref } from "vue"
 
 const props = defineProps<{
   zoneIndex?: number
+  isLast?: boolean
 }>()
 
 const emit = defineEmits<{
@@ -18,13 +19,6 @@ const focused = ref(false)
 const rootEl = ref<HTMLElement | null>(null)
 const inputEl = ref<HTMLInputElement | null>(null)
 
-/**
- * Keydown handler on the root `<div>`. Handles the full set of keys
- * including character-by-character text entry. This is the active
- * handler when the zone is focused programmatically (via `focusZone()`
- * in Editor.vue). When the `<input>` is the active element, events
- * are handled by `onInputKeydown` instead.
- */
 function onKeydown(e: KeyboardEvent) {
   if (e.key === "ArrowUp") {
     e.preventDefault()
@@ -40,7 +34,8 @@ function onKeydown(e: KeyboardEvent) {
 
   if (e.key === "Escape") {
     e.preventDefault()
-    deactivate()
+    focused.value = false
+    emit("blur")
     return
   }
 
@@ -57,143 +52,81 @@ function onKeydown(e: KeyboardEvent) {
   }
 }
 
-/**
- * Keydown handler for the `<input>` element. Only handles navigation
- * keys (ArrowUp/Down, Escape, Enter). Character keys are intentionally
- * NOT intercepted — they flow into the input naturally and are captured
- * by `onInput`. This avoids duplicate blocks on mobile where:
- * 1. The first character's `keydown` fires
- * 2. Autocorrect/IME replaces the field value
- * 3. `@input` fires with the full word
- * If character keys were handled here, steps 1 and 3 would each emit
- * `activate`, creating two blocks.
- */
-function onInputKeydown(e: KeyboardEvent) {
-  if (e.key === "ArrowUp") {
-    e.preventDefault()
-    emit("navigate-up")
-    return
-  }
-
-  if (e.key === "ArrowDown") {
-    e.preventDefault()
-    emit("navigate-down")
-    return
-  }
-
-  if (e.key === "Escape") {
-    e.preventDefault()
-    deactivate()
-    return
-  }
-
-  if (e.key === "Enter") {
-    e.preventDefault()
-    const value = (e.target as HTMLInputElement).value
-    emit("activate", value)
-    return
-  }
-}
-
 function onPaste(e: ClipboardEvent) {
   e.preventDefault()
   const text = e.clipboardData?.getData("text/plain") ?? ""
   emit("activate", text)
 }
 
-/**
- * Activate the zone: show the input and request focus. Uses `nextTick`
- * so the `<input>` is in the DOM before `.focus()` is called. A native
- * `<input>` is required here — `tabindex` on a `<div>` does not trigger
- * the mobile soft keyboard.
- */
-function activate() {
-  if (focused.value) return
+function onFocus() {
   focused.value = true
   emit("focus")
   nextTick(() => inputEl.value?.focus())
 }
 
-function deactivate() {
+function onBlur() {
   focused.value = false
   emit("blur")
 }
 
-function onClick() {
-  activate()
-}
-
-/**
- * Deactivate only when focus leaves the entire zone subtree. Without
- * this check, focusing the child `<input>` would trigger `blur` on the
- * root `<div>` and immediately close the zone. `focusout` bubbles, and
- * `relatedTarget` tells us where focus went — if it's still inside the
- * zone (e.g. the input), we stay active.
- */
-function onFocusout(e: FocusEvent) {
-  if (!rootEl.value?.contains(e.relatedTarget as Node)) {
-    deactivate()
-  }
-}
-
-/**
- * Single source of truth for text entry. On desktop, characters arrive
- * one by one (each fires `activate`). On mobile, autocorrect may flush
- * the entire word at once — `activate` is called once with the full
- * value, avoiding duplicate blocks.
- */
-function onInput(e: Event) {
-  const value = (e.target as HTMLInputElement).value
-  if (value) {
-    emit("activate", value)
-  }
-}
-
-defineExpose({
-  focus: activate,
-})
+defineExpose({ focus: () => rootEl.value?.focus() })
 </script>
 
 <template>
   <div
     ref="rootEl"
-    role="button"
-    aria-label="Insert new block"
-    class="insertion-zone relative outline-none cursor-pointer transition-all duration-150"
+    class="relative outline-none cursor-text select-none transition-[height,background-color] duration-300 ease-in-out"
+    :class="focused ? 'h-12 bg-(--ui-primary)/[0.04]' : (isLast ? 'h-8' : 'h-2') + ' bg-transparent'"
     :data-zone-index="zoneIndex"
     :data-drop-target="''"
-    :class="focused ? 'h-10' : 'h-1'"
-    @keydown="onKeydown"
+    tabindex="0"
+    role="button"
+    aria-label="Insert block"
     @paste="onPaste"
-    @click="onClick"
-    @focusout="onFocusout"
+    @click="rootEl?.focus()"
+    @focus="onFocus"
     @mouseenter="hovered = true"
     @mouseleave="hovered = false"
   >
-    <div
-      v-if="hovered && !focused"
-      class="absolute inset-0 flex items-center"
+    <!-- Hover: a whisper, not a shout -->
+    <Transition
+      enter-active-class="transition-opacity duration-200"
+      leave-active-class="transition-opacity duration-100"
+      enter-from-class="opacity-0"
+      leave-to-class="opacity-0"
     >
-      <USeparator
-        icon="i-lucide-square-plus"
-        class="w-full text-(--ui-text-muted)"
-        :ui="{ icon: 'text-(--ui-text-muted)' }"
-        color="neutral"
-        type="dotted"
-      />
-    </div>
-
-    <div
-      v-if="focused"
-      class="absolute inset-0 rounded-lg border-2 bg-(--ui-bg-elevated) flex items-center gap-2.5 px-4 shadow-sm border-(--ui-text-muted)/40"
+      <div
+        v-if="hovered && !focused"
+        class="absolute inset-0 flex items-center justify-center gap-2.5 text-(--ui-text-dimmed)"
+      >
+        <div class="flex-1 h-px bg-(--ui-border)" />
+        <span class="flex items-center gap-1.5 text-xs italic">
+          <UIcon name="i-lucide-pencil-line" class="size-3 not-italic" />
+          write something
+        </span>
+        <div class="flex-1 h-px bg-(--ui-border)" />
+      </div>
+    </Transition>
+
+    <!-- Focused: paper opening up -->
+    <Transition
+      enter-active-class="transition-opacity duration-250"
+      enter-from-class="opacity-0"
     >
-      <input
-        ref="inputEl"
-        class="w-full bg-transparent outline-none text-sm placeholder-(--ui-text-muted)"
-        placeholder="Type to add a block..."
-        @input="onInput"
-        @keydown="onInputKeydown"
-      />
-    </div>
+      <div
+        v-if="focused"
+        class="absolute inset-0 flex items-center gap-2 px-1"
+      >
+        <input
+          ref="inputEl"
+          class="w-full ml-2 bg-transparent outline-none text-sm italic text-(--ui-text-dimmed) placeholder-(--ui-text-dimmed)/60"
+          :style="{ caretColor: 'var(--ui-primary)' }"
+          placeholder="What's on your mind?"
+          @keydown.stop="onKeydown"
+          @paste="onPaste"
+          @blur="onBlur"
+        />
+      </div>
+    </Transition>
   </div>
 </template>

+ 1 - 1
packages/editor/src/components/EditorSuggestionMenu.vue

@@ -171,7 +171,7 @@ defineExpose({ forwardKey })
               class="w-5 h-5 shrink-0 text-(--ui-text-muted)"
             />
 
-            <span class="flex-1 truncate text-(--ui-text-main)">{{ item.label }}</span>
+            <span class="flex-1 truncate text-default">{{ item.label }}</span>
 
             <span
               v-if="item.suffix"

+ 8 - 5
packages/editor/src/components/EditorToolbar.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
-import { nextTick, ref, watch, computed } from "vue"
-import { useFloating, offset, flip, shift } from "@floating-ui/vue"
+import { flip, offset, shift, useFloating } from "@floating-ui/vue"
+import { computed, nextTick, ref, watch } from "vue"
 import type { FormattingHandlers } from "@/lib/formatting"
 import EditorToolbarContent from "./EditorToolbarContent.vue"
 
@@ -68,7 +68,7 @@ watch(
     if (props.mode === "fixed" || props.mode === undefined) return
 
     const sel = window.getSelection()
-    if (!sel || !sel.rangeCount) {
+    if (!sel?.rangeCount) {
       virtualRef.value = null
       return
     }
@@ -119,7 +119,9 @@ const inputRef = ref<any>(null)
 
 const promptOpen = computed({
   get: () => promptKind.value !== null,
-  set: (val: boolean) => { if (!val) promptKind.value = null },
+  set: (val: boolean) => {
+    if (!val) promptKind.value = null
+  },
 })
 
 const promptLabels = computed(() => ({
@@ -188,7 +190,8 @@ function cancelPrompt() {
       role="toolbar"
       aria-label="Formatting toolbar"
       data-slot="base"
-      class="flex items-stretch gap-1.5 rounded-lg border border-default bg-white dark:bg-neutral-950 shadow-lg p-1"
+      data-editor-overlay
+      class="flex items-stretch gap-1.5 rounded-lg border border-default bg-(--ui-bg) shadow-lg p-1"
       :style="floatingStyles"
       @mousedown.prevent
     >

+ 42 - 7
packages/editor/src/components/EditorToolbarContent.vue

@@ -69,14 +69,49 @@ const headingItems = computed(() => {
   if (!h) return []
   const current = currentHeading.value
   return [
-    { label: "Paragraph", icon: "i-lucide-text", active: current === null, onSelect: () => h.setHeading(null) },
+    {
+      label: "Paragraph",
+      icon: "i-lucide-text",
+      active: current === null,
+      onSelect: () => h.setHeading(null),
+    },
     { type: "separator" as const },
-    { label: "Heading 1", icon: "i-lucide-heading-1", active: current === 1, onSelect: () => h.setHeading(1) },
-    { label: "Heading 2", icon: "i-lucide-heading-2", active: current === 2, onSelect: () => h.setHeading(2) },
-    { label: "Heading 3", icon: "i-lucide-heading-3", active: current === 3, onSelect: () => h.setHeading(3) },
-    { label: "Heading 4", icon: "i-lucide-heading-4", active: current === 4, onSelect: () => h.setHeading(4) },
-    { label: "Heading 5", icon: "i-lucide-heading-5", active: current === 5, onSelect: () => h.setHeading(5) },
-    { label: "Heading 6", icon: "i-lucide-heading-6", active: current === 6, onSelect: () => h.setHeading(6) },
+    {
+      label: "Heading 1",
+      icon: "i-lucide-heading-1",
+      active: current === 1,
+      onSelect: () => h.setHeading(1),
+    },
+    {
+      label: "Heading 2",
+      icon: "i-lucide-heading-2",
+      active: current === 2,
+      onSelect: () => h.setHeading(2),
+    },
+    {
+      label: "Heading 3",
+      icon: "i-lucide-heading-3",
+      active: current === 3,
+      onSelect: () => h.setHeading(3),
+    },
+    {
+      label: "Heading 4",
+      icon: "i-lucide-heading-4",
+      active: current === 4,
+      onSelect: () => h.setHeading(4),
+    },
+    {
+      label: "Heading 5",
+      icon: "i-lucide-heading-5",
+      active: current === 5,
+      onSelect: () => h.setHeading(5),
+    },
+    {
+      label: "Heading 6",
+      icon: "i-lucide-heading-6",
+      active: current === 6,
+      onSelect: () => h.setHeading(6),
+    },
   ]
 })
 </script>

+ 16 - 6
packages/editor/src/components/__tests__/define-model-repro.test.ts

@@ -1,7 +1,6 @@
 import { mount } from "@vue/test-utils"
-import { describe, expect, it, vi } from "vitest"
-import { defineComponent, ref, watch, h } from "vue"
-import Editor from "@/components/Editor.vue"
+import { describe, expect, it } from "vitest"
+import { defineComponent, h, ref, watch } from "vue"
 
 // ── Root cause ──
 //
@@ -36,7 +35,7 @@ import Editor from "@/components/Editor.vue"
 
 describe("defineModel — local mode in Vue 3.5", () => {
   it("manual ref + watch on props.content works (demonstrates correct behavior)", () => {
-    let capturedValue: unknown = undefined
+    let capturedValue: unknown
 
     const TestComp = defineComponent({
       props: {
@@ -45,8 +44,19 @@ describe("defineModel — local mode in Vue 3.5", () => {
       },
       setup(props) {
         const modelRef = ref(props.content)
-        watch(() => props.content, (val) => { if (val !== undefined) modelRef.value = val })
-        watch(() => modelRef.value, (val) => { capturedValue = val }, { immediate: true })
+        watch(
+          () => props.content,
+          (val) => {
+            if (val !== undefined) modelRef.value = val
+          },
+        )
+        watch(
+          () => modelRef.value,
+          (val) => {
+            capturedValue = val
+          },
+          { immediate: true },
+        )
         return () => h("div")
       },
     })

+ 83 - 29
packages/editor/src/components/__tests__/editor-integration.test.ts

@@ -1,8 +1,9 @@
-import { describe, expect, it, vi } from "vitest"
-import { ref } from "vue"
 import { EditorState } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
+import { describe, expect, it, vi } from "vitest"
+import { ref } from "vue"
 import { stableId } from "@/__test-utils__"
+import { createPasteHandler } from "@/composables/usePasteHandler"
 import type { PatternOpenPayload } from "@/composables/usePatternPlugin"
 import {
   buildMentionGroups,
@@ -10,13 +11,11 @@ import {
   useMentionGroups,
   useSlashGroups,
 } from "@/composables/useSuggestionGroups"
+import { contentToDoc } from "@/lib/content-model"
+import { insertBlock, moveBlock } from "@/lib/editor-operations"
 import type { FormattingHandlers } from "@/lib/formatting"
-import { createOperationHistory } from "@/lib/operation-history"
-import { createPasteHandler } from "@/composables/usePasteHandler"
-import { insertBlock } from "@/lib/editor-operations"
-import { moveBlock } from "@/lib/editor-operations"
 import { classifyBlock } from "@/lib/markdown-rules/block-classifier"
-import { contentToDoc } from "@/lib/content-model"
+import { createOperationHistory } from "@/lib/operation-history"
 import { schema } from "@/lib/schema"
 
 function createMockSession(
@@ -69,7 +68,7 @@ describe("Editor integration — menu routing", () => {
     expect(activeSession.value).toBeNull()
     onPatternOpen(createMockSession("command"))
     expect(activeSession.value).not.toBeNull()
-    expect(activeSession.value!.kind).toBe("command")
+    expect(activeSession.value?.kind).toBe("command")
     onPatternClose()
     expect(activeSession.value).toBeNull()
   })
@@ -112,9 +111,9 @@ describe("Editor integration — menu routing", () => {
       position: { x: 50, y: 100 },
       range: { from: 0, to: 9 },
     })
-    expect(activeSession.value!.query).toBe("/newquery")
-    expect(activeSession.value!.position).toEqual({ x: 50, y: 100 })
-    expect(activeSession.value!.range).toEqual({ from: 0, to: 9 })
+    expect(activeSession.value?.query).toBe("/newquery")
+    expect(activeSession.value?.position).toEqual({ x: 50, y: 100 })
+    expect(activeSession.value?.range).toEqual({ from: 0, to: 9 })
   })
 })
 
@@ -123,7 +122,7 @@ describe("content-change-op coalescing", () => {
     const history = createOperationHistory()
     const COALESCE_MS = 500
     let canUndo = false
-    let canRedo = false
+    let _canRedo = false
 
     function handleOp(op: {
       blockId: string
@@ -154,15 +153,40 @@ describe("content-change-op coalescing", () => {
         })
       }
       canUndo = history.canUndo
-      canRedo = history.canRedo
+      _canRedo = history.canRedo
     }
 
     // Rapid typing — should coalesce into 1 entry
-    handleOp({ blockId: "a", previousContent: "", newContent: "h", timestamp: 1000 })
-    handleOp({ blockId: "a", previousContent: "h", newContent: "he", timestamp: 1050 })
-    handleOp({ blockId: "a", previousContent: "he", newContent: "hel", timestamp: 1120 })
-    handleOp({ blockId: "a", previousContent: "hel", newContent: "hell", timestamp: 1180 })
-    handleOp({ blockId: "a", previousContent: "hell", newContent: "hello", timestamp: 1250 })
+    handleOp({
+      blockId: "a",
+      previousContent: "",
+      newContent: "h",
+      timestamp: 1000,
+    })
+    handleOp({
+      blockId: "a",
+      previousContent: "h",
+      newContent: "he",
+      timestamp: 1050,
+    })
+    handleOp({
+      blockId: "a",
+      previousContent: "he",
+      newContent: "hel",
+      timestamp: 1120,
+    })
+    handleOp({
+      blockId: "a",
+      previousContent: "hel",
+      newContent: "hell",
+      timestamp: 1180,
+    })
+    handleOp({
+      blockId: "a",
+      previousContent: "hell",
+      newContent: "hello",
+      timestamp: 1250,
+    })
 
     expect(canUndo).toBe(true)
     expect(history.peek()?.type).toBe("set-block-content")
@@ -211,9 +235,19 @@ describe("content-change-op coalescing", () => {
     }
 
     // First burst
-    handleOp({ blockId: "a", previousContent: "", newContent: "hi", timestamp: 1000 })
+    handleOp({
+      blockId: "a",
+      previousContent: "",
+      newContent: "hi",
+      timestamp: 1000,
+    })
     // Pause > 500ms
-    handleOp({ blockId: "a", previousContent: "hi", newContent: "hi there", timestamp: 1600 })
+    handleOp({
+      blockId: "a",
+      previousContent: "hi",
+      newContent: "hi there",
+      timestamp: 1600,
+    })
 
     // Two separate entries
     const first = history.undo()
@@ -258,8 +292,18 @@ describe("content-change-op coalescing", () => {
       }
     }
 
-    handleOp({ blockId: "a", previousContent: "", newContent: "hello", timestamp: 1000 })
-    handleOp({ blockId: "b", previousContent: "", newContent: "world", timestamp: 1100 })
+    handleOp({
+      blockId: "a",
+      previousContent: "",
+      newContent: "hello",
+      timestamp: 1000,
+    })
+    handleOp({
+      blockId: "b",
+      previousContent: "",
+      newContent: "world",
+      timestamp: 1100,
+    })
 
     // Two separate entries (different block)
     const first = history.undo()
@@ -304,7 +348,12 @@ describe("content-change-op coalescing", () => {
     }
 
     // 1. Type "hello" in block A
-    handleContentOp({ blockId: "a", previousContent: "", newContent: "hello", timestamp: 1000 })
+    handleContentOp({
+      blockId: "a",
+      previousContent: "",
+      newContent: "hello",
+      timestamp: 1000,
+    })
 
     // 2. Split at index 0 (creates block B with "world")
     history.execute({
@@ -318,12 +367,17 @@ describe("content-change-op coalescing", () => {
     })
 
     // 3. Type " test" in block B
-    handleContentOp({ blockId: "b", previousContent: "", newContent: " test", timestamp: 2100 })
+    handleContentOp({
+      blockId: "b",
+      previousContent: "",
+      newContent: " test",
+      timestamp: 2100,
+    })
 
     // Step 3 undo — revert block B typing
     const undo1 = history.undo()
     expect(undo1).not.toBeNull()
-    expect(undo1!.type).toBe("set-block-content")
+    expect(undo1?.type).toBe("set-block-content")
     expect(undo1).toHaveProperty("blockId", "b")
     expect(undo1).toHaveProperty("newContent", "")
     expect(history.canUndo).toBe(true)
@@ -331,13 +385,13 @@ describe("content-change-op coalescing", () => {
     // Step 2 undo — revert the split (merge block B back into block A)
     const undo2 = history.undo()
     expect(undo2).not.toBeNull()
-    expect(undo2!.type).toBe("merge-block")
+    expect(undo2?.type).toBe("merge-block")
     expect(history.canUndo).toBe(true)
 
     // Step 1 undo — revert block A typing
     const undo3 = history.undo()
     expect(undo3).not.toBeNull()
-    expect(undo3!.type).toBe("set-block-content")
+    expect(undo3?.type).toBe("set-block-content")
     expect(undo3).toHaveProperty("blockId", "a")
     expect(undo3).toHaveProperty("newContent", "")
     expect(history.canUndo).toBe(false)
@@ -494,7 +548,7 @@ describe("suggestion menu selection", () => {
       .find((item) => item.label === "Heading 1")
 
     expect(heading1).toBeDefined()
-    heading1!.onSelect()
+    heading1?.onSelect()
 
     expect(respond).toHaveBeenCalledWith({ text: "# ", mode: "replace" })
     expect(closeSession).toHaveBeenCalledWith("completed")
@@ -523,7 +577,7 @@ describe("suggestion menu selection", () => {
 
     expect(firstPage).toBeDefined()
     expect(groups[0].id).toBe("pages")
-    firstPage!.onSelect()
+    firstPage?.onSelect()
 
     expect(respond).toHaveBeenCalledWith({
       text: "[[Getting Started]]",

+ 12 - 7
packages/editor/src/components/__tests__/editor-toolbar.test.ts

@@ -30,18 +30,23 @@ function createWrapper(handlers: unknown = null) {
     },
     global: {
       stubs: {
-        UButton: { template: '<button><slot /></button>' },
-        UDropdownMenu: { template: '<div><slot /></div>' },
-        UTooltip: { template: '<div><slot /></div>' },
-        USeparator: { template: '<div />' },
+        UButton: { template: "<button><slot /></button>" },
+        UDropdownMenu: { template: "<div><slot /></div>" },
+        UTooltip: { template: "<div><slot /></div>" },
+        USeparator: { template: "<div />" },
         UModal: {
           props: ["open"],
-          template: '<div v-if="open" class="modal-content"><slot name="content" /></div>',
+          template:
+            '<div v-if="open" class="modal-content"><slot name="content" /></div>',
+        },
+        UCard: {
+          template:
+            '<div><slot name="header" /><slot /><slot name="footer" /></div>',
         },
-        UCard: { template: '<div><slot name="header" /><slot /><slot name="footer" /></div>' },
         UInput: {
           props: ["modelValue", "placeholder"],
-          template: '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" @keydown="$emit(\'keydown\', $event)" />',
+          template:
+            '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" @keydown="$emit(\'keydown\', $event)" />',
         },
       },
     },

+ 10 - 4
packages/editor/src/composables/__tests__/node-views.test.ts

@@ -1,6 +1,6 @@
 import { EditorState } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
 import { MathBlockView } from "@/composables/useMathBlockView"
 import { schema } from "@/lib/schema"
 
@@ -36,7 +36,9 @@ function createView(content: string) {
 
 describe("MathBlockView", () => {
   it("creates DOM structure with correct classes", () => {
-    const node = schema.nodes.math_block.create(null, [schema.text("$$\nE = mc^2\n$$")])
+    const node = schema.nodes.math_block.create(null, [
+      schema.text("$$\nE = mc^2\n$$"),
+    ])
     const view = createView("$$\nE = mc^2\n$$")
 
     const nodeView = new MathBlockView(node, view, () => 0)
@@ -69,7 +71,9 @@ describe("MathBlockView", () => {
 
     const nodeView = new MathBlockView(mathNode, view, () => 0)
 
-    const updatedNode = schema.nodes.math_block.create(null, [schema.text("$$\nx^2\n$$")])
+    const updatedNode = schema.nodes.math_block.create(null, [
+      schema.text("$$\nx^2\n$$"),
+    ])
     expect(nodeView.update(updatedNode)).toBe(true)
   })
 
@@ -137,7 +141,9 @@ describe("CodeBlockView", () => {
 
     const nodeView = new CodeBlockView(node, view, () => 0)
 
-    const updatedNode = schema.nodes.code_block.create({ language: "ts" }, [textNode])
+    const updatedNode = schema.nodes.code_block.create({ language: "ts" }, [
+      textNode,
+    ])
     expect(nodeView.update(updatedNode)).toBe(true)
 
     nodeView.destroy()

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

@@ -1,11 +1,11 @@
 import { EditorState, TextSelection } from "prosemirror-state"
 import { describe, expect, it, vi } from "vitest"
-import { contentToDoc } from "@/lib/content-model"
 import {
   createPatternPlugin,
   getPatternSession,
   type PatternSession,
 } from "@/composables/usePatternPlugin"
+import { contentToDoc } from "@/lib/content-model"
 
 describe("Pattern plugin", () => {
   function createTestState(content: string, cursorPos?: number) {
@@ -180,4 +180,4 @@ describe("Pattern plugin", () => {
 
     expect(session).toBeNull()
   })
-})
+})

+ 1 - 1
packages/editor/src/composables/useKeyboard.ts

@@ -44,7 +44,7 @@ export function useKeyboard(config: KeyboardConfig) {
 
   function onKeydownCapture(e: KeyboardEvent) {
     const target = e.target as HTMLElement | null
-    if (!target || !target.closest(".editor-shell")) return
+    if (!target?.closest(".editor-shell")) return
 
     const ctx: KeyboardContext = {
       isPaletteOpen: activeSession.value !== null,

+ 3 - 5
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -18,9 +18,9 @@ import type { Tree } from "@lezer/common"
 import type { Node as ProsemirrorNode } from "prosemirror-model"
 import { Plugin, PluginKey, TextSelection } from "prosemirror-state"
 import { Decoration, DecorationSet } from "prosemirror-view"
+import { inlineToString } from "@/lib/content-model"
 import { renderKatexSync } from "@/lib/katex"
 import { createLogger } from "@/lib/logger"
-import { inlineToString } from "@/lib/content-model"
 import {
   type DecorationRange,
   MarkdownRuleEngine,
@@ -170,7 +170,7 @@ function buildCombinedText(paragraphs: ParagraphInfo[]): {
   const boundaries: number[] = [0]
   let text = ""
   for (let i = 0; i < paragraphs.length; i++) {
-    text += paragraphs[i]!.text
+    text += paragraphs[i]?.text
     const sepLen =
       i < paragraphs.length - 1 ? separatorBetween(paragraphs, i).length : 1
     boundaries.push(text.length + sepLen)
@@ -392,9 +392,7 @@ function applyTableDecorations(
               e.stopPropagation()
               view.dispatch(
                 view.state.tr.setSelection(
-                  TextSelection.near(
-                    view.state.doc.resolve(tokenStart.pmPos),
-                  ),
+                  TextSelection.near(view.state.doc.resolve(tokenStart.pmPos)),
                 ),
               )
               view.focus()

+ 2 - 2
packages/editor/src/composables/usePasteHandler.ts

@@ -17,7 +17,7 @@ export function createPasteHandler(onSplit: PasteSplitHandler) {
     if (lines.length <= 1) return false
 
     const classifications = lines.map((line) => classifyBlock(line))
-    const firstType = classifications[0]!.kind
+    const firstType = classifications[0]?.kind
 
     const isContinuationBlock = (kind: string) =>
       kind === "blockquote" &&
@@ -53,7 +53,7 @@ export function createPasteHandler(onSplit: PasteSplitHandler) {
 
     let foundBreak = false
     for (let i = 1; i < lines.length; i++) {
-      const kind = classifications[i]!.kind
+      const kind = classifications[i]?.kind
       const line = lines[i]!
       if (!foundBreak && (kind === firstType || kind === "paragraph")) {
         firstGroupLines.push(line)

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

@@ -266,7 +266,11 @@ class PatternView {
   private manualClose = false
   private log = createLogger("PatternPlugin")
 
-  constructor(view: EditorView, callbacks: PatternCallbacks, specs: PatternSpec[]) {
+  constructor(
+    view: EditorView,
+    callbacks: PatternCallbacks,
+    specs: PatternSpec[],
+  ) {
     this.view = view
     this.callbacks = callbacks
     this.specs = specs

+ 37 - 28
packages/editor/src/index.ts

@@ -1,21 +1,56 @@
 import type { App } from "vue"
-import EditorBlock from "@/components/EditorBlock.vue"
 import Editor from "@/components/Editor.vue"
+import EditorBlock from "@/components/EditorBlock.vue"
 import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
 import EditorToolbar from "@/components/EditorToolbar.vue"
 
 import "katex/dist/katex.css"
 import "@/assets/style.css"
 
+export type { EditorFocusTarget } from "@/components/Editor.vue"
+export type { ToolbarMode } from "@/components/EditorToolbar.vue"
 export type { FocusTarget } from "@/composables/useFocusRegistry"
+export type {
+  KeyBinding,
+  KeyboardAction,
+  KeyboardConfig,
+} from "@/composables/useKeyboard"
+export {
+  defaultKeyBinding,
+  useKeyboard,
+  vimKeyBinding,
+} from "@/composables/useKeyboard"
+export type {
+  PatternClosePayload,
+  PatternKind,
+  PatternOpenPayload,
+  PatternSession,
+  PatternSpec,
+  PatternUpdatePayload,
+} from "@/composables/usePatternPlugin"
+export type { SuggestionActionItem } from "@/composables/useSuggestionGroups"
+export {
+  buildMentionGroups,
+  buildSlashGroups,
+  useMentionGroups,
+  useSlashGroups,
+} from "@/composables/useSuggestionGroups"
 export {
   extractEmbeddedId,
   generateBlockId,
   splitMarkdownIntoBlocks,
   stampBlockId,
 } from "@/lib/block-parser"
+export { moveBlock } from "@/lib/editor-operations"
 export type { FormattingHandlers } from "@/lib/formatting"
 export type { MarkdownPattern } from "@/lib/markdown-extensions"
+export type {
+  BlockRule,
+  MarkdownRule,
+  ParsedDecorations,
+} from "@/lib/markdown-rules"
+export { MarkdownRuleEngine } from "@/lib/markdown-rules"
+export { registerNodeView } from "@/lib/node-view-registry"
 export type {
   DeleteBlockOp,
   EditorOperation,
@@ -34,7 +69,6 @@ export {
   createOperationHistory,
   setTimeSource,
 } from "@/lib/operation-history"
-export { moveBlock } from "@/lib/editor-operations"
 export type {
   EditorTheme,
   ThemeColors,
@@ -46,32 +80,7 @@ export {
   themePresets,
   themeToCSSVars,
 } from "@/lib/theme"
-export type {
-  PatternClosePayload,
-  PatternKind,
-  PatternOpenPayload,
-  PatternSession,
-  PatternSpec,
-  PatternUpdatePayload,
-} from "@/composables/usePatternPlugin"
-export type { KeyBinding, KeyboardAction, KeyboardConfig } from "@/composables/useKeyboard"
-export {
-  defaultKeyBinding,
-  useKeyboard,
-  vimKeyBinding,
-} from "@/composables/useKeyboard"
-export type { SuggestionActionItem } from "@/composables/useSuggestionGroups"
-export {
-  buildMentionGroups,
-  buildSlashGroups,
-  useMentionGroups,
-  useSlashGroups,
-} from "@/composables/useSuggestionGroups"
-export { registerNodeView } from "@/lib/node-view-registry"
-export type { BlockRule, MarkdownRule, ParsedDecorations } from "@/lib/markdown-rules"
-export { MarkdownRuleEngine } from "@/lib/markdown-rules"
-export { EditorBlock, Editor, EditorSuggestionMenu, EditorToolbar }
-export type { ToolbarMode } from "@/components/EditorToolbar.vue"
+export { Editor, EditorBlock, EditorSuggestionMenu, EditorToolbar }
 
 export default {
   install(app: App) {

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

@@ -249,10 +249,16 @@ describe("roundtrip (hard_break preservation)", () => {
     ["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"],
+    [
+      "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"],
+    [
+      "properties (motivating case)",
+      "## Properties\n\nauthor:: Enesis Editor\nversion:: 0.1.0",
+    ],
     ["single line only", "line1\nline2\nline3"],
     ["empty string", ""],
   ]

+ 4 - 5
packages/editor/src/lib/__tests__/debug-tag.test.ts

@@ -1,6 +1,5 @@
-import { describe, it, expect } from "vitest"
-import { parser } from "@lezer/markdown"
-import { GFM } from "@lezer/markdown"
+import { GFM, parser } from "@lezer/markdown"
+import { describe, expect, it } from "vitest"
 import { createMarkdownExtensions } from "@/lib/markdown-extensions"
 
 const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])
@@ -13,7 +12,7 @@ describe("Debug Tag parser", () => {
     tree.iterate({
       enter(node) {
         names.push(node.name)
-      }
+      },
     })
     expect(names).toContain("Tag")
   })
@@ -25,7 +24,7 @@ describe("Debug Tag parser", () => {
     tree.iterate({
       enter(node) {
         names.push(node.name)
-      }
+      },
     })
     expect(names).toContain("Tag")
   })

+ 47 - 14
packages/editor/src/lib/__tests__/editor-operations.test.ts

@@ -1,7 +1,6 @@
-import { describe, expect, it, vi } from "vitest"
+import { describe, expect, it } 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,
@@ -12,8 +11,11 @@ import {
   setBlockContent,
   splitBlocks,
 } from "@/lib/editor-operations"
+import { classifyBlock } from "@/lib/markdown-rules/block-classifier"
 
-function block(overrides: Partial<EditorBlockData> & { content: string }): EditorBlockData {
+function block(
+  overrides: Partial<EditorBlockData> & { content: string },
+): EditorBlockData {
   return { id: "auto", depth: 0, ...overrides }
 }
 
@@ -35,14 +37,20 @@ describe("splitBlocks", () => {
     expect(result.blocks[1]).toMatchObject({ id: "b", content: "bef" })
 
     // New block inserted after
-    expect(result.blocks[2]).toMatchObject({ id: "new-0", depth: 1, content: "aft" })
+    expect(result.blocks[2]).toMatchObject({
+      id: "new-0",
+      depth: 1,
+      content: "aft",
+    })
 
     expect(result.blocks).toHaveLength(3)
     expect(result.newBlock).toMatchObject({ id: "new-0", content: "aft" })
   })
 
   it("preserves depth of the original block", () => {
-    const blocks: EditorBlockData[] = [block({ id: "a", depth: 2, content: "x" })]
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", depth: 2, content: "x" }),
+    ]
     const result = splitBlocks(blocks, 0, "a", "b", stableId("n"))
     expect(result.blocks[0].depth).toBe(2)
     expect(result.blocks[1].depth).toBe(2)
@@ -55,7 +63,9 @@ describe("splitBlocks", () => {
   })
 
   it("does not mutate the original array", () => {
-    const blocks: EditorBlockData[] = [block({ id: "a", depth: 1, content: "original" })]
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", depth: 1, content: "original" }),
+    ]
     const copy = [...blocks]
     splitBlocks(blocks, 0, "before", "after", stableId("n"))
     expect(blocks).toEqual(copy)
@@ -89,7 +99,10 @@ describe("mergePrevious", () => {
   })
 
   it("returns no change when index is 0 or out of bounds", () => {
-    const blocks: EditorBlockData[] = [block({ id: "a", content: "x" }), block({ id: "b", content: "y" })]
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", content: "x" }),
+      block({ id: "b", content: "y" }),
+    ]
     const atZero = mergePrevious(blocks, 0)
     expect(atZero.blocks).toHaveLength(2)
 
@@ -163,7 +176,9 @@ describe("deleteIfEmpty", () => {
 
 describe("indentBlock", () => {
   it("increments depth of the block", () => {
-    const blocks: EditorBlockData[] = [block({ id: "a", depth: 0, content: "x" })]
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", depth: 0, content: "x" }),
+    ]
     const result = indentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(1)
   })
@@ -185,14 +200,18 @@ describe("indentBlock", () => {
   })
 
   it("caps depth at 10", () => {
-    const blocks: EditorBlockData[] = [block({ id: "a", depth: 10, content: "x" })]
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", depth: 10, content: "x" }),
+    ]
     const result = indentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(10)
     expect(result.childIndices).toEqual([])
   })
 
   it("does not mutate the original array", () => {
-    const blocks: EditorBlockData[] = [block({ id: "a", depth: 0, content: "x" })]
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", depth: 0, content: "x" }),
+    ]
     const copy = [...blocks]
     indentBlock(blocks, 0)
     expect(blocks).toEqual(copy)
@@ -203,7 +222,9 @@ describe("indentBlock", () => {
 
 describe("outdentBlock", () => {
   it("decrements depth of the block", () => {
-    const blocks: EditorBlockData[] = [block({ id: "a", depth: 2, content: "x" })]
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", depth: 2, content: "x" }),
+    ]
     const result = outdentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(1)
   })
@@ -224,14 +245,18 @@ describe("outdentBlock", () => {
   })
 
   it("floors depth at 0", () => {
-    const blocks: EditorBlockData[] = [block({ id: "a", depth: 0, content: "x" })]
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", depth: 0, content: "x" }),
+    ]
     const result = outdentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(0)
     expect(result.childIndices).toEqual([])
   })
 
   it("does not mutate the original array", () => {
-    const blocks: EditorBlockData[] = [block({ id: "a", depth: 2, content: "x" })]
+    const blocks: EditorBlockData[] = [
+      block({ id: "a", depth: 2, content: "x" }),
+    ]
     const copy = [...blocks]
     outdentBlock(blocks, 0)
     expect(blocks).toEqual(copy)
@@ -372,7 +397,15 @@ describe("classifyBlock", () => {
   })
 
   it("classifies all task states", () => {
-    const states = ["TODO", "DOING", "DONE", "LATER", "NOW", "WAITING", "CANCELLED"]
+    const states = [
+      "TODO",
+      "DOING",
+      "DONE",
+      "LATER",
+      "NOW",
+      "WAITING",
+      "CANCELLED",
+    ]
     for (const s of states) {
       const result = classifyBlock(`${s} task`)
       expect(result.kind).toBe("task")

+ 3 - 1
packages/editor/src/lib/__tests__/formatting.test.ts

@@ -382,7 +382,9 @@ describe("insertPageRef", () => {
     const view = getView("hello ", 6)
     insertPageRef(view, "Page Name", "Alias")
     const doc = view.state.doc
-    expect(doc.textBetween(1, doc.content.size)).toBe("hello [[Page Name|Alias]]")
+    expect(doc.textBetween(1, doc.content.size)).toBe(
+      "hello [[Page Name|Alias]]",
+    )
   })
 
   it("wraps selected text as the page ref", () => {

+ 2 - 3
packages/editor/src/lib/__tests__/lezer-debug.test.ts

@@ -1,6 +1,5 @@
+import { GFM, parser } from "@lezer/markdown"
 import { describe, it } from "vitest"
-import { parser } from "@lezer/markdown"
-import { GFM } from "@lezer/markdown"
 import { createMarkdownExtensions } from "@/lib/markdown-extensions"
 
 const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])
@@ -24,7 +23,7 @@ describe("Lezer AST debug", () => {
       tree.iterate({
         enter(node) {
           console.log(
-            `${"  ".repeat(node.type.isTop ? 0 : 1)}${node.name} [${node.from}-${node.to}] "${text.slice(node.from, node.to)}"`
+            `${"  ".repeat(node.type.isTop ? 0 : 1)}${node.name} [${node.from}-${node.to}] "${text.slice(node.from, node.to)}"`,
           )
         },
       })

+ 1 - 2
packages/editor/src/lib/__tests__/lezer-structure.test.ts

@@ -1,6 +1,5 @@
+import { GFM, parser } from "@lezer/markdown"
 import { describe, it } from "vitest"
-import { parser } from "@lezer/markdown"
-import { GFM } from "@lezer/markdown"
 import { createMarkdownExtensions } from "@/lib/markdown-extensions"
 
 const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])

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

@@ -173,9 +173,11 @@ describe("default config", () => {
   })
 
   it("handles localStorage unavailable gracefully", async () => {
-    const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
-      throw new Error("storage unavailable")
-    })
+    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(() => {})

+ 8 - 4
packages/editor/src/lib/__tests__/markdown-parser.test.ts

@@ -963,7 +963,9 @@ describe("table", () => {
   })
 
   it("parses table with inline markdown in header cells", () => {
-    const result = parseMarkdown("| `code` | **bold** |\n| --- | --- |\n| a | b |")
+    const result = parseMarkdown(
+      "| `code` | **bold** |\n| --- | --- |\n| a | b |",
+    )
     expect(result.blocks).toHaveLength(1)
     expect(result.blocks[0].type).toBe("table")
   })
@@ -991,7 +993,9 @@ describe("table", () => {
   })
 
   it("handles table with inline markdown in cells", () => {
-    const result = parseMarkdown("| **bold** | *italic* |\n| --- | --- |\n| `code` | text |")
+    const result = parseMarkdown(
+      "| **bold** | *italic* |\n| --- | --- |\n| `code` | text |",
+    )
     expect(result.blocks).toHaveLength(1)
     expect(result.blocks[0].type).toBe("table")
   })
@@ -1004,7 +1008,7 @@ describe("tokenization depth exhaustion", () => {
   })
 
   it("handles 10+ layers of emphasis nesting without crashing", () => {
-    const input = "*".repeat(20) + "text" + "*".repeat(20)
+    const input = `${"*".repeat(20)}text${"*".repeat(20)}`
     const result = parseMarkdown(input)
     expect(Array.isArray(result.content)).toBe(true)
   })
@@ -1016,7 +1020,7 @@ describe("tokenization depth exhaustion", () => {
   })
 
   it("handles very long unbroken inline syntax", () => {
-    const input = "**" + "x".repeat(500) + "**"
+    const input = `**${"x".repeat(500)}**`
     const result = parseMarkdown(input)
     expect(result.content).toHaveLength(1)
     expect(result.content[0].type).toBe("strong")

+ 2 - 2
packages/editor/src/lib/__tests__/operation-history.test.ts

@@ -435,7 +435,7 @@ describe("createOperationHistory", () => {
       // Inverse should restore to the preserved previousContent
       const inverse = h.undo()
       expect(inverse).not.toBeNull()
-      expect(inverse!.type).toBe("set-block-content")
+      expect(inverse?.type).toBe("set-block-content")
       expect((inverse as any).newContent).toBe("")
       expect((inverse as any).previousContent).toBe("hello")
     })
@@ -472,7 +472,7 @@ describe("createOperationHistory", () => {
       // Redo
       const redo1 = h.redo()
       expect(redo1).not.toBeNull()
-      expect(redo1!.type).toBe("set-block-content")
+      expect(redo1?.type).toBe("set-block-content")
       expect((redo1 as any).newContent).toBe("hi there")
       expect(h.canUndo).toBe(true)
       expect(h.canRedo).toBe(false)

+ 1 - 1
packages/editor/src/lib/content-model.ts

@@ -102,7 +102,7 @@ export function contentToDoc(content: string): ProsemirrorNode {
     } else {
       // 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) {
+      if (lines[i]?.length === 0) {
         i++
         continue
       }

+ 2 - 2
packages/editor/src/lib/katex.ts

@@ -7,7 +7,7 @@ export async function renderKatex(
 ) {
   if (!mod) mod = (await import("katex")).default
   try {
-    mod!.render(source, el, { throwOnError: false, displayMode })
+    mod?.render(source, el, { throwOnError: false, displayMode })
   } catch {
     el.textContent = source
   }
@@ -23,7 +23,7 @@ export function renderKatexSync(
     return
   }
   try {
-    mod!.render(source, el, { throwOnError: false, displayMode })
+    mod?.render(source, el, { throwOnError: false, displayMode })
   } catch {
     el.textContent = source
   }

+ 2 - 5
packages/editor/src/lib/markdown-extensions.ts

@@ -247,7 +247,7 @@ class TaskParser implements LeafBlockParser {
     const markerLen = match[1]!.length
     // biome-ignore lint/style/noNonNullAssertion: match confirmed by TASK_RE above
     const matchIndex = match.index!
-    const afterMarker = matchIndex + match[0]!.length
+    const afterMarker = matchIndex + match[0]?.length
     const markerStart = leaf.start + leading + matchIndex
     cx.addLeafElement(
       leaf,
@@ -290,10 +290,7 @@ export function createMarkdownExtensions(
       ...(patternExt.defineNodes ?? []),
       { name: "InlineMath", block: false },
     ],
-    parseInline: [
-      ...(patternExt.parseInline ?? []),
-      inlineMathParser,
-    ],
+    parseInline: [...(patternExt.parseInline ?? []), inlineMathParser],
   }
   return [merged, TaskBlock]
 }

+ 4 - 1
packages/editor/src/lib/node-view-registry.ts

@@ -16,7 +16,10 @@ import type { NodeView } from "prosemirror-view"
 export type NodeViewFactory = (
   node: Node,
   view: Parameters<
-    Exclude<import("prosemirror-view").EditorProps["nodeViews"], undefined>[string]
+    Exclude<
+      import("prosemirror-view").EditorProps["nodeViews"],
+      undefined
+    >[string]
   >[1],
   getPos: () => number,
 ) => NodeView

+ 21 - 21
packages/editor/src/lib/operation-history.ts

@@ -219,28 +219,28 @@ export function computeInverse(op: EditorOperation): EditorOperation {
         timestamp: now,
       }
 
-      case "set-block-content":
-        return {
-          type: "set-block-content",
-          blockId: op.blockId,
-          previousContent: op.newContent,
-          newContent: op.previousContent,
-          timestamp: now,
-        }
-
-      // Inverse of move is move in the opposite direction.
-      case "move-block":
-        return {
-          type: "move-block",
-          fromIndex: op.toIndex,
-          toIndex: op.fromIndex,
-          blockId: op.blockId,
-          content: op.content,
-          depth: op.depth,
-          timestamp: now,
-        }
-    }
+    case "set-block-content":
+      return {
+        type: "set-block-content",
+        blockId: op.blockId,
+        previousContent: op.newContent,
+        newContent: op.previousContent,
+        timestamp: now,
+      }
+
+    // Inverse of move is move in the opposite direction.
+    case "move-block":
+      return {
+        type: "move-block",
+        fromIndex: op.toIndex,
+        toIndex: op.fromIndex,
+        blockId: op.blockId,
+        content: op.content,
+        depth: op.depth,
+        timestamp: now,
+      }
   }
+}
 
 // ── Operation history ────────────────────────────────────────────────
 

+ 7 - 1
packages/editor/src/lib/schema.ts

@@ -7,7 +7,13 @@
  */
 import { type NodeSpec, Schema } from "prosemirror-model"
 
-type DOMNode = "doc" | "paragraph" | "hard_break" | "code_block" | "math_block" | "text"
+type DOMNode =
+  | "doc"
+  | "paragraph"
+  | "hard_break"
+  | "code_block"
+  | "math_block"
+  | "text"
 
 /** Node definitions */
 const nodes: Record<DOMNode, NodeSpec> = {

+ 1 - 3
packages/editor/vite.config.ts

@@ -7,9 +7,7 @@ import { defineConfig } from "vite"
 export default defineConfig({
   plugins: [vue(), tailwindcss(), ui()],
   resolve: {
-    alias: [
-      { find: /^@\//, replacement: `${resolve(__dirname, "src")}/` },
-    ],
+    alias: [{ find: /^@\//, replacement: `${resolve(__dirname, "src")}/` }],
   },
   build: {
     lib: {

+ 1 - 3
packages/editor/vitest.config.ts

@@ -5,9 +5,7 @@ import { defineConfig } from "vitest/config"
 export default defineConfig({
   plugins: [Vue()],
   resolve: {
-    alias: [
-      { find: /^@\//, replacement: `${resolve(__dirname, "src")}/` },
-    ],
+    alias: [{ find: /^@\//, replacement: `${resolve(__dirname, "src")}/` }],
   },
   test: {
     environment: "jsdom",