Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f484b02
refactor(honeycrisp): split god factory into focused state modules an…
braden-w Mar 18, 2026
0608d90
refactor(honeycrisp): fix code smells — prop-driven NoteList, self-de…
braden-w Mar 18, 2026
2377cc2
refactor(honeycrisp): extract self-contained FolderMenuItem, remove d…
braden-w Mar 18, 2026
0d2175d
refactor(honeycrisp): inline single-use functions, update skill with …
braden-w Mar 18, 2026
6d7cfd7
refactor: inline 17 single-use handle* functions across 5 apps
braden-w Mar 19, 2026
f8e25c1
feat(svelte): add fromKv/fromTable reactive bindings, rename package …
braden-w Mar 19, 2026
f5ba4c1
refactor: replace spread-to-array with iterator helpers (.toArray(), …
braden-w Mar 19, 2026
d1aefd7
docs(skills): reorganize iterator helpers into TS skill, add reactive…
braden-w Mar 19, 2026
d88ed99
docs(skills): add CRUD and observation API to workspace-api skill
braden-w Mar 19, 2026
dbe3bc7
refactor(tsconfig): clean up base config, add base.dom.json for brows…
braden-w Mar 19, 2026
ce73115
refactor(constants): remove redundant lib from tsconfig, extend base
braden-w Mar 19, 2026
a690e24
refactor(svelte-utils): extend base.dom, remove redundant lib override
braden-w Mar 19, 2026
dbefc47
refactor(sync-client): extend base.dom, remove redundant lib override
braden-w Mar 19, 2026
0392f09
refactor(ui): extend base.dom, remove redundant lib override
braden-w Mar 19, 2026
46e8aab
refactor(filesystem,sync,workspace,cli): extend base, remove redundan…
braden-w Mar 19, 2026
2c97f5b
refactor(vault): extend base, remove redundant options
braden-w Mar 19, 2026
942e897
refactor(tab-manager-markdown): extend base, remove lib/target overrides
braden-w Mar 19, 2026
b1e6ef8
refactor(opensidian): extend base.dom + svelte-kit, remove redundant …
braden-w Mar 19, 2026
f3bb73e
refactor(yjs-size-benchmark): extend base.dom, remove redundant options
braden-w Mar 19, 2026
33749b3
refactor(tsconfig): promote sourceMap and resolveJsonModule to base
braden-w Mar 19, 2026
d788373
refactor: flatten actions namespace in state modules for consistent f…
braden-w Mar 19, 2026
c22a72a
refactor(tsconfig): add base.lib.json for library packages
braden-w Mar 19, 2026
26ff271
docs(svelte): clarify $derived caching over getters
braden-w Mar 19, 2026
da68b0a
docs(svelte): generalize $derived guidance
braden-w Mar 19, 2026
72826de
refactor(tsconfig): promote allowJs, allowImportingTsExtensions, outD…
braden-w Mar 19, 2026
0952803
refactor(sveltekit): add bun-types to all SvelteKit apps
braden-w Mar 19, 2026
0f524cc
Merge remote-tracking branch 'origin/main' into opencode/glowing-pixel
braden-w Mar 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 297 additions & 6 deletions .agents/skills/svelte/SKILL.md
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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

Expand Down Expand Up @@ -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
<!-- Bad: re-creates array on every render -->
<FujiSidebar entries={entries.values().toArray()} />

<!-- Good: cached via $derived -->
<script>
const entriesArray = $derived(entries.values().toArray());
</script>
<FujiSidebar entries={entriesArray} />
```

### 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)
Expand Down Expand Up @@ -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
<!-- BAD: Unnecessary wrapper function -->
<!-- BAD: Extracted single-use function with no JSDoc or semantic value -->
<script>
function handleShare() {
share.mutate({ id });
Expand All @@ -182,12 +281,57 @@ Never create functions prefixed with `handle` in the script tag. If the function
<Button onclick={handleShare}>Share</Button>
<Item onclick={() => handleSelectItem(item.id)} />

<!-- GOOD: Inline the logic directly -->
<!-- GOOD: Inlined at the call site -->
<Button onclick={() => share.mutate({ id })}>Share</Button>
<Item onclick={() => goto(`/items/${item.id}`)} />
```

This keeps related logic co-located with the UI element that triggers it, making the code easier to follow.
This also applies to longer handlers. If the logic is linear (guard clauses + branches, not deeply nested), inline it even if it's 10–15 lines:

```svelte
<!-- GOOD: Inlined keyboard shortcut handler -->
<svelte:window onkeydown={(e) => {
const meta = e.metaKey || e.ctrlKey;
if (!meta) return;
if (e.key === 'k') {
e.preventDefault();
commandPaletteOpen = !commandPaletteOpen;
return;
}
if (e.key === 'n') {
e.preventDefault();
notesState.createNote();
}
}} />
```

### The Exception: JSDoc + Semantic Name

Keep a single-use function extracted **only** when both conditions are met:

1. It has **JSDoc** explaining why it exists as a named unit.
2. The name provides a **clear semantic meaning** that makes the template more readable than the inlined version would be.

```svelte
<script lang="ts">
/**
* Navigate the note list with arrow keys, wrapping at boundaries.
* Operates on the flattened display-order ID list to respect date grouping.
*/
function navigateWithArrowKeys(e: KeyboardEvent) {
// 15 lines of keyboard navigation logic...
}
</script>

<!-- The semantic name communicates intent better than inlined logic would -->
<div onkeydown={navigateWithArrowKeys} tabindex="-1">
```

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

Expand Down Expand Up @@ -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
<!-- BAD: Reading global state for info the prop already carries -->
<script lang="ts">
import { viewState } from '$lib/state';
let { note }: { note: Note } = $props();

// viewState.isRecentlyDeletedView is redundant — note.deletedAt has the answer
const showRestoreActions = $derived(viewState.isRecentlyDeletedView);
</script>

<!-- GOOD: Derive from the prop itself -->
<script lang="ts">
let { note }: { note: Note } = $props();

// The note knows its own state — no global state needed
const isDeleted = $derived(note.deletedAt !== undefined);
</script>
```

### 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
<!-- SMELL: Same flag checked 3+ times -->
<script lang="ts">
const notes = $derived(
isRecentlyDeletedView ? deletedNotes : filteredNotes, // branch 1
);
</script>

{#if !isRecentlyDeletedView} <!-- branch 2 -->
<div>sort controls...</div>
{/if}

{#if isRecentlyDeletedView} <!-- branch 3 -->
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
<!-- Parent: one branch point, explicit data flow -->
{#if viewState.isRecentlyDeletedView}
<NoteList
notes={notesState.deletedNotes}
title="Recently Deleted"
showControls={false}
emptyMessage="No deleted notes"
/>
{:else}
<NoteList
notes={viewState.filteredNotes}
title={viewState.folderName}
/>
{/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
<!-- BAD: Copy-paste ×3 with only value/label changing -->
<DropdownMenu.Item onclick={() => setSortBy('dateEdited')}>
{#if sortBy === 'dateEdited'}<CheckIcon class="mr-2 size-4" />{/if}
Date Edited
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setSortBy('dateCreated')}>
{#if sortBy === 'dateCreated'}<CheckIcon class="mr-2 size-4" />{/if}
Date Created
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setSortBy('title')}>
{#if sortBy === 'title'}<CheckIcon class="mr-2 size-4" />{/if}
Title
</DropdownMenu.Item>

<!-- GOOD: Data-driven with {#each} -->
<script lang="ts">
const sortOptions = [
{ value: 'dateEdited' as const, label: 'Date Edited' },
{ value: 'dateCreated' as const, label: 'Date Created' },
{ value: 'title' as const, label: 'Title' },
];
</script>

{#each sortOptions as option}
<DropdownMenu.Item onclick={() => setSortBy(option.value)}>
{#if sortBy === option.value}
<CheckIcon class="mr-2 size-4" />
{:else}
<span class="mr-2 size-4"></span>
{/if}
{option.label}
</DropdownMenu.Item>
{/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)}
<Tooltip.Root>
<Tooltip.Trigger>
<Toggle size="sm" {pressed} onPressedChange={onToggle}>
<svelte:component this={icon} class="size-4" />
</Toggle>
</Tooltip.Trigger>
<Tooltip.Content>{label}</Tooltip.Content>
</Tooltip.Root>
{/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.
17 changes: 17 additions & 0 deletions .agents/skills/typescript/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading