diff --git a/AGENTS.md b/AGENTS.md
index a48bc8b..0118e2b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -125,6 +125,8 @@ Key test files:
- `src/services/crypto.test.js` - Crypto primitives
- `src/services/security-audit.test.js` - Security invariants
+**After implementing any feature or fix, you MUST run `npm run test:coverage` and verify all coverage thresholds pass before committing.** If coverage drops below thresholds, add tests for your new code until thresholds are met. IF you can't write meaningful tests to increase coverage, stop and ask me what to do.
+
---
## Beads Workflow
diff --git a/services/metadata.js b/services/metadata.js
index a6728a5..7ab2da5 100644
--- a/services/metadata.js
+++ b/services/metadata.js
@@ -10,7 +10,7 @@ import { lookup } from 'node:dns/promises'
const FETCH_TIMEOUT = 10000
const MAX_BODY_SIZE = 2 * 1024 * 1024 // 2MB limit for HTML
-const USER_AGENT = 'Mozilla/5.0 (compatible; Hypermark/1.0; +https://hypermark.app)'
+const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
// Cache: domain -> { result, expiresAt }
const metadataCache = new Map()
@@ -169,7 +169,14 @@ async function fetchPage(url) {
signal: controller.signal,
headers: {
'User-Agent': USER_AGENT,
- 'Accept': 'text/html,application/xhtml+xml',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ 'Cache-Control': 'no-cache',
+ 'Sec-Fetch-Dest': 'document',
+ 'Sec-Fetch-Mode': 'navigate',
+ 'Sec-Fetch-Site': 'none',
+ 'Sec-Fetch-User': '?1',
},
redirect: 'follow',
})
diff --git a/src/components/bookmarks/BookmarkContextMenu.jsx b/src/components/bookmarks/BookmarkContextMenu.jsx
new file mode 100644
index 0000000..bfc09fd
--- /dev/null
+++ b/src/components/bookmarks/BookmarkContextMenu.jsx
@@ -0,0 +1,133 @@
+import { useState, useEffect, useRef, useLayoutEffect } from 'react'
+import { createPortal } from 'react-dom'
+import { Search } from '../ui/Icons'
+
+export function BookmarkContextMenu({ actions, position, onClose }) {
+ const [query, setQuery] = useState('')
+ const [selectedIndex, setSelectedIndex] = useState(0)
+ const menuRef = useRef(null)
+ const inputRef = useRef(null)
+
+ const filtered = actions.filter(a =>
+ a.label.toLowerCase().includes(query.toLowerCase())
+ )
+
+ // Auto-focus search input
+ useEffect(() => {
+ const id = requestAnimationFrame(() => inputRef.current?.focus())
+ return () => cancelAnimationFrame(id)
+ }, [])
+
+ // Close on scroll
+ useEffect(() => {
+ const handle = () => onClose()
+ window.addEventListener('scroll', handle, true)
+ return () => window.removeEventListener('scroll', handle, true)
+ }, [onClose])
+
+ // Keep menu within viewport
+ useLayoutEffect(() => {
+ const el = menuRef.current
+ if (!el) return
+ const rect = el.getBoundingClientRect()
+ const vw = window.innerWidth
+ const vh = window.innerHeight
+ let x = position.x
+ let y = position.y
+ if (x + rect.width > vw - 8) x = vw - rect.width - 8
+ if (y + rect.height > vh - 8) y = vh - rect.height - 8
+ if (x < 8) x = 8
+ if (y < 8) y = 8
+ el.style.left = `${x}px`
+ el.style.top = `${y}px`
+ }, [position])
+
+ // Reset selection when search changes
+ useEffect(() => { setSelectedIndex(0) }, [query])
+
+ const handleKeyDown = (e) => {
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault()
+ setSelectedIndex(i => Math.min(i + 1, filtered.length - 1))
+ break
+ case 'ArrowUp':
+ e.preventDefault()
+ setSelectedIndex(i => Math.max(i - 1, 0))
+ break
+ case 'Enter':
+ e.preventDefault()
+ if (filtered[selectedIndex]) {
+ filtered[selectedIndex].handler()
+ onClose()
+ }
+ break
+ case 'Escape':
+ e.preventDefault()
+ onClose()
+ break
+ }
+ }
+
+ return createPortal(
+ <>
+
{ e.preventDefault(); onClose() }}
+ />
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Search actions..."
+ className="bg-transparent border-none outline-none text-sm text-foreground placeholder:text-muted-foreground w-full"
+ />
+
+
+
+ {filtered.length === 0 ? (
+
No actions found
+ ) : (
+ filtered.map((action, index) => {
+ const Icon = action.icon
+ return (
+
+ )
+ })
+ )}
+
+
+ >,
+ document.body
+ )
+}
diff --git a/src/components/bookmarks/BookmarkContextMenu.test.jsx b/src/components/bookmarks/BookmarkContextMenu.test.jsx
new file mode 100644
index 0000000..7dc8b98
--- /dev/null
+++ b/src/components/bookmarks/BookmarkContextMenu.test.jsx
@@ -0,0 +1,172 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { BookmarkContextMenu } from './BookmarkContextMenu'
+
+const Pencil = (props) =>
+const Trash = (props) =>
+
+const defaultActions = [
+ { id: 'edit', label: 'Edit', icon: Pencil, shortcut: 'E', handler: vi.fn() },
+ { id: 'delete', label: 'Delete', icon: Trash, shortcut: 'D', variant: 'destructive', handler: vi.fn() },
+]
+
+const defaultPosition = { x: 100, y: 200 }
+
+function renderMenu(props = {}) {
+ const onClose = vi.fn()
+ const actions = props.actions || defaultActions.map(a => ({ ...a, handler: vi.fn() }))
+ const result = render(
+
+ )
+ return { ...result, onClose, actions }
+}
+
+describe('BookmarkContextMenu', () => {
+ it('renders all actions', () => {
+ renderMenu()
+ expect(screen.getByText('Edit')).toBeInTheDocument()
+ expect(screen.getByText('Delete')).toBeInTheDocument()
+ })
+
+ it('renders search input with placeholder', () => {
+ renderMenu()
+ expect(screen.getByPlaceholderText('Search actions...')).toBeInTheDocument()
+ })
+
+ it('renders shortcut hints', () => {
+ renderMenu()
+ expect(screen.getByText('E')).toBeInTheDocument()
+ expect(screen.getByText('D')).toBeInTheDocument()
+ })
+
+ it('filters actions by search query', async () => {
+ const user = userEvent.setup()
+ renderMenu()
+ const input = screen.getByPlaceholderText('Search actions...')
+ await user.type(input, 'edi')
+ expect(screen.getByText('Edit')).toBeInTheDocument()
+ expect(screen.queryByText('Delete')).not.toBeInTheDocument()
+ })
+
+ it('shows "No actions found" when search matches nothing', async () => {
+ const user = userEvent.setup()
+ renderMenu()
+ const input = screen.getByPlaceholderText('Search actions...')
+ await user.type(input, 'zzzzz')
+ expect(screen.getByText('No actions found')).toBeInTheDocument()
+ })
+
+ it('calls action handler and onClose when action clicked', async () => {
+ const user = userEvent.setup()
+ const { actions, onClose } = renderMenu()
+ await user.click(screen.getByText('Edit'))
+ expect(actions[0].handler).toHaveBeenCalledOnce()
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('calls onClose when backdrop is clicked', async () => {
+ const user = userEvent.setup()
+ const { onClose } = renderMenu()
+ // The backdrop is the first child in the portal - a fixed inset-0 div
+ const backdrop = document.querySelector('.fixed.inset-0.z-40')
+ await user.click(backdrop)
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('calls onClose on Escape key', () => {
+ const { onClose } = renderMenu()
+ fireEvent.keyDown(screen.getByPlaceholderText('Search actions...'), { key: 'Escape' })
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('selects action with Enter key', () => {
+ const { actions, onClose } = renderMenu()
+ const input = screen.getByPlaceholderText('Search actions...')
+ // First action is selected by default
+ fireEvent.keyDown(input, { key: 'Enter' })
+ expect(actions[0].handler).toHaveBeenCalledOnce()
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('navigates with ArrowDown and selects with Enter', () => {
+ const { actions, onClose } = renderMenu()
+ const input = screen.getByPlaceholderText('Search actions...')
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
+ fireEvent.keyDown(input, { key: 'Enter' })
+ expect(actions[1].handler).toHaveBeenCalledOnce()
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('navigates with ArrowUp after ArrowDown', () => {
+ const { actions, onClose } = renderMenu()
+ const input = screen.getByPlaceholderText('Search actions...')
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
+ fireEvent.keyDown(input, { key: 'ArrowUp' })
+ fireEvent.keyDown(input, { key: 'Enter' })
+ expect(actions[0].handler).toHaveBeenCalledOnce()
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('does not go below last action with ArrowDown', () => {
+ const { actions, onClose } = renderMenu()
+ const input = screen.getByPlaceholderText('Search actions...')
+ // Press down 10 times - should stop at index 1 (last)
+ for (let i = 0; i < 10; i++) {
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
+ }
+ fireEvent.keyDown(input, { key: 'Enter' })
+ expect(actions[1].handler).toHaveBeenCalledOnce()
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('does not go above first action with ArrowUp', () => {
+ const { actions, onClose } = renderMenu()
+ const input = screen.getByPlaceholderText('Search actions...')
+ // Press up 10 times - should stay at index 0
+ for (let i = 0; i < 10; i++) {
+ fireEvent.keyDown(input, { key: 'ArrowUp' })
+ }
+ fireEvent.keyDown(input, { key: 'Enter' })
+ expect(actions[0].handler).toHaveBeenCalledOnce()
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('closes on contextmenu event on backdrop', () => {
+ const { onClose } = renderMenu()
+ const backdrop = document.querySelector('.fixed.inset-0.z-40')
+ fireEvent.contextMenu(backdrop)
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('closes on scroll', () => {
+ const { onClose } = renderMenu()
+ fireEvent.scroll(window)
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+
+ it('renders at the given position', () => {
+ renderMenu({ position: { x: 150, y: 250 } })
+ const menu = document.querySelector('[style*="position: fixed"]')
+ expect(menu.style.left).toBe('150px')
+ expect(menu.style.top).toBe('250px')
+ })
+
+ it('resets selection index when search changes', async () => {
+ const user = userEvent.setup()
+ const { actions, onClose } = renderMenu()
+ const input = screen.getByPlaceholderText('Search actions...')
+ // Move to second item
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
+ // Type to filter - should reset to index 0
+ await user.type(input, 'Del')
+ fireEvent.keyDown(input, { key: 'Enter' })
+ // After filtering to "Delete" only, index 0 is "Delete"
+ expect(actions[1].handler).toHaveBeenCalledOnce()
+ expect(onClose).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/components/bookmarks/BookmarkItem.jsx b/src/components/bookmarks/BookmarkItem.jsx
index 4cf020f..5a98827 100644
--- a/src/components/bookmarks/BookmarkItem.jsx
+++ b/src/components/bookmarks/BookmarkItem.jsx
@@ -1,8 +1,8 @@
-import { forwardRef } from 'react'
-import { Pencil, Trash, Check } from '../ui/Icons'
+import { forwardRef, useRef, useCallback } from 'react'
+import { Check } from '../ui/Icons'
export const BookmarkItem = forwardRef(function BookmarkItem(
- { bookmark, isSelected, isChecked, selectionMode, keyboardNavActive, onEdit, onDelete, onTagClick, onToggleSelect, onMouseEnter },
+ { bookmark, isSelected, isChecked, selectionMode, keyboardNavActive, onEdit, onTagClick, onToggleSelect, onMouseEnter, onContextMenu },
ref
) {
const { title, url, tags } = bookmark
@@ -16,20 +16,61 @@ export const BookmarkItem = forwardRef(function BookmarkItem(
const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
+ // Long-press detection for mobile context menu
+ const longPressTimer = useRef(null)
+ const isLongPress = useRef(false)
+ const touchOrigin = useRef(null)
+
+ const handleTouchStart = useCallback((e) => {
+ isLongPress.current = false
+ const touch = e.touches[0]
+ touchOrigin.current = { x: touch.clientX, y: touch.clientY }
+ longPressTimer.current = setTimeout(() => {
+ isLongPress.current = true
+ onContextMenu?.(bookmark, { x: touch.clientX, y: touch.clientY })
+ }, 500)
+ }, [bookmark, onContextMenu])
+
+ const handleTouchEnd = useCallback(() => {
+ clearTimeout(longPressTimer.current)
+ }, [])
+
+ const handleTouchMove = useCallback((e) => {
+ if (touchOrigin.current) {
+ const touch = e.touches[0]
+ const dx = touch.clientX - touchOrigin.current.x
+ const dy = touch.clientY - touchOrigin.current.y
+ if (dx * dx + dy * dy > 100) {
+ clearTimeout(longPressTimer.current)
+ }
+ }
+ }, [])
+
+ const handleRightClick = useCallback((e) => {
+ e.preventDefault()
+ onContextMenu?.(bookmark, { x: e.clientX, y: e.clientY })
+ }, [bookmark, onContextMenu])
+
const handleClick = (e) => {
+ if (isLongPress.current) {
+ isLongPress.current = false
+ return
+ }
if (selectionMode) {
e.preventDefault()
onToggleSelect?.(bookmark._id)
} else if (e.shiftKey) {
e.preventDefault()
onToggleSelect?.(bookmark._id, true)
+ } else {
+ // Click on empty area opens edit
+ onEdit?.(bookmark)
}
}
const handleCheckboxClick = (e) => {
e.stopPropagation()
e.preventDefault()
- // If not in selection mode, clicking checkbox initiates selection
if (!selectionMode) {
onToggleSelect?.(bookmark._id, true)
} else {
@@ -37,13 +78,16 @@ export const BookmarkItem = forwardRef(function BookmarkItem(
}
}
- // Keyboard selection should be visible on top of checked state
const showKeyboardSelection = isSelected && keyboardNavActive
return (
- {/* Always render checkbox area for consistent alignment */}
+ {/* Checkbox */}
)