Compare commits

...
11 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
11 changed files with 261 additions and 43 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.25.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.26.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()
};
+3 -1
View File
@@ -143,7 +143,8 @@ body::before {
}
/* Nested task lists take full width below the parent item */
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"] {
.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;
}
@@ -166,6 +167,7 @@ body::before {
.prose ul.task-list .task-checkbox {
cursor: pointer;
margin: 0;
margin-top: 0.2em;
flex-shrink: 0;
}
+141 -28
View File
@@ -63,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 ?? []);
@@ -188,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) {
@@ -200,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);
@@ -215,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);
}
@@ -226,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;
@@ -241,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() {
@@ -302,7 +413,7 @@
showOverflowMenu = false;
if (noteId) {
await archiveNote(noteId);
onClose();
dismissOverlay();
}
}
@@ -310,7 +421,7 @@
showOverflowMenu = false;
if (noteId) {
await trashNote(noteId);
onClose();
dismissOverlay();
}
}
@@ -329,12 +440,13 @@
<!-- 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]"
@@ -584,6 +696,7 @@
{/if}
</div>
{/if}
{/if}
</div>
{#if showShareDialog && noteId}
+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({
+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();
});
});
+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.26.0</span>
<span class="footer-version">v0.27.1</span>
</div>
</footer>