Compare commits

...
23 Commits
Author SHA1 Message Date
github-actions[bot] 53f5bdcbd8 chore: bump version to 0.27.1 2026-03-25 07:26:53 +00:00
Frederic CabassutandClaude Opus 4.6 b6dbdbe9a5 fix: prevent click-through to NoteCards when closing editor overlay
On installed PWAs, closing the editor by tapping the overlay would cause
the browser's delayed synthetic click to land on the NoteCard underneath,
immediately reopening a different note. Fixed by keeping a transparent
shield in the DOM for 400ms after close to absorb the ghost click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:15:58 +01:00
github-actions[bot] 21efb1d811 chore: pin docker-compose to v0.27.0 [skip ci] 2026-03-24 22:56:34 +00:00
github-actions[bot] e2ee18893f chore: bump version to 0.27.0 2026-03-24 22:50:50 +00:00
Frederic CabassutandClaude Opus 4.6 be53b4b26e fix: sync attachment changes to store and skip spurious auto-save
Two issues fixed:

1. Attachment operations (upload, feature, remove) only updated the local
   editor state, not the notes store. Before auto-save, this was masked
   because saveAndClose always called updateNote, whose PATCH response
   refreshed the store. With auto-save skipping redundant saves, the store
   was never updated with attachment changes. Now attachment handlers
   update the store directly.

2. TiptapEditor fires onUpdate on init with round-tripped markdown that
   can differ from the original. Added an autoSaveReady guard to absorb
   this initial normalization as the baseline, preventing spurious saves
   when reopening existing notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 042c4c06e5 fix: rename test note to avoid collision with offline test
The auto-save test used "Survive Reload" which collides with the same
title in offline.spec.ts when tests run in parallel with a shared DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 9d1c898b84 fix: address review feedback on auto-save
- Snapshot fields at start of performSave to avoid race with user edits
  during async save (fields could change between await and lastSaved update)
- Only update lastSaved tracking on successful save, so failed saves
  are retried on the next debounce cycle
- Stop restoring title after Untitled fallback in autoSave(), which was
  re-triggering the auto-save effect
- Capture note count before opening editor in empty-note e2e test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 07b27c01e7 feat: auto-save notes with debounced persistence
Notes are now automatically saved 2 seconds after the user stops editing,
preventing data loss when the browser tab is closed without closing the
editor first. A beforeunload handler provides an additional safety net.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:49:08 +01:00
Frederic CabassutandClaude Opus 4.6 4f4cc69b9f fix: replace hardcoded test passwords with runtime-generated UUIDs
Generate random passwords in global-setup and share via temp file so
all Playwright workers use the same credentials. Screenshot scripts
use module-level crypto.randomUUID() since they run single-process.
Unit test auth passwords also use randomUUID().

Eliminates security scanner alerts for passwords committed to git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:29:45 +01:00
Frederic CabassutandClaude Opus 4.6 3f81f22026 fix: align checkboxes in richtext task list previews
Add missing nested task list rule for .prose context (matching TipTap's
existing rule) and adjust checkbox vertical alignment with margin-top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:46:13 +01:00
github-actions[bot] 9331351c6d chore: pin docker-compose to v0.26.0 [skip ci] 2026-03-22 19:16:52 +00:00
github-actions[bot] cd93c18f92 chore: bump version to 0.26.0 2026-03-22 19:11:39 +00:00
Frederic CabassutandClaude Opus 4.6 172ab88b33 feat: custom tooltip action replacing native title attributes
Add a reusable `use:tooltip` Svelte action as a drop-in replacement for
native title attributes. Styled to match the retro parchment theme with
JetBrains Mono font, dark background, and 400ms hover delay.

Replace all title attributes in NoteEditor, NoteCard, and NotesView
with the custom tooltip. The action supports top/bottom positioning
and auto-clamps to viewport edges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:09:32 +01:00
Frederic CabassutandClaude Opus 4.6 c82f720b02 fix: add cursor pointer to create crumb buttons
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:04:23 +01:00
Frederic CabassutandClaude Opus 4.6 548aa14d63 fix: add cursor pointer to create crumb button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:03:26 +01:00
Frederic CabassutandClaude Opus 4.6 c66217e6f1 feat: add checklist shortcut to create button
Add a checkbox icon next to the "Add a crumb..." button that opens a new
note directly in checklist mode, similar to Google Keep's quick-create
bar. The NoteEditor accepts a new initialChecklistMode prop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:53:01 +01:00
Frederic CabassutandClaude Opus 4.6 6c9781fb24 fix: sharing indicator popover opens downward to avoid overflow clipping
The popover was opening upward (bottom-full) which got clipped by the
NoteCard's overflow-hidden. Changed to open downward (top-full) so it
stays within the card's visual area.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:50:48 +01:00
Frederic CabassutandClaude Opus 4.6 b44cd40e36 fix: align nested checkboxes in rich text task lists
Nested TipTap task lists (indented children) broke the flex layout
because the nested ul was treated as an inline flex child. Now the
li uses flex-wrap with align-items: flex-start, and nested task lists
take full width with left padding for indentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:49:10 +01:00
Frederic CabassutandClaude Opus 4.6 8b9f9dfee8 feat: add overflow menu with checklist actions and action bar cleanup
- Add vertical dots (⋮) overflow menu to NoteEditor bottom action bar
- New checklist actions: "Delete checked" and "Uncheck all"
- Move secondary actions to menu: checklist toggle, markdown toggle,
  version history, move to trash
- Promote archive to the action bar for quick access
- Keep in bar: color picker, image upload, share, archive, ⋮, close
- Menu renders as fixed-position portal to escape card overflow-hidden
- Click-outside handler closes the menu
- Update e2e tests to use overflow menu for checklist/markdown toggles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:47:02 +01:00
Frederic CabassutandClaude Opus 4.6 5a2a63e2bb fix: prevent 3-way merge from reverting sequential single-user edits
The server's 3-way content merge was incorrectly triggering on sequential
edits by the same user. When a user checked then unchecked a checklist
item, the merge saw local === base (both unchecked) and kept the server's
checked state, silently discarding the uncheck.

Now the merge only runs when there's an actual concurrent edit
(baseVersion < existing.version), meaning another user saved between the
client's load and this save. For single-user sequential edits, the
client's content is used directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:51:14 +01:00
Frederic CabassutandClaude Opus 4.6 6eb35e9df2 perf: add BuildX + GHA caching to CI docker validation build
CI only needs to verify the Dockerfile builds on amd64 — multi-arch
is handled by the release workflow with native ARM runners. Switching
from bare `docker build` to BuildX with GHA layer caching speeds up
subsequent builds significantly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:28:48 +01:00
Frederic CabassutandClaude Opus 4.6 f1b42f115a perf: parallelize multi-arch Docker builds with native ARM runners
Replace sequential QEMU-emulated builds with parallel native builds
using a matrix strategy (ubuntu-latest for amd64, ubuntu-24.04-arm
for arm64) and manifest merging. This eliminates the ~5-10x QEMU
overhead and should cut Docker build time roughly in half.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:06:37 +01:00
github-actions[bot] 133217d552 chore: pin docker-compose to v0.25.0 [skip ci] 2026-03-21 23:03:17 +00:00
21 changed files with 838 additions and 169 deletions
+75 -18
View File
@@ -48,13 +48,20 @@ jobs:
needs: bump
uses: ./.github/workflows/ci.yml
release:
name: Build & Push Docker Image
runs-on: ubuntu-latest
build:
name: Build Docker (${{ matrix.platform }})
needs: [bump, ci]
permissions:
contents: write
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:
@@ -65,13 +72,6 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
type=raw,value=latest
- name: Set up QEMU (for multi-platform builds)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -83,17 +83,74 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.runner }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge manifests & release
runs-on: ubuntu-latest
needs: [bump, build]
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
type=raw,value=latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
@@ -104,7 +161,7 @@ jobs:
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: [bump, release]
needs: [bump, merge]
permissions:
contents: write
steps:
+11 -1
View File
@@ -121,5 +121,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: docker build -t crumbs:ci .
uses: docker/build-push-action@v6
with:
context: .
push: false
provenance: false
tags: crumbs:ci
cache-from: type=gha,scope=ci-docker
cache-to: type=gha,scope=ci-docker,mode=max
+72 -17
View File
@@ -13,10 +13,18 @@ jobs:
ci:
uses: ./.github/workflows/ci.yml
release:
name: Build & Push Docker Image
runs-on: ubuntu-latest
build:
name: Build Docker (${{ matrix.platform }})
needs: ci
strategy:
fail-fast: true
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
@@ -25,13 +33,6 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
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
@@ -43,17 +44,71 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.runner }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge manifests & release
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
@@ -63,7 +118,7 @@ jobs:
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: release
needs: merge
permissions:
contents: write
steps:
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.24.0
image: ghcr.io/bretzel-app/crumbs:0.27.0
container_name: crumbs
ports:
- "3000:3000"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.25.0",
"version": "0.27.1",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
+2 -2
View File
@@ -8,11 +8,11 @@ export const MOBILE_VIEWPORT = { width: 390, height: 844 };
export const ADMIN = {
email: 'admin@bretzel.app',
displayName: 'Bretzel Admin',
password: 'bretzel2026!'
password: crypto.randomUUID()
};
export const COLLABORATOR = {
email: 'salzig@bretzel.app',
displayName: 'Salzig Bretzel',
password: 'salzig2026!!'
password: crypto.randomUUID()
};
+32 -1
View File
@@ -135,12 +135,20 @@ body::before {
.tiptap ul[data-type="taskList"] li,
.prose ul.task-list li {
display: flex;
align-items: center;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.375rem;
margin: 0;
padding: 0;
}
/* Nested task lists take full width below the parent item */
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"],
.prose ul.task-list li > ul.task-list {
flex-basis: 100%;
padding-left: 1.5rem;
}
.tiptap ul[data-type="taskList"] li::marker,
.prose ul.task-list li::marker {
content: none;
@@ -151,6 +159,7 @@ body::before {
display: flex;
align-items: center;
flex-shrink: 0;
margin-top: 0.125rem;
}
/* Shared checkbox styles */
@@ -158,6 +167,7 @@ body::before {
.prose ul.task-list .task-checkbox {
cursor: pointer;
margin: 0;
margin-top: 0.2em;
flex-shrink: 0;
}
@@ -195,3 +205,24 @@ body::before {
.prose ul.task-list li p {
margin: 0;
}
/* 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;
}
+9 -8
View File
@@ -12,6 +12,7 @@
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';
interface ChecklistItem {
text: string;
@@ -117,7 +118,7 @@
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unpin"
use:tooltip={"Unpin"}
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
@@ -160,7 +161,7 @@
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Restore"
use:tooltip={"Restore"}
data-testid="restore-btn"
>
<Undo2 class="h-4 w-4" />
@@ -168,7 +169,7 @@
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete forever"
use:tooltip={"Delete forever"}
data-testid="delete-forever-btn"
>
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
@@ -178,7 +179,7 @@
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Pin"
use:tooltip={"Pin"}
data-testid="pin-btn"
>
<Bookmark class="h-4 w-4" />
@@ -188,7 +189,7 @@
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Unarchive"
use:tooltip={"Unarchive"}
data-testid="unarchive-btn"
>
<ArchiveRestore class="h-4 w-4" />
@@ -197,7 +198,7 @@
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Archive"
use:tooltip={"Archive"}
data-testid="archive-btn"
>
<Archive class="h-4 w-4" />
@@ -207,7 +208,7 @@
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Leave note"
use:tooltip={"Leave note"}
data-testid="leave-btn"
>
<UserMinus class="h-4 w-4" />
@@ -216,7 +217,7 @@
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
use:tooltip={"Trash"}
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
+312 -68
View File
@@ -7,7 +7,7 @@
import ImageUpload from './ImageUpload.svelte';
import ShareDialog from './ShareDialog.svelte';
import NoteHistory from './NoteHistory.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { updateNote, createNote, trashNote, archiveNote } 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';
@@ -24,14 +24,21 @@
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 { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js';
import { tooltip } from '$lib/utils/tooltip.js';
interface Props {
note: Note | null;
isNew?: boolean;
initialChecklistMode?: boolean;
onClose: () => void;
}
const { note, isNew = false, onClose }: Props = $props();
const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props();
const prefs = getPreferences();
// svelte-ignore state_referenced_locally
@@ -41,7 +48,7 @@
// svelte-ignore state_referenced_locally
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? false);
let checklistMode = $state(note?.checklistMode ?? initialChecklistMode);
let showColorPicker = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
@@ -56,6 +63,100 @@
// 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);
});
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
@@ -70,6 +171,29 @@
let showShareDialog = $state(false);
let showHistory = $state(false);
let showOverflowMenu = $state(false);
let overflowBtnEl: 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 (overflowBtnEl?.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
@@ -158,7 +282,13 @@
});
function handleAttachmentUpload(attachment: Attachment) {
attachmentsList = [...attachmentsList, attachment];
const updated = [...attachmentsList, attachment];
attachmentsList = updated;
if (noteId) {
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
}
}
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
@@ -170,9 +300,14 @@
body: JSON.stringify({ featured })
});
if (res.ok) {
attachmentsList = attachmentsList.map((a) =>
const updated = 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);
@@ -185,7 +320,11 @@
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'DELETE'
});
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
const updated = attachmentsList.filter((a) => a.id !== attachmentId);
attachmentsList = updated;
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
);
} catch (err) {
console.error('Failed to remove attachment:', err);
}
@@ -196,11 +335,11 @@
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
});
// 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.
// 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).
let pointerdownOnOverlay = false;
let closing = $state(false);
function handleOverlayPointerdown(e: PointerEvent) {
pointerdownOnOverlay = e.target === e.currentTarget;
@@ -211,35 +350,37 @@
pointerdownOnOverlay = false;
}
function dismissOverlay() {
closing = true;
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()) {
onClose();
dismissOverlay();
return;
}
if (currentlyNew) {
await createNote({ title, content, color, checklistMode });
} else if (noteId) {
await updateNote(noteId, { title, content, color, checklistMode });
// Final save if auto-save hasn't caught up yet
if (hasUnsavedChanges()) {
await performSave();
}
onClose();
dismissOverlay();
}
/** Auto-save a new note without closing, returns the new note ID */
async function autoSave(): Promise<string | null> {
if (!currentlyNew) return noteId;
const created = await createNote({
title: title || 'Untitled',
content,
color,
checklistMode
});
if (created) {
noteId = created.id;
currentlyNew = false;
return created.id;
}
return null;
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;
}
async function toggleImageUpload() {
@@ -267,16 +408,45 @@
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 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]"
class="fixed inset-0 z-40 {closing ? '' : '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="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]"
@@ -358,7 +528,7 @@
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Background color"
use:tooltip={"Background color"}
data-testid="color-picker-toggle"
>
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
@@ -370,51 +540,22 @@
{/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}
<Type class="h-5 w-5" />
{:else}
<SquareCheck class="h-5 w-5" />
{/if}
</button>
<!-- Image attachment toggle -->
<button
onclick={toggleImageUpload}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Image attachments"
use:tooltip={"Attachments"}
data-testid="image-toggle"
>
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
</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}
<FileText class="h-5 w-5" />
{:else}
<FileCode class="h-5 w-5" />
{/if}
</button>
<!-- Share button (owner only for new shares, all for viewing) -->
<!-- Share button -->
{#if isOwner}
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Share note"
use:tooltip={"Share"}
data-testid="share-toggle"
>
{#if hasPublicLink}
@@ -426,22 +567,33 @@
{/if}
</button>
{:else if isShared}
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
<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>
{/if}
<!-- History button (only for saved notes) -->
<!-- Archive button (only for saved notes) -->
{#if !currentlyNew}
<button
onclick={toggleHistory}
onclick={handleArchive}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Version history"
data-testid="history-toggle"
use:tooltip={"Archive"}
data-testid="archive-note-btn"
>
<History class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
<Archive class="h-5 w-5" />
</button>
{/if}
<!-- Overflow menu trigger -->
<button
bind:this={overflowBtnEl}
onclick={() => (showOverflowMenu = !showOverflowMenu)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
use:tooltip={"More"}
data-testid="overflow-menu-btn"
>
<EllipsisVertical class="h-5 w-5" />
</button>
</div>
<button
@@ -453,6 +605,98 @@
</button>
</div>
</div>
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
{#if showOverflowMenu && overflowBtnEl}
{@const rect = overflowBtnEl.getBoundingClientRect()}
<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="bottom: {window.innerHeight - rect.top + 4}px; right: {window.innerWidth - rect.right}px;"
data-testid="overflow-menu"
>
<!-- 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(--border)]/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(--border)]/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(--border)]/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(--border)]/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(--border)]/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(--border)]/10"
data-testid="uncheck-all-btn"
>
<ListX class="h-4 w-4" />
Uncheck all
</button>
{/if}
</div>
{/if}
{/if}
</div>
{#if showShareDialog && noteId}
+23 -9
View File
@@ -6,6 +6,8 @@
import { onMount } from 'svelte';
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';
interface Props {
filter: NoteFilter;
@@ -16,6 +18,7 @@
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(() => {
@@ -32,6 +35,7 @@
function closeEditor() {
editingNote = null;
showNewNote = false;
newNoteChecklist = false;
history.replaceState(null, '', location.pathname);
}
@@ -55,14 +59,24 @@
{#if filter === 'all' && !tag}
<div class="mx-auto mb-6 hidden max-w-xl md:block">
<button
onclick={() => (showNewNote = true)}
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-testid="new-note-btn"
>
<Plus class="h-4 w-4" />
Add a crumb...
</button>
<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>
</div>
<button
onclick={() => (showNewNote = true)}
@@ -119,7 +133,7 @@
{/if}
{#if showNewNote}
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} />
{/if}
{#if editingNote}
+1 -1
View File
@@ -66,7 +66,7 @@
{#if showPopover}
<div
class="absolute right-0 bottom-full z-20 mb-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]"
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}
+5 -5
View File
@@ -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 } from 'crypto';
import { randomBytes, randomUUID } 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 = 'mysecurepassword';
const password = randomUUID();
const hash = await argon2.hash(password);
expect(hash).not.toBe(password);
expect(await argon2.verify(hash, password)).toBe(true);
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
expect(await argon2.verify(hash, randomUUID())).toBe(false);
});
it('should produce different hashes for the same password', async () => {
const password = 'mysecurepassword';
const password = randomUUID();
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 = 'testpassword123';
const password = randomUUID();
const hash = await argon2.hash(password);
await db.insert(users).values({
+14 -15
View File
@@ -238,21 +238,20 @@ 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) {
// 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();
// 3-way merge to preserve concurrent edits from other users.
// Only attempt merge when there's a concurrent edit: the client's
// baseVersion is behind the current version, meaning someone else
// saved between the client's load and this save.
const hasConcurrentEdit = input.baseVersion !== undefined
&& input.baseVersion < existing.version;
if (hasConcurrentEdit && input.content !== existing.content) {
const baseSnapshot = 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();
if (baseSnapshot && baseSnapshot.content !== existing.content) {
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
+83
View File
@@ -0,0 +1,83 @@
/**
* Custom tooltip action — drop-in replacement for title attributes.
* Usage: <button use:tooltip={"Label"}>
* Or with options: <button use:tooltip={{ text: "Label", position: "bottom" }}>
*/
export type TooltipPosition = 'top' | 'bottom';
interface TooltipOptions {
text: string;
position?: TooltipPosition;
}
function parseOptions(value: string | TooltipOptions): { text: string; position: TooltipPosition } {
if (typeof value === 'string') return { text: value, position: 'top' };
return { text: value.text, position: value.position ?? 'top' };
}
export function tooltip(node: HTMLElement, value: string | TooltipOptions) {
let opts = parseOptions(value);
let el: HTMLDivElement | null = null;
let timeout: ReturnType<typeof setTimeout> | undefined;
function show() {
timeout = setTimeout(() => {
if (!opts.text) return;
el = document.createElement('div');
el.className = 'tooltip-popup';
el.textContent = opts.text;
document.body.appendChild(el);
position();
}, 400);
}
function position() {
if (!el) return;
const rect = node.getBoundingClientRect();
const tipRect = el.getBoundingClientRect();
let top: number;
if (opts.position === 'bottom') {
top = rect.bottom + 6;
} else {
top = rect.top - tipRect.height - 6;
}
let left = rect.left + rect.width / 2 - tipRect.width / 2;
// Clamp to viewport
left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8));
el.style.top = `${top}px`;
el.style.left = `${left}px`;
el.classList.add('tooltip-visible');
}
function hide() {
clearTimeout(timeout);
if (el) {
el.remove();
el = null;
}
}
node.addEventListener('mouseenter', show);
node.addEventListener('mouseleave', hide);
node.addEventListener('pointerdown', hide);
// Remove native title to prevent double tooltip
const nativeTitle = node.getAttribute('title');
if (nativeTitle) node.removeAttribute('title');
return {
update(newValue: string | TooltipOptions) {
opts = parseOptions(newValue);
},
destroy() {
hide();
node.removeEventListener('mouseenter', show);
node.removeEventListener('mouseleave', hide);
node.removeEventListener('pointerdown', hide);
if (nativeTitle) node.setAttribute('title', nativeTitle);
}
};
}
+5 -1
View File
@@ -1,4 +1,8 @@
import { test, expect, setupAndLogin } from './helpers/fixtures.js';
import { readFileSync } from 'fs';
import { TEST_CREDENTIALS_FILE } from './global-setup';
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
test.describe.serial('Authentication', () => {
test('Scenario: Unauthenticated user is prompted to set up or log in', async ({ page }) => {
@@ -78,7 +82,7 @@ test.describe('Multi-user', () => {
data: {
email: 'user2@test.com',
displayName: 'User Two',
password: 'userpassword123',
password: userPassword,
role: 'user'
}
});
+83
View File
@@ -0,0 +1,83 @@
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
test.describe('Auto-save', () => {
test('Scenario: New note is auto-saved without closing the editor', async ({ authenticatedPage: page }) => {
// When the user starts a new note and types a title and content
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Auto-saved Note');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('This should be saved automatically');
// Then the note is persisted after the auto-save delay (2s debounce)
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible({ timeout: 5000 });
// And closing the editor preserves the note
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible();
});
test('Scenario: Edits to an existing note are auto-saved', async ({ authenticatedPage: page }) => {
// Given a note titled "Edit Me" exists
await createNote(page, 'Edit Me', 'Original content');
// When the user opens the note and changes the title
await noteCard(page, 'Edit Me').click();
await page.getByTestId('note-title-input').clear();
await page.getByTestId('note-title-input').fill('Edited Title');
// Then the updated title is auto-saved after the debounce delay
// Wait for the API call that confirms the save
await page.waitForResponse(
(res) => res.url().includes('/api/notes/') && res.request().method() === 'PATCH',
{ timeout: 5000 }
);
// And the note card reflects the change while editor is still open
await expect(noteCard(page, 'Edited Title')).toBeVisible();
// Cleanup: close the editor
await page.getByTestId('close-editor-btn').click();
});
test('Scenario: Empty note is not auto-saved', async ({ authenticatedPage: page }) => {
// Given the current number of notes
const noteCards = page.locator('[data-testid="note-card"]');
const countBefore = await noteCards.count();
// When the user opens a new note but leaves it empty
await page.getByTestId('new-note-btn').click();
// And waits longer than the auto-save delay
await page.waitForTimeout(3000);
// And closes the empty editor
await page.getByTestId('close-editor-btn').click();
// Then no new note was created
const countAfter = await noteCards.count();
expect(countAfter).toBe(countBefore);
});
test('Scenario: Auto-save persists note across page reload', async ({ authenticatedPage: page }) => {
// When the user creates a note and waits for auto-save
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Auto-save Survives Reload');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('Content that survives');
// Wait for auto-save to fire
await page.waitForResponse(
(res) => res.url().includes('/api/notes') && res.request().method() === 'POST',
{ timeout: 5000 }
);
// When the page is reloaded without closing the editor
await page.reload();
await page.waitForLoadState('networkidle');
// Then the note is still visible
await expect(noteCard(page, 'Auto-save Survives Reload')).toBeVisible();
});
});
+79 -13
View File
@@ -1,11 +1,17 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
/** Toggle checklist mode via the overflow menu. */
async function toggleChecklistMode(page: Page) {
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('checklist-toggle').click();
}
/** Create a checklist note via UI. Leaves the editor closed. */
async function createChecklistNote(page: Page, title: string, items: string[]) {
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill(title);
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
for (let i = 0; i < items.length; i++) {
await page.getByTestId('checklist-input').nth(i).fill(items[i]);
if (i < items.length - 1) {
@@ -22,7 +28,7 @@ test.describe('Checklist', () => {
await page.getByTestId('new-note-btn').click();
// When the user enables checklist mode
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
// Then the checklist component is displayed
await expect(page.getByTestId('checklist')).toBeVisible();
@@ -60,10 +66,35 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
});
test('Scenario: Sequential check and uncheck both persist correctly', async ({ authenticatedPage: page }) => {
// Given a checklist note with an item "Buy milk" exists
await createChecklistNote(page, 'Tasks2', ['Buy milk']);
// When the user checks the item and closes
await noteCard(page, 'Tasks2').click();
await page.getByTestId('checklist-checkbox').first().click();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
await page.getByTestId('close-editor-btn').click();
// Then the checked state persists
await noteCard(page, 'Tasks2').click();
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
// When the user unchecks the item from the done section and closes
await page.getByTestId('checklist-done-checkbox').first().click();
await expect(page.getByTestId('checklist-checkbox')).toHaveCount(1);
await page.getByTestId('close-editor-btn').click();
// Then the unchecked state also persists (not reverted by 3-way merge)
await noteCard(page, 'Tasks2').click();
await expect(page.getByTestId('checklist-checkbox')).toHaveCount(1);
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
});
test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => {
// Given a checklist with one item
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('First item');
// When the user presses Enter
@@ -102,7 +133,7 @@ test.describe('Checklist', () => {
test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => {
// Given a checklist with two items where the second is empty
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('First item');
await page.getByTestId('checklist-input').first().press('Enter');
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
@@ -118,7 +149,7 @@ test.describe('Checklist', () => {
// Given a checklist note with two items
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Hide Done Test');
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Done task');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
@@ -139,7 +170,7 @@ test.describe('Checklist', () => {
test('Scenario: Arrow keys navigate between checklist items', async ({ authenticatedPage: page }) => {
// Given a checklist with three items
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('First');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Second');
@@ -162,7 +193,7 @@ test.describe('Checklist', () => {
test('Scenario: Tab indents an item and Shift+Tab outdents it', async ({ authenticatedPage: page }) => {
// Given a checklist with two top-level items
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Parent');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Child');
@@ -183,7 +214,7 @@ test.describe('Checklist', () => {
test('Scenario: Checking a parent checks all its children', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and two children
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Buy groceries');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Milk');
@@ -202,7 +233,7 @@ test.describe('Checklist', () => {
test('Scenario: Checking a child shows read-only parent label in done section', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and a child
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Groceries');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Milk');
@@ -220,7 +251,7 @@ test.describe('Checklist', () => {
test('Scenario: Unchecking a parent from done restores group to active', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and child, all checked
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Shopping');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Milk');
@@ -256,7 +287,7 @@ test.describe('Checklist', () => {
test('Scenario: Enter on a child creates a sibling at the same level', async ({ authenticatedPage: page }) => {
// Given a checklist with a parent and a child
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().fill('Parent');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Child 1');
@@ -285,14 +316,49 @@ test.describe('Checklist', () => {
test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => {
// Given checklist mode is enabled on a new note
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
await expect(page.getByTestId('checklist')).toBeVisible();
// When the user disables checklist mode
await page.getByTestId('checklist-toggle').click();
await toggleChecklistMode(page);
// Then the rich text editor is displayed again
await expect(page.getByTestId('tiptap-editor')).toBeVisible();
await expect(page.getByTestId('checklist')).not.toBeVisible();
});
test('Scenario: Delete checked items removes only done items', async ({ authenticatedPage: page }) => {
// Given a checklist note with two checked and one unchecked item
await createChecklistNote(page, 'Cleanup', ['Keep me', 'Done 1', 'Done 2']);
await noteCard(page, 'Cleanup').click();
await page.getByTestId('checklist-checkbox').nth(1).click();
await page.getByTestId('checklist-checkbox').nth(1).click();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
// When the user deletes checked items via the overflow menu
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('delete-checked-btn').click();
// Then only the unchecked item remains
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Keep me');
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
});
test('Scenario: Uncheck all items moves everything back to active', async ({ authenticatedPage: page }) => {
// Given a checklist note with checked items
await createChecklistNote(page, 'Reset', ['Item A', 'Item B']);
await noteCard(page, 'Reset').click();
await page.getByTestId('checklist-checkbox').first().click();
await page.getByTestId('checklist-checkbox').first().click();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
// When the user unchecks all via the overflow menu
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('uncheck-all-btn').click();
// Then all items are back in the active section
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
await expect(page.getByTestId('checklist-toggle-done')).not.toBeVisible();
});
});
+10 -4
View File
@@ -13,11 +13,17 @@ async function runTiptapCommand(page: Page, commandFn: string) {
);
}
/** Toggle markdown mode via the overflow menu. */
async function toggleMarkdownMode(page: Page) {
await page.getByTestId('overflow-menu-btn').click();
await page.getByTestId('markdown-toggle').click();
}
/** Type content into the TipTap editor via markdown mode (reliable for e2e) */
async function typeViaMarkdown(page: Page, content: string) {
await page.getByTestId('markdown-toggle').click();
await toggleMarkdownMode(page);
await page.getByTestId('note-content-input').fill(content);
await page.getByTestId('markdown-toggle').click();
await toggleMarkdownMode(page);
}
test.describe('Rich text formatting', () => {
@@ -34,7 +40,7 @@ test.describe('Rich text formatting', () => {
await expect(editor.locator('strong')).toHaveText('world');
// And the markdown content contains bold syntax
await page.getByTestId('markdown-toggle').click();
await toggleMarkdownMode(page);
await expect(page.getByTestId('note-content-input')).toHaveValue('hello **world**');
});
@@ -95,7 +101,7 @@ test.describe('Rich text formatting', () => {
await expect(editor.locator('strong')).toHaveText('bold');
// When the user toggles markdown mode
await page.getByTestId('markdown-toggle').click();
await toggleMarkdownMode(page);
// Then the raw markdown textarea is shown with the markdown source
await expect(page.getByTestId('note-content-input')).toBeVisible();
+13 -1
View File
@@ -1,6 +1,8 @@
import { existsSync, unlinkSync } from 'fs';
import { existsSync, unlinkSync, writeFileSync } from 'fs';
import { randomUUID } from 'crypto';
const TEST_DB = './data/test-crumbs.db';
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
export default function globalSetup() {
// Clean test database before E2E run so each run starts fresh
@@ -10,4 +12,14 @@ export default function globalSetup() {
unlinkSync(path);
}
}
// Generate random passwords for this test run, shared across all workers
writeFileSync(
TEST_CREDENTIALS_FILE,
JSON.stringify({
testPassword: `test-${randomUUID()}`,
collabPassword: `collab-${randomUUID()}`,
userPassword: `user-${randomUUID()}`
})
);
}
+6 -2
View File
@@ -1,12 +1,16 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test';
import { readFileSync } from 'fs';
import { collectCoverage } from './coverage';
import { TEST_CREDENTIALS_FILE } from '../global-setup';
const { testPassword, collabPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
const TEST_EMAIL = 'admin@test.com';
const TEST_PASSWORD = 'testpassword123';
const TEST_PASSWORD = testPassword;
const TEST_DISPLAY_NAME = 'Test Admin';
const COLLAB_EMAIL = 'collab@test.com';
const COLLAB_PASSWORD = 'collabpass123';
const COLLAB_PASSWORD = collabPassword;
const COLLAB_NAME = 'Collaborator User';
/** Find a specific note card by its title text */
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.25.0</span>
<span class="footer-version">v0.27.1</span>
</div>
</footer>