Phase 9: ESL superpowers — Chinese gloss + tone-rewrite

Inline Chinese gloss (offline) and a "say it more naturally" / tone-rewrite,
the two ESL features for the Mandarin-speaking writer.

Gloss: embedded English→Chinese dictionary (gloss.json.gz, 57k common words
built from ECDICT via scripts/build_gloss.py). lexicon gains Gloss()/Result.Gloss
and a lightweight GET /api/gloss/{word}; the right-click WordCard leads with the
中文; GlossTip shows it on a 350ms hover (reuses wordAt, so CJK is never glossed).
Offline + instant, works with the LLM down.

Rewrite: selecting text pops a SelectionBubble (更自然 + the tone vocabulary);
picking a style calls POST /api/docs/:id/rewrite (llm.RunRewrite, stateless,
owner-scoped) and shows a RewritePreview (original→rewrite, accept/cancel/retry).
Accept applies it in-editor.

Tests added in lexicon and suggestions. go build/vet/test, tsc, vite all clean;
live smoke vs a fake vLLM verified gloss + rewrite + 400/404/502 paths.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 00:07:26 -07:00
parent 8e1111d768
commit 60eba25fee
20 changed files with 1036 additions and 14 deletions

View File

@@ -81,16 +81,23 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- [x] **Stop saving empty docs** — blank `Untitled` drafts now self-discard: `handleCreate` reuses an existing blank instead of stacking another; `openDoc` deletes the blank doc being left. Cleaned the 2 existing orphan empties from the live DB. (Backend also refuses to snapshot empties.)
- Verified: go build/vet/test + tsc + vite all clean; live smoke on a throwaway binary — auto-snapshot on save, throttle holds at 1, manual snapshot, restore brings back exact text + leaves a `pre_restore`, empty doc → no snapshot, md/docx export with CJK+bold+heading+list, docx validates as "Microsoft Word 2007+".
### Phase 9 — ESL superpowers ✅
- [x] **Inline Chinese gloss (offline)** — embedded English→Chinese dictionary (`internal/lexicon/data/gloss.json.gz`, ~1.3MB, 57k common words built from ECDICT via `scripts/build_gloss.py`: frequency-gated to rank ≤50k, `[网络]`/slang/archaic sense-lines dropped, trimmed to ≤3 senses / 80 chars). `Lexicon` gains a `gloss` map + `Gloss(word)` (same `candidates()` de-inflection as defs/syns); `Result` gains a `Gloss` field. Two surfaces: the right-click **WordCard** now leads with the 中文 gloss, and a new lightweight `GET /api/gloss/{word}` (→ `{word, gloss}`, cached) backs the **hover tooltip**. Offline + instant, works with the LLM down (north-star reliability). Frontend: `GlossTip` (dark pointer-events-none bubble under the resting word; 350ms hover delay; reuses `wordAt` so CJK is never glossed — it's the source language), wired into `EditorCore`'s `onMouseMove`/`onMouseLeave` with a request-token guard, suppressed during selection/preview/other popovers.
- [x] **"Say it more naturally" / tone-rewrite** — selecting text pops a `SelectionBubble` (✨更自然 + the tone vocabulary 学术/专业/轻松/幽默/创意/说服, mirrored from `ToneSelect`/`styleGuidance`). Picking a style calls `POST /api/docs/:id/rewrite` (`{text, style}``{rewrite}`), shown in a `RewritePreview` (original struck-through → rewrite, 用这个/取消, breathing-dot loading, gentle retry on failure). Accept applies it in-editor via `insertContentAt` over the captured PM range. Backend: `llm.RunRewrite` (one-shot Complete, `RewriteMaxRunes` 2000 cap, `cleanRewrite` strips stray wrapping quotes) + `rewriteSystemTemplate`/`styleGuidance` in `prompts.go`; handler in `internal/suggestions/rewrite.go` (owner-scoped 404, 400 on empty/too-long, 502 on LLM-down). **Stateless** — not persisted as a suggestion; the version history captures the resulting doc change.
- Tests: `lexicon` gloss + Lookup-includes-gloss + inflection/miss; `suggestions` rewrite happy-path (style steering + de-quote asserted), empty→400, unknown-doc→404. go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM (fresh port 8055, throwaway DB; pre-existing dev servers on :8077/:8099 untouched): gloss for `river`/inflected/CJK-empty/nonsense-empty, `word/happy` carries the gloss, rewrite returns text, empty→400, unknown→404, LLM-down→502; new CSS classes present in the built bundle.
- **Known limitation**: `Gloss` tries the literal form first (matching defs/syns ordering), so an inflected word that is *itself* a separate ECDICT headword resolves to that entry rather than de-inflecting (e.g. `rivers` → the proper-noun "Rivers" sense, not `river`). The base form always glosses correctly; acceptable.
### Deferred (post-v1-local)
- [ ] Authentik OIDC auth + session middleware ← **on hold: user doing foundational work first**
- [ ] Copyleaks Tier-2 + webhook HMAC
- [ ] Dockerfile, docker-compose, Traefik, deploy to write.parodia.dev
### Next-up (post-v1 product, agreed with user 2026-06-26)
- [ ] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite.
- [x] **Phase 9 — ESL superpowers**: inline Chinese gloss on hover/select; "say it more naturally" / tone-rewrite. ✅ (see Phase 9 above)
- [ ] **Phase 10 — organization & polish**: cross-doc search, folders/tags, tablet/touch polish, warm LLM-down failure states.
## Session log
- 2026-06-26: **Phase 9 complete** (ESL superpowers: inline Chinese gloss + tone-rewrite). Decisions confirmed with user: gloss is an **offline EC dictionary** (instant, LLM-down-proof, fits the embedded-lexicon ethos), rewrite is a **selection bubble**. Data: `scripts/build_gloss.py` builds `internal/lexicon/data/gloss.json.gz` from ECDICT (66MB csv → 1.3MB gz, 57k freq-≤50k words, cleaned/trimmed). Backend: `lexicon` gloss map + `Gloss()`/`Result.Gloss` + `GET /api/gloss/{word}`; `llm.RunRewrite` + rewrite prompt/`styleGuidance`; `internal/suggestions/rewrite.go` (`POST /api/docs/:id/rewrite`, stateless, owner-scoped). Frontend: `GlossTip` hover tooltip (350ms delay, reuses `wordAt`, CJK-safe) + gloss line in `WordCard`; `SelectionBubble` + `RewritePreview` wired through `EditorCore` (onMouseMove/onSelectionUpdate, request-token guards, clears on edit/doc-switch); `api.glossWord`/`api.rewriteSelection`; CSS for the three new surfaces (+ print-hidden). Tests added in `lexicon` and `suggestions`. All builds/tests/vet/tsc/vite clean; live smoke vs fake vLLM on :8055 verified gloss + rewrite + 400/404/502 paths. Next: **Phase 10 (organization & polish).**
- 2026-06-26: **Phase 8 complete** (Trust foundation: version history + export) + empty-doc fix. Backend: migration `0003_document_versions`; `internal/docs/versions.go` (throttled auto-snapshot wired into `update`, manual snapshot, restore-with-pre_restore, prune to 40 auto, owner-scoped via join) and `internal/docs/export.go` (pure-Go Tiptap-JSON → md/html/txt/docx, no deps, CJK-safe filenames via RFC 5987). `DocumentVersion` model + kind constants. Tests: `versions_test.go`, `export_test.go` (incl. valid-zip docx assertion). Frontend: `api.client` version/export methods; `ExportMenu` + `HistoryPanel` components wired into the title row; `@media print` stylesheet + `.petal-no-print` for the browser PDF path; `editorEpoch` remount on restore. Empty-doc fix in `App.tsx` (blank drafts reuse-on-create + discard-on-leave via refs to dodge stale closures); deleted 2 orphan empties from the live :8099 DB. Multi-session plan agreed: this session = Phase 8; **Phase 9 (ESL gloss + tone-rewrite)** next, then **Phase 10 (search/folders/polish)**; **auth/deploy (was Phase 11) shelved** until user's foundational work lands. All builds/tests/vet clean; live smoke verified the full version+export+restore flow end-to-end. Next: **Phase 9.**
- 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created.
- 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user.

View File

@@ -69,8 +69,12 @@ func main() {
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
api.Mount("/suggestions", sug.Routes())
// Offline word lookups (definition + synonyms) for the right-click popover.
api.Mount("/word", lexicon.NewHandler().Routes())
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
// the right-click popover, and the lightweight Chinese-only gloss for the
// inline hover/select tooltip. One handler so the datasets load once.
lex := lexicon.NewHandler()
api.Mount("/word", lex.Routes())
api.Mount("/gloss", lex.GlossRoutes())
})
// Everything else: serve the embedded SPA (with index.html fallback for client routing).

View File

@@ -1,9 +1,10 @@
// Package lexicon serves offline word lookups — a definition and a list of
// synonyms for a single word — from two public-domain datasets compiled into the
// binary. Definitions come from the Wordset dictionary (modern, concise glosses
// with a part of speech and example, which read kindly for an ESL writer);
// synonyms come from the Moby Thesaurus. Both are gzipped JSON, decompressed
// lazily on first use so a writer who never right-clicks a word pays nothing.
// Package lexicon serves offline word lookups — a Chinese gloss, a definition,
// and a list of synonyms for a single word — from public datasets compiled into
// the binary. Definitions come from the Wordset dictionary (modern, concise
// glosses with a part of speech and example, which read kindly for an ESL
// writer); synonyms come from the Moby Thesaurus; the Chinese gloss comes from
// ECDICT. All are gzipped JSON, decompressed lazily on first use so a writer who
// never looks up a word pays nothing.
package lexicon
import _ "embed"
@@ -19,3 +20,10 @@ var definitionsGz []byte
//
//go:embed data/synonyms.json.gz
var synonymsGz []byte
// glossGz is the gzipped English→Chinese gloss map: word → 中文 gloss, lowercase
// keys. Built from ECDICT (scripts/build_gloss.py), filtered to common words so
// an ESL writer who speaks Mandarin gets an instant translation on hover/lookup.
//
//go:embed data/gloss.json.gz
var glossGz []byte

Binary file not shown.

View File

@@ -25,6 +25,15 @@ func (h *Handler) Routes() chi.Router {
return r
}
// GlossRoutes returns the router mounted at /api/gloss — the lightweight
// Chinese-only lookup behind the inline hover/select gloss. It shares the
// Handler's Lexicon, so the datasets still load just once.
func (h *Handler) GlossRoutes() chi.Router {
r := chi.NewRouter()
r.Get("/{word}", h.gloss)
return r
}
// lookup returns the definition + synonyms for one word. A word found in neither
// dataset still returns 200 with empty lists, so the popover can show a friendly
// "nothing found" rather than an error state.
@@ -48,3 +57,25 @@ func (h *Handler) lookup(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
_ = json.NewEncoder(w).Encode(res)
}
// gloss returns just the Chinese translation for one word. Like lookup, a miss
// is a 200 with an empty gloss so the hover tooltip can quietly skip rather than
// error.
func (h *Handler) gloss(w http.ResponseWriter, r *http.Request) {
word := chi.URLParam(r, "word")
if decoded, err := url.PathUnescape(word); err == nil {
word = decoded
}
res, err := h.Lex.Gloss(word)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=86400")
_ = json.NewEncoder(w).Encode(res)
}

View File

@@ -20,13 +20,24 @@ type Meaning struct {
// Result is the full lookup for one word. Either list may be empty (the word
// isn't a headword in that dataset); the frontend handles a partial or empty
// result gracefully.
// result gracefully. Gloss is the Chinese translation (empty when the word isn't
// in the gloss dataset) — shown first in the popover for the Mandarin-speaking
// writer.
type Result struct {
Word string `json:"word"`
Gloss string `json:"gloss"`
Definitions []Meaning `json:"definitions"`
Synonyms []string `json:"synonyms"`
}
// GlossResult is the lightweight payload for the inline hover/select gloss: just
// the word and its Chinese translation, no definitions or synonyms. Kept small
// so the hover tooltip is instant and trivially cacheable.
type GlossResult struct {
Word string `json:"word"`
Gloss string `json:"gloss"`
}
// maxSynonyms caps how many synonyms we hand the popover, even though the dataset
// stores up to ~50 per word — a long flat wall of words overwhelms more than it
// helps, especially for an ESL reader scanning for the right fit.
@@ -44,6 +55,7 @@ type Lexicon struct {
loadErr error
defs map[string][][]string // word → [[pos, def, example], …]
synonyms map[string][]string // word → [synonym, …]
gloss map[string]string // word → Chinese gloss
}
// New returns a Lexicon. The datasets aren't read until the first Lookup.
@@ -59,6 +71,10 @@ func (l *Lexicon) load() {
l.loadErr = fmt.Errorf("load synonyms: %w", err)
return
}
if err := gunzipJSON(glossGz, &l.gloss); err != nil {
l.loadErr = fmt.Errorf("load gloss: %w", err)
return
}
})
}
@@ -78,6 +94,8 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
return res, nil
}
res.Gloss = lookupGloss(l.gloss, norm)
if raw := lookupDefs(l.defs, norm); raw != nil {
for _, m := range raw {
res.Definitions = append(res.Definitions, toMeaning(m))
@@ -97,6 +115,32 @@ func (l *Lexicon) Lookup(word string) (Result, error) {
return res, nil
}
// Gloss returns just the Chinese translation for word (empty when absent). This
// is the fast path behind the inline hover/select gloss — it skips the
// definition and synonym datasets entirely.
func (l *Lexicon) Gloss(word string) (GlossResult, error) {
l.load()
if l.loadErr != nil {
return GlossResult{}, l.loadErr
}
norm := strings.ToLower(strings.TrimSpace(word))
return GlossResult{Word: word, Gloss: lookupGloss(l.gloss, norm)}, nil
}
// lookupGloss walks the candidate forms of a word and returns the first gloss
// hit (so "running"/"studies" resolve via the same de-inflection as defs/syns).
func lookupGloss(m map[string]string, word string) string {
if word == "" {
return ""
}
for _, c := range candidates(word) {
if v, ok := m[c]; ok {
return v
}
}
return ""
}
// lookupDefs / lookupSyns walk the candidate forms of a word and return the
// first dataset hit. They're separate (rather than a generic helper) only
// because the two maps have different value types.

View File

@@ -51,6 +51,48 @@ func TestLookupUnknownWord(t *testing.T) {
}
}
func TestGloss(t *testing.T) {
l := New()
// A common word carries a Chinese gloss...
res, err := l.Gloss("river")
if err != nil {
t.Fatalf("Gloss: %v", err)
}
if res.Word != "river" {
t.Errorf("Word = %q, want %q", res.Word, "river")
}
if res.Gloss == "" {
t.Errorf("expected a Chinese gloss for %q, got none", "river")
}
// ...and the inflected form resolves via the same de-inflection.
inflected, err := l.Gloss("rivers")
if err != nil {
t.Fatalf("Gloss(rivers): %v", err)
}
if inflected.Gloss == "" {
t.Errorf("expected a gloss for inflected %q, got none", "rivers")
}
// A nonsense word is a clean empty (not an error).
miss, err := l.Gloss("zzzxqqq")
if err != nil {
t.Fatalf("Gloss(miss): %v", err)
}
if miss.Gloss != "" {
t.Errorf("expected empty gloss for nonsense word, got %q", miss.Gloss)
}
}
func TestLookupIncludesGloss(t *testing.T) {
l := New()
res, err := l.Lookup("happy")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if res.Gloss == "" {
t.Errorf("expected Lookup to include a Chinese gloss for %q", "happy")
}
}
func TestCandidates(t *testing.T) {
cases := map[string]string{
"cats": "cat",

View File

@@ -120,3 +120,48 @@ Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encoura
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
}
// rewriteSystemTemplate drives the "say it more naturally" / tone-rewrite tool.
// The writer selects a passage and picks a style; the model rewrites that
// passage in place. The instruction is deliberately strict about returning ONLY
// the rewritten passage so the result can be dropped straight into the editor —
// no quotes, no preamble, no commentary to strip.
const rewriteSystemTemplate = `You are Petal, a warm English writing assistant helping someone who speaks English ` +
`as a second language. Rewrite the passage the user sends so that it %s, while preserving its original ` +
`meaning. Fix any grammar mistakes and awkward phrasing along the way. Keep it about the same length — ` +
`do not add new ideas, explanations, or commentary.
Respond with ONLY the rewritten passage. No quotation marks around it, no preamble, no notes — just the ` +
`rewritten English text, ready to drop back into the document.`
// styleGuidance maps a rewrite style onto the clause describing the target
// register. "natural" is the default "say it more naturally" action; the rest
// mirror the document-tone vocabulary (see toneGuidance / the ToneSelect UI).
// An unknown style falls back to the natural rewrite.
func styleGuidance(style string) string {
switch style {
case "academic":
return "reads as formal, academic English suited to a school essay or research paper"
case "professional":
return "reads as polished, professional English suited to a workplace email or report"
case "casual":
return "sounds relaxed, friendly, and conversational"
case "humorous":
return "has a light, playful, good-humored tone"
case "creative":
return "is vivid, expressive, and imaginative"
case "persuasive":
return "is confident and persuasive"
default: // "natural"
return "sounds natural and fluent, the way a native English speaker would naturally say it"
}
}
// RewriteMessages builds the message array for a tone-rewrite: the styled system
// instruction plus the passage to rewrite as the user turn.
func RewriteMessages(text, style string) []Message {
return []Message{
{Role: "system", Content: fmt.Sprintf(rewriteSystemTemplate, styleGuidance(style))},
{Role: "user", Content: text},
}
}

46
internal/llm/rewrite.go Normal file
View File

@@ -0,0 +1,46 @@
package llm
import (
"context"
"strings"
)
// RewriteMaxRunes caps how much selected text a single rewrite will accept. A
// rewrite is a focused "fix this sentence/paragraph" action, not a whole-doc
// pass — bounding it keeps latency sane and the model on-task. The handler
// rejects longer selections before calling the model.
const RewriteMaxRunes = 2000
// RunRewrite rewrites a selected passage in the requested style (e.g. "natural",
// "academic"). It is a one-shot Complete — the result is shown as a preview the
// writer accepts or discards, so we want the whole rewrite before rendering.
func RunRewrite(ctx context.Context, client LLMClient, text, style string) (string, error) {
out, err := client.Complete(ctx, CompletionRequest{
Messages: RewriteMessages(text, style),
MaxTokens: 1024,
Temperature: 0.7,
TopP: 0.9,
RepetitionPenalty: 1.1,
})
if err != nil {
return "", err
}
return cleanRewrite(out), nil
}
// cleanRewrite trims the model's output down to just the rewritten passage. The
// prompt asks for no quotes or preamble, but small instruct models occasionally
// wrap the answer in matching quotes — strip a single surrounding pair so the
// text drops cleanly into the editor.
func cleanRewrite(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 {
first, last := s[0], s[len(s)-1]
if (first == '"' && last == '"') ||
(first == '\'' && last == '\'') ||
(strings.HasPrefix(s, "“") && strings.HasSuffix(s, "”")) {
s = strings.TrimSpace(strings.Trim(s, "\"'“”"))
}
}
return s
}

View File

@@ -44,6 +44,7 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
func (h *Handler) RegisterDocRoutes(r chi.Router) {
r.Post("/{id}/check", h.check)
r.Post("/{id}/voice", h.voice)
r.Post("/{id}/rewrite", h.rewrite)
r.Get("/{id}/suggestions", h.listForDoc)
}

View File

@@ -0,0 +1,76 @@
package suggestions
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// rewriteRequest is the body the selection bubble posts: the selected passage
// and the target style ("natural", "academic", …). The text is the client's
// live selection — unlike a checkpoint, there is nothing to anchor server-side,
// so the rewrite is stateless and never persisted (the editor applies it
// directly, and the version history captures the resulting document change).
type rewriteRequest struct {
Text string `json:"text"`
Style string `json:"style"`
}
type rewriteResponse struct {
Rewrite string `json:"rewrite"`
}
// rewrite runs a one-shot tone-rewrite over a selected passage and returns the
// rewritten text for the editor to preview. The document id scopes the request
// to the owner (and reserves room to feed document context to the model later),
// even though the passage itself rides in the body.
func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
var body rewriteRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
text := strings.TrimSpace(body.Text)
if text == "" {
errorJSON(w, http.StatusBadRequest, "no text to rewrite")
return
}
if len([]rune(text)) > llm.RewriteMaxRunes {
errorJSON(w, http.StatusBadRequest, "selection too long to rewrite")
return
}
// Scope to the owner so a stray id can't drive rewrites against another
// user's document (and 404 cleanly when it doesn't exist).
var exists int
err := h.DB.QueryRow(
`SELECT 1 FROM documents WHERE id = ? AND user_id = ?`,
docID, db.LocalUserID,
).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "document not found")
return
}
if err != nil {
serverError(w, err)
return
}
out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style)
if err != nil {
errorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, rewriteResponse{Rewrite: out})
}

View File

@@ -0,0 +1,79 @@
package suggestions
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// recordingClient captures the last Complete request so the rewrite test can
// assert the styled system prompt and the selected passage reached the model.
type recordingClient struct {
response string
last llm.CompletionRequest
}
func (c *recordingClient) Complete(_ context.Context, req llm.CompletionRequest) (string, error) {
c.last = req
return c.response, nil
}
func (c *recordingClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan string, error) {
ch := make(chan string)
close(ch)
return ch, nil
}
func TestRewrite(t *testing.T) {
// The model wraps its answer in quotes; cleanRewrite should strip them.
client := &recordingClient{response: `"I have two apples."`}
srv, docID, _ := newTestServer(t, client)
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/rewrite",
`{"text":"I has two apple.","style":"academic"}`)
if rec.Code != http.StatusOK {
t.Fatalf("rewrite: code=%d body=%s", rec.Code, rec.Body)
}
var resp rewriteResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Rewrite != "I have two apples." {
t.Fatalf("rewrite = %q, want the de-quoted text", resp.Rewrite)
}
// The passage rode in as the user turn, and the style steered the system turn.
msgs := client.last.Messages
if len(msgs) != 2 || msgs[0].Role != "system" || msgs[1].Role != "user" {
t.Fatalf("unexpected message shape: %+v", msgs)
}
if msgs[1].Content != "I has two apple." {
t.Fatalf("passage not forwarded: %q", msgs[1].Content)
}
if !strings.Contains(msgs[0].Content, "academic") {
t.Fatalf("system prompt missing academic style guidance:\n%s", msgs[0].Content)
}
}
func TestRewriteEmptyText(t *testing.T) {
client := &recordingClient{response: "anything"}
srv, docID, _ := newTestServer(t, client)
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/rewrite", `{"text":" ","style":"natural"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("empty text: want 400, got %d", rec.Code)
}
}
func TestRewriteUnknownDoc(t *testing.T) {
client := &recordingClient{response: "anything"}
srv, _, _ := newTestServer(t, client)
rec := do(t, srv, http.MethodPost, "/docs/does-not-exist/rewrite",
`{"text":"hello there","style":"natural"}`)
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown doc: want 404, got %d", rec.Code)
}
}

106
scripts/build_gloss.py Normal file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Build the embedded English->Chinese gloss dataset from ECDICT.
Petal shows an instant Chinese gloss when the writer (an English-as-a-second-
language user whose first language is Mandarin) hovers or right-clicks a word.
The gloss is served offline from a small map compiled into the Go binary, the
same way the English definitions/synonyms are (see internal/lexicon).
Source: ECDICT (https://github.com/skywind3000/ECDICT), MIT-licensed. We keep
only common single words that carry a Chinese translation, trim each gloss to a
couple of senses, and drop the noisy "[网络]" (internet-slang) lines — the result
gzips to a few MB, in line with the other lexicon assets.
Usage:
curl -sL https://raw.githubusercontent.com/skywind3000/ECDICT/master/ecdict.csv -o ecdict.csv
python3 scripts/build_gloss.py ecdict.csv internal/lexicon/data/gloss.json.gz
"""
import csv
import gzip
import json
import re
import sys
# Frequency gate: keep a word only if ECDICT ranks it in the BNC or COCA
# frequency lists (frq/bnc > 0). That bounds the asset to the words an ESL
# writer actually meets, dropping the long tail of archaic/technical headwords.
# Words below this rank but present nowhere in the frequency lists are skipped.
MAX_RANK = 50000
# A gloss is at most this many sense-lines and characters, so the hover bubble
# stays a glance, not a wall of text.
MAX_SENSES = 3
MAX_CHARS = 80
# Single English word: letters, with internal apostrophe/hyphen (so "don't",
# "well-being" survive but multi-word phrases and codes are dropped).
WORD_RE = re.compile(r"^[a-z][a-z'\-]*[a-z]$|^[a-z]$")
# Tag lines we drop from a translation: "[网络]" is crowd-sourced internet slang,
# "[俚]" slang, "[古]" archaic — none help an ESL writer pick everyday meaning.
DROP_TAG_RE = re.compile(r"^\[(网络|俚|古|罕|废)\]")
def clean_gloss(translation: str) -> str:
"""Trim an ECDICT translation to a compact, glanceable Chinese gloss."""
# ECDICT separates senses with a literal backslash-n; normalize to real
# newlines (and tolerate genuine newlines) before splitting.
translation = translation.replace("\\n", "\n")
senses = []
for line in translation.split("\n"):
line = line.strip()
if not line or DROP_TAG_RE.match(line):
continue
senses.append(line)
if len(senses) >= MAX_SENSES:
break
gloss = "".join(senses)
if len(gloss) > MAX_CHARS:
gloss = gloss[:MAX_CHARS].rstrip(";, ") + ""
return gloss
def rank(row: dict) -> int:
"""Best (lowest, non-zero) frequency rank across COCA and BNC."""
ranks = []
for key in ("frq", "bnc"):
try:
v = int(row.get(key) or 0)
except ValueError:
v = 0
if v > 0:
ranks.append(v)
return min(ranks) if ranks else 0
def main(src: str, dst: str) -> None:
out: dict[str, str] = {}
kept_rank: dict[str, int] = {}
with open(src, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
word = (row.get("word") or "").strip().lower()
translation = (row.get("translation") or "").strip()
if not word or not translation or not WORD_RE.match(word):
continue
r = rank(row)
if r == 0 or r > MAX_RANK:
continue
gloss = clean_gloss(translation)
if not gloss:
continue
# On a duplicate headword keep the more frequent (lower-rank) entry.
if word in kept_rank and kept_rank[word] <= r:
continue
out[word] = gloss
kept_rank[word] = r
payload = json.dumps(out, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
with gzip.open(dst, "wb", compresslevel=9) as gz:
gz.write(payload)
print(f"{len(out)} words -> {dst} ({len(payload)} bytes json)")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit("usage: build_gloss.py <ecdict.csv> <out.json.gz>")
main(sys.argv[1], sys.argv[2])

View File

@@ -37,14 +37,21 @@ export interface WordMeaning {
example?: string
}
// The offline lookup for one word: a few definition senses and a list of
// synonyms. Either list may be empty when the word isn't a headword.
// The offline lookup for one word: a Chinese gloss, a few definition senses, and
// a list of synonyms. Any of these may be empty when the word isn't a headword.
export interface WordInfo {
word: string
gloss: string // Chinese translation; '' when the word isn't in the gloss set
definitions: WordMeaning[]
synonyms: string[]
}
// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
export interface Gloss {
word: string
gloss: string
}
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
// A point-in-time snapshot of a document. List responses omit the heavy
@@ -134,8 +141,19 @@ export const api = {
exportUrl: (id: string, format: ExportFormat) =>
`/api/docs/${id}/export?format=${format}`,
// Offline word lookup (definition + synonyms) for the right-click popover.
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
// Lightweight Chinese-only gloss for the inline hover/select tooltip — instant
// and offline, so it fires on hover without spinning up the heavier lookup.
glossWord: (word: string) => req<Gloss>(`/gloss/${encodeURIComponent(word)}`),
// Tone-rewrite: rewrites a selected passage in the given style ('natural',
// 'academic', …) and returns the rewritten text for an in-editor preview. Not
// persisted — the editor applies it directly on accept.
rewriteSelection: (docId: string, text: string, style: string) =>
req<{ rewrite: string }>(`/docs/${docId}/rewrite`, {
method: 'POST',
body: JSON.stringify({ text, style }),
}),
// Current deployed build id — changes whenever a new frontend ships. The
// app polls this to offer a refresh. Bypasses any cache so the answer is live.

View File

@@ -11,6 +11,9 @@ import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHigh
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
import { MisspellCard } from './MisspellCard'
import { WordCard } from './WordCard'
import { GlossTip } from './GlossTip'
import { SelectionBubble } from './SelectionBubble'
import { RewritePreview, type RewriteStatus } from './RewritePreview'
import { api, type Suggestion, type WordInfo } from '../../api/client'
import type { SpellChecker } from '../../hooks/useSpellChecker'
@@ -106,6 +109,39 @@ interface HoverState {
left: number
}
// The inline hover gloss: the word under the resting pointer, its Chinese
// translation, the PM range it covers (to dedupe re-fetches), and where to anchor.
interface GlossState {
word: string
gloss: string
from: number
to: number
top: number
left: number
}
// A non-empty text selection that the rewrite bubble hovers over.
interface SelectionState {
from: number
to: number
text: string
top: number
left: number
}
// An in-flight or completed tone-rewrite preview, pinned over the selection it
// came from. `from`/`to` are the PM range the accepted rewrite replaces.
interface RewriteState {
from: number
to: number
original: string
style: string
top: number
left: number
status: RewriteStatus
rewrite: string
}
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
// alignment, a placeholder, character counting, and the suggestion-highlight
// decoration layer. Hovering a highlight opens its SuggestionCard.
@@ -138,6 +174,15 @@ export function EditorCore({
// While the Ask Petal panel is expanded the card is pinned: the hover-close
// timer is suppressed so chatting doesn't dismiss it. A click outside closes.
const [pinned, setPinned] = useState(false)
// Inline hover gloss (rest the pointer on a word → its Chinese meaning).
const [gloss, setGloss] = useState<GlossState | null>(null)
const glossTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const glossReqRef = useRef(0)
// The rewrite affordances: a bubble over the current selection, and the
// preview that replaces it once a style is chosen.
const [selection, setSelection] = useState<SelectionState | null>(null)
const [rewrite, setRewrite] = useState<RewriteState | null>(null)
const rewriteReqRef = useRef(0)
const editor = useEditor({
extensions: [
@@ -158,12 +203,37 @@ export function EditorCore({
// Any edit shifts positions, stranding the popover anchors.
setMisspell(null)
setWordInfo(null)
setGloss(null)
setSelection(null)
// A stale rewrite preview points at a range that just moved — drop it.
rewriteReqRef.current++
setRewrite(null)
onChange({
content: JSON.stringify(editor.getJSON()),
content_text: editor.getText(),
word_count: editor.storage.characterCount.words(),
})
},
onSelectionUpdate: ({ editor }) => {
// A selection supersedes the hover gloss; an empty one clears the bubble.
setGloss(null)
const { from, to, empty } = editor.state.selection
const wrapper = wrapperRef.current
if (empty || !wrapper) {
setSelection(null)
return
}
const text = editor.state.doc.textBetween(from, to, ' ').trim()
if (!text) {
setSelection(null)
return
}
const start = editor.view.coordsAtPos(from)
const wrapRect = wrapper.getBoundingClientRect()
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 360))
const top = start.top - wrapRect.top
setSelection({ from, to, text, top, left })
},
})
// When the selected document changes, swap in its content without emitting an
@@ -174,6 +244,10 @@ export function EditorCore({
setHover(null)
setMisspell(null)
setWordInfo(null)
setGloss(null)
setSelection(null)
rewriteReqRef.current++
setRewrite(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, editor])
@@ -381,6 +455,143 @@ export function EditorCore({
[editor, wordInfo],
)
// Hover gloss: rest the pointer on an English word and, after a short delay,
// show its Chinese meaning from the offline gloss dataset. Suppressed while a
// selection, preview, or another popover is active, or over a word that has its
// own affordance (a suggestion highlight / a misspelling).
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!editor) return
if (selection || rewrite || misspell || wordInfo || pinned) return
if (!editor.state.selection.empty) return
const t = e.target as HTMLElement
const clear = () => {
clearTimeout(glossTimer.current)
glossReqRef.current++
setGloss(null)
}
if (t.closest('.petal-suggestion') || t.closest('.petal-misspelling')) {
clear()
return
}
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) {
clear()
return
}
const range = wordAt(editor.state.doc, coords.pos)
if (!range) {
clear()
return
}
// Already showing this exact word — leave it be (no flicker on micro-moves).
if (gloss && gloss.from === range.from && gloss.to === range.to) return
clearTimeout(glossTimer.current)
const token = ++glossReqRef.current
glossTimer.current = setTimeout(() => {
api
.glossWord(range.word)
.then((g) => {
if (token !== glossReqRef.current) return
const wrapper = wrapperRef.current
if (!g.gloss || !wrapper) {
setGloss(null)
return
}
const start = editor.view.coordsAtPos(range.from)
const end = editor.view.coordsAtPos(range.to)
const wrapRect = wrapper.getBoundingClientRect()
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280))
const top = end.bottom - wrapRect.top + 6
setGloss({ word: range.word, gloss: g.gloss, from: range.from, to: range.to, top, left })
})
.catch(() => {
if (token === glossReqRef.current) setGloss(null)
})
}, 350)
},
[editor, selection, rewrite, misspell, wordInfo, pinned, gloss],
)
// Leaving the editor surface drops any pending/shown gloss.
const handleMouseLeave = useCallback(() => {
clearTimeout(glossTimer.current)
glossReqRef.current++
setGloss(null)
}, [])
// runRewrite fires the LLM rewrite for a captured range + style and tracks it
// through loading → ready/error. A request token discards a response whose
// preview has since been cancelled or superseded.
const runRewrite = useCallback(
(from: number, to: number, original: string, style: string, top: number, left: number) => {
const token = ++rewriteReqRef.current
setRewrite({ from, to, original, style, top, left, status: 'loading', rewrite: '' })
api
.rewriteSelection(docId, original, style)
.then((res) => {
if (token === rewriteReqRef.current) {
setRewrite((r) => (r ? { ...r, status: 'ready', rewrite: res.rewrite } : null))
}
})
.catch((err) => {
console.error('rewrite failed', err)
if (token === rewriteReqRef.current) {
setRewrite((r) => (r ? { ...r, status: 'error' } : null))
}
})
},
[docId],
)
// Picking a style in the selection bubble: capture the selection's range +
// text, anchor a preview below it, and kick off the rewrite.
const handleRewrite = useCallback(
(style: string) => {
if (!editor || !selection) return
const wrapper = wrapperRef.current
if (!wrapper) return
const start = editor.view.coordsAtPos(selection.from)
const end = editor.view.coordsAtPos(selection.to)
const wrapRect = wrapper.getBoundingClientRect()
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 340))
const top = end.bottom - wrapRect.top + 6
setSelection(null)
setGloss(null)
runRewrite(selection.from, selection.to, selection.text, style, top, left)
},
[editor, selection, runRewrite],
)
const acceptRewrite = useCallback(() => {
if (editor && rewrite && rewrite.rewrite) {
editor.chain().focus().insertContentAt({ from: rewrite.from, to: rewrite.to }, rewrite.rewrite).run()
}
rewriteReqRef.current++
setRewrite(null)
}, [editor, rewrite])
const cancelRewrite = useCallback(() => {
rewriteReqRef.current++
setRewrite(null)
}, [])
const retryRewrite = useCallback(() => {
if (rewrite) runRewrite(rewrite.from, rewrite.to, rewrite.original, rewrite.style, rewrite.top, rewrite.left)
}, [rewrite, runRewrite])
// A pointer-down outside the rewrite preview dismisses it.
useEffect(() => {
if (!rewrite) return
const onDown = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('.petal-rewrite-card')) return
rewriteReqRef.current++
setRewrite(null)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [rewrite])
// A pointer-down outside the word popover (and not on another word, which would
// reopen it via the context menu) closes it.
useEffect(() => {
@@ -409,6 +620,7 @@ export function EditorCore({
useEffect(() => () => {
clearTimeout(closeTimer.current)
clearTimeout(confettiTimer.current)
clearTimeout(glossTimer.current)
}, [])
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
@@ -430,11 +642,32 @@ export function EditorCore({
className="relative flex-1"
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleSpellClick}
onContextMenu={handleContextMenu}
>
<EditorContent editor={editor} className="h-full" />
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
{gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
{selection && !rewrite && (
<SelectionBubble
style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
onRewrite={handleRewrite}
/>
)}
{rewrite && (
<RewritePreview
style={rewrite.style}
status={rewrite.status}
original={rewrite.original}
rewrite={rewrite.rewrite}
cardStyle={{ top: rewrite.top, left: rewrite.left }}
onAccept={acceptRewrite}
onCancel={cancelRewrite}
onRetry={retryRewrite}
/>
)}
{wordInfo && (
<WordCard
word={wordInfo.word}

View File

@@ -0,0 +1,32 @@
// GlossTip is the inline hover gloss: rest the pointer on an English word and a
// small bubble shows its Chinese meaning, pulled instantly from the offline
// gloss dataset. It's a pure reading aid — pointer-events are off so it never
// steals hover or blocks a click, and it sits just under the word. Bilingual
// chrome elsewhere puts zh first; here the gloss IS the content (the word is
// already on screen), so the bubble shows only the 中文.
interface Props {
gloss: string
style: React.CSSProperties
}
export function GlossTip({ gloss, style }: Props) {
return (
<div
className="petal-gloss-tip pointer-events-none absolute z-20 px-2.5 py-1.5 text-sm"
role="tooltip"
style={{
maxWidth: 280,
background: 'var(--color-plum)',
color: 'var(--color-surface)',
borderRadius: 'var(--radius-input)',
boxShadow: 'var(--shadow-soft)',
lineHeight: 1.35,
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
...style,
}}
>
{gloss}
</div>
)
}

View File

@@ -0,0 +1,136 @@
import { REWRITE_STYLES } from './SelectionBubble'
// RewritePreview shows the result of a tone-rewrite before it touches the
// document: the writer's original passage, the model's rewrite beneath it, and
// accept/cancel. It mirrors the SuggestionCard's warm, bilingual chrome. While
// the model runs it shows a breathing dot; on failure it offers a gentle retry.
export type RewriteStatus = 'loading' | 'ready' | 'error'
interface Props {
style: string // the chosen rewrite style key (for the header label)
status: RewriteStatus
original: string
rewrite: string
cardStyle: React.CSSProperties
onAccept: () => void
onCancel: () => void
onRetry: () => void
}
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
export function RewritePreview({
style,
status,
original,
rewrite,
cardStyle,
onAccept,
onCancel,
onRetry,
}: Props) {
const meta = REWRITE_STYLES.find((s) => s.value === style) ?? REWRITE_STYLES[0]
return (
<div
className="petal-rewrite-card absolute z-30 p-3.5 text-sm"
role="dialog"
aria-label="Rewrite preview"
onMouseDown={(e) => e.stopPropagation()}
style={{
width: 340,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
fontFamily: CJK,
...cardStyle,
}}
>
<div className="flex items-center gap-1.5">
<span
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
<span aria-hidden>{meta.emoji}</span>
{meta.zh} · {meta.en}
</span>
<span className="text-xs font-semibold" style={{ color: 'var(--color-muted)' }}>
· Rewrite
</span>
</div>
{/* Original passage, dimmed — what's being replaced. */}
<p
className="mt-3 leading-snug"
style={{ color: 'var(--color-muted)', textDecoration: 'line-through', textDecorationColor: 'var(--color-border)' }}
>
{original}
</p>
{status === 'loading' && (
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
<span
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: 'var(--color-accent)' }}
aria-hidden
/>
· Rewriting
</div>
)}
{status === 'error' && (
<div className="mt-3">
<p className="leading-snug" style={{ color: 'var(--color-muted)' }}>
· Couldnt rewrite try again
</p>
<div className="mt-2.5 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-full px-3 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
· Cancel
</button>
<button
type="button"
onClick={onRetry}
className="rounded-full px-3 py-1 text-xs font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
· Retry
</button>
</div>
</div>
)}
{status === 'ready' && (
<>
<p className="mt-2 leading-snug" style={{ color: 'var(--color-plum)' }}>
{rewrite}
</p>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-full px-3 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
· Cancel
</button>
<button
type="button"
onClick={onAccept}
className="rounded-full px-3 py-1 text-xs font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
· Use this
</button>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,85 @@
// SelectionBubble floats above a text selection and offers to rewrite it: a
// prominent "✨ 更自然 Say it naturally" action plus the tone vocabulary (学术,
// 轻松, …) mirrored from the document-tone picker. Picking one hands the style up
// to EditorCore, which calls the LLM and shows a preview. Buttons use
// onMouseDown→preventDefault so clicking them doesn't collapse the selection
// before the handler captures its range.
export interface RewriteStyle {
value: string
emoji: string
zh: string
en: string
}
// 'natural' is the default "say it more naturally" rewrite; the rest mirror the
// llm styleGuidance keys (and the ToneSelect labels) so the two stay in step.
export const REWRITE_STYLES: RewriteStyle[] = [
{ value: 'natural', emoji: '✨', zh: '更自然', en: 'Natural' },
{ value: 'academic', emoji: '🎓', zh: '学术', en: 'Academic' },
{ value: 'professional', emoji: '💼', zh: '专业', en: 'Professional' },
{ value: 'casual', emoji: '☕', zh: '轻松', en: 'Casual' },
{ value: 'humorous', emoji: '😄', zh: '幽默', en: 'Humorous' },
{ value: 'creative', emoji: '🎨', zh: '创意', en: 'Creative' },
{ value: 'persuasive', emoji: '📣', zh: '说服', en: 'Persuasive' },
]
interface Props {
style: React.CSSProperties
onRewrite: (style: string) => void
}
const CJK = "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif"
export function SelectionBubble({ style, onRewrite }: Props) {
const [natural, ...tones] = REWRITE_STYLES
return (
<div
className="petal-selection-bubble absolute z-30 flex max-w-[360px] flex-wrap items-center gap-1.5 p-2"
role="toolbar"
aria-label="Rewrite the selection"
onMouseDown={(e) => e.preventDefault()} // keep the editor selection
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-pill)',
boxShadow: 'var(--shadow-soft)',
fontFamily: CJK,
...style,
}}
>
<button
type="button"
onClick={() => onRewrite(natural.value)}
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)', color: 'var(--color-plum)' }}
title="Rewrite the selection to sound more natural"
>
<span aria-hidden>{natural.emoji}</span>
<span>{natural.zh}</span>
<span className="font-semibold" style={{ color: 'var(--color-plum)', opacity: 0.7 }}>
{natural.en}
</span>
</button>
<span className="mx-0.5 h-5 w-px shrink-0" style={{ background: 'var(--color-border)' }} />
{tones.map((t) => (
<button
key={t.value}
type="button"
onClick={() => onRewrite(t.value)}
className="inline-flex h-8 items-center gap-1 whitespace-nowrap px-2.5 text-sm font-semibold"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-lavender)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
title={`Rewrite in a ${t.en.toLowerCase()} tone`}
>
<span aria-hidden>{t.emoji}</span>
<span>{t.zh}</span>
</button>
))}
</div>
)
}

View File

@@ -17,7 +17,8 @@ interface Props {
export function WordCard({ word, info, loading, style, onReplace }: Props) {
const definitions = info?.definitions ?? []
const synonyms = info?.synonyms ?? []
const empty = !loading && definitions.length === 0 && synonyms.length === 0
const gloss = info?.gloss ?? ''
const empty = !loading && !gloss && definitions.length === 0 && synonyms.length === 0
return (
<div
@@ -47,6 +48,19 @@ export function WordCard({ word, info, loading, style, onReplace }: Props) {
</span>
</div>
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
{gloss && (
<p
className="mt-2.5 leading-snug"
style={{
color: 'var(--color-plum)',
fontFamily: "'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif",
}}
>
{gloss}
</p>
)}
{loading && (
<div className="mt-3 inline-flex items-center gap-1.5" style={{ color: 'var(--color-muted)' }}>
<span

View File

@@ -163,6 +163,18 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both;
}
/* --- ESL superpowers (Phase 9) ----------------------------------------------
Inline Chinese gloss on hover (a small dark tooltip under the word), and the
selection rewrite bubble + its preview card. All share the soft pop-in. The
gloss tip fades in a touch faster — it's a fleeting reading aid, not a panel. */
.petal-gloss-tip {
animation: petal-suggestion-in 140ms ease both;
}
.petal-selection-bubble,
.petal-rewrite-card {
animation: petal-suggestion-in 200ms ease both;
}
/* --- Accept confetti --------------------------------------------------------
A tiny CSS-only burst played where a suggestion is accepted: four colored
dots spray up-and-out, then fade. No JS animation — each dot reads its
@@ -279,6 +291,9 @@ button, a, input {
.petal-sidebar,
.petal-suggestion-card,
.petal-misspell-card,
.petal-gloss-tip,
.petal-selection-bubble,
.petal-rewrite-card,
.petal-confetti,
.petal-companion {
display: none !important;