Phase 10: organization & polish — cross-doc search, tags, touch, warm failures

Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in
sync by triggers, back-filled from existing docs). GET /api/search uses the FTS
index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words
resolve); snippets are built in Go with rune-aware boundaries and sentinel
highlights.

Tags: user-scoped tags + document_tags join (both cascade), idempotent
create/assign, per-tag doc counts. Doc list and search carry each doc's tags
(one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten
DocList with chips + filter bar + search.

Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse-
pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion
cards.

Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual
StatusBar note (writing still saves locally).

Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update-
reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 06:29:56 -07:00
parent 60eba25fee
commit 9e141e4169
21 changed files with 1841 additions and 51 deletions

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, type DocSummary, type Document, type Suggestion } from './api/client'
import { api, type DocSummary, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
import { useAutoSave } from './hooks/useAutoSave'
import { useCheckpoint } from './hooks/useCheckpoint'
import { useSpellChecker } from './hooks/useSpellChecker'
import { useTags } from './hooks/useTags'
import { DocList } from './components/DocList/DocList'
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
import { ToneSelect } from './components/Editor/ToneSelect'
@@ -27,6 +28,9 @@ export default function App() {
// Distraction-free mode: entered on editor focus, collapses the doc-list
// sidebar. Escape or a click outside the editor canvas restores it.
const [focusMode, setFocusMode] = useState(false)
// Mobile drawer: below the tablet breakpoint the sidebar is an overlay toggled
// by the header hamburger. Ignored on wide screens (sidebar is always in-flow).
const [drawerOpen, setDrawerOpen] = useState(false)
const canvasRef = useRef<HTMLDivElement>(null)
// Monotonic counters the companion watches to react to writing + accepts.
const [editTick, setEditTick] = useState(0)
@@ -59,20 +63,67 @@ export default function App() {
suggestions,
checking,
voicing,
llmDown,
schedule: scheduleCheckpoint,
runVoice,
removeSuggestion,
} = useCheckpoint(currentDoc?.id ?? null)
// Browser-side spell checker — loads the en-US dictionary once per session.
const { checker: spellChecker, addWord } = useSpellChecker()
// The tag roster (with counts). Assignments live on the doc summaries below.
const { tags: tagRoster, refresh: refreshTags, createTag } = useTags()
// Patch a summary in the sidebar list (optimistic title / word-count updates).
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)))
}, [])
// Attach or detach a tag on a document, updating the sidebar optimistically and
// refreshing the roster so its counts stay current. Tags are kept sorted by
// name to match the server's ordering.
const setDocTag = useCallback(
async (docId: string, tag: Tag, attach: boolean) => {
setDocs((prev) =>
prev.map((d) => {
if (d.id !== docId) return d
const without = d.tags.filter((t) => t.id !== tag.id)
const next = attach ? [...without, tag] : without
next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
return { ...d, tags: next }
}),
)
try {
if (attach) await api.assignTag(docId, tag.id)
else await api.unassignTag(docId, tag.id)
void refreshTags()
} catch (err) {
console.error('tag assignment failed', err)
void refreshTags()
}
},
[refreshTags],
)
const handleToggleTag = useCallback(
(docId: string, tag: Tag) => {
const doc = docs.find((d) => d.id === docId)
const has = doc?.tags.some((t) => t.id === tag.id) ?? false
void setDocTag(docId, tag, !has)
},
[docs, setDocTag],
)
const handleCreateTag = useCallback(
async (docId: string, name: string, color: TagColor) => {
const tag = await createTag(name, color)
if (tag) void setDocTag(docId, tag, true)
},
[createTag, setDocTag],
)
const openDoc = useCallback(
async (id: string) => {
setDrawerOpen(false) // close the mobile drawer when a doc is chosen
// Decide the leaving doc's fate before any await mutates state.
const leaving = currentDocRef.current
const leavingBlank = isBlankDraft()
@@ -103,7 +154,7 @@ export default function App() {
let list = await api.listDocs()
if (list.length === 0) {
const fresh = await api.createDoc()
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at }]
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] }]
setDocs(list)
setCurrentDoc(fresh)
setTitle(fresh.title)
@@ -129,7 +180,7 @@ export default function App() {
await saveNow()
const fresh = await api.createDoc()
setDocs((prev) => [
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at },
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] },
...prev,
])
setCurrentDoc(fresh)
@@ -265,23 +316,44 @@ export default function App() {
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<button
type="button"
aria-label="Toggle document list"
onClick={() => {
setFocusMode(false)
setDrawerOpen((v) => !v)
}}
className="petal-sidebar-toggle petal-tap-sm -ml-2 mr-1 items-center justify-center text-xl"
style={{ borderRadius: 'var(--radius-pill)', width: 36, height: 36, color: 'var(--color-plum)' }}
>
</button>
<span className="text-xl">🌸</span>
<span className="text-lg font-extrabold text-plum">Petal</span>
</header>
<div className="flex min-h-0 flex-1">
<div className="relative flex min-h-0 flex-1">
<div
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}
className={`petal-no-print petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}${drawerOpen ? ' petal-drawer-open' : ''}`}
>
<DocList
docs={docs}
roster={tagRoster}
selectedId={currentDoc?.id ?? null}
onSelect={openDoc}
onCreate={handleCreate}
onDelete={handleDelete}
onToggleTag={handleToggleTag}
onCreateTag={handleCreateTag}
/>
</div>
<div
className={`petal-no-print petal-scrim${drawerOpen ? ' petal-scrim-show' : ''}`}
onClick={() => setDrawerOpen(false)}
aria-hidden
/>
<main className="flex min-w-0 flex-1 flex-col">
{currentDoc ? (
<>
@@ -346,6 +418,7 @@ export default function App() {
saveStatus={status}
checking={checking}
voicing={voicing}
llmDown={llmDown}
/>
</div>
</>

View File

@@ -1,11 +1,34 @@
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
// Vite proxies to the Go server in dev and the binary serves directly in prod.
// A tag's palette key, mapped to a CSS color on the frontend. Mirrors the
// backend TagColor* constants; unknown values render as rose.
export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey'
export interface Tag {
id: string
name: string
color: TagColor
doc_count?: number // present only in the tag-roster listing
}
export interface DocSummary {
id: string
title: string
word_count: number
updated_at: string
tags: Tag[]
}
// One cross-document search hit. `snippet` is plain text with the matched span
// wrapped in the  sentinels (see splitSnippet) for highlighting.
export interface SearchResult {
id: string
title: string
word_count: number
updated_at: string
snippet: string
tags: Tag[]
}
export interface Document {
@@ -155,11 +178,78 @@ export const api = {
body: JSON.stringify({ text, style }),
}),
// Cross-document full-text search (title + body). Returns hits with a
// highlighted snippet. Empty query returns []. Encodes the term for the URL.
search: (q: string) => req<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
// Tags. listTags returns the roster with per-tag document counts; createTag is
// idempotent on name (returns the existing tag if it already exists);
// updateTag recolors/renames; deleteTag removes it (assignments cascade).
// assignTag/unassignTag link a tag to one document.
listTags: () => req<Tag[]>('/tags'),
createTag: (name: string, color: TagColor) =>
req<Tag>('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }),
updateTag: (id: string, patch: { name?: string; color?: TagColor }) =>
req<Tag>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
deleteTag: (id: string) => req<void>(`/tags/${id}`, { method: 'DELETE' }),
assignTag: (docId: string, tagId: string) =>
req<void>(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }),
unassignTag: (docId: string, tagId: string) =>
req<void>(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }),
// Current deployed build id — changes whenever a new frontend ships. The
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
}
// The sentinel characters the server wraps a search match in (\x01 … \x02). Used
// by splitSnippet to render the matched span highlighted.
export const SNIPPET_HL_START = String.fromCharCode(1)
export const SNIPPET_HL_END = String.fromCharCode(2)
// splitSnippet breaks a server snippet into ordered { text, hit } segments so the
// UI can bold the matched span(s) without dangerously setting innerHTML.
export function splitSnippet(snippet: string): { text: string; hit: boolean }[] {
const out: { text: string; hit: boolean }[] = []
let rest = snippet
for (;;) {
const start = rest.indexOf(SNIPPET_HL_START)
if (start < 0) {
if (rest) out.push({ text: rest, hit: false })
break
}
if (start > 0) out.push({ text: rest.slice(0, start), hit: false })
const end = rest.indexOf(SNIPPET_HL_END, start + 1)
if (end < 0) {
// Unterminated (shouldn't happen) — emit the remainder plainly.
out.push({ text: rest.slice(start + 1), hit: false })
break
}
out.push({ text: rest.slice(start + 1, end), hit: true })
rest = rest.slice(end + 1)
}
return out
}
// tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall
// back to the rose accent, matching the backend's coercion.
export function tagColorVar(color: TagColor | string): string {
switch (color) {
case 'mint':
return 'var(--color-mint)'
case 'peach':
return 'var(--color-peach)'
case 'lavender':
return 'var(--color-lavender)'
case 'sky':
return 'var(--color-sky)'
case 'honey':
return 'var(--color-honey)'
default:
return 'var(--color-accent)'
}
}
// One turn in an Ask Petal conversation. History lives only in the component —
// the server is stateless and re-injects the suggestion context every request.
export interface ChatMessage {

View File

@@ -1,26 +1,71 @@
import type { DocSummary } from '../../api/client'
import { useMemo, useState } from 'react'
import type { DocSummary, Tag, TagColor } from '../../api/client'
import { DocListItem } from './DocListItem'
import { SearchBox } from './SearchBox'
import { TagChip } from './TagChip'
interface Props {
docs: DocSummary[]
roster: Tag[]
selectedId: string | null
onSelect: (id: string) => void
onCreate: () => void
onDelete: (id: string) => void
onToggleTag: (docId: string, tag: Tag) => void
onCreateTag: (docId: string, name: string, color: TagColor) => void
}
// DocList is the 260px sidebar: the document browser plus a New button.
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) {
// DocList is the sidebar: a cross-document search box, a tag filter bar, the New
// button, and the document browser (each row showing its tag chips).
export function DocList({
docs,
roster,
selectedId,
onSelect,
onCreate,
onDelete,
onToggleTag,
onCreateTag,
}: Props) {
// Active tag filter (null = show all). Cleared automatically if the tag
// disappears from the roster.
const [filterId, setFilterId] = useState<string | null>(null)
const activeFilter = filterId && roster.some((t) => t.id === filterId) ? filterId : null
const filtered = useMemo(
() => (activeFilter ? docs.filter((d) => d.tags.some((t) => t.id === activeFilter)) : docs),
[docs, activeFilter],
)
// Only surface tags that are actually in use, so the filter bar stays tidy.
const usedTags = useMemo(() => roster.filter((t) => (t.doc_count ?? 0) > 0), [roster])
return (
<aside
className="flex h-full w-[260px] flex-col gap-2 p-3"
className="flex h-full w-[280px] flex-col gap-2 p-3"
style={{ borderRight: '1px solid var(--color-border)' }}
>
<SearchBox onSelect={onSelect} />
{usedTags.length > 0 && (
<div className="flex flex-wrap gap-1 pb-0.5">
{usedTags.map((t) => (
<TagChip
key={t.id}
tag={t}
count={t.doc_count}
active={activeFilter === t.id}
onClick={() => setFilterId((cur) => (cur === t.id ? null : t.id))}
/>
))}
</div>
)}
<button
type="button"
onClick={onCreate}
className="flex items-center justify-center gap-2 py-2.5 text-sm font-bold text-white"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
className="petal-tap flex items-center justify-center gap-2 text-sm font-bold text-white"
style={{ minHeight: 44, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
@@ -28,18 +73,21 @@ export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Prop
</button>
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
{docs.length === 0 ? (
{filtered.length === 0 ? (
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
No documents yet.
{activeFilter ? 'No documents with this tag.' : 'No documents yet.'}
</p>
) : (
docs.map((doc) => (
filtered.map((doc) => (
<DocListItem
key={doc.id}
doc={doc}
active={doc.id === selectedId}
roster={roster}
onSelect={() => onSelect(doc.id)}
onDelete={() => onDelete(doc.id)}
onToggleTag={onToggleTag}
onCreateTag={onCreateTag}
/>
))
)}

View File

@@ -1,48 +1,101 @@
import type { DocSummary } from '../../api/client'
import { useState } from 'react'
import type { DocSummary, Tag, TagColor } from '../../api/client'
import { TagChip } from './TagChip'
import { TagPicker } from './TagPicker'
interface Props {
doc: DocSummary
active: boolean
roster: Tag[]
onSelect: () => void
onDelete: () => void
onToggleTag: (docId: string, tag: Tag) => void
onCreateTag: (docId: string, name: string, color: TagColor) => void
}
// One row in the sidebar: title + word count, a delete affordance on hover, and
// a rose wash when it's the open document.
export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
// One row in the sidebar: title + word count, its tag chips, a tag affordance and
// a delete affordance on hover, and a rose wash when it's the open document.
export function DocListItem({
doc,
active,
roster,
onSelect,
onDelete,
onToggleTag,
onCreateTag,
}: Props) {
const [picking, setPicking] = useState(false)
const assignedIds = new Set(doc.tags.map((t) => t.id))
return (
<div
onClick={onSelect}
className="group flex cursor-pointer items-center gap-2 px-3 py-2.5"
className="group relative flex cursor-pointer flex-col gap-1 px-3 py-2.5"
style={{
borderRadius: 'var(--radius-card)',
background: active ? 'var(--color-surface)' : 'transparent',
boxShadow: active ? 'var(--shadow-soft)' : 'none',
}}
>
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-semibold"
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-semibold"
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
>
{doc.title || 'Untitled'}
</div>
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
</div>
</div>
{/* Tag + delete affordances: always reachable on touch (no hover), fade in
on hover for the pointer experience. */}
<button
type="button"
aria-label="Tag document"
title="Tags"
onClick={(e) => {
e.stopPropagation()
setPicking((v) => !v)
}}
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-sm"
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
>
{doc.title || 'Untitled'}
</div>
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
</div>
🏷
</button>
<button
type="button"
aria-label="Delete document"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="petal-row-action flex h-7 w-7 shrink-0 items-center justify-center text-base"
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
>
×
</button>
{picking && (
<TagPicker
roster={roster}
assignedIds={assignedIds}
onToggle={(t) => onToggleTag(doc.id, t)}
onCreate={(name, color) => onCreateTag(doc.id, name, color)}
onClose={() => setPicking(false)}
/>
)}
</div>
<button
type="button"
aria-label="Delete document"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="flex h-6 w-6 shrink-0 items-center justify-center text-base opacity-0 group-hover:opacity-100"
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
>
×
</button>
{doc.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{doc.tags.map((t) => (
<TagChip key={t.id} tag={t} onRemove={() => onToggleTag(doc.id, t)} />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useRef, useState } from 'react'
import { api, splitSnippet, type SearchResult } from '../../api/client'
interface Props {
// Called when a result is chosen — opens that document.
onSelect: (id: string) => void
}
const DEBOUNCE_MS = 220
// SearchBox is the cross-document finder at the top of the sidebar. Typing
// debounces a full-text query; results drop in below with a highlighted snippet.
// Clearing (× or empty) returns the sidebar to the normal document list.
export function SearchBox({ onSelect }: Props) {
const [q, setQ] = useState('')
const [results, setResults] = useState<SearchResult[] | null>(null)
const [busy, setBusy] = useState(false)
const runRef = useRef(0)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => {
clearTimeout(debounceRef.current)
const term = q.trim()
if (!term) {
setResults(null)
setBusy(false)
return
}
setBusy(true)
const run = ++runRef.current
debounceRef.current = setTimeout(async () => {
try {
const hits = await api.search(term)
if (run === runRef.current) setResults(hits)
} catch (err) {
console.error('search failed', err)
if (run === runRef.current) setResults([])
} finally {
if (run === runRef.current) setBusy(false)
}
}, DEBOUNCE_MS)
return () => clearTimeout(debounceRef.current)
}, [q])
const clear = () => {
setQ('')
setResults(null)
}
return (
<div className="flex flex-col gap-1.5">
<div className="relative">
<span
aria-hidden
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: 'var(--color-muted)' }}
>
🔍
</span>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索 · Search"
aria-label="Search documents"
className="petal-tap w-full bg-transparent pl-9 pr-8 text-sm focus:outline-none"
style={{
height: 40,
borderRadius: 'var(--radius-pill)',
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
color: 'var(--color-plum)',
}}
/>
{q && (
<button
type="button"
aria-label="Clear search"
onClick={clear}
className="absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center"
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
>
×
</button>
)}
</div>
{results !== null && (
<div className="petal-search-results flex flex-col gap-0.5">
{busy && results.length === 0 ? (
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
· Searching
</p>
) : results.length === 0 ? (
<p className="px-2 py-3 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
· No matches
</p>
) : (
results.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onSelect(r.id)}
className="petal-tap flex flex-col items-start gap-0.5 px-3 py-2 text-left"
style={{ borderRadius: 'var(--radius-card)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span
className="w-full truncate text-sm font-bold"
style={{ color: 'var(--color-plum)' }}
>
{r.title || 'Untitled'}
</span>
{r.snippet && (
<span
className="line-clamp-2 text-xs"
style={{ color: 'var(--color-muted)', lineHeight: 1.4 }}
>
{splitSnippet(r.snippet).map((seg, i) =>
seg.hit ? (
<mark
key={i}
style={{
background: 'color-mix(in srgb, var(--color-accent) 35%, white)',
color: 'var(--color-plum)',
borderRadius: 3,
padding: '0 1px',
}}
>
{seg.text}
</mark>
) : (
<span key={i}>{seg.text}</span>
),
)}
</span>
)}
</button>
))
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,75 @@
import { tagColorVar, type Tag } from '../../api/client'
interface Props {
tag: Tag
// When set, renders an interactive chip (filter toggle); `active` fills it.
onClick?: () => void
active?: boolean
// When set, shows a small × to remove the tag (used on document rows).
onRemove?: () => void
// Optional trailing count (used in the filter bar).
count?: number
size?: 'sm' | 'md'
}
// TagChip is the pill used everywhere a tag appears: on document rows, in the
// filter bar, and in the assign popover. The palette color tints a soft wash; a
// filled variant marks an active filter. Keeps tap targets comfortable on touch.
export function TagChip({ tag, onClick, active, onRemove, count, size = 'sm' }: Props) {
const color = tagColorVar(tag.color)
const interactive = !!onClick
const pad = size === 'md' ? '0.3rem 0.7rem' : '0.12rem 0.5rem'
const font = size === 'md' ? '0.8rem' : '0.7rem'
return (
<span
onClick={
onClick
? (e) => {
e.stopPropagation()
onClick()
}
: undefined
}
className={`petal-tag-chip inline-flex items-center gap-1 whitespace-nowrap font-bold${interactive ? ' cursor-pointer' : ''}`}
style={{
borderRadius: 'var(--radius-pill)',
padding: pad,
fontSize: font,
lineHeight: 1.2,
background: active ? color : 'color-mix(in srgb, ' + color + ' 22%, white)',
color: active ? 'white' : 'var(--color-plum)',
border: `1px solid ${active ? color : 'color-mix(in srgb, ' + color + ' 45%, white)'}`,
}}
>
<span
aria-hidden
className="inline-block shrink-0"
style={{
width: 7,
height: 7,
borderRadius: 'var(--radius-pill)',
background: active ? 'white' : color,
}}
/>
{tag.name}
{typeof count === 'number' && (
<span style={{ opacity: 0.7, fontWeight: 700 }}>{count}</span>
)}
{onRemove && (
<button
type="button"
aria-label={`Remove ${tag.name}`}
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className="ml-0.5 inline-flex items-center justify-center"
style={{ color: 'inherit', opacity: 0.65, fontSize: '0.85em', lineHeight: 1 }}
>
×
</button>
)}
</span>
)
}

View File

@@ -0,0 +1,139 @@
import { useEffect, useRef, useState } from 'react'
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
const COLORS: TagColor[] = ['rose', 'mint', 'peach', 'lavender', 'sky', 'honey']
interface Props {
roster: Tag[]
assignedIds: Set<string>
onToggle: (tag: Tag) => void
onCreate: (name: string, color: TagColor) => void
onClose: () => void
}
// TagPicker is the small popover for managing one document's tags: tap an
// existing tag to attach/detach it, or type a new name (with a color swatch) to
// create-and-attach. Closes on outside click or Escape.
export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }: Props) {
const [name, setName] = useState('')
const [color, setColor] = useState<TagColor>('rose')
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const onDown = (e: PointerEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose()
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
// Defer so the opening click doesn't immediately close it.
const id = setTimeout(() => document.addEventListener('pointerdown', onDown), 0)
document.addEventListener('keydown', onKey)
return () => {
clearTimeout(id)
document.removeEventListener('pointerdown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [onClose])
const submit = () => {
const n = name.trim()
if (!n) return
onCreate(n, color)
setName('')
}
return (
<div
ref={ref}
onClick={(e) => e.stopPropagation()}
className="petal-tag-picker absolute z-20 flex w-60 flex-col gap-2 p-3"
style={{
top: 'calc(100% + 4px)',
right: 0,
borderRadius: 'var(--radius-card)',
background: 'var(--color-surface)',
boxShadow: 'var(--shadow-soft)',
border: '1px solid var(--color-border)',
}}
>
<div className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Tags
</div>
{roster.length > 0 && (
<div className="flex max-h-40 flex-wrap gap-1.5 overflow-y-auto">
{roster.map((t) => {
const on = assignedIds.has(t.id)
const c = tagColorVar(t.color)
return (
<button
key={t.id}
type="button"
onClick={() => onToggle(t)}
className="petal-tap inline-flex items-center gap-1 whitespace-nowrap text-xs font-bold"
style={{
borderRadius: 'var(--radius-pill)',
padding: '0.25rem 0.6rem',
background: on ? c : 'color-mix(in srgb, ' + c + ' 18%, white)',
color: on ? 'white' : 'var(--color-plum)',
border: `1px solid ${on ? c : 'color-mix(in srgb, ' + c + ' 40%, white)'}`,
}}
>
{on && <span aria-hidden></span>}
{t.name}
</button>
)
})}
</div>
)}
<div className="flex flex-col gap-1.5 pt-1" style={{ borderTop: '1px solid var(--color-border)' }}>
<div className="flex items-center gap-1.5">
{COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
onClick={() => setColor(c)}
className="petal-tap-sm"
style={{
width: 18,
height: 18,
borderRadius: 'var(--radius-pill)',
background: tagColorVar(c),
border: color === c ? '2px solid var(--color-plum)' : '2px solid transparent',
}}
/>
))}
</div>
<div className="flex items-center gap-1.5">
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submit()
}}
placeholder="新标签 · New tag"
aria-label="New tag name"
className="petal-tap min-w-0 flex-1 bg-transparent px-2.5 text-sm focus:outline-none"
style={{
height: 34,
borderRadius: 'var(--radius-input)',
border: '1px solid var(--color-border)',
color: 'var(--color-plum)',
}}
/>
<button
type="button"
onClick={submit}
className="petal-tap-sm shrink-0 px-3 text-sm font-bold text-white"
style={{ height: 34, borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
>
</button>
</div>
</div>
</div>
)
}

View File

@@ -362,8 +362,17 @@ export function EditorCore({
// Click a red-underlined word to open its spelling popover, anchored under the
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
// misspelling appearing elsewhere); nspell supplies the corrections.
//
// Tapping an AI-suggestion highlight also opens its card here — on touch there's
// no hover, so the tap is the only way in (mouse users still get hover).
const handleSpellClick = useCallback(
(e: React.MouseEvent) => {
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
if (suggestionEl) {
const id = suggestionEl.getAttribute('data-suggestion-id')
if (id) openCardFor(id, suggestionEl)
return
}
if (!editor || !spellChecker) return
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
if (!target) return
@@ -383,7 +392,7 @@ export function EditorCore({
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
},
[editor, spellChecker, closeCard],
[editor, spellChecker, closeCard, openCardFor],
)
const replaceMisspelling = useCallback(
@@ -634,6 +643,20 @@ export function EditorCore({
return () => document.removeEventListener('mousedown', onDown)
}, [pinned, closeCard])
// Touch has no hover, so the card is opened by a tap and can't close itself on
// mouse-leave. Whenever a card is open, a pointer-down outside both the card and
// any highlight dismisses it. Excluding `.petal-suggestion` lets a tap on
// another highlight reopen for that one (via the click handler) without flicker.
useEffect(() => {
if (!hover) return
const onDown = (e: PointerEvent) => {
const t = e.target as HTMLElement
if (!t.closest('.petal-suggestion-card') && !t.closest('.petal-suggestion')) closeCard()
}
document.addEventListener('pointerdown', onDown)
return () => document.removeEventListener('pointerdown', onDown)
}, [hover, closeCard])
return (
<div className="flex flex-1 flex-col">
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />

View File

@@ -11,6 +11,9 @@ interface Props {
checking: boolean
// True while a whole-document voice pass runs — shows a breathing honey dot.
voicing: boolean
// True when Petal can't reach its LLM helper — shows a gentle, reassuring note
// (the writing still saves locally, so this is awareness, not an error).
llmDown: boolean
}
const SAVE_LABEL: Record<SaveStatus, string> = {
@@ -24,7 +27,7 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
// StatusBar is the slim footer: word count on the left, save state and the
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
// circle that breathes while a check is in flight (spec → Signature animations).
export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Props) {
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmDown }: Props) {
const label = SAVE_LABEL[saveStatus]
// The expanded stats panel toggles open when the word count is clicked.
const [statsOpen, setStatsOpen] = useState(false)
@@ -86,6 +89,20 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Pr
</span>
</>
)}
{llmDown && !checking && !voicing && (
<>
<span aria-hidden>·</span>
<span
className="inline-flex items-center gap-1.5"
title="Petal can't reach its writing helper right now — your text is still saved."
style={{ color: 'var(--color-honey)' }}
>
<span aria-hidden>🌙</span>
<span></span>
<span style={{ opacity: 0.75 }}>· Petal's helper is resting · 文字已保存</span>
</span>
</>
)}
{label && (
<>
<span aria-hidden>·</span>

View File

@@ -13,6 +13,11 @@ export function useCheckpoint(docId: string | null) {
const [checking, setChecking] = useState(false)
// True while a whole-document voice pass is in flight (explicit user action).
const [voicing, setVoicing] = useState(false)
// True when the last LLM pass couldn't reach the model (server 502 or network
// error). Drives a gentle, reassuring "helper is resting" note — the writing
// itself still saves fine, so this is awareness, not an error. Cleared on the
// next success or doc switch.
const [llmDown, setLlmDown] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const docIdRef = useRef(docId)
@@ -30,9 +35,11 @@ export function useCheckpoint(docId: string | null) {
const fresh = await api.checkDoc(id)
if (run === runRef.current && id === docIdRef.current) {
setSuggestions(fresh)
setLlmDown(false)
}
} catch (err) {
console.error('checkpoint failed', err)
if (run === runRef.current) setLlmDown(true)
} finally {
if (run === runRef.current) setChecking(false)
}
@@ -50,9 +57,11 @@ export function useCheckpoint(docId: string | null) {
const full = await api.voiceDoc(id)
if (run === runRef.current && id === docIdRef.current) {
setSuggestions(full)
setLlmDown(false)
}
} catch (err) {
console.error('voice pass failed', err)
if (run === runRef.current) setLlmDown(true)
} finally {
if (run === runRef.current) setVoicing(false)
}
@@ -72,6 +81,7 @@ export function useCheckpoint(docId: string | null) {
setSuggestions([])
setChecking(false)
setVoicing(false)
setLlmDown(false)
if (!docId) return
let cancelled = false
void (async () => {
@@ -94,5 +104,5 @@ export function useCheckpoint(docId: string | null) {
setSuggestions((prev) => prev.filter((s) => s.id !== id))
}, [])
return { suggestions, checking, voicing, schedule, runVoice, removeSuggestion }
return { suggestions, checking, voicing, llmDown, schedule, runVoice, removeSuggestion }
}

63
web/src/hooks/useTags.ts Normal file
View File

@@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from 'react'
import { api, type Tag, type TagColor } from '../api/client'
// useTags owns the user's tag roster (the full set the writer has created, with
// per-tag document counts). Document↔tag assignments live in App alongside the
// document list; this hook is just the roster plus create/recolor/delete and a
// refresh used to keep counts current after an assignment changes.
export function useTags() {
const [tags, setTags] = useState<Tag[]>([])
const refresh = useCallback(async () => {
try {
setTags(await api.listTags())
} catch (err) {
console.error('failed to load tags', err)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
// Create (or reuse) a tag, returning it. The roster is refreshed so the new
// tag appears with a zero count.
const createTag = useCallback(
async (name: string, color: TagColor): Promise<Tag | null> => {
try {
const tag = await api.createTag(name, color)
await refresh()
return tag
} catch (err) {
console.error('failed to create tag', err)
return null
}
},
[refresh],
)
const recolorTag = useCallback(
async (id: string, color: TagColor) => {
// Optimistic recolor; refresh reconciles.
setTags((prev) => prev.map((t) => (t.id === id ? { ...t, color } : t)))
try {
await api.updateTag(id, { color })
} catch (err) {
console.error('failed to recolor tag', err)
void refresh()
}
},
[refresh],
)
const deleteTag = useCallback(async (id: string) => {
setTags((prev) => prev.filter((t) => t.id !== id))
try {
await api.deleteTag(id)
} catch (err) {
console.error('failed to delete tag', err)
}
}, [])
return { tags, refresh, createTag, recolorTag, deleteTag }
}

View File

@@ -282,6 +282,102 @@ button, a, input {
50% { opacity: 1; }
}
/* --- Organization & search (Phase 10) --------------------------------------
Tag chips, the search results list, and per-row affordances. The row actions
(tag / delete) fade in on hover for pointer users but are always visible on
touch (no hover) — see the coarse-pointer block below. */
.petal-tag-chip {
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.petal-search-results {
animation: petal-suggestion-in 160ms ease both;
}
.petal-tag-picker {
animation: petal-suggestion-in 160ms ease both;
}
.petal-row-action {
opacity: 0;
transition: opacity 160ms ease, background 160ms ease;
}
.group:hover .petal-row-action,
.petal-row-action:focus-visible {
opacity: 1;
}
.petal-row-action:hover {
background: var(--color-surface-alt);
}
/* Clamp a search snippet to two lines. */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* --- Touch & tablet polish --------------------------------------------------
On coarse pointers (tablets, phones) there's no hover, so make tap targets
comfortable and reveal affordances that would otherwise be hover-only. */
@media (pointer: coarse) {
.petal-tap {
min-height: 44px;
}
.petal-tap-sm {
min-height: 36px;
}
/* Row actions can't rely on hover — keep them visible and roomy. */
.petal-row-action {
opacity: 1;
height: 36px;
width: 36px;
}
/* Bigger, easier-to-hit suggestion/word/tag pills. */
.petal-tag-chip {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
}
/* --- Responsive sidebar (narrow screens) ------------------------------------
Below the tablet breakpoint the sidebar becomes an overlay drawer toggled by a
hamburger in the header, instead of a permanent column. A scrim sits behind it.
On wide screens the toggle + scrim are hidden and the sidebar is in-flow. */
.petal-sidebar-toggle {
display: none;
}
.petal-scrim {
display: none;
}
@media (max-width: 768px) {
.petal-sidebar-toggle {
display: inline-flex;
}
.petal-sidebar {
position: fixed;
top: 48px; /* below the 12-height header */
bottom: 0;
left: 0;
z-index: 30;
background: var(--color-bg);
box-shadow: var(--shadow-soft);
}
/* On mobile the sidebar is hidden by default; the drawer-open class slides it
in. (Distraction-free's petal-sidebar-hidden still wins to keep it closed.) */
.petal-sidebar:not(.petal-drawer-open) {
width: 0;
transform: translateX(-24px);
opacity: 0;
pointer-events: none;
}
.petal-scrim.petal-scrim-show {
display: block;
position: fixed;
inset: 48px 0 0 0;
z-index: 20;
background: rgba(61, 46, 57, 0.18);
}
}
/* Print / Save-as-PDF: strip every bit of app chrome and editing decoration so
only the title and the writing itself reach the page. This is Petal's PDF
path — it uses the browser's own fonts, so CJK renders correctly with no