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)}
        + >