diff --git a/.gemini/settings.json b/.gemini/settings.json
new file mode 100644
index 00000000..8de9d583
--- /dev/null
+++ b/.gemini/settings.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "svelte": {
+ "command": "npx",
+ "args": ["-y", "@sveltejs/mcp"]
+ }
+ }
+}
diff --git a/CLAUDE.md b/AGENTS.md
similarity index 99%
rename from CLAUDE.md
rename to AGENTS.md
index 843fdc9b..36d041bd 100644
--- a/CLAUDE.md
+++ b/AGENTS.md
@@ -5,11 +5,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands
**Development:**
+
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
**Implementation Guidelines:**
+
- Before implementing any feature, read `ARCHITECTURE.md` for design decisions and `IMPLEMENTATION_PLAN.md` for the step-by-step implementation spec
- Design decisions go in `ARCHITECTURE.md`, implementation steps go in `IMPLEMENTATION_PLAN.md`
- New features must be specified before implementation begins — the spec should be concise but sufficient to derive the implementation from
@@ -20,22 +22,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- You can suggest what the next step could be, but don't implement it
**Refactoring Guidelines:**
+
- During refactors, make ONLY the minimal changes needed (e.g., renaming APIs)
- Do NOT "improve" or restructure logic while refactoring
- If you see something that could be improved, note it separately for a future task
- Refactoring and improving are two separate activities - never combine them
**Code Style:**
+
- Use snake_case for all variable names, function names, and identifiers
- This applies to JavaScript/TypeScript code, test files, and any new code written
**Styling:**
+
- Use Tailwind CSS classes whenever possible
- Minimize custom CSS - only use it for things Tailwind can't handle (e.g., CSS custom properties like `var(--svedit-editing-stroke)`)
- Use Tailwind's arbitrary value syntax for custom properties: `text-(--svedit-editing-stroke)`, `border-(--svedit-editing-stroke)`
- Do not use rounded corners (keep elements rectangular)
**What to NOT change (keep camelCase):**
+
- `window.getSelection()` - native API
- `document.activeElement` - native API
- `navigator.clipboard` - native API
@@ -49,9 +55,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Pattern**: If it's a web platform API or Svelte API, keep camelCase. If it's our custom variable/function name, use snake_case.
**File Extensions:**
+
- Files using Svelte runes (`$state`, `$derived`, `$effect`, etc.) must use `.svelte.js` or `.svelte.ts` extension
**Documentation Style:**
+
- Use sentence case for all headings in documentation (README.md, etc.)
- Use sentence case for code comments
- Sentence case means: capitalize only the first word and proper nouns
@@ -72,16 +80,19 @@ Svedit is a rich content editor template built with Svelte 5 that uses a graph-b
### Core Components
**Document Model:**
+
- `Document` - Central document class with state management, transactions, and history
- `Tras` - Handles atomic operations on the document
- Documents are represented as graphs of nodes with properties and references
**Selection:**
+
- Supports text, node, and property selections
- Maps between internal selection model and DOM selection
- Handles complex selection scenarios like backwards selections and multi-node selections
**Key Components:**
+
- `Svedit.svelte` - Main editor component with event handling and selection management
- `NodeArrayProperty.svelte` - Renders containers that hold sequences of nodes
- `AnnotatedTextProperty.svelte` - Handles annotated text rendering and editing
@@ -90,6 +101,7 @@ Svedit is a rich content editor template built with Svelte 5 that uses a graph-b
### Schema
Content is defined through schemas that specify:
+
- Node types and their properties
- Property types: `string`, `integer`, `boolean`, `string_array`, `annotated_text`, `node`, `node_array`
- Reference relationships between nodes
@@ -105,6 +117,7 @@ Content is defined through schemas that specify:
## Schema and Inserter
When adding new properties to a node type:
+
1. Add to schema in `create_session.js` (`document_schema`)
2. Add to inserter in `create_session.js` (`inserters`)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 33be4a1e..92f2a950 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -14,17 +14,17 @@ The app uses Svelte's experimental async features and SvelteKit's remote functio
```js
const config = {
- kit: {
- adapter: adapter(),
- experimental: {
- remoteFunctions: true
- }
- },
- compilerOptions: {
- experimental: {
- async: true
- }
- }
+ kit: {
+ adapter: adapter(),
+ experimental: {
+ remoteFunctions: true
+ }
+ },
+ compilerOptions: {
+ experimental: {
+ async: true
+ }
+ }
};
```
@@ -40,7 +40,7 @@ const config = {
import migrate from '$lib/server/migrate.js';
export async function init() {
- migrate();
+ migrate();
}
```
@@ -205,16 +205,16 @@ There are two media node types: `image` and `video`. Each has the same visual pr
```json
{
- "id": "feature_1_image",
- "type": "image",
- "src": "c4b519da4c0a6512b5d9519aac0d9df7fab9152a6df109515456ada4702fabdb.webp",
- "width": 1600,
- "height": 900,
- "alt": "Feature image",
- "scale": 1.0,
- "focal_point_x": 0.5,
- "focal_point_y": 0.5,
- "object_fit": "cover"
+ "id": "feature_1_image",
+ "type": "image",
+ "src": "c4b519da4c0a6512b5d9519aac0d9df7fab9152a6df109515456ada4702fabdb.webp",
+ "width": 1600,
+ "height": 900,
+ "alt": "Feature image",
+ "scale": 1.0,
+ "focal_point_x": 0.5,
+ "focal_point_y": 0.5,
+ "object_fit": "cover"
}
```
@@ -222,16 +222,16 @@ There are two media node types: `image` and `video`. Each has the same visual pr
```json
{
- "id": "hero_video",
- "type": "video",
- "src": "e7a3f1bc...abcd.mp4",
- "width": 1920,
- "height": 1080,
- "alt": "Product demo",
- "scale": 1.0,
- "focal_point_x": 0.5,
- "focal_point_y": 0.5,
- "object_fit": "cover"
+ "id": "hero_video",
+ "type": "video",
+ "src": "e7a3f1bc...abcd.mp4",
+ "width": 1920,
+ "height": 1080,
+ "alt": "Product demo",
+ "scale": 1.0,
+ "focal_point_x": 0.5,
+ "focal_point_y": 0.5,
+ "object_fit": "cover"
}
```
@@ -297,8 +297,8 @@ The paste handler uses `get_media_type(file)` to map each file's MIME type to a
```js
/** @returns {'image' | 'video'} */
function get_media_type(file) {
- if (file.type.startsWith('video/')) return 'video';
- return 'image';
+ if (file.type.startsWith('video/')) return 'video';
+ return 'image';
}
```
@@ -384,13 +384,13 @@ variant URL = /assets/{stem}/w320.webp
```html
```
@@ -402,12 +402,12 @@ Different media types are handled differently:
The asset id always includes the file extension. The stem (id without extension) is used to derive the variant directory.
-| Type | Node type | Client processing | Asset id example | Variants |
-|---|---|---|---|---|
-| Static images (JPEG, PNG, WebP, HEIC) | `image` | Resize to `MAX_IMAGE_WIDTH`, convert to WebP via WASM | `c4b519da...fabdb.webp` | Yes (`c4b519da...fabdb/w320.webp`, etc.) |
-| Animated GIFs | `image` | Passthrough | `c4b519da...fabdb.gif` | No |
-| SVGs | `image` | Passthrough | `c4b519da...fabdb.svg` | No |
-| Videos (MP4) | `video` | Passthrough | `c4b519da...fabdb.mp4` | No |
+| Type | Node type | Client processing | Asset id example | Variants |
+| ------------------------------------- | --------- | ----------------------------------------------------- | ----------------------- | ---------------------------------------- |
+| Static images (JPEG, PNG, WebP, HEIC) | `image` | Resize to `MAX_IMAGE_WIDTH`, convert to WebP via WASM | `c4b519da...fabdb.webp` | Yes (`c4b519da...fabdb/w320.webp`, etc.) |
+| Animated GIFs | `image` | Passthrough | `c4b519da...fabdb.gif` | No |
+| SVGs | `image` | Passthrough | `c4b519da...fabdb.svg` | No |
+| Videos (MP4) | `video` | Passthrough | `c4b519da...fabdb.mp4` | No |
### Image size constraints
@@ -618,6 +618,41 @@ In `hooks.server.js`, on every request:
- `POST /api/login` — authenticate with `{ password }`, sets session cookie
- `POST /api/logout` — clears session cookie, deletes session row
+## Highlights block
+
+The highlights block is a compact list for property-style metadata (e.g. "Units: 8"). It renders as a flex list with `justify-between`, and each row has a bottom border underline.
+
+Schema:
+
+- `highlights` (block)
+ - `items` — `node_array` of `highlight_item`
+- `highlight_item` (block)
+ - `label` — `annotated_text`, single line
+ - `value` — `annotated_text`, single line
+
+Editing:
+
+- A visible "add row" handle appears below the list in edit mode.
+
+## Columns block
+
+The columns block is a parent layout container that holds multiple column node arrays. Each column can contain any standard page body block (prose, hero, gallery, etc.), enabling multi-column sections without changing the page body structure.
+
+Schema:
+
+- `columns` (block)
+ - `layout` — integer layout selector
+ - `columns` — `node_array` of `column`
+- `column` (block)
+ - `content` — `node_array` of page body block types
+
+Layouts:
+
+- Layout 1: two equal columns (`md:grid-cols-2`)
+- Layout 2: three equal columns (`md:grid-cols-3`)
+
+Mobile behavior: columns stack into a single column (`grid-cols-1`).
+
## Future: optional S3 storage
Assets are stored on the local filesystem (`ASSET_PATH`) by default. This keeps the app fully self-contained — a single deployment with no external dependencies beyond the server itself. For most sites this is sufficient.
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index 8326069e..cd5be3e3 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -70,6 +70,7 @@ export const ASSET_BASE = '/assets';
`src/lib/config.js` (which imports from `node:path`) remains server-only and unchanged. The Web Worker and client code import from `asset-config.js`.
`ASSET_BASE` is the URL prefix used to construct asset URLs on the client. All `src` values in saved documents are bare asset ids (e.g. `c4b519da...fabdb.webp`). Components prefix them with `ASSET_BASE` at render time to build the full URL. This is the only valid source for saved images — no absolute URLs, no external URLs, no relative paths. A `src` value is either:
+
- `blob:...` — unsaved, only valid during the current editing session
- A bare asset id — saved, rendered as `{ASSET_BASE}/{asset_id}`
@@ -96,10 +97,10 @@ The client computes the SHA-256 hex hash of the **source file** (before any proc
```js
async function hash_blob(blob) {
- const buffer = await blob.arrayBuffer();
- const hash_buffer = await crypto.subtle.digest('SHA-256', buffer);
- const hash_array = Array.from(new Uint8Array(hash_buffer));
- return hash_array.map(b => b.toString(16).padStart(2, '0')).join('');
+ const buffer = await blob.arrayBuffer();
+ const hash_buffer = await crypto.subtle.digest('SHA-256', buffer);
+ const hash_array = Array.from(new Uint8Array(hash_buffer));
+ return hash_array.map((b) => b.toString(16).padStart(2, '0')).join('');
}
```
@@ -166,6 +167,7 @@ ASSET_PATH/
```
Functions:
+
- `asset_path(asset_id)` — full path for an original: `join(ASSET_PATH, asset_id)`
- `variant_dir(asset_id)` — directory for variants: strip extension from asset_id, `join(ASSET_PATH, stem)`
- `variant_path(asset_id, width)` — full path for a variant: `join(variant_dir(asset_id), 'w' + width + '.webp')`
@@ -187,10 +189,10 @@ Svedit already has a `handle_image_paste` callback in `create_session.js` that f
2. Start background processing for the pasted file (runs concurrently, does not block the editor):
a. Compute the SHA-256 hash of the source file.
b. Determine the asset type:
- - **SVG** (`image/svg+xml`): passthrough — no WASM processing. Extract dimensions via `` element. The blob is the original file. Extension: `.svg`.
- - **Animated GIF** (`image/gif` with multiple frames): passthrough — no WASM processing. Extract dimensions via `` element. The blob is the original file. Extension: `.gif`.
- - **Static raster image** (JPEG, PNG, WebP, HEIC, HEIF, still GIF): process via WASM worker — decode, resize if > `MAX_IMAGE_WIDTH`, encode as WebP, generate all applicable width variants. Extension: `.webp`.
- c. Store the processing result (hash, original blob, variants, dimensions) keyed by the blob URL in a `Map`. This map is consulted during the save flow.
+ - **SVG** (`image/svg+xml`): passthrough — no WASM processing. Extract dimensions via `` element. The blob is the original file. Extension: `.svg`.
+ - **Animated GIF** (`image/gif` with multiple frames): passthrough — no WASM processing. Extract dimensions via `` element. The blob is the original file. Extension: `.gif`.
+ - **Static raster image** (JPEG, PNG, WebP, HEIC, HEIF, still GIF): process via WASM worker — decode, resize if > `MAX_IMAGE_WIDTH`, encode as WebP, generate all applicable width variants. Extension: `.webp`.
+ c. Store the processing result (hash, original blob, variants, dimensions) keyed by the blob URL in a `Map`. This map is consulted during the save flow.
3. The user can continue editing. Processing happens in the background.
The processing/upload map and its logic should live in a dedicated module (e.g. `src/lib/client/asset-upload.js`), not inline in `create_session.js` or `+page.svelte`.
@@ -224,6 +226,7 @@ Use XHR (not `fetch`) for uploads so we get `upload.onprogress` events for progr
Update `save_document` in `api.remote.js` to track asset references as part of the save transaction:
1. Add the `asset_refs` table in a new migration step (`add_asset_refs`):
+
```sql
CREATE TABLE asset_refs (
asset_id TEXT NOT NULL,
@@ -248,9 +251,9 @@ The Svedit image component needs to handle two `src` modes:
```html
```
@@ -259,6 +262,7 @@ Only include variants for widths strictly smaller than the image's `width`. The
### File summary
New files:
+
- `src/lib/client/asset-processor.js` — Web Worker for WASM image processing
- `src/lib/client/process-asset.js` — main-thread wrapper for the worker
- `src/lib/server/asset-storage.js` — filesystem operations for assets
@@ -270,6 +274,7 @@ New files:
- `src/routes/assets/[...path]/+server.js` — GET serve assets from disk
Modified files:
+
- `vite.config.js` — add `optimizeDeps.exclude` and `worker.format`
- `package.json` — add `@jsquash/webp` and `@jsquash/resize` dependencies
- `src/lib/server/migrations.js` — add `add_asset_refs` migration step
@@ -363,8 +368,8 @@ CMD ["node", "/app/scripts/start-app.js"]
import { server as app } from '/app/build/index.js';
function shutdownServer() {
- console.log('Server doing graceful shutdown');
- app.server.close();
+ console.log('Server doing graceful shutdown');
+ app.server.close();
}
process.on('SIGINT', shutdownServer);
@@ -477,6 +482,7 @@ ls /data/assets/ # inspect uploaded assets (from SSH)
### File summary
New files:
+
- `Dockerfile` — multi-stage Docker build
- `fly.toml` — Fly.io configuration
- `.dockerignore` — Docker build context exclusions
@@ -484,6 +490,7 @@ New files:
- `scripts/start-app.js` — Node.js entry point with graceful shutdown
Modified files:
+
- `svelte.config.js` — switch to `@sveltejs/adapter-node`
- `package.json` — add `@sveltejs/adapter-node`, remove `@sveltejs/adapter-auto`
@@ -534,6 +541,7 @@ The property names stay as `image` / `logo` for now. `default_node_type` stays `
### Component changes
**`Video.svelte`** — new component, mirrors `Image.svelte` structure:
+
- Receives `path` prop, reads node from session
- Resolves `src` the same way (blob URL → direct, asset id → `ASSET_BASE` prefix)
- Renders `