Files
petal/web/src/App.tsx
T
prosolis de251ceae2 Make the suggestion rail's overhang reachable, and keep the text in view
The margin rail hangs off an absolutely-positioned column, so its cards add
no layout height. On her live document that meant four 173px cards anchored
inside 126px of text: a 714px stack over a page whose scrollHeight equalled
its clientHeight. The lower cards weren't far from their sentence, they were
off-screen with nothing to scroll.

The rail now reports how far its resolved stack reaches and the wrapper takes
that as a minimum height, so the space those cards occupy is real, scrollable
page. minHeight never shrinks the column, so a rail that fits beside its text
is unaffected.

Scrolling into that space would have carried every sentence off the top, so
the prose is pinned while the stack overhangs it. The offset is
min(0, port - content): prose shorter than the viewport pins at the top,
taller prose pins by its bottom edge, keeping the last lines visible — those
are the ones the overhanging cards flag.

The prose box has to stay at its natural height. Keeping the old h-full made
it measure the wrapper this change had just grown, reporting the cards'
height as the text's own, so the pin could never trip.

Verified in a browser at the review's 1517x810, driven offline by the rule
pack: 8 cards over 95px of prose gained 675px of scroll where there was none,
the last card lands fully in view with the text still on screen, tall prose
pins bottom-anchored without disturbing ordinary scrolling, and hover-linking
still glows the right span.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 23:07:09 -07:00

645 lines
25 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { api, type DocSummary, type DocUpdate, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
import { useAutoSave } from './hooks/useAutoSave'
import { findingKey, 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'
import { ChromeStrip } from './components/Editor/ChromeStrip'
import { ExportMenu } from './components/Export/ExportMenu'
import { HistoryPanel } from './components/History/HistoryPanel'
import { GardenPanel } from './components/Garden/GardenPanel'
import { StatusBar } from './components/StatusBar/StatusBar'
import { PetalCompanion } from './components/Companion/PetalCompanion'
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
import { SignInOverlay } from './components/Auth/SignInOverlay'
import { useSession } from './hooks/useSession'
import { takeDraft } from './lib/drafts'
import { useVersionWatch } from './hooks/useVersionWatch'
import { PetalFall } from './effects/PetalFall'
import { usePack } from './i18n'
import { useNightMode } from './hooks/useNightMode'
import { playSuggestionSound } from './audio/sounds'
export default function App() {
const updateAvailable = useVersionWatch()
// Late-night calm mode: dark theme + falling stars instead of petals. The hook
// toggles the `petal-night` class on <html>; we pass the flag to the ambient
// layer so the petals become stars.
const night = useNightMode()
// Who's writing, and whether the server still recognises them. `signedOut`
// flips the moment any call comes back 401.
const { me, signedOut } = useSession()
const t = usePack()
// A real account to sign out of, as opposed to the hardcoded local user a
// build without auth configured runs as.
const account = me && me.id !== 'local' ? { name: me.display_name || me.email } : null
const [docs, setDocs] = useState<DocSummary[]>([])
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
const [title, setTitle] = useState('')
const [wordCount, setWordCount] = useState(0)
// The current document's target tone (steers checkpoint advice) and its live
// plain text (drives the expanded writing-stats panel in the StatusBar).
const [tone, setTone] = useState('general')
const [docText, setDocText] = useState('')
const [ready, setReady] = useState(false)
// 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)
const [acceptTick, setAcceptTick] = useState(0)
// History drawer visibility, and an epoch bumped on restore to force the
// editor to remount with the restored content (its initialContent is read
// only on mount).
const [historyOpen, setHistoryOpen] = useState(false)
const [gardenOpen, setGardenOpen] = useState(false)
const [editorEpoch, setEditorEpoch] = useState(0)
// Live mirrors of the current doc's editable fields so the "discard blank
// drafts on navigation" logic can read the latest values without rebuilding
// callbacks on every keystroke.
const currentDocRef = useRef(currentDoc)
const titleRef = useRef(title)
const wordCountRef = useRef(wordCount)
currentDocRef.current = currentDoc
titleRef.current = title
wordCountRef.current = wordCount
// A throwaway blank draft: no words and still the default/empty title. We
// delete these on navigation rather than leave orphan "Untitled" docs behind.
const isBlankDraft = useCallback(() => {
const t = titleRef.current.trim()
return wordCountRef.current === 0 && (t === '' || t === 'Untitled')
}, [])
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
const {
suggestions,
checking,
voicing,
collocating,
llmDown,
schedule: scheduleCheckpoint,
runVoice,
runCollocation,
removeSuggestion,
resolveServerId,
} = 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],
)
// If the session lapsed while she was writing, the body that couldn't be
// saved was stashed on this device. Opening the document again is where it
// comes back: the stashed fields win over the server's older copy, and a save
// is scheduled straight away so it stops being local-only. Version history
// makes this safe to do silently — the server's copy is one restore away.
const pendingRescueRef = useRef<{ id: string; body: DocUpdate } | null>(null)
const rescueDraft = useCallback((doc: Document): Document => {
const stashed = takeDraft(doc.id)
if (!stashed) return doc
const patch = Object.fromEntries(
Object.entries(stashed.body).filter(([, v]) => v !== undefined),
)
if (Object.keys(patch).length === 0) return doc
const merged = { ...doc, ...patch } as Document
if (merged.content === doc.content && merged.title === doc.title) return doc
// Save it, but only once this really is the open document — the auto-save
// writes to whichever doc is current when its timer fires.
pendingRescueRef.current = { id: doc.id, body: stashed.body }
return merged
}, [])
useEffect(() => {
const rescued = pendingRescueRef.current
if (rescued && currentDoc?.id === rescued.id) {
pendingRescueRef.current = null
schedule(rescued.body)
}
}, [currentDoc?.id, schedule])
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()
await saveNow() // flush any pending edits to the doc we're leaving
const doc = rescueDraft(await api.getDoc(id))
setCurrentDoc(doc)
setTitle(doc.title)
setWordCount(doc.word_count)
setTone(doc.tone || 'general')
setDocText(doc.content_text)
// Leaving an untouched blank draft for a different doc? Drop it silently
// (best-effort cleanup — a 404 just means it's already gone).
if (leaving && leaving.id !== id && leavingBlank) {
api.deleteDoc(leaving.id).catch(() => {})
setDocs((prev) => prev.filter((d) => d.id !== leaving.id))
}
},
[saveNow, isBlankDraft],
)
// Initial load: fetch the list, opening the first doc (or creating one).
const bootedRef = useRef(false)
useEffect(() => {
if (bootedRef.current) return
bootedRef.current = true
;(async () => {
try {
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, tags: [] }]
setDocs(list)
setCurrentDoc(fresh)
setTitle(fresh.title)
setWordCount(0)
setTone(fresh.tone || 'general')
setDocText('')
} else {
setDocs(list)
await openDoc(list[0].id)
}
} catch (err) {
console.error('failed to load documents', err)
} finally {
setReady(true)
}
})()
}, [openDoc])
const handleCreate = useCallback(async () => {
// Already sitting on a fresh blank draft? Reuse it instead of stacking
// another empty "Untitled" on top.
if (currentDocRef.current && isBlankDraft()) return
await saveNow()
const fresh = await api.createDoc()
setDocs((prev) => [
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] },
...prev,
])
setCurrentDoc(fresh)
setTitle(fresh.title)
setWordCount(0)
setTone(fresh.tone || 'general')
setDocText('')
}, [saveNow, isBlankDraft])
// Duplicate a document: copy its body/tone into a fresh doc under the pack's
// "copy" title,
// then open the copy. Tags aren't carried over (a fresh start for the copy).
const handleDuplicate = useCallback(
async (id: string) => {
try {
await saveNow() // flush in case we're duplicating the open doc
const src = await api.getDoc(id)
const fresh = await api.createDoc()
const dupTitle = t.app.duplicateTitle(src.title?.trim() || 'Untitled')
const updated = await api.updateDoc(fresh.id, {
title: dupTitle,
content: src.content,
content_text: src.content_text,
tone: src.tone,
word_count: src.word_count,
})
setDocs((prev) => [
{ id: updated.id, title: updated.title, word_count: updated.word_count, updated_at: updated.updated_at, tags: [] },
...prev,
])
setCurrentDoc(updated)
setTitle(updated.title)
setWordCount(updated.word_count)
setTone(updated.tone || 'general')
setDocText(updated.content_text)
} catch (err) {
console.error('duplicate failed', err)
}
},
[saveNow],
)
const handleDelete = useCallback(
async (id: string) => {
await api.deleteDoc(id)
setDocs((prev) => {
const remaining = prev.filter((d) => d.id !== id)
if (currentDoc?.id === id) {
if (remaining.length > 0) {
void openDoc(remaining[0].id)
} else {
setCurrentDoc(null)
setTitle('')
setWordCount(0)
setTone('general')
setDocText('')
}
}
return remaining
})
},
[currentDoc, openDoc],
)
const handleTitleChange = useCallback(
(value: string) => {
setTitle(value)
if (currentDoc) {
patchSummary(currentDoc.id, { title: value })
schedule({ title: value })
}
},
[currentDoc, patchSummary, schedule],
)
// She took the kitten up on its daily invitation. The prompt becomes the
// blank page's title, so the question she agreed to answer stays in front of
// her while she answers it — rather than being said once and then gone the
// moment the bubble fades.
const handleAcceptInvitation = useCallback(
(prompt: string) => {
if (!currentDoc) return
handleTitleChange(prompt)
},
[currentDoc, handleTitleChange],
)
const handleEditorChange = useCallback(
(change: EditorChange) => {
setWordCount(change.word_count)
setDocText(change.content_text)
setEditTick((n) => n + 1)
if (currentDoc) {
patchSummary(currentDoc.id, { word_count: change.word_count })
schedule(change)
scheduleCheckpoint(change.content_text)
}
},
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
)
// Changing the tone persists it and re-runs the checkpoint so Petal's advice
// re-tunes to the new register. The auto-save (1.5s) lands before the
// checkpoint debounce (4s), so the server reads the updated tone.
const handleToneChange = useCallback(
(value: string) => {
setTone(value)
if (currentDoc) {
schedule({ tone: value })
scheduleCheckpoint()
}
},
[currentDoc, schedule, scheduleCheckpoint],
)
// Accept applies the replacement in the editor (handled in EditorCore) and
// marks the suggestion accepted; dismiss just rejects it. Both drop it locally.
// A rule-pack card can be accepted before its row exists — the edit has already
// landed either way, so a missing id just means there's nothing to file.
const handleAccept = useCallback(
async (s: Suggestion) => {
removeSuggestion(s.id)
setAcceptTick((n) => n + 1)
try {
const id = await resolveServerId(s)
if (id) await api.acceptSuggestion(id)
} catch (err) {
console.error('accept failed', err)
}
},
[removeSuggestion, resolveServerId],
)
// After restoring a version, swap the restored doc into the editor. Bumping
// editorEpoch remounts EditorCore so it picks up the restored content.
const handleRestored = useCallback(
(doc: Document) => {
setCurrentDoc(doc)
setTitle(doc.title)
setWordCount(doc.word_count)
setTone(doc.tone || 'general')
setDocText(doc.content_text)
patchSummary(doc.id, { title: doc.title, word_count: doc.word_count })
setEditorEpoch((n) => n + 1)
},
[patchSummary],
)
// Escape always restores the sidebar while in distraction-free mode.
useEffect(() => {
if (!focusMode) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFocusMode(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [focusMode])
// A pointer-down outside the centered editor canvas (the cream gutters, the
// header, the status bar) also restores the sidebar.
const handleChromeDown = useCallback((e: React.MouseEvent) => {
if (!canvasRef.current?.contains(e.target as Node)) setFocusMode(false)
}, [])
const handleDismiss = useCallback(
async (s: Suggestion) => {
removeSuggestion(s.id)
try {
const id = await resolveServerId(s)
if (id) await api.dismissSuggestion(id)
} catch (err) {
console.error('dismiss failed', err)
}
},
[removeSuggestion, resolveServerId],
)
// Play a soft sound when freshly-checked suggestions arrive — one per distinct
// new type, lightly staggered so a batch reads as a little melody rather than
// a pile-up. We track which ids we've already chimed for, and only chime for
// recently-created suggestions so opening a doc with old pending advice stays
// silent (the existing set was created in a past session).
// Rule-pack findings are chimed by their wording, not their id: the same fix
// appears first as a provisional card and then as its persisted row, and the
// writer should hear it once.
const chimedRef = useRef<Set<string>>(new Set())
useEffect(() => {
const key = (s: Suggestion) => (s.source === 'local' ? `local:${findingKey(s)}` : s.id)
const fresh = suggestions.filter((s) => !chimedRef.current.has(key(s)))
fresh.forEach((s) => chimedRef.current.add(key(s)))
const justMade = fresh.filter(
(s) => Date.now() - new Date(s.created_at).getTime() < 12_000,
)
if (justMade.length === 0) return
const types = [...new Set(justMade.map((s) => s.type))].slice(0, 3)
const timers = types.map((t, i) =>
window.setTimeout(() => playSuggestionSound(t), i * 150),
)
return () => timers.forEach((id) => window.clearTimeout(id))
}, [suggestions])
// Forget chimed ids when switching documents so the set can't grow unbounded.
useEffect(() => {
chimedRef.current = new Set()
}, [currentDoc?.id])
return (
<div className="flex h-full flex-col">
<PetalFall night={night} />
<header
onMouseDown={handleChromeDown}
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-3 md: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>
{/* The wordmark is the first thing to go on a small phone. The header
holds a hamburger, a name and the garden button, and the garden
button's label is a langpack string: 词汇花园 · Garden is 130px
where Jardim de palavras · Garden is nearly 200, which is the
difference between fitting a 320px screen and scrolling the whole
app sideways. Of everything in this row, the one that can be spared
is the app's own name — she is already inside the app, and the
blossom stays. */}
<span className="petal-wordmark text-lg font-extrabold text-plum">Petal</span>
<button
type="button"
onClick={() => setGardenOpen(true)}
aria-label="Vocabulary garden"
title="Words you've looked up, blooming for review"
className="petal-tap-sm ml-auto inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
style={{
borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface)',
color: 'var(--color-plum)',
boxShadow: 'var(--shadow-soft)',
}}
>
<span aria-hidden>🌷</span>
<span>{t.app.garden}</span>
<span style={{ color: 'var(--color-muted)' }}>· Garden</span>
</button>
</header>
<div className="relative flex min-h-0 flex-1">
<div
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}
onDuplicate={handleDuplicate}
onToggleTag={handleToggleTag}
onCreateTag={handleCreateTag}
account={account}
/>
</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 ? (
<>
<div
onMouseDown={handleChromeDown}
// `petal-scrollport` marks this as the editor's scrolling
// ancestor; EditorCore measures it to pin the text column while
// the suggestion rail's overhang is scrolled.
className="petal-scrollport flex flex-1 flex-col overflow-y-auto px-6 py-8"
>
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
{/* Title, then the three chrome pills. Their labels are
bilingual and don't shrink, so how much width this row
wants is a property of the langpack — 历史 is two glyphs
where Historique is ten — and on a phone no language's
version of it fits. The pills therefore live in a strip
that scrolls itself; the title takes its own line below
the drawer breakpoint so it keeps its full width. */}
<div className="mb-5 flex flex-wrap items-center gap-2 md:gap-3">
<input
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Untitled"
aria-label="Document title"
className="min-w-0 flex-1 basis-full bg-transparent text-3xl font-extrabold text-plum focus:outline-none md:basis-0"
style={{ fontFamily: 'var(--font-ui)' }}
/>
<ChromeStrip className="petal-no-print flex items-center gap-2 py-0.5 md:gap-3">
<ToneSelect value={tone} onChange={handleToneChange} />
<button
type="button"
onClick={() => setHistoryOpen(true)}
aria-label="Version history"
title="Browse and restore earlier versions"
className="inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
style={{
borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface)',
color: 'var(--color-plum)',
boxShadow: 'var(--shadow-soft)',
}}
>
<span aria-hidden>🕘</span>
<span>{t.app.history}</span>
<span style={{ color: 'var(--color-muted)' }}>· History</span>
</button>
<ExportMenu docId={currentDoc.id} />
</ChromeStrip>
</div>
<EditorCore
key={`${currentDoc.id}:${editorEpoch}`}
docId={currentDoc.id}
initialContent={currentDoc.content}
onChange={handleEditorChange}
suggestions={suggestions}
onAccept={handleAccept}
onDismiss={handleDismiss}
onVoiceCheck={runVoice}
voicing={voicing}
onCollocationCheck={runCollocation}
collocating={collocating}
onFocusMode={() => setFocusMode(true)}
spellChecker={spellChecker}
onAddWord={addWord}
/>
</div>
</div>
<div className="petal-no-print" onMouseDown={handleChromeDown}>
<StatusBar
wordCount={wordCount}
text={docText}
saveStatus={status}
checking={checking}
voicing={voicing}
collocating={collocating}
llmDown={llmDown}
/>
</div>
</>
) : (
<div
className="flex flex-1 items-center justify-center text-sm"
style={{ color: 'var(--color-muted)' }}
>
{ready ? 'Create a document to start writing.' : 'Loading…'}
</div>
)}
</main>
</div>
{historyOpen && currentDoc && (
<HistoryPanel
docId={currentDoc.id}
onClose={() => setHistoryOpen(false)}
onRestored={handleRestored}
/>
)}
{gardenOpen && (
<GardenPanel
onClose={() => setGardenOpen(false)}
onOpenDoc={(id) => {
setGardenOpen(false)
void openDoc(id)
}}
/>
)}
{updateAvailable && <UpdateBanner />}
{/* The session lapsed. The editor stays visible behind this — nothing has
been taken away — and anything unsaved is already on disk. */}
{signedOut && <SignInOverlay hasDraft={status === 'signed-out'} />}
<div className="petal-no-print">
<PetalCompanion
wordCount={wordCount}
saveStatus={status}
llmDown={llmDown}
editTick={editTick}
acceptTick={acceptTick}
text={docText}
blankPage={wordCount === 0 && docText.trim() === ''}
onAcceptInvitation={handleAcceptInvitation}
/>
</div>
</div>
)
}