diff --git a/.agents/skills/svelte/SKILL.md b/.agents/skills/svelte/SKILL.md
index ef644601e4..6b83dfb2eb 100644
--- a/.agents/skills/svelte/SKILL.md
+++ b/.agents/skills/svelte/SKILL.md
@@ -1,6 +1,6 @@
---
name: svelte
-description: Svelte 5 patterns including TanStack Query mutations, shadcn-svelte components, and component composition. Use when writing Svelte components, using TanStack Query, or working with shadcn-svelte UI.
+description: Svelte 5 patterns including TanStack Query mutations, SvelteMap reactive state, shadcn-svelte components, and component composition. Use when writing Svelte components, using TanStack Query, working with SvelteMap/fromTable/fromKv, or working with shadcn-svelte UI.
metadata:
author: epicenter
version: '1.0'
@@ -19,6 +19,7 @@ Use this pattern when you need to:
- Decide between `createMutation` in `.svelte` and `.execute()` in `.ts`.
- Follow shadcn-svelte import, composition, and component organization patterns.
- Refactor one-off `handle*` wrappers into inline template actions.
+- Convert SvelteMap data to arrays for derived state or component props.
# `$derived` Value Mapping: Use `satisfies Record`, Not Ternaries
@@ -73,6 +74,100 @@ Reserve `$derived.by()` for multi-statement logic where you genuinely need a fun
See `docs/articles/record-lookup-over-nested-ternaries.md` for rationale.
+# Reactive Table State Pattern
+
+When a factory function exposes workspace table data via `fromTable`, follow this three-layer convention:
+
+```typescript
+// 1. Map — reactive source (private, suffixed with Map)
+const foldersMap = fromTable(workspaceClient.tables.folders);
+
+// 2. Derived array — cached materialization (private, no suffix)
+const folders = $derived(foldersMap.values().toArray());
+
+// 3. Getter — public API (matches the derived name)
+return {
+ get folders() {
+ return folders;
+ },
+};
+```
+
+Naming: `{name}Map` (private source) → `{name}` (cached derived) → `get {name}()` (public getter).
+
+### With Sort or Filter
+
+Chain operations inside `$derived` — the entire pipeline is cached:
+
+```typescript
+const tabs = $derived(tabsMap.values().toArray().sort((a, b) => b.savedAt - a.savedAt));
+const notes = $derived(allNotes.filter((n) => n.deletedAt === undefined));
+```
+
+See the `typescript` skill for iterator helpers (`.toArray()`, `.filter()`, `.find()` on `IteratorObject`).
+
+### Template Props
+
+For component props expecting `T[]`, derive in the script block — never materialize in the template:
+
+```svelte
+
+
+
+
+
+
+```
+
+### Why `$derived`, Not a Plain Getter
+
+Put reactive computations in `$derived`, not inside public getters.
+
+A getter may still be reactive if it reads reactive state, but it recomputes on every access. `$derived` computes reactively and caches until dependencies change.
+
+Use `$derived` for the computation. Use the getter only as a pass-through to expose that derived value.
+
+See `docs/articles/derived-vs-getter-caching-matters.md` for rationale.
+
+# Reactive State Module Conventions
+
+State modules use a factory function that returns a flat object with getters and methods, exported as a singleton.
+
+```typescript
+function createBookmarkState() {
+ const bookmarksMap = fromTable(workspaceClient.tables.bookmarks);
+ const bookmarks = $derived(bookmarksMap.values().toArray());
+
+ return {
+ get bookmarks() { return bookmarks; },
+ async add(tab: Tab) { /* ... */ },
+ remove(id: BookmarkId) { /* ... */ },
+ };
+}
+
+export const bookmarkState = createBookmarkState();
+```
+
+## Naming
+
+| Concern | Convention | Example |
+|---|---|---|
+| **Export name** | `xState` for domain state; descriptive noun for utilities | `bookmarkState`, `notesState`, `deviceConfig`, `vadRecorder` |
+| **Factory function** | `createX()` matching the export name | `createBookmarkState()` |
+| **File name** | Domain name, optionally with `-state` suffix | `bookmark-state.svelte.ts`, `auth.svelte.ts` |
+
+Use the `State` suffix when the export name would collide with a key property (`bookmarkState.bookmarks`, not `bookmarks.bookmarks`).
+
+## Accessor Patterns
+
+| Data Shape | Accessor | Example |
+|---|---|---|
+| **Collection** | Named getter | `bookmarkState.bookmarks`, `notesState.notes` |
+| **Single reactive value** | `.current` (Svelte 5 convention) | `selectedFolderId.current`, `serverUrl.current` |
+| **Keyed lookup** | `.get(key)` | `toolTrustState.get(name)`, `deviceConfig.get(key)` |
+
# Mutation Pattern Preference
## In Svelte Files (.svelte)
@@ -163,12 +258,16 @@ Only use `.execute()` in Svelte files when:
2. You're performing a one-off operation
3. You need fine-grained control over async flow
-## No `handle*` Functions - Always Inline
+## Single-Use Functions: Inline or Document
-Never create functions prefixed with `handle` in the script tag. If the function is used only once and the logic isn't deeply nested, inline it directly in the template:
+If a function is defined in the script tag and used only once in the template, inline it at the call site. This applies to event handlers, callbacks, and any other single-use logic.
+
+### Why Inline?
+
+Single-use extracted functions add indirection — the reader jumps between the function definition and the template to understand what happens on click/keydown/etc. Inlining keeps cause and effect together at the point where the action happens.
```svelte
-
+
+
+
+
+```
+
+Without JSDoc and a meaningful name, extract it anyway — the indirection isn't earning its keep.
+
+### Multi-Use Functions
+
+Functions used **2 or more times** should always stay extracted — this rule only applies to single-use functions.
# Styling
@@ -385,3 +529,150 @@ When building interactive components (especially with dialogs/modals), create se
- When you find yourself passing callbacks just to update parent state
The key insight: It's perfectly fine to instantiate multiple dialogs (one per row) rather than managing a single shared dialog with complex state. Modern frameworks handle this efficiently, and the code clarity is worth it.
+
+# Prop-First Data Derivation
+
+When a component receives a prop that already carries the information needed for a decision, derive from the prop. Never reach into global state for data the component already has.
+
+```svelte
+
+
+
+
+
+```
+
+### Why This Matters
+
+- **Self-describing**: The component works correctly regardless of which view rendered it.
+- **Fewer imports**: Dropping a global state import reduces coupling.
+- **Testable**: Pass a note with `deletedAt` set and the component behaves correctly — no need to mock view state.
+
+### The Rule
+
+If the data needed for a decision is already on a prop (directly or derivable), **always** derive from the prop. Global state is for information the component genuinely doesn't have.
+
+# View-Mode Branching Limit
+
+If a component checks the same boolean flag (like `isRecentlyDeletedView`, `isEditing`, `isCompact`) in **3 or more template locations**, the component is likely serving two purposes and should be considered for extraction.
+
+```svelte
+
+
+
+{#if !isRecentlyDeletedView}
+
sort controls...
+{/if}
+
+{#if isRecentlyDeletedView}
+ No deleted notes
+{:else}
+ No notes yet
+{/if}
+```
+
+### The Fix: Push Branching Up to the Parent
+
+Move the view-mode decision to the parent. The child component takes the varying data as props:
+
+```svelte
+
+{#if viewState.isRecentlyDeletedView}
+
+{:else}
+
+{/if}
+```
+
+The child becomes dumb — it renders what it's told, with zero awareness of view modes. This keeps the branching in **one place** instead of scattered across the component tree.
+
+### The Threshold
+
+- **1–2 checks**: Acceptable — simple conditional rendering.
+- **3+ checks on the same flag**: The component is likely two views in one. Consider pushing the varying data up as props.
+
+# Data-Driven Repetitive Markup
+
+When **3 or more sequential sibling elements** follow an identical pattern with only data varying, consider extracting the data into an array and using `{#each}` or a `{#snippet}`.
+
+```svelte
+
+
setSortBy('dateEdited')}>
+ {#if sortBy === 'dateEdited'}{/if}
+ Date Edited
+
+
setSortBy('dateCreated')}>
+ {#if sortBy === 'dateCreated'}{/if}
+ Date Created
+
+
setSortBy('title')}>
+ {#if sortBy === 'title'}{/if}
+ Title
+
+
+
+
+
+{#each sortOptions as option}
+
setSortBy(option.value)}>
+ {#if sortBy === option.value}
+
+ {:else}
+
+ {/if}
+ {option.label}
+
+{/each}
+```
+
+For more complex repeated patterns (e.g., toolbar buttons with tooltips), use `{#snippet}` to define the shared structure once:
+
+```svelte
+{#snippet toggleButton(pressed: boolean, onToggle: () => void, icon: typeof BoldIcon, label: string)}
+
+
+
+
+
+
+ {label}
+
+{/snippet}
+
+{@render toggleButton(activeFormats.bold, () => editor?.chain().focus().toggleBold().run(), BoldIcon, 'Bold (⌘B)')}
+{@render toggleButton(activeFormats.italic, () => editor?.chain().focus().toggleItalic().run(), ItalicIcon, 'Italic (⌘I)')}
+```
+
+### When NOT to Extract
+
+- **2 or fewer** repetitions — extraction adds indirection without meaningful savings.
+- **Structurally similar but semantically different** — if the elements serve different purposes and might diverge, keep them separate.
diff --git a/.agents/skills/typescript/SKILL.md b/.agents/skills/typescript/SKILL.md
index deeacec3cb..b33c05d9ba 100644
--- a/.agents/skills/typescript/SKILL.md
+++ b/.agents/skills/typescript/SKILL.md
@@ -165,6 +165,23 @@ When the record is used once, inline it. When it's shared or has 5+ entries, ext
See `docs/articles/record-lookup-over-nested-ternaries.md` for rationale.
+## Iterator Helpers Over Spread
+
+TS 5.9+ with `lib: ["ESNext"]` includes TC39 Iterator Helpers (Stage 4). `MapIterator`, `SetIterator`, and `ArrayIterator` all extend `IteratorObject`, which provides `.filter()`, `.map()`, `.find()`, `.toArray()`, `.some()`, `.every()`, `.reduce()`, `.take()`, `.drop()`, `.flatMap()`, and `.forEach()`.
+
+**Prefer `.toArray()` over `[...spread]`** for materializing iterators:
+
+```typescript
+// Bad
+const all = [...map.values()];
+const active = [...map.values()].filter((n) => !n.deleted);
+
+// Good
+const all = map.values().toArray();
+const active = map.values().filter((n) => !n.deleted).toArray();
+```
+
+`.sort()` is not on `IteratorObject` (requires random access). Materialize first: `map.values().toArray().sort(fn)`.
# Type Co-location Principles
## Never Use Generic Type Buckets
diff --git a/.agents/skills/workspace-api/SKILL.md b/.agents/skills/workspace-api/SKILL.md
index 324107a5ef..d4bba66e12 100644
--- a/.agents/skills/workspace-api/SKILL.md
+++ b/.agents/skills/workspace-api/SKILL.md
@@ -1,6 +1,6 @@
---
name: workspace-api
-description: Workspace API patterns for defineTable, defineKv, versioning, and migrations. Use when defining workspace schemas, adding versions to existing tables, or writing migration functions.
+description: Workspace API patterns for defineTable, defineKv, versioning, migrations, and data access (CRUD + observation). Use when defining workspace schemas, reading/writing table data, observing changes, or writing migration functions.
metadata:
author: epicenter
version: '4.0'
@@ -10,13 +10,14 @@ metadata:
Type-safe schema definitions for tables and KV stores.
-> **Related Skills**: See `yjs` for Yjs CRDT patterns and shared types.
+> **Related Skills**: See `yjs` for Yjs CRDT patterns and shared types. See `svelte` for reactive wrappers (`fromTable`, `fromKv`).
## When to Apply This Skill
- Defining a new table or KV store with `defineTable()` or `defineKv()`
- Adding a new version to an existing table definition
- Writing table migration functions
+- Reading, writing, or observing table/KV data
## Tables
@@ -275,6 +276,49 @@ return { ...row, views: 0, _v: 2 }; // Works — contextual narrowing
return { ...row, views: 0, _v: 2 as const }; // Also works — redundant
```
+## Reading & Observing Data
+
+### Table CRUD
+
+```typescript
+table.get(id) // { status: 'valid', row } | { status: 'not_found' } | { status: 'invalid' }
+table.getAllValid() // T[] — all rows that pass schema validation
+table.set(row) // upsert full row (replaces entire row)
+table.update(id, partial) // merge partial fields into existing row
+table.delete(id) // remove row
+table.has(id) // boolean
+table.count() // number
+```
+
+### KV CRUD
+
+```typescript
+kv.get('key') // returns value (or default from defineKv)
+kv.set('key', value) // set value
+```
+
+### Observation
+
+Tables and KV stores support change observation for reactive updates:
+
+```typescript
+// Table — callback receives changed row IDs per Y.Transaction
+const unsub = tables.posts.observe((changedIds) => {
+ for (const id of changedIds) {
+ const result = tables.posts.get(id);
+ // ...
+ }
+});
+
+// KV — per-key observation
+const unsub = kv.observe('theme', (change) => {
+ if (change.type === 'set') { /* change.value */ }
+ if (change.type === 'delete') { /* fell back to default */ }
+});
+```
+
+**In Svelte apps**, prefer `fromTable`/`fromKv` from `@epicenter/svelte` instead of raw observers. See the `svelte` skill for the reactive table state pattern.
+
## Document Content (Per-Row Y.Docs)
Tables with `.withDocument()` create a content Y.Doc per row. Content is stored using a **timeline model**: a `Y.Array('timeline')` inside the Y.Doc, where each entry is a typed `Y.Map` supporting text, richtext, and sheet modes.
diff --git a/apps/fuji/package.json b/apps/fuji/package.json
index 121c77ab32..46bee21f4b 100644
--- a/apps/fuji/package.json
+++ b/apps/fuji/package.json
@@ -12,6 +12,7 @@
},
"devDependencies": {
"@epicenter/ui": "workspace:*",
+ "bun-types": "catalog:",
"@lucide/svelte": "catalog:",
"@sveltejs/adapter-static": "catalog:",
"@sveltejs/kit": "catalog:",
@@ -25,7 +26,8 @@
"typescript": "catalog:",
"vite": "catalog:"
},
- "dependencies": {
+"dependencies": {
+ "@epicenter/svelte": "workspace:*",
"@epicenter/workspace": "workspace:*",
"@tanstack/svelte-query": "catalog:",
"@tanstack/svelte-query-devtools": "catalog:",
diff --git a/apps/fuji/src/lib/components/TagInput.svelte b/apps/fuji/src/lib/components/TagInput.svelte
index a1046dcece..5f45348ae2 100644
--- a/apps/fuji/src/lib/components/TagInput.svelte
+++ b/apps/fuji/src/lib/components/TagInput.svelte
@@ -16,19 +16,6 @@
let inputValue = $state('');
- function handleKeydown(e: KeyboardEvent) {
- if (e.key === 'Enter' && inputValue.trim()) {
- e.preventDefault();
- const value = inputValue.trim().toLowerCase();
- if (!values.includes(value)) {
- onAdd(value);
- }
- inputValue = '';
- }
- if (e.key === 'Backspace' && !inputValue && values.length > 0) {
- onRemove(values[values.length - 1]!);
- }
- }
{
+ if (e.key === 'Enter' && inputValue.trim()) {
+ e.preventDefault();
+ const value = inputValue.trim().toLowerCase();
+ if (!values.includes(value)) {
+ onAdd(value);
+ }
+ inputValue = '';
+ }
+ if (e.key === 'Backspace' && !inputValue && values.length > 0) {
+ onRemove(values[values.length - 1]!);
+ }
+ }}
>
diff --git a/apps/fuji/src/routes/+page.svelte b/apps/fuji/src/routes/+page.svelte
index 602d8a9c6b..c0c9ced3d6 100644
--- a/apps/fuji/src/routes/+page.svelte
+++ b/apps/fuji/src/routes/+page.svelte
@@ -3,6 +3,7 @@
import { SidebarProvider } from '@epicenter/ui/sidebar';
import type { DocumentHandle } from '@epicenter/workspace';
import { dateTimeStringNow, generateId } from '@epicenter/workspace';
+ import { fromKv, fromTable } from '@epicenter/svelte';
import ClockIcon from '@lucide/svelte/icons/clock';
import TableIcon from '@lucide/svelte/icons/table-2';
import type * as Y from 'yjs';
@@ -14,9 +15,10 @@
// ─── Reactive State ────────────────────────────────────────────────────────────
- let entries = $state
([]);
- let selectedEntryId = $state(null);
- let viewMode = $state<'table' | 'timeline'>('table');
+ const entries = fromTable(workspaceClient.tables.entries);
+ const entriesArray = $derived(entries.values().toArray());
+ const selectedEntryId = fromKv(workspaceClient.kv, 'selectedEntryId');
+ const viewMode = fromKv(workspaceClient.kv, 'viewMode');
let currentYText = $state(null);
let currentDocHandle = $state(null);
@@ -26,48 +28,15 @@
let activeTagFilter = $state(null);
let searchQuery = $state('');
- // ─── Workspace Observation ───────────────────────────────────────────────────
-
- $effect(() => {
- entries = workspaceClient.tables.entries.getAllValid();
-
- const kvEntryId = workspaceClient.kv.get('selectedEntryId');
- selectedEntryId = kvEntryId.status === 'valid' ? kvEntryId.value : null;
-
- const kvViewMode = workspaceClient.kv.get('viewMode');
- viewMode = kvViewMode.status === 'valid' ? kvViewMode.value : 'table';
-
- const unsubEntries = workspaceClient.tables.entries.observe(() => {
- entries = workspaceClient.tables.entries.getAllValid();
- });
-
- const unsubSelectedEntry = workspaceClient.kv.observe(
- 'selectedEntryId',
- (change) => {
- selectedEntryId = change.type === 'set' ? change.value : null;
- },
- );
-
- const unsubViewMode = workspaceClient.kv.observe('viewMode', (change) => {
- viewMode = change.type === 'set' ? change.value : 'table';
- });
-
- return () => {
- unsubEntries();
- unsubSelectedEntry();
- unsubViewMode();
- };
- });
-
// ─── Derived State ───────────────────────────────────────────────────────────
const selectedEntry = $derived(
- entries.find((e) => e.id === selectedEntryId) ?? null,
+ selectedEntryId.current ? entries.get(selectedEntryId.current) ?? null : null,
);
/** Entries filtered by sidebar type/tag filters. */
const filteredEntries = $derived.by(() => {
- let result = entries;
+ let result = entriesArray;
const typeFilter = activeTypeFilter;
const tagFilter = activeTagFilter;
if (typeFilter) {
@@ -81,7 +50,7 @@
// ─── Actions ─────────────────────────────────────────────────────────────────
- function createEntry() {
+function createEntry() {
const id = generateId() as unknown as EntryId;
workspaceClient.tables.entries.set({
id,
@@ -91,38 +60,21 @@
updatedAt: dateTimeStringNow(),
_v: 2,
});
- workspaceClient.kv.set('selectedEntryId', id);
+ selectedEntryId.current = id;
}
function toggleViewMode() {
- const next = viewMode === 'table' ? 'timeline' : 'table';
- workspaceClient.kv.set('viewMode', next);
+ const next = viewMode.current === 'table' ? 'timeline' : 'table';
+ viewMode.current = next;
}
// ─── Keyboard Shortcuts ───────────────────────────────────────────────────────
- function handleKeydown(event: KeyboardEvent) {
- const isInputFocused =
- event.target instanceof HTMLInputElement ||
- event.target instanceof HTMLTextAreaElement ||
- (event.target instanceof HTMLElement && event.target.isContentEditable);
-
- if (event.key === 'n' && event.metaKey) {
- event.preventDefault();
- createEntry();
- return;
- }
-
- if (event.key === 'Escape' && !isInputFocused && selectedEntryId) {
- event.preventDefault();
- workspaceClient.kv.set('selectedEntryId', null);
- }
- }
// ─── Document Handle (Y.Text) ────────────────────────────────────────────────
$effect(() => {
- const entryId = selectedEntryId;
+ const entryId = selectedEntryId.current;
if (!entryId) {
currentYText = null;
currentDocHandle = null;
@@ -147,35 +99,60 @@
});
-
+ {
+ const isInputFocused =
+ event.target instanceof HTMLInputElement ||
+ event.target instanceof HTMLTextAreaElement ||
+ (event.target instanceof HTMLElement && event.target.isContentEditable);
+
+ if (event.key === 'n' && event.metaKey) {
+ event.preventDefault();
+ const id = generateId() as unknown as EntryId;
+ workspaceClient.tables.entries.set({
+ id,
+ title: '',
+ preview: '',
+ createdAt: dateTimeStringNow(),
+ updatedAt: dateTimeStringNow(),
+ _v: 2,
+ });
+ selectedEntryId.current = id;
+ return;
+ }
+
+ if (event.key === 'Escape' && !isInputFocused && selectedEntryId.current) {
+ event.preventDefault();
+ selectedEntryId.current = null;
+ }
+}} />
(activeTypeFilter = type)}
onFilterByTag={(tag) => (activeTagFilter = tag)}
onSearchChange={(query) => (searchQuery = query)}
- onSelectEntry={(id) => workspaceClient.kv.set('selectedEntryId', id)}
+ onSelectEntry={(id) => (selectedEntryId.current = id)}
/>
{#if selectedEntry && currentYText}
- {#key selectedEntryId}
+ {#key selectedEntryId.current}
{
- if (!selectedEntryId) return;
- workspaceClient.tables.entries.update(selectedEntryId, updates);
+ if (!selectedEntryId.current) return;
+ workspaceClient.tables.entries.update(selectedEntryId.current, updates);
}}
onPreviewChange={(preview) => {
- if (!selectedEntryId) return;
- workspaceClient.tables.entries.update(selectedEntryId, { preview });
+ if (!selectedEntryId.current) return;
+ workspaceClient.tables.entries.update(selectedEntryId.current, { preview });
}}
- onBack={() => workspaceClient.kv.set('selectedEntryId', null)}
+ onBack={() => (selectedEntryId.current = null)}
/>
{/key}
{:else if selectedEntry}
@@ -190,9 +167,9 @@
size="icon"
class="size-7"
onclick={toggleViewMode}
- title={viewMode === 'table' ? 'Switch to timeline' : 'Switch to table'}
+ title={viewMode.current === 'table' ? 'Switch to timeline' : 'Switch to table'}
>
- {#if viewMode === 'table'}
+ {#if viewMode.current === 'table'}
{:else}
@@ -200,19 +177,19 @@
- {#if viewMode === 'table'}
+ {#if viewMode.current === 'table'}