fix: use Svelte action for contenteditable to prevent cursor destruction

The reactive {#html} binding inside contenteditable replaced innerHTML on
every keystroke, destroying the cursor and causing text duplication.

Instead, use a use:editableContent action that sets innerHTML on mount
and only syncs when the element is NOT focused. Linkification happens
on blur, matching Google Keep's behavior. Update e2e tests to blur
before asserting link visibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-04-08 22:36:19 +02:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent 0ece0b7b7f
commit 4c13d345fc
13 changed files with 1160 additions and 747 deletions
@@ -0,0 +1 @@
[ 77ms] [WARNING] Error while trying to use the following icon from the Manifest: http://localhost:4174/pwa-192x192.png (Download error or resource isn't a valid image) @ http://localhost:4174/login:0
@@ -0,0 +1 @@
[ 64ms] [WARNING] Error while trying to use the following icon from the Manifest: http://localhost:4174/pwa-192x192.png (Download error or resource isn't a valid image) @ http://localhost:4174/login:0
@@ -0,0 +1,2 @@
[ 47ms] [WARNING] Error while trying to use the following icon from the Manifest: http://localhost:4174/pwa-192x192.png (Download error or resource isn't a valid image) @ http://localhost:4174/login:0
[ 10288ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:4174/api/auth/login:0
@@ -0,0 +1,3 @@
[ 38ms] [WARNING] Error while trying to use the following icon from the Manifest: http://localhost:4174/pwa-192x192.png (Download error or resource isn't a valid image) @ http://localhost:4174/login:0
[ 6586ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:4174/api/trips:0
[ 6595ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:4174/api/trips:0
@@ -0,0 +1,7 @@
[ 58ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4174/pwa-192x192.png:0
[ 58ms] [WARNING] Error while trying to use the following icon from the Manifest: http://localhost:4174/pwa-192x192.png (Download error or resource isn't a valid image) @ http://localhost:4174/:0
[ 59ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:4174/api/trips:0
[ 63ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:4174/api/trips:0
[ 21790ms] [ERROR] Failed to load resource: net::ERR_INTERNET_DISCONNECTED @ http://localhost:4174/pwa-192x192.png:0
[ 21790ms] [WARNING] Error while trying to use the following icon from the Manifest: http://localhost:4174/pwa-192x192.png (Download error or resource isn't a valid image) @ http://localhost:4174/:0
[ 21795ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:4174/api/trips:0
@@ -0,0 +1,994 @@
# Checklist Hyperlinks 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:** Auto-detect URLs in checklist items and render them as clickable hyperlinks across all views (editor, done section, card preview, shared view), with a link popover in the editor matching Google Keep's UX.
**Architecture:** A `linkifyText()` utility HTML-escapes text then wraps detected URLs in `<a>` tags. Active checklist items switch from `<input type="text">` to `contenteditable` divs to render inline links. A `LinkPopover` component appears on link click in the editor. Read-only views (done section, NoteCard, shared view) use `{@html linkifyText()}` with direct click-to-open.
**Tech Stack:** Svelte 5, TypeScript, Vitest, Playwright, Lucide icons
**Spec:** `docs/superpowers/specs/2026-04-08-checklist-hyperlinks-design.md`
---
### Task 1: `linkifyText` and `unlinkifyHtml` utilities
**Files:**
- Modify: `src/lib/utils/checklist.ts` (append new functions after line 153)
- Test: `src/lib/utils/checklist.test.ts` (append new describe blocks)
- [ ] **Step 1: Write failing tests for `linkifyText`**
Add to `src/lib/utils/checklist.test.ts`:
```typescript
import { linkifyText, unlinkifyHtml } from '$lib/utils/checklist.js';
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" 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" 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" 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(
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
);
});
it('HTML-escapes text around URLs', () => {
expect(linkifyText('<b>bold</b> https://example.com')).toContain('&lt;b&gt;bold&lt;/b&gt;');
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&amp;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" class="checklist-link">https://example.com</a>.'
);
});
it('returns empty string for empty input', () => {
expect(linkifyText('')).toBe('');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm vitest run src/lib/utils/checklist.test.ts`
Expected: FAIL — `linkifyText` is not exported from checklist.ts
- [ ] **Step 3: Implement `linkifyText`**
Add to `src/lib/utils/checklist.ts` after the `serializeChecklist` function:
```typescript
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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" class="checklist-link">${escapeHtml(match)}</a>`;
lastIndex = index + match.length;
}
result += escapeHtml(text.slice(lastIndex));
return result;
}
```
- [ ] **Step 4: Run `linkifyText` tests to verify they pass**
Run: `pnpm vitest run src/lib/utils/checklist.test.ts`
Expected: All `linkifyText` tests PASS
- [ ] **Step 5: Write failing tests for `unlinkifyHtml`**
Add to `src/lib/utils/checklist.test.ts`:
```typescript
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');
});
});
```
- [ ] **Step 6: Run tests to verify they fail**
Run: `pnpm vitest run src/lib/utils/checklist.test.ts`
Expected: FAIL — `unlinkifyHtml` is not exported
- [ ] **Step 7: Implement `unlinkifyHtml`**
Add to `src/lib/utils/checklist.ts` after `linkifyText`:
```typescript
export function unlinkifyHtml(html: string): string {
if (!html) return '';
return html.replace(/<[^>]*>/g, '');
}
```
- [ ] **Step 8: Run all tests to verify they pass**
Run: `pnpm vitest run src/lib/utils/checklist.test.ts`
Expected: All tests PASS
- [ ] **Step 9: Commit**
```bash
git add src/lib/utils/checklist.ts src/lib/utils/checklist.test.ts
git commit -m "feat: add linkifyText and unlinkifyHtml utilities for checklist hyperlinks"
```
---
### Task 2: CSS styles for checklist links
**Files:**
- Modify: `src/app.css` (add after line 207, before the tooltip section)
- [ ] **Step 1: Add checklist-link styles**
Add to `src/app.css` after the `.prose ul.task-list li p { margin: 0; }` block (line 207) and before the `/* Custom tooltips */` comment (line 209):
```css
/* Checklist link styles */
.checklist-link {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.checklist-link:hover {
color: var(--primary-hover);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/app.css
git commit -m "style: add checklist-link CSS for hyperlink display"
```
---
### Task 3: LinkPopover component
**Files:**
- Create: `src/lib/components/LinkPopover.svelte`
- [ ] **Step 1: Create the LinkPopover component**
Create `src/lib/components/LinkPopover.svelte`:
```svelte
<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} />
<!-- 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>
```
- [ ] **Step 2: Commit**
```bash
git add src/lib/components/LinkPopover.svelte
git commit -m "feat: add LinkPopover component for checklist hyperlinks"
```
---
### Task 4: Convert active checklist items to `contenteditable`
**Files:**
- Modify: `src/lib/components/Checklist.svelte`
This is the main change. Replace the `<input type="text">` with a `contenteditable` div and integrate the link popover.
- [ ] **Step 1: Add imports and link popover state**
In `src/lib/components/Checklist.svelte`, update the import line for checklist utilities (line 9) to include the new functions:
```typescript
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml } from '$lib/utils/checklist.js';
```
Add the LinkPopover import after the existing component imports (after line 8):
```typescript
import LinkPopover from './LinkPopover.svelte';
```
Add link popover state after the `flipDurationMs` declaration (after line 21):
```typescript
let linkPopover = $state<{ url: string; anchor: DOMRect } | null>(null);
```
- [ ] **Step 2: Update `updateText` to accept contenteditable HTML**
The existing `updateText` function (line 88) accepts a plain text string from `<input>`. For contenteditable, the input event gives us innerHTML. Update `updateText` to extract plain text:
Replace the `updateText` function (lines 88-94):
```typescript
function updateText(id: string, innerHTML: string) {
const item = items.find((i) => i.id === id);
if (item) {
item.text = unlinkifyHtml(innerHTML);
emitChange();
}
}
```
- [ ] **Step 3: Update `handleKeydown` for contenteditable behavior**
The Backspace check (line 132) currently checks `items[index].text === ''`. This still works because `updateText` now extracts plain text on every input event. However, `handleKeydown` also references `data-testid="checklist-input"` when querying for arrow key navigation (lines 136, 140). These selectors need to work with `[contenteditable]` divs instead of `<input>` elements.
Replace the `handleKeydown` function (lines 127-156):
```typescript
function handleKeydown(e: KeyboardEvent, id: string) {
const index = items.findIndex((i) => i.id === id);
if (e.key === 'Enter') {
e.preventDefault();
addItem(index);
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
e.preventDefault();
removeItem(id);
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]');
inputs[Math.max(0, index - 1)]?.focus();
}, 0);
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const inputs = Array.from(document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]'));
const current = inputs.findIndex((el) => el.dataset.itemId === id);
const target = e.key === 'ArrowUp' ? current - 1 : current + 1;
if (target >= 0 && target < inputs.length) {
e.preventDefault();
inputs[target].focus();
}
} else if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
items = indentItem(id, items, activeItems);
emitChange();
} else if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
items = outdentItem(id, items);
emitChange();
}
}
```
(The only changes: `HTMLInputElement` -> `HTMLElement` in the querySelectorAll calls, and `dataset.itemId` for the arrow key lookup.)
- [ ] **Step 4: Add link click handler and paste handler**
Add after the `handleKeydown` function (before the directional lock drag handle section):
```typescript
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') ?? '';
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 }));
}
```
- [ ] **Step 5: Update `addItem` focus to work with contenteditable**
Update the `addItem` function's focus logic (lines 108-110). Change `HTMLInputElement` to `HTMLElement`:
```typescript
setTimeout(() => {
document.querySelector<HTMLElement>(`[data-item-id="${newId}"]`)?.focus();
}, 0);
```
- [ ] **Step 6: Replace `<input type="text">` with `contenteditable` div in the template**
Replace the `<input type="text" ...>` block (lines 279-288) with:
```svelte
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
contenteditable="true"
oninput={(e) => updateText(item.id, (e.target as HTMLElement).innerHTML)}
onkeydown={(e) => handleKeydown(e, item.id)}
onclick={handleLinkClick}
onpaste={handlePaste}
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"
data-testid="checklist-input"
data-item-id={item.id}
role="textbox"
>{@html linkifyText(item.text)}</div>
```
Note: The `{@html ...}` must be on the same line as the opening `>` to avoid extra whitespace in contenteditable.
- [ ] **Step 7: Add empty-state placeholder CSS**
Add to `src/app.css` after the `.checklist-link:hover` rule:
```css
[data-placeholder]:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
pointer-events: none;
}
```
- [ ] **Step 8: Add LinkPopover to the template**
Add at the end of the component template, just before the closing `</div>` (before line 355):
```svelte
{#if linkPopover}
<LinkPopover url={linkPopover.url} anchor={linkPopover.anchor} onClose={() => (linkPopover = null)} />
{/if}
```
- [ ] **Step 9: Run type check**
Run: `pnpm check`
Expected: No type errors
- [ ] **Step 10: Commit**
```bash
git add src/lib/components/Checklist.svelte src/lib/components/LinkPopover.svelte src/app.css
git commit -m "feat: replace checklist text inputs with contenteditable for inline link rendering"
```
---
### Task 5: Linkify the done section in Checklist.svelte
**Files:**
- Modify: `src/lib/components/Checklist.svelte`
- [ ] **Step 1: Linkify done item text**
In `Checklist.svelte`, find the done item text span (line 339):
```svelte
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
```
Replace with:
```svelte
<span class="flex-1 text-sm text-[var(--text-muted)] line-through break-words min-w-0">{@html linkifyText(item.text)}</span>
```
- [ ] **Step 2: Linkify done parent label text**
Find the parent label span (line 327):
```svelte
<span class="text-sm text-[var(--text-muted)]">{group.parentLabel.text}</span>
```
Replace with:
```svelte
<span class="text-sm text-[var(--text-muted)] break-words min-w-0">{@html linkifyText(group.parentLabel.text)}</span>
```
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/Checklist.svelte
git commit -m "feat: linkify URLs in checklist done section"
```
---
### Task 6: Linkify NoteCard checklist preview
**Files:**
- Modify: `src/lib/components/NoteCard.svelte`
- [ ] **Step 1: Add import**
Add `linkifyText` import to NoteCard.svelte. After the existing imports (e.g., after line 15):
```typescript
import { linkifyText } from '$lib/utils/checklist.js';
```
- [ ] **Step 2: Linkify checklist item text**
Find the checklist item text span (line 145):
```svelte
<span class="break-words min-w-0">{item.text}</span>
```
Replace with:
```svelte
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
```
- [ ] **Step 3: Prevent link clicks from opening the note editor**
Links inside the NoteCard should open the URL instead of triggering `onEdit`. The card's `onclick` handler (line 73) currently opens the editor. Links need `e.stopPropagation()`.
Add a click handler on the checklist `<ul>` (line 134) to intercept link clicks:
Replace:
```svelte
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
```
With:
```svelte
<!-- 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(); }}>
```
- [ ] **Step 4: Commit**
```bash
git add src/lib/components/NoteCard.svelte
git commit -m "feat: linkify URLs in NoteCard checklist preview"
```
---
### Task 7: Linkify shared note checklist view
**Files:**
- Modify: `src/routes/(share)/s/[token]/+page.svelte`
- [ ] **Step 1: Add import**
Add `linkifyText` import. After the existing imports (after line 4):
```typescript
import { linkifyText } from '$lib/utils/checklist.js';
```
- [ ] **Step 2: Linkify checklist item text**
Find the checklist item text span (line 83):
```svelte
<span class="break-words min-w-0">{item.text}</span>
```
Replace with:
```svelte
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
```
- [ ] **Step 3: Commit**
```bash
git add "src/routes/(share)/s/[token]/+page.svelte"
git commit -m "feat: linkify URLs in shared note checklist view"
```
---
### Task 8: Update existing e2e tests for contenteditable
**Files:**
- Modify: `tests/e2e/checklist.spec.ts`
The existing tests use `.fill()` and `.toHaveValue()` on checklist inputs, which work with `<input>` but not `contenteditable`. These need to be updated.
- [ ] **Step 1: Update the `createChecklistNote` helper**
Replace the `createChecklistNote` function (lines 11-23):
```typescript
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 toggleChecklistMode(page);
for (let i = 0; i < items.length; i++) {
await page.getByTestId('checklist-input').nth(i).focus();
await page.keyboard.type(items[i]);
if (i < items.length - 1) {
await page.getByTestId('checklist-input').nth(i).press('Enter');
}
}
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, title)).toBeVisible();
}
```
Key change: `.fill()` replaced with `.focus()` + `page.keyboard.type()` (contenteditable doesn't support `.fill()`).
- [ ] **Step 2: Update all `.fill()` calls on checklist inputs throughout the file**
Every `page.getByTestId('checklist-input')...fill('Text')` must change to:
```typescript
await page.getByTestId('checklist-input')...focus();
await page.keyboard.type('Text');
```
Affected tests and the specific changes:
**"Enter key adds a new checklist item" (line 98):**
```typescript
// Before:
await page.getByTestId('checklist-input').first().fill('First item');
// After:
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First item');
```
**"Backspace on empty item removes it" (line 137):**
```typescript
// Before:
await page.getByTestId('checklist-input').first().fill('First item');
// After:
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First item');
```
**"Completed items are separated into a done section" (lines 153-155):**
```typescript
// Before:
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');
// After:
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Done task');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Pending task');
```
**"Arrow keys navigate between checklist items" (lines 174-178):**
```typescript
// Before:
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');
await page.getByTestId('checklist-input').nth(1).press('Enter');
await page.getByTestId('checklist-input').nth(2).fill('Third');
// After:
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('First');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Second');
await page.getByTestId('checklist-input').nth(1).press('Enter');
await page.getByTestId('checklist-input').nth(2).focus();
await page.keyboard.type('Third');
```
**"Tab indents / Shift+Tab outdents" (lines 197-199):**
```typescript
// Before:
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');
// After:
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Parent');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Child');
```
**"Checking a parent checks all its children" (lines 218-223):**
```typescript
// Before:
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');
await page.getByTestId('checklist-input').nth(1).press('Tab');
await page.getByTestId('checklist-input').nth(1).press('Enter');
await page.getByTestId('checklist-input').nth(2).fill('Eggs');
// After:
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Buy groceries');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
await page.getByTestId('checklist-input').nth(1).press('Tab');
await page.getByTestId('checklist-input').nth(1).press('Enter');
await page.getByTestId('checklist-input').nth(2).focus();
await page.keyboard.type('Eggs');
```
**"Checking a child shows read-only parent label" (lines 237-240):**
```typescript
// Before:
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');
// After:
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Groceries');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
```
**"Unchecking a parent from done restores group" (lines 255-258):**
```typescript
// Before:
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');
// After:
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Shopping');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Milk');
```
**"Enter on a child creates a sibling" (lines 291-294):**
```typescript
// Before:
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');
// After:
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('Parent');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).focus();
await page.keyboard.type('Child 1');
```
- [ ] **Step 3: Update all `.toHaveValue()` assertions on checklist inputs**
Replace `.toHaveValue('text')` with `.toHaveText('text')`:
**"Checklist item persists" (line 49):**
```typescript
// Before:
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
// After:
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
```
**"Sequential check and uncheck" (line 91):**
```typescript
// Before:
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
// After:
await expect(page.getByTestId('checklist-input').first()).toHaveText('Buy milk');
```
**"Enter key focuses new item" (line 130):**
```typescript
// Before:
await expect(page.getByTestId('checklist-input').nth(1)).toHaveValue('');
// After:
await expect(page.getByTestId('checklist-input').nth(1)).toHaveText('');
```
**"Completed items separated" (line 162):**
```typescript
// Before:
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
// After:
await expect(page.getByTestId('checklist-input').first()).toHaveText('Pending task');
```
**"Delete checked items" (line 344):**
```typescript
// Before:
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Keep me');
// After:
await expect(page.getByTestId('checklist-input').first()).toHaveText('Keep me');
```
- [ ] **Step 4: Run all checklist e2e tests**
Run: `pnpm test:e2e --grep "Checklist"`
Expected: All existing tests PASS
- [ ] **Step 5: Commit**
```bash
git add tests/e2e/checklist.spec.ts
git commit -m "test: update e2e tests for contenteditable checklist inputs"
```
---
### Task 9: E2E tests for checklist hyperlinks
**Files:**
- Modify: `tests/e2e/checklist.spec.ts`
- [ ] **Step 1: Write e2e tests for hyperlink behavior**
Add to `tests/e2e/checklist.spec.ts` inside the existing `test.describe('Checklist', ...)` block, after the last test:
```typescript
test('Scenario: URL typed in a checklist item becomes a clickable link', async ({ authenticatedPage: page }) => {
// Given a new checklist note
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
// When the user types a URL
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('check https://example.com today');
// Then the URL is rendered as a link
const link = page.getByTestId('checklist-input').first().locator('a');
await expect(link).toBeVisible();
await expect(link).toHaveAttribute('href', 'https://example.com');
});
test('Scenario: Clicking a link in a checklist item shows the link popover', async ({ authenticatedPage: page }) => {
// Given a checklist item with a URL
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('https://example.com');
// When the user clicks the link
await page.getByTestId('checklist-input').first().locator('a').click();
// Then the link popover appears with an Open button
await expect(page.getByTestId('link-popover')).toBeVisible();
await expect(page.getByTestId('link-popover-open')).toBeVisible();
});
test('Scenario: Link popover dismisses on Escape', async ({ authenticatedPage: page }) => {
// Given a link popover is open
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('https://example.com');
await page.getByTestId('checklist-input').first().locator('a').click();
await expect(page.getByTestId('link-popover')).toBeVisible();
// When the user presses Escape
await page.keyboard.press('Escape');
// Then the popover is dismissed
await expect(page.getByTestId('link-popover')).not.toBeVisible();
});
test('Scenario: Links in done section are clickable', async ({ authenticatedPage: page }) => {
// Given a checklist item with a URL is checked
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('https://example.com');
await page.getByTestId('checklist-checkbox').first().click();
// Then the done section shows the URL as a link
await expect(page.getByTestId('checklist-done-section').locator('a.checklist-link')).toBeVisible();
});
test('Scenario: Links in NoteCard preview are rendered', async ({ authenticatedPage: page }) => {
// Given a checklist note with a URL
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Link Note');
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('visit https://example.com');
await page.getByTestId('close-editor-btn').click();
// Then the NoteCard preview shows the URL as a link
const card = noteCard(page, 'Link Note');
await expect(card.locator('a.checklist-link')).toBeVisible();
await expect(card.locator('a.checklist-link')).toHaveAttribute('href', 'https://example.com');
});
```
- [ ] **Step 2: Run e2e tests**
Run: `pnpm test:e2e --grep "Checklist"`
Expected: All checklist tests PASS (both existing and new)
- [ ] **Step 3: Commit**
```bash
git add tests/e2e/checklist.spec.ts
git commit -m "test: add e2e tests for checklist hyperlink support"
```
---
### Task 10: Full verification
- [ ] **Step 1: Run unit tests**
Run: `pnpm vitest run`
Expected: All unit tests PASS
- [ ] **Step 2: Run type check**
Run: `pnpm check`
Expected: No errors
- [ ] **Step 3: Run full e2e test suite**
Run: `pnpm test:e2e`
Expected: All tests PASS
- [ ] **Step 4: Run lint**
Run: `pnpm lint`
Expected: No lint errors
@@ -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)
@@ -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.
@@ -0,0 +1,130 @@
# Checklist Hyperlink Support
**Issue:** bretzel-app/crumbs#59
**Date:** 2026-04-08
## Summary
Add automatic URL detection and clickable hyperlinks to checklist items across all views — the interactive checklist editor, NoteCard previews, done section, and shared note view. Matches Google Keep's behavior where links are recognized immediately as you type.
## URL Detection Utility
New functions in `src/lib/utils/checklist.ts`:
### `linkifyText(text: string): string`
Takes plain text, HTML-escapes it, then wraps detected URLs in `<a>` tags.
- Detects `https://`, `http://`, and bare `www.` prefixes
- Output links have `target="_blank"`, `rel="noopener"`, and `class="checklist-link"`
- HTML-escapes the input **before** wrapping URLs to prevent XSS
- Returns safe HTML string suitable for `{@html ...}`
### `unlinkifyHtml(html: string): string`
Extracts plain text from HTML content (strips all tags). Used to get raw text back from contenteditable divs for serialization to the checklist data model.
## Active Items — `contenteditable` Replacing `<input type="text">`
In `Checklist.svelte`, replace each `<input type="text">` (line 279-288) with a `<div contenteditable="true">` that renders item text with linkified HTML via `{@html linkifyText(item.text)}`.
### Editing behavior
- On `input` event: extract plain text via `unlinkifyHtml()`, update `item.text`, re-render with `linkifyText()`
- Save and restore cursor position after re-linkification to avoid cursor jumping
- On `paste`: intercept and insert `text/plain` only (strip any HTML from clipboard)
- Prevent default on Enter (existing behavior — creates new item)
- All existing keyboard shortcuts preserved: Enter, Backspace (delete empty), Tab (indent), Shift+Tab (outdent), ArrowUp/ArrowDown (navigate)
### Link click behavior
- Clicking an `<a>` tag inside contenteditable prevents default navigation
- Instead, shows a `LinkPopover` component positioned near the clicked link
- The popover has a single "Open" button that opens the URL in a new tab
## Link Popover Component
New component: `src/lib/components/LinkPopover.svelte`
### Props
- `url: string` — the href to open
- `anchor: DOMRect` — bounding rect of the clicked link element
- `onClose: () => void` — callback to dismiss
### Appearance
- Small floating element positioned below the anchor link (or above if near viewport bottom)
- Contains a single "Open" button with the Lucide `external-link` icon
- Styled with the retro parchment theme:
- `bg-[var(--bg-surface)]`
- `border border-[var(--border-subtle)]`
- Hard-offset shadow: `var(--card-shadow)`
- `rounded-sm`
### Dismissal
- Click outside the popover
- Escape key
- Scrolling the checklist
## Read-Only Contexts
### NoteCard Preview (`src/lib/components/NoteCard.svelte`)
Line 145: change `{item.text}` to `{@html linkifyText(item.text)}`.
Links open directly in a new tab on click (no popover — this is a read-only preview).
### Shared Note View (`src/routes/(share)/s/[token]/+page.svelte`)
Line 83: change `{item.text}` to `{@html linkifyText(item.text)}`.
Links open directly in a new tab on click (no popover).
### Done Section in `Checklist.svelte`
Line 339: change `{item.text}` to `{@html linkifyText(item.text)}`.
Links open directly in a new tab on click (no popover — done items aren't being edited).
## Link Styling
Add to `src/app.css`:
```css
.checklist-link {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.checklist-link:hover {
color: var(--primary-hover);
}
```
## Files Changed
| File | Change |
|---|---|
| `src/lib/utils/checklist.ts` | Add `linkifyText()` and `unlinkifyHtml()` |
| `src/lib/components/Checklist.svelte` | `contenteditable` for active items, linkified done section, link popover integration |
| `src/lib/components/LinkPopover.svelte` | New component — "Open" button popover |
| `src/lib/components/NoteCard.svelte` | `{@html linkifyText(item.text)}` in checklist preview |
| `src/routes/(share)/s/[token]/+page.svelte` | `{@html linkifyText(item.text)}` in shared checklist |
| `src/app.css` | `.checklist-link` styles |
## Testing
### Unit tests
- `linkifyText` — detects http/https/www URLs, escapes HTML entities, handles multiple URLs in one string, handles edge cases (URL at start/end, adjacent punctuation)
- `unlinkifyHtml` — strips tags, preserves text content
### E2E tests
- Type a URL in a checklist item, verify it becomes a clickable link
- Click a link, verify the popover appears with "Open" button
- Verify link popover dismisses on click-outside and Escape
- Verify links are clickable in NoteCard preview
- Verify links are clickable in done section
+17 -1
View File
@@ -157,6 +157,20 @@
}
}
// 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');
@@ -305,17 +319,19 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
contenteditable="true"
use:editableContent={item.text}
oninput={(e) => updateText(item.id, (e.target as HTMLElement).innerHTML)}
onkeydown={(e) => handleKeydown(e, item.id)}
onclick={handleLinkClick}
onpaste={handlePaste}
onblur={(e) => { (e.target as HTMLElement).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"
data-testid="checklist-input"
data-item-id={item.id}
role="textbox"
tabindex="0"
>{@html linkifyText(item.text)}</div>
></div>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
+1 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Crumbs API",
"description": "Auto-generated API spec for crumbs",
"version": "0.24.0",
"version": "0.26.0",
"contact": {
"name": "Bretzel",
"url": "https://bretzel.app"
+4 -1
View File
@@ -386,9 +386,10 @@ test.describe('Checklist', () => {
await page.getByTestId('new-note-btn').click();
await toggleChecklistMode(page);
// When the user types a URL
// When the user types a URL and leaves the field
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('check https://example.com today');
await page.getByTestId('note-title-input').focus();
// Then the URL is rendered as a link
const link = page.getByTestId('checklist-input').first().locator('a');
@@ -402,6 +403,7 @@ test.describe('Checklist', () => {
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('https://example.com');
await page.getByTestId('note-title-input').focus();
// When the user clicks the link
await page.getByTestId('checklist-input').first().locator('a').click();
@@ -417,6 +419,7 @@ test.describe('Checklist', () => {
await toggleChecklistMode(page);
await page.getByTestId('checklist-input').first().focus();
await page.keyboard.type('https://example.com');
await page.getByTestId('note-title-input').focus();
await page.getByTestId('checklist-input').first().locator('a').click();
await expect(page.getByTestId('link-popover')).toBeVisible();