Compare commits

..
Author SHA1 Message Date
Frederic CabassutandClaude Opus 4.6 4fcb21d191 feat: enable PWA offline support with service worker
Add SvelteKitPWA plugin to generate a service worker that precaches
static assets and uses NetworkFirst for API routes, enabling the app
to load offline. Generate pretzel emoji PWA icons and remove the old
static manifest (now generated by the plugin).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:03:34 +01:00
Frederic CabassutandClaude Opus 4.6 cc92652358 feat: trigger Dokploy redeploy after release
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:55:45 +01:00
Frederic CabassutandClaude Opus 4.6 c27b391a72 fix: add .dockerignore to prevent host node_modules in image
Excludes node_modules, build artifacts, and test data from the Docker
build context. Fixes invalid ELF header error when building on macOS
for Linux containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:29:10 +01:00
Frederic CabassutandClaude Opus 4.6 931e106da8 fix: add workflow_call trigger to CI for reuse by release workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:25:54 +01:00
Frederic CabassutandClaude Opus 4.6 679599c721 chore: bump version to 0.1.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:24:59 +01:00
Frederic CabassutandClaude Opus 4.6 bf8aeb32fa fix: type error on tiptap-markdown storage access
Cast editor storage to Record<string, any> since tiptap-markdown
does not ship type declarations for its storage extension.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:24:25 +01:00
Frederic CabassutandClaude Opus 4.6 60b2347c91 feat: push Docker image to GitHub Container Registry on release
Replace generic registry secrets with ghcr.io using the built-in
GITHUB_TOKEN. Use docker/metadata-action for automatic semver tagging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:19:12 +01:00
Frederic CabassutandClaude Opus 4.6 3bde75592f feat: rename "notes" to "crumbs" throughout the UI
Replace all user-facing "note(s)" with "crumb(s)" — sidebar, search
placeholder, editor placeholders, empty states. Use Lucide icons
(StickyNote, Archive, Trash2, Tag) in sidebar for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:17:30 +01:00
Frederic CabassutandClaude Opus 4.6 1adb58c6b0 feat: add sticky footer with branding
Add "Crumbs by Bretzel — made with 🥨 in Strasbourg" footer pinned
to the bottom of the page using flexbox layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:08:51 +01:00
Frederic CabassutandClaude Opus 4.6 c619515ca6 feat: remove dark mode support
Remove ThemeToggle component, theme store, dark: Tailwind variants,
and dark color values from NOTE_COLORS. Simplify color logic to use
a single bg property. Delete dark mode e2e tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:07:18 +01:00
Frederic CabassutandClaude Opus 4.6 6f3f7c7e03 fix: undo/redo buttons now reactively update on editor changes
Add a tick counter that increments on every TipTap transaction,
propagated from TiptapEditor through NoteEditor to FormattingToolbar.
This forces Svelte 5 to re-evaluate canUndo/canRedo state in the
template since plain object method calls aren't tracked reactively.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:00:58 +01:00
Frederic CabassutandClaude Opus 4.6 26b3ae26c3 feat: icon-based formatting toolbar with dropdowns and link popover
Replace plain text toolbar labels with Lucide SVG icons. Group heading,
list, and alignment options into dropdown menus to reduce toolbar width.
Replace window.prompt link insertion with an inline popover (input, apply,
open, remove). Add underline and text-align TipTap extensions. Remove
task list from TipTap editor (broken styling). Fix task list bullet
rendering in note card preview. Increase editor min-height for markdown
mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:50:52 +01:00
Frederic CabassutandClaude Opus 4.6 6a2e54497f feat: replace textarea with TipTap rich text editor
- Add TipTap WYSIWYG editor (headless @tiptap/core) with StarterKit,
  TaskList, TaskItem, Link, Placeholder, and tiptap-markdown extensions
- Always-visible formatting toolbar with undo/redo, H1-H3, bold, italic,
  strikethrough, inline code, code block, blockquote, bullet/ordered/task
  lists, horizontal rule, and link
- Raw markdown toggle for power users
- Render rich text (HTML) on note cards instead of stripped plain text
- Update all e2e tests for TipTap contenteditable editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:24:33 +01:00
Frederic CabassutandClaude Opus 4.6 19c2c7435a fix: render checklist items with checkboxes on note cards
Checklist-mode notes now display as a visual checklist preview (up to
8 items with overflow indicator) instead of raw markdown checkbox syntax.
Regular notes keep the truncated plain-text preview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:32:53 +01:00
Frederic CabassutandClaude Opus 4.6 cbe2a12f7c fix: flaky search e2e test due to duplicate title across test suites
Use a unique title "Grocery Errands" instead of "Shopping List" which
collided with the checklist test suite in the shared test database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:26:54 +01:00
Frederic CabassutandClaude Opus 4.6 26cae27857 docs: deduplicate README, add OpenAPI spec with auto-generated API docs
- Slim README to essentials, link to docs/ for details
- Add docs/openapi.yaml as single source of truth for the API
- Add scripts/generate-api-docs.ts to produce docs/API.md from the spec
- Add pnpm docs:api / make docs-api target

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:24:29 +01:00
Claude 66c91df983 feat: Crumbs by Bretzel — self-hostable notes app MVP
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:16:45 +01:00
232 changed files with 2156 additions and 26782 deletions
-423
View File
@@ -1,423 +0,0 @@
---
name: drizzle-migrations
description: Drizzle ORM schema management and SQLite migrations — adding tables, modifying columns, creating indexes, generating and running migrations, Drizzle query patterns. NOT for Prisma, TypeORM,
Sequelize, or raw SQL migration tools.
allowed-tools: Read,Write,Edit,Bash(npm:*,npx:*)
metadata:
category: Data & Analytics
tags:
- database
- drizzle
- migrations
pairs-with:
- skill: database-design-patterns
reason: Schema migration implementation follows database design pattern decisions
- skill: supabase-admin
reason: Drizzle migrations run against Supabase PostgreSQL databases with RLS considerations
- skill: fullstack-debugger
reason: Migration failures and schema mismatches are common fullstack debugging scenarios
---
# Drizzle ORM Migrations
This skill helps you manage database schema changes using Drizzle ORM with SQLite.
## When to Use
✅ **USE this skill for:**
- Adding new tables or modifying existing columns
- Generating and running database migrations
- Drizzle-specific query patterns and relations
- SQLite schema best practices with Drizzle
- Setting up Drizzle configuration
❌ **DO NOT use for:**
- Supabase/PostgreSQL → use `supabase-admin` skill
- Raw SQL without Drizzle → use standard SQL resources
- Prisma ORM → different syntax and patterns
- General database design theory → use database architecture resources
## Project Setup
**Configuration**: `drizzle.config.ts`
```typescript
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: './data/app.db',
},
});
```
**Commands**:
```bash
npm run db:generate # Generate migration files
npm run db:push # Push schema directly (dev only)
npm run db:studio # Open Drizzle Studio GUI
```
## Schema Definition
Location: `src/db/schema.ts`
### Table Definition
```typescript
import { sqliteTable, text, integer, real, blob } from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';
// Basic table
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
username: text('username').notNull(),
passwordHash: text('password_hash'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at'),
});
// Table with foreign key
export const checkIns = sqliteTable('check_ins', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, {
onDelete: 'cascade',
}),
mood: integer('mood').notNull(),
cravingLevel: integer('craving_level').notNull(),
sleepHours: real('sleep_hours'),
notes: text('notes'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
// Table with composite index
export const auditLog = sqliteTable('audit_log', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
action: text('action').notNull(),
targetType: text('target_type'),
targetId: text('target_id'),
details: text('details'), // JSON string
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
}, (table) => ({
userActionIdx: index('idx_audit_user_action').on(table.userId, table.action),
createdAtIdx: index('idx_audit_created').on(table.createdAt),
}));
```
### Relations
```typescript
export const usersRelations = relations(users, ({ many }) => ({
checkIns: many(checkIns),
sessions: many(sessions),
journalEntries: many(journalEntries),
}));
export const checkInsRelations = relations(checkIns, ({ one }) => ({
user: one(users, {
fields: [checkIns.userId],
references: [users.id],
}),
}));
```
## Column Types
### SQLite Types in Drizzle
```typescript
import {
sqliteTable,
text, // TEXT - strings, JSON, dates
integer, // INTEGER - numbers, booleans (0/1)
real, // REAL - floating point
blob, // BLOB - binary data
} from 'drizzle-orm/sqlite-core';
const examples = sqliteTable('examples', {
// Strings
name: text('name').notNull(),
description: text('description'),
// Numbers
count: integer('count').notNull().default(0),
rating: real('rating'),
// Booleans (stored as 0/1)
isActive: integer('is_active', { mode: 'boolean' }).default(true),
// Dates (stored as ISO strings)
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: text('expires_at'),
// JSON (stored as TEXT)
metadata: text('metadata', { mode: 'json' }),
// Enums (stored as TEXT)
status: text('status', { enum: ['pending', 'active', 'archived'] }),
});
```
## Migration Strategies
### Strategy 1: Push (Development Only)
```bash
npm run db:push
```
- Directly applies schema changes
- Fast for development
- **Never use in production**
### Strategy 2: Generate & Migrate (Production)
```bash
# 1. Generate migration file
npm run db:generate
# 2. Review generated SQL in /drizzle folder
# 3. Apply migration (in code or manually)
```
### Applying Migrations in Code
```typescript
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import Database from 'better-sqlite3';
const sqlite = new Database('./data/app.db');
const db = drizzle(sqlite);
// Run migrations
migrate(db, { migrationsFolder: './drizzle' });
```
## Common Schema Changes
### Adding a New Table
```typescript
// 1. Add to schema.ts
export const newFeature = sqliteTable('new_feature', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
name: text('name').notNull(),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
// 2. Add relations
export const newFeatureRelations = relations(newFeature, ({ one }) => ({
user: one(users, {
fields: [newFeature.userId],
references: [users.id],
}),
}));
// 3. Generate migration
// npm run db:generate
```
### Adding a Column
```typescript
// In schema.ts, add the new column
export const users = sqliteTable('users', {
// existing columns...
newColumn: text('new_column'), // Add this
});
// Generate migration
// npm run db:generate
```
### Adding an Index
```typescript
export const messages = sqliteTable('messages', {
id: text('id').primaryKey(),
conversationId: text('conversation_id').notNull(),
createdAt: text('created_at').notNull(),
}, (table) => ({
// Add index
convCreatedIdx: index('idx_messages_conv_created')
.on(table.conversationId, table.createdAt),
}));
```
### Renaming (Requires Manual SQL)
SQLite doesn't support direct column renames in older versions. For complex changes:
```sql
-- drizzle/XXXX_rename_column.sql
-- Manual migration for column rename
-- 1. Create new table with desired schema
CREATE TABLE users_new (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, -- renamed from username
created_at TEXT NOT NULL
);
-- 2. Copy data
INSERT INTO users_new SELECT id, email, username, created_at FROM users;
-- 3. Drop old table
DROP TABLE users;
-- 4. Rename new table
ALTER TABLE users_new RENAME TO users;
```
## Query Patterns
### Basic Queries
```typescript
import { db } from '@/db';
import { eq, and, or, desc, asc, like, gte, lte } from 'drizzle-orm';
import { users, checkIns } from '@/db/schema';
// Select all
const allUsers = await db.select().from(users);
// Select with conditions
const activeUsers = await db
.select()
.from(users)
.where(eq(users.isActive, true));
// Select specific columns
const userEmails = await db
.select({ id: users.id, email: users.email })
.from(users);
// Complex where clause
const results = await db
.select()
.from(checkIns)
.where(
and(
eq(checkIns.userId, userId),
gte(checkIns.createdAt, startDate),
lte(checkIns.createdAt, endDate)
)
)
.orderBy(desc(checkIns.createdAt))
.limit(30);
```
### Insert
```typescript
// Single insert
const [newUser] = await db
.insert(users)
.values({
id: generateId(),
email: 'user@example.com',
username: 'newuser',
})
.returning();
// Bulk insert
await db.insert(checkIns).values([
{ id: '1', userId, mood: 7, cravingLevel: 2 },
{ id: '2', userId, mood: 8, cravingLevel: 1 },
]);
// Upsert (insert or update)
await db
.insert(users)
.values({ id: 'user-1', email: 'new@example.com' })
.onConflictDoUpdate({
target: users.id,
set: { email: 'new@example.com' },
});
```
### Update
```typescript
await db
.update(users)
.set({ username: 'newname', updatedAt: new Date().toISOString() })
.where(eq(users.id, userId));
```
### Delete
```typescript
// Always use WHERE clause!
await db
.delete(checkIns)
.where(eq(checkIns.id, checkInId));
// Delete with multiple conditions
await db
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
lte(sessions.expiresAt, new Date().toISOString())
)
);
```
### Joins
```typescript
const userWithCheckIns = await db
.select({
user: users,
checkIn: checkIns,
})
.from(users)
.leftJoin(checkIns, eq(users.id, checkIns.userId))
.where(eq(users.id, userId));
```
### Aggregations
```typescript
import { count, avg, sum, max, min } from 'drizzle-orm';
const stats = await db
.select({
totalCheckIns: count(),
avgMood: avg(checkIns.mood),
maxStreak: max(checkIns.streak),
})
.from(checkIns)
.where(eq(checkIns.userId, userId));
```
## Best Practices
1. **Always use transactions for related changes**
```typescript
await db.transaction(async (tx) => {
await tx.insert(users).values(userData);
await tx.insert(profiles).values(profileData);
});
```
2. **Always include WHERE on DELETE/UPDATE**
3. **Use indexes for frequently queried columns**
4. **Store dates as ISO strings for SQLite**
5. **Use `returning()` to get inserted/updated rows**
6. **Generate migrations, don't push to production**
## References
- [Drizzle ORM Docs](https://orm.drizzle.team/docs/overview)
- [Drizzle SQLite](https://orm.drizzle.team/docs/get-started-sqlite)
- [Drizzle Migrations](https://orm.drizzle.team/docs/migrations)
@@ -1,81 +0,0 @@
---
name: svelte5-best-practices
description: "Svelte 5 runes, snippets, SvelteKit patterns, and modern best practices for TypeScript and component development. Use when writing, reviewing, or refactoring Svelte 5 components and SvelteKit applications. Triggers on: Svelte components, runes ($state, $derived, $effect, $props, $bindable, $inspect), snippets ({#snippet}, {@render}), event handling, SvelteKit data loading, form actions, Svelte 4 to Svelte 5 migration, store to rune migration, slots to snippets migration, TypeScript props typing, generic components, SSR state isolation, performance optimization, or component testing."
license: MIT
metadata:
author: ejirocodes
version: '1.0.0'
---
# Svelte 5 Best Practices
## Quick Reference
| Topic | When to Use | Reference |
|-------|-------------|-----------|
| **Runes** | $state, $derived, $effect, $props, $bindable, $inspect | [runes.md](references/runes.md) |
| **Snippets** | Replacing slots, {#snippet}, {@render} | [snippets.md](references/snippets.md) |
| **Events** | onclick handlers, callback props, context API | [events.md](references/events.md) |
| **TypeScript** | Props typing, generic components | [typescript.md](references/typescript.md) |
| **Migration** | Svelte 4 to 5, stores to runes | [migration.md](references/migration.md) |
| **SvelteKit** | Load functions, form actions, SSR, page typing | [sveltekit.md](references/sveltekit.md) |
| **Performance** | Universal reactivity, avoiding over-reactivity, streaming | [performance.md](references/performance.md) |
## Essential Patterns
### Reactive State
```svelte
<script>
let count = $state(0); // Reactive state
let doubled = $derived(count * 2); // Computed value
</script>
```
### Component Props
```svelte
<script>
let { name, count = 0 } = $props();
let { value = $bindable() } = $props(); // Two-way binding
</script>
```
### Snippets (replacing slots)
```svelte
<script>
let { children, header } = $props();
</script>
{@render header?.()}
{@render children()}
```
### Event Handlers
```svelte
<!-- Svelte 5: use onclick, not on:click -->
<button onclick={() => count++}>Click</button>
```
### Callback Props (replacing createEventDispatcher)
```svelte
<script>
let { onclick } = $props();
</script>
<button onclick={() => onclick?.({ data })}>Click</button>
```
## Common Mistakes
1. **Using `let` without `$state`** - Variables are not reactive without `$state()`
2. **Using `$effect` for derived values** - Use `$derived` instead
3. **Using `on:click` syntax** - Use `onclick` in Svelte 5
4. **Using `createEventDispatcher`** - Use callback props instead
5. **Using `<slot>`** - Use snippets with `{@render}`
6. **Forgetting `$bindable()`** - Required for `bind:` to work
7. **Setting module-level state in SSR** - Causes cross-request leaks
8. **Sequential awaits in load functions** - Use `Promise.all` for parallel requests
@@ -1,353 +0,0 @@
# Svelte 5 Events Reference
## Table of Contents
- [Event Handler Syntax](#event-handler-syntax)
- [Callback Props Pattern](#callback-props-pattern)
- [Context API](#context-api)
---
## Event Handler Syntax
Svelte 5 replaces `on:click` directive syntax with standard HTML attribute syntax `onclick`.
### Basic Event Handlers
**Svelte 4:**
```svelte
<button on:click={handleClick}>Click</button>
<input on:input={handleInput} />
<form on:submit={handleSubmit}>...</form>
```
**Svelte 5:**
```svelte
<button onclick={handleClick}>Click</button>
<input oninput={handleInput} />
<form onsubmit={handleSubmit}>...</form>
```
### Event Modifiers Migration
Event modifiers no longer exist. Use wrapper functions:
**Svelte 4:**
```svelte
<form on:submit|preventDefault={handleSubmit}>...</form>
<button on:click|stopPropagation={handleClick}>...</button>
```
**Svelte 5:**
```svelte
<script>
function handleSubmit(event) {
event.preventDefault();
// ... handle form
}
function handleClick(event) {
event.stopPropagation();
// ... handle click
}
</script>
<form onsubmit={handleSubmit}>...</form>
<button onclick={handleClick}>...</button>
```
### Capture, Passive, and NonPassive
```svelte
<!-- Capture phase -->
<div onclickcapture={handleCapture}>
<button onclick={handleClick}>Click</button>
</div>
<!-- Passive listener -->
<div ontouchstartpassive={handleTouch}>...</div>
<!-- Non-passive -->
<div ontouchmovenonpassive={(e) => e.preventDefault()}>...</div>
```
### Inline Handlers
```svelte
<button onclick={() => count++}>Count: {count}</button>
<input oninput={(e) => name = e.target.value} />
```
### Event Handler Shorthand
```svelte
<script>
function onclick(event) {
console.log('Clicked!', event);
}
</script>
<button {onclick}>Click</button>
```
### Spreading Event Handlers
```svelte
<script>
let handlers = {
onclick: () => console.log('clicked'),
onmouseenter: () => console.log('entered'),
onmouseleave: () => console.log('left')
};
</script>
<button {...handlers}>Hover or Click</button>
```
### Multiple Handlers for Same Event
In Svelte 5, combine logic into one handler:
```svelte
<script>
function handleClick(event) {
handler1(event);
handler2(event);
}
</script>
<button onclick={handleClick}>...</button>
```
### TypeScript Event Typing
```svelte
<script lang="ts">
function handleClick(event: MouseEvent) {
console.log(event.clientX, event.clientY);
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
console.log(target.value);
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
const formData = new FormData(event.currentTarget as HTMLFormElement);
}
</script>
```
---
## Callback Props Pattern
Svelte 5 deprecates `createEventDispatcher` in favor of callback props for component-to-parent communication.
### Basic Event Pattern
**Svelte 4:**
```svelte
<!-- Button.svelte -->
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
</script>
<button on:click={() => dispatch('click', { timestamp: Date.now() })}>Click</button>
<!-- Parent.svelte -->
<Button on:click={(e) => console.log(e.detail)} />
```
**Svelte 5:**
```svelte
<!-- Button.svelte -->
<script>
let { onclick } = $props();
</script>
<button onclick={() => onclick?.({ timestamp: Date.now() })}>Click</button>
<!-- Parent.svelte -->
<Button onclick={(data) => console.log(data)} />
```
### Multiple Callbacks
```svelte
<!-- Dialog.svelte -->
<script>
let { onconfirm, oncancel, onclose } = $props();
</script>
<dialog>
<button onclick={() => onconfirm?.()}>Confirm</button>
<button onclick={() => oncancel?.()}>Cancel</button>
<button onclick={() => onclose?.()}>X</button>
</dialog>
<!-- Parent.svelte -->
<Dialog
onconfirm={() => save()}
oncancel={() => reset()}
onclose={() => visible = false}
/>
```
### Typed Callbacks with TypeScript
```svelte
<script lang="ts">
interface Props {
value?: string;
onsearch?: (query: string) => void;
onchange?: (value: string) => void;
onclear?: () => void;
}
let { value = '', onsearch, onchange, onclear }: Props = $props();
function handleInput(e: Event) {
const newValue = (e.target as HTMLInputElement).value;
value = newValue;
onchange?.(newValue);
}
</script>
```
### Forwarding Native Events
```svelte
<script lang="ts">
import type { MouseEventHandler } from 'svelte/elements';
interface Props {
onclick?: MouseEventHandler<HTMLButtonElement>;
children: import('svelte').Snippet;
}
let { onclick, children, ...rest }: Props = $props();
</script>
<button {onclick} {...rest}>{@render children()}</button>
```
---
## Context API
Context functions must be called synchronously during component initialization.
### Correct Context Usage
```svelte
<script>
import { setContext, getContext } from 'svelte';
// These run during initialization - CORRECT
setContext('theme', 'dark');
const theme = getContext('theme');
// INCORRECT - in $effect or callback
// $effect(() => { setContext('theme', 'dark'); }); // ERROR
</script>
```
### Reactive Context Values
Pass `$state` objects for reactive context:
```svelte
<!-- Provider.svelte -->
<script>
import { setContext } from 'svelte';
let theme = $state('light');
setContext('theme', {
get current() { return theme; },
toggle() { theme = theme === 'light' ? 'dark' : 'light'; }
});
let { children } = $props();
</script>
{@render children()}
<!-- Consumer.svelte -->
<script>
import { getContext } from 'svelte';
const themeContext = getContext('theme');
</script>
<p>Theme: {themeContext.current}</p>
<button onclick={themeContext.toggle}>Toggle</button>
```
### Type-Safe Context Pattern
```ts
// context.ts
import { setContext, getContext } from 'svelte';
const THEME_KEY = Symbol('theme');
interface ThemeContext {
current: 'light' | 'dark';
toggle: () => void;
}
export function setThemeContext(context: ThemeContext) {
setContext(THEME_KEY, context);
}
export function getThemeContext(): ThemeContext {
const context = getContext<ThemeContext>(THEME_KEY);
if (!context) throw new Error('Theme context not found');
return context;
}
```
### Avoid Generic Keys
```svelte
<script>
// Option 1: Symbol
const USER_KEY = Symbol('user');
setContext(USER_KEY, userData);
// Option 2: Unique string
setContext('myapp:user', userData);
// Option 3: Object key
const userKey = {};
setContext(userKey, userData);
</script>
```
### hasContext Check
```svelte
<script>
import { hasContext, getContext } from 'svelte';
const hasTheme = hasContext('theme');
const theme = hasTheme ? getContext('theme') : { current: 'light' };
</script>
```
### Context Boundaries
Context flows down the component tree:
```svelte
<!-- Parent.svelte -->
<script>
import { getContext, setContext } from 'svelte';
const level = getContext('level'); // 0
setContext('level', level + 1); // Override for children
</script>
<Child /> <!-- Will see level = 1 -->
```
@@ -1,339 +0,0 @@
# Svelte 4 to Svelte 5 Migration Reference
## Table of Contents
- [Migrating Reactive Statements](#migrating-reactive-statements)
- [Migrating Stores to Runes](#migrating-stores-to-runes)
---
## Migrating Reactive Statements
Svelte 5 replaces `$:` with `$derived` (for values) and `$effect` (for side effects).
### Computed Values: $: to $derived
**Svelte 4:**
```svelte
<script>
let count = 0;
$: doubled = count * 2;
$: quadrupled = doubled * 2;
</script>
```
**Svelte 5:**
```svelte
<script>
let count = $state(0);
let doubled = $derived(count * 2);
let quadrupled = $derived(doubled * 2);
</script>
```
### Complex Computed Values: $derived.by
**Svelte 4:**
```svelte
<script>
let items = [];
let filter = 'all';
$: filteredItems = {
if (filter === 'all') return items;
if (filter === 'active') return items.filter(i => !i.done);
return items.filter(i => i.done);
};
</script>
```
**Svelte 5:**
```svelte
<script>
let items = $state([]);
let filter = $state('all');
let filteredItems = $derived.by(() => {
if (filter === 'all') return items;
if (filter === 'active') return items.filter(i => !i.done);
return items.filter(i => i.done);
});
</script>
```
### Side Effects: $: to $effect
**Svelte 4:**
```svelte
<script>
let count = 0;
$: console.log('Count changed:', count);
$: document.title = `Count: ${count}`;
</script>
```
**Svelte 5:**
```svelte
<script>
let count = $state(0);
$effect(() => { console.log('Count changed:', count); });
$effect(() => { document.title = `Count: ${count}`; });
</script>
```
### Conditional Side Effects
**Svelte 4:**
```svelte
<script>
let value;
$: if (value > 100) { alert('Value too high!'); }
</script>
```
**Svelte 5:**
```svelte
<script>
let value = $state(0);
$effect(() => {
if (value > 100) { alert('Value too high!'); }
});
</script>
```
### Props Migration
**Svelte 4:**
```svelte
<script>
export let name;
export let count = 0;
$: greeting = `Hello, ${name}!`;
</script>
```
**Svelte 5:**
```svelte
<script>
let { name, count = 0 } = $props();
let greeting = $derived(`Hello, ${name}!`);
</script>
```
### Effect with Cleanup
**Svelte 4:**
```svelte
<script>
import { onDestroy } from 'svelte';
let count = 0;
let interval;
$: {
clearInterval(interval);
interval = setInterval(() => console.log(count), 1000);
}
onDestroy(() => clearInterval(interval));
</script>
```
**Svelte 5:**
```svelte
<script>
let count = $state(0);
$effect(() => {
const interval = setInterval(() => console.log(count), 1000);
return () => clearInterval(interval); // Cleanup built-in
});
</script>
```
### Migration Cheat Sheet
| Svelte 4 Pattern | Svelte 5 Replacement |
|------------------|----------------------|
| `let x = 0` (in component) | `let x = $state(0)` |
| `export let prop` | `let { prop } = $props()` |
| `$: derived = expr` | `let derived = $derived(expr)` |
| `$: { complex }` (value) | `let x = $derived.by(() => { ... })` |
| `$: console.log(x)` | `$effect(() => console.log(x))` |
| `$: if (x) { ... }` | `$effect(() => { if (x) { ... } })` |
| `$: document.title = x` | `$effect(() => { document.title = x })` |
### Automated Migration
```bash
npx sv migrate svelte-5
```
---
## Migrating Stores to Runes
Svelte 5 runes can replace most store use cases with simpler, more direct reactive state.
### Local Component State
**Svelte 4:**
```svelte
<script>
import { writable } from 'svelte/store';
const count = writable(0);
function increment() { count.update(n => n + 1); }
</script>
<button on:click={increment}>Count: {$count}</button>
```
**Svelte 5:**
```svelte
<script>
let count = $state(0);
function increment() { count++; }
</script>
<button onclick={increment}>Count: {count}</button>
```
### Shared State Across Components
**Svelte 4 (stores.ts):**
```ts
import { writable } from 'svelte/store';
export const user = writable(null);
export const theme = writable('light');
```
**Svelte 5 (state.svelte.ts):**
```ts
export const user = $state<User | null>(null);
export const theme = $state({ current: 'light' as 'light' | 'dark' });
export function setTheme(newTheme: 'light' | 'dark') {
theme.current = newTheme;
}
```
```svelte
<script>
import { theme, setTheme } from './state.svelte';
</script>
<p>Theme: {theme.current}</p>
<button onclick={() => setTheme('dark')}>Dark Mode</button>
```
### Derived Store to $derived
**Svelte 4:**
```ts
import { writable, derived } from 'svelte/store';
export const items = writable([]);
export const completedCount = derived(items, $items =>
$items.filter(i => i.done).length
);
```
**Svelte 5:**
```ts
// state.svelte.ts
export const items = $state<Item[]>([]);
export function getCompletedCount() {
return items.filter(i => i.done).length;
}
```
```svelte
<script>
import { items } from './state.svelte';
let completedCount = $derived(items.filter(i => i.done).length);
</script>
```
### Custom Store to Reactive Class
**Svelte 4:**
```ts
function createCounter() {
const { subscribe, set, update } = writable(0);
return {
subscribe,
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(0)
};
}
export const counter = createCounter();
```
**Svelte 5:**
```ts
// counter.svelte.ts
class Counter {
value = $state(0);
increment() { this.value++; }
decrement() { this.value--; }
reset() { this.value = 0; }
}
export const counter = new Counter();
```
```svelte
<script>
import { counter } from './counter.svelte';
</script>
<p>{counter.value}</p>
<button onclick={() => counter.increment()}>+</button>
```
### Async State Pattern
**Svelte 5:**
```ts
// api.svelte.ts
export const state = $state({
data: null as Data | null,
loading: false,
error: null as Error | null
});
export async function fetchData() {
state.loading = true;
state.error = null;
try {
const res = await fetch('/api/data');
state.data = await res.json();
} catch (e) {
state.error = e as Error;
} finally {
state.loading = false;
}
}
```
### When to Still Use Stores
1. **Library interop** - Libraries that expect Svelte stores
2. **Legacy code** - Gradual migration
3. **Observable patterns** - When you need the subscribe API
4. **Server-side rendering** - Stores have built-in SSR handling
### Store to Rune Cheat Sheet
| Store Pattern | Rune Replacement |
|---------------|------------------|
| `writable(value)` | `$state(value)` |
| `$store` (auto-subscribe) | Direct access to `$state` value |
| `store.set(x)` | `state = x` |
| `store.update(fn)` | Direct mutation or reassignment |
| `derived(store, fn)` | `$derived(fn())` in component |
| `readable(value, start)` | `$state` + `$effect` for setup |
@@ -1,443 +0,0 @@
# Svelte 5 Performance Reference
## Table of Contents
- [Universal Reactivity](#universal-reactivity)
- [Avoiding Over-Reactivity](#avoiding-over-reactivity)
- [Preventing Load Waterfalls](#preventing-load-waterfalls)
- [Streaming Non-Critical Data](#streaming-non-critical-data)
- [Component Testing](#component-testing)
---
## Universal Reactivity
Svelte 5 allows runes in `.svelte.js` or `.svelte.ts` files for shared state outside components.
### Shared Counter State
```ts
// counter.svelte.ts
export const counter = $state({ count: 0 });
export function increment() { counter.count++; }
export function decrement() { counter.count--; }
export function reset() { counter.count = 0; }
```
```svelte
<script>
import { counter, increment } from './counter.svelte';
</script>
<p>Count: {counter.count}</p>
<button onclick={increment}>+</button>
```
### Object State with Getters
```ts
// user.svelte.ts
const state = $state<User>({ firstName: '', lastName: '', email: '' });
export const user = {
get firstName() { return state.firstName; },
set firstName(v: string) { state.firstName = v; },
get fullName() { return `${state.firstName} ${state.lastName}`; },
get isValid() { return state.firstName && state.email.includes('@'); }
};
```
### Reactive Class Pattern
```ts
// todo.svelte.ts
class TodoStore {
items = $state<Todo[]>([]);
filter = $state<'all' | 'active' | 'completed'>('all');
get filtered() {
switch (this.filter) {
case 'active': return this.items.filter(t => !t.done);
case 'completed': return this.items.filter(t => t.done);
default: return this.items;
}
}
add(text: string) {
this.items.push({ id: Date.now(), text, done: false });
}
}
export const todos = new TodoStore();
```
### Important Notes
1. **File extension matters**: Must use `.svelte.js` or `.svelte.ts`
2. **No $derived in module scope**: Use getters for derived values
3. **SSR caution**: Avoid initializing browser-only state at module level
---
## Avoiding Over-Reactivity
Common mistakes with runes can cause unnecessary re-renders, infinite loops, or degraded performance.
### Anti-Pattern 1: Using $effect to Set Derived Values
**WRONG:**
```svelte
<script>
let count = $state(0);
let doubled = $state(0);
$effect(() => { doubled = count * 2; }); // Anti-pattern!
</script>
```
**CORRECT:**
```svelte
<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
```
### Anti-Pattern 2: Circular Dependencies
**WRONG:**
```svelte
<script>
let a = $state(1);
let b = $state(2);
$effect(() => {
if (a > 10) b = 0;
if (b > 10) a = 0; // Triggers the effect again!
});
</script>
```
**CORRECT: Use separate effects or event handlers**
### Anti-Pattern 3: Not Using untrack
**WRONG:**
```svelte
<script>
let count = $state(0);
let log = $state([]);
$effect(() => {
log.push(`Count is ${count}`); // log change triggers re-run!
});
</script>
```
**CORRECT:**
```svelte
<script>
import { untrack } from 'svelte';
let count = $state(0);
let log = $state([]);
$effect(() => {
untrack(() => { log.push(`Count is ${count}`); });
});
</script>
```
### Anti-Pattern 4: Heavy Computations in $derived
**WRONG:**
```svelte
<script>
let items = $state([/* thousands */]);
let filter = $state('');
let filtered = $derived(
items.filter(item => JSON.stringify(item).includes(filter))
);
</script>
```
**CORRECT: Debounce expensive computations**
```svelte
<script>
let filter = $state('');
let debouncedFilter = $state('');
$effect(() => {
const timeout = setTimeout(() => { debouncedFilter = filter; }, 300);
return () => clearTimeout(timeout);
});
let filtered = $derived(
items.filter(item => item.name.includes(debouncedFilter))
);
</script>
```
### Anti-Pattern 5: Effect for DOM Manipulation
**WRONG:**
```svelte
<script>
let visible = $state(false);
let element;
$effect(() => {
if (element) element.style.display = visible ? 'block' : 'none';
});
</script>
```
**CORRECT:**
```svelte
{#if visible}<div>Content</div>{/if}
<!-- Or -->
<div class:hidden={!visible}>Content</div>
```
### Performance Tips
1. Use `$derived` over `$effect` for computed values
2. Debounce expensive reactive computations
3. Use `untrack` to prevent unnecessary dependencies
4. Group related state into objects
5. Let Svelte handle DOM updates
---
## Preventing Load Waterfalls
Sequential API calls multiply latency. Use parallel requests and streaming.
### Anti-Pattern: Waterfall
**WRONG (3 seconds total):**
```ts
export const load = async ({ fetch }) => {
const user = await fetch('/api/user').then(r => r.json()); // 1s
const posts = await fetch(`/api/users/${user.id}/posts`).then(r => r.json()); // 1s
const comments = await fetch('/api/comments').then(r => r.json()); // 1s
return { user, posts, comments };
};
```
### Pattern 1: Parallel with Promise.all
**CORRECT (1 second total):**
```ts
export const load = async ({ fetch }) => {
const [user, posts, comments] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]);
return { user, posts, comments };
};
```
### Pattern 2: Partial Dependencies
```ts
export const load = async ({ fetch }) => {
const user = await fetch('/api/user').then(r => r.json());
const [posts, followers] = await Promise.all([
fetch(`/api/users/${user.id}/posts`).then(r => r.json()),
fetch(`/api/users/${user.id}/followers`).then(r => r.json())
]);
return { user, posts, followers };
};
```
### Pattern 3: Streaming Non-Critical Data
```ts
export const load = async ({ fetch }) => {
const user = await fetch('/api/user').then(r => r.json());
return {
user,
recommendations: fetch('/api/recommendations').then(r => r.json()),
analytics: fetch('/api/analytics').then(r => r.json())
};
};
```
### Performance Comparison
| Pattern | 3 APIs x 1s each |
|---------|------------------|
| Sequential | 3 seconds |
| Parallel (Promise.all) | 1 second |
| Streaming | 0s initial, streams rest |
---
## Streaming Non-Critical Data
Return promises from load functions to stream non-essential data after initial page render.
### Basic Streaming
**WRONG (blocks on slow data):**
```ts
export const load = async ({ fetch }) => {
const user = await fetch('/api/user').then(r => r.json()); // 100ms
const analytics = await fetch('/api/analytics').then(r => r.json()); // 2000ms
return { user, analytics }; // Page blocked for 2.1 seconds
};
```
**CORRECT (stream slow data):**
```ts
export const load = async ({ fetch }) => {
const user = await fetch('/api/user').then(r => r.json());
const analytics = fetch('/api/analytics').then(r => r.json()); // Don't await
return {
user, // Available in 100ms
analytics // Streams when ready
};
};
```
### Component Handling
```svelte
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<header><h1>Welcome, {data.user.name}</h1></header>
<aside>
{#await data.analytics}
<div class="skeleton">Loading analytics...</div>
{:then analytics}
<div class="analytics">
<p>Page views: {analytics.views}</p>
</div>
{:catch}
<div class="error">Failed to load analytics</div>
{/await}
</aside>
```
### When to Stream
| Data Type | Stream? | Reason |
|-----------|---------|--------|
| User info | No | Critical for layout |
| Main content | No | Users came for this |
| Analytics | Yes | Not user-facing |
| Recommendations | Yes | Supplementary |
| Comments | Maybe | Important but can load later |
---
## Component Testing
Svelte 5 components with runes require proper Vitest configuration.
### Option 1: Vitest Browser Mode (Recommended)
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
test: {
browser: {
enabled: true,
provider: 'playwright',
name: 'chromium'
}
}
});
```
### Option 2: JSDOM with @testing-library/svelte
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
test: {
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true
}
});
```
### Basic Component Test
```ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import Counter from './Counter.svelte';
test('increments count when clicked', async () => {
render(Counter);
const button = screen.getByRole('button');
expect(button).toHaveTextContent('Count: 0');
await fireEvent.click(button);
expect(button).toHaveTextContent('Count: 1');
});
```
### Testing Props
```ts
test('renders with custom name', () => {
render(Greeting, { props: { name: 'Alice' } });
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
});
```
### Testing Callbacks
```ts
import { vi } from 'vitest';
test('calls onclick callback when clicked', async () => {
const handleClick = vi.fn();
render(Button, { props: { onclick: handleClick } });
await fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
```
### Testing Async Components
```ts
import { waitFor } from '@testing-library/svelte';
test('loads and displays user data', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ name: 'Alice' })
});
render(UserProfile, { props: { userId: '123' } });
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
```
@@ -1,394 +0,0 @@
# Svelte 5 Runes Reference
## Table of Contents
- [$state - Reactive State](#state---reactive-state)
- [$derived - Computed Values](#derived---computed-values)
- [$effect - Side Effects](#effect---side-effects)
- [$props - Component Props](#props---component-props)
- [$bindable - Two-Way Binding](#bindable---two-way-binding)
- [$inspect - Debugging](#inspect---debugging)
---
## $state - Reactive State
In Svelte 5, plain `let` declarations are no longer automatically reactive. Use `$state()` for reactive state.
### Basic Usage
```svelte
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>
Clicks: {count}
</button>
```
### Object and Array State
Objects and arrays are deeply reactive by default:
```svelte
<script>
let user = $state({ name: 'Alice', age: 30 });
let items = $state(['apple', 'banana']);
</script>
<button onclick={() => user.age++}>Age: {user.age}</button>
<button onclick={() => items.push('cherry')}>Items: {items.length}</button>
```
### Class State
```svelte
<script>
class Counter {
count = $state(0);
increment() { this.count++; }
}
const counter = new Counter();
</script>
<button onclick={() => counter.increment()}>{counter.count}</button>
```
### Raw State (Opt-out of Deep Reactivity)
```svelte
<script>
let items = $state.raw([1, 2, 3]);
// This WON'T trigger an update:
items.push(4);
// This WILL trigger an update:
items = [...items, 4];
</script>
```
---
## $derived - Computed Values
Replaces `$:` reactive statements for computed values.
### Basic Usage
```svelte
<script>
let count = $state(0);
let doubled = $derived(count * 2);
let quadrupled = $derived(doubled * 2);
</script>
```
### Complex Derivations with $derived.by
```svelte
<script>
let items = $state([1, 2, 3, 4, 5]);
let filter = $state('even');
let filteredItems = $derived.by(() => {
if (filter === 'even') return items.filter(n => n % 2 === 0);
return items.filter(n => n % 2 !== 0);
});
</script>
```
### Deriving from Props
```svelte
<script>
let { firstName, lastName } = $props();
let fullName = $derived(`${firstName} ${lastName}`);
</script>
```
### $derived vs $effect
Use `$derived` for computing values, `$effect` for side effects:
```svelte
<script>
let count = $state(0);
// CORRECT: Use $derived for computed values
let doubled = $derived(count * 2);
// INCORRECT: Don't use $effect to set derived values
// let doubled;
// $effect(() => { doubled = count * 2; }); // Anti-pattern!
</script>
```
---
## $effect - Side Effects
Runs code when component mounts and when dependencies change. Requires cleanup for subscriptions.
### Basic Effect with Cleanup
```svelte
<script>
let count = $state(0);
$effect(() => {
const interval = setInterval(() => count++, 1000);
return () => clearInterval(interval); // Cleanup
});
</script>
```
### DOM Event Listeners
```svelte
<script>
let mouseX = $state(0);
let mouseY = $state(0);
$effect(() => {
function handleMouseMove(event) {
mouseX = event.clientX;
mouseY = event.clientY;
}
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
});
</script>
```
### External Subscriptions
```svelte
<script>
let { userId } = $props();
let userData = $state(null);
$effect(() => {
const unsubscribe = database.subscribe(`users/${userId}`, (data) => {
userData = data;
});
return () => unsubscribe();
});
</script>
```
### $effect.pre for Pre-DOM Updates
```svelte
<script>
let div;
let messages = $state([]);
$effect.pre(() => {
if (div) {
const shouldScroll = div.scrollTop + div.clientHeight >= div.scrollHeight - 20;
if (shouldScroll) {
tick().then(() => { div.scrollTop = div.scrollHeight; });
}
}
});
</script>
```
### Untracked Dependencies
```svelte
<script>
import { untrack } from 'svelte';
let count = $state(0);
let logCount = $state(0);
$effect(() => {
// Only runs when count changes, not logCount
console.log(count, untrack(() => logCount));
});
</script>
```
---
## $props - Component Props
Replaces `export let` for declaring component props.
### Basic Usage
```svelte
<script>
let { name, count = 0, disabled = false } = $props();
</script>
```
### Rest Props Pattern
```svelte
<script>
let { class: className, children, ...restProps } = $props();
</script>
<div class={className} {...restProps}>
{@render children?.()}
</div>
```
### Renaming Reserved Words
```svelte
<script>
let {
class: className, // 'class' is reserved
for: htmlFor, // 'for' is reserved
...rest
} = $props();
</script>
```
### TypeScript Props
```svelte
<script lang="ts">
interface Props {
name: string;
count?: number;
onClick?: (event: MouseEvent) => void;
}
let { name, count = 0, onClick }: Props = $props();
</script>
```
### Props with Children
```svelte
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
children: Snippet;
footer?: Snippet;
}
let { title, children, footer }: Props = $props();
</script>
<article>
<h1>{title}</h1>
<main>{@render children()}</main>
{#if footer}<footer>{@render footer()}</footer>{/if}
</article>
```
---
## $bindable - Two-Way Binding
Props must be explicitly marked with `$bindable()` to support `bind:`.
### Basic Usage
```svelte
<!-- Input.svelte -->
<script>
let { value = $bindable() } = $props();
</script>
<input bind:value />
<!-- Parent.svelte -->
<script>
import Input from './Input.svelte';
let name = $state('');
</script>
<Input bind:value={name} />
```
### Bindable with Default Value
```svelte
<script>
let { value = $bindable('default') } = $props();
</script>
```
### Multiple Bindable Props
```svelte
<script>
let {
value = $bindable(''),
checked = $bindable(false),
selected = $bindable(null)
} = $props();
</script>
```
### TypeScript with Bindable
```svelte
<script lang="ts">
interface Props {
value: string;
disabled?: boolean;
}
let { value = $bindable(''), disabled = false }: Props = $props();
</script>
```
---
## $inspect - Debugging
Development-only debugging that logs values when they change. Stripped in production.
### Basic Usage
```svelte
<script>
let count = $state(0);
$inspect(count); // Logs every time count changes
</script>
```
### Multiple Values
```svelte
<script>
let name = $state('Alice');
let age = $state(30);
$inspect(name, age);
</script>
```
### Custom Logging with $inspect.with
```svelte
<script>
let user = $state({ name: 'Alice', age: 30 });
$inspect.with((type, ...values) => {
if (type === 'init') console.log('Initial value:', values);
else console.log('Updated to:', values);
}, user);
</script>
```
### Debugging with Breakpoints
```svelte
<script>
let data = $state({ x: 0, y: 0 });
$inspect.with((type, value) => {
if (type === 'update' && value.x > 100) debugger;
}, data);
</script>
```
@@ -1,291 +0,0 @@
# Svelte 5 Snippets Reference
## Table of Contents
- [Replacing Slots with Snippets](#replacing-slots-with-snippets)
- [Rendering with @render](#rendering-with-render)
---
## Replacing Slots with Snippets
Svelte 5 replaces `<slot>` with snippets - a more powerful and type-safe composition primitive.
### Default Content (children)
**Svelte 4:**
```svelte
<div class="card"><slot /></div>
```
**Svelte 5:**
```svelte
<script>
let { children } = $props();
</script>
<div class="card">{@render children?.()}</div>
```
### Named Slots to Named Snippets
**Svelte 4:**
```svelte
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
<!-- Usage -->
<Layout>
<h1 slot="header">Title</h1>
<p>Content</p>
<span slot="footer">Footer</span>
</Layout>
```
**Svelte 5:**
```svelte
<script>
let { header, children, footer } = $props();
</script>
<header>{@render header?.()}</header>
<main>{@render children?.()}</main>
<footer>{@render footer?.()}</footer>
<!-- Usage -->
<Layout>
{#snippet header()}<h1>Title</h1>{/snippet}
<p>Content</p>
{#snippet footer()}<span>Footer</span>{/snippet}
</Layout>
```
### Slot Props to Snippet Parameters
**Svelte 4:**
```svelte
<ul>
{#each items as item}
<li><slot {item} /></li>
{/each}
</ul>
<List {items} let:item>
<span>{item.name}</span>
</List>
```
**Svelte 5:**
```svelte
<script>
let { items, children } = $props();
</script>
<ul>
{#each items as item}
<li>{@render children?.(item)}</li>
{/each}
</ul>
<List {items}>
{#snippet children(item)}
<span>{item.name}</span>
{/snippet}
</List>
```
### Slot Fallback to Snippet Fallback
```svelte
<script>
let { children } = $props();
</script>
{#if children}
{@render children()}
{:else}
<p>Default content</p>
{/if}
```
### Defining Snippets Within Components
```svelte
<script>
let items = $state([
{ id: 1, name: 'Item 1', type: 'normal' },
{ id: 2, name: 'Item 2', type: 'featured' }
]);
</script>
{#snippet normalItem(item)}
<li>{item.name}</li>
{/snippet}
{#snippet featuredItem(item)}
<li class="featured">*** {item.name} ***</li>
{/snippet}
<ul>
{#each items as item}
{#if item.type === 'featured'}
{@render featuredItem(item)}
{:else}
{@render normalItem(item)}
{/if}
{/each}
</ul>
```
### TypeScript Typing
```svelte
<script lang="ts">
import type { Snippet } from 'svelte';
interface Item { id: number; name: string; }
interface Props {
header?: Snippet;
children: Snippet<[item: Item]>;
footer?: Snippet;
}
let { header, children, footer }: Props = $props();
</script>
```
---
## Rendering with @render
Snippets must be rendered using `{@render}`. Optional snippets need null-safe calling.
### Basic Snippet Rendering
```svelte
<script>
let { children } = $props();
</script>
<div>{@render children()}</div>
```
### Optional Snippets
Always use optional chaining for snippets that might not be provided:
```svelte
<script>
let { children } = $props();
</script>
{@render children?.()}
```
### Conditional Rendering
```svelte
<script>
let { header, children, footer } = $props();
</script>
{#if header}
<header>{@render header()}</header>
{/if}
<main>{@render children?.()}</main>
{#if footer}
<footer>{@render footer()}</footer>
{/if}
```
### Rendering with Arguments
```svelte
<script>
let { items, itemTemplate } = $props();
</script>
<ul>
{#each items as item, index}
<li>{@render itemTemplate(item, index)}</li>
{/each}
</ul>
<!-- Usage -->
<List {items}>
{#snippet itemTemplate(item, index)}
<span>{index + 1}. {item.name}</span>
{/snippet}
</List>
```
### Rendering Multiple Times
Snippets can be rendered multiple times:
```svelte
<script>
let { icon } = $props();
</script>
<button>
{@render icon?.()}
<span>Click me</span>
{@render icon?.()}
</button>
```
### Rendering Dynamic Snippets
```svelte
<script>
let { normalView, editView, isEditing } = $props();
let currentView = $derived(isEditing ? editView : normalView);
</script>
{@render currentView?.()}
```
### TypeScript with @render
```svelte
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
header?: Snippet;
row: Snippet<[data: { id: number; name: string }]>;
}
let { children, header, row }: Props = $props();
</script>
{@render header?.()}
{@render children()}
{@render row({ id: 1, name: 'Test' })}
```
### Passing Snippets to Child Components
```svelte
<!-- Wrapper.svelte -->
<script>
import Inner from './Inner.svelte';
let { itemRenderer } = $props();
</script>
<Inner {itemRenderer} />
<!-- Inner.svelte -->
<script>
let { itemRenderer } = $props();
</script>
{#each items as item}
{@render itemRenderer?.(item)}
{/each}
```
@@ -1,401 +0,0 @@
# SvelteKit Patterns Reference
## Table of Contents
- [Load Function Types](#load-function-types)
- [Page Props Typing](#page-props-typing)
- [Form Actions Error Handling](#form-actions-error-handling)
- [SSR State Isolation](#ssr-state-isolation)
---
## Load Function Types
SvelteKit has two load function types: universal (`+page.js`) and server-only (`+page.server.ts`).
### When to Use Each
| Use +page.server.ts | Use +page.js |
|---------------------|--------------|
| Accessing secrets/credentials | Public APIs only |
| Database connections | Non-serializable data (functions, classes) |
| Server-only APIs | Client-side caching benefits |
| Sensitive business logic | |
### Security: Secrets in Server Load
**WRONG (secrets exposed):**
```ts
// +page.js - DANGEROUS: runs in browser!
export const load = async ({ fetch }) => {
const response = await fetch('https://api.stripe.com/charges', {
headers: { 'Authorization': `Bearer ${STRIPE_SECRET_KEY}` } // EXPOSED!
});
return { charges: await response.json() };
};
```
**CORRECT:**
```ts
// +page.server.ts - only runs on server
import { STRIPE_SECRET_KEY } from '$env/static/private';
export const load = async ({ fetch }) => {
const response = await fetch('https://api.stripe.com/charges', {
headers: { 'Authorization': `Bearer ${STRIPE_SECRET_KEY}` }
});
return { charges: await response.json() };
};
```
### Serialization: Non-serializable in Universal Load
**WRONG (server load can't serialize functions):**
```ts
// +page.server.ts
export const load = async () => {
return {
formatDate: (date: Date) => date.toLocaleDateString(), // ERROR
parser: new DOMParser() // ERROR
};
};
```
**CORRECT:**
```ts
// +page.js
export const load = async () => {
return {
formatDate: (date: Date) => date.toLocaleDateString(), // OK
parser: typeof window !== 'undefined' ? new DOMParser() : null
};
};
```
### Database Access
```ts
// +page.server.ts - CORRECT
import { db } from '$lib/server/database';
export const load = async () => {
const users = await db.query('SELECT * FROM users');
return { users };
};
```
### Environment Variables
```ts
// +page.server.ts - Private env vars
import { DATABASE_URL, API_SECRET } from '$env/static/private';
// +page.js - Only public env vars
import { PUBLIC_API_URL } from '$env/static/public';
```
---
## Page Props Typing
SvelteKit generates types in `./$types` for full type safety with `$props()`.
### Basic Page Data Typing
```svelte
<!-- +page.svelte -->
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<h1>{data.title}</h1>
```
### Page Data with Form Actions
```svelte
<script lang="ts">
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
{#if form?.success}
<p class="success">{form.message}</p>
{/if}
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
```
### Layout Data Typing
```svelte
<!-- +layout.svelte -->
<script lang="ts">
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
</script>
<nav>
{#if data.user}<span>Welcome, {data.user.name}</span>{/if}
</nav>
{@render children()}
```
### Corresponding Load Function
```ts
// +page.server.ts
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async () => {
return {
title: 'My Page',
items: await fetchItems()
};
};
export const actions: Actions = {
default: async ({ request }) => {
return { success: true, message: 'Saved successfully' };
}
};
```
### Error Page
```svelte
<!-- +error.svelte -->
<script lang="ts">
import { page } from '$app/state';
</script>
<h1>{page.status}: {page.error?.message}</h1>
```
---
## Form Actions Error Handling
SvelteKit distinguishes between validation errors (`fail()`) and unexpected errors (`throw error()`).
### Use fail() for Validation Errors
**WRONG:**
```ts
import { error } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
if (!email?.toString().includes('@')) {
throw error(400, 'Invalid email'); // Shows error page!
}
}
};
```
**CORRECT:**
```ts
import { fail } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email')?.toString() ?? '';
if (!email.includes('@')) {
return fail(400, {
error: 'Invalid email address',
email // Return for repopulation
});
}
return { success: true };
}
};
```
### Use throw error() for Unexpected Errors
```ts
import { fail, error } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
// Validation - use fail()
if (!data.get('title')) {
return fail(400, { error: 'Title is required' });
}
try {
await database.save(data);
return { success: true };
} catch (e) {
// Unexpected error - use throw
throw error(500, 'Unable to save. Please try again later.');
}
}
};
```
### Structured Validation Responses
```ts
interface FormErrors {
email?: string;
password?: string;
}
export const actions = {
register: async ({ request }) => {
const data = await request.formData();
const errors: FormErrors = {};
if (!email) errors.email = 'Email is required';
if (password.length < 8) errors.password = 'Password must be at least 8 characters';
if (Object.keys(errors).length > 0) {
return fail(400, { errors, values: { email } });
}
return { success: true };
}
};
```
### Handle in Component
```svelte
<script lang="ts">
import type { PageProps } from './$types';
import { enhance } from '$app/forms';
let { form }: PageProps = $props();
</script>
<form method="POST" action="?/register" use:enhance>
<label>
Email
<input name="email" value={form?.values?.email ?? ''} />
{#if form?.errors?.email}
<span class="error">{form.errors.email}</span>
{/if}
</label>
<button type="submit">Register</button>
</form>
```
### Error Response Summary
| Situation | Function | Result |
|-----------|----------|--------|
| Missing field | `fail(400, {...})` | Form state preserved |
| Invalid format | `fail(400, {...})` | Form state preserved |
| Not found | `throw error(404, ...)` | Error page |
| Server crash | `throw error(500, ...)` | Error page |
| Auth required | `throw redirect(303, ...)` | Redirect |
---
## SSR State Isolation
Shared server state persists across requests, potentially leaking data between users.
### Dangerous: Module-Level State
**WRONG:**
```ts
// +page.server.ts
let currentUser = null; // SHARED ACROSS ALL REQUESTS!
export const load = async ({ locals }) => {
currentUser = locals.user; // User B overwrites User A
return { user: currentUser };
};
```
**CORRECT:**
```ts
export const load = async ({ locals }) => {
return { user: locals.user }; // Each request gets its own locals
};
```
### Dangerous: Global Stores
**WRONG:**
```ts
// stores.svelte.ts
export const user = $state<User | null>(null); // Server-side singleton!
```
**CORRECT: Use locals and return data from load**
```ts
// +layout.server.ts
export const load = async ({ locals }) => {
return { user: locals.user };
};
```
### Safe Patterns
**Pattern 1: Use locals**
```ts
// hooks.server.ts
export const handle = async ({ event, resolve }) => {
event.locals.user = await authenticate(event);
event.locals.requestId = crypto.randomUUID();
return resolve(event);
};
// +page.server.ts
export const load = async ({ locals }) => {
return { user: locals.user };
};
```
**Pattern 2: Context for Component Trees**
```svelte
<!-- +layout.svelte -->
<script lang="ts">
import { setContext } from 'svelte';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
setContext('user', {
get current() { return data.user; }
});
</script>
{@render children()}
```
**Pattern 3: Client-Only State**
```ts
// stores.svelte.ts
import { browser } from '$app/environment';
function createClientStore() {
if (!browser) return { value: null };
const state = $state({ value: null });
return state;
}
export const clientState = createClientStore();
```
### SSR Safety Checklist
- [ ] No module-level `let` variables that store user data
- [ ] No global `$state` that gets set during SSR
- [ ] No singleton service classes with mutable state
- [ ] All user-specific data flows through `locals`
- [ ] All page data comes from load function returns
@@ -1,305 +0,0 @@
# Svelte 5 TypeScript Reference
## Table of Contents
- [Props Typing](#props-typing)
- [Generic Components](#generic-components)
---
## Props Typing
`$props()` requires specific patterns for TypeScript typing.
### Basic Props Typing
**Inline typing:**
```svelte
<script lang="ts">
let { name, count = 0 }: { name: string; count?: number } = $props();
</script>
```
**Interface typing (recommended):**
```svelte
<script lang="ts">
interface Props {
name: string;
count?: number;
disabled?: boolean;
}
let { name, count = 0, disabled = false }: Props = $props();
</script>
```
### Children and Snippets Typing
```svelte
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet; // Required children
header?: Snippet; // Optional snippet
row: Snippet<[data: RowData]>; // Snippet with parameter
cell?: Snippet<[value: string, index: number]>; // Multiple params
}
interface RowData { id: number; name: string; }
let { children, header, row, cell }: Props = $props();
</script>
```
### Callback Props Typing
```svelte
<script lang="ts">
interface Props {
value: string;
onchange?: (value: string) => void;
onsubmit?: (data: FormData) => Promise<void>;
onclick?: (event: MouseEvent) => void;
}
let { value, onchange, onsubmit, onclick }: Props = $props();
</script>
```
### Rest Props with HTML Attributes
```svelte
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends HTMLButtonAttributes {
variant?: 'primary' | 'secondary';
loading?: boolean;
}
let { variant = 'primary', loading = false, ...rest }: Props = $props();
</script>
<button class={variant} disabled={loading} {...rest}>
{#if loading}Loading...{:else}{@render children?.()}{/if}
</button>
```
### Input Element Props
```svelte
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLInputAttributes, 'value'> {
value?: string;
label: string;
error?: string;
}
let { value = $bindable(''), label, error, ...rest }: Props = $props();
</script>
```
### Union Type Props
```svelte
<script lang="ts">
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface Props {
variant?: ButtonVariant;
size?: ButtonSize;
children: Snippet;
}
let { variant = 'primary', size = 'md', children }: Props = $props();
</script>
<button class="{variant} {size}">{@render children()}</button>
```
### Discriminated Union Props
```svelte
<script lang="ts">
type Props =
| { type: 'link'; href: string; children: Snippet }
| { type: 'button'; onclick: () => void; children: Snippet };
let props: Props = $props();
</script>
{#if props.type === 'link'}
<a href={props.href}>{@render props.children()}</a>
{:else}
<button onclick={props.onclick}>{@render props.children()}</button>
{/if}
```
---
## Generic Components
Svelte 5 uses the `generics` attribute for type-safe reusable components.
### Basic Generic Component
```svelte
<!-- List.svelte -->
<script lang="ts" generics="T">
interface Props {
items: T[];
children: import('svelte').Snippet<[item: T, index: number]>;
}
let { items, children }: Props = $props();
</script>
<ul>
{#each items as item, index}
<li>{@render children(item, index)}</li>
{/each}
</ul>
<!-- Usage -->
<script lang="ts">
import List from './List.svelte';
interface User { id: number; name: string; }
let users: User[] = $state([{ id: 1, name: 'Alice' }]);
</script>
<List items={users}>
{#snippet children(user, index)}
<span>{index + 1}. {user.name}</span> <!-- Fully typed! -->
{/snippet}
</List>
```
### Multiple Type Parameters
```svelte
<script lang="ts" generics="K, V">
interface Props {
entries: [K, V][];
renderKey: import('svelte').Snippet<[key: K]>;
renderValue: import('svelte').Snippet<[value: V]>;
}
let { entries, renderKey, renderValue }: Props = $props();
</script>
<dl>
{#each entries as [key, value]}
<dt>{@render renderKey(key)}</dt>
<dd>{@render renderValue(value)}</dd>
{/each}
</dl>
```
### Constrained Generics
```svelte
<script lang="ts" generics="T extends { id: string | number }">
interface Props {
items: T[];
selected?: T;
onselect?: (item: T) => void;
children: import('svelte').Snippet<[item: T, isSelected: boolean]>;
}
let { items, selected, onselect, children }: Props = $props();
</script>
{#each items as item}
<div
class:selected={selected?.id === item.id}
onclick={() => onselect?.(item)}
>
{@render children(item, selected?.id === item.id)}
</div>
{/each}
```
### Generic with Default Type
```svelte
<script lang="ts" generics="T = Record<string, unknown>">
interface Props {
data: T[];
columns: (keyof T)[];
}
let { data, columns }: Props = $props();
</script>
```
### Generic Select Component
```svelte
<script lang="ts" generics="T">
interface Props {
options: T[];
value?: T;
getLabel: (option: T) => string;
getValue: (option: T) => string;
onchange?: (selected: T | undefined) => void;
placeholder?: string;
}
let {
options,
value = $bindable(),
getLabel,
getValue,
onchange,
placeholder = 'Select...'
}: Props = $props();
function handleChange(e: Event) {
const selectedValue = (e.target as HTMLSelectElement).value;
const selected = options.find(o => getValue(o) === selectedValue);
value = selected;
onchange?.(selected);
}
</script>
<select onchange={handleChange}>
<option value="">{placeholder}</option>
{#each options as option}
<option
value={getValue(option)}
selected={value && getValue(value) === getValue(option)}
>
{getLabel(option)}
</option>
{/each}
</select>
```
### Generic Async Component
```svelte
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
interface Props {
promise: Promise<T>;
loading?: Snippet;
error?: Snippet<[error: Error]>;
children: Snippet<[data: T]>;
}
let { promise, loading, error, children }: Props = $props();
</script>
{#await promise}
{#if loading}{@render loading()}{:else}<p>Loading...</p>{/if}
{:then data}
{@render children(data)}
{:catch err}
{#if error}{@render error(err)}{:else}<p>Error: {err.message}</p>{/if}
{/await}
```
@@ -1,17 +0,0 @@
# Sveltekit Data Flow
SvelteKit data flow guidance. Use for load functions, form actions,
and server/client data. Covers +page.server.ts vs +page.ts,
serialization, fail(), redirect(), error().
## Structure
- `SKILL.md` - Main skill instructions
- `references/` - Detailed documentation loaded as needed
- `scripts/` - Executable code for deterministic operations
- `assets/` - Templates, images, or other resources
## Usage
This skill is automatically discovered by Claude when relevant to the
task.
@@ -1,79 +0,0 @@
---
name: sveltekit-data-flow
# IMPORTANT: Keep description on ONE line for Claude Code compatibility
# prettier-ignore
description: SvelteKit data flow guidance. Use for load functions, form actions, server/client data, and invalidation. Covers +page.server.ts vs +page.ts, serialization, fail(), redirect(), error(), invalidateAll().
---
# SvelteKit Data Flow
## Quick Start
**Which file?** Server-only (DB/secrets): `+page.server.ts` |
Universal (runs both): `+page.ts` | API: `+server.ts`
**Load decision:** Need server resources? → server load | Need client
APIs? → universal load
**Form actions:** Always `+page.server.ts`. Return `fail()` for
errors, throw `redirect()` to navigate, throw `error()` for failures.
## Example
```typescript
// +page.server.ts
import { fail, redirect } from '@sveltejs/kit';
export const load = async ({ locals }) => {
const user = await db.users.get(locals.userId);
return { user }; // Must be JSON-serializable
};
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
if (!email) return fail(400, { email, missing: true });
await updateEmail(email);
throw redirect(303, '/success');
},
};
```
## Reference Files
- [load-functions.md](references/load-functions.md) - Server vs
universal
- [form-actions.md](references/form-actions.md) - Form handling
patterns
- [serialization.md](references/serialization.md) - What can/can't
serialize
- [error-redirect-handling.md](references/error-redirect-handling.md) -
fail/redirect/error
- [client-auth-invalidation.md](references/client-auth-invalidation.md) -
invalidateAll() after client-side auth
## Notes
- Server load → universal load via `data` param | ALWAYS
`throw redirect()/error()`
- No class instances/functions from server load (not serializable)
- **Last verified:** 2025-01-11
<!--
PROGRESSIVE DISCLOSURE GUIDELINES:
- Keep this file ~50 lines total (max ~150 lines)
- Use 1-2 code blocks only (recommend 1)
- Keep description <200 chars for Level 1 efficiency
- Move detailed docs to references/ for Level 3 loading
- This is Level 2 - quick reference ONLY, not a manual
LLM WORKFLOW (when editing this file):
1. Write/edit SKILL.md
2. Format (if formatter available)
3. Run: claude-skills-cli validate <path>
4. If multi-line description warning: run claude-skills-cli doctor <path>
5. Validate again to confirm
-->
@@ -1,125 +0,0 @@
# Client-Side Auth Invalidation
When using client-side auth libraries (Better Auth, Firebase, Supabase client),
layout server data doesn't auto-refresh after login/logout.
## The Problem
```typescript
// signin/+page.svelte - BROKEN
async function handle_signin() {
await auth_client.signIn.email({ email, password });
goto('/'); // Layout still shows "logged out"
}
```
**Why?** Client-side navigation (`goto()`) doesn't re-run server load functions.
The session cookie is set, but `+layout.server.ts` data is stale.
## Solution A: Inline Invalidation (Simple)
Call `invalidateAll()` after auth state change:
```typescript
// signin/+page.svelte
import { goto, invalidateAll } from '$app/navigation';
async function handle_signin() {
const result = await auth_client.signIn.email({ email, password });
if (result.error) return;
await invalidateAll(); // Re-runs ALL load functions
goto('/');
}
// signout handler
async function handle_signout() {
await auth_client.signOut();
await invalidateAll();
}
```
**Use when:** Single auth entry point, simple apps.
## Solution B: Auth State Listener (Robust)
Set up a listener in root layout that auto-invalidates on auth changes:
```svelte
<!-- +layout.svelte -->
<script>
import { invalidateAll } from '$app/navigation';
import { onMount } from 'svelte';
import { auth_client } from '$lib/auth-client';
onMount(() => {
// Listen for auth state changes
const unsubscribe = auth_client.onAuthStateChange(() => {
invalidateAll();
});
return unsubscribe;
});
</script>
```
**Use when:** Multiple auth flows (OAuth, magic links, etc.), complex apps.
## Common Mistakes
### Not awaiting invalidateAll()
```typescript
// WRONG - race condition
invalidateAll();
goto('/');
// RIGHT - wait for data refresh
await invalidateAll();
goto('/');
```
### Destructuring layout data
```svelte
<!-- WRONG - static snapshot -->
<script>
let { data } = $props();
const { user } = data; // Never updates after invalidateAll()
</script>
<!-- RIGHT - reactive access -->
<script>
let { data } = $props();
</script>
{data.user?.email}
```
## When invalidateAll() Runs
Per [Svelte docs](https://svelte.dev/tutorial/kit/invalidate-all):
> `invalidateAll()` is the nuclear option - it re-runs ALL load functions
> for the current page, regardless of dependencies.
This includes:
- `+layout.server.ts` (all levels)
- `+layout.ts` (all levels)
- `+page.server.ts`
- `+page.ts`
## Comparison with Server-Side Auth
| Approach | Auth Location | Invalidation |
|----------|--------------|--------------|
| Form actions | Server (`+page.server.ts`) | Automatic (page reload) |
| Client auth | Browser (auth_client) | Manual (`invalidateAll()`) |
Form actions with `throw redirect()` cause a full navigation, which
naturally re-runs load functions. Client-side auth with `goto()` does not.
## See Also
- [error-redirect-handling.md](error-redirect-handling.md) - Server-side redirects
- [load-functions.md](load-functions.md) - Server vs universal loads
@@ -1,407 +0,0 @@
# Error & Redirect Handling: fail(), redirect(), error()
## The Three Ways to Handle Failures
| Function | When | Must Throw? | Use Case |
| ------------ | ---------------- | ----------- | ------------------------------------- |
| `fail()` | Validation error | No (return) | Form validation errors |
| `redirect()` | Navigate user | **YES** | After successful action |
| `error()` | Fatal error | **YES** | Unauthorized, not found, server error |
## fail() - Validation Errors
**Return** (don't throw) from form actions to show validation errors:
```typescript
import { fail } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
// Validation failed - return fail()
if (!email || !email.includes('@')) {
return fail(400, {
email,
error: 'Invalid email',
missing: !email,
});
}
// Success - process and maybe redirect
await processEmail(email);
throw redirect(303, '/success');
},
};
```
```svelte
<!-- +page.svelte -->
<script>
export let form; // Contains fail() return value
</script>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<form method="POST">
<input name="email" value={form?.email ?? ''} />
{#if form?.missing}
<span>Email is required</span>
{/if}
<button>Submit</button>
</form>
```
**Key points:**
- **Return** fail() (don't throw)
- Status code (400 = bad request)
- Return validation errors + form data to repopulate fields
- Accessible via `form` prop in page component
- Page stays on same URL
## redirect() - Navigation
**Throw** redirect() to navigate user to another page:
```typescript
import { redirect } from '@sveltejs/kit';
export const actions = {
login: async ({ request, cookies }) => {
const data = await request.formData();
const user = await authenticate(data);
if (!user) {
return fail(401, { error: 'Invalid credentials' });
}
cookies.set('session', user.sessionToken, { path: '/' });
// Success - redirect to dashboard
throw redirect(303, '/dashboard'); // MUST throw
},
};
```
**Status codes:**
- `303` - See Other (recommended for POST → GET redirect)
- `301` - Moved Permanently
- `302` - Found (temporary redirect)
- `307` - Temporary Redirect (preserves method)
- `308` - Permanent Redirect (preserves method)
**Use 303** for most cases (especially after form submission).
**Key points:**
- **MUST throw** (not return)
- Use status 303 for form actions
- Can redirect to external URLs
- Can use relative paths: `throw redirect(303, '..')`
## error() - Fatal Errors
**Throw** error() for unrecoverable errors (auth, not found, server
error):
```typescript
import { error } from '@sveltejs/kit';
export const load = async ({ params, locals }) => {
const post = await db.query.posts.findFirst({
where: eq(posts.id, params.id),
});
if (!post) {
throw error(404, 'Post not found'); // MUST throw
}
if (post.authorId !== locals.userId) {
throw error(403, 'Forbidden'); // MUST throw
}
return { post };
};
```
**Common status codes:**
- `400` - Bad Request
- `401` - Unauthorized (not logged in)
- `403` - Forbidden (logged in but no permission)
- `404` - Not Found
- `500` - Internal Server Error
**With custom error page:**
```typescript
// src/routes/posts/[id]/+page.server.ts
import { error } from '@sveltejs/kit';
export const load = async ({ params }) => {
const post = await getPost(params.id);
if (!post) {
throw error(404, {
message: 'Post not found',
postId: params.id,
});
}
return { post };
};
```
```svelte
<!-- src/routes/posts/[id]/+error.svelte -->
<script>
import { page } from '$app/stores';
</script>
<h1>{$page.status}: {$page.error.message}</h1>
{#if $page.error.postId}
<p>Could not find post with ID: {$page.error.postId}</p>
{/if}
```
**Key points:**
- **MUST throw** (not return)
- Renders closest `+error.svelte` file
- Accessible via `$page.status` and `$page.error`
- Stops load function execution
- Use for authorization, not found, server errors
## Common Mistakes
### ❌ Not Throwing redirect()
```typescript
// WRONG
export const actions = {
default: async () => {
redirect(303, '/home'); // DOESN'T WORK
},
};
// RIGHT
export const actions = {
default: async () => {
throw redirect(303, '/home'); // MUST throw
},
};
```
### ❌ Not Throwing error()
```typescript
// WRONG
export const load = async () => {
error(404, 'Not found'); // DOESN'T WORK
};
// RIGHT
export const load = async () => {
throw error(404, 'Not found'); // MUST throw
};
```
### ❌ Throwing fail()
```typescript
// WRONG
export const actions = {
default: async () => {
throw fail(400, { error: 'Bad' }); // Don't throw
},
};
// RIGHT
export const actions = {
default: async () => {
return fail(400, { error: 'Bad' }); // Return
},
};
```
### ❌ Catching redirect Without Rethrowing
```typescript
// WRONG
export const actions = {
default: async () => {
try {
await doSomething();
throw redirect(303, '/success');
} catch (e) {
console.error(e); // Catches redirect - it won't work!
return fail(500, { error: 'Failed' });
}
},
};
// RIGHT
import { isRedirect } from '@sveltejs/kit';
export const actions = {
default: async () => {
try {
await doSomething();
throw redirect(303, '/success');
} catch (e) {
if (isRedirect(e)) throw e; // Rethrow redirect
console.error(e);
return fail(500, { error: 'Failed' });
}
},
};
```
## Decision Tree
```
Problem in form action?
├─ Validation error (show to user) → return fail(400, { errors })
├─ Success (navigate) → throw redirect(303, '/success')
└─ Fatal error (auth, not found) → throw error(403, 'Forbidden')
Problem in load function?
├─ Data not found → throw error(404, 'Not found')
├─ Unauthorized → throw error(401, 'Unauthorized')
├─ Forbidden → throw error(403, 'Forbidden')
└─ Server error → throw error(500, 'Server error')
```
## Patterns
### Pattern 1: Validate, Process, Redirect
```typescript
export const actions = {
create: async ({ request }) => {
const data = await request.formData();
// 1. Validate
if (!isValid(data)) {
return fail(400, { errors: getErrors(data) });
}
// 2. Process
const post = await createPost(data);
// 3. Redirect
throw redirect(303, `/posts/${post.id}`);
},
};
```
### Pattern 2: Check Auth, Load Data
```typescript
export const load = async ({ locals, params }) => {
// 1. Check auth
if (!locals.user) {
throw error(401, 'Please log in');
}
// 2. Load data
const post = await getPost(params.id);
if (!post) {
throw error(404, 'Post not found');
}
// 3. Check permission
if (post.authorId !== locals.user.id) {
throw error(403, 'Not your post');
}
return { post };
};
```
### Pattern 3: Conditional Redirect
```typescript
export const load = async ({ locals }) => {
// Redirect if already logged in
if (locals.user) {
throw redirect(303, '/dashboard');
}
return {}; // Show login page
};
```
## Summary Table
| | fail() | redirect() | error() |
| -------------------- | ----------------- | --------------- | --------------- |
| **Throw or return?** | Return | **Throw** | **Throw** |
| **Use in** | Form actions | Actions & load | Actions & load |
| **Purpose** | Validation errors | Navigate | Fatal errors |
| **Status codes** | 400-499 | 301-308 | 400-599 |
| **Accessible via** | `form` prop | N/A (navigates) | `+error.svelte` |
| **Stays on page?** | Yes | No (navigates) | No (error page) |
| **Example** | Invalid email | After save | Not found |
## Client-Side Navigation After Auth
When using client-side auth (e.g., Better Auth client), **always use `goto()` with `invalidateAll: true`** to ensure layout data refreshes:
```svelte
<script>
import { goto } from '$app/navigation';
import { auth_client } from '$lib/auth-client';
async function handle_signin() {
const result = await auth_client.signIn.email({ email, password });
if (!result.error) {
// ✅ CORRECT: invalidateAll ensures layout load functions re-run
await goto('/dashboard', { invalidateAll: true });
}
}
</script>
```
### ❌ Common Mistake: Separate invalidateAll + goto
```typescript
// WRONG: Data might not refresh before navigation
await invalidateAll();
goto('/dashboard');
// ALSO WRONG: goto doesn't wait for invalidation
await invalidateAll();
await goto('/dashboard');
```
### ✅ Correct Pattern
```typescript
// RIGHT: Single call with invalidateAll option
await goto('/dashboard', { invalidateAll: true });
```
**Why this matters:** After client-side auth, cookies are set but the root layout's `load` function (which typically checks `auth.api.getSession()`) has cached data. Using `invalidateAll: true` forces all load functions to re-run with the new session cookie.
**Note:** Server-side redirects with `throw redirect()` don't have this problem because they trigger a full page load.
## Quick Checklist
- ✅ Using `return fail()` for validation errors?
- ✅ Using `throw redirect()` (not return) after success?
- ✅ Using `throw error()` (not return) for fatal errors?
- ✅ Not catching redirects/errors without rethrowing?
- ✅ Using status code 303 for POST redirects?
- ✅ Using `goto(url, { invalidateAll: true })` after client-side auth?
@@ -1,316 +0,0 @@
# Form Actions
## Basic Pattern
Form actions live in `+page.server.ts` and handle form submissions:
```typescript
// +page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
const password = data.get('password');
if (!email) {
return fail(400, { email, missing: true });
}
await login(email, password);
throw redirect(303, '/dashboard');
},
};
```
```svelte
<!-- +page.svelte -->
<script>
export let form; // Contains return value from action
</script>
<form method="POST">
<input name="email" value={form?.email ?? ''} />
{#if form?.missing}
<p class="error">Email is required</p>
{/if}
<button>Login</button>
</form>
```
## Named Actions
```typescript
export const actions: Actions = {
login: async ({ request }) => {
// Handle login
},
register: async ({ request }) => {
// Handle registration
},
};
```
```svelte
<form method="POST" action="?/login">...</form>
<form method="POST" action="?/register">...</form>
```
## Return Values
### Option 1: fail() - Show Error to User
```typescript
import { fail } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
if (!isValid(data)) {
return fail(400, {
error: 'Invalid data',
fields: Object.fromEntries(data), // Preserve input
});
}
// Success - no return needed
},
};
```
### Option 2: redirect() - Navigate After Success
```typescript
import { redirect } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
await processForm(request);
throw redirect(303, '/success'); // MUST throw
},
};
```
### Option 3: error() - Fatal Error
```typescript
import { error } from '@sveltejs/kit';
export const actions = {
default: async ({ request }) => {
const user = await getCurrentUser();
if (!user) {
throw error(401, 'Unauthorized'); // MUST throw
}
// Process...
},
};
```
## Progressive Enhancement
Form works without JavaScript:
```svelte
<script>
import { enhance } from '$app/forms';
</script>
<form method="POST" use:enhance>
<!-- Works with or without JS -->
</form>
```
With custom handling:
```svelte
<form
method="POST"
use:enhance={({ formData, cancel }) => {
// Before submit
formData.append('timestamp', Date.now().toString());
return async ({ result, update }) => {
// After response
if (result.type === 'success') {
await update(); // Update form prop
}
};
}}
>
...
</form>
```
## Validation Pattern
```typescript
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const rawData = {
email: data.get('email'),
password: data.get('password'),
};
const result = schema.safeParse(rawData);
if (!result.success) {
return fail(400, {
errors: result.error.flatten().fieldErrors,
data: rawData,
});
}
// result.data is validated
await createUser(result.data);
throw redirect(303, '/welcome');
},
};
```
## Common Mistakes
### ❌ Not Throwing redirect/error
```typescript
// WRONG
export const actions = {
default: async () => {
redirect(303, '/home'); // DOESN'T WORK - must throw
},
};
// RIGHT
export const actions = {
default: async () => {
throw redirect(303, '/home');
},
};
```
### ❌ Catching redirect Without Rethrowing
```typescript
// WRONG
export const actions = {
default: async () => {
try {
await doSomething();
throw redirect(303, '/success');
} catch (e) {
console.error(e); // Catches redirect!
}
},
};
// RIGHT
export const actions = {
default: async () => {
try {
await doSomething();
throw redirect(303, '/success');
} catch (e) {
if (e instanceof Redirect) throw e; // Rethrow
console.error(e);
}
},
};
```
### ❌ Returning non-serializable data
```typescript
// WRONG
export const actions = {
default: async () => {
return { date: new Date() }; // Date is not serializable
},
};
// RIGHT
export const actions = {
default: async () => {
return { date: new Date().toISOString() };
},
};
```
## File Upload
```typescript
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const file = data.get('file') as File;
if (!file || file.size === 0) {
return fail(400, { error: 'No file uploaded' });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await saveFile(buffer, file.name);
throw redirect(303, '/uploads');
},
};
```
```svelte
<form method="POST" enctype="multipart/form-data">
<input type="file" name="file" required />
<button>Upload</button>
</form>
```
## Form Prop in Page
```svelte
<script>
export let data; // From load function
export let form; // From action return value
</script>
{#if form?.error}
<div class="error">{form.error}</div>
{/if}
{#if form?.success}
<div class="success">Success!</div>
{/if}
<form method="POST">
<input name="email" value={form?.email ?? data.email ?? ''} />
{#if form?.errors?.email}
<p>{form.errors.email}</p>
{/if}
<button>Submit</button>
</form>
```
## Key Rules
1. ✅ Actions must be in `+page.server.ts` (not `+page.ts`)
2. ✅ ALWAYS throw `redirect()` and `error()` (not return)
3. ✅ Return `fail()` for validation errors
4. ✅ Return only serializable data
5. ✅ Don't catch redirects/errors without rethrowing
6. ✅ Use `enhance` for progressive enhancement
7. ✅ Access FormData with `data.get('fieldName')`
@@ -1,292 +0,0 @@
# Load Functions: Server vs Universal
## Decision Matrix
| Need | Use | File |
| ----------------------------------- | -------------- | ----------------- |
| Database access | Server load | `+page.server.ts` |
| Secrets/env vars | Server load | `+page.server.ts` |
| Server-only packages | Server load | `+page.server.ts` |
| Browser APIs (window, localStorage) | Universal load | `+page.ts` |
| Client-side fetch | Universal load | `+page.ts` |
| Runs on both | Universal load | `+page.ts` |
## Server Load (+page.server.ts)
**When:** Need server-only resources (DB, secrets, server APIs)
**Runs:** Only on server (never in browser)
```typescript
// src/routes/profile/+page.server.ts
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/database';
export const load: PageServerLoad = async ({ locals, params }) => {
// Access server-only resources
const user = await db.query.users.findFirst({
where: eq(users.id, locals.userId),
});
const posts = await db.query.posts.findMany({
where: eq(posts.authorId, user.id),
});
// Must return serializable data
return {
user: {
id: user.id,
name: user.name,
email: user.email,
},
posts,
};
};
```
**Key points:**
- Runs only on server
- Can access `$lib/server/*` imports
- Can use secrets from `env` safely
- Return values must be JSON-serializable
- Output is automatically passed to universal load
## Universal Load (+page.ts)
**When:** Need to run on both server and client, or need browser APIs
**Runs:** Server (during SSR) AND client (during navigation)
```typescript
// src/routes/dashboard/+page.ts
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ data, fetch }) => {
// `data` comes from +page.server.ts if it exists
const { user } = data;
// Fetch additional data (works on both server and client)
const response = await fetch('/api/stats');
const stats = await response.json();
// Can access browser APIs (but check if in browser first)
const theme =
typeof window !== 'undefined'
? localStorage.getItem('theme')
: null;
return {
user,
stats,
theme,
};
};
```
**Key points:**
- Runs on both server AND client
- Receives server load output as `data` parameter
- Use SvelteKit's `fetch` (automatically handles SSR)
- Check `typeof window !== 'undefined'` for browser APIs
- Cannot import from `$lib/server/*`
## Data Flow
```
Request → Server Load (+page.server.ts)
↓ (returns { user })
Universal Load (+page.ts)
↓ (receives data: { user }, returns { user, stats })
Page Component (+page.svelte)
↓ (receives data: { user, stats })
```
**Example:**
```typescript
// +page.server.ts
export const load = async () => {
return { serverData: 'from server' };
};
// +page.ts
export const load = async ({ data }) => {
console.log(data.serverData); // 'from server'
return { ...data, clientData: 'from universal' };
};
// +page.svelte
<script>
export let data; // { serverData, clientData }
</script>
```
## Common Patterns
### Pattern 1: Server + Universal
```typescript
// +page.server.ts - Fetch sensitive data
export const load = async ({ locals }) => {
const user = await getUser(locals.session);
return { user };
};
// +page.ts - Fetch public data
export const load = async ({ data, fetch }) => {
const publicPosts = await fetch('/api/posts').then((r) => r.json());
return { ...data, publicPosts };
};
```
### Pattern 2: Conditional Universal Load
```typescript
// +page.ts
import { browser } from '$app/environment';
export const load = async ({ fetch }) => {
const serverData = await fetch('/api/data').then((r) => r.json());
// Only run in browser
let clientOnlyData = null;
if (browser) {
clientOnlyData = localStorage.getItem('cache');
}
return { serverData, clientOnlyData };
};
```
### Pattern 3: Depends for Revalidation
```typescript
// +page.ts
export const load = async ({ fetch, depends }) => {
depends('app:posts'); // Invalidate with invalidate('app:posts')
const posts = await fetch('/api/posts').then((r) => r.json());
return { posts };
};
// Somewhere else:
import { invalidate } from '$app/navigation';
invalidate('app:posts'); // Re-runs load function
```
## Common Mistakes
### ❌ Importing Server Code in Universal Load
```typescript
// +page.ts - WRONG
import { db } from '$lib/server/database'; // ERROR - can't import server code
export const load = async () => {
const users = await db.query.users.findMany(); // Won't work
return { users };
};
```
**Fix:** Move to `+page.server.ts`
### ❌ Returning Non-Serializable Data
```typescript
// +page.server.ts - WRONG
export const load = async () => {
const user = await User.findOne(); // Returns class instance
return { user }; // ERROR - class instances aren't serializable
};
```
**Fix:** Return plain objects
```typescript
export const load = async () => {
const user = await User.findOne();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
},
};
};
```
### ❌ Using window/localStorage Without Check
```typescript
// +page.ts - WRONG
export const load = async () => {
const theme = localStorage.getItem('theme'); // ERROR on server
return { theme };
};
```
**Fix:** Check for browser
```typescript
import { browser } from '$app/environment';
export const load = async () => {
const theme = browser ? localStorage.getItem('theme') : 'light';
return { theme };
};
```
## TypeScript
```typescript
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({
locals,
params,
url,
}) => {
// TypeScript knows return type must be serializable
return {
user: {
id: 1,
name: 'Alice',
},
};
};
```
```typescript
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ data, fetch, params }) => {
// `data` is typed from +page.server.ts return type
return {
...data,
extra: 'data',
};
};
```
## When to Use Which
**Use Server Load when:**
- Need database access
- Need secrets/environment variables
- Need server-only npm packages
- Need to hide implementation details
**Use Universal Load when:**
- Need browser APIs (localStorage, window)
- Need to fetch public APIs (works on both)
- Want client-side navigation without server round-trip
- Data is public and doesn't need server resources
**Use Both when:**
- Server load fetches sensitive data
- Universal load fetches public data or adds client-side enhancements
@@ -1,317 +0,0 @@
# Serialization: What Can/Can't Be Returned
## The Rule
**Server load functions and form actions must return JSON-serializable
data.**
Data travels from server → client as JSON. Non-JSON types break.
## ✅ Serializable (Safe)
| Type | Example | Notes |
| ------------ | ------------------------- | --------------------------------- |
| String | `"hello"` | ✅ |
| Number | `42`, `3.14` | ✅ |
| Boolean | `true`, `false` | ✅ |
| null | `null` | ✅ |
| Array | `[1, 2, 3]` | ✅ |
| Plain Object | `{ name: 'Alice' }` | ✅ |
| Nested | `{ user: { posts: [] } }` | ✅ If all values are serializable |
## ❌ NOT Serializable (Breaks)
| Type | Example | Why | Fix |
| -------------- | -------------- | -------------------------- | -------------------------------------------- |
| Date | `new Date()` | Becomes string | Use `.toISOString()` |
| undefined | `undefined` | Removed from JSON | Use `null` |
| Function | `() => {}` | Can't serialize | Remove or convert to data |
| Class instance | `new User()` | Only serializes properties | Convert to plain object |
| Map | `new Map()` | Becomes `{}` | Convert to object: `Object.fromEntries(map)` |
| Set | `new Set()` | Becomes `{}` | Convert to array: `Array.from(set)` |
| BigInt | `123n` | Error | Convert to string |
| Symbol | `Symbol('id')` | Removed | Don't use |
| RegExp | `/test/` | Becomes `{}` | Convert to string |
| Error | `new Error()` | Loses stack | Extract message/code |
## Examples
### ❌ Wrong: Returning Date
```typescript
// +page.server.ts - WRONG
export const load = async () => {
return {
createdAt: new Date(), // Serializes as string, not Date object
};
};
```
```svelte
<!-- +page.svelte -->
<script>
export let data;
console.log(data.createdAt); // String, not Date
console.log(data.createdAt.getTime()); // ERROR - not a Date
</script>
```
### ✅ Right: Convert Date to ISO String
```typescript
// +page.server.ts - RIGHT
export const load = async () => {
const user = await db.users.findFirst();
return {
user: {
id: user.id,
name: user.name,
createdAt: user.createdAt.toISOString(), // Convert to string
},
};
};
```
```svelte
<!-- +page.svelte -->
<script>
export let data;
const createdAt = new Date(data.user.createdAt); // Parse back to Date
</script>
```
### ❌ Wrong: Returning Class Instance
```typescript
// +page.server.ts - WRONG
class User {
constructor(
public id: number,
public name: string,
) {}
getDisplayName() {
return `User: ${this.name}`;
}
}
export const load = async () => {
const user = new User(1, 'Alice');
return { user }; // Methods are lost during serialization
};
```
```svelte
<script>
export let data;
console.log(data.user.getDisplayName()); // ERROR - method doesn't exist
</script>
```
### ✅ Right: Return Plain Object
```typescript
// +page.server.ts - RIGHT
export const load = async () => {
const user = await db.users.findFirst();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
// Only plain data, no methods
},
};
};
```
### ❌ Wrong: undefined Values
```typescript
// +page.server.ts - WRONG
export const load = async () => {
return {
name: 'Alice',
email: undefined, // Removed during JSON.stringify
};
};
```
```svelte
<script>
export let data;
console.log('email' in data); // false - key is missing!
</script>
```
### ✅ Right: Use null
```typescript
// +page.server.ts - RIGHT
export const load = async () => {
return {
name: 'Alice',
email: null, // Preserved
};
};
```
### ❌ Wrong: Map/Set
```typescript
// +page.server.ts - WRONG
export const load = async () => {
const tags = new Set(['svelte', 'typescript']);
const metadata = new Map([['version', '1.0']]);
return {
tags, // Becomes {}
metadata, // Becomes {}
};
};
```
### ✅ Right: Convert to Array/Object
```typescript
// +page.server.ts - RIGHT
export const load = async () => {
const tags = new Set(['svelte', 'typescript']);
const metadata = new Map([['version', '1.0']]);
return {
tags: Array.from(tags), // ['svelte', 'typescript']
metadata: Object.fromEntries(metadata), // { version: '1.0' }
};
};
```
## ORM Returns (Drizzle, Prisma)
Most ORMs return plain objects with Date fields:
```typescript
// +page.server.ts
export const load = async () => {
const user = await db.query.users.findFirst();
// user = { id: 1, name: 'Alice', createdAt: Date }
return {
user: {
...user,
createdAt: user.createdAt.toISOString(), // Convert Date
},
};
};
```
Or use a helper:
```typescript
function serialize<T extends Record<string, any>>(obj: T): T {
return JSON.parse(JSON.stringify(obj)); // Forces serialization
}
export const load = async () => {
const user = await db.query.users.findFirst();
return { user: serialize(user) };
};
```
## BigInt
```typescript
// +page.server.ts - WRONG
export const load = async () => {
return {
userId: 123456789012345678901234567890n, // ERROR - can't serialize
};
};
// +page.server.ts - RIGHT
export const load = async () => {
return {
userId: '123456789012345678901234567890', // String
};
};
```
## Detecting Serialization Issues
SvelteKit will throw an error if you try to return non-serializable
data:
```
Error: Data returned from `load` while rendering / is not serializable:
- Cannot stringify arbitrary non-POJOs
```
## Testing Serialization
```typescript
const data = { user: new User() };
try {
JSON.parse(JSON.stringify(data));
console.log('✅ Serializable');
} catch (e) {
console.log('❌ Not serializable');
}
```
## TypeScript Help
Use `satisfies` to catch issues at compile time:
```typescript
import type { PageServerLoad } from './$types';
export const load = (async () => {
return {
user: {
id: 1,
name: 'Alice',
createdAt: new Date(), // TypeScript allows this, but it's problematic
},
};
}) satisfies PageServerLoad;
```
Better: Create a type that only allows primitives:
```typescript
type Serializable =
| string
| number
| boolean
| null
| { [key: string]: Serializable }
| Serializable[];
export const load: PageServerLoad = async () => {
const data: Serializable = {
user: {
id: 1,
name: 'Alice',
createdAt: new Date().toISOString(), // Must be string
},
};
return data;
};
```
## Quick Checklist
Before returning from server load or form action:
1. ✅ All values are string, number, boolean, null, array, or plain
object?
2. ✅ No Date objects? (convert to `.toISOString()`)
3. ✅ No undefined? (use null)
4. ✅ No class instances? (convert to plain objects)
5. ✅ No Map/Set? (convert to object/array)
6. ✅ No functions?
7. ✅ No BigInt? (convert to string)
@@ -1,17 +0,0 @@
# Sveltekit Structure
SvelteKit structure guidance. Use for routing, layouts, error
handling, and SSR. Covers file naming (+page vs +layout vs +server),
nested layouts, error boundaries, and hydration.
## Structure
- `SKILL.md` - Main skill instructions
- `references/` - Detailed documentation loaded as needed
- `scripts/` - Executable code for deterministic operations
- `assets/` - Templates, images, or other resources
## Usage
This skill is automatically discovered by Claude when relevant to the
task.
@@ -1,79 +0,0 @@
---
name: sveltekit-structure
# IMPORTANT: Keep description on ONE line for Claude Code compatibility
# prettier-ignore
description: SvelteKit structure guidance. Use for routing, layouts, error handling, SSR, or svelte:boundary. Covers file naming, nested layouts, error boundaries, pending UI, and hydration.
---
# SvelteKit Structure
## Quick Start
**File types:** `+page.svelte` (page) | `+layout.svelte` (wrapper) |
`+error.svelte` (error boundary) | `+server.ts` (API endpoint)
**Routes:** `src/routes/about/+page.svelte` → `/about` |
`src/routes/posts/[id]/+page.svelte` → `/posts/123`
**Layouts:** Apply to all child routes. `+layout.svelte` at any level
wraps descendants.
## Example
```
src/routes/
├── +layout.svelte # Root layout (all pages)
├── +page.svelte # Homepage /
├── about/+page.svelte # /about
└── dashboard/
├── +layout.svelte # Dashboard layout (dashboard pages only)
├── +page.svelte # /dashboard
└── settings/+page.svelte # /dashboard/settings
```
```svelte
<!-- +layout.svelte -->
<script>
let { children } = $props();
</script>
<nav><!-- Navigation --></nav>
<main>{@render children()}</main>
<footer><!-- Footer --></footer>
```
## Reference Files
- [file-naming.md](references/file-naming.md) - File naming
conventions
- [layout-patterns.md](references/layout-patterns.md) - Nested layouts
- [error-handling.md](references/error-handling.md) - +error.svelte
placement
- [svelte-boundary.md](references/svelte-boundary.md) -
Component-level error/pending
- [ssr-hydration.md](references/ssr-hydration.md) - SSR and
browser-only code
## Notes
- Layouts: `{@render children()}` | Errors: +error.svelte _above_
failing route
- Groups: `(name)` folders don't affect URL | Client-only: check
`browser`
- **Last verified:** 2025-01-11
<!--
PROGRESSIVE DISCLOSURE GUIDELINES:
- Keep this file ~50 lines total (max ~150 lines)
- Use 1-2 code blocks only (recommend 1)
- Keep description <200 chars for Level 1 efficiency
- Move detailed docs to references/ for Level 3 loading
- This is Level 2 - quick reference ONLY, not a manual
LLM WORKFLOW (when editing this file):
1. Write/edit SKILL.md
2. Format (if formatter available)
3. Run: claude-skills-cli validate <path>
4. If multi-line description warning: run claude-skills-cli doctor <path>
5. Validate again to confirm
-->
@@ -1,274 +0,0 @@
# Error Handling
## Error Boundary Placement
**Key rule:** `+error.svelte` must be _above_ the failing route in the
hierarchy.
```
src/routes/
├── +error.svelte # Catches errors in all routes below
├── +page.svelte # If this errors → uses +error.svelte above
└── admin/
├── +error.svelte # Catches errors in admin routes
└── +page.svelte # If this errors → uses admin/+error.svelte
```
**Wrong:**
```
src/routes/dashboard/
├── +layout.svelte # If this errors...
└── +error.svelte # This won't catch it (too low)
```
**Right:**
```
src/routes/
├── +error.svelte # Catches dashboard layout errors
└── dashboard/
├── +layout.svelte
└── +error.svelte # Catches dashboard page errors
```
## Error Propagation
Errors bubble up to the nearest `+error.svelte`:
```
src/routes/
├── +error.svelte # Level 1 (root fallback)
└── blog/
├── +error.svelte # Level 2 (blog fallback)
└── [slug]/
├── +layout.server.ts # Error here → blog/+error.svelte
├── +page.server.ts # Error here → blog/+error.svelte
└── +page.svelte # Error here → blog/+error.svelte
```
**If no error boundary exists at that level, it goes to the parent.**
## Basic Error Page
```svelte
<!-- +error.svelte -->
<script>
import { page } from '$app/stores';
</script>
<h1>{$page.status}</h1><p>{$page.error.message}</p>
```
## Custom Error Data
```typescript
// +page.server.ts
import { error } from '@sveltejs/kit';
export const load = async ({ params }) => {
const post = await getPost(params.id);
if (!post) {
throw error(404, {
message: 'Post not found',
postId: params.id,
});
}
return { post };
};
```
```svelte
<!-- +error.svelte -->
<script>
import { page } from '$app/stores';
</script>
<h1>{$page.status}</h1>
<p>{$page.error.message}</p>
{#if $page.error.postId}
<p>Could not find post with ID: {$page.error.postId}</p>
{/if}
```
## Status Code Specific Errors
```svelte
<!-- +error.svelte -->
<script>
import { page } from '$app/stores';
</script>
{#if $page.status === 404}
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/">Go home</a>
{:else if $page.status === 403}
<h1>Access Denied</h1>
<p>You don't have permission to view this page.</p>
{:else if $page.status === 401}
<h1>Unauthorized</h1>
<p>Please log in to continue.</p>
<a href="/login">Login</a>
{:else if $page.status >= 500}
<h1>Server Error</h1>
<p>Something went wrong on our end. Please try again later.</p>
{:else}
<h1>Error {$page.status}</h1>
<p>{$page.error.message}</p>
{/if}
```
## Common Status Codes
- **400** - Bad Request (malformed request)
- **401** - Unauthorized (not logged in)
- **403** - Forbidden (logged in but no permission)
- **404** - Not Found (resource doesn't exist)
- **500** - Internal Server Error (unhandled exception)
- **503** - Service Unavailable (server overloaded/down)
## Error in Layout vs Page
**Layout error:**
```typescript
// src/routes/dashboard/+layout.server.ts
export const load = async ({ locals }) => {
if (!locals.user) {
throw error(401, 'Not logged in'); // Caught by +error.svelte ABOVE dashboard
}
return { user: locals.user };
};
```
**Page error:**
```typescript
// src/routes/dashboard/settings/+page.server.ts
export const load = async () => {
throw error(404, 'Settings not found'); // Caught by nearest +error.svelte
};
```
## Expected Errors vs Unexpected Errors
**Expected (use error()):**
```typescript
// User requests invalid resource
if (!post) throw error(404, 'Post not found');
// User lacks permission
if (post.authorId !== user.id) throw error(403, 'Not your post');
```
**Unexpected (let it bubble):**
```typescript
// Unhandled exceptions (DB connection fails, etc.)
// Will show generic 500 error
const posts = await db.query.posts.findMany(); // Might throw
```
## Handling Errors in handleError Hook
```typescript
// src/hooks.server.ts
import type { HandleServerError } from '@sveltejs/kit';
export const handleError: HandleServerError = ({ error, event }) => {
// Log to error tracking service
console.error('Error:', error, 'Path:', event.url.pathname);
// Return user-friendly message (don't expose internals)
return {
message: 'An unexpected error occurred',
code: error?.code ?? 'UNKNOWN',
};
};
```
## Error in Load vs Error in Component
**Load function error (recommended):**
```typescript
// +page.server.ts
export const load = async ({ params }) => {
const user = await getUser(params.id);
if (!user) throw error(404, 'User not found'); // Shows +error.svelte
return { user };
};
```
**Component error (not ideal):**
```svelte
<!-- +page.svelte - Don't do this -->
<script>
export let data;
// If data.user is undefined, component just shows nothing
// No error boundary triggered
</script>
<h1>{data.user.name}</h1> <!-- Might crash if user undefined -->
```
**Best practice:** Validate and throw errors in `load` functions, not
components.
## Fallback Error Handling
Always have a root `+error.svelte`:
```svelte
<!-- src/routes/+error.svelte -->
<script>
import { page } from '$app/stores';
import { dev } from '$app/environment';
</script>
<h1>Oops! Something went wrong</h1>
{#if dev}
<!-- Show details in dev mode -->
<pre>{JSON.stringify($page.error, null, 2)}</pre>
{:else}
<!-- Generic message in production -->
<p>We're sorry, but something unexpected happened.</p>
{/if}
<a href="/">Go home</a>
```
## Testing Error Boundaries
```typescript
// +page.server.ts
export const load = async ({ url }) => {
// Test error page in dev
if (url.searchParams.has('test-error')) {
throw error(500, 'Test error');
}
return {};
};
```
Visit `http://localhost:5173/page?test-error` to see error boundary.
## Key Takeaways
1. ✅ Place `+error.svelte` ABOVE failing routes
2. ✅ Errors bubble up to nearest error boundary
3. ✅ Always have a root `src/routes/+error.svelte`
4. ✅ Throw errors in `load` functions, not components
5. ✅ Use specific status codes (401, 403, 404, 500)
6. ✅ Customize error pages per section (blog, admin, etc.)
7. ✅ Use `handleError` hook for logging/monitoring
8. ✅ Show detailed errors in dev, generic in production
@@ -1,69 +0,0 @@
# File Naming Conventions
## Core Files
| File | Purpose | Runs | Example |
| ------------------- | --------------------- | --------------------- | --------------------------------------------- |
| `+page.svelte` | Page component | Client & Server (SSR) | `/routes/about/+page.svelte` → `/about` |
| `+page.ts` | Universal load | Client & Server | Data for +page.svelte |
| `+page.server.ts` | Server load & actions | Server only | DB queries, form actions |
| `+layout.svelte` | Layout wrapper | Client & Server | Wraps child routes |
| `+layout.ts` | Layout universal load | Client & Server | Data for +layout.svelte |
| `+layout.server.ts` | Layout server load | Server only | Auth, user data |
| `+error.svelte` | Error boundary | Client & Server | Shown when error thrown |
| `+server.ts` | API endpoint | Server only | `/routes/api/users/+server.ts` → `/api/users` |
## Route Parameters
| Pattern | Matches | Example |
| -------------- | -------------- | ---------------------------------------------------------------- |
| `[id]` | Single param | `/posts/[id]/+page.svelte` → `/posts/123` |
| `[slug]` | Single param | `/blog/[slug]/+page.svelte` → `/blog/hello-world` |
| `[[optional]]` | Optional param | `/search/[[query]]/+page.svelte` → `/search` or `/search/svelte` |
| `[...rest]` | Rest params | `/docs/[...path]/+page.svelte` → `/docs/a/b/c` |
## Route Groups
| Pattern | Purpose | URL |
| ------------- | ---------------------------- | ---------------------------------------------- |
| `(group)` | Group routes (no URL impact) | `/(app)/dashboard/+page.svelte` → `/dashboard` |
| `(marketing)` | Separate layouts | Different layout for marketing pages |
## Special Files
- `hooks.server.ts` - Server hooks (handle function, runs on every
request)
- `hooks.client.ts` - Client hooks (runs in browser)
- `app.html` - HTML template
- `service-worker.ts` - Service worker
- `params/*.ts` - Param validators
## Common Patterns
```
src/routes/
├── +layout.svelte # Root layout
├── +layout.ts # Root data
├── +page.svelte # Homepage
├── +error.svelte # Root error boundary
│
├── (app)/ # App routes (grouped)
│ ├── +layout.svelte # App layout (auth required)
│ ├── dashboard/+page.svelte # /dashboard
│ └── settings/+page.svelte # /settings
│
├── (marketing)/ # Marketing routes (grouped)
│ ├── +layout.svelte # Marketing layout
│ ├── about/+page.svelte # /about
│ └── pricing/+page.svelte # /pricing
│
├── blog/
│ ├── +page.svelte # /blog (list)
│ └── [slug]/
│ ├── +page.svelte # /blog/post-title
│ └── +page.server.ts # Load post data
│
└── api/
└── posts/
└── +server.ts # API: GET/POST /api/posts
```
@@ -1,387 +0,0 @@
# Layout Patterns
## Basic Layout
```svelte
<!-- src/routes/+layout.svelte -->
<script>
let { children } = $props();
</script>
<header>Header</header>
<main>{@render children()}</main>
<footer>Footer</footer>
```
**Key points:**
- Must declare `children` in `$props()`
- Use `{@render children()}` to render nested content
- Root layout wraps ALL pages
## Nested Layouts
Layouts inherit from parent layouts:
```
src/routes/
├── +layout.svelte # Root layout (all pages)
└── dashboard/
├── +layout.svelte # Dashboard layout (dashboard pages only)
└── +page.svelte # Uses both layouts
```
**Result:** Root layout wraps dashboard layout wraps page.
**Rendering order:**
```
Root +layout.svelte
└─ Dashboard +layout.svelte
└─ +page.svelte
```
### Example: Nested Layout Pattern
```svelte
<!-- src/routes/+layout.svelte -->
<script>
let { children } = $props();
</script>
<div class="app">
<nav>Global Nav</nav>
{@render children()}
</div>
```
```svelte
<!-- src/routes/dashboard/+layout.svelte -->
<script>
let { children } = $props();
</script>
<div class="dashboard">
<aside>Dashboard Sidebar</aside>
<main>{@render children()}</main>
</div>
```
```svelte
<!-- src/routes/dashboard/+page.svelte --><h1>Dashboard Home</h1>
```
**Rendered HTML structure:**
```html
<div class="app">
<nav>Global Nav</nav>
<div class="dashboard">
<aside>Dashboard Sidebar</aside>
<main>
<h1>Dashboard Home</h1>
</main>
</div>
</div>
```
## Layout Groups
Use `(groups)` to organize without affecting URLs:
```
src/routes/
├── (marketing)/
│ ├── +layout.svelte # Marketing layout
│ ├── about/+page.svelte # /about (uses marketing layout)
│ └── pricing/+page.svelte # /pricing (uses marketing layout)
│
└── (app)/
├── +layout.svelte # App layout
├── dashboard/+page.svelte # /dashboard (uses app layout)
└── settings/+page.svelte # /settings (uses app layout)
```
**Key points:**
- Parentheses make groups invisible in URLs
- `/about` route, NOT `/(marketing)/about`
- Different layouts for different sections
- Useful for authentication boundaries
### Example: Marketing vs App Layouts
```svelte
<!-- src/routes/(marketing)/+layout.svelte -->
<script>
let { children } = $props();
</script>
<header class="marketing-header">
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/login">Login</a>
</nav>
</header>
{@render children()}
<footer>© 2024 Company</footer>
```
```svelte
<!-- src/routes/(app)/+layout.svelte -->
<script>
let { children, data } = $props();
</script>
<header class="app-header">
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
<span>Welcome, {data.user.name}</span>
</nav>
</header>
{@render children()}
```
## Reset Layout
Use `@` in filename to break layout inheritance:
**NOT RECOMMENDED** - This feature is deprecated in SvelteKit 2+.
Instead, use layout groups to create separate hierarchies:
```
src/routes/
├── +layout.svelte # Root layout (for most pages)
├── (app)/
│ └── +layout.svelte # App layout
└── (public)/
└── +layout.svelte # Public layout (completely separate)
```
## Layout with Data Loading
```svelte
<!-- src/routes/+layout.server.ts -->
export const load = async ({ locals }) => {
// Available to all child routes
return {
user: locals.user,
};
};
```
```svelte
<!-- src/routes/+layout.svelte -->
<script>
let { children, data } = $props();
</script>
<header>
{#if data.user}
<span>Welcome, {data.user.name}</span>
{:else}
<a href="/login">Login</a>
{/if}
</header>
{@render children()}
```
## Conditional Layout Content
```svelte
<!-- +layout.svelte -->
<script>
import { page } from '$app/stores';
let { children } = $props();
</script>
{#if !$page.url.pathname.startsWith('/admin')}
<header>Public Header</header>
{/if}
{@render children()}
{#if !$page.url.pathname.includes('/checkout')}
<footer>Footer</footer>
{/if}
```
**Better approach:** Use layout groups instead of conditionals.
## Protected Layouts
```typescript
// src/routes/(app)/+layout.server.ts
import { redirect } from '@sveltejs/kit';
export const load = async ({ locals }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
return {
user: locals.user,
};
};
```
All routes under `(app)` group now require authentication.
## Sharing Layout State
```svelte
<!-- src/routes/+layout.svelte -->
<script>
import { setContext } from 'svelte';
let { children, data } = $props();
// Share state with all descendant components
setContext('user', data.user);
</script>
{@render children()}
```
```svelte
<!-- Any child component -->
<script>
import { getContext } from 'svelte';
const user = getContext('user');
</script>
<p>Hello, {user.name}</p>
```
## Layout Slot Props (Snippets)
```svelte
<!-- src/routes/dashboard/+layout.svelte -->
<script>
let { children, header } = $props();
</script>
<div class="dashboard">
<aside>Sidebar</aside>
<div class="content">
{#if header}
<header>{@render header()}</header>
{/if}
<main>{@render children()}</main>
</div>
</div>
```
```svelte
<!-- src/routes/dashboard/+page.svelte -->
{#snippet header()}
<h1>Custom Dashboard Header</h1>
{/snippet}
<p>Dashboard content</p>
```
## Common Patterns
### Pattern 1: Auth Boundary
```
src/routes/
├── +layout.svelte # Root (no auth)
├── (public)/
│ ├── +layout.svelte # Public layout (landing pages)
│ ├── +page.svelte # /
│ └── about/+page.svelte # /about
└── (protected)/
├── +layout.server.ts # Check auth, redirect if not logged in
├── +layout.svelte # Protected layout (app UI)
├── dashboard/+page.svelte # /dashboard
└── settings/+page.svelte # /settings
```
### Pattern 2: Multi-tenant with Shared Root
```
src/routes/
├── +layout.svelte # Root layout (shared)
├── admin/
│ ├── +layout.svelte # Admin layout
│ └── users/+page.svelte # /admin/users
└── client/
├── +layout.svelte # Client layout
└── projects/+page.svelte # /client/projects
```
### Pattern 3: Progressive Enhancement
```svelte
<!-- src/routes/+layout.svelte -->
<script>
import { page } from '$app/stores';
import { navigating } from '$app/stores';
let { children } = $props();
</script>
<div class="app">
{#if $navigating}
<div class="loading-bar"></div>
{/if}
{@render children()}
</div>
<style>
.loading-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: blue;
animation: loading 1s ease-in-out;
}
</style>
```
## Layout Best Practices
1. ✅ Keep root layout minimal (shared across ALL pages)
2. ✅ Use layout groups for separate sections
3. ✅ Load shared data in layout's load function
4. ✅ Use context for sharing state with descendants
5. ✅ Avoid too many nested layouts (max 2-3 levels)
6. ✅ Don't put auth logic in root layout (use groups)
7. ✅ Remember: layouts share data DOWN, not UP
8. ❌ Avoid conditionals in layouts (use groups instead)
## Debugging Layouts
```svelte
<!-- src/routes/+layout.svelte -->
<script>
import { page } from '$app/stores';
let { children } = $props();
$effect(() => {
console.log('Current route:', $page.url.pathname);
console.log('Layout data:', $page.data);
});
</script>
{@render children()}
```
## Key Takeaways
- Layouts wrap all child routes
- Nested layouts create hierarchy (root → section → page)
- Groups `(name)` organize without affecting URLs
- Load data in `+layout.server.ts` to share with children
- Use context to share reactive state
- Keep layouts simple and focused
@@ -1,92 +0,0 @@
# SSR & Hydration
## The Problem
SvelteKit runs on server (SSR) then hydrates in browser. Code using
browser APIs (`window`, `document`, `localStorage`) fails on server.
## Solution: Check for Browser
```typescript
import { browser } from '$app/environment';
// In load function
export const load = async () => {
const theme = browser ? localStorage.getItem('theme') : 'light';
return { theme };
};
```
```svelte
<!-- In component -->
<script>
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let data = $state(null);
// Option 1: Use browser check
if (browser) {
data = localStorage.getItem('data');
}
// Option 2: Use onMount (only runs in browser)
onMount(() => {
data = localStorage.getItem('data');
});
// Option 3: Use $effect with browser check
$effect(() => {
if (browser) {
data = localStorage.getItem('data');
}
});
</script>
```
## Common Mistakes
### ❌ Using window Without Check
```typescript
// WRONG - fails on server
export const load = async () => {
const width = window.innerWidth; // ERROR on server
return { width };
};
// RIGHT
import { browser } from '$app/environment';
export const load = async () => {
const width = browser ? window.innerWidth : 1024;
return { width };
};
```
### ❌ Accessing DOM in Load
```typescript
// WRONG
export const load = async () => {
const el = document.getElementById('root'); // ERROR on server
};
// RIGHT - Do DOM stuff in onMount
export const load = async () => {
return {};
};
// Then in component:
onMount(() => {
const el = document.getElementById('root');
});
```
## Disable SSR (Not Recommended)
```typescript
// +page.ts
export const ssr = false; // Disables SSR for this page
```
Only use when absolutely necessary (e.g., heavy Canvas/WebGL).
@@ -1,150 +0,0 @@
# svelte:boundary Component
> Available in Svelte 5.3+
## Two Purposes
1. **Error boundaries** - catch rendering errors
2. **Pending UI** - show loading state while `await` resolves
## Basic Error Boundary
```svelte
<svelte:boundary onerror={(e, reset) => console.error(e)}>
<RiskyComponent />
{#snippet failed(error, reset)}
<p>Error: {error.message}</p>
<button onclick={reset}>Try again</button>
{/snippet}
</svelte:boundary>
```
## Pending UI (Loading States)
> **⚠️ Known Bug:** `<svelte:boundary>` + `{@const await}` causes
> infinite navigation loops during client-side page transitions when
> pages share async queries. See
> [sveltejs/svelte#17717](https://github.com/sveltejs/svelte/issues/17717).
> Use `{#await}` blocks until this is fixed.
With `experimental.async: true` (Svelte 5.36+), `{@const await}` is
possible but **not safe for pages with shared queries or navigation**:
```svelte
<!-- ⚠️ Causes navigation loops - use {#await} instead -->
<svelte:boundary>
{#snippet pending()}
<LoadingSpinner />
{/snippet}
{@const data = await loadData()}
<DataView {data} />
</svelte:boundary>
```
**Safe alternative:**
```svelte
{#await loadData()}
<LoadingSpinner />
{:then data}
<DataView {data} />
{:catch error}
<p>Error: {error.message}</p>
{/await}
```
## Combined Error + Pending
Use `svelte:boundary` for **error catching only**, with `{#await}` for
async data:
```svelte
<svelte:boundary onerror={logError}>
{#snippet failed(error, reset)}
<p>Failed to load user</p>
<button onclick={reset}>Retry</button>
{/snippet}
{#await fetchUser()}
<p>Loading user...</p>
{:then user}
<UserProfile {user} />
{:catch error}
<p>Error: {error.message}</p>
{/await}
</svelte:boundary>
```
## What Gets Caught
**Caught:**
- Errors during rendering
- Errors in `$effect`
**NOT Caught:**
- Event handler errors (`onclick`, etc.)
- Errors after `setTimeout`
- Async errors outside boundary's await
## vs +error.svelte
| Feature | svelte:boundary | +error.svelte |
| -------- | ----------------------- | ------------- |
| Scope | Component subtree | Route segment |
| Reset | Built-in reset function | Navigate away |
| Pending | Yes (pending snippet) | No |
| Use case | Component-level | Page-level |
## Error Tracking Integration
```svelte
<svelte:boundary
onerror={(error, reset) => {
// Send to Sentry, LogRocket, etc.
errorTracker.captureException(error);
}}
>
<App />
{#snippet failed(error, reset)}
<ErrorFallback {error} {reset} />
{/snippet}
</svelte:boundary>
```
## Nested Boundaries
Inner boundary catches first:
```svelte
<svelte:boundary>
<!-- Outer fallback -->
{#snippet failed(e)}
<p>Outer caught: {e.message}</p>
{/snippet}
<svelte:boundary>
<!-- Inner fallback -->
{#snippet failed(e)}
<p>Inner caught: {e.message}</p>
{/snippet}
<ComponentThatMightFail />
</svelte:boundary>
</svelte:boundary>
```
## Key Points
- Use `svelte:boundary` for component-level error isolation
- Use `+error.svelte` for route-level error pages
- `pending` snippet shows only on **initial** load (not on refresh - no flicker)
- `failed` snippet replaces content on error
- `reset` function lets users retry
- Errors in event handlers are NOT caught
- Requires `experimental.async: true` in svelte.config.js for `{@const await}`
- **⚠️ Bug:** `{@const await}` + navigation causes infinite loops ([#17717](https://github.com/sveltejs/svelte/issues/17717)) — use `{#await}` instead
@@ -1,270 +0,0 @@
---
name: typescript-best-practices
description: Provides TypeScript patterns for type-first development, making illegal states unrepresentable, exhaustive handling, and runtime validation. Must use when reading or writing TypeScript/JavaScript files.
---
# TypeScript Best Practices
## Pair with React Best Practices
When working with React components (`.tsx`, `.jsx` files or `@react` imports), always load `react-best-practices` alongside this skill. This skill covers TypeScript fundamentals; React-specific patterns (effects, hooks, refs, component design) are in the dedicated React skill.
## Type-First Development
Types define the contract before implementation. Follow this workflow:
1. **Define the data model** - types, interfaces, and schemas first
2. **Define function signatures** - input/output types before logic
3. **Implement to satisfy types** - let the compiler guide completeness
4. **Validate at boundaries** - runtime checks where data enters the system
### Make Illegal States Unrepresentable
Use the type system to prevent invalid states at compile time.
**Discriminated unions for mutually exclusive states:**
```ts
// Good: only valid combinations possible
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// Bad: allows invalid combinations like { loading: true, error: Error }
type RequestState<T> = {
loading: boolean;
data?: T;
error?: Error;
};
```
**Branded types for domain primitives:**
```ts
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
// Compiler prevents passing OrderId where UserId expected
function getUser(id: UserId): Promise<User> { /* ... */ }
function createUserId(id: string): UserId {
return id as UserId;
}
```
**Const assertions for literal unions:**
```ts
const ROLES = ['admin', 'user', 'guest'] as const;
type Role = typeof ROLES[number]; // 'admin' | 'user' | 'guest'
// Array and type stay in sync automatically
function isValidRole(role: string): role is Role {
return ROLES.includes(role as Role);
}
```
**Required vs optional fields - be explicit:**
```ts
// Creation: some fields required
type CreateUser = {
email: string;
name: string;
};
// Update: all fields optional
type UpdateUser = Partial<CreateUser>;
// Database row: all fields present
type User = CreateUser & {
id: UserId;
createdAt: Date;
};
```
## Module Structure
Prefer smaller, focused files: one component, hook, or utility per file. Split when a file handles multiple concerns or exceeds ~200 lines. Colocate tests with implementation (`foo.test.ts` alongside `foo.ts`). Group related files by feature rather than by type.
## Functional Patterns
- Prefer `const` over `let`; use `readonly` and `Readonly<T>` for immutable data.
- Use `array.map/filter/reduce` over `for` loops; chain transformations in pipelines.
- Write pure functions for business logic; isolate side effects in dedicated modules.
- Avoid mutating function parameters; return new objects/arrays instead.
## Instructions
- Enable `strict` mode; model data with interfaces and types. Strong typing catches bugs at compile time.
- Every code path returns a value or throws; use exhaustive `switch` with `never` checks in default. Unhandled cases become compile errors.
- Propagate errors with context; catching requires re-throwing or returning a meaningful result. Hidden failures delay debugging.
- Handle edge cases explicitly: empty arrays, null/undefined inputs, boundary values. Defensive checks prevent runtime surprises.
- Use `await` for async calls; wrap external calls with contextual error messages. Unhandled rejections crash Node processes.
- Add or update focused tests when changing logic; test behavior, not implementation details.
## Examples
Explicit failure for unimplemented logic:
```ts
export function buildWidget(widgetType: string): never {
throw new Error(`buildWidget not implemented for type: ${widgetType}`);
}
```
Exhaustive switch with never check:
```ts
type Status = "active" | "inactive";
export function processStatus(status: Status): string {
switch (status) {
case "active":
return "processing";
case "inactive":
return "skipped";
default: {
const _exhaustive: never = status;
throw new Error(`unhandled status: ${_exhaustive}`);
}
}
}
```
Wrap external calls with context:
```ts
export async function fetchWidget(id: string): Promise<Widget> {
const response = await fetch(`/api/widgets/${id}`);
if (!response.ok) {
throw new Error(`fetch widget ${id} failed: ${response.status}`);
}
return response.json();
}
```
Debug logging with namespaced logger:
```ts
import debug from "debug";
const log = debug("myapp:widgets");
export function createWidget(name: string): Widget {
log("creating widget: %s", name);
const widget = { id: crypto.randomUUID(), name };
log("created widget: %s", widget.id);
return widget;
}
```
## Runtime Validation with Zod
- Define schemas as single source of truth; infer TypeScript types with `z.infer<>`. Avoid duplicating types and schemas.
- Use `safeParse` for user input where failure is expected; use `parse` at trust boundaries where invalid data is a bug.
- Compose schemas with `.extend()`, `.pick()`, `.omit()`, `.merge()` for DRY definitions.
- Add `.transform()` for data normalization at parse time (trim strings, parse dates).
- Include descriptive error messages; use `.refine()` for custom validation logic.
### Examples
Schema as source of truth with type inference:
```ts
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
createdAt: z.string().transform((s) => new Date(s)),
});
type User = z.infer<typeof UserSchema>;
```
Return parse results to callers (never swallow errors):
```ts
import { z, SafeParseReturnType } from "zod";
export function parseUserInput(raw: unknown): SafeParseReturnType<unknown, User> {
return UserSchema.safeParse(raw);
}
// Caller handles both success and error:
const result = parseUserInput(formData);
if (!result.success) {
setErrors(result.error.flatten().fieldErrors);
return;
}
await submitUser(result.data);
```
Strict parsing at trust boundaries:
```ts
export async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`fetch user ${id} failed: ${response.status}`);
}
const data = await response.json();
return UserSchema.parse(data); // throws if API contract violated
}
```
Schema composition:
```ts
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
const UpdateUserSchema = CreateUserSchema.partial();
const UserWithPostsSchema = UserSchema.extend({
posts: z.array(PostSchema),
});
```
## Configuration
- Load config from environment variables at startup; validate with Zod before use. Invalid config should crash immediately.
- Define a typed config object as single source of truth; avoid accessing `process.env` throughout the codebase.
- Use sensible defaults for development; require explicit values for production secrets.
### Examples
Typed config with Zod validation:
```ts
import { z } from "zod";
const ConfigSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
export const config = ConfigSchema.parse(process.env);
```
Access config values (not process.env directly):
```ts
import { config } from "./config";
const server = app.listen(config.PORT);
const db = connect(config.DATABASE_URL);
```
## Optional: type-fest
For advanced type utilities beyond TypeScript builtins, consider [type-fest](https://github.com/sindresorhus/type-fest):
- `Opaque<T, Token>` - cleaner branded types than manual `& { __brand }` pattern
- `PartialDeep<T>` - recursive partial for nested objects
- `ReadonlyDeep<T>` - recursive readonly for immutable data
- `LiteralUnion<Literals, Fallback>` - literals with autocomplete + string fallback
- `SetRequired<T, K>` / `SetOptional<T, K>` - targeted field modifications
- `Simplify<T>` - flatten complex intersection types in IDE tooltips
```ts
import type { Opaque, PartialDeep, SetRequired } from 'type-fest';
// Branded type (cleaner than manual approach)
type UserId = Opaque<string, 'UserId'>;
// Deep partial for patch operations
type UserPatch = PartialDeep<User>;
// Make specific fields required
type UserWithEmail = SetRequired<Partial<User>, 'email'>;
```
+1 -25
View File
@@ -1,32 +1,8 @@
# Database path (relative to project root or absolute)
DATABASE_URL=./data/crumbs.db
# Server origin (required for CSRF protection and OAuth redirect URLs)
# Server origin (required for CSRF protection)
ORIGIN=http://localhost:3000
# Node environment
NODE_ENV=development
# --- OAuth Providers (optional, auto-enabled when set) ---
# Google OAuth 2.0
# AUTH_GOOGLE_CLIENT_ID=
# AUTH_GOOGLE_CLIENT_SECRET=
# GitHub OAuth
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
# Generic OIDC (e.g. Authentik, Keycloak, Okta)
# AUTH_OIDC_ISSUER=https://your-idp.example.com
# AUTH_OIDC_CLIENT_ID=
# AUTH_OIDC_CLIENT_SECRET=
# AUTH_OIDC_DISPLAY_NAME=SSO
# --- Email Notifications (optional, auto-enabled when SMTP_HOST is set) ---
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASS=
# SMTP_FROM=Crumbs <noreply@example.com>
-2
View File
@@ -1,2 +0,0 @@
# Default owner for everything in the repo
* @fredicious
-40
View File
@@ -1,40 +0,0 @@
---
name: Bug Report
about: Something isn't working as expected
title: "bug: "
labels: bug
assignees: ""
---
## Description
A clear summary of the bug.
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
What should happen.
## Actual Behavior
What happens instead.
## Environment
- **Crumbs version**: (e.g. 0.3.2)
- **Browser**: (e.g. Chrome 120, Firefox 121)
- **OS**: (e.g. macOS 15, Ubuntu 24.04)
- **Deployment**: (Docker / Node.js / dev server)
## Screenshots
If applicable, add screenshots to help explain the problem.
## Additional Context
Any other relevant information (logs, related issues, etc.).
-27
View File
@@ -1,27 +0,0 @@
---
name: Feature Request
about: Suggest an idea or improvement
title: "feat: "
labels: enhancement
assignees: ""
---
## Description
A clear description of the feature you'd like to see.
## Motivation
Why is this feature important? What problem does it solve?
## Proposed Solution
How do you think this should work?
## Alternatives Considered
Have you considered other approaches? If so, why were they ruled out?
## Additional Context
Any mockups, links, or references that help illustrate the request.
-18
View File
@@ -1,18 +0,0 @@
## Summary
What does this PR do and why?
## Test Plan
- [ ] Unit tests added/updated
- [ ] E2E tests added/updated (if user-facing)
- [ ] `make check` passes
- [ ] `make test` passes
## Related Issues
Closes #
## Screenshots
If this changes the UI, include before/after screenshots.
-126
View File
@@ -1,126 +0,0 @@
name: Bump & Release
on:
workflow_dispatch:
inputs:
bump:
description: 'Override bump type (leave empty to auto-detect)'
required: false
type: choice
options:
- ''
- patch
- minor
- major
jobs:
bump:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Run release script
env:
BUMP_TYPE: ${{ github.event.inputs.bump }}
run: bash scripts/release.sh "$BUMP_TYPE"
- name: Output new tag
id: release-tag
run: echo "tag=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
ci:
needs: bump
uses: ./.github/workflows/ci.yml
release:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: [bump, ci]
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump.outputs.tag }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
type=raw,value=latest
- name: Set up QEMU (for multi-platform builds)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.bump.outputs.tag }}
generate_release_notes: true
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: [bump, release]
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update docker-compose.yml
env:
TAG: ${{ needs.bump.outputs.tag }}
run: |
VERSION="${TAG#v}"
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docker-compose.yml
git diff --cached --quiet && exit 0
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
git push origin main
+19 -41
View File
@@ -46,24 +46,12 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:unit:coverage
- name: Coverage summary
if: always()
run: bash scripts/coverage-summary.sh "Unit Test Coverage" coverage/unit/coverage-summary.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: unit-coverage
path: coverage/unit/
retention-days: 14
- run: pnpm test:unit
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.0-noble
needs: [lint-and-check, unit-tests]
steps:
- uses: actions/checkout@v4
@@ -78,46 +66,36 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Build (with coverage instrumentation)
run: pnpm build
env:
DATABASE_URL: ./data/test-crumbs.db
NODE_ENV: test
VITE_COVERAGE: 'true'
- name: Cache Playwright Browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Install Playwright System Dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- run: pnpm test:e2e
env:
CI: true
VITE_COVERAGE: 'true'
HOME: /root
- name: Merge E2E coverage
if: always()
run: npx nyc report
- name: Coverage summary
if: always()
run: bash scripts/coverage-summary.sh "E2E Test Coverage" coverage/e2e/coverage-summary.json
- uses: actions/upload-artifact@v4
if: always()
if: failure()
with:
name: playwright-report
path: |
playwright-report/
test-results/
path: playwright-report/
retention-days: 7
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-coverage
path: coverage/e2e/
retention-days: 14
docker-build:
name: Docker Build
runs-on: ubuntu-latest
needs: [lint-and-check, unit-tests]
steps:
- uses: actions/checkout@v4
-44
View File
@@ -1,44 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
-50
View File
@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+2 -28
View File
@@ -30,9 +30,6 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Set up QEMU (for multi-platform builds)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -47,7 +44,6 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -59,27 +55,5 @@ jobs:
with:
generate_release_notes: true
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update docker-compose.yml
env:
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docker-compose.yml
git diff --cached --quiet && exit 0
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
git push origin main
- name: Trigger Dokploy redeploy
run: curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}"
-36
View File
@@ -1,36 +0,0 @@
name: Deploy Website
on:
push:
branches: [main]
paths: ['website/**']
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: website
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
-14
View File
@@ -31,17 +31,3 @@ vite.config.ts.timestamp-*
# Test results
/test-results
/playwright-report
# Coverage
/coverage
.nyc_output
# Claude Code
/.claude/
# Local notes
todo.md
# Worktrees
.worktrees/
package-lock.json
-7
View File
@@ -1,7 +0,0 @@
{
"report-dir": "coverage/e2e",
"temp-dir": "coverage/e2e/raw",
"reporter": ["text", "html", "lcov", "json-summary"],
"include": ["src/**"],
"exclude": ["node_modules", "tests"]
}
-42
View File
@@ -1,42 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.2] - 2026-03-07
### Fixed
- Update docker-compose only after Docker image is pushed
## [0.3.1] - 2026-03-07
### Fixed
- Include tags in sync pull response and update API spec
### Changed
- Update FEATURES.md, DEPLOYMENT.md, and CLAUDE.md documentation
## [0.3.0] - 2026-03-06
### Added
- Tag management system with auto-extraction from note content
- Full-text search across titles, content, and tags
- Dark mode with system preference detection
- Note pinning, archive, and trash workflows
- Image attachments with drag-and-drop upload
- 12 color themes for note cards
- PWA support with offline-first IndexedDB sync
- LWW CRDT-based conflict resolution
- Docker deployment with multi-stage build
- CI/CD pipeline with GitHub Actions
- Comprehensive E2E test suite with Playwright
- OpenAPI spec and auto-generated API docs
### Changed
- Initial public release
[0.3.2]: https://github.com/bretzel-app/crumbs/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/bretzel-app/crumbs/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/bretzel-app/crumbs/releases/tag/v0.3.0
+10 -108
View File
@@ -2,7 +2,7 @@
## Project overview
Crumbs by Bretzel — self-hostable note-taking app. Multi-user with password auth + OAuth/SSO, offline-first PWA with CRDT-based sync.
Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first PWA with CRDT-based sync.
## Tech stack
@@ -10,83 +10,25 @@ Crumbs by Bretzel — self-hostable note-taking app. Multi-user with password au
- **Adapter**: `@sveltejs/adapter-node` (builds to `build/`, runs via `node build`)
- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`)
- **Database**: SQLite via better-sqlite3 + Drizzle ORM (WAL mode, schema at `src/lib/server/db/schema.ts`)
- **Auth**: Multi-user with password auth (Argon2) + OAuth (Google, GitHub, OIDC), role-based (admin/user), session cookies (30-day expiry)
- **Auth**: Single-user password auth with Argon2 hashing, session cookies (30-day expiry)
- **PWA**: `@vite-pwa/sveltekit` for service worker and offline caching
- **Package manager**: pnpm
## Design system: Retro Parchment
The UI follows a **parchment + 8-bit retro** aesthetic. All design decisions must preserve this character.
### Color palette
All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colors (e.g. `text-gray-500`, `bg-red-600`) — always reference CSS variables:
| Variable | Value | Usage |
|---|---|---|
| `--bg-base` | `#f0e6d3` | Page background (warm parchment) |
| `--bg-surface` | `#faf5eb` | Cards, panels, inputs |
| `--primary` | `#C8860A` | Brand gold — links, active states, accents |
| `--primary-hover` | `#E8A020` | Gold hover state |
| `--text` | `#1a1a2e` | Primary text (near-black) |
| `--text-muted` | `#6b6272` | Secondary text, placeholders |
| `--border` | `#1a1a2e` | Strong borders (editor, login card) |
| `--border-subtle` | `#d4cabb` | Default card borders, dividers |
| `--destructive` | `#a63d2f` | Delete actions, error icons |
| `--error-bg/border/text` | warm reds | Error states (themed, not clinical red) |
| `--success-bg/text` | warm greens | Success states |
| `--card-shadow` | `2px 2px 0px` | Hard-offset retro shadow (no blur) |
| `--card-shadow-hover` | `3px 3px 0px` | Hover shadow in primary gold |
Note colors for cards are defined in `src/lib/utils/colors.ts`.
### Typography
- **Body**: `JetBrains Mono` (monospace) — the hacker/dev aesthetic
- **Brand / empty states**: `Press Start 2P` (pixel font) — the 8-bit accent
- These two fonts are intentional. Do not replace them or add others.
### Visual rules
- **Shadows**: Always hard-offset (`2px 2px 0px`) with zero blur. Never use `shadow-sm`, `shadow-md` etc. — they look too modern.
- **Corners**: `rounded-sm` everywhere. Sharp, not rounded. Exception: color picker circles use `rounded-full`.
- **Borders**: Default card borders use `--border-subtle`. `--border` is for strong emphasis (editor, login card). Hover borders use `--primary`.
- **Animations**: Crisp and fast (150ms `ease-out`). No spring physics, no bounce. The retro feel comes from snappy transitions.
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
- **Icons**: Pixelarticons via `@iconify/svelte` — use `<Icon icon="pixelarticons:name" width={size} class="..." />`. Keep at 16-20px size. Exception: Bold, Italic, Strikethrough, Underline stay as Lucide (`lucide-svelte`). See full mapping in `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`.
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
### Layout groups
- `(auth)` — Login/setup pages: minimal centered layout, no app chrome
- `(app)` — Main app: header, sidebar, footer, toast notifications
## Project structure
```
src/
routes/
+layout.svelte # Root layout (imports CSS only)
(auth)/ # Layout group: login/setup (minimal, no app chrome)
+layout.svelte # Centered parchment background
login/ # Login page
setup/ # First-time password setup
(app)/ # Layout group: main app (header, sidebar, footer)
+layout.svelte # App shell with Header, Sidebar, Toast
+page.svelte # Main notes view
archive/ # Archived notes view
trash/ # Trashed notes view
tag/[name]/ # Notes filtered by tag
settings/ # Settings with tabbed layout (profile, MCP, users)
api/auth/ # Login, logout, setup, OAuth endpoints
api/admin/users/ # Admin user management (CRUD, role, sessions)
routes/ # SvelteKit pages and API routes
+page.svelte # Main notes view
login/ # Login page
setup/ # First-time password setup
api/auth/ # Login, logout, setup endpoints
api/notes/ # CRUD + attachments
api/search/ # Full-text search
api/sync/ # Offline sync
api/tags/ # Tag management
lib/
components/ # Svelte components (NoteCard, NoteEditor, TagChip, etc.)
components/ # Svelte components (NoteCard, NoteEditor, ColorPicker, etc.)
stores/ # Svelte stores (notes.ts, theme.ts)
server/db/ # Drizzle schema + connection (schema.ts, index.ts)
server/ # Auth logic, attachment handling
@@ -98,10 +40,6 @@ tests/
unit/ # Vitest unit tests
e2e/ # Playwright e2e tests (Gherkin-style)
helpers/fixtures.ts # Shared authenticatedPage fixture
website/ # Static landing page (crumbs.bretzel.app)
index.html # Landing page with feature cards, screenshots, deploy section
styles.css # Landing page styles (retro parchment theme)
assets/ # Favicon, screenshots
```
## Commands
@@ -129,18 +67,6 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
| `make docker-down` | — | Stop containers |
| `make docker-logs` | — | Tail container logs |
| `make clean` | — | Remove build artifacts + test DBs |
| `make release` | — | Auto-bump version from commits, tag, and push |
| `make release-patch` | — | Bump patch version, tag, and push |
| `make release-minor` | — | Bump minor version, tag, and push |
| `make release-major` | — | Bump major version, tag, and push |
## Git conventions
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
- **Main branch**: `main` — use as base for all PRs and feature branches
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
- **Rebase before PR**: Always `git rebase origin/main` (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR
## CI/CD
@@ -153,23 +79,12 @@ GitHub Actions with two workflows:
4. **E2E Tests** — Playwright with Chromium (depends on step 3, uploads report on failure)
5. **Docker Build** — validates the Docker image builds (depends on step 3)
**Bump & Release** (`.github/workflows/bump.yml`) — workflow_dispatch (primary release method):
- Runs `scripts/release.sh` to bump version in `package.json` + `docker-compose.yml`, tag, and push
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io`
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags:
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io` (GitHub Container Registry)
- Creates a GitHub Release with auto-generated notes
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags (fallback for manual/local tag pushes):
- Same as above: CI pipeline, Docker build/push, GitHub Release
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
## Database schema changes
When adding or modifying tables in `src/lib/server/db/schema.ts`:
- **If the table has a foreign key to `users`**: update `deleteUser()` in `src/lib/server/auth.ts` to delete from the new table before the user row is removed. Order matters — delete child rows before parent rows.
- **If the table has a foreign key to `notes`**: either add `onDelete: 'cascade'` in the schema definition, or add explicit cleanup in the note deletion logic.
## Testing philosophy: BDD/TDD
Write tests first or alongside features. Tests serve as living documentation of expected behavior.
@@ -187,7 +102,6 @@ Write tests first or alongside features. Tests serve as living documentation of
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
- Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers)
- Auth tests use `test.describe.serial` because they depend on sequential database state
- **Offline/online tests**: After `setOffline(false)`, always manually dispatch the `online` event via `page.evaluate(() => window.dispatchEvent(new Event('online')))` — Playwright doesn't reliably emit it in headless/CI environments, causing flaky `waitForResponse` timeouts
### Gherkin-style Given/When/Then
@@ -218,15 +132,3 @@ E2E tests follow Cucumber BDD conventions via comments. Follow the [Better Gherk
```
**Resilience test** — ask: *"Will this comment need to change if the UI implementation changes?"* If yes, rewrite it to remove implementation details. The Playwright code underneath handles the *how*; the comments describe the *what*.
## Documentation
Keep docs in `docs/` up to date when making changes. If a feature, API endpoint, deployment config, or architectural decision changes, update the relevant doc file in the same commit or PR:
| Doc | Covers |
|-----|--------|
| `docs/FEATURES.md` | User-facing feature list |
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
| `docs/AUTH.md` | Authentication setup, OAuth/SSO provider guides (Authentik, Keycloak, etc.) |
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
-31
View File
@@ -1,31 +0,0 @@
# Code of Conduct
## Our Commitment
Crumbs is a community project, and we want everyone to feel welcome regardless of background, identity, or experience level. We are committed to providing a harassment-free experience for all participants.
## Expected Behavior
- Be respectful and considerate in communication
- Give and accept constructive feedback gracefully
- Focus on what is best for the project and community
- Show empathy towards others
## Unacceptable Behavior
- Harassment, intimidation, or discrimination of any kind
- Personal attacks, insults, or derogatory language
- Publishing others' private information without consent
- Any conduct that would be considered inappropriate in a professional setting
## Scope
This code of conduct applies to all project spaces — issues, pull requests, discussions, and any other communication channel associated with Crumbs.
## Enforcement
Instances of unacceptable behavior may be reported to the maintainers at **security@bretzel.app**. All reports will be reviewed and handled confidentially. Maintainers reserve the right to remove, edit, or reject contributions that violate this code of conduct, and to temporarily or permanently ban contributors for repeated or severe violations.
## Attribution
This code of conduct is inspired by the [Contributor Covenant](https://www.contributor-covenant.org/) v2.1.
-88
View File
@@ -1,88 +0,0 @@
# Contributing to Crumbs
Thanks for your interest in contributing! This guide will help you get started.
## Development Setup
```bash
# Clone the repo
git clone https://github.com/bretzel-app/crumbs.git
cd crumbs
# Install dependencies
pnpm install
# Start the dev server
pnpm dev
```
Open http://localhost:5173 and set your password on first visit.
Run `make help` to see all available commands.
## Project Structure
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full system design. Key directories:
- `src/routes/` — SvelteKit pages and API endpoints
- `src/lib/components/` — Svelte 5 components (runes, not stores)
- `src/lib/server/db/` — Drizzle ORM schema and database connection
- `src/lib/sync/` — Offline sync and CRDT logic
- `tests/unit/` — Vitest unit tests
- `tests/e2e/` — Playwright end-to-end tests
## Making Changes
1. **Fork and branch** — Create a feature branch from `main`
2. **Follow existing patterns** — Match the code style you see in the codebase
3. **Write tests** — Add unit tests for logic, E2E tests for user-facing features
4. **Run checks before pushing:**
```bash
make check # Type checking
make lint # Linting
make test-unit # Unit tests
make test-e2e # E2E tests
```
5. **Open a PR** — Target `main`, describe what and why
## Commit Messages
We use conventional-style commit messages:
```
feat: add tag autocomplete
fix: prevent duplicate notes on sync
docs: update deployment guide
chore: bump dependencies
```
Keep the subject line under 70 characters. Use the body for context when needed.
## Code Style
- **Svelte 5 runes** — Use `$state`, `$derived`, `$props`, `$effect` (not Svelte 4 stores)
- **TypeScript** — Strict mode, no `any` unless absolutely necessary
- **Tailwind CSS v4** — Use CSS variables from `src/app.css`, never hardcoded Tailwind colors
- **Design system** — Follow the Retro Parchment aesthetic described in `CLAUDE.md`
## Testing
- **Unit tests**: Pure logic (parsing, auth, sync). Run with `make test-unit`
- **E2E tests**: User workflows in the browser. Run with `make test-e2e`
- E2E tests use Gherkin-style Given/When/Then comments (see `CLAUDE.md` for conventions)
## Reporting Bugs
Use [GitHub Issues](https://github.com/bretzel-app/crumbs/issues) with the bug report template. Include:
- Steps to reproduce
- Expected vs actual behavior
- Browser/OS and Crumbs version
## Security Issues
**Do not open public issues for security vulnerabilities.** See [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025-present Bretzel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -22
View File
@@ -1,8 +1,6 @@
.PHONY: help dev build preview check test test-unit test-e2e \
test-unit-coverage test-e2e-coverage lint \
.PHONY: help dev build preview check test test-unit test-e2e lint \
db-push db-generate db-migrate db-studio docs-api \
install docker-build docker-up docker-down docker-logs clean \
release release-patch release-minor release-major
install docker-build docker-up docker-down docker-logs clean
help: ## List all targets
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
@@ -28,12 +26,6 @@ test-unit: ## Run unit tests only
test-e2e: ## Run e2e tests only
pnpm test:e2e
test-unit-coverage: ## Run unit tests with coverage
pnpm test:unit:coverage
test-e2e-coverage: ## Run e2e tests with coverage
VITE_COVERAGE=true pnpm build && pnpm test:e2e && npx nyc report
lint: ## Run linter
pnpm lint
@@ -70,15 +62,3 @@ docker-logs: ## Tail container logs
clean: ## Remove build artifacts and test DBs
rm -rf build .svelte-kit node_modules/.vite
rm -f data/test-crumbs.db
release: ## Auto-bump version from commits, tag, and push
bash scripts/release.sh $(BUMP)
release-patch: ## Bump patch version, tag, and push
bash scripts/release.sh patch
release-minor: ## Bump minor version, tag, and push
bash scripts/release.sh minor
release-major: ## Bump major version, tag, and push
bash scripts/release.sh major
+7 -72
View File
@@ -1,17 +1,8 @@
[![CI](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/github/v/release/bretzel-app/crumbs)](https://github.com/bretzel-app/crumbs/releases)
[![Docker](https://img.shields.io/badge/docker-ghcr.io-blue.svg)](https://github.com/bretzel-app/crumbs/pkgs/container/crumbs)
# 🥨 Crumbs by Bretzel
<p align="center">
<img src="static/favicon-96x96.png" alt="Crumbs logo" width="96" height="96">
</p>
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
# Crumbs by Bretzel
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
## Install
## Quick Start
### Docker (Recommended)
@@ -19,56 +10,7 @@ A self-hostable, offline-first note-taking app that respects your privacy. Part
docker compose up -d
```
Open http://localhost:3000 and create your admin account on first visit.
#### With SSO (optional)
```yaml
# docker-compose.yml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "3000:3000"
volumes:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=your-client-id
- AUTH_OIDC_CLIENT_SECRET=your-client-secret
- AUTH_OIDC_DISPLAY_NAME=Authentik
restart: unless-stopped
volumes:
crumbs-data:
```
Google and GitHub OAuth are also supported. See [docs/AUTH.md](docs/AUTH.md) for all providers and setup guides.
#### With Email Notifications (optional)
Add SMTP settings to enable email notifications (share alerts, welcome emails, security alerts):
```yaml
environment:
- SMTP_HOST=smtp.example.com
- SMTP_PORT=587
- SMTP_USER=your-username
- SMTP_PASS=your-password
- SMTP_FROM=Crumbs <noreply@example.com>
```
Auto-enabled when `SMTP_HOST` is set. Users can opt out in Settings > Preferences. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#email-notifications-optional) for details.
### Manual (Node.js)
```bash
git clone <repo-url> crumbs && cd crumbs
pnpm install
pnpm build
DATABASE_URL=./data/crumbs.db ORIGIN=http://localhost:3000 node build
```
Open http://localhost:3000 and set your password on first visit.
### Development
@@ -90,12 +32,9 @@ pnpm build # Production build
- Rich notes with Markdown, checklists, image attachments, and 12 color themes
- Organize with #tags, pinning, archive, and trash
- Full-text search across titles, content, and tags
- Note version history — browse and restore previous versions
- Share notes with other users on the same instance
- Email notifications for shares, security alerts, and account events (optional SMTP)
- Dark mode with system detection
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
- MCP server — let AI assistants (Claude Code, etc.) manage your notes
- Multi-user auth (Argon2) with optional OAuth/SSO (Google, GitHub, OIDC)
- Single-user password auth (Argon2)
- Docker deployment with a single command
See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
@@ -121,12 +60,8 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|----------|-------------|
| [Features](docs/FEATURES.md) | Detailed feature list and behavior |
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
| [Auth](docs/AUTH.md) | Password auth, OAuth/SSO setup (Authentik, Keycloak, etc.) |
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
| [API](docs/API.md) | REST API reference (auto-generated) |
| [Contributing](CONTRIBUTING.md) | How to contribute |
| [Security](SECURITY.md) | Vulnerability reporting policy |
| [Changelog](CHANGELOG.md) | Release history |
## CI/CD
@@ -137,4 +72,4 @@ Configure registry via GitHub Secrets: `REGISTRY_URL`, `REGISTRY_USER`, `REGISTR
## License
[MIT](LICENSE)
MIT
-44
View File
@@ -1,44 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|--------------------|
| latest | Yes |
| < latest | No |
We recommend always running the latest version of Crumbs.
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them via [GitHub's private vulnerability reporting](https://github.com/bretzel-app/crumbs/security/advisories/new).
Include as much of the following as possible:
- Description of the vulnerability
- Steps to reproduce
- Impact assessment
- Suggested fix (if any)
You should receive an initial response within 48 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure.
## Scope
Since Crumbs is a self-hosted application, the following are in scope:
- Authentication bypass or session hijacking
- SQL injection or data leaks
- Cross-site scripting (XSS)
- Server-side request forgery (SSRF)
- Path traversal or file access vulnerabilities
- Denial of service via crafted input
## Disclosure Policy
We follow coordinated disclosure. Once a fix is available, we will:
1. Release a patched version
2. Publish a security advisory on GitHub
3. Credit the reporter (unless anonymity is requested)
+2 -17
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.22.3
build: .
container_name: crumbs
ports:
- "3000:3000"
@@ -10,22 +10,7 @@ services:
- NODE_ENV=production
- DATA_DIR=/data
- DATABASE_URL=/data/crumbs.db
- ORIGIN=${ORIGIN:-http://localhost:3000}
# OAuth / SSO (optional — providers auto-enable when configured)
- AUTH_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID:-}
- AUTH_GOOGLE_CLIENT_SECRET=${AUTH_GOOGLE_CLIENT_SECRET:-}
- AUTH_GITHUB_CLIENT_ID=${AUTH_GITHUB_CLIENT_ID:-}
- AUTH_GITHUB_CLIENT_SECRET=${AUTH_GITHUB_CLIENT_SECRET:-}
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
- AUTH_OIDC_CLIENT_ID=${AUTH_OIDC_CLIENT_ID:-}
- AUTH_OIDC_CLIENT_SECRET=${AUTH_OIDC_CLIENT_SECRET:-}
- AUTH_OIDC_DISPLAY_NAME=${AUTH_OIDC_DISPLAY_NAME:-}
# Email notifications (optional — auto-enabled when SMTP_HOST is set)
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-}
- ORIGIN=http://localhost:3000
restart: unless-stopped
volumes:
-299
View File
@@ -103,7 +103,6 @@ List notes
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -111,17 +110,6 @@ List notes
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
@@ -137,7 +125,6 @@ Create a note
```json
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
@@ -160,7 +147,6 @@ Create a note
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -168,17 +154,6 @@ Create a note
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
```
@@ -206,7 +181,6 @@ Get a note
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -214,17 +188,6 @@ Get a note
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
```
@@ -249,7 +212,6 @@ Partial update — only include changed fields.
```json
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
@@ -272,7 +234,6 @@ Partial update — only include changed fields.
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -280,17 +241,6 @@ Partial update — only include changed fields.
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
```
@@ -321,123 +271,6 @@ Permanently delete a note
---
## Collaboration
### `GET /api/notes/{id}/collaborators`
List collaborators
Returns all collaborators for a note. Requires access (owner or collaborator).
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Response:** `200`
```json
[
{
"userId": 1,
"displayName": "displayName",
"email": "email",
"addedAt": "2025-01-01T00:00:00.000Z"
}
]
```
---
### `POST /api/notes/{id}/collaborators`
Add a collaborator
Owner-only. Adds a user as collaborator on the note.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Request:**
```json
{
"userId": 1
}
```
**Response:** `201`
```json
[
{
"userId": 1,
"displayName": "displayName",
"email": "email",
"addedAt": "2025-01-01T00:00:00.000Z"
}
]
```
**Errors:** `400` Invalid request (missing userId, owner as collaborator), `409` User is already a collaborator
---
### `DELETE /api/notes/{id}/collaborators`
Remove a collaborator
Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
| `userId` | query | | yes | |
**Response:** `200`
```json
{
"success": true
}
```
---
## Users
### `GET /api/users/search`
Search users
Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `q` | query | string | yes | Search query (matches email or displayName) |
**Response:** `200`
```json
[
{
"id": 1,
"displayName": "displayName",
"email": "email"
}
]
```
---
## Attachments
### `GET /api/notes/{id}/attachments`
@@ -452,24 +285,9 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
| `attachmentId` | query | string | no | |
| `thumb` | query | string | no | Set to "1" to return the thumbnail instead of the full image (requires attachmentId) (1) |
**Response:** `200`
```json
[
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
```
---
### `POST /api/notes/{id}/attachments`
@@ -493,7 +311,6 @@ Upload an image attachment
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
```
@@ -549,7 +366,6 @@ Searches across note titles, content, and tags. Excludes trashed notes.
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -557,17 +373,6 @@ Searches across note titles, content, and tags. Excludes trashed notes.
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
@@ -618,7 +423,6 @@ Pull changes since timestamp
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -626,17 +430,6 @@ Pull changes since timestamp
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
@@ -658,7 +451,6 @@ Push local changes
"operation": "create",
"timestamp": 1,
"data": {
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
@@ -681,95 +473,4 @@ Push local changes
}
```
**Errors:** `400` Invalid changes payload
---
## Settings
### `GET /api/settings/api-keys`
List API keys
**Response:** `200`
```json
[
{
"id": "uuid",
"name": "name",
"keyPrefix": "keyPrefix",
"createdAt": "2025-01-01T00:00:00.000Z",
"lastUsedAt": "2025-01-01T00:00:00.000Z"
}
]
```
---
### `POST /api/settings/api-keys`
Create API key
**Request:**
```json
{
"name": "name"
}
```
**Response:** `201`
```json
{
"id": "id",
"key": "key",
"name": "name",
"keyPrefix": "keyPrefix"
}
```
---
### `DELETE /api/settings/api-keys/{id}`
Revoke API key
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Response:** `200`
```json
{
"success": true
}
```
**Errors:** `404` Key not found
---
## MCP
### `POST /api/mcp`
MCP Streamable HTTP endpoint
Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
**Request:**
```json
{}
```
**Response:** `200`
**Errors:** `400` Invalid session, `401` Invalid or missing API key
---
+7 -51
View File
@@ -49,14 +49,6 @@ For a single-user app with multiple devices:
- If timestamps match: higher version wins
- If both match: local version is preferred
### Attachment Data Flow
1. User drops/picks image in NoteEditor
2. Client optimizes: resize ≤1920px, compress to WebP, generate 200px thumbnail
3. **Online**: POST optimized + thumbnail to `/api/notes/{id}/attachments` → server saves to `data/attachments/` + DB
4. **Offline**: store blobs in IDB `pendingAttachments` store → display via `URL.createObjectURL()` → on reconnect, sync pushes to server
5. **Display**: NoteCard uses `&thumb=1` URL (200px WebP), editor shows full-size
6. **SW Caching**: `CacheFirst` rule caches attachment URLs — previously-viewed images available offline
## Tech Stack Rationale
### SvelteKit
@@ -83,68 +75,32 @@ For a single-user app with multiple devices:
- New v4 uses Vite plugin (faster builds)
### Argon2
- Memory-hard password hashing (resists GPU attacks)
- See [AUTH.md](AUTH.md) for authentication details
- Current best practice for password hashing
- Memory-hard (resists GPU attacks)
- Winner of the Password Hashing Competition
## Database Schema
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
Key tables:
- `users` - User accounts (password hash, OAuth provider link, role)
- `users` - Single user (password hash)
- `sessions` - Auth sessions (30-day TTL)
- `notes` - Core note data with version tracking
- `tags` - Unique tag names
- `note_tags` - Many-to-many note ↔ tag
- `attachments` - Image file metadata (path + thumbnailPath)
- `api_keys` - MCP API keys (SHA-256 hashed, prefix for display)
- `note_collaborators` - Sharing relationships (noteId + userId + addedBy)
- `note_user_state` - Per-user pin/archive/sortOrder for shared notes
- `attachments` - Image file metadata
- `sync_log` - Sync operation history
### Collaboration Access Control
Notes support sharing with other users on the same instance:
- **Owner**: Full control (edit, trash, delete, share/unshare)
- **Collaborator**: Can edit content, color, checklist mode; has independent pin/archive/sortOrder state via `note_user_state`
- Access checks use `canAccessNote()` / `requireNoteAccess()` / `requireNoteOwnership()` helpers
- `note_user_state` stores per-user view state (pinned, archived, sortOrder) — collaborators see their own organization
- When owner trashes a note, it vanishes for all collaborators
- Collaborators can leave (remove themselves) from shared notes
## MCP Server
Crumbs includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server that allows AI assistants (Claude Code, Claude Desktop, etc.) to interact with notes programmatically.
### Architecture
- **Endpoint**: `POST /api/mcp` (Streamable HTTP transport)
- **Auth**: Bearer token via API keys (generated in Settings)
- **Sessions**: Stateful per MCP spec — session ID in `mcp-session-id` header
- **Transport**: `WebStandardStreamableHTTPServerTransport` (works with SvelteKit's Web Standard Request/Response)
### Shared Logic
MCP tool handlers and REST API routes both call the same service layer (`src/lib/server/notes-service.ts`). This avoids duplicating business logic:
```
REST API routes ──┐
├──► notes-service.ts ──► Drizzle DB
MCP tool handlers ┘
```
### Tools (14)
`list_notes`, `get_note`, `create_note`, `update_note`, `trash_note`, `restore_note`, `archive_note`, `unarchive_note`, `delete_note`, `search_notes`, `list_tags`, `pin_note`, `reorder_notes`, `upload_image`
## File Organization
```
src/lib/server/ # Server-only code (DB, auth, attachments, notes-service)
src/lib/server/mcp/ # MCP server + tool definitions
src/lib/server/ # Server-only code (DB, auth, attachments)
src/lib/sync/ # Sync engine (shared types, server + client implementations)
src/lib/stores/ # Svelte reactive stores (notes, theme, auth, search)
src/lib/components/ # Svelte UI components
src/lib/utils/ # Pure utility functions (markdown, tags, colors)
src/lib/types/ # TypeScript type definitions
src/routes/api/ # REST API + MCP endpoints
src/routes/api/ # REST API endpoints
src/routes/ # Page routes (SvelteKit file-based routing)
```
-209
View File
@@ -1,209 +0,0 @@
# Authentication
Crumbs supports password-based authentication and optional OAuth/SSO via Google, GitHub, or any OpenID Connect (OIDC) provider (e.g. Authentik, Keycloak, Okta, Auth0).
## First-run setup
On first launch, Crumbs presents a setup screen to create the initial admin account with email and password (minimum 8 characters). This account becomes the instance administrator.
## Password authentication
- Passwords are hashed with **Argon2** (argon2id variant)
- Sessions use httpOnly cookies with 30-day expiry
- Admins can reset passwords and revoke sessions for any user via Settings
## OAuth / SSO
OAuth providers are **auto-enabled** when their environment variables are set — no code changes needed. The login page dynamically shows buttons for each configured provider.
### How it works
1. User clicks an OAuth button on the login page
2. Crumbs redirects to the provider's authorization endpoint (with PKCE)
3. Provider authenticates the user and redirects back to Crumbs
4. Crumbs validates the response, extracts the user's email and name
5. If a matching user exists (by provider ID or email), a session is created
6. If no matching user exists, the login is rejected (invite-only model)
### Invite-only model
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
1. Go to **Settings > Users > Invite User**
2. Create the user with the **same email** they use on the OAuth provider
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
### Environment variables
#### Google OAuth
| Variable | Description |
|----------|-------------|
| `AUTH_GOOGLE_CLIENT_ID` | Google OAuth 2.0 client ID |
| `AUTH_GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 client secret |
**Callback URL:** `https://<your-domain>/api/auth/oauth/google/callback`
Setup:
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
2. Create an OAuth 2.0 Client ID (Web application)
3. Add the callback URL above as an authorized redirect URI
4. Copy the client ID and secret into your environment
#### GitHub OAuth
| Variable | Description |
|----------|-------------|
| `AUTH_GITHUB_CLIENT_ID` | GitHub OAuth app client ID |
| `AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret |
**Callback URL:** `https://<your-domain>/api/auth/oauth/github/callback`
Setup:
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Create a new OAuth App
3. Set the authorization callback URL to the callback URL above
4. Copy the client ID and secret into your environment
#### Generic OIDC
Any OpenID Connect-compliant provider works via the generic OIDC integration. Crumbs automatically discovers endpoints via the provider's `/.well-known/openid-configuration` document.
| Variable | Description |
|----------|-------------|
| `AUTH_OIDC_ISSUER` | OIDC issuer URL (must serve `/.well-known/openid-configuration`) |
| `AUTH_OIDC_CLIENT_ID` | OIDC client ID |
| `AUTH_OIDC_CLIENT_SECRET` | OIDC client secret |
| `AUTH_OIDC_DISPLAY_NAME` | Button label on login page (default: `SSO`) |
**Callback URL:** `https://<your-domain>/api/auth/oauth/oidc/callback`
**Required scopes:** `openid`, `profile`, `email`
### Provider guides
#### Authentik
[Authentik](https://goauthentik.io/) is a self-hosted identity provider that works with Crumbs via the generic OIDC integration.
**1. Create a provider in Authentik:**
1. Open the Authentik admin panel
2. Go to **Applications > Providers > Create**
3. Select **OAuth2/OpenID Connect**
4. Configure:
- **Name:** `Crumbs`
- **Authorization flow:** `default-provider-authorization-implicit-consent` (or `explicit-consent` to prompt users)
- **Client type:** `Confidential`
- **Redirect URIs:** `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
- **Scopes:** `openid`, `profile`, `email`
5. Save and note the **Client ID** and **Client Secret**
**2. Create an application in Authentik:**
1. Go to **Applications > Applications > Create**
2. Configure:
- **Name:** `Crumbs`
- **Slug:** `crumbs`
- **Provider:** Select the `Crumbs` provider you just created
- **Launch URL:** `https://<your-crumbs-domain>` (optional, for Authentik's app dashboard)
3. Save
**3. Configure Crumbs:**
```yaml
# docker-compose.yml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
environment:
- ORIGIN=https://notes.example.com
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=<client-id>
- AUTH_OIDC_CLIENT_SECRET=<client-secret>
- AUTH_OIDC_DISPLAY_NAME=Authentik
```
> **Note:** The issuer URL for Authentik follows the pattern `https://<authentik-domain>/application/o/<app-slug>/`. This URL must serve a valid `/.well-known/openid-configuration` document — you can verify by visiting `https://<authentik-domain>/application/o/crumbs/.well-known/openid-configuration` in your browser.
**4. Pre-create users:**
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
#### Keycloak
1. Create a new client in your Keycloak realm
2. Set **Client type** to `OpenID Connect`, **Client authentication** to `On`
3. Add the callback URL: `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
4. Set the issuer URL to `https://<keycloak-domain>/realms/<realm-name>`
```env
AUTH_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
AUTH_OIDC_CLIENT_ID=crumbs
AUTH_OIDC_CLIENT_SECRET=<client-secret>
AUTH_OIDC_DISPLAY_NAME=Keycloak
```
#### Okta / Auth0
Use the issuer URL from your Okta org or Auth0 tenant:
```env
# Okta
AUTH_OIDC_ISSUER=https://your-org.okta.com
# Auth0
AUTH_OIDC_ISSUER=https://your-tenant.auth0.com
```
### Docker example with multiple providers
```yaml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "3000:3000"
volumes:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
# Google
- AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
- AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
# GitHub
- AUTH_GITHUB_CLIENT_ID=Iv1.abc123
- AUTH_GITHUB_CLIENT_SECRET=abc123secret
# Authentik (OIDC)
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=crumbs-client-id
- AUTH_OIDC_CLIENT_SECRET=crumbs-client-secret
- AUTH_OIDC_DISPLAY_NAME=Authentik
restart: unless-stopped
volumes:
crumbs-data:
```
All configured providers will appear as buttons on the login page.
## Security notes
- All OAuth flows use **PKCE** (Proof Key for Code Exchange) with S256 challenge method
- OAuth state and code verifier are stored as httpOnly cookies (10-minute expiry)
- Passwords are hashed with **Argon2** (never stored in plain text)
- Sessions expire after **30 days**
- The `ORIGIN` environment variable must match your actual domain for CSRF protection
- Always use **HTTPS** in production (via reverse proxy)
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| OAuth button doesn't appear | Provider env vars not set or missing a required variable | Check all required env vars are set and restart |
| "No account found" after OAuth | User doesn't exist in Crumbs | Create the user via Settings with matching email |
| OIDC discovery fails | Issuer URL is wrong or unreachable | Verify `AUTH_OIDC_ISSUER` serves `/.well-known/openid-configuration` |
| Redirect URI mismatch | Callback URL in provider doesn't match `ORIGIN` | Ensure `ORIGIN` matches your domain and the callback URL uses the same origin |
| PKCE error | Provider doesn't support S256 PKCE | Most modern providers support it; check provider docs |
+4 -48
View File
@@ -16,7 +16,7 @@ Open http://localhost:3000 and set your password.
# docker-compose.yml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
build: .
ports:
- "8080:3000" # Change external port
volumes:
@@ -100,8 +100,6 @@ node build
## Environment Variables
### Core
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `./data/crumbs.db` | SQLite database path |
@@ -110,24 +108,6 @@ node build
| `PORT` | `3000` | HTTP port |
| `NODE_ENV` | `development` | Set to `production` for deployment |
### OAuth / SSO (optional)
Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported. Providers are auto-enabled when their env vars are set. See **[AUTH.md](AUTH.md)** for environment variables, callback URLs, provider-specific setup guides, and troubleshooting.
### Email Notifications (optional)
Email notifications are auto-enabled when `SMTP_HOST` is set. See [FEATURES.md](FEATURES.md#note-sharing--collaboration) for the full list of notification types.
| Variable | Default | Description |
|----------|---------|-------------|
| `SMTP_HOST` | — | SMTP server hostname (e.g. `smtp.example.com`) |
| `SMTP_PORT` | `587` | SMTP port (`587` for STARTTLS, `465` for SSL) |
| `SMTP_USER` | — | SMTP username (optional for unauthenticated relays) |
| `SMTP_PASS` | — | SMTP password |
| `SMTP_FROM` | `Crumbs <noreply@localhost>` | Sender address |
Users can opt out of share notifications in **Settings > Preferences**.
## Health Check
The Docker image includes a health check that pings `/login`:
@@ -136,34 +116,10 @@ The Docker image includes a health check that pings `/login`:
docker inspect --format='{{.State.Health.Status}}' crumbs
```
## MCP Server Configuration
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes. See [ARCHITECTURE.md](ARCHITECTURE.md#mcp-server) for technical details and the full tool list.
### Setup
1. Open **Settings** in the Crumbs UI
2. Create an API key (give it a name like "Claude Code")
3. Copy the key (shown only once)
4. Configure your MCP client:
```json
{
"mcpServers": {
"crumbs": {
"type": "streamable-http",
"url": "https://your-crumbs-instance/api/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
## Security Notes
- Always use HTTPS in production (via reverse proxy)
- Set a strong password (min 8 characters recommended)
- The `ORIGIN` variable must match your actual domain for CSRF protection
- API keys are SHA-256 hashed (never stored in plain text)
- See [AUTH.md](AUTH.md#security-notes) for authentication security details (password hashing, session expiry, PKCE)
- Sessions expire after 30 days
- Passwords are hashed with Argon2
+27 -86
View File
@@ -8,27 +8,16 @@
- Content supports full Markdown syntax
- Auto-save on editor close
### Rich Text Editor
- WYSIWYG editing powered by Tiptap
- Formatting toolbar with visual controls
- Raw Markdown mode toggle for direct editing
- Undo/Redo with keyboard shortcuts
### Text Formatting
- **Bold** (`**text**` / Ctrl+B)
- *Italic* (`*text*` / Ctrl+I)
- Underline (Ctrl+U)
- ~~Strikethrough~~ (`~~text~~` / Ctrl+Shift+X)
### Markdown Support
- **Bold** (`**text**`)
- *Italic* (`*text*`)
- Headings (`# H1` through `###### H6`)
- Blockquotes (`> quote`)
- Unordered lists (`- item`)
- Ordered lists (`1. item`)
- Code blocks (triple backtick)
- Inline code (single backtick)
- Links — insert, edit, and remove with URL input
- Tables (insert, add/delete rows and columns)
- Dividers (horizontal rules)
- Text alignment (left, center, right, justify)
- Tables
- Links (auto-linkified URLs)
- Line breaks
### Checklists / Task Lists
@@ -42,13 +31,7 @@
- Upload images via file picker or drag-and-drop
- Supports JPEG, PNG, GIF, WebP, SVG
- Max file size: 10MB
- **Client-side optimization**: images resized to fit 1920×1920, compressed to WebP (~80% quality), transparent PNGs preserved
- **Auto-generated thumbnails**: 200×200 WebP at 60% quality for fast card previews
- Thumbnail strip on note cards (up to 3 images, "+N" badge for more)
- Full-size images in editor, thumbnails on cards (`&thumb=1`)
- **Offline support**: optimized blobs stored in IndexedDB, displayed via blob URLs, auto-synced on reconnect
- Pending upload indicator on offline-queued images
- Service worker CacheFirst caching for previously-viewed images
- Thumbnail previews in note cards
- Remove attachments from editor
### Tag Organization
@@ -59,15 +42,15 @@
- Tags inside code blocks and URLs are ignored
### Color-Coded Notes
12 note colors:
12 colors matching Google Keep's palette:
- Default, Coral, Peach, Sand, Mint, Sage
- Fog, Storm, Dusk, Blossom, Clay, Chalk
- Colors adapt to note theme
- Colors adapt to light/dark mode
### Favorite Notes
- Favorite important notes to always appear at top
- Favorites section separated from other notes
- Toggle favorite from note card hover actions
### Pin Notes
- Pin important notes to always appear at top
- Pinned section separated from unpinned notes
- Toggle pin from note card hover actions
### Archive
- Archive notes to declutter main view
@@ -80,77 +63,35 @@
- Restore notes from trash
- Permanently delete from trash
### Note Sharing / Collaboration
- Share notes with other users on the same instance
- User search autocomplete for adding collaborators
- Full collaboration: both owner and collaborators can edit content, color, and checklist mode
- Per-user state: pin, archive, and sort order are independent per user
- Owner-only actions: trash, delete, share/unshare
- Collaborator indicator (Users icon) on shared note cards
- Hover popover showing collaborator names
- Share dialog accessible from editor toolbar
- Collaborators can leave shared notes
- Shared notes appear mixed in main list alongside owned notes
- Email notifications (requires SMTP config, opt-out in preferences):
- Share notifications when a note is shared with you
- Welcome email when admin creates your account
- Password reset notification
- Account deletion confirmation
- Email address change notification
- Role change notification (admin/user)
- Removed from shared note notification
- Shared note permanently deleted notification
- Account locked alert (after failed login attempts)
### Note Version History
- Browse previous versions of any note
- Versions are automatically saved when content changes
- Restore any previous version with one click
- Version snapshots are only created when content actually changes (not on metadata-only updates)
### Full-Text Search
- Search across note titles, content, and tags
- Real-time search results as you type
- Clear search to restore original view
- Case-insensitive matching
### Authentication & OAuth / SSO
- Multi-user with role-based access (admin / user)
- Password auth (Argon2) + optional OAuth/SSO (Google, GitHub, any OIDC provider)
- Session-based auth with active session management
- Invite-only OAuth model with PKCE-secured flows
- See [AUTH.md](AUTH.md) for full details and provider setup guides
### Dark Mode
- System preference detection (auto)
- Manual toggle override
- Preference persisted in localStorage
- All components adapt to theme
- Note colors adjust for dark backgrounds
### User Management (admin)
- Admin dashboard to create, list, and delete users
- Assign roles (admin / user)
- Reset passwords for any user
- Revoke all sessions (force logout) for any user
### Single-User Auth
- First-run setup: create password (min 8 chars)
- Argon2 password hashing (industry standard)
- Session-based auth with 30-day expiry
- All routes protected except /login and /setup
- Logout clears session
### PWA / Offline-First
- Installable as standalone app
- Web app manifest with icons
- Service worker for asset caching (CacheFirst for attachments, NetworkFirst for API)
- IndexedDB for local data storage (notes + pending attachments)
- Background sync every 30 seconds (notes + offline attachments)
- Service worker for asset caching
- IndexedDB for local data storage
- Background sync every 30 seconds
- LWW conflict resolution for multi-device use
- Sync status indicator (synced/syncing/offline/error)
### MCP Server (Model Context Protocol)
- Built-in MCP endpoint at `POST /api/mcp` (Streamable HTTP transport)
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; favorite, reorder notes; upload images
- API key authentication (Bearer token)
- Compatible with Claude Code, Claude Desktop, and other MCP clients
- Stateful sessions with automatic cleanup
### API Key Management
- Generate API keys from the Settings page
- Keys use `crumbs_` prefix for easy identification
- SHA-256 hashed storage (keys shown once at creation)
- Track last-used timestamps
- Revoke keys with confirmation dialog
- MCP client config example shown in Settings
### Docker Deployment
- Multi-stage Dockerfile (build + slim runtime)
- docker-compose.yml with persistent volume
-142
View File
@@ -1,142 +0,0 @@
# E2E BDD Testing Best Practices
This document defines the 10 best practices for writing E2E tests in Crumbs. All tests use Playwright with Gherkin-style BDD comments.
## 1. Declarative scenarios, not imperative scripts
Describe **what** the system does, not **how** the user clicks through the UI.
```
// Good: "When the user creates a note titled 'Shopping List'"
// Bad: "When the user clicks the + button, types 'Shopping List' in the title field, and clicks save"
```
If the UI changes (button moves, field renames), only the Playwright code changes — the scenario comment stays stable.
## 2. Given describes state, not navigation
`Given` sets up preconditions. It should read like a database fixture, not a user journey.
```
// Good: "Given a note titled 'Old Note' exists in the trash"
// Bad: "Given the user creates a note, then clicks trash, then confirms deletion"
```
Use API calls or helpers like `createNote()` in `Given` steps to avoid coupling tests to unrelated UI flows.
## 3. One behavior per scenario
Each `test()` should verify **one outcome**. If you need multiple `Then` assertions about different behaviors, split them.
```ts
// Good: Two focused scenarios
test('Trashed note disappears from main view', ...)
test('Trashed note appears in trash view', ...)
// Bad: One bloated scenario testing everything
test('Trash workflow', ...) // creates, trashes, checks main, checks trash, restores...
```
## 4. Scenario names describe outcomes, not actions
Name tests by the **observable result**, not the user action that triggers it.
```ts
// Good: test('Archived note no longer appears in the main view')
// Bad: test('User archives a note')
```
This makes test reports read like a behavior specification.
## 5. Isolate test state completely
Each test should start from a **known, clean state**. Use API-based setup/teardown or the shared `authenticatedPage` fixture.
```ts
// Use API to create/reset state before each test
test.beforeEach(async ({ authenticatedPage: page }) => {
await page.request.put('/api/preferences', { data: { ... } });
});
```
This prevents the "test B fails because test A didn't clean up" problem — especially important since Playwright runs workers in parallel.
## 6. Use `test.describe.serial` only when truly sequential
Reserve `serial` for flows where state genuinely cascades (setup -> login -> session). For everything else, isolate with fixtures. Treat `serial` as a **code smell** — most tests should run in **any order**.
## 7. Abstract repeated UI actions into helpers
When multiple scenarios share the same UI interaction (e.g., "create a note"), extract it into a helper at the **intent level**.
```ts
// helpers/fixtures.ts
export async function createNote(page, title, content?) { ... }
// In tests — reads like BDD
await createNote(page, 'My Note', 'Hello');
```
The shared `authenticatedPage` fixture and `noteCard()` locator are examples of this pattern. The `createNote()` helper abstracts the 4-step note creation flow used across most test files.
## 8. Apply the resilience test to every comment
Before committing a Gherkin comment, ask: *"Would this comment need to change if we redesigned the UI?"*
- If **yes** — rewrite to remove implementation details
- If **no** — it's at the right abstraction level
```
// Fails (mentions hover + button):
// "When the user hovers the card and clicks the 3-dot menu"
// Passes:
// "When the user opens the note actions menu"
```
## 9. Prefer `await expect(...).toBeVisible()` over arbitrary waits
Playwright's auto-waiting is powerful. Never use `page.waitForTimeout()` — it's flaky and slow. Assert on the **expected end state** instead.
```ts
// Good: Waits intelligently for the element
await expect(page.getByText('Note saved')).toBeVisible();
// Bad: Arbitrary delay
await page.waitForTimeout(2000);
```
The exception is `waitForLoadState('networkidle')` after authentication redirects, where the full page reload makes auto-waiting insufficient.
## 10. Structure tests in the Given/When/Then rhythm
Use comments to maintain the three-act structure. This makes tests scannable and self-documenting.
```ts
test('Pinned note appears at the top of the list', async ({ authenticatedPage: page }) => {
// Given a note titled "Important" exists
await createNote(page, 'Important');
// When the user pins the note
await pinNote(page, 'Important');
// Then it appears before unpinned notes
const firstCard = page.locator('[data-testid="note-card"]').first();
await expect(firstCard).toContainText('Important');
});
```
## Quick reference
| Practice | Category | Goal |
|----------|----------|------|
| 1. Declarative scenarios | Language | Stable comments despite UI changes |
| 2. Given = state | Language | Decouple setup from UI flow |
| 3. One behavior per test | Structure | Focused, debuggable tests |
| 4. Outcome-based names | Language | Test reports read as specs |
| 5. Isolate state | Reliability | No cross-test pollution |
| 6. Avoid serial | Reliability | Parallel-safe by default |
| 7. Extract helpers | Structure | DRY, intent-level code |
| 8. Resilience test | Language | UI-change-proof comments |
| 9. Auto-wait assertions | Reliability | No flaky timeouts |
| 10. Given/When/Then | Structure | Scannable three-act tests |
+1 -254
View File
@@ -168,104 +168,6 @@ paths:
'404':
description: Note not found
/api/notes/{id}/collaborators:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
get:
summary: List collaborators
description: Returns all collaborators for a note. Requires access (owner or collaborator).
tags: [Collaboration]
responses:
'200':
description: Collaborator list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Collaborator'
post:
summary: Add a collaborator
description: Owner-only. Adds a user as collaborator on the note.
tags: [Collaboration]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [userId]
properties:
userId:
type: integer
responses:
'201':
description: Collaborator added, returns updated list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Collaborator'
'400':
description: Invalid request (missing userId, owner as collaborator)
'409':
description: User is already a collaborator
delete:
summary: Remove a collaborator
description: Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
tags: [Collaboration]
parameters:
- name: userId
in: query
required: true
schema:
oneOf:
- type: integer
- type: string
enum: [self]
responses:
'200':
description: Collaborator removed
content:
application/json:
schema:
$ref: '#/components/schemas/Success'
/api/users/search:
get:
summary: Search users
description: Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
tags: [Users]
parameters:
- name: q
in: query
required: true
schema:
type: string
description: Search query (matches email or displayName)
responses:
'200':
description: Matching users (max 10)
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
displayName:
type: string
email:
type: string
/api/notes/{id}/attachments:
parameters:
- name: id
@@ -283,21 +185,9 @@ paths:
in: query
schema:
type: string
- name: thumb
in: query
description: Set to "1" to return the thumbnail instead of the full image (requires attachmentId)
schema:
type: string
enum: ['1']
responses:
'200':
description: Attachment list (without attachmentId) or file content (with attachmentId)
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Attachment'
description: Attachment list or file content
post:
summary: Upload an image attachment
tags: [Attachments]
@@ -313,10 +203,6 @@ paths:
type: string
format: binary
description: Image file (max 10MB)
thumbnail:
type: string
format: binary
description: Pre-generated thumbnail (WebP, ~200px)
responses:
'201':
description: Attachment metadata
@@ -420,93 +306,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Success'
'400':
description: Invalid changes payload
/api/settings/api-keys:
get:
summary: List API keys
tags: [Settings]
responses:
'200':
description: List of API keys (without hashes)
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ApiKey'
post:
summary: Create API key
tags: [Settings]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
responses:
'201':
description: API key created (key shown only once)
content:
application/json:
schema:
type: object
properties:
id:
type: string
key:
type: string
name:
type: string
keyPrefix:
type: string
/api/settings/api-keys/{id}:
delete:
summary: Revoke API key
tags: [Settings]
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Key revoked
content:
application/json:
schema:
$ref: '#/components/schemas/Success'
'404':
description: Key not found
/api/mcp:
post:
summary: MCP Streamable HTTP endpoint
description: Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
tags: [MCP]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON-RPC 2.0 message
responses:
'200':
description: JSON-RPC response (may be SSE stream)
'400':
description: Invalid session
'401':
description: Invalid or missing API key
components:
securitySchemes:
@@ -514,10 +313,6 @@ components:
type: apiKey
in: cookie
name: session
bearerAuth:
type: http
scheme: bearer
description: API key (crumbs_...)
schemas:
Success:
@@ -545,10 +340,6 @@ components:
type: boolean
trashed:
type: boolean
trashedAt:
type: string
format: date-time
nullable: true
checklistMode:
type: boolean
sortOrder:
@@ -565,18 +356,10 @@ components:
type: array
items:
type: string
attachments:
type: array
items:
$ref: '#/components/schemas/Attachment'
NoteInput:
type: object
properties:
id:
type: string
format: uuid
description: Optional — generated if omitted
title:
type: string
content:
@@ -616,10 +399,6 @@ components:
type: string
size:
type: integer
thumbnailPath:
type: string
nullable: true
description: Server path to thumbnail file (null if no thumbnail)
createdAt:
type: string
format: date-time
@@ -639,35 +418,3 @@ components:
description: Unix timestamp in milliseconds
data:
$ref: '#/components/schemas/NoteInput'
Collaborator:
type: object
properties:
userId:
type: integer
displayName:
type: string
email:
type: string
addedAt:
type: string
format: date-time
ApiKey:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
keyPrefix:
type: string
description: First 12 characters of the key for display
createdAt:
type: string
format: date-time
lastUsedAt:
type: string
format: date-time
nullable: true
@@ -1,672 +0,0 @@
# Pixelarticons Migration + Favorites Rename — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace Lucide icons with pixelarticons via @iconify/svelte across the app (except 4 formatting letterforms), and rename user-facing "pinned" to "favorites" with a heart icon.
**Architecture:** Each component file gets its Lucide imports replaced with a single `Icon` import from `@iconify/svelte`. The `size` prop becomes `width`, and the icon name becomes a `pixelarticons:name` string. The 4 formatting letterforms (Bold, Italic, Strikethrough, Underline) stay as Lucide. The favorites rename touches stores, components, MCP server, and one e2e test.
**Tech Stack:** `@iconify/svelte` v5.2.1, `@iconify-json/pixelarticons` v1.2.4 (both already installed), `lucide-svelte` (kept for 4 icons)
**Spec:** `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`
---
## File Structure
No new files created. All changes are modifications to existing files:
**Icon migration (16 files):** Replace Lucide imports with `@iconify/svelte` Icon component.
**Favorites rename (4 source files + 1 test file):** Rename stores, functions, labels, test IDs.
---
### Task 1: Favorites Rename — Stores
**Files:**
- Modify: `src/lib/stores/notes.ts:38-44,234-236`
- [ ] **Step 1: Rename store exports and function**
In `src/lib/stores/notes.ts`, make these changes:
```typescript
// Line 38-44: Rename stores
export const favoriteNotes = derived(filteredNotes, ($notes) =>
sortNotes($notes.filter((n) => n.pinned))
);
export const unfavoritedNotes = derived(filteredNotes, ($notes) =>
sortNotes($notes.filter((n) => !n.pinned))
);
// Line 234-236: Rename function
export async function toggleFavorite(id: string, currentPinned: boolean): Promise<Note | null> {
return updateNote(id, { pinned: !currentPinned });
}
```
Note: The `pinned` field in updateNote stays — it's the DB column name.
- [ ] **Step 2: Verify no type errors**
Run: `pnpm check 2>&1 | head -30`
Expected: Errors in files that still reference old names (NoteCard, NotesView) — that's expected, we fix those next.
- [ ] **Step 3: Commit**
```bash
git add src/lib/stores/notes.ts
git commit -m "refactor: rename pinned stores to favorites"
```
---
### Task 2: Favorites Rename — NoteCard Component
**Files:**
- Modify: `src/lib/components/NoteCard.svelte:4,110-118,170-178`
- [ ] **Step 1: Update import**
Change line 4:
```typescript
import { toggleFavorite, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
```
- [ ] **Step 2: Update favorite indicator (lines 110-118)**
Replace the pinned indicator block:
```svelte
{#if note.pinned}
<button
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unfavorite"
data-testid="favorite-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
</button>
{/if}
```
- [ ] **Step 3: Update favorite button (lines 170-178)**
Replace the pin button block:
```svelte
{#if !note.pinned}
<button
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Favorite"
data-testid="favorite-btn"
>
<Bookmark class="h-4 w-4" />
</button>
{/if}
```
- [ ] **Step 4: Commit**
```bash
git add src/lib/components/NoteCard.svelte
git commit -m "refactor: rename pin to favorite in NoteCard"
```
---
### Task 3: Favorites Rename — NotesView Component
**Files:**
- Modify: `src/lib/components/NotesView.svelte:5,89-104`
- [ ] **Step 1: Update imports (line 5)**
```typescript
import { favoriteNotes, unfavoritedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
```
- [ ] **Step 2: Update template references**
Replace all `$pinnedNotes` with `$favoriteNotes`, `$unpinnedNotes` with `$unfavoritedNotes`, and label `"Pinned"` with `"Favorites"`:
Line 89: `{#if $favoriteNotes.length > 0}`
Line 91: `<NoteGrid notes={$favoriteNotes} label="Favorites" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />`
Line 96: `notes={$unfavoritedNotes}`
Line 97: `label={$favoriteNotes.length > 0 ? 'Others' : ''}`
Line 104: `{#if $notesLoaded && $favoriteNotes.length === 0 && $unfavoritedNotes.length === 0}`
Note: Keep `dndType="pinned-notes"` unchanged — it's an internal DnD identifier, not user-facing.
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/NotesView.svelte
git commit -m "refactor: rename pinned to favorites in NotesView"
```
---
### Task 4: Favorites Rename — MCP Server
**Files:**
- Modify: `src/lib/server/mcp/server.ts:241-265`
- [ ] **Step 1: Rename MCP tool**
Replace the `pin_note` tool definition (lines 241-265):
```typescript
server.tool(
'favorite_note',
'Favorite or unfavorite a note',
{
id: z.string().describe('Note ID'),
pinned: z.boolean().describe('Whether to favorite (true) or unfavorite (false)')
},
async ({ id, pinned }: { id: string; pinned: boolean }) => {
const result = updateNote(db, userId, id, { pinned });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [
{
type: 'text' as const,
text: `Note "${result.title}" ${pinned ? 'favorited' : 'unfavorited'}`
}
]
};
}
);
```
Note: The `pinned` parameter name stays since it maps to the DB column.
- [ ] **Step 2: Commit**
```bash
git add src/lib/server/mcp/server.ts
git commit -m "refactor: rename pin_note MCP tool to favorite_note"
```
---
### Task 5: Favorites Rename — E2E Test
**Files:**
- Modify: `tests/e2e/organization.spec.ts:4-15`
- [ ] **Step 1: Update test scenario**
Replace lines 4-15:
```typescript
test('Scenario: Favorited note appears under the Favorites section', async ({ authenticatedPage: page }) => {
// Given a note titled "Favorite Me" exists
await createNote(page, 'Favorite Me');
// When the user favorites the note
const favCard = noteCard(page, 'Favorite Me');
await favCard.hover();
await favCard.getByTestId('favorite-btn').first().click({ force: true });
// Then the "Favorites" section is visible
await expect(page.getByText('Favorites')).toBeVisible();
});
```
- [ ] **Step 2: Run check to verify favorites rename is complete**
Run: `pnpm check 2>&1 | head -20`
Expected: PASS (no errors related to old pin names)
- [ ] **Step 3: Commit**
```bash
git add tests/e2e/organization.spec.ts
git commit -m "test: update pin test to favorites"
```
---
### Task 6: Icon Migration — Header + SearchBar
**Files:**
- Modify: `src/lib/components/Layout/Header.svelte:2-5`
- Modify: `src/lib/components/SearchBar.svelte:4-6`
- [ ] **Step 1: Migrate Header.svelte**
Replace imports (lines 2-5):
```typescript
import SearchBar from '../SearchBar.svelte';
import SyncIndicator from '../SyncIndicator.svelte';
import Icon from '@iconify/svelte';
```
Replace icon usage in template:
- `<Menu class="h-6 w-6 text-[var(--text)]" />` → `<Icon icon="pixelarticons:menu" class="h-6 w-6 text-[var(--text)]" />`
- `<Search class="h-5 w-5 text-[var(--text)]" />` → `<Icon icon="pixelarticons:search" class="h-5 w-5 text-[var(--text)]" />`
- [ ] **Step 2: Migrate SearchBar.svelte**
Replace imports (lines 4-6):
```typescript
import Icon from '@iconify/svelte';
```
Replace icon usage:
- `<Search .../>` → `<Icon icon="pixelarticons:search" .../>`
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
- `<ArrowLeft .../>` → `<Icon icon="pixelarticons:arrow-left" .../>`
Match existing `class` attributes exactly — just change the component and add the `icon` prop.
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/Layout/Header.svelte src/lib/components/SearchBar.svelte
git commit -m "feat: migrate Header and SearchBar to pixelarticons"
```
---
### Task 7: Icon Migration — Sidebar
**Files:**
- Modify: `src/lib/components/Layout/Sidebar.svelte:2-4,19-23,47,65,81`
- [ ] **Step 1: Replace imports and nav items**
Replace the import (line 4):
```typescript
import Icon from '@iconify/svelte';
```
Replace navItems array (lines 19-23):
```typescript
const navItems = [
{ href: '/', label: 'Crumbs', icon: 'pixelarticons:sticky-note', match: (p: string) => p === '/' },
{ href: '/archive', label: 'Archive', icon: 'pixelarticons:archive', match: (p: string) => p === '/archive' },
{ href: '/trash', label: 'Trash', icon: 'pixelarticons:trash', match: (p: string) => p === '/trash' }
];
```
- [ ] **Step 2: Update template icon usage**
Line 47: `<item.icon size={20} />` → `<Icon icon={item.icon} width={20} />`
Line 65: `<Tag size={16} />` → `<Icon icon="pixelarticons:label" width={16} />`
Line 81: `<Settings size={20} />` → `<Icon icon="pixelarticons:settings-cog" width={20} />`
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/Layout/Sidebar.svelte
git commit -m "feat: migrate Sidebar to pixelarticons"
```
---
### Task 8: Icon Migration — NoteCard
**Files:**
- Modify: `src/lib/components/NoteCard.svelte:5-13,117,159,167,177,187,196,206,215`
- [ ] **Step 1: Replace imports**
Replace lines 5-13:
```typescript
import Icon from '@iconify/svelte';
```
Remove all individual Lucide imports (Undo2, Trash2, Bookmark, Archive, ArchiveRestore, UserMinus).
- [ ] **Step 2: Replace all icon usages in template**
- `<Bookmark class="h-4 w-4 fill-[var(--primary)]" />` (favorite indicator) → `<Icon icon="pixelarticons:heart" class="h-4 w-4 fill-[var(--primary)]" />`
- `<Bookmark class="h-4 w-4" />` (favorite button) → `<Icon icon="pixelarticons:heart" class="h-4 w-4" />`
- `<Undo2 class="h-4 w-4" />` (restore) → `<Icon icon="pixelarticons:undo" class="h-4 w-4" />`
- `<Trash2 class="h-4 w-4 text-[var(--destructive)]" />` → `<Icon icon="pixelarticons:trash" class="h-4 w-4 text-[var(--destructive)]" />`
- `<Trash2 class="h-4 w-4" />` → `<Icon icon="pixelarticons:trash" class="h-4 w-4" />`
- `<Archive class="h-4 w-4" />` → `<Icon icon="pixelarticons:archive" class="h-4 w-4" />`
- `<ArchiveRestore class="h-4 w-4" />` → `<Icon icon="pixelarticons:archive" class="h-4 w-4" />`
- `<UserMinus class="h-4 w-4" />` → `<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />`
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/NoteCard.svelte
git commit -m "feat: migrate NoteCard to pixelarticons"
```
---
### Task 9: Icon Migration — NoteEditor
**Files:**
- Modify: `src/lib/components/NoteEditor.svelte:16-24`
- [ ] **Step 1: Replace imports**
Replace lines 16-24:
```typescript
import Icon from '@iconify/svelte';
```
- [ ] **Step 2: Replace all icon usages**
Search the file for each Lucide component and replace:
- `<Palette .../>` → `<Icon icon="pixelarticons:colors-swatch" .../>`
- `<SquareCheck .../>` → `<Icon icon="pixelarticons:checkbox-on" .../>`
- `<ImageIcon .../>` → `<Icon icon="pixelarticons:image" .../>`
- `<Type .../>` → `<Icon icon="pixelarticons:art-text" .../>`
- `<FileCode .../>` → `<Icon icon="pixelarticons:script" .../>`
- `<FileText .../>` → `<Icon icon="pixelarticons:file-text" .../>`
- `<UserPlus .../>` → `<Icon icon="pixelarticons:user-plus" .../>`
- `<Users .../>` → `<Icon icon="pixelarticons:users" .../>`
- `<History .../>` → `<Icon icon="pixelarticons:timeline" .../>`
For each, keep the existing `size` prop but rename to `width`. Keep `class` as-is.
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/NoteEditor.svelte
git commit -m "feat: migrate NoteEditor to pixelarticons"
```
---
### Task 10: Icon Migration — FormattingToolbar
This is the largest file. Bold, Italic, Strikethrough, Underline stay as Lucide. Everything else migrates.
**Files:**
- Modify: `src/lib/components/FormattingToolbar.svelte:2-31,131-482`
- [ ] **Step 1: Replace imports**
Replace the import block (lines 3-31):
```typescript
import {
Bold,
Italic,
Strikethrough,
Underline
} from 'lucide-svelte';
import Icon from '@iconify/svelte';
```
- [ ] **Step 2: Replace icon usages in template**
Replace every non-B/I/S/U icon. The pattern: `<IconName size={iconSize} />` → `<Icon icon="pixelarticons:name" width={iconSize} />`. Full mapping:
- `<Undo2 size={iconSize} />` → `<Icon icon="pixelarticons:undo" width={iconSize} />`
- `<Redo2 size={iconSize} />` → `<Icon icon="pixelarticons:redo" width={iconSize} />`
- `<Heading size={iconSize} />` → `<Icon icon="pixelarticons:heading" width={iconSize} />`
- `<List size={iconSize} />` (appears 3x) → `<Icon icon="pixelarticons:list" width={iconSize} />`
- `<ListOrdered size={iconSize} />` → `<Icon icon="pixelarticons:bulletlist" width={iconSize} />`
- `<ListChecks size={iconSize} />` → `<Icon icon="pixelarticons:checklist" width={iconSize} />`
- `<Link size={iconSize} />` → `<Icon icon="pixelarticons:link" width={iconSize} />`
- `<CornerDownLeft size={16} />` → `<Icon icon="pixelarticons:corner-down-left" width={16} />`
- `<ExternalLink size={16} />` → `<Icon icon="pixelarticons:external-link" width={16} />`
- `<Trash2 size={16} />` → `<Icon icon="pixelarticons:trash" width={16} />`
- `<Table2 size={iconSize} />` (appears 2x) → `<Icon icon="pixelarticons:table" width={iconSize} />`
- `<ChevronDown size={chevronSize} />` (appears 4x) → `<Icon icon="pixelarticons:chevron-down" width={chevronSize} />`
- `<BetweenHorizontalEnd size={iconSize} />` → `<Icon icon="pixelarticons:add-row" width={iconSize} />`
- `<BetweenVerticalEnd size={iconSize} />` → `<Icon icon="pixelarticons:add-col" width={iconSize} />`
- `<Minus size={iconSize} />` (appears 3x) → `<Icon icon="pixelarticons:minus" width={iconSize} />`
- `<RemoveFormatting size={iconSize} />` → `<Icon icon="pixelarticons:remove-box" width={iconSize} />`
- `<Code size={iconSize} />` → `<Icon icon="pixelarticons:braces" width={iconSize} />`
- `<TextQuote size={iconSize} />` → `<Icon icon="pixelarticons:quote-text-inline" width={iconSize} />`
- `<CodeXml size={iconSize} />` → `<Icon icon="pixelarticons:braces" width={iconSize} />`
- `<AlignLeft size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-left" width={iconSize} />`
- `<AlignCenter size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-center" width={iconSize} />`
- `<AlignRight size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-right" width={iconSize} />`
- `<AlignJustify size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-justify" width={iconSize} />`
Leave these unchanged: `<Bold size={iconSize} />`, `<Italic size={iconSize} />`, `<Strikethrough size={iconSize} />`, `<Underline size={iconSize} />`
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/FormattingToolbar.svelte
git commit -m "feat: migrate FormattingToolbar to pixelarticons (keep B/I/S/U as Lucide)"
```
---
### Task 11: Icon Migration — SyncIndicator
**Files:**
- Modify: `src/lib/components/SyncIndicator.svelte:4-7,54-61`
- [ ] **Step 1: Replace imports**
Replace lines 4-7:
```typescript
import Icon from '@iconify/svelte';
```
- [ ] **Step 2: Replace icon usages**
Replace the status icon block (lines 53-61):
```svelte
{#if displayStatus === 'synced'}
<Icon icon="pixelarticons:cloud-done" class="h-5 w-5 text-[var(--primary)]" />
{:else if displayStatus === 'syncing'}
<Icon icon="pixelarticons:sync" class="h-5 w-5 text-[var(--primary)] opacity-60" />
{:else if displayStatus === 'offline'}
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--text-muted)]" />
{:else}
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--destructive)]" />
{/if}
```
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/SyncIndicator.svelte
git commit -m "feat: migrate SyncIndicator to pixelarticons"
```
---
### Task 12: Icon Migration — Remaining Small Components
**Files:**
- Modify: `src/lib/components/NoteHistory.svelte:3-5`
- Modify: `src/lib/components/ShareDialog.svelte:3-5`
- Modify: `src/lib/components/Checklist.svelte:2-5`
- Modify: `src/lib/components/CollaboratorPopover.svelte:3`
- Modify: `src/lib/components/ImageLightbox.svelte:2`
- Modify: `src/lib/components/ImageUpload.svelte:2-4`
- Modify: `src/lib/components/NotesView.svelte:8`
- [ ] **Step 1: Migrate NoteHistory.svelte**
Replace imports:
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
- `<RotateCcw .../>` → `<Icon icon="pixelarticons:undo" .../>`
- `<Clock .../>` → `<Icon icon="pixelarticons:clock" .../>`
- [ ] **Step 2: Migrate ShareDialog.svelte**
Replace imports:
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
- `<UserMinus .../>` → `<Icon icon="pixelarticons:user-minus" .../>`
- `<Search .../>` → `<Icon icon="pixelarticons:search" .../>`
- [ ] **Step 3: Migrate Checklist.svelte**
Replace imports:
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
- `<Plus .../>` → `<Icon icon="pixelarticons:plus" .../>`
- `<GripVertical .../>` → `<Icon icon="pixelarticons:drag-and-drop" .../>`
- `<ChevronRight .../>` → `<Icon icon="pixelarticons:chevron-right" .../>`
- [ ] **Step 4: Migrate CollaboratorPopover.svelte**
Replace import:
```typescript
import Icon from '@iconify/svelte';
```
Replace: `<Users .../>` → `<Icon icon="pixelarticons:users" .../>`
- [ ] **Step 5: Migrate ImageLightbox.svelte**
Replace import:
```typescript
import Icon from '@iconify/svelte';
```
Replace: `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
- [ ] **Step 6: Migrate ImageUpload.svelte**
Replace imports:
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
- `<Star .../>` → `<Icon icon="pixelarticons:pin" .../>`
- `<RefreshCw .../>` → `<Icon icon="pixelarticons:reload" .../>`
- [ ] **Step 7: Migrate NotesView.svelte (Plus icon)**
Replace import (line 8):
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<Plus class="h-4 w-4" />` → `<Icon icon="pixelarticons:plus" class="h-4 w-4" />`
- `<Plus class="h-5 w-5" />` → `<Icon icon="pixelarticons:plus" class="h-5 w-5" />`
- [ ] **Step 8: Commit**
```bash
git add src/lib/components/NoteHistory.svelte src/lib/components/ShareDialog.svelte src/lib/components/Checklist.svelte src/lib/components/CollaboratorPopover.svelte src/lib/components/ImageLightbox.svelte src/lib/components/ImageUpload.svelte src/lib/components/NotesView.svelte
git commit -m "feat: migrate remaining components to pixelarticons"
```
---
### Task 13: Icon Migration — Settings Pages
**Files:**
- Modify: `src/routes/(app)/settings/+layout.svelte:3`
- Modify: `src/routes/(app)/settings/mcp/+page.svelte:2`
- [ ] **Step 1: Migrate settings layout**
Replace import (line 3):
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<SlidersHorizontal size={16} />` → `<Icon icon="pixelarticons:sliders" width={16} />`
- `<User size={16} />` → `<Icon icon="pixelarticons:user" width={16} />`
- `<Cpu size={16} />` → `<Icon icon="pixelarticons:cpu" width={16} />`
- `<ShieldCheck size={16} />` → `<Icon icon="pixelarticons:shield" width={16} />`
- `<Info size={16} />` → `<Icon icon="pixelarticons:info-box" width={16} />`
- [ ] **Step 2: Migrate MCP settings page**
Replace import (line 2):
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<Key .../>` → `<Icon icon="pixelarticons:lock" .../>`
- `<Copy .../>` → `<Icon icon="pixelarticons:copy" .../>`
- `<Trash2 .../>` → `<Icon icon="pixelarticons:trash" .../>`
- `<Plus .../>` → `<Icon icon="pixelarticons:plus" .../>`
- `<Check .../>` → `<Icon icon="pixelarticons:check" .../>`
- [ ] **Step 3: Commit**
```bash
git add src/routes/(app)/settings/+layout.svelte src/routes/(app)/settings/mcp/+page.svelte
git commit -m "feat: migrate settings pages to pixelarticons"
```
---
### Task 14: Update docs/FEATURES.md
**Files:**
- Modify: `docs/FEATURES.md:67-70,141`
- [ ] **Step 1: Rename "Pin Notes" section to "Favorite Notes"**
Replace lines 67-70:
```markdown
### Favorite Notes
- Favorite important notes to always appear at top
- Favorites section separated from other notes
- Toggle favorite from note card hover actions
```
- [ ] **Step 2: Update MCP tools description**
Line 141: replace `pin` with `favorite` in the tools list:
```markdown
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; favorite, reorder notes; upload images
```
- [ ] **Step 3: Commit**
```bash
git add docs/FEATURES.md
git commit -m "docs: rename pin to favorites in FEATURES.md"
```
---
### Task 15: Verification
- [ ] **Step 1: Run type check**
Run: `pnpm check`
Expected: PASS with no errors
- [ ] **Step 2: Run unit tests**
Run: `pnpm test:unit`
Expected: All tests pass (DB/CRDT tests use `pinned` field which is unchanged)
- [ ] **Step 3: Run e2e tests**
Run: `pnpm test:e2e`
Expected: All tests pass including the updated organization spec
- [ ] **Step 4: Update CLAUDE.md icons section**
Add to the "Visual rules" section in CLAUDE.md after the "Icons" line:
```markdown
- **Icons**: Pixelarticons via `@iconify/svelte` — use `<Icon icon="pixelarticons:name" width={size} class="..." />`. Exception: Bold, Italic, Strikethrough, Underline stay as Lucide (`lucide-svelte`). See full mapping in `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`.
```
- [ ] **Step 5: Commit docs update**
```bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md with pixelarticons icon usage"
```
@@ -1,182 +0,0 @@
# Icon Migration: Lucide → Pixelarticons + "Pinned" → "Favorites" Rename
**Date:** 2026-03-20
**Status:** Approved
## Summary
Replace Lucide icons with pixelarticons (via `@iconify/svelte`) across the app to match the retro parchment aesthetic. Keep Lucide only for 4 text-formatting letterform icons (Bold, Italic, Strikethrough, Underline). Additionally, rename the user-facing "pinned" concept to "favorites" with a heart icon, while keeping the `pinned` database column unchanged.
## Motivation
The current Lucide icons have smooth, modern strokes that clash with the 8-bit retro parchment design system. Pixelarticons are drawn on a strict 24×24 pixel grid with no anti-aliasing — a natural fit for the `Press Start 2P` pixel font and hard-offset shadow aesthetic already in use.
## Technical Approach
### Icon Library
- **Package:** `@iconify/svelte` (v5.2.1, already installed) + `@iconify-json/pixelarticons` (v1.2.4, already installed)
- **Usage:** `<Icon icon="pixelarticons:icon-name" width={size} class="..." />`
- **Tree-shaking:** Iconify handles this automatically — only icons used in code are bundled
### Icon Mapping
56 icons migrated to pixelarticons, 4 kept as Lucide:
#### Navigation & UI
| Lucide | Pixelarticons |
|--------|--------------|
| Menu | `menu` |
| Search | `search` |
| X / XIcon | `close` |
| Plus | `plus` |
| Check | `check` |
| ArrowLeft | `arrow-left` |
| ChevronRight | `chevron-right` |
| ChevronDown | `chevron-down` |
| Settings | `settings-cog` |
| ExternalLink | `external-link` |
#### Notes & Content
| Lucide | Pixelarticons | Notes |
|--------|--------------|-------|
| StickyNote | `sticky-note` | |
| Archive | `archive` | |
| ArchiveRestore | `archive` | Context makes it clear |
| Trash2 | `trash` | |
| Bookmark | `heart` | Renamed to "favorite" |
| Clock | `clock` | |
| History | `timeline` | |
| FileText | `file-text` | |
| FileCode | `script` | |
| ImageIcon | `image` | |
| Palette | `colors-swatch` | |
| GripVertical | `drag-and-drop` | |
| Star | `pin` | Used in ImageUpload for "featured image" toggle — not related to favorites |
| SquareCheck | `checkbox-on` | |
| Type | `art-text` | |
#### Text Formatting (toolbar)
| Lucide | Pixelarticons | Notes |
|--------|--------------|-------|
| **Bold** | **Keep Lucide** | Letterform |
| **Italic** | **Keep Lucide** | Letterform |
| **Strikethrough** | **Keep Lucide** | Letterform |
| **Underline** | **Keep Lucide** | Letterform |
| Code | `braces` | |
| CodeXml | `braces` | |
| Heading | `heading` | |
| Link | `link` | |
| TextQuote | `quote-text-inline` | |
| List | `list` | |
| ListOrdered | `bulletlist` | |
| ListChecks | `checklist` | |
| AlignLeft | `text-align-left` | |
| AlignCenter | `text-align-center` | |
| AlignRight | `text-align-right` | |
| AlignJustify | `text-align-justify` | |
| Table2 | `table` | |
| Minus | `minus` | |
| RemoveFormatting | `remove-box` | |
| CornerDownLeft | `corner-down-left` | |
| BetweenHorizontalEnd | `add-row` | |
| BetweenVerticalEnd | `add-col` | |
#### Undo/Redo
| Lucide | Pixelarticons |
|--------|--------------|
| Undo2 | `undo` |
| Redo2 | `redo` |
| RotateCcw | `undo` |
| RefreshCw | `reload` |
#### Sync Status
| Lucide | Pixelarticons | Notes |
|--------|--------------|-------|
| CloudCheck | `cloud-done` | |
| CloudSync | `sync` | |
| CloudOff | `cloud` | Color conveys offline state |
| CloudAlert | `cloud` | Color conveys error state |
#### Users & Admin
| Lucide | Pixelarticons |
|--------|--------------|
| User | `user` |
| Users | `users` |
| UserPlus | `user-plus` |
| UserMinus | `user-minus` |
| Cpu | `cpu` |
| ShieldCheck | `shield` |
| SlidersHorizontal | `sliders` |
| Info | `info-box` |
| Key | `lock` |
| Copy | `copy` |
| Tag | `label` |
### "Pinned" → "Favorites" Rename
**Scope:** User-facing labels, function names, store names, MCP tool names, test IDs. The database column `pinned` stays unchanged to avoid a migration.
**Renames:**
| Current | New |
|---------|-----|
| `pinnedNotes` (store) | `favoriteNotes` |
| `unpinnedNotes` (store) | `unfavoritedNotes` |
| `togglePin()` | `toggleFavorite()` |
| `"Pinned"` label in NotesView | `"Favorites"` |
| `"Pin"` / `"Unpin"` tooltips in NoteCard | `"Favorite"` / `"Unfavorite"` |
| `pin_note` MCP tool | `favorite_note` |
| `pin-btn` / `pin-indicator` test IDs | `favorite-btn` / `favorite-indicator` |
**Not renamed (internal/DB):**
- `pinned` column in `notes` and `noteUserState` tables
- `pinned` field in TypeScript types (`Note`, `NoteUpdate`, `NoteChange`)
- `pinned` references in CRDT sync logic
- `pinned` in server-side sync overlay
## Files to Modify
### Icon migration (16 files)
1. `src/lib/components/Layout/Sidebar.svelte`
2. `src/lib/components/Layout/Header.svelte`
3. `src/lib/components/FormattingToolbar.svelte`
4. `src/lib/components/NoteCard.svelte`
5. `src/lib/components/NoteEditor.svelte`
6. `src/lib/components/NoteHistory.svelte`
7. `src/lib/components/SyncIndicator.svelte`
8. `src/lib/components/SearchBar.svelte`
9. `src/lib/components/ShareDialog.svelte`
10. `src/lib/components/Checklist.svelte`
11. `src/lib/components/CollaboratorPopover.svelte`
12. `src/lib/components/ImageLightbox.svelte`
13. `src/lib/components/ImageUpload.svelte`
14. `src/lib/components/NotesView.svelte`
15. `src/routes/(app)/settings/+layout.svelte`
16. `src/routes/(app)/settings/mcp/+page.svelte`
### Favorites rename (7 files)
1. `src/lib/stores/notes.ts` — store names + `toggleFavorite`
2. `src/lib/components/NoteCard.svelte` — tooltips, test IDs, icon
3. `src/lib/components/NotesView.svelte` — label, store imports
4. `src/lib/server/mcp/server.ts` — tool name + description
5. `docs/FEATURES.md` — update feature description
6. `tests/e2e/organization.spec.ts` — `pin-btn` test ID + `getByText('Pinned')` → `getByText('Favorites')` + scenario names/comments
7. Note: `tests/e2e/sharing.spec.ts` references `pinned` as a data field — does NOT need renaming (DB field stays `pinned`)
### Cleanup
- Remove `lucide-svelte` from `package.json` once only 4 icons remain — or keep it as a dependency for those 4. Decision: keep it, the 4 formatting icons are deeply integrated.
### Documentation
- Update `CLAUDE.md` "Icons" section to document pixelarticons usage
- Update `docs/FEATURES.md` if it mentions pinning
## Testing Strategy
- Run `pnpm check` after migration to catch type errors
- Run `pnpm test:unit` to verify sync/CRDT tests still pass (they use `pinned` field which is unchanged)
- Run `pnpm test:e2e` to verify UI still works — tests referencing `pin-btn`/`pin-indicator` will need updating
- Visual verification: icons render correctly at all sizes (12-20px)
## Rendering Note
Pixelarticons are designed for the 24×24 grid and render sharpest at multiples of 24px. At the sizes used in this app (12-20px), they will still look good because `@iconify/svelte` renders them as SVG (vector), but the pixel-art crispness is maximized at 24px. Consider using `image-rendering: pixelated` on icon SVGs if sub-24px rendering looks blurry, though SVG scaling typically handles this well.
-67
View File
@@ -1,67 +0,0 @@
CREATE TABLE `attachments` (
`id` text PRIMARY KEY NOT NULL,
`note_id` text NOT NULL,
`filename` text NOT NULL,
`mime_type` text NOT NULL,
`size` integer NOT NULL,
`path` text NOT NULL,
`thumbnail_path` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `note_tags` (
`note_id` text NOT NULL,
`tag_id` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `note_tags_note_id_idx` ON `note_tags` (`note_id`);--> statement-breakpoint
CREATE INDEX `note_tags_tag_id_idx` ON `note_tags` (`tag_id`);--> statement-breakpoint
CREATE TABLE `notes` (
`id` text PRIMARY KEY NOT NULL,
`title` text DEFAULT '' NOT NULL,
`content` text DEFAULT '' NOT NULL,
`color` text DEFAULT 'default' NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`trashed` integer DEFAULT false NOT NULL,
`trashed_at` integer,
`checklist_mode` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`version` integer DEFAULT 1 NOT NULL
);
--> statement-breakpoint
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `sync_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`note_id` text NOT NULL,
`operation` text NOT NULL,
`timestamp` integer NOT NULL,
`client_id` text NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
CREATE TABLE `tags` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`);--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`password_hash` text NOT NULL,
`created_at` integer NOT NULL
);
-37
View File
@@ -1,37 +0,0 @@
CREATE TABLE `login_attempts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`ip` text NOT NULL,
`email` text NOT NULL,
`success` integer NOT NULL,
`timestamp` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `login_attempts_ip_timestamp_idx` ON `login_attempts` (`ip`,`timestamp`);--> statement-breakpoint
DROP INDEX `tags_name_unique`;--> statement-breakpoint
ALTER TABLE `tags` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`email` text DEFAULT '' NOT NULL,
`display_name` text DEFAULT '' NOT NULL,
`role` text DEFAULT 'user' NOT NULL,
`password_hash` text,
`auth_provider` text DEFAULT 'password' NOT NULL,
`provider_id` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_users`("id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at") SELECT "id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at" FROM `users`;--> statement-breakpoint
DROP TABLE `users`;--> statement-breakpoint
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
ALTER TABLE `attachments` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
ALTER TABLE `attachments` ADD `featured` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `notes` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
ALTER TABLE `sessions` ADD `created_at` integer;--> statement-breakpoint
ALTER TABLE `sessions` ADD `user_agent` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `ip` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `last_used_at` integer;--> statement-breakpoint
ALTER TABLE `sync_log` ADD `user_id` integer NOT NULL REFERENCES users(id);
-114
View File
@@ -1,114 +0,0 @@
CREATE TABLE `api_keys` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL,
`key_hash` text NOT NULL,
`key_prefix` text NOT NULL,
`created_at` integer NOT NULL,
`last_used_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `note_collaborators` (
`note_id` text NOT NULL,
`user_id` integer NOT NULL,
`added_by` integer NOT NULL,
`added_at` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `note_collaborators_unique` ON `note_collaborators` (`note_id`,`user_id`);--> statement-breakpoint
CREATE INDEX `note_collaborators_user_id_idx` ON `note_collaborators` (`user_id`);--> statement-breakpoint
CREATE TABLE `note_user_state` (
`note_id` text NOT NULL,
`user_id` integer NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `note_user_state_unique` ON `note_user_state` (`note_id`,`user_id`);--> statement-breakpoint
CREATE TABLE `user_preferences` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_preferences_user_key_unique` ON `user_preferences` (`user_id`,`key`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_attachments` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`note_id` text NOT NULL,
`filename` text NOT NULL,
`mime_type` text NOT NULL,
`size` integer NOT NULL,
`path` text NOT NULL,
`thumbnail_path` text,
`featured` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_attachments`("id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at") SELECT "id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at" FROM `attachments`;--> statement-breakpoint
DROP TABLE `attachments`;--> statement-breakpoint
ALTER TABLE `__new_attachments` RENAME TO `attachments`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE TABLE `__new_notes` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`title` text DEFAULT '' NOT NULL,
`content` text DEFAULT '' NOT NULL,
`color` text DEFAULT 'default' NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`trashed` integer DEFAULT false NOT NULL,
`trashed_at` integer,
`checklist_mode` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`version` integer DEFAULT 1 NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_notes`("id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version") SELECT "id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version" FROM `notes`;--> statement-breakpoint
DROP TABLE `notes`;--> statement-breakpoint
ALTER TABLE `__new_notes` RENAME TO `notes`;--> statement-breakpoint
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
CREATE TABLE `__new_sync_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`note_id` text NOT NULL,
`operation` text NOT NULL,
`timestamp` integer NOT NULL,
`client_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_sync_log`("id", "user_id", "note_id", "operation", "timestamp", "client_id") SELECT "id", "user_id", "note_id", "operation", "timestamp", "client_id" FROM `sync_log`;--> statement-breakpoint
DROP TABLE `sync_log`;--> statement-breakpoint
ALTER TABLE `__new_sync_log` RENAME TO `sync_log`;--> statement-breakpoint
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
CREATE TABLE `__new_tags` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`name` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_tags`("id", "user_id", "name") SELECT "id", "user_id", "name" FROM `tags`;--> statement-breakpoint
DROP TABLE `tags`;--> statement-breakpoint
ALTER TABLE `__new_tags` RENAME TO `tags`;--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);
-465
View File
@@ -1,465 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "682b02aa-a28d-4904-8996-cbc4d740f626",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"attachments": {
"name": "attachments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"attachments_note_id_notes_id_fk": {
"name": "attachments_note_id_notes_id_fk",
"tableFrom": "attachments",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_tags": {
"name": "note_tags",
"columns": {
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"note_tags_note_id_idx": {
"name": "note_tags_note_id_idx",
"columns": [
"note_id"
],
"isUnique": false
},
"note_tags_tag_id_idx": {
"name": "note_tags_tag_id_idx",
"columns": [
"tag_id"
],
"isUnique": false
}
},
"foreignKeys": {
"note_tags_note_id_notes_id_fk": {
"name": "note_tags_note_id_notes_id_fk",
"tableFrom": "note_tags",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"note_tags_tag_id_tags_id_fk": {
"name": "note_tags_tag_id_tags_id_fk",
"tableFrom": "note_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"pinned": {
"name": "pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"archived": {
"name": "archived",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed": {
"name": "trashed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed_at": {
"name": "trashed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checklist_mode": {
"name": "checklist_mode",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {
"notes_trashed_archived_idx": {
"name": "notes_trashed_archived_idx",
"columns": [
"trashed",
"archived"
],
"isUnique": false
},
"notes_updated_at_idx": {
"name": "notes_updated_at_idx",
"columns": [
"updated_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_log": {
"name": "sync_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sync_log_timestamp_idx": {
"name": "sync_log_timestamp_idx",
"columns": [
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {
"sync_log_note_id_notes_id_fk": {
"name": "sync_log_note_id_notes_id_fk",
"tableFrom": "sync_log",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_name_unique": {
"name": "tags_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
-684
View File
@@ -1,684 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9eb400be-28ff-4b92-be35-1cd0b344f2e3",
"prevId": "682b02aa-a28d-4904-8996-cbc4d740f626",
"tables": {
"attachments": {
"name": "attachments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"featured": {
"name": "featured",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"attachments_user_id_users_id_fk": {
"name": "attachments_user_id_users_id_fk",
"tableFrom": "attachments",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"attachments_note_id_notes_id_fk": {
"name": "attachments_note_id_notes_id_fk",
"tableFrom": "attachments",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"login_attempts": {
"name": "login_attempts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"success": {
"name": "success",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"login_attempts_ip_timestamp_idx": {
"name": "login_attempts_ip_timestamp_idx",
"columns": [
"ip",
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_tags": {
"name": "note_tags",
"columns": {
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"note_tags_note_id_idx": {
"name": "note_tags_note_id_idx",
"columns": [
"note_id"
],
"isUnique": false
},
"note_tags_tag_id_idx": {
"name": "note_tags_tag_id_idx",
"columns": [
"tag_id"
],
"isUnique": false
}
},
"foreignKeys": {
"note_tags_note_id_notes_id_fk": {
"name": "note_tags_note_id_notes_id_fk",
"tableFrom": "note_tags",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"note_tags_tag_id_tags_id_fk": {
"name": "note_tags_tag_id_tags_id_fk",
"tableFrom": "note_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"pinned": {
"name": "pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"archived": {
"name": "archived",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed": {
"name": "trashed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed_at": {
"name": "trashed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checklist_mode": {
"name": "checklist_mode",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {
"notes_user_id_idx": {
"name": "notes_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"notes_trashed_archived_idx": {
"name": "notes_trashed_archived_idx",
"columns": [
"trashed",
"archived"
],
"isUnique": false
},
"notes_updated_at_idx": {
"name": "notes_updated_at_idx",
"columns": [
"updated_at"
],
"isUnique": false
}
},
"foreignKeys": {
"notes_user_id_users_id_fk": {
"name": "notes_user_id_users_id_fk",
"tableFrom": "notes",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_used_at": {
"name": "last_used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_log": {
"name": "sync_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sync_log_timestamp_idx": {
"name": "sync_log_timestamp_idx",
"columns": [
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {
"sync_log_user_id_users_id_fk": {
"name": "sync_log_user_id_users_id_fk",
"tableFrom": "sync_log",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"sync_log_note_id_notes_id_fk": {
"name": "sync_log_note_id_notes_id_fk",
"tableFrom": "sync_log",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_name_user_unique": {
"name": "tags_name_user_unique",
"columns": [
"name",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"tags_user_id_users_id_fk": {
"name": "tags_user_id_users_id_fk",
"tableFrom": "tags",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'password'"
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1773000424830,
"tag": "0000_wooden_vargas",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1773016788899,
"tag": "0001_strange_lord_hawal",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1773150888301,
"tag": "0002_deep_black_widow",
"breakpoints": true
}
]
}
+31 -68
View File
@@ -1,29 +1,18 @@
{
"name": "crumbs",
"private": true,
"version": "0.22.3",
"version": "0.1.3",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
"homepage": "https://crumbs.bretzel.app",
"repository": {
"type": "git",
"url": "https://github.com/bretzel-app/crumbs.git"
},
"scripts": {
"dev": "vite dev",
"build": "npx tsx scripts/generate-openapi.ts && vite build",
"openapi": "npx tsx scripts/generate-openapi.ts",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:coverage": "VITE_COVERAGE=true pnpm build && pnpm test:e2e && nyc report",
"coverage:merge-e2e": "nyc merge coverage/e2e/raw coverage/e2e/merged.json && nyc report",
"test": "pnpm test:unit && pnpm test:e2e",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
@@ -33,63 +22,43 @@
"docs:api": "tsx scripts/generate-api-docs.ts"
},
"dependencies": {
"@iconify-json/pixelarticons": "^1.2.4",
"@iconify/svelte": "^5.2.1",
"@modelcontextprotocol/sdk": "^1.27.1",
"@sveltejs/adapter-node": "^5.5.4",
"@tiptap/core": "^3.20.1",
"@tiptap/extension-link": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/extension-table": "^3.20.1",
"@tiptap/extension-table-cell": "^3.20.1",
"@tiptap/extension-table-header": "^3.20.1",
"@tiptap/extension-table-row": "^3.20.1",
"@tiptap/extension-task-item": "^3.20.1",
"@tiptap/extension-task-list": "^3.20.1",
"@tiptap/extension-text-align": "^3.20.1",
"@tiptap/extension-underline": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"arctic": "^3.7.0",
"argon2": "^0.43.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.7",
"idb": "^8.0.3",
"@sveltejs/adapter-node": "^5.0.0",
"@tiptap/core": "^3.20.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-task-item": "^3.20.0",
"@tiptap/extension-task-list": "^3.20.0",
"@tiptap/extension-text-align": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"argon2": "^0.43.0",
"better-sqlite3": "^11.0.0",
"drizzle-orm": "^0.44.0",
"idb": "^8.0.0",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.1.1",
"node-diff3": "^3.2.0",
"nodemailer": "^8.0.2",
"svelte-dnd-action": "^0.9.69",
"markdown-it": "^14.0.0",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.1.0",
"zod": "^4.3.6",
"zxcvbn": "^4.4.2"
"uuid": "^11.0.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/kit": "^2.53.4",
"@playwright/test": "^1.50.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^7.0.11",
"@tailwindcss/vite": "^4.0.0",
"@types/better-sqlite3": "^7.6.0",
"@types/markdown-it": "^14.0.0",
"@types/uuid": "^10.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.9",
"nyc": "^18.0.0",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"tailwindcss": "^4.2.1",
"@vite-pwa/sveltekit": "^1.0.0",
"drizzle-kit": "^0.31.0",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.0.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.4",
"workbox-window": "^7.4.0",
"vitest": "^3.0.0",
"yaml": "^2.8.2"
},
"pnpm": {
@@ -97,12 +66,6 @@
"argon2",
"better-sqlite3",
"esbuild"
],
"overrides": {
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0",
"hono": ">=4.12.7"
}
]
}
}
}
+10 -14
View File
@@ -1,41 +1,37 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
globalSetup: './tests/e2e/global-setup.ts',
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
workers: process.env.CI ? 4 : undefined,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
globalSetup: './tests/e2e/global-setup.ts',
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
serviceWorkers: 'block'
screenshot: 'only-on-failure'
},
projects: [
{
name: 'auth-setup',
testMatch: 'auth.spec.ts',
fullyParallel: false,
name: 'setup',
testMatch: /auth\.spec\.ts/,
use: { ...devices['Desktop Chrome'] }
},
{
name: 'app',
testIgnore: 'auth.spec.ts',
dependencies: ['auth-setup'],
name: 'chromium',
testIgnore: /auth\.spec\.ts/,
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
command: 'pnpm preview --port 4173',
command: 'pnpm build && pnpm preview --port 4173',
port: 4173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
DATABASE_URL: './data/test-crumbs.db',
NODE_ENV: 'test'
DATABASE_URL: './data/test-crumbs.db'
}
}
});
+527 -1903
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
#!/usr/bin/env bash
# Writes a coverage summary table to $GITHUB_STEP_SUMMARY (or stdout if not in CI).
# Usage: scripts/coverage-summary.sh <title> <json-summary-path>
set -euo pipefail
TITLE="${1:?Usage: coverage-summary.sh <title> <json-summary-path>}"
JSON="${2:?Usage: coverage-summary.sh <title> <json-summary-path>}"
if [ ! -f "$JSON" ]; then
echo "Coverage summary not found: $JSON" >&2
exit 0
fi
# Resolve to absolute path for require()
ABS_JSON="$(cd "$(dirname "$JSON")" && pwd)/$(basename "$JSON")"
# Extract totals with node (available in CI, no jq dependency)
read -r stmts branches funcs lines < <(node -e "
const s = require('$ABS_JSON').total;
console.log(s.statements.pct, s.branches.pct, s.functions.pct, s.lines.pct);
")
TABLE="## $TITLE
| Metric | Coverage |
|--------|----------|
| Statements | ${stmts}% |
| Branches | ${branches}% |
| Functions | ${funcs}% |
| Lines | ${lines}% |
"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "$TABLE" >> "$GITHUB_STEP_SUMMARY"
else
echo "$TABLE"
fi
-147
View File
@@ -1,147 +0,0 @@
/**
* Auto-generates an OpenAPI spec from the SvelteKit route tree.
*
* Scans all +server.ts files under src/routes/api/ for exported HTTP methods and
* builds a minimal OpenAPI 3.1 spec at static/.well-known/openapi.json.
*
* Usage: npx tsx scripts/generate-openapi.ts
*/
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
import { resolve, relative, dirname, join } from 'path';
const ROOT = process.cwd();
const ROUTES_DIR = resolve(ROOT, 'src/routes/api');
const OUTPUT = resolve(ROOT, 'static/api/openapi.json');
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'));
function walkDir(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
results.push(...walkDir(full));
} else if (entry === '+server.ts') {
results.push(full);
}
}
return results;
}
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
const METHOD_RE = new RegExp(`export\\s+const\\s+(${HTTP_METHODS.join('|')})`, 'g');
function svelteKitPathToOpenAPI(filePath: string): string {
const rel = relative(ROUTES_DIR, dirname(filePath));
const segments = rel === '' ? [] : rel.split('/');
const apiPath =
'/api/' +
segments
.map((s) => {
const match = s.match(/^\[(.+)\]$/);
return match ? `{${match[1]}}` : s;
})
.join('/');
return apiPath;
}
function extractMethods(filePath: string): string[] {
const content = readFileSync(filePath, 'utf-8');
const methods: string[] = [];
let match;
while ((match = METHOD_RE.exec(content)) !== null) {
methods.push(match[1].toLowerCase());
}
METHOD_RE.lastIndex = 0;
return methods;
}
function inferTag(path: string): string {
const first = path.replace('/api/', '').split('/')[0];
return first.charAt(0).toUpperCase() + first.slice(1);
}
function extractPathParams(path: string) {
const params: { name: string; in: string; required: boolean; schema: { type: string } }[] = [];
const re = /\{(\w+)\}/g;
let match;
while ((match = re.exec(path)) !== null) {
params.push({ name: match[1], in: 'path', required: true, schema: { type: 'string' } });
}
return params;
}
const files = walkDir(ROUTES_DIR);
const paths: Record<string, Record<string, unknown>> = {};
for (const fullPath of files) {
const apiPath = svelteKitPathToOpenAPI(fullPath);
const methods = extractMethods(fullPath);
if (methods.length === 0) continue;
const pathParams = extractPathParams(apiPath);
const pathEntry: Record<string, unknown> = {};
if (pathParams.length > 0) {
pathEntry.parameters = pathParams;
}
for (const method of methods) {
const operation: Record<string, unknown> = {
tags: [inferTag(apiPath)],
responses: {
'200': { description: 'Success' }
}
};
if (['post', 'put', 'patch'].includes(method)) {
operation.requestBody = {
content: {
'application/json': {
schema: { type: 'object' }
}
}
};
}
pathEntry[method] = operation;
}
paths[apiPath] = pathEntry;
}
const sortedPaths: Record<string, Record<string, unknown>> = {};
for (const key of Object.keys(paths).sort()) {
sortedPaths[key] = paths[key];
}
const spec = {
openapi: '3.1.0',
info: {
title: `${pkg.name.charAt(0).toUpperCase() + pkg.name.slice(1)} API`,
description: `Auto-generated API spec for ${pkg.name}`,
version: pkg.version,
contact: {
name: 'Bretzel',
url: 'https://bretzel.app'
}
},
servers: [{ url: '/', description: 'Current instance' }],
security: [{ sessionCookie: [] }],
paths: sortedPaths,
components: {
securitySchemes: {
sessionCookie: {
type: 'apiKey',
in: 'cookie',
name: 'session'
}
}
}
};
mkdirSync(dirname(OUTPUT), { recursive: true });
writeFileSync(OUTPUT, JSON.stringify(spec, null, 2) + '\n');
console.log(`Generated ${relative(ROOT, OUTPUT)} with ${Object.keys(paths).length} paths`);
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Automate version bump, tag, and push based on conventional commits.
# Usage: bash scripts/release.sh [patch|minor|major]
# If no argument is given, the bump type is inferred from commits since the last tag.
BUMP="${1:-}"
# --- Safety checks ---
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ "$BRANCH" != "main" ]]; then
echo "Error: must be on the main branch (currently on '$BRANCH')" >&2
exit 1
fi
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean" >&2
exit 1
fi
git fetch origin main --tags --quiet
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [[ "$LOCAL" != "$REMOTE" ]]; then
echo "Error: local main is not in sync with origin/main" >&2
echo " local: $LOCAL" >&2
echo " remote: $REMOTE" >&2
exit 1
fi
# --- Bump type inference ---
if [[ -z "$BUMP" ]]; then
LAST_TAG=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "")
if [[ -z "$LAST_TAG" ]]; then
echo "No previous v* tag found, defaulting to patch"
BUMP="patch"
else
COMMITS=$(git log "$LAST_TAG"..HEAD --pretty=format:'%s%n%b')
if echo "$COMMITS" | grep -qiE 'BREAKING CHANGE:|^[a-z]+(\(.*\))?!:'; then
BUMP="major"
elif echo "$COMMITS" | grep -qE '^feat(\(.*\))?:'; then
BUMP="minor"
else
BUMP="patch"
fi
fi
fi
if [[ "$BUMP" != "patch" && "$BUMP" != "minor" && "$BUMP" != "major" ]]; then
echo "Error: invalid bump type '$BUMP' (must be patch, minor, or major)" >&2
exit 1
fi
# --- Bump, tag, push ---
echo "Bumping $BUMP version..."
npm version "$BUMP" -m "chore: bump version to %s" --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
# Update version in marketing website
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
else
sed -i "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
fi
git add package.json website/index.html
git commit -m "chore: bump version to $NEW_VERSION"
git tag "v$NEW_VERSION"
echo "Pushing to origin..."
git push origin main --tags
echo ""
echo "Released v$NEW_VERSION ($BUMP bump)"
echo "GitHub Actions release workflow will handle the rest."
-30
View File
@@ -1,30 +0,0 @@
{
"version": 1,
"skills": {
"drizzle-migrations": {
"source": "erichowens/some_claude_skills",
"sourceType": "github",
"computedHash": "d2af96b92935e26f53d97616aa51a02f114c9da8129c5943408d3b4b19a10423"
},
"svelte5-best-practices": {
"source": "ejirocodes/agent-skills",
"sourceType": "github",
"computedHash": "bfcbba3d355aede6f6be9720f195206d4dcaa7c152787ea00c06cb4a1ac44ba4"
},
"sveltekit-data-flow": {
"source": "spences10/svelte-skills-kit",
"sourceType": "github",
"computedHash": "1ef78409b38d1cb7324d8e0a78ac3f4303e0a9c5b5789bfc2d646c0806faa618"
},
"sveltekit-structure": {
"source": "spences10/svelte-skills-kit",
"sourceType": "github",
"computedHash": "a935fd51b32e4e37d23286c04748825a6a193ef74d9980385449aa4f80646f9d"
},
"typescript-best-practices": {
"source": "0xbigboss/claude-code",
"sourceType": "github",
"computedHash": "838d6737e59e8b15457a6af23112bc3a107a8ac098606a19a43c2be0db2dbe5a"
}
}
}
-176
View File
@@ -1,178 +1,2 @@
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
:root {
--bg-base: #f0e6d3;
--bg-surface: #faf5eb;
--primary: #C8860A;
--primary-hover: #E8A020;
--text: #1a1a2e;
--text-muted: #6b6272;
--border: #1a1a2e;
--border-subtle: #d4cabb;
--destructive: #a63d2f;
--error-bg: #f5e0db;
--error-border: #d4a69e;
--error-text: #8b2e1f;
--success-bg: #dce8d4;
--success-text: #3a5a2e;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
}
body {
font-family: 'JetBrains Mono', monospace;
accent-color: var(--primary);
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: repeating-conic-gradient(#1a1a2e 0% 25%, transparent 0% 50%);
background-size: 4px 4px;
z-index: 0;
}
/* Editor entrance animations */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pop-in {
from { opacity: 0; transform: scale(0.95) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Prose overrides for parchment theme */
.prose {
--tw-prose-body: var(--text);
--tw-prose-headings: var(--text);
--tw-prose-links: var(--primary);
--tw-prose-code: var(--text);
--tw-prose-pre-bg: var(--bg-surface);
--tw-prose-pre-code: var(--text);
}
.prose :where(code):not(:where([class~="not-prose"] *)) {
background-color: var(--bg-surface);
border-radius: 2px;
padding: 0.125rem 0.25rem;
}
.prose :where(a):not(:where([class~="not-prose"] *)) {
color: var(--primary);
}
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
color: var(--primary-hover);
}
/* Table styles for TipTap editor */
.tiptap table {
border-collapse: collapse;
width: 100%;
margin: 0.5rem 0;
}
.tiptap th,
.tiptap td {
border: 1px solid var(--border-subtle);
padding: 0.375rem 0.625rem;
text-align: left;
vertical-align: top;
min-width: 4rem;
}
.tiptap th {
font-weight: 600;
background-color: color-mix(in srgb, var(--bg-base) 60%, transparent);
}
.tiptap td p,
.tiptap th p {
margin: 0;
}
.tiptap .selectedCell {
background-color: color-mix(in srgb, var(--primary) 12%, transparent);
}
/* Task list styles — shared between TipTap editor and markdown-it preview */
/* TipTap: ul[data-type="taskList"] | markdown-it: ul.task-list */
.tiptap ul[data-type="taskList"],
.prose ul.task-list {
list-style: none;
padding-left: 0;
margin: 0.25rem 0;
}
.tiptap ul[data-type="taskList"] li,
.prose ul.task-list li {
display: flex;
align-items: center;
gap: 0.375rem;
margin: 0;
padding: 0;
}
.tiptap ul[data-type="taskList"] li::marker,
.prose ul.task-list li::marker {
content: none;
}
/* TipTap: checkbox is inside a label */
.tiptap ul[data-type="taskList"] li label {
display: flex;
align-items: center;
flex-shrink: 0;
}
/* Shared checkbox styles */
.tiptap ul[data-type="taskList"] li label input[type="checkbox"],
.prose ul.task-list .task-checkbox {
cursor: pointer;
margin: 0;
flex-shrink: 0;
}
/* TipTap: text content is in a div>p */
.tiptap ul[data-type="taskList"] li > div {
flex: 1;
min-width: 0;
}
.tiptap ul[data-type="taskList"] li > div p {
margin: 0;
}
/* Checked item styling */
.tiptap ul[data-type="taskList"] li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--text-muted);
}
/* Override prose spacing for task lists */
.prose ul[data-type="taskList"],
.prose ul.task-list {
padding-left: 0;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.prose ul[data-type="taskList"] li,
.prose ul.task-list li {
padding-left: 0;
margin-top: 0;
margin-bottom: 0;
}
.prose ul.task-list li p {
margin: 0;
}
+1 -11
View File
@@ -1,19 +1,9 @@
/// <reference types="vite-plugin-pwa/info" />
/// <reference types="vite-plugin-pwa/vanillajs" />
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
user: {
id: number;
email: string;
displayName: string;
role: 'admin' | 'user';
} | null;
}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
+5 -8
View File
@@ -3,14 +3,11 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f0e6d3" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Crumbs" />
<meta name="theme-color" content="#ffffff" />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🥨</text></svg>"
/>
<title>Crumbs</title>
%sveltekit.head%
</head>
+3 -47
View File
@@ -1,54 +1,17 @@
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { validateSession, isSetupComplete } from '$lib/server/auth.js';
import { validateApiKey, getUserForApiKey } from '$lib/server/api-keys.js';
const PUBLIC_PATHS = [
'/login',
'/setup',
'/api/auth/login',
'/api/auth/setup',
'/api/auth/oauth'
];
const PUBLIC_PATHS = ['/login', '/setup', '/api/auth/login', '/api/auth/setup'];
export const handle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
// Initialize locals
event.locals.user = null;
// Allow public paths
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return resolve(event);
}
// API routes: authenticate via Bearer token if present
if (pathname.startsWith('/api/')) {
const authHeader = event.request.headers.get('authorization');
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (token) {
const { valid, userId } = validateApiKey(token);
if (!valid || !userId) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const user = getUserForApiKey(userId);
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
event.locals.user = user;
return resolve(event);
}
}
// Check if setup is complete
const setupDone = await isSetupComplete();
if (!setupDone) {
@@ -58,16 +21,9 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
// Validate session and populate user
// Validate session
const sessionToken = event.cookies.get('session');
if (sessionToken) {
const result = await validateSession(sessionToken);
if (result.valid && result.user) {
event.locals.user = result.user;
}
}
if (!event.locals.user) {
if (!sessionToken || !(await validateSession(sessionToken))) {
if (pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
+50 -129
View File
@@ -1,10 +1,5 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
interface ChecklistItem {
id: string;
text: string;
checked: boolean;
}
@@ -16,26 +11,15 @@
let { content, onChange }: Props = $props();
// svelte-ignore state_referenced_locally
let items = $state<ChecklistItem[]>(parseChecklist(content));
let doneExpanded = $state(true);
const flipDurationMs = 150;
let activeItems = $derived(items.filter((i) => !i.checked));
let doneItems = $derived(items.filter((i) => i.checked));
let doneCount = $derived(doneItems.length);
function generateId(): string {
return crypto.randomUUID();
}
function parseChecklist(text: string): ChecklistItem[] {
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
if (!text.trim()) return [{ text: '', checked: false }];
const lines = text.split('\n');
return lines.map((line) => {
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
return { id: generateId(), text: line, checked: false };
if (line.startsWith('- [x] ')) return { text: line.slice(6), checked: true };
if (line.startsWith('- [ ] ')) return { text: line.slice(6), checked: false };
return { text: line, checked: false };
});
}
@@ -49,151 +33,88 @@
onChange(serializeChecklist(items));
}
function toggleItem(id: string) {
const item = items.find((i) => i.id === id);
if (item) {
item.checked = !item.checked;
emitChange();
}
function toggleItem(index: number) {
items[index].checked = !items[index].checked;
emitChange();
}
function updateText(id: string, text: string) {
const item = items.find((i) => i.id === id);
if (item) {
item.text = text;
emitChange();
}
function updateText(index: number, text: string) {
items[index].text = text;
emitChange();
}
function addItem(afterIndex: number) {
const newItem = { id: generateId(), text: '', checked: false };
items.splice(afterIndex + 1, 0, newItem);
items.splice(afterIndex + 1, 0, { text: '', checked: false });
items = [...items];
emitChange();
// Focus new input on next tick
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[afterIndex + 1]?.focus();
}, 0);
}
function removeItem(id: string) {
function removeItem(index: number) {
if (items.length <= 1) return;
const index = items.findIndex((i) => i.id === id);
items.splice(index, 1);
items = [...items];
emitChange();
}
function handleKeydown(e: KeyboardEvent, id: string) {
const index = items.findIndex((i) => i.id === id);
function handleKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
addItem(index);
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
e.preventDefault();
removeItem(id);
removeItem(index);
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[Math.max(0, index - 1)]?.focus();
}, 0);
}
}
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
// Merge reordered active items with done items
items = [...e.detail.items, ...items.filter((i) => i.checked)];
}
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
items = [...e.detail.items, ...items.filter((i) => i.checked)];
emitChange();
}
</script>
<div class="space-y-0.5" data-testid="checklist">
<section
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="space-y-0.5"
>
{#each activeItems as item (item.id)}
<div class="group flex items-center gap-2 py-1.5" animate:flip={{ duration: flipDurationMs }}>
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
<Icon icon="pixelarticons:drag-and-drop" class="h-4 w-4 text-[var(--text-muted)]" />
</div>
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-checkbox"
/>
<input
type="text"
value={item.text}
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
onkeydown={(e) => handleKeydown(e, item.id)}
class="flex-1 min-w-0 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
placeholder="List item"
data-testid="checklist-input"
/>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-remove"
>
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
</section>
<div class="space-y-1" data-testid="checklist">
{#each items as item, index}
<div class="flex items-center gap-2">
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(index)}
class="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
data-testid="checklist-checkbox"
/>
<input
type="text"
value={item.text}
oninput={(e) => updateText(index, (e.target as HTMLInputElement).value)}
onkeydown={(e) => handleKeydown(e, index)}
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-gray-400 line-through' : 'text-gray-800'}"
placeholder="List item"
data-testid="checklist-input"
/>
<button
onclick={() => removeItem(index)}
class="opacity-0 hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-remove"
>
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
<button
onclick={() => addItem(items.length - 1)}
class="flex items-center gap-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
class="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700"
data-testid="checklist-add"
>
<Icon icon="pixelarticons:plus" class="h-4 w-4" />
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add item
</button>
{#if doneCount > 0}
<div class="mt-2 border-t border-[var(--border-subtle)] pt-2">
<button
onclick={() => (doneExpanded = !doneExpanded)}
class="flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text)]"
data-testid="checklist-toggle-done"
>
<Icon icon="pixelarticons:chevron-right" class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
{doneCount} done
</button>
{#if doneExpanded}
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
{#each doneItems as item (item.id)}
<div class="group flex items-center gap-2 py-1.5">
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-done-checkbox"
/>
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-done-remove"
>
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
@@ -1,68 +0,0 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
interface Props {
collaborators: Collaborator[];
ownerName?: string;
}
const { collaborators, ownerName }: Props = $props();
let showPopover = $state(false);
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
function handleMouseEnter() {
hoverTimeout = setTimeout(() => {
showPopover = true;
}, 150);
}
function handleMouseLeave() {
clearTimeout(hoverTimeout);
showPopover = false;
}
function handleClick(e: Event) {
e.stopPropagation();
showPopover = !showPopover;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative inline-flex"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
<button
type="button"
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--primary)]"
title="Shared note"
onclick={handleClick}
data-testid="collaborator-indicator"
>
<Icon icon="pixelarticons:users" class="h-4 w-4" />
</button>
{#if showPopover}
<div
class="absolute right-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
data-testid="collaborator-popover"
>
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
<ul class="space-y-0.5">
{#if ownerName}
<li class="text-xs text-[var(--text)]">
{ownerName} <span class="text-[var(--text-muted)]">(Owner)</span>
</li>
{/if}
{#each collaborators as collab}
<li class="text-xs text-[var(--text)]">
{collab.displayName || collab.email}
</li>
{/each}
</ul>
</div>
{/if}
</div>
+1 -1
View File
@@ -15,7 +15,7 @@
{@const colors = NOTE_COLORS[option.value]}
<button
onclick={() => onSelect(option.value)}
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-gray-800' : 'border-transparent'}"
style="background-color: {colors.bg}"
title={option.label}
data-testid="color-{option.value}"
+139 -207
View File
@@ -1,12 +1,29 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import {
Undo2,
Redo2,
Heading,
List,
ListOrdered,
TextQuote,
CodeXml,
Bold,
Italic,
Strikethrough,
Underline
Code,
Underline,
Link,
CornerDownLeft,
ExternalLink,
Trash2,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
Minus,
ChevronDown
} from 'lucide-svelte';
import Icon from '@iconify/svelte';
interface Props {
tick?: number;
@@ -17,9 +34,6 @@
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
let openDropdown: string | null = $state(null);
let linkUrl: string = $state('');
@@ -66,15 +80,15 @@
const chevronSize = 14;
function btnClass(active: boolean = false, disabled: boolean = false): string {
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
return `rounded p-1.5 text-gray-600 hover:bg-black/10 ${active ? 'bg-black/10' : ''} ${disabled ? 'opacity-30' : ''}`;
}
function dropdownBtnClass(active: boolean = false): string {
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
return `flex items-center gap-0.5 rounded p-1.5 text-gray-600 hover:bg-black/10 ${active ? 'bg-black/10' : ''}`;
}
function dropdownItemClass(active: boolean = false): string {
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--border)]/5 ${active ? 'bg-[var(--border)]/5' : ''}`;
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-black/5 ${active ? 'bg-black/5' : ''}`;
}
function handlePointerDown(event: PointerEvent) {
@@ -95,102 +109,66 @@
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<div
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
class="flex flex-wrap items-center gap-0.5 border-b border-gray-200/50 px-2 py-1"
data-testid="formatting-toolbar"
>
<!-- Undo/Redo -->
<!-- History -->
<button
onclick={() => editor?.chain().focus().undo().run()}
disabled={!canUndo()}
class={btnClass(false, !canUndo())}
title="Undo"
title="Undo (Ctrl+Z)"
data-testid="format-undo"
>
<Icon icon="pixelarticons:undo" width={iconSize} />
<Undo2 size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().redo().run()}
disabled={!canRedo()}
class={btnClass(false, !canRedo())}
title="Redo"
title="Redo (Ctrl+Shift+Z)"
data-testid="format-redo"
>
<Icon icon="pixelarticons:redo" width={iconSize} />
<Redo2 size={iconSize} />
</button>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Inline -->
<button
onclick={() => editor?.chain().focus().toggleBold().run()}
class={btnClass(isActive('bold'))}
title="Bold (Ctrl+B)"
data-testid="format-bold"
>
<Bold size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleItalic().run()}
class={btnClass(isActive('italic'))}
title="Italic (Ctrl+I)"
data-testid="format-italic"
>
<Italic size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleStrike().run()}
class={btnClass(isActive('strike'))}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
>
<Strikethrough size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleUnderline().run()}
class={btnClass(isActive('underline'))}
title="Underline (Ctrl+U)"
data-testid="format-underline"
>
<Underline size={iconSize} />
</button>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<div class="mx-1 h-4 w-px bg-gray-300"></div>
<!-- Heading dropdown -->
<div class="relative" data-dropdown="heading">
<button
onclick={() => toggleDropdown('heading')}
class={dropdownBtnClass(isActive('heading'))}
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
title="Heading"
data-testid="format-heading"
>
<Icon icon="pixelarticons:heading" width={iconSize} />
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
<Heading size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'heading'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 1 }))}
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
<span class="w-6 text-xs font-semibold text-gray-400">H1</span>
<span class="font-semibold">Heading 1</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 2 }))}
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
<span class="w-6 text-xs font-semibold text-gray-400">H2</span>
<span>Heading 2</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 3 }))}
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
<span class="w-6 text-xs font-semibold text-gray-400">H3</span>
<span>Heading 3</span>
</button>
</div>
@@ -201,55 +179,108 @@
<div class="relative" data-dropdown="list">
<button
onclick={() => toggleDropdown('list')}
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
title="Lists"
data-testid="format-list"
>
<Icon icon="pixelarticons:list" width={iconSize} />
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
<List size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'list'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<div class="absolute left-0 top-full z-50 mt-1 min-w-[170px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
<button
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('bulletList'))}
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
data-testid="format-bullet-list"
>
<Icon icon="pixelarticons:list" width={iconSize} />
<List size={iconSize} />
<span>Bullet list</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('orderedList'))}
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
data-testid="format-ordered-list"
>
<Icon icon="pixelarticons:bulletlist" width={iconSize} />
<ListOrdered size={iconSize} />
<span>Ordered list</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('taskList'))}
data-testid="format-task-list"
>
<Icon icon="pixelarticons:checklist" width={iconSize} />
<span>Task list</span>
</button>
</div>
{/if}
</div>
<button
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
class={btnClass(editor?.isActive('blockquote') ?? false)}
title="Blockquote"
data-testid="format-blockquote"
>
<TextQuote size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
class={btnClass(editor?.isActive('codeBlock') ?? false)}
title="Code block"
data-testid="format-code-block"
>
<CodeXml size={iconSize} />
</button>
<div class="mx-1 h-4 w-px bg-gray-300"></div>
<!-- Inline -->
<button
onclick={() => editor?.chain().focus().toggleBold().run()}
class={btnClass(editor?.isActive('bold') ?? false)}
title="Bold (Ctrl+B)"
data-testid="format-bold"
>
<Bold size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleItalic().run()}
class={btnClass(editor?.isActive('italic') ?? false)}
title="Italic (Ctrl+I)"
data-testid="format-italic"
>
<Italic size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleStrike().run()}
class={btnClass(editor?.isActive('strike') ?? false)}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
>
<Strikethrough size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCode().run()}
class={btnClass(editor?.isActive('code') ?? false)}
title="Inline code (Ctrl+E)"
data-testid="format-code"
>
<Code size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleUnderline().run()}
class={btnClass(editor?.isActive('underline') ?? false)}
title="Underline (Ctrl+U)"
data-testid="format-underline"
>
<Underline size={iconSize} />
</button>
<!-- Link popover -->
<div class="relative" data-dropdown="link">
<button
onclick={() => toggleDropdown('link')}
class={btnClass(isActive('link'))}
class={btnClass(editor?.isActive('link') ?? false)}
title="Insert link (Ctrl+K)"
data-testid="format-link"
>
<Icon icon="pixelarticons:link" width={iconSize} />
<Link size={iconSize} />
</button>
{#if openDropdown === 'link'}
<div class="absolute left-1/2 bottom-full z-50 mb-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
<div class="absolute left-1/2 top-full z-50 mt-1 -translate-x-1/2 rounded-lg border border-gray-200 bg-white px-2 py-1.5 shadow-lg">
<form
class="flex items-center gap-1"
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
@@ -259,202 +290,103 @@
bind:value={linkUrl}
type="url"
placeholder="Paste a link..."
class="w-44 bg-transparent px-1.5 py-1 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
class="w-44 bg-transparent px-1.5 py-1 text-sm text-gray-700 outline-none placeholder:text-gray-400"
data-testid="format-link-input"
/>
<button
type="submit"
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)]"
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-gray-600"
title="Apply link"
data-testid="format-link-apply"
>
<Icon icon="pixelarticons:corner-down-left" width={16} />
<CornerDownLeft size={16} />
</button>
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
<div class="mx-0.5 h-4 w-px bg-gray-300"></div>
<button
type="button"
onclick={openLink}
disabled={!getAttrs('link').href}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
disabled={!editor?.getAttributes('link').href}
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-gray-600 disabled:opacity-30"
title="Open link"
data-testid="format-link-open"
>
<Icon icon="pixelarticons:external-link" width={16} />
<ExternalLink size={16} />
</button>
<button
type="button"
onclick={removeLink}
disabled={!isActive('link')}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
disabled={!editor?.isActive('link')}
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-red-500 disabled:opacity-30"
title="Remove link"
data-testid="format-unlink"
>
<Icon icon="pixelarticons:trash" width={16} />
<Trash2 size={16} />
</button>
</form>
</div>
{/if}
</div>
<!-- Table dropdown -->
<div class="relative" data-dropdown="table">
<button
onclick={() => toggleDropdown('table')}
class={dropdownBtnClass(isActive('table'))}
title="Table"
data-testid="format-table"
>
<Icon icon="pixelarticons:table" width={iconSize} />
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
</button>
{#if openDropdown === 'table'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-insert"
>
<Icon icon="pixelarticons:table" width={iconSize} />
<span>Insert table</span>
</button>
{#if isActive('table')}
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-row"
>
<Icon icon="pixelarticons:add-row" width={iconSize} />
<span>Add row</span>
</button>
<button
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-col"
>
<Icon icon="pixelarticons:add-col" width={iconSize} />
<span>Add column</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-row"
>
<Icon icon="pixelarticons:minus" width={iconSize} />
<span>Delete row</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-col"
>
<Icon icon="pixelarticons:minus" width={iconSize} />
<span>Delete column</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
class={`${dropdownItemClass()} text-[var(--destructive)]`}
data-testid="format-table-delete"
>
<Icon icon="pixelarticons:remove-box" width={iconSize} />
<span>Delete table</span>
</button>
{/if}
</div>
{/if}
</div>
<div class="mx-1 h-4 w-px bg-gray-300"></div>
<!-- Alignment dropdown -->
<!-- Align dropdown -->
<div class="relative" data-dropdown="align">
<button
onclick={() => toggleDropdown('align')}
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
class={dropdownBtnClass(editor?.isActive({ textAlign: 'center' }) || editor?.isActive({ textAlign: 'right' }) || editor?.isActive({ textAlign: 'justify' }) ? true : false)}
title="Text alignment"
data-testid="format-align"
>
{#if isActive({ textAlign: 'center' })}
<Icon icon="pixelarticons:text-align-center" width={iconSize} />
{:else if isActive({ textAlign: 'right' })}
<Icon icon="pixelarticons:text-align-right" width={iconSize} />
{:else if isActive({ textAlign: 'justify' })}
<Icon icon="pixelarticons:text-align-justify" width={iconSize} />
{:else}
<Icon icon="pixelarticons:text-align-left" width={iconSize} />
{/if}
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
<AlignLeft size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'align'}
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
<button
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
data-testid="format-align-left"
>
<Icon icon="pixelarticons:text-align-left" width={iconSize} />
<AlignLeft size={iconSize} />
<span>Left</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
class={dropdownItemClass(editor?.isActive({ textAlign: 'center' }) ?? false)}
data-testid="format-align-center"
>
<Icon icon="pixelarticons:text-align-center" width={iconSize} />
<AlignCenter size={iconSize} />
<span>Center</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
class={dropdownItemClass(editor?.isActive({ textAlign: 'right' }) ?? false)}
data-testid="format-align-right"
>
<Icon icon="pixelarticons:text-align-right" width={iconSize} />
<AlignRight size={iconSize} />
<span>Right</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
class={dropdownItemClass(editor?.isActive({ textAlign: 'justify' }) ?? false)}
data-testid="format-align-justify"
>
<Icon icon="pixelarticons:text-align-justify" width={iconSize} />
<AlignJustify size={iconSize} />
<span>Justify</span>
</button>
</div>
{/if}
</div>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<div class="mx-1 h-4 w-px bg-gray-300"></div>
<!-- Block-level formatting -->
<button
onclick={() => editor?.chain().focus().toggleCode().run()}
class={btnClass(isActive('code'))}
title="Inline code"
data-testid="format-code"
>
<Icon icon="pixelarticons:braces" width={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
class={btnClass(isActive('blockquote'))}
title="Blockquote"
data-testid="format-blockquote"
>
<Icon icon="pixelarticons:quote-text-inline" width={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
class={btnClass(isActive('codeBlock'))}
title="Code block"
data-testid="format-code-block"
>
<Icon icon="pixelarticons:braces" width={iconSize} />
</button>
<!-- Insert -->
<button
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
class={btnClass()}
title="Divider"
title="Horizontal rule"
data-testid="format-hr"
>
<Icon icon="pixelarticons:minus" width={iconSize} />
<Minus size={iconSize} />
</button>
</div>
-58
View File
@@ -1,58 +0,0 @@
<script lang="ts">
import Icon from '@iconify/svelte';
interface Props {
src: string;
alt: string;
onClose: () => void;
}
const { src, alt, onClose }: Props = $props();
let dialogEl: HTMLDivElement | undefined = $state();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
}
function handleBackgroundClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
$effect(() => {
dialogEl?.focus();
});
</script>
<div
bind:this={dialogEl}
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
onclick={handleBackgroundClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label="Image lightbox"
tabindex="-1"
data-testid="image-lightbox"
>
<button
onclick={onClose}
class="absolute right-4 top-4 rounded-sm p-2 text-white hover:bg-white/10"
aria-label="Close lightbox"
data-testid="lightbox-close"
>
<Icon icon="pixelarticons:close" class="h-6 w-6" />
</button>
<img
{src}
{alt}
class="max-h-[90vh] max-w-[90vw] object-contain rounded-sm animate-[pop-in_150ms_ease-out]"
data-testid="lightbox-image"
/>
</div>
+38 -145
View File
@@ -1,81 +1,27 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import ImageLightbox from './ImageLightbox.svelte';
import type { Attachment } from '$lib/types/index.js';
import { optimizeImage } from '$lib/utils/image-optimize.js';
import { browser } from '$app/environment';
import { addPendingAttachment, type PendingAttachment } from '$lib/sync/idb.js';
import { showToast } from '$lib/stores/toast.js';
interface Props {
noteId: string;
attachments: Attachment[];
onUpload: (attachment: Attachment) => void;
onRemove: (attachmentId: string) => void;
onToggleFeatured?: (attachmentId: string, featured: boolean) => void;
showDropZone?: boolean;
}
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured, showDropZone = true }: Props = $props();
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
let uploading = $state(false);
let optimizing = $state(false);
let dragOver = $state(false);
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
async function handleFiles(files: FileList | null) {
if (!files || !noteId) return;
uploading = true;
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
optimizing = true;
let optimized: Blob;
let thumbnail: Blob;
try {
const result = await optimizeImage(file);
optimized = result.optimized;
thumbnail = result.thumbnail;
} catch (err) {
console.error('Optimization failed, using original:', err);
optimized = file;
thumbnail = file;
}
optimizing = false;
// Offline: store in IDB and display via blob URL
if (browser && !navigator.onLine) {
const tempId = crypto.randomUUID();
const pending: PendingAttachment = {
id: tempId,
noteId,
optimized,
thumbnail,
filename: file.name,
mimeType: optimized.type || file.type,
timestamp: Date.now()
};
await addPendingAttachment(pending);
const blobUrl = URL.createObjectURL(optimized);
onUpload({
id: tempId,
noteId,
filename: file.name,
mimeType: optimized.type || file.type,
size: optimized.size,
path: blobUrl,
pending: true,
createdAt: new Date()
} as Attachment & { pending: boolean });
continue;
}
uploading = true;
const formData = new FormData();
formData.append('file', new File([optimized], file.name, { type: optimized.type || file.type }));
formData.append('thumbnail', new File([thumbnail], `${file.name}_thumb.webp`, { type: 'image/webp' }));
formData.append('file', file);
try {
const res = await fetch(`/api/notes/${noteId}/attachments`, {
@@ -85,16 +31,12 @@
if (res.ok) {
const attachment = await res.json();
onUpload(attachment);
} else {
console.error('Upload failed:', res.status, await res.text());
showToast('Failed to upload image', 'error');
}
} catch (err) {
console.error('Upload failed:', err);
showToast('Failed to upload image', 'error');
}
uploading = false;
}
uploading = false;
}
function handleDrop(e: DragEvent) {
@@ -114,42 +56,22 @@
{#if attachments.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each attachments as attachment}
{@const isPending = 'pending' in attachment && attachment.pending}
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
<div class="group relative">
<button
type="button"
class="h-20 w-20 cursor-pointer rounded-sm p-0 border-0 bg-transparent overflow-hidden"
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
<img
src="/api/notes/{noteId}/attachments/{attachment.id}"
alt={attachment.filename}
class="h-20 w-20 rounded object-cover"
data-testid="attachment-thumbnail"
>
<img
src={imgSrc}
alt={attachment.filename}
class="h-20 w-20 object-cover {isPending ? 'opacity-70' : ''}"
/>
</button>
{#if isPending}
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
<Icon icon="pixelarticons:reload" class="h-3 w-3 text-white" />
</div>
{:else if onToggleFeatured}
<button
onclick={() => onToggleFeatured(attachment.id, !attachment.featured)}
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 transition-opacity {attachment.featured ? 'opacity-100' : 'max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100'}"
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
data-testid="toggle-featured"
>
<Icon icon="pixelarticons:pin" class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
</button>
{/if}
/>
<button
onclick={() => onRemove(attachment.id)}
class="absolute -right-1 -top-1 rounded-full bg-[var(--destructive)] p-0.5 text-white transition-opacity max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100"
class="absolute -right-1 -top-1 hidden rounded-full bg-red-500 p-0.5 text-white group-hover:block"
aria-label="Remove attachment"
data-testid="remove-attachment"
>
<Icon icon="pixelarticons:close" class="h-3 w-3" />
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
@@ -157,58 +79,29 @@
{/if}
<!-- Upload area -->
{#if showDropZone}
<!-- Drop zone for desktop, browse-only label for mobile -->
<div
class="hidden rounded-sm border-2 border-dashed p-4 text-center transition-colors md:block {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={() => (dragOver = false)}
role="button"
tabindex="0"
data-testid="upload-dropzone"
>
{#if optimizing}
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
{:else if uploading}
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
{:else}
<label class="cursor-pointer">
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
<input
type="file"
accept="image/*"
multiple
class="hidden"
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
data-testid="file-input"
/>
</label>
{/if}
</div>
<!-- Mobile: compact browse button -->
<div class="md:hidden">
{#if optimizing}
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
{:else if uploading}
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
{:else}
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-sm text-[var(--text-muted)] hover:border-[var(--primary)]">
<span>Add images</span>
<input
type="file"
accept="image/*"
multiple
class="hidden"
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
data-testid="file-input-mobile"
/>
</label>
{/if}
</div>
{/if}
<div
class="rounded-lg border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-amber-500 bg-amber-50' : 'border-gray-300'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={() => (dragOver = false)}
role="button"
tabindex="0"
data-testid="upload-dropzone"
>
{#if uploading}
<p class="text-sm text-gray-500">Uploading...</p>
{:else}
<label class="cursor-pointer">
<span class="text-sm text-gray-500">Drop images here or <span class="text-amber-600 underline">browse</span></span>
<input
type="file"
accept="image/*"
multiple
class="hidden"
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
data-testid="file-input"
/>
</label>
{/if}
</div>
</div>
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
{/if}
+20 -37
View File
@@ -1,52 +1,35 @@
<script lang="ts">
import SearchBar from '../SearchBar.svelte';
import SyncIndicator from '../SyncIndicator.svelte';
import Icon from '@iconify/svelte';
interface Props {
onMenuToggle: () => void;
}
let { onMenuToggle }: Props = $props();
let mobileSearchOpen = $state(false);
</script>
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-[var(--border)] bg-[var(--bg-surface)] px-4">
{#if mobileSearchOpen}
<!-- Mobile expanded search -->
<div class="flex flex-1 items-center gap-2 lg:hidden">
<SearchBar onClose={() => (mobileSearchOpen = false)} />
</div>
{:else}
<button
onclick={onMenuToggle}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
aria-label="Toggle sidebar"
>
<Icon icon="pixelarticons:menu" class="h-6 w-6 text-[var(--text)]" />
</button>
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-gray-200 bg-white px-4 shadow-sm">
<button
onclick={onMenuToggle}
class="rounded-full p-2 hover:bg-gray-100"
aria-label="Toggle sidebar"
>
<svg class="h-6 w-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="flex items-center gap-2">
<img src="/favicon.svg" alt="" class="h-8 w-8" />
<h1 class="font-['Press_Start_2P'] text-lg text-[var(--primary)]">Crumbs</h1>
</div>
<div class="flex items-center gap-2">
<span class="text-2xl">🥨</span>
<h1 class="text-xl font-semibold text-gray-800">Crumbs</h1>
</div>
<!-- Desktop search bar -->
<div class="mx-4 hidden flex-1 lg:block">
<SearchBar />
</div>
<div class="mx-4 flex-1">
<SearchBar />
</div>
<!-- Mobile search icon -->
<button
onclick={() => (mobileSearchOpen = true)}
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
aria-label="Search"
>
<Icon icon="pixelarticons:search" class="h-5 w-5 text-[var(--text)]" />
</button>
<div class="flex items-center gap-2">
<SyncIndicator />
</div>
{/if}
<div class="flex items-center gap-2">
<SyncIndicator />
</div>
</header>
+46 -55
View File
@@ -1,85 +1,76 @@
<script lang="ts">
import { page } from '$app/stores';
import { allTags } from '$lib/stores/notes.js';
import Icon from '@iconify/svelte';
import { currentFilter, loadNotes, allTags, selectedTag } from '$lib/stores/notes.js';
import { StickyNote, Archive, Trash2, Tag } from 'lucide-svelte';
import type { NoteFilter } from '$lib/types/index.js';
interface Props {
open: boolean;
onClose?: () => void;
}
let { open, onClose }: Props = $props();
let { open }: Props = $props();
function closeMobile() {
if (window.matchMedia('(max-width: 1023px)').matches) {
onClose?.();
}
function setFilter(filter: NoteFilter) {
selectedTag.set(null);
loadNotes(filter);
}
const navItems = [
{ href: '/', label: 'Crumbs', icon: 'pixelarticons:sticky-note', match: (p: string) => p === '/' },
{ href: '/archive', label: 'Archive', icon: 'pixelarticons:archive', match: (p: string) => p === '/archive' },
{ href: '/trash', label: 'Trash', icon: 'pixelarticons:trash', match: (p: string) => p === '/trash' }
];
function selectTag(tag: string | null) {
selectedTag.set(tag);
loadNotes('all');
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 top-16 z-10 bg-black/30 lg:hidden"
onclick={onClose}
onkeydown={(e) => { if (e.key === 'Escape') onClose?.(); }}
></div>
{/if}
<aside
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-[var(--border)] bg-[var(--bg-surface)] transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'}"
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-gray-200 bg-white transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0"
>
<nav class="p-2">
<ul class="space-y-1">
{#each navItems as item}
<li>
<a
href={item.href}
onclick={closeMobile}
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {item.match($page.url.pathname) ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Icon icon={item.icon} width={20} />
{item.label}
</a>
</li>
{/each}
<li>
<button
onclick={() => setFilter('all')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
>
<StickyNote size={20} />
Crumbs
</button>
</li>
<li>
<button
onclick={() => setFilter('archived')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
>
<Archive size={20} />
Archive
</button>
</li>
<li>
<button
onclick={() => setFilter('trashed')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
>
<Trash2 size={20} />
Trash
</button>
</li>
</ul>
{#if $allTags.length > 0}
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">Tags</h3>
<div class="mt-6 border-t border-gray-200 pt-4">
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-gray-500">Tags</h3>
<ul class="mt-2 space-y-1">
{#each $allTags as tag}
<li>
<a
href="/tag/{tag}"
onclick={closeMobile}
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$page.url.pathname === `/tag/${tag}` ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
<button
onclick={() => selectTag($selectedTag === tag ? null : tag)}
class="flex w-full items-center gap-3 rounded-full px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
>
<Icon icon="pixelarticons:label" width={16} />
<Tag size={16} />
#{tag}
</a>
</button>
</li>
{/each}
</ul>
</div>
{/if}
</nav>
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)]">
<a
href="/settings"
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$page.url.pathname.startsWith('/settings') ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
onclick={closeMobile}
data-testid="settings-link"
>
<Icon icon="pixelarticons:settings-cog" width={20} />
Settings
</a>
</div>
</aside>
+29 -2
View File
@@ -1,11 +1,38 @@
<script lang="ts">
import { toasts, dismiss } from '$lib/stores/toast.js';
import { writable } from 'svelte/store';
interface ToastMessage {
id: number;
text: string;
type: 'info' | 'success' | 'error';
action?: { label: string; handler: () => void };
}
let counter = 0;
export const toasts = writable<ToastMessage[]>([]);
export function showToast(
text: string,
type: 'info' | 'success' | 'error' = 'info',
action?: { label: string; handler: () => void },
duration = 4000
) {
const id = ++counter;
toasts.update((t) => [...t, { id, text, type, action }]);
if (!action) {
setTimeout(() => dismiss(id), duration);
}
}
export function dismiss(id: number) {
toasts.update((t) => t.filter((toast) => toast.id !== id));
}
</script>
<div class="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
{#each $toasts as toast (toast.id)}
<div
class="flex items-center gap-3 rounded-sm border px-4 py-3 shadow-[var(--card-shadow)] {toast.type === 'error' ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : toast.type === 'success' ? 'border-[var(--border-subtle)] bg-[var(--success-bg)] text-[var(--success-text)]' : 'border-[var(--border)] bg-[var(--text)] text-[var(--bg-base)]'}"
class="flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg {toast.type === 'error' ? 'bg-red-600 text-white' : toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-gray-800 text-white'}"
>
<span class="text-sm">{toast.text}</span>
{#if toast.action}
+86 -119
View File
@@ -1,11 +1,8 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { toggleFavorite, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
import ImageLightbox from './ImageLightbox.svelte';
import CollaboratorPopover from './CollaboratorPopover.svelte';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
interface ChecklistItem {
text: string;
@@ -15,10 +12,9 @@
interface Props {
note: Note;
onEdit: (note: Note) => void;
fullHeight?: boolean;
}
let { note, onEdit, fullHeight = false }: Props = $props();
let { note, onEdit }: Props = $props();
$effect(() => {
const colors = NOTE_COLORS[note.color];
@@ -26,8 +22,6 @@
});
let cardStyle = $state('');
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
const renderedContent = $derived(renderMarkdown(note.content));
@@ -40,24 +34,16 @@
: []
);
const activeChecklistItems = $derived(checklistItems.filter(i => !i.checked));
const doneChecklistItems = $derived(checklistItems.filter(i => i.checked));
const sortedChecklistItems = $derived([...activeChecklistItems, ...doneChecklistItems]);
const featuredAttachments = $derived((note.attachments ?? []).filter(a => a.featured));
function stop(fn: () => void) {
return (e: Event) => {
e.stopPropagation();
fn();
};
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<article
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 outline-none transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
class="group relative cursor-pointer rounded-lg border border-gray-200 p-4 shadow-sm transition-shadow hover:shadow-md"
style={cardStyle}
onclick={() => onEdit(note)}
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
@@ -66,155 +52,136 @@
data-testid="note-card"
data-note-id={note.id}
>
<!-- Thumbnail strip (featured images only) -->
{#if featuredAttachments.length > 0}
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
{#each featuredAttachments.slice(0, 3) as attachment}
<div class="relative min-w-0 flex-1">
<button
type="button"
class="h-24 w-full cursor-pointer p-0 border-0 bg-transparent"
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
data-testid="card-thumbnail"
>
<img
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
alt={attachment.filename}
class="h-24 w-full object-cover"
loading="lazy"
/>
</button>
</div>
{/each}
{#if featuredAttachments.length > 3}
<div class="absolute right-1 top-1 rounded-sm bg-black/60 px-1.5 py-0.5 text-xs font-medium text-white" data-testid="card-thumbnail-count">
+{featuredAttachments.length - 3}
</div>
{/if}
</div>
{/if}
<!-- Status indicators (top-right) -->
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
{#if note.isShared && note.collaborators}
<CollaboratorPopover
collaborators={note.collaborators}
ownerName={note.isOwner ? 'You' : undefined}
/>
{/if}
{#if note.pinned}
<button
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unfavorite"
data-testid="favorite-indicator"
>
<Icon icon="pixelarticons:heart" class="h-4 w-4 fill-[var(--primary)]" />
</button>
{/if}
</div>
{#if note.title}
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
<h3 class="mb-2 text-sm font-semibold text-gray-900">{note.title}</h3>
{/if}
{#if note.checklistMode && checklistItems.length > 0}
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
{#each sortedChecklistItems.slice(0, 8) as item}
<li class="flex items-start gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
<input
type="checkbox"
checked={item.checked}
disabled
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
data-testid="card-checklist-checkbox"
/>
<span class="break-words min-w-0">{item.text}</span>
<ul class="space-y-1" data-testid="note-checklist-preview">
{#each checklistItems.slice(0, 8) as item}
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-gray-400 line-through' : 'text-gray-700'}">
<input type="checkbox" checked={item.checked} disabled class="h-3.5 w-3.5 rounded border-gray-300 text-amber-600" />
<span class="truncate">{item.text}</span>
</li>
{/each}
{#if checklistItems.length > 8}
<li class="text-xs text-[var(--text-muted)]">+{checklistItems.length - 8} more</li>
<li class="text-xs text-gray-400">+{checklistItems.length - 8} more</li>
{/if}
</ul>
{:else if note.content}
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-gray-700" data-testid="note-content-preview">
{@html renderedContent}
</div>
{/if}
{#if note.tags && note.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each note.tags as tag}
<span class="rounded-full bg-gray-200/60 px-2 py-0.5 text-xs text-gray-600">
#{tag}
</span>
{/each}
</div>
{/if}
<!-- Action buttons - show on hover -->
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
<div class="absolute bottom-1 right-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{#if $currentFilter === 'trashed'}
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-full p-1.5 hover:bg-black/10"
title="Restore"
data-testid="restore-btn"
>
<Icon icon="pixelarticons:undo" class="h-4 w-4" />
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-full p-1.5 hover:bg-black/10"
title="Delete forever"
data-testid="delete-forever-btn"
>
<Icon icon="pixelarticons:trash" class="h-4 w-4 text-[var(--destructive)]" />
<svg class="h-4 w-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{:else}
{#if !note.pinned}
<button
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Favorite"
data-testid="favorite-btn"
>
<Icon icon="pixelarticons:heart" class="h-4 w-4" />
</button>
{/if}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-full p-1.5 hover:bg-black/10"
title={note.pinned ? 'Unpin' : 'Pin'}
data-testid="pin-btn"
>
<svg class="h-4 w-4 {note.pinned ? 'text-amber-600' : ''}" fill={note.pinned ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
{#if $currentFilter === 'archived'}
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-full p-1.5 hover:bg-black/10"
title="Unarchive"
data-testid="unarchive-btn"
>
<Icon icon="pixelarticons:archive" class="h-4 w-4" />
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</button>
{:else}
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
class="rounded-full p-1.5 hover:bg-black/10"
title="Archive"
data-testid="archive-btn"
>
<Icon icon="pixelarticons:archive" class="h-4 w-4" />
</button>
{/if}
{#if note.isShared && !note.isOwner}
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Leave note"
data-testid="leave-btn"
>
<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />
</button>
{:else}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
data-testid="trash-btn"
>
<Icon icon="pixelarticons:trash" class="h-4 w-4" />
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</button>
{/if}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10"
title="Delete"
data-testid="trash-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{/if}
</div>
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
<!-- Pin indicator -->
{#if note.pinned}
<div class="absolute right-2 top-2">
<svg class="h-4 w-4 text-amber-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</div>
{/if}
</article>
<style>
article :global(.prose li:has(.task-checkbox)) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.25rem;
}
article :global(.prose li:has(.task-checkbox))::before {
display: none;
}
article :global(.prose li:has(.task-checkbox))::marker {
content: none;
}
article :global(.task-checkbox) {
margin-top: 0.15rem;
flex-shrink: 0;
}
</style>
+52 -319
View File
@@ -1,19 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import ColorPicker from './ColorPicker.svelte';
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
import TiptapEditor from './TiptapEditor.svelte';
import ImageUpload from './ImageUpload.svelte';
import ShareDialog from './ShareDialog.svelte';
import NoteHistory from './NoteHistory.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { notes } from '$lib/stores/notes.js';
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
import type { Note, NoteColor } from '$lib/types/index.js';
interface Props {
note: Note | null;
@@ -22,213 +15,44 @@
}
const { note, isNew = false, onClose }: Props = $props();
const prefs = getPreferences();
// svelte-ignore state_referenced_locally
let title = $state(note?.title ?? '');
// svelte-ignore state_referenced_locally
let content = $state(note?.content ?? '');
// svelte-ignore state_referenced_locally
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
let color = $state<NoteColor>(note?.color ?? 'default');
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? false);
let showColorPicker = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
let rawMarkdownMode = $state(false);
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
// Mutable note identity — allows transitioning from new → saved without closing
// svelte-ignore state_referenced_locally
let noteId = $state<string | null>(note?.id ?? null);
// svelte-ignore state_referenced_locally
let currentlyNew = $state(isNew);
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
// Guard against mobile ghost clicks: on touch devices, a tap on the note card
// can produce a synthetic click that lands on editor buttons rendered at the same
// coordinates. Suppress pointer events on toolbar controls until mount completes.
let toolbarInteractive = $state(false);
onMount(() => {
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
return () => clearTimeout(timer);
});
let showShareDialog = $state(false);
let showHistory = $state(false);
// svelte-ignore state_referenced_locally
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
const isOwner = $derived(note?.isOwner !== false);
const isShared = $derived(collaboratorsList.length > 0);
// Use displayName from first user or 'You' for owner
const ownerName = $derived(isOwner ? 'You' : 'Owner');
function handleCollaboratorsUpdate(updated: Collaborator[]) {
collaboratorsList = updated;
// Update the note in the store
if (noteId) {
notes.update((list) =>
list.map((n) =>
n.id === noteId
? { ...n, collaborators: updated, isShared: updated.length > 0 }
: n
)
);
}
}
async function toggleShareDialog() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showShareDialog = !showShareDialog;
}
async function toggleHistory() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showHistory = !showHistory;
}
async function handleHistoryRestored() {
if (!noteId) return;
// Reload the note content from the server after a restore
try {
const res = await fetch(`/api/notes/${noteId}`);
if (res.ok) {
const updated = await res.json();
title = updated.title ?? '';
content = updated.content ?? '';
color = updated.color ?? 'default';
checklistMode = updated.checklistMode ?? false;
// Bump editor tick to force TiptapEditor to re-render
editorTick++;
// Update the store too
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, ...updated } : n))
);
}
} catch {
// failed silently
}
}
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
$effect(() => {
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
fetch(`/api/notes/${noteId}/attachments`)
.then((res) => res.ok ? res.json() : [])
.then((data: Attachment[]) => {
if (data.length > 0) attachmentsList = data;
})
.catch(() => {});
}
});
function handleAttachmentUpload(attachment: Attachment) {
attachmentsList = [...attachmentsList, attachment];
}
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
if (!noteId) return;
try {
const res = await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ featured })
});
if (res.ok) {
attachmentsList = attachmentsList.map((a) =>
a.id === attachmentId ? { ...a, featured } : a
);
}
} catch (err) {
console.error('Failed to toggle featured:', err);
}
}
async function handleAttachmentRemove(attachmentId: string) {
if (!noteId) return;
try {
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'DELETE'
});
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
} catch (err) {
console.error('Failed to remove attachment:', err);
}
}
let bgStyle = $state('');
$effect(() => {
const colors = NOTE_COLORS[color];
bgStyle = `background-color: ${colors.bg}`;
});
// Use pointer events to detect backdrop clicks. Unlike mouse events,
// pointer events are never synthetically re-dispatched from touch, so
// the tap that opened the editor on mobile cannot ghost-click the overlay.
// Both pointerdown and pointerup must target the overlay itself to close.
let pointerdownOnOverlay = false;
function handleOverlayPointerdown(e: PointerEvent) {
pointerdownOnOverlay = e.target === e.currentTarget;
}
function handleOverlayPointerup(e: PointerEvent) {
if (pointerdownOnOverlay && e.target === e.currentTarget) saveAndClose();
pointerdownOnOverlay = false;
}
async function saveAndClose() {
async function save() {
if (!title.trim() && !content.trim()) {
onClose();
return;
}
if (currentlyNew) {
if (isNew) {
await createNote({ title, content, color, checklistMode });
} else if (noteId) {
await updateNote(noteId, { title, content, color, checklistMode });
} else if (note) {
await updateNote(note.id, { title, content, color, checklistMode });
}
onClose();
}
/** Auto-save a new note without closing, returns the new note ID */
async function autoSave(): Promise<string | null> {
if (!currentlyNew) return noteId;
const created = await createNote({
title: title || 'Untitled',
content,
color,
checklistMode
});
if (created) {
noteId = created.id;
currentlyNew = false;
return created.id;
}
return null;
}
async function toggleImageUpload() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showImageUpload = !showImageUpload;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
saveAndClose();
save();
}
}
@@ -247,16 +71,16 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
onpointerdown={handleOverlayPointerdown}
onpointerup={handleOverlayPointerup}
class="fixed inset-0 z-40 flex items-start justify-center bg-black/50 pt-20"
onclick={save}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
class="mx-4 w-full max-w-xl rounded-lg border border-gray-200 shadow-xl"
style={bgStyle}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
data-testid="note-editor"
>
@@ -265,82 +89,56 @@
type="text"
placeholder="Title"
bind:value={title}
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-gray-900 outline-none placeholder:text-gray-500"
data-testid="note-title-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (textareaEl) textareaEl.focus();
else if (tiptapEditor) tiptapEditor.commands.focus('start');
}
}}
/>
<!-- Content -->
<div class="max-h-[60vh] overflow-y-auto">
{#if checklistMode}
<div class="px-4 py-2">
<Checklist {content} onChange={(c) => (content = c)} />
</div>
{:else if rawMarkdownMode}
<textarea
bind:this={textareaEl}
placeholder="Add a crumb..."
bind:value={content}
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
rows="12"
data-testid="note-content-input"
></textarea>
{:else}
<TiptapEditor
{content}
onUpdate={(md) => (content = md)}
onEditor={(e) => (tiptapEditor = e)}
onTransaction={() => editorTick++}
placeholder="Add a crumb..."
/>
{/if}
</div>
<!-- Image attachments -->
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
<ImageUpload
noteId={noteId}
attachments={attachmentsList}
onUpload={handleAttachmentUpload}
onRemove={handleAttachmentRemove}
onToggleFeatured={handleToggleFeatured}
showDropZone={showImageUpload}
/>
{#if checklistMode}
<div class="px-4 py-2">
<Checklist {content} onChange={(c) => (content = c)} />
</div>
{:else if rawMarkdownMode}
<textarea
bind:this={textareaEl}
placeholder="Add a crumb..."
bind:value={content}
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-gray-800 outline-none placeholder:text-gray-500"
rows="12"
data-testid="note-content-input"
></textarea>
{:else}
<TiptapEditor
{content}
onUpdate={(md) => (content = md)}
onEditor={(e) => (tiptapEditor = e)}
onTransaction={() => editorTick++}
placeholder="Add a crumb..."
/>
{/if}
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
</div>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
{/if}
<!-- Toolbar -->
<div
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
style={toolbarInteractive ? '' : 'pointer-events: none'}
>
<div class="flex items-center justify-between border-t border-gray-200/50 px-2 py-2">
<div class="flex items-center gap-1">
<!-- Color picker toggle -->
<div class="relative">
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-full p-2 hover:bg-black/10"
title="Background color"
data-testid="color-picker-toggle"
>
<Icon icon="pixelarticons:colors-swatch" class="h-5 w-5 text-[var(--text-muted)]" />
<svg class="h-5 w-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</button>
{#if showColorPicker}
<div class="absolute left-0 bottom-full z-10 mb-2 w-[calc(100vw-4rem)] max-w-xs rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
<div class="absolute left-0 top-full mt-2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg">
<ColorPicker selected={color} onSelect={handleColorSelect} />
</div>
{/if}
@@ -349,78 +147,31 @@
<!-- Checklist mode toggle -->
<button
onclick={() => (checklistMode = !checklistMode)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title={checklistMode ? 'Switch to text' : 'Checklist mode'}
class="rounded-full p-2 hover:bg-black/10 {checklistMode ? 'text-amber-600' : ''}"
title="Checklist mode"
data-testid="checklist-toggle"
>
{#if checklistMode}
<Icon icon="pixelarticons:art-text" class="h-5 w-5" />
{:else}
<Icon icon="pixelarticons:checkbox-on" class="h-5 w-5" />
{/if}
</button>
<!-- Image attachment toggle -->
<button
onclick={toggleImageUpload}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Image attachments"
data-testid="image-toggle"
>
<Icon icon="pixelarticons:image" class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
<!-- Raw markdown mode toggle -->
<button
onclick={toggleMarkdownMode}
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
disabled={checklistMode}
class="rounded-full p-2 hover:bg-black/10 {rawMarkdownMode ? 'text-amber-600' : ''}"
title="Markdown mode"
data-testid="markdown-toggle"
>
{#if rawMarkdownMode}
<Icon icon="pixelarticons:file-text" class="h-5 w-5" />
{:else}
<Icon icon="pixelarticons:script" class="h-5 w-5" />
{/if}
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</button>
<!-- Share button (owner only for new shares, all for viewing) -->
{#if isOwner}
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Share note"
data-testid="share-toggle"
>
{#if isShared}
<Icon icon="pixelarticons:users" class="h-5 w-5 text-[var(--primary)]" />
{:else}
<Icon icon="pixelarticons:user-plus" class="h-5 w-5" />
{/if}
</button>
{:else if isShared}
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
<Icon icon="pixelarticons:users" class="h-5 w-5" />
</span>
{/if}
<!-- History button (only for saved notes) -->
{#if !currentlyNew}
<button
onclick={toggleHistory}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Version history"
data-testid="history-toggle"
>
<Icon icon="pixelarticons:timeline" class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
</button>
{/if}
</div>
<button
onclick={saveAndClose}
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
onclick={save}
class="rounded px-4 py-1 text-sm font-medium text-gray-700 hover:bg-black/10"
data-testid="close-editor-btn"
>
Close
@@ -428,21 +179,3 @@
</div>
</div>
</div>
{#if showShareDialog && noteId}
<ShareDialog
noteId={noteId}
collaborators={collaboratorsList}
{ownerName}
onClose={() => (showShareDialog = false)}
onUpdate={handleCollaboratorsUpdate}
/>
{/if}
{#if showHistory && noteId}
<NoteHistory
noteId={noteId}
onClose={() => (showHistory = false)}
onRestored={handleHistoryRestored}
/>
{/if}
+8 -48
View File
@@ -1,63 +1,23 @@
<script lang="ts">
import NoteCard from './NoteCard.svelte';
import { dndzone, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import type { Note } from '$lib/types/index.js';
interface Props {
notes: Note[];
label?: string;
onEdit: (note: Note) => void;
draggable?: boolean;
dndType?: string;
onReorder?: (noteIds: string[]) => void;
}
let { notes, label = '', onEdit, draggable = false, dndType = 'notes', onReorder }: Props = $props();
let localItems = $state<Note[]>([]);
$effect(() => {
localItems = [...notes];
});
const flipDurationMs = 150;
function handleConsider(e: CustomEvent<DndEvent<Note>>) {
localItems = e.detail.items;
}
function handleFinalize(e: CustomEvent<DndEvent<Note>>) {
localItems = e.detail.items;
onReorder?.(localItems.map((n) => n.id));
}
let displayItems = $derived(draggable ? localItems : notes);
let { notes, label = '', onEdit }: Props = $props();
</script>
{#if displayItems.length > 0}
{#if notes.length > 0}
{#if label}
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">{label}</p>
{/if}
{#if draggable}
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
data-testid="note-grid"
use:dndzone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {}, delayTouchStart: 400 }}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each localItems as note (note.id)}
<div class="h-full outline-none" animate:flip={{ duration: flipDurationMs }}>
<NoteCard {note} {onEdit} fullHeight />
</div>
{/each}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
{#each displayItems as note (note.id)}
<NoteCard {note} {onEdit} />
{/each}
</div>
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-gray-500">{label}</p>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
{#each notes as note (note.id)}
<NoteCard {note} {onEdit} />
{/each}
</div>
{/if}
-222
View File
@@ -1,222 +0,0 @@
<script lang="ts">
import type { NoteVersionSummary, NoteVersion } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
import { showToast } from '$lib/stores/toast.js';
interface Props {
noteId: string;
onClose: () => void;
onRestored: () => void;
}
const { noteId, onClose, onRestored }: Props = $props();
let versions = $state<NoteVersionSummary[]>([]);
let selectedVersion = $state<NoteVersion | null>(null);
let isLoading = $state(true);
let isLoadingVersion = $state(false);
let isRestoring = $state(false);
let showConfirm = $state(false);
$effect(() => {
// Track noteId explicitly so this only re-runs when the note changes
noteId;
void loadVersions();
});
async function loadVersions() {
isLoading = true;
try {
const res = await fetch(`/api/notes/${noteId}/versions`);
if (res.ok) {
versions = await res.json();
}
} catch {
// failed silently
} finally {
isLoading = false;
}
}
async function selectVersion(versionId: string) {
isLoadingVersion = true;
selectedVersion = null;
try {
const res = await fetch(`/api/notes/${noteId}/versions/${versionId}`);
if (res.ok) {
selectedVersion = await res.json();
}
} catch {
// failed silently
} finally {
isLoadingVersion = false;
}
}
async function restoreVersion() {
if (!selectedVersion) return;
isRestoring = true;
try {
const res = await fetch(
`/api/notes/${noteId}/versions/${selectedVersion.id}/restore`,
{ method: 'POST' }
);
if (res.ok) {
showToast('Version restored successfully', 'success');
onRestored();
onClose();
} else {
showToast('Failed to restore version', 'error');
}
} catch {
showToast('Failed to restore version', 'error');
} finally {
isRestoring = false;
showConfirm = false;
}
}
function formatDate(date: Date | string): string {
const d = new Date(date);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10"
onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
data-testid="history-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 flex w-full max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
data-testid="history-panel"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
<div class="flex items-center gap-2">
<Icon icon="pixelarticons:clock" class="h-4 w-4 text-[var(--text-muted)]" />
<h2 class="text-sm font-semibold text-[var(--text)]">Version History</h2>
</div>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
title="Close history"
data-testid="close-history-btn"
>
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
<div class="flex min-h-0 max-h-[60vh] flex-1">
<!-- Version list -->
<div class="w-56 flex-none border-r border-[var(--border-subtle)] overflow-y-auto">
{#if isLoading}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
Loading…
</div>
{:else if versions.length === 0}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
No history yet
</div>
{:else}
<ul class="divide-y divide-[var(--border-subtle)]">
{#each versions as v (v.id)}
<li>
<button
class="w-full px-3 py-3 text-left hover:bg-[var(--border)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
onclick={() => selectVersion(v.id)}
data-testid="version-item"
>
<div class="flex items-center justify-between gap-1">
<span class="text-xs font-semibold text-[var(--primary)]">v{v.version}</span>
</div>
<div class="mt-0.5 text-xs text-[var(--text-muted)]">{formatDate(v.createdAt)}</div>
{#if v.title}
<div class="mt-1 truncate text-xs font-medium text-[var(--text)]">{v.title}</div>
{/if}
{#if v.contentPreview}
<div class="mt-0.5 line-clamp-2 text-xs text-[var(--text-muted)]">{v.contentPreview}</div>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Version preview -->
<div class="flex min-w-0 flex-1 flex-col">
{#if isLoadingVersion}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
Loading…
</div>
{:else if selectedVersion}
<div class="flex flex-1 flex-col overflow-y-auto">
<div class="flex-1 overflow-y-auto px-4 py-3">
{#if selectedVersion.title}
<h3 class="mb-2 text-base font-semibold text-[var(--text)]">{selectedVersion.title}</h3>
{/if}
<pre class="whitespace-pre-wrap text-sm text-[var(--text)] font-[inherit]">{selectedVersion.content || '(empty)'}</pre>
</div>
<!-- Restore footer -->
<div class="border-t border-[var(--border-subtle)] px-4 py-3">
{#if showConfirm}
<div class="flex flex-col gap-2">
<p class="text-xs text-[var(--text-muted)]">
This will replace the current note. The current version will be saved as a snapshot.
</p>
<div class="flex gap-2">
<button
onclick={restoreVersion}
disabled={isRestoring}
class="flex items-center gap-1.5 rounded-sm border border-[var(--destructive)] bg-[var(--destructive)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50"
data-testid="confirm-restore-btn"
>
<Icon icon="pixelarticons:undo" class="h-3.5 w-3.5" />
{isRestoring ? 'Restoring…' : 'Confirm Restore'}
</button>
<button
onclick={() => (showConfirm = false)}
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
>
Cancel
</button>
</div>
</div>
{:else}
<button
onclick={() => (showConfirm = true)}
class="flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)] transition-colors"
data-testid="restore-btn"
>
<Icon icon="pixelarticons:undo" class="h-3.5 w-3.5" />
Restore this version
</button>
{/if}
</div>
</div>
{:else}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
Select a version to preview
</div>
{/if}
</div>
</div>
</div>
</div>
-127
View File
@@ -1,127 +0,0 @@
<script lang="ts">
import NoteGrid from '$lib/components/NoteGrid.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import TagFilter from '$lib/components/TagFilter.svelte';
import { favoriteNotes, unfavoritedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
import { onMount } from 'svelte';
import type { Note, NoteFilter } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
interface Props {
filter: NoteFilter;
tag?: string | null;
}
let { filter, tag = null }: Props = $props();
let editingNote: Note | null = $state(null);
let showNewNote = $state(false);
// Sync store with route props and reload notes when filter changes
$effect(() => {
currentFilter.set(filter);
selectedTag.set(tag);
loadNotes(filter);
});
function openEditor(note: Note) {
editingNote = note;
history.replaceState(null, '', `#${note.id}`);
}
function closeEditor() {
editingNote = null;
showNewNote = false;
history.replaceState(null, '', location.pathname);
}
function handleReorder(noteIds: string[]) {
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
updateSortOrders(orders);
}
onMount(() => {
const hash = location.hash.slice(1);
if (!hash) return;
const unsubscribe = notes.subscribe(($notes) => {
if ($notes.length === 0) return;
const note = $notes.find((n) => n.id === hash);
if (note) editingNote = note;
unsubscribe();
});
});
</script>
{#if filter === 'all' && !tag}
<div class="mx-auto mb-6 hidden max-w-xl md:block">
<button
onclick={() => (showNewNote = true)}
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-testid="new-note-btn"
>
<Icon icon="pixelarticons:plus" class="h-4 w-4" />
Add a crumb...
</button>
</div>
<button
onclick={() => (showNewNote = true)}
class="fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-sm border border-[var(--border)] bg-[var(--primary)] text-white shadow-[var(--card-shadow)] transition-colors hover:bg-[var(--primary-hover)] md:hidden"
aria-label="Add a crumb"
data-testid="new-note-fab"
>
<Icon icon="pixelarticons:plus" class="h-5 w-5" />
</button>
{/if}
<div class="mb-4">
<TagFilter />
</div>
{#if filter === 'archived'}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
{:else if filter === 'trashed'}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
{:else if tag}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">#{tag}</h2>
{/if}
{#if $favoriteNotes.length > 0}
<div class="mb-6">
<NoteGrid notes={$favoriteNotes} label="Favorites" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
</div>
{/if}
<NoteGrid
notes={$unfavoritedNotes}
label={$favoriteNotes.length > 0 ? 'Others' : ''}
onEdit={openEditor}
draggable
dndType="unpinned-notes"
onReorder={handleReorder}
/>
{#if $notesLoaded && $favoriteNotes.length === 0 && $unfavoritedNotes.length === 0}
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
<p class="font-['Press_Start_2P'] text-xs">
{#if filter === 'trashed'}
No crumbs in trash
{:else if filter === 'archived'}
No archived crumbs
{:else if tag}
No crumbs with tag #{tag}
{:else}
No crumbs yet
{/if}
</p>
</div>
{/if}
{#if showNewNote}
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
{/if}
{#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} />
{/if}
@@ -1,46 +0,0 @@
<script lang="ts">
import type { ZXCVBNResult } from 'zxcvbn';
let { password = '' }: { password: string } = $props();
let result = $state<ZXCVBNResult | null>(null);
$effect(() => {
if (password.length > 0) {
import('zxcvbn').then((mod) => {
result = mod.default(password);
});
} else {
result = null;
}
});
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];
const colors = [
'bg-[var(--destructive)]',
'bg-[var(--destructive)]',
'bg-[var(--primary)]',
'bg-[var(--success-text)]',
'bg-[var(--success-text)]'
];
</script>
{#if result}
<div class="mt-1 mb-2" data-testid="password-strength">
<div class="flex gap-1">
{#each { length: 4 } as _, i}
<div
class="h-1 flex-1 rounded-sm transition-colors {i <= result.score
? colors[result.score]
: 'bg-[var(--border-subtle)]'}"
></div>
{/each}
</div>
<p class="mt-1 text-xs text-[var(--text-muted)]">
{labels[result.score]}
{#if result.feedback.warning}
— {result.feedback.warning}
{/if}
</p>
</div>
{/if}
+10 -30
View File
@@ -1,22 +1,10 @@
<script lang="ts">
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
interface Props {
onClose?: () => void;
}
let { onClose }: Props = $props();
let query = $state('');
let originalNotes: Note[] = [];
let isSearching = $state(false);
let inputEl: HTMLInputElement | undefined = $state();
$effect(() => {
if (onClose) inputEl?.focus();
});
async function handleSearch() {
if (!query.trim()) {
@@ -46,34 +34,26 @@
isSearching = false;
}
}
function close() {
clearSearch();
onClose?.();
}
</script>
<div class="relative flex-1 max-w-2xl">
<div class="flex items-center rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 focus-within:border-[var(--primary)]">
{#if onClose}
<button onclick={close} class="mr-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Back">
<Icon icon="pixelarticons:arrow-left" class="h-5 w-5" />
</button>
{:else}
<Icon icon="pixelarticons:search" class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
{/if}
<div class="relative max-w-2xl">
<div class="flex items-center rounded-lg bg-gray-100 px-4 py-2">
<svg class="mr-3 h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
bind:this={inputEl}
type="text"
placeholder="Search crumbs..."
bind:value={query}
oninput={handleSearch}
class="w-full bg-transparent text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
class="w-full bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-500"
data-testid="search-input"
/>
{#if query}
<button onclick={clearSearch} class="ml-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Clear search">
<Icon icon="pixelarticons:close" class="h-5 w-5" />
<button onclick={clearSearch} class="ml-2 text-gray-500 hover:text-gray-700" aria-label="Clear search">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
-189
View File
@@ -1,189 +0,0 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
import { showToast } from '$lib/stores/toast.js';
interface UserResult {
id: number;
displayName: string;
email: string;
}
interface Props {
noteId: string;
collaborators: Collaborator[];
ownerName: string;
onClose: () => void;
onUpdate: (collaborators: Collaborator[]) => void;
}
const { noteId, collaborators, ownerName, onClose, onUpdate }: Props = $props();
let searchQuery = $state('');
let searchResults = $state<UserResult[]>([]);
let isSearching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function handleSearch(query: string) {
clearTimeout(searchTimeout);
if (!query.trim()) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
isSearching = true;
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const users: UserResult[] = await res.json();
// Filter out existing collaborators
const collabIds = new Set(collaborators.map((c) => c.userId));
searchResults = users.filter((u) => !collabIds.has(u.id));
}
} catch {
// Search failed silently
} finally {
isSearching = false;
}
}, 300);
}
$effect(() => {
handleSearch(searchQuery);
});
async function addCollaborator(user: UserResult) {
try {
const res = await fetch(`/api/notes/${noteId}/collaborators`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id })
});
if (res.ok) {
const updated: Collaborator[] = await res.json();
onUpdate(updated);
searchQuery = '';
searchResults = [];
showToast(`Shared with ${user.displayName || user.email}`, 'success');
} else {
const err = await res.json().catch(() => ({ message: 'Failed to share' }));
showToast(err.message || 'Failed to share', 'error');
}
} catch {
showToast('Failed to share note', 'error');
}
}
async function removeCollaborator(userId: number) {
try {
const res = await fetch(`/api/notes/${noteId}/collaborators?userId=${userId}`, {
method: 'DELETE'
});
if (res.ok) {
const updated = collaborators.filter((c) => c.userId !== userId);
onUpdate(updated);
}
} catch {
showToast('Failed to remove collaborator', 'error');
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
onclick={onClose}
onkeydown={handleKeydown}
data-testid="share-dialog-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
data-testid="share-dialog"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
title="Close"
>
<Icon icon="pixelarticons:close" class="h-4 w-4" />
</button>
</div>
<!-- Search input -->
<div class="relative px-4 pt-3">
<div class="relative">
<Icon icon="pixelarticons:search" class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
<input
type="text"
placeholder="Search users..."
bind:value={searchQuery}
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] py-2 pl-8 pr-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="share-search-input"
/>
</div>
<!-- Search results dropdown -->
{#if searchResults.length > 0}
<ul class="absolute left-4 right-4 z-10 mt-1 max-h-40 overflow-y-auto rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]" data-testid="share-search-results">
{#each searchResults as user}
<li>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--primary)]/10"
onclick={() => addCollaborator(user)}
data-testid="share-user-result"
>
<span class="font-medium">{user.displayName || 'User'}</span>
{#if user.email}
<span class="ml-1 text-[var(--text-muted)]">{user.email}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Current collaborators -->
<div class="px-4 py-3">
<p class="mb-2 text-xs font-semibold text-[var(--text-muted)]">People with access</p>
<ul class="space-y-1">
<!-- Owner -->
<li class="flex items-center justify-between rounded-sm px-2 py-1.5">
<span class="text-sm text-[var(--text)]">
{ownerName}
<span class="text-xs text-[var(--text-muted)]">(Owner)</span>
</span>
</li>
<!-- Collaborators -->
{#each collaborators as collab}
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
<span class="text-sm text-[var(--text)]">
{collab.displayName || collab.email}
</span>
<button
onclick={() => removeCollaborator(collab.userId)}
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--destructive)]"
title="Remove"
data-testid="remove-collaborator-btn"
>
<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />
</button>
</li>
{/each}
</ul>
</div>
</div>
</div>
+19 -54
View File
@@ -1,59 +1,24 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getSyncStatus, onSyncStatusChange, sync, type SyncStatus } from '$lib/sync/client.js';
import Icon from '@iconify/svelte';
let status: SyncStatus = $state(getSyncStatus());
let displayStatus: SyncStatus = $state(getSyncStatus());
let minDisplayTimeout: ReturnType<typeof setTimeout> | undefined;
let pendingStatus: SyncStatus | undefined;
function applyStatus(s: SyncStatus) {
if (displayStatus === s) return;
if (minDisplayTimeout) {
pendingStatus = s;
return;
}
displayStatus = s;
minDisplayTimeout = setTimeout(() => {
minDisplayTimeout = undefined;
if (pendingStatus !== undefined && pendingStatus !== displayStatus) {
applyStatus(pendingStatus);
pendingStatus = undefined;
}
}, 300);
}
let unsubscribe: (() => void) | undefined;
onMount(() => {
unsubscribe = onSyncStatusChange((s) => {
status = s;
applyStatus(s);
});
});
onDestroy(() => {
unsubscribe?.();
if (minDisplayTimeout) clearTimeout(minDisplayTimeout);
});
// Placeholder for Phase 6 - will be connected to sync store
let status: 'synced' | 'syncing' | 'offline' | 'error' = $state('synced');
</script>
<button
class="flex cursor-pointer items-center rounded-sm p-1 transition-colors hover:bg-[var(--border-subtle)]/50"
title="Sync status: {status} — click to sync"
onclick={() => sync()}
data-testid="sync-indicator"
>
{#if displayStatus === 'synced'}
<Icon icon="pixelarticons:cloud-done" class="h-5 w-5 text-[var(--primary)]" />
{:else if displayStatus === 'syncing'}
<Icon icon="pixelarticons:sync" class="h-5 w-5 text-[var(--primary)] opacity-60" />
{:else if displayStatus === 'offline'}
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--text-muted)]" />
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
{#if status === 'synced'}
<svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{:else if status === 'syncing'}
<svg class="h-5 w-5 animate-spin text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{:else if status === 'offline'}
<svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
</svg>
{:else}
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--destructive)]" />
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
{/if}
</button>
</div>
-28
View File
@@ -1,28 +0,0 @@
<script lang="ts">
interface Props {
tag: string;
active?: boolean;
onclick?: () => void;
}
let { tag, active = false, onclick }: Props = $props();
</script>
{#if onclick}
<button
{onclick}
class="whitespace-nowrap rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
? 'bg-[var(--primary)] text-white'
: 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
data-testid="tag-chip"
>
#{tag}
</button>
{:else}
<span
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs font-medium text-[var(--text-muted)]"
data-testid="tag-chip"
>
#{tag}
</span>
{/if}
+10 -22
View File
@@ -1,33 +1,21 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { allTags } from '$lib/stores/notes.js';
import TagChip from './TagChip.svelte';
import { allTags, selectedTag } from '$lib/stores/notes.js';
function toggleTag(tag: string) {
const isActive = $page.url.pathname === `/tag/${tag}`;
goto(isActive ? '/' : `/tag/${tag}`);
}
function isTagActive(tag: string, pathname: string): boolean {
return pathname === `/tag/${tag}`;
selectedTag.update((current) => (current === tag ? null : tag));
}
</script>
{#if $allTags.length > 0}
<!-- svelte-ignore css_unused_selector -->
<div class="tag-scroll flex gap-2 overflow-x-auto max-md:flex-nowrap md:flex-wrap" data-testid="tag-filter">
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
{#each $allTags as tag}
<TagChip {tag} active={isTagActive(tag, $page.url.pathname)} onclick={() => toggleTag(tag)} />
<button
onclick={() => toggleTag(tag)}
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {$selectedTag === tag ? 'bg-amber-200 text-amber-900' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
data-testid="tag-chip"
>
#{tag}
</button>
{/each}
</div>
{/if}
<style>
.tag-scroll {
scrollbar-width: none;
}
.tag-scroll::-webkit-scrollbar {
display: none;
}
</style>
+3 -20
View File
@@ -6,12 +6,6 @@
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Placeholder from '@tiptap/extension-placeholder';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown';
interface Props {
@@ -36,12 +30,6 @@
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({ nested: true }),
Markdown
],
content,
@@ -69,27 +57,22 @@
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={element}
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none px-4 py-2 text-gray-800"
data-testid="tiptap-editor"
role="textbox"
aria-multiline="true"
tabindex="0"
onclick={() => editor?.commands.focus()}
></div>
<style>
.tiptap-wrapper :global(.tiptap) {
outline: none;
flex: 1;
min-height: 80px;
}
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
color: var(--text-muted);
color: #9ca3af;
pointer-events: none;
height: 0;
}
-125
View File
@@ -1,125 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { generateApiKey, validateApiKey, listApiKeys, deleteApiKey } from './api-keys.js';
import { users } from './db/schema.js';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
describe('API Keys', () => {
let db: BetterSQLite3Database<typeof schema>;
let userId: number;
beforeEach(() => {
({ db } = createTestDb());
// Create a test user
db.insert(users)
.values({ email: 'test@example.com', displayName: 'Test', createdAt: new Date() })
.run();
userId = 1;
});
describe('generateApiKey', () => {
it('returns key with crumbs_ prefix', () => {
const result = generateApiKey('Test Key', userId, db);
expect(result.key).toMatch(/^crumbs_[a-f0-9]{64}$/);
expect(result.name).toBe('Test Key');
expect(result.id).toBeTruthy();
expect(result.keyPrefix).toBe(result.key.slice(0, 12));
});
it('generates unique keys each time', () => {
const key1 = generateApiKey('Key 1', userId, db);
const key2 = generateApiKey('Key 2', userId, db);
expect(key1.key).not.toBe(key2.key);
expect(key1.id).not.toBe(key2.id);
});
});
describe('validateApiKey', () => {
it('returns valid with userId for a valid key', () => {
const { key } = generateApiKey('Test Key', userId, db);
const result = validateApiKey(key, db);
expect(result.valid).toBe(true);
expect(result.userId).toBe(userId);
});
it('returns invalid for an invalid key', () => {
const result = validateApiKey('crumbs_invalid', db);
expect(result.valid).toBe(false);
expect(result.userId).toBeNull();
});
it('returns invalid for empty string', () => {
expect(validateApiKey('', db).valid).toBe(false);
});
it('returns invalid for key without prefix', () => {
expect(validateApiKey('no_prefix_here', db).valid).toBe(false);
});
it('updates lastUsedAt on validation', () => {
const { key, id } = generateApiKey('Test Key', userId, db);
const before = listApiKeys(userId, db).find((k) => k.id === id);
expect(before?.lastUsedAt).toBeNull();
validateApiKey(key, db);
const after = listApiKeys(userId, db).find((k) => k.id === id);
expect(after?.lastUsedAt).toBeInstanceOf(Date);
});
});
describe('listApiKeys', () => {
it('returns empty array when no keys exist', () => {
expect(listApiKeys(userId, db)).toEqual([]);
});
it('returns keys without hash', () => {
generateApiKey('Key 1', userId, db);
generateApiKey('Key 2', userId, db);
const keys = listApiKeys(userId, db);
expect(keys).toHaveLength(2);
expect(keys[0]).toHaveProperty('name');
expect(keys[0]).toHaveProperty('keyPrefix');
expect(keys[0]).toHaveProperty('createdAt');
expect(keys[0]).not.toHaveProperty('keyHash');
});
it('only returns keys for the specified user', () => {
db.insert(users)
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
.run();
const otherUserId = 2;
generateApiKey('My Key', userId, db);
generateApiKey('Other Key', otherUserId, db);
expect(listApiKeys(userId, db)).toHaveLength(1);
expect(listApiKeys(otherUserId, db)).toHaveLength(1);
});
});
describe('deleteApiKey', () => {
it('deletes an existing key and returns true', () => {
const { id, key } = generateApiKey('Test Key', userId, db);
expect(deleteApiKey(id, userId, db)).toBe(true);
expect(validateApiKey(key, db).valid).toBe(false);
expect(listApiKeys(userId, db)).toHaveLength(0);
});
it('returns false for non-existent key', () => {
expect(deleteApiKey('nonexistent', userId, db)).toBe(false);
});
it('cannot delete another user\'s key', () => {
db.insert(users)
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
.run();
const otherUserId = 2;
const { id } = generateApiKey('Other Key', otherUserId, db);
expect(deleteApiKey(id, userId, db)).toBe(false);
});
});
});
-105
View File
@@ -1,105 +0,0 @@
import { db } from './db/index.js';
import { apiKeys, users } from './db/schema.js';
import { eq } from 'drizzle-orm';
import { randomBytes, createHash } from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
type Db = BetterSQLite3Database<typeof schema>;
const PREFIX = 'crumbs_';
function hashKey(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
export function generateApiKey(
name: string,
userId: number,
database: Db = db
): { id: string; key: string; name: string; keyPrefix: string } {
const id = uuidv4();
const raw = randomBytes(32).toString('hex');
const key = `${PREFIX}${raw}`;
const keyHash = hashKey(key);
const keyPrefix = key.slice(0, 12);
database
.insert(apiKeys)
.values({
id,
userId,
name,
keyHash,
keyPrefix,
createdAt: new Date()
})
.run();
return { id, key, name, keyPrefix };
}
export function validateApiKey(
key: string,
database: Db = db
): { valid: boolean; userId: number | null } {
if (!key || !key.startsWith(PREFIX)) return { valid: false, userId: null };
const keyHash = hashKey(key);
const row = database.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get();
if (!row) return { valid: false, userId: null };
database
.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, row.id))
.run();
return { valid: true, userId: row.userId };
}
export function getUserForApiKey(
userId: number,
database: Db = db
): App.Locals['user'] | null {
const user = database
.select({
id: users.id,
email: users.email,
displayName: users.displayName,
role: users.role
})
.from(users)
.where(eq(users.id, userId))
.get();
return user ? (user as App.Locals['user']) : null;
}
export function listApiKeys(userId: number, database: Db = db) {
return database
.select({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt
})
.from(apiKeys)
.where(eq(apiKeys.userId, userId))
.all();
}
export function deleteApiKey(id: string, userId: number, database: Db = db): boolean {
const existing = database
.select()
.from(apiKeys)
.where(eq(apiKeys.id, id))
.get();
if (!existing || existing.userId !== userId) return false;
const result = database.delete(apiKeys).where(eq(apiKeys.id, id)).run();
return result.changes > 0;
}
-100
View File
@@ -1,100 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteCollaborators, users } from './db/schema.js';
import type { Db } from './db/index.js';
import { canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId } from './api-utils.js';
let db: Db;
const OWNER_ID = 1;
const COLLAB_ID = 2;
const STRANGER_ID = 3;
function seedUsers() {
db.insert(users)
.values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() },
{ email: 'stranger@test.com', displayName: 'Stranger', role: 'user', authProvider: 'password', createdAt: new Date() }
])
.run();
}
function seedNote(id: string) {
db.insert(notes)
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
function shareNote(noteId: string) {
db.insert(noteCollaborators)
.values({ noteId, userId: COLLAB_ID, addedBy: OWNER_ID, addedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('canAccessNote', () => {
it('should allow owner to access their note', () => {
seedNote('n1');
const result = canAccessNote(db, 'n1', OWNER_ID);
expect(result).toEqual({ canAccess: true, isOwner: true });
});
it('should allow collaborator to access shared note', () => {
seedNote('n1');
shareNote('n1');
const result = canAccessNote(db, 'n1', COLLAB_ID);
expect(result).toEqual({ canAccess: true, isOwner: false });
});
it('should deny non-collaborator access', () => {
seedNote('n1');
const result = canAccessNote(db, 'n1', STRANGER_ID);
expect(result).toEqual({ canAccess: false, isOwner: false });
});
it('should return false for non-existent note', () => {
const result = canAccessNote(db, 'nonexistent', OWNER_ID);
expect(result).toEqual({ canAccess: false, isOwner: false });
});
});
describe('requireNoteAccess', () => {
it('should throw 404 for unauthorized user', () => {
seedNote('n1');
expect(() => requireNoteAccess(db, 'n1', STRANGER_ID)).toThrow();
});
it('should return isOwner for authorized owner', () => {
seedNote('n1');
expect(requireNoteAccess(db, 'n1', OWNER_ID)).toEqual({ isOwner: true });
});
});
describe('requireNoteOwnership', () => {
it('should throw 404 for collaborator', () => {
seedNote('n1');
shareNote('n1');
expect(() => requireNoteOwnership(db, 'n1', COLLAB_ID)).toThrow();
});
it('should not throw for owner', () => {
seedNote('n1');
expect(() => requireNoteOwnership(db, 'n1', OWNER_ID)).not.toThrow();
});
});
describe('getUserId', () => {
it('should throw 401 when no user in locals', () => {
expect(() => getUserId({ locals: {} as App.Locals })).toThrow();
});
it('should return user id from locals', () => {
const id = getUserId({ locals: { user: { id: 42, role: 'user', email: '', displayName: '' } } as App.Locals });
expect(id).toBe(42);
});
});
-52
View File
@@ -1,52 +0,0 @@
import { error } from '@sveltejs/kit';
import type { Db } from './db/index.js';
import { notes, noteCollaborators } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
type EventWithLocals = {
locals: App.Locals;
};
export function getUserId(event: EventWithLocals): number {
if (!event.locals.user) throw error(401, 'Unauthorized');
return event.locals.user.id;
}
export function requireAdmin(event: EventWithLocals) {
const user = event.locals.user;
if (!user || user.role !== 'admin') throw error(403, 'Forbidden');
return user;
}
/**
* Check if a user can access a note (as owner or collaborator).
*/
export function canAccessNote(db: Db, noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
if (!note) return { canAccess: false, isOwner: false };
if (note.userId === userId) return { canAccess: true, isOwner: true };
const collab = db
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
.get();
return { canAccess: !!collab, isOwner: false };
}
/**
* Require that a user can access a note. Throws 404 if no access.
*/
export function requireNoteAccess(db: Db, noteId: string, userId: number): { isOwner: boolean } {
const { canAccess, isOwner } = canAccessNote(db, noteId, userId);
if (!canAccess) throw error(404, 'Not found');
return { isOwner };
}
/**
* Require that a user is the owner of a note. Throws 404 if not owner.
*/
export function requireNoteOwnership(db: Db, noteId: string, userId: number): void {
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
if (!note || note.userId !== userId) throw error(404, 'Not found');
}
+9 -68
View File
@@ -2,10 +2,9 @@ import { existsSync, mkdirSync } from 'fs';
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import type { Db } from './db/index.js';
import { db } from './db/index.js';
import { attachments } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import type { Attachment } from '$lib/types/index.js';
import { eq } from 'drizzle-orm';
const DATA_DIR = process.env.DATA_DIR || './data';
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
@@ -16,11 +15,8 @@ if (!existsSync(ATTACHMENTS_DIR)) {
}
export async function saveAttachment(
db: Db,
noteId: string,
file: File,
userId: number,
thumbnail?: File | Blob | null
file: File
): Promise<typeof attachments.$inferSelect> {
const id = uuidv4();
const ext = file.name.split('.').pop() || 'bin';
@@ -30,25 +26,15 @@ export async function saveAttachment(
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
let thumbnailPath: string | null = null;
if (thumbnail) {
const thumbFilename = `${id}_thumb.webp`;
thumbnailPath = join(ATTACHMENTS_DIR, thumbFilename);
const thumbBuffer = Buffer.from(await thumbnail.arrayBuffer());
await writeFile(thumbnailPath, thumbBuffer);
}
const [attachment] = await db
.insert(attachments)
.values({
id,
userId,
noteId,
filename: file.name,
mimeType: file.type,
size: file.size,
path: filePath,
thumbnailPath,
createdAt: new Date()
})
.returning();
@@ -56,67 +42,22 @@ export async function saveAttachment(
return attachment;
}
/**
* Batch fetch attachments for multiple notes in a single query.
* Returns a Map of noteId → Attachment[].
*/
export function fetchAttachmentsForNotes(db: Db, noteIds: string[]): Map<string, Attachment[]> {
if (noteIds.length === 0) return new Map();
const rows = db
.select()
.from(attachments)
.where(inArray(attachments.noteId, noteIds))
.all();
const map = new Map<string, Attachment[]>();
for (const row of rows) {
const existing = map.get(row.noteId);
if (existing) {
existing.push(row as Attachment);
} else {
map.set(row.noteId, [row as Attachment]);
}
}
return map;
export async function getAttachment(id: string) {
return db.select().from(attachments).where(eq(attachments.id, id)).get();
}
export async function getAttachment(db: Db, id: string, noteId: string) {
return db
.select()
.from(attachments)
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
.get();
}
export async function getAttachmentsByNote(db: Db, noteId: string) {
export async function getAttachmentsByNote(noteId: string) {
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
}
export async function updateAttachment(db: Db, id: string, noteId: string, data: { featured: boolean }) {
const [updated] = await db
.update(attachments)
.set({ featured: data.featured })
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
.returning();
return updated ?? null;
}
export async function deleteAttachment(db: Db, id: string, noteId: string) {
const attachment = await getAttachment(db, id, noteId);
export async function deleteAttachment(id: string) {
const attachment = await getAttachment(id);
if (attachment) {
try {
await unlink(attachment.path);
} catch {
// File may already be deleted
}
if (attachment.thumbnailPath) {
try {
await unlink(attachment.thumbnailPath);
} catch {
// Thumbnail may already be deleted
}
}
await db.delete(attachments).where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)));
await db.delete(attachments).where(eq(attachments.id, id));
}
}
+5 -12
View File
@@ -41,25 +41,18 @@ describe('Auth - User Setup', () => {
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'admin@test.com',
displayName: 'Admin',
role: 'admin',
passwordHash: hash,
createdAt: new Date()
});
const user = await db.select().from(users).get();
expect(user).toBeDefined();
expect(user!.email).toBe('admin@test.com');
expect(user!.role).toBe('admin');
expect(user!.passwordHash).not.toBe(password);
expect(await argon2.verify(user!.passwordHash!, password)).toBe(true);
expect(await argon2.verify(user!.passwordHash, password)).toBe(true);
});
it('should only allow one admin setup', async () => {
it('should only allow one user', async () => {
await db.insert(users).values({
email: 'admin@test.com',
role: 'admin',
passwordHash: 'hash1',
createdAt: new Date()
});
@@ -73,7 +66,7 @@ describe('Auth - Sessions', () => {
it('should create and validate a session', async () => {
const [user] = await db
.insert(users)
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
@@ -92,7 +85,7 @@ describe('Auth - Sessions', () => {
it('should detect expired sessions', async () => {
const [user] = await db
.insert(users)
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
@@ -110,7 +103,7 @@ describe('Auth - Sessions', () => {
it('should delete a session', async () => {
const [user] = await db
.insert(users)
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');

Some files were not shown because too many files have changed in this diff Show More