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..3e64bc1 100644
--- a/src/app.css
+++ b/src/app.css
@@ -32,6 +32,80 @@ body {
color: var(--color-foreground);
}
+/* iOS Safari auto-zooms the page when a focused form control's font-size
+ is below 16px — with the add dialog autofocusing its URL field, that
+ zoomed the whole sheet off screen. Keep every text control at >=16px on
+ small screens (!important to beat Tailwind's .text-sm utility class);
+ desktop keeps the compact sizes. */
+@media (max-width: 39.99rem) {
+ input:not([type="checkbox"]):not([type="radio"]),
+ select,
+ textarea {
+ font-size: 16px !important;
+ }
+}
+
+/* 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) {
+ /* Keep the sheet (and its save footer) above the iOS soft keyboard.
+ The dialog component measures the visual viewport and sets these
+ variables; with no keyboard they resolve to the normal bottom-pinned
+ full-height sheet. Being unlayered, this wins over the component's
+ Tailwind utilities (bottom-0, max-h-[85dvh]). */
+ .add-dialog-content {
+ bottom: var(--keyboard-inset, 0px);
+ max-height: min(85dvh, calc(var(--visual-viewport-height, 100dvh) - 12px));
+ transition: bottom 100ms ease-out;
+ }
+
+ .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..5f4505a
--- /dev/null
+++ b/src/components/bookmarks/AddBookmarkDialog.jsx
@@ -0,0 +1,465 @@
+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('')
+ // Callback-ref state (not a plain ref): Radix assigns the forwarded ref
+ // after parent effects run, so the keyboard-inset effect keys off this.
+ const [contentEl, setContentEl] = useState(null)
+ 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])
+
+ // iOS Safari overlays the soft keyboard instead of resizing the layout
+ // viewport (it ignores interactive-widget=resizes-content), which would
+ // leave the sheet's save footer hidden behind the keyboard. Track the
+ // visual viewport and lift the sheet by the keyboard's height via CSS
+ // variables consumed only by the mobile positioning rules in app.css.
+ useEffect(() => {
+ if (!open || !contentEl) return
+ const vv = window.visualViewport
+ if (!vv) return
+
+ const update = () => {
+ const keyboardInset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
+ contentEl.style.setProperty('--keyboard-inset', `${keyboardInset}px`)
+ contentEl.style.setProperty('--visual-viewport-height', `${vv.height}px`)
+ }
+
+ update()
+ vv.addEventListener('resize', update)
+ vv.addEventListener('scroll', update)
+ return () => {
+ vv.removeEventListener('resize', update)
+ vv.removeEventListener('scroll', update)
+ }
+ }, [open, contentEl])
+
+ // 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 */}
+