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
+14
View File
@@ -307,6 +307,18 @@ export default function App() {
[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)
@@ -597,6 +609,8 @@ export default function App() {
editTick={editTick}
acceptTick={acceptTick}
text={docText}
blankPage={wordCount === 0 && docText.trim() === ''}
onAcceptInvitation={handleAcceptInvitation}
/>
</div>
</div>
+10 -2
View File
@@ -157,6 +157,10 @@ export interface Suggestion {
explanation: string
type: SuggestionType
status: 'pending' | 'accepted' | 'rejected'
// Which engine proposed it — the offline rule pack or the model. The rail
// deliberately renders both identically; this is here because the wire format
// carries it, not because the writer is ever shown it.
source?: 'llm' | 'local'
created_at: string
}
@@ -174,14 +178,18 @@ export interface GrowthJournal {
}
// A deterministic, rule-based fix detected client-side (see Companion/prose.ts).
// The frontend owns mechanics detection; the backend only persists these as the
// 'mechanics' suggestion family. Spans are exact plaintext offsets.
// The frontend owns offline detection; the backend only persists these. Spans
// are exact plaintext offsets. `type` names the family the finding belongs to:
// 'mechanics' for a fix to this sentence, 'collocation' for the miscollocation
// rules, whose findings are chunks worth keeping and are filed — and planted in
// the garden on accept — exactly like the LLM coach's.
export interface MechanicsFinding {
from: number
to: number
original: string
replacement: string
explanation: string
type: 'mechanics' | 'collocation'
}
// One dictionary's worth of personal words — the ones she's excused from
@@ -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>
)}
@@ -0,0 +1,261 @@
import { afterEach, describe, expect, it } from 'vitest'
import { analyzeProse, mechanicsFindings } from './prose'
import { resetPackForTests, setPackLang } from '../../i18n'
// Phase 22's offline half: the grammar-lite rule pack, the embedded
// miscollocation list, the false-friend heads-up, and the per-pair L1 rules.
//
// The bar these tests enforce is the one the pack promises: **precision over
// recall**. Every rule is pinned in two directions — the mistake it must catch,
// and the correct English next to it that it must leave alone. A rule that
// cannot be guarded that way was left out of the pack rather than tested
// loosely here.
afterEach(() => resetPackForTests())
const rules = (text: string) => analyzeProse(text).map((h) => h.rule)
const findings = (text: string) => mechanicsFindings(text)
// The one-click fix a given rule produced, if any.
function fix(text: string, original: string) {
return findings(text).find((f) => f.original.toLowerCase() === original.toLowerCase())
}
// Every finding must be able to anchor: its span has to be exactly the text it
// claims, or the editor applies the edit to the wrong characters.
function expectExactSpans(text: string) {
for (const f of findings(text)) {
expect(text.slice(f.from, f.to), `span mismatch for "${f.original}"`).toBe(f.original)
}
return findings(text)
}
describe('prepositions', () => {
it('depend of → depend on, keeping the writer\'s own verb form', () => {
expect(fix('It all depends of the weather on the day we travel.', 'depends of')?.replacement).toBe(
'depends on',
)
expect(fix('Depending of the weather we will go to the beach today.', 'Depending of')?.replacement).toBe(
'Depending on',
)
})
it('discuss about → discuss (the preposition simply goes)', () => {
expect(fix('We discussed about the plan for a long time yesterday.', 'discussed about')?.replacement).toBe(
'discussed',
)
})
it('explain me → explain to me', () => {
expect(fix('Can you explain me the rules of this game again please.', 'explain me')?.replacement).toBe(
'explain to me',
)
})
it('listen the radio → listen to the radio', () => {
expect(fix('I listen the radio every morning while I make my coffee.', 'listen the')?.replacement).toBe(
'listen to the',
)
})
// The pairings deliberately NOT in the list, because they are only usually
// wrong. Each of these is correct English and must stay silent.
it('leaves the correct prepositions alone', () => {
expect(rules('It all depends on the weather on the day we travel.')).not.toContain('preposition')
expect(rules('We discussed the plan for a long time yesterday afternoon.')).not.toContain('preposition')
expect(rules('I listen to the radio every morning while I make coffee.')).not.toContain('preposition')
// Left out of the pack on purpose — "married with children" is a phrase,
// "arrive to" wants at or in, "different than" is ordinary American usage.
expect(rules('She is married with children and lives near the old harbour.')).not.toContain('preposition')
expect(rules('This result is different than the one we saw last week.')).not.toContain('preposition')
})
})
describe('doubled comparatives', () => {
it('more better → better', () => {
expect(fix('This one is more better than the other one we tried.', 'more better')?.replacement).toBe('better')
})
it('most easiest → easiest', () => {
expect(fix('That was the most easiest question on the whole exam paper.', 'most easiest')?.replacement).toBe(
'easiest',
)
})
// The guard the generic /\w+er/ pattern would have failed: these are correct.
it('leaves ordinary "more/most + adjective" alone', () => {
expect(rules('She is more clever than anyone else in the whole class.')).not.toContain('doublecomp')
expect(rules('He was the most eager student in the room that morning.')).not.toContain('doublecomp')
expect(rules('This is the most beautiful garden I have ever seen here.')).not.toContain('doublecomp')
})
})
describe('people is', () => {
it('people is → people are, and people has → people have', () => {
expect(fix('Many people is waiting outside the hall in the rain.', 'people is')?.replacement).toBe('people are')
expect(fix('Some people has never seen the sea in their whole life.', 'people has')?.replacement).toBe(
'people have',
)
})
it('leaves the plural alone', () => {
expect(rules('Many people are waiting outside the hall in the rain.')).not.toContain('peopleare')
})
})
describe('miscollocations', () => {
// The whole point of the family: these file as 'collocation', not
// 'mechanics', so an accepted chunk plants in the vocabulary garden exactly
// as one the LLM coach proposed would.
it('files as the collocation family, not as mechanics', () => {
const f = fix('I had to do a decision about the job offer quickly.', 'do a decision')
expect(f?.replacement).toBe('make a decision')
expect(f?.type).toBe('collocation')
})
it('mechanics fixes keep their own family', () => {
expect(fix('I saw the the cat in the garden this morning.', 'the the')?.type).toBe('mechanics')
})
it('agrees with the tense the writer was already using', () => {
expect(fix('She did a mistake on the form and had to start again.', 'did a mistake')?.replacement).toBe(
'made a mistake',
)
expect(fix('He is making his homework at the kitchen table right now.', 'making his homework')?.replacement).toBe(
'doing his homework',
)
})
it('say me → tell me', () => {
expect(fix('Please say me what happened at the meeting this afternoon.', 'say me')?.replacement).toBe('tell me')
})
it('make a photo → take a photo', () => {
expect(fix('We made a photo together in front of the old church.', 'made a photo')?.replacement).toBe(
'took a photo',
)
})
it('strong rain → heavy rain', () => {
expect(fix('There was strong rain all afternoon and we stayed inside.', 'strong rain')?.replacement).toBe(
'heavy rain',
)
})
// "strong wind" is the correct pairing, and the rule that fixes "big wind"
// must not propose it as a change to itself.
it('never proposes a phrase identical to what she wrote', () => {
expect(rules('There was a strong wind blowing across the open field today.')).not.toContain('collocation')
expect(fix('There was a big wind blowing across the open field today.', 'big wind')?.replacement).toBe(
'strong wind',
)
})
it('leaves the correct pairings alone', () => {
expect(rules('I had to make a decision about the job offer quickly.')).not.toContain('collocation')
expect(rules('She does her homework at the kitchen table every evening.')).not.toContain('collocation')
expect(rules('We took a photo together in front of the old church.')).not.toContain('collocation')
})
})
describe('false friends', () => {
it('flags a Portuguese false friend for the pt-PT pair, awareness-only', () => {
setPackLang('pt-PT')
const hints = analyzeProse('I will eventually finish the report before the end of the week.')
const ff = hints.find((h) => h.rule === 'falsefriend')
expect(ff).toBeDefined()
// Never a card: the word may well be the one she meant, and a one-click
// "fix" would be Petal deciding that for her.
expect(ff?.fix).toBeUndefined()
expect(findings('I will eventually finish the report before the end of the week.')).toEqual([])
})
it('raises at most one per pass — a heads-up, not a sweep', () => {
setPackLang('pt-PT')
const hints = analyzeProse(
'Actually I did not pretend to assist the lecture at the library this week.',
)
expect(hints.filter((h) => h.rule === 'falsefriend')).toHaveLength(1)
})
it('says nothing for the zh pair, which has no false friends at all', () => {
setPackLang('zh')
expect(rules('I will eventually finish the report before the end of the week.')).not.toContain('falsefriend')
})
})
describe('per-pair L1 interference', () => {
it('pt-PT: "have 30 years" → "am 30 years old"', () => {
setPackLang('pt-PT')
expect(fix('My sister I have 30 years and she is older than me.', 'I have 30 years')?.replacement).toBe(
'I am 30 years old',
)
// The subject and tense she wrote in are carried into the correction.
expect(fix('When we met she had twenty years and I was still at school.', 'she had twenty years')?.replacement).toBe(
'she was twenty years old',
)
})
it('pt-PT: "I am agree" → "I agree"', () => {
setPackLang('pt-PT')
expect(fix('I am agree with everything that was said at the meeting.', 'I am agree')?.replacement).toBe('I agree')
})
it('pt-PT: "since three years" → "for three years"', () => {
setPackLang('pt-PT')
expect(fix('I have lived in this city since three years and I love it.', 'since three years')?.replacement).toBe(
'for three years',
)
})
it('pt-PT: leaves "since" with a starting point alone', () => {
setPackLang('pt-PT')
expect(rules('I have lived in this city since 2020 and I still love it.')).not.toContain('since')
})
it('zh: "very like" → "really like", and "open the light" → "turn on the light"', () => {
setPackLang('zh')
expect(fix('I very like the small garden behind my grandmother house.', 'very like')?.replacement).toBe(
'really like',
)
expect(fix('Please open the light before you come into the dark room.', 'open the light')?.replacement).toBe(
'turn on the light',
)
expect(fix('She closed the television and went straight to bed last night.', 'closed the television')?.replacement).toBe(
'turned off the television',
)
})
it('zh: although…but is awareness-only — the "but" is too common to anchor a card to', () => {
setPackLang('zh')
const text = 'Although it was raining hard, but we still went to the park.'
expect(rules(text)).toContain('althoughbut')
expect(findings(text).some((f) => f.original.includes('but'))).toBe(false)
})
it('zh: leaves "very + adjective" alone', () => {
setPackLang('zh')
expect(rules('I am very happy about the small garden behind the house.')).not.toContain('veryverb')
})
// The gating is the reason these rules can be confident. A rule that is a
// near-certainty for one L1 is only a guess for another, and a guess does not
// belong in a rule pack that runs on every keystroke.
it('does not run one pair\'s interference rules for the other pair', () => {
setPackLang('zh')
expect(rules('I have 30 years and I still live near the old harbour.')).not.toContain('haveyears')
setPackLang('pt-PT')
expect(rules('I very like the small garden behind my grandmother house.')).not.toContain('veryverb')
})
})
describe('spans stay exact across every new rule', () => {
it('anchors each finding to the text it claims', () => {
setPackLang('pt-PT')
expectExactSpans(
'I have 30 years and I am agree that we depends of the weather, and she did a mistake since three years.',
)
setPackLang('zh')
expectExactSpans('I very like to open the light, and many people is more better at it than me.')
})
})
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { markInvited, mayInvite, todayKey } from './invitation'
import { resetPackForTests, setPackLang } from '../../i18n'
import { zh } from '../../i18n/packs/zh'
import { ptPT } from '../../i18n/packs/pt-PT'
import { declined, invitations } from './tips'
// The suite runs without a DOM, so localStorage is stubbed the same way
// prefs.test.ts does it.
function fakeStorage(): Storage {
const map = new Map<string, string>()
return {
get length() {
return map.size
},
key: (i: number) => [...map.keys()][i] ?? null,
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
clear: () => map.clear(),
} as Storage
}
beforeEach(() => vi.stubGlobal('localStorage', fakeStorage()))
afterEach(() => {
vi.unstubAllGlobals()
resetPackForTests()
})
describe('once a day', () => {
it('offers, then does not offer again the same day', () => {
expect(mayInvite()).toBe(true)
markInvited()
expect(mayInvite()).toBe(false)
})
it('offers again the next day', () => {
const monday = new Date(2026, 6, 27, 10, 0)
const tuesday = new Date(2026, 6, 28, 9, 0)
markInvited(monday)
expect(mayInvite(monday)).toBe(false)
expect(mayInvite(tuesday)).toBe(true)
})
// The whole promise of §5c: nothing is counting. A month away has to look
// exactly like a day away, because the alternative is a streak, and a streak
// punishes exactly the person this feature is for.
it('treats a month away the same as a day away', () => {
const june = new Date(2026, 5, 1, 10, 0)
const july = new Date(2026, 6, 27, 10, 0)
markInvited(june)
expect(mayInvite(july)).toBe(true)
// And after being asked again, still only ever one stored value: a date.
markInvited(july)
expect(localStorage.getItem('petal.invited')).toBe(todayKey(july))
// One stored value, and it is a date. Nothing accumulates.
expect(localStorage.length).toBe(1)
})
it('uses the writer\'s local day, so a late night and the small hours differ', () => {
const lateNight = new Date(2026, 6, 27, 23, 30)
const smallHours = new Date(2026, 6, 28, 1, 15)
markInvited(lateNight)
expect(mayInvite(smallHours)).toBe(true)
})
})
describe('the invitation copy', () => {
const PACKS = [
{ name: 'zh', pack: zh },
{ name: 'pt-PT', pack: ptPT },
]
it('every pack offers something to write about, and a way to say no', () => {
for (const { name, pack } of PACKS) {
expect(pack.companion.invitations.length, name).toBeGreaterThan(3)
expect(pack.companion.inviteAccept.trim(), name).not.toBe('')
expect(pack.companion.inviteDecline.trim(), name).not.toBe('')
expect(pack.companion.declined.native.trim(), name).not.toBe('')
}
})
// The copy is bound by the same rule as the timing: no streaks, no guilt, no
// counting of days. This is the part a well-meaning future edit would undo —
// "day 4 in a row!" is a natural thing to write and the wrong thing to say.
it('never invokes a streak, a target, or a missed day', () => {
const forbidden =
/streak|in a row|every day|don't break|dont break|missed|behind|连续|打卡|坚持|seguidos|todos os dias|falhaste/i
for (const { name, pack } of PACKS) {
const copy = [
...pack.companion.invitations.flatMap((l) => [l.native, l.en]),
pack.companion.inviteAccept,
pack.companion.inviteDecline,
pack.companion.declined.native,
pack.companion.declined.en,
]
for (const line of copy) {
expect(line, `${name}: ${line}`).not.toMatch(forbidden)
}
}
})
it('reads from the pair in force, like every other companion line', () => {
setPackLang('pt-PT')
expect(invitations()).toBe(ptPT.companion.invitations)
expect(declined()).toBe(ptPT.companion.declined)
setPackLang('zh')
expect(invitations()).toBe(zh.companion.invitations)
})
})
@@ -0,0 +1,36 @@
// When the companion may offer its daily invitation to write.
//
// A handful of lines, pulled out of the timing engine on purpose: this is the
// part of §5c with the ethics in it, and it should be readable and testable on
// its own rather than buried in a heartbeat.
//
// The rule is a *date*, not a count and not a run of days. Petal remembers the
// last day it asked and nothing else — so there is no streak to break, no tally
// of days missed, and nothing that gets worse for being away a week. Coming
// back after a month looks exactly like coming back tomorrow, which is the only
// version of this feature worth shipping to someone learning a language.
//
// Either answer spends the day's invitation. Being asked again after saying no
// would turn "not today" into a negotiation.
import { readPref, writePref } from '../../lib/prefs'
const INVITED_KEY = 'petal.invited'
// The local calendar day. Local rather than UTC because "today" is the writer's
// day: a nudge at 11pm and another at 1am would otherwise be two different days.
export function todayKey(now = new Date()): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
// mayInvite reports whether today's invitation is still unspent.
export function mayInvite(now = new Date()): boolean {
return readPref(INVITED_KEY) !== todayKey(now)
}
// markInvited spends it — called when the invitation is *offered*, not when it
// is accepted, because declining has to count too.
export function markInvited(now = new Date()): void {
writePref(INVITED_KEY, todayKey(now))
}
+446 -7
View File
@@ -19,6 +19,7 @@
// fix-bearing hints so the same span never appears as both a bubble and a card.
import { pack } from '../../i18n'
import type { PairLang } from '../../i18n'
import type { Line } from './tips'
// The pair's prose copy. Read per finding rather than captured once, so a rule
@@ -41,8 +42,16 @@ export interface Fix {
from: number
to: number
replacement: string
// Which suggestion family the card belongs to. Omitted means 'mechanics' — a
// fix to *this* sentence. The miscollocation rules set 'collocation', because
// what they hand over is a reusable chunk: the writer sees the same rail and
// the same warm phrasing as the LLM coach, and an accepted chunk plants in the
// vocabulary garden exactly as the coach's would.
family?: FindingFamily
}
export type FindingFamily = 'mechanics' | 'collocation'
// A deterministic suggestion-card finding, derived from an applyable hint. Mirrors
// the backend's card shape (original/replacement/explanation + span) so the card
// pipeline can persist it as the 'mechanics' family. `explanation` is the English
@@ -53,6 +62,9 @@ export interface MechanicsFinding {
original: string
replacement: string
explanation: string
// The family the server should file it under (see Fix.family). Always sent, so
// the backend never has to infer it from the endpoint it arrived on.
type: FindingFamily
}
// ── small text helpers ──────────────────────────────────────────────────────
@@ -630,19 +642,438 @@ function thanThen(text: string, out: ProseHint[]) {
}
}
// "people is" — people is already plural in English, and every language Petal
// pairs with has a singular word for it (人 / a gente / les gens / la gente).
// Universal rather than per-pair for exactly that reason.
const PEOPLE_IS_RE = /\b(people)\s+(is|was|has)\b/gi
const PEOPLE_PLURAL: Record<string, string> = { is: 'are', was: 'were', has: 'have' }
function peopleAre(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
PEOPLE_IS_RE.lastIndex = 0
while ((m = PEOPLE_IS_RE.exec(text))) {
const plural = PEOPLE_PLURAL[m[2].toLowerCase()]
const fixed = `${m[1]} ${matchCase(m[2], plural)}`
out.push({
id: `peopleare:${m.index}`,
rule: 'peopleare',
native: P().peopleArePlural(plural),
en: `“People” is plural in English: “people ${plural}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// ── preposition pairs ───────────────────────────────────────────────────────
// The verbs and adjectives whose preposition English simply *decides* for you.
// There is no rule to learn here — "depend" takes "on" and that is the end of
// it — which is exactly what makes the offline pack the right place for them:
// they are data, not judgement, and a lookup is instant.
//
// Every entry is a pairing that is wrong in essentially all contexts. The ones
// that are only *usually* wrong were left out on purpose: "married with" is a
// mistake until "married with children", "arrive to" wants at or in depending
// on the noun, "different than" is ordinary American English. Precision over
// recall — a confident wrong correction costs more than a quiet miss.
interface PrepRule {
// Matched case-insensitively, with \b at both ends. The first group is the
// head word (kept, casing preserved), the rest is replaced wholesale.
re: RegExp
// The corrected phrase, with $1 standing for the captured head word.
to: string
}
const PREPOSITIONS: PrepRule[] = [
{ re: /\b(depend|depends|depended|depending)\s+of\b/gi, to: '$1 on' },
{ re: /\b(discuss|discusses|discussed|discussing)\s+about\b/gi, to: '$1' },
{ re: /\b(participate|participates|participated|participating)\s+to\b/gi, to: '$1 in' },
{ re: /\b(interested)\s+(?:about|for)\b/gi, to: '$1 in' },
{ re: /\b(responsible)\s+of\b/gi, to: '$1 for' },
{ re: /\b(capable)\s+to\b/gi, to: '$1 of' },
{ re: /\b(afraid)\s+(?:from|of to)\b/gi, to: '$1 of' },
{ re: /\b(according)\s+with\b/gi, to: '$1 to' },
{ re: /\b(explain|explains|explained)\s+(me|us|him|her|them)\b/gi, to: '$1 to $2' },
// "listen the radio" — the object of "listen" always arrives through "to".
{ re: /\b(listen|listens|listened|listening)\s+(the|a|an|my|your|his|her|our|their|this|that|these|those|music|me|him|us|them)\b/gi, to: '$1 to $2' },
]
function prepositions(text: string, out: ProseHint[]) {
for (const rule of PREPOSITIONS) {
let m: RegExpExecArray | null
rule.re.lastIndex = 0
while ((m = rule.re.exec(text))) {
// Rebuild the corrected phrase from the captures so the head word keeps the
// writer's own casing ("Depending of" → "Depending on").
const fixed = rule.to.replace(/\$(\d)/g, (_, d: string) => m![Number(d)] ?? '')
if (fixed === m[0]) continue
out.push({
id: `prep:${m.index}:${key(m[0])}`,
rule: 'preposition',
native: P().preposition(m[0].trim(), fixed),
en: `In English it's “${fixed}”, not “${m[0].trim()}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
}
// Doubled comparatives and superlatives — "more better", "most easiest". The
// -er/-est ending already carries the comparison, so the "more"/"most" is the
// part that goes. An explicit form list rather than a generic /\w+er/ pattern,
// which would catch "more clever" and "most eager" (both perfectly correct).
const COMPARATIVE_FORMS =
'better|worse|greater|older|younger|bigger|smaller|larger|faster|slower|higher|' +
'lower|cheaper|stronger|weaker|easier|harder|earlier|later|sooner|longer|' +
'shorter|taller|richer|poorer|happier|safer|nicer|closer|warmer|colder'
const SUPERLATIVE_FORMS =
'best|worst|greatest|oldest|youngest|biggest|smallest|largest|fastest|slowest|' +
'highest|lowest|cheapest|strongest|weakest|easiest|hardest|earliest|latest|' +
'soonest|longest|shortest|tallest|richest|poorest|happiest|safest|nicest|' +
'closest|warmest|coldest'
const DOUBLE_COMPARATIVE_RE = new RegExp(
`\\b(more)\\s+(${COMPARATIVE_FORMS})\\b|\\b(most)\\s+(${SUPERLATIVE_FORMS})\\b`,
'gi',
)
function doubleComparative(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
DOUBLE_COMPARATIVE_RE.lastIndex = 0
while ((m = DOUBLE_COMPARATIVE_RE.exec(text))) {
const lead = m[1] ?? m[3]
const word = m[2] ?? m[4]
out.push({
id: `doublecomp:${m.index}`,
rule: 'doublecomp',
native: P().doubleComparative(lead, word),
en: `${word}” is already the comparison — “${lead}” isn't needed: just “${word}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(lead, word) },
})
}
}
// ── miscollocations (the collocation family, offline half) ──────────────────
// A few dozen entries of curated data doing what the collocation coach does
// with a model behind a VPN. These are the do/make, say/tell, heavy-rain pairs
// that fill every ESL collocation workbook: the writer's grammar is perfect and
// the pairing is simply not the one English uses.
//
// They file as 'collocation', not 'mechanics', because that is what they are —
// and it earns the writer the rest of the family's behaviour for free: the same
// card, and a phrase card planted in the vocabulary garden when she accepts.
// The writer never needs to know which engine spoke.
//
// Each entry is a whole-phrase swap so the card can anchor by string, and the
// object is captured rather than listed, so "do a serious mistake" is caught
// alongside "do a mistake".
interface CollocationRule {
re: RegExp
to: string
}
const MISCOLLOCATIONS: CollocationRule[] = [
// do / make — the classic pair, in both directions.
{ re: /\b(do|does|did|doing)\s+(a|an|the|my|your|his|her|our|their)\s+(decision|mistake|mistakes|effort|question|questions|progress|joke|jokes|favou?r)\b/gi, to: 'MAKE' },
{ re: /\b(make|makes|made|making)\s+(my|your|his|her|our|their|the)\s+(homework|housework|laundry|dishes|shopping)\b/gi, to: 'DO' },
{ re: /\b(make|makes|made|making)\s+(a|an|the|my|your|his|her|our|their)\s+(photo|photos|picture|pictures|shower|bath|walk|trip|nap|break|exam|exams|test|bus|taxi|train)\b/gi, to: 'TAKE' },
{ re: /\b(make|makes|made|making)\s+(a|an|the)\s+(party|baby|good time|meeting)\b/gi, to: 'HAVE' },
{ re: /\b(make|makes|made|making)\s+(a|an|the|my|your|his|her)\s+(question|questions)\b/gi, to: 'ASK' },
{ re: /\b(make|makes|made|making|do|does|did|doing)\s+attention\b/gi, to: 'PAY_ATTENTION' },
// say / tell — "say me" for "tell me" is near-universal among learners.
{ re: /\b(say|says|said|saying)\s+(me|him|her|us|them)\b/gi, to: 'TELL' },
{ re: /\b(say|says|said|saying)\s+(a|the)\s+(lie|lies|truth|joke|jokes|story|stories)\b/gi, to: 'TELL_A' },
// Weather and intensity — English picks a different adjective per noun.
{ re: /\b(strong|big|hard|huge)\s+(rain|snow|traffic|fog)\b/gi, to: 'HEAVY' },
{ re: /\b(strong|heavy|big)\s+(wind|winds)\b/gi, to: 'STRONG_WIND' },
]
// Rebuild the corrected phrase for one miscollocation match. Kept as code rather
// than a `to` template because the verb has to agree with the writer's own tense
// ("did a mistake" → "made a mistake"), and only the verb form knows that.
const VERB_FORMS: Record<string, Record<string, string>> = {
make: { base: 'make', s: 'makes', past: 'made', ing: 'making' },
do: { base: 'do', s: 'does', past: 'did', ing: 'doing' },
take: { base: 'take', s: 'takes', past: 'took', ing: 'taking' },
have: { base: 'have', s: 'has', past: 'had', ing: 'having' },
ask: { base: 'ask', s: 'asks', past: 'asked', ing: 'asking' },
tell: { base: 'tell', s: 'tells', past: 'told', ing: 'telling' },
pay: { base: 'pay', s: 'pays', past: 'paid', ing: 'paying' },
}
// Which slot of VERB_FORMS the writer's own verb occupies, so the replacement
// lands in the same tense she was writing in.
function verbSlot(verb: string): string {
const v = verb.toLowerCase()
if (v.endsWith('ing')) return 'ing'
if (v === 'did' || v === 'made' || v === 'took' || v === 'had' || v === 'told' || v === 'said' || v === 'paid' || v.endsWith('ed')) return 'past'
if (v === 'does' || v === 'says' || v === 'has' || v.endsWith('s')) return 's'
return 'base'
}
function collocationFix(m: RegExpExecArray, to: string): string | null {
const slot = verbSlot(m[1])
const conj = (v: string) => matchCase(m[1], VERB_FORMS[v][slot])
switch (to) {
case 'MAKE':
case 'DO':
case 'TAKE':
case 'HAVE':
case 'ASK':
return `${conj(to.toLowerCase())} ${m[2]} ${m[3]}`
case 'TELL':
return `${conj('tell')} ${m[2]}`
case 'TELL_A':
return `${conj('tell')} ${m[2]} ${m[3]}`
case 'PAY_ATTENTION':
return `${conj('pay')} attention`
case 'HEAVY':
return `${matchCase(m[1], 'heavy')} ${m[2]}`
case 'STRONG_WIND':
return m[1].toLowerCase() === 'strong' ? null : `${matchCase(m[1], 'strong')} ${m[2]}`
default:
return null
}
}
function miscollocations(text: string, out: ProseHint[]) {
for (const rule of MISCOLLOCATIONS) {
let m: RegExpExecArray | null
rule.re.lastIndex = 0
while ((m = rule.re.exec(text))) {
const fixed = collocationFix(m, rule.to)
if (!fixed || fixed.toLowerCase() === m[0].toLowerCase()) continue
out.push({
id: `colloc:${m.index}:${key(m[0])}`,
rule: 'collocation',
native: P().collocation(m[0].trim(), fixed),
en: `English usually pairs these differently: “${fixed}” rather than “${m[0].trim()}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed, family: 'collocation' },
})
}
}
}
// ── false friends ───────────────────────────────────────────────────────────
// A word that looks like one of hers and means something else. This is the
// mistake that makes a learner feel foolish rather than merely corrected, so
// Petal only ever raises an eyebrow: awareness-only, one per pass, and never a
// replacement. "Actually" really might be the word she wanted — the flag says
// what it means in English and lets her decide.
//
// The list is per pair and lives in the langpack (a zh pair has none: the trap
// needs a shared script to spring). See Pack.falseFriends.
function falseFriends(text: string, out: ProseHint[]) {
const list = pack().falseFriends
const words = Object.keys(list)
if (words.length === 0) return
// Escaped, because these keys are pack data: a future author writing "e.g."
// should get a heads-up, not a pattern that quietly matches everything.
const re = new RegExp(`\\b(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`, 'gi')
let m: RegExpExecArray | null
while ((m = re.exec(text))) {
const entry = list[m[1].toLowerCase()]
if (!entry) continue
out.push({
id: `falsefriend:${m[1].toLowerCase()}`,
rule: 'falsefriend',
native: entry.native,
en: entry.en,
})
return // one per pass — a heads-up, not a sweep
}
}
// ── per-pair L1 interference ────────────────────────────────────────────────
// Mistakes that are not "English mistakes" at all but the writer's own language
// showing through: *ter 30 anos* becomes "have 30 years", 很喜欢 becomes "very
// like", 开灯 becomes "open the light". They are gated by pair precisely so they
// can be confident — a pattern that is a near-certainty for a Portuguese speaker
// is only a guess for anybody else, and a guess doesn't belong in a rule pack.
//
// The rules a pair *doesn't* get are as deliberate as the ones it does. Mandarin
// drops articles and slips he/she, both of which the plan names — and neither is
// detectable from the text alone. "She said he was late" is a perfect sentence
// whichever pronoun was meant, and no offline rule can tell a missing "the" from
// a mass noun. Flagging them would mean correcting correct writing, which is the
// one thing this pack promises not to do.
const NUMBER_WORD =
'one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|' +
'fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|' +
'fifty|sixty|seventy|eighty|ninety'
// *Ter X anos* / *avoir X ans* / *tener X años*: age is something you *have* in
// every Romance language and something you *are* in English.
const HAVE_YEARS_RE = new RegExp(
`\\b(I|you|we|they|he|she)\\s+(have|has|had)\\s+(\\d{1,3}|${NUMBER_WORD})\\s+years(\\s+old)?\\b`,
'gi',
)
// The English "to be" that matches the subject and the tense she wrote in.
function beFor(subject: string, verb: string): string {
const past = verb.toLowerCase() === 'had'
const s = subject.toLowerCase()
if (s === 'i') return past ? 'was' : 'am'
if (s === 'he' || s === 'she') return past ? 'was' : 'is'
return past ? 'were' : 'are'
}
function haveYears(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
HAVE_YEARS_RE.lastIndex = 0
while ((m = HAVE_YEARS_RE.exec(text))) {
const fixed = `${m[1]} ${beFor(m[1], m[2])} ${m[3]} years old`
out.push({
id: `haveyears:${m.index}`,
rule: 'haveyears',
native: P().ageIsNotHave(m[3]),
en: `In English you *are* your age: “${fixed}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// *Estou de acordo* / *je suis d'accord*: agreement is a verb in English, so the
// "to be" in front of it has nothing to do.
const AM_AGREE_RE = /\b(I|you|we|they|he|she)\s+(am|are|is|was|were)\s+agree\b/gi
function amAgree(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
AM_AGREE_RE.lastIndex = 0
while ((m = AM_AGREE_RE.exec(text))) {
const past = /^(was|were)$/i.test(m[2])
const verb = past ? 'agreed' : m[1].toLowerCase() === 'he' || m[1].toLowerCase() === 'she' ? 'agrees' : 'agree'
const fixed = `${m[1]} ${verb}`
out.push({
id: `amagree:${m.index}`,
rule: 'amagree',
native: P().agreeIsAVerb,
en: `“Agree” is already the verb: “${fixed}”, not “${m[0].trim()}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// *desde há três anos* / *depuis trois ans*: a stretch of time takes "for";
// "since" wants the moment it started.
const SINCE_DURATION_RE = new RegExp(
`\\b(since)\\s+((?:\\d{1,3}|${NUMBER_WORD}|a few|several|many)\\s+(?:years|months|weeks|days|hours|minutes))\\b`,
'gi',
)
function sinceDuration(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
SINCE_DURATION_RE.lastIndex = 0
while ((m = SINCE_DURATION_RE.exec(text))) {
const fixed = `${matchCase(m[1], 'for')} ${m[2]}`
out.push({
id: `since:${m.index}`,
rule: 'since',
native: P().forNotSince(m[2]),
en: `For a length of time use “for”: “${fixed}”. “Since” names when it started.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// 很喜欢 — 很 modifies adjectives *and* verbs in Mandarin, so "very" arrives in
// front of English verbs, where it cannot go.
const VERY_VERB_RE =
/\b(very)\s+(like|likes|liked|want|wants|wanted|enjoy|enjoys|enjoyed|hope|hopes|hoped|miss|misses|missed|need|needs|needed|love|loves|loved|agree|agrees|agreed|understand|understands)\b/gi
function veryVerb(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
VERY_VERB_RE.lastIndex = 0
while ((m = VERY_VERB_RE.exec(text))) {
const fixed = `${matchCase(m[1], 'really')} ${m[2]}`
out.push({
id: `veryverb:${m.index}`,
rule: 'veryverb',
native: P().veryBeforeVerb(m[2]),
en: `“Very” goes with adjectives, not verbs — “${fixed}” (or “${m[2]} … very much”).`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// 开灯 / 关电视 — Mandarin opens and closes appliances; English turns them on
// and off.
const TURN_FORMS: Record<string, string> = {
base: 'turn',
s: 'turns',
past: 'turned',
ing: 'turning',
}
const OPEN_LIGHT_RE =
/\b(open|opens|opened|close|closes|closed)\s+(the|a|my|your|his|her|our|their)\s+(light|lights|lamp|tv|television|radio|computer|fan|heater|air conditioner|air-conditioner)\b/gi
function openTheLight(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
OPEN_LIGHT_RE.lastIndex = 0
while ((m = OPEN_LIGHT_RE.exec(text))) {
const opening = m[1].toLowerCase().startsWith('open')
const turn = matchCase(m[1], TURN_FORMS[verbSlot(m[1])] ?? 'turn')
const fixed = `${turn} ${opening ? 'on' : 'off'} ${m[2]} ${m[3]}`
out.push({
id: `openlight:${m.index}`,
rule: 'openlight',
native: P().turnOnNotOpen(m[3], opening),
en: `In English you turn a ${m[3]} ${opening ? 'on' : 'off'}: “${fixed}”.`,
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
})
}
}
// 虽然…但是 is a matched pair in Mandarin; English takes one or the other, never
// both. Awareness-only: which half to drop is the writer's call, and the "but"
// on its own is far too common a word to anchor a card to.
const ALTHOUGH_BUT_RE = /\b(although|though|even though)\b[^.!?]{0,120}?,?\s+but\b/gi
function althoughBut(text: string, out: ProseHint[]) {
let m: RegExpExecArray | null
ALTHOUGH_BUT_RE.lastIndex = 0
while ((m = ALTHOUGH_BUT_RE.exec(text))) {
out.push({
id: `althoughbut:${key(m[0])}`,
rule: 'althoughbut',
native: P().althoughOrBut(m[1]),
en: `English uses “${m[1]}” or “but”, not both — one of them can go.`,
})
return
}
}
type Rule = (text: string, out: ProseHint[]) => void
// Which interference rules belong to which pair. A pair with no entry simply
// runs the shared pack, which is the correct behaviour for a language Petal has
// not studied yet rather than a gap to fill with guesses.
const L1_RULES: Partial<Record<PairLang, Rule[]>> = {
zh: [veryVerb, openTheLight, althoughBut],
'pt-PT': [haveYears, amAgree, sinceDuration],
fr: [haveYears, amAgree, sinceDuration],
es: [haveYears, amAgree, sinceDuration],
}
// ── orchestration ───────────────────────────────────────────────────────────
// Rules run in priority order — the ones the writer cares most about first, so
// that when several fire at once the companion leads with the weightiest note.
// The false-friend heads-up sits near the top: of everything here it is the one
// that costs her most to find out about later.
const RULES: Array<(text: string, out: ProseHint[]) => void> = [
runOns,
commaSplices,
falseFriends,
antecedents,
oxford,
miscollocations,
articles,
uncountables,
properCaps,
subjectVerbAgreement,
peopleAre,
prepositions,
doubleComparative,
pluralAfterNumber,
doubleDeterminer,
thereIsPlural,
@@ -656,6 +1087,13 @@ const RULES: Array<(text: string, out: ProseHint[]) => void> = [
spaceAfterPunct,
]
// Every rule that runs for this writer: the shared pack, plus the interference
// rules belonging to her pair. Read per call rather than built once — the pair
// isn't known until /api/me answers, and the checker runs long before and after.
function rulesFor(): Rule[] {
return [...RULES, ...(L1_RULES[pack().code] ?? [])]
}
// analyzeProse returns context-aware hints, highest-priority first. It bails on
// text too short to advise on (mid-thought drafts shouldn't get picked apart).
// Hints that carry a `fix` are applyable (they also surface as suggestion cards);
@@ -664,26 +1102,27 @@ export function analyzeProse(text: string): ProseHint[] {
const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0
if (englishWords < 8) return []
const out: ProseHint[] = []
for (const rule of RULES) rule(text, out)
for (const rule of rulesFor()) rule(text, out)
return out
}
// mechanicsFindings returns every applyable deterministic fix in the text, as
// suggestion-card findings with exact spans. No word-count floor: a doubled word
// or a stray lowercase “i” is worth fixing even in a short draft, the way a
// spell-checker would. The card pipeline persists these as the 'mechanics'
// family; collisions with the LLM cards are resolved server-side (mechanics
// wins, since its span is exact).
// spell-checker would. The card pipeline persists these under the family each
// finding names (mechanics, or collocation for the miscollocation rules);
// collisions with the LLM cards are resolved server-side (the offline card wins,
// since its span is exact).
export function mechanicsFindings(text: string): MechanicsFinding[] {
const hints: ProseHint[] = []
for (const rule of RULES) rule(text, hints)
for (const rule of rulesFor()) rule(text, hints)
const found: MechanicsFinding[] = []
for (const h of hints) {
if (!h.fix) continue
const { from, to, replacement } = h.fix
const { from, to, replacement, family } = h.fix
const original = text.slice(from, to)
if (!original || original === replacement) continue
found.push({ from, to, original, replacement, explanation: h.en })
found.push({ from, to, original, replacement, explanation: h.en, type: family ?? 'mechanics' })
}
// Two rules can occasionally claim overlapping spans (e.g. a doubled word that
// also reads as stacked determiners). Resolve to one card per stretch of text:
+2
View File
@@ -18,6 +18,8 @@ export const errors = (): Line[] => pack().companion.errors
export const greeting = (): Line => pack().companion.greeting
export const welcomeBack = (): Line => pack().companion.welcomeBack
export const milestoneLine = (n: number): Line => pack().companion.milestone(n)
export const invitations = (): Line[] => pack().companion.invitations
export const declined = (): Line => pack().companion.declined
// Word-count milestones worth a little cheer — every 100 words, on up. A count,
// not copy: the same in every language.
+101 -4
View File
@@ -4,15 +4,18 @@ import {
MILESTONES,
bedtime,
breaks,
declined,
encouragements,
errors,
greeting,
invitations,
milestoneLine,
pick,
tips,
welcomeBack,
type Line,
} from './tips'
import { markInvited, mayInvite } from './invitation'
import { analyzeProse } from './prose'
import { personalCheer, warmPersonalCheers } from './journalCheers'
import { playPop, playSound, type SoundName } from '../../audio/sounds'
@@ -22,9 +25,12 @@ import { isBedtime } from '../../lib/night'
// otherwise to an emoji placeholder (see PetalCompanion).
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime'
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime' | 'invite'
export interface Bubble extends Line {
tone: BubbleTone
// Present only on the daily invitation: the two answers it can be given. The
// bubble is otherwise a thing to read, so this is the one that grows buttons.
invite?: { prompt: string }
}
interface Signals {
@@ -40,6 +46,10 @@ interface Signals {
// The document's plain text, used by the rules-based prose checker to offer
// context-aware writing notes instead of only generic tips.
text: string
// True when the open document is still empty — she has Petal in front of her
// and nothing started. The only condition under which the daily invitation is
// offered: a writer already mid-paragraph does not need to be invited.
blankPage: boolean
}
// Timing knobs (ms). Tuned to feel present but never naggy.
@@ -48,6 +58,18 @@ const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
const BEDTIME_GAP = 30 * 60_000 // at most one "go to bed" nudge per this window
// How long an empty page sits there before the kitten offers something to write
// about. Long enough that a writer who opened Petal knowing what she wanted to
// say is already typing, short enough to still be an offer rather than an
// interruption.
const INVITE_AFTER_MS = 50_000
// …and the window closes: past this the session has its own shape, and an
// invitation would be arriving out of nowhere.
const INVITE_WINDOW_MS = 8 * 60_000
// The once-a-day rule itself lives in ./invitation — it is the part of this
// feature with a promise in it (a date, never a streak), and it reads better
// stated once than tangled into the heartbeat below.
// The late-night window itself (isBedtime) lives in ../../lib/night so the
// companion nag and the night-mode theme/starfall share one definition.
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
@@ -64,7 +86,17 @@ const now = () => Date.now()
// length of the native + English lines so denser advice stays up long enough
// to actually finish reading.
function readBubbleMs(b: Bubble): number {
const base = b.tone === 'cheer' ? CHEER_MS : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : BUBBLE_MS
// An invitation is the one bubble with a decision in it, so it gets the
// longest look — and it still leaves on its own, which is a third way of
// saying no that costs nothing.
const base =
b.tone === 'cheer'
? CHEER_MS
: b.tone === 'invite'
? BUBBLE_MS + 12_000
: b.tone === 'bedtime'
? BUBBLE_MS + 4_000
: BUBBLE_MS
const chars = b.native.length + b.en.length
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
}
@@ -72,7 +104,15 @@ function readBubbleMs(b: Bubble): number {
// useCompanion is the behavior engine: it watches writing signals and decides
// when the kitten speaks, what mood it shows, and how to pace itself so the
// companion feels alive without interrupting. UI-agnostic — returns state only.
export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Signals) {
export function useCompanion({
wordCount,
saveStatus,
llmDown,
editTick,
acceptTick,
text,
blankPage,
}: Signals) {
const [mood, setMood] = useState<Mood>('idle')
const [bubble, setBubble] = useState<Bubble | null>(null)
@@ -84,6 +124,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
const lastBedtime = useRef(0)
const nextMilestone = useRef(0) // index into MILESTONES
const sleeping = useRef(false)
const invited = useRef(false) // this session, alongside the stored date
// Latest text for the prose checker, read lazily by the heartbeat (kept in a
// ref so per-keystroke changes don't re-arm the interval).
@@ -144,6 +185,27 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
return { ...pick(tips()), tone: 'tip' }
}, [])
// The daily invitation. Accepting hands the prompt back to the app (it titles
// the blank page with it, so the question stays in view while she answers it);
// declining costs nothing at all and says so. Either answer spends the day's
// one invitation — being asked twice after saying no would make "no" a
// negotiation.
const blankRef = useRef(blankPage)
blankRef.current = blankPage
const onInviteRef = useRef<((prompt: string) => void) | undefined>(undefined)
const acceptInvite = useCallback((prompt: string) => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
setBubble(null)
setMood('happy')
onInviteRef.current?.(prompt)
}, [])
const declineInvite = useCallback(() => {
say({ ...declined(), tone: 'tip' })
}, [say])
const dismiss = useCallback(() => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
@@ -264,6 +326,26 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
const t = now()
const idleFor = t - lastActivity.current
// An empty page, a little way into the session, and no invitation yet
// today: offer something small to write about. Checked before the idle
// branch on purpose — sitting in front of a blank page without typing is
// exactly the state this is for, and it is the one state the nap rule
// would otherwise swallow.
const sinceStart = t - sessionStart.current
if (
blankRef.current &&
!invited.current &&
sinceStart > INVITE_AFTER_MS &&
sinceStart < INVITE_WINDOW_MS &&
mayInvite()
) {
invited.current = true
markInvited()
const line = pick(invitations())
say({ ...line, tone: 'invite', invite: { prompt: line.en } })
return
}
if (idleFor > IDLE_MS) {
sleeping.current = true
if (!bubble) setMood('sleeping')
@@ -297,6 +379,12 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
return () => clearInterval(id)
}, [bubble, say, nextTip])
// The app's handler for an accepted invitation, kept in a ref so a new
// callback identity never re-arms the heartbeat.
const setInviteHandler = useCallback((fn: (prompt: string) => void) => {
onInviteRef.current = fn
}, [])
useEffect(
() => () => {
clearTimeout(bubbleTimer.current)
@@ -305,5 +393,14 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
[],
)
return { mood, bubble, dismiss, holdBubble, releaseBubble }
return {
mood,
bubble,
dismiss,
holdBubble,
releaseBubble,
acceptInvite,
declineInvite,
setInviteHandler,
}
}
+28 -1
View File
@@ -32,7 +32,12 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
const reverse = info?.reverse ?? null
// Null whenever the dictionary has no opinion — the chip then doesn't render.
const band = info ? wordBand(info.frequency ?? 0, info.difficulty ?? -1) : null
const empty = !loading && !gloss && !reverse && definitions.length === 0 && synonyms.length === 0
// A word that looks like one of hers and means something else. Curated per
// pair (the zh pack has none), and shown the moment she looks the word up —
// which is the moment she is deciding whether to trust it.
const falseFriend = t.falseFriends[word.toLowerCase()] ?? null
const empty =
!loading && !gloss && !reverse && !falseFriend && definitions.length === 0 && synonyms.length === 0
return (
<div
@@ -130,6 +135,28 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
</div>
)}
{/* The false-friend heads-up, above the definition because it is the one
thing here she might otherwise not think to check. Deliberately not a
warning: no red, no exclamation, and nothing to accept or dismiss —
the word may well be exactly the one she meant, and the card's job is
only to make sure she knows what it says in English. */}
{falseFriend && (
<div
className="mt-2.5 rounded-xl px-2.5 py-2"
style={{ background: 'var(--color-lavender)' }}
>
<p className="leading-snug" style={{ color: 'var(--color-plum)' }}>
<span aria-hidden className="mr-1">
🫖
</span>
{falseFriend.native}
</p>
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
{falseFriend.en}
</p>
</div>
)}
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
{gloss && (
<p
+40
View File
@@ -220,3 +220,43 @@ describe('the pt-PT pack', () => {
expect(ptPT.editor.alsoIn).not.toBe(zh.editor.alsoIn)
})
})
// False friends are a per-pair dataset rather than copy: the Latin pairs carry
// the traps their writers actually fall into, and the zh pair legitimately has
// none. Both halves of that are worth pinning.
describe('false friends', () => {
it('the zh pair has none, because the trap needs a shared script', () => {
expect(Object.keys(zh.falseFriends)).toHaveLength(0)
})
it('the pt-PT pair carries the ones that cost most', () => {
// Not an exhaustive list — these are the four every European Portuguese
// speaker meets in their first month of writing English.
for (const word of ['actually', 'pretend', 'realize', 'library']) {
expect(ptPT.falseFriends[word], word).toBeDefined()
}
expect(Object.keys(ptPT.falseFriends).length).toBeGreaterThan(10)
})
it('is keyed by the lowercase English word, so a lookup can find it', () => {
for (const p of PACKS) {
for (const key of Object.keys(p.falseFriends)) {
expect(key, `${p.code}: "${key}" must be lowercase`).toBe(key.toLowerCase())
expect(p.falseFriends[key].native.trim(), key).not.toBe('')
expect(p.falseFriends[key].en.trim(), key).not.toBe('')
}
}
})
// The heads-up must never read as an accusation: she may well have meant the
// word. It says what the English one means and stops there.
it('never tells her she is wrong', () => {
const forbidden = /wrong|mistake|error|incorrect|don't use|do not use|errado|erro|incorrecto/i
for (const p of PACKS) {
for (const [key, line] of Object.entries(p.falseFriends)) {
expect(line.native, `${p.code}/${key}`).not.toMatch(forbidden)
expect(line.en, `${p.code}/${key}`).not.toMatch(forbidden)
}
}
})
})
+110
View File
@@ -115,6 +115,23 @@ export const ptPT: Pack = {
en: `Wow — ${words} words already! Amazing. 🎉`,
}),
// Uma coisa pequena para escrever, oferecida uma vez por dia a uma página
// em branco. Memórias e opiniões, nunca exercícios: não há aqui nada que se
// possa errar, que é precisamente a intenção.
invitations: [
{ native: 'Escreve 50 palavras: uma coisa pequena que te fez sorrir hoje 🌸', en: 'Write 50 words: one small thing that made you smile today.' },
{ native: 'Escreve 50 palavras: a melhor coisa que comeste hoje', en: 'Write 50 words: the best thing you ate today.' },
{ native: 'Escreve 50 palavras: o que vês da tua janela neste momento', en: 'Write 50 words: what you can see out of your window right now.' },
{ native: 'Escreve 50 palavras: um sítio onde voltarias de bom grado', en: 'Write 50 words: somewhere you would happily go back to.' },
{ native: 'Escreve 50 palavras: uma coisa que aprendeste esta semana', en: 'Write 50 words: one thing you learned this week.' },
{ native: 'Escreve 50 palavras: algo para dizeres a ti própria daqui a um ano', en: 'Write 50 words: something to tell yourself a year from now.' },
{ native: 'Escreve 50 palavras: uma música que tens ouvido ultimamente', en: 'Write 50 words: a song you have had on lately.' },
{ native: 'Escreve 50 palavras: alguém a quem gostavas de agradecer hoje', en: 'Write 50 words: someone you would like to thank today.' },
],
inviteAccept: 'Vamos lá · Lets write',
inviteDecline: 'Hoje não · Not today',
declined: { native: 'Está bem, volto a dormir 😴', en: 'Fair enough — back to my nap. 😴' },
names: {
cat: 'Gato dorminhoco',
dog: 'Cão contente',
@@ -146,6 +163,99 @@ export const ptPT: Pack = {
itsOwn: '“its” = “it is”. Para dizer “o seu / dele”, é “its” — portanto “its own”.',
itsIs: (rest) => `Aqui é “its ${rest}” (it is); “its” é o possessivo.`,
thanNotThen: (word) => `Nas comparações usa-se “than”, não “then”: “${word} than”.`,
preposition: (wrong, right) => `Em inglês diz-se “${right}”, não “${wrong}” — esta preposição é fixa.`,
collocation: (wrong, right) => `Em inglês estas palavras andam juntas assim: “${right}”, e não “${wrong}”.`,
doubleComparative: (lead, word) => `${word}” já é o comparativo — não precisa do “${lead}”: basta “${word}”.`,
peopleArePlural: (verb) => `“People” é plural em inglês: “people ${verb}”.`,
ageIsNotHave: (years) => `Em inglês a idade é com o verbo *to be*, não com *have*: “I am ${years} years old”.`,
agreeIsAVerb: '“Agree” já é o verbo — não leva *to be* à frente: diz-se “I agree”.',
forNotSince: (duration) => `Para uma duração usa-se “for”: “for ${duration}”. O “since” marca o início (since 2020).`,
veryBeforeVerb: (verb) => `“Very” só acompanha adjectivos, não verbos: “really ${verb}”, ou “${verb} … very much”.`,
turnOnNotOpen: (thing, on) => `Em inglês os aparelhos não se abrem nem se fecham — ligam-se e desligam-se: “turn ${on ? 'on' : 'off'} the ${thing}”.`,
althoughOrBut: (word) => `Em inglês usa-se “${word}” ou “but”, nunca os dois na mesma frase.`,
},
// Os falsos amigos entre o português e o inglês — a armadilha que faz uma
// pessoa sentir-se tola em vez de apenas corrigida. Por isso são só um aviso:
// o Petal nunca troca a palavra, porque “actually” pode muito bem ser mesmo a
// que ela queria.
falseFriends: {
actually: {
native: '“Actually” quer dizer *na verdade*, não *actualmente*. Para “actualmente” diz-se “currently” / “nowadays”.',
en: '“Actually” means *in fact*. For the Portuguese *actualmente*, English uses “currently”.',
},
eventually: {
native: '“Eventually” quer dizer *por fim, mais cedo ou mais tarde* — não *eventualmente*. Para isso: “possibly” ou “if necessary”.',
en: '“Eventually” means *in the end*, not *possibly*.',
},
pretend: {
native: '“Pretend” é *fingir*. Para *pretender* diz-se “intend” ou “plan to”.',
en: '“Pretend” means to fake something; the Portuguese *pretender* is “intend”.',
},
realize: {
native: '“Realize” é *aperceber-se*. Para *realizar* (fazer, levar a cabo) diz-se “carry out” ou “achieve”.',
en: '“Realize” means to become aware; *realizar* is “carry out”.',
},
assist: {
native: '“Assist” é *ajudar*. Para *assistir a* (ver) diz-se “attend” ou “watch”.',
en: '“Assist” means to help; *assistir a* is “attend” or “watch”.',
},
library: {
native: '“Library” é a *biblioteca*. A *livraria* é “bookshop” / “bookstore”.',
en: '“Library” is where books are lent; a shop that sells them is a “bookshop”.',
},
parents: {
native: '“Parents” são só o pai e a mãe. Os *parentes* são “relatives”.',
en: '“Parents” means mother and father only; *parentes* are “relatives”.',
},
sensible: {
native: '“Sensible” é *sensato*. Para *sensível* diz-se “sensitive”.',
en: '“Sensible” means level-headed; *sensível* is “sensitive”.',
},
push: {
native: '“Push” é *empurrar* — o contrário de *puxar* (“pull”).',
en: '“Push” is the opposite of *puxar*, which is “pull”.',
},
college: {
native: '“College” é o ensino superior. O *colégio* (escola) é “school”.',
en: '“College” is higher education; a *colégio* is a “school”.',
},
exit: {
native: '“Exit” é a *saída*. O *êxito* é “success”.',
en: '“Exit” is the way out; *êxito* is “success”.',
},
fabric: {
native: '“Fabric” é o *tecido*. A *fábrica* é “factory”.',
en: '“Fabric” is cloth; a *fábrica* is a “factory”.',
},
lecture: {
native: '“Lecture” é uma *aula / palestra*. A *leitura* é “reading”.',
en: '“Lecture” is a talk; *leitura* is “reading”.',
},
notice: {
native: '“Notice” é *reparar* ou um *aviso*. A *notícia* é “news”.',
en: '“Notice” is to spot something; *notícia* is “news”.',
},
support: {
native: '“Support” é *apoiar*. Para *suportar* (aguentar) diz-se “put up with” ou “bear”.',
en: '“Support” means to back someone up; *suportar* is to “bear”.',
},
costume: {
native: '“Costume” é um *disfarce / traje*. Os *costumes* são “customs” ou “habits”.',
en: '“Costume” is fancy dress; *costumes* are “customs”.',
},
deception: {
native: '“Deception” é *engano*. A *decepção* é “disappointment”.',
en: '“Deception” means being misled; *decepção* is “disappointment”.',
},
injury: {
native: '“Injury” é uma *lesão*. A *injúria* é “insult”.',
en: '“Injury” is physical harm; *injúria* is an “insult”.',
},
balcony: {
native: '“Balcony” é a *varanda*. O *balcão* é “counter”.',
en: '“Balcony” is a terrace; a *balcão* is a “counter”.',
},
},
docs: {
+34
View File
@@ -99,6 +99,25 @@ export const zh: Pack = {
en: `Wow — ${words} words already! Amazing. 🎉`,
}),
// A small thing to write about, offered once a day to an empty page. Each
// one is a memory or an opinion rather than an exercise — nothing here can
// be got wrong, which is the point. Fifty words is a deliberately small ask.
invitations: [
{ native: '写 50 个字:今天让你微笑的一件小事 🌸', en: 'Write 50 words: one small thing that made you smile today.' },
{ native: '写 50 个字:今天吃到最好吃的东西', en: 'Write 50 words: the best thing you ate today.' },
{ native: '写 50 个字:你现在窗外看到了什么?', en: "Write 50 words: what you can see out of your window right now." },
{ native: '写 50 个字:一个你很想再去一次的地方', en: 'Write 50 words: somewhere you would happily go back to.' },
{ native: '写 50 个字:这个星期学到的一件事', en: 'Write 50 words: one thing you learned this week.' },
{ native: '写 50 个字:给一年后的自己说句话', en: 'Write 50 words: something to tell yourself a year from now.' },
{ native: '写 50 个字:最近听到的一首歌', en: 'Write 50 words: a song you have had on lately.' },
{ native: '写 50 个字:今天想谢谢谁?', en: 'Write 50 words: someone you would like to thank today.' },
],
inviteAccept: '好,来写 · Lets write',
inviteDecline: '今天不了 · Not today',
// The entire consequence of saying no. No streak was broken, because there
// is no streak.
declined: { native: '好吧,我继续睡 😴', en: 'Fair enough — back to my nap. 😴' },
// Keyed by the companion ids in Companion/companions.ts.
names: {
cat: '瞌睡猫',
@@ -131,8 +150,23 @@ export const zh: Pack = {
itsOwn: '“its” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
itsIs: (rest) => `这里应该是 “its ${rest}”(it is),“its” 是“它的”。`,
thanNotThen: (word) => `比较的时候用 “than”,不是 “then”:“${word} than”。`,
preposition: (wrong, right) => `英文习惯说 “${right}”,不是 “${wrong}”,这个介词是固定搭配。`,
collocation: (wrong, right) => `英文里这两个词一般这样搭:“${right}”,而不是 “${wrong}”。`,
doubleComparative: (lead, word) => `${word}” 本身已经是比较级了,前面不用再加 “${lead}”:写 “${word}” 就好。`,
peopleArePlural: (verb) => `“people” 在英文里是复数,要用 “people ${verb}”。`,
ageIsNotHave: (years) => `英文说年龄用 be 动词,不是 have:“I am ${years} years old”。`,
agreeIsAVerb: '“agree” 本身就是动词,前面不用加 be:说 “I agree” 就好。',
forNotSince: (duration) => `表示持续了多久用 “for”:“for ${duration}”;“since” 后面接的是起点(since 2020)。`,
veryBeforeVerb: (verb) => `“very” 只能修饰形容词,不能直接放在动词前面:可以说 “really ${verb}”,或者 “${verb} … very much”。`,
turnOnNotOpen: (thing, on) => `英文里电器是 turn ${on ? 'on' : 'off'},不用 open/close:“turn ${on ? 'on' : 'off'} the ${thing}”。`,
althoughOrBut: (word) => `英文里 “${word}” 和 “but” 只用一个就够了,不像中文的“虽然…但是…”。`,
},
// The zh pair has no false friends: the trap needs a shared script, and 汉字
// and the Latin alphabet never look alike. An empty list here is the honest
// answer, not an unwritten one.
falseFriends: {},
docs: {
sortRecent: '最近 · Recent',
sortTitle: '标题 · Title',
+36
View File
@@ -69,6 +69,13 @@ export interface Pack {
milestone: (words: number) => Line
// Mascot names, keyed by the companion ids in companions.ts.
names: Record<string, string>
// A once-a-day invitation to write, offered only to a blank page (§5c).
// Explicitly no streaks and no guilt: `declined` is the sleepy, entirely
// untroubled reply to "not today", and it is the only consequence there is.
invitations: Line[]
inviteAccept: string
inviteDecline: string
declined: Line
}
// Rule-based prose notes (see Companion/prose.ts). Detection is English
@@ -95,8 +102,37 @@ export interface Pack {
itsOwn: string
itsIs: (rest: string) => string
thanNotThen: (word: string) => string
// Grammar-lite, the offline half of the suggestion rail. `preposition` and
// `collocation` are single templates rather than a line per entry: the rule
// pack behind them is a few dozen curated pairs, and a pack author should be
// able to say "English says X, not Y" once and have every pair read well.
preposition: (wrong: string, right: string) => string
collocation: (wrong: string, right: string) => string
doubleComparative: (lead: string, word: string) => string
peopleArePlural: (verb: string) => string
// Interference from the writer's own language (see prose.ts L1_RULES). Every
// pack owns all of these even though a pair only ever runs some of them —
// the copy is cheap, and the day Petal decides a rule is safe for another
// pair it should not also need new strings.
ageIsNotHave: (years: string) => string
agreeIsAVerb: string
forNotSince: (duration: string) => string
veryBeforeVerb: (verb: string) => string
turnOnNotOpen: (thing: string, on: boolean) => string
althoughOrBut: (word: string) => string
}
// Words that look like one of hers and mean something else, keyed by the
// lowercase English word. This is the mistake that makes a learner feel
// foolish rather than merely corrected, so both surfaces are heads-up only:
// a quiet block in the WordCard, and at most one gentle companion note per
// pass. Petal never "fixes" one — *actually* may well be the word she meant.
//
// A pair needs a shared script for the trap to spring at all, so the zh pack
// is legitimately empty; the Latin pairs carry the two dozen that account for
// most of the damage.
falseFriends: Record<string, Line>
docs: {
sortRecent: string
sortTitle: string