mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
@@ -0,0 +1,423 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,353 @@
|
||||
# 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 -->
|
||||
```
|
||||
@@ -0,0 +1,339 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,443 @@
|
||||
# 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();
|
||||
});
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,394 @@
|
||||
# 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>
|
||||
```
|
||||
@@ -0,0 +1,291 @@
|
||||
# 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}
|
||||
```
|
||||
@@ -0,0 +1,401 @@
|
||||
# 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
|
||||
@@ -0,0 +1,305 @@
|
||||
# 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}
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
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
|
||||
-->
|
||||
@@ -0,0 +1,125 @@
|
||||
# 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
|
||||
@@ -0,0 +1,407 @@
|
||||
# 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?
|
||||
@@ -0,0 +1,316 @@
|
||||
# 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')`
|
||||
@@ -0,0 +1,292 @@
|
||||
# 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
|
||||
@@ -0,0 +1,317 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
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
|
||||
-->
|
||||
@@ -0,0 +1,274 @@
|
||||
# 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
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,387 @@
|
||||
# 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
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,150 @@
|
||||
# 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
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
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'>;
|
||||
```
|
||||
+25
-1
@@ -1,8 +1,32 @@
|
||||
# Database path (relative to project root or absolute)
|
||||
DATABASE_URL=./data/crumbs.db
|
||||
|
||||
# Server origin (required for CSRF protection)
|
||||
# Server origin (required for CSRF protection and OAuth redirect URLs)
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Default owner for everything in the repo
|
||||
* @fredicious
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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.).
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,18 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,184 @@
|
||||
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
|
||||
|
||||
build:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
needs: [bump, ci]
|
||||
permissions:
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
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 }}
|
||||
|
||||
- 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 by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
|
||||
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.runner }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge manifests & release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, build]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- 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 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: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- 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, merge]
|
||||
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
|
||||
+53
-27
@@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'claude/**']
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
@@ -19,8 +19,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -37,8 +35,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -46,18 +42,28 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test:unit
|
||||
- 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
|
||||
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-and-check, unit-tests]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -66,38 +72,58 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- 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
|
||||
- name: Build (with coverage instrumentation)
|
||||
run: pnpm build
|
||||
env:
|
||||
DATABASE_URL: ./data/test-crumbs.db
|
||||
NODE_ENV: test
|
||||
VITE_COVERAGE: 'true'
|
||||
|
||||
- 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: failure()
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
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
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t crumbs:ci .
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
provenance: false
|
||||
tags: crumbs:ci
|
||||
cache-from: type=gha,scope=ci-docker
|
||||
cache-to: type=gha,scope=ci-docker,mode=max
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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:*)'
|
||||
|
||||
@@ -13,13 +13,77 @@ jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
needs: ci
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
|
||||
- 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 by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
|
||||
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.runner }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge manifests & release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -40,25 +104,38 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
needs: merge
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Trigger Dokploy redeploy
|
||||
run: curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}"
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Screenshots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
screenshots:
|
||||
name: Capture Landing Page Screenshots
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright with system deps
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Build app
|
||||
run: pnpm build
|
||||
env:
|
||||
DATABASE_URL: ./data/screenshots.db
|
||||
|
||||
- name: Capture screenshots
|
||||
run: pnpm exec tsx scripts/screenshots.ts
|
||||
env:
|
||||
DATABASE_URL: ./data/screenshots.db
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add website/assets/screenshot-*.png
|
||||
git diff --cached --quiet && echo "No changes" && exit 0
|
||||
git commit -m "chore: update landing page screenshots [skip ci]"
|
||||
git push
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
+17
@@ -31,3 +31,20 @@ vite.config.ts.timestamp-*
|
||||
# Test results
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
# Coverage
|
||||
/coverage
|
||||
.nyc_output
|
||||
|
||||
# Claude Code
|
||||
/.claude/
|
||||
|
||||
# Local notes
|
||||
todo.md
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Superpowers brainstorm artifacts
|
||||
.superpowers/
|
||||
package-lock.json
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"report-dir": "coverage/e2e",
|
||||
"temp-dir": "coverage/e2e/raw",
|
||||
"reporter": ["text", "html", "lcov", "json-summary"],
|
||||
"include": ["src/**"],
|
||||
"exclude": ["node_modules", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project overview
|
||||
|
||||
Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first PWA with CRDT-based sync.
|
||||
Crumbs by Bretzel — self-hostable note-taking app. Multi-user with password auth + OAuth/SSO, offline-first PWA with CRDT-based sync.
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -10,25 +10,115 @@ Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first
|
||||
- **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**: Single-user password auth with Argon2 hashing, session cookies (30-day expiry)
|
||||
- **Auth**: Multi-user with password auth (Argon2) + OAuth (Google, GitHub, OIDC), role-based (admin/user), 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 |
|
||||
| `--accent-check` | `var(--primary)` (light) / `#c8a44a` (dark) | Checkbox accent color — dimmed gold in dark mode so a long done-list doesn't shout |
|
||||
| `--hover-wash` | `var(--border)` (light) / `#ece3d3` (dark) | Tint source for hover/active washes (`bg-[var(--hover-wash)]/N`) — kept cream in dark mode since `--border`'s mid-grey is invisible at low opacity |
|
||||
|
||||
Note colors for cards are defined in `src/lib/utils/colors.ts`.
|
||||
|
||||
### Dark theme
|
||||
|
||||
Dark mode overrides every colour variable in `[data-theme="dark"]` (`src/app.css`). It is not a
|
||||
mechanical inversion of light mode — two differences are deliberate:
|
||||
|
||||
- **`--primary` differs per theme.** `#C8860A` on parchment, `#e0a030` on ink. The light gold reads
|
||||
mustard on a dark background.
|
||||
- **`--border` is a warm mid-grey (`#57514b`), not mirrored cream.** A bright 1px line on a dark
|
||||
background reads as glare rather than structure; the hard offset shadow carries the retro framing.
|
||||
|
||||
Note colours have two representations, and they are not interchangeable:
|
||||
|
||||
| Map | Function | Used by | Role |
|
||||
|---|---|---|---|
|
||||
| `NOTE_COLORS` / `NOTE_COLORS_DARK` | `getNoteColor(color, isDark)` | Cards, editor, share page | Quiet **surface** you read text on |
|
||||
| `NOTE_CHIPS_DARK` | `getNoteChip(color, isDark)` | `ColorPicker` only | Vivid **label** you identify at a glance |
|
||||
|
||||
A dark card sits at low lightness, where a 28px picker circle would read as a grey dot — small
|
||||
patches lose apparent saturation. Never fill a picker swatch with `getNoteColor()`. In light mode
|
||||
`getNoteChip()` returns the pastel card colour, because there the pastels *are* the cards.
|
||||
|
||||
When changing any dark note colour, keep the invariants asserted in `src/lib/utils/colors.test.ts`:
|
||||
AA contrast (4.5:1) against both `--text` and `--text-muted` on every dark surface, no two dark card
|
||||
surfaces closer than an RGB distance of 8, no two dark chips closer than an RGB distance of 25, and
|
||||
`getNoteChip()` returning the card colour in light mode but a distinct chip in dark mode.
|
||||
|
||||
`--bg-base` is duplicated as a literal in `src/app.html` (FOUC script) and
|
||||
`src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`) for the browser theme-colour. Update all three
|
||||
together.
|
||||
|
||||
### 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(--accent-check)` globally — resolves to `--primary` in light mode and to a dimmed gold (`#c8a44a`) in dark mode so a long done-list doesn't shout.
|
||||
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
|
||||
- **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/ # SvelteKit pages and API routes
|
||||
+page.svelte # Main notes view
|
||||
login/ # Login page
|
||||
setup/ # First-time password setup
|
||||
api/auth/ # Login, logout, setup endpoints
|
||||
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)
|
||||
api/notes/ # CRUD + attachments
|
||||
api/search/ # Full-text search
|
||||
api/sync/ # Offline sync
|
||||
api/tags/ # Tag management
|
||||
lib/
|
||||
components/ # Svelte components (NoteCard, NoteEditor, ColorPicker, etc.)
|
||||
components/ # Svelte components (NoteCard, NoteEditor, TagChip, etc.)
|
||||
stores/ # Svelte stores (notes.ts, theme.ts)
|
||||
server/db/ # Drizzle schema + connection (schema.ts, index.ts)
|
||||
server/ # Auth logic, attachment handling
|
||||
@@ -40,6 +130,10 @@ 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
|
||||
@@ -67,24 +161,47 @@ 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
|
||||
|
||||
GitHub Actions with two workflows:
|
||||
|
||||
**CI** (`.github/workflows/ci.yml`) — runs on push to `main`/`claude/**` and PRs to `main`:
|
||||
**CI** (`.github/workflows/ci.yml`) — runs on push to `main` and PRs to `main` (branch work is validated once via the PR event, avoiding duplicate push+PR runs):
|
||||
1. **Lint & Type Check** — `pnpm check`
|
||||
2. **Unit Tests** — `pnpm test:unit`
|
||||
3. **Build** — `pnpm build` (depends on steps 1+2)
|
||||
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)
|
||||
|
||||
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags:
|
||||
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io` (GitHub Container Registry)
|
||||
**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`
|
||||
- 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.
|
||||
@@ -102,6 +219,7 @@ 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
|
||||
|
||||
@@ -132,3 +250,15 @@ 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`) |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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).
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
@@ -1,6 +1,8 @@
|
||||
.PHONY: help dev build preview check test test-unit test-e2e lint \
|
||||
.PHONY: help dev build preview check test test-unit test-e2e \
|
||||
test-unit-coverage test-e2e-coverage lint \
|
||||
db-push db-generate db-migrate db-studio docs-api \
|
||||
install docker-build docker-up docker-down docker-logs clean
|
||||
install docker-build docker-up docker-down docker-logs clean \
|
||||
release release-patch release-minor release-major
|
||||
|
||||
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}'
|
||||
@@ -26,6 +28,12 @@ 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
|
||||
|
||||
@@ -62,3 +70,15 @@ 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
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
# 🥨 Crumbs by Bretzel
|
||||
[](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](https://github.com/bretzel-app/crumbs/releases)
|
||||
[](https://github.com/bretzel-app/crumbs/pkgs/container/crumbs)
|
||||
|
||||
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
|
||||
<p align="center">
|
||||
<img src="static/favicon-96x96.png" alt="Crumbs logo" width="96" height="96">
|
||||
</p>
|
||||
|
||||
## Quick Start
|
||||
# Crumbs by Bretzel
|
||||
|
||||
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
|
||||
|
||||
<p align="center">
|
||||
<img src="website/assets/screenshot-grid.png" alt="Crumbs notes grid view" width="720">
|
||||
</p>
|
||||
|
||||
## Install
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
@@ -10,7 +23,56 @@ A self-hostable, offline-first note-taking app inspired by Google Keep. Part of
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open http://localhost:3000 and set your password on first visit.
|
||||
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
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
@@ -32,9 +94,12 @@ 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
|
||||
- Dark mode with system detection
|
||||
- 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)
|
||||
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
|
||||
- Single-user password auth (Argon2)
|
||||
- MCP server — let AI assistants (Claude Code, etc.) manage your notes
|
||||
- Multi-user auth (Argon2) with optional OAuth/SSO (Google, GitHub, OIDC)
|
||||
- Docker deployment with a single command
|
||||
|
||||
See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|
||||
@@ -60,8 +125,12 @@ 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
|
||||
|
||||
@@ -72,4 +141,4 @@ Configure registry via GitHub Secrets: `REGISTRY_URL`, `REGISTRY_USER`, `REGISTR
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
[MIT](LICENSE)
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# 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)
|
||||
+17
-2
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
build: .
|
||||
image: ghcr.io/bretzel-app/crumbs:0.31.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -10,7 +10,22 @@ services:
|
||||
- NODE_ENV=production
|
||||
- DATA_DIR=/data
|
||||
- DATABASE_URL=/data/crumbs.db
|
||||
- ORIGIN=http://localhost:3000
|
||||
- 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:-}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
+407
@@ -103,6 +103,7 @@ 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",
|
||||
@@ -110,6 +111,17 @@ 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -125,6 +137,7 @@ Create a note
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -147,6 +160,7 @@ 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",
|
||||
@@ -154,6 +168,17 @@ 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -181,6 +206,7 @@ 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",
|
||||
@@ -188,6 +214,17 @@ 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -212,6 +249,7 @@ Partial update — only include changed fields.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -234,6 +272,7 @@ 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",
|
||||
@@ -241,6 +280,17 @@ 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -271,6 +321,231 @@ 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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sharing
|
||||
|
||||
### `POST /api/notes/{id}/share`
|
||||
|
||||
Create a public share link
|
||||
|
||||
Owner-only. Creates a public share URL for the note. Idempotent — returns existing token if already shared.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
|
||||
**Response:** `201`
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "token",
|
||||
"url": "url"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `404` Note not found or not owner
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/notes/{id}/share`
|
||||
|
||||
Revoke a public share link
|
||||
|
||||
Owner-only. Removes the public share link for the note.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
|
||||
**Response:** `204`
|
||||
|
||||
**Errors:** `404` Note not found or not owner
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/shared/{token}`
|
||||
|
||||
Get a shared note (public)
|
||||
|
||||
No authentication required. Returns the note content for a valid share token. Rate limited to 60 requests per minute per IP.
|
||||
|
||||
> No authentication required.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `token` | path | string | yes | |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"checklistMode": true,
|
||||
"color": "default",
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"featured": true,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `404` Invalid token or note is trashed, `429` Rate limit exceeded
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/shared/{token}/attachment/{attachmentId}`
|
||||
|
||||
Get an attachment from a shared note (public)
|
||||
|
||||
No authentication required. Serves image binary for an attachment belonging to the shared note. Rate limited.
|
||||
|
||||
> No authentication required.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `token` | path | string | yes | |
|
||||
| `attachmentId` | path | string | yes | |
|
||||
| `thumb` | query | string | no | Set to '1' to get the thumbnail version (1) |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
**Errors:** `404` Invalid token, note trashed, or attachment not found, `429` Rate limit exceeded
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
@@ -285,9 +560,24 @@ 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`
|
||||
@@ -311,6 +601,7 @@ Upload an image attachment
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
@@ -366,6 +657,7 @@ 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",
|
||||
@@ -373,6 +665,17 @@ 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -423,6 +726,7 @@ 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",
|
||||
@@ -430,6 +734,17 @@ 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -451,6 +766,7 @@ Push local changes
|
||||
"operation": "create",
|
||||
"timestamp": 1,
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -473,4 +789,95 @@ 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
|
||||
|
||||
---
|
||||
|
||||
+80
-7
@@ -49,6 +49,14 @@ 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
|
||||
@@ -75,32 +83,97 @@ For a single-user app with multiple devices:
|
||||
- New v4 uses Vite plugin (faster builds)
|
||||
|
||||
### Argon2
|
||||
- Current best practice for password hashing
|
||||
- Memory-hard (resists GPU attacks)
|
||||
- Winner of the Password Hashing Competition
|
||||
- Memory-hard password hashing (resists GPU attacks)
|
||||
- See [AUTH.md](AUTH.md) for authentication details
|
||||
|
||||
## Database Schema
|
||||
|
||||
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
|
||||
|
||||
Key tables:
|
||||
- `users` - Single user (password hash)
|
||||
- `users` - User accounts (password hash, OAuth provider link, role)
|
||||
- `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
|
||||
- `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
|
||||
- `shared_notes` - Public share links (noteId → token, 1:1 with cascade delete)
|
||||
- `sync_log` - Sync operation history
|
||||
|
||||
### Nested Checklist Serialization
|
||||
|
||||
Checklist items are stored as plain text in the note `content` field, one item per line. Child items use a 2-space indent prefix:
|
||||
|
||||
```
|
||||
- [ ] Parent item
|
||||
- [ ] Child item
|
||||
- [x] Checked child item
|
||||
- [x] Another parent
|
||||
```
|
||||
|
||||
- A line starting with ` - [ ]` or ` - [x]` (2 spaces) is parsed as a child of the preceding top-level item.
|
||||
- The `parentId` field on `ChecklistItem` is **runtime-only** — it is derived from position during parsing and never stored explicitly.
|
||||
- This format is fully backward compatible: flat checklists (no indented lines) parse identically to before.
|
||||
|
||||
### 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`
|
||||
|
||||
## Dark Mode
|
||||
|
||||
Theme switching uses CSS variable overrides on the `<html>` element:
|
||||
|
||||
- **CSS**: `[data-theme="dark"]` attribute on `<html>` overrides all `--bg-*`, `--text-*`, `--border-*`, and `--primary-*` variables defined in `src/app.css`
|
||||
- **Persistence**: preference stored as `theme` in the existing `userPreferences` key-value system (values: `"system"` | `"light"` | `"dark"`)
|
||||
- **FOUC prevention**: a blocking inline `<script>` in `app.html` reads the preference from localStorage and sets `data-theme` before any content renders
|
||||
- **Reactivity**: a `$effect` in the root `+layout.svelte` watches the theme store and updates `document.documentElement.dataset.theme` on change
|
||||
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and
|
||||
returns the appropriate surface variant per theme. Picker swatches use `getNoteChip()` instead —
|
||||
in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker
|
||||
draws from a separate vivid `NOTE_CHIPS_DARK` map.
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
src/lib/server/ # Server-only code (DB, auth, attachments)
|
||||
src/lib/server/ # Server-only code (DB, auth, attachments, notes-service)
|
||||
src/lib/server/mcp/ # MCP server + tool definitions
|
||||
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 endpoints
|
||||
src/routes/api/ # REST API + MCP endpoints
|
||||
src/routes/ # Page routes (SvelteKit file-based routing)
|
||||
```
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# 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 |
|
||||
+48
-4
@@ -16,7 +16,7 @@ Open http://localhost:3000 and set your password.
|
||||
# docker-compose.yml
|
||||
services:
|
||||
crumbs:
|
||||
build: .
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
ports:
|
||||
- "8080:3000" # Change external port
|
||||
volumes:
|
||||
@@ -100,6 +100,8 @@ node build
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Core
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `./data/crumbs.db` | SQLite database path |
|
||||
@@ -108,6 +110,24 @@ 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`:
|
||||
@@ -116,10 +136,34 @@ 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
|
||||
- Sessions expire after 30 days
|
||||
- Passwords are hashed with Argon2
|
||||
- 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)
|
||||
|
||||
+100
-23
@@ -8,16 +8,27 @@
|
||||
- Content supports full Markdown syntax
|
||||
- Auto-save on editor close
|
||||
|
||||
### Markdown Support
|
||||
- **Bold** (`**text**`)
|
||||
- *Italic* (`*text*`)
|
||||
### 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)
|
||||
- Headings (`# H1` through `###### H6`)
|
||||
- Blockquotes (`> quote`)
|
||||
- Unordered lists (`- item`)
|
||||
- Ordered lists (`1. item`)
|
||||
- Code blocks (triple backtick)
|
||||
- Inline code (single backtick)
|
||||
- Tables
|
||||
- Links (auto-linkified URLs)
|
||||
- Links — insert, edit, and remove with URL input
|
||||
- Tables (insert, add/delete rows and columns)
|
||||
- Dividers (horizontal rules)
|
||||
- Text alignment (left, center, right, justify)
|
||||
- Line breaks
|
||||
|
||||
### Checklists / Task Lists
|
||||
@@ -26,12 +37,23 @@
|
||||
- Check/uncheck items with checkboxes
|
||||
- Checked items get strikethrough styling
|
||||
- Enter key adds new item, Backspace removes empty items
|
||||
- **Nested checklists**: single-level nesting for sub-tasks
|
||||
- Tab / Shift+Tab to indent or outdent an item (keyboard)
|
||||
- Drag handle horizontal swipe for mobile indentation
|
||||
- Checking a parent item checks all its children
|
||||
- Done section groups checked children under their parent label
|
||||
|
||||
### Image Attachments
|
||||
- Upload images via file picker or drag-and-drop
|
||||
- Supports JPEG, PNG, GIF, WebP, SVG
|
||||
- Max file size: 10MB
|
||||
- Thumbnail previews in note cards
|
||||
- **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
|
||||
- Remove attachments from editor
|
||||
|
||||
### Tag Organization
|
||||
@@ -42,10 +64,16 @@
|
||||
- Tags inside code blocks and URLs are ignored
|
||||
|
||||
### Color-Coded Notes
|
||||
12 colors matching Google Keep's palette:
|
||||
12 note colors:
|
||||
- Default, Coral, Peach, Sand, Mint, Sage
|
||||
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
||||
- Colors adapt to light/dark mode
|
||||
- Colors adapt to note theme
|
||||
|
||||
### Dark Mode
|
||||
- Three-way theme toggle: System / Light / Dark (in Settings > Preferences)
|
||||
- Dark mode uses near-neutral warm ink surfaces (not a sepia-tinted parchment) — retro aesthetic preserved in both themes
|
||||
- Respects system `prefers-color-scheme` preference by default
|
||||
- FOUC-free: theme applied before first paint via a blocking inline script
|
||||
|
||||
### Pin Notes
|
||||
- Pin important notes to always appear at top
|
||||
@@ -63,35 +91,84 @@
|
||||
- Restore notes from trash
|
||||
- Permanently delete from trash
|
||||
|
||||
### Note Sharing / Collaboration
|
||||
- **Public link sharing**: share any note via a public URL — no login required for viewers
|
||||
- Toggle public link on/off from the share dialog
|
||||
- Copy shareable URL with one click
|
||||
- Read-only public page with full markdown/checklist/image rendering
|
||||
- Public links are automatically invalidated when a note is trashed
|
||||
- Rate-limited public endpoints (60 req/min per IP)
|
||||
- Globe icon on note cards indicates a public link is active
|
||||
- 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
|
||||
- Sharing indicator on note cards (Globe for public, Users for collaborators-only)
|
||||
- Hover popover showing sharing status and 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
|
||||
|
||||
### Dark Mode
|
||||
- System preference detection (auto)
|
||||
- Manual toggle override
|
||||
- Preference persisted in localStorage
|
||||
- All components adapt to theme
|
||||
- Note colors adjust for dark backgrounds
|
||||
### 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
|
||||
|
||||
### 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
|
||||
### 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
|
||||
|
||||
### PWA / Offline-First
|
||||
- Installable as standalone app
|
||||
- Web app manifest with icons
|
||||
- Service worker for asset caching
|
||||
- IndexedDB for local data storage
|
||||
- Background sync every 30 seconds
|
||||
- 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)
|
||||
- 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; pin, 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
@@ -0,0 +1,142 @@
|
||||
# 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 |
|
||||
+393
-1
@@ -168,6 +168,202 @@ 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/notes/{id}/share:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
post:
|
||||
summary: Create a public share link
|
||||
description: Owner-only. Creates a public share URL for the note. Idempotent — returns existing token if already shared.
|
||||
tags: [Sharing]
|
||||
responses:
|
||||
'201':
|
||||
description: Share link created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
'404':
|
||||
description: Note not found or not owner
|
||||
delete:
|
||||
summary: Revoke a public share link
|
||||
description: Owner-only. Removes the public share link for the note.
|
||||
tags: [Sharing]
|
||||
responses:
|
||||
'204':
|
||||
description: Share link revoked
|
||||
'404':
|
||||
description: Note not found or not owner
|
||||
|
||||
/api/shared/{token}:
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
summary: Get a shared note (public)
|
||||
description: No authentication required. Returns the note content for a valid share token. Rate limited to 60 requests per minute per IP.
|
||||
tags: [Sharing]
|
||||
security: []
|
||||
responses:
|
||||
'200':
|
||||
description: Shared note data
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SharedNoteData'
|
||||
'404':
|
||||
description: Invalid token or note is trashed
|
||||
'429':
|
||||
description: Rate limit exceeded
|
||||
|
||||
/api/shared/{token}/attachment/{attachmentId}:
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: attachmentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
summary: Get an attachment from a shared note (public)
|
||||
description: No authentication required. Serves image binary for an attachment belonging to the shared note. Rate limited.
|
||||
tags: [Sharing]
|
||||
security: []
|
||||
parameters:
|
||||
- name: thumb
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: ['1']
|
||||
description: Set to '1' to get the thumbnail version
|
||||
responses:
|
||||
'200':
|
||||
description: Image binary
|
||||
content:
|
||||
image/*:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'404':
|
||||
description: Invalid token, note trashed, or attachment not found
|
||||
'429':
|
||||
description: Rate limit exceeded
|
||||
|
||||
/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
|
||||
@@ -185,9 +381,21 @@ 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 or file content
|
||||
description: Attachment list (without attachmentId) or file content (with attachmentId)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Attachment'
|
||||
post:
|
||||
summary: Upload an image attachment
|
||||
tags: [Attachments]
|
||||
@@ -203,6 +411,10 @@ 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
|
||||
@@ -306,6 +518,93 @@ 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:
|
||||
@@ -313,8 +612,53 @@ components:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: session
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: API key (crumbs_...)
|
||||
|
||||
schemas:
|
||||
PublicAttachment:
|
||||
type: object
|
||||
description: Sanitized attachment metadata for public sharing (no server paths)
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
filename:
|
||||
type: string
|
||||
mimeType:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
featured:
|
||||
type: boolean
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
SharedNoteData:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
content:
|
||||
type: string
|
||||
checklistMode:
|
||||
type: boolean
|
||||
color:
|
||||
type: string
|
||||
enum: [default, coral, peach, sand, mint, sage, fog, storm, dusk, blossom, clay, chalk]
|
||||
attachments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PublicAttachment'
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
Success:
|
||||
type: object
|
||||
properties:
|
||||
@@ -340,6 +684,10 @@ components:
|
||||
type: boolean
|
||||
trashed:
|
||||
type: boolean
|
||||
trashedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
checklistMode:
|
||||
type: boolean
|
||||
sortOrder:
|
||||
@@ -356,10 +704,18 @@ 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:
|
||||
@@ -399,6 +755,10 @@ 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
|
||||
@@ -418,3 +778,35 @@ 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
|
||||
|
||||
@@ -0,0 +1,781 @@
|
||||
# Dark Mode 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:** Add a dark parchment theme variant with system/light/dark toggle in Settings > Preferences.
|
||||
|
||||
**Architecture:** CSS `[data-theme="dark"]` attribute on `<html>` overrides CSS variables in `app.css`. A blocking inline script in `app.html` prevents FOUC. Theme preference uses the existing `UserPreferences` key-value system. A reactive `isDarkMode` rune in `theme.svelte.ts` drives note card color switching.
|
||||
|
||||
**Tech Stack:** SvelteKit, Svelte 5 runes, CSS custom properties, Tailwind CSS v4, Vitest, Playwright
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-20-dark-mode-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Modify | `src/app.css` | Add `--grid-dot` to `:root`, add `[data-theme="dark"]` block, update `body::before` |
|
||||
| Modify | `src/app.html` | Add FOUC-prevention inline script |
|
||||
| Modify | `src/lib/types/preferences.ts` | Add `theme` field to `UserPreferences` |
|
||||
| Create | `src/lib/utils/theme.svelte.ts` | `applyTheme()`, reactive `isDarkMode` rune |
|
||||
| Modify | `src/lib/utils/colors.ts` | Add `NOTE_COLORS_DARK`, export `getNoteColor()` |
|
||||
| Modify | `src/routes/+layout.svelte` | Add theme `$effect` watcher |
|
||||
| Modify | `src/routes/(app)/settings/preferences/+page.svelte` | Add theme segmented toggle |
|
||||
| Modify | `src/lib/components/NoteCard.svelte` | Use `getNoteColor()` with `isDarkMode` |
|
||||
| Modify | `src/lib/components/NoteEditor.svelte` | Use `getNoteColor()` with `isDarkMode` |
|
||||
| Modify | `src/lib/components/ColorPicker.svelte` | Use dark color swatches when `isDarkMode` |
|
||||
| Skip | `src/routes/(share)/s/[token]/+page.svelte` | Out of scope per spec (public share pages) |
|
||||
| Modify | `.gitignore` | Add `.superpowers/` |
|
||||
| Create | `tests/unit/theme.test.ts` | Unit tests for `applyTheme` and `isDarkMode` |
|
||||
| Create | `tests/unit/colors.test.ts` | Unit tests for `getNoteColor` |
|
||||
| Create | `tests/e2e/dark-mode.spec.ts` | E2E tests for theme toggle, persistence, system detection |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: CSS Dark Theme Variables
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app.css:6-23` (`:root` block) and `src/app.css:36` (`body::before`)
|
||||
|
||||
- [ ] **Step 1: Add `--grid-dot` to `:root` and add `[data-theme="dark"]` block**
|
||||
|
||||
In `src/app.css`, add `--grid-dot: #1a1a2e;` to the end of the `:root` block, then add the dark theme block after `:root`:
|
||||
|
||||
```css
|
||||
: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);
|
||||
--grid-dot: #1a1a2e;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #1a1715;
|
||||
--bg-surface: #2a2520;
|
||||
--text: #e8dcc8;
|
||||
--text-muted: #9a8e7e;
|
||||
--border: #e8dcc8;
|
||||
--border-subtle: #3a3530;
|
||||
--destructive: #d4604e;
|
||||
--error-bg: #3a2020;
|
||||
--error-border: #5a3030;
|
||||
--error-text: #e8a090;
|
||||
--success-bg: #203020;
|
||||
--success-text: #90c880;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #e8dcc8;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `body::before` to use `--grid-dot` variable**
|
||||
|
||||
Replace the hardcoded color in the `body::before` rule:
|
||||
|
||||
```css
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.03;
|
||||
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
|
||||
background-size: 4px 4px;
|
||||
z-index: 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify by temporarily adding `data-theme="dark"` to `app.html`**
|
||||
|
||||
In `src/app.html`, temporarily change `<html lang="en">` to `<html lang="en" data-theme="dark">`. Run `make dev` and visually confirm dark background and light text. Then revert the change.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app.css
|
||||
git commit -m "feat: add dark theme CSS variables and grid-dot extraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Theme Preference Type
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/types/preferences.ts`
|
||||
|
||||
- [ ] **Step 1: Add `theme` field to `UserPreferences`**
|
||||
|
||||
```typescript
|
||||
export interface UserPreferences {
|
||||
theme: 'system' | 'light' | 'dark';
|
||||
defaultNoteMode: 'richtext' | 'markdown';
|
||||
defaultNoteColor: NoteColor;
|
||||
hideFooter: boolean;
|
||||
sidebarDefaultState: 'open' | 'collapsed';
|
||||
notifyOnShare: boolean;
|
||||
notifyOnCollabRemoved: boolean;
|
||||
notifyOnNoteDeleted: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
theme: 'system',
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
sidebarDefaultState: 'open',
|
||||
notifyOnShare: true,
|
||||
notifyOnCollabRemoved: true,
|
||||
notifyOnNoteDeleted: true
|
||||
};
|
||||
```
|
||||
|
||||
Do NOT add `theme` to `BOOLEAN_PREF_KEYS`.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/types/preferences.ts
|
||||
git commit -m "feat: add theme preference type (system/light/dark)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Theme Utility — `applyTheme` and `isDarkMode`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/utils/theme.svelte.ts`
|
||||
- Test: `tests/unit/theme.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for theme utility**
|
||||
|
||||
Note: `theme.svelte.ts` uses module-scope `$state` which requires the Svelte compiler. The Vitest node environment does not process `.svelte.ts` runes correctly, so we test only the DOM side-effects of `applyTheme` (attribute and meta tag changes). The reactive `isDarkMode` / `getIsDarkMode()` behavior is covered by E2E tests instead.
|
||||
|
||||
Create `tests/unit/theme.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock $app/environment
|
||||
vi.mock('$app/environment', () => ({ browser: true }));
|
||||
|
||||
describe('applyTheme', () => {
|
||||
let applyTheme: (theme: 'system' | 'light' | 'dark') => void;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset DOM
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.remove();
|
||||
const metaEl = document.createElement('meta');
|
||||
metaEl.setAttribute('name', 'theme-color');
|
||||
metaEl.setAttribute('content', '#f0e6d3');
|
||||
document.head.appendChild(metaEl);
|
||||
|
||||
// Reset module state
|
||||
vi.resetModules();
|
||||
const mod = await import('$lib/utils/theme.svelte.js');
|
||||
applyTheme = mod.applyTheme;
|
||||
});
|
||||
|
||||
it('sets data-theme="dark" for dark mode', () => {
|
||||
applyTheme('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('removes data-theme for light mode', () => {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
applyTheme('light');
|
||||
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
||||
});
|
||||
|
||||
it('updates meta theme-color for dark mode', () => {
|
||||
applyTheme('dark');
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
expect(meta?.getAttribute('content')).toBe('#1a1715');
|
||||
});
|
||||
|
||||
it('updates meta theme-color for light mode', () => {
|
||||
applyTheme('dark');
|
||||
applyTheme('light');
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
expect(meta?.getAttribute('content')).toBe('#f0e6d3');
|
||||
});
|
||||
|
||||
it('falls back to system for unknown values', () => {
|
||||
// matchMedia returns false by default in jsdom → resolves to light
|
||||
applyTheme('invalid' as 'system' | 'light' | 'dark');
|
||||
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves system preference to dark when prefers-color-scheme is dark', () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-color-scheme: dark)',
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}))
|
||||
});
|
||||
applyTheme('system');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pnpm vitest run tests/unit/theme.test.ts`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Implement `theme.svelte.ts`**
|
||||
|
||||
Create `src/lib/utils/theme.svelte.ts`:
|
||||
|
||||
```typescript
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const LIGHT_THEME_COLOR = '#f0e6d3';
|
||||
const DARK_THEME_COLOR = '#1a1715';
|
||||
|
||||
let darkMode = $state(false);
|
||||
|
||||
export function getIsDarkMode(): boolean {
|
||||
return darkMode;
|
||||
}
|
||||
|
||||
export function applyTheme(theme: 'system' | 'light' | 'dark'): void {
|
||||
if (!browser) return;
|
||||
|
||||
// Validate input
|
||||
if (theme !== 'system' && theme !== 'light' && theme !== 'dark') {
|
||||
theme = 'system';
|
||||
}
|
||||
|
||||
// Resolve system preference
|
||||
let resolved = theme;
|
||||
if (theme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
const isDark = resolved === 'dark';
|
||||
darkMode = isDark;
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) {
|
||||
meta.setAttribute('content', isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pnpm vitest run tests/unit/theme.test.ts`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/utils/theme.svelte.ts tests/unit/theme.test.ts
|
||||
git commit -m "feat: add theme utility with applyTheme and isDarkMode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Note Card Dark Colors
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/utils/colors.ts`
|
||||
- Test: `tests/unit/colors.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for `getNoteColor`**
|
||||
|
||||
Create `tests/unit/colors.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getNoteColor, NOTE_COLORS, NOTE_COLORS_DARK } from '$lib/utils/colors.js';
|
||||
|
||||
describe('getNoteColor', () => {
|
||||
it('returns light color when isDark is false', () => {
|
||||
expect(getNoteColor('default', false)).toBe('#faf5eb');
|
||||
expect(getNoteColor('coral', false)).toBe('#faafa8');
|
||||
});
|
||||
|
||||
it('returns dark color when isDark is true', () => {
|
||||
expect(getNoteColor('default', true)).toBe('#2a2520');
|
||||
expect(getNoteColor('coral', true)).toBe('#4a2522');
|
||||
});
|
||||
|
||||
it('returns default color for unknown color name', () => {
|
||||
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
|
||||
expect(getNoteColor('unknown' as any, true)).toBe('#2a2520');
|
||||
});
|
||||
|
||||
it('has matching keys in light and dark color maps', () => {
|
||||
const lightKeys = Object.keys(NOTE_COLORS).sort();
|
||||
const darkKeys = Object.keys(NOTE_COLORS_DARK).sort();
|
||||
expect(lightKeys).toEqual(darkKeys);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pnpm vitest run tests/unit/colors.test.ts`
|
||||
Expected: FAIL — `getNoteColor` and `NOTE_COLORS_DARK` not found
|
||||
|
||||
- [ ] **Step 3: Add dark colors and helper to `colors.ts`**
|
||||
|
||||
Update `src/lib/utils/colors.ts`:
|
||||
|
||||
```typescript
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
|
||||
default: { bg: '#faf5eb', label: 'Default' },
|
||||
coral: { bg: '#faafa8', label: 'Coral' },
|
||||
peach: { bg: '#f39f76', label: 'Peach' },
|
||||
sand: { bg: '#fff8b8', label: 'Sand' },
|
||||
mint: { bg: '#e2f6d3', label: 'Mint' },
|
||||
sage: { bg: '#b4ddd3', label: 'Sage' },
|
||||
fog: { bg: '#d4e4ed', label: 'Fog' },
|
||||
storm: { bg: '#aeccdc', label: 'Storm' },
|
||||
dusk: { bg: '#d3bfdb', label: 'Dusk' },
|
||||
blossom: { bg: '#f6e2dd', label: 'Blossom' },
|
||||
clay: { bg: '#e9e3d4', label: 'Clay' },
|
||||
chalk: { bg: '#efeff1', label: 'Chalk' }
|
||||
};
|
||||
|
||||
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
|
||||
default: { bg: '#2a2520', label: 'Default' },
|
||||
coral: { bg: '#4a2522', label: 'Coral' },
|
||||
peach: { bg: '#4a3020', label: 'Peach' },
|
||||
sand: { bg: '#3a3520', label: 'Sand' },
|
||||
mint: { bg: '#2a3a22', label: 'Mint' },
|
||||
sage: { bg: '#223a32', label: 'Sage' },
|
||||
fog: { bg: '#222e3a', label: 'Fog' },
|
||||
storm: { bg: '#1e2a35', label: 'Storm' },
|
||||
dusk: { bg: '#352540', label: 'Dusk' },
|
||||
blossom: { bg: '#3a2830', label: 'Blossom' },
|
||||
clay: { bg: '#302e28', label: 'Clay' },
|
||||
chalk: { bg: '#2e2e30', label: 'Chalk' }
|
||||
};
|
||||
|
||||
export function getNoteColor(color: NoteColor, isDark: boolean): string {
|
||||
const map = isDark ? NOTE_COLORS_DARK : NOTE_COLORS;
|
||||
return (map[color] ?? map.default).bg;
|
||||
}
|
||||
|
||||
export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]) => ({
|
||||
value: value as NoteColor,
|
||||
label
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pnpm vitest run tests/unit/colors.test.ts`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/utils/colors.ts tests/unit/colors.test.ts
|
||||
git commit -m "feat: add dark note card colors and getNoteColor helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: FOUC Prevention Script
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app.html`
|
||||
|
||||
- [ ] **Step 1: Add inline script to `app.html`**
|
||||
|
||||
Insert after the `<meta name="theme-color">` tag (line 6) and before `%sveltekit.head%` (line 15):
|
||||
|
||||
```html
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
|
||||
var t = p.theme || 'system';
|
||||
if (t !== 'light' && t !== 'dark') t = 'system';
|
||||
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
if (t === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
var m = document.querySelector('meta[name="theme-color"]');
|
||||
if (m) m.content = '#1a1715';
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app.html
|
||||
git commit -m "feat: add FOUC-prevention script for dark mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Reactive Theme in Root Layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/+layout.svelte`
|
||||
|
||||
- [ ] **Step 1: Add theme `$effect` to root layout**
|
||||
|
||||
The root layout currently imports CSS and registers the PWA service worker. Add the theme `$effect` here so both `(app)` and `(auth)` layouts get the theme applied. **Important:** Keep `initPreferences()` in `(app)/+layout.svelte` — do NOT move it here. The `(app)` layout reads `sidebarDefaultState` immediately after init, and moving init to the root layout would create a race condition. The root layout only watches the preference reactively via `$effect`, which fires after preferences are loaded.
|
||||
|
||||
On auth pages (login/setup), `initPreferences()` hasn't been called, so `prefs.theme` will be `'system'` (the default). The FOUC script already handles the initial theme from localStorage, and the `$effect` will apply the correct theme once it runs.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
import { browser } from '$app/environment';
|
||||
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import { applyTheme } from '$lib/utils/theme.svelte.js';
|
||||
|
||||
let { children } = $props();
|
||||
let mediaQuery: MediaQueryList | undefined;
|
||||
let systemListener: ((e: MediaQueryListEvent) => void) | undefined;
|
||||
|
||||
const prefs = $derived(getPreferences());
|
||||
|
||||
onMount(async () => {
|
||||
if (pwaInfo) {
|
||||
const { registerSW } = await import('virtual:pwa-register');
|
||||
registerSW({
|
||||
immediate: true,
|
||||
onRegistered(r: ServiceWorkerRegistration | undefined) {
|
||||
console.log('SW Registered:', r);
|
||||
},
|
||||
onRegisterError(error: Error) {
|
||||
console.log('SW registration error', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const theme = prefs.theme ?? 'system';
|
||||
applyTheme(theme);
|
||||
|
||||
// Listen for system preference changes when in system mode
|
||||
if (systemListener && mediaQuery) {
|
||||
mediaQuery.removeEventListener('change', systemListener);
|
||||
}
|
||||
if (theme === 'system') {
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
systemListener = () => applyTheme('system');
|
||||
mediaQuery.addEventListener('change', systemListener);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (systemListener && mediaQuery) {
|
||||
mediaQuery.removeEventListener('change', systemListener);
|
||||
}
|
||||
});
|
||||
|
||||
const webManifest = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{@html webManifest}
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
**Note:** `initPreferences()` stays in `(app)/+layout.svelte`. The root layout only adds the theme `$effect` and does not call `initPreferences()`.
|
||||
|
||||
- [ ] **Step 2: Verify dev server works**
|
||||
|
||||
Run `make dev`, open the app, go to Settings > Preferences. The theme toggle doesn't exist yet, but the app should render without errors. Check browser console for any issues.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/+layout.svelte src/routes/\(app\)/+layout.svelte
|
||||
git commit -m "feat: add reactive theme watcher in root layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Theme Toggle in Settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/(app)/settings/preferences/+page.svelte`
|
||||
|
||||
- [ ] **Step 1: Add theme segmented toggle**
|
||||
|
||||
Add as the first preference section (before "Default note mode"):
|
||||
|
||||
```svelte
|
||||
<!-- Theme -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-[var(--text)]">Theme</span>
|
||||
<div class="flex gap-1" role="group" aria-label="Theme">
|
||||
{#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
|
||||
<button
|
||||
onclick={() => updatePreference('theme', value)}
|
||||
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.theme === value ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
|
||||
data-testid="pref-theme-{value}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Manually test**
|
||||
|
||||
Run `make dev`. Go to Settings > Preferences. Click System / Light / Dark and confirm:
|
||||
- Clicking "Dark" immediately applies dark theme
|
||||
- Clicking "Light" reverts to light theme
|
||||
- Clicking "System" follows OS preference
|
||||
- Refreshing the page persists the choice (no FOUC)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/\(app\)/settings/preferences/+page.svelte
|
||||
git commit -m "feat: add theme toggle to preferences settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Update Components to Use Dark Note Colors
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteCard.svelte:2,29-32`
|
||||
- Modify: `src/lib/components/NoteEditor.svelte:12,194-197`
|
||||
- Modify: `src/lib/components/ColorPicker.svelte`
|
||||
|
||||
**Important:** `getIsDarkMode()` must be called **inside** `$effect` or `$derived` blocks for reactivity. Calling it as a top-level `const` would capture the value once and never update when the theme changes. Each component already has an `$effect` block where it computes card/bg styles — add `getIsDarkMode()` inside those existing blocks.
|
||||
|
||||
Public share page (`src/routes/(share)/s/[token]/+page.svelte`) is out of scope per the spec.
|
||||
|
||||
- [ ] **Step 1: Update `NoteCard.svelte`**
|
||||
|
||||
Replace `NOTE_COLORS` import with `getNoteColor` and `getIsDarkMode`:
|
||||
|
||||
```svelte
|
||||
<!-- Change import at top of script -->
|
||||
import { getNoteColor } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
|
||||
<!-- Inside the existing $effect block where cardStyle is computed (around lines 29-32): -->
|
||||
$effect(() => {
|
||||
// ... existing logic ...
|
||||
const bg = getNoteColor(note.color, getIsDarkMode());
|
||||
cardStyle = `background-color: ${bg}`;
|
||||
});
|
||||
```
|
||||
|
||||
Remove the `NOTE_COLORS` import. `getIsDarkMode()` is called inside the `$effect` so it re-runs when the theme changes.
|
||||
|
||||
- [ ] **Step 2: Update `NoteEditor.svelte`**
|
||||
|
||||
Same pattern — inside the existing `$effect` where `bgStyle` is computed:
|
||||
|
||||
```svelte
|
||||
import { getNoteColor } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
|
||||
<!-- Inside the existing $effect (around lines 194-197): -->
|
||||
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
|
||||
```
|
||||
|
||||
Remove the `NOTE_COLORS` import.
|
||||
|
||||
- [ ] **Step 3: Update `ColorPicker.svelte`**
|
||||
|
||||
The ColorPicker shows circular swatches. Use `$derived` for the dark mode check so swatches update reactively:
|
||||
|
||||
```svelte
|
||||
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
|
||||
<!-- Use getIsDarkMode() in the template directly (it's reactive in Svelte 5 template expressions): -->
|
||||
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
|
||||
```
|
||||
|
||||
Remove `NOTE_COLORS` import if no longer needed.
|
||||
|
||||
- [ ] **Step 4: Verify all components render correctly in both themes**
|
||||
|
||||
Run `make dev`. Toggle between light and dark in Settings > Preferences. Check:
|
||||
- Note cards show correct dark/light background colors
|
||||
- Color picker shows dark/light swatches
|
||||
- Note editor uses correct background color
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteCard.svelte src/lib/components/NoteEditor.svelte src/lib/components/ColorPicker.svelte
|
||||
git commit -m "feat: use dark note card colors in all components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: E2E Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/dark-mode.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Write E2E tests**
|
||||
|
||||
Create `tests/e2e/dark-mode.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Dark mode', () => {
|
||||
test('Scenario: Theme toggle persists selection across page reload', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the user is on the preferences page
|
||||
await page.goto('/settings/preferences');
|
||||
|
||||
// When the user selects dark theme
|
||||
await page.getByTestId('pref-theme-dark').click();
|
||||
|
||||
// Then the page applies dark theme
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
// When the page is reloaded
|
||||
await page.reload();
|
||||
|
||||
// Then the dark theme persists without flash
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
await expect(page.getByTestId('pref-theme-dark')).toHaveClass(/font-medium/);
|
||||
});
|
||||
|
||||
test('Scenario: Light mode removes dark theme attribute', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given dark theme is active
|
||||
await page.goto('/settings/preferences');
|
||||
await page.getByTestId('pref-theme-dark').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
// When the user selects light theme
|
||||
await page.getByTestId('pref-theme-light').click();
|
||||
|
||||
// Then the dark theme attribute is removed
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-theme');
|
||||
});
|
||||
|
||||
test('Scenario: System mode follows OS dark preference', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given the OS prefers dark mode
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
|
||||
// When the user selects system theme
|
||||
await page.goto('/settings/preferences');
|
||||
await page.getByTestId('pref-theme-system').click();
|
||||
|
||||
// Then the page applies dark theme from system preference
|
||||
// Use toHaveAttribute with timeout to handle async $effect execution
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark', { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Scenario: Note cards use dark colors in dark mode', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given dark theme is active
|
||||
await page.goto('/settings/preferences');
|
||||
await page.getByTestId('pref-theme-dark').click();
|
||||
|
||||
// When viewing the notes page
|
||||
await page.goto('/');
|
||||
|
||||
// Then note cards should have dark background colors
|
||||
const noteCard = page.getByTestId('note-card').first();
|
||||
if (await noteCard.isVisible()) {
|
||||
const bg = await noteCard.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
// Dark default color is #2a2520 = rgb(42, 37, 32)
|
||||
expect(bg).not.toBe('rgb(250, 245, 235)'); // Not the light default
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run E2E tests**
|
||||
|
||||
Run: `pnpm test:e2e tests/e2e/dark-mode.spec.ts`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/dark-mode.spec.ts
|
||||
git commit -m "test: add E2E tests for dark mode toggle and persistence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Housekeeping and Type Check
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitignore`
|
||||
|
||||
- [ ] **Step 1: Add `.superpowers/` to `.gitignore`**
|
||||
|
||||
Append to `.gitignore`:
|
||||
|
||||
```
|
||||
# Superpowers brainstorm artifacts
|
||||
.superpowers/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run type check**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: No errors. If there are type errors, fix them.
|
||||
|
||||
- [ ] **Step 3: Run full test suite**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All unit and E2E tests pass.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitignore
|
||||
git commit -m "chore: add .superpowers/ to gitignore"
|
||||
```
|
||||
@@ -0,0 +1,557 @@
|
||||
# Dark Theme Palette Rework 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 the dark theme's palette with a systematically generated one, and decouple the colour-picker swatch from the note card surface so the twelve colours are distinguishable.
|
||||
|
||||
**Architecture:** Purely a colour-data and CSS-variable change. `NOTE_COLORS_DARK` in `src/lib/utils/colors.ts` gets new values; a new `NOTE_CHIPS_DARK` map plus `getNoteChip()` serves the picker only. The `[data-theme="dark"]` block in `src/app.css` is rewritten. Consumers (`NoteCard`, `NoteEditor`, the public share page) already resolve colour through `getNoteColor()` and need no changes — only `ColorPicker.svelte` switches functions.
|
||||
|
||||
**Tech Stack:** SvelteKit + Svelte 5 runes, Tailwind CSS v4, Vitest (unit), Playwright (e2e), pnpm.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-31-dark-palette-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Light mode must not change. Any diff touching `NOTE_COLORS` (the light map) or `:root` colour values other than the new `--accent-check` is out of scope.
|
||||
- Never hardcode Tailwind colours (`text-gray-500`, `bg-red-600`). Always reference CSS variables. (Project rule, `CLAUDE.md`.)
|
||||
- Run `pnpm check` before every commit — not just tests. (Project rule.)
|
||||
- Run `pnpm build` before `pnpm test:e2e` locally, or e2e tests run against a stale bundle.
|
||||
- Conventional commit prefixes: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`.
|
||||
- Exact dark palette values are given verbatim in each task. Do not re-derive or "improve" them — they were generated to hold WCAG AA against `--text` and `--text-muted` on all twelve surfaces, and hand-tweaking breaks that.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: New dark note-card colours
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/utils/colors.ts:18-31` (the `NOTE_COLORS_DARK` map)
|
||||
- Test: `src/lib/utils/colors.test.ts`
|
||||
- Modify: `tests/e2e/public-sharing.spec.ts` (asserted dark `fog` value)
|
||||
- Modify: `tests/e2e/dark-mode.spec.ts:84` (stale comment)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from earlier tasks.
|
||||
- Produces: `NOTE_COLORS_DARK` with the values below. `getNoteColor(color: NoteColor, isDark: boolean): string` keeps its existing signature. Task 2 relies on `getNoteColor` being unchanged; Task 2's chip values must differ from every value here.
|
||||
|
||||
- [ ] **Step 1: Write the failing invariant tests**
|
||||
|
||||
Add to `src/lib/utils/colors.test.ts`. These assert the *properties* that broke, not the hex strings — a hex-only test would have passed happily on the old broken palette.
|
||||
|
||||
```ts
|
||||
/** WCAG relative luminance. */
|
||||
function relativeLuminance(hex: string): number {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const channels = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrastRatio(a: string, b: string): number {
|
||||
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
describe('dark note colours', () => {
|
||||
// Must track [data-theme="dark"] in src/app.css.
|
||||
const DARK_TEXT = '#ece3d3';
|
||||
const DARK_TEXT_MUTED = '#b3a695';
|
||||
|
||||
it('keeps body text above the AA threshold on every dark card', () => {
|
||||
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
|
||||
expect(contrastRatio(bg, DARK_TEXT), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps muted text above the AA threshold on every dark card', () => {
|
||||
// This is the invariant the old palette broke: muted/struck-through text
|
||||
// became unreadable on the lighter surfaces.
|
||||
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
|
||||
expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the twelve surfaces within a narrow lightness band so they read as a family', () => {
|
||||
const luminances = Object.values(NOTE_COLORS_DARK).map((c) => relativeLuminance(c.bg));
|
||||
expect(Math.max(...luminances) - Math.min(...luminances)).toBeLessThan(0.03);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: FAIL. Only the two hex-pinning assertions fail (they still pin the old values). The AA
|
||||
tests are forward guards, not regressions of the old palette — the old palette actually clears
|
||||
both AA thresholds. The distinguishability test is the one that would fail on the old palette
|
||||
(its closest pair, `fog`/`storm`, sits at RGB distance 7.5).
|
||||
|
||||
- [ ] **Step 3: Replace the dark palette**
|
||||
|
||||
In `src/lib/utils/colors.ts`, replace the whole `NOTE_COLORS_DARK` map with these values exactly:
|
||||
|
||||
```ts
|
||||
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
|
||||
default: { bg: '#21201f', label: 'Default' },
|
||||
coral: { bg: '#3f1f1c', label: 'Coral' },
|
||||
peach: { bg: '#3f2b1c', label: 'Peach' },
|
||||
sand: { bg: '#3c371b', label: 'Sand' },
|
||||
mint: { bg: '#2a3d1d', label: 'Mint' },
|
||||
sage: { bg: '#1f3d36', label: 'Sage' },
|
||||
fog: { bg: '#253946', label: 'Fog' },
|
||||
storm: { bg: '#1d283d', label: 'Storm' },
|
||||
dusk: { bg: '#3d2843', label: 'Dusk' },
|
||||
blossom: { bg: '#3d2129', label: 'Blossom' },
|
||||
clay: { bg: '#363126', label: 'Clay' },
|
||||
chalk: { bg: '#303036', label: 'Chalk' }
|
||||
};
|
||||
```
|
||||
|
||||
Note for the implementer: `blossom` and `storm` intentionally shift hue relative to their light counterparts (toward pink and true blue). At higher chroma the original hues collided with `coral`/`peach` and `fog`. This is deliberate — see the spec.
|
||||
|
||||
- [ ] **Step 4: Update the two hex assertions that pin the old values**
|
||||
|
||||
In `src/lib/utils/colors.test.ts`, the existing `getNoteColor` tests assert old hexes. Change them:
|
||||
|
||||
```ts
|
||||
it('returns dark color when isDark is true', () => {
|
||||
expect(getNoteColor('default', true)).toBe('#21201f');
|
||||
expect(getNoteColor('coral', true)).toBe('#3f1f1c');
|
||||
});
|
||||
|
||||
it('returns default color for unknown color name', () => {
|
||||
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
|
||||
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the unit tests to verify they pass**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: PASS, all tests in the file.
|
||||
|
||||
- [ ] **Step 6: Update the e2e test that asserts a dark note colour**
|
||||
|
||||
`tests/e2e/public-sharing.spec.ts` — the "Shared note follows a dark-mode visitor theme" scenario asserts dark `fog`. `#253946` is `rgb(37, 57, 70)`:
|
||||
|
||||
```ts
|
||||
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(37, 57, 70)'
|
||||
);
|
||||
```
|
||||
|
||||
In `tests/e2e/dark-mode.spec.ts:84`, the assertion itself is a negative one (`not.toBe` light default) and needs no change, but its comment names the old hex. Update it:
|
||||
|
||||
```ts
|
||||
// Dark default color is #21201f = rgb(33, 32, 31); NOT light default rgb(250, 245, 235)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify types, then the full e2e suites that touch colour**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: 0 errors.
|
||||
|
||||
Run: `pnpm build && pnpm test:e2e public-sharing dark-mode organization`
|
||||
Expected: PASS. (`organization.spec.ts` exercises the picker but asserts no hex, so it should be unaffected — it is in the list to prove that.)
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts tests/e2e/public-sharing.spec.ts tests/e2e/dark-mode.spec.ts
|
||||
git commit -m "feat(theme): rebuild dark note colours as a consistent family"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Decouple picker swatches from card surfaces
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/utils/colors.ts` (add `NOTE_CHIPS_DARK` and `getNoteChip`)
|
||||
- Modify: `src/lib/components/ColorPicker.svelte:2,19`
|
||||
- Test: `src/lib/utils/colors.test.ts`
|
||||
- Test: `tests/e2e/dark-mode.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `NOTE_COLORS_DARK` and `getNoteColor(color: NoteColor, isDark: boolean): string` from Task 1.
|
||||
- Produces: `NOTE_CHIPS_DARK: Record<NoteColor, string>` and `getNoteChip(color: NoteColor, isDark: boolean): string`. No later task depends on these.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `src/lib/utils/colors.test.ts`:
|
||||
|
||||
```ts
|
||||
describe('getNoteChip', () => {
|
||||
it('returns the card colour in light mode, where pastels already are the cards', () => {
|
||||
for (const color of Object.keys(NOTE_COLORS) as NoteColor[]) {
|
||||
expect(getNoteChip(color, false)).toBe(getNoteColor(color, false));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a distinct vivid chip in dark mode, not the card surface', () => {
|
||||
for (const color of Object.keys(NOTE_COLORS_DARK) as NoteColor[]) {
|
||||
expect(getNoteChip(color, true)).not.toBe(getNoteColor(color, true));
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to default for an unknown colour', () => {
|
||||
expect(getNoteChip('unknown' as any, true)).toBe(NOTE_CHIPS_DARK.default);
|
||||
});
|
||||
|
||||
it('has matching keys with the colour maps', () => {
|
||||
expect(Object.keys(NOTE_CHIPS_DARK).sort()).toEqual(Object.keys(NOTE_COLORS_DARK).sort());
|
||||
});
|
||||
|
||||
it('keeps every pair of chips far enough apart to tell apart at 28px', () => {
|
||||
// The regression this guards: at low chroma coral/peach/blossom and
|
||||
// fog/storm collapse into each other.
|
||||
const entries = Object.entries(NOTE_CHIPS_DARK);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
for (let j = i + 1; j < entries.length; j++) {
|
||||
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
|
||||
const toRgb = (hex: string) => {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
};
|
||||
const [ra, ga, ba] = toRgb(a);
|
||||
const [rb, gb, bb] = toRgb(b);
|
||||
const distance = Math.hypot(ra - rb, ga - gb, ba - bb);
|
||||
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Extend the import at the top of the file:
|
||||
|
||||
```ts
|
||||
import {
|
||||
getNoteColor,
|
||||
getNoteChip,
|
||||
NOTE_COLORS,
|
||||
NOTE_COLORS_DARK,
|
||||
NOTE_CHIPS_DARK
|
||||
} from '$lib/utils/colors.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: FAIL — `getNoteChip is not a function` / `NOTE_CHIPS_DARK` undefined.
|
||||
|
||||
- [ ] **Step 3: Add the chip map and accessor**
|
||||
|
||||
Append to `src/lib/utils/colors.ts`, after `getNoteColor`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Picker swatches are labels, not surfaces. A 28px circle at card lightness
|
||||
* reads as a grey dot — small patches lose apparent saturation — so the dark
|
||||
* picker uses vivid mid-lightness chips that share the card's hue but not its
|
||||
* lightness. Light mode needs no equivalent: there the pastels are the cards.
|
||||
*/
|
||||
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
|
||||
default: '#8d857c',
|
||||
coral: '#d04f43',
|
||||
peach: '#cd7c42',
|
||||
sand: '#c7b138',
|
||||
mint: '#7bbd4c',
|
||||
sage: '#47b89d',
|
||||
fog: '#609abe',
|
||||
storm: '#547bc0',
|
||||
dusk: '#a965bd',
|
||||
blossom: '#c05d78',
|
||||
clay: '#b5954a',
|
||||
chalk: '#c3c3c8'
|
||||
};
|
||||
|
||||
export function getNoteChip(color: NoteColor, isDark: boolean): string {
|
||||
if (!isDark) return getNoteColor(color, false);
|
||||
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Point the picker at the chips**
|
||||
|
||||
In `src/lib/components/ColorPicker.svelte`, change the import on line 2 and the style on line 19:
|
||||
|
||||
```svelte
|
||||
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
|
||||
```
|
||||
|
||||
```svelte
|
||||
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
|
||||
```
|
||||
|
||||
Leave everything else alone — the 2px `--border-subtle` ring stays, and it's what gives each swatch its boundary regardless of fill.
|
||||
|
||||
- [ ] **Step 6: Add the e2e scenario**
|
||||
|
||||
Append inside the `test.describe.serial('Dark mode', ...)` block in `tests/e2e/dark-mode.spec.ts`:
|
||||
|
||||
```ts
|
||||
test('Scenario: Colour picker swatches are identifiable in dark mode', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given dark theme is active
|
||||
await page.goto('/settings/preferences');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByTestId('pref-theme-dark').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
// When the user opens the colour picker while editing a note
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Swatch Test');
|
||||
await page.getByTestId('color-picker-toggle').click();
|
||||
await expect(page.getByTestId('color-picker')).toBeVisible();
|
||||
|
||||
// Then a swatch shows its vivid chip rather than the muted card surface
|
||||
await expect(page.getByTestId('color-coral')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(208, 79, 67)'
|
||||
);
|
||||
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: 0 errors.
|
||||
|
||||
Run: `pnpm build && pnpm test:e2e dark-mode organization`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts src/lib/components/ColorPicker.svelte tests/e2e/dark-mode.spec.ts
|
||||
git commit -m "feat(theme): give the dark colour picker vivid identifiable chips"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: New dark CSS variables and checkbox accent
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app.css:6-24` (`:root` — add `--accent-check` only), `src/app.css:26-42` (the `[data-theme="dark"]` block), `src/app.css:44-48` (`accent-color`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing. The `--text`/`--text-muted` values here must match the constants asserted in Task 1's tests (`#ece3d3`, `#b3a695`).
|
||||
- Produces: `--accent-check` custom property, available in both themes.
|
||||
|
||||
- [ ] **Step 1: Add `--accent-check` to `:root`**
|
||||
|
||||
In the `:root` block in `src/app.css`, after `--grid-dot`:
|
||||
|
||||
```css
|
||||
--grid-dot: #1a1a2e;
|
||||
--accent-check: var(--primary);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the dark block**
|
||||
|
||||
Replace the entire `[data-theme="dark"]` block with:
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #141312;
|
||||
--bg-surface: #1e1c1b;
|
||||
--primary: #e0a030;
|
||||
--primary-hover: #f2b855;
|
||||
--text: #ece3d3;
|
||||
--text-muted: #b3a695;
|
||||
--border: #57514b;
|
||||
--border-subtle: #302d2b;
|
||||
--destructive: #e2705c;
|
||||
--error-bg: #3a2320;
|
||||
--error-border: #5c3a34;
|
||||
--error-text: #f0a898;
|
||||
--success-bg: #1f2e1f;
|
||||
--success-text: #8fc97a;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #ece3d3;
|
||||
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
|
||||
--accent-check: #c8a44a;
|
||||
}
|
||||
```
|
||||
|
||||
Two of these reverse earlier decisions on purpose: `--primary` is no longer shared with light mode (`#C8860A` reads mustard on ink), and `--border` is a warm mid-grey rather than the mirrored cream `#e8dcc8` (a bright 1px line on a dark background reads as glare, not structure).
|
||||
|
||||
- [ ] **Step 3: Point `accent-color` at the new variable**
|
||||
|
||||
In the `html, body` rule:
|
||||
|
||||
```css
|
||||
html, body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
accent-color: var(--accent-check);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify light mode is untouched and dark mode renders**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: 0 errors.
|
||||
|
||||
Run: `pnpm build && pnpm test:e2e dark-mode public-sharing`
|
||||
Expected: PASS. Task 1's e2e assertions cover the dark note colours; nothing asserts the chrome variables, so this step is proving no regression rather than proving the change.
|
||||
|
||||
Then look at it: `pnpm preview`, open the app, switch to dark in Settings → Preferences, and check the notes grid, the sidebar, an open editor, and a checklist with done items. Contrast maths can't judge the pixel-grid overlay or the gold hover states.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app.css
|
||||
git commit -m "feat(theme): replace dark surfaces, borders and accents with the warm ink palette"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Sync the browser theme-colour with the new base
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/utils/theme.svelte.ts:4`
|
||||
- Modify: `src/app.html:21`
|
||||
- Test: `src/lib/utils/theme.test.ts:40`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `--bg-base` `#141312` from Task 3. These two hardcoded copies must equal it.
|
||||
- Produces: nothing.
|
||||
|
||||
- [ ] **Step 1: Update the failing test first**
|
||||
|
||||
In `src/lib/utils/theme.test.ts`:
|
||||
|
||||
```ts
|
||||
it('updates meta theme-color for dark mode', () => {
|
||||
applyTheme('dark');
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
expect(meta?.getAttribute('content')).toBe('#141312');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify it fails**
|
||||
|
||||
Run: `pnpm test:unit theme`
|
||||
Expected: FAIL — received `#1a1715`.
|
||||
|
||||
- [ ] **Step 3: Update both hardcoded copies**
|
||||
|
||||
`src/lib/utils/theme.svelte.ts` line 4:
|
||||
|
||||
```ts
|
||||
const DARK_THEME_COLOR = '#141312';
|
||||
```
|
||||
|
||||
`src/app.html` line 21, inside the FOUC script:
|
||||
|
||||
```js
|
||||
if (m) m.content = '#141312';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `pnpm test:unit theme`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Verify and commit**
|
||||
|
||||
Run: `pnpm check && pnpm test:unit`
|
||||
Expected: 0 errors, all unit tests pass.
|
||||
|
||||
```bash
|
||||
git add src/lib/utils/theme.svelte.ts src/app.html src/lib/utils/theme.test.ts
|
||||
git commit -m "fix(theme): sync browser theme-colour with the new dark base"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Document the palette
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (the "Design system: Retro Parchment" section)
|
||||
- Modify: `docs/ARCHITECTURE.md:155-165` (the dark mode section)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything from Tasks 1-4.
|
||||
- Produces: nothing.
|
||||
|
||||
- [ ] **Step 1: Document the dark palette in `CLAUDE.md`**
|
||||
|
||||
The colour table there lists only light values, which is why the dark palette drifted in the first place. After the existing table and the note about `src/lib/utils/colors.ts`, add:
|
||||
|
||||
```markdown
|
||||
### Dark theme
|
||||
|
||||
Dark mode overrides every colour variable in `[data-theme="dark"]` (`src/app.css`). It is not a
|
||||
mechanical inversion of light mode — two differences are deliberate:
|
||||
|
||||
- **`--primary` differs per theme.** `#C8860A` on parchment, `#e0a030` on ink. The light gold reads
|
||||
mustard on a dark background.
|
||||
- **`--border` is a warm mid-grey (`#57514b`), not mirrored cream.** A bright 1px line on a dark
|
||||
background reads as glare rather than structure; the hard offset shadow carries the retro framing.
|
||||
|
||||
Note colours have two representations, and they are not interchangeable:
|
||||
|
||||
| Map | Function | Used by | Role |
|
||||
|---|---|---|---|
|
||||
| `NOTE_COLORS` / `NOTE_COLORS_DARK` | `getNoteColor(color, isDark)` | Cards, editor, share page | Quiet **surface** you read text on |
|
||||
| `NOTE_CHIPS_DARK` | `getNoteChip(color, isDark)` | `ColorPicker` only | Vivid **label** you identify at a glance |
|
||||
|
||||
A dark card sits at ~18% lightness, where a 28px picker circle would read as a grey dot — small
|
||||
patches lose apparent saturation. Never fill a picker swatch with `getNoteColor()`. In light mode
|
||||
`getNoteChip()` returns the pastel card colour, because there the pastels *are* the cards.
|
||||
|
||||
When changing any dark note colour, keep the invariants asserted in `src/lib/utils/colors.test.ts`:
|
||||
AA contrast against both `--text` and `--text-muted` on every surface, a narrow lightness band across
|
||||
the twelve, and no two chips closer than an RGB distance of 25.
|
||||
|
||||
`--bg-base` is duplicated as a literal in `src/app.html` (FOUC script) and
|
||||
`src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`) for the browser theme-colour. Update all three
|
||||
together.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `docs/ARCHITECTURE.md`**
|
||||
|
||||
In the dark mode section, the line describing note colours currently mentions only `getNoteColor()`. Replace it with:
|
||||
|
||||
```markdown
|
||||
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and
|
||||
returns the appropriate surface variant per theme. Picker swatches use `getNoteChip()` instead —
|
||||
in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker
|
||||
draws from a separate vivid `NOTE_CHIPS_DARK` map.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the docs match the code**
|
||||
|
||||
Re-read both edited sections against `src/lib/utils/colors.ts` and `src/app.css`. Every function name, map name, file path and hex value must exist as written. No verification command can catch a wrong hex in prose — check it by eye.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/ARCHITECTURE.md
|
||||
git commit -m "docs: document the dark palette and the card-vs-chip distinction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] `pnpm check` — 0 errors
|
||||
- [ ] `pnpm test:unit` — all pass
|
||||
- [ ] `pnpm build && pnpm test:e2e` — full suite passes
|
||||
- [ ] Manual pass in dark mode: notes grid, sidebar, header, open editor, checklist with done items, colour picker, a public share link, an error state (e.g. a failed login), and light mode unchanged
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
# Dark Mode / Theme Toggle — Design Spec
|
||||
|
||||
**Issue:** #26
|
||||
**Date:** 2026-03-20
|
||||
**Status:** Draft
|
||||
|
||||
## Summary
|
||||
|
||||
Add a dark variant of the retro parchment theme with a three-way toggle (system / light / dark) in Settings > Preferences. The dark theme preserves the retro 8-bit character — warm dark tones, not cold grays.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Toggle location:** Settings > Preferences only (no header icon)
|
||||
- **UI pattern:** Segmented buttons (system / light / dark), matching existing `defaultNoteMode` toggle style
|
||||
- **Implementation:** CSS `[data-theme="dark"]` attribute on `<html>`, variable overrides in `app.css`
|
||||
- **Pixel grid texture:** Inverted to light dots (`#e8dcc8`) at 3% opacity in dark mode
|
||||
- **Default:** `system` (follows `prefers-color-scheme`)
|
||||
- **Light mode:** No `data-theme` attribute set (`:root` defaults apply). `data-theme="dark"` is only set for dark mode. This means no redundant `[data-theme="light"]` block is needed.
|
||||
|
||||
## 1. CSS Variable Overrides
|
||||
|
||||
Add `--grid-dot` to the existing `:root` block and add a `[data-theme="dark"]` block after it in `app.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* ...existing vars... */
|
||||
--grid-dot: #1a1a2e; /* new — extracted from body::before */
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #1a1715;
|
||||
--bg-surface: #2a2520;
|
||||
--text: #e8dcc8;
|
||||
--text-muted: #9a8e7e;
|
||||
--border: #e8dcc8;
|
||||
--border-subtle: #3a3530;
|
||||
--destructive: #d4604e;
|
||||
--error-bg: #3a2020;
|
||||
--error-border: #5a3030;
|
||||
--error-text: #e8a090;
|
||||
--success-bg: #203020;
|
||||
--success-text: #90c880;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #e8dcc8;
|
||||
}
|
||||
```
|
||||
|
||||
`--primary` and `--primary-hover` remain unchanged — gold works on both backgrounds.
|
||||
|
||||
`--card-shadow` uses `var(--border-subtle)` in both modes for consistency — the dark `--border-subtle` (#3a3530) produces the right effect.
|
||||
|
||||
Update `body::before` to use the new variable:
|
||||
```css
|
||||
body::before {
|
||||
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Theme Preference Storage
|
||||
|
||||
Extend the existing `UserPreferences` system:
|
||||
|
||||
- **Type change:** Add `theme: 'system' | 'light' | 'dark'` to `UserPreferences` interface
|
||||
- **Default:** `'system'`
|
||||
- **Storage:** Same key-value `userPreferences` table + localStorage cache — no schema migration needed
|
||||
- **Do NOT add `theme` to `BOOLEAN_PREF_KEYS`** — it's a string enum, not a boolean
|
||||
|
||||
Files to modify:
|
||||
- `src/lib/types/preferences.ts` — add `theme` field and default
|
||||
|
||||
The existing `fromServerRecord` in `preferences.svelte.ts` does not validate string values. The `applyTheme` function (Section 4) must guard against unexpected values and fall back to `'system'` if the value is not one of the three valid options.
|
||||
|
||||
## 3. FOUC Prevention
|
||||
|
||||
Add a blocking `<script>` in `app.html` `<head>`, **after** the `<meta name="theme-color">` tag and **before** `%sveltekit.head%`:
|
||||
|
||||
```html
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
|
||||
var t = p.theme || 'system';
|
||||
if (t !== 'light' && t !== 'dark') t = 'system';
|
||||
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
if (t === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
var m = document.querySelector('meta[name="theme-color"]');
|
||||
if (m) m.content = '#1a1715';
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
This runs synchronously before first paint. The script validates the theme value and null-checks the meta element.
|
||||
|
||||
## 4. Reactive Theme Application
|
||||
|
||||
Create `src/lib/utils/theme.svelte.ts` (`.svelte.ts` to enable Svelte 5 runes) with:
|
||||
|
||||
- `applyTheme(theme: 'system' | 'light' | 'dark')` — resolves system preference, sets/removes `data-theme="dark"` on `document.documentElement`, updates `<meta name="theme-color">`. Guards against unexpected values (falls back to `'system'`). All DOM/`matchMedia` access guarded with `browser` check from `$app/environment`.
|
||||
- `isDarkMode` — a reactive `$state` boolean, updated by `applyTheme` and the `matchMedia` listener. Components import this to conditionally use dark color variants.
|
||||
|
||||
Place the theme `$effect` in the **root `+layout.svelte`** (not just `(app)` layout). This ensures both `(app)` and `(auth)` layout groups get the theme applied. The root layout currently only imports CSS — this adds a small `$effect` that watches `preferences.theme` and calls `applyTheme`. It also registers a `matchMedia('(prefers-color-scheme: dark)')` change listener when in system mode, cleaned up on destroy.
|
||||
|
||||
## 5. Note Card Dark Colors
|
||||
|
||||
Add a parallel `NOTE_COLORS_DARK` map in `src/lib/utils/colors.ts`:
|
||||
|
||||
| Color | Light | Dark |
|
||||
|-------|-------|------|
|
||||
| default | `#faf5eb` | `#2a2520` |
|
||||
| coral | `#faafa8` | `#4a2522` |
|
||||
| peach | `#f39f76` | `#4a3020` |
|
||||
| sand | `#fff8b8` | `#3a3520` |
|
||||
| mint | `#e2f6d3` | `#2a3a22` |
|
||||
| sage | `#b4ddd3` | `#223a32` |
|
||||
| fog | `#d4e4ed` | `#222e3a` |
|
||||
| storm | `#aeccdc` | `#1e2a35` |
|
||||
| dusk | `#d3bfdb` | `#352540` |
|
||||
| blossom | `#f6e2dd` | `#3a2830` |
|
||||
| clay | `#e9e3d4` | `#302e28` |
|
||||
| chalk | `#efeff1` | `#2e2e30` |
|
||||
|
||||
Export a helper `getNoteColor(color: NoteColor, isDark: boolean): string` that returns the correct bg hex.
|
||||
|
||||
Components (`NoteCard.svelte`, `NoteEditor.svelte`, `ColorPicker.svelte`) import `isDarkMode` from `theme.svelte.ts` and pass it to `getNoteColor`. This is the single approach — do not use direct DOM inspection for dark mode detection.
|
||||
|
||||
## 6. Settings UI
|
||||
|
||||
Add a "Theme" section to `src/routes/(app)/settings/preferences/+page.svelte` as the **first** preference (before "Default note mode"):
|
||||
|
||||
```svelte
|
||||
<!-- Theme -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-[var(--text)]">Theme</span>
|
||||
<div class="flex gap-1" role="group" aria-label="Theme">
|
||||
{#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
|
||||
<button
|
||||
onclick={() => updatePreference('theme', value)}
|
||||
class="rounded-sm border px-4 py-2 text-sm transition-colors ..."
|
||||
data-testid="pref-theme-{value}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Test IDs: `pref-theme-system`, `pref-theme-light`, `pref-theme-dark`.
|
||||
|
||||
## 7. Landing Page
|
||||
|
||||
Out of scope for this spec. The landing page (`website/`) uses its own color system separate from `app.css`. Dark mode support for the landing page will be tracked separately.
|
||||
|
||||
## 8. Prose / Typography Overrides
|
||||
|
||||
The `.prose` overrides in `app.css` already use CSS variables, so they'll automatically adapt. No changes needed.
|
||||
|
||||
The TipTap editor styles (table borders, selected cells, task lists) also use CSS variables. No changes needed.
|
||||
|
||||
## 9. Color Picker in Dark Mode
|
||||
|
||||
The `ColorPicker` component shows circular swatches. In dark mode, these should display the dark color variants. The component imports `isDarkMode` from `theme.svelte.ts` and uses `getNoteColor(color, isDarkMode)` to get the correct swatch background.
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### Unit tests
|
||||
- `theme.svelte.ts` — test `applyTheme` sets correct attribute and meta tag, validates unknown values
|
||||
- `colors.ts` — test `getNoteColor` returns correct color for light/dark
|
||||
|
||||
### E2E tests
|
||||
- Theme toggle in preferences persists and applies (use `data-testid` selectors)
|
||||
- System preference detection (use Playwright's `page.emulateMedia({ colorScheme: 'dark' })`)
|
||||
- No FOUC on page reload in dark mode
|
||||
- Note cards render with correct dark colors
|
||||
|
||||
## 11. PWA Manifest
|
||||
|
||||
The PWA manifest (`@vite-pwa/sveltekit`) contains a static `theme_color`. This cannot be dynamically changed. The manifest will keep the light theme color (`#f0e6d3`). The `<meta name="theme-color">` is updated dynamically by the FOUC script and `applyTheme`, which handles the browser chrome color. This is a known limitation of PWA manifests and is acceptable.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Header quick-toggle icon
|
||||
- Per-note theme override
|
||||
- Custom color theme builder
|
||||
- Dark mode for public shared note pages (can be added later)
|
||||
- Dark mode for landing page (separate task)
|
||||
@@ -0,0 +1,232 @@
|
||||
# Public Note Sharing — Design Spec
|
||||
|
||||
**Issue**: [#7 — Public links and shared notes](https://github.com/bretzel-app/crumbs/issues/7)
|
||||
**Date**: 2026-03-20
|
||||
|
||||
## Overview
|
||||
|
||||
Allow sharing individual notes via a public URL that anyone can view without authentication. Read-only sharing via unguessable tokens. Integrates into the existing ShareDialog alongside collaborator management.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Share UI location | Inside existing ShareDialog | One "Share" entry point for all sharing (public + collaborators) |
|
||||
| Expiry | Deferred to future version | Simpler v1; active/revoked toggle only |
|
||||
| Rate limiting | In-memory per-IP | Lightweight, resets on restart; token entropy handles enumeration |
|
||||
| Architecture | Separated shares-service | Follows collaborators-service pattern; clean module boundary |
|
||||
| Sharing icon | Globe (public) or Users (collaborators) | Single icon slot; tooltip shows combined sharing state |
|
||||
|
||||
## Schema
|
||||
|
||||
New `sharedNotes` table in `src/lib/server/db/schema.ts`:
|
||||
|
||||
```typescript
|
||||
export const sharedNotes = sqliteTable('shared_notes', {
|
||||
noteId: text('note_id').primaryKey().references(() => notes.id, { onDelete: 'cascade' }),
|
||||
token: text('token').notNull().unique(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }), // NULL = no expiry, reserved for future use
|
||||
});
|
||||
```
|
||||
|
||||
- `noteId` as primary key — one share per note, no separate `id` needed
|
||||
- `integer` timestamps with `mode: 'timestamp'` — matches all other tables in the schema
|
||||
- `createdAt` set in service layer (`new Date()`), not via SQL default
|
||||
- CASCADE delete — deleting the note removes the share
|
||||
- Token: `nanoid(21)` — ~128 bits of entropy
|
||||
- No update to `deleteUser()` needed — shares cascade through notes (foreign keys enabled via `PRAGMA foreign_keys = ON` in db connection)
|
||||
|
||||
## Types
|
||||
|
||||
Add to `src/lib/types/index.ts`:
|
||||
|
||||
```typescript
|
||||
export interface NoteShare {
|
||||
noteId: string;
|
||||
token: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
```
|
||||
|
||||
Extend the `Note` interface:
|
||||
|
||||
```typescript
|
||||
export interface Note {
|
||||
// ...existing fields
|
||||
shareToken?: string; // populated during hydration if a public share exists
|
||||
}
|
||||
```
|
||||
|
||||
## Service Layer
|
||||
|
||||
New file: `src/lib/server/shares-service.ts`
|
||||
|
||||
| Function | Params | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `createShare` | `db, noteId, userId` | `{ token }` | Verify ownership, generate nanoid(21), insert row, return token. If share already exists, return existing token. |
|
||||
| `revokeShare` | `db, noteId, userId` | `void` | Verify ownership, delete share row |
|
||||
| `getShareByNoteId` | `db, noteId` | `NoteShare \| null` | Return share if exists (for ShareDialog) |
|
||||
| `getSharedNote` | `db, token` | `SharedNoteData \| null` | Public — fetch note by token, return null if trashed or not found |
|
||||
| `fetchSharesForNotes` | `db, noteIds` | `Map<string, string>` | Batch fetch — returns Map of noteId → token for hydration (only token needed for NoteCard indicator) |
|
||||
|
||||
`SharedNoteData` type (returned to public viewers):
|
||||
|
||||
```typescript
|
||||
interface SharedNoteData {
|
||||
title: string;
|
||||
content: string;
|
||||
checklistMode: boolean;
|
||||
color: NoteColor;
|
||||
attachments: Attachment[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### `POST /api/notes/[id]/share`
|
||||
|
||||
- **Auth**: Required (owner only)
|
||||
- **Body**: None
|
||||
- **Response**: `{ token: string, url: string }`
|
||||
- **Behavior**: Creates share or returns existing one. URL constructed from request origin + `/s/${token}`
|
||||
|
||||
### `DELETE /api/notes/[id]/share`
|
||||
|
||||
- **Auth**: Required (owner only)
|
||||
- **Response**: `204 No Content`
|
||||
- **Behavior**: Deletes share row. Link immediately returns 404.
|
||||
|
||||
### `GET /api/shared/[token]`
|
||||
|
||||
- **Auth**: None (public)
|
||||
- **Rate limit**: 60 requests/min per IP (in-memory)
|
||||
- **Response**: `SharedNoteData` JSON
|
||||
- **Error cases**:
|
||||
- Invalid/unknown token → 404
|
||||
- Note is trashed → 404
|
||||
- Rate limited → 429
|
||||
|
||||
### `GET /api/shared/[token]/attachment/[attachmentId]`
|
||||
|
||||
- **Auth**: None (public)
|
||||
- **Rate limit**: Same as above
|
||||
- **Response**: Image binary (proxied from attachment storage)
|
||||
- **Behavior**: Verify attachment belongs to the shared note, then serve
|
||||
|
||||
## Routes
|
||||
|
||||
```
|
||||
src/routes/
|
||||
(share)/
|
||||
+layout.svelte # Minimal layout: parchment bg, centered, no app chrome
|
||||
s/[token]/
|
||||
+page.server.ts # Load shared note by token via shares-service
|
||||
+page.svelte # Read-only note renderer
|
||||
```
|
||||
|
||||
### Auth Middleware
|
||||
|
||||
Add `/s/` and `/api/shared` to `PUBLIC_PATHS` in `src/hooks.server.ts`. Both are needed — `/s/` for the public page route, `/api/shared` for the public API endpoints.
|
||||
|
||||
### Public Page Design
|
||||
|
||||
- Parchment background (`--bg-base`)
|
||||
- Centered content card (`--bg-surface`, `--border-subtle`, `--card-shadow`)
|
||||
- Note title in JetBrains Mono
|
||||
- Rendered markdown content or checklist (read-only)
|
||||
- Inline images from attachments
|
||||
- No tags, no edit controls, no sidebar, no navigation
|
||||
- Footer: "POWERED BY CRUMBS" in Press Start 2P pixel font, links to project
|
||||
|
||||
## UI Components
|
||||
|
||||
### ShareDialog Changes
|
||||
|
||||
Add a "Public link" section **above** the existing collaborators section:
|
||||
|
||||
**Inactive state:**
|
||||
- Globe icon + "Public link" label + toggle switch (off)
|
||||
- Subtitle: "Anyone with the link can view this note (read-only)"
|
||||
|
||||
**Active state:**
|
||||
- Globe icon + "Public link" label + toggle switch (on, gold)
|
||||
- URL input (readonly) + "Copy" button
|
||||
- Subtitle: "Toggle off to revoke access. The link will return a 404."
|
||||
- Border highlight in `--primary` gold
|
||||
|
||||
Toggle on → calls `POST /api/notes/[id]/share`, displays URL
|
||||
Toggle off → calls `DELETE /api/notes/[id]/share`, hides URL
|
||||
Copy button → clipboard API, brief "Copied!" feedback
|
||||
|
||||
### NoteCard Share Indicator
|
||||
|
||||
Single icon slot in top-right of NoteCard (replaces showing both icons):
|
||||
|
||||
| State | Icon | Color |
|
||||
|---|---|---|
|
||||
| Public link active | `Globe` (Lucide) | `--primary` gold |
|
||||
| Collaborators only | `Users` (Lucide) | `--text-muted` |
|
||||
| Both | `Globe` (Lucide) | `--primary` gold |
|
||||
| Neither | No icon | — |
|
||||
|
||||
**Hover tooltip** shows combined sharing info regardless of which icon is displayed:
|
||||
- "Shared publicly via link" (if public share exists)
|
||||
- "Shared with N collaborator(s)" (if collaborators exist)
|
||||
- Both lines if both exist
|
||||
|
||||
### NoteEditor Share Button
|
||||
|
||||
Existing Share button behavior unchanged. The Globe/Users icon logic from NoteCard also applies to the editor toolbar button to indicate current sharing state.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
New file: `src/lib/server/ip-rate-limit.ts` — separate from the existing DB-backed `rate-limit.ts` (which is login-specific).
|
||||
|
||||
```typescript
|
||||
// In-memory store: Map<IP, { count: number, windowStart: number }>
|
||||
// 60 requests per minute per IP
|
||||
// Applied to GET /api/shared/[token] and GET /api/shared/[token]/attachment/[id]
|
||||
```
|
||||
|
||||
Resets on server restart. No database table needed.
|
||||
|
||||
## Hydration
|
||||
|
||||
`hydrateNotes()` in `notes-service.ts` must be updated to call `fetchSharesForNotes()` and populate `shareToken` on each note. This follows the existing pattern where `hydrateNotes` already batch-fetches tags, attachments, and collaborators.
|
||||
|
||||
## Security
|
||||
|
||||
- Tokens are unguessable: nanoid(21) = ~128 bits of entropy
|
||||
- `/s/` and `/api/shared` routes bypass auth middleware — only these prefixes
|
||||
- Trashed notes return 404 even if share token is valid
|
||||
- Only note owners can create/revoke shares — collaborators cannot
|
||||
- No user information exposed on public page (no username, no email, no tags)
|
||||
- Rate limiting prevents brute-force enumeration
|
||||
- Attachments served through share-specific route (validates attachment belongs to shared note)
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
- Expiry dates / auto-expire
|
||||
- Password-protected shares
|
||||
- Edit access through share links
|
||||
- Share analytics / view counts
|
||||
- Email notifications for share events
|
||||
- E2EE interaction (no E2EE exists)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Share toggle in ShareDialog creates/revokes public link
|
||||
- [ ] Unique nanoid(21) token generated per shared note
|
||||
- [ ] Public `/s/[token]` route renders note without auth
|
||||
- [ ] Copy link to clipboard with feedback
|
||||
- [ ] Revoke share → link returns 404
|
||||
- [ ] Globe indicator on NoteCard for publicly shared notes
|
||||
- [ ] Combined tooltip showing all sharing state
|
||||
- [ ] Trashed notes return 404 even if shared
|
||||
- [ ] Minimal, parchment-styled public page with "Powered by Crumbs"
|
||||
- [ ] In-memory rate limiting on public endpoints
|
||||
- [ ] Attachments accessible on public page
|
||||
@@ -0,0 +1,322 @@
|
||||
# Screenshot Automation for Landing Page
|
||||
|
||||
## Overview
|
||||
|
||||
A Playwright-based script that seeds the Crumbs app with bretzel-themed demo data, captures 10 screenshots (7 desktop + 3 mobile) for the landing page, and commits them. Triggered manually via a GitHub Actions workflow.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
scripts/
|
||||
screenshots.ts # Main orchestrator: seed + capture
|
||||
fixtures/
|
||||
bretzel-pixel.ts # Generates a pixel-art bretzel PNG programmatically
|
||||
```
|
||||
|
||||
Single script (`scripts/screenshots.ts`) that:
|
||||
1. Spawns the preview server as a child process (`pnpm preview --port 4173`), same approach as `playwright.config.ts` uses for e2e tests. The app uses `@sveltejs/adapter-node`, so `vite preview` import won't work — must spawn via CLI.
|
||||
2. Deletes any existing screenshot DB to start fresh
|
||||
3. Seeds data via API calls (setup admin, login, create notes/users/keys)
|
||||
4. Navigates to each view and captures screenshots at specific viewports
|
||||
5. Saves PNGs directly to `website/assets/`
|
||||
6. Kills the preview server in `finally` block
|
||||
|
||||
## Seed Data — Bretzel Universe
|
||||
|
||||
### Users
|
||||
|
||||
| User | Email | Role | Purpose |
|
||||
|------|-------|------|---------|
|
||||
| Bretzel Admin | admin@bretzel.app | admin | Main user, owns all notes |
|
||||
| Alice Chen | alice@bretzel.app | user | Collaborator for sharing screenshot |
|
||||
|
||||
### Tags
|
||||
|
||||
Passed explicitly in the `tags` array of each `POST /api/notes` request body: `baking`, `bretzel`, `dev`, `devops`, `homelab`, `personal`, `reading`, `weekend`. Tags are NOT auto-parsed from hashtags in content — they must be provided as a separate field.
|
||||
|
||||
### Notes
|
||||
|
||||
| # | Title | Color | Mode | Tags | Pinned | Special |
|
||||
|---|-------|-------|------|------|--------|---------|
|
||||
| 1 | Bretzel Ingredients | sand | checklist | #baking, #bretzel | yes | Checklist items (see below) |
|
||||
| 2 | Self-Hosting Stack | fog | rich text | #homelab, #devops | no | Homelab list (see below) |
|
||||
| 3 | Back to Retro Gaming | peach | rich text | #reading, #personal | no | Retro gaming note |
|
||||
| 4 | The Best Software Is the Software You Own | coral | rich text | #personal, #bretzel | no | Self-hosting manifesto |
|
||||
| 5 | Weekend Plans | mint | rich text | #personal, #weekend | no | Casual weekend plans |
|
||||
| 6 | Ancient Flute Harmonica Discovered | chalk | rich text | #reading | no | Quirky news note |
|
||||
| 7 | Baking code for the... | clay | checklist | #dev, #bretzel | no | Dev/baking crossover |
|
||||
|
||||
### Note Content Details
|
||||
|
||||
**Bretzel Ingredients** (checklist):
|
||||
- [x] 500g bread flour
|
||||
- [x] 300ml warm water
|
||||
- [x] 10g salt
|
||||
- [ ] 7g dry yeast
|
||||
- [ ] 30g butter (softened)
|
||||
- [ ] Baking soda for the bath
|
||||
- [ ] Coarse salt for topping
|
||||
|
||||
**Self-Hosting Stack** (rich text — used for editor screenshot):
|
||||
```
|
||||
Current homelab setup:
|
||||
|
||||
**Crumbs** — notes (obviously)
|
||||
**42** — holiday budget tracking
|
||||
**Dokploy** — deployment platform
|
||||
**Outline** — team wiki
|
||||
**Navidrome** — music streaming
|
||||
**Vaultwarden** — passwords
|
||||
**Nextcloud** — files & calendar
|
||||
**Plausible** — analytics
|
||||
|
||||
Everything below daily reverse proxy.
|
||||
```
|
||||
|
||||
**The Best Software Is the Software You Own** (rich text):
|
||||
```
|
||||
Self-host everything. Trust no cloud.
|
||||
|
||||
The best software is the software you own.
|
||||
Your data. Your server. Your rules.
|
||||
|
||||
If it can't run on your hardware, it's not really yours.
|
||||
```
|
||||
|
||||
**Back to Retro Gaming** (rich text):
|
||||
```
|
||||
Games to replay this summer:
|
||||
|
||||
Chrono Trigger (SNES) — best RPG ever made
|
||||
The Legend of Zelda: Link's Awakening (GB)
|
||||
Castlevania: Symphony of the Night (PS1)
|
||||
Advance Wars (GBA)
|
||||
Final Fantasy Tactics (PS1)
|
||||
|
||||
The pixel art era was peak game design.
|
||||
```
|
||||
|
||||
**Weekend Plans** (rich text):
|
||||
```
|
||||
Saturday:
|
||||
- Farmer's market (get sourdough starter)
|
||||
- Fix the Dokploy SSL cert renewal
|
||||
- Bake a batch of bretzels
|
||||
|
||||
Sunday:
|
||||
- Retro gaming marathon
|
||||
- Update the self-hosting stack
|
||||
- Plan next trip on 42
|
||||
```
|
||||
|
||||
**Ancient Flute Harmonica Discovered** (rich text):
|
||||
```
|
||||
Archaeologists found a 40,000-year-old flute carved from vulture bone.
|
||||
|
||||
The oldest known musical instrument.
|
||||
Imagine the first song ever played.
|
||||
```
|
||||
|
||||
**Baking code for the...** (checklist):
|
||||
```
|
||||
- [x] Set up CI/CD pipeline
|
||||
- [x] Write the CRDT sync logic
|
||||
- [ ] Bake the docker image
|
||||
- [ ] Deploy to the homelab
|
||||
- [ ] Celebrate with a bretzel
|
||||
```
|
||||
|
||||
### Image Attachments
|
||||
|
||||
**Pixel-art bretzel image** (`scripts/fixtures/bretzel-pixel.ts`):
|
||||
- Generated programmatically as a PNG using raw pixel data (no external dependencies)
|
||||
- ~64x64 pixel grid, bretzel shape in warm browns (#C8860A gold, #8B6914 dark, #F0E6D3 parchment bg)
|
||||
- Attached as featured image to "Bretzel Ingredients" note
|
||||
- Also create a second smaller image attached to the same note (e.g. a pixel-art salt shaker or oven) for visual variety
|
||||
|
||||
### Version History
|
||||
|
||||
Send 4-5 `PATCH /api/notes/:id` calls to "Self-Hosting Stack" with varying content. Version history entries are created as a side-effect of each PATCH — there is no explicit "create version" endpoint. Each version adds or changes a tool in the stack, so the diff is meaningful when viewed in the history panel.
|
||||
|
||||
### API Keys
|
||||
|
||||
Create via API after seeding:
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| Claude Code | For MCP integration screenshot |
|
||||
| N8N | For automation screenshot |
|
||||
|
||||
## Screenshot Capture Plan
|
||||
|
||||
### Desktop (1280x800)
|
||||
|
||||
| # | Filename | View | Capture Steps |
|
||||
|---|----------|------|---------------|
|
||||
| 1 | screenshot-grid.png | Home grid | Navigate to `/`, wait for notes to render, ensure sidebar is expanded with tags visible |
|
||||
| 2 | screenshot-editor.png | Note editor | Click "Self-Hosting Stack" note, wait for editor to open with content and toolbar visible |
|
||||
| 3 | screenshot-checklist.png | Checklist mode | Click "Bretzel Ingredients" note, wait for checklist items with checkboxes visible |
|
||||
| 4 | screenshot-sharing.png | Share dialog | Open a note, click share button, wait for ShareDialog with "Alice Chen" listed as collaborator |
|
||||
| 5 | screenshot-history.png | Version history | Open "Self-Hosting Stack", click history button, select a middle version in the list, wait for preview panel |
|
||||
| 6 | screenshot-attachments.png | Attachments | Open "Bretzel Ingredients" note (has featured bretzel image), ensure image thumbnails and featured strip visible |
|
||||
| 7 | screenshot-api.png | Settings > API/MCP | Navigate to `/settings/mcp`, wait for API keys list ("Claude Code", "N8N") and MCP config JSON block |
|
||||
|
||||
### Mobile (390x844)
|
||||
|
||||
| # | Filename | View | Capture Steps |
|
||||
|---|----------|------|---------------|
|
||||
| 1 | screenshot-mobile-grid.png | Home grid | Navigate to `/`, wait for notes in mobile layout (no sidebar, tags as horizontal pills) |
|
||||
| 2 | screenshot-mobile-editor.png | Note editor | Open "Self-Hosting Stack" note, wait for mobile editor |
|
||||
| 3 | screenshot-mobile-checklist.png | Checklist | Open "Bretzel Ingredients" note, wait for mobile checklist view |
|
||||
|
||||
### Capture Settings
|
||||
- Format: PNG
|
||||
- Full page: false (viewport only)
|
||||
- Animations disabled via `page.emulateMedia({ reducedMotion: 'reduce' })` to avoid capturing mid-animation
|
||||
- Wait for `networkidle` after each navigation before capturing
|
||||
|
||||
## GitHub Actions Workflow
|
||||
|
||||
**File**: `.github/workflows/screenshots.yml`
|
||||
|
||||
```yaml
|
||||
name: Screenshots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
screenshots:
|
||||
name: Capture Landing Page Screenshots
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers and system deps
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Build app
|
||||
run: pnpm build
|
||||
env:
|
||||
DATABASE_URL: ./data/screenshots.db
|
||||
|
||||
- name: Capture screenshots
|
||||
run: pnpm exec tsx scripts/screenshots.ts
|
||||
env:
|
||||
DATABASE_URL: ./data/screenshots.db
|
||||
|
||||
- name: Commit screenshots
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add website/assets/screenshot-*.png
|
||||
git diff --cached --quiet && echo "No screenshot changes" && exit 0
|
||||
git commit -m "chore: update landing page screenshots [skip ci]"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## Script Structure
|
||||
|
||||
```typescript
|
||||
// scripts/screenshots.ts
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { generateBretzelPixelArt } from './fixtures/bretzel-pixel';
|
||||
|
||||
const BASE = 'http://localhost:4173';
|
||||
const DESKTOP = { width: 1280, height: 800 };
|
||||
const MOBILE = { width: 390, height: 844 };
|
||||
const OUTPUT = 'website/assets';
|
||||
|
||||
async function main() {
|
||||
// 1. Start preview server (spawn child process, wait for port 4173)
|
||||
const server = await startPreviewServer();
|
||||
|
||||
// 2. Launch browser
|
||||
const browser = await chromium.launch();
|
||||
|
||||
try {
|
||||
// 3. Seed data (desktop context — API calls work at any viewport)
|
||||
const seedContext = await browser.newContext({ viewport: DESKTOP });
|
||||
const seedPage = await seedContext.newPage();
|
||||
await seed(seedPage);
|
||||
|
||||
// 4. Desktop screenshots
|
||||
await captureDesktop(seedPage);
|
||||
await seedContext.close();
|
||||
|
||||
// 5. Mobile screenshots
|
||||
const mobileContext = await browser.newContext({ viewport: MOBILE });
|
||||
const mobilePage = await mobileContext.newPage();
|
||||
await login(mobilePage);
|
||||
await captureMobile(mobilePage);
|
||||
await mobileContext.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function seed(page: Page) {
|
||||
// Setup admin via POST /api/auth/setup
|
||||
// Login via POST /api/auth/login (sets session cookie on page context)
|
||||
// Create notes via POST /api/notes (with content, color, tags:string[], pinned, checklistMode)
|
||||
// → capture note IDs from responses for later use
|
||||
// Create collaborator user via POST /api/admin/users
|
||||
// → capture alice.id (numeric) from response
|
||||
// Share a note via POST /api/notes/:id/collaborators { userId: alice.id }
|
||||
// Create version history: PATCH /api/notes/:id × 4-5 with varying content
|
||||
// Upload pixel-art bretzel image via POST /api/notes/:id/attachments (multipart form)
|
||||
// Create API keys via POST /api/settings/api-keys { name: "Claude Code" } and { name: "N8N" }
|
||||
}
|
||||
|
||||
async function captureDesktop(page: Page) {
|
||||
// D1: Grid view
|
||||
// D2: Editor (Self-Hosting Stack)
|
||||
// D3: Checklist (Bretzel Ingredients)
|
||||
// D4: Sharing dialog
|
||||
// D5: Version history
|
||||
// D6: Attachments
|
||||
// D7: API/MCP settings
|
||||
}
|
||||
|
||||
async function captureMobile(page: Page) {
|
||||
// M1: Grid view
|
||||
// M2: Editor
|
||||
// M3: Checklist
|
||||
}
|
||||
```
|
||||
|
||||
## Landing Page Updates
|
||||
|
||||
After the screenshots are generated, `website/index.html` needs updating:
|
||||
- Add new screenshot entries for: checklist, sharing, history, attachments, API
|
||||
- Update alt text and labels
|
||||
- Hero image stays as `screenshot-grid.png`
|
||||
- Mobile section adds checklist screenshot, replaces sidebar screenshot
|
||||
|
||||
## Dependencies
|
||||
|
||||
No new dependencies. Uses:
|
||||
- `playwright` (already in devDependencies for e2e tests)
|
||||
- `tsx` (already in devDependencies for other scripts)
|
||||
- Node.js built-in `fs`, `path`, `Buffer` for pixel-art PNG generation
|
||||
- SvelteKit's preview server (spawned as child process via `pnpm preview`)
|
||||
|
||||
## Open Questions
|
||||
|
||||
None — all decisions resolved during brainstorming.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Dark Theme Palette Rework — Design Spec
|
||||
|
||||
**Date:** 2026-07-31
|
||||
**Status:** Approved
|
||||
**Supersedes the palette portion of:** `2026-03-20-dark-mode-design.md`
|
||||
|
||||
## Summary
|
||||
|
||||
The dark theme's colours were authored one at a time rather than as a set. The result: twelve note
|
||||
colours that collapse into indistinguishable dark mud, a picker of twelve grey dots, sepia-tinted
|
||||
surfaces, and a near-white editor outline that reads as glare. This spec replaces the dark palette
|
||||
with a systematically generated one, and splits the picker swatch from the card surface — they are
|
||||
different jobs and should not share a value.
|
||||
|
||||
Light mode is unchanged.
|
||||
|
||||
## Problems being fixed
|
||||
|
||||
1. **Note colours are indistinguishable.** `coral #4a2522`, `peach #4a3020` and `clay #302e28` differ
|
||||
by a few percent lightness with almost no chroma. Lightness wanders across the set (17%–19%) with
|
||||
no consistent relationship, so they don't read as a family.
|
||||
2. **Several cards don't separate from the page.** `clay`, `chalk` and `default` sit within a few
|
||||
percent of `--bg-base`, so cards lose their edges.
|
||||
3. **The picker is unreadable.** `ColorPicker.svelte` fills each swatch with the card background. A
|
||||
28px circle at 18% lightness cannot read as a colour — small patches lose apparent saturation, so
|
||||
twelve swatches become twelve grey dots.
|
||||
4. **Surfaces are sepia.** `--bg-base #1a1715` / `--bg-surface #2a2520` are brown-tinted; combined
|
||||
with gold and cream on top, the UI reads dim rather than crisp.
|
||||
5. **The editor frame glares.** `--border` mirrors light mode's near-black ink as near-white cream.
|
||||
The mirror is false: dark-on-light edges absorb, light-on-dark edges emit. At equal contrast the
|
||||
inverted border reads as a glowing rectangle, not as structure.
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Decision | Choice |
|
||||
|---|---|---|
|
||||
| 1 | Base surfaces | **Warm ink** — pull the brown out, keep a trace of warmth |
|
||||
| 2 | Card lightness | **Deep tinted (~18%)** — colours identifiable, page still reads dark |
|
||||
| 3 | Picker swatches | **Vivid mid-lightness chips**, decoupled from the card colour |
|
||||
| 4 | Chip scope | **Dark mode only** — light mode keeps its pastel swatches |
|
||||
| 5 | Chrome | **Quiet frame** — `--border` becomes warm mid-grey, not cream |
|
||||
|
||||
On (4): in light mode the pastels genuinely *are* the card colours, so they preview correctly there.
|
||||
Only dark mode needs the split.
|
||||
|
||||
## 1. CSS variables (`src/app.css`)
|
||||
|
||||
Replace the `[data-theme="dark"]` block:
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #141312;
|
||||
--bg-surface: #1e1c1b;
|
||||
--primary: #e0a030;
|
||||
--primary-hover: #f2b855;
|
||||
--text: #ece3d3;
|
||||
--text-muted: #b3a695;
|
||||
--border: #57514b;
|
||||
--border-subtle: #302d2b;
|
||||
--destructive: #e2705c;
|
||||
--error-bg: #3a2320;
|
||||
--error-border: #5c3a34;
|
||||
--error-text: #f0a898;
|
||||
--success-bg: #1f2e1f;
|
||||
--success-text: #8fc97a;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #ece3d3;
|
||||
--accent-check: #c8a44a;
|
||||
}
|
||||
```
|
||||
|
||||
Three of these are notable changes rather than tweaks:
|
||||
|
||||
- **`--primary` is no longer shared across themes.** `#C8860A` was chosen for parchment; on ink it
|
||||
reads mustard. Dark mode gets `#e0a030` (8.17:1 on the page).
|
||||
- **`--border` becomes `#57514b`** instead of cream `#e8dcc8`. Frames the editor without glare; the
|
||||
hard offset shadow still carries the retro structure.
|
||||
- **`--text-muted` lifts `#9a8e7e` → `#b3a695`.** Required for AA on the tinted cards. This is the
|
||||
widest-reaching change in the spec — every muted element in dark mode shifts slightly.
|
||||
|
||||
### Checkbox accent
|
||||
|
||||
`accent-color: var(--primary)` makes a long done-list shout in dark mode. Add a dedicated variable so
|
||||
the checkbox can be dimmed independently of links and the brand:
|
||||
|
||||
```css
|
||||
:root { --accent-check: var(--primary); } /* light: unchanged */
|
||||
html, body { accent-color: var(--accent-check); }
|
||||
```
|
||||
|
||||
## 2. Note card colours (`src/lib/utils/colors.ts`)
|
||||
|
||||
Replace `NOTE_COLORS_DARK`. Every entry keeps its light counterpart's hue, holds lightness
|
||||
near-constant against today's values (measured average change: +4.1 saturation points, −0.18
|
||||
lightness points), and nudges two crowded hues apart (`blossom` and `storm` shift toward pink
|
||||
and true blue respectively) so the twelve read as distinguishable siblings rather than a smudge.
|
||||
The visible improvement in dark mode comes from the base surfaces, the picker chips, the
|
||||
softened border, and the lifted gold — not from the card surfaces, which are close in chroma to
|
||||
the previous dark values.
|
||||
|
||||
```ts
|
||||
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
|
||||
default: { bg: '#21201f', label: 'Default' },
|
||||
coral: { bg: '#3f1f1c', label: 'Coral' },
|
||||
peach: { bg: '#3f2b1c', label: 'Peach' },
|
||||
sand: { bg: '#3c371b', label: 'Sand' },
|
||||
mint: { bg: '#2a3d1d', label: 'Mint' },
|
||||
sage: { bg: '#1f3d36', label: 'Sage' },
|
||||
fog: { bg: '#253946', label: 'Fog' },
|
||||
storm: { bg: '#1d283d', label: 'Storm' },
|
||||
dusk: { bg: '#3d2843', label: 'Dusk' },
|
||||
blossom: { bg: '#3d2129', label: 'Blossom' },
|
||||
clay: { bg: '#363126', label: 'Clay' },
|
||||
chalk: { bg: '#303036', label: 'Chalk' }
|
||||
};
|
||||
```
|
||||
|
||||
**Two hues are deliberately nudged apart.** At low chroma, `coral`/`peach`/`blossom` are three
|
||||
barely-different oranges and `fog`/`storm` are two barely-different blues — invisible in light mode,
|
||||
but they collide once chroma rises. So in dark mode `blossom` moves toward pink (H344) and `storm`
|
||||
toward true blue (H218). Their light-mode values are untouched, so the same note is a slightly
|
||||
different hue in each theme. This is an accepted trade: distinguishable beats identical.
|
||||
|
||||
Measured on these values: body text ≥ 9.25:1, muted text ≥ 4.94:1, links ≥ 5.18:1 on every card.
|
||||
All clear WCAG AA.
|
||||
|
||||
## 3. Picker chips (`src/lib/utils/colors.ts`)
|
||||
|
||||
New map, used only by the picker, only in dark mode:
|
||||
|
||||
```ts
|
||||
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
|
||||
default: '#8d857c', coral: '#d04f43', peach: '#cd7c42', sand: '#c7b138',
|
||||
mint: '#7bbd4c', sage: '#47b89d', fog: '#609abe', storm: '#547bc0',
|
||||
dusk: '#a965bd', blossom: '#c05d78', clay: '#b5954a', chalk: '#c3c3c8'
|
||||
};
|
||||
|
||||
/** Picker swatch colour: a vivid label, not the card surface. */
|
||||
export function getNoteChip(color: NoteColor, isDark: boolean): string {
|
||||
if (!isDark) return getNoteColor(color, false); // light mode: pastel == card
|
||||
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
|
||||
}
|
||||
```
|
||||
|
||||
`default` and `chalk` stay deliberately neutral (grey and off-white) — they are the "no colour"
|
||||
slots. Chips are non-text UI, so WCAG's bar is 3:1, not 4:1; every chip clears that comfortably
|
||||
against `--bg-surface`, with the lowest at `coral` 3.95:1 (`storm` 4.02:1, `blossom` 4.10:1). The
|
||||
closest pair (`fog`/`storm`) is separated by an RGB distance of 33, which is comfortably tellable
|
||||
apart at 28px.
|
||||
|
||||
`ColorPicker.svelte` switches from `getNoteColor` to `getNoteChip`. Nothing else uses chips — cards,
|
||||
the editor and the public share page keep calling `getNoteColor`.
|
||||
|
||||
## 4. Theme-colour meta tag
|
||||
|
||||
`#1a1715` is hardcoded in two places that must stay in sync with `--bg-base`:
|
||||
|
||||
- `src/app.html` (the FOUC script, `m.content = '#1a1715'`)
|
||||
- `src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`)
|
||||
|
||||
Both become `#141312`.
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/app.css` | Rewrite `[data-theme="dark"]`; add `--accent-check` to `:root`; point `accent-color` at it |
|
||||
| `src/lib/utils/colors.ts` | New `NOTE_COLORS_DARK`; add `NOTE_CHIPS_DARK` + `getNoteChip()` |
|
||||
| `src/lib/components/ColorPicker.svelte` | Use `getNoteChip()` instead of `getNoteColor()` |
|
||||
| `src/app.html` | Theme-colour `#1a1715` → `#141312` |
|
||||
| `src/lib/utils/theme.svelte.ts` | `DARK_THEME_COLOR` → `#141312` |
|
||||
| `src/lib/utils/colors.test.ts` | Update dark `default` assertions; add `getNoteChip` coverage |
|
||||
| `src/lib/utils/theme.test.ts` | Update theme-colour assertion |
|
||||
| `tests/e2e/dark-mode.spec.ts` | Update asserted dark default `rgb(42,37,32)` → `rgb(33,32,31)` |
|
||||
| `tests/e2e/public-sharing.spec.ts` | Update asserted dark `fog` `rgb(34,46,58)` → `rgb(37,57,70)` |
|
||||
| `CLAUDE.md` | Document the dark palette and the card-vs-chip distinction in the design system section |
|
||||
| `docs/ARCHITECTURE.md` | Update the dark mode section for `getNoteChip` |
|
||||
|
||||
Nothing needs to change in `NoteCard`, `NoteEditor`, or the share page — they already resolve colour
|
||||
through `getNoteColor`, so they inherit the new palette.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit (`colors.test.ts`)** — the palette is data, so test its invariants rather than its hex values:
|
||||
|
||||
- `getNoteChip(c, false)` equals `getNoteColor(c, false)` for all twelve (light mode is unchanged)
|
||||
- `getNoteChip(c, true)` differs from `getNoteColor(c, true)` for all twelve (chip ≠ surface)
|
||||
- unknown colour falls back to `default` in both maps
|
||||
- every dark card holds ≥ 4.5:1 against `--text-muted #b3a695` — this is the invariant that broke
|
||||
before, so it gets asserted rather than eyeballed
|
||||
- no two dark chips are within an RGB distance of 25 (guards the crowded-hue regression)
|
||||
|
||||
**E2E (`dark-mode.spec.ts`)** — extend the existing "note cards use dark colors" scenario to assert a
|
||||
picker swatch is *not* the card background, which is the bug in user-visible terms.
|
||||
|
||||
**Manual** — the pixel-grid overlay and gold hover states want a real look on device; contrast maths
|
||||
can't judge those.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Light mode, including its picker
|
||||
- The 12-colour set itself (no colours added, removed, or renamed)
|
||||
- Per-note custom colours
|
||||
- `prefers-contrast` / high-contrast variants
|
||||
@@ -0,0 +1,129 @@
|
||||
# Note Linking — Design Spec
|
||||
|
||||
**Issue**: [#74 — feat: link to other crumbs (notes) inside the app](https://github.com/bretzel-app/crumbs/issues/74)
|
||||
**Date**: 2026-08-03
|
||||
|
||||
## Overview
|
||||
|
||||
Let a note reference another note by ID, so clicking it opens the target note in the editor. Reuses the existing "insert link" toolbar entry point (Ctrl+K) rather than introducing a new `[[wikilink]]`-style typing syntax. The displayed link text always tracks the target note's current title — it's a live reference, not frozen text — and the target note gets a "Referenced by" backlinks section.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Link creation UX | Extend the existing link toolbar dropdown with a note-search mode | Matches the issue reporter's own proposed solution; reuses the Link dropdown, `/api/search`, and toolbar infra as-is instead of building a new inline autocomplete |
|
||||
| Click behavior | Opens the target note in the editor overlay, same as clicking a note card | Matches how every other note-opening interaction already works in the app |
|
||||
| Link label | Always the target note's live title | A stale link label after a rename would be confusing; this requires modeling the link as an atomic node rather than a mark (see Architecture) |
|
||||
| Broken/inaccessible links | Grey out, inert, content untouched | Nothing is silently rewritten; if the note is restored from trash the link works again automatically |
|
||||
| Backlinks | Included in v1 | Requested explicitly; reuses the same extract-and-sync pattern tags already use, so the incremental cost is a reverse query, not new infrastructure |
|
||||
| Public share page | Note-links never resolve, even if the target is also shared | Simplest, most conservative option — zero chance of leaking the existence of a private note to an anonymous visitor |
|
||||
| Link picker scope | Owned notes + notes shared with you | Same visibility scope the main notes list already uses; `searchNotes()` already implements this |
|
||||
| Removing an existing link | Hover-reveal a small × on the chip | Click already navigates instantly, so it can't also open an edit/remove popover the way external links do; a hover-revealed remove button solves this without touching the click behavior. Repointing a link is just remove + re-insert — no separate "edit target" UI |
|
||||
|
||||
## Architecture
|
||||
|
||||
**Data model** — a new self-referential join table, mirroring `noteTags`/`tags`:
|
||||
|
||||
```typescript
|
||||
export const noteLinks = sqliteTable(
|
||||
'note_links',
|
||||
{
|
||||
sourceNoteId: text('source_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
targetNoteId: text('target_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('note_links_source_idx').on(table.sourceNoteId),
|
||||
index('note_links_target_idx').on(table.targetNoteId)
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
**Markdown representation** — a note-link round-trips through raw markdown as a normal-looking link with a custom URI scheme:
|
||||
|
||||
```
|
||||
[Grocery List](crumb-note://<note-id>)
|
||||
```
|
||||
|
||||
This keeps the existing raw-markdown-mode toggle showing something readable, and keeps exported/copied content sensible outside the app. The title baked into the markdown text is only ever a fallback for external tools — inside the app, parsing recognizes the `crumb-note://` scheme and rebuilds the live node, discarding whatever static text was there.
|
||||
|
||||
**Editor node** — a new tiptap **node** (not a mark) named `NoteLink`, with a single attribute `{ noteId: string }`. Unlike a mark, a node fully owns its own rendering, which is what makes "always show the live title" possible — a mark decorates existing typed text and has no clean way to swap that text later.
|
||||
|
||||
## Components
|
||||
|
||||
### Client
|
||||
|
||||
| Component | Change |
|
||||
|---|---|
|
||||
| `src/lib/components/tiptap/NoteLink.ts` (new) | Inline atomic tiptap node extension. `NodeView` looks up the target's title via `getNote(id)` from `src/lib/sync/idb.ts` — **not** the reactive `notes` writable store, which only holds whatever the current filter (all/archived/trashed) last loaded and would wrongly grey out a valid note just because it's outside the current view. IndexedDB is kept complete for all accessible notes by the existing `/api/sync` incremental sync, independent of which filter is on screen, so it's the correct source of truth here — and it works offline too. Renders a chip (note icon + title), greys out and disables click if the note isn't found. Click opens the note editor overlay for that ID. A small × button is layered on the chip using the app's existing hover-action pattern (`max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100` — always visible on touch, hover-revealed on desktop) that deletes the node from the document without navigating; there's no separate "change target" action, since deleting and re-inserting is simple enough that a dedicated repoint UI isn't worth building. |
|
||||
| `src/lib/components/TiptapEditor.svelte` | Register the `NoteLink` node extension alongside the existing ones. |
|
||||
| `src/lib/components/FormattingToolbar.svelte` | The existing link dropdown gets a note-search results list below the URL input, live-filtered by whatever's typed there (debounced, via `/api/search`, excluding the currently-open note) — no heuristic needed to guess whether the typed text is a URL or a note search; both affordances are just available side by side. Applying the URL input still applies the normal external `Link` mark; picking a search result instead inserts a `NoteLink` node at the cursor (replacing any selection). |
|
||||
| `src/lib/components/NoteEditor.svelte` | New "Referenced by" section, shown when `note.backlinks` is non-empty — a small chip list near where tags render, each chip opens that referencing note. |
|
||||
|
||||
### Server
|
||||
|
||||
| Function | Location | Description |
|
||||
|---|---|---|
|
||||
| `extractNoteLinks(content): string[]` | `src/lib/utils/note-links.ts` (new) | Regex-extracts `crumb-note://<id>` references from markdown content, same code-block/URL exclusions `extractTags` already applies. |
|
||||
| `syncNoteLinks(db, noteId, targetIds, userId)` | `src/lib/server/note-links.ts` (new) | Mirrors `syncNoteTags`: clears existing rows for the note, re-inserts the current set, within the same save transaction. |
|
||||
| `fetchBacklinksForNote(db, noteId, userId)` | `src/lib/server/note-links.ts` (new) | Reverse query on `noteLinks.targetNoteId`, joined through the same access rules as everything else (owner or collaborator on the *source* note), returning `{id, title}` pairs. |
|
||||
|
||||
`getNote()` in `notes-service.ts` calls `fetchBacklinksForNote` and attaches the result as `backlinks`. This is **not** added to `hydrateNotes()` (which also backs list views) — computing backlinks for every note in a list load would be wasted work for something only the open editor displays.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Create** — pick a note in the toolbar's link dropdown → a `NoteLink` node is inserted at the cursor.
|
||||
2. **Save** — content round-trips to markdown as usual; the note-save transaction additionally runs `extractNoteLinks` + `syncNoteLinks`, alongside the existing tag sync.
|
||||
3. **Reopen** — markdown parses back into the doc; a custom parse rule recognizes `crumb-note://` and produces a `NoteLink` node; its `NodeView` looks up the live title via IndexedDB (`getNote(id)`), not the filter-scoped reactive store (see Components).
|
||||
4. **Click** — opens the target note in the editor overlay.
|
||||
5. **Rename** — no action needed anywhere: the title is looked up live at render time, so the next render already shows the new title.
|
||||
6. **Trash / permanent delete / no access** — all three are treated identically: the note simply isn't found in IndexedDB (or, server-side, fails the backlink query's access check), so the link renders inert. The referencing note's content is never rewritten.
|
||||
7. **Public share page** — doesn't use the tiptap editor; content is rendered server-side via `markdown-it`. That render path strips `crumb-note://` links down to plain, non-clickable text, regardless of whether the target is also shared.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Malformed `crumb-note://` reference** during markdown parse falls back to plain text rather than crashing the parse.
|
||||
- **Search race conditions** in the toolbar's note-picker are debounced and guarded against stale results overwriting newer ones, same pattern the existing search feature (`tests/e2e/search.spec.ts`) already covers.
|
||||
- **Self-links** — the currently-open note is filtered out of its own picker results.
|
||||
- **Circular references** (A ↔ B) are a non-issue — clicking only ever opens one note at a time, nothing recurses.
|
||||
- **Cross-user link privacy** — the picker only offers notes you can already access, so under normal use a link can only point at something you had legitimate access to when you created it. Hand-editing raw markdown with a guessed note ID is the only way around that, and note IDs are unguessable UUIDs — the same "if you have the ID, you were meant to have it" trust model the app already applies to session/share tokens. Accepted as a low-severity edge case rather than adding extra access-control plumbing for it.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit (Vitest)**
|
||||
- `extractNoteLinks` — extracts IDs, ignores code blocks/URLs, dedupes, handles empty content
|
||||
- `syncNoteLinks` — clears + re-inserts correctly
|
||||
- `fetchBacklinksForNote` — returns only notes the requesting user can access
|
||||
- Markdown round-trip — a `NoteLink` node serializes to `[title](crumb-note://id)` and parses back to the same node with the right `noteId`
|
||||
|
||||
**E2E (Playwright)**
|
||||
- Linking to a note from the toolbar renders a live chip; clicking it opens that note
|
||||
- Renaming the linked note updates the displayed link text next time it's viewed
|
||||
- A link to a trashed/deleted/inaccessible note renders inert and doesn't navigate
|
||||
- The linked-to note shows a "Referenced by" backlink to the note that links to it
|
||||
- A public share page never renders a note-link as clickable, even if the target is also shared
|
||||
- The note-picker excludes the currently-open note from its own search results
|
||||
- Hovering a note-link chip reveals a × button that removes it from the document without navigating; the × is always visible (not hover-gated) on touch devices
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
- Inline `[[wikilink]]`/`@mention` autocomplete while typing (toolbar-only for now; can be added later without touching the data model)
|
||||
- Note graph / visual map view
|
||||
- Ranking or sorting backlinks beyond simple list order
|
||||
- Cross-user access-control tightening for hand-crafted `crumb-note://` references (see Error Handling)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Link toolbar dropdown supports searching and picking another note, in addition to pasting a URL
|
||||
- [ ] Picking a note inserts a live `NoteLink` node showing the target's current title
|
||||
- [ ] Clicking a note-link opens the target note in the editor overlay
|
||||
- [ ] Renaming a note updates its link's displayed text everywhere, without editing referencing notes' content
|
||||
- [ ] Trashed, deleted, or inaccessible link targets render greyed-out and inert
|
||||
- [ ] Note editor shows a "Referenced by" section listing notes that link to it
|
||||
- [ ] Public share page never renders a note-link as clickable
|
||||
- [ ] Note-links round-trip correctly through raw markdown mode as `[title](crumb-note://id)`
|
||||
- [ ] The link picker excludes the current note and includes both owned and shared-with-you notes
|
||||
- [ ] Hovering a note-link chip reveals a × that removes it without navigating; always visible on touch
|
||||
@@ -0,0 +1,67 @@
|
||||
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
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
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);
|
||||
@@ -0,0 +1,114 @@
|
||||
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`);
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE `note_versions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`version` integer NOT NULL,
|
||||
`title` text DEFAULT '' NOT NULL,
|
||||
`content` text DEFAULT '' NOT NULL,
|
||||
`checklist_mode` integer DEFAULT false NOT NULL,
|
||||
`color` text DEFAULT 'default' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `note_versions_note_id_idx` ON `note_versions` (`note_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `note_versions_note_id_version_unique` ON `note_versions` (`note_id`,`version`);--> statement-breakpoint
|
||||
CREATE TABLE `shared_notes` (
|
||||
`note_id` text PRIMARY KEY NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`expires_at` integer,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `shared_notes_token_unique` ON `shared_notes` (`token`);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE `note_links` (
|
||||
`source_note_id` text NOT NULL,
|
||||
`target_note_id` text NOT NULL,
|
||||
FOREIGN KEY (`source_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`target_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `note_links_source_idx` ON `note_links` (`source_note_id`);--> statement-breakpoint
|
||||
CREATE INDEX `note_links_target_idx` ON `note_links` (`target_note_id`);
|
||||
@@ -0,0 +1,465 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
{
|
||||
"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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1774037379518,
|
||||
"tag": "0003_uneven_tomorrow_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1785824271578,
|
||||
"tag": "0004_violet_nicolaos",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+76
-35
@@ -1,18 +1,30 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.1.7",
|
||||
"version": "0.32.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"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": "vite build",
|
||||
"build": "npx tsx scripts/generate-openapi.ts && vite build",
|
||||
"openapi": "npx tsx scripts/generate-openapi.ts",
|
||||
"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",
|
||||
@@ -22,50 +34,79 @@
|
||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-link": "^3.29.2",
|
||||
"@tiptap/extension-placeholder": "^3.29.2",
|
||||
"@tiptap/extension-table": "^3.29.2",
|
||||
"@tiptap/extension-table-cell": "^3.29.2",
|
||||
"@tiptap/extension-table-header": "^3.29.2",
|
||||
"@tiptap/extension-table-row": "^3.29.2",
|
||||
"@tiptap/extension-task-item": "^3.29.2",
|
||||
"@tiptap/extension-task-list": "^3.29.2",
|
||||
"@tiptap/extension-text-align": "^3.29.2",
|
||||
"@tiptap/extension-underline": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
"@tiptap/starter-kit": "^3.29.2",
|
||||
"arctic": "^3.7.0",
|
||||
"argon2": "^0.43.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"markdown-it": "^14.3.0",
|
||||
"nanoid": "^5.1.16",
|
||||
"node-diff3": "^3.2.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"svelte-dnd-action": "^0.9.77",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.0.0"
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/markdown-it": "^14.0.0",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@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",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.7",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"nyc": "^18.0.0",
|
||||
"svelte": "^5.56.8",
|
||||
"svelte-check": "^4.7.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.0.0",
|
||||
"yaml": "^2.8.2"
|
||||
"vite": "^7.3.6",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^3.2.7",
|
||||
"workbox-window": "^7.4.1",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"argon2",
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"esbuild@<0.25.0": "0.25.12",
|
||||
"cookie": ">=0.7.0 <1",
|
||||
"hono": ">=4.12.7",
|
||||
"devalue": ">=5.8.1",
|
||||
"path-to-regexp@8": ">=8.4.0",
|
||||
"fast-uri": ">=3.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-10
@@ -1,37 +1,41 @@
|
||||
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: process.env.CI ? 2 : 0,
|
||||
retries: 0,
|
||||
workers: process.env.CI ? 4 : undefined,
|
||||
reporter: 'html',
|
||||
globalSetup: './tests/e2e/global-setup.ts',
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure'
|
||||
screenshot: 'only-on-failure',
|
||||
serviceWorkers: 'block'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /auth\.spec\.ts/,
|
||||
name: 'auth-setup',
|
||||
testMatch: 'auth.spec.ts',
|
||||
fullyParallel: false,
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
testIgnore: /auth\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
name: 'app',
|
||||
testIgnore: 'auth.spec.ts',
|
||||
dependencies: ['auth-setup'],
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
}
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm build && pnpm preview --port 4173',
|
||||
command: 'pnpm preview --port 4173',
|
||||
port: 4173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
DATABASE_URL: './data/test-crumbs.db'
|
||||
DATABASE_URL: './data/test-crumbs.db',
|
||||
NODE_ENV: 'test'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Generated
+3732
-2087
File diff suppressed because it is too large
Load Diff
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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`);
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/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."
|
||||
@@ -0,0 +1,110 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { existsSync, unlinkSync, mkdirSync } from 'fs';
|
||||
import { seed, login } from './screenshots/seed';
|
||||
import { captureDesktop, captureMobile } from './screenshots/capture';
|
||||
import {
|
||||
BASE_URL,
|
||||
SCREENSHOT_DB,
|
||||
DESKTOP_VIEWPORT,
|
||||
MOBILE_VIEWPORT,
|
||||
OUTPUT_DIR
|
||||
} from './screenshots/constants';
|
||||
|
||||
function cleanDatabase(): void {
|
||||
for (const ext of ['', '-journal', '-wal', '-shm']) {
|
||||
const path = `${SCREENSHOT_DB}${ext}`;
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path);
|
||||
console.log(` deleted ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok || res.status === 302) return;
|
||||
} catch {
|
||||
// Server not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Server not reachable at ${url} after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
function startServer(): Promise<ChildProcess> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const dir = SCREENSHOT_DB.substring(0, SCREENSHOT_DB.lastIndexOf('/'));
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
|
||||
const server = spawn('pnpm', ['preview', '--port', '4173'], {
|
||||
env: { ...process.env, DATABASE_URL: SCREENSHOT_DB, NODE_ENV: 'production' },
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
server.on('error', reject);
|
||||
server.on('exit', (code) => {
|
||||
if (code !== null && code !== 0) reject(new Error(`Server exited with code ${code}`));
|
||||
});
|
||||
|
||||
// Poll until server responds (stdout detection is unreliable in CI)
|
||||
waitForServer(BASE_URL, 60_000)
|
||||
.then(() => resolve(server))
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('Screenshot automation starting...\n');
|
||||
|
||||
// 1. Clean DB
|
||||
console.log('Cleaning database...');
|
||||
cleanDatabase();
|
||||
|
||||
// 2. Ensure output dir exists
|
||||
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
// 3. Start preview server
|
||||
console.log('Starting preview server...');
|
||||
const server = await startServer();
|
||||
console.log('Server ready.\n');
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
try {
|
||||
// 4. Seed data (in desktop context)
|
||||
console.log('Seeding data...');
|
||||
const desktopContext = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
|
||||
const desktopPage = await desktopContext.newPage();
|
||||
await seed(desktopPage);
|
||||
console.log('Seed complete.\n');
|
||||
|
||||
// 5. Desktop screenshots
|
||||
await captureDesktop(desktopPage);
|
||||
await desktopContext.close();
|
||||
console.log('');
|
||||
|
||||
// 6. Mobile screenshots (fresh context with mobile viewport)
|
||||
const mobileContext = await browser.newContext({ viewport: MOBILE_VIEWPORT });
|
||||
const mobilePage = await mobileContext.newPage();
|
||||
await login(mobilePage);
|
||||
await captureMobile(mobilePage);
|
||||
await mobileContext.close();
|
||||
|
||||
console.log('\nAll screenshots captured successfully!');
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.kill('SIGTERM');
|
||||
// Force exit — pnpm spawns vite as a grandchild process that
|
||||
// server.kill() doesn't reach, leaving the CI job hanging
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Screenshot automation failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
import { join } from 'path';
|
||||
import { BASE_URL, OUTPUT_DIR } from './constants';
|
||||
|
||||
async function screenshot(page: Page, name: string): Promise<void> {
|
||||
await page.screenshot({ path: join(OUTPUT_DIR, name), type: 'png' });
|
||||
console.log(` captured ${name}`);
|
||||
}
|
||||
|
||||
async function waitForApp(page: Page): Promise<void> {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
/** Click a note card by its title (targets the h3 inside note-card to avoid content substring matches) */
|
||||
function noteCard(page: Page, title: string): Locator {
|
||||
return page.locator('[data-testid="note-card"]', { has: page.locator('h3', { hasText: title }) });
|
||||
}
|
||||
|
||||
export async function captureDesktop(page: Page): Promise<void> {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
console.log('Capturing desktop screenshots...');
|
||||
|
||||
// D1: Grid view (also used as hero)
|
||||
await page.goto(BASE_URL);
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-grid.png');
|
||||
|
||||
// D2: Rich text editor — open "Self-Hosting Stack"
|
||||
await noteCard(page, 'Self-Hosting Stack').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-editor.png');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await waitForApp(page);
|
||||
|
||||
// D3: Checklist — open "Bretzel Ingredients"
|
||||
await noteCard(page, 'Bretzel Ingredients').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-checklist.png');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await waitForApp(page);
|
||||
|
||||
// D4: Sharing dialog — open "Weekend Plans", click share
|
||||
await noteCard(page, 'Weekend Plans').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await page.getByTestId('share-toggle').click();
|
||||
await page.waitForSelector('[data-testid="share-dialog"]');
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-sharing.png');
|
||||
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await waitForApp(page);
|
||||
|
||||
// D5: Version history — open "Self-Hosting Stack", click history
|
||||
await noteCard(page, 'Self-Hosting Stack').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await page.getByTestId('history-toggle').click();
|
||||
await page.waitForSelector('[data-testid="history-panel"]');
|
||||
// Wait for version items to load from API
|
||||
await page.waitForSelector('[data-testid="version-item"]', { timeout: 10_000 });
|
||||
const versionItems = page.getByTestId('version-item');
|
||||
if ((await versionItems.count()) > 1) {
|
||||
await versionItems.nth(1).click();
|
||||
}
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-history.png');
|
||||
// Close history panel first (overlay blocks close-editor-btn)
|
||||
await page.getByTestId('close-history-btn').click();
|
||||
await waitForApp(page);
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await waitForApp(page);
|
||||
|
||||
// D6: API/MCP settings
|
||||
await page.goto(`${BASE_URL}/settings/mcp`);
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-api.png');
|
||||
}
|
||||
|
||||
export async function captureMobile(page: Page): Promise<void> {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
console.log('Capturing mobile screenshots...');
|
||||
|
||||
// M1: Mobile grid view
|
||||
await page.goto(BASE_URL);
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-mobile-grid.png');
|
||||
|
||||
// M2: Mobile editor — open "Self-Hosting Stack"
|
||||
await noteCard(page, 'Self-Hosting Stack').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-mobile-editor.png');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await waitForApp(page);
|
||||
|
||||
// M3: Mobile checklist — open "Bretzel Ingredients"
|
||||
await noteCard(page, 'Bretzel Ingredients').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-mobile-checklist.png');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const BASE_URL = 'http://localhost:4173';
|
||||
export const SCREENSHOT_DB = './data/screenshots.db';
|
||||
export const OUTPUT_DIR = 'website/assets';
|
||||
|
||||
export const DESKTOP_VIEWPORT = { width: 1280, height: 800 };
|
||||
export const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
export const ADMIN = {
|
||||
email: 'admin@bretzel.app',
|
||||
displayName: 'Bretzel Admin',
|
||||
password: crypto.randomUUID()
|
||||
};
|
||||
|
||||
export const COLLABORATOR = {
|
||||
email: 'salzig@bretzel.app',
|
||||
displayName: 'Salzig Bretzel',
|
||||
password: crypto.randomUUID()
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { BASE_URL, ADMIN, COLLABORATOR } from './constants';
|
||||
|
||||
type HttpMethod = 'post' | 'patch' | 'get' | 'delete';
|
||||
|
||||
async function api(page: Page, method: HttpMethod, path: string, data?: unknown) {
|
||||
const res = await page.request[method](
|
||||
`${BASE_URL}${path}`,
|
||||
data !== undefined ? { data } : undefined
|
||||
);
|
||||
if (!res.ok()) {
|
||||
throw new Error(`${method.toUpperCase()} ${path} failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
interface NoteData {
|
||||
key: string;
|
||||
title: string;
|
||||
content: string;
|
||||
color: string;
|
||||
pinned?: boolean;
|
||||
checklistMode?: boolean;
|
||||
}
|
||||
|
||||
function getNotesData(): NoteData[] {
|
||||
// Created in reverse display order so newest (first created) appears last
|
||||
// and the grid reads naturally top-left to bottom-right
|
||||
return [
|
||||
{
|
||||
key: 'bakingcode',
|
||||
title: 'Crumbs v0.23 Release',
|
||||
content: `- [x] CRDT sync logic
|
||||
- [x] MCP server integration
|
||||
- [ ] Bake the Docker image
|
||||
- [ ] Deploy to the homelab
|
||||
- [ ] Celebrate with a bretzel`,
|
||||
color: 'clay',
|
||||
checklistMode: true
|
||||
},
|
||||
{
|
||||
key: 'coding-music',
|
||||
title: 'Coding Playlists',
|
||||
content: `Curated on **Navidrome** for maximum focus.
|
||||
|
||||
### Late Night Focus
|
||||
|
||||
1. *Tycho* — Dive (ambient electronic)
|
||||
2. *Boards of Canada* — Music Has the Right to Children
|
||||
3. *Nujabes* — Modal Soul
|
||||
4. *C418* — Minecraft Volume Alpha
|
||||
|
||||
### Saturday Baking
|
||||
|
||||
For slow mornings in the kitchen:
|
||||
|
||||
1. *Khruangbin* — Con Todo El Mundo
|
||||
2. *Tatsuro Yamashita* — For You
|
||||
|
||||
#music #personal`,
|
||||
color: 'chalk'
|
||||
},
|
||||
{
|
||||
key: 'weekend',
|
||||
title: 'Weekend Plans',
|
||||
content: `### Saturday
|
||||
|
||||
- Farmer's market — get sourdough starter
|
||||
- Fix the Dokploy SSL cert renewal
|
||||
- Bake a batch of bretzels
|
||||
|
||||
### Sunday
|
||||
|
||||
- Retro gaming marathon on *RetroAssembly*
|
||||
- Update the self-hosting stack
|
||||
- Plan next trip on **42**
|
||||
|
||||
> A weekend well spent brings a week of content.
|
||||
|
||||
#personal #weekend`,
|
||||
color: 'mint'
|
||||
},
|
||||
{
|
||||
key: 'manifesto',
|
||||
title: '',
|
||||
content: `The best software is the software you **own**. Self-host everything. Trust no cloud. 🥨
|
||||
|
||||
#personal #selfhosting`,
|
||||
color: 'coral'
|
||||
},
|
||||
{
|
||||
key: 'retro',
|
||||
title: 'Back to Retro Gaming',
|
||||
content: `Games running on **RetroAssembly** this summer:
|
||||
|
||||
- **Chrono Trigger** (SNES) — best RPG ever made
|
||||
- **Link's Awakening** (GB) — pure portable magic
|
||||
- **Symphony of the Night** (PS1) — the *definitive* Metroidvania
|
||||
- **Advance Wars** (GBA) — turn-based perfection
|
||||
- **Final Fantasy Tactics** (PS1) — political intrigue + job system
|
||||
|
||||
> The pixel art era was peak game design.
|
||||
|
||||
#gaming #retro`,
|
||||
color: 'peach'
|
||||
},
|
||||
{
|
||||
key: 'selfhosting',
|
||||
title: 'Self-Hosting Stack',
|
||||
content: `All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
|
||||
|
||||
### Core Services
|
||||
|
||||
- **Crumbs** — notes *(obviously)*
|
||||
- **Dokploy** — deployment platform
|
||||
- **Outline** — team wiki
|
||||
|
||||
### Monitoring
|
||||
|
||||
> Everything that can break *will* break at 3 AM on a Sunday.
|
||||
|
||||
Just getting started. More to come.
|
||||
|
||||
#homelab #devops`,
|
||||
color: 'fog'
|
||||
},
|
||||
{
|
||||
key: 'bretzel',
|
||||
title: 'Bretzel Ingredients',
|
||||
content: `- [x] 500g bread flour
|
||||
- [x] 300ml warm water
|
||||
- [x] 10g salt
|
||||
- [ ] 7g dry yeast
|
||||
- [ ] 30g butter (softened)
|
||||
- [ ] Baking soda for the bath
|
||||
- [ ] Coarse salt for topping`,
|
||||
color: 'sand',
|
||||
checklistMode: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function getVersionHistoryContents(): string[] {
|
||||
return [
|
||||
// Version 2: Add Navidrome + RetroAssembly
|
||||
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
|
||||
|
||||
### Core Services
|
||||
|
||||
- **Crumbs** — notes *(obviously)*
|
||||
- **Dokploy** — deployment platform
|
||||
- **Outline** — team wiki
|
||||
- **Navidrome** — music streaming
|
||||
- **RetroAssembly** — retro game emulation
|
||||
|
||||
### Monitoring
|
||||
|
||||
> Everything that can break *will* break at 3 AM on a Sunday.
|
||||
|
||||
Growing the stack.
|
||||
|
||||
#homelab #devops`,
|
||||
|
||||
// Version 3: Add 42 + Umami
|
||||
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
|
||||
|
||||
### Core Services
|
||||
|
||||
- **Crumbs** — notes *(obviously)*
|
||||
- **42** — holiday budget tracking
|
||||
- **Dokploy** — deployment platform
|
||||
- **Outline** — team wiki
|
||||
- **Navidrome** — music streaming
|
||||
- **RetroAssembly** — retro game emulation
|
||||
- **Umami** — privacy-first analytics
|
||||
|
||||
### Monitoring
|
||||
|
||||
> Everything that can break *will* break at 3 AM on a Sunday.
|
||||
|
||||
The stack is getting serious.
|
||||
|
||||
#homelab #devops`,
|
||||
|
||||
// Version 4: Final version
|
||||
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
|
||||
|
||||
### Core Services
|
||||
|
||||
- **Crumbs** — notes *(obviously)*
|
||||
- **42** — holiday budget tracking
|
||||
- **Dokploy** — deployment platform
|
||||
- **Outline** — team wiki
|
||||
- **Navidrome** — music streaming
|
||||
- **RetroAssembly** — retro game emulation
|
||||
- **Umami** — privacy-first analytics
|
||||
|
||||
### Monitoring
|
||||
|
||||
> Everything that can break *will* break at 3 AM on a Sunday.
|
||||
|
||||
Everything below daily reverse proxy. The dream stack is *almost* complete.
|
||||
|
||||
#homelab #devops`
|
||||
];
|
||||
}
|
||||
|
||||
export async function seed(page: Page): Promise<void> {
|
||||
// 1. Setup admin (sets session cookie)
|
||||
await api(page, 'post', '/api/auth/setup', {
|
||||
email: ADMIN.email,
|
||||
displayName: ADMIN.displayName,
|
||||
password: ADMIN.password
|
||||
});
|
||||
console.log(' admin created');
|
||||
|
||||
// 2. Create collaborator user
|
||||
const alice = await api(page, 'post', '/api/admin/users', {
|
||||
email: COLLABORATOR.email,
|
||||
displayName: COLLABORATOR.displayName,
|
||||
password: COLLABORATOR.password,
|
||||
role: 'user'
|
||||
});
|
||||
const aliceId: number = alice.id;
|
||||
console.log(' collaborator created (id:', aliceId, ')');
|
||||
|
||||
// 3. Create notes (order matters — newest appear first in grid)
|
||||
const noteData = getNotesData();
|
||||
const noteIds: Record<string, string> = {};
|
||||
for (const note of noteData) {
|
||||
const created = await api(page, 'post', '/api/notes', {
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
color: note.color,
|
||||
pinned: note.pinned ?? false,
|
||||
checklistMode: note.checklistMode ?? false
|
||||
});
|
||||
noteIds[note.key] = created.id;
|
||||
console.log(' note created:', note.title);
|
||||
}
|
||||
|
||||
// 4. Share "Weekend Plans" with Alice
|
||||
await api(page, 'post', `/api/notes/${noteIds['weekend']}/collaborators`, {
|
||||
userId: aliceId
|
||||
});
|
||||
console.log(' shared "Weekend Plans" with Alice');
|
||||
|
||||
// 5. Create version history for "Self-Hosting Stack"
|
||||
const versionContents = getVersionHistoryContents();
|
||||
for (let i = 0; i < versionContents.length; i++) {
|
||||
await api(page, 'patch', `/api/notes/${noteIds['selfhosting']}`, {
|
||||
content: versionContents[i]
|
||||
});
|
||||
console.log(` version ${i + 2} created for "Self-Hosting Stack"`);
|
||||
}
|
||||
|
||||
// 6. Create API keys
|
||||
await api(page, 'post', '/api/settings/api-keys', { name: 'Claude Code' });
|
||||
await api(page, 'post', '/api/settings/api-keys', { name: 'N8N' });
|
||||
console.log(' API keys created');
|
||||
}
|
||||
|
||||
export async function login(page: Page): Promise<void> {
|
||||
await page.goto(`${BASE_URL}/login`);
|
||||
await page.getByTestId('email-input').fill(ADMIN.email);
|
||||
await page.getByTestId('password-input').fill(ADMIN.password);
|
||||
await page.getByTestId('login-btn').click();
|
||||
await page.waitForURL('**/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+213
-2
@@ -12,10 +12,53 @@
|
||||
--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);
|
||||
--grid-dot: #1a1a2e;
|
||||
--accent-check: var(--primary);
|
||||
/* Tint source for hover/active washes. Light mode reuses the ink border, so
|
||||
light styling is byte-identical; dark mode keeps the cream that --border
|
||||
used to supply, because a mid-grey wash is invisible on a dark surface. */
|
||||
--hover-wash: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #141312;
|
||||
--bg-surface: #1e1c1b;
|
||||
--primary: #e0a030;
|
||||
--primary-hover: #f2b855;
|
||||
--text: #ece3d3;
|
||||
--text-muted: #b3a695;
|
||||
--border: #57514b;
|
||||
--border-subtle: #302d2b;
|
||||
--destructive: #e2705c;
|
||||
--error-bg: #3a2320;
|
||||
--error-border: #5c3a34;
|
||||
--error-text: #f0a898;
|
||||
--success-bg: #1f2e1f;
|
||||
--success-text: #8fc97a;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #ece3d3;
|
||||
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
|
||||
--accent-check: #c8a44a;
|
||||
--hover-wash: #ece3d3;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
accent-color: var(--accent-check);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body.editor-scroll-locked {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
@@ -24,11 +67,27 @@ body::before {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.03;
|
||||
background-image: repeating-conic-gradient(#1a1a2e 0% 25%, transparent 0% 50%);
|
||||
background-image: repeating-conic-gradient(var(--grid-dot) 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); }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Prose overrides for parchment theme */
|
||||
.prose {
|
||||
--tw-prose-body: var(--text);
|
||||
@@ -52,3 +111,155 @@ body::before {
|
||||
.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: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Nested task lists take full width below the parent item */
|
||||
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"],
|
||||
.prose ul.task-list li > ul.task-list {
|
||||
flex-basis: 100%;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Shared checkbox styles */
|
||||
.tiptap ul[data-type="taskList"] li label input[type="checkbox"],
|
||||
.prose ul.task-list .task-checkbox {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
margin-top: 0.2em;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Checklist link styles */
|
||||
.checklist-link {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.checklist-link:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
[data-placeholder]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Custom tooltips */
|
||||
.tooltip-popup {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.2;
|
||||
color: var(--bg-surface);
|
||||
background: var(--text);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-out;
|
||||
}
|
||||
|
||||
.tooltip-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
Vendored
+14
-2
@@ -1,11 +1,23 @@
|
||||
/// <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 {}
|
||||
interface Locals {
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: 'admin' | 'user';
|
||||
} | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
interface PageState {
|
||||
noteEditorOpen?: boolean;
|
||||
}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -2,14 +2,34 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- interactive-widget=resizes-content: on Android the keyboard shrinks the layout viewport via
|
||||
the native inset path, so bottom-anchored UI (editor toolbar) lands exactly on the keyboard —
|
||||
Chromium's visualViewport.height can under-report by the nav-bar height. iOS ignores this
|
||||
attribute and relies on the visualViewport tracking in NoteEditor instead. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#f0e6d3" />
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
|
||||
var t = p.theme || 'system';
|
||||
if (t !== 'light' && t !== 'dark') t = 'system';
|
||||
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
if (t === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
var m = document.querySelector('meta[name="theme-color"]');
|
||||
if (m) m.content = '#141312';
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<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>"
|
||||
/>
|
||||
<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" />
|
||||
<title>Crumbs</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
+49
-3
@@ -1,17 +1,56 @@
|
||||
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'];
|
||||
const PUBLIC_PATHS = [
|
||||
'/login',
|
||||
'/setup',
|
||||
'/api/auth/login',
|
||||
'/api/auth/setup',
|
||||
'/api/auth/oauth',
|
||||
'/s/',
|
||||
'/api/shared'
|
||||
];
|
||||
|
||||
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) {
|
||||
@@ -21,9 +60,16 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// Validate session
|
||||
// Validate session and populate user
|
||||
const sessionToken = event.cookies.get('session');
|
||||
if (!sessionToken || !(await validateSession(sessionToken))) {
|
||||
if (sessionToken) {
|
||||
const result = await validateSession(sessionToken);
|
||||
if (result.valid && result.user) {
|
||||
event.locals.user = result.user;
|
||||
}
|
||||
}
|
||||
|
||||
if (!event.locals.user) {
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Mock for $app/environment used in Vitest unit tests
|
||||
export const browser = true;
|
||||
export const dev = false;
|
||||
export const building = false;
|
||||
export const version = 'test';
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Polyfill Svelte 5 runes for Vitest.
|
||||
* The Svelte compiler transforms these in .svelte and .svelte.ts files,
|
||||
* but Vitest doesn't run the Svelte compiler, so we provide plain-JS stubs.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
(globalThis as any).$state = <T>(initial: T): T => initial;
|
||||
(globalThis as any).$derived = <T>(value: T): T => value;
|
||||
(globalThis as any).$effect = (_fn: () => void): void => {};
|
||||
(globalThis as any).$props = (): any => ({});
|
||||
@@ -1,8 +1,13 @@
|
||||
<script lang="ts">
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml } from '$lib/utils/checklist.js';
|
||||
import LinkPopover from './LinkPopover.svelte';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
@@ -11,110 +16,399 @@
|
||||
|
||||
let { content, onChange }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||
let doneExpanded = $state(true);
|
||||
const flipDurationMs = 150;
|
||||
let linkPopover = $state<{ url: string; anchor: DOMRect } | null>(null);
|
||||
|
||||
function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ text: '', checked: false }];
|
||||
const lines = text.split('\n');
|
||||
return lines.map((line) => {
|
||||
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 };
|
||||
});
|
||||
// Track the last content we emitted so we can distinguish self-originated
|
||||
// changes from external ones (sync, history restore, etc.)
|
||||
let lastEmitted = content;
|
||||
|
||||
// Re-parse items when content changes externally (not from our own emitChange)
|
||||
$effect(() => {
|
||||
if (content !== lastEmitted) {
|
||||
items = parseChecklist(content);
|
||||
lastEmitted = content;
|
||||
}
|
||||
});
|
||||
|
||||
let activeItems = $derived(items.filter((i) => !i.checked));
|
||||
let doneItems = $derived(items.filter((i) => i.checked));
|
||||
let doneCount = $derived(doneItems.length);
|
||||
|
||||
interface DoneSectionGroup {
|
||||
parentLabel: ChecklistItem | null;
|
||||
children: ChecklistItem[];
|
||||
}
|
||||
|
||||
function serializeChecklist(list: ChecklistItem[]): string {
|
||||
return list
|
||||
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
||||
.join('\n');
|
||||
}
|
||||
let doneSectionGroups = $derived.by(() => {
|
||||
const groups: DoneSectionGroup[] = [];
|
||||
const childrenByParent = new Map<string, ChecklistItem[]>();
|
||||
const topLevelDone: ChecklistItem[] = [];
|
||||
|
||||
for (const item of doneItems) {
|
||||
if (item.parentId) {
|
||||
const siblings = childrenByParent.get(item.parentId) ?? [];
|
||||
siblings.push(item);
|
||||
childrenByParent.set(item.parentId, siblings);
|
||||
} else {
|
||||
topLevelDone.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of topLevelDone) {
|
||||
const doneChildren = childrenByParent.get(item.id) ?? [];
|
||||
groups.push({ parentLabel: null, children: [item, ...doneChildren] });
|
||||
childrenByParent.delete(item.id);
|
||||
}
|
||||
|
||||
for (const [parentId, children] of childrenByParent) {
|
||||
const parent = items.find((i) => i.id === parentId);
|
||||
if (parent) {
|
||||
groups.push({ parentLabel: parent, children });
|
||||
} else {
|
||||
groups.push({ parentLabel: null, children });
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
function emitChange() {
|
||||
onChange(serializeChecklist(items));
|
||||
const serialized = serializeChecklist(items);
|
||||
lastEmitted = serialized;
|
||||
onChange(serialized);
|
||||
}
|
||||
|
||||
function toggleItem(index: number) {
|
||||
items[index].checked = !items[index].checked;
|
||||
function toggleItem(id: string) {
|
||||
items = toggleItemWithCascade(id, items);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function updateText(index: number, text: string) {
|
||||
items[index].text = text;
|
||||
emitChange();
|
||||
function updateText(id: string, innerHTML: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.text = unlinkifyHtml(innerHTML);
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
function addItem(afterIndex: number) {
|
||||
items.splice(afterIndex + 1, 0, { text: '', checked: false });
|
||||
const afterItem = items[afterIndex];
|
||||
const newItem: ChecklistItem = {
|
||||
id: generateId(),
|
||||
text: '',
|
||||
checked: false,
|
||||
parentId: afterItem?.parentId ?? null
|
||||
};
|
||||
items.splice(afterIndex + 1, 0, newItem);
|
||||
items = [...items];
|
||||
emitChange();
|
||||
// Focus new input on next tick
|
||||
const newId = newItem.id;
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
inputs[afterIndex + 1]?.focus();
|
||||
document.querySelector<HTMLElement>(`[data-item-id="${newId}"]`)?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
if (items.length <= 1) return;
|
||||
function removeItem(id: string) {
|
||||
if (items.length <= 1) {
|
||||
if (items[0].parentId) items[0].parentId = null;
|
||||
items = [...items];
|
||||
emitChange();
|
||||
return;
|
||||
}
|
||||
items = items.map((i) => (i.parentId === id ? { ...i, parentId: null } : i));
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
items.splice(index, 1);
|
||||
items = [...items];
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent, index: number) {
|
||||
function handleKeydown(e: KeyboardEvent, id: string) {
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addItem(index);
|
||||
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
|
||||
e.preventDefault();
|
||||
removeItem(index);
|
||||
removeItem(id);
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
const inputs = document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]');
|
||||
inputs[Math.max(0, index - 1)]?.focus();
|
||||
}, 0);
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
const inputs = Array.from(document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]'));
|
||||
const current = inputs.findIndex((el) => el.dataset.itemId === id);
|
||||
const target = e.key === 'ArrowUp' ? current - 1 : current + 1;
|
||||
if (target >= 0 && target < inputs.length) {
|
||||
e.preventDefault();
|
||||
inputs[target].focus();
|
||||
}
|
||||
} else if (e.key === 'Tab' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
items = indentItem(id, items, activeItems);
|
||||
emitChange();
|
||||
} else if (e.key === 'Tab' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
items = outdentItem(id, items);
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
// Svelte action: sets innerHTML on mount and syncs from external changes only.
|
||||
// While the user is typing (element is focused), we don't re-render — that would
|
||||
// destroy the cursor. Linkification happens on blur via the onblur handler.
|
||||
function editableContent(node: HTMLElement, text: string) {
|
||||
node.innerHTML = linkifyText(text);
|
||||
return {
|
||||
update(newText: string) {
|
||||
if (document.activeElement !== node) {
|
||||
node.innerHTML = linkifyText(newText);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleLinkClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest('a');
|
||||
if (anchor) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
linkPopover = { url: anchor.href, anchor: anchor.getBoundingClientRect() };
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData?.getData('text/plain') ?? '').replace(/[\r\n]+/g, ' ');
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(document.createTextNode(text));
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
(e.target as HTMLElement).dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
// --- Directional lock drag handle ---
|
||||
// Instead of blocking DnD events (which corrupts its internal state),
|
||||
// we detect horizontal intent and store the pending operation. DnD runs
|
||||
// its full course unimpeded. After DnD finalizes, we apply the indent.
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let pendingIndent: { id: string; direction: 'indent' | 'outdent' } | null = null;
|
||||
let directionLocked = false;
|
||||
const LOCK_THRESHOLD = 10;
|
||||
const INDENT_THRESHOLD = 32;
|
||||
|
||||
function windowPointerMove(e: PointerEvent) {
|
||||
if (directionLocked) return;
|
||||
|
||||
const dx = Math.abs(e.clientX - dragStartX);
|
||||
const dy = Math.abs(e.clientY - dragStartY);
|
||||
if (dx >= LOCK_THRESHOLD || dy >= LOCK_THRESHOLD) {
|
||||
directionLocked = true;
|
||||
if (dx <= dy) {
|
||||
// Vertical — DnD handles it, we're done
|
||||
removeWindowListeners();
|
||||
}
|
||||
// Horizontal — keep listening for pointerup to measure total dx
|
||||
}
|
||||
}
|
||||
|
||||
function windowPointerUp(e: PointerEvent) {
|
||||
const dx = e.clientX - dragStartX;
|
||||
if (Math.abs(dx) > INDENT_THRESHOLD && directionLocked && currentDragItemId) {
|
||||
pendingIndent = {
|
||||
id: currentDragItemId,
|
||||
direction: dx > 0 ? 'indent' : 'outdent'
|
||||
};
|
||||
}
|
||||
removeWindowListeners();
|
||||
directionLocked = false;
|
||||
}
|
||||
|
||||
function addWindowListeners() {
|
||||
window.addEventListener('pointermove', windowPointerMove, { capture: true });
|
||||
window.addEventListener('pointerup', windowPointerUp, { capture: true });
|
||||
}
|
||||
|
||||
function removeWindowListeners() {
|
||||
window.removeEventListener('pointermove', windowPointerMove, { capture: true });
|
||||
window.removeEventListener('pointerup', windowPointerUp, { capture: true });
|
||||
}
|
||||
|
||||
let currentDragItemId = '';
|
||||
function handleDragPointerDown(e: PointerEvent, id: string) {
|
||||
dragStartX = e.clientX;
|
||||
dragStartY = e.clientY;
|
||||
directionLocked = false;
|
||||
pendingIndent = null;
|
||||
currentDragItemId = id;
|
||||
addWindowListeners();
|
||||
}
|
||||
|
||||
onDestroy(removeWindowListeners);
|
||||
|
||||
// --- DnD handlers ---
|
||||
function recomputeParentIds(reorderedItems: ChecklistItem[]): ChecklistItem[] {
|
||||
let lastTopLevelId: string | null = null;
|
||||
return reorderedItems.map((item) => {
|
||||
if (item.parentId === null) {
|
||||
lastTopLevelId = item.id;
|
||||
return item;
|
||||
}
|
||||
return { ...item, parentId: lastTopLevelId };
|
||||
});
|
||||
}
|
||||
|
||||
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||
}
|
||||
|
||||
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||
const reorderedActive = recomputeParentIds(e.detail.items);
|
||||
items = [...reorderedActive, ...items.filter((i) => i.checked)];
|
||||
emitChange();
|
||||
|
||||
// Apply pending indent/outdent from horizontal drag (after DnD fully cleans up)
|
||||
if (pendingIndent && pendingIndent.id) {
|
||||
const { id, direction } = pendingIndent;
|
||||
pendingIndent = null;
|
||||
if (direction === 'indent') {
|
||||
items = indentItem(id, items, activeItems);
|
||||
} else {
|
||||
items = outdentItem(id, items);
|
||||
}
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
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-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
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-[var(--text-muted)]" 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}
|
||||
<div class="space-y-0.5" data-testid="checklist">
|
||||
<section
|
||||
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: { outline: 'none' } }}
|
||||
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 outline-none {item.parentId ? 'pl-8' : ''}"
|
||||
data-testid={item.parentId ? 'checklist-child-row' : undefined}
|
||||
animate:flip={{ duration: flipDurationMs }}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div use:dragHandle
|
||||
aria-label="drag handle"
|
||||
onpointerdown={(e) => handleDragPointerDown(e, item.id)}
|
||||
class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150 touch-none outline-none"
|
||||
data-testid="checklist-drag-handle">
|
||||
<GripVertical 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"
|
||||
/>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
contenteditable="true"
|
||||
use:editableContent={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLElement).innerHTML)}
|
||||
onkeydown={(e) => handleKeydown(e, item.id)}
|
||||
onclick={handleLinkClick}
|
||||
onpaste={handlePaste}
|
||||
onblur={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
requestAnimationFrame(() => {
|
||||
if (el.isConnected && !el.contains(document.activeElement)) {
|
||||
el.innerHTML = linkifyText(item.text);
|
||||
}
|
||||
});
|
||||
}}
|
||||
class="flex-1 min-w-0 bg-transparent text-sm outline-none break-words {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
data-placeholder="List item"
|
||||
aria-placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
data-item-id={item.id}
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
></div>
|
||||
<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"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<button
|
||||
onclick={() => addItem(items.length - 1)}
|
||||
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
class="flex items-center gap-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-add"
|
||||
>
|
||||
<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>
|
||||
<Plus class="h-4 w-4" />
|
||||
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"
|
||||
>
|
||||
<ChevronRight 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" data-testid="checklist-done-section">
|
||||
{#each doneSectionGroups as group}
|
||||
{#if group.parentLabel}
|
||||
<div class="flex items-center gap-2 py-1.5 pl-5" data-testid="checklist-done-parent-label">
|
||||
<input type="checkbox" disabled class="h-4 w-4 rounded border-[var(--border-subtle)] opacity-50" />
|
||||
<span class="text-sm text-[var(--text-muted)] break-words min-w-0">{@html linkifyText(group.parentLabel.text)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each group.children as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1.5 {item.parentId ? 'pl-10' : 'pl-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 break-words min-w-0">{@html linkifyText(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"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if linkPopover}
|
||||
<LinkPopover url={linkPopover.url} anchor={linkPopover.anchor} onClose={() => (linkPopover = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
|
||||
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"
|
||||
>
|
||||
<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,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS, COLOR_OPTIONS } from '$lib/utils/colors.js';
|
||||
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
@@ -12,11 +13,10 @@
|
||||
|
||||
<div class="flex flex-wrap gap-1" data-testid="color-picker">
|
||||
{#each COLOR_OPTIONS as option}
|
||||
{@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-transparent'}"
|
||||
style="background-color: {colors.bg}"
|
||||
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
|
||||
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
|
||||
title={option.label}
|
||||
data-testid="color-{option.value}"
|
||||
></button>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
Heading,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
TextQuote,
|
||||
CodeXml,
|
||||
Bold,
|
||||
@@ -22,38 +23,118 @@
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
Minus,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
Table2,
|
||||
BetweenHorizontalEnd,
|
||||
BetweenVerticalEnd,
|
||||
RemoveFormatting
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
tick?: number;
|
||||
editor: Editor | undefined;
|
||||
currentNoteId?: string | null;
|
||||
}
|
||||
|
||||
let { editor, tick }: Props = $props();
|
||||
let { editor, tick, currentNoteId = null }: Props = $props();
|
||||
|
||||
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
|
||||
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
|
||||
// A stale `editor` reference can still point at an instance mid-teardown (e.g. while
|
||||
// NoteEditor swaps in a fresh Editor across a markdown-mode toggle); calling its
|
||||
// commands after `destroy()` throws, so every accessor here must check isDestroyed too.
|
||||
function canUndo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().undo().run()) ?? false; }
|
||||
function canRedo() { void tick; return (!editor?.isDestroyed && 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?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && editor?.getAttributes(type)) || {}; }
|
||||
|
||||
let openDropdown: string | null = $state(null);
|
||||
let dropdownAnchorEl: HTMLElement | null = $state(null);
|
||||
let linkUrl: string = $state('');
|
||||
let linkInput: HTMLInputElement | undefined = $state();
|
||||
let ignorePositionInvalidationUntil = 0;
|
||||
|
||||
function toggleDropdown(name: string) {
|
||||
let noteSearchResults: { id: string; title: string }[] = $state([]);
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function searchNotesForLink(query: string) {
|
||||
const requestId = ++noteSearchRequestId;
|
||||
if (!query.trim()) {
|
||||
noteSearchResults = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (requestId !== noteSearchRequestId) return;
|
||||
if (!res.ok) return;
|
||||
const results: { id: string; title: string }[] = await res.json();
|
||||
noteSearchResults = results.filter((n) => n.id !== currentNoteId);
|
||||
} catch {
|
||||
// Search failed silently
|
||||
}
|
||||
}
|
||||
|
||||
let noteSearchRequestId = 0;
|
||||
|
||||
function handleLinkInput() {
|
||||
clearTimeout(noteSearchTimer);
|
||||
noteSearchTimer = setTimeout(() => searchNotesForLink(linkUrl), 200);
|
||||
}
|
||||
|
||||
function insertNoteLink(noteId: string) {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().insertContent({ type: 'noteLink', attrs: { noteId } }).run();
|
||||
noteSearchResults = [];
|
||||
closeDropdowns();
|
||||
}
|
||||
|
||||
const dropdownGap = 4;
|
||||
const viewportMargin = 10;
|
||||
|
||||
function toggleDropdown(name: string, anchorEl: HTMLElement) {
|
||||
if (openDropdown === name) {
|
||||
openDropdown = null;
|
||||
closeDropdowns();
|
||||
} else {
|
||||
ignorePositionInvalidationUntil = performance.now() + 600;
|
||||
openDropdown = name;
|
||||
dropdownAnchorEl = anchorEl;
|
||||
if (name === 'link') {
|
||||
linkUrl = editor?.getAttributes('link').href ?? '';
|
||||
// Focus input after Svelte renders the popover
|
||||
requestAnimationFrame(() => linkInput?.focus());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDropdownClick(name: string, event: MouseEvent) {
|
||||
toggleDropdown(name, event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
function preventToolbarMouseFocus(event: MouseEvent) {
|
||||
if (event.button === 0) event.preventDefault();
|
||||
}
|
||||
|
||||
function handleCommandClick(command: () => void) {
|
||||
command();
|
||||
}
|
||||
|
||||
function closeDropdowns() {
|
||||
openDropdown = null;
|
||||
dropdownAnchorEl = null;
|
||||
clearTimeout(noteSearchTimer);
|
||||
noteSearchResults = [];
|
||||
}
|
||||
|
||||
function closeDropdownsAfterPositionChange() {
|
||||
if (!openDropdown || performance.now() < ignorePositionInvalidationUntil) return;
|
||||
closeDropdowns();
|
||||
}
|
||||
|
||||
function getDropdownStyle(rect: DOMRect, width: number): string {
|
||||
const left = Math.max(viewportMargin, Math.min(rect.left, window.innerWidth - width - viewportMargin));
|
||||
// CSS `bottom` on position:fixed and getBoundingClientRect() both use layout-viewport
|
||||
// coordinates, so the reference height must be innerHeight — visualViewport.height is
|
||||
// smaller while the keyboard is open and would push the dropdown behind the keyboard.
|
||||
const bottom = window.innerHeight - rect.top + dropdownGap;
|
||||
return `bottom: ${bottom}px; left: ${left}px;`;
|
||||
}
|
||||
|
||||
function applyLink() {
|
||||
@@ -80,15 +161,15 @@
|
||||
const chevronSize = 14;
|
||||
|
||||
function btnClass(active: boolean = false, disabled: boolean = false): string {
|
||||
return `rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
}
|
||||
|
||||
function dropdownBtnClass(active: boolean = false): string {
|
||||
return `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 `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/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-[var(--text)] hover:bg-[var(--hover-wash)]/5 ${active ? 'bg-[var(--hover-wash)]/5' : ''}`;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
@@ -104,289 +185,463 @@
|
||||
closeDropdowns();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!openDropdown || !window.visualViewport) return;
|
||||
|
||||
window.visualViewport.addEventListener('resize', closeDropdownsAfterPositionChange);
|
||||
window.visualViewport.addEventListener('scroll', closeDropdownsAfterPositionChange);
|
||||
return () => {
|
||||
window.visualViewport?.removeEventListener('resize', closeDropdownsAfterPositionChange);
|
||||
window.visualViewport?.removeEventListener('scroll', closeDropdownsAfterPositionChange);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
||||
<svelte:window onscroll={closeDropdownsAfterPositionChange} onresize={closeDropdownsAfterPositionChange} />
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||
class="flex overflow-x-auto md:flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1 scrollbar-hide"
|
||||
style="scrollbar-width: none; -ms-overflow-style: none;"
|
||||
data-testid="formatting-toolbar"
|
||||
onscroll={closeDropdownsAfterPositionChange}
|
||||
>
|
||||
<!-- History -->
|
||||
<!-- Undo/Redo -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().undo().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().undo().run())}
|
||||
disabled={!canUndo()}
|
||||
class={btnClass(false, !canUndo())}
|
||||
title="Undo (Ctrl+Z)"
|
||||
title="Undo"
|
||||
data-testid="format-undo"
|
||||
>
|
||||
<Undo2 size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().redo().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().redo().run())}
|
||||
disabled={!canRedo()}
|
||||
class={btnClass(false, !canRedo())}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
title="Redo"
|
||||
data-testid="format-redo"
|
||||
>
|
||||
<Redo2 size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="relative" data-dropdown="heading">
|
||||
<button
|
||||
onclick={() => toggleDropdown('heading')}
|
||||
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
|
||||
title="Heading"
|
||||
data-testid="format-heading"
|
||||
>
|
||||
<Heading size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'heading'}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
||||
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="font-semibold">Heading 1</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
||||
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>Heading 2</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
||||
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>Heading 3</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- List dropdown -->
|
||||
<div class="relative" data-dropdown="list">
|
||||
<button
|
||||
onclick={() => toggleDropdown('list')}
|
||||
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'list'}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
|
||||
data-testid="format-bullet-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
<span>Bullet list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
|
||||
data-testid="format-ordered-list"
|
||||
>
|
||||
<ListOrdered size={iconSize} />
|
||||
<span>Ordered 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-[var(--border-subtle)]"></div>
|
||||
<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(editor?.isActive('bold') ?? false)}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => 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(editor?.isActive('italic') ?? false)}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => 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(editor?.isActive('strike') ?? false)}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => 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().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)}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleUnderline().run())}
|
||||
class={btnClass(isActive('underline'))}
|
||||
title="Underline (Ctrl+U)"
|
||||
data-testid="format-underline"
|
||||
>
|
||||
<Underline size={iconSize} />
|
||||
</button>
|
||||
|
||||
<!-- Link popover -->
|
||||
<div class="relative" data-dropdown="link">
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div data-dropdown="heading">
|
||||
<button
|
||||
onclick={() => toggleDropdown('link')}
|
||||
class={btnClass(editor?.isActive('link') ?? false)}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('heading', e)}
|
||||
class={dropdownBtnClass(isActive('heading'))}
|
||||
title="Heading"
|
||||
data-testid="format-heading"
|
||||
>
|
||||
<Heading size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- List dropdown -->
|
||||
<div data-dropdown="list">
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('list', e)}
|
||||
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Link popover -->
|
||||
<div data-dropdown="link">
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('link', e)}
|
||||
class={btnClass(isActive('link'))}
|
||||
title="Insert link (Ctrl+K)"
|
||||
data-testid="format-link"
|
||||
>
|
||||
<Link size={iconSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'link'}
|
||||
<div class="absolute left-1/2 top-full z-50 mt-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
|
||||
<form
|
||||
class="flex items-center gap-1"
|
||||
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
|
||||
>
|
||||
<input
|
||||
bind:this={linkInput}
|
||||
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)]"
|
||||
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)]"
|
||||
title="Apply link"
|
||||
data-testid="format-link-apply"
|
||||
>
|
||||
<CornerDownLeft size={16} />
|
||||
</button>
|
||||
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openLink}
|
||||
disabled={!editor?.getAttributes('link').href}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
||||
title="Open link"
|
||||
data-testid="format-link-open"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={removeLink}
|
||||
disabled={!editor?.isActive('link')}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
|
||||
title="Remove link"
|
||||
data-testid="format-unlink"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Align dropdown -->
|
||||
<div class="relative" data-dropdown="align">
|
||||
<!-- Table dropdown -->
|
||||
<div data-dropdown="table">
|
||||
<button
|
||||
onclick={() => toggleDropdown('align')}
|
||||
class={dropdownBtnClass(editor?.isActive({ textAlign: 'center' }) || editor?.isActive({ textAlign: 'right' }) || editor?.isActive({ textAlign: 'justify' }) ? true : false)}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('table', e)}
|
||||
class={dropdownBtnClass(isActive('table'))}
|
||||
title="Table"
|
||||
data-testid="format-table"
|
||||
>
|
||||
<Table2 size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alignment dropdown -->
|
||||
<div data-dropdown="align">
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('align', e)}
|
||||
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
|
||||
title="Text alignment"
|
||||
data-testid="format-align"
|
||||
>
|
||||
<AlignLeft size={iconSize} />
|
||||
{#if isActive({ textAlign: 'center' })}
|
||||
<AlignCenter size={iconSize} />
|
||||
{:else if isActive({ textAlign: 'right' })}
|
||||
<AlignRight size={iconSize} />
|
||||
{:else if isActive({ textAlign: 'justify' })}
|
||||
<AlignJustify size={iconSize} />
|
||||
{:else}
|
||||
<AlignLeft size={iconSize} />
|
||||
{/if}
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'align'}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
|
||||
data-testid="format-align-left"
|
||||
>
|
||||
<AlignLeft size={iconSize} />
|
||||
<span>Left</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'center' }) ?? false)}
|
||||
data-testid="format-align-center"
|
||||
>
|
||||
<AlignCenter size={iconSize} />
|
||||
<span>Center</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'right' }) ?? false)}
|
||||
data-testid="format-align-right"
|
||||
>
|
||||
<AlignRight size={iconSize} />
|
||||
<span>Right</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'justify' }) ?? false)}
|
||||
data-testid="format-align-justify"
|
||||
>
|
||||
<AlignJustify size={iconSize} />
|
||||
<span>Justify</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Insert -->
|
||||
<!-- Block-level formatting -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCode().run())}
|
||||
class={btnClass(isActive('code'))}
|
||||
title="Inline code"
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Code size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBlockquote().run())}
|
||||
class={btnClass(isActive('blockquote'))}
|
||||
title="Blockquote"
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<TextQuote size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCodeBlock().run())}
|
||||
class={btnClass(isActive('codeBlock'))}
|
||||
title="Code block"
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<CodeXml size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().setHorizontalRule().run())}
|
||||
class={btnClass()}
|
||||
title="Horizontal rule"
|
||||
title="Divider"
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Render dropdown menus in a fixed portal so they aren't clipped by overflow-x-auto -->
|
||||
{#if openDropdown && dropdownAnchorEl}
|
||||
{@const rect = dropdownAnchorEl.getBoundingClientRect()}
|
||||
|
||||
{#if openDropdown === 'heading'}
|
||||
<div
|
||||
class="fixed z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
||||
style={getDropdownStyle(rect, 170)}
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 1 }))}
|
||||
data-testid="format-h1"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
|
||||
<span class="font-semibold">Heading 1</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 2 }))}
|
||||
data-testid="format-h2"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
|
||||
<span>Heading 2</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 3 }))}
|
||||
data-testid="format-h3"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
|
||||
<span>Heading 3</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if openDropdown === 'list'}
|
||||
<div
|
||||
class="fixed z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
||||
style={getDropdownStyle(rect, 190)}
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('bulletList'))}
|
||||
data-testid="format-bullet-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
<span>Bullet list</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('orderedList'))}
|
||||
data-testid="format-ordered-list"
|
||||
>
|
||||
<ListOrdered size={iconSize} />
|
||||
<span>Ordered list</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('taskList'))}
|
||||
data-testid="format-task-list"
|
||||
>
|
||||
<ListChecks size={iconSize} />
|
||||
<span>Task list</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if openDropdown === 'link'}
|
||||
<div
|
||||
class="fixed z-50 mb-1 min-w-[200px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5"
|
||||
style={getDropdownStyle(rect, 220)}
|
||||
data-dropdown="true"
|
||||
>
|
||||
<form
|
||||
class="flex items-center gap-1"
|
||||
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
|
||||
>
|
||||
<input
|
||||
bind:this={linkInput}
|
||||
bind:value={linkUrl}
|
||||
oninput={handleLinkInput}
|
||||
type="url"
|
||||
placeholder="Paste a link..."
|
||||
class="w-44 bg-transparent px-1.5 py-1 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="format-link-input"
|
||||
/>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
type="submit"
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)]"
|
||||
title="Apply link"
|
||||
data-testid="format-link-apply"
|
||||
>
|
||||
<CornerDownLeft size={16} />
|
||||
</button>
|
||||
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
type="button"
|
||||
onclick={openLink}
|
||||
disabled={!getAttrs('link').href}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
||||
title="Open link"
|
||||
data-testid="format-link-open"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
type="button"
|
||||
onclick={removeLink}
|
||||
disabled={!isActive('link')}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--destructive)] disabled:opacity-30"
|
||||
title="Remove link"
|
||||
data-testid="format-link-remove"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</form>
|
||||
{#if noteSearchResults.length > 0}
|
||||
<div class="mt-1 max-h-40 overflow-y-auto border-t border-[var(--border-subtle)] pt-1">
|
||||
{#each noteSearchResults as result (result.id)}
|
||||
<button
|
||||
type="button"
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => insertNoteLink(result.id)}
|
||||
class="block w-full truncate rounded-sm px-1.5 py-1 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="format-link-note-result"
|
||||
>
|
||||
{result.title || 'Untitled'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if openDropdown === 'table'}
|
||||
<div
|
||||
class="fixed z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
||||
style={getDropdownStyle(rect, 210)}
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-insert-table"
|
||||
>
|
||||
<Table2 size={iconSize} />
|
||||
<span>Insert table</span>
|
||||
</button>
|
||||
{#if isActive('table')}
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-add-row"
|
||||
>
|
||||
<BetweenHorizontalEnd size={iconSize} />
|
||||
<span>Add row</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-add-column"
|
||||
>
|
||||
<BetweenVerticalEnd size={iconSize} />
|
||||
<span>Add column</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-delete-row"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete row</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-delete-column"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete column</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
|
||||
class={`${dropdownItemClass()} text-[var(--destructive)]`}
|
||||
data-testid="format-delete-table"
|
||||
>
|
||||
<RemoveFormatting size={iconSize} />
|
||||
<span>Delete table</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if openDropdown === 'align'}
|
||||
<div
|
||||
class="fixed z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
||||
style={getDropdownStyle(rect, 180)}
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
|
||||
data-testid="format-align-left"
|
||||
>
|
||||
<AlignLeft size={iconSize} />
|
||||
<span>Left</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
|
||||
data-testid="format-align-center"
|
||||
>
|
||||
<AlignCenter size={iconSize} />
|
||||
<span>Center</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
|
||||
data-testid="format-align-right"
|
||||
>
|
||||
<AlignRight size={iconSize} />
|
||||
<span>Right</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
|
||||
data-testid="format-align-justify"
|
||||
>
|
||||
<AlignJustify size={iconSize} />
|
||||
<span>Justify</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
[data-testid="formatting-toolbar"]::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
|
||||
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"
|
||||
>
|
||||
<XIcon 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>
|
||||
@@ -1,27 +1,83 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Star from 'lucide-svelte/icons/star';
|
||||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||
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 }: Props = $props();
|
||||
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured, showDropZone = true }: 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', file);
|
||||
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' }));
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/attachments`, {
|
||||
@@ -31,12 +87,16 @@
|
||||
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) {
|
||||
@@ -56,22 +116,42 @@
|
||||
{#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">
|
||||
<img
|
||||
src="/api/notes/{noteId}/attachments/{attachment.id}"
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 rounded-sm object-cover"
|
||||
<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; }}
|
||||
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">
|
||||
<RefreshCw 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"
|
||||
>
|
||||
<Star 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 hidden rounded-full bg-red-500 p-0.5 text-white group-hover:block"
|
||||
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"
|
||||
aria-label="Remove attachment"
|
||||
data-testid="remove-attachment"
|
||||
>
|
||||
<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>
|
||||
<XIcon class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -79,29 +159,58 @@
|
||||
{/if}
|
||||
|
||||
<!-- Upload area -->
|
||||
<div
|
||||
class="rounded-sm border-2 border-dashed p-4 text-center transition-colors {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 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>
|
||||
{#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>
|
||||
|
||||
{#if lightboxSrc}
|
||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||
{/if}
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
<script lang="ts">
|
||||
import SearchBar from '../SearchBar.svelte';
|
||||
import SyncIndicator from '../SyncIndicator.svelte';
|
||||
import Menu from 'lucide-svelte/icons/menu';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
|
||||
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">
|
||||
<button
|
||||
onclick={onMenuToggle}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg class="h-6 w-6 text-[var(--text)]" 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>
|
||||
{#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(--hover-wash)]/10"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Menu class="h-6 w-6 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl">🥨</span>
|
||||
<h1 class="font-['Press_Start_2P'] text-lg text-[var(--primary)]">Crumbs</h1>
|
||||
</div>
|
||||
<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="mx-4 flex-1">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<!-- Desktop search bar -->
|
||||
<div class="mx-4 hidden flex-1 lg:block">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<SyncIndicator />
|
||||
</div>
|
||||
<!-- Mobile search icon -->
|
||||
<button
|
||||
onclick={() => (mobileSearchOpen = true)}
|
||||
class="ml-auto rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 lg:hidden"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search class="h-5 w-5 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<SyncIndicator />
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
@@ -1,57 +1,54 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
import { page } from '$app/stores';
|
||||
import { allTags } from '$lib/stores/notes.js';
|
||||
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let { open }: Props = $props();
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
function setFilter(filter: NoteFilter) {
|
||||
selectedTag.set(null);
|
||||
loadNotes(filter);
|
||||
function closeMobile() {
|
||||
if (window.matchMedia('(max-width: 1023px)').matches) {
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
function selectTag(tag: string | null) {
|
||||
selectedTag.set(tag);
|
||||
loadNotes('all');
|
||||
}
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Crumbs', icon: StickyNote, match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: Archive, match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: Trash2, match: (p: string) => p === '/trash' }
|
||||
];
|
||||
</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'} lg:translate-x-0"
|
||||
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'}"
|
||||
>
|
||||
<nav class="p-2">
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('all')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<StickyNote size={20} />
|
||||
Crumbs
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('archived')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Archive size={20} />
|
||||
Archive
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('trashed')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
Trash
|
||||
</button>
|
||||
</li>
|
||||
{#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)]'}"
|
||||
>
|
||||
<item.icon size={20} />
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if $allTags.length > 0}
|
||||
@@ -60,17 +57,29 @@
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each $allTags as tag}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectTag($selectedTag === tag ? null : tag)}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
<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)]'}"
|
||||
>
|
||||
<Tag size={16} />
|
||||
#{tag}
|
||||
</button>
|
||||
</a>
|
||||
</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"
|
||||
>
|
||||
<Settings size={20} />
|
||||
Settings
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,38 +1,11 @@
|
||||
<script lang="ts">
|
||||
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));
|
||||
}
|
||||
import { toasts, dismiss } from '$lib/stores/toast.js';
|
||||
</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 border-[var(--border)] px-4 py-3 {toast.type === 'error' ? 'bg-red-600 text-white' : toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-[var(--text)] text-[var(--bg-base)]'}"
|
||||
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)]'}"
|
||||
>
|
||||
<span class="text-sm">{toast.text}</span>
|
||||
{#if toast.action}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user