Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions docs/ux-audit-mobile-add-bookmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# UX Audit: Adding Bookmarks on Mobile

**Scope:** Only the add-bookmark flow on mobile (≤640px). Audited at 390×844
(iPhone 14 class) against the running app, with desktop (1440×900) as the
regression baseline. Element sizes below were measured in the browser, not
estimated from code.

**Add paths that exist today:**

| Path | Mobile status |
|---|---|
| Header **+ Add** → inline draft card | Works, but the card is keyboard-first (see findings) |
| **Share sheet** (PWA share target) | Works well — creates bookmark + toast, no form needed |
| **Paste anywhere** (`usePasteToBookmark`) | Effectively desktop-only — document-level `paste` events require a hardware keyboard; touch users can only paste into a focused input |
| **`g n` hotkey** | Desktop-only by nature |

---

## Findings (ranked)

### 1. URL entry fights the mobile keyboard — FIXED

The URL field in the draft card is a bare `type="text"` input with no
`inputmode`, `autocapitalize`, `autocorrect`, `spellcheck`, or `enterkeyhint`
attributes, and it is focused from an async effect.

Consequences on a phone:

- iOS/Android autocapitalize the first letter (`Https://…`) and autocorrect
domain names while typing; both routinely produce "Invalid URL".
- The generic text keyboard is shown instead of the URL layout
(`/`, `.`, `.com` conveniences).
- The return key is a generic newline key instead of **Go**, hiding the fact
that Enter saves the bookmark.
- Because focus happens in a `useEffect` (async relative to the tap on
**Add**), iOS Safari does not reliably open the keyboard — the user has to
tap the field a second time. The field is also only **16px tall**
(measured 318×16), a very small target.

**Fix applied:** `inputMode="url"`, `autoCapitalize="none"`,
`autoCorrect="off"`, `spellCheck={false}`, `enterKeyHint="go"`, plus
`autoFocus` (focus now happens synchronously in the commit triggered by the
tap, so the keyboard opens). The input gets a taller touch target on mobile
(`py-2`) while staying pixel-identical on `sm+`. The tag input similarly gets
`autoCapitalize="none"`/`autoCorrect="off"`/`spellCheck={false}` — tags are
lowercased on create, so autocapitalize only caused fights. Title/desc keep
autocorrect (they're prose).

### 2. The copied-URL fast path doesn't exist on touch — FIXED

The dominant real-world mobile flow is *copy link in browser → open
Hypermark → add it*. Desktop covers this brilliantly (paste anywhere creates
a bookmark instantly), but on touch that hook never fires: mobile browsers
only emit `paste` events into focused editable elements. The mobile user must
tap **Add**, tap the tiny URL line, long-press, wait for the callout, then tap
Paste — four precise interactions for the single most common action.

**Fix applied:** the draft card now shows a **Paste** button in the URL row
(when the field is empty and the async Clipboard API is available). One tap
reads the clipboard via `navigator.clipboard.readText()`, validates and
normalizes the URL, fills the field, and moves focus to the title. Non-URL or
unreadable clipboard shows the inline error instead of silently doing
nothing. Desktop gets the same button — a net improvement there too.

### 3. Touch targets and keyboard-only affordances in the draft card — FIXED

Measured in the draft card at 390px:

- **Save** button: 51×16px; **Cancel**: 61×16px. Apple HIG/Material both ask
for ≥44px targets; these are a third of that, and they sit next to each
other, so mis-taps either discard the draft or save it half-finished.
- "Read later" checkbox: 14×14px.
- The footer permanently shows **"Enter to save · Esc to cancel"** and the tag
input shows **"Press ↓ to see existing tags"** — instructions for keys that
don't exist on a touch screen, spending scarce vertical space while the
keyboard is up.

**Fix applied:** Save/Cancel get ≥44px-tall touch targets on mobile
(`py-3`, extra gap so the hit areas don't collide), the Read later label gets
a taller hit area and a 16px checkbox on mobile, and both keyboard hints are
hidden below `sm`. All of this is breakpoint-gated: desktop renders exactly
as before (the tag dropdown already opens on focus, so mobile loses nothing
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.
8. **Share-target duplicate toast is dead-end** — "Already bookmarked" could
offer "View it" to jump to the existing bookmark.

## Verification

- Playwright at 390×844 (mobile emulation, touch) and 1440×900: draft card
screenshots before/after; measured button/input rects before/after.
- `npm run test:coverage` passes all thresholds; new tests cover the paste
button, keyboard attributes, and mobile-target classes
(`src/components/bookmarks/BookmarkInlineCard.test.jsx`).
97 changes: 74 additions & 23 deletions src/components/bookmarks/BookmarkInlineCard.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { forwardRef, useRef, useEffect, useState, useCallback } from 'react'
import { ExternalLink, Check, X } from 'lucide-react'
import { ExternalLink, Check, X, ClipboardPaste } from 'lucide-react'
import { TagInput } from '../ui/TagInput'
import { Tag } from '../ui/Tag'
import { SaveIndicator } from '../ui/SaveIndicator'
Expand Down Expand Up @@ -38,6 +38,11 @@ export const BookmarkInlineCard = forwardRef(function BookmarkInlineCard(
const [urlError, setUrlError] = useState('')
const [editMode, setEditMode] = useState(true)
const [saveCount, setSaveCount] = useState(0)
// 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)
)

// Extract domain from URL
let domain = ''
Expand Down Expand Up @@ -240,6 +245,29 @@ export const BookmarkInlineCard = forwardRef(function BookmarkInlineCard(
onDiscard?.()
}

// 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 (!validateUrl(text)) {
setUrlError("Clipboard doesn't contain a link")
return
}
setLocalUrl(normalizeUrl(text))
setUrlError('')
titleInputRef.current?.focus()
}

// Ctrl/Cmd+Enter to save from any field
useHotkeys(
{ 'mod+enter': handleDone },
Expand Down Expand Up @@ -337,22 +365,41 @@ export const BookmarkInlineCard = forwardRef(function BookmarkInlineCard(
/>
)}
{isNew ? (
<div className="flex-1">
<input
ref={urlInputRef}
type="text"
value={localUrl}
onChange={(e) => {
setLocalUrl(e.target.value)
setUrlError('')
}}
onBlur={handleUrlBlur}
onKeyDown={(e) => handleKeyDown(e, 'url')}
className={`w-full bg-transparent border-none outline-none text-xs placeholder:text-muted-foreground/50 p-0 focus:ring-0 ${urlError ? 'text-destructive' : ''}`}
placeholder="https://example.com"
/>
{urlError && (
<span className="text-[10px] text-destructive">{urlError}</span>
<div className="flex-1 flex items-center gap-2">
<div className="flex-1 min-w-0">
<input
ref={urlInputRef}
type="text"
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
enterKeyHint="go"
autoFocus
value={localUrl}
onChange={(e) => {
setLocalUrl(e.target.value)
setUrlError('')
}}
onBlur={handleUrlBlur}
onKeyDown={(e) => handleKeyDown(e, 'url')}
className={`w-full bg-transparent border-none outline-none text-xs placeholder:text-muted-foreground/50 py-2 sm:py-0 px-0 focus:ring-0 ${urlError ? 'text-destructive' : ''}`}
placeholder="https://example.com"
/>
{urlError && (
<span className="text-[10px] text-destructive">{urlError}</span>
)}
</div>
{canPasteFromClipboard && !localUrl.trim() && (
<button
type="button"
onClick={handlePasteUrl}
className="flex-shrink-0 inline-flex items-center gap-1.5 h-9 sm:h-7 px-3 sm:px-2.5 rounded-md bg-accent/60 text-xs font-medium text-foreground/80 hover:bg-accent hover:text-foreground transition-colors"
aria-label="Paste link from clipboard"
>
<ClipboardPaste className="w-3.5 h-3.5" />
Paste
</button>
)}
</div>
) : (
Expand All @@ -378,6 +425,8 @@ export const BookmarkInlineCard = forwardRef(function BookmarkInlineCard(
<input
ref={titleInputRef}
type="text"
enterKeyHint="done"
autoFocus={!isNew}
value={localTitle}
onChange={(e) => setLocalTitle(e.target.value)}
onBlur={handleTitleBlur}
Expand Down Expand Up @@ -425,12 +474,12 @@ export const BookmarkInlineCard = forwardRef(function BookmarkInlineCard(

{/* Read Later checkbox */}
<div className="pt-1">
<label className="flex items-center gap-2 cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors">
<label className="inline-flex items-center gap-2 py-2 sm:py-0 cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors">
<input
type="checkbox"
checked={localReadLater}
onChange={(e) => handleReadLaterChange(e.target.checked)}
className="h-3.5 w-3.5 rounded border-input bg-background"
className="h-4 w-4 sm:h-3.5 sm:w-3.5 rounded border-input bg-background"
/>
<span className="text-xs">Read later</span>
</label>
Expand All @@ -439,10 +488,10 @@ export const BookmarkInlineCard = forwardRef(function BookmarkInlineCard(

{/* Action footer */}
<div className="pt-3 flex items-center justify-between border-t border-border/40">
<div className="flex items-center gap-3">
<div className="flex items-center gap-5 sm:gap-3">
<button
onClick={handleDone}
className="flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 transition-colors"
className="flex items-center gap-1.5 py-3.5 sm:py-0 text-xs font-medium text-primary hover:text-primary/80 transition-colors"
>
<div className="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10">
<Check className="w-2.5 h-2.5" />
Expand All @@ -451,7 +500,7 @@ export const BookmarkInlineCard = forwardRef(function BookmarkInlineCard(
</button>
<button
onClick={handleDiscard}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-destructive transition-colors"
className="flex items-center gap-1.5 py-3.5 sm:py-0 text-xs font-medium text-muted-foreground hover:text-destructive transition-colors"
>
<X className="w-3.5 h-3.5" />
{isNew ? 'Cancel' : 'Close'}
Expand All @@ -460,7 +509,9 @@ export const BookmarkInlineCard = forwardRef(function BookmarkInlineCard(

<div className="flex items-center gap-2 text-[10px] text-muted-foreground/50 font-medium">
<SaveIndicator show={saveCount} />
Enter to save · Esc to {isNew ? 'cancel' : 'close'}
<span className="hidden sm:inline">
Enter to save · Esc to {isNew ? 'cancel' : 'close'}
</span>
</div>
</div>
</div>
Expand Down
Loading
Loading