ソースを参照

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 14 時間 前
コミット
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. BIN
      apps/tauri/src-tauri/icons/128x128.png
  12. BIN
      apps/tauri/src-tauri/icons/[email protected]
  13. BIN
      apps/tauri/src-tauri/icons/32x32.png
  14. BIN
      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. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/inline-formatting-blurred-chromium-darwin.png
  47. BIN
      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"])
 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)
   console.log("page-ref-clicked", pageName, alias)
 }
 }
 
 

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

@@ -1,11 +1,12 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import { Editor, EditorToolbar } from "@enesis/editor"
 import type { ToolbarMode } from "@enesis/editor"
 import type { ToolbarMode } from "@enesis/editor"
+import { Editor, EditorToolbar } from "@enesis/editor"
 import { computed, ref } from "vue"
 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(() => {
 const themeOverride = computed(() => {
   if (typeof window === "undefined") return undefined
   if (typeof window === "undefined") return undefined
@@ -32,7 +33,7 @@ const content = ref(`
 
 
   * ## Text Formatting
   * ## Text Formatting
 
 
-    Try **bold**, *italic*, \`code\`, ~~strikethrough~~, ^^highlight^^, and $\LaTeX$ math.
+    Try **bold**, *italic*, \`code\`, ~~strikethrough~~, ^^highlight^^, and $LaTeX$ math.
 
 
   * ## References
   * ## 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.`)
 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.
 Type double parentheses to search for block references.
 
 

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

@@ -1,9 +1,10 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import { Editor, EditorToolbar } from "@enesis/editor"
 import type { ToolbarMode } from "@enesis/editor"
 import type { ToolbarMode } from "@enesis/editor"
+import { Editor, EditorToolbar } from "@enesis/editor"
 import { ref } from "vue"
 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
 * ## Heading 2
 * TODO This task can be toggled from the toolbar
 * TODO This task can be toggled from the toolbar
 * [[Page refs]] and #tags are supported inline`)
 * [[Page refs]] and #tags are supported inline`)

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

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

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

@@ -7,10 +7,7 @@ export default defineNuxtConfig({
 
 
   modules: ["@nuxt/ui", "@pinia/nuxt"],
   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: {
   colorMode: {
     classSuffix: "",
     classSuffix: "",

+ 0 - 1
apps/tauri/package.json

@@ -13,7 +13,6 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@enesis/editor": "workspace:*",
     "@enesis/editor": "workspace:*",
-    "@fontsource-variable/geist": "^5.2.9",
     "@nuxt/ui": "^4.7.1",
     "@nuxt/ui": "^4.7.1",
     "@nuxtjs/color-mode": "^4.0.1",
     "@nuxtjs/color-mode": "^4.0.1",
     "@pinia/nuxt": "^0.11.3",
     "@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">
 <svg width="160" height="140" viewBox="0 0 160 140" fill="none" xmlns="http://www.w3.org/2000/svg">
   <!-- Top block -->
   <!-- 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 -->
   <!-- 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 -->
   <!-- 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 -->
   <!-- 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 -->
   <!-- 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 -->
   <!-- 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 -->
   <!-- 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>
 </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>

BIN
apps/tauri/src-tauri/icons/128x128.png


BIN
apps/tauri/src-tauri/icons/[email protected]


BIN
apps/tauri/src-tauri/icons/32x32.png


BIN
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";
 @source "../../../../packages/editor/src";
 
 
 @theme {
 @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 open = defineModel<boolean>("open", { required: true })
 
 
 const tabs = ref([
 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>
 </script>
 
 
 <template>
 <template>
   <USidebar
   <USidebar
     v-model:open="open"
     v-model:open="open"
-    side="right"
     collapsible="offcanvas"
     collapsible="offcanvas"
-    :ui="{ header: 'border-b border-default' }"
-    style="--sidebar-width: 14rem"
+    side="right"
+    :ui="{ width: 'w-80' }"
+    rail
   >
   >
     <template #header>
     <template #header>
       <UTabs
       <UTabs
@@ -38,35 +29,20 @@ const headings = ref([
       />
       />
     </template>
     </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>
     </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>
     </div>
   </USidebar>
   </USidebar>
 </template>
 </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">
 <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>
 </script>
 
 
 <template>
 <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 />
       <slot />
     </UMain>
     </UMain>
 
 
-    <!-- <AppRightSidebar v-model:open="rightOpen" /> -->
+    <AppRightSidebar v-model:open="ui.rightSidebarOpen" />
   </div>
   </div>
 </template>
 </template>

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

@@ -11,10 +11,15 @@
  * incremental re-index fast and preserving rowids for in-memory references.
  * incremental re-index fast and preserving rowids for in-memory references.
  */
  */
 
 
-import Database from "@tauri-apps/plugin-sql"
 import { splitMarkdownIntoBlocks } from "@enesis/editor"
 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 { SCHEMA_SQL } from "./schema"
-import { contentHash, detectBlockType, extractTitle, extractLinks, type ExtractedLink } from "./parse"
 
 
 export interface IndexerOptions {
 export interface IndexerOptions {
   /** Tauri invoke wrapper for listing directory contents. */
   /** 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.
  * 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.
  * 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}`)
   const db = await Database.load(`sqlite:${dbPath}`)
   await db.execute(SCHEMA_SQL)
   await db.execute(SCHEMA_SQL)
   return db
   return db
@@ -53,7 +56,7 @@ export async function indexFile(
   content: string,
   content: string,
 ): Promise<void> {
 ): Promise<void> {
   const now = Math.floor(Date.now() / 1000)
   const now = Math.floor(Date.now() / 1000)
-  const hash = contentHash(content)
+  const _hash = contentHash(content)
   const title = extractTitle(content)
   const title = extractTitle(content)
 
 
   await db.execute(
   await db.execute(
@@ -92,7 +95,16 @@ export async function indexFile(
          block_type = excluded.block_type,
          block_type = excluded.block_type,
          content_hash = excluded.content_hash,
          content_hash = excluded.content_hash,
          modified_at = excluded.modified_at`,
          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
     // 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", () => {
 describe("contentHash", () => {
   it("returns a deterministic hash for the same content", () => {
   it("returns a deterministic hash for the same content", () => {
@@ -84,7 +89,10 @@ describe("extractLinks", () => {
   it("extracts [[Page Name]] references", () => {
   it("extracts [[Page Name]] references", () => {
     const links = extractLinks("see [[Other Page]] for details")
     const links = extractLinks("see [[Other Page]] for details")
     expect(links).toHaveLength(1)
     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", () => {
   it("extracts [[Page|Alias]] references", () => {
@@ -107,20 +115,24 @@ describe("extractLinks", () => {
 
 
   it("lowercases tag targets", () => {
   it("lowercases tag targets", () => {
     const links = extractLinks("#Project-Alpha")
     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", () => {
   it("extracts multiple link types from mixed content", () => {
     const content = "Page [[Foo]] and ((id-123)) and #tag"
     const content = "Page [[Foo]] and ((id-123)) and #tag"
     const links = extractLinks(content)
     const links = extractLinks(content)
     expect(links).toHaveLength(3)
     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", () => {
   it("records correct offset positions", () => {
     const content = "text [[Foo]] text"
     const content = "text [[Foo]] text"
     const links = extractLinks(content)
     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", () => {
   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
   let hash = 0
   for (let i = 0; i < content.length; i++) {
   for (let i = 0; i < content.length; i++) {
     const chr = content.charCodeAt(i)
     const chr = content.charCodeAt(i)
-    hash = ((hash << 5) - hash) + chr
+    hash = (hash << 5) - hash + chr
     hash |= 0
     hash |= 0
   }
   }
   return hash.toString(36)
   return hash.toString(36)
@@ -16,7 +16,12 @@ export function contentHash(content: string): string {
 export function detectBlockType(content: string): string {
 export function detectBlockType(content: string): string {
   const first = content.trimStart()
   const first = content.trimStart()
   if (/^#{1,6}\s/.test(first)) return "heading"
   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 "callout"
   if (/^>\s/.test(first)) return "blockquote"
   if (/^>\s/.test(first)) return "blockquote"
   if (/^```/.test(first)) return "code"
   if (/^```/.test(first)) return "code"
@@ -29,7 +34,7 @@ export function detectBlockType(content: string): string {
 /** Extract the first `# Heading 1` from markdown content. */
 /** Extract the first `# Heading 1` from markdown content. */
 export function extractTitle(content: string): string {
 export function extractTitle(content: string): string {
   const match = content.match(/^# (.+)/m)
   const match = content.match(/^# (.+)/m)
-  return match ? match[1]!.trim() : ""
+  return match ? match[1]?.trim() : ""
 }
 }
 
 
 export interface ExtractedLink {
 export interface ExtractedLink {
@@ -44,18 +49,24 @@ export interface ExtractedLink {
  */
  */
 export function extractLinks(content: string): ExtractedLink[] {
 export function extractLinks(content: string): ExtractedLink[] {
   const links: 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 })
     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
   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 Database from "better-sqlite3"
+import { beforeAll, describe, expect, it } from "vitest"
 import { SCHEMA_SQL } from "./schema"
 import { SCHEMA_SQL } from "./schema"
 
 
 let db: Database.Database
 let db: Database.Database
@@ -11,44 +11,58 @@ beforeAll(() => {
 
 
 describe("schema integrity", () => {
 describe("schema integrity", () => {
   it("creates pages table", () => {
   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")
     expect(result?.name).toBe("pages")
   })
   })
 
 
   it("creates blocks table", () => {
   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")
     expect(result?.name).toBe("blocks")
   })
   })
 
 
   it("creates links table", () => {
   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")
     expect(result?.name).toBe("links")
   })
   })
 
 
   it("creates blocks_fts virtual table", () => {
   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")
     expect(result?.name).toBe("blocks_fts")
   })
   })
 
 
   it("enforces pages.path primary key", () => {
   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(() => {
     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()
     }).toThrow()
   })
   })
 
 
   it("creates all expected indices", () => {
   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()
     const names = indices.map((i) => i.name).sort()
     expect(names).toContain("idx_blocks_page")
     expect(names).toContain("idx_blocks_page")
     expect(names).toContain("idx_links_target")
     expect(names).toContain("idx_links_target")

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

@@ -1,56 +1,7 @@
 <script setup lang="ts">
 <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>
 </script>
 
 
 <template>
 <template>
-  <AppEditor v-model:content="content" />
+  <JournalView />
 </template>
 </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.
  * Workspace Pinia store.
  * Single source of truth for workspace state across all components.
  * 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 { 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 type Database from "@tauri-apps/plugin-sql"
+import { defineStore } from "pinia"
 import { useFileSystem } from "~/composables/useFileSystem"
 import { useFileSystem } from "~/composables/useFileSystem"
 import { useSettings } from "~/composables/useSettings"
 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
 // Track files the app wrote itself, to avoid re-indexing on our own writes
 const inFlightWrites = new Set<string>()
 const inFlightWrites = new Set<string>()
@@ -33,7 +34,8 @@ export const useWorkspaceStore = defineStore("workspace", () => {
   const loading = ref(false)
   const loading = ref(false)
   const db = ref<Database | null>(null)
   const db = ref<Database | null>(null)
 
 
-  const { readFile, writeFile, listDirectory, createDirectory } = useFileSystem()
+  const { readFile, writeFile, listDirectory, createDirectory } =
+    useFileSystem()
   const settings = useSettings()
   const settings = useSettings()
 
 
   const journals = computed(() => files.value.filter((f) => f.isJournal))
   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
     // 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",
       "fs-watch:change",
       async (event) => {
       async (event) => {
         const { path: changedPath, kind } = event.payload
         const { path: changedPath, kind } = event.payload
 
 
         // Ignore events from files we wrote ourselves
         // Ignore events from files we wrote ourselves
-        if (inFlightWrites.has(changedPath) || inFlightWrites.has(changedPath.replace("/./", "/"))) {
+        if (
+          inFlightWrites.has(changedPath) ||
+          inFlightWrites.has(changedPath.replace("/./", "/"))
+        ) {
           return
           return
         }
         }
 
 
@@ -75,7 +80,11 @@ export const useWorkspaceStore = defineStore("workspace", () => {
           }
           }
           await scanFiles()
           await scanFiles()
         } catch (e) {
         } 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) => {
       files.value = markdownFiles.map((p) => {
         const name = p.split("/").pop() ?? p
         const name = p.split("/").pop() ?? p
         const isJournal = !!name.match(/^\d{4}-\d{2}-\d{2}\.md$/)
         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 {
     } finally {
       loading.value = false
       loading.value = false
@@ -138,7 +152,10 @@ export const useWorkspaceStore = defineStore("workspace", () => {
     return await readFile(`${workspacePath.value}/${relativePath}`)
     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
     if (!workspacePath.value) return
     await writeFile(`${workspacePath.value}/${relativePath}`, content)
     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.type("hello world")
     await page.keyboard.press("Enter")
     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("hello")
     expect(content).toContain("world")
     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 focusLastBlockAtEnd(page)
     await page.keyboard.type("line1")
     await page.keyboard.type("line1")
     await page.keyboard.press("Shift+Enter")
     await page.keyboard.press("Shift+Enter")
     await page.keyboard.type("line2")
     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 }) => {
   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 focusLastBlockAtEnd(page)
     await page.keyboard.press("Enter")
     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.
     // The new empty block at the end should be focused. Backspace removes it.
     await page.keyboard.press("Backspace")
     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 }) => {
   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.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 ")
     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("First block")
     expect(content).toContain("Second 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.press("Enter")
     await page.keyboard.type("```")
     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(
     const codeBlock = page.locator(
       `[role="textbox"][data-block-id="${lastId}"] [data-codeblock-editor]`,
       `[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.press("Enter")
     await page.keyboard.type("```")
     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(
     const codeBlock = page.locator(
       `[role="textbox"][data-block-id="${lastId}"] [data-codeblock-editor]`,
       `[role="textbox"][data-block-id="${lastId}"] [data-codeblock-editor]`,
@@ -34,7 +38,9 @@ test.describe("Code block", () => {
     await codeBlock.locator(".cm-content").click()
     await codeBlock.locator(".cm-content").click()
     await page.keyboard.type("const x = 1;")
     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;")
     expect(content).toContain("const x = 1;")
   })
   })
 
 
@@ -43,8 +49,10 @@ test.describe("Code block", () => {
     await page.keyboard.press("Enter")
     await page.keyboard.press("Enter")
     await page.keyboard.type("```")
     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(
     const codeBlock = page.locator(
       `[role="textbox"][data-block-id="${lastId}"] [data-codeblock-editor]`,
       `[role="textbox"][data-block-id="${lastId}"] [data-codeblock-editor]`,
@@ -53,20 +61,26 @@ test.describe("Code block", () => {
     await codeBlock.locator(".cm-content").click()
     await codeBlock.locator(".cm-content").click()
     await page.keyboard.type("const x = 1;")
     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("```")
     expect(content).toContain("const x = 1;")
     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 focusLastBlockAtEnd(page)
     await page.keyboard.press("Enter")
     await page.keyboard.press("Enter")
     await page.keyboard.type("paragraph above")
     await page.keyboard.type("paragraph above")
     await page.keyboard.press("Enter")
     await page.keyboard.press("Enter")
     await page.keyboard.type("```")
     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(
     const codeBlock = page.locator(
       `[role="textbox"][data-block-id="${lastId}"] [data-codeblock-editor]`,
       `[role="textbox"][data-block-id="${lastId}"] [data-codeblock-editor]`,
@@ -81,7 +95,9 @@ test.describe("Code block", () => {
     // Type should go to the paragraph above
     // Type should go to the paragraph above
     await page.keyboard.type(" added 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")
     expect(content).toContain("paragraph above added above")
   })
   })
 
 
@@ -90,8 +106,10 @@ test.describe("Code block", () => {
     await page.keyboard.press("Enter")
     await page.keyboard.press("Enter")
     await page.keyboard.type("```")
     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 scoped = `[role="textbox"][data-block-id="${lastId}"]`
 
 
     const codeBlock = page.locator(`${scoped} [data-codeblock-editor]`)
     const codeBlock = page.locator(`${scoped} [data-codeblock-editor]`)
@@ -102,6 +120,8 @@ test.describe("Code block", () => {
     await page.keyboard.press("Backspace")
     await page.keyboard.press("Backspace")
     await page.waitForTimeout(100)
     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 }) => {
   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
     // 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)
     // Position cursor after "**block**" (14 chars into the paragraph text)
     await page.evaluate(
     await page.evaluate(
       ({ bid, from, to }: { bid: string; from: number; to: number }) =>
       ({ bid, from, to }: { bid: string; from: number; to: number }) =>
@@ -18,13 +20,17 @@ test.describe("Cursor boundary behavior", () => {
 
 
     await page.keyboard.type("XX")
     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")
     expect(content).toContain("XX")
   })
   })
 
 
   test("typing before bold delimiter flows continuously", async ({ page }) => {
   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
     // Position cursor right before "**block**" — "Each **" = 5 chars
     await page.evaluate(
     await page.evaluate(
       ({ bid, from, to }: { bid: string; from: number; to: number }) =>
       ({ bid, from, to }: { bid: string; from: number; to: number }) =>
@@ -34,14 +40,20 @@ test.describe("Cursor boundary behavior", () => {
 
 
     await page.keyboard.type("YY")
     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")
     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
     // 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(
     await page.evaluate(
       ({ bid }: { bid: string }) =>
       ({ bid }: { bid: string }) =>
         window.__testUtils__?.replaceContent(bid, "hello ~~world~~ test"),
         window.__testUtils__?.replaceContent(bid, "hello ~~world~~ test"),
@@ -55,7 +67,9 @@ test.describe("Cursor boundary behavior", () => {
 
 
     await page.keyboard.type("ZZ")
     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")
     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 }) => {
   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
     // Use programmatic reorderBlocks instead of HTML5 DnD, since Playwright
     // cannot reliably trigger the browser's drag-and-drop engine.
     // 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)
 
 
     // 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
     // Move block at index 2 to before block 0
     await page.evaluate(() => {
     await page.evaluate(() => {
       window.__testUtils__?.reorderBlocks(2, 0)
       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()
     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
     // Focus and type in first block
     const firstPm = page.locator(`[role="textbox"][data-block-id="${firstId}"]`)
     const firstPm = page.locator(`[role="textbox"][data-block-id="${firstId}"]`)
     await firstPm.click()
     await firstPm.click()
     await expect(firstPm).toBeFocused()
     await expect(firstPm).toBeFocused()
     await page.keyboard.type("AAA")
     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")
     expect(afterFirstType).toContain("AAA")
 
 
     // Click and type in second block
     // 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 secondPm.click()
     await expect(secondPm).toBeFocused()
     await expect(secondPm).toBeFocused()
     await page.keyboard.type("BBB")
     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")
     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) {
 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 }) => {
   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(
     await page.evaluate(
-      ({ bid }: { bid: string }) => window.__testUtils__?.replaceContent(bid, "hello"),
+      ({ bid }: { bid: string }) =>
+        window.__testUtils__?.replaceContent(bid, "hello"),
       { bid: firstId },
       { bid: firstId },
     )
     )
     await page.evaluate(
     await page.evaluate(
@@ -20,23 +23,32 @@ test.describe("History coalescing", () => {
     const before = await page.evaluate(() => window.__testUtils__?.getContent())
     const before = await page.evaluate(() => window.__testUtils__?.getContent())
     // Type rapidly — coalesced within same event tick (<500ms apart)
     // Type rapidly — coalesced within same event tick (<500ms apart)
     await page.keyboard.type("xyz")
     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)
     expect(afterType).not.toBe(before)
 
 
     // Single undo reverts all three characters
     // Single undo reverts all three characters
     await page.locator('[data-toolbar-action="undo"]').click()
     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
     // Redo restores them
     await page.locator('[data-toolbar-action="redo"]').click()
     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 }) => {
   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(
     await page.evaluate(
-      ({ bid }: { bid: string }) => window.__testUtils__?.replaceContent(bid, "hello"),
+      ({ bid }: { bid: string }) =>
+        window.__testUtils__?.replaceContent(bid, "hello"),
       { bid: firstId },
       { bid: firstId },
     )
     )
     await page.evaluate(
     await page.evaluate(
@@ -53,10 +65,14 @@ test.describe("History coalescing", () => {
 
 
     // First undo reverts only "b" (separate history entry)
     // First undo reverts only "b" (separate history entry)
     await page.locator('[data-toolbar-action="undo"]').click()
     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"
     // Second undo reverts "a"
     await page.locator('[data-toolbar-action="undo"]').click()
     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")
     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
     // Focus the PM editor
     const pm = page.locator('[role="textbox"][data-block-id]').first()
     const pm = page.locator('[role="textbox"][data-block-id]').first()
     await pm.click()
     await pm.click()
@@ -35,9 +37,7 @@ test.describe("IME composition", () => {
       }
       }
 
 
       // End composition (commit the text)
       // End composition (commit the text)
-      el.dispatchEvent(
-        new CompositionEvent("compositionend", { data: "/" }),
-      )
+      el.dispatchEvent(new CompositionEvent("compositionend", { data: "/" }))
     })
     })
 
 
     await page.waitForTimeout(200)
     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 }) => {
   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()
     expect(blockIds).toBeDefined()
-    const id = blockIds![2] // block with "References" content
+    const id = blockIds?.[2] // block with "References" content
 
 
     await page.evaluate(
     await page.evaluate(
       ({ bid }) => window.__testUtils__?.insertText(bid, " [[Test Page]]"),
       ({ bid }) => window.__testUtils__?.insertText(bid, " [[Test Page]]"),
@@ -28,10 +30,14 @@ test.describe("Live preview markers", () => {
 
 
     // The ref chip renders within the modified block
     // The ref chip renders within the modified block
     const refBlock = page.locator(`[role="textbox"][data-block-id="${id}"]`)
     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()
     const pm = page.locator('[role="textbox"][data-block-id]').first()
     await pm.focus()
     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 }) => {
   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")
     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")
     expect(content).toContain("pasted text")
   })
   })
 
 
   test("pastes multi-line uniform text into same block", async ({ page }) => {
   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")
     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("line1")
     expect(content).toContain("line2")
     expect(content).toContain("line2")
     expect(content).toContain("line3")
     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")
     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()
     const firstBlock = page.locator("[data-block-id]").first()
     await firstBlock.focus()
     await firstBlock.focus()
     await page.keyboard.type("Hello Playwright")
     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")
     expect(content).toContain("Hello Playwright")
   })
   })
 
 
   test("__testUtils__ selectText works", async ({ page }) => {
   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).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()
     const firstBlock = page.locator("[data-block-id]").first()
     await firstBlock.focus()
     await firstBlock.focus()
     await page.keyboard.type("/")
     await page.keyboard.type("/")
@@ -75,7 +77,9 @@ test.describe("Suggestion menu", () => {
     await expect(lastItem).toHaveAttribute("data-highlighted")
     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()
     const firstBlock = page.locator("[data-block-id]").first()
     await firstBlock.focus()
     await firstBlock.focus()
     await page.keyboard.type("/")
     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) {
   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(
     await page.evaluate(
       ({ bid, t }: { bid: string; t: string }) =>
       ({ bid, t }: { bid: string; t: string }) =>
         window.__testUtils__?.replaceContent(bid, t),
         window.__testUtils__?.replaceContent(bid, t),
@@ -18,8 +20,10 @@ test.describe("Toolbar", () => {
   test("bold button wraps selected text in **", async ({ page }) => {
   test("bold button wraps selected text in **", async ({ page }) => {
     await replaceFirstBlockContent(page, "bold this")
     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(
     await page.evaluate(
       ({ bid, from, to }) => window.__testUtils__?.selectText(bid, from, to),
       ({ bid, from, to }) => window.__testUtils__?.selectText(bid, from, to),
       { bid: firstId, from: 0, to: 9 },
       { bid: firstId, from: 0, to: 9 },
@@ -27,15 +31,19 @@ test.describe("Toolbar", () => {
 
 
     await page.locator('[data-toolbar-action="bold"]').click()
     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**")
     expect(content).toContain("**bold this**")
   })
   })
 
 
   test("italic button wraps selected text in *", async ({ page }) => {
   test("italic button wraps selected text in *", async ({ page }) => {
     await replaceFirstBlockContent(page, "italic this")
     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(
     await page.evaluate(
       ({ bid, from, to }) => window.__testUtils__?.selectText(bid, from, to),
       ({ bid, from, to }) => window.__testUtils__?.selectText(bid, from, to),
       { bid: firstId, from: 0, to: 11 },
       { bid: firstId, from: 0, to: 11 },
@@ -43,14 +51,21 @@ test.describe("Toolbar", () => {
 
 
     await page.locator('[data-toolbar-action="italic"]').click()
     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*")
     expect(content).toContain("*italic this*")
   })
   })
 
 
   test("undo button reverts last change", async ({ page }) => {
   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")
     await page.keyboard.type("X")
 
 
     const before = await page.evaluate(() => window.__testUtils__?.getContent())
     const before = await page.evaluate(() => window.__testUtils__?.getContent())
@@ -62,17 +77,26 @@ test.describe("Toolbar", () => {
   })
   })
 
 
   test("redo button restores undone change", async ({ page }) => {
   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")
     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="undo"]').click()
     await page.locator('[data-toolbar-action="redo"]').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)
     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 }) => {
   test("inline formatting blurred @visual", async ({ page }) => {
     await page.goto("/editor?e2e=true&marker-mode=live-preview")
     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
     // Text formatting block (index 2): italic, code, strikethrough, highlight
     await page.evaluate(
     await page.evaluate(
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
-      blockIds![2],
+      blockIds?.[2],
     )
     )
     await page.evaluate(() => window.__testUtils__?.toggleBlur())
     await page.evaluate(() => window.__testUtils__?.toggleBlur())
     await expect(page).toHaveScreenshot("inline-formatting-blurred.png")
     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 }) => {
   test("table raw pipes focused @visual", async ({ page }) => {
     await page.goto("/editor?e2e=true")
     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
     // Table block is at index 6
     await page.evaluate(
     await page.evaluate(
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
-      blockIds![6],
+      blockIds?.[6],
     )
     )
     await page.keyboard.type("| ")
     await page.keyboard.type("| ")
     await expect(page).toHaveScreenshot("table-focused.png")
     await expect(page).toHaveScreenshot("table-focused.png")
@@ -107,22 +111,26 @@ test.describe("Visual regression @visual", () => {
 
 
   test("code block focused @visual", async ({ page }) => {
   test("code block focused @visual", async ({ page }) => {
     await page.goto("/editor?e2e=true")
     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
     // Code block is at index 7
     await page.evaluate(
     await page.evaluate(
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
-      blockIds![7],
+      blockIds?.[7],
     )
     )
     await expect(page).toHaveScreenshot("code-focused.png")
     await expect(page).toHaveScreenshot("code-focused.png")
   })
   })
 
 
   test("callout block focused @visual", async ({ page }) => {
   test("callout block focused @visual", async ({ page }) => {
     await page.goto("/editor?e2e=true")
     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
     // Callout block is at index 8
     await page.evaluate(
     await page.evaluate(
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
-      blockIds![8],
+      blockIds?.[8],
     )
     )
     await expect(page).toHaveScreenshot("callout-focused.png")
     await expect(page).toHaveScreenshot("callout-focused.png")
   })
   })
@@ -157,10 +165,12 @@ test.describe("Visual regression @visual", () => {
 
 
   test("full editor after typing @visual", async ({ page }) => {
   test("full editor after typing @visual", async ({ page }) => {
     await page.goto("/editor?e2e=true")
     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(
     await page.evaluate(
       (bid: string) => window.__testUtils__?.focusAtEnd(bid),
       (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.keyboard.type("\n\n# New Content\n\nTyped by the user.")
     await page.waitForTimeout(300)
     await page.waitForTimeout(300)

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


BIN
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: {
   webServer: {
-    command:
-      "pnpm --filter @enesis/dev dev",
+    command: "pnpm --filter @enesis/dev dev",
     url: "http://localhost:5173",
     url: "http://localhost:5173",
     reuseExistingServer: !process.env.CI,
     reuseExistingServer: !process.env.CI,
     timeout: 30_000,
     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 { TextSelection } from "prosemirror-state"
+import type { EditorView } from "prosemirror-view"
 import { vi } from "vitest"
 import { vi } from "vitest"
 import { contentToDoc } from "@/lib/content-model"
 import { contentToDoc } from "@/lib/content-model"
 import { schema } from "@/lib/schema"
 import { schema } from "@/lib/schema"

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

@@ -1,6 +1,29 @@
-@import "tailwindcss";
+@reference "tailwindcss";
 @reference "@nuxt/ui";
 @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 ─────────────────────────────────────────────────── */
 /* ── Base Editor ─────────────────────────────────────────────────── */
 
 
 .ProseMirror {
 .ProseMirror {
@@ -37,12 +60,12 @@
 
 
 /* Compressed / Squeezed state when blurred in live-preview mode */
 /* Compressed / Squeezed state when blurred in live-preview mode */
 .md-decorator-hidden {
 .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;
   pointer-events: none;
 }
 }
 
 
@@ -169,12 +192,12 @@
 
 
 /* Task markers are always visible — status is critical info, not just syntax */
 /* Task markers are always visible — status is critical info, not just syntax */
 .md-task-marker.md-decorator-hidden {
 .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;
   pointer-events: none;
 }
 }
 
 
@@ -425,7 +448,7 @@
 }
 }
 
 
 .md-table-hidden {
 .md-table-hidden {
-  display: none !important;
+  display: none;
 }
 }
 
 
 .md-table-rendered {
 .md-table-rendered {
@@ -507,7 +530,7 @@
 }
 }
 
 
 .md-math-hidden {
 .md-math-hidden {
-  display: none !important;
+  display: none;
 }
 }
 
 
 .md-math-rendered {
 .md-math-rendered {
@@ -527,7 +550,7 @@
 /* ── Image ──────────────────────────────────────────────── */
 /* ── Image ──────────────────────────────────────────────── */
 
 
 .md-image-hidden {
 .md-image-hidden {
-  display: none !important;
+  display: none;
 }
 }
 
 
 .md-image-wrapper {
 .md-image-wrapper {
@@ -610,8 +633,8 @@
   .md-block-ref,
   .md-block-ref,
   .md-tag,
   .md-tag,
   .ProseMirror-gapcursor {
   .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">
 <script setup lang="ts">
-import type { EditorView } from "prosemirror-view"
 import { TextSelection } from "prosemirror-state"
 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 EditorBlock from "@/components/EditorBlock.vue"
 import EditorInsertionZone from "@/components/EditorInsertionZone.vue"
 import EditorInsertionZone from "@/components/EditorInsertionZone.vue"
 import {
 import {
@@ -26,6 +34,7 @@ import {
   serializeBlocks,
   serializeBlocks,
   splitMarkdownIntoBlocks,
   splitMarkdownIntoBlocks,
 } from "@/lib/block-parser"
 } from "@/lib/block-parser"
+import { inlineToString } from "@/lib/content-model"
 import {
 import {
   moveBlock,
   moveBlock,
   deleteIfEmpty as opDeleteIfEmpty,
   deleteIfEmpty as opDeleteIfEmpty,
@@ -36,7 +45,6 @@ import {
   splitBlocks as opSplitBlocks,
   splitBlocks as opSplitBlocks,
 } from "@/lib/editor-operations"
 } from "@/lib/editor-operations"
 import type { FormattingHandlers } from "@/lib/formatting"
 import type { FormattingHandlers } from "@/lib/formatting"
-import { inlineToString } from "@/lib/content-model"
 import { createLogger } from "@/lib/logger"
 import { createLogger } from "@/lib/logger"
 import {
 import {
   createOperationHistory,
   createOperationHistory,
@@ -58,9 +66,25 @@ const emit = defineEmits<{
   "asset-dropped": [payload: { file: File; pos: number }]
   "asset-dropped": [payload: { file: File; pos: number }]
 }>()
 }>()
 
 
+export type EditorFocusTarget =
+  | FocusTarget
+  | "first-block"
+  | "last-block"
+  | "first-zone"
+  | "last-zone"
+  | true
+
 const props = withDefaults(
 const props = withDefaults(
   defineProps<{
   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
     markerMode?: MarkerVisibilityMode
     debug?: string
     debug?: string
     theme?: ThemeInput
     theme?: ThemeInput
@@ -72,9 +96,7 @@ const props = withDefaults(
     /** Enables window.__testUtils__ for Playwright E2E tests */
     /** Enables window.__testUtils__ for Playwright E2E tests */
     testMode?: boolean
     testMode?: boolean
   }>(),
   }>(),
-  {
-    focused: false,
-  },
+  {},
 )
 )
 
 
 const themeCSSVars = computed(() => {
 const themeCSSVars = computed(() => {
@@ -426,7 +448,7 @@ function onBlockBlurHandler(event: FocusEvent) {
   // Keep handlers when focus moves to the toolbar (e.g., heading dropdown click).
   // Keep handlers when focus moves to the toolbar (e.g., heading dropdown click).
   // Only clear when focus moves outside the editor.
   // Only clear when focus moves outside the editor.
   const target = event.relatedTarget as HTMLElement | null
   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
   activeHandlers.value = null
   activeView.value = null
   activeView.value = null
   emit("blur")
   emit("blur")
@@ -694,12 +716,51 @@ function onOutdent(index: number) {
 
 
 const focusedTarget = ref<FocusTarget | null>(null)
 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) => {
 watch(registry.focusedId, (id) => {
   if (id !== null) {
   if (id !== null) {
     focusedTarget.value = { kind: "block", id }
     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) {
 function onZoneFocus(index: number) {
   focusedTarget.value = { kind: "zone", index }
   focusedTarget.value = { kind: "zone", index }
 }
 }
@@ -824,9 +885,7 @@ onMounted(() => {
       if (pmFrom === null) pmFrom = doc.content.size
       if (pmFrom === null) pmFrom = doc.content.size
       if (pmTo === null) pmTo = doc.content.size
       if (pmTo === null) pmTo = doc.content.size
       v.dispatch(
       v.dispatch(
-        v.state.tr.setSelection(
-          TextSelection.create(doc, pmFrom, pmTo),
-        ),
+        v.state.tr.setSelection(TextSelection.create(doc, pmFrom, pmTo)),
       )
       )
     },
     },
     getBlockIds() {
     getBlockIds() {
@@ -860,9 +919,9 @@ onMounted(() => {
       const end = v.state.doc.content.size
       const end = v.state.doc.content.size
       v.focus()
       v.focus()
       v.dispatch(
       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) {
     focusAtStart(blockId: string) {
@@ -878,9 +937,9 @@ onMounted(() => {
         }
         }
       })
       })
       v.dispatch(
       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() {
     toggleBlur() {
@@ -966,7 +1025,7 @@ defineExpose({
         <EditorInsertionZone
         <EditorInsertionZone
         :ref="(el: any) => { if (el) zoneRefs.set(i, el); else zoneRefs.delete(i) }"
         :ref="(el: any) => { if (el) zoneRefs.set(i, el); else zoneRefs.delete(i) }"
         :zone-index="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)"
         @activate="(content: string) => onZoneActivate(i, content)"
         @focus="onZoneFocus(i)"
         @focus="onZoneFocus(i)"
         @blur="onZoneBlur"
         @blur="onZoneBlur"
@@ -989,11 +1048,11 @@ defineExpose({
           draggable="true"
           draggable="true"
           class="flex items-center justify-center cursor-grab active:cursor-grabbing rounded transition-all"
           class="flex items-center justify-center cursor-grab active:cursor-grabbing rounded transition-all"
           data-drag-handle
           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)"
           @dragstart="onDragStart(i, block.id, block.content, block.depth, $event)"
           @dragend="onDragEnd"
           @dragend="onDragEnd"
         >
         >
-          <UIcon name="i-ph-dot-duotone" class="size-6" />
+          <UIcon name="i-lucide-grip-vertical" class="size-4" />
         </div>
         </div>
       </div>
       </div>
 
 
@@ -1002,7 +1061,7 @@ defineExpose({
           :id="block.id"
           :id="block.id"
           :on-boundary-exit="(dir) => onBlockBoundaryExit(i, dir)"
           :on-boundary-exit="(dir) => onBlockBoundaryExit(i, dir)"
           v-model:content="block.content"
           v-model:content="block.content"
-          :focused="focused && i === 0"
+          :focused="focusedTarget?.kind === 'block' && focusedTarget.id === block.id"
           :marker-mode="markerMode"
           :marker-mode="markerMode"
           :depth="block.depth"
           :depth="block.depth"
           :debug="debug"
           :debug="debug"
@@ -1039,7 +1098,8 @@ defineExpose({
     <EditorInsertionZone
     <EditorInsertionZone
       :ref="(el: any) => { if (el) zoneRefs.set(blocks.length, el); else zoneRefs.delete(blocks.length) }"
       :ref="(el: any) => { if (el) zoneRefs.set(blocks.length, el); else zoneRefs.delete(blocks.length) }"
       :zone-index="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)"
       @activate="(content: string) => onZoneActivate(blocks.length, content)"
       @focus="onZoneFocus(blocks.length)"
       @focus="onZoneFocus(blocks.length)"
       @blur="onZoneBlur"
       @blur="onZoneBlur"

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

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

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

@@ -3,6 +3,7 @@ import { nextTick, ref } from "vue"
 
 
 const props = defineProps<{
 const props = defineProps<{
   zoneIndex?: number
   zoneIndex?: number
+  isLast?: boolean
 }>()
 }>()
 
 
 const emit = defineEmits<{
 const emit = defineEmits<{
@@ -18,13 +19,6 @@ const focused = ref(false)
 const rootEl = ref<HTMLElement | null>(null)
 const rootEl = ref<HTMLElement | null>(null)
 const inputEl = ref<HTMLInputElement | 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) {
 function onKeydown(e: KeyboardEvent) {
   if (e.key === "ArrowUp") {
   if (e.key === "ArrowUp") {
     e.preventDefault()
     e.preventDefault()
@@ -40,7 +34,8 @@ function onKeydown(e: KeyboardEvent) {
 
 
   if (e.key === "Escape") {
   if (e.key === "Escape") {
     e.preventDefault()
     e.preventDefault()
-    deactivate()
+    focused.value = false
+    emit("blur")
     return
     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) {
 function onPaste(e: ClipboardEvent) {
   e.preventDefault()
   e.preventDefault()
   const text = e.clipboardData?.getData("text/plain") ?? ""
   const text = e.clipboardData?.getData("text/plain") ?? ""
   emit("activate", text)
   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
   focused.value = true
   emit("focus")
   emit("focus")
   nextTick(() => inputEl.value?.focus())
   nextTick(() => inputEl.value?.focus())
 }
 }
 
 
-function deactivate() {
+function onBlur() {
   focused.value = false
   focused.value = false
   emit("blur")
   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>
 </script>
 
 
 <template>
 <template>
   <div
   <div
     ref="rootEl"
     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-zone-index="zoneIndex"
     :data-drop-target="''"
     :data-drop-target="''"
-    :class="focused ? 'h-10' : 'h-1'"
-    @keydown="onKeydown"
+    tabindex="0"
+    role="button"
+    aria-label="Insert block"
     @paste="onPaste"
     @paste="onPaste"
-    @click="onClick"
-    @focusout="onFocusout"
+    @click="rootEl?.focus()"
+    @focus="onFocus"
     @mouseenter="hovered = true"
     @mouseenter="hovered = true"
     @mouseleave="hovered = false"
     @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>
   </div>
 </template>
 </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)"
               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
             <span
               v-if="item.suffix"
               v-if="item.suffix"

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

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 <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 type { FormattingHandlers } from "@/lib/formatting"
 import EditorToolbarContent from "./EditorToolbarContent.vue"
 import EditorToolbarContent from "./EditorToolbarContent.vue"
 
 
@@ -68,7 +68,7 @@ watch(
     if (props.mode === "fixed" || props.mode === undefined) return
     if (props.mode === "fixed" || props.mode === undefined) return
 
 
     const sel = window.getSelection()
     const sel = window.getSelection()
-    if (!sel || !sel.rangeCount) {
+    if (!sel?.rangeCount) {
       virtualRef.value = null
       virtualRef.value = null
       return
       return
     }
     }
@@ -119,7 +119,9 @@ const inputRef = ref<any>(null)
 
 
 const promptOpen = computed({
 const promptOpen = computed({
   get: () => promptKind.value !== null,
   get: () => promptKind.value !== null,
-  set: (val: boolean) => { if (!val) promptKind.value = null },
+  set: (val: boolean) => {
+    if (!val) promptKind.value = null
+  },
 })
 })
 
 
 const promptLabels = computed(() => ({
 const promptLabels = computed(() => ({
@@ -188,7 +190,8 @@ function cancelPrompt() {
       role="toolbar"
       role="toolbar"
       aria-label="Formatting toolbar"
       aria-label="Formatting toolbar"
       data-slot="base"
       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"
       :style="floatingStyles"
       @mousedown.prevent
       @mousedown.prevent
     >
     >

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

@@ -69,14 +69,49 @@ const headingItems = computed(() => {
   if (!h) return []
   if (!h) return []
   const current = currentHeading.value
   const current = currentHeading.value
   return [
   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 },
     { 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>
 </script>

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

@@ -1,7 +1,6 @@
 import { mount } from "@vue/test-utils"
 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 ──
 // ── Root cause ──
 //
 //
@@ -36,7 +35,7 @@ import Editor from "@/components/Editor.vue"
 
 
 describe("defineModel — local mode in Vue 3.5", () => {
 describe("defineModel — local mode in Vue 3.5", () => {
   it("manual ref + watch on props.content works (demonstrates correct behavior)", () => {
   it("manual ref + watch on props.content works (demonstrates correct behavior)", () => {
-    let capturedValue: unknown = undefined
+    let capturedValue: unknown
 
 
     const TestComp = defineComponent({
     const TestComp = defineComponent({
       props: {
       props: {
@@ -45,8 +44,19 @@ describe("defineModel — local mode in Vue 3.5", () => {
       },
       },
       setup(props) {
       setup(props) {
         const modelRef = ref(props.content)
         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")
         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 { EditorState } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
 import { EditorView } from "prosemirror-view"
+import { describe, expect, it, vi } from "vitest"
+import { ref } from "vue"
 import { stableId } from "@/__test-utils__"
 import { stableId } from "@/__test-utils__"
+import { createPasteHandler } from "@/composables/usePasteHandler"
 import type { PatternOpenPayload } from "@/composables/usePatternPlugin"
 import type { PatternOpenPayload } from "@/composables/usePatternPlugin"
 import {
 import {
   buildMentionGroups,
   buildMentionGroups,
@@ -10,13 +11,11 @@ import {
   useMentionGroups,
   useMentionGroups,
   useSlashGroups,
   useSlashGroups,
 } from "@/composables/useSuggestionGroups"
 } from "@/composables/useSuggestionGroups"
+import { contentToDoc } from "@/lib/content-model"
+import { insertBlock, moveBlock } from "@/lib/editor-operations"
 import type { FormattingHandlers } from "@/lib/formatting"
 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 { classifyBlock } from "@/lib/markdown-rules/block-classifier"
-import { contentToDoc } from "@/lib/content-model"
+import { createOperationHistory } from "@/lib/operation-history"
 import { schema } from "@/lib/schema"
 import { schema } from "@/lib/schema"
 
 
 function createMockSession(
 function createMockSession(
@@ -69,7 +68,7 @@ describe("Editor integration — menu routing", () => {
     expect(activeSession.value).toBeNull()
     expect(activeSession.value).toBeNull()
     onPatternOpen(createMockSession("command"))
     onPatternOpen(createMockSession("command"))
     expect(activeSession.value).not.toBeNull()
     expect(activeSession.value).not.toBeNull()
-    expect(activeSession.value!.kind).toBe("command")
+    expect(activeSession.value?.kind).toBe("command")
     onPatternClose()
     onPatternClose()
     expect(activeSession.value).toBeNull()
     expect(activeSession.value).toBeNull()
   })
   })
@@ -112,9 +111,9 @@ describe("Editor integration — menu routing", () => {
       position: { x: 50, y: 100 },
       position: { x: 50, y: 100 },
       range: { from: 0, to: 9 },
       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 history = createOperationHistory()
     const COALESCE_MS = 500
     const COALESCE_MS = 500
     let canUndo = false
     let canUndo = false
-    let canRedo = false
+    let _canRedo = false
 
 
     function handleOp(op: {
     function handleOp(op: {
       blockId: string
       blockId: string
@@ -154,15 +153,40 @@ describe("content-change-op coalescing", () => {
         })
         })
       }
       }
       canUndo = history.canUndo
       canUndo = history.canUndo
-      canRedo = history.canRedo
+      _canRedo = history.canRedo
     }
     }
 
 
     // Rapid typing — should coalesce into 1 entry
     // 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(canUndo).toBe(true)
     expect(history.peek()?.type).toBe("set-block-content")
     expect(history.peek()?.type).toBe("set-block-content")
@@ -211,9 +235,19 @@ describe("content-change-op coalescing", () => {
     }
     }
 
 
     // First burst
     // First burst
-    handleOp({ blockId: "a", previousContent: "", newContent: "hi", timestamp: 1000 })
+    handleOp({
+      blockId: "a",
+      previousContent: "",
+      newContent: "hi",
+      timestamp: 1000,
+    })
     // Pause > 500ms
     // 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
     // Two separate entries
     const first = history.undo()
     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)
     // Two separate entries (different block)
     const first = history.undo()
     const first = history.undo()
@@ -304,7 +348,12 @@ describe("content-change-op coalescing", () => {
     }
     }
 
 
     // 1. Type "hello" in block A
     // 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")
     // 2. Split at index 0 (creates block B with "world")
     history.execute({
     history.execute({
@@ -318,12 +367,17 @@ describe("content-change-op coalescing", () => {
     })
     })
 
 
     // 3. Type " test" in block B
     // 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
     // Step 3 undo — revert block B typing
     const undo1 = history.undo()
     const undo1 = history.undo()
     expect(undo1).not.toBeNull()
     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("blockId", "b")
     expect(undo1).toHaveProperty("newContent", "")
     expect(undo1).toHaveProperty("newContent", "")
     expect(history.canUndo).toBe(true)
     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)
     // Step 2 undo — revert the split (merge block B back into block A)
     const undo2 = history.undo()
     const undo2 = history.undo()
     expect(undo2).not.toBeNull()
     expect(undo2).not.toBeNull()
-    expect(undo2!.type).toBe("merge-block")
+    expect(undo2?.type).toBe("merge-block")
     expect(history.canUndo).toBe(true)
     expect(history.canUndo).toBe(true)
 
 
     // Step 1 undo — revert block A typing
     // Step 1 undo — revert block A typing
     const undo3 = history.undo()
     const undo3 = history.undo()
     expect(undo3).not.toBeNull()
     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("blockId", "a")
     expect(undo3).toHaveProperty("newContent", "")
     expect(undo3).toHaveProperty("newContent", "")
     expect(history.canUndo).toBe(false)
     expect(history.canUndo).toBe(false)
@@ -494,7 +548,7 @@ describe("suggestion menu selection", () => {
       .find((item) => item.label === "Heading 1")
       .find((item) => item.label === "Heading 1")
 
 
     expect(heading1).toBeDefined()
     expect(heading1).toBeDefined()
-    heading1!.onSelect()
+    heading1?.onSelect()
 
 
     expect(respond).toHaveBeenCalledWith({ text: "# ", mode: "replace" })
     expect(respond).toHaveBeenCalledWith({ text: "# ", mode: "replace" })
     expect(closeSession).toHaveBeenCalledWith("completed")
     expect(closeSession).toHaveBeenCalledWith("completed")
@@ -523,7 +577,7 @@ describe("suggestion menu selection", () => {
 
 
     expect(firstPage).toBeDefined()
     expect(firstPage).toBeDefined()
     expect(groups[0].id).toBe("pages")
     expect(groups[0].id).toBe("pages")
-    firstPage!.onSelect()
+    firstPage?.onSelect()
 
 
     expect(respond).toHaveBeenCalledWith({
     expect(respond).toHaveBeenCalledWith({
       text: "[[Getting Started]]",
       text: "[[Getting Started]]",

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

@@ -30,18 +30,23 @@ function createWrapper(handlers: unknown = null) {
     },
     },
     global: {
     global: {
       stubs: {
       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: {
         UModal: {
           props: ["open"],
           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: {
         UInput: {
           props: ["modelValue", "placeholder"],
           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 { EditorState } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
 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 { MathBlockView } from "@/composables/useMathBlockView"
 import { schema } from "@/lib/schema"
 import { schema } from "@/lib/schema"
 
 
@@ -36,7 +36,9 @@ function createView(content: string) {
 
 
 describe("MathBlockView", () => {
 describe("MathBlockView", () => {
   it("creates DOM structure with correct classes", () => {
   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 view = createView("$$\nE = mc^2\n$$")
 
 
     const nodeView = new MathBlockView(node, view, () => 0)
     const nodeView = new MathBlockView(node, view, () => 0)
@@ -69,7 +71,9 @@ describe("MathBlockView", () => {
 
 
     const nodeView = new MathBlockView(mathNode, view, () => 0)
     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)
     expect(nodeView.update(updatedNode)).toBe(true)
   })
   })
 
 
@@ -137,7 +141,9 @@ describe("CodeBlockView", () => {
 
 
     const nodeView = new CodeBlockView(node, view, () => 0)
     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)
     expect(nodeView.update(updatedNode)).toBe(true)
 
 
     nodeView.destroy()
     nodeView.destroy()

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

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

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

@@ -44,7 +44,7 @@ export function useKeyboard(config: KeyboardConfig) {
 
 
   function onKeydownCapture(e: KeyboardEvent) {
   function onKeydownCapture(e: KeyboardEvent) {
     const target = e.target as HTMLElement | null
     const target = e.target as HTMLElement | null
-    if (!target || !target.closest(".editor-shell")) return
+    if (!target?.closest(".editor-shell")) return
 
 
     const ctx: KeyboardContext = {
     const ctx: KeyboardContext = {
       isPaletteOpen: activeSession.value !== null,
       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 type { Node as ProsemirrorNode } from "prosemirror-model"
 import { Plugin, PluginKey, TextSelection } from "prosemirror-state"
 import { Plugin, PluginKey, TextSelection } from "prosemirror-state"
 import { Decoration, DecorationSet } from "prosemirror-view"
 import { Decoration, DecorationSet } from "prosemirror-view"
+import { inlineToString } from "@/lib/content-model"
 import { renderKatexSync } from "@/lib/katex"
 import { renderKatexSync } from "@/lib/katex"
 import { createLogger } from "@/lib/logger"
 import { createLogger } from "@/lib/logger"
-import { inlineToString } from "@/lib/content-model"
 import {
 import {
   type DecorationRange,
   type DecorationRange,
   MarkdownRuleEngine,
   MarkdownRuleEngine,
@@ -170,7 +170,7 @@ function buildCombinedText(paragraphs: ParagraphInfo[]): {
   const boundaries: number[] = [0]
   const boundaries: number[] = [0]
   let text = ""
   let text = ""
   for (let i = 0; i < paragraphs.length; i++) {
   for (let i = 0; i < paragraphs.length; i++) {
-    text += paragraphs[i]!.text
+    text += paragraphs[i]?.text
     const sepLen =
     const sepLen =
       i < paragraphs.length - 1 ? separatorBetween(paragraphs, i).length : 1
       i < paragraphs.length - 1 ? separatorBetween(paragraphs, i).length : 1
     boundaries.push(text.length + sepLen)
     boundaries.push(text.length + sepLen)
@@ -392,9 +392,7 @@ function applyTableDecorations(
               e.stopPropagation()
               e.stopPropagation()
               view.dispatch(
               view.dispatch(
                 view.state.tr.setSelection(
                 view.state.tr.setSelection(
-                  TextSelection.near(
-                    view.state.doc.resolve(tokenStart.pmPos),
-                  ),
+                  TextSelection.near(view.state.doc.resolve(tokenStart.pmPos)),
                 ),
                 ),
               )
               )
               view.focus()
               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
     if (lines.length <= 1) return false
 
 
     const classifications = lines.map((line) => classifyBlock(line))
     const classifications = lines.map((line) => classifyBlock(line))
-    const firstType = classifications[0]!.kind
+    const firstType = classifications[0]?.kind
 
 
     const isContinuationBlock = (kind: string) =>
     const isContinuationBlock = (kind: string) =>
       kind === "blockquote" &&
       kind === "blockquote" &&
@@ -53,7 +53,7 @@ export function createPasteHandler(onSplit: PasteSplitHandler) {
 
 
     let foundBreak = false
     let foundBreak = false
     for (let i = 1; i < lines.length; i++) {
     for (let i = 1; i < lines.length; i++) {
-      const kind = classifications[i]!.kind
+      const kind = classifications[i]?.kind
       const line = lines[i]!
       const line = lines[i]!
       if (!foundBreak && (kind === firstType || kind === "paragraph")) {
       if (!foundBreak && (kind === firstType || kind === "paragraph")) {
         firstGroupLines.push(line)
         firstGroupLines.push(line)

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

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

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

@@ -1,21 +1,56 @@
 import type { App } from "vue"
 import type { App } from "vue"
-import EditorBlock from "@/components/EditorBlock.vue"
 import Editor from "@/components/Editor.vue"
 import Editor from "@/components/Editor.vue"
+import EditorBlock from "@/components/EditorBlock.vue"
 import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
 import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
 import EditorToolbar from "@/components/EditorToolbar.vue"
 import EditorToolbar from "@/components/EditorToolbar.vue"
 
 
 import "katex/dist/katex.css"
 import "katex/dist/katex.css"
 import "@/assets/style.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 { 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 {
 export {
   extractEmbeddedId,
   extractEmbeddedId,
   generateBlockId,
   generateBlockId,
   splitMarkdownIntoBlocks,
   splitMarkdownIntoBlocks,
   stampBlockId,
   stampBlockId,
 } from "@/lib/block-parser"
 } from "@/lib/block-parser"
+export { moveBlock } from "@/lib/editor-operations"
 export type { FormattingHandlers } from "@/lib/formatting"
 export type { FormattingHandlers } from "@/lib/formatting"
 export type { MarkdownPattern } from "@/lib/markdown-extensions"
 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 {
 export type {
   DeleteBlockOp,
   DeleteBlockOp,
   EditorOperation,
   EditorOperation,
@@ -34,7 +69,6 @@ export {
   createOperationHistory,
   createOperationHistory,
   setTimeSource,
   setTimeSource,
 } from "@/lib/operation-history"
 } from "@/lib/operation-history"
-export { moveBlock } from "@/lib/editor-operations"
 export type {
 export type {
   EditorTheme,
   EditorTheme,
   ThemeColors,
   ThemeColors,
@@ -46,32 +80,7 @@ export {
   themePresets,
   themePresets,
   themeToCSSVars,
   themeToCSSVars,
 } from "@/lib/theme"
 } 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 {
 export default {
   install(app: App) {
   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"],
     ["triple line", "line1\nline2\nline3"],
     ["multiple paragraphs", "para1\n\npara2\n\npara3"],
     ["multiple paragraphs", "para1\n\npara2\n\npara3"],
     ["code block", "```js\nconst x = 1\n```"],
     ["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$$"],
     ["math block", "$$\nE = mc^2\n$$"],
     ["wrapped math", "text\n\n$$\na^2 + b^2 = c^2\n$$\n\nmore"],
     ["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"],
     ["single line only", "line1\nline2\nline3"],
     ["empty string", ""],
     ["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"
 import { createMarkdownExtensions } from "@/lib/markdown-extensions"
 
 
 const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])
 const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])
@@ -13,7 +12,7 @@ describe("Debug Tag parser", () => {
     tree.iterate({
     tree.iterate({
       enter(node) {
       enter(node) {
         names.push(node.name)
         names.push(node.name)
-      }
+      },
     })
     })
     expect(names).toContain("Tag")
     expect(names).toContain("Tag")
   })
   })
@@ -25,7 +24,7 @@ describe("Debug Tag parser", () => {
     tree.iterate({
     tree.iterate({
       enter(node) {
       enter(node) {
         names.push(node.name)
         names.push(node.name)
-      }
+      },
     })
     })
     expect(names).toContain("Tag")
     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 { stableId } from "@/__test-utils__"
 import type { EditorBlockData } from "@/lib/block-parser"
 import type { EditorBlockData } from "@/lib/block-parser"
-import { classifyBlock } from "@/lib/markdown-rules/block-classifier"
 import {
 import {
   deleteIfEmpty,
   deleteIfEmpty,
   indentBlock,
   indentBlock,
@@ -12,8 +11,11 @@ import {
   setBlockContent,
   setBlockContent,
   splitBlocks,
   splitBlocks,
 } from "@/lib/editor-operations"
 } 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 }
   return { id: "auto", depth: 0, ...overrides }
 }
 }
 
 
@@ -35,14 +37,20 @@ describe("splitBlocks", () => {
     expect(result.blocks[1]).toMatchObject({ id: "b", content: "bef" })
     expect(result.blocks[1]).toMatchObject({ id: "b", content: "bef" })
 
 
     // New block inserted after
     // 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.blocks).toHaveLength(3)
     expect(result.newBlock).toMatchObject({ id: "new-0", content: "aft" })
     expect(result.newBlock).toMatchObject({ id: "new-0", content: "aft" })
   })
   })
 
 
   it("preserves depth of the original block", () => {
   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"))
     const result = splitBlocks(blocks, 0, "a", "b", stableId("n"))
     expect(result.blocks[0].depth).toBe(2)
     expect(result.blocks[0].depth).toBe(2)
     expect(result.blocks[1].depth).toBe(2)
     expect(result.blocks[1].depth).toBe(2)
@@ -55,7 +63,9 @@ describe("splitBlocks", () => {
   })
   })
 
 
   it("does not mutate the original array", () => {
   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]
     const copy = [...blocks]
     splitBlocks(blocks, 0, "before", "after", stableId("n"))
     splitBlocks(blocks, 0, "before", "after", stableId("n"))
     expect(blocks).toEqual(copy)
     expect(blocks).toEqual(copy)
@@ -89,7 +99,10 @@ describe("mergePrevious", () => {
   })
   })
 
 
   it("returns no change when index is 0 or out of bounds", () => {
   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)
     const atZero = mergePrevious(blocks, 0)
     expect(atZero.blocks).toHaveLength(2)
     expect(atZero.blocks).toHaveLength(2)
 
 
@@ -163,7 +176,9 @@ describe("deleteIfEmpty", () => {
 
 
 describe("indentBlock", () => {
 describe("indentBlock", () => {
   it("increments depth of the block", () => {
   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)
     const result = indentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(1)
     expect(result.blocks[0].depth).toBe(1)
   })
   })
@@ -185,14 +200,18 @@ describe("indentBlock", () => {
   })
   })
 
 
   it("caps depth at 10", () => {
   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)
     const result = indentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(10)
     expect(result.blocks[0].depth).toBe(10)
     expect(result.childIndices).toEqual([])
     expect(result.childIndices).toEqual([])
   })
   })
 
 
   it("does not mutate the original array", () => {
   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]
     const copy = [...blocks]
     indentBlock(blocks, 0)
     indentBlock(blocks, 0)
     expect(blocks).toEqual(copy)
     expect(blocks).toEqual(copy)
@@ -203,7 +222,9 @@ describe("indentBlock", () => {
 
 
 describe("outdentBlock", () => {
 describe("outdentBlock", () => {
   it("decrements depth of the block", () => {
   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)
     const result = outdentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(1)
     expect(result.blocks[0].depth).toBe(1)
   })
   })
@@ -224,14 +245,18 @@ describe("outdentBlock", () => {
   })
   })
 
 
   it("floors depth at 0", () => {
   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)
     const result = outdentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(0)
     expect(result.blocks[0].depth).toBe(0)
     expect(result.childIndices).toEqual([])
     expect(result.childIndices).toEqual([])
   })
   })
 
 
   it("does not mutate the original array", () => {
   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]
     const copy = [...blocks]
     outdentBlock(blocks, 0)
     outdentBlock(blocks, 0)
     expect(blocks).toEqual(copy)
     expect(blocks).toEqual(copy)
@@ -372,7 +397,15 @@ describe("classifyBlock", () => {
   })
   })
 
 
   it("classifies all task states", () => {
   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) {
     for (const s of states) {
       const result = classifyBlock(`${s} task`)
       const result = classifyBlock(`${s} task`)
       expect(result.kind).toBe("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)
     const view = getView("hello ", 6)
     insertPageRef(view, "Page Name", "Alias")
     insertPageRef(view, "Page Name", "Alias")
     const doc = view.state.doc
     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", () => {
   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 { describe, it } from "vitest"
-import { parser } from "@lezer/markdown"
-import { GFM } from "@lezer/markdown"
 import { createMarkdownExtensions } from "@/lib/markdown-extensions"
 import { createMarkdownExtensions } from "@/lib/markdown-extensions"
 
 
 const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])
 const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])
@@ -24,7 +23,7 @@ describe("Lezer AST debug", () => {
       tree.iterate({
       tree.iterate({
         enter(node) {
         enter(node) {
           console.log(
           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 { describe, it } from "vitest"
-import { parser } from "@lezer/markdown"
-import { GFM } from "@lezer/markdown"
 import { createMarkdownExtensions } from "@/lib/markdown-extensions"
 import { createMarkdownExtensions } from "@/lib/markdown-extensions"
 
 
 const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])
 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 () => {
   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 { createLogger } = await freshLogger()
     const log = createLogger("Any")
     const log = createLogger("Any")
     const spy = vi.spyOn(console, "info").mockImplementation(() => {})
     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", () => {
   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).toHaveLength(1)
     expect(result.blocks[0].type).toBe("table")
     expect(result.blocks[0].type).toBe("table")
   })
   })
@@ -991,7 +993,9 @@ describe("table", () => {
   })
   })
 
 
   it("handles table with inline markdown in cells", () => {
   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).toHaveLength(1)
     expect(result.blocks[0].type).toBe("table")
     expect(result.blocks[0].type).toBe("table")
   })
   })
@@ -1004,7 +1008,7 @@ describe("tokenization depth exhaustion", () => {
   })
   })
 
 
   it("handles 10+ layers of emphasis nesting without crashing", () => {
   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)
     const result = parseMarkdown(input)
     expect(Array.isArray(result.content)).toBe(true)
     expect(Array.isArray(result.content)).toBe(true)
   })
   })
@@ -1016,7 +1020,7 @@ describe("tokenization depth exhaustion", () => {
   })
   })
 
 
   it("handles very long unbroken inline syntax", () => {
   it("handles very long unbroken inline syntax", () => {
-    const input = "**" + "x".repeat(500) + "**"
+    const input = `**${"x".repeat(500)}**`
     const result = parseMarkdown(input)
     const result = parseMarkdown(input)
     expect(result.content).toHaveLength(1)
     expect(result.content).toHaveLength(1)
     expect(result.content[0].type).toBe("strong")
     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
       // Inverse should restore to the preserved previousContent
       const inverse = h.undo()
       const inverse = h.undo()
       expect(inverse).not.toBeNull()
       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).newContent).toBe("")
       expect((inverse as any).previousContent).toBe("hello")
       expect((inverse as any).previousContent).toBe("hello")
     })
     })
@@ -472,7 +472,7 @@ describe("createOperationHistory", () => {
       // Redo
       // Redo
       const redo1 = h.redo()
       const redo1 = h.redo()
       expect(redo1).not.toBeNull()
       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((redo1 as any).newContent).toBe("hi there")
       expect(h.canUndo).toBe(true)
       expect(h.canUndo).toBe(true)
       expect(h.canRedo).toBe(false)
       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 {
     } else {
       // Non-empty line: collect consecutive non-empty lines into one paragraph
       // Non-empty line: collect consecutive non-empty lines into one paragraph
       // with hard_break nodes between them. Blank lines separate paragraphs.
       // with hard_break nodes between them. Blank lines separate paragraphs.
-      if (lines[i]!.length === 0) {
+      if (lines[i]?.length === 0) {
         i++
         i++
         continue
         continue
       }
       }

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

@@ -7,7 +7,7 @@ export async function renderKatex(
 ) {
 ) {
   if (!mod) mod = (await import("katex")).default
   if (!mod) mod = (await import("katex")).default
   try {
   try {
-    mod!.render(source, el, { throwOnError: false, displayMode })
+    mod?.render(source, el, { throwOnError: false, displayMode })
   } catch {
   } catch {
     el.textContent = source
     el.textContent = source
   }
   }
@@ -23,7 +23,7 @@ export function renderKatexSync(
     return
     return
   }
   }
   try {
   try {
-    mod!.render(source, el, { throwOnError: false, displayMode })
+    mod?.render(source, el, { throwOnError: false, displayMode })
   } catch {
   } catch {
     el.textContent = source
     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
     const markerLen = match[1]!.length
     // biome-ignore lint/style/noNonNullAssertion: match confirmed by TASK_RE above
     // biome-ignore lint/style/noNonNullAssertion: match confirmed by TASK_RE above
     const matchIndex = match.index!
     const matchIndex = match.index!
-    const afterMarker = matchIndex + match[0]!.length
+    const afterMarker = matchIndex + match[0]?.length
     const markerStart = leaf.start + leading + matchIndex
     const markerStart = leaf.start + leading + matchIndex
     cx.addLeafElement(
     cx.addLeafElement(
       leaf,
       leaf,
@@ -290,10 +290,7 @@ export function createMarkdownExtensions(
       ...(patternExt.defineNodes ?? []),
       ...(patternExt.defineNodes ?? []),
       { name: "InlineMath", block: false },
       { name: "InlineMath", block: false },
     ],
     ],
-    parseInline: [
-      ...(patternExt.parseInline ?? []),
-      inlineMathParser,
-    ],
+    parseInline: [...(patternExt.parseInline ?? []), inlineMathParser],
   }
   }
   return [merged, TaskBlock]
   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 = (
 export type NodeViewFactory = (
   node: Node,
   node: Node,
   view: Parameters<
   view: Parameters<
-    Exclude<import("prosemirror-view").EditorProps["nodeViews"], undefined>[string]
+    Exclude<
+      import("prosemirror-view").EditorProps["nodeViews"],
+      undefined
+    >[string]
   >[1],
   >[1],
   getPos: () => number,
   getPos: () => number,
 ) => NodeView
 ) => NodeView

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

@@ -219,28 +219,28 @@ export function computeInverse(op: EditorOperation): EditorOperation {
         timestamp: now,
         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 ────────────────────────────────────────────────
 // ── Operation history ────────────────────────────────────────────────
 
 

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

@@ -7,7 +7,13 @@
  */
  */
 import { type NodeSpec, Schema } from "prosemirror-model"
 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 */
 /** Node definitions */
 const nodes: Record<DOMNode, NodeSpec> = {
 const nodes: Record<DOMNode, NodeSpec> = {

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

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

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

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

+ 0 - 3
pnpm-lock.yaml

@@ -78,9 +78,6 @@ importers:
       '@enesis/editor':
       '@enesis/editor':
         specifier: workspace:*
         specifier: workspace:*
         version: link:../../packages/editor
         version: link:../../packages/editor
-      '@fontsource-variable/geist':
-        specifier: ^5.2.9
-        version: 5.2.9
       '@nuxt/ui':
       '@nuxt/ui':
         specifier: ^4.7.1
         specifier: ^4.7.1
         version: 4.7.1(@internationalized/[email protected])(@internationalized/[email protected])(@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))(@tiptap/[email protected]([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected](@vue/[email protected])([email protected]([email protected])([email protected]([email protected])))([email protected]([email protected])))([email protected]([email protected]))([email protected])([email protected])
         version: 4.7.1(@internationalized/[email protected])(@internationalized/[email protected])(@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))(@tiptap/[email protected]([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected](@vue/[email protected])([email protected]([email protected])([email protected]([email protected])))([email protected]([email protected])))([email protected]([email protected]))([email protected])([email protected])