Merge feat/translate-card: her own language gets its own card

When she reaches for Chinese mid-sentence, the card now says 翻译 · Translate
rather than Clarity. Petal already found the span and already rendered it into
English; the type was the whole gap, and it is derived from the span rather
than asked of the model so it can't drift between passes.

Carries migration 0015 (suggestions table rebuild for the extended type CHECK)
and two findings from the running page: the inline underline needs a per-type
colour rule or it renders invisibly, and at her viewport the rail is disabled —
the inline hover card is what she sees.

Green: go vet, go test ./..., tsc --noEmit, 199 vitest tests.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-28 00:32:04 -07:00
20 changed files with 917 additions and 15 deletions
+107
View File
@@ -399,6 +399,106 @@ card's strikethrough line so she can still see what she wrote).
card within one check cycle; accepting swaps in the English; an card within one check cycle; accepting swaps in the English; an
English-only doc and a Chinese-only doc are unaffected. English-only doc and a Chinese-only doc are unaffected.
### 5 — DONE (sixth session). The label was the whole gap, and it isn't the model's to give.
The re-scoped premise held: Petal already found the span and already rendered
it into English. Only the type was wrong. So the work was to decide that type
*structurally* rather than ask for it — a model that re-reasons every pass
would drift between labels for a sentence that hadn't changed, which is exactly
the instability item 2 just spent a session removing.
**Implemented:**
- `language.go``isTranslation(original, replacement, pairLang)`. Both halves
must hold: the span reads as her language *and* what Petal offers back reads
as English. The second half is not decoration — a Chinese span rewritten into
different Chinese is something else, and Petal has no business calling it a
translation.
- `reconcile.go` — the promotion sits at the single point where a type is
stamped, and only on the open-typed grammar checkpoint. A pass with a
`forceType` owns its family outright: voice reads paragraphs for tone and its
rows carry no replacement, so a "translation" there would be a card offering
nothing to accept. There is a test for exactly that.
- `normalizeType` still refuses `translate` from the model, deliberately. A
model that volunteers the label lands on grammar and is then promoted — or
not — on the evidence.
- Migration `0015` rebuilds the suggestions table for the extended `type` CHECK
(SQLite can't ALTER one), as `0005` and `0008` did before it.
- Client: `--color-jade`, the `translate` entry in `TYPE_META`, and
`.petal-suggestion-translate` for the inline underline.
- The pill is the one bilingual type name in the rail — `翻译 · Translate`,
`Tradução · Translate`, `Traduction · Translate`, from the pack. Every other
type stays English on purpose: those are the terms she is learning, and she is
learning them in English. This card's whole subject is her own language.
- The rail card stops truncating its two lines for a translation. Elsewhere the
original and replacement differ by a word and the explanation below is what
she reads; here the two lines are a whole sentence in each language and *are*
the card.
**The pair families need different tests, and pretending otherwise was the
trap.** The item's plan says to reuse the CJK detection from spellcheck — which
works, for zh, because it is a different script. It gives nothing for pt-PT, fr
or es, where no such signal exists. Those fall back to function words, and need
two distinct markers before Petal will claim anything; the lists deliberately
omit every word that is *also* English (`do`, `con`, `ya`, `todo`, and the
pan-Romance shorts), even where that costs a very common one. A single marker is
never enough, so a one-word Portuguese span won't trip it — a single word is a
vocabulary question, not a translation. The whole heuristic is justified by how
cheap its failure is: a wrong answer changes a coloured pill and nothing else,
because the replacement, the explanation and the Accept button are identical
either way.
**Two things only the browser could have told me.**
- **The inline underline was invisible.** The decoration plugin emits a
per-type class, and `.petal-suggestion` sets `border-bottom: 2px solid
transparent` — so a type with no colour rule renders with no underline at
all. Every test passed; the flagship span simply had no mark under it on
screen. Found by looking at the page, and it is the reason this doc keeps
insisting on that.
- **At her viewport there is no rail.** `railEnabled` needs 348 px beside the
editor, and at 1517×810 with the document list open the margin is 258 — so
the card she actually gets is the inline hover panel, not a rail card. Worth
knowing before item 7 is scoped: that item assumes the rail is what she sees
and treats the anchored popover as the missing half. On this screen it is the
other way round. (The fifth session measured rail cards at the same width, so
some state does reach it; not chased here.)
**Verified against the running binary**, with a stand-in model server so no VPN
or GPU was involved. The stub types the Chinese finding `"clarity"` — exactly
what the live build did — so the label on screen can only have come from Petal's
own detection. Through the real `/check`: `grammar` for the English sentence,
`translate` for the Chinese one. In the browser: the jade underline distinct
from grammar's mint, and the card showing the `翻译 · Translate` pill, the
Chinese struck through and wrapped over two lines rather than clipped, the
English rendering, the bilingual explanation, and Accept.
**Not verified in a browser:** the *rail* card's version of the same thing. The
rail is unreachable on this 1517 px display (above), and forcing it by hand kept
being overridden by React's own layout. Its label goes through the same
`typeLabel` call the hover card just proved, and its diff differs only by a
class toggle — but that is a reading, not a measurement, and it is written down
as one.
Coverage: `language_test.go` (the flagship sentence; a lone Han rune; one
Chinese word inside English prose; Chinese→Chinese; a Chinese span on the wrong
pair; all three Latin pairs; an English sentence stuffed with pan-Romance
lookalikes; French elision; unknown and absent pairs), `translate_type_test.go`
(the type through the real `/check`, an English correction keeping its own type,
and voice unable to mint one), `suggestionMeta.test.ts` (every type has a colour
and a name, translate's colour is its own, the pill is bilingual per pair and
every other pill isn't), and `db_test.go`'s
`TestTranslateTypeMigrationPreservesRows` — 0015 rebuilds the table, so it is
the first migration here that could silently *drop her rows*; every column,
both timestamps and both indexes are asserted across it.
One test changed contract: the pt-PT pack's Brazilian-forms grep searched
`JSON.stringify(pack)`, which includes field *names* — and duly failed on
`translateLabel`, since it lowercases to "transla·tela·bel" and so "contains"
the pt-BR *tela*. It now searches the pack's copy only, with two canaries, since
every assertion in it is a negative and a haystack that quietly went empty would
make the whole test pass by having nothing to search.
## 6. Ask Petal answers: bilingual, and room to read ## 6. Ask Petal answers: bilingual, and room to read
The card's *explanation* is bilingual, but the Ask Petal *answer* came back The card's *explanation* is bilingual, but the Ask Petal *answer* came back
@@ -504,6 +604,13 @@ vitest tests. So the three sessions of work she hasn't seen, she can now
see. Untouched: 6, 7, 8, item 3's incremental half, item 5's re-scoped see. Untouched: 6, 7, 8, item 3's incremental half, item 5's re-scoped
Translate card type.)* Translate card type.)*
*(Sixth session, second half: item 5 done — see the subsection under it. It sits
on `feat/translate-card`, **not deployed**; `main` is at the stack merge. Two
findings there are worth reading before picking the next item: the inline
underline needs a per-type CSS rule or it renders invisibly, and at her actual
viewport the rail is disabled — the inline hover card is what she sees, which
inverts item 7's premise. Untouched: 6, 7, 8, item 3's incremental half.)*
**Suggested next (sixth session onward):** items 6, 7, 8 are all untouched **Suggested next (sixth session onward):** items 6, 7, 8 are all untouched
and all small; item 5's re-scoped Translate card type is the cheapest and all small; item 5's re-scoped Translate card type is the cheapest
*visible* win (see its Status note — the span is already detected and *visible* win (see its Status note — the span is already detected and
+38
View File
@@ -523,6 +523,44 @@ CREATE TABLE checked_chunks (
hash TEXT NOT NULL, hash TEXT NOT NULL,
PRIMARY KEY (doc_id, family, hash) PRIMARY KEY (doc_id, family, hash)
); );
`,
},
{
// A sentence she wrote in her own language gets its own type. Petal already
// detected such spans and already rendered them into English — it just
// filed the result under 'clarity', so the pair model's flagship moment
// read as tidying up her Chinese. As with 0005 and 0008, the `type` CHECK
// can't be ALTERed in place, so rebuild the table with the extended
// constraint, copy every row across, and recreate both indexes.
//
// Existing rows are left on whatever type they have. A card she is already
// reading keeps the label she has already read (the same rule reconcile.go
// follows for a re-proposed edit); new findings get the new label.
name: "0015_translate_suggestion_type",
stmt: `
CREATE TABLE suggestions_new (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
from_pos INTEGER NOT NULL,
to_pos INTEGER NOT NULL,
original TEXT NOT NULL,
replacement TEXT NOT NULL,
explanation TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','translate','voice','collocation','mechanics')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME,
source TEXT NOT NULL DEFAULT 'llm',
chunk_hash TEXT NOT NULL DEFAULT ''
);
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at, resolved_at, source, chunk_hash)
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at, resolved_at, source, chunk_hash FROM suggestions;
DROP TABLE suggestions;
ALTER TABLE suggestions_new RENAME TO suggestions;
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
CREATE INDEX idx_suggestions_resolved ON suggestions(status, resolved_at);
`, `,
}, },
} }
+137
View File
@@ -3,6 +3,7 @@ package db
import ( import (
"path/filepath" "path/filepath"
"testing" "testing"
"time"
) )
func TestOpenMigratesAndSeeds(t *testing.T) { func TestOpenMigratesAndSeeds(t *testing.T) {
@@ -227,3 +228,139 @@ func TestSuggestionSourceBackfill(t *testing.T) {
t.Errorf("default source = %q, want %q", fresh, SuggestionSourceLLM) t.Errorf("default source = %q, want %q", fresh, SuggestionSourceLLM)
} }
} }
// TestTranslateTypeMigrationPreservesRows runs migration 0015 against a database
// that predates it. Unlike the two backfills above, 0015 *rebuilds the table* —
// SQLite can't ALTER a CHECK constraint — so it copies every row across by hand,
// and a column left out of that copy list silently loses her data. Every test
// elsewhere starts from a fresh database and would never notice; the live box has
// years of rows in it.
func TestTranslateTypeMigrationPreservesRows(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
d, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
// Rewind to the pre-0015 table: the same shape, minus 'translate' in the CHECK.
if _, err := d.Exec(`
CREATE TABLE suggestions_old (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
from_pos INTEGER NOT NULL,
to_pos INTEGER NOT NULL,
original TEXT NOT NULL,
replacement TEXT NOT NULL,
explanation TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice','collocation','mechanics')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME,
source TEXT NOT NULL DEFAULT 'llm',
chunk_hash TEXT NOT NULL DEFAULT ''
);
DROP TABLE suggestions;
ALTER TABLE suggestions_old RENAME TO suggestions;
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
CREATE INDEX idx_suggestions_resolved ON suggestions(status, resolved_at);
DELETE FROM schema_migrations WHERE name = '0015_translate_suggestion_type';
`); err != nil {
t.Fatalf("rewind schema: %v", err)
}
if _, err := d.Exec(`INSERT INTO documents (id, user_id) VALUES ('d1', ?)`, LocalUserID); err != nil {
t.Fatalf("insert document: %v", err)
}
// One row with every column carrying a distinguishable value, so a dropped
// column shows up as a changed value rather than as a passing test.
if _, err := d.Exec(
`INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at, resolved_at, source, chunk_hash)
VALUES ('s-1', 'd1', 7, 11, 'by foots', 'on foot', 'idiom advice she has read', 'idiom', 'accepted', '2026-01-02 03:04:05', '2026-01-02 03:05:00', 'local', 'abc123')`,
); err != nil {
t.Fatalf("seed row: %v", err)
}
d.Close()
d2, err := Open(path)
if err != nil {
t.Fatalf("reopen (migrate): %v", err)
}
defer d2.Close()
var (
docID, original, replacement, explanation string
typ, status, source, chunkHash string
from, to int
// Scanned as instants, not strings: the driver renders a DATETIME column in
// its own format, so the claim is "the same moment", not the same text.
createdAt, resolvedAt time.Time
)
if err := d2.QueryRow(
`SELECT doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at, resolved_at, source, chunk_hash
FROM suggestions WHERE id = 's-1'`,
).Scan(&docID, &from, &to, &original, &replacement, &explanation,
&typ, &status, &createdAt, &resolvedAt, &source, &chunkHash); err != nil {
t.Fatalf("read migrated row: %v", err)
}
for _, c := range []struct{ name, got, want string }{
{"doc_id", docID, "d1"},
{"original", original, "by foots"},
{"replacement", replacement, "on foot"},
{"explanation", explanation, "idiom advice she has read"},
{"type", typ, SuggestionTypeIdiom},
{"status", status, SuggestionStatusAccepted},
{"source", source, SuggestionSourceLocal},
{"chunk_hash", chunkHash, "abc123"},
} {
if c.got != c.want {
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
}
}
if from != 7 || to != 11 {
t.Errorf("offsets = (%d, %d), want (7, 11)", from, to)
}
// created_at and resolved_at must survive: the rail's arrival chime keys on
// created_at, and the growth journal counts by resolved_at. A rebuild that
// reset either would re-chime her whole document and rewrite her history.
for _, c := range []struct {
name string
got time.Time
want string
}{
{"created_at", createdAt, "2026-01-02 03:04:05"},
{"resolved_at", resolvedAt, "2026-01-02 03:05:00"},
} {
want, err := time.Parse("2006-01-02 15:04:05", c.want)
if err != nil {
t.Fatalf("parse want: %v", err)
}
if !c.got.Equal(want) {
t.Errorf("%s = %v, want the original instant %v", c.name, c.got, want)
}
}
// The point of the rebuild: the new type is now insertable, and a bogus one
// still isn't.
if _, err := d2.Exec(
`INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type)
VALUES ('s-2', 'd1', 0, 3, '苹果', 'apple', 'x', ?)`, SuggestionTypeTranslate,
); err != nil {
t.Fatalf("insert translate row: %v", err)
}
if _, err := d2.Exec(
`INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type)
VALUES ('s-3', 'd1', 0, 3, 'x', 'y', 'x', 'nonsense')`,
); err == nil {
t.Error("CHECK constraint accepted an unknown type after the rebuild")
}
// Both indexes must come back, or every document load starts table-scanning.
for _, idx := range []string{"idx_suggestions_doc_id", "idx_suggestions_resolved"} {
var name string
if err := d2.QueryRow(
`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`, idx,
).Scan(&name); err != nil {
t.Errorf("index %s missing after rebuild: %v", idx, err)
}
}
}
+8 -1
View File
@@ -104,7 +104,7 @@ type Suggestion struct {
Original string `json:"original"` Original string `json:"original"`
Replacement string `json:"replacement"` Replacement string `json:"replacement"`
Explanation string `json:"explanation"` Explanation string `json:"explanation"`
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice | collocation Type string `json:"type"` // grammar | phrasing | idiom | clarity | translate | voice | collocation
Status string `json:"status"` // pending | accepted | rejected Status string `json:"status"` // pending | accepted | rejected
// Source names the engine that proposed the edit, not its family: an offline // Source names the engine that proposed the edit, not its family: an offline
// rule and the model can both propose a collocation, and the writer is never // rule and the model can both propose a collocation, and the writer is never
@@ -119,6 +119,13 @@ const (
SuggestionTypePhrasing = "phrasing" SuggestionTypePhrasing = "phrasing"
SuggestionTypeIdiom = "idiom" SuggestionTypeIdiom = "idiom"
SuggestionTypeClarity = "clarity" SuggestionTypeClarity = "clarity"
// A span she wrote in her own language, rendered into English. Not a
// correction — nothing was wrong with it — which is why it is its own type
// rather than a clarity fix: the card is the pair model's flagship moment
// (SUGGESTIONS §1), and labelling it "Clarity" reads as a tidy-up of her
// first language. The model isn't asked for this label; it is derived from the
// span itself (see suggestions/language.go), so it can't drift.
SuggestionTypeTranslate = "translate"
SuggestionTypeVoice = "voice" SuggestionTypeVoice = "voice"
SuggestionTypeCollocation = "collocation" SuggestionTypeCollocation = "collocation"
SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM) SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM)
+8 -3
View File
@@ -284,7 +284,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
// Nothing to analyze on an empty document — skip the LLM round-trip. The // Nothing to analyze on an empty document — skip the LLM round-trip. The
// family's rows go with the text they were about. // family's rows go with the text they were about.
if strings.TrimSpace(contentText) == "" { if strings.TrimSpace(contentText) == "" {
if err := h.reconcilePending(docID, contentText, nil, scope, nil, nil, false); err != nil { if err := h.reconcilePending(docID, contentText, pairLang, nil, scope, nil, nil, false); err != nil {
httputil.ServerError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -320,7 +320,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
if len(changed) == 0 { if len(changed) == 0 {
// Every sentence has already been read. Drop the rows whose sentence is // Every sentence has already been read. Drop the rows whose sentence is
// gone, keep the rest exactly as they are, and answer immediately. // gone, keep the rest exactly as they are, and answer immediately.
if err := h.reconcilePending(docID, contentText, nil, scope, chunks, nil, false); err != nil { if err := h.reconcilePending(docID, contentText, pairLang, nil, scope, chunks, nil, false); err != nil {
httputil.ServerError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -365,7 +365,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
// A whole-document pass re-read everything, so every one of its rows is up for // A whole-document pass re-read everything, so every one of its rows is up for
// re-proposal; a chunked pass only puts the sentences it asked about in play. // re-proposal; a chunked pass only puts the sentences it asked about in play.
if err := h.reconcilePending(docID, contentText, raw, scope, chunks, fresh, !scope.chunked); err != nil { if err := h.reconcilePending(docID, contentText, pairLang, raw, scope, chunks, fresh, !scope.chunked); err != nil {
httputil.ServerError(w, err) httputil.ServerError(w, err)
return return
} }
@@ -756,6 +756,11 @@ func locate(contentText, original string) (int, int) {
// normalizeType maps the model's type string onto a valid suggestion type, // normalizeType maps the model's type string onto a valid suggestion type,
// defaulting unknown values to grammar so a stray label never trips the CHECK. // defaulting unknown values to grammar so a stray label never trips the CHECK.
//
// 'translate' is absent on purpose, and stays absent even though the type now
// exists: it is decided from the span (see language.go), never taken from the
// model. A model that volunteers the label anyway lands on grammar here and is
// then promoted — or not — on the evidence.
func normalizeType(t string) string { func normalizeType(t string) string {
switch strings.ToLower(strings.TrimSpace(t)) { switch strings.ToLower(strings.TrimSpace(t)) {
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity, db.SuggestionTypeCollocation: case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity, db.SuggestionTypeCollocation:
+174
View File
@@ -0,0 +1,174 @@
package suggestions
import (
"strings"
"unicode"
)
// Telling her language from English, well enough to label a card.
//
// When the checkpoint quotes a span she wrote in her own language and hands back
// an English rendering, that is not a correction — nothing was wrong with what
// she wrote — and it should not be filed under 'clarity'. The label is derived
// here rather than asked of the model: a type is structural, and a model that
// re-reasons every pass would drift between labels for the same sentence.
//
// The failure mode is deliberately cheap. Getting this wrong changes a card's
// coloured pill and nothing else — the replacement, the explanation and the
// Accept button are identical either way — so a heuristic is the right tool. It
// is written to under-claim: a span it isn't sure about stays whatever the model
// called it.
//
// The two pair families need genuinely different tests, and pretending otherwise
// would be the bug:
//
// - zh is a different script. Counting Han runes is close to certain.
// - pt-PT, fr and es share the Latin alphabet with English, where no such
// signal exists. Those fall back to function words — the short, extremely
// common words a sentence in that language can hardly avoid and an English
// sentence has no reason to contain.
// isTranslation reports whether this edit is her own language rendered into
// English, rather than a correction to her English. Both halves must hold: the
// quoted span reads as the pair language, and what Petal offers back reads as
// English. The second half matters — a Chinese span rewritten into different
// Chinese is something else entirely, and Petal has no business calling it a
// translation.
func isTranslation(original, replacement, pairLang string) bool {
if strings.TrimSpace(original) == "" || strings.TrimSpace(replacement) == "" {
return false
}
return readsAsPairLang(original, pairLang) && readsAsEnglish(replacement)
}
// readsAsPairLang reports whether s is predominantly in the writer's language.
func readsAsPairLang(s, pairLang string) bool {
switch normalizePairLang(pairLang) {
case "zh":
han, latin := scriptCounts(s)
// Predominantly, not merely partly: one Chinese word inside an English
// sentence is a vocabulary question, and the sentence around it is still
// English prose with its own grammar to correct. Two runes is the floor
// because a single Han character is as likely to be a stray keystroke.
return han >= 2 && han > latin
case "pt-PT", "fr", "es":
return distinctMarkers(s, latinMarkers[normalizePairLang(pairLang)]) >= 2
}
// A pair Petal has no test for. Say no: an unlabelled card is a card that
// reads as it did yesterday, and a wrongly-labelled one is a new defect.
return false
}
// readsAsEnglish reports whether s is English prose rather than more of her own
// language. It is not a language identifier — it only has to separate "English"
// from "the pair language", and it is only ever asked about text Petal itself
// generated, so the bar is low on purpose: Latin letters present, and not
// swamped by another script.
func readsAsEnglish(s string) bool {
han, latin := scriptCounts(s)
return latin > 0 && latin > han
}
// normalizePairLang folds the stored `users.pair_lang` into the codes below.
// Empty (a document whose owner has no pair recorded) falls through to no test.
func normalizePairLang(pairLang string) string {
switch p := strings.ToLower(strings.TrimSpace(pairLang)); p {
case "zh", "zh-cn", "zh-hans":
return "zh"
case "pt", "pt-pt":
return "pt-PT"
case "fr", "fr-fr":
return "fr"
case "es", "es-es":
return "es"
default:
return p
}
}
// scriptCounts counts Han runes and ASCII letters. Everything else — digits,
// punctuation, spaces, emoji — is ignored, so trailing 。or a stray comma
// changes nothing.
func scriptCounts(s string) (han, latin int) {
for _, r := range s {
switch {
case unicode.Is(unicode.Han, r):
han++
case r < unicode.MaxASCII && unicode.IsLetter(r):
latin++
}
}
return han, latin
}
// distinctMarkers counts how many *different* marker words appear in s. Distinct
// rather than total: "que ... que" is one writer's habit, while "eu quero" is two
// independent pieces of evidence.
func distinctMarkers(s string, markers map[string]bool) int {
if len(markers) == 0 {
return 0
}
seen := map[string]bool{}
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
// Split on anything that isn't a letter, so punctuation and digits are
// separators. Apostrophes included: French elision (j'ai, n'est) should
// yield its parts.
return !unicode.IsLetter(r)
}) {
if markers[w] {
seen[w] = true
}
}
return len(seen)
}
// Function words that a sentence in each Latin pair can hardly avoid.
//
// Curated against English, not for coverage: every entry here is a word an
// English sentence has essentially no reason to contain, which is why the lists
// omit plenty of far more common words. Deliberately absent — each of them a
// false positive waiting to happen — is anything that is *also* an English word:
// the pan-Romance shorts (a, o, e, as, no, on, en, de, se, na, mi, son, era,
// plus, pour, si, ma, ce, ne), Portuguese "do", Spanish "con", "ya" and "todo".
// Dropping "con" costs the Spanish list one of its commonest words, and that is
// the right trade — a marker that fires on English corroborates the wrong
// answer, which is worse than a sentence Petal declines to label.
//
// A single marker is not enough (see readsAsPairLang), so these lists are read
// as evidence to be corroborated rather than as a decision.
var latinMarkers = map[string]map[string]bool{
"fr": words(
"je", "tu", "il", "elle", "ils", "elles", "nous", "vous", "est", "sont",
"était", "étais", "une", "des", "les", "du", "dans", "avec", "que", "qui",
"mais", "très", "être", "avoir", "pas", "cette", "cet", "ces", "mon",
"mes", "notre", "votre", "leur", "aussi", "alors", "parce", "comme",
"beaucoup", "toujours", "jamais", "quand", "bien", "chose", "temps",
"moi", "toi", "lui", "peux", "veux", "sais", "faire", "dit", "aujourd",
"hui", "quelque", "chez", "tout", "tous", "rien", "déjà", "encore",
),
"pt-PT": words(
"eu", "você", "ele", "ela", "eles", "elas", "nós", "são", "uma", "os",
"da", "dos", "das", "com", "que", "mas", "muito", "não", "meu",
"minha", "seu", "sua", "isso", "este", "esta", "está", "estou", "quero",
"também", "quando", "porque", "coisa", "tempo", "fazer", "sempre",
"nunca", "bem", "obrigado", "obrigada", "gosto", "tenho", "tem", "foi",
"ser", "ter", "mais", "já", "ainda", "aqui", "ali", "nada", "tudo",
"todos", "para", "pela", "pelo", "sobre", "assim",
),
"es": words(
"yo", "él", "ella", "ellos", "ellas", "nosotros", "una", "los", "las",
"del", "que", "pero", "muy", "esto", "esta", "este", "está",
"estoy", "quiero", "también", "cuando", "porque", "cosa", "tiempo",
"hacer", "siempre", "nunca", "bien", "gracias", "tengo", "tiene", "fue",
"ser", "tener", "más", "aquí", "allí", "nada", "todos",
"para", "sobre", "así", "hola", "señor", "usted", "muchas",
),
}
func words(list ...string) map[string]bool {
out := make(map[string]bool, len(list))
for _, w := range list {
out[w] = true
}
return out
}
+176
View File
@@ -0,0 +1,176 @@
package suggestions
import "testing"
// The flagship case, and the ones next to it that must NOT become translations.
func TestIsTranslation(t *testing.T) {
cases := []struct {
name string
original string
replacement string
pairLang string
want bool
}{
{
// The sentence from the UX review, verbatim.
name: "whole Chinese sentence rendered into English",
original: "我想说这句话但是不知道用英语怎么说。",
replacement: "I want to say this but I don't know how to say it in English.",
pairLang: "zh",
want: true,
},
{
name: "ordinary English correction is not a translation",
original: "She goes to market yesterday",
replacement: "She went to the market yesterday",
pairLang: "zh",
want: false,
},
{
// One Chinese word inside English prose. The sentence around it is
// still English with its own grammar to fix, and calling the card a
// translation would mislabel a grammar fix.
name: "single Chinese word inside an English sentence",
original: "I bought a 苹果 at the store",
replacement: "I bought an apple at the store",
pairLang: "zh",
want: false,
},
{
name: "a lone stray Han rune is not a sentence",
original: "的",
replacement: "of",
pairLang: "zh",
want: false,
},
{
// Chinese in, Chinese out: whatever this is, Petal is not translating.
name: "Chinese rewritten as Chinese",
original: "我想说这句话",
replacement: "我要说这句话",
pairLang: "zh",
want: false,
},
{
// The same Chinese span, but the writer is on the French pair. Petal
// has no business offering to translate a language she never claimed.
name: "Chinese span on a non-zh pair",
original: "我想说这句话但是不知道用英语怎么说。",
replacement: "I want to say this in English.",
pairLang: "fr",
want: false,
},
{
name: "French sentence rendered into English",
original: "Je ne sais pas comment le dire en anglais.",
replacement: "I don't know how to say it in English.",
pairLang: "fr",
want: true,
},
{
name: "Portuguese sentence rendered into English",
original: "Eu quero dizer isso mas não sei como.",
replacement: "I want to say this but I don't know how.",
pairLang: "pt-PT",
want: true,
},
{
name: "Spanish sentence rendered into English",
original: "Yo quiero decir esto pero no sé cómo.",
replacement: "I want to say this but I don't know how.",
pairLang: "es",
want: true,
},
{
// A single marker is not evidence. "Que" appears in English writing
// about other languages, in names, in quoted phrases.
name: "one Latin marker is not enough",
original: "The word que confused me",
replacement: "The word que confuses me",
pairLang: "pt-PT",
want: false,
},
{
// The words most likely to sink this heuristic: English function words
// that are also Romance function words. They are kept out of the lists
// precisely so this sentence stays a grammar fix.
name: "English full of pan-Romance lookalikes",
original: "I do not know if a con man on the plus side as no era",
replacement: "I do not know whether a con man, on the plus side, is no era",
pairLang: "es",
want: false,
},
{
name: "English with a borrowed French phrase stays English",
original: "It was a pas de deux, more or less",
replacement: "It was a pas de deux, more or less.",
pairLang: "fr",
want: false,
},
{
name: "empty replacement (an awareness-only finding)",
original: "我想说这句话但是不知道用英语怎么说。",
replacement: "",
pairLang: "zh",
want: false,
},
{
// A document whose owner has no pair recorded. No test, no label.
name: "no pair language",
original: "我想说这句话但是不知道用英语怎么说。",
replacement: "I want to say this in English.",
pairLang: "",
want: false,
},
{
// An unshipped pair. Same rule: decline rather than guess.
name: "unknown pair language",
original: "Ich weiß nicht wie man das sagt.",
replacement: "I don't know how to say that.",
pairLang: "de",
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isTranslation(c.original, c.replacement, c.pairLang); got != c.want {
t.Errorf("isTranslation(%q, %q, %q) = %v, want %v",
c.original, c.replacement, c.pairLang, got, c.want)
}
})
}
}
// pair_lang is stored as the pack code, but a stored value has drifted before
// (see the picker's history), so the fold is tested rather than assumed.
func TestNormalizePairLang(t *testing.T) {
for in, want := range map[string]string{
"zh": "zh", "zh-CN": "zh", "ZH": "zh",
"pt": "pt-PT", "pt-PT": "pt-PT", "pt-pt": "pt-PT",
"fr": "fr", "fr-FR": "fr",
"es": "es", "es-ES": "es",
" zh ": "zh",
"": "",
"de": "de",
} {
if got := normalizePairLang(in); got != want {
t.Errorf("normalizePairLang(%q) = %q, want %q", in, got, want)
}
}
}
// French elision must yield its parts, or "j'ai" and "n'est" — two of the
// commonest shapes in the language — count for nothing.
func TestElisionYieldsMarkers(t *testing.T) {
if n := distinctMarkers("Je n'est pas", latinMarkers["fr"]); n < 3 {
t.Errorf("elided French: got %d markers, want >= 3 (je, est, pas)", n)
}
}
// Distinct, not total: one word repeated is one piece of evidence.
func TestRepeatedMarkerCountsOnce(t *testing.T) {
if n := distinctMarkers("que que que", latinMarkers["pt-PT"]); n != 1 {
t.Errorf("repeated marker: got %d, want 1", n)
}
}
+11 -1
View File
@@ -130,8 +130,11 @@ func reposition(tx *sql.Tx, row pendingRow, from, to int, chunkHash string) erro
// called at all). inPlayAll marks the whole-document passes — voice and the // called at all). inPlayAll marks the whole-document passes — voice and the
// collocation coach — where every row is up for re-proposal because the model // collocation coach — where every row is up for re-proposal because the model
// just re-read everything. // just re-read everything.
//
// `pairLang` is the writer's own language, needed only to type a finding that
// turns out to be her language rendered into English (see language.go).
func (h *Handler) reconcilePending( func (h *Handler) reconcilePending(
docID, contentText string, docID, contentText, pairLang string,
raw []llm.RawSuggestion, raw []llm.RawSuggestion,
scope pendingScope, scope pendingScope,
chunks, fresh []chunk, chunks, fresh []chunk,
@@ -225,9 +228,16 @@ func (h *Handler) reconcilePending(
} }
continue continue
} }
// A pass with a forced type owns its family outright and is never asked
// about translation: voice reads whole paragraphs for tone, and the
// collocation coach is about English word pairings. Only the open-typed
// grammar checkpoint can turn out to have been handed her own language.
typ := scope.forceType typ := scope.forceType
if typ == "" { if typ == "" {
typ = normalizeType(s.Type) typ = normalizeType(s.Type)
if isTranslation(s.Original, s.Replacement, pairLang) {
typ = db.SuggestionTypeTranslate
}
} }
if _, err := tx.Exec( if _, err := tx.Exec(
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, source, chunk_hash) `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type, source, chunk_hash)
+107
View File
@@ -0,0 +1,107 @@
package suggestions
import (
"encoding/json"
"net/http"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// The pair model's flagship moment, end to end: she reaches for a sentence in
// her own language mid-document, and the card that comes back is labelled as a
// translation rather than as a tidy-up of her Chinese.
//
// The label is asserted through the real /check path rather than against
// isTranslation directly, because the point of the item was never the detector —
// Petal already found these spans and already rendered them into English. What
// was wrong was the type that reached the rail.
func TestChineseSpanBecomesATranslateCard(t *testing.T) {
// Note the model calls it "clarity", as the live build did. The type it
// volunteers is not consulted.
client := &stubClient{response: `{"suggestions":[
{"original":"我想说这句话但是不知道用英语怎么说。","replacement":"I want to say this but I don't know how to say it in English.","explanation":"这是英文说法 · Here is how to say it in English","type":"clarity"}
]}`}
srv, docID, database := newPairServer(t, client, "zh")
setDocTextDB(t, database, docID, "My weekend was good. 我想说这句话但是不知道用英语怎么说。")
var out []db.Suggestion
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
if rec.Code != http.StatusOK {
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 1 {
t.Fatalf("want 1 card, got %d: %+v", len(out), out)
}
if out[0].Type != db.SuggestionTypeTranslate {
t.Fatalf("card type = %q, want %q", out[0].Type, db.SuggestionTypeTranslate)
}
// The rendering and the reasoning are the model's, untouched — only the label
// is Petal's.
if out[0].Replacement != "I want to say this but I don't know how to say it in English." {
t.Fatalf("replacement was rewritten: %q", out[0].Replacement)
}
}
// The other half of the same claim: an ordinary English correction on the same
// writer's document keeps the type the model gave it. A relabel that fired on
// everything would be no better than the label it replaced.
func TestEnglishCorrectionKeepsItsType(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"original":"My weekend was very good","replacement":"My weekend was wonderful","explanation":"stronger wording","type":"phrasing"}
]}`}
srv, docID, database := newPairServer(t, client, "zh")
setDocTextDB(t, database, docID, "My weekend was very good.")
var out []db.Suggestion
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out) != 1 {
t.Fatalf("want 1 card, got %d: %+v", len(out), out)
}
if out[0].Type != db.SuggestionTypePhrasing {
t.Fatalf("card type = %q, want %q", out[0].Type, db.SuggestionTypePhrasing)
}
}
// The voice pass reads whole paragraphs for tone and stamps its own family. A
// Chinese paragraph must not be able to smuggle a translate row into it — voice
// rows carry no replacement to accept, so a "translation" there would be a card
// offering nothing.
func TestVoicePassCannotProduceATranslateCard(t *testing.T) {
client := &stubClient{response: `{"suggestions":[
{"original":"我想说这句话但是不知道用英语怎么说。","replacement":"I want to say this in English.","explanation":"tone","type":"clarity"}
]}`}
srv, docID, database := newPairServer(t, client, "zh")
setDocTextDB(t, database, docID, "A first paragraph.\n\n我想说这句话但是不知道用英语怎么说。")
var out []db.Suggestion
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", "")
if rec.Code != http.StatusOK {
t.Fatalf("voice: code=%d body=%s", rec.Code, rec.Body)
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
for _, s := range out {
if s.Type == db.SuggestionTypeTranslate {
t.Fatalf("voice pass produced a translate card: %+v", s)
}
}
}
// setDocTextDB is setDocText for the pair harness, which hands back the DB
// rather than the Handler.
func setDocTextDB(t *testing.T, database *db.DB, docID, text string) {
t.Helper()
if _, err := database.Exec(
`UPDATE documents SET content_text = ? WHERE id = ?`, text, docID,
); err != nil {
t.Fatalf("update doc text: %v", err)
}
}
+12 -1
View File
@@ -101,7 +101,18 @@ export interface Gloss {
reverse?: string 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, // 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 // 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 { useState } from 'react'
import type { Suggestion } from '../../api/client' import type { Suggestion } from '../../api/client'
import { usePack } from '../../i18n'
import { AskPetal } from './AskPetal' import { AskPetal } from './AskPetal'
import { TYPE_META } from './suggestionMeta' import { TYPE_META, typeLabel } from './suggestionMeta'
interface Props { interface Props {
suggestion: Suggestion suggestion: Suggestion
@@ -28,7 +29,9 @@ export function SuggestionCard({
onPointerLeave, onPointerLeave,
onExpandChange, onExpandChange,
}: Props) { }: Props) {
const pack = usePack()
const meta = TYPE_META[suggestion.type] const meta = TYPE_META[suggestion.type]
const label = typeLabel(suggestion.type, pack)
const hasReplacement = suggestion.replacement.trim() !== '' const hasReplacement = suggestion.replacement.trim() !== ''
const [asking, setAsking] = useState(false) const [asking, setAsking] = useState(false)
@@ -43,7 +46,7 @@ export function SuggestionCard({
return ( return (
<div <div
role="dialog" role="dialog"
aria-label={`${meta.label} suggestion`} aria-label={`${label} suggestion`}
onMouseEnter={onPointerEnter} onMouseEnter={onPointerEnter}
onMouseLeave={onPointerLeave} onMouseLeave={onPointerLeave}
className="petal-suggestion-card absolute z-20 p-3.5 text-sm" 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" 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)' }} style={{ background: meta.color, color: 'var(--color-plum)' }}
> >
{meta.label} {label}
</span> </span>
{hasReplacement && ( {hasReplacement && (
+19 -5
View File
@@ -1,7 +1,8 @@
import { forwardRef, useLayoutEffect, useRef, useState } from 'react' import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
import type { Suggestion } from '../../api/client' import type { Suggestion } from '../../api/client'
import { usePack } from '../../i18n'
import { AskPetal } from './AskPetal' 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 // Vertical breathing room kept between stacked cards when their natural anchors
// would otherwise collide. // would otherwise collide.
@@ -138,14 +139,21 @@ const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand }, { suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
ref, ref,
) { ) {
const pack = usePack()
const meta = TYPE_META[suggestion.type] const meta = TYPE_META[suggestion.type]
const label = typeLabel(suggestion.type, pack)
const hasReplacement = suggestion.replacement.trim() !== '' 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 ( return (
<div <div
ref={ref} ref={ref}
role="group" role="group"
aria-label={`${meta.label} suggestion`} aria-label={`${label} suggestion`}
onMouseEnter={() => onHover(suggestion.id)} onMouseEnter={() => onHover(suggestion.id)}
onMouseLeave={() => onHover(null)} onMouseLeave={() => onHover(null)}
className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`} 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" 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)' }} style={{ background: meta.color, color: 'var(--color-plum)' }}
> >
{meta.label} {label}
</span> </span>
<button <button
type="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"> <button type="button" onClick={() => onActivate(suggestion.id)} className="mt-2 block w-full text-left">
{hasReplacement && ( {hasReplacement && (
<span className="flex flex-col gap-0.5" style={{ fontFamily: 'var(--font-body)' }}> <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} {suggestion.original}
</span> </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} {suggestion.replacement}
</span> </span>
</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 { SuggestionType } from '../../api/client'
import type { Pack } from '../../i18n'
// Per-type accent color + human label, mirroring the design tokens. Shared by the // Per-type accent color + human label, mirroring the design tokens. Shared by the
// inline hover SuggestionCard and the margin SuggestionRail so a given suggestion // 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' }, phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
idiom: { color: 'var(--color-lavender)', label: 'Idiom' }, idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
clarity: { color: 'var(--color-sky)', label: 'Clarity' }, 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' }, voice: { color: 'var(--color-honey)', label: 'Voice' },
collocation: { color: 'var(--color-blossom)', label: 'Word pairing' }, collocation: { color: 'var(--color-blossom)', label: 'Word pairing' },
mechanics: { color: 'var(--color-sage)', label: 'Tidy-up' }, 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. // author's pack satisfies isn't a shape, it's a coincidence.
const PACKS: Pack[] = [zh, ptPT, fr] 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(() => { beforeEach(() => {
resetPackForTests() resetPackForTests()
}) })
@@ -194,10 +208,23 @@ describe('the pt-PT pack', () => {
// is invisible to anyone who doesn't read Portuguese — including whoever // is invisible to anyone who doesn't read Portuguese — including whoever
// reviews this diff. // reviews this diff.
it('is European Portuguese, not Brazilian', () => { 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 // Lowercased: half this copy is sentences, and a form that only ever appears
// at the start of one ("Actualmente…") would otherwise walk straight past // at the start of one ("Actualmente…") would otherwise walk straight past
// both greps below. // 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. // 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ê']) { 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', replacePlaceholder: 'Remplacer par · Replace',
replace: 'Remplacer', replace: 'Remplacer',
replaceAll: 'Tout', replaceAll: 'Tout',
translateLabel: 'Traduction · Translate',
spelling: 'Orthographe · Spelling', spelling: 'Orthographe · Spelling',
noSuggestions: 'Aucune suggestion · No suggestions', noSuggestions: 'Aucune suggestion · No suggestions',
addToDictionary: 'Ajouter au dictionnaire · Add to dictionary', addToDictionary: 'Ajouter au dictionnaire · Add to dictionary',
+1
View File
@@ -292,6 +292,7 @@ export const ptPT: Pack = {
replacePlaceholder: 'Substituir por · Replace', replacePlaceholder: 'Substituir por · Replace',
replace: 'Substituir', replace: 'Substituir',
replaceAll: 'Tudo', replaceAll: 'Tudo',
translateLabel: 'Tradução · Translate',
spelling: 'Ortografia · Spelling', spelling: 'Ortografia · Spelling',
noSuggestions: 'Sem sugestões · No suggestions', noSuggestions: 'Sem sugestões · No suggestions',
addToDictionary: 'Adicionar ao dicionário · Add to dictionary', addToDictionary: 'Adicionar ao dicionário · Add to dictionary',
+1
View File
@@ -192,6 +192,7 @@ export const zh: Pack = {
replacePlaceholder: '替换为 · Replace', replacePlaceholder: '替换为 · Replace',
replace: '替换', replace: '替换',
replaceAll: '全部', replaceAll: '全部',
translateLabel: '翻译 · Translate',
spelling: '拼写 · Spelling', spelling: '拼写 · Spelling',
noSuggestions: '没有建议 · No suggestions', noSuggestions: '没有建议 · No suggestions',
addToDictionary: '添加到词典 · Add to dictionary', addToDictionary: '添加到词典 · Add to dictionary',
+6
View File
@@ -162,6 +162,12 @@ export interface Pack {
replacePlaceholder: string replacePlaceholder: string
replace: string replace: string
replaceAll: 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 spelling: string
noSuggestions: string noSuggestions: string
addToDictionary: string addToDictionary: string
+7
View File
@@ -22,6 +22,12 @@
--color-honey: #CE9B4F; /* voice */ --color-honey: #CE9B4F; /* voice */
--color-blossom: #E59ABF; /* collocation — warm blossom pink */ --color-blossom: #E59ABF; /* collocation — warm blossom pink */
--color-sage: #B7C7B9; /* mechanics — calm sage, a gentle tidy-up nudge */ --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 */ --color-success: #8FCFA8; /* saved / accepted */
/* Typography */ /* Typography */
@@ -233,6 +239,7 @@ button, a, input {
.petal-suggestion-phrasing { border-bottom-color: var(--color-peach); } .petal-suggestion-phrasing { border-bottom-color: var(--color-peach); }
.petal-suggestion-idiom { border-bottom-color: var(--color-lavender); } .petal-suggestion-idiom { border-bottom-color: var(--color-lavender); }
.petal-suggestion-clarity { border-bottom-color: var(--color-sky); } .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-voice { border-bottom-color: var(--color-honey); }
.petal-suggestion-collocation { border-bottom-color: var(--color-blossom); } .petal-suggestion-collocation { border-bottom-color: var(--color-blossom); }
.petal-suggestion-mechanics { border-bottom-color: var(--color-sage); } .petal-suggestion-mechanics { border-bottom-color: var(--color-sage); }