Finish Phase 22: the half of Petal that works with the tunnel down

Grammar lite, the false-friend list, the daily invitation and the offline
miscollocations — the four remaining §5–§6 items, all client-side and all
alive on a box that cannot reach the model.

The offline collocations forced a schema change. `type` had been doubling
as the answer to "which engine found this" — `mechanics` meant offline —
and that stops being true the moment an offline rule proposes a
collocation. Migration 0013 adds `source` (llm | local) and every pass now
scopes its DELETE by engine; without it the coach silently wiped every
offline chunk on the page. Existing rows backfill by type, so a pre-0013
collocation row is claimed as the coach's, which it was: the offline list
did not exist yet.

The rule pack is hand-curated rather than mined, and the entries left out
are the point — `married with` is wrong until "married with children",
`arrive to` wants at or in depending on the noun. A pack running on every
keystroke must not correct correct writing.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 15:05:55 -07:00
parent e9b8595456
commit 1bbc8fc8d3
20 changed files with 1678 additions and 50 deletions
@@ -13,6 +13,11 @@ interface Props {
editTick: number
acceptTick: number
text: string
// The open document is still empty — the one state the daily writing
// invitation is offered in.
blankPage: boolean
// Called when she takes the invitation up, with the English prompt.
onAcceptInvitation: (prompt: string) => void
}
// Emoji placeholder per mood, used for any mood a companion has no Lottie for.
@@ -30,17 +35,38 @@ const STORAGE_KEY = 'petal.companion'
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
// break reminders. Clicking the mascot opens a picker to switch companions
// (the choice persists in localStorage).
export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) {
export function PetalCompanion({
wordCount,
saveStatus,
llmDown,
editTick,
acceptTick,
text,
blankPage,
onAcceptInvitation,
}: Props) {
const t = usePack()
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
const {
mood,
bubble,
dismiss,
holdBubble,
releaseBubble,
acceptInvite,
declineInvite,
setInviteHandler,
} = useCompanion({
wordCount,
saveStatus,
llmDown,
editTick,
acceptTick,
text,
blankPage,
})
useEffect(() => setInviteHandler(onAcceptInvitation), [setInviteHandler, onAcceptInvitation])
const [companionId, setCompanionId] = useState<string>(
() => readPref(STORAGE_KEY) || DEFAULT_COMPANION,
)
@@ -179,6 +205,36 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
>
{bubble.en}
</p>
{/* The daily invitation's two answers. "Not today" is a real button
sitting level with the other one, not a small grey escape — a no
that has to be hunted for isn't much of a no. */}
{bubble.invite && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
acceptInvite(bubble.invite!.prompt)
}}
className="rounded-full px-3.5 py-1.5 text-sm font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
{t.companion.inviteAccept}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
declineInvite()
}}
className="rounded-full px-3.5 py-1.5 text-sm font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
{t.companion.inviteDecline}
</button>
</div>
)}
</div>
)}