diff --git a/.playwright-mcp/console-2026-03-12T20-20-32-009Z.log b/.playwright-mcp/console-2026-03-12T20-20-32-009Z.log
new file mode 100644
index 0000000..b5995ba
--- /dev/null
+++ b/.playwright-mcp/console-2026-03-12T20-20-32-009Z.log
@@ -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
diff --git a/.playwright-mcp/console-2026-03-12T20-21-06-783Z.log b/.playwright-mcp/console-2026-03-12T20-21-06-783Z.log
new file mode 100644
index 0000000..336dfad
--- /dev/null
+++ b/.playwright-mcp/console-2026-03-12T20-21-06-783Z.log
@@ -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
diff --git a/.playwright-mcp/console-2026-03-12T20-22-38-444Z.log b/.playwright-mcp/console-2026-03-12T20-22-38-444Z.log
new file mode 100644
index 0000000..e022d7e
--- /dev/null
+++ b/.playwright-mcp/console-2026-03-12T20-22-38-444Z.log
@@ -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
diff --git a/.playwright-mcp/console-2026-03-12T20-23-16-654Z.log b/.playwright-mcp/console-2026-03-12T20-23-16-654Z.log
new file mode 100644
index 0000000..053a6e0
--- /dev/null
+++ b/.playwright-mcp/console-2026-03-12T20-23-16-654Z.log
@@ -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
diff --git a/.playwright-mcp/console-2026-03-12T20-23-45-907Z.log b/.playwright-mcp/console-2026-03-12T20-23-45-907Z.log
new file mode 100644
index 0000000..2b22157
--- /dev/null
+++ b/.playwright-mcp/console-2026-03-12T20-23-45-907Z.log
@@ -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
diff --git a/docs/superpowers/plans/2026-04-08-checklist-hyperlinks.md b/docs/superpowers/plans/2026-04-08-checklist-hyperlinks.md
new file mode 100644
index 0000000..8f9899b
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-08-checklist-hyperlinks.md
@@ -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 `` tags. Active checklist items switch from `` 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 https://example.com today'
+ );
+ });
+
+ it('converts http URLs to anchor tags', () => {
+ expect(linkifyText('go to http://example.com')).toBe(
+ 'go to http://example.com'
+ );
+ });
+
+ it('converts bare www URLs with https href', () => {
+ expect(linkifyText('check www.example.com')).toBe(
+ 'check www.example.com'
+ );
+ });
+
+ 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('')).toBe(
+ '<script>alert("xss")</script>'
+ );
+ });
+
+ it('HTML-escapes text around URLs', () => {
+ expect(linkifyText('bold https://example.com')).toContain('<b>bold</b>');
+ expect(linkifyText('bold https://example.com')).toContain('href="https://example.com"');
+ });
+
+ it('handles URL at start of string', () => {
+ expect(linkifyText('https://example.com is great')).toContain('href="https://example.com"');
+ });
+
+ it('handles URL at end of string', () => {
+ expect(linkifyText('visit https://example.com')).toContain('href="https://example.com"');
+ });
+
+ it('does not linkify partial matches without protocol or www', () => {
+ expect(linkifyText('example.com is not linked')).toBe('example.com is not linked');
+ });
+
+ it('handles URLs with paths and query strings', () => {
+ const url = 'https://example.com/path?q=1&b=2';
+ const result = linkifyText(url);
+ expect(result).toContain('href="https://example.com/path?q=1&b=2"');
+ });
+
+ it('strips trailing punctuation from URLs', () => {
+ expect(linkifyText('see https://example.com.')).toBe(
+ 'see https://example.com.'
+ );
+ });
+
+ 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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<]+[^\s<.,;:!?)'"}\]]/g;
+
+export function linkifyText(text: string): string {
+ if (!text) return '';
+
+ const matches: { index: number; match: string; href: string }[] = [];
+ let m: RegExpExecArray | null;
+ const regex = new RegExp(URL_REGEX.source, 'g');
+
+ while ((m = regex.exec(text)) !== null) {
+ const match = m[0];
+ const href = match.startsWith('www.') ? `https://${match}` : match;
+ matches.push({ index: m.index, match, href });
+ }
+
+ if (matches.length === 0) return escapeHtml(text);
+
+ let result = '';
+ let lastIndex = 0;
+ for (const { index, match, href } of matches) {
+ result += escapeHtml(text.slice(lastIndex, index));
+ result += `${escapeHtml(match)}`;
+ 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 https://example.com 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('https://a.com and https://b.com'))
+ .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
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
+
+
+
+
+
+
e.stopPropagation()}
+ data-testid="link-popover"
+>
+
+
+```
+
+- [ ] **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 `` 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 ``. 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 `` 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('[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('[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(`[data-item-id="${newId}"]`)?.focus();
+}, 0);
+```
+
+- [ ] **Step 6: Replace `` with `contenteditable` div in the template**
+
+Replace the `` block (lines 279-288) with:
+
+```svelte
+
+ 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)}
+```
+
+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 `` (before line 355):
+
+```svelte
+{#if linkPopover}
+ (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
+{item.text}
+```
+
+Replace with:
+
+```svelte
+{@html linkifyText(item.text)}
+```
+
+- [ ] **Step 2: Linkify done parent label text**
+
+Find the parent label span (line 327):
+
+```svelte
+{group.parentLabel.text}
+```
+
+Replace with:
+
+```svelte
+{@html linkifyText(group.parentLabel.text)}
+```
+
+- [ ] **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
+{item.text}
+```
+
+Replace with:
+
+```svelte
+{@html linkifyText(item.text)}
+```
+
+- [ ] **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 `` (line 134) to intercept link clicks:
+
+Replace:
+```svelte
+
+```
+
+With:
+```svelte
+
+ { 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
+{item.text}
+```
+
+Replace with:
+
+```svelte
+{@html linkifyText(item.text)}
+```
+
+- [ ] **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 `` 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
diff --git a/docs/superpowers/specs/2026-03-20-dark-mode-design.md b/docs/superpowers/specs/2026-03-20-dark-mode-design.md
deleted file mode 100644
index 6b2b453..0000000
--- a/docs/superpowers/specs/2026-03-20-dark-mode-design.md
+++ /dev/null
@@ -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 ``, 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 `
-```
-
-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 ``. 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
-
- {#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
-
- {/each}
-
-
-```
-
-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 `` 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)
diff --git a/docs/superpowers/specs/2026-03-20-public-note-sharing-design.md b/docs/superpowers/specs/2026-03-20-public-note-sharing-design.md
deleted file mode 100644
index 119e469..0000000
--- a/docs/superpowers/specs/2026-03-20-public-note-sharing-design.md
+++ /dev/null
@@ -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` | 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
-// 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
diff --git a/docs/superpowers/specs/2026-03-20-screenshot-automation-design.md b/docs/superpowers/specs/2026-03-20-screenshot-automation-design.md
deleted file mode 100644
index 6344988..0000000
--- a/docs/superpowers/specs/2026-03-20-screenshot-automation-design.md
+++ /dev/null
@@ -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.
diff --git a/docs/superpowers/specs/2026-04-08-checklist-hyperlinks-design.md b/docs/superpowers/specs/2026-04-08-checklist-hyperlinks-design.md
new file mode 100644
index 0000000..55ffe02
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-08-checklist-hyperlinks-design.md
@@ -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 `` 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 ``
+
+In `Checklist.svelte`, replace each `` (line 279-288) with a `` 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 `
` 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
diff --git a/src/lib/components/Checklist.svelte b/src/lib/components/Checklist.svelte
index 58628fd..070487f 100644
--- a/src/lib/components/Checklist.svelte
+++ b/src/lib/components/Checklist.svelte
@@ -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 @@
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)}
+ >