When she writes in Chinese, say Translate — not Clarity

She reaches for her own language mid-sentence when English won't come, and
Petal already handled it: it found the span and rendered it into English. It
just filed the result as a Clarity fix, so the pair model's flagship moment
read as tidying up her Chinese.

The type is now derived from the span rather than asked of the model. A type is
structural, and a model that re-reasons every pass would drift between labels
for a sentence nobody had touched — the instability the last session spent
itself removing. The label the model volunteers is still ignored.

Only the grammar checkpoint can be promoted. A pass with a forced type owns its
family: voice reads paragraphs for tone and its rows carry no replacement, so a
"translation" there would be a card offering nothing to accept.

zh is a different script and counting Han runes is close to certain. The Latin
pairs share an alphabet with English and get none of that, so they fall back to
function words and need two before Petal claims anything — with every word that
is also English left out, even the common ones. The heuristic is justified by
how cheap being wrong is: it changes a coloured pill, and nothing else.

The pill is the one bilingual type name in the rail. Every other type stays
English because those are the terms she is learning; this card's whole subject
is her own language. And it stops truncating its two lines — elsewhere the diff
is a word and the explanation is what she reads, but here the two sentences are
the card.

Two things only the running page could report. The inline underline was
invisible: the decoration carries a per-type class and the base rule is a
transparent border, so a type with no colour rule gets no mark at all. And at
1517×810 with the document list open there is no rail — the margin is 258 where
railEnabled wants 348 — so what she gets is the inline hover card. Item 7 is
written the other way round.

Migration 0015 rebuilds the suggestions table for the CHECK, which makes it the
first one here that could quietly drop her rows; there is a test that carries
every column, both timestamps and both indexes across it.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-28 00:09:42 -07:00
parent 3bcc967f51
commit 25e415daa2
20 changed files with 917 additions and 15 deletions
+12 -1
View File
@@ -101,7 +101,18 @@ export interface Gloss {
reverse?: string
}
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation' | 'mechanics'
// 'translate' is a span she wrote in her own language, rendered into English —
// not a correction. The server decides the label from the span itself, never from
// the model, so the client can trust it (see suggestions/language.go).
export type SuggestionType =
| 'grammar'
| 'phrasing'
| 'idiom'
| 'clarity'
| 'translate'
| 'voice'
| 'collocation'
| 'mechanics'
// One word in the vocabulary garden: a looked-up word with its gloss/phonetic,
// the sentence it was met in, and its spaced-repetition state. `reps` drives how
+6 -3
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'
import type { Suggestion } from '../../api/client'
import { usePack } from '../../i18n'
import { AskPetal } from './AskPetal'
import { TYPE_META } from './suggestionMeta'
import { TYPE_META, typeLabel } from './suggestionMeta'
interface Props {
suggestion: Suggestion
@@ -28,7 +29,9 @@ export function SuggestionCard({
onPointerLeave,
onExpandChange,
}: Props) {
const pack = usePack()
const meta = TYPE_META[suggestion.type]
const label = typeLabel(suggestion.type, pack)
const hasReplacement = suggestion.replacement.trim() !== ''
const [asking, setAsking] = useState(false)
@@ -43,7 +46,7 @@ export function SuggestionCard({
return (
<div
role="dialog"
aria-label={`${meta.label} suggestion`}
aria-label={`${label} suggestion`}
onMouseEnter={onPointerEnter}
onMouseLeave={onPointerLeave}
className="petal-suggestion-card absolute z-20 p-3.5 text-sm"
@@ -60,7 +63,7 @@ export function SuggestionCard({
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold"
style={{ background: meta.color, color: 'var(--color-plum)' }}
>
{meta.label}
{label}
</span>
{hasReplacement && (
+19 -5
View File
@@ -1,7 +1,8 @@
import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
import type { Suggestion } from '../../api/client'
import { usePack } from '../../i18n'
import { AskPetal } from './AskPetal'
import { TYPE_META } from './suggestionMeta'
import { TYPE_META, typeLabel } from './suggestionMeta'
// Vertical breathing room kept between stacked cards when their natural anchors
// would otherwise collide.
@@ -138,14 +139,21 @@ const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
ref,
) {
const pack = usePack()
const meta = TYPE_META[suggestion.type]
const label = typeLabel(suggestion.type, pack)
const hasReplacement = suggestion.replacement.trim() !== ''
// Every other card truncates its diff to one line each: the original and the
// replacement differ by a word or two, and the explanation below is the part
// she reads. A translation is the reverse — the two lines are a whole sentence
// in each language, and they are the entire point of the card — so it wraps.
const clampDiff = suggestion.type !== 'translate'
return (
<div
ref={ref}
role="group"
aria-label={`${meta.label} suggestion`}
aria-label={`${label} suggestion`}
onMouseEnter={() => onHover(suggestion.id)}
onMouseLeave={() => onHover(null)}
className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`}
@@ -156,7 +164,7 @@ const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.68rem] font-bold"
style={{ background: meta.color, color: 'var(--color-plum)' }}
>
{meta.label}
{label}
</span>
<button
type="button"
@@ -173,10 +181,16 @@ const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
<button type="button" onClick={() => onActivate(suggestion.id)} className="mt-2 block w-full text-left">
{hasReplacement && (
<span className="flex flex-col gap-0.5" style={{ fontFamily: 'var(--font-body)' }}>
<span className="truncate text-[0.9rem] line-through" style={{ color: 'var(--color-muted)' }}>
<span
className={`text-[0.9rem] line-through${clampDiff ? ' truncate' : ''}`}
style={{ color: 'var(--color-muted)' }}
>
{suggestion.original}
</span>
<span className="truncate text-[0.9rem] font-medium" style={{ color: 'var(--color-plum)' }}>
<span
className={`text-[0.9rem] font-medium${clampDiff ? ' truncate' : ''}`}
style={{ color: 'var(--color-plum)' }}
>
{suggestion.replacement}
</span>
</span>
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import type { SuggestionType } from '../../api/client'
import { fr } from '../../i18n/packs/fr'
import { ptPT } from '../../i18n/packs/pt-PT'
import { zh } from '../../i18n/packs/zh'
import { TYPE_META, typeLabel } from './suggestionMeta'
// Every type the API can send. Listed by hand rather than derived, so adding a
// suggestion type to the union without giving it a colour and a name fails here
// instead of rendering a card with `undefined` on its pill.
const ALL: SuggestionType[] = [
'grammar',
'phrasing',
'idiom',
'clarity',
'translate',
'voice',
'collocation',
'mechanics',
]
describe('TYPE_META', () => {
it('covers every suggestion type with a colour and a label', () => {
for (const type of ALL) {
expect(TYPE_META[type], type).toBeDefined()
expect(TYPE_META[type].color, `${type} colour`).toMatch(/^var\(--color-/)
expect(TYPE_META[type].label, `${type} label`).not.toBe('')
}
})
it('gives translate its own colour', () => {
const used = ALL.filter((t) => t !== 'translate').map((t) => TYPE_META[t].color)
expect(used).not.toContain(TYPE_META.translate.color)
})
})
describe('typeLabel', () => {
// The one bilingual pill, and it has to speak the writer's own pair — a
// hardcoded 翻译 would be simply wrong on the screen of a Portuguese writer.
it('names a translation in the writers own language', () => {
expect(typeLabel('translate', zh)).toBe('翻译 · Translate')
expect(typeLabel('translate', ptPT)).toBe('Tradução · Translate')
expect(typeLabel('translate', fr)).toBe('Traduction · Translate')
})
it('leaves every other type in English, whatever the pair', () => {
for (const p of [zh, ptPT, fr]) {
for (const type of ALL.filter((t) => t !== 'translate')) {
expect(typeLabel(type, p), `${type} on ${p.code}`).toBe(TYPE_META[type].label)
}
}
})
})
@@ -1,4 +1,5 @@
import type { SuggestionType } from '../../api/client'
import type { Pack } from '../../i18n'
// Per-type accent color + human label, mirroring the design tokens. Shared by the
// inline hover SuggestionCard and the margin SuggestionRail so a given suggestion
@@ -8,7 +9,22 @@ export const TYPE_META: Record<SuggestionType, { color: string; label: string }>
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
// The label here is only the fallback (and what a screen reader gets if the
// pack hasn't arrived yet) — see typeLabel.
translate: { color: 'var(--color-jade)', label: 'Translate' },
voice: { color: 'var(--color-honey)', label: 'Voice' },
collocation: { color: 'var(--color-blossom)', label: 'Word pairing' },
mechanics: { color: 'var(--color-sage)', label: 'Tidy-up' },
}
// typeLabel is what the pill says. It exists for exactly one type: a translation
// card names itself in the writer's own language first, because that language is
// its entire subject. Every other type keeps its English name — those are the
// terms she is learning, and she is learning them in English.
//
// Taking the pack rather than reading the module singleton keeps the label
// reactive: the pair language isn't known until /api/me answers, and a card
// rendered before then must relabel itself when it does.
export function typeLabel(type: SuggestionType, pack: Pack): string {
return type === 'translate' ? pack.editor.translateLabel : TYPE_META[type].label
}
+28 -1
View File
@@ -11,6 +11,20 @@ import type { Pack } from './types'
// author's pack satisfies isn't a shape, it's a coincidence.
const PACKS: Pack[] = [zh, ptPT, fr]
// Every string a pack would ever put on screen, and nothing else — field names
// excluded (see the pt-PT grep below for what including them cost). Templates are
// invoked so an interpolated line is checked as she'd read it, with the same
// stand-in arguments the old JSON replacer used.
function copyOf(p: Pack): string {
const strings = (v: unknown): string[] => {
if (typeof v === 'string') return [v]
if (typeof v === 'function') return strings((v as (...a: unknown[]) => unknown)(1, 'x'))
if (v && typeof v === 'object') return Object.values(v).flatMap(strings)
return []
}
return strings(p).join('\n')
}
beforeEach(() => {
resetPackForTests()
})
@@ -194,10 +208,23 @@ describe('the pt-PT pack', () => {
// is invisible to anyone who doesn't read Portuguese — including whoever
// reviews this diff.
it('is European Portuguese, not Brazilian', () => {
// The pack's *copy*, and only its copy. Keys are English identifiers and can
// never be Brazilian, but they used to be in this haystack — and a naive
// substring grep duly failed on `translateLabel`, which lowercases to
// "transla·tela·bel" and so "contains" the pt-BR *tela*. A test that fires on
// its own field names is a test that gets deleted the third time it does it.
//
// Lowercased: half this copy is sentences, and a form that only ever appears
// at the start of one ("Actualmente…") would otherwise walk straight past
// both greps below.
const text = JSON.stringify(ptPT, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v)).toLowerCase()
const text = copyOf(ptPT).toLowerCase()
// Canaries. Every assertion below is a *negative*, so a copyOf that quietly
// returned nothing — or stopped invoking templates — would make this whole
// test pass by having nothing to search. The second line is inside a
// function, and only appears if templates are still being called.
expect(text, 'copyOf collected no pack copy').toContain('sinónimos')
expect(text, 'copyOf stopped invoking templates').toContain('já vais em 1 palavras')
// Brazilian spellings and vocabulary that would give the pack away.
for (const bad of ['sinônimo', 'acadêmico', 'arquivo', 'tela', 'salvar', 'deletar', 'usuário', 'você']) {
+1
View File
@@ -314,6 +314,7 @@ export const fr: Pack = {
replacePlaceholder: 'Remplacer par · Replace',
replace: 'Remplacer',
replaceAll: 'Tout',
translateLabel: 'Traduction · Translate',
spelling: 'Orthographe · Spelling',
noSuggestions: 'Aucune suggestion · No suggestions',
addToDictionary: 'Ajouter au dictionnaire · Add to dictionary',
+1
View File
@@ -292,6 +292,7 @@ export const ptPT: Pack = {
replacePlaceholder: 'Substituir por · Replace',
replace: 'Substituir',
replaceAll: 'Tudo',
translateLabel: 'Tradução · Translate',
spelling: 'Ortografia · Spelling',
noSuggestions: 'Sem sugestões · No suggestions',
addToDictionary: 'Adicionar ao dicionário · Add to dictionary',
+1
View File
@@ -192,6 +192,7 @@ export const zh: Pack = {
replacePlaceholder: '替换为 · Replace',
replace: '替换',
replaceAll: '全部',
translateLabel: '翻译 · Translate',
spelling: '拼写 · Spelling',
noSuggestions: '没有建议 · No suggestions',
addToDictionary: '添加到词典 · Add to dictionary',
+6
View File
@@ -162,6 +162,12 @@ export interface Pack {
replacePlaceholder: string
replace: string
replaceAll: string
// The one suggestion-type pill that is bilingual. Every other type name
// (Grammar, Phrasing, Idiom) stays English: they are the vocabulary of the
// thing she is learning, and she is learning it in English. This card is the
// opposite case — its whole subject is her own language — so it says so in
// her language first. The pack holds the rendered string, separator and all.
translateLabel: string
spelling: string
noSuggestions: string
addToDictionary: string
+7
View File
@@ -22,6 +22,12 @@
--color-honey: #CE9B4F; /* voice */
--color-blossom: #E59ABF; /* collocation — warm blossom pink */
--color-sage: #B7C7B9; /* mechanics — calm sage, a gentle tidy-up nudge */
/* translate — jade. Deliberately the one saturated teal in the set: this is the
card that carries her own language across into English, and it should be
findable at a glance in a stack of pastels. Jade rather than a cooler blue
because the colour is warmly auspicious in the language it most often
serves, and it stays clear of grammar's mint and clarity's sky. */
--color-jade: #6FB5B8;
--color-success: #8FCFA8; /* saved / accepted */
/* Typography */
@@ -233,6 +239,7 @@ button, a, input {
.petal-suggestion-phrasing { border-bottom-color: var(--color-peach); }
.petal-suggestion-idiom { border-bottom-color: var(--color-lavender); }
.petal-suggestion-clarity { border-bottom-color: var(--color-sky); }
.petal-suggestion-translate { border-bottom-color: var(--color-jade); }
.petal-suggestion-voice { border-bottom-color: var(--color-honey); }
.petal-suggestion-collocation { border-bottom-color: var(--color-blossom); }
.petal-suggestion-mechanics { border-bottom-color: var(--color-sage); }