diff --git a/packages/ui/src/tree-view/types.ts b/packages/ui/src/tree-view/types.ts
new file mode 100644
index 0000000000..5aaea8b294
--- /dev/null
+++ b/packages/ui/src/tree-view/types.ts
@@ -0,0 +1,21 @@
+import type { WithChildren, WithoutChildren } from 'bits-ui';
+import type { Snippet } from 'svelte';
+import type { HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements';
+
+export type TreeViewRootProps = HTMLAttributes;
+
+export type TreeViewFolderProps = WithChildren<{
+ name: string;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ class?: string;
+ icon?: Snippet<[{ name: string; open: boolean }]>;
+}>;
+
+export type TreeViewFilePropsWithoutHTML = WithChildren<{
+ name: string;
+ icon?: Snippet<[{ name: string }]>;
+}>;
+
+export type TreeViewFileProps = WithoutChildren &
+ TreeViewFilePropsWithoutHTML;
diff --git a/packages/ui/src/utils.ts b/packages/ui/src/utils.ts
index 50338df890..848565bb60 100644
--- a/packages/ui/src/utils.ts
+++ b/packages/ui/src/utils.ts
@@ -1,13 +1,9 @@
-/*
- Installed from @ieedan/shadcn-svelte-extras
-*/
-
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
-export type WithElementRef = T & {
- ref?: null | U;
-};
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
// biome-ignore lint/suspicious/noExplicitAny: inherited from shadcn-svelte
export type WithoutChild = T extends { child?: any } ? Omit : T;
@@ -16,6 +12,6 @@ export type WithoutChildren = T extends { children?: any }
? Omit
: T;
export type WithoutChildrenOrChild = WithoutChildren>;
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
-}
+export type WithElementRef = T & {
+ ref?: U | null;
+};
diff --git a/packages/workspace/AGENTS.md b/packages/workspace/AGENTS.md
index c48c88dfd0..c2c8a7ec4d 100644
--- a/packages/workspace/AGENTS.md
+++ b/packages/workspace/AGENTS.md
@@ -8,6 +8,10 @@ Core library shared across apps.
- All functions return `Result` types
- Use Bun for everything (see below)
+## Content Model
+
+`handle.read()`/`handle.write()` on `DocumentHandle` use raw `Y.Text('content')`, which is NOT the same shared type as the filesystem's timeline (`Y.Array('timeline')`). Apps using `@epicenter/filesystem` must use `fs.content.read/write` for timeline-backed content access. Direct `handle.ydoc.getText('content')` access is an anti-pattern. See `specs/20260313T224500-unify-document-content-model.md`.
+
## Bun Usage
Default to Bun instead of Node.js:
diff --git a/packages/workspace/README.md b/packages/workspace/README.md
index cd82ceacf0..3704dc7183 100644
--- a/packages/workspace/README.md
+++ b/packages/workspace/README.md
@@ -1477,6 +1477,11 @@ client.whenReady; // Promise for async initialization
await client.destroy(); // Cleanup resources
```
+### Document Content Model
+
+> **Important**: Tables with `.withDocument()` create per-row content Y.Docs. These use a **timeline model** (`Y.Array('timeline')`) in `@epicenter/filesystem` for multi-format content (text, binary, sheet). `DocumentHandle.read()`/`write()` use a different shared type (`Y.Text('content')`) and are not timeline-aware. Apps using the filesystem package should use `fs.content.read()`/`fs.content.write()` for content access. Accessing `handle.ydoc.getText('content')` or `handle.ydoc.getXmlFragment('content')` directly is discouraged—use `createTimeline(ydoc)` from `@epicenter/filesystem` instead. See `specs/20260313T224500-unify-document-content-model.md`.
+
+
### Column Schemas
```typescript
diff --git a/packages/workspace/src/workspace/README.md b/packages/workspace/src/workspace/README.md
index 3444b538b1..ebbff74bc9 100644
--- a/packages/workspace/src/workspace/README.md
+++ b/packages/workspace/src/workspace/README.md
@@ -103,6 +103,14 @@ if (result.status === 'valid') {
For detailed rationale on all of this, see [the guide](docs/articles/20260127T120000-static-workspace-api-guide.md).
+## Document Content
+
+Tables with `.withDocument()` create per-row Y.Docs for content. These Y.Docs use a **timeline model** (`Y.Array('timeline')` with nested typed entries) in the filesystem package.
+
+**Important**: `DocumentHandle.read()`/`write()` currently use `Y.Text('content')`, which is a different shared type than the timeline. If your app uses `@epicenter/filesystem`, prefer `fs.content.read()`/`fs.content.write()` for timeline-backed access. Accessing `handle.ydoc.getText('content')` directly is also discouraged.
+
+See `specs/20260313T224500-unify-document-content-model.md` for the full unification plan and anti-pattern reference.
+
## Testing
The tests are in `*.test.ts` files next to the implementation. Use `new Y.Doc()` for in-memory tests. Migrations are validated by reading old data and checking the result. Look at existing tests for patterns.
diff --git a/packages/workspace/src/workspace/types.ts b/packages/workspace/src/workspace/types.ts
index 32bb396ad9..613b83d558 100644
--- a/packages/workspace/src/workspace/types.ts
+++ b/packages/workspace/src/workspace/types.ts
@@ -251,15 +251,45 @@ export type ClaimedDocumentColumns<
* All operations are scoped to this specific document. Content methods
* (read, write) are synchronous because the Y.Doc is already open.
* Exports are a property, not a function, because they belong to this doc.
+ *
+ * **Content model warning**: `read()` and `write()` operate on a raw
+ * `Y.Text('content')` shared type, which is NOT the same as the filesystem's
+ * timeline (`Y.Array('timeline')`). If your app uses the filesystem package,
+ * prefer `fs.content.read()`/`fs.content.write()` instead. Direct `handle.ydoc`
+ * access for content (e.g. `handle.ydoc.getText('content')`) is also discouraged—
+ * use `createTimeline(handle.ydoc)` from `@epicenter/filesystem` instead.
+ *
+ * See `specs/20260313T224500-unify-document-content-model.md` for the unification plan.
*/
export type DocumentHandle = {
- /** The raw Y.Doc — escape hatch for custom operations (timelines, binary, sheets). */
+ /**
+ * The underlying Y.Doc for this document.
+ *
+ * Use for genuinely custom shared types (awareness, cursors, non-content data).
+ * For content operations, prefer the filesystem's timeline-backed helpers
+ * (`fs.content.read/write` or `createTimeline(ydoc)`) over raw shared type
+ * access like `ydoc.getText('content')` or `ydoc.getXmlFragment('content')`.
+ */
ydoc: Y.Doc;
- /** Read the document's text content (from `ydoc.getText('content')`). */
+ /**
+ * Read the document's text content from `ydoc.getText('content')`.
+ *
+ * **Warning**: This reads from a raw `Y.Text('content')` shared type, NOT the
+ * filesystem's timeline. If your app uses `@epicenter/filesystem`, prefer
+ * `fs.content.read(id)` which reads from the timeline. This method will be
+ * unified with the timeline in a future version.
+ */
read(): string;
- /** Replace the document's text content. */
+ /**
+ * Replace the document's text content in `ydoc.getText('content')`.
+ *
+ * **Warning**: This writes to a raw `Y.Text('content')` shared type, NOT the
+ * filesystem's timeline. If your app uses `@epicenter/filesystem`, prefer
+ * `fs.content.write(id, text)` which writes to the timeline. This method will
+ * be unified with the timeline in a future version.
+ */
write(text: string): void;
/**
diff --git a/specs/20260219T094400-migrate-filesystem-to-document-binding.md b/specs/20260219T094400-migrate-filesystem-to-document-binding.md
index e0ac4e18bb..04db63dcf2 100644
--- a/specs/20260219T094400-migrate-filesystem-to-document-binding.md
+++ b/specs/20260219T094400-migrate-filesystem-to-document-binding.md
@@ -1,6 +1,7 @@
# Migrate Filesystem Package to Document Binding API
> **Note**: The `.docs` access pattern described here was replaced by `client.documents` — see specs/20260221T204200-documents-top-level-namespace.md
+> **Content model note**: The "two content access paths" design described in this spec's review section is superseded by `specs/20260313T224500-unify-document-content-model.md`. The dual model (handle Y.Text vs filesystem timeline) causes silent data loss and is being unified on the timeline model.
**Date**: 2026-02-19
**Status**: Complete
diff --git a/specs/20260313T143100-opensidian-ui-idiomaticity.md b/specs/20260313T143100-opensidian-ui-idiomaticity.md
new file mode 100644
index 0000000000..dea4b96d39
--- /dev/null
+++ b/specs/20260313T143100-opensidian-ui-idiomaticity.md
@@ -0,0 +1,187 @@
+# OpenSidian UI Idiomaticity
+
+**Date**: 2026-03-13
+**Status**: Implemented
+**Author**: AI-assisted
+
+## Overview
+
+Fix specific non-idiomatic patterns in OpenSidian's UI where hand-written markup reimplements existing shadcn-svelte primitives from `@epicenter/ui`. These are targeted, mechanical fixes—not new features.
+
+## Motivation
+
+### Current State
+
+OpenSidian has 10 components built on `@epicenter/ui` primitives (Collapsible, ContextMenu, Dialog, AlertDialog, Breadcrumb, Button, Resizable, ScrollArea, Separator). The overall structure is sound, but several components bypass available primitives with manual implementations.
+
+**Problem 1: Raw `` with reimplemented styling**
+
+`CreateDialog.svelte` and `RenameDialog.svelte` use raw `` elements with manually copied shadcn classes:
+
+```svelte
+
+
+```
+
+This is literally the `Input` component's class string copy-pasted. If the design system updates Input styling, these dialogs won't pick up the change.
+
+**Problem 2: Inline SVG icons**
+
+`TreeNode.svelte` defines SVG icons inline (ChevronRight, Folder, FolderOpen, File). Each is 5–10 lines of SVG markup hardcoded in the template. The monorepo already uses icon components elsewhere.
+
+**Problem 3: Missing `Textarea` component**
+
+`ContentEditor.svelte` uses a raw `