forked from mirror/crumbs-original
Compare commits
359
Commits
+18
-76
@@ -48,83 +48,17 @@ jobs:
|
||||
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
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, build]
|
||||
needs: [bump, ci]
|
||||
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
|
||||
ref: ${{ needs.bump.outputs.tag }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
@@ -136,6 +70,9 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU (for multi-platform builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -146,11 +83,16 @@ jobs:
|
||||
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: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -161,7 +103,7 @@ jobs:
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, merge]
|
||||
needs: [bump, release]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, 'claude/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
@@ -19,6 +19,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -35,6 +37,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -59,11 +63,13 @@ jobs:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.58.0-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -115,15 +121,5 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
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
|
||||
run: docker build -t crumbs:ci .
|
||||
|
||||
@@ -13,77 +13,13 @@ jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
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
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: ci
|
||||
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
|
||||
@@ -94,6 +30,9 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU (for multi-platform builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -104,11 +43,16 @@ jobs:
|
||||
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: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -118,7 +62,7 @@ jobs:
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: merge
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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
|
||||
@@ -44,7 +44,4 @@ todo.md
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Superpowers brainstorm artifacts
|
||||
.superpowers/
|
||||
package-lock.json
|
||||
|
||||
@@ -37,41 +37,9 @@ All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colo
|
||||
| `--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
|
||||
@@ -85,8 +53,8 @@ together.
|
||||
- **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.
|
||||
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
|
||||
- **Icons**: Pixelarticons via `@iconify/svelte` — use `<Icon icon="pixelarticons:name" width={size} class="..." />`. Keep at 16-20px size. Exception: Bold, Italic, Strikethrough, Underline stay as Lucide (`lucide-svelte`). See full mapping in `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`.
|
||||
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
|
||||
|
||||
### Layout groups
|
||||
@@ -178,7 +146,7 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
|
||||
|
||||
GitHub Actions with two workflows:
|
||||
|
||||
**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):
|
||||
**CI** (`.github/workflows/ci.yml`) — runs on push to `main`/`claude/**` and PRs to `main`:
|
||||
1. **Lint & Type Check** — `pnpm check`
|
||||
2. **Unit Tests** — `pnpm test:unit`
|
||||
3. **Build** — `pnpm build` (depends on steps 1+2)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
@@ -11,10 +11,6 @@
|
||||
|
||||
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)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.32.0
|
||||
image: ghcr.io/bretzel-app/crumbs:0.22.3
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
-108
@@ -410,114 +410,6 @@ Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||
@@ -100,24 +100,8 @@ Key tables:
|
||||
- `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:
|
||||
@@ -151,19 +135,6 @@ 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
|
||||
|
||||
```
|
||||
|
||||
+3
-22
@@ -10,24 +10,7 @@ On first launch, Crumbs presents a setup screen to create the initial admin acco
|
||||
|
||||
- Passwords are hashed with **Argon2** (argon2id variant)
|
||||
- Sessions use httpOnly cookies with 30-day expiry
|
||||
- Admins can revoke the sessions of, and reset the password of, users other than themselves via **Settings > Users** — see [Admin password resets](#admin-password-resets)
|
||||
|
||||
### Admin password resets
|
||||
|
||||
An admin can reset the password of any user other than themselves, provided that account signs in with a password. Crumbs generates the new password and shows it once in that user's row — copy it and pass it to the user out of band, because it is not stored anywhere and cannot be shown again once dismissed. If SMTP is configured Crumbs also tries to notify the user that their password was reset, but the attempt is best-effort: a delivery failure is logged on the server and never surfaced in the UI, so a successful reset is not evidence the user was told. The notification never contains the password.
|
||||
|
||||
The control appears on every row, but is unavailable in two cases, each stating its reason on the row:
|
||||
|
||||
- **Your own row** — change your own password in **Settings > Profile**, which verifies your current password first.
|
||||
- **An account that signs in via OAuth/SSO** — it has no password the login form would accept, so setting one would hand the user a credential that cannot work.
|
||||
|
||||
It is also unavailable while a generated password is still on screen, and that case gives no reason — dismiss the password first if you need to generate another.
|
||||
|
||||
A reset does not sign the user out. **Revoke all sessions** is a separate action on the same row and, like the reset, is not offered for your own account.
|
||||
|
||||
A displayed password is only good until that account's password changes again: any later reset — by another admin, or by the user themselves from **Settings > Profile** — silently invalidates one still on screen, and nothing marks it as stale. Separately, if Crumbs cannot confirm a reset it says so and shows the password anyway; keep that one, since it may or may not have been applied, and reset again if the user cannot sign in.
|
||||
|
||||
> **No password recovery.** Crumbs has no forgot-password flow. An admin who forgets their own password cannot reset it themselves: the admin control excludes their own row, and the profile flow requires the current password. Another admin can reset you, so keep a second admin account as cheap insurance. Failing that, if an OAuth/SSO provider is configured and your account's email matches your identity there, signing in with that provider gets you back in without a password — at the cost of handing the account over to the provider permanently, after which your password no longer works. With no other admin and no provider configured, there is no way back in through the app.
|
||||
- Admins can reset passwords and revoke sessions for any user via Settings
|
||||
|
||||
## OAuth / SSO
|
||||
|
||||
@@ -46,14 +29,12 @@ OAuth providers are **auto-enabled** when their environment variables are set
|
||||
|
||||
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
|
||||
|
||||
1. Go to **Settings > Users** and fill in the **Create User** form
|
||||
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.
|
||||
|
||||
Tick **OAuth-only (no password)** when creating these accounts — the provider is how they will sign in anyway. If you set a password instead, treat it as temporary: the first OAuth sign-in hands the account over to the provider, after which the password no longer works at the login form and the admin password reset is unavailable for that account.
|
||||
|
||||
### Environment variables
|
||||
|
||||
#### Google OAuth
|
||||
@@ -149,7 +130,7 @@ services:
|
||||
|
||||
**4. Pre-create users:**
|
||||
|
||||
In Crumbs, go to **Settings > Users** and create accounts with emails matching your Authentik users, ticking **OAuth-only (no password)**. They can then log in via the "Authentik" button on the login page.
|
||||
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
|
||||
|
||||
|
||||
+9
-30
@@ -37,11 +37,6 @@
|
||||
- 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
|
||||
@@ -69,16 +64,10 @@
|
||||
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
||||
- 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
|
||||
- Pinned section separated from unpinned notes
|
||||
- Toggle pin from note card hover actions
|
||||
### Favorite Notes
|
||||
- Favorite important notes to always appear at top
|
||||
- Favorites section separated from other notes
|
||||
- Toggle favorite from note card hover actions
|
||||
|
||||
### Archive
|
||||
- Archive notes to declutter main view
|
||||
@@ -92,20 +81,13 @@
|
||||
- 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
|
||||
- Collaborator indicator (Users icon) on shared note cards
|
||||
- Hover popover showing collaborator names
|
||||
- Share dialog accessible from editor toolbar
|
||||
- Collaborators can leave shared notes
|
||||
- Shared notes appear mixed in main list alongside owned notes
|
||||
@@ -142,11 +124,8 @@
|
||||
### User Management (admin)
|
||||
- Admin dashboard to create, list, and delete users
|
||||
- Assign roles (admin / user)
|
||||
- Reset another user's password — Crumbs generates a 20-character password and shows it once in that user's row, with a copy button; it cannot be shown again
|
||||
- Only for accounts that sign in with a password — OAuth/SSO accounts show why the reset is unavailable
|
||||
- Your own password is changed from Settings > Profile, not here
|
||||
- A reset does not sign the user out — revoke their sessions separately to force a logout
|
||||
- Revoke all sessions (force logout) for any user other than yourself
|
||||
- Reset passwords for any user
|
||||
- Revoke all sessions (force logout) for any user
|
||||
|
||||
### PWA / Offline-First
|
||||
- Installable as standalone app
|
||||
@@ -159,7 +138,7 @@
|
||||
|
||||
### 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
|
||||
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; favorite, reorder notes; upload images
|
||||
- API key authentication (Bearer token)
|
||||
- Compatible with Claude Code, Claude Desktop, and other MCP clients
|
||||
- Stateful sessions with automatic cleanup
|
||||
|
||||
@@ -237,104 +237,6 @@ paths:
|
||||
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
|
||||
@@ -618,47 +520,6 @@ components:
|
||||
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:
|
||||
|
||||
@@ -1,781 +0,0 @@
|
||||
# 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,672 @@
|
||||
# Pixelarticons Migration + Favorites Rename — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace Lucide icons with pixelarticons via @iconify/svelte across the app (except 4 formatting letterforms), and rename user-facing "pinned" to "favorites" with a heart icon.
|
||||
|
||||
**Architecture:** Each component file gets its Lucide imports replaced with a single `Icon` import from `@iconify/svelte`. The `size` prop becomes `width`, and the icon name becomes a `pixelarticons:name` string. The 4 formatting letterforms (Bold, Italic, Strikethrough, Underline) stay as Lucide. The favorites rename touches stores, components, MCP server, and one e2e test.
|
||||
|
||||
**Tech Stack:** `@iconify/svelte` v5.2.1, `@iconify-json/pixelarticons` v1.2.4 (both already installed), `lucide-svelte` (kept for 4 icons)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
No new files created. All changes are modifications to existing files:
|
||||
|
||||
**Icon migration (16 files):** Replace Lucide imports with `@iconify/svelte` Icon component.
|
||||
**Favorites rename (4 source files + 1 test file):** Rename stores, functions, labels, test IDs.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Favorites Rename — Stores
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/stores/notes.ts:38-44,234-236`
|
||||
|
||||
- [ ] **Step 1: Rename store exports and function**
|
||||
|
||||
In `src/lib/stores/notes.ts`, make these changes:
|
||||
|
||||
```typescript
|
||||
// Line 38-44: Rename stores
|
||||
export const favoriteNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => n.pinned))
|
||||
);
|
||||
|
||||
export const unfavoritedNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => !n.pinned))
|
||||
);
|
||||
|
||||
// Line 234-236: Rename function
|
||||
export async function toggleFavorite(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
```
|
||||
|
||||
Note: The `pinned` field in updateNote stays — it's the DB column name.
|
||||
|
||||
- [ ] **Step 2: Verify no type errors**
|
||||
|
||||
Run: `pnpm check 2>&1 | head -30`
|
||||
Expected: Errors in files that still reference old names (NoteCard, NotesView) — that's expected, we fix those next.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/stores/notes.ts
|
||||
git commit -m "refactor: rename pinned stores to favorites"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Favorites Rename — NoteCard Component
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteCard.svelte:4,110-118,170-178`
|
||||
|
||||
- [ ] **Step 1: Update import**
|
||||
|
||||
Change line 4:
|
||||
```typescript
|
||||
import { toggleFavorite, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update favorite indicator (lines 110-118)**
|
||||
|
||||
Replace the pinned indicator block:
|
||||
```svelte
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unfavorite"
|
||||
data-testid="favorite-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update favorite button (lines 170-178)**
|
||||
|
||||
Replace the pin button block:
|
||||
```svelte
|
||||
{#if !note.pinned}
|
||||
<button
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Favorite"
|
||||
data-testid="favorite-btn"
|
||||
>
|
||||
<Bookmark class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteCard.svelte
|
||||
git commit -m "refactor: rename pin to favorite in NoteCard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Favorites Rename — NotesView Component
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NotesView.svelte:5,89-104`
|
||||
|
||||
- [ ] **Step 1: Update imports (line 5)**
|
||||
|
||||
```typescript
|
||||
import { favoriteNotes, unfavoritedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update template references**
|
||||
|
||||
Replace all `$pinnedNotes` with `$favoriteNotes`, `$unpinnedNotes` with `$unfavoritedNotes`, and label `"Pinned"` with `"Favorites"`:
|
||||
|
||||
Line 89: `{#if $favoriteNotes.length > 0}`
|
||||
Line 91: `<NoteGrid notes={$favoriteNotes} label="Favorites" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />`
|
||||
Line 96: `notes={$unfavoritedNotes}`
|
||||
Line 97: `label={$favoriteNotes.length > 0 ? 'Others' : ''}`
|
||||
Line 104: `{#if $notesLoaded && $favoriteNotes.length === 0 && $unfavoritedNotes.length === 0}`
|
||||
|
||||
Note: Keep `dndType="pinned-notes"` unchanged — it's an internal DnD identifier, not user-facing.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NotesView.svelte
|
||||
git commit -m "refactor: rename pinned to favorites in NotesView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Favorites Rename — MCP Server
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/server/mcp/server.ts:241-265`
|
||||
|
||||
- [ ] **Step 1: Rename MCP tool**
|
||||
|
||||
Replace the `pin_note` tool definition (lines 241-265):
|
||||
```typescript
|
||||
server.tool(
|
||||
'favorite_note',
|
||||
'Favorite or unfavorite a note',
|
||||
{
|
||||
id: z.string().describe('Note ID'),
|
||||
pinned: z.boolean().describe('Whether to favorite (true) or unfavorite (false)')
|
||||
},
|
||||
async ({ id, pinned }: { id: string; pinned: boolean }) => {
|
||||
const result = updateNote(db, userId, id, { pinned });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Note "${result.title}" ${pinned ? 'favorited' : 'unfavorited'}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Note: The `pinned` parameter name stays since it maps to the DB column.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/server/mcp/server.ts
|
||||
git commit -m "refactor: rename pin_note MCP tool to favorite_note"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Favorites Rename — E2E Test
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/e2e/organization.spec.ts:4-15`
|
||||
|
||||
- [ ] **Step 1: Update test scenario**
|
||||
|
||||
Replace lines 4-15:
|
||||
```typescript
|
||||
test('Scenario: Favorited note appears under the Favorites section', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Favorite Me" exists
|
||||
await createNote(page, 'Favorite Me');
|
||||
|
||||
// When the user favorites the note
|
||||
const favCard = noteCard(page, 'Favorite Me');
|
||||
await favCard.hover();
|
||||
await favCard.getByTestId('favorite-btn').first().click({ force: true });
|
||||
|
||||
// Then the "Favorites" section is visible
|
||||
await expect(page.getByText('Favorites')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run check to verify favorites rename is complete**
|
||||
|
||||
Run: `pnpm check 2>&1 | head -20`
|
||||
Expected: PASS (no errors related to old pin names)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/organization.spec.ts
|
||||
git commit -m "test: update pin test to favorites"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Icon Migration — Header + SearchBar
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/Layout/Header.svelte:2-5`
|
||||
- Modify: `src/lib/components/SearchBar.svelte:4-6`
|
||||
|
||||
- [ ] **Step 1: Migrate Header.svelte**
|
||||
|
||||
Replace imports (lines 2-5):
|
||||
```typescript
|
||||
import SearchBar from '../SearchBar.svelte';
|
||||
import SyncIndicator from '../SyncIndicator.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace icon usage in template:
|
||||
- `<Menu class="h-6 w-6 text-[var(--text)]" />` → `<Icon icon="pixelarticons:menu" class="h-6 w-6 text-[var(--text)]" />`
|
||||
- `<Search class="h-5 w-5 text-[var(--text)]" />` → `<Icon icon="pixelarticons:search" class="h-5 w-5 text-[var(--text)]" />`
|
||||
|
||||
- [ ] **Step 2: Migrate SearchBar.svelte**
|
||||
|
||||
Replace imports (lines 4-6):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace icon usage:
|
||||
- `<Search .../>` → `<Icon icon="pixelarticons:search" .../>`
|
||||
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<ArrowLeft .../>` → `<Icon icon="pixelarticons:arrow-left" .../>`
|
||||
|
||||
Match existing `class` attributes exactly — just change the component and add the `icon` prop.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/Layout/Header.svelte src/lib/components/SearchBar.svelte
|
||||
git commit -m "feat: migrate Header and SearchBar to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Icon Migration — Sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/Layout/Sidebar.svelte:2-4,19-23,47,65,81`
|
||||
|
||||
- [ ] **Step 1: Replace imports and nav items**
|
||||
|
||||
Replace the import (line 4):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace navItems array (lines 19-23):
|
||||
```typescript
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Crumbs', icon: 'pixelarticons:sticky-note', match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: 'pixelarticons:archive', match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: 'pixelarticons:trash', match: (p: string) => p === '/trash' }
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update template icon usage**
|
||||
|
||||
Line 47: `<item.icon size={20} />` → `<Icon icon={item.icon} width={20} />`
|
||||
Line 65: `<Tag size={16} />` → `<Icon icon="pixelarticons:label" width={16} />`
|
||||
Line 81: `<Settings size={20} />` → `<Icon icon="pixelarticons:settings-cog" width={20} />`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/Layout/Sidebar.svelte
|
||||
git commit -m "feat: migrate Sidebar to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Icon Migration — NoteCard
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteCard.svelte:5-13,117,159,167,177,187,196,206,215`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace lines 5-13:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Remove all individual Lucide imports (Undo2, Trash2, Bookmark, Archive, ArchiveRestore, UserMinus).
|
||||
|
||||
- [ ] **Step 2: Replace all icon usages in template**
|
||||
|
||||
- `<Bookmark class="h-4 w-4 fill-[var(--primary)]" />` (favorite indicator) → `<Icon icon="pixelarticons:heart" class="h-4 w-4 fill-[var(--primary)]" />`
|
||||
- `<Bookmark class="h-4 w-4" />` (favorite button) → `<Icon icon="pixelarticons:heart" class="h-4 w-4" />`
|
||||
- `<Undo2 class="h-4 w-4" />` (restore) → `<Icon icon="pixelarticons:undo" class="h-4 w-4" />`
|
||||
- `<Trash2 class="h-4 w-4 text-[var(--destructive)]" />` → `<Icon icon="pixelarticons:trash" class="h-4 w-4 text-[var(--destructive)]" />`
|
||||
- `<Trash2 class="h-4 w-4" />` → `<Icon icon="pixelarticons:trash" class="h-4 w-4" />`
|
||||
- `<Archive class="h-4 w-4" />` → `<Icon icon="pixelarticons:archive" class="h-4 w-4" />`
|
||||
- `<ArchiveRestore class="h-4 w-4" />` → `<Icon icon="pixelarticons:archive" class="h-4 w-4" />`
|
||||
- `<UserMinus class="h-4 w-4" />` → `<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteCard.svelte
|
||||
git commit -m "feat: migrate NoteCard to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Icon Migration — NoteEditor
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteEditor.svelte:16-24`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace lines 16-24:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace all icon usages**
|
||||
|
||||
Search the file for each Lucide component and replace:
|
||||
- `<Palette .../>` → `<Icon icon="pixelarticons:colors-swatch" .../>`
|
||||
- `<SquareCheck .../>` → `<Icon icon="pixelarticons:checkbox-on" .../>`
|
||||
- `<ImageIcon .../>` → `<Icon icon="pixelarticons:image" .../>`
|
||||
- `<Type .../>` → `<Icon icon="pixelarticons:art-text" .../>`
|
||||
- `<FileCode .../>` → `<Icon icon="pixelarticons:script" .../>`
|
||||
- `<FileText .../>` → `<Icon icon="pixelarticons:file-text" .../>`
|
||||
- `<UserPlus .../>` → `<Icon icon="pixelarticons:user-plus" .../>`
|
||||
- `<Users .../>` → `<Icon icon="pixelarticons:users" .../>`
|
||||
- `<History .../>` → `<Icon icon="pixelarticons:timeline" .../>`
|
||||
|
||||
For each, keep the existing `size` prop but rename to `width`. Keep `class` as-is.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteEditor.svelte
|
||||
git commit -m "feat: migrate NoteEditor to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Icon Migration — FormattingToolbar
|
||||
|
||||
This is the largest file. Bold, Italic, Strikethrough, Underline stay as Lucide. Everything else migrates.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/FormattingToolbar.svelte:2-31,131-482`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace the import block (lines 3-31):
|
||||
```typescript
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Underline
|
||||
} from 'lucide-svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace icon usages in template**
|
||||
|
||||
Replace every non-B/I/S/U icon. The pattern: `<IconName size={iconSize} />` → `<Icon icon="pixelarticons:name" width={iconSize} />`. Full mapping:
|
||||
|
||||
- `<Undo2 size={iconSize} />` → `<Icon icon="pixelarticons:undo" width={iconSize} />`
|
||||
- `<Redo2 size={iconSize} />` → `<Icon icon="pixelarticons:redo" width={iconSize} />`
|
||||
- `<Heading size={iconSize} />` → `<Icon icon="pixelarticons:heading" width={iconSize} />`
|
||||
- `<List size={iconSize} />` (appears 3x) → `<Icon icon="pixelarticons:list" width={iconSize} />`
|
||||
- `<ListOrdered size={iconSize} />` → `<Icon icon="pixelarticons:bulletlist" width={iconSize} />`
|
||||
- `<ListChecks size={iconSize} />` → `<Icon icon="pixelarticons:checklist" width={iconSize} />`
|
||||
- `<Link size={iconSize} />` → `<Icon icon="pixelarticons:link" width={iconSize} />`
|
||||
- `<CornerDownLeft size={16} />` → `<Icon icon="pixelarticons:corner-down-left" width={16} />`
|
||||
- `<ExternalLink size={16} />` → `<Icon icon="pixelarticons:external-link" width={16} />`
|
||||
- `<Trash2 size={16} />` → `<Icon icon="pixelarticons:trash" width={16} />`
|
||||
- `<Table2 size={iconSize} />` (appears 2x) → `<Icon icon="pixelarticons:table" width={iconSize} />`
|
||||
- `<ChevronDown size={chevronSize} />` (appears 4x) → `<Icon icon="pixelarticons:chevron-down" width={chevronSize} />`
|
||||
- `<BetweenHorizontalEnd size={iconSize} />` → `<Icon icon="pixelarticons:add-row" width={iconSize} />`
|
||||
- `<BetweenVerticalEnd size={iconSize} />` → `<Icon icon="pixelarticons:add-col" width={iconSize} />`
|
||||
- `<Minus size={iconSize} />` (appears 3x) → `<Icon icon="pixelarticons:minus" width={iconSize} />`
|
||||
- `<RemoveFormatting size={iconSize} />` → `<Icon icon="pixelarticons:remove-box" width={iconSize} />`
|
||||
- `<Code size={iconSize} />` → `<Icon icon="pixelarticons:braces" width={iconSize} />`
|
||||
- `<TextQuote size={iconSize} />` → `<Icon icon="pixelarticons:quote-text-inline" width={iconSize} />`
|
||||
- `<CodeXml size={iconSize} />` → `<Icon icon="pixelarticons:braces" width={iconSize} />`
|
||||
- `<AlignLeft size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-left" width={iconSize} />`
|
||||
- `<AlignCenter size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-center" width={iconSize} />`
|
||||
- `<AlignRight size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-right" width={iconSize} />`
|
||||
- `<AlignJustify size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-justify" width={iconSize} />`
|
||||
|
||||
Leave these unchanged: `<Bold size={iconSize} />`, `<Italic size={iconSize} />`, `<Strikethrough size={iconSize} />`, `<Underline size={iconSize} />`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/FormattingToolbar.svelte
|
||||
git commit -m "feat: migrate FormattingToolbar to pixelarticons (keep B/I/S/U as Lucide)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Icon Migration — SyncIndicator
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/SyncIndicator.svelte:4-7,54-61`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace lines 4-7:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace icon usages**
|
||||
|
||||
Replace the status icon block (lines 53-61):
|
||||
```svelte
|
||||
{#if displayStatus === 'synced'}
|
||||
<Icon icon="pixelarticons:cloud-done" class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if displayStatus === 'syncing'}
|
||||
<Icon icon="pixelarticons:sync" class="h-5 w-5 text-[var(--primary)] opacity-60" />
|
||||
{:else if displayStatus === 'offline'}
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--destructive)]" />
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/SyncIndicator.svelte
|
||||
git commit -m "feat: migrate SyncIndicator to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Icon Migration — Remaining Small Components
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteHistory.svelte:3-5`
|
||||
- Modify: `src/lib/components/ShareDialog.svelte:3-5`
|
||||
- Modify: `src/lib/components/Checklist.svelte:2-5`
|
||||
- Modify: `src/lib/components/CollaboratorPopover.svelte:3`
|
||||
- Modify: `src/lib/components/ImageLightbox.svelte:2`
|
||||
- Modify: `src/lib/components/ImageUpload.svelte:2-4`
|
||||
- Modify: `src/lib/components/NotesView.svelte:8`
|
||||
|
||||
- [ ] **Step 1: Migrate NoteHistory.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<RotateCcw .../>` → `<Icon icon="pixelarticons:undo" .../>`
|
||||
- `<Clock .../>` → `<Icon icon="pixelarticons:clock" .../>`
|
||||
|
||||
- [ ] **Step 2: Migrate ShareDialog.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<UserMinus .../>` → `<Icon icon="pixelarticons:user-minus" .../>`
|
||||
- `<Search .../>` → `<Icon icon="pixelarticons:search" .../>`
|
||||
|
||||
- [ ] **Step 3: Migrate Checklist.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<Plus .../>` → `<Icon icon="pixelarticons:plus" .../>`
|
||||
- `<GripVertical .../>` → `<Icon icon="pixelarticons:drag-and-drop" .../>`
|
||||
- `<ChevronRight .../>` → `<Icon icon="pixelarticons:chevron-right" .../>`
|
||||
|
||||
- [ ] **Step 4: Migrate CollaboratorPopover.svelte**
|
||||
|
||||
Replace import:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace: `<Users .../>` → `<Icon icon="pixelarticons:users" .../>`
|
||||
|
||||
- [ ] **Step 5: Migrate ImageLightbox.svelte**
|
||||
|
||||
Replace import:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace: `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
|
||||
- [ ] **Step 6: Migrate ImageUpload.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<Star .../>` → `<Icon icon="pixelarticons:pin" .../>`
|
||||
- `<RefreshCw .../>` → `<Icon icon="pixelarticons:reload" .../>`
|
||||
|
||||
- [ ] **Step 7: Migrate NotesView.svelte (Plus icon)**
|
||||
|
||||
Replace import (line 8):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<Plus class="h-4 w-4" />` → `<Icon icon="pixelarticons:plus" class="h-4 w-4" />`
|
||||
- `<Plus class="h-5 w-5" />` → `<Icon icon="pixelarticons:plus" class="h-5 w-5" />`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteHistory.svelte src/lib/components/ShareDialog.svelte src/lib/components/Checklist.svelte src/lib/components/CollaboratorPopover.svelte src/lib/components/ImageLightbox.svelte src/lib/components/ImageUpload.svelte src/lib/components/NotesView.svelte
|
||||
git commit -m "feat: migrate remaining components to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Icon Migration — Settings Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/(app)/settings/+layout.svelte:3`
|
||||
- Modify: `src/routes/(app)/settings/mcp/+page.svelte:2`
|
||||
|
||||
- [ ] **Step 1: Migrate settings layout**
|
||||
|
||||
Replace import (line 3):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace usages:
|
||||
- `<SlidersHorizontal size={16} />` → `<Icon icon="pixelarticons:sliders" width={16} />`
|
||||
- `<User size={16} />` → `<Icon icon="pixelarticons:user" width={16} />`
|
||||
- `<Cpu size={16} />` → `<Icon icon="pixelarticons:cpu" width={16} />`
|
||||
- `<ShieldCheck size={16} />` → `<Icon icon="pixelarticons:shield" width={16} />`
|
||||
- `<Info size={16} />` → `<Icon icon="pixelarticons:info-box" width={16} />`
|
||||
|
||||
- [ ] **Step 2: Migrate MCP settings page**
|
||||
|
||||
Replace import (line 2):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace usages:
|
||||
- `<Key .../>` → `<Icon icon="pixelarticons:lock" .../>`
|
||||
- `<Copy .../>` → `<Icon icon="pixelarticons:copy" .../>`
|
||||
- `<Trash2 .../>` → `<Icon icon="pixelarticons:trash" .../>`
|
||||
- `<Plus .../>` → `<Icon icon="pixelarticons:plus" .../>`
|
||||
- `<Check .../>` → `<Icon icon="pixelarticons:check" .../>`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/(app)/settings/+layout.svelte src/routes/(app)/settings/mcp/+page.svelte
|
||||
git commit -m "feat: migrate settings pages to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Update docs/FEATURES.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/FEATURES.md:67-70,141`
|
||||
|
||||
- [ ] **Step 1: Rename "Pin Notes" section to "Favorite Notes"**
|
||||
|
||||
Replace lines 67-70:
|
||||
```markdown
|
||||
### Favorite Notes
|
||||
- Favorite important notes to always appear at top
|
||||
- Favorites section separated from other notes
|
||||
- Toggle favorite from note card hover actions
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update MCP tools description**
|
||||
|
||||
Line 141: replace `pin` with `favorite` in the tools list:
|
||||
```markdown
|
||||
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; favorite, reorder notes; upload images
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/FEATURES.md
|
||||
git commit -m "docs: rename pin to favorites in FEATURES.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Verification
|
||||
|
||||
- [ ] **Step 1: Run type check**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: PASS with no errors
|
||||
|
||||
- [ ] **Step 2: Run unit tests**
|
||||
|
||||
Run: `pnpm test:unit`
|
||||
Expected: All tests pass (DB/CRDT tests use `pinned` field which is unchanged)
|
||||
|
||||
- [ ] **Step 3: Run e2e tests**
|
||||
|
||||
Run: `pnpm test:e2e`
|
||||
Expected: All tests pass including the updated organization spec
|
||||
|
||||
- [ ] **Step 4: Update CLAUDE.md icons section**
|
||||
|
||||
Add to the "Visual rules" section in CLAUDE.md after the "Icons" line:
|
||||
|
||||
```markdown
|
||||
- **Icons**: Pixelarticons via `@iconify/svelte` — use `<Icon icon="pixelarticons:name" width={size} class="..." />`. Exception: Bold, Italic, Strikethrough, Underline stay as Lucide (`lucide-svelte`). See full mapping in `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit docs update**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: update CLAUDE.md with pixelarticons icon usage"
|
||||
```
|
||||
@@ -1,557 +0,0 @@
|
||||
# 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
@@ -1,190 +0,0 @@
|
||||
# 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,182 @@
|
||||
# Icon Migration: Lucide → Pixelarticons + "Pinned" → "Favorites" Rename
|
||||
|
||||
**Date:** 2026-03-20
|
||||
**Status:** Approved
|
||||
|
||||
## Summary
|
||||
|
||||
Replace Lucide icons with pixelarticons (via `@iconify/svelte`) across the app to match the retro parchment aesthetic. Keep Lucide only for 4 text-formatting letterform icons (Bold, Italic, Strikethrough, Underline). Additionally, rename the user-facing "pinned" concept to "favorites" with a heart icon, while keeping the `pinned` database column unchanged.
|
||||
|
||||
## Motivation
|
||||
|
||||
The current Lucide icons have smooth, modern strokes that clash with the 8-bit retro parchment design system. Pixelarticons are drawn on a strict 24×24 pixel grid with no anti-aliasing — a natural fit for the `Press Start 2P` pixel font and hard-offset shadow aesthetic already in use.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Icon Library
|
||||
|
||||
- **Package:** `@iconify/svelte` (v5.2.1, already installed) + `@iconify-json/pixelarticons` (v1.2.4, already installed)
|
||||
- **Usage:** `<Icon icon="pixelarticons:icon-name" width={size} class="..." />`
|
||||
- **Tree-shaking:** Iconify handles this automatically — only icons used in code are bundled
|
||||
|
||||
### Icon Mapping
|
||||
|
||||
56 icons migrated to pixelarticons, 4 kept as Lucide:
|
||||
|
||||
#### Navigation & UI
|
||||
| Lucide | Pixelarticons |
|
||||
|--------|--------------|
|
||||
| Menu | `menu` |
|
||||
| Search | `search` |
|
||||
| X / XIcon | `close` |
|
||||
| Plus | `plus` |
|
||||
| Check | `check` |
|
||||
| ArrowLeft | `arrow-left` |
|
||||
| ChevronRight | `chevron-right` |
|
||||
| ChevronDown | `chevron-down` |
|
||||
| Settings | `settings-cog` |
|
||||
| ExternalLink | `external-link` |
|
||||
|
||||
#### Notes & Content
|
||||
| Lucide | Pixelarticons | Notes |
|
||||
|--------|--------------|-------|
|
||||
| StickyNote | `sticky-note` | |
|
||||
| Archive | `archive` | |
|
||||
| ArchiveRestore | `archive` | Context makes it clear |
|
||||
| Trash2 | `trash` | |
|
||||
| Bookmark | `heart` | Renamed to "favorite" |
|
||||
| Clock | `clock` | |
|
||||
| History | `timeline` | |
|
||||
| FileText | `file-text` | |
|
||||
| FileCode | `script` | |
|
||||
| ImageIcon | `image` | |
|
||||
| Palette | `colors-swatch` | |
|
||||
| GripVertical | `drag-and-drop` | |
|
||||
| Star | `pin` | Used in ImageUpload for "featured image" toggle — not related to favorites |
|
||||
| SquareCheck | `checkbox-on` | |
|
||||
| Type | `art-text` | |
|
||||
|
||||
#### Text Formatting (toolbar)
|
||||
| Lucide | Pixelarticons | Notes |
|
||||
|--------|--------------|-------|
|
||||
| **Bold** | **Keep Lucide** | Letterform |
|
||||
| **Italic** | **Keep Lucide** | Letterform |
|
||||
| **Strikethrough** | **Keep Lucide** | Letterform |
|
||||
| **Underline** | **Keep Lucide** | Letterform |
|
||||
| Code | `braces` | |
|
||||
| CodeXml | `braces` | |
|
||||
| Heading | `heading` | |
|
||||
| Link | `link` | |
|
||||
| TextQuote | `quote-text-inline` | |
|
||||
| List | `list` | |
|
||||
| ListOrdered | `bulletlist` | |
|
||||
| ListChecks | `checklist` | |
|
||||
| AlignLeft | `text-align-left` | |
|
||||
| AlignCenter | `text-align-center` | |
|
||||
| AlignRight | `text-align-right` | |
|
||||
| AlignJustify | `text-align-justify` | |
|
||||
| Table2 | `table` | |
|
||||
| Minus | `minus` | |
|
||||
| RemoveFormatting | `remove-box` | |
|
||||
| CornerDownLeft | `corner-down-left` | |
|
||||
| BetweenHorizontalEnd | `add-row` | |
|
||||
| BetweenVerticalEnd | `add-col` | |
|
||||
|
||||
#### Undo/Redo
|
||||
| Lucide | Pixelarticons |
|
||||
|--------|--------------|
|
||||
| Undo2 | `undo` |
|
||||
| Redo2 | `redo` |
|
||||
| RotateCcw | `undo` |
|
||||
| RefreshCw | `reload` |
|
||||
|
||||
#### Sync Status
|
||||
| Lucide | Pixelarticons | Notes |
|
||||
|--------|--------------|-------|
|
||||
| CloudCheck | `cloud-done` | |
|
||||
| CloudSync | `sync` | |
|
||||
| CloudOff | `cloud` | Color conveys offline state |
|
||||
| CloudAlert | `cloud` | Color conveys error state |
|
||||
|
||||
#### Users & Admin
|
||||
| Lucide | Pixelarticons |
|
||||
|--------|--------------|
|
||||
| User | `user` |
|
||||
| Users | `users` |
|
||||
| UserPlus | `user-plus` |
|
||||
| UserMinus | `user-minus` |
|
||||
| Cpu | `cpu` |
|
||||
| ShieldCheck | `shield` |
|
||||
| SlidersHorizontal | `sliders` |
|
||||
| Info | `info-box` |
|
||||
| Key | `lock` |
|
||||
| Copy | `copy` |
|
||||
| Tag | `label` |
|
||||
|
||||
### "Pinned" → "Favorites" Rename
|
||||
|
||||
**Scope:** User-facing labels, function names, store names, MCP tool names, test IDs. The database column `pinned` stays unchanged to avoid a migration.
|
||||
|
||||
**Renames:**
|
||||
| Current | New |
|
||||
|---------|-----|
|
||||
| `pinnedNotes` (store) | `favoriteNotes` |
|
||||
| `unpinnedNotes` (store) | `unfavoritedNotes` |
|
||||
| `togglePin()` | `toggleFavorite()` |
|
||||
| `"Pinned"` label in NotesView | `"Favorites"` |
|
||||
| `"Pin"` / `"Unpin"` tooltips in NoteCard | `"Favorite"` / `"Unfavorite"` |
|
||||
| `pin_note` MCP tool | `favorite_note` |
|
||||
| `pin-btn` / `pin-indicator` test IDs | `favorite-btn` / `favorite-indicator` |
|
||||
|
||||
**Not renamed (internal/DB):**
|
||||
- `pinned` column in `notes` and `noteUserState` tables
|
||||
- `pinned` field in TypeScript types (`Note`, `NoteUpdate`, `NoteChange`)
|
||||
- `pinned` references in CRDT sync logic
|
||||
- `pinned` in server-side sync overlay
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Icon migration (16 files)
|
||||
1. `src/lib/components/Layout/Sidebar.svelte`
|
||||
2. `src/lib/components/Layout/Header.svelte`
|
||||
3. `src/lib/components/FormattingToolbar.svelte`
|
||||
4. `src/lib/components/NoteCard.svelte`
|
||||
5. `src/lib/components/NoteEditor.svelte`
|
||||
6. `src/lib/components/NoteHistory.svelte`
|
||||
7. `src/lib/components/SyncIndicator.svelte`
|
||||
8. `src/lib/components/SearchBar.svelte`
|
||||
9. `src/lib/components/ShareDialog.svelte`
|
||||
10. `src/lib/components/Checklist.svelte`
|
||||
11. `src/lib/components/CollaboratorPopover.svelte`
|
||||
12. `src/lib/components/ImageLightbox.svelte`
|
||||
13. `src/lib/components/ImageUpload.svelte`
|
||||
14. `src/lib/components/NotesView.svelte`
|
||||
15. `src/routes/(app)/settings/+layout.svelte`
|
||||
16. `src/routes/(app)/settings/mcp/+page.svelte`
|
||||
|
||||
### Favorites rename (7 files)
|
||||
1. `src/lib/stores/notes.ts` — store names + `toggleFavorite`
|
||||
2. `src/lib/components/NoteCard.svelte` — tooltips, test IDs, icon
|
||||
3. `src/lib/components/NotesView.svelte` — label, store imports
|
||||
4. `src/lib/server/mcp/server.ts` — tool name + description
|
||||
5. `docs/FEATURES.md` — update feature description
|
||||
6. `tests/e2e/organization.spec.ts` — `pin-btn` test ID + `getByText('Pinned')` → `getByText('Favorites')` + scenario names/comments
|
||||
7. Note: `tests/e2e/sharing.spec.ts` references `pinned` as a data field — does NOT need renaming (DB field stays `pinned`)
|
||||
|
||||
### Cleanup
|
||||
- Remove `lucide-svelte` from `package.json` once only 4 icons remain — or keep it as a dependency for those 4. Decision: keep it, the 4 formatting icons are deeply integrated.
|
||||
|
||||
### Documentation
|
||||
- Update `CLAUDE.md` "Icons" section to document pixelarticons usage
|
||||
- Update `docs/FEATURES.md` if it mentions pinning
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Run `pnpm check` after migration to catch type errors
|
||||
- Run `pnpm test:unit` to verify sync/CRDT tests still pass (they use `pinned` field which is unchanged)
|
||||
- Run `pnpm test:e2e` to verify UI still works — tests referencing `pin-btn`/`pin-indicator` will need updating
|
||||
- Visual verification: icons render correctly at all sizes (12-20px)
|
||||
|
||||
## Rendering Note
|
||||
|
||||
Pixelarticons are designed for the 24×24 grid and render sharpest at multiples of 24px. At the sizes used in this app (12-20px), they will still look good because `@iconify/svelte` renders them as SVG (vector), but the pixel-art crispness is maximized at 24px. Consider using `image-rendering: pixelated` on icon SVGs if sub-24px rendering looks blurry, though SVG scaling typically handles this well.
|
||||
@@ -1,232 +0,0 @@
|
||||
# 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
|
||||
@@ -1,322 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,205 +0,0 @@
|
||||
# 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
|
||||
@@ -1,129 +0,0 @@
|
||||
# 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
|
||||
@@ -1,23 +0,0 @@
|
||||
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`);
|
||||
@@ -1,9 +0,0 @@
|
||||
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`);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -22,20 +22,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
|
||||
}
|
||||
]
|
||||
}
|
||||
+43
-47
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.33.0",
|
||||
"version": "0.22.3",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
"homepage": "https://crumbs.bretzel.app",
|
||||
@@ -34,64 +33,64 @@
|
||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@iconify-json/pixelarticons": "^1.2.4",
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tiptap/core": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-table": "^3.20.1",
|
||||
"@tiptap/extension-table-cell": "^3.20.1",
|
||||
"@tiptap/extension-table-header": "^3.20.1",
|
||||
"@tiptap/extension-table-row": "^3.20.1",
|
||||
"@tiptap/extension-task-item": "^3.20.1",
|
||||
"@tiptap/extension-task-list": "^3.20.1",
|
||||
"@tiptap/extension-text-align": "^3.20.1",
|
||||
"@tiptap/extension-underline": "^3.20.1",
|
||||
"@tiptap/pm": "^3.20.1",
|
||||
"@tiptap/starter-kit": "^3.20.1",
|
||||
"arctic": "^3.7.0",
|
||||
"argon2": "^0.43.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.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",
|
||||
"markdown-it": "^14.1.1",
|
||||
"node-diff3": "^3.2.0",
|
||||
"nodemailer": "^8.0.2",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/uuid": "^10.0.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",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"nyc": "^18.0.0",
|
||||
"svelte": "^5.56.8",
|
||||
"svelte-check": "^4.7.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.5",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.6",
|
||||
"vite": "^7.3.1",
|
||||
"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"
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.2.4",
|
||||
"workbox-window": "^7.4.0",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -102,11 +101,8 @@
|
||||
"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"
|
||||
"cookie": ">=0.7.0",
|
||||
"hono": ">=4.12.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2083
-2352
File diff suppressed because it is too large
Load Diff
@@ -1,110 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
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');
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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()
|
||||
};
|
||||
@@ -1,271 +0,0 @@
|
||||
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');
|
||||
}
|
||||
+4
-91
@@ -20,45 +20,11 @@
|
||||
--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);
|
||||
}
|
||||
|
||||
[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 {
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
accent-color: var(--accent-check);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body.editor-scroll-locked {
|
||||
overflow: hidden;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
body::before {
|
||||
@@ -67,7 +33,7 @@ body::before {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.03;
|
||||
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
|
||||
background-image: repeating-conic-gradient(#1a1a2e 0% 25%, transparent 0% 50%);
|
||||
background-size: 4px 4px;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -83,11 +49,6 @@ body::before {
|
||||
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);
|
||||
@@ -155,20 +116,12 @@ body::before {
|
||||
.tiptap ul[data-type="taskList"] li,
|
||||
.prose ul.task-list li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
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;
|
||||
@@ -179,7 +132,6 @@ body::before {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Shared checkbox styles */
|
||||
@@ -187,7 +139,6 @@ body::before {
|
||||
.prose ul.task-list .task-checkbox {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
margin-top: 0.2em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -225,41 +176,3 @@ body::before {
|
||||
.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
+1
-3
@@ -15,9 +15,7 @@ declare global {
|
||||
} | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
interface PageState {
|
||||
noteEditorOpen?: boolean;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-20
@@ -2,27 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!-- 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="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
|
||||
+1
-3
@@ -8,9 +8,7 @@ const PUBLIC_PATHS = [
|
||||
'/setup',
|
||||
'/api/auth/login',
|
||||
'/api/auth/setup',
|
||||
'/api/auth/oauth',
|
||||
'/s/',
|
||||
'/api/shared'
|
||||
'/api/auth/oauth'
|
||||
];
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// 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';
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* 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,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
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 Icon from '@iconify/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 ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
@@ -20,106 +20,64 @@
|
||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||
let doneExpanded = $state(true);
|
||||
const flipDurationMs = 150;
|
||||
let linkPopover = $state<{ url: string; anchor: DOMRect } | null>(null);
|
||||
|
||||
// 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 generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
let doneSectionGroups = $derived.by(() => {
|
||||
const groups: DoneSectionGroup[] = [];
|
||||
const childrenByParent = new Map<string, ChecklistItem[]>();
|
||||
const topLevelDone: ChecklistItem[] = [];
|
||||
function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
|
||||
const lines = text.split('\n');
|
||||
return lines.map((line) => {
|
||||
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
|
||||
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
|
||||
return { id: generateId(), text: line, checked: false };
|
||||
});
|
||||
}
|
||||
|
||||
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 serializeChecklist(list: ChecklistItem[]): string {
|
||||
return list
|
||||
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const serialized = serializeChecklist(items);
|
||||
lastEmitted = serialized;
|
||||
onChange(serialized);
|
||||
onChange(serializeChecklist(items));
|
||||
}
|
||||
|
||||
function toggleItem(id: string) {
|
||||
items = toggleItemWithCascade(id, items);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function updateText(id: string, innerHTML: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.text = unlinkifyHtml(innerHTML);
|
||||
item.checked = !item.checked;
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
function updateText(id: string, text: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.text = text;
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
function addItem(afterIndex: number) {
|
||||
const afterItem = items[afterIndex];
|
||||
const newItem: ChecklistItem = {
|
||||
id: generateId(),
|
||||
text: '',
|
||||
checked: false,
|
||||
parentId: afterItem?.parentId ?? null
|
||||
};
|
||||
const newItem = { id: generateId(), text: '', checked: false };
|
||||
items.splice(afterIndex + 1, 0, newItem);
|
||||
items = [...items];
|
||||
emitChange();
|
||||
const newId = newItem.id;
|
||||
setTimeout(() => {
|
||||
document.querySelector<HTMLElement>(`[data-item-id="${newId}"]`)?.focus();
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
inputs[afterIndex + 1]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
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));
|
||||
if (items.length <= 1) return;
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
items.splice(index, 1);
|
||||
items = [...items];
|
||||
@@ -135,179 +93,34 @@
|
||||
e.preventDefault();
|
||||
removeItem(id);
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]');
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[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>>) {
|
||||
// Merge reordered active items with done items
|
||||
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)];
|
||||
items = [...e.detail.items, ...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-0.5" data-testid="checklist">
|
||||
<section
|
||||
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: { outline: 'none' } }}
|
||||
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="space-y-0.5"
|
||||
>
|
||||
{#each activeItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1.5 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 class="group flex items-center gap-2 py-1.5" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
|
||||
<Icon icon="pixelarticons:drag-and-drop" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -316,37 +129,22 @@
|
||||
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)}
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
|
||||
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"
|
||||
class="flex-1 min-w-0 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
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)]" />
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -357,7 +155,7 @@
|
||||
class="flex items-center gap-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-add"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
<Icon icon="pixelarticons:plus" class="h-4 w-4" />
|
||||
Add item
|
||||
</button>
|
||||
|
||||
@@ -368,47 +166,34 @@
|
||||
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' : ''}" />
|
||||
<Icon icon="pixelarticons:chevron-right" class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
|
||||
{doneCount} done
|
||||
</button>
|
||||
|
||||
{#if doneExpanded}
|
||||
<div class="mt-1 space-y-0.5" 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}
|
||||
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
|
||||
{#each doneItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(item.id)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-done-checkbox"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-done-remove"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if linkPopover}
|
||||
<LinkPopover url={linkPopover.url} anchor={linkPopover.anchor} onClose={() => (linkPopover = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface Props {
|
||||
collaborators: Collaborator[];
|
||||
@@ -42,7 +42,7 @@
|
||||
onclick={handleClick}
|
||||
data-testid="collaborator-indicator"
|
||||
>
|
||||
<Users class="h-4 w-4" />
|
||||
<Icon icon="pixelarticons:users" class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{#if showPopover}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
import { NOTE_COLORS, COLOR_OPTIONS } from '$lib/utils/colors.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
@@ -13,10 +12,11 @@
|
||||
|
||||
<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-[var(--border-subtle)]'}"
|
||||
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
|
||||
style="background-color: {colors.bg}"
|
||||
title={option.label}
|
||||
data-testid="color-{option.value}"
|
||||
></button>
|
||||
|
||||
@@ -1,140 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import {
|
||||
Undo2,
|
||||
Redo2,
|
||||
Heading,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
TextQuote,
|
||||
CodeXml,
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Code,
|
||||
Underline,
|
||||
Link,
|
||||
CornerDownLeft,
|
||||
ExternalLink,
|
||||
Trash2,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
Minus,
|
||||
ChevronDown,
|
||||
Table2,
|
||||
BetweenHorizontalEnd,
|
||||
BetweenVerticalEnd,
|
||||
RemoveFormatting
|
||||
Underline
|
||||
} from 'lucide-svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface Props {
|
||||
tick?: number;
|
||||
editor: Editor | undefined;
|
||||
currentNoteId?: string | null;
|
||||
}
|
||||
|
||||
let { editor, tick, currentNoteId = null }: Props = $props();
|
||||
let { editor, tick }: Props = $props();
|
||||
|
||||
// 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; }
|
||||
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
|
||||
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return (!editor?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && editor?.getAttributes(type)) || {}; }
|
||||
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
|
||||
|
||||
let openDropdown: string | null = $state(null);
|
||||
let dropdownAnchorEl: HTMLElement | null = $state(null);
|
||||
let linkUrl: string = $state('');
|
||||
let linkInput: HTMLInputElement | undefined = $state();
|
||||
let ignorePositionInvalidationUntil = 0;
|
||||
|
||||
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) {
|
||||
function toggleDropdown(name: string) {
|
||||
if (openDropdown === name) {
|
||||
closeDropdowns();
|
||||
openDropdown = null;
|
||||
} 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() {
|
||||
@@ -161,15 +66,15 @@
|
||||
const chevronSize = 14;
|
||||
|
||||
function btnClass(active: boolean = false, disabled: boolean = false): string {
|
||||
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
}
|
||||
|
||||
function dropdownBtnClass(active: boolean = false): string {
|
||||
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''}`;
|
||||
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
|
||||
}
|
||||
|
||||
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(--hover-wash)]/5 ${active ? 'bg-[var(--hover-wash)]/5' : ''}`;
|
||||
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' : ''}`;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
@@ -185,56 +90,39 @@
|
||||
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 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;"
|
||||
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||
data-testid="formatting-toolbar"
|
||||
onscroll={closeDropdownsAfterPositionChange}
|
||||
>
|
||||
<!-- Undo/Redo -->
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().undo().run())}
|
||||
onclick={() => editor?.chain().focus().undo().run()}
|
||||
disabled={!canUndo()}
|
||||
class={btnClass(false, !canUndo())}
|
||||
title="Undo"
|
||||
data-testid="format-undo"
|
||||
>
|
||||
<Undo2 size={iconSize} />
|
||||
<Icon icon="pixelarticons:undo" width={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().redo().run())}
|
||||
onclick={() => editor?.chain().focus().redo().run()}
|
||||
disabled={!canRedo()}
|
||||
class={btnClass(false, !canRedo())}
|
||||
title="Redo"
|
||||
data-testid="format-redo"
|
||||
>
|
||||
<Redo2 size={iconSize} />
|
||||
<Icon icon="pixelarticons:redo" width={iconSize} />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Inline -->
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBold().run())}
|
||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||
class={btnClass(isActive('bold'))}
|
||||
title="Bold (Ctrl+B)"
|
||||
data-testid="format-bold"
|
||||
@@ -242,8 +130,7 @@
|
||||
<Bold size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleItalic().run())}
|
||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
class={btnClass(isActive('italic'))}
|
||||
title="Italic (Ctrl+I)"
|
||||
data-testid="format-italic"
|
||||
@@ -251,8 +138,7 @@
|
||||
<Italic size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleStrike().run())}
|
||||
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
class={btnClass(isActive('strike'))}
|
||||
title="Strikethrough (Ctrl+Shift+X)"
|
||||
data-testid="format-strikethrough"
|
||||
@@ -260,8 +146,7 @@
|
||||
<Strikethrough size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleUnderline().run())}
|
||||
onclick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
class={btnClass(isActive('underline'))}
|
||||
title="Underline (Ctrl+U)"
|
||||
data-testid="format-underline"
|
||||
@@ -272,376 +157,304 @@
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div data-dropdown="heading">
|
||||
<div class="relative" data-dropdown="heading">
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('heading', e)}
|
||||
onclick={() => toggleDropdown('heading')}
|
||||
class={dropdownBtnClass(isActive('heading'))}
|
||||
title="Heading"
|
||||
data-testid="format-heading"
|
||||
>
|
||||
<Heading size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
<Icon icon="pixelarticons:heading" width={iconSize} />
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'heading'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
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
|
||||
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
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- List dropdown -->
|
||||
<div data-dropdown="list">
|
||||
<div class="relative" data-dropdown="list">
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('list', e)}
|
||||
onclick={() => toggleDropdown('list')}
|
||||
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
<Icon icon="pixelarticons:list" width={iconSize} />
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'list'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('bulletList'))}
|
||||
data-testid="format-bullet-list"
|
||||
>
|
||||
<Icon icon="pixelarticons:list" width={iconSize} />
|
||||
<span>Bullet list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('orderedList'))}
|
||||
data-testid="format-ordered-list"
|
||||
>
|
||||
<Icon icon="pixelarticons:bulletlist" width={iconSize} />
|
||||
<span>Ordered list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('taskList'))}
|
||||
data-testid="format-task-list"
|
||||
>
|
||||
<Icon icon="pixelarticons:checklist" width={iconSize} />
|
||||
<span>Task list</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Link popover -->
|
||||
<div data-dropdown="link">
|
||||
<div class="relative" data-dropdown="link">
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('link', e)}
|
||||
onclick={() => toggleDropdown('link')}
|
||||
class={btnClass(isActive('link'))}
|
||||
title="Insert link (Ctrl+K)"
|
||||
data-testid="format-link"
|
||||
>
|
||||
<Link size={iconSize} />
|
||||
<Icon icon="pixelarticons:link" width={iconSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'link'}
|
||||
<div class="absolute left-1/2 bottom-full z-50 mb-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
|
||||
<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"
|
||||
>
|
||||
<Icon icon="pixelarticons:corner-down-left" width={16} />
|
||||
</button>
|
||||
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openLink}
|
||||
disabled={!getAttrs('link').href}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
||||
title="Open link"
|
||||
data-testid="format-link-open"
|
||||
>
|
||||
<Icon icon="pixelarticons:external-link" width={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={removeLink}
|
||||
disabled={!isActive('link')}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
|
||||
title="Remove link"
|
||||
data-testid="format-unlink"
|
||||
>
|
||||
<Icon icon="pixelarticons:trash" width={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Table dropdown -->
|
||||
<div data-dropdown="table">
|
||||
<div class="relative" data-dropdown="table">
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('table', e)}
|
||||
onclick={() => toggleDropdown('table')}
|
||||
class={dropdownBtnClass(isActive('table'))}
|
||||
title="Table"
|
||||
data-testid="format-table"
|
||||
>
|
||||
<Table2 size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
<Icon icon="pixelarticons:table" width={iconSize} />
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'table'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-insert"
|
||||
>
|
||||
<Icon icon="pixelarticons:table" width={iconSize} />
|
||||
<span>Insert table</span>
|
||||
</button>
|
||||
{#if isActive('table')}
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-add-row"
|
||||
>
|
||||
<Icon icon="pixelarticons:add-row" width={iconSize} />
|
||||
<span>Add row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-add-col"
|
||||
>
|
||||
<Icon icon="pixelarticons:add-col" width={iconSize} />
|
||||
<span>Add column</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-row"
|
||||
>
|
||||
<Icon icon="pixelarticons:minus" width={iconSize} />
|
||||
<span>Delete row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-col"
|
||||
>
|
||||
<Icon icon="pixelarticons:minus" width={iconSize} />
|
||||
<span>Delete column</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
|
||||
class={`${dropdownItemClass()} text-[var(--destructive)]`}
|
||||
data-testid="format-table-delete"
|
||||
>
|
||||
<Icon icon="pixelarticons:remove-box" width={iconSize} />
|
||||
<span>Delete table</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Alignment dropdown -->
|
||||
<div data-dropdown="align">
|
||||
<div class="relative" data-dropdown="align">
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('align', e)}
|
||||
onclick={() => toggleDropdown('align')}
|
||||
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
|
||||
title="Text alignment"
|
||||
data-testid="format-align"
|
||||
>
|
||||
{#if isActive({ textAlign: 'center' })}
|
||||
<AlignCenter size={iconSize} />
|
||||
<Icon icon="pixelarticons:text-align-center" width={iconSize} />
|
||||
{:else if isActive({ textAlign: 'right' })}
|
||||
<AlignRight size={iconSize} />
|
||||
<Icon icon="pixelarticons:text-align-right" width={iconSize} />
|
||||
{:else if isActive({ textAlign: 'justify' })}
|
||||
<AlignJustify size={iconSize} />
|
||||
<Icon icon="pixelarticons:text-align-justify" width={iconSize} />
|
||||
{:else}
|
||||
<AlignLeft size={iconSize} />
|
||||
<Icon icon="pixelarticons:text-align-left" width={iconSize} />
|
||||
{/if}
|
||||
<ChevronDown size={chevronSize} />
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'align'}
|
||||
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
|
||||
data-testid="format-align-left"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-left" width={iconSize} />
|
||||
<span>Left</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
|
||||
data-testid="format-align-center"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-center" width={iconSize} />
|
||||
<span>Center</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
|
||||
data-testid="format-align-right"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-right" width={iconSize} />
|
||||
<span>Right</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
|
||||
data-testid="format-align-justify"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-justify" width={iconSize} />
|
||||
<span>Justify</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Block-level formatting -->
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCode().run())}
|
||||
onclick={() => editor?.chain().focus().toggleCode().run()}
|
||||
class={btnClass(isActive('code'))}
|
||||
title="Inline code"
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Code size={iconSize} />
|
||||
<Icon icon="pixelarticons:braces" width={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBlockquote().run())}
|
||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
class={btnClass(isActive('blockquote'))}
|
||||
title="Blockquote"
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<TextQuote size={iconSize} />
|
||||
<Icon icon="pixelarticons:quote-text-inline" width={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCodeBlock().run())}
|
||||
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
class={btnClass(isActive('codeBlock'))}
|
||||
title="Code block"
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<CodeXml size={iconSize} />
|
||||
<Icon icon="pixelarticons:braces" width={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().setHorizontalRule().run())}
|
||||
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||
class={btnClass()}
|
||||
title="Divider"
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<Icon icon="pixelarticons:minus" width={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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
@@ -46,7 +46,7 @@
|
||||
aria-label="Close lightbox"
|
||||
data-testid="lightbox-close"
|
||||
>
|
||||
<XIcon class="h-6 w-6" />
|
||||
<Icon icon="pixelarticons:close" class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<img
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<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 Icon from '@iconify/svelte';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
import { optimizeImage } from '$lib/utils/image-optimize.js';
|
||||
@@ -133,7 +131,7 @@
|
||||
</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" />
|
||||
<Icon icon="pixelarticons:reload" class="h-3 w-3 text-white" />
|
||||
</div>
|
||||
{:else if onToggleFeatured}
|
||||
<button
|
||||
@@ -142,7 +140,7 @@
|
||||
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'}" />
|
||||
<Icon icon="pixelarticons:pin" class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
@@ -151,7 +149,7 @@
|
||||
aria-label="Remove attachment"
|
||||
data-testid="remove-attachment"
|
||||
>
|
||||
<XIcon class="h-3 w-3" />
|
||||
<Icon icon="pixelarticons:close" class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<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';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface Props {
|
||||
onMenuToggle: () => void;
|
||||
@@ -21,10 +20,10 @@
|
||||
{:else}
|
||||
<button
|
||||
onclick={onMenuToggle}
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Menu class="h-6 w-6 text-[var(--text)]" />
|
||||
<Icon icon="pixelarticons:menu" class="h-6 w-6 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -40,10 +39,10 @@
|
||||
<!-- Mobile search icon -->
|
||||
<button
|
||||
onclick={() => (mobileSearchOpen = true)}
|
||||
class="ml-auto rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 lg:hidden"
|
||||
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search class="h-5 w-5 text-[var(--text)]" />
|
||||
<Icon icon="pixelarticons:search" class="h-5 w-5 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { allTags } from '$lib/stores/notes.js';
|
||||
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -17,9 +17,9 @@
|
||||
}
|
||||
|
||||
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' }
|
||||
{ href: '/', label: 'Crumbs', icon: 'pixelarticons:sticky-note', match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: 'pixelarticons:archive', match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: 'pixelarticons:trash', match: (p: string) => p === '/trash' }
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
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} />
|
||||
<Icon icon={item.icon} width={20} />
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
@@ -62,7 +62,7 @@
|
||||
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} />
|
||||
<Icon icon="pixelarticons:label" width={16} />
|
||||
#{tag}
|
||||
</a>
|
||||
</li>
|
||||
@@ -78,7 +78,7 @@
|
||||
onclick={closeMobile}
|
||||
data-testid="settings-link"
|
||||
>
|
||||
<Settings size={20} />
|
||||
<Icon icon="pixelarticons:settings-cog" width={20} />
|
||||
Settings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
anchor: DOMRect;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { url, anchor, onClose }: Props = $props();
|
||||
|
||||
let popoverEl: HTMLDivElement | undefined = $state();
|
||||
let top = $state(0);
|
||||
let left = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
if (!popoverEl) return;
|
||||
const rect = popoverEl.getBoundingClientRect();
|
||||
|
||||
// Position below the anchor link, or above if near viewport bottom
|
||||
const spaceBelow = window.innerHeight - anchor.bottom;
|
||||
if (spaceBelow >= rect.height + 8) {
|
||||
top = anchor.bottom + 4;
|
||||
} else {
|
||||
top = anchor.top - rect.height - 4;
|
||||
}
|
||||
|
||||
left = anchor.left;
|
||||
// Clamp to viewport
|
||||
if (left + rect.width > window.innerWidth - 8) {
|
||||
left = window.innerWidth - rect.width - 8;
|
||||
}
|
||||
if (left < 8) left = 8;
|
||||
});
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (popoverEl && !popoverEl.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} onscroll={onClose} onresize={onClose} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={popoverEl}
|
||||
class="fixed z-50 flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-1.5 shadow-[var(--card-shadow)]"
|
||||
style="top: {top}px; left: {left}px;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
data-testid="link-popover"
|
||||
>
|
||||
<button
|
||||
onclick={openLink}
|
||||
class="flex items-center gap-1 text-xs text-[var(--primary)] hover:text-[var(--primary-hover)]"
|
||||
data-testid="link-popover-open"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,24 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { getNoteColor } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import { toggleFavorite, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import SharingIndicator from './SharingIndicator.svelte';
|
||||
import CollaboratorPopover from './CollaboratorPopover.svelte';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Undo2 from 'lucide-svelte/icons/undo-2';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import Bookmark from 'lucide-svelte/icons/bookmark';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
import { linkifyText } from '$lib/utils/checklist.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
indented: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -30,7 +21,8 @@
|
||||
let { note, onEdit, fullHeight = false }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
cardStyle = `background-color: ${getNoteColor(note.color, getIsDarkMode())}`;
|
||||
const colors = NOTE_COLORS[note.color];
|
||||
cardStyle = `background-color: ${colors.bg}`;
|
||||
});
|
||||
|
||||
let cardStyle = $state('');
|
||||
@@ -41,14 +33,10 @@
|
||||
|
||||
const checklistItems = $derived<ChecklistItem[]>(
|
||||
note.checklistMode
|
||||
? note.content.split('\n').filter(l => l.trim()).map(line => {
|
||||
const indented = line.startsWith(' - [');
|
||||
return {
|
||||
text: line.replace(/^ {0,2}- \[[ x]\] /, ''),
|
||||
checked: /^ {0,2}- \[x\] /.test(line),
|
||||
indented
|
||||
};
|
||||
})
|
||||
? note.content.split('\n').filter(l => l.trim()).map(line => ({
|
||||
text: line.replace(/^- \[[ x]\] /, ''),
|
||||
checked: line.startsWith('- [x] ')
|
||||
}))
|
||||
: []
|
||||
);
|
||||
|
||||
@@ -69,7 +57,7 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
|
||||
<article
|
||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 outline-none transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] max-h-[17rem] overflow-hidden {fullHeight ? 'h-full' : ''}"
|
||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 outline-none transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
|
||||
style={cardStyle}
|
||||
onclick={() => onEdit(note)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
|
||||
@@ -108,21 +96,20 @@
|
||||
|
||||
<!-- Status indicators (top-right) -->
|
||||
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
|
||||
{#if note.shareToken || (note.isShared && note.collaborators)}
|
||||
<SharingIndicator
|
||||
shareToken={note.shareToken}
|
||||
collaborators={note.collaborators ?? []}
|
||||
isOwner={note.isOwner ?? true}
|
||||
{#if note.isShared && note.collaborators}
|
||||
<CollaboratorPopover
|
||||
collaborators={note.collaborators}
|
||||
ownerName={note.isOwner ? 'You' : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Unpin"}
|
||||
data-testid="pin-indicator"
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unfavorite"
|
||||
data-testid="favorite-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
<Icon icon="pixelarticons:heart" class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -132,13 +119,9 @@
|
||||
{/if}
|
||||
|
||||
{#if note.checklistMode && checklistItems.length > 0}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview"
|
||||
onclick={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}
|
||||
onkeydown={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}>
|
||||
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
|
||||
{#each sortedChecklistItems.slice(0, 8) as item}
|
||||
<li class="flex items-start gap-2 text-sm {item.indented ? 'pl-4 ' : ''}{item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
data-testid={item.indented ? 'card-checklist-child' : undefined}>
|
||||
<li class="flex items-start gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
@@ -146,7 +129,7 @@
|
||||
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
|
||||
data-testid="card-checklist-checkbox"
|
||||
/>
|
||||
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
|
||||
<span class="break-words min-w-0">{item.text}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if checklistItems.length > 8}
|
||||
@@ -164,67 +147,67 @@
|
||||
{#if $currentFilter === 'trashed'}
|
||||
<button
|
||||
onclick={stop(() => restoreNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Restore"}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Restore"
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<Undo2 class="h-4 w-4" />
|
||||
<Icon icon="pixelarticons:undo" class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onclick={stop(() => deleteNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Delete forever"}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete forever"
|
||||
data-testid="delete-forever-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
|
||||
<Icon icon="pixelarticons:trash" class="h-4 w-4 text-[var(--destructive)]" />
|
||||
</button>
|
||||
{:else}
|
||||
{#if !note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Pin"}
|
||||
data-testid="pin-btn"
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Favorite"
|
||||
data-testid="favorite-btn"
|
||||
>
|
||||
<Bookmark class="h-4 w-4" />
|
||||
<Icon icon="pixelarticons:heart" class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if $currentFilter === 'archived'}
|
||||
<button
|
||||
onclick={stop(() => unarchiveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Unarchive"}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Unarchive"
|
||||
data-testid="unarchive-btn"
|
||||
>
|
||||
<ArchiveRestore class="h-4 w-4" />
|
||||
<Icon icon="pixelarticons:archive" class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => archiveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Archive"}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Archive"
|
||||
data-testid="archive-btn"
|
||||
>
|
||||
<Archive class="h-4 w-4" />
|
||||
<Icon icon="pixelarticons:archive" class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if note.isShared && !note.isOwner}
|
||||
<button
|
||||
onclick={stop(() => leaveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Leave note"}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Leave note"
|
||||
data-testid="leave-btn"
|
||||
>
|
||||
<UserMinus class="h-4 w-4" />
|
||||
<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => trashNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Trash"}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete"
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
<Icon icon="pixelarticons:trash" class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<script module lang="ts">
|
||||
let bodyScrollLockCount = 0;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { pushState } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import Checklist from './Checklist.svelte';
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
@@ -13,40 +7,21 @@
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import ShareDialog from './ShareDialog.svelte';
|
||||
import NoteHistory from './NoteHistory.svelte';
|
||||
import { updateNote, createNote, trashNote, archiveNote } from '$lib/stores/notes.js';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { notes } from '$lib/stores/notes.js';
|
||||
import { getNoteColor } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor, Attachment, Collaborator, NoteBacklink } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import ImageIcon from 'lucide-svelte/icons/image';
|
||||
import Type from 'lucide-svelte/icons/type';
|
||||
import FileCode from 'lucide-svelte/icons/file-code';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
import UserPlus from 'lucide-svelte/icons/user-plus';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
import Globe from 'lucide-svelte/icons/globe';
|
||||
import History from 'lucide-svelte/icons/history';
|
||||
import EllipsisVertical from 'lucide-svelte/icons/ellipsis-vertical';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import ListX from 'lucide-svelte/icons/list-x';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
isNew?: boolean;
|
||||
initialChecklistMode?: boolean;
|
||||
onClose: () => void;
|
||||
onOpenNote: (noteId: string) => void;
|
||||
}
|
||||
|
||||
const { note, isNew = false, initialChecklistMode = false, onClose, onOpenNote }: Props = $props();
|
||||
const { note, isNew = false, onClose }: Props = $props();
|
||||
const prefs = getPreferences();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -56,7 +31,7 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let checklistMode = $state(note?.checklistMode ?? initialChecklistMode);
|
||||
let checklistMode = $state(note?.checklistMode ?? false);
|
||||
let showColorPicker = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -71,212 +46,30 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentlyNew = $state(isNew);
|
||||
|
||||
// Auto-save: track last-saved state to detect real changes
|
||||
let lastSavedTitle = note?.title ?? '';
|
||||
let lastSavedContent = note?.content ?? '';
|
||||
let lastSavedColor: NoteColor = note?.color ?? prefs.defaultNoteColor;
|
||||
let lastSavedChecklistMode = note?.checklistMode ?? initialChecklistMode;
|
||||
let isSaving = false;
|
||||
let savingPromise: Promise<void> | null = null;
|
||||
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
// TiptapEditor fires onUpdate on init with round-tripped markdown that may differ
|
||||
// from the original. Track whether auto-save should be active yet.
|
||||
let autoSaveReady = isNew;
|
||||
|
||||
function hasUnsavedChanges(): boolean {
|
||||
return (
|
||||
title !== lastSavedTitle ||
|
||||
content !== lastSavedContent ||
|
||||
color !== lastSavedColor ||
|
||||
checklistMode !== lastSavedChecklistMode
|
||||
);
|
||||
}
|
||||
|
||||
async function performSave() {
|
||||
if (isSaving) return;
|
||||
// Snapshot current fields to avoid races with user edits during await
|
||||
const snap = { title, content, color, checklistMode };
|
||||
if (!snap.title.trim() && !snap.content.trim()) return;
|
||||
if (
|
||||
snap.title === lastSavedTitle &&
|
||||
snap.content === lastSavedContent &&
|
||||
snap.color === lastSavedColor &&
|
||||
snap.checklistMode === lastSavedChecklistMode
|
||||
) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
if (currentlyNew) {
|
||||
const created = await createNote(snap);
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
lastSavedTitle = snap.title;
|
||||
lastSavedContent = snap.content;
|
||||
lastSavedColor = snap.color;
|
||||
lastSavedChecklistMode = snap.checklistMode;
|
||||
}
|
||||
} else if (noteId) {
|
||||
const updated = await updateNote(noteId, snap);
|
||||
if (updated) {
|
||||
lastSavedTitle = snap.title;
|
||||
lastSavedContent = snap.content;
|
||||
lastSavedColor = snap.color;
|
||||
lastSavedChecklistMode = snap.checklistMode;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced auto-save: triggers 2s after the user stops editing
|
||||
$effect(() => {
|
||||
const _t = title;
|
||||
const _c = content;
|
||||
const _col = color;
|
||||
const _cm = checklistMode;
|
||||
|
||||
if (!autoSaveReady) {
|
||||
// Absorb TiptapEditor's initial content normalization as the baseline
|
||||
lastSavedContent = _c;
|
||||
autoSaveReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_t === lastSavedTitle && _c === lastSavedContent && _col === lastSavedColor && _cm === lastSavedChecklistMode) return;
|
||||
if (!_t.trim() && !_c.trim()) return;
|
||||
|
||||
autoSaveTimer = setTimeout(() => {
|
||||
savingPromise = performSave();
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(autoSaveTimer);
|
||||
});
|
||||
|
||||
// Safety net: save on tab close / navigation
|
||||
$effect(() => {
|
||||
function handleBeforeUnload() {
|
||||
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
||||
performSave();
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
});
|
||||
|
||||
// Safety net for note-link/backlink navigation: NotesView.svelte remounts this
|
||||
// component (rather than reusing the instance) whenever the user switches to a
|
||||
// different note while the editor is open. That teardown skips the debounced
|
||||
// autosave timer's cleanup (it only clears the timer, it doesn't flush) and
|
||||
// beforeunload (which never fires for an in-SPA unmount), so a very recent edit
|
||||
// could otherwise be silently dropped.
|
||||
onDestroy(() => {
|
||||
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
||||
performSave();
|
||||
}
|
||||
});
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let backlinksList = $state<NoteBacklink[]>(note?.backlinks ?? []);
|
||||
|
||||
let viewportHeight = $state('100dvh');
|
||||
let viewportTop = $state('0px');
|
||||
|
||||
// Guard against mobile ghost clicks: on touch devices, a tap on the note card
|
||||
// can produce a synthetic click that lands on editor buttons rendered at the same
|
||||
// coordinates. Suppress pointer events on toolbar controls until mount completes.
|
||||
let toolbarInteractive = $state(false);
|
||||
onMount(() => {
|
||||
function updateViewport() {
|
||||
if (window.visualViewport) {
|
||||
viewportHeight = `${window.visualViewport.height}px`;
|
||||
viewportTop = `${window.visualViewport.offsetTop}px`;
|
||||
} else {
|
||||
viewportHeight = `${window.innerHeight}px`;
|
||||
viewportTop = '0px';
|
||||
}
|
||||
}
|
||||
|
||||
updateViewport();
|
||||
const handleVisualViewportChange = () => {
|
||||
requestAnimationFrame(updateViewport);
|
||||
};
|
||||
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', handleVisualViewportChange);
|
||||
window.visualViewport.addEventListener('scroll', handleVisualViewportChange);
|
||||
} else {
|
||||
window.addEventListener('resize', handleVisualViewportChange);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', handleVisualViewportChange);
|
||||
window.visualViewport.removeEventListener('scroll', handleVisualViewportChange);
|
||||
} else {
|
||||
window.removeEventListener('resize', handleVisualViewportChange);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Lock body scroll while editor is open. Multiple editor instances can overlap during
|
||||
// the ghost-click grace period, so keep the class until the last instance unmounts.
|
||||
$effect(() => {
|
||||
bodyScrollLockCount += 1;
|
||||
document.body.classList.add('editor-scroll-locked');
|
||||
return () => {
|
||||
bodyScrollLockCount = Math.max(0, bodyScrollLockCount - 1);
|
||||
if (bodyScrollLockCount === 0) {
|
||||
document.body.classList.remove('editor-scroll-locked');
|
||||
}
|
||||
};
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
let showShareDialog = $state(false);
|
||||
let showHistory = $state(false);
|
||||
let showOverflowMenu = $state(false);
|
||||
let desktopOverflowBtnEl: HTMLButtonElement | undefined = $state();
|
||||
let mobileOverflowBtnEl: HTMLButtonElement | undefined = $state();
|
||||
let overflowAnchorEl: HTMLButtonElement | undefined = $state();
|
||||
let overflowMenuEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Close overflow menu when clicking outside
|
||||
$effect(() => {
|
||||
if (!showOverflowMenu) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (overflowAnchorEl?.contains(target) || overflowMenuEl?.contains(target)) return;
|
||||
showOverflowMenu = false;
|
||||
}
|
||||
// Use setTimeout to avoid the current click from closing the menu
|
||||
const timer = setTimeout(() => document.addEventListener('click', handleClickOutside), 0);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
});
|
||||
|
||||
const hasDoneItems = $derived(
|
||||
checklistMode && content.includes('[x]')
|
||||
);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentShareToken = $state<string | undefined>(note?.shareToken);
|
||||
const isOwner = $derived(note?.isOwner !== false);
|
||||
const isShared = $derived(collaboratorsList.length > 0);
|
||||
const hasPublicLink = $derived(!!currentShareToken);
|
||||
// Use displayName from first user or 'You' for owner
|
||||
const ownerName = $derived(isOwner ? 'You' : 'Owner');
|
||||
|
||||
function handleCollaboratorsUpdate(updated: Collaborator[]) {
|
||||
collaboratorsList = updated;
|
||||
// Update the note in the store
|
||||
if (noteId) {
|
||||
notes.update((list) =>
|
||||
list.map((n) =>
|
||||
@@ -288,19 +81,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleShareUpdate(token: string | null) {
|
||||
currentShareToken = token ?? undefined;
|
||||
if (noteId) {
|
||||
notes.update((list) =>
|
||||
list.map((n) =>
|
||||
n.id === noteId
|
||||
? { ...n, shareToken: token ?? undefined }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleShareDialog() {
|
||||
if (currentlyNew) {
|
||||
const id = await autoSave();
|
||||
@@ -340,56 +120,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB).
|
||||
// Guarded against post-teardown writes: since NotesView.svelte now remounts this
|
||||
// component on every note-switch (rather than reusing the instance), a fetch that
|
||||
// resolves after teardown would otherwise write to $state on an already-destroyed
|
||||
// instance racing the newly-mounted one.
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||
let destroyed = false;
|
||||
fetch(`/api/notes/${noteId}/attachments`)
|
||||
.then((res) => res.ok ? res.json() : [])
|
||||
.then((data: Attachment[]) => {
|
||||
if (destroyed) return;
|
||||
if (data.length > 0) attachmentsList = data;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
destroyed = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch backlinks for existing notes if not pre-populated (e.g. loaded from IDB) —
|
||||
// note.backlinks is only ever attached server-side by getNote(), which nothing
|
||||
// else in the client calls when opening a note (the bulk notes store and
|
||||
// IndexedDB never carry it), so this mirrors the attachments effect above to
|
||||
// fill it in on open. Same post-teardown guard as above.
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.backlinks || note.backlinks.length === 0)) {
|
||||
let destroyed = false;
|
||||
fetch(`/api/notes/${noteId}`)
|
||||
.then((res) => res.ok ? res.json() : null)
|
||||
.then((data: Note | null) => {
|
||||
if (destroyed) return;
|
||||
if (data?.backlinks && data.backlinks.length > 0) backlinksList = data.backlinks;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
destroyed = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function handleAttachmentUpload(attachment: Attachment) {
|
||||
const updated = [...attachmentsList, attachment];
|
||||
attachmentsList = updated;
|
||||
if (noteId) {
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
}
|
||||
attachmentsList = [...attachmentsList, attachment];
|
||||
}
|
||||
|
||||
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
|
||||
@@ -401,14 +145,9 @@
|
||||
body: JSON.stringify({ featured })
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = attachmentsList.map((a) =>
|
||||
attachmentsList = attachmentsList.map((a) =>
|
||||
a.id === attachmentId ? { ...a, featured } : a
|
||||
);
|
||||
attachmentsList = updated;
|
||||
// Keep the notes store in sync so the card reflects featured state
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle featured:', err);
|
||||
@@ -421,11 +160,7 @@
|
||||
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const updated = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||
attachmentsList = updated;
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||
);
|
||||
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove attachment:', err);
|
||||
}
|
||||
@@ -433,14 +168,15 @@
|
||||
|
||||
let bgStyle = $state('');
|
||||
$effect(() => {
|
||||
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
|
||||
const colors = NOTE_COLORS[color];
|
||||
bgStyle = `background-color: ${colors.bg}`;
|
||||
});
|
||||
|
||||
// Close on backdrop tap/click. On close, a transparent shield stays in the DOM
|
||||
// for 400ms to absorb the browser's delayed synthetic click that would otherwise
|
||||
// pass through to NoteCards underneath (ghost click).
|
||||
// Use pointer events to detect backdrop clicks. Unlike mouse events,
|
||||
// pointer events are never synthetically re-dispatched from touch, so
|
||||
// the tap that opened the editor on mobile cannot ghost-click the overlay.
|
||||
// Both pointerdown and pointerup must target the overlay itself to close.
|
||||
let pointerdownOnOverlay = false;
|
||||
let closing = $state(false);
|
||||
|
||||
function handleOverlayPointerdown(e: PointerEvent) {
|
||||
pointerdownOnOverlay = e.target === e.currentTarget;
|
||||
@@ -451,63 +187,35 @@
|
||||
pointerdownOnOverlay = false;
|
||||
}
|
||||
|
||||
// Close on browser back — Android back button / iOS back swipe. The editor is
|
||||
// fullscreen on mobile with no backdrop to tap, so back must dismiss it instead of
|
||||
// leaving the page. On mount, push a shallow-routing history entry; navigating back
|
||||
// pops it and closes the editor. UI closes pop the entry themselves via
|
||||
// history.back() so no stale entry is left behind.
|
||||
let historyEntryPushed = false;
|
||||
|
||||
onMount(() => {
|
||||
// Reuse an existing entry (e.g. back-navigating to a note restored from the
|
||||
// URL hash) instead of stacking a second one.
|
||||
if (!page.state.noteEditorOpen) {
|
||||
pushState('', { noteEditorOpen: true });
|
||||
}
|
||||
historyEntryPushed = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (page.state.noteEditorOpen || !historyEntryPushed || closing) return;
|
||||
historyEntryPushed = false;
|
||||
saveAndClose();
|
||||
});
|
||||
|
||||
function dismissOverlay() {
|
||||
closing = true;
|
||||
if (historyEntryPushed) {
|
||||
historyEntryPushed = false;
|
||||
history.back();
|
||||
}
|
||||
setTimeout(() => onClose(), 400);
|
||||
}
|
||||
|
||||
async function saveAndClose() {
|
||||
clearTimeout(autoSaveTimer);
|
||||
// Wait for any in-flight auto-save to finish
|
||||
if (savingPromise) await savingPromise;
|
||||
|
||||
if (!title.trim() && !content.trim()) {
|
||||
dismissOverlay();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Final save if auto-save hasn't caught up yet
|
||||
if (hasUnsavedChanges()) {
|
||||
await performSave();
|
||||
if (currentlyNew) {
|
||||
await createNote({ title, content, color, checklistMode });
|
||||
} else if (noteId) {
|
||||
await updateNote(noteId, { title, content, color, checklistMode });
|
||||
}
|
||||
dismissOverlay();
|
||||
onClose();
|
||||
}
|
||||
|
||||
/** Auto-save a new note without closing, returns the new note ID */
|
||||
async function autoSave(): Promise<string | null> {
|
||||
if (!currentlyNew) return noteId;
|
||||
clearTimeout(autoSaveTimer);
|
||||
if (savingPromise) await savingPromise;
|
||||
// For feature-triggered saves (image upload, share), use 'Untitled' fallback
|
||||
if (!title.trim()) title = 'Untitled';
|
||||
await performSave();
|
||||
return noteId;
|
||||
const created = await createNote({
|
||||
title: title || 'Untitled',
|
||||
content,
|
||||
color,
|
||||
checklistMode
|
||||
});
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
return created.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function toggleImageUpload() {
|
||||
@@ -535,94 +243,41 @@
|
||||
requestAnimationFrame(() => textareaEl?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
showOverflowMenu = false;
|
||||
if (noteId) {
|
||||
await archiveNote(noteId);
|
||||
dismissOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTrash() {
|
||||
showOverflowMenu = false;
|
||||
if (noteId) {
|
||||
await trashNote(noteId);
|
||||
dismissOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCheckedItems() {
|
||||
const items = parseChecklist(content).filter((i) => !i.checked);
|
||||
content = serializeChecklist(items);
|
||||
showOverflowMenu = false;
|
||||
}
|
||||
|
||||
function uncheckAllItems() {
|
||||
const items = parseChecklist(content).map((i) => ({ ...i, checked: false }));
|
||||
content = serializeChecklist(items);
|
||||
showOverflowMenu = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed left-0 w-full z-40 {closing ? '' : 'flex md:items-start justify-center md:overflow-y-auto md:bg-black/50 md:pt-20 md:pb-10 animate-[fade-in_150ms_ease-out]'}"
|
||||
style="top: {viewportTop}; height: {viewportHeight};"
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
onpointerdown={handleOverlayPointerdown}
|
||||
onpointerup={handleOverlayPointerup}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
{#if !closing}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative flex h-full w-full flex-col md:overflow-hidden border-0 md:h-auto md:max-w-xl md:mx-4 lg:max-w-2xl md:rounded-sm md:border md:border-[var(--border)] md:shadow-[var(--card-shadow)] animate-[slide-up_150ms_ease-out] md:animate-[pop-in_150ms_ease-out]"
|
||||
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
style={bgStyle}
|
||||
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
||||
data-testid="note-editor"
|
||||
>
|
||||
<!-- Gap filler to hide iOS Safari keyboard animation/viewport lag -->
|
||||
<div class="absolute top-full left-0 w-full h-[50vh] md:hidden" style={bgStyle}></div>
|
||||
<!-- Header (Mobile: Back + Title, Desktop: just Title) -->
|
||||
<div class="flex items-center gap-2 border-b md:border-b-0 border-[var(--border-subtle)] px-2 py-2 md:px-4 md:pt-4 md:pb-0 shrink-0 touch-none">
|
||||
<!-- Mobile Back Button -->
|
||||
<button
|
||||
onclick={saveAndClose}
|
||||
class="md:hidden rounded-sm p-2 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 flex items-center shrink-0"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft class="h-6 w-6" />
|
||||
</button>
|
||||
<!-- Title -->
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
bind:value={title}
|
||||
class="flex-1 min-w-0 bg-transparent px-2 md:px-0 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="note-title-input"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (textareaEl) textareaEl.focus();
|
||||
else if (tiptapEditor) tiptapEditor.commands.focus('start');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Mobile Overflow Trigger -->
|
||||
<button
|
||||
bind:this={mobileOverflowBtnEl}
|
||||
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = mobileOverflowBtnEl; }}
|
||||
class="md:hidden rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 text-[var(--text)] shrink-0"
|
||||
data-testid="mobile-overflow-menu-btn"
|
||||
>
|
||||
<EllipsisVertical class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Title -->
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
bind:value={title}
|
||||
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="note-title-input"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (textareaEl) textareaEl.focus();
|
||||
else if (tiptapEditor) tiptapEditor.commands.focus('start');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain md:max-h-[60vh] md:flex-none">
|
||||
<div class="max-h-[60vh] overflow-y-auto">
|
||||
{#if checklistMode}
|
||||
<div class="px-4 py-2">
|
||||
<Checklist {content} onChange={(c) => (content = c)} />
|
||||
@@ -632,7 +287,7 @@
|
||||
bind:this={textareaEl}
|
||||
placeholder="Add a crumb..."
|
||||
bind:value={content}
|
||||
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
rows="12"
|
||||
data-testid="note-content-input"
|
||||
></textarea>
|
||||
@@ -642,7 +297,6 @@
|
||||
onUpdate={(md) => (content = md)}
|
||||
onEditor={(e) => (tiptapEditor = e)}
|
||||
onTransaction={() => editorTick++}
|
||||
{onOpenNote}
|
||||
placeholder="Add a crumb..."
|
||||
/>
|
||||
{/if}
|
||||
@@ -650,7 +304,7 @@
|
||||
|
||||
<!-- Image attachments -->
|
||||
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
<ImageUpload
|
||||
noteId={noteId}
|
||||
attachments={attachmentsList}
|
||||
@@ -662,35 +316,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Backlinks -->
|
||||
{#if backlinksList.length > 0}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
|
||||
<p class="mb-1 text-xs font-medium text-[var(--text-muted)]">Referenced by</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each backlinksList as backlink (backlink.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onOpenNote(backlink.id)}
|
||||
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs text-[var(--text)] hover:border-[var(--primary)]"
|
||||
data-testid="backlink-chip"
|
||||
>
|
||||
{backlink.title || 'Untitled'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} currentNoteId={noteId} />
|
||||
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar (Desktop only) -->
|
||||
<!-- Toolbar -->
|
||||
<div
|
||||
class="hidden md:flex shrink-0 items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2 touch-none"
|
||||
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
|
||||
style={toolbarInteractive ? '' : 'pointer-events: none'}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -698,11 +333,11 @@
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (showColorPicker = !showColorPicker)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Background color"}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Background color"
|
||||
data-testid="color-picker-toggle"
|
||||
>
|
||||
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
<Icon icon="pixelarticons:colors-swatch" class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
{#if showColorPicker}
|
||||
<div class="absolute left-0 bottom-full z-10 mb-2 w-[calc(100vw-4rem)] max-w-xs rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
|
||||
@@ -711,216 +346,87 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Checklist mode toggle -->
|
||||
<button
|
||||
onclick={() => (checklistMode = !checklistMode)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title={checklistMode ? 'Switch to text' : 'Checklist mode'}
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
{#if checklistMode}
|
||||
<Icon icon="pixelarticons:art-text" class="h-5 w-5" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:checkbox-on" class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Image attachment toggle -->
|
||||
<button
|
||||
onclick={toggleImageUpload}
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Attachments"}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Image attachments"
|
||||
data-testid="image-toggle"
|
||||
>
|
||||
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
<Icon icon="pixelarticons:image" class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
|
||||
<!-- Share button -->
|
||||
<!-- Raw markdown mode toggle -->
|
||||
<button
|
||||
onclick={toggleMarkdownMode}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
|
||||
disabled={checklistMode}
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
<Icon icon="pixelarticons:file-text" class="h-5 w-5" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:script" class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Share button (owner only for new shares, all for viewing) -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={toggleShareDialog}
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Share"}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Share note"
|
||||
data-testid="share-toggle"
|
||||
>
|
||||
{#if hasPublicLink}
|
||||
<Globe class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if isShared}
|
||||
<Users class="h-5 w-5 text-[var(--primary)]" />
|
||||
{#if isShared}
|
||||
<Icon icon="pixelarticons:users" class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else}
|
||||
<UserPlus class="h-5 w-5" />
|
||||
<Icon icon="pixelarticons:user-plus" class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if isShared}
|
||||
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" use:tooltip={"Shared note"}>
|
||||
<Users class="h-5 w-5" />
|
||||
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
|
||||
<Icon icon="pixelarticons:users" class="h-5 w-5" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Archive button (only for saved notes) -->
|
||||
<!-- History button (only for saved notes) -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={handleArchive}
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Archive"}
|
||||
data-testid="archive-note-btn"
|
||||
onclick={toggleHistory}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Version history"
|
||||
data-testid="history-toggle"
|
||||
>
|
||||
<Archive class="h-5 w-5" />
|
||||
<Icon icon="pixelarticons:timeline" class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Overflow menu trigger -->
|
||||
<button
|
||||
bind:this={desktopOverflowBtnEl}
|
||||
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = desktopOverflowBtnEl; }}
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"More"}
|
||||
data-testid="overflow-menu-btn"
|
||||
>
|
||||
<EllipsisVertical class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={saveAndClose}
|
||||
class="hidden md:block rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="close-editor-btn"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
|
||||
{#if showOverflowMenu && overflowAnchorEl}
|
||||
{@const rect = overflowAnchorEl.getBoundingClientRect()}
|
||||
{@const isMobileTrigger = overflowAnchorEl === mobileOverflowBtnEl}
|
||||
<div
|
||||
bind:this={overflowMenuEl}
|
||||
class="fixed z-50 w-max whitespace-nowrap rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]"
|
||||
style={isMobileTrigger ? `top: ${rect.bottom + 4}px; right: ${window.innerWidth - rect.right}px;` : `bottom: ${window.innerHeight - rect.top + 4}px; right: ${window.innerWidth - rect.right}px;`}
|
||||
data-testid="overflow-menu"
|
||||
>
|
||||
<!-- Mobile-only actions -->
|
||||
{#if isMobileTrigger}
|
||||
<!-- Color picker -->
|
||||
<div class="px-3 py-2 w-56 whitespace-normal">
|
||||
<span class="mb-2 block text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">Color</span>
|
||||
<ColorPicker selected={color} onSelect={(c) => { handleColorSelect(c); showOverflowMenu = false; }} />
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<button
|
||||
onclick={() => { toggleImageUpload(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
>
|
||||
<ImageIcon class="h-4 w-4 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
Attachments
|
||||
</button>
|
||||
|
||||
<!-- Share -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={() => { toggleShareDialog(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
>
|
||||
{#if hasPublicLink}
|
||||
<Globe class="h-4 w-4 text-[var(--primary)]" />
|
||||
{:else if isShared}
|
||||
<Users class="h-4 w-4 text-[var(--primary)]" />
|
||||
{:else}
|
||||
<UserPlus class="h-4 w-4" />
|
||||
{/if}
|
||||
Share
|
||||
</button>
|
||||
{:else if isShared}
|
||||
<div class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-muted)]">
|
||||
<Users class="h-4 w-4" />
|
||||
Shared note
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Archive -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={handleArchive}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
>
|
||||
<Archive class="h-4 w-4" />
|
||||
Archive
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||
{/if}
|
||||
<!-- Checklist mode toggle -->
|
||||
<button
|
||||
onclick={() => { checklistMode = !checklistMode; showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
{#if checklistMode}
|
||||
<Type class="h-4 w-4" />
|
||||
Switch to text
|
||||
{:else}
|
||||
<SquareCheck class="h-4 w-4" />
|
||||
Checklist mode
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Markdown mode toggle (not available in checklist mode) -->
|
||||
{#if !checklistMode}
|
||||
<button
|
||||
onclick={() => { toggleMarkdownMode(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
<FileText class="h-4 w-4" />
|
||||
Rich text mode
|
||||
{:else}
|
||||
<FileCode class="h-4 w-4" />
|
||||
Markdown mode
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- History -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={() => { toggleHistory(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="history-toggle"
|
||||
>
|
||||
<History class="h-4 w-4 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
Version history
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Trash (only for saved notes) -->
|
||||
{#if !currentlyNew}
|
||||
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={handleTrash}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="trash-note-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Move to trash
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Checklist-specific actions -->
|
||||
{#if checklistMode}
|
||||
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||
{#if hasDoneItems}
|
||||
<button
|
||||
onclick={deleteCheckedItems}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="delete-checked-btn"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Delete checked
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={uncheckAllItems}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="uncheck-all-btn"
|
||||
>
|
||||
<ListX class="h-4 w-4" />
|
||||
Uncheck all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showShareDialog && noteId}
|
||||
@@ -928,10 +434,8 @@
|
||||
noteId={noteId}
|
||||
collaborators={collaboratorsList}
|
||||
{ownerName}
|
||||
shareToken={currentShareToken}
|
||||
onClose={() => (showShareDialog = false)}
|
||||
onUpdate={handleCollaboratorsUpdate}
|
||||
onShareUpdate={handleShareUpdate}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { NoteVersionSummary, NoteVersion } from '$lib/types/index.js';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import RotateCcw from 'lucide-svelte/icons/rotate-ccw';
|
||||
import Clock from 'lucide-svelte/icons/clock';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
|
||||
interface Props {
|
||||
@@ -111,16 +109,16 @@
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<Icon icon="pixelarticons:clock" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<h2 class="text-sm font-semibold text-[var(--text)]">Version History</h2>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
|
||||
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||
title="Close history"
|
||||
data-testid="close-history-btn"
|
||||
>
|
||||
<X class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +138,7 @@
|
||||
{#each versions as v (v.id)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full px-3 py-3 text-left hover:bg-[var(--hover-wash)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
|
||||
class="w-full px-3 py-3 text-left hover:bg-[var(--border)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
|
||||
onclick={() => selectVersion(v.id)}
|
||||
data-testid="version-item"
|
||||
>
|
||||
@@ -190,12 +188,12 @@
|
||||
class="flex items-center gap-1.5 rounded-sm border border-[var(--destructive)] bg-[var(--destructive)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||
data-testid="confirm-restore-btn"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
<Icon icon="pixelarticons:undo" class="h-3.5 w-3.5" />
|
||||
{isRestoring ? 'Restoring…' : 'Confirm Restore'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showConfirm = false)}
|
||||
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -207,7 +205,7 @@
|
||||
class="flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)] transition-colors"
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
<Icon icon="pixelarticons:undo" class="h-3.5 w-3.5" />
|
||||
Restore this version
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { favoriteNotes, unfavoritedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import type { Note, NoteFilter } from '$lib/types/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
import { getNote as getIdbNote } from '$lib/sync/idb.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface Props {
|
||||
filter: NoteFilter;
|
||||
@@ -20,7 +16,6 @@
|
||||
|
||||
let editingNote: Note | null = $state(null);
|
||||
let showNewNote = $state(false);
|
||||
let newNoteChecklist = $state(false);
|
||||
|
||||
// Sync store with route props and reload notes when filter changes
|
||||
$effect(() => {
|
||||
@@ -29,33 +24,15 @@
|
||||
loadNotes(filter);
|
||||
});
|
||||
|
||||
// Use SvelteKit's replaceState (not the raw history API) so the router keeps
|
||||
// ownership of the entry — NoteEditor's close-on-back relies on popstate being
|
||||
// handled by SvelteKit.
|
||||
function openEditor(note: Note) {
|
||||
editingNote = note;
|
||||
replaceState(`#${note.id}`, {});
|
||||
history.replaceState(null, '', `#${note.id}`);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
newNoteChecklist = false;
|
||||
replaceState(location.pathname, {});
|
||||
}
|
||||
|
||||
// Reads from the local IndexedDB cache rather than fetching over the network —
|
||||
// this keeps note-link navigation instant and offline-safe (matching how the
|
||||
// rest of the app opens notes), and backlinks are filled in progressively by
|
||||
// NoteEditor's own fetch-if-not-pre-populated effect once the editor is open,
|
||||
// the same pattern already used there for attachments.
|
||||
let openNoteRequestId = 0;
|
||||
|
||||
async function openNoteById(noteId: string) {
|
||||
const requestId = ++openNoteRequestId;
|
||||
const note = await getIdbNote(noteId);
|
||||
if (requestId !== openNoteRequestId) return; // a newer navigation superseded this one
|
||||
if (note) openEditor(note);
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
@@ -78,24 +55,14 @@
|
||||
|
||||
{#if filter === 'all' && !tag}
|
||||
<div class="mx-auto mb-6 hidden max-w-xl md:block">
|
||||
<div class="flex cursor-pointer items-center gap-0 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] transition-colors hover:border-[var(--primary)]">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex flex-1 cursor-pointer items-center gap-3 px-4 py-3 text-left text-sm text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { newNoteChecklist = true; showNewNote = true; }}
|
||||
class="cursor-pointer rounded-sm p-3 text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||
use:tooltip={"New checklist"}
|
||||
data-testid="new-checklist-btn"
|
||||
>
|
||||
<SquareCheck class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:plus" class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
@@ -103,7 +70,7 @@
|
||||
aria-label="Add a crumb"
|
||||
data-testid="new-note-fab"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
<Icon icon="pixelarticons:plus" class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -119,22 +86,22 @@
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">#{tag}</h2>
|
||||
{/if}
|
||||
|
||||
{#if $pinnedNotes.length > 0}
|
||||
{#if $favoriteNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
|
||||
<NoteGrid notes={$favoriteNotes} label="Favorites" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NoteGrid
|
||||
notes={$unpinnedNotes}
|
||||
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||
notes={$unfavoritedNotes}
|
||||
label={$favoriteNotes.length > 0 ? 'Others' : ''}
|
||||
onEdit={openEditor}
|
||||
draggable
|
||||
dndType="unpinned-notes"
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
{#if $notesLoaded && $favoriteNotes.length === 0 && $unfavoritedNotes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
|
||||
<p class="font-['Press_Start_2P'] text-xs">
|
||||
@@ -152,17 +119,9 @@
|
||||
{/if}
|
||||
|
||||
{#if showNewNote}
|
||||
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} onOpenNote={openNoteById} />
|
||||
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
|
||||
{/if}
|
||||
|
||||
{#if editingNote}
|
||||
<!-- Forces a full destroy+remount on note-switch (e.g. clicking a note-link
|
||||
while the editor is already open) instead of reusing the same instance
|
||||
with a new `note` prop — a persisting instance's own back-navigation
|
||||
effect misreads `openEditor`'s replaceState call as "browser back was
|
||||
pressed" and closes the editor, and its once-only local state never
|
||||
resyncs to the new note otherwise. -->
|
||||
{#key editingNote.id}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} onOpenNote={openNoteById} />
|
||||
{/key}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} />
|
||||
{/if}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { searchQuery, searchResults } from '$lib/stores/notes.js';
|
||||
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { onDestroy } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface Props {
|
||||
onClose?: () => void;
|
||||
@@ -13,64 +10,41 @@
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let originalNotes: Note[] = [];
|
||||
let isSearching = $state(false);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
let searchController: AbortController | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (onClose) inputEl?.focus();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
searchController?.abort();
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
searchController?.abort();
|
||||
searchController = undefined;
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
if (!trimmedQuery) {
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
if (!query.trim()) {
|
||||
if (isSearching) {
|
||||
notes.set(originalNotes);
|
||||
isSearching = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
searchController = controller;
|
||||
if (!isSearching) {
|
||||
notes.subscribe((n) => (originalNotes = n))();
|
||||
isSearching = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmedQuery)}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
if (res.ok) {
|
||||
const results: Note[] = await res.json();
|
||||
if (searchController !== controller || query.trim() !== trimmedQuery) return;
|
||||
// Commit the query alongside its results so the view switches to the
|
||||
// (possibly empty) result set only once it's ready — this avoids
|
||||
// flashing an empty state over the current notes while the request is
|
||||
// in flight, and keeps searchQuery consistent with searchResults on
|
||||
// a failed request (neither updates).
|
||||
searchResults.set(results);
|
||||
searchQuery.set(trimmedQuery);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) {
|
||||
console.error('Search failed:', error);
|
||||
}
|
||||
} finally {
|
||||
if (searchController === controller) searchController = undefined;
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const results = await res.json();
|
||||
notes.set(results);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchController?.abort();
|
||||
searchController = undefined;
|
||||
query = '';
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
if (isSearching) {
|
||||
notes.set(originalNotes);
|
||||
isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -83,10 +57,10 @@
|
||||
<div class="flex items-center rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 focus-within:border-[var(--primary)]">
|
||||
{#if onClose}
|
||||
<button onclick={close} class="mr-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Back">
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
<Icon icon="pixelarticons:arrow-left" class="h-5 w-5" />
|
||||
</button>
|
||||
{:else}
|
||||
<Search class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
|
||||
<Icon icon="pixelarticons:search" class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
|
||||
{/if}
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
@@ -99,7 +73,7 @@
|
||||
/>
|
||||
{#if query}
|
||||
<button onclick={clearSearch} class="ml-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Clear search">
|
||||
<X class="h-5 w-5" />
|
||||
<Icon icon="pixelarticons:close" class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import Globe from 'lucide-svelte/icons/globe';
|
||||
import Link from 'lucide-svelte/icons/link';
|
||||
import Copy from 'lucide-svelte/icons/copy';
|
||||
import Check from 'lucide-svelte/icons/check';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
import { page } from '$app/state';
|
||||
|
||||
interface UserResult {
|
||||
id: number;
|
||||
@@ -20,65 +13,17 @@
|
||||
noteId: string;
|
||||
collaborators: Collaborator[];
|
||||
ownerName: string;
|
||||
shareToken?: string;
|
||||
onClose: () => void;
|
||||
onUpdate: (collaborators: Collaborator[]) => void;
|
||||
onShareUpdate: (token: string | null) => void;
|
||||
}
|
||||
|
||||
const { noteId, collaborators, ownerName, shareToken, onClose, onUpdate, onShareUpdate }: Props = $props();
|
||||
const { noteId, collaborators, ownerName, onClose, onUpdate }: Props = $props();
|
||||
|
||||
let publicShareToken = $state(shareToken ?? null);
|
||||
let copying = $state(false);
|
||||
let togglingShare = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<UserResult[]>([]);
|
||||
let isSearching = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const shareUrl = $derived(publicShareToken ? `${page.url.origin}/s/${publicShareToken}` : null);
|
||||
|
||||
async function togglePublicShare() {
|
||||
togglingShare = true;
|
||||
try {
|
||||
if (publicShareToken) {
|
||||
const res = await fetch(`/api/notes/${noteId}/share`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
showToast('Failed to remove share link', 'error');
|
||||
return;
|
||||
}
|
||||
publicShareToken = null;
|
||||
onShareUpdate(null);
|
||||
showToast('Public link removed', 'success');
|
||||
} else {
|
||||
const res = await fetch(`/api/notes/${noteId}/share`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
showToast('Failed to create share link', 'error');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
publicShareToken = data.token;
|
||||
onShareUpdate(data.token);
|
||||
showToast('Public link created', 'success');
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to update share link', 'error');
|
||||
} finally {
|
||||
togglingShare = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyShareUrl() {
|
||||
if (!shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
copying = true;
|
||||
setTimeout(() => (copying = false), 2000);
|
||||
} catch {
|
||||
showToast('Failed to copy link', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch(query: string) {
|
||||
clearTimeout(searchTimeout);
|
||||
if (!query.trim()) {
|
||||
@@ -91,6 +36,7 @@
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const users: UserResult[] = await res.json();
|
||||
// Filter out existing collaborators
|
||||
const collabIds = new Set(collaborators.map((c) => c.userId));
|
||||
searchResults = users.filter((u) => !collabIds.has(u.id));
|
||||
}
|
||||
@@ -168,71 +114,17 @@
|
||||
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
|
||||
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||
title="Close"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Public link section -->
|
||||
<div class="px-4 pt-3 pb-3" data-testid="public-link-section">
|
||||
<div
|
||||
class="rounded-sm border p-3 transition-colors {publicShareToken
|
||||
? 'border-[var(--primary)] bg-[var(--primary)]/5'
|
||||
: 'border-[var(--border-subtle)]'}"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Globe class="h-4 w-4 {publicShareToken ? 'text-[var(--primary)]' : 'text-[var(--text-muted)]'}" />
|
||||
<span class="text-sm font-medium text-[var(--text)]">Public link</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={togglePublicShare}
|
||||
disabled={togglingShare}
|
||||
class="relative h-5 w-9 rounded-sm transition-colors {publicShareToken
|
||||
? 'bg-[var(--primary)]'
|
||||
: 'bg-[var(--border-subtle)]'}"
|
||||
title={publicShareToken ? 'Disable public link' : 'Enable public link'}
|
||||
data-testid="public-share-toggle"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 h-4 w-4 rounded-sm bg-[var(--bg-surface)] shadow-[1px_1px_0px_var(--border-subtle)] transition-all {publicShareToken
|
||||
? 'left-[1.125rem]'
|
||||
: 'left-0.5'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if publicShareToken && shareUrl}
|
||||
<div class="mt-2 flex items-center gap-1" data-testid="share-url-container">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-2 py-1.5">
|
||||
<Link class="h-3 w-3 shrink-0 text-[var(--text-muted)]" />
|
||||
<span class="truncate text-xs text-[var(--text-muted)]" data-testid="share-url-text">{shareUrl}</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={copyShareUrl}
|
||||
class="shrink-0 rounded-sm border border-[var(--border-subtle)] p-1.5 hover:border-[var(--primary)] hover:bg-[var(--primary)]/5 transition-colors"
|
||||
title="Copy link"
|
||||
data-testid="copy-share-url-btn"
|
||||
>
|
||||
{#if copying}
|
||||
<Check class="h-4 w-4 text-[var(--success-text)]" />
|
||||
{:else}
|
||||
<Copy class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Search input -->
|
||||
<div class="relative px-4 pt-3">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<Icon icon="pixelarticons:search" class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
@@ -277,7 +169,7 @@
|
||||
</li>
|
||||
<!-- Collaborators -->
|
||||
{#each collaborators as collab}
|
||||
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--hover-wash)]/10" data-testid="share-collaborator">
|
||||
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
|
||||
<span class="text-sm text-[var(--text)]">
|
||||
{collab.displayName || collab.email}
|
||||
</span>
|
||||
@@ -287,7 +179,7 @@
|
||||
title="Remove"
|
||||
data-testid="remove-collaborator-btn"
|
||||
>
|
||||
<UserMinus class="h-4 w-4" />
|
||||
<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import Globe from 'lucide-svelte/icons/globe';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
|
||||
interface Props {
|
||||
shareToken?: string;
|
||||
collaborators: Collaborator[];
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
const { shareToken, collaborators, isOwner }: Props = $props();
|
||||
|
||||
let showPopover = $state(false);
|
||||
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const hasPublicLink = $derived(!!shareToken);
|
||||
const hasCollaborators = $derived(collaborators.length > 0);
|
||||
|
||||
const tooltipLines = $derived.by(() => {
|
||||
const lines: string[] = [];
|
||||
if (hasPublicLink) lines.push('Shared publicly via link');
|
||||
if (hasCollaborators) {
|
||||
lines.push(`Shared with ${collaborators.length} collaborator${collaborators.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
|
||||
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 {hasPublicLink ? 'text-[var(--primary)]' : 'text-[var(--text-muted)]'} hover:text-[var(--primary)]"
|
||||
title={tooltipLines.join(', ')}
|
||||
aria-label={tooltipLines.join(', ')}
|
||||
onclick={handleClick}
|
||||
data-testid="sharing-indicator"
|
||||
>
|
||||
{#if hasPublicLink}
|
||||
<Globe class="h-4 w-4" />
|
||||
{:else}
|
||||
<Users class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if showPopover}
|
||||
<div
|
||||
class="absolute right-0 top-full z-20 mt-1 min-w-[180px] 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="sharing-popover"
|
||||
>
|
||||
{#if hasPublicLink}
|
||||
<div class="mb-1 flex items-center gap-1.5">
|
||||
<Globe class="h-3 w-3 text-[var(--primary)]" />
|
||||
<span class="text-xs font-semibold text-[var(--primary)]">Public link active</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasCollaborators}
|
||||
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
|
||||
<ul class="space-y-0.5">
|
||||
{#if isOwner}
|
||||
<li class="text-xs text-[var(--text)]">
|
||||
You <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>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,10 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getSyncStatus, onSyncStatusChange, sync, type SyncStatus } from '$lib/sync/client.js';
|
||||
import CloudCheck from 'lucide-svelte/icons/cloud-check';
|
||||
import CloudSync from 'lucide-svelte/icons/cloud-sync';
|
||||
import CloudOff from 'lucide-svelte/icons/cloud-off';
|
||||
import CloudAlert from 'lucide-svelte/icons/cloud-alert';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let status: SyncStatus = $state(getSyncStatus());
|
||||
let displayStatus: SyncStatus = $state(getSyncStatus());
|
||||
@@ -51,12 +48,12 @@
|
||||
data-testid="sync-indicator"
|
||||
>
|
||||
{#if displayStatus === 'synced'}
|
||||
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
|
||||
<Icon icon="pixelarticons:cloud-done" class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if displayStatus === 'syncing'}
|
||||
<CloudSync class="h-5 w-5 text-[var(--primary)] opacity-60" />
|
||||
<Icon icon="pixelarticons:sync" class="h-5 w-5 text-[var(--primary)] opacity-60" />
|
||||
{:else if displayStatus === 'offline'}
|
||||
<CloudOff class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
{:else}
|
||||
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--destructive)]" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -13,134 +13,58 @@
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { NoteLink } from './tiptap/NoteLink.js';
|
||||
import { getAllNotes } from '$lib/sync/idb.js';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
onUpdate: (markdown: string) => void;
|
||||
onEditor?: (editor: Editor) => void;
|
||||
onTransaction?: () => void;
|
||||
onOpenNote?: (noteId: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { content, onUpdate, onEditor, onTransaction, onOpenNote, placeholder = 'Add a crumb...' }: Props = $props();
|
||||
let { content, onUpdate, onEditor, onTransaction, placeholder = 'Add a crumb...' }: Props = $props();
|
||||
|
||||
let element: HTMLDivElement | undefined = $state();
|
||||
let editor: Editor | undefined = $state();
|
||||
|
||||
const titleIndexRef = { index: new Map<string, string>() };
|
||||
|
||||
function isTaskCheckboxTarget(target: EventTarget | null): boolean {
|
||||
if (target instanceof Element) {
|
||||
const checkbox = target.matches('input[type="checkbox"]')
|
||||
? target
|
||||
: target.closest('label')?.querySelector('input[type="checkbox"]');
|
||||
return checkbox !== null && checkbox !== undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTouchDevice(): boolean {
|
||||
return window.matchMedia('(pointer: coarse)').matches || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
function handleWrapperPointerDown(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch' && isTaskCheckboxTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWrapperClick(event: MouseEvent) {
|
||||
if (isTaskCheckboxTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor?.commands.focus();
|
||||
}
|
||||
|
||||
function handleWrapperChangeCapture(event: Event) {
|
||||
if (!isTouchDevice() || !(event.target instanceof HTMLInputElement)) return;
|
||||
if (event.target.type !== 'checkbox' || !editor) return;
|
||||
|
||||
const taskItem = event.target.closest('li[data-checked]');
|
||||
if (!taskItem) return;
|
||||
|
||||
const taskItemPosition = editor.view.posAtDOM(taskItem, 0) - 1;
|
||||
const taskItemNode = editor.state.doc.nodeAt(taskItemPosition);
|
||||
if (taskItemNode?.type.name !== 'taskItem') return;
|
||||
|
||||
event.stopPropagation();
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setNodeMarkup(taskItemPosition, undefined, {
|
||||
...taskItemNode.attrs,
|
||||
checked: event.target.checked
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let destroyed = false;
|
||||
let editorInstance: Editor | undefined;
|
||||
|
||||
(async () => {
|
||||
const allNotes = await getAllNotes();
|
||||
if (destroyed) return;
|
||||
titleIndexRef.index = new Map(allNotes.filter((n) => !n.trashed).map((n) => [n.id, n.title]));
|
||||
|
||||
editorInstance = new Editor({
|
||||
element: element!,
|
||||
extensions: [
|
||||
StarterKit.configure({ link: false, underline: false }),
|
||||
Link.configure({ openOnClick: false }),
|
||||
Underline,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
editor = new Editor({
|
||||
element: element!,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({ openOnClick: false }),
|
||||
Underline,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
NoteLink.configure({ titleIndexRef, onOpenNote: onOpenNote ?? (() => {}) }),
|
||||
Markdown
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
if (destroyed) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
|
||||
},
|
||||
onTransaction: () => {
|
||||
// Removing this component's DOM node (e.g. a {#key}-driven remount
|
||||
// in NotesView.svelte when switching to a different note) fires a
|
||||
// native blur on the still-focused contenteditable, which
|
||||
// ProseMirror's blur plugin turns into one last transaction —
|
||||
// synchronously, from inside Svelte's own render/effect pass that's
|
||||
// doing the removal. Mutating $state right there crashes with
|
||||
// state_unsafe_mutation; the `destroyed` check alone isn't enough
|
||||
// since this can fire before onMount's own cleanup runs. Defer to a
|
||||
// microtask so the write lands after Svelte's current pass finishes.
|
||||
queueMicrotask(() => {
|
||||
if (destroyed) return;
|
||||
editor = editorInstance;
|
||||
onTransaction?.();
|
||||
});
|
||||
}
|
||||
});
|
||||
editor = editorInstance;
|
||||
|
||||
if (element && !destroyed) {
|
||||
(element as any).__tiptapEditor = editorInstance;
|
||||
TaskItem.configure({ nested: true }),
|
||||
Markdown
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
|
||||
},
|
||||
onTransaction: () => {
|
||||
// Trigger Svelte reactivity for active state checks
|
||||
editor = editor;
|
||||
onTransaction?.();
|
||||
}
|
||||
});
|
||||
|
||||
onEditor?.(editorInstance);
|
||||
})();
|
||||
// Expose editor on DOM element for e2e testing
|
||||
if (element) {
|
||||
(element as any).__tiptapEditor = editor;
|
||||
}
|
||||
|
||||
onEditor?.(editor);
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
editorInstance?.destroy();
|
||||
editor?.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -148,14 +72,12 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={element}
|
||||
class="tiptap-wrapper prose md:prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
|
||||
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
|
||||
data-testid="tiptap-editor"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
tabindex="0"
|
||||
onpointerdown={handleWrapperPointerDown}
|
||||
onclick={handleWrapperClick}
|
||||
onchangecapture={handleWrapperChangeCapture}
|
||||
onclick={() => editor?.commands.focus()}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
|
||||
|
||||
export interface NoteLinkOptions {
|
||||
titleIndexRef: { index: Map<string, string> };
|
||||
onOpenNote: (noteId: string) => void;
|
||||
}
|
||||
|
||||
export const NoteLink = Node.create<NoteLinkOptions>({
|
||||
name: 'noteLink',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
titleIndexRef: { index: new Map<string, string>() },
|
||||
onOpenNote: () => {}
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
noteId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-note-id'),
|
||||
renderHTML: (attributes) => ({ 'data-note-id': attributes.noteId })
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'a[href^="crumb-note://"]',
|
||||
priority: 100,
|
||||
getAttrs: (element) => {
|
||||
const href = (element as HTMLElement).getAttribute('href') ?? '';
|
||||
const noteId = parseNoteLinkHref(href);
|
||||
return noteId ? { noteId } : false;
|
||||
}
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['a', mergeAttributes(HTMLAttributes, { href: `crumb-note://${HTMLAttributes['data-note-id']}` })];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize: (state: { write: (s: string) => void }, node: { attrs: { noteId: string } }) => {
|
||||
state.write(serializeNoteLinkMarkdown(node.attrs.noteId, this.options.titleIndexRef.index));
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, editor, getPos }) => {
|
||||
const noteId = node.attrs.noteId as string;
|
||||
const title = this.options.titleIndexRef.index.get(noteId);
|
||||
|
||||
const dom = document.createElement('span');
|
||||
dom.setAttribute('data-note-id', noteId);
|
||||
dom.className =
|
||||
'group relative inline-flex items-center gap-1 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-0.5 text-sm';
|
||||
|
||||
const label = document.createElement('span');
|
||||
if (title !== undefined) {
|
||||
label.textContent = title;
|
||||
label.className = 'cursor-pointer text-[var(--primary)] hover:text-[var(--primary-hover)]';
|
||||
label.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
this.options.onOpenNote(noteId);
|
||||
});
|
||||
} else {
|
||||
label.textContent = 'Note not found';
|
||||
label.className = 'text-[var(--text-muted)] opacity-60';
|
||||
}
|
||||
dom.appendChild(label);
|
||||
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = '×';
|
||||
removeButton.setAttribute('aria-label', 'Remove note link');
|
||||
removeButton.className =
|
||||
'max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100 text-[var(--text-muted)] hover:text-[var(--destructive)]';
|
||||
removeButton.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const pos = getPos();
|
||||
if (pos === undefined) return;
|
||||
editor.chain().focus().deleteRange({ from: pos, to: pos + node.nodeSize }).run();
|
||||
});
|
||||
dom.appendChild(removeButton);
|
||||
|
||||
return { dom };
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
|
||||
|
||||
describe('parseNoteLinkHref', () => {
|
||||
it('should extract the note ID from a crumb-note:// href', () => {
|
||||
expect(parseNoteLinkHref('crumb-note://abc-123')).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('should return null for a non-crumb-note href', () => {
|
||||
expect(parseNoteLinkHref('https://example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for an empty string', () => {
|
||||
expect(parseNoteLinkHref('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeNoteLinkMarkdown', () => {
|
||||
it('should produce a markdown link with the title from the index', () => {
|
||||
const titleIndex = new Map([['abc-123', 'Grocery List']]);
|
||||
expect(serializeNoteLinkMarkdown('abc-123', titleIndex)).toBe('[Grocery List](crumb-note://abc-123)');
|
||||
});
|
||||
|
||||
it('should fall back to Untitled when the ID is not in the index', () => {
|
||||
const titleIndex = new Map<string, string>();
|
||||
expect(serializeNoteLinkMarkdown('missing-id', titleIndex)).toBe('[Untitled](crumb-note://missing-id)');
|
||||
});
|
||||
|
||||
it('should escape markdown-special characters in the title', () => {
|
||||
const titleIndex = new Map([['id-1', 'Buy [milk] today']]);
|
||||
expect(serializeNoteLinkMarkdown('id-1', titleIndex)).toBe('[Buy \\[milk\\] today](crumb-note://id-1)');
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
const NOTE_LINK_SCHEME = 'crumb-note://';
|
||||
|
||||
/** Extracts the note ID from a crumb-note:// href, or null if it's not that scheme. */
|
||||
export function parseNoteLinkHref(href: string): string | null {
|
||||
if (!href.startsWith(NOTE_LINK_SCHEME)) return null;
|
||||
return href.slice(NOTE_LINK_SCHEME.length) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes markdown-special characters the same way a plain link's visible
|
||||
* text is escaped, so a title containing `[`, `]`, `*`, etc. can't corrupt
|
||||
* the surrounding markdown syntax.
|
||||
*/
|
||||
function escapeMarkdownText(text: string): string {
|
||||
return text.replace(/[[\]\\*_`]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Produces the `[title](crumb-note://id)` markdown representation of a note-link. */
|
||||
export function serializeNoteLinkMarkdown(noteId: string, titleIndex: Map<string, string>): string {
|
||||
const title = titleIndex.get(noteId) ?? 'Untitled';
|
||||
return `[${escapeMarkdownText(title)}](${NOTE_LINK_SCHEME}${noteId})`;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
|
||||
import { users, sessions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './db/schema.js';
|
||||
|
||||
@@ -16,16 +16,16 @@ beforeEach(() => {
|
||||
|
||||
describe('Auth - Password Hashing', () => {
|
||||
it('should hash and verify a password correctly', async () => {
|
||||
const password = randomUUID();
|
||||
const password = 'mysecurepassword';
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
expect(hash).not.toBe(password);
|
||||
expect(await argon2.verify(hash, password)).toBe(true);
|
||||
expect(await argon2.verify(hash, randomUUID())).toBe(false);
|
||||
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
|
||||
});
|
||||
|
||||
it('should produce different hashes for the same password', async () => {
|
||||
const password = randomUUID();
|
||||
const password = 'mysecurepassword';
|
||||
const hash1 = await argon2.hash(password);
|
||||
const hash2 = await argon2.hash(password);
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('Auth - Password Hashing', () => {
|
||||
|
||||
describe('Auth - User Setup', () => {
|
||||
it('should create a user with hashed password', async () => {
|
||||
const password = randomUUID();
|
||||
const password = 'testpassword123';
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
await db.insert(users).values({
|
||||
|
||||
+18
-47
@@ -227,53 +227,24 @@ export function getUser(userId: number): User | null {
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: number): Promise<void> {
|
||||
// Atomic cascade: all-or-nothing. Also clear rows from other users that
|
||||
// reference the deleted user's notes (e.g. sync_log, note_user_state,
|
||||
// note_collaborators) so foreign-key checks don't block the final delete.
|
||||
const tx = sqlite.transaction((uid: number) => {
|
||||
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_user_state WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM sync_log WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM attachments WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_versions WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid);
|
||||
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM users WHERE id = ?').run(uid);
|
||||
});
|
||||
tx(userId);
|
||||
// Cascade: delete user's data in dependency order
|
||||
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM note_user_state WHERE user_id = ?').run(userId);
|
||||
sqlite
|
||||
.prepare('DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ?')
|
||||
.run(userId, userId);
|
||||
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(userId);
|
||||
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
db.delete(users).where(eq(users.id, userId)).run();
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
|
||||
@@ -124,13 +124,6 @@ sqlite.exec(`
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_notes (
|
||||
note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
@@ -226,13 +219,6 @@ sqlite.exec(`
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS note_links_source_idx ON note_links(source_note_id);
|
||||
CREATE INDEX IF NOT EXISTS note_links_target_idx ON note_links(target_note_id);
|
||||
`);
|
||||
|
||||
// Replace old tags unique index with compound index including user_id
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createTestDb } from './test-helpers.js';
|
||||
import { notes, noteLinks, users } from './schema.js';
|
||||
|
||||
describe('noteLinks schema', () => {
|
||||
it('should store a source/target link row', () => {
|
||||
const { db } = createTestDb();
|
||||
db.insert(users)
|
||||
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
|
||||
db.insert(notes)
|
||||
.values([
|
||||
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
|
||||
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toEqual([{ sourceNoteId: 'source-note', targetNoteId: 'target-note' }]);
|
||||
});
|
||||
|
||||
it('should cascade-delete link rows when the source note is deleted', () => {
|
||||
const { db, sqlite } = createTestDb();
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
db.insert(users)
|
||||
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
db.insert(notes)
|
||||
.values([
|
||||
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
|
||||
|
||||
db.delete(notes).where(eq(notes.id, 'source-note')).run();
|
||||
|
||||
expect(db.select().from(noteLinks).all()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -80,22 +80,6 @@ export const noteTags = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
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)
|
||||
]
|
||||
);
|
||||
|
||||
export const attachments = sqliteTable('attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
@@ -206,15 +190,6 @@ export const userPreferences = sqliteTable(
|
||||
(table) => [uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key)]
|
||||
);
|
||||
|
||||
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' })
|
||||
});
|
||||
|
||||
export const noteVersions = sqliteTable(
|
||||
'note_versions',
|
||||
{
|
||||
|
||||
@@ -68,13 +68,6 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
|
||||
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
|
||||
|
||||
CREATE TABLE note_links (
|
||||
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX note_links_source_idx ON note_links(source_note_id);
|
||||
CREATE INDEX note_links_target_idx ON note_links(target_note_id);
|
||||
|
||||
CREATE TABLE note_collaborators (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
@@ -144,13 +137,6 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
);
|
||||
CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key);
|
||||
|
||||
CREATE TABLE shared_notes (
|
||||
note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE note_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// We need to re-import for each test to get a fresh module state
|
||||
let checkIpRateLimit: (ip: string) => boolean;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const mod = await import('./ip-rate-limit.js');
|
||||
checkIpRateLimit = mod.checkIpRateLimit;
|
||||
});
|
||||
|
||||
describe('checkIpRateLimit', () => {
|
||||
it('allows requests under the limit', () => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
expect(checkIpRateLimit('1.2.3.4')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks requests over the limit', () => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
checkIpRateLimit('1.2.3.4');
|
||||
}
|
||||
expect(checkIpRateLimit('1.2.3.4')).toBe(false);
|
||||
});
|
||||
|
||||
it('tracks different IPs independently', () => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
checkIpRateLimit('1.1.1.1');
|
||||
}
|
||||
expect(checkIpRateLimit('1.1.1.1')).toBe(false);
|
||||
expect(checkIpRateLimit('2.2.2.2')).toBe(true);
|
||||
});
|
||||
|
||||
it('resets after the window expires', () => {
|
||||
vi.useFakeTimers();
|
||||
for (let i = 0; i < 60; i++) {
|
||||
checkIpRateLimit('1.2.3.4');
|
||||
}
|
||||
expect(checkIpRateLimit('1.2.3.4')).toBe(false);
|
||||
|
||||
// Advance past the 1-minute window
|
||||
vi.advanceTimersByTime(61_000);
|
||||
expect(checkIpRateLimit('1.2.3.4')).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
const store = new Map<string, { count: number; windowStart: number }>();
|
||||
const WINDOW_MS = 60_000; // 1 minute
|
||||
const MAX_REQUESTS = 60;
|
||||
const MAX_ENTRIES = 10_000;
|
||||
let cleanupCounter = 0;
|
||||
|
||||
export function checkIpRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = store.get(ip);
|
||||
if (!entry || now - entry.windowStart > WINDOW_MS) {
|
||||
store.set(ip, { count: 1, windowStart: now });
|
||||
pruneIfNeeded(now);
|
||||
return true;
|
||||
}
|
||||
entry.count++;
|
||||
return entry.count <= MAX_REQUESTS;
|
||||
}
|
||||
|
||||
function pruneIfNeeded(now: number) {
|
||||
cleanupCounter++;
|
||||
if (cleanupCounter < 100 || store.size < MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
cleanupCounter = 0;
|
||||
for (const [key, entry] of store) {
|
||||
if (now - entry.windowStart > WINDOW_MS) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
import {
|
||||
listNotes,
|
||||
@@ -32,134 +29,6 @@ const NOTE_COLORS = [
|
||||
'chalk'
|
||||
] as const;
|
||||
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB, matches the manual attachment upload limit
|
||||
const MAX_IMAGE_REDIRECTS = 5;
|
||||
|
||||
// Blocks loopback, unspecified, link-local (incl. cloud metadata 169.254.169.254),
|
||||
// RFC1918/ULA private ranges, 6to4/IPv4-mapped tunnels, and multicast/broadcast so
|
||||
// upload_image can't be used to reach internal network services.
|
||||
function isPrivateOrReservedIp(address: string): boolean {
|
||||
if (address.includes(':')) {
|
||||
const normalized = address.toLowerCase();
|
||||
if (normalized === '::' || normalized === '::1') return true;
|
||||
if (normalized.startsWith('fe80:')) return true;
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
|
||||
if (normalized.startsWith('2002:')) return true; // 6to4 tunnel, can embed a private IPv4
|
||||
if (normalized.startsWith('::ffff:')) return isPrivateOrReservedIp(normalized.split(':').pop()!);
|
||||
// Legacy IPv4-compatible form, e.g. "::1.2.3.4" (distinct from "::ffff:1.2.3.4")
|
||||
const ipv4Compatible = normalized.match(/^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (ipv4Compatible) return isPrivateOrReservedIp(ipv4Compatible[1]);
|
||||
return false;
|
||||
}
|
||||
const parts = address.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true;
|
||||
const [a, b] = parts;
|
||||
if (a === 10 || a === 127 || a === 0) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 100 && b >= 64 && b <= 127) return true;
|
||||
if (a >= 224) return true; // multicast (224.0.0.0/4) and broadcast/reserved (240.0.0.0/4, 255.255.255.255)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves every A/AAAA record for a hostname and rejects if ANY is private/reserved
|
||||
// (not just the first), so a validated hostname can't have a private record hiding
|
||||
// behind a public one in the same DNS answer.
|
||||
async function resolveValidatedAddress(hostname: string): Promise<{ address: string; family: number }> {
|
||||
const records = await lookup(hostname, { all: true });
|
||||
if (records.length === 0) throw new Error('DNS lookup returned no addresses');
|
||||
for (const record of records) {
|
||||
if (isPrivateOrReservedIp(record.address)) {
|
||||
throw new Error('URL resolves to a private or reserved address');
|
||||
}
|
||||
}
|
||||
return records[0];
|
||||
}
|
||||
|
||||
interface PinnedResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: { get(name: string): string | null };
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
// Issues the request against the exact address validated by resolveValidatedAddress,
|
||||
// bypassing a second DNS resolution at connect time — otherwise a rebinding attacker
|
||||
// could serve a public address to the validator and a private one to the real connect.
|
||||
function requestPinnedAddress(url: URL, address: string, family: number): Promise<PinnedResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
||||
const req = requestFn(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
servername: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method: 'GET',
|
||||
timeout: 15000,
|
||||
lookup: (_hostname: string, _options: unknown, callback: (err: null, address: string, family: number) => void) => {
|
||||
callback(null, address, family);
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_IMAGE_BYTES) {
|
||||
req.destroy(new Error('Image too large (max 10MB)'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const status = res.statusCode ?? 0;
|
||||
resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: res.statusMessage ?? '',
|
||||
headers: {
|
||||
get: (name: string) => {
|
||||
const value = res.headers[name.toLowerCase()];
|
||||
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
||||
}
|
||||
},
|
||||
arrayBuffer: async () =>
|
||||
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer
|
||||
});
|
||||
});
|
||||
res.on('error', reject);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Fetches an attacker-supplied URL for the MCP upload_image tool, re-validating the
|
||||
// target on every redirect hop so a public URL can't SSRF via a 3xx to an internal address.
|
||||
async function fetchPublicImage(imageUrl: string): Promise<PinnedResponse> {
|
||||
let currentUrl = new URL(imageUrl);
|
||||
for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop++) {
|
||||
if (currentUrl.protocol !== 'http:' && currentUrl.protocol !== 'https:') {
|
||||
throw new Error('Only http/https URLs are allowed');
|
||||
}
|
||||
const { address, family } = await resolveValidatedAddress(currentUrl.hostname);
|
||||
const response = await requestPinnedAddress(currentUrl, address, family);
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) return response;
|
||||
currentUrl = new URL(location, currentUrl);
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
throw new Error('Too many redirects');
|
||||
}
|
||||
|
||||
export function createMcpServer(userId: number): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'crumbs',
|
||||
@@ -370,8 +239,8 @@ export function createMcpServer(userId: number): McpServer {
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'pin_note',
|
||||
'Pin or unpin a note',
|
||||
'favorite_note',
|
||||
'Favorite or unfavorite a note',
|
||||
{
|
||||
id: z.string().describe('Note ID'),
|
||||
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
|
||||
@@ -388,7 +257,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Note "${result.title}" ${pinned ? 'pinned' : 'unpinned'}`
|
||||
text: `Note "${result.title}" ${pinned ? 'favorited' : 'unfavorited'}`
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -433,7 +302,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchPublicImage(imageUrl);
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
content: [
|
||||
@@ -446,30 +315,8 @@ export function createMcpServer(userId: number): McpServer {
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'URL did not return an image' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length'));
|
||||
if (contentLength > MAX_IMAGE_BYTES) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/png';
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.byteLength > MAX_IMAGE_BYTES) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
|
||||
const filename = `upload_${Date.now()}.${ext}`;
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteLinks, noteCollaborators, users } from './db/schema.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
|
||||
|
||||
let db: Db;
|
||||
const OWNER_ID = 1;
|
||||
const OTHER_USER_ID = 2;
|
||||
|
||||
function seedUser(id: number, email: string) {
|
||||
db.insert(users)
|
||||
.values({ id, email, displayName: email, role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string, userId: number, title = '') {
|
||||
db.insert(notes)
|
||||
.values({ id, userId, title, content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUser(OWNER_ID, 'owner@test.com');
|
||||
seedUser(OTHER_USER_ID, 'other@test.com');
|
||||
});
|
||||
|
||||
describe('syncNoteLinks', () => {
|
||||
it('should create link rows for each target', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
seedNote('target-2', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1', 'target-2']);
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should remove old links and add new ones on re-sync', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
seedNote('target-2', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1']);
|
||||
syncNoteLinks(db, 'source', ['target-2']);
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toEqual([{ sourceNoteId: 'source', targetNoteId: 'target-2' }]);
|
||||
});
|
||||
|
||||
it('should handle an empty target list (removes all links)', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1']);
|
||||
syncNoteLinks(db, 'source', []);
|
||||
|
||||
expect(db.select().from(noteLinks).all()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchBacklinksForNote', () => {
|
||||
it('should return notes that link to the given note', () => {
|
||||
seedNote('source', OWNER_ID, 'Source Note');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
|
||||
const backlinks = fetchBacklinksForNote(db, 'target', OWNER_ID);
|
||||
expect(backlinks).toEqual([{ id: 'source', title: 'Source Note' }]);
|
||||
});
|
||||
|
||||
it('should return an empty array when nothing links to the note', () => {
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should exclude a backlink from a source note the requesting user cannot access', () => {
|
||||
seedNote('source', OTHER_USER_ID, 'Other Users Note');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include a backlink from a source note shared with the requesting user', () => {
|
||||
seedNote('source', OTHER_USER_ID, 'Shared Source');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'source', userId: OWNER_ID, addedBy: OTHER_USER_ID, addedAt: new Date() })
|
||||
.run();
|
||||
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([{ id: 'source', title: 'Shared Source' }]);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteLinks, notes } from './db/schema.js';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
|
||||
/**
|
||||
* Sync link rows for a note: removes old associations, inserts the current set.
|
||||
* Mirrors syncNoteTags — called right after a note's content is saved.
|
||||
*/
|
||||
export function syncNoteLinks(db: Db, noteId: string, targetIds: string[]): void {
|
||||
db.delete(noteLinks).where(eq(noteLinks.sourceNoteId, noteId)).run();
|
||||
|
||||
for (const targetId of targetIds) {
|
||||
db.insert(noteLinks).values({ sourceNoteId: noteId, targetNoteId: targetId }).run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find notes that link to the given note, scoped to what the requesting user
|
||||
* can access — this both respects normal sharing boundaries and, as a side
|
||||
* effect, prevents an unrelated note from appearing in someone's backlinks
|
||||
* just because it happens to reference a note ID it was never shared to see.
|
||||
*/
|
||||
export function fetchBacklinksForNote(
|
||||
db: Db,
|
||||
noteId: string,
|
||||
userId: number
|
||||
): { id: string; title: string }[] {
|
||||
const linkRows = db
|
||||
.select({ sourceNoteId: noteLinks.sourceNoteId })
|
||||
.from(noteLinks)
|
||||
.where(eq(noteLinks.targetNoteId, noteId))
|
||||
.all();
|
||||
|
||||
if (linkRows.length === 0) return [];
|
||||
|
||||
const sourceIds = linkRows.map((r) => r.sourceNoteId);
|
||||
const accessibleIds = sourceIds.filter((id) => canAccessNote(db, id, userId).canAccess);
|
||||
if (accessibleIds.length === 0) return [];
|
||||
|
||||
const sourceNotes = db
|
||||
.select({ id: notes.id, title: notes.title })
|
||||
.from(notes)
|
||||
.where(inArray(notes.id, accessibleIds))
|
||||
.all();
|
||||
|
||||
return sourceNotes;
|
||||
}
|
||||
@@ -263,46 +263,6 @@ describe('updateNote', () => {
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
|
||||
expect(updated!.tags).toContain('newtag');
|
||||
});
|
||||
|
||||
it('should merge concurrent content edits using the snapshot created by the first update', () => {
|
||||
seedNote('n1', {
|
||||
title: 'Shopping',
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true,
|
||||
version: 1
|
||||
});
|
||||
shareNote('n1');
|
||||
|
||||
const ownerUpdate = updateNote(db, OWNER_ID, 'n1', {
|
||||
content: '- [ ] Milk\n- [x] Bread',
|
||||
baseVersion: 1
|
||||
});
|
||||
expect(ownerUpdate!.version).toBe(2);
|
||||
|
||||
const collabUpdate = updateNote(db, COLLAB_ID, 'n1', {
|
||||
content: '- [x] Milk\n- [ ] Bread',
|
||||
baseVersion: 1
|
||||
});
|
||||
|
||||
expect(collabUpdate!.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep the incoming edit when a metadata-only bump left no base snapshot', () => {
|
||||
seedNote('n1', { title: 'Note', content: 'hello', version: 1 });
|
||||
|
||||
// A metadata-only change bumps the version but creates no snapshot.
|
||||
updateNote(db, OWNER_ID, 'n1', { pinned: true });
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(0);
|
||||
|
||||
// A later concurrent content edit based on the pre-bump version therefore has
|
||||
// no base to merge against — the incoming edit must be preserved, not dropped.
|
||||
const edited = updateNote(db, OWNER_ID, 'n1', { content: 'hello world', baseVersion: 1 });
|
||||
|
||||
expect(edited!.content).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNote', () => {
|
||||
@@ -374,45 +334,3 @@ describe('reorderNotes', () => {
|
||||
expect(collabView!.sortOrder).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('note links', () => {
|
||||
it('should sync note-links when a note is created with a reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, {
|
||||
title: 'Source',
|
||||
content: `See [Target](crumb-note://${target.id})`
|
||||
});
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
|
||||
});
|
||||
|
||||
it('should sync note-links when a note is updated with a reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, { title: 'Source', content: '' });
|
||||
|
||||
updateNote(db, OWNER_ID, source.id, { content: `See [Target](crumb-note://${target.id})` });
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
|
||||
});
|
||||
|
||||
it('should remove a backlink when the referencing note is edited to drop the reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, {
|
||||
title: 'Source',
|
||||
content: `See [Target](crumb-note://${target.id})`
|
||||
});
|
||||
|
||||
updateNote(db, OWNER_ID, source.id, { content: 'No longer references anything' });
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty backlinks array for a note nothing links to', () => {
|
||||
const note = createNote(db, OWNER_ID, { title: 'Lonely note' });
|
||||
const reopened = getNote(db, OWNER_ID, note.id);
|
||||
expect(reopened?.backlinks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
|
||||
import { eq, and, like, or, inArray, sql } from 'drizzle-orm';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState, noteVersions } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray, sql, lte } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { extractNoteLinks } from '$lib/utils/note-links.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
|
||||
import { fetchAttachmentsForNotes } from './attachments.js';
|
||||
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||
import { fetchSharesForNotes } from './shares-service.js';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
import { createSnapshot, getBaseContent } from './versions-service.js';
|
||||
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
|
||||
import { createSnapshot } from './versions-service.js';
|
||||
import { mergeContent } from '$lib/utils/content-merge.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
interface NoteRow {
|
||||
@@ -36,7 +33,6 @@ function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
|
||||
const tagMap = fetchTagsForNotes(db, noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
|
||||
const collabMap = fetchCollaboratorsForNotes(db, noteIds);
|
||||
const shareMap = fetchSharesForNotes(db, noteIds);
|
||||
|
||||
return noteRows.map((note) => {
|
||||
const collaborators = collabMap.get(note.id) ?? [];
|
||||
@@ -48,8 +44,7 @@ function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
|
||||
attachments: attachmentMap.get(note.id) ?? [],
|
||||
collaborators,
|
||||
isOwner,
|
||||
isShared,
|
||||
shareToken: shareMap.get(note.id) ?? undefined
|
||||
isShared
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -157,10 +152,7 @@ export function getNote(db: Db, userId: number, id: string) {
|
||||
}
|
||||
|
||||
const result = hydrateNotes(db, [effectiveNote], userId);
|
||||
const hydrated = result[0];
|
||||
if (!hydrated) return null;
|
||||
|
||||
return { ...hydrated, backlinks: fetchBacklinksForNote(db, id, userId) };
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
export interface CreateNoteInput {
|
||||
@@ -202,9 +194,6 @@ export function createNote(db: Db, userId: number, input: CreateNoteInput) {
|
||||
});
|
||||
syncNoteTags(db, id, extractedTags, userId);
|
||||
|
||||
const extractedLinks = extractNoteLinks(content);
|
||||
syncNoteLinks(db, id, extractedLinks);
|
||||
|
||||
return { ...newNote, tags: extractedTags };
|
||||
}
|
||||
|
||||
@@ -246,18 +235,30 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
|
||||
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
|
||||
if (input.content !== undefined) {
|
||||
// Preserve concurrent edits at line/block granularity when the client
|
||||
// saved from an older note version.
|
||||
const hasConcurrentEdit = input.baseVersion !== undefined
|
||||
&& input.baseVersion < existing.version;
|
||||
// 3-way merge to preserve concurrent edits from other users
|
||||
if (input.content !== existing.content) {
|
||||
const baseSnapshot = input.baseVersion !== undefined
|
||||
? db.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, id), lte(noteVersions.version, input.baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get()
|
||||
: db.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, id))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
sharedUpdates.content = hasConcurrentEdit
|
||||
? mergeContentUpdate({
|
||||
baseContent: getBaseContent(db, id, input.baseVersion),
|
||||
incomingContent: input.content,
|
||||
currentContent: existing.content
|
||||
})
|
||||
: input.content;
|
||||
if (baseSnapshot && baseSnapshot.content !== existing.content) {
|
||||
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
|
||||
} else {
|
||||
sharedUpdates.content = input.content;
|
||||
}
|
||||
} else {
|
||||
sharedUpdates.content = input.content;
|
||||
}
|
||||
hasSharedUpdates = true;
|
||||
}
|
||||
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
|
||||
@@ -275,22 +276,6 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
|
||||
}
|
||||
|
||||
const hasActualContentChange =
|
||||
(input.title !== undefined && input.title !== existing.title) ||
|
||||
(sharedUpdates.content !== undefined && sharedUpdates.content !== existing.content) ||
|
||||
(input.color !== undefined && input.color !== existing.color) ||
|
||||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
createSnapshot(db, id, {
|
||||
version: existing.version,
|
||||
title: existing.title,
|
||||
content: existing.content,
|
||||
checklistMode: existing.checklistMode,
|
||||
color: existing.color
|
||||
});
|
||||
}
|
||||
|
||||
if (hasSharedUpdates || isOwner) {
|
||||
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
|
||||
}
|
||||
@@ -334,11 +319,26 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
const content = `${updated.title} ${updated.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(db, id, extractedTags, existing.userId);
|
||||
const extractedLinks = extractNoteLinks(content);
|
||||
syncNoteLinks(db, id, extractedLinks);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a version snapshot only when at least one content field actually changed
|
||||
const hasActualContentChange =
|
||||
(input.title !== undefined && input.title !== existing.title) ||
|
||||
(input.content !== undefined && input.content !== existing.content) ||
|
||||
(input.color !== undefined && input.color !== existing.color) ||
|
||||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
createSnapshot(db, id, {
|
||||
version: existing.version,
|
||||
title: existing.title,
|
||||
content: existing.content,
|
||||
checklistMode: existing.checklistMode,
|
||||
color: existing.color
|
||||
});
|
||||
}
|
||||
|
||||
return getNote(db, userId, id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, users, attachments } from './db/schema.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import {
|
||||
fetchSharesForNotes,
|
||||
createShare,
|
||||
revokeShare,
|
||||
getShareByNoteId,
|
||||
getSharedNote
|
||||
} from './shares-service.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
const OWNER_ID = 1;
|
||||
|
||||
function seedUsers() {
|
||||
db.insert(users)
|
||||
.values([
|
||||
{
|
||||
email: 'owner@test.com',
|
||||
displayName: 'Owner',
|
||||
role: 'admin',
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
}
|
||||
])
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(
|
||||
id: string,
|
||||
overrides: Partial<typeof notes.$inferInsert> = {}
|
||||
) {
|
||||
const now = new Date();
|
||||
db.insert(notes)
|
||||
.values({
|
||||
id,
|
||||
userId: OWNER_ID,
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const result = createTestDb();
|
||||
db = result.db;
|
||||
seedUsers();
|
||||
});
|
||||
|
||||
describe('createShare', () => {
|
||||
it('creates a share and returns a token', () => {
|
||||
seedNote('note-1');
|
||||
const { token } = createShare(db, 'note-1', OWNER_ID);
|
||||
expect(token).toBeDefined();
|
||||
expect(token.length).toBe(21);
|
||||
});
|
||||
|
||||
it('returns the same token on duplicate creation (idempotent)', () => {
|
||||
seedNote('note-1');
|
||||
const first = createShare(db, 'note-1', OWNER_ID);
|
||||
const second = createShare(db, 'note-1', OWNER_ID);
|
||||
expect(first.token).toBe(second.token);
|
||||
});
|
||||
|
||||
it('throws 404 for non-existent note', () => {
|
||||
expect(() => createShare(db, 'nonexistent', OWNER_ID)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeShare', () => {
|
||||
it('removes the share token', () => {
|
||||
seedNote('note-1');
|
||||
createShare(db, 'note-1', OWNER_ID);
|
||||
revokeShare(db, 'note-1', OWNER_ID);
|
||||
const share = getShareByNoteId(db, 'note-1');
|
||||
expect(share).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShareByNoteId', () => {
|
||||
it('returns null when no share exists', () => {
|
||||
seedNote('note-1');
|
||||
expect(getShareByNoteId(db, 'note-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns share when one exists', () => {
|
||||
seedNote('note-1');
|
||||
const { token } = createShare(db, 'note-1', OWNER_ID);
|
||||
const share = getShareByNoteId(db, 'note-1');
|
||||
expect(share).not.toBeNull();
|
||||
expect(share!.token).toBe(token);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharedNote', () => {
|
||||
it('returns note data for valid token', () => {
|
||||
seedNote('note-1', { title: 'Shared Title', content: 'Shared content' });
|
||||
const { token } = createShare(db, 'note-1', OWNER_ID);
|
||||
|
||||
const data = getSharedNote(db, token);
|
||||
expect(data).not.toBeNull();
|
||||
expect(data!.title).toBe('Shared Title');
|
||||
expect(data!.content).toBe('Shared content');
|
||||
});
|
||||
|
||||
it('returns null for trashed note', () => {
|
||||
seedNote('note-1', { trashed: true });
|
||||
const { token } = createShare(db, 'note-1', OWNER_ID);
|
||||
|
||||
expect(getSharedNote(db, token)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid token', () => {
|
||||
expect(getSharedNote(db, 'invalid-token')).toBeNull();
|
||||
});
|
||||
|
||||
it('includes attachments', () => {
|
||||
seedNote('note-1');
|
||||
db.insert(attachments)
|
||||
.values({
|
||||
id: 'att-1',
|
||||
userId: OWNER_ID,
|
||||
noteId: 'note-1',
|
||||
filename: 'image.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1024,
|
||||
path: '/data/attachments/att-1.png',
|
||||
createdAt: new Date()
|
||||
})
|
||||
.run();
|
||||
const { token } = createShare(db, 'note-1', OWNER_ID);
|
||||
|
||||
const data = getSharedNote(db, token);
|
||||
expect(data!.attachments).toHaveLength(1);
|
||||
expect(data!.attachments[0].filename).toBe('image.png');
|
||||
// Verify server paths are not exposed
|
||||
expect(data!.attachments[0]).not.toHaveProperty('path');
|
||||
expect(data!.attachments[0]).not.toHaveProperty('thumbnailPath');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSharesForNotes', () => {
|
||||
it('returns empty map for no notes', () => {
|
||||
const result = fetchSharesForNotes(db, []);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns tokens for shared notes', () => {
|
||||
seedNote('note-1');
|
||||
seedNote('note-2');
|
||||
seedNote('note-3');
|
||||
const { token: token1 } = createShare(db, 'note-1', OWNER_ID);
|
||||
const { token: token2 } = createShare(db, 'note-2', OWNER_ID);
|
||||
|
||||
const result = fetchSharesForNotes(db, ['note-1', 'note-2', 'note-3']);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get('note-1')).toBe(token1);
|
||||
expect(result.get('note-2')).toBe(token2);
|
||||
expect(result.get('note-3')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { sharedNotes, notes, attachments } from './db/schema.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { requireNoteOwnership } from './api-utils.js';
|
||||
import type { SharedNoteData } from '$lib/types/index.js';
|
||||
|
||||
/**
|
||||
* Batch fetch share tokens for multiple notes.
|
||||
* Returns a Map of noteId → token.
|
||||
*/
|
||||
export function fetchSharesForNotes(db: Db, noteIds: string[]): Map<string, string> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
.select({ noteId: sharedNotes.noteId, token: sharedNotes.token })
|
||||
.from(sharedNotes)
|
||||
.where(inArray(sharedNotes.noteId, noteIds))
|
||||
.all();
|
||||
|
||||
const map = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
map.set(row.noteId, row.token);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a public share link for a note. Idempotent — returns existing token if already shared.
|
||||
*/
|
||||
export function createShare(db: Db, noteId: string, userId: number): { token: string } {
|
||||
requireNoteOwnership(db, noteId, userId);
|
||||
|
||||
const existing = db
|
||||
.select({ token: sharedNotes.token })
|
||||
.from(sharedNotes)
|
||||
.where(eq(sharedNotes.noteId, noteId))
|
||||
.get();
|
||||
|
||||
if (existing) return { token: existing.token };
|
||||
|
||||
const token = nanoid(21);
|
||||
db.insert(sharedNotes)
|
||||
.values({ noteId, token, createdAt: new Date() })
|
||||
.run();
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a public share link for a note.
|
||||
*/
|
||||
export function revokeShare(db: Db, noteId: string, userId: number): void {
|
||||
requireNoteOwnership(db, noteId, userId);
|
||||
|
||||
db.delete(sharedNotes)
|
||||
.where(eq(sharedNotes.noteId, noteId))
|
||||
.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get share info for a specific note.
|
||||
*/
|
||||
export function getShareByNoteId(db: Db, noteId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(sharedNotes)
|
||||
.where(eq(sharedNotes.noteId, noteId))
|
||||
.get() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a shared note by its public token. Returns null if note is trashed or token is invalid.
|
||||
*/
|
||||
export function getSharedNote(db: Db, token: string): (SharedNoteData & { noteId: string }) | null {
|
||||
const share = db
|
||||
.select()
|
||||
.from(sharedNotes)
|
||||
.where(eq(sharedNotes.token, token))
|
||||
.get();
|
||||
|
||||
if (!share) return null;
|
||||
|
||||
const note = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, share.noteId), eq(notes.trashed, false)))
|
||||
.get();
|
||||
|
||||
if (!note) return null;
|
||||
|
||||
const noteAttachments = db
|
||||
.select()
|
||||
.from(attachments)
|
||||
.where(eq(attachments.noteId, share.noteId))
|
||||
.all();
|
||||
|
||||
const publicAttachments = noteAttachments.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
featured: a.featured,
|
||||
createdAt: a.createdAt
|
||||
}));
|
||||
|
||||
return {
|
||||
noteId: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
checklistMode: note.checklistMode,
|
||||
color: note.color as SharedNoteData['color'],
|
||||
attachments: publicAttachments,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
|
||||
import { users, notes, noteVersions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote, getBaseContent } from './versions-service.js';
|
||||
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote } from './versions-service.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
@@ -44,32 +44,6 @@ beforeEach(() => {
|
||||
seedUser();
|
||||
});
|
||||
|
||||
describe('getBaseContent', () => {
|
||||
it('returns null when the base version is unknown', () => {
|
||||
seedNote();
|
||||
expect(getBaseContent(db, NOTE_ID, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the snapshot content at or before the base version', () => {
|
||||
seedNote();
|
||||
createSnapshot(db, NOTE_ID, { version: 1, title: 'T', content: 'v1', checklistMode: false, color: 'default' });
|
||||
createSnapshot(db, NOTE_ID, { version: 3, title: 'T', content: 'v3', checklistMode: false, color: 'default' });
|
||||
|
||||
expect(getBaseContent(db, NOTE_ID, 2)).toBe('v1');
|
||||
expect(getBaseContent(db, NOTE_ID, 3)).toBe('v3');
|
||||
});
|
||||
|
||||
it('returns null instead of a newer snapshot when none exists at or before the base version', () => {
|
||||
seedNote();
|
||||
// Only a snapshot NEWER than the requested base exists (e.g. the real base was
|
||||
// pruned). Using it as the merge base would treat unseen lines as deletions,
|
||||
// so getBaseContent must return null rather than fall back to it.
|
||||
createSnapshot(db, NOTE_ID, { version: 5, title: 'T', content: 'v5', checklistMode: false, color: 'default' });
|
||||
|
||||
expect(getBaseContent(db, NOTE_ID, 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSnapshot', () => {
|
||||
it('should store a snapshot for a note', () => {
|
||||
seedNote();
|
||||
|
||||
@@ -1,46 +1,18 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteVersions, notes } from './db/schema.js';
|
||||
import { eq, and, desc, lte } from 'drizzle-orm';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { NoteVersion, NoteVersionSummary, NoteColor } from '$lib/types/index.js';
|
||||
|
||||
const MAX_VERSIONS_PER_NOTE = 50;
|
||||
const CONTENT_PREVIEW_LENGTH = 80;
|
||||
|
||||
export type Transaction = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
export type SnapshotDb = Db | Transaction;
|
||||
|
||||
/**
|
||||
* Resolve the 3-way merge base for a concurrent edit: the content of the snapshot
|
||||
* at or before the client's base version. Returns null when the base version is
|
||||
* unknown or no snapshot at/before it exists. Callers must NOT substitute a newer
|
||||
* snapshot as the base — a base newer than what the client actually saw makes the
|
||||
* merge treat other users' lines as deletions and silently drop them.
|
||||
*/
|
||||
export function getBaseContent(
|
||||
db: SnapshotDb,
|
||||
noteId: string,
|
||||
baseVersion: number | undefined
|
||||
): string | null {
|
||||
if (baseVersion === undefined) return null;
|
||||
|
||||
const snapshot = db
|
||||
.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
return snapshot?.content ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot of a note's current state.
|
||||
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
|
||||
*/
|
||||
export function createSnapshot(
|
||||
db: SnapshotDb,
|
||||
db: Db,
|
||||
noteId: string,
|
||||
snapshot: {
|
||||
version: number;
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeNotesByVersion, reconcileSearchResults } from './notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
function makeNote(overrides: Partial<Note> = {}): Note {
|
||||
return {
|
||||
id: 'note-1',
|
||||
title: 'Test',
|
||||
content: '- [ ] Item',
|
||||
color: 'default',
|
||||
pinned: false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
checklistMode: true,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
version: 1,
|
||||
tags: [],
|
||||
attachments: [],
|
||||
collaborators: [],
|
||||
isOwner: true,
|
||||
isShared: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergeNotesByVersion', () => {
|
||||
it('keeps the local note when it has a higher version than the incoming note', () => {
|
||||
const local = makeNote({ id: 'n1', version: 3, content: '- [x] Checked' });
|
||||
const incoming = makeNote({ id: 'n1', version: 2, content: '- [ ] Unchecked' });
|
||||
|
||||
const result = mergeNotesByVersion([local], [incoming]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe('- [x] Checked');
|
||||
expect(result[0].version).toBe(3);
|
||||
});
|
||||
|
||||
it('uses the incoming note when it has a higher or equal version', () => {
|
||||
const local = makeNote({ id: 'n1', version: 2, content: '- [ ] Old' });
|
||||
const incoming = makeNote({ id: 'n1', version: 3, content: '- [x] New' });
|
||||
|
||||
const result = mergeNotesByVersion([local], [incoming]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe('- [x] New');
|
||||
expect(result[0].version).toBe(3);
|
||||
});
|
||||
|
||||
it('uses the incoming note when versions are equal', () => {
|
||||
const local = makeNote({ id: 'n1', version: 2, content: 'local' });
|
||||
const incoming = makeNote({ id: 'n1', version: 2, content: 'incoming' });
|
||||
|
||||
const result = mergeNotesByVersion([local], [incoming]);
|
||||
|
||||
expect(result[0].content).toBe('incoming');
|
||||
});
|
||||
|
||||
it('handles notes not present in the local store', () => {
|
||||
const incoming = makeNote({ id: 'n2', version: 1 });
|
||||
|
||||
const result = mergeNotesByVersion([], [incoming]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('n2');
|
||||
});
|
||||
|
||||
it('preserves local-only notes not present in the incoming data', () => {
|
||||
const localOnly = makeNote({ id: 'n1', version: 1, content: 'just created' });
|
||||
const serverNote = makeNote({ id: 'n2', version: 1, content: 'from server' });
|
||||
|
||||
const result = mergeNotesByVersion([localOnly], [serverNote]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.find((n) => n.id === 'n1')?.content).toBe('just created');
|
||||
expect(result.find((n) => n.id === 'n2')?.content).toBe('from server');
|
||||
});
|
||||
|
||||
it('preserves multiple notes correctly during a race condition', () => {
|
||||
const localNotes = [
|
||||
makeNote({ id: 'n1', version: 5, content: '- [x] Just saved' }),
|
||||
makeNote({ id: 'n2', version: 1, content: 'unchanged' })
|
||||
];
|
||||
const incomingNotes = [
|
||||
makeNote({ id: 'n1', version: 4, content: '- [ ] Stale from server' }),
|
||||
makeNote({ id: 'n2', version: 2, content: 'updated on server' })
|
||||
];
|
||||
|
||||
const result = mergeNotesByVersion(localNotes, incomingNotes);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// n1: local version 5 > incoming version 4 → keep local
|
||||
expect(result[0].content).toBe('- [x] Just saved');
|
||||
// n2: local version 1 < incoming version 2 → use incoming
|
||||
expect(result[1].content).toBe('updated on server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileSearchResults', () => {
|
||||
it('replaces a stale result with the canonical note', () => {
|
||||
const canonical = makeNote({ id: 'n1', title: 'Updated', version: 2 });
|
||||
|
||||
const result = reconcileSearchResults(
|
||||
[canonical],
|
||||
[makeNote({ id: 'n1', title: 'Stale', version: 1 })]
|
||||
);
|
||||
|
||||
expect(result).toEqual([canonical]);
|
||||
});
|
||||
|
||||
it('keeps a result absent from the canonical store (hit not yet loaded locally)', () => {
|
||||
const serverHit = makeNote({ id: 'n2', title: 'From another device' });
|
||||
|
||||
const result = reconcileSearchResults([makeNote({ id: 'n1' })], [serverHit]);
|
||||
|
||||
expect(result).toEqual([serverHit]);
|
||||
});
|
||||
|
||||
it('drops a result that is trashed in the canonical store', () => {
|
||||
const trashed = makeNote({ id: 'n1', trashed: true });
|
||||
|
||||
const result = reconcileSearchResults([trashed], [makeNote({ id: 'n1', trashed: false })]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves result order while reconciling a mix of cases', () => {
|
||||
const canonical = makeNote({ id: 'n1', title: 'Canonical', version: 2 });
|
||||
const trashed = makeNote({ id: 'n2', trashed: true });
|
||||
const absent = makeNote({ id: 'n3', title: 'Absent' });
|
||||
|
||||
const result = reconcileSearchResults(
|
||||
[canonical, trashed],
|
||||
[makeNote({ id: 'n1', title: 'Stale', version: 1 }), trashed, absent]
|
||||
);
|
||||
|
||||
expect(result).toEqual([canonical, absent]);
|
||||
});
|
||||
|
||||
it('returns an empty array when there are no results', () => {
|
||||
expect(reconcileSearchResults([makeNote({ id: 'n1' })], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
+8
-64
@@ -8,41 +8,11 @@ export const notes = writable<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('all');
|
||||
export const selectedTag = writable<string | null>(null);
|
||||
export const notesLoaded = writable(false);
|
||||
export const searchQuery = writable<string>('');
|
||||
export const searchResults = writable<Note[]>([]);
|
||||
|
||||
/**
|
||||
* Reconcile search results against the canonical notes store so local edits and
|
||||
* trashing propagate into the active result set without re-querying the server.
|
||||
*
|
||||
* A result absent from the canonical store is kept as-is — it's a server hit not
|
||||
* yet loaded locally (e.g. created on another device), and dropping it would make
|
||||
* valid matches disappear mid-session. Notes removed locally (deleted or left) are
|
||||
* pruned explicitly at their call sites; trashed notes are dropped here.
|
||||
*/
|
||||
export function reconcileSearchResults(canonicalNotes: Note[], results: Note[]): Note[] {
|
||||
const notesMap = new Map(canonicalNotes.map((note) => [note.id, note]));
|
||||
return results.flatMap((result) => {
|
||||
const canonical = notesMap.get(result.id);
|
||||
if (!canonical) return [result];
|
||||
return canonical.trashed ? [] : [canonical];
|
||||
});
|
||||
}
|
||||
|
||||
// Keep searchResults in sync with edits/trashing in the notes store. These search
|
||||
// stores (like the others in this module) are client-only state; the subscription
|
||||
// lives for the app's lifetime and is intentionally never unsubscribed.
|
||||
notes.subscribe(($notes) => {
|
||||
searchResults.update(($results) => {
|
||||
if ($results.length === 0) return $results;
|
||||
return reconcileSearchResults($notes, $results);
|
||||
});
|
||||
});
|
||||
|
||||
export const filteredNotes = derived(
|
||||
[notes, selectedTag, currentFilter, searchQuery, searchResults],
|
||||
([$notes, $selectedTag, $filter, $searchQuery, $searchResults]) => {
|
||||
let result = $searchQuery.trim() ? $searchResults : $notes;
|
||||
[notes, selectedTag, currentFilter],
|
||||
([$notes, $selectedTag, $filter]) => {
|
||||
let result = $notes;
|
||||
if ($filter === 'all') {
|
||||
result = result.filter((n) => !n.trashed && !n.archived);
|
||||
} else if ($filter === 'archived') {
|
||||
@@ -65,11 +35,11 @@ function sortNotes(list: Note[]): Note[] {
|
||||
});
|
||||
}
|
||||
|
||||
export const pinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
export const favoriteNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => n.pinned))
|
||||
);
|
||||
|
||||
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
export const unfavoritedNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => !n.pinned))
|
||||
);
|
||||
|
||||
@@ -83,35 +53,12 @@ function isNetworkError(err: unknown): boolean {
|
||||
return err instanceof TypeError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge incoming notes with current store, keeping the higher-version note
|
||||
* when a conflict exists. This prevents a stale loadNotes response from
|
||||
* overwriting a just-saved note (race between sync interval and updateNote).
|
||||
*/
|
||||
export function mergeNotesByVersion(current: Note[], incoming: Note[]): Note[] {
|
||||
const currentMap = new Map(current.map((n) => [n.id, n]));
|
||||
const incomingIds = new Set<string>();
|
||||
const merged = incoming.map((incomingNote) => {
|
||||
incomingIds.add(incomingNote.id);
|
||||
const local = currentMap.get(incomingNote.id);
|
||||
if (local && local.version > incomingNote.version) return local;
|
||||
return incomingNote;
|
||||
});
|
||||
// Preserve local-only notes (e.g. just created, not yet in server/IDB response)
|
||||
for (const local of current) {
|
||||
if (!incomingIds.has(local.id)) {
|
||||
merged.push(local);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
// Load from IDB first for instant display
|
||||
try {
|
||||
const cached = await getAllNotes();
|
||||
if (cached.length > 0) {
|
||||
notes.update((current) => current.length > 0 ? mergeNotesByVersion(current, cached) : cached);
|
||||
notes.set(cached);
|
||||
}
|
||||
} catch {
|
||||
// IDB unavailable — continue with server fetch
|
||||
@@ -124,7 +71,7 @@ export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
const res = await fetch(`/api/notes?filter=${filter}`);
|
||||
if (res.ok) {
|
||||
const data: Note[] = await res.json();
|
||||
notes.update((current) => mergeNotesByVersion(current, data));
|
||||
notes.set(data);
|
||||
currentFilter.set(filter);
|
||||
|
||||
// Update IDB with server state
|
||||
@@ -248,7 +195,6 @@ export async function deleteNote(id: string): Promise<boolean> {
|
||||
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
@@ -256,7 +202,6 @@ export async function deleteNote(id: string): Promise<boolean> {
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
@@ -286,7 +231,7 @@ export async function unarchiveNote(id: string): Promise<Note | null> {
|
||||
return updateNote(id, { archived: false });
|
||||
}
|
||||
|
||||
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
export async function toggleFavorite(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
|
||||
@@ -295,7 +240,6 @@ export async function leaveNote(id: string): Promise<boolean> {
|
||||
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.j
|
||||
describe('preferences defaults', () => {
|
||||
it('should have correct default values', () => {
|
||||
expect(DEFAULT_PREFERENCES).toEqual({
|
||||
theme: 'system',
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from '../server/db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users, syncLog, noteVersions } from '../server/db/schema.js';
|
||||
import { notes, noteCollaborators, noteUserState, users, syncLog } from '../server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from '../server/db/schema.js';
|
||||
@@ -289,64 +289,6 @@ describe('processSyncPush — shared notes', () => {
|
||||
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
});
|
||||
|
||||
it('should create a base snapshot during sync so later queued edits can merge', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', {
|
||||
updatedAt: t1,
|
||||
createdAt: t1,
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true,
|
||||
version: 1
|
||||
});
|
||||
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1.getTime() + 1000,
|
||||
data: { content: '- [ ] Milk\n- [x] Bread' },
|
||||
baseVersion: 1
|
||||
}], OWNER_ID);
|
||||
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1.getTime() + 2000,
|
||||
data: { content: '- [x] Milk\n- [ ] Bread' },
|
||||
baseVersion: 1
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should retain at most 50 snapshots created by sync updates', async () => {
|
||||
const createdAt = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', {
|
||||
updatedAt: createdAt,
|
||||
createdAt,
|
||||
content: 'version 1',
|
||||
version: 1
|
||||
});
|
||||
|
||||
for (let version = 2; version <= 52; version++) {
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: createdAt.getTime() + version,
|
||||
data: { content: `version ${version}` },
|
||||
baseVersion: version - 1
|
||||
}], OWNER_ID);
|
||||
}
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(50);
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(false);
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 51)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow non-collaborator to update the note', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
// Create note without sharing with user 3
|
||||
|
||||
+38
-30
@@ -1,12 +1,37 @@
|
||||
import type { Db } from '$lib/server/db/index.js';
|
||||
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, sql } from 'drizzle-orm';
|
||||
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql, desc, lte } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { syncNoteTags } from '$lib/server/tags.js';
|
||||
import { createSnapshot, getBaseContent } from '$lib/server/versions-service.js';
|
||||
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
|
||||
import { canAccessNote } from '$lib/server/api-utils.js';
|
||||
import { mergeContent } from '$lib/utils/content-merge.js';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
|
||||
/**
|
||||
* Find the base content for 3-way merge from version snapshots.
|
||||
* Returns the content from the snapshot at or before the given version,
|
||||
* or the current note content if no snapshot is found.
|
||||
*/
|
||||
function getBaseContent(tx: Parameters<Parameters<Db['transaction']>[0]>[0], noteId: string, baseVersion?: number): string | null {
|
||||
if (baseVersion !== undefined) {
|
||||
const snapshot = tx.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (snapshot) return snapshot.content;
|
||||
}
|
||||
// Fallback: get the most recent snapshot before the current version
|
||||
const latest = tx.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, noteId))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
return latest?.content ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
@@ -44,17 +69,16 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
|
||||
if (change.data.color !== undefined) sharedData.color = change.data.color;
|
||||
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
|
||||
|
||||
// Content: merge only when the client saved from an older note version.
|
||||
// Content: 3-way merge to preserve concurrent edits from other users
|
||||
if (change.data.content !== undefined && change.data.content !== note.content) {
|
||||
const hasConcurrentEdit = change.baseVersion !== undefined
|
||||
&& change.baseVersion < note.version;
|
||||
sharedData.content = hasConcurrentEdit
|
||||
? mergeContentUpdate({
|
||||
baseContent: getBaseContent(tx, change.noteId, change.baseVersion),
|
||||
incomingContent: change.data.content,
|
||||
currentContent: note.content
|
||||
})
|
||||
: change.data.content;
|
||||
const baseContent = getBaseContent(tx, change.noteId, change.baseVersion);
|
||||
if (baseContent !== null && baseContent !== note.content) {
|
||||
// 3-way merge: base, incoming (local), server current (remote)
|
||||
sharedData.content = mergeContent(baseContent, change.data.content, note.content);
|
||||
} else {
|
||||
// No base or base matches server — take incoming content
|
||||
sharedData.content = change.data.content;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOwner) {
|
||||
@@ -68,22 +92,6 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
|
||||
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
|
||||
}
|
||||
|
||||
const hasActualContentChange =
|
||||
(change.data.title !== undefined && change.data.title !== note.title) ||
|
||||
(sharedData.content !== undefined && sharedData.content !== note.content) ||
|
||||
(change.data.color !== undefined && change.data.color !== note.color) ||
|
||||
(change.data.checklistMode !== undefined && change.data.checklistMode !== note.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
createSnapshot(tx, change.noteId, {
|
||||
version: note.version,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
checklistMode: note.checklistMode,
|
||||
color: note.color
|
||||
});
|
||||
}
|
||||
|
||||
tx.update(notes)
|
||||
.set(sharedData)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
|
||||
@@ -17,13 +17,6 @@ export interface Note {
|
||||
collaborators?: Collaborator[];
|
||||
isOwner?: boolean;
|
||||
isShared?: boolean;
|
||||
shareToken?: string;
|
||||
backlinks?: NoteBacklink[];
|
||||
}
|
||||
|
||||
export interface NoteBacklink {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Collaborator {
|
||||
@@ -125,31 +118,5 @@ export interface NoteVersion {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface NoteShare {
|
||||
noteId: string;
|
||||
token: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
export interface PublicAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
featured: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SharedNoteData {
|
||||
title: string;
|
||||
content: string;
|
||||
checklistMode: boolean;
|
||||
color: NoteColor;
|
||||
attachments: PublicAttachment[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type { UserPreferences } from './preferences.js';
|
||||
export { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from './preferences.js';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { NoteColor } from './index.js';
|
||||
|
||||
export interface UserPreferences {
|
||||
theme: 'system' | 'light' | 'dark';
|
||||
defaultNoteMode: 'richtext' | 'markdown';
|
||||
defaultNoteColor: NoteColor;
|
||||
hideFooter: boolean;
|
||||
@@ -12,7 +11,6 @@ export interface UserPreferences {
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
theme: 'system',
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { canResetPassword, passwordResetBlockedReason } from './auth-methods.js';
|
||||
|
||||
const NON_PASSWORD_PROVIDERS = ['none', 'google', 'github', 'oidc'];
|
||||
|
||||
describe('canResetPassword', () => {
|
||||
it('allows a reset for a password account', () => {
|
||||
expect(canResetPassword({ authProvider: 'password' })).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks a reset for every non-password provider', () => {
|
||||
// verifyPassword() filters on authProvider === 'password', so a hash
|
||||
// written for any other provider could never be used to sign in.
|
||||
for (const authProvider of NON_PASSWORD_PROVIDERS) {
|
||||
expect(canResetPassword({ authProvider }), authProvider).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks a reset for an unenumerated provider', () => {
|
||||
expect(canResetPassword({ authProvider: 'saml' })).toBe(false);
|
||||
expect(canResetPassword({ authProvider: '' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('passwordResetBlockedReason', () => {
|
||||
it('returns null for a password account', () => {
|
||||
expect(passwordResetBlockedReason({ authProvider: 'password' })).toBeNull();
|
||||
});
|
||||
|
||||
it('names the provider in the reason', () => {
|
||||
expect(passwordResetBlockedReason({ authProvider: 'none' })).toBe(
|
||||
'This account has no password — it signs in via OAuth.'
|
||||
);
|
||||
expect(passwordResetBlockedReason({ authProvider: 'google' })).toBe(
|
||||
'Managed by Google — password login is disabled for this account.'
|
||||
);
|
||||
expect(passwordResetBlockedReason({ authProvider: 'github' })).toBe(
|
||||
'Managed by GitHub — password login is disabled for this account.'
|
||||
);
|
||||
expect(passwordResetBlockedReason({ authProvider: 'oidc' })).toBe(
|
||||
'Managed by SSO — password login is disabled for this account.'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to a generic reason for an unenumerated provider', () => {
|
||||
expect(passwordResetBlockedReason({ authProvider: 'saml' })).toBe(
|
||||
'Password login is not available for this account.'
|
||||
);
|
||||
});
|
||||
|
||||
it('always explains a blocked reset', () => {
|
||||
// authProvider is typed `string`, so no missing branch can be caught by
|
||||
// the compiler. A null reason here would render a disabled control with
|
||||
// no explanation — the confusion issue #77 reported.
|
||||
//
|
||||
// The Object.prototype keys are the interesting cases: with a plain object
|
||||
// literal as the lookup table they resolve to inherited members, so a
|
||||
// nullish fallback never fires and the function returns a function or an
|
||||
// object in place of its declared string. Asserting typeof rather than
|
||||
// truthiness is what catches that.
|
||||
const providers = [
|
||||
...NON_PASSWORD_PROVIDERS,
|
||||
'saml',
|
||||
'',
|
||||
'PASSWORD',
|
||||
'ldap',
|
||||
'unknown',
|
||||
'__proto__',
|
||||
'toString',
|
||||
'constructor',
|
||||
'hasOwnProperty',
|
||||
'valueOf'
|
||||
];
|
||||
for (const authProvider of providers) {
|
||||
const user = { authProvider };
|
||||
expect(canResetPassword(user), authProvider).toBe(false);
|
||||
const reason = passwordResetBlockedReason(user);
|
||||
expect(typeof reason, authProvider).toBe('string');
|
||||
expect(reason, authProvider).not.toBe('');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth-methods module', () => {
|
||||
it('does not use Math.random', () => {
|
||||
// The reset-eligibility rule must be pure and deterministic.
|
||||
const source = readFileSync(new URL('./auth-methods.ts', import.meta.url), 'utf8');
|
||||
expect(source).not.toContain('Math.random');
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { User } from '$lib/types/index.js';
|
||||
|
||||
/**
|
||||
* Human-readable reason a password reset is unavailable, keyed by auth provider.
|
||||
* Anything absent from this map falls back to BLOCKED_REASON_FALLBACK, so the
|
||||
* blocked path can never be left unexplained.
|
||||
*
|
||||
* A Map, not an object literal: an object inherits Object.prototype, so a lookup
|
||||
* for 'toString' or '__proto__' would resolve to an inherited member instead of
|
||||
* missing, and the fallback below would never fire. A Map has no such keys, so
|
||||
* totality holds by construction rather than by a guard that could be dropped.
|
||||
*/
|
||||
const BLOCKED_REASONS = new Map<string, string>([
|
||||
['none', 'This account has no password — it signs in via OAuth.'],
|
||||
['google', 'Managed by Google — password login is disabled for this account.'],
|
||||
['github', 'Managed by GitHub — password login is disabled for this account.'],
|
||||
['oidc', 'Managed by SSO — password login is disabled for this account.']
|
||||
]);
|
||||
|
||||
const BLOCKED_REASON_FALLBACK = 'Password login is not available for this account.';
|
||||
|
||||
/**
|
||||
* Whether an admin may set a new password for this account.
|
||||
*
|
||||
* Password login is gated on `authProvider` — verifyPassword() only matches rows
|
||||
* where it equals 'password' — so writing a hash for any other provider stores a
|
||||
* credential that can never be used to sign in.
|
||||
*/
|
||||
export function canResetPassword(user: Pick<User, 'authProvider'>): boolean {
|
||||
return user.authProvider === 'password';
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a password reset is blocked, or null when it is allowed.
|
||||
*
|
||||
* `authProvider` is typed `string`, so the compiler cannot enforce exhaustiveness
|
||||
* here. The fallback guarantees the invariant this module exists for: whenever
|
||||
* `canResetPassword` is false, this returns a non-empty explanation, so the UI
|
||||
* never shows a disabled control without saying why.
|
||||
*/
|
||||
export function passwordResetBlockedReason(user: Pick<User, 'authProvider'>): string | null {
|
||||
if (canResetPassword(user)) return null;
|
||||
return BLOCKED_REASONS.get(user.authProvider) ?? BLOCKED_REASON_FALLBACK;
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml, type ChecklistItem } from '$lib/utils/checklist.js';
|
||||
|
||||
describe('parseChecklist', () => {
|
||||
it('parses flat checklist items', () => {
|
||||
const items = parseChecklist('- [ ] Milk\n- [x] Eggs');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({ text: 'Milk', checked: false, parentId: null });
|
||||
expect(items[1]).toMatchObject({ text: 'Eggs', checked: true, parentId: null });
|
||||
});
|
||||
|
||||
it('parses nested children with 2-space prefix', () => {
|
||||
const items = parseChecklist('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0]).toMatchObject({ text: 'Groceries', checked: false, parentId: null });
|
||||
expect(items[1]).toMatchObject({ text: 'Milk', checked: false, parentId: items[0].id });
|
||||
expect(items[2]).toMatchObject({ text: 'Eggs', checked: true, parentId: items[0].id });
|
||||
});
|
||||
|
||||
it('treats orphaned children as top-level', () => {
|
||||
const items = parseChecklist(' - [ ] Orphan\n- [ ] Parent');
|
||||
expect(items[0]).toMatchObject({ text: 'Orphan', parentId: null });
|
||||
});
|
||||
|
||||
it('returns one empty item for blank content', () => {
|
||||
const items = parseChecklist('');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ text: '', checked: false, parentId: null });
|
||||
});
|
||||
|
||||
it('handles mixed top-level and nested items', () => {
|
||||
const content = '- [ ] A\n - [ ] A1\n - [ ] A2\n- [ ] B\n - [x] B1';
|
||||
const items = parseChecklist(content);
|
||||
expect(items).toHaveLength(5);
|
||||
expect(items[1].parentId).toBe(items[0].id);
|
||||
expect(items[2].parentId).toBe(items[0].id);
|
||||
expect(items[4].parentId).toBe(items[3].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeChecklist', () => {
|
||||
it('serializes flat items', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Milk', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Eggs', checked: true, parentId: null }
|
||||
];
|
||||
expect(serializeChecklist(items)).toBe('- [ ] Milk\n- [x] Eggs');
|
||||
});
|
||||
|
||||
it('serializes nested items with 2-space prefix', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Groceries', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Milk', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'Eggs', checked: true, parentId: '1' }
|
||||
];
|
||||
expect(serializeChecklist(items)).toBe('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
|
||||
});
|
||||
|
||||
it('groups children under parents regardless of array order', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '3', text: 'B', checked: false, parentId: null },
|
||||
{ id: '2', text: 'A1', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = serializeChecklist(items);
|
||||
expect(result).toBe('- [ ] A\n - [ ] A1\n- [ ] B');
|
||||
});
|
||||
|
||||
it('round-trips parse then serialize', () => {
|
||||
const original = '- [ ] Groceries\n - [ ] Milk\n - [x] Eggs\n- [ ] Clean';
|
||||
const items = parseChecklist(original);
|
||||
expect(serializeChecklist(items)).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleItemWithCascade', () => {
|
||||
it('checking a parent checks all children', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Child A', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'Child B', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('1', items);
|
||||
expect(result.every((i) => i.checked)).toBe(true);
|
||||
});
|
||||
|
||||
it('unchecking a parent unchecks all children', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: true, parentId: null },
|
||||
{ id: '2', text: 'Child A', checked: true, parentId: '1' },
|
||||
{ id: '3', text: 'Child B', checked: true, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('1', items);
|
||||
expect(result.every((i) => !i.checked)).toBe(true);
|
||||
});
|
||||
|
||||
it('checking a child does not affect parent', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Child', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('2', items);
|
||||
expect(result.find((i) => i.id === '1')!.checked).toBe(false);
|
||||
expect(result.find((i) => i.id === '2')!.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('unchecking a child with deleted parent orphans it', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '2', text: 'Orphan', checked: true, parentId: 'deleted-id' }
|
||||
];
|
||||
const result = toggleItemWithCascade('2', items);
|
||||
expect(result[0].checked).toBe(false);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('unchecking a child repositions it after its parent and siblings', () => {
|
||||
// Simulates: parent + 2 children, one child checked (at end of array as done item)
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: false, parentId: null },
|
||||
{ id: '3', text: 'Child B', checked: false, parentId: '1' },
|
||||
{ id: '4', text: 'Other', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Child A', checked: true, parentId: '1' } // done, at end
|
||||
];
|
||||
const result = toggleItemWithCascade('2', items);
|
||||
// Child A should be repositioned after Child B (last sibling), not stay at end
|
||||
const ids = result.map(i => i.id);
|
||||
expect(ids).toEqual(['1', '3', '2', '4']);
|
||||
expect(result.find(i => i.id === '2')!.checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('indentItem', () => {
|
||||
it('sets parentId to nearest preceding top-level item', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: null }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
|
||||
});
|
||||
|
||||
it('no-op if already a child', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result).toEqual(items);
|
||||
});
|
||||
|
||||
it('no-op if first item (no parent above)', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('1', items, active);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('reparents children to grandparent when indenting a parent', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: null },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '2' },
|
||||
{ id: '4', text: 'D', checked: false, parentId: '2' }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
|
||||
expect(result.find((i) => i.id === '4')!.parentId).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outdentItem', () => {
|
||||
it('sets parentId to null', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('no-op if already top-level', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null }
|
||||
];
|
||||
const result = outdentItem('1', items);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('adopts consecutive siblings below with same parentId', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '1' },
|
||||
{ id: '4', text: 'D', checked: false, parentId: '1' },
|
||||
{ id: '5', text: 'E', checked: false, parentId: null }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('2');
|
||||
expect(result.find((i) => i.id === '4')!.parentId).toBe('2');
|
||||
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('stops adoption at first non-sibling', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' },
|
||||
{ id: '5', text: 'E', checked: false, parentId: null },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkifyText', () => {
|
||||
it('converts https URLs to anchor tags', () => {
|
||||
expect(linkifyText('visit https://example.com today')).toBe(
|
||||
'visit <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a> today'
|
||||
);
|
||||
});
|
||||
|
||||
it('converts http URLs to anchor tags', () => {
|
||||
expect(linkifyText('go to http://example.com')).toBe(
|
||||
'go to <a href="http://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">http://example.com</a>'
|
||||
);
|
||||
});
|
||||
|
||||
it('converts bare www URLs with https href', () => {
|
||||
expect(linkifyText('check www.example.com')).toBe(
|
||||
'check <a href="https://www.example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">www.example.com</a>'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple URLs in one string', () => {
|
||||
const result = linkifyText('see https://a.com and https://b.com');
|
||||
expect(result).toContain('href="https://a.com"');
|
||||
expect(result).toContain('href="https://b.com"');
|
||||
});
|
||||
|
||||
it('returns plain text unchanged when no URLs present', () => {
|
||||
expect(linkifyText('just some text')).toBe('just some text');
|
||||
});
|
||||
|
||||
it('HTML-escapes user content to prevent XSS', () => {
|
||||
expect(linkifyText('<script>alert("xss")</script>')).toBe(
|
||||
'<script>alert("xss")</script>'
|
||||
);
|
||||
});
|
||||
|
||||
it('HTML-escapes text around URLs', () => {
|
||||
expect(linkifyText('<b>bold</b> https://example.com')).toContain('<b>bold</b>');
|
||||
expect(linkifyText('<b>bold</b> https://example.com')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('handles URL at start of string', () => {
|
||||
expect(linkifyText('https://example.com is great')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('handles URL at end of string', () => {
|
||||
expect(linkifyText('visit https://example.com')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('does not linkify partial matches without protocol or www', () => {
|
||||
expect(linkifyText('example.com is not linked')).toBe('example.com is not linked');
|
||||
});
|
||||
|
||||
it('handles URLs with paths and query strings', () => {
|
||||
const url = 'https://example.com/path?q=1&b=2';
|
||||
const result = linkifyText(url);
|
||||
expect(result).toContain('href="https://example.com/path?q=1&b=2"');
|
||||
});
|
||||
|
||||
it('strips trailing punctuation from URLs', () => {
|
||||
expect(linkifyText('see https://example.com.')).toBe(
|
||||
'see <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a>.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(linkifyText('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlinkifyHtml', () => {
|
||||
it('strips anchor tags and returns text content', () => {
|
||||
expect(unlinkifyHtml('visit <a href="https://example.com">https://example.com</a> today'))
|
||||
.toBe('visit https://example.com today');
|
||||
});
|
||||
|
||||
it('returns plain text unchanged', () => {
|
||||
expect(unlinkifyHtml('just text')).toBe('just text');
|
||||
});
|
||||
|
||||
it('handles multiple anchor tags', () => {
|
||||
expect(unlinkifyHtml('<a href="https://a.com">https://a.com</a> and <a href="https://b.com">https://b.com</a>'))
|
||||
.toBe('https://a.com and https://b.com');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(unlinkifyHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles div and br tags from contenteditable', () => {
|
||||
expect(unlinkifyHtml('line1<br>line2')).toBe('line1line2');
|
||||
});
|
||||
|
||||
it('decodes HTML entities after stripping tags', () => {
|
||||
expect(unlinkifyHtml('Tom & Jerry')).toBe('Tom & Jerry');
|
||||
expect(unlinkifyHtml('<b>bold</b>')).toBe('<b>bold</b>');
|
||||
expect(unlinkifyHtml('say "hello"')).toBe('say "hello"');
|
||||
});
|
||||
|
||||
it('converts from contenteditable to regular spaces', () => {
|
||||
expect(unlinkifyHtml('hello world')).toBe('hello world');
|
||||
expect(unlinkifyHtml('a b')).toBe('a b');
|
||||
});
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
export interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function isChild(item: ChecklistItem): boolean {
|
||||
return item.parentId !== null;
|
||||
}
|
||||
|
||||
export function getChildren(parentId: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
return items.filter((i) => i.parentId === parentId);
|
||||
}
|
||||
|
||||
export function getParent(item: ChecklistItem, items: ChecklistItem[]): ChecklistItem | undefined {
|
||||
return item.parentId ? items.find((i) => i.id === item.parentId) : undefined;
|
||||
}
|
||||
|
||||
export function toggleItemWithCascade(id: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
const target = items.find((i) => i.id === id)!;
|
||||
const newChecked = !target.checked;
|
||||
|
||||
let result = items.map((item) => {
|
||||
if (item.id === id) return { ...item, checked: newChecked };
|
||||
if (item.parentId === id) return { ...item, checked: newChecked };
|
||||
return item;
|
||||
});
|
||||
|
||||
// When unchecking a child: reposition it after its parent/siblings in the array
|
||||
// (otherwise it stays at the end of the array where done items live)
|
||||
if (!newChecked && target.parentId) {
|
||||
const parentExists = result.some((i) => i.id === target.parentId);
|
||||
if (!parentExists) {
|
||||
// Parent deleted — orphan it
|
||||
result = result.map((i) => (i.id === id ? { ...i, parentId: null } : i));
|
||||
} else {
|
||||
// Remove the item from its current position
|
||||
const uncheckedItem = result.find((i) => i.id === id)!;
|
||||
result = result.filter((i) => i.id !== id);
|
||||
// Find the last sibling or the parent, insert after it
|
||||
let insertAfter = -1;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (result[i].id === target.parentId || result[i].parentId === target.parentId) {
|
||||
insertAfter = i;
|
||||
}
|
||||
}
|
||||
if (insertAfter >= 0) {
|
||||
result.splice(insertAfter + 1, 0, uncheckedItem);
|
||||
} else {
|
||||
result.push(uncheckedItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function indentItem(id: string, items: ChecklistItem[], activeItems: ChecklistItem[]): ChecklistItem[] {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || item.parentId !== null) return items;
|
||||
|
||||
const activeIndex = activeItems.findIndex((i) => i.id === id);
|
||||
let newParentId: string | null = null;
|
||||
for (let i = activeIndex - 1; i >= 0; i--) {
|
||||
if (activeItems[i].parentId === null) {
|
||||
newParentId = activeItems[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!newParentId) return items;
|
||||
|
||||
return items.map((i) => {
|
||||
if (i.id === id) return { ...i, parentId: newParentId };
|
||||
if (i.parentId === id) return { ...i, parentId: newParentId };
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
export function outdentItem(id: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
const item = items[index];
|
||||
if (!item || item.parentId === null) return items;
|
||||
|
||||
const originalParentId = item.parentId;
|
||||
|
||||
const adoptees: string[] = [];
|
||||
for (let i = index + 1; i < items.length; i++) {
|
||||
if (items[i].parentId === originalParentId) {
|
||||
adoptees.push(items[i].id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return items.map((i) => {
|
||||
if (i.id === id) return { ...i, parentId: null };
|
||||
if (adoptees.includes(i.id)) return { ...i, parentId: id };
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ id: generateId(), text: '', checked: false, parentId: null }];
|
||||
|
||||
let lastTopLevelId: string | null = null;
|
||||
return text.split('\n').map((line) => {
|
||||
if (line.startsWith(' - [x] ')) {
|
||||
return { id: generateId(), text: line.slice(8), checked: true, parentId: lastTopLevelId };
|
||||
}
|
||||
if (line.startsWith(' - [ ] ')) {
|
||||
return { id: generateId(), text: line.slice(8), checked: false, parentId: lastTopLevelId };
|
||||
}
|
||||
const item: ChecklistItem = { id: generateId(), text: '', checked: false, parentId: null };
|
||||
if (line.startsWith('- [x] ')) {
|
||||
item.text = line.slice(6);
|
||||
item.checked = true;
|
||||
} else if (line.startsWith('- [ ] ')) {
|
||||
item.text = line.slice(6);
|
||||
} else {
|
||||
item.text = line;
|
||||
}
|
||||
lastTopLevelId = item.id;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeChecklist(items: ChecklistItem[]): string {
|
||||
const topLevel = items.filter((i) => i.parentId === null);
|
||||
const childMap = new Map<string, ChecklistItem[]>();
|
||||
for (const item of items) {
|
||||
if (item.parentId) {
|
||||
const siblings = childMap.get(item.parentId) ?? [];
|
||||
siblings.push(item);
|
||||
childMap.set(item.parentId, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const parent of topLevel) {
|
||||
lines.push(`- [${parent.checked ? 'x' : ' '}] ${parent.text}`);
|
||||
const children = childMap.get(parent.id) ?? [];
|
||||
for (const child of children) {
|
||||
lines.push(` - [${child.checked ? 'x' : ' '}] ${child.text}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<]+[^\s<.,;:!?)'"}\]]/g;
|
||||
|
||||
export function linkifyText(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
const matches: { index: number; match: string; href: string }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
const regex = new RegExp(URL_REGEX.source, 'g');
|
||||
|
||||
while ((m = regex.exec(text)) !== null) {
|
||||
const match = m[0];
|
||||
const href = match.startsWith('www.') ? `https://${match}` : match;
|
||||
matches.push({ index: m.index, match, href });
|
||||
}
|
||||
|
||||
if (matches.length === 0) return escapeHtml(text);
|
||||
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
for (const { index, match, href } of matches) {
|
||||
result += escapeHtml(text.slice(lastIndex, index));
|
||||
result += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer" class="checklist-link">${escapeHtml(match)}</a>`;
|
||||
lastIndex = index + match.length;
|
||||
}
|
||||
result += escapeHtml(text.slice(lastIndex));
|
||||
return result;
|
||||
}
|
||||
|
||||
export function unlinkifyHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
return html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getNoteColor,
|
||||
getNoteChip,
|
||||
NOTE_COLORS,
|
||||
NOTE_COLORS_DARK,
|
||||
NOTE_CHIPS_DARK
|
||||
} from '$lib/utils/colors.js';
|
||||
import type { NoteColor } from '$lib/types/index.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('#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');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
function toRgb(hex: string): [number, number, number] {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
/** WCAG relative luminance. */
|
||||
function relativeLuminance(hex: string): number {
|
||||
const channels = toRgb(hex).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);
|
||||
}
|
||||
|
||||
function rgbDistance(a: string, b: string): number {
|
||||
const [ra, ga, ba] = toRgb(a);
|
||||
const [rb, gb, bb] = toRgb(b);
|
||||
return Math.hypot(ra - rb, ga - gb, ba - bb);
|
||||
}
|
||||
|
||||
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', () => {
|
||||
// The shipped dark theme failed this at 3.77:1 with its previous muted
|
||||
// colour (#9a8e7e). This guard exists so a future palette edit cannot
|
||||
// reintroduce it — it does not discriminate the old surfaces, which were
|
||||
// darker and so scored higher against the lifted muted colour.
|
||||
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
|
||||
expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every pair of dark surfaces distinguishable', () => {
|
||||
// The old palette failed this: fog and storm sat 7.55 apart, so the twelve
|
||||
// colours read as one dark smudge. Threshold 8 clears that while leaving
|
||||
// headroom above the current closest pair, coral/peach at 12.0.
|
||||
const entries = Object.entries(NOTE_COLORS_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 distance = rgbDistance(a.bg, b.bg);
|
||||
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(8);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 distance = rgbDistance(a, b);
|
||||
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every chip lighter than every card, so chips read as vivid dots', () => {
|
||||
// A chip set to card-lightness values could still pass the pairwise
|
||||
// distance test above (25 apart) while being just as dark as the cards,
|
||||
// reintroducing the "twelve grey dots" bug this palette fixed. Measured:
|
||||
// the lightest card (mint) has luminance 0.0392; the darkest chip (coral)
|
||||
// has luminance 0.1941 — almost 5x higher, comfortable headroom either
|
||||
// side of the 0.1 threshold below.
|
||||
const cardLuminances = Object.values(NOTE_COLORS_DARK).map(({ bg }) => relativeLuminance(bg));
|
||||
const maxCardLuminance = Math.max(...cardLuminances);
|
||||
expect(maxCardLuminance).toBeLessThan(0.1);
|
||||
|
||||
for (const [name, chip] of Object.entries(NOTE_CHIPS_DARK)) {
|
||||
expect(relativeLuminance(chip), name).toBeGreaterThan(0.1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,53 +15,7 @@ export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
|
||||
chalk: { bg: '#efeff1', label: 'Chalk' }
|
||||
};
|
||||
|
||||
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' }
|
||||
};
|
||||
|
||||
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
|
||||
}));
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeContent, mergeContentUpdate } from './content-merge.js';
|
||||
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
|
||||
|
||||
describe('mergeContent (3-way)', () => {
|
||||
it('should return remote when local is unchanged', () => {
|
||||
@@ -89,56 +89,17 @@ describe('mergeContent (3-way)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeContentUpdate', () => {
|
||||
it('should merge an incoming update against current content when a base is available', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: '- [ ] Milk\n- [ ] Bread',
|
||||
incomingContent: '- [x] Milk\n- [ ] Bread',
|
||||
currentContent: '- [ ] Milk\n- [x] Bread'
|
||||
});
|
||||
|
||||
expect(result).toBe('- [x] Milk\n- [x] Bread');
|
||||
describe('mergeContentTwoWay', () => {
|
||||
it('should return remote when both are identical', () => {
|
||||
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should keep the incoming edit when no base snapshot is available', () => {
|
||||
// Without a common ancestor a 3-way merge is impossible; dropping the
|
||||
// incoming edit would silently lose the user's changes.
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: null,
|
||||
incomingContent: '- [x] Milk',
|
||||
currentContent: '- [ ] Bread'
|
||||
});
|
||||
it('should attempt merge when contents differ', () => {
|
||||
const local = '- [x] Milk\n- [ ] Bread';
|
||||
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
|
||||
expect(result).toBe('- [x] Milk');
|
||||
});
|
||||
|
||||
it('should keep current content when the incoming edit equals it', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: null,
|
||||
incomingContent: 'same',
|
||||
currentContent: 'same'
|
||||
});
|
||||
|
||||
expect(result).toBe('same');
|
||||
});
|
||||
|
||||
it('should keep current content when the incoming edit matches the base (no local change)', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: 'base',
|
||||
incomingContent: 'base',
|
||||
currentContent: 'server changed'
|
||||
});
|
||||
|
||||
expect(result).toBe('server changed');
|
||||
});
|
||||
|
||||
it('should take the incoming edit when the server is unchanged from the base', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: 'base',
|
||||
incomingContent: 'incoming change',
|
||||
currentContent: 'base'
|
||||
});
|
||||
|
||||
expect(result).toBe('incoming change');
|
||||
const result = mergeContentTwoWay(local, remote);
|
||||
// Without a base, conflict resolution prefers remote
|
||||
expect(result).toContain('- [ ] Bread');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,28 +78,11 @@ export function mergeContent(base: string, local: string, remote: string): strin
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an incoming content snapshot into the current server content.
|
||||
*
|
||||
* Uses a 3-way merge when a base (common ancestor) is available. Without a base —
|
||||
* e.g. the base snapshot was pruned, or the version was bumped by a metadata-only
|
||||
* change that created no snapshot — a 3-way merge is impossible, so keep the
|
||||
* incoming edit rather than silently discarding the user's changes.
|
||||
* 2-way merge fallback when no base is available.
|
||||
* Uses an empty string as the base, so all lines from both sides appear
|
||||
* as additions. For conflicts (same region), prefers remote.
|
||||
*/
|
||||
export function mergeContentUpdate(input: {
|
||||
baseContent: string | null;
|
||||
incomingContent: string;
|
||||
currentContent: string;
|
||||
}): string {
|
||||
const { baseContent, incomingContent, currentContent } = input;
|
||||
|
||||
if (incomingContent === currentContent) return currentContent;
|
||||
if (baseContent === incomingContent) return currentContent;
|
||||
if (baseContent === currentContent) return incomingContent;
|
||||
|
||||
if (baseContent === null) {
|
||||
console.warn('[content-merge] no base snapshot for concurrent edit; keeping incoming content');
|
||||
return incomingContent;
|
||||
}
|
||||
|
||||
return mergeContent(baseContent, incomingContent, currentContent);
|
||||
export function mergeContentTwoWay(local: string, remote: string): string {
|
||||
if (local === remote) return remote;
|
||||
return mergeContent('', local, remote);
|
||||
}
|
||||
|
||||
@@ -85,16 +85,3 @@ describe('stripMarkdown', () => {
|
||||
expect(stripMarkdown('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdown note-link handling', () => {
|
||||
it('should render a crumb-note:// link as plain text, not a clickable anchor', () => {
|
||||
const html = renderMarkdown('See [Grocery List](crumb-note://abc-123) for details');
|
||||
expect(html).not.toContain('<a');
|
||||
expect(html).toContain('Grocery List');
|
||||
});
|
||||
|
||||
it('should still render a normal external link as a real anchor', () => {
|
||||
const html = renderMarkdown('See [Example](https://example.com) for details');
|
||||
expect(html).toContain('<a href="https://example.com"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,25 +39,6 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
}
|
||||
});
|
||||
|
||||
md.core.ruler.after('task-lists', 'note-links', (state) => {
|
||||
for (const token of state.tokens) {
|
||||
if (token.type !== 'inline' || !token.children) continue;
|
||||
|
||||
const children = token.children;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].type !== 'link_open') continue;
|
||||
const href = children[i].attrGet('href') ?? '';
|
||||
if (!href.startsWith('crumb-note://')) continue;
|
||||
|
||||
let closeIndex = i + 1;
|
||||
while (closeIndex < children.length && children[closeIndex].type !== 'link_close') closeIndex++;
|
||||
children.splice(closeIndex, 1);
|
||||
children.splice(i, 1);
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function renderMarkdown(content: string): string {
|
||||
return md.render(content);
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractNoteLinks } from './note-links.js';
|
||||
|
||||
describe('extractNoteLinks', () => {
|
||||
it('should extract a single note-link reference', () => {
|
||||
expect(extractNoteLinks('See [Grocery List](crumb-note://abc-123)')).toEqual(['abc-123']);
|
||||
});
|
||||
|
||||
it('should extract multiple distinct references', () => {
|
||||
const content = '[A](crumb-note://id-1) and [B](crumb-note://id-2)';
|
||||
expect(extractNoteLinks(content)).toEqual(['id-1', 'id-2']);
|
||||
});
|
||||
|
||||
it('should deduplicate repeated references', () => {
|
||||
const content = '[A](crumb-note://id-1) again: [A again](crumb-note://id-1)';
|
||||
expect(extractNoteLinks(content)).toEqual(['id-1']);
|
||||
});
|
||||
|
||||
it('should return an empty array for content with no note-links', () => {
|
||||
expect(extractNoteLinks('Just [a normal link](https://example.com)')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty array for empty content', () => {
|
||||
expect(extractNoteLinks('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not match crumb-note:// occurring inside a code block', () => {
|
||||
const content = '```\n[A](crumb-note://id-1)\n```';
|
||||
expect(extractNoteLinks(content)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not match crumb-note:// occurring inside inline code', () => {
|
||||
expect(extractNoteLinks('Use `crumb-note://id-1` as an example')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Extract crumb-note://<id> references from markdown content.
|
||||
* Mirrors extractTags' code-block/inline-code exclusion so a reference
|
||||
* mentioned as an example inside a code sample isn't treated as a real link.
|
||||
*/
|
||||
export function extractNoteLinks(content: string): string[] {
|
||||
if (!content) return [];
|
||||
|
||||
const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
|
||||
const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]*`/g, '');
|
||||
|
||||
const matches = withoutInlineCode.match(/crumb-note:\/\/([\w-]+)/g);
|
||||
if (!matches) return [];
|
||||
|
||||
const ids = matches.map((m) => m.replace('crumb-note://', ''));
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { generatePassword, PASSWORD_ALPHABET } from './password.js';
|
||||
|
||||
const LOWERCASE = /[abcdefghijkmnpqrstuvwxyz]/;
|
||||
const UPPERCASE = /[ABCDEFGHJKLMNPQRSTUVWXYZ]/;
|
||||
const DIGIT = /[23456789]/;
|
||||
const SYMBOL = /[!@#$%^&*]/;
|
||||
|
||||
/** Visually ambiguous characters the charset deliberately excludes. */
|
||||
const AMBIGUOUS = ['0', 'O', 'o', '1', 'l', 'I'];
|
||||
|
||||
describe('PASSWORD_ALPHABET', () => {
|
||||
it('is exactly 64 distinct characters', () => {
|
||||
// 256 % 64 === 0, which is what keeps nanoid's masking exactly uniform.
|
||||
// A charset edit that breaks this silently biases every password.
|
||||
expect(PASSWORD_ALPHABET).toHaveLength(64);
|
||||
expect(new Set(PASSWORD_ALPHABET).size).toBe(64);
|
||||
});
|
||||
|
||||
it('excludes every visually ambiguous character', () => {
|
||||
for (const char of AMBIGUOUS) {
|
||||
expect(PASSWORD_ALPHABET.includes(char), char).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePassword', () => {
|
||||
it('returns a 20-character password by default', () => {
|
||||
expect(generatePassword()).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('honours an explicit length', () => {
|
||||
expect(generatePassword(8)).toHaveLength(8);
|
||||
expect(generatePassword(32)).toHaveLength(32);
|
||||
expect(generatePassword(64)).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('always includes at least one character from each of the four groups', () => {
|
||||
// Not a statistical bet: the function only ever returns a class-complete
|
||||
// password or throws, so every sample must pass.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const password = generatePassword();
|
||||
expect(password, `sample ${i}`).toMatch(LOWERCASE);
|
||||
expect(password, `sample ${i}`).toMatch(UPPERCASE);
|
||||
expect(password, `sample ${i}`).toMatch(DIGIT);
|
||||
expect(password, `sample ${i}`).toMatch(SYMBOL);
|
||||
}
|
||||
});
|
||||
|
||||
it('never emits a visually ambiguous character', () => {
|
||||
// 0 O o 1 l I survive neither being read aloud nor retyped.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const password = generatePassword();
|
||||
for (const char of AMBIGUOUS) {
|
||||
expect(password.includes(char), `sample ${i} contains ${char}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('draws only from the pinned 64-character charset', () => {
|
||||
const charset = /^[abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*]+$/;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(generatePassword(), `sample ${i}`).toMatch(charset);
|
||||
}
|
||||
});
|
||||
|
||||
it('produces a different password on each call', () => {
|
||||
const passwords = new Set(Array.from({ length: 20 }, () => generatePassword()));
|
||||
expect(passwords.size).toBe(20);
|
||||
});
|
||||
|
||||
it('throws below the 8-character server minimum', () => {
|
||||
expect(() => generatePassword(7)).toThrow(/8/);
|
||||
expect(() => generatePassword(0)).toThrow(/8/);
|
||||
expect(() => generatePassword(-1)).toThrow(/8/);
|
||||
});
|
||||
|
||||
it('does not use Math.random anywhere in the module', () => {
|
||||
// Randomness must come from nanoid's CSPRNG, not Math.random.
|
||||
const source = readFileSync(new URL('./password.ts', import.meta.url), 'utf8');
|
||||
expect(source).not.toContain('Math.random');
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
// The excluded characters — 0 O o 1 l I — are visually ambiguous, so a generated
|
||||
// password survives being read aloud or retyped from a screenshot.
|
||||
const LOWERCASE = 'abcdefghijkmnpqrstuvwxyz'; // no l, no o
|
||||
const UPPERCASE = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // no I, no O
|
||||
const DIGITS = '23456789'; // no 0, no 1
|
||||
const SYMBOLS = '!@#$%^&*';
|
||||
|
||||
/**
|
||||
* Pinned at exactly 64 characters. 64 is a power of two, so nanoid's masking
|
||||
* takes its exactly-uniform fast path (256 % 64 === 0) and no character is
|
||||
* favoured over another. At 64 symbols a 20-character password carries 120 bits
|
||||
* of entropy — 119.79 once conditioned on the class-completeness retry below.
|
||||
* Do not change the size without understanding both consequences.
|
||||
*/
|
||||
export const PASSWORD_ALPHABET = LOWERCASE + UPPERCASE + DIGITS + SYMBOLS;
|
||||
|
||||
const GROUPS = [LOWERCASE, UPPERCASE, DIGITS, SYMBOLS];
|
||||
|
||||
/** The server rejects anything shorter. */
|
||||
const MIN_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Per-attempt failure is 13.5% at the default length of 20 and 61.9% at the
|
||||
* minimum of 8, so exhausting the cap has probability ~1e-87 and ~1e-21
|
||||
* respectively — unreachable in practice. The bound exists so a future charset
|
||||
* edit cannot turn the retry into an infinite loop.
|
||||
*/
|
||||
const MAX_ATTEMPTS = 100;
|
||||
|
||||
const randomString = customAlphabet(PASSWORD_ALPHABET);
|
||||
|
||||
function containsAnyOf(password: string, group: string): boolean {
|
||||
return [...password].some((char) => group.includes(char));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random password containing at least one lowercase letter, one
|
||||
* uppercase letter, one digit and one symbol.
|
||||
*
|
||||
* A password missing a group is discarded whole and regenerated, which keeps the
|
||||
* result uniform over exactly the set of valid passwords. Forcing characters
|
||||
* into chosen positions and shuffling would need its own unbiased index source.
|
||||
*/
|
||||
export function generatePassword(length = 20): string {
|
||||
if (length < MIN_LENGTH) {
|
||||
throw new Error(`Password length must be at least ${MIN_LENGTH}, got ${length}`);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
const password = randomString(length);
|
||||
if (GROUPS.every((group) => containsAnyOf(password, group))) return password;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to generate a password with all character groups after ${MAX_ATTEMPTS} attempts`
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user