diff --git a/docs/superpowers/plans/2026-04-08-checklist-hyperlinks.md b/docs/superpowers/plans/2026-04-08-checklist-hyperlinks.md deleted file mode 100644 index 8f9899b..0000000 --- a/docs/superpowers/plans/2026-04-08-checklist-hyperlinks.md +++ /dev/null @@ -1,994 +0,0 @@ -# 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 `