From d108c48669dd3d5473d258c4d8ecc6a0c4b08a12 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 10:07:54 +0000 Subject: [PATCH 1/3] redesign: add-bookmark flow as bottom sheet (mobile) / dialog (desktop) Redesigns the add-bookmark flow from first principles around its one job: capture a link fast. New bookmarks no longer use the inline draft card at the top of the list; they go through a new AddBookmarkDialog. Why: the inline card scrolled with the list under the sticky header, so on phones the tags row and Save/Cancel actions routinely ended up under the soft keyboard, Enter-to-save was the only reliable affordance, and multi-line notes were impossible (Enter saved, Shift+Enter doesn't exist on touch). The URL - the only required field - was a 16px line at the top of a form that gave title and description equal billing. The new dialog: - Bottom sheet on mobile: pinned to the viewport bottom with a sticky footer, so the full-width Save button stays visible above the keyboard (verified at 390x450 keyboard-up viewport - the body scrolls, the footer doesn't). index.html now sets interactive-widget=resizes-content so Android Chrome resizes the layout viewport. Centered top-anchored dialog on sm+. - URL first: the only required field, hero position, one-tap Paste button inside the field, Save disabled until a URL is entered, inline errors for invalid/unreadable clipboard. - Content suggestions finally reach manual adds: committing a URL (paste or blur) auto-fills empty title/description and offers suggested tags as one-tap chips when the opt-in feature is enabled. - Notes are a real textarea: Enter inserts a newline, Cmd/Ctrl+Enter saves from anywhere; Enter in URL/title saves (enterKeyHint go/done). - Draft protection: tapping outside a non-empty draft no longer discards it; Cancel and Esc still do. - All touch targets >=44px; Radix Dialog provides focus trap, scroll lock and aria wiring; entrance animations respect prefers-reduced-motion. BookmarkInlineCard is now edit-only (the URL was never editable there anyway since URLs are immutable after create), dropping its isNew branches, paste button and draft badge. Draft-card tests moved to AddBookmarkDialog.test.jsx and extended (suggestions, disabled save, newline-in-notes); inline card tests trimmed to edit behaviors. Verified with Playwright at 390x844 (touch), 390x450 (keyboard up) and 1440x900: paste -> autofill -> tag -> read-later -> save -> toast -> appears in list and in the Read Later filter; invalid URL and dirty- draft dismissal probed. npm run test:coverage passes all thresholds (52 files, 1282 tests). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NibfKDyuyzU7MPgyeNe7yD --- .claude/skills/verify/SKILL.md | 41 ++ docs/ux-audit-mobile-add-bookmark.md | 48 +- index.html | 2 +- src/app.css | 50 ++ .../bookmarks/AddBookmarkDialog.jsx | 436 ++++++++++++++++++ .../bookmarks/AddBookmarkDialog.test.jsx | 284 ++++++++++++ .../bookmarks/BookmarkInlineCard.jsx | 276 ++--------- .../bookmarks/BookmarkInlineCard.test.jsx | 216 ++------- src/components/bookmarks/BookmarkList.jsx | 18 +- 9 files changed, 942 insertions(+), 429 deletions(-) create mode 100644 .claude/skills/verify/SKILL.md create mode 100644 src/components/bookmarks/AddBookmarkDialog.jsx create mode 100644 src/components/bookmarks/AddBookmarkDialog.test.jsx diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..64410eb --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,41 @@ +--- +name: verify +description: Build, launch, and drive Hypermark to verify UI changes end-to-end. +--- + +# Verifying Hypermark changes + +## Launch + +```bash +npm install # fresh containers have no node_modules +npx vite --port 5199 --strictPort & # dev server; ready in ~3s, serves on http://localhost:5199/ +``` + +The app is fully usable without a signaling server or Nostr relays — sync +just stays offline. Console shows `ERR_TUNNEL_CONNECTION_FAILED` for the +WebRTC signaling websocket and Google favicon fetches in sandboxed +environments; that's environment noise, not app breakage. + +## Drive (Playwright) + +Chromium is pre-installed; `playwright` (npm) must be installed in a +scratch dir. Launch with: + +```js +chromium.launch({ executablePath: '/opt/pw-browsers/chromium-1194/chrome-linux/chrome' }) +// note: /opt/pw-browsers/chromium/ exists but has no chrome-linux/ inside — use the versioned dir +``` + +Useful contexts: +- Mobile: `{ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }` +- Desktop: `{ viewport: { width: 1440, height: 900 } }` +- Keyboard-up simulation: viewport height ~450 +- Clipboard flows need `permissions: ['clipboard-read', 'clipboard-write']` + +## Flows worth driving + +- Add bookmark: header "+ Add" button (`aria-label="Add bookmark"`) → + AddBookmarkDialog (bottom sheet on mobile, centered dialog on sm+). +- Read Later filter: navigate to `#/?filter=readlater`. +- Bookmarks persist in IndexedDB (y-indexeddb) — a fresh context starts empty. diff --git a/docs/ux-audit-mobile-add-bookmark.md b/docs/ux-audit-mobile-add-bookmark.md index 9dccd1b..65a13a0 100644 --- a/docs/ux-audit-mobile-add-bookmark.md +++ b/docs/ux-audit-mobile-add-bookmark.md @@ -86,21 +86,45 @@ by hiding the ↓ hint). ## Backlog (real, but not top 3) -4. **Multi-line descriptions are impossible on mobile.** Enter in the - description textarea saves the card; newline requires Shift+Enter, which - soft keyboards don't have. -5. **Manual adds never fetch content suggestions.** The paste and share-target - paths auto-fill title/description via `content-suggestion`, but the draft - card doesn't use `useContentSuggestion` at all — on mobile, typing a title - is exactly the thing users want automated. -6. **Draft is lost silently.** Tapping outside/Cancel discards a typed draft - with no undo; Android back gesture closes the PWA entirely. -7. **The draft card renders above the list but below the sticky header** — on - short viewports with the keyboard up, the tags/save row can end up under - the keyboard; the card could render in a bottom sheet on mobile instead. +4. **Multi-line descriptions are impossible on mobile.** — FIXED by the + redesign below: in `AddBookmarkDialog` the notes field is a normal + textarea where Enter inserts a newline (Cmd/Ctrl+Enter saves). +5. **Manual adds never fetch content suggestions.** — FIXED by the redesign + below: committing a URL (paste or blur) fetches suggestions when the + feature is enabled, auto-fills empty title/description, and offers + suggested tags as one-tap chips. +6. **Draft is lost silently.** — Largely fixed: tapping outside a non-empty + draft no longer discards it (Cancel/Esc still do, as explicit intent). +7. **The draft card renders above the list but below the sticky header** — + FIXED by the redesign below: adding now happens in a bottom sheet with a + sticky save footer instead of an inline card. 8. **Share-target duplicate toast is dead-end** — "Already bookmarked" could offer "View it" to jump to the existing bookmark. +--- + +## Redesign (follow-up to this audit) + +The add flow was subsequently redesigned from first principles around one +job: *capture a link fast*. New bookmarks no longer use the inline draft +card (`BookmarkInlineCard` is now edit-only); they go through +`AddBookmarkDialog`: + +- **Mobile (<640px): bottom sheet.** Pinned to the bottom of the viewport + with a sticky footer, so the full-width **Save bookmark** button stays + visible above the soft keyboard (verified at 390×450, i.e. keyboard up — + the body scrolls, the footer doesn't move). `index.html` sets + `interactive-widget=resizes-content` so Android Chrome resizes the layout + viewport instead of overlaying the keyboard. +- **Desktop (≥640px): centered dialog**, top-anchored so it doesn't jump + when suggestions fill in. Enter/Esc and Cmd+Enter still work; the + keyboard hints stay desktop-only. +- **URL is the hero.** The only required field, first in the sheet, with + the one-tap Paste button from finding 2 inside the field. Save is + disabled until a URL is entered; invalid URLs get an inline error. +- **All touch targets ≥44px** on mobile (inputs h-12, save h-12, whole + "Read later" row is the switch). + ## Verification - Playwright at 390×844 (mobile emulation, touch) and 1440×900: draft card diff --git a/index.html b/index.html index 0444110..cf54c30 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + diff --git a/src/app.css b/src/app.css index 4300aba..76e598c 100644 --- a/src/app.css +++ b/src/app.css @@ -32,6 +32,56 @@ body { color: var(--color-foreground); } +/* Add-bookmark dialog: slide up as a sheet on mobile, fade/scale on desktop */ +.add-dialog-overlay[data-state="open"] { + animation: add-dialog-fade-in 180ms ease-out; +} + +@keyframes add-dialog-fade-in { + from { + opacity: 0; + } +} + +@media (max-width: 39.99rem) { + .add-dialog-content[data-state="open"] { + animation: add-sheet-in 260ms cubic-bezier(0.16, 1, 0.3, 1); + } + + @keyframes add-sheet-in { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } + } +} + +@media (min-width: 40rem) { + .add-dialog-content[data-state="open"] { + animation: add-dialog-in 150ms ease-out; + } + + @keyframes add-dialog-in { + from { + opacity: 0; + transform: translate(-50%, 8px) scale(0.98); + } + to { + opacity: 1; + transform: translate(-50%, 0) scale(1); + } + } +} + +@media (prefers-reduced-motion: reduce) { + .add-dialog-overlay[data-state="open"], + .add-dialog-content[data-state="open"] { + animation: none; + } +} + /* Hide browser's native search clear button */ input[type="text"]::-webkit-search-cancel-button, input[type="search"]::-webkit-search-cancel-button { diff --git a/src/components/bookmarks/AddBookmarkDialog.jsx b/src/components/bookmarks/AddBookmarkDialog.jsx new file mode 100644 index 0000000..169d5d7 --- /dev/null +++ b/src/components/bookmarks/AddBookmarkDialog.jsx @@ -0,0 +1,436 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X, Link2, ClipboardPaste, Loader2, Plus } from 'lucide-react' +import { TagInput } from '../ui/TagInput' +import { Tag } from '../ui/Tag' +import { + getAllTags, + createBookmark, + isValidUrl, + normalizeUrl, +} from '../../services/bookmarks' +import { useContentSuggestion } from '../../hooks/useContentSuggestion' +import { cn } from '@/utils/cn' + +/** + * AddBookmarkDialog - The add-bookmark flow, redesigned around one job: + * capture a link fast. + * + * - URL is the only required field and gets the hero spot, with one-tap + * Paste when the clipboard is available. + * - Renders as a bottom sheet on mobile (save button always visible above + * the keyboard) and a centered dialog on desktop. + * - If content suggestions are enabled, committing a URL auto-fills the + * title/description and offers suggested tags. + * - Enter in URL/title saves; Enter in the description inserts a newline + * (Cmd/Ctrl+Enter saves from anywhere). + * - Tapping outside won't discard a non-empty draft; Cancel/Esc will. + */ +export function AddBookmarkDialog({ open, onClose, onSaved }) { + const urlInputRef = useRef(null) + const titleInputRef = useRef(null) + + const [url, setUrl] = useState('') + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [tags, setTags] = useState([]) + const [readLater, setReadLater] = useState(false) + const [urlError, setUrlError] = useState('') + const [allTags, setAllTags] = useState([]) + // Async Clipboard API is unavailable in some browsers (e.g. Firefox + // without permission) — only offer the Paste button when readText exists. + const [canPasteFromClipboard] = useState( + () => typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.readText) + ) + + const { + suggestions, + loading: suggestionsLoading, + suggest, + clear: clearSuggestions, + enabled: suggestionsEnabled, + } = useContentSuggestion() + + // Track which fields the user has typed in, so suggestions never + // overwrite manual input. + const userEditedRef = useRef({ title: false, description: false }) + const lastSuggestedUrlRef = useRef('') + + // Reset to a blank draft every time the dialog opens + useEffect(() => { + if (open) { + setUrl('') + setTitle('') + setDescription('') + setTags([]) + setReadLater(false) + setUrlError('') + userEditedRef.current = { title: false, description: false } + lastSuggestedUrlRef.current = '' + clearSuggestions() + try { + setAllTags(getAllTags()) + } catch { + setAllTags([]) + } + } + }, [open, clearSuggestions]) + + // Fill empty fields when suggestions arrive + useEffect(() => { + if (!suggestions) return + if (suggestions.title && !userEditedRef.current.title) { + setTitle(suggestions.title) + } + if (suggestions.description && !userEditedRef.current.description) { + setDescription(suggestions.description) + } + }, [suggestions]) + + const suggestedTags = useMemo(() => { + if (!suggestions?.suggestedTags) return [] + return suggestions.suggestedTags + .map((t) => t.toLowerCase()) + .filter((t) => !tags.includes(t)) + .slice(0, 6) + }, [suggestions, tags]) + + let domain = '' + if (url && isValidUrl(url)) { + try { + domain = new URL(normalizeUrl(url)).hostname.replace('www.', '') + } catch { + domain = '' + } + } + const faviconUrl = domain + ? `https://www.google.com/s2/favicons?domain=${domain}&sz=32` + : null + + // Fetch title/description/tag suggestions once per committed URL + const requestSuggestions = useCallback( + (rawUrl) => { + if (!suggestionsEnabled || !isValidUrl(rawUrl)) return + const normalized = normalizeUrl(rawUrl) + if (normalized === lastSuggestedUrlRef.current) return + lastSuggestedUrlRef.current = normalized + suggest(normalized) + }, + [suggestionsEnabled, suggest] + ) + + const handleUrlBlur = () => { + if (!url.trim()) return + if (isValidUrl(url)) { + setUrlError('') + requestSuggestions(url) + } else { + setUrlError('Invalid URL') + } + } + + // One-tap paste for touch devices, where the document-level + // paste-to-bookmark shortcut can't fire + const handlePasteUrl = async () => { + let text = '' + try { + text = (await navigator.clipboard.readText())?.trim() || '' + } catch { + setUrlError("Couldn't read clipboard") + return + } + if (!text) { + setUrlError('Clipboard is empty') + return + } + if (!isValidUrl(text)) { + setUrlError("Clipboard doesn't contain a link") + return + } + const normalized = normalizeUrl(text) + setUrl(normalized) + setUrlError('') + requestSuggestions(normalized) + titleInputRef.current?.focus() + } + + const handleSave = useCallback(() => { + if (!url.trim()) { + setUrlError('URL is required') + urlInputRef.current?.focus() + return + } + if (!isValidUrl(url)) { + setUrlError('Invalid URL') + urlInputRef.current?.focus() + return + } + + const normalized = normalizeUrl(url) + const data = { + url: normalized, + title: title.trim() || normalized, + description, + tags, + readLater, + } + + try { + createBookmark(data) + onSaved?.(data) + onClose?.() + } catch (error) { + console.error('Failed to save bookmark:', error) + setUrlError('Failed to save bookmark') + } + }, [url, title, description, tags, readLater, onSaved, onClose]) + + const isDirty = + url.trim() !== '' || title.trim() !== '' || description.trim() !== '' || tags.length > 0 + + const handleOpenChange = (nextOpen) => { + if (!nextOpen) onClose?.() + } + + // Cmd/Ctrl+Enter saves from any field + const handleContentKeyDown = (e) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + handleSave() + } + } + + const handleEnterSaves = (e) => { + if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + handleSave() + } + } + + const addSuggestedTag = (tag) => { + if (!tags.includes(tag)) setTags([...tags, tag]) + } + + const removeTag = (tag) => setTags(tags.filter((t) => t !== tag)) + + const canSave = url.trim() !== '' + + return ( + + + + { + e.preventDefault() + urlInputRef.current?.focus() + }} + onInteractOutside={(e) => { + // Don't silently discard a non-empty draft on an outside tap + if (isDirty) e.preventDefault() + }} + onKeyDown={handleContentKeyDown} + aria-describedby={undefined} + className={cn( + 'add-dialog-content fixed z-50 flex flex-col bg-card shadow-2xl outline-none', + // Mobile: bottom sheet pinned above the keyboard + 'inset-x-0 bottom-0 max-h-[85dvh] rounded-t-2xl border-t border-border', + // Desktop: top-anchored centered panel (doesn't jump when suggestions fill in) + 'sm:inset-x-auto sm:bottom-auto sm:left-1/2 sm:top-[14vh] sm:-translate-x-1/2', + 'sm:w-full sm:max-w-lg sm:max-h-[72vh] sm:rounded-xl sm:border' + )} + > + {/* Header */} +
+ + New bookmark + + + + +
+ + {/* Body */} +
+ {/* URL — the only required field, gets the hero spot */} +
+
+ {faviconUrl ? ( + { + e.target.style.opacity = 0 + }} + /> + ) : ( + + )} + { + setUrl(e.target.value) + setUrlError('') + }} + onBlur={handleUrlBlur} + onKeyDown={handleEnterSaves} + className="flex-1 min-w-0 h-full bg-transparent border-none outline-none text-sm placeholder:text-muted-foreground/60 focus:ring-0" + placeholder="Paste or type a link" + /> + {canPasteFromClipboard && !url.trim() && ( + + )} +
+ {urlError && ( +

{urlError}

+ )} + {suggestionsLoading && ( +

+ + Fetching page info… +

+ )} +
+ + {/* Title */} + { + userEditedRef.current.title = true + setTitle(e.target.value) + }} + onKeyDown={handleEnterSaves} + className="w-full h-12 sm:h-11 rounded-lg border border-input bg-background px-3 text-sm font-medium placeholder:text-muted-foreground/60 placeholder:font-normal outline-none focus:ring-2 focus:ring-ring transition-shadow" + placeholder="Title (optional)" + /> + + {/* Description — Enter inserts a newline, like any textarea */} +