4 Commits

Author SHA1 Message Date
prosolis
adc0cdff1c Add butterfly/parrot/wiggle-dog companions; scale mascot + tips
- Three new Lottie companions: Wiggle Dog (摇尾狗), Butterfly (蝴蝶),
  Parrot (鹦鹉). Parrot is mirrored via a new `flip` flag on Companion,
  threaded through LottiePlayer (scaleX(-1)) since the asset faces left.
- Mascot size now scales with the viewport: --petal-companion-size
  clamp(10rem, 17vw, 20rem); art, emoji fallback, and the sleep "z" all
  derive from it.
- Larger, more legible tip bubble (1.4rem zh / 1.15rem en, wider bubble).
- Bubbles linger longer for a bilingual ESL read: baseline 9s→14s,
  cheer 6s→9s, per-char 45→55ms, cap 20s→32s.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 11:08:55 -07:00
prosolis
9d6698dcc6 Merge fix/suggestion-renag-anchoring: stop re-nagging resolved suggestions + typographic anchoring
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 10:40:30 -07:00
prosolis
6783ce7a51 Suggestions: stop re-nagging resolved edits + fix typographic anchoring
Two fixes for the "accept Petal's change, then it nags about the same
sentence moments later" report:

- replacePending now suppresses any freshly-generated suggestion whose
  original->replacement matches one the user already accepted or
  dismissed for that doc. The model has no memory between passes, so
  without this it re-proposes the identical edit on the next checkpoint.

- findRange anchored suggestions by exact string match, which missed
  whenever the model echoed an `original` with plain ASCII (straight
  quotes, --, ...) while the document held the Typography-converted
  glyphs (curly quotes, em-dash, single-char ellipsis). The miss meant
  no highlight AND a silent no-op on accept, which then fed the re-nag
  above. foldTypography canonicalizes those variants (with a source
  index map for length changes) so matching survives the mismatch.

Covered by a server-side regression test (accept+dismiss then re-check
returns nothing) and frontend unit tests for the fold and anchoring.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 10:35:57 -07:00
prosolis
3ac6382696 Merge feat/writer-power-ups: editor power-ups (find/replace, read-aloud, backup, phonetic)
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 09:47:50 -07:00
12 changed files with 304 additions and 22 deletions

View File

@@ -154,6 +154,14 @@ var (
// replacePending swaps a document's pending suggestions within one family for a // replacePending swaps a document's pending suggestions within one family for a
// fresh batch in a single transaction. Accepted/rejected suggestions and the // fresh batch in a single transaction. Accepted/rejected suggestions and the
// other family's pending rows are left untouched. // other family's pending rows are left untouched.
//
// Suggestions the user already accepted or dismissed are suppressed from the
// fresh batch: the model has no memory between passes, so without this it would
// re-propose the identical edit on the very next checkpoint — re-nagging a
// sentence the user already resolved. This matters most when an accept silently
// no-ops (the `original` text couldn't be anchored in the editor, so the doc
// never changed): the sentence is unaltered, yet the user shouldn't see the same
// card again.
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error { func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
tx, err := h.DB.Begin() tx, err := h.DB.Begin()
if err != nil { if err != nil {
@@ -168,7 +176,15 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
return err return err
} }
actioned, err := actionedKeys(tx, docID)
if err != nil {
return err
}
for _, s := range raw { for _, s := range raw {
if _, seen := actioned[suggestionKey(s.Original, s.Replacement)]; seen {
continue
}
typ := scope.forceType typ := scope.forceType
if typ == "" { if typ == "" {
typ = normalizeType(s.Type) typ = normalizeType(s.Type)
@@ -186,6 +202,39 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
return tx.Commit() return tx.Commit()
} }
// actionedKeys returns the set of original→replacement keys the user has already
// accepted or rejected for this document, so a fresh checkpoint can skip
// re-proposing them. Keyed on the trimmed original+replacement pair, matching the
// normalization ParseCheckpoint applies, so a genuinely different edit on the same
// sentence is not suppressed.
func actionedKeys(tx *sql.Tx, docID string) (map[string]struct{}, error) {
rows, err := tx.Query(
`SELECT original, replacement FROM suggestions
WHERE doc_id = ? AND status IN (?, ?)`,
docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected,
)
if err != nil {
return nil, err
}
defer rows.Close()
keys := make(map[string]struct{})
for rows.Next() {
var original, replacement string
if err := rows.Scan(&original, &replacement); err != nil {
return nil, err
}
keys[suggestionKey(original, replacement)] = struct{}{}
}
return keys, rows.Err()
}
// suggestionKey is the dedup identity for a suggestion: its trimmed original and
// replacement text. Two suggestions with the same key are "the same edit."
func suggestionKey(original, replacement string) string {
return strings.TrimSpace(original) + "\x00" + strings.TrimSpace(replacement)
}
// listForDoc returns the document's current pending suggestions (used when the // listForDoc returns the document's current pending suggestions (used when the
// editor loads a document, before any new checkpoint fires). // editor loads a document, before any new checkpoint fires).
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) { func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {

View File

@@ -127,6 +127,44 @@ func TestCheckpointFlow(t *testing.T) {
} }
} }
// TestResolvedSuggestionsNotReproposed proves a checkpoint won't re-nag a
// sentence the user already accepted or dismissed: the model returns the same
// raw batch on the next pass (it has no memory), but the resolved edits are
// suppressed. This is the "accept it, then it nags again moments later" bug.
func TestResolvedSuggestionsNotReproposed(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},
{"original":"two apple","replacement":"two apples","explanation":"plural noun","type":"grammar"}
]}`}
srv, docID, h := newTestServer(t, client)
// Zero the checkpoint floor so the second pass runs instead of being throttled.
h.Limit = llm.NewRateLimiter(0)
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
var got []db.Suggestion
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if len(got) != 2 {
t.Fatalf("first pass: want 2, got %d", len(got))
}
// Accept one, dismiss the other.
do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", "")
do(t, srv, http.MethodPost, "/suggestions/"+got[1].ID+"/dismiss", "")
// Second checkpoint returns the identical raw batch — both must be suppressed.
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
if rec.Code != http.StatusOK {
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
}
var again []db.Suggestion
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
t.Fatalf("decode: %v", err)
}
if len(again) != 0 {
t.Fatalf("resolved suggestions should not be re-proposed, got %d: %+v", len(again), again)
}
}
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own // TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
// disjoint pending families: running one never wipes the other's flags, and // disjoint pending families: running one never wipes the other's flags, and
// each endpoint returns the unified pending set. // each endpoint returns the unified pending set.

View File

@@ -11,6 +11,8 @@ interface Props {
animationData?: object animationData?: object
loop?: boolean loop?: boolean
className?: string className?: string
// Mirror the animation left↔right (for assets drawn facing the wrong way).
flip?: boolean
fallback: React.ReactNode fallback: React.ReactNode
} }
@@ -48,7 +50,7 @@ function fitToContent(anim: AnimationItem, svg: SVGSVGElement) {
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully // A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
// offline). Reloads the animation whenever the data changes — moods swap by // offline). Reloads the animation whenever the data changes — moods swap by
// passing a different `animationData`. // passing a different `animationData`.
export function LottiePlayer({ animationData, loop = true, className, fallback }: Props) { export function LottiePlayer({ animationData, loop = true, className, flip, fallback }: Props) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const anim = useRef<AnimationItem | null>(null) const anim = useRef<AnimationItem | null>(null)
@@ -74,5 +76,12 @@ export function LottiePlayer({ animationData, loop = true, className, fallback }
}, [animationData, loop]) }, [animationData, loop])
if (!animationData) return <>{fallback}</> if (!animationData) return <>{fallback}</>
return <div ref={ref} className={className} aria-hidden /> return (
<div
ref={ref}
className={className}
style={flip ? { transform: 'scaleX(-1)' } : undefined}
aria-hidden
/>
)
} }

View File

@@ -147,7 +147,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
onClick={dismiss} onClick={dismiss}
onMouseEnter={holdBubble} onMouseEnter={holdBubble}
onMouseLeave={releaseBubble} onMouseLeave={releaseBubble}
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5" className="petal-bubble pointer-events-auto max-w-[420px] cursor-pointer p-4 pr-5"
style={{ style={{
background: 'var(--color-surface)', background: 'var(--color-surface)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
@@ -158,10 +158,16 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
}} }}
title="Click to dismiss" title="Click to dismiss"
> >
<p className="text-sm font-bold leading-snug" style={{ color: 'var(--color-plum)' }}> <p
className="font-bold leading-snug"
style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }}
>
{bubble.zh} {bubble.zh}
</p> </p>
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}> <p
className="mt-0.5 leading-snug"
style={{ color: 'var(--color-muted)', fontSize: '1.15rem' }}
>
{bubble.en} {bubble.en}
</p> </p>
</div> </div>
@@ -174,8 +180,9 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
aria-label="Choose a companion" aria-label="Choose a companion"
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`} className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
style={{ style={{
width: 144, // Size scales with the viewport — see --petal-companion-size in index.css.
height: 144, width: 'var(--petal-companion-size)',
height: 'var(--petal-companion-size)',
padding: 0, padding: 0,
borderRadius: 'var(--radius-pill)', borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface-alt)', background: 'var(--color-surface-alt)',
@@ -189,8 +196,13 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
<LottiePlayer <LottiePlayer
key={companion.id} key={companion.id}
animationData={animationData} animationData={animationData}
className="h-32 w-32" flip={companion.flip}
fallback={<span style={{ fontSize: 72, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>} className="petal-companion-art"
fallback={
<span style={{ fontSize: 'calc(var(--petal-companion-size) * 0.5)', lineHeight: 1 }}>
{MOOD_EMOJI[renderMood]}
</span>
}
/> />
{napping && ( {napping && (
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}> <span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,9 @@
import type { Mood } from './useCompanion' import type { Mood } from './useCompanion'
import sleepingCat from './animations/sleeping-cat.json' import sleepingCat from './animations/sleeping-cat.json'
import happyDog from './animations/happy-dog.json' import happyDog from './animations/happy-dog.json'
import wiggleDog from './animations/wiggle-dog.json'
import butterfly from './animations/butterfly.json'
import parrot from './animations/parrot.json'
export interface Companion { export interface Companion {
id: string id: string
@@ -12,6 +15,9 @@ export interface Companion {
// When true the mascot keeps its calm sway + drifting zzz regardless of mood // When true the mascot keeps its calm sway + drifting zzz regardless of mood
// (e.g. a cat that's always asleep but still talks in its sleep). // (e.g. a cat that's always asleep but still talks in its sleep).
alwaysAsleep?: boolean alwaysAsleep?: boolean
// When true the rendered animation is mirrored left↔right — for assets drawn
// facing the "wrong" way for our bottom-right corner (e.g. the parrot).
flip?: boolean
} }
// The roster. Add a companion by dropping a pure-vector Lottie JSON into // The roster. Add a companion by dropping a pure-vector Lottie JSON into
@@ -44,6 +50,43 @@ export const COMPANIONS: Companion[] = [
// no sleeping clip → stays in its idle pose instead of visibly napping // no sleeping clip → stays in its idle pose instead of visibly napping
}, },
}, },
{
id: 'wiggle-dog',
name: 'Wiggle Dog',
zh: '摇尾狗',
emoji: '🐕',
animations: {
idle: wiggleDog,
happy: wiggleDog,
talking: wiggleDog,
celebrate: wiggleDog,
},
},
{
id: 'butterfly',
name: 'Butterfly',
zh: '蝴蝶',
emoji: '🦋',
animations: {
idle: butterfly,
happy: butterfly,
talking: butterfly,
celebrate: butterfly,
},
},
{
id: 'parrot',
name: 'Parrot',
zh: '鹦鹉',
emoji: '🦜',
flip: true, // asset faces left; mirror it to face into the page
animations: {
idle: parrot,
happy: parrot,
talking: parrot,
celebrate: parrot,
},
},
] ]
export const DEFAULT_COMPANION = 'cat' export const DEFAULT_COMPANION = 'cat'

View File

@@ -47,10 +47,10 @@ const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by // How long a bubble lingers. These are *floors* — readBubbleMs extends them by
// how much there is to read, since she reads both the Mandarin and the English // how much there is to read, since she reads both the Mandarin and the English
// (and tips now quote a slice of her own sentence, so they run longer). // (and tips now quote a slice of her own sentence, so they run longer).
const BUBBLE_MS = 9_000 // baseline for a tip / break const BUBBLE_MS = 14_000 // baseline for a tip / break
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages const CHEER_MS = 9_000 // a short cheer still needs a beat in two languages
const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read const READ_MS_PER_CHAR = 55 // ~18 chars/sec, generous for a bilingual ESL read
const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever const MAX_BUBBLE_MS = 32_000 // cap so a long quote can't pin the bubble forever
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
const now = () => Date.now() const now = () => Date.now()

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { foldTypography, findRange } from './SuggestionHighlight'
import { Schema, type Node as PMNode } from '@tiptap/pm/model'
// A minimal doc/paragraph/text schema, enough to exercise findRange's textblock
// walk without pulling in the full editor.
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
text: { group: 'inline' },
},
})
// Build a single-paragraph doc with the given plaintext.
const para = (text: string): PMNode => schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
describe('foldTypography', () => {
it('folds curly quotes back to straight ASCII', () => {
expect(foldTypography('“Hes here,” she said').folded).toBe('"He\'s here," she said')
})
it('folds dashes and ellipsis, keeping a source-index map across length changes', () => {
const { folded, map } = foldTypography('wait—really...')
expect(folded).toBe('wait—really…')
// The em dash is already one glyph; the `...` collapsed 3 source chars to 1,
// so the trailing sentinel still points just past the last source char.
expect(map[map.length - 1]).toBe('wait—really...'.length)
})
it('treats `--` and `...` typed-but-unconverted as their canonical glyphs', () => {
expect(foldTypography('a--b...c').folded).toBe('a—b…c')
})
})
describe('findRange typographic anchoring', () => {
it('anchors a model echo with straight quotes onto curly-quote document text', () => {
const doc = para('She said “hello” to me')
const range = findRange(doc, '“hello”'.replace(/[“”]/g, '"')) // model echoed "hello"
expect(range).not.toBeNull()
// The matched span must cover the curly-quoted region in the real document.
expect(doc.textBetween(range!.from - 1, range!.to - 1)).toContain('hello')
})
it('anchors an ASCII `--` echo onto an em-dash in the document', () => {
const doc = para('I waited—forever')
const range = findRange(doc, 'waited--forever')
expect(range).not.toBeNull()
})
it('still returns null when the text genuinely is not present', () => {
expect(findRange(para('nothing to see'), 'absent phrase')).toBeNull()
})
})

View File

@@ -42,21 +42,87 @@ export function mapOffset(block: PMNode, blockPos: number, targetOffset: number)
return result return result
} }
// foldTypography canonicalizes the typographic variants that the editor's
// Typography input rules introduce (curly quotes, em/en dashes, single-glyph
// ellipsis) and the unicode spaces a paste can carry. The model echoes a
// suggestion's `original` from the same text but often normalizes these back to
// plain ASCII — straight quotes, `--`, `...` — so an exact match would miss and
// the suggestion could be neither highlighted nor applied.
//
// It returns the folded string plus `map`, where map[k] is the source index at
// which folded character k begins. A multi-character source run (`...`, `--`)
// folds to one glyph, so lengths differ; map projects a match in folded space
// back onto real document offsets. map has a trailing sentinel (= source length)
// so map[start + folded.length] yields the offset just past the match.
export function foldTypography(text: string): { folded: string; map: number[] } {
let folded = ''
const map: number[] = []
let i = 0
while (i < text.length) {
// Multi-character ASCII sequences the Typography rules would have replaced.
if (text.startsWith('...', i)) {
folded += '…'
map.push(i)
i += 3
continue
}
if (text[i] === '-' && text[i + 1] === '-') {
folded += '—'
map.push(i)
i += 2
continue
}
folded += foldChar(text[i])
map.push(i)
i++
}
map.push(text.length)
return { folded, map }
}
// foldChar maps a single character onto its canonical form (length-preserving).
function foldChar(c: string): string {
switch (c) {
case '':
case '':
case '':
case '':
return "'"
case '“':
case '”':
case '„':
case '‟':
return '"'
case '': // en dash → em dash, the canonical form `--` also folds to
return '—'
case ' ': // non-breaking space
case '': // thin space
case '': // narrow no-break space
return ' '
default:
return c
}
}
// findRange locates the first occurrence of `search` within a single textblock // findRange locates the first occurrence of `search` within a single textblock
// and returns its ProseMirror range, or null if the string isn't present (the // and returns its ProseMirror range, or null if the string isn't present (the
// user may have edited or removed it since the checkpoint ran). Exported so the // user may have edited or removed it since the checkpoint ran). Matching is done
// accept flow can resolve the same span to replace. // in canonical typographic space (see foldTypography) so curly-quote/dash/ellipsis
// differences between the model's echo and the live document don't strand the
// anchor. Exported so the accept flow can resolve the same span to replace.
export function findRange(doc: PMNode, search: string): { from: number; to: number } | null { export function findRange(doc: PMNode, search: string): { from: number; to: number } | null {
if (!search) return null const needle = foldTypography(search).folded
if (!needle) return null
let result: { from: number; to: number } | null = null let result: { from: number; to: number } | null = null
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
if (result) return false if (result) return false
if (!node.isTextblock) return true // keep descending to the textblock if (!node.isTextblock) return true // keep descending to the textblock
const idx = node.textContent.indexOf(search) const { folded, map } = foldTypography(node.textContent)
const idx = folded.indexOf(needle)
if (idx >= 0) { if (idx >= 0) {
result = { result = {
from: mapOffset(node, pos, idx), from: mapOffset(node, pos, map[idx]),
to: mapOffset(node, pos, idx + search.length), to: mapOffset(node, pos, map[idx + needle.length]),
} }
} }
return false // never descend into a textblock's inline children return false // never descend into a textblock's inline children

View File

@@ -319,9 +319,17 @@ button, a, input {
The cozy corner mascot. Gently bobs while awake, settles and sways slowly The cozy corner mascot. Gently bobs while awake, settles and sways slowly
while napping; its speech bubble pops in; little zzz drift up when asleep. */ while napping; its speech bubble pops in; little zzz drift up when asleep. */
.petal-companion { .petal-companion {
/* Mascot size scales with the viewport width: ~original on a laptop, up to
~2× on a large desktop. Tune the middle (vw) term to taste. */
--petal-companion-size: clamp(10rem, 17vw, 20rem);
animation: petal-bob 3.2s ease-in-out infinite; animation: petal-bob 3.2s ease-in-out infinite;
transition: transform 200ms ease; transition: transform 200ms ease;
} }
/* The Lottie art sits inside the round badge with a little breathing room. */
.petal-companion-art {
width: 88%;
height: 88%;
}
.petal-companion:hover { .petal-companion:hover {
transform: translateY(-2px) scale(1.04); transform: translateY(-2px) scale(1.04);
} }
@@ -347,10 +355,10 @@ button, a, input {
} }
.petal-zzz { .petal-zzz {
top: 6px; top: calc(var(--petal-companion-size) * 0.04);
right: 14px; right: calc(var(--petal-companion-size) * 0.1);
font-weight: 800; font-weight: 800;
font-size: 1.1rem; font-size: calc(var(--petal-companion-size) * 0.12);
animation: petal-zzz 2.4s ease-in-out infinite; animation: petal-zzz 2.4s ease-in-out infinite;
} }
@keyframes petal-zzz { @keyframes petal-zzz {