Merge: editor tone, word lookup, expanded stats

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 23:23:34 -07:00
23 changed files with 1021 additions and 32 deletions

View File

@@ -15,6 +15,7 @@ import (
"gitea.parodia.dev/drwily/petal/internal/config"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/docs"
"gitea.parodia.dev/drwily/petal/internal/lexicon"
"gitea.parodia.dev/drwily/petal/internal/llm"
"gitea.parodia.dev/drwily/petal/internal/suggestions"
"gitea.parodia.dev/drwily/petal/web"
@@ -67,6 +68,9 @@ 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())
})
// Everything else: serve the embedded SPA (with index.html fallback for client routing).

View File

@@ -169,6 +169,13 @@ CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
`,
},
{
// Per-document tone: guides the grammar-checkpoint LLM so advice fits
// the writer's target register (academic essay vs casual journal).
// 'general' means no specific tone steering.
name: "0002_document_tone",
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
},
}
}

View File

@@ -21,6 +21,7 @@ type Document struct {
Title string `json:"title"`
Content string `json:"content"` // Tiptap JSON
ContentText string `json:"content_text"` // plain text for the LLM
Tone string `json:"tone"` // target writing tone; steers LLM advice
WordCount int `json:"word_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`

View File

@@ -81,11 +81,11 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var doc db.Document
err := h.DB.QueryRow(
`INSERT INTO documents (user_id) VALUES (?)
RETURNING id, user_id, title, content, content_text, word_count, created_at, updated_at`,
RETURNING id, user_id, title, content, content_text, tone, word_count, created_at, updated_at`,
db.LocalUserID,
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
)
if err != nil {
serverError(w, err)
@@ -115,6 +115,7 @@ type updateRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
ContentText *string `json:"content_text"`
Tone *string `json:"tone"`
WordCount *int `json:"word_count"`
}
@@ -134,10 +135,11 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
SET title = COALESCE(?, title),
content = COALESCE(?, content),
content_text = COALESCE(?, content_text),
tone = COALESCE(?, tone),
word_count = COALESCE(?, word_count),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`,
req.Title, req.Content, req.ContentText, req.WordCount, id, db.LocalUserID,
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
)
if err != nil {
serverError(w, err)
@@ -177,13 +179,13 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
func (h *Handler) fetch(id string) (db.Document, error) {
var doc db.Document
err := h.DB.QueryRow(
`SELECT id, user_id, title, content, content_text, word_count, created_at, updated_at
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
FROM documents
WHERE id = ? AND user_id = ?`,
id, db.LocalUserID,
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
)
return doc, err
}

21
internal/lexicon/data.go Normal file
View File

@@ -0,0 +1,21 @@
// 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
import _ "embed"
// definitionsGz is the gzipped Wordset definitions map: word → [[pos, def,
// example], …], lowercase keys. Built by the data-prep step (see commit notes).
//
//go:embed data/definitions.json.gz
var definitionsGz []byte
// synonymsGz is the gzipped Moby thesaurus map: headword → [synonym, …],
// lowercase keys, capped per word to keep the popover (and the binary) small.
//
//go:embed data/synonyms.json.gz
var synonymsGz []byte

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,50 @@
package lexicon
import (
"encoding/json"
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
)
// Handler serves the word-lookup endpoint backed by a single shared Lexicon.
type Handler struct {
Lex *Lexicon
}
// New constructs a Handler with a fresh (lazily-loaded) Lexicon.
func NewHandler() *Handler { return &Handler{Lex: New()} }
// Routes returns the router mounted at /api/word. The word is a path segment so
// "/api/word/happy" reads naturally; it's URL-decoded to tolerate the rare
// punctuated token.
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/{word}", h.lookup)
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.
func (h *Handler) lookup(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.Lookup(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")
// Word lookups are static for the life of the build; let the browser cache
// them so repeated right-clicks on the same word are instant.
w.Header().Set("Cache-Control", "public, max-age=86400")
_ = json.NewEncoder(w).Encode(res)
}

205
internal/lexicon/lexicon.go Normal file
View File

@@ -0,0 +1,205 @@
package lexicon
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"strings"
"sync"
)
// Meaning is one sense of a word: its part of speech, the gloss, and an optional
// usage example. The frontend renders a few of these in the word popover.
type Meaning struct {
PartOfSpeech string `json:"part_of_speech"`
Definition string `json:"definition"`
Example string `json:"example,omitempty"`
}
// 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.
type Result struct {
Word string `json:"word"`
Definitions []Meaning `json:"definitions"`
Synonyms []string `json:"synonyms"`
}
// 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.
const maxSynonyms = 16
// maxDefinitions caps the senses shown so the popover stays a glance, not an
// essay.
const maxDefinitions = 4
// Lexicon holds the lazily-loaded datasets. The maps are populated once, on the
// first Lookup, behind a sync.Once so startup stays instant and a load error is
// remembered rather than retried on every request.
type Lexicon struct {
once sync.Once
loadErr error
defs map[string][][]string // word → [[pos, def, example], …]
synonyms map[string][]string // word → [synonym, …]
}
// New returns a Lexicon. The datasets aren't read until the first Lookup.
func New() *Lexicon { return &Lexicon{} }
func (l *Lexicon) load() {
l.once.Do(func() {
if err := gunzipJSON(definitionsGz, &l.defs); err != nil {
l.loadErr = fmt.Errorf("load definitions: %w", err)
return
}
if err := gunzipJSON(synonymsGz, &l.synonyms); err != nil {
l.loadErr = fmt.Errorf("load synonyms: %w", err)
return
}
})
}
// Lookup returns the definition senses and synonyms for word. It first tries the
// word as written (lowercased), then a few simple morphological reductions
// (plurals, -ed/-ing/-ly) so "running" or "happily" still resolve. A word found
// in neither dataset yields a Result with empty lists (not an error).
func (l *Lexicon) Lookup(word string) (Result, error) {
l.load()
if l.loadErr != nil {
return Result{}, l.loadErr
}
norm := strings.ToLower(strings.TrimSpace(word))
res := Result{Word: word, Definitions: []Meaning{}, Synonyms: []string{}}
if norm == "" {
return res, nil
}
if raw := lookupDefs(l.defs, norm); raw != nil {
for _, m := range raw {
res.Definitions = append(res.Definitions, toMeaning(m))
if len(res.Definitions) >= maxDefinitions {
break
}
}
}
if syns := lookupSyns(l.synonyms, norm); syns != nil {
if len(syns) > maxSynonyms {
syns = syns[:maxSynonyms]
}
res.Synonyms = append(res.Synonyms, syns...)
}
return res, nil
}
// 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.
func lookupDefs(m map[string][][]string, word string) [][]string {
for _, c := range candidates(word) {
if v, ok := m[c]; ok {
return v
}
}
return nil
}
func lookupSyns(m map[string][]string, word string) []string {
for _, c := range candidates(word) {
if v, ok := m[c]; ok {
return v
}
}
return nil
}
// candidates returns the lemma forms to try, in priority order: the word itself,
// then conservative de-inflections. This is deliberately lightweight — a full
// stemmer would over-reduce ("business" → "busy") and surface wrong entries; a
// handful of common English suffix rules covers the everyday cases without a
// dependency. Duplicates are fine (map lookup is cheap); order is what matters.
func candidates(word string) []string {
out := []string{word}
add := func(s string) {
if len(s) >= 2 && s != word {
out = append(out, s)
}
}
switch {
case strings.HasSuffix(word, "ies"): // studies → study
add(word[:len(word)-3] + "y")
case strings.HasSuffix(word, "es"): // boxes → box, wishes → wish
add(word[:len(word)-2])
add(word[:len(word)-1])
case strings.HasSuffix(word, "s"): // cats → cat
add(word[:len(word)-1])
}
if strings.HasSuffix(word, "ing") { // running → run, making → make
stem := word[:len(word)-3]
add(stem)
add(stem + "e")
add(undouble(stem))
}
if strings.HasSuffix(word, "ed") { // hoped → hope, stopped → stop
stem := word[:len(word)-2]
add(stem)
add(word[:len(word)-1])
add(undouble(stem))
}
if strings.HasSuffix(word, "ly") { // happily handled above via ies path? no — quickly → quick
add(word[:len(word)-2])
}
if strings.HasSuffix(word, "ily") { // happily → happy
add(word[:len(word)-3] + "y")
}
return out
}
// undouble collapses a doubled final consonant (stopp → stop, runn → run) so the
// -ed/-ing stems of doubled-consonant verbs resolve to their base form.
func undouble(stem string) string {
n := len(stem)
if n >= 2 && stem[n-1] == stem[n-2] {
return stem[:n-1]
}
return stem
}
// toMeaning maps a compact [pos, def, example] triple from the dataset onto the
// JSON-friendly Meaning. The dataset always stores three elements, but we guard
// the length so a malformed row can't panic.
func toMeaning(m []string) Meaning {
var out Meaning
if len(m) > 0 {
out.PartOfSpeech = m[0]
}
if len(m) > 1 {
out.Definition = m[1]
}
if len(m) > 2 {
out.Example = m[2]
}
return out
}
// gunzipJSON decompresses gz and decodes the JSON into v.
func gunzipJSON(gz []byte, v any) error {
r, err := gzip.NewReader(bytes.NewReader(gz))
if err != nil {
return err
}
defer r.Close()
data, err := io.ReadAll(r)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}

View File

@@ -0,0 +1,76 @@
package lexicon
import "testing"
func TestLookupKnownWord(t *testing.T) {
l := New()
res, err := l.Lookup("happy")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if len(res.Definitions) == 0 {
t.Errorf("expected definitions for %q, got none", "happy")
}
if len(res.Synonyms) == 0 {
t.Errorf("expected synonyms for %q, got none", "happy")
}
if len(res.Synonyms) > maxSynonyms {
t.Errorf("synonyms not capped: got %d, want <= %d", len(res.Synonyms), maxSynonyms)
}
if len(res.Definitions) > maxDefinitions {
t.Errorf("definitions not capped: got %d, want <= %d", len(res.Definitions), maxDefinitions)
}
}
func TestLookupMorphology(t *testing.T) {
l := New()
// Inflected forms should resolve to their base entry via candidates().
for _, w := range []string{"running", "studies", "boxes", "quickly", "stopped"} {
res, err := l.Lookup(w)
if err != nil {
t.Fatalf("Lookup(%q): %v", w, err)
}
if len(res.Definitions) == 0 && len(res.Synonyms) == 0 {
t.Errorf("expected some result for inflected %q, got nothing", w)
}
}
}
func TestLookupUnknownWord(t *testing.T) {
l := New()
res, err := l.Lookup("zzzxqqq")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if len(res.Definitions) != 0 || len(res.Synonyms) != 0 {
t.Errorf("expected empty result for nonsense word, got %+v", res)
}
// Empty result must still serialize as [] not null for the frontend.
if res.Definitions == nil || res.Synonyms == nil {
t.Errorf("empty slices must be non-nil for JSON []: %+v", res)
}
}
func TestCandidates(t *testing.T) {
cases := map[string]string{
"cats": "cat",
"studies": "study",
"running": "run",
"hoped": "hope",
"quickly": "quick",
"happily": "happy",
}
for word, want := range cases {
got := candidates(word)
found := false
for _, c := range got {
if c == want {
found = true
break
}
}
if !found {
t.Errorf("candidates(%q) = %v, missing expected base %q", word, got, want)
}
}
}

View File

@@ -31,9 +31,9 @@ type checkpointResponse struct {
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
// applies the latency-guard truncation and the checkpoint sampling parameters
// from the spec.
func RunCheckpoint(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{
Messages: CheckpointMessages(TruncateDoc(contentText)),
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
MaxTokens: 1024,
Temperature: 0.3,
RepetitionPenalty: 1.15,

View File

@@ -8,7 +8,7 @@ const checkpointSystemPrompt = `You are a warm, encouraging writing assistant he
`Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing, ` +
`incorrect idiom usage, or unclear sentences that are common ESL patterns.
Be specific, friendly, and explain WHY each suggestion improves the writing.
Be specific, friendly, and explain WHY each suggestion improves the writing.%s
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
@@ -24,11 +24,32 @@ Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
If the writing looks good, return: {"suggestions": []}`
// toneGuidance returns a sentence steering the checkpoint toward the writer's
// chosen tone, or "" for the neutral default. The clause is appended to the
// checkpoint instructions so the model's phrasing suggestions fit the target
// register (e.g. an academic essay vs a casual journal). Unknown values fall
// back to no steering, so a stray tone string is harmless.
func toneGuidance(tone string) string {
clause, ok := map[string]string{
"academic": "formal, academic, and objective — suited to a school essay or research paper",
"professional": "polished and professional — suited to a workplace email or report",
"casual": "relaxed, friendly, and conversational",
"humorous": "light, playful, and good-humored",
"creative": "vivid, expressive, and imaginative — suited to a story or personal narrative",
"persuasive": "confident and persuasive — suited to an argument or opinion piece",
}[tone]
if !ok {
return ""
}
return "\n\nThe writer wants this document to read as " + clause + ". When phrasing could " +
"be improved, prefer suggestions that fit that tone, and gently flag wording that clashes with it."
}
// CheckpointMessages builds the message array for a grammar checkpoint over the
// given (already-truncated) document text.
func CheckpointMessages(contentText string) []Message {
// given (already-truncated) document text, steered toward the document's tone.
func CheckpointMessages(contentText, tone string) []Message {
return []Message{
{Role: "system", Content: checkpointSystemPrompt},
{Role: "system", Content: fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))},
{Role: "user", Content: contentText},
}
}

View File

@@ -16,7 +16,10 @@ const VoiceInterval = 20 * time.Second
// for a Tier-1 voice pass and parses the JSON result. It reuses the checkpoint's
// tolerant parser and a larger token budget, since one pass may flag several
// passages. Each flag carries a null replacement (awareness-only).
func RunVoice(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
// The tone argument is accepted for a uniform pass signature but ignored: voice
// consistency is judged against the document's own established voice, not an
// externally-chosen register.
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{
Messages: VoiceMessages(contentText),
MaxTokens: 2048,

View File

@@ -69,8 +69,9 @@ func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
}
// pass is the signature shared by the grammar checkpoint and the voice pass:
// given the document text it returns the model's raw suggestions.
type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([]llm.RawSuggestion, error)
// given the document text and the document's tone it returns the model's raw
// suggestions. The voice pass ignores tone (see llm.RunVoice).
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string) ([]llm.RawSuggestion, error)
// runPass is the shared body for both LLM passes. It loads the document text,
// enforces the pass's per-document rate limit, runs the model, swaps in the
@@ -79,11 +80,11 @@ type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([
func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) {
docID := chi.URLParam(r, "id")
var contentText string
var contentText, tone string
err := h.DB.QueryRow(
`SELECT content_text FROM documents WHERE id = ? AND user_id = ?`,
`SELECT content_text, tone FROM documents WHERE id = ? AND user_id = ?`,
docID, db.LocalUserID,
).Scan(&contentText)
).Scan(&contentText, &tone)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "document not found")
return
@@ -111,7 +112,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
return
}
raw, err := run(r.Context(), h.Client, contentText)
raw, err := run(r.Context(), h.Client, contentText, tone)
if err != nil {
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
return

View File

@@ -5,6 +5,7 @@ import { useCheckpoint } from './hooks/useCheckpoint'
import { useSpellChecker } from './hooks/useSpellChecker'
import { DocList } from './components/DocList/DocList'
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
import { ToneSelect } from './components/Editor/ToneSelect'
import { StatusBar } from './components/StatusBar/StatusBar'
import { PetalCompanion } from './components/Companion/PetalCompanion'
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
@@ -16,6 +17,10 @@ export default function App() {
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
const [title, setTitle] = useState('')
const [wordCount, setWordCount] = useState(0)
// The current document's target tone (steers checkpoint advice) and its live
// plain text (drives the expanded writing-stats panel in the StatusBar).
const [tone, setTone] = useState('general')
const [docText, setDocText] = useState('')
const [ready, setReady] = useState(false)
// Distraction-free mode: entered on editor focus, collapses the doc-list
// sidebar. Escape or a click outside the editor canvas restores it.
@@ -49,6 +54,8 @@ export default function App() {
setCurrentDoc(doc)
setTitle(doc.title)
setWordCount(doc.word_count)
setTone(doc.tone || 'general')
setDocText(doc.content_text)
},
[saveNow],
)
@@ -68,6 +75,8 @@ export default function App() {
setCurrentDoc(fresh)
setTitle(fresh.title)
setWordCount(0)
setTone(fresh.tone || 'general')
setDocText('')
} else {
setDocs(list)
await openDoc(list[0].id)
@@ -90,6 +99,8 @@ export default function App() {
setCurrentDoc(fresh)
setTitle(fresh.title)
setWordCount(0)
setTone(fresh.tone || 'general')
setDocText('')
}, [saveNow])
const handleDelete = useCallback(
@@ -104,6 +115,8 @@ export default function App() {
setCurrentDoc(null)
setTitle('')
setWordCount(0)
setTone('general')
setDocText('')
}
}
return remaining
@@ -126,6 +139,7 @@ export default function App() {
const handleEditorChange = useCallback(
(change: EditorChange) => {
setWordCount(change.word_count)
setDocText(change.content_text)
setEditTick((n) => n + 1)
if (currentDoc) {
patchSummary(currentDoc.id, { word_count: change.word_count })
@@ -136,6 +150,20 @@ export default function App() {
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
)
// Changing the tone persists it and re-runs the checkpoint so Petal's advice
// re-tunes to the new register. The auto-save (1.5s) lands before the
// checkpoint debounce (4s), so the server reads the updated tone.
const handleToneChange = useCallback(
(value: string) => {
setTone(value)
if (currentDoc) {
schedule({ tone: value })
scheduleCheckpoint()
}
},
[currentDoc, schedule, scheduleCheckpoint],
)
// Accept applies the replacement in the editor (handled in EditorCore) and
// marks the suggestion accepted; dismiss just rejects it. Both drop it locally.
const handleAccept = useCallback(
@@ -209,14 +237,17 @@ export default function App() {
className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
>
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
<div className="mb-5 flex items-center gap-3">
<input
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Untitled"
aria-label="Document title"
className="mb-5 w-full bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
style={{ fontFamily: 'var(--font-ui)' }}
/>
<ToneSelect value={tone} onChange={handleToneChange} />
</div>
<EditorCore
key={currentDoc.id}
docId={currentDoc.id}
@@ -236,6 +267,7 @@ export default function App() {
<div onMouseDown={handleChromeDown}>
<StatusBar
wordCount={wordCount}
text={docText}
saveStatus={status}
checking={checking}
voicing={voicing}

View File

@@ -14,6 +14,7 @@ export interface Document {
title: string
content: string // Tiptap JSON (stringified)
content_text: string // flattened plain text
tone: string // target writing tone; steers LLM advice
word_count: number
created_at: string
updated_at: string
@@ -25,9 +26,25 @@ export interface DocUpdate {
title?: string
content?: string
content_text?: string
tone?: string
word_count?: number
}
// One sense of a word from the offline dictionary.
export interface WordMeaning {
part_of_speech: string
definition: string
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.
export interface WordInfo {
word: string
definitions: WordMeaning[]
synonyms: string[]
}
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
// A single LLM-proposed edit. `original` is the source of truth for placement —
@@ -83,6 +100,9 @@ export const api = {
dismissSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
// Offline word lookup (definition + synonyms) for the right-click popover.
lookupWord: (word: string) => req<WordInfo>(`/word/${encodeURIComponent(word)}`),
// 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.
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),

View File

@@ -10,7 +10,8 @@ import { SuggestionCard } from './SuggestionCard'
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
import { MisspellCard } from './MisspellCard'
import type { Suggestion } from '../../api/client'
import { WordCard } from './WordCard'
import { api, type Suggestion, type WordInfo } from '../../api/client'
import type { SpellChecker } from '../../hooks/useSpellChecker'
export interface EditorChange {
@@ -50,6 +51,19 @@ interface MisspellState {
left: number
}
// The open right-click word popover (definition + synonyms), or null. `info` is
// null while the offline lookup is in flight (`loading`); the card shows a
// looking-up state until it resolves.
interface WordInfoState {
word: string
from: number
to: number
top: number
left: number
loading: boolean
info: WordInfo | null
}
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
// spray up-and-out from a point; each reads its direction from --dx/--dy.
const CONFETTI_DOTS = [
@@ -112,6 +126,10 @@ export function EditorCore({
const [hover, setHover] = useState<HoverState | null>(null)
// The open spelling popover (click a red-underlined word), or null.
const [misspell, setMisspell] = useState<MisspellState | null>(null)
// The open right-click word popover (definition + synonyms), or null.
const [wordInfo, setWordInfo] = useState<WordInfoState | null>(null)
// Token to discard a word lookup whose popover has since closed/changed.
const wordReqRef = useRef(0)
// Transient confetti burst played at the last accept location.
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
@@ -137,8 +155,9 @@ export function EditorCore({
},
onFocus: () => onFocusMode?.(),
onUpdate: ({ editor }) => {
// Any edit shifts positions, stranding the spelling popover's anchor.
// Any edit shifts positions, stranding the popover anchors.
setMisspell(null)
setWordInfo(null)
onChange({
content: JSON.stringify(editor.getJSON()),
content_text: editor.getText(),
@@ -154,6 +173,7 @@ export function EditorCore({
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
setHover(null)
setMisspell(null)
setWordInfo(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, editor])
@@ -186,6 +206,8 @@ export function EditorCore({
Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
)
const top = elRect.bottom - wrapRect.top + 6
// A suggestion card and the word popover shouldn't stack.
setWordInfo(null)
setHover((prev) => {
// Moving to a different highlight resets any Ask Petal pin.
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
@@ -284,6 +306,7 @@ export function EditorCore({
const top = elRect.bottom - wrapRect.top + 6
// Opening a spelling popover supersedes any AI-suggestion hover card.
closeCard()
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
},
[editor, spellChecker, closeCard],
@@ -304,6 +327,72 @@ export function EditorCore({
setMisspell(null)
}, [misspell, onAddWord])
// Right-click a word to look it up: resolve the exact word span under the
// pointer, anchor a popover beneath it, and kick off the offline lookup. The
// card opens immediately in a loading state and fills in when the (local)
// lookup returns. Right-clicking off any word falls through to the native menu.
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (!editor) return
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) return
const range = wordAt(editor.state.doc, coords.pos)
if (!range) return
const wrapper = wrapperRef.current
if (!wrapper) return
e.preventDefault()
// Anchor under the word itself (not the click point) so the card lines up
// with the text the way the spelling popover does.
const start = editor.view.coordsAtPos(range.from)
const end = editor.view.coordsAtPos(range.to)
const wrapRect = wrapper.getBoundingClientRect()
const cardWidth = 300
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - cardWidth))
const top = end.bottom - wrapRect.top + 6
// Opening a word lookup supersedes any suggestion/spelling card.
closeCard()
setMisspell(null)
const token = ++wordReqRef.current
setWordInfo({ word: range.word, from: range.from, to: range.to, top, left, loading: true, info: null })
api
.lookupWord(range.word)
.then((info) => {
if (token === wordReqRef.current) {
setWordInfo((w) => (w ? { ...w, loading: false, info } : null))
}
})
.catch((err) => {
console.error('word lookup failed', err)
if (token === wordReqRef.current) {
setWordInfo((w) => (w ? { ...w, loading: false, info: null } : null))
}
})
},
[editor, closeCard],
)
const replaceWord = useCallback(
(synonym: string) => {
if (editor && wordInfo) {
editor.chain().focus().insertContentAt({ from: wordInfo.from, to: wordInfo.to }, synonym).run()
}
setWordInfo(null)
},
[editor, wordInfo],
)
// A pointer-down outside the word popover (and not on another word, which would
// reopen it via the context menu) closes it.
useEffect(() => {
if (!wordInfo) return
const onDown = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('.petal-word-card')) return
setWordInfo(null)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [wordInfo])
// A pointer-down outside the popover (and not on another misspelling, which
// would reopen it) closes the spelling card.
useEffect(() => {
@@ -342,9 +431,19 @@ export function EditorCore({
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onClick={handleSpellClick}
onContextMenu={handleContextMenu}
>
<EditorContent editor={editor} className="h-full" />
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
{wordInfo && (
<WordCard
word={wordInfo.word}
info={wordInfo.info}
loading={wordInfo.loading}
style={{ top: wordInfo.top, left: wordInfo.left }}
onReplace={replaceWord}
/>
)}
{misspell && (
<MisspellCard
word={misspell.word}

View File

@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react'
// ToneSelect lets the writer set the document's target tone, which steers the
// grammar-checkpoint LLM toward the right register (an academic essay vs a casual
// journal). A small custom dropdown (not a native <select>) so it can carry the
// bilingual zh·en labels and emoji that match Petal's chrome — the writer uses
// Mandarin and English. The `value` strings mirror the backend's tone keys.
export interface ToneOption {
value: string
emoji: string
zh: string
en: string
}
// Keep these `value`s in sync with llm.toneGuidance on the server. 'general'
// means no steering (Petal's default friendly ESL advice).
export const TONES: ToneOption[] = [
{ value: 'general', emoji: '🌸', zh: '通用', en: 'General' },
{ 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 {
value: string
onChange: (value: string) => void
}
export function ToneSelect({ value, onChange }: Props) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const current = TONES.find((t) => t.value === value) ?? TONES[0]
// Click outside closes the menu.
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [open])
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
aria-label="Document tone"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap px-3 text-sm font-bold"
style={{
borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface)',
color: 'var(--color-plum)',
boxShadow: 'var(--shadow-soft)',
}}
title="Set the tone — Petal tailors its advice to match"
>
<span aria-hidden>{current.emoji}</span>
<span>{current.zh}</span>
<span style={{ color: 'var(--color-muted)' }}>· {current.en}</span>
<span aria-hidden style={{ color: 'var(--color-muted)' }}>
</span>
</button>
{open && (
<div
role="listbox"
className="petal-word-card absolute right-0 z-30 mt-1.5 p-1.5"
style={{
width: 200,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
}}
>
{TONES.map((t) => {
const active = t.value === value
return (
<button
key={t.value}
type="button"
role="option"
aria-selected={active}
onClick={() => {
onChange(t.value)
setOpen(false)
}}
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm font-semibold"
style={{
background: active ? 'var(--color-surface-alt)' : 'transparent',
color: 'var(--color-plum)',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
onMouseLeave={(e) =>
(e.currentTarget.style.background = active ? 'var(--color-surface-alt)' : 'transparent')
}
>
<span aria-hidden>{t.emoji}</span>
<span>{t.zh}</span>
<span className="font-normal" style={{ color: 'var(--color-muted)' }}>
{t.en}
</span>
</button>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,116 @@
import type { WordInfo } from '../../api/client'
// WordCard is the right-click popover for any word: its dictionary definition(s)
// on top and tappable synonym pills below. Clicking a synonym replaces the word
// in place. Both datasets are offline, so this opens instantly and fills in as
// the (local) lookup returns. Labels are bilingual (zh-first, en subtitle) to
// match the rest of Petal's chrome — the writer uses Mandarin and English.
interface Props {
word: string
info: WordInfo | null
loading: boolean
style: React.CSSProperties
onReplace: (synonym: string) => void
}
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
return (
<div
role="dialog"
aria-label={`Definition and synonyms for ${word}`}
className="petal-word-card absolute z-20 p-3.5 text-sm"
style={{
width: 300,
maxHeight: 340,
overflowY: 'auto',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
...style,
}}
>
<div className="flex items-center gap-1.5">
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
style={{ background: 'var(--color-lavender)', color: 'var(--color-plum)' }}
>
· Word
</span>
<span className="font-bold" style={{ color: 'var(--color-plum)' }}>
{word}
</span>
</div>
{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
/>
· Looking up
</div>
)}
{definitions.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Definition
</p>
<ol className="space-y-1.5">
{definitions.map((m, i) => (
<li key={i} className="leading-snug" style={{ color: 'var(--color-plum)' }}>
{m.part_of_speech && (
<span className="mr-1 italic" style={{ color: 'var(--color-accent-hover)' }}>
{m.part_of_speech}
</span>
)}
{m.definition}
{m.example && (
<span className="mt-0.5 block italic" style={{ color: 'var(--color-muted)' }}>
{m.example}
</span>
)}
</li>
))}
</ol>
</div>
)}
{synonyms.length > 0 && (
<div className="mt-3">
<p className="mb-1.5 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Synonyms <span className="font-normal">( · tap to swap)</span>
</p>
<div className="flex flex-wrap gap-1.5">
{synonyms.map((s) => (
<button
key={s}
type="button"
onClick={() => onReplace(s)}
className="rounded-full px-3 py-1 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
>
{s}
</button>
))}
</div>
</div>
)}
{empty && (
<p className="mt-3 leading-snug" style={{ color: 'var(--color-muted)' }}>
· Nothing found for this word
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { useMemo } from 'react'
import { computeStats, gradeBand } from './stats'
// StatsPanel is the popover that opens above the word count: a small grid of
// writing statistics computed from the live document. Bilingual zh·en labels to
// match Petal's chrome. Reading level shows a friendly band, not just a number.
interface Props {
text: string
wordCount: number
}
interface Row {
zh: string
en: string
value: string
}
export function StatsPanel({ text, wordCount }: Props) {
const rows = useMemo<Row[]>(() => {
const s = computeStats(text, wordCount)
const band = gradeBand(s.gradeLevel)
const fmt = (n: number, d = 0) =>
n.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d })
return [
{ zh: '字数', en: 'Words', value: fmt(s.words) },
{ zh: '字符', en: 'Characters', value: fmt(s.characters) },
{ zh: '句子', en: 'Sentences', value: fmt(s.sentences) },
{ zh: '段落', en: 'Paragraphs', value: fmt(s.paragraphs) },
{ zh: '页数', en: 'Pages', value: `~${fmt(Math.max(s.pages, s.words > 0 ? 0.1 : 0), 1)}` },
{ zh: '阅读时间', en: 'Reading time', value: readingTime(s.readingTimeMin) },
{ zh: '平均词长', en: 'Avg word length', value: `${fmt(s.avgWordLength, 1)}` },
{ zh: '词汇丰富度', en: 'Word variety', value: `${fmt(s.variety * 100)}%` },
{
zh: '阅读难度',
en: 'Reading level',
value: s.words > 0 ? `${band.en} · ${fmt(s.gradeLevel, 1)}` : '—',
},
]
}, [text, wordCount])
return (
<div
role="dialog"
aria-label="Writing statistics"
className="petal-word-card absolute bottom-7 left-0 z-30 p-3.5"
style={{
width: 268,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
color: 'var(--color-plum)',
}}
>
<p className="mb-2 text-xs font-bold" style={{ color: 'var(--color-muted)' }}>
· Writing stats
</p>
<dl className="space-y-1.5">
{rows.map((r) => (
<div key={r.en} className="flex items-baseline justify-between gap-3 text-sm">
<dt style={{ color: 'var(--color-muted)' }}>
<span className="font-semibold" style={{ color: 'var(--color-plum)' }}>
{r.zh}
</span>{' '}
{r.en}
</dt>
<dd className="font-bold tabular-nums">{r.value}</dd>
</div>
))}
</dl>
</div>
)
}
// readingTime renders minutes as a friendly "< 1 min" / "N min" string.
function readingTime(min: number): string {
if (min <= 0) return '0 min'
if (min < 1) return '< 1 min'
return `${Math.round(min)} min`
}

View File

@@ -1,7 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave'
import { StatsPanel } from './StatsPanel'
interface Props {
wordCount: number
// Live plain text of the document, for the expanded stats panel.
text: string
saveStatus: SaveStatus
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
checking: boolean
@@ -20,16 +24,44 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
// StatusBar is the slim footer: word count on the left, save state and the
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
// circle that breathes while a check is in flight (spec → Signature animations).
export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) {
export function StatusBar({ wordCount, text, saveStatus, checking, voicing }: Props) {
const label = SAVE_LABEL[saveStatus]
// The expanded stats panel toggles open when the word count is clicked.
const [statsOpen, setStatsOpen] = useState(false)
const statsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!statsOpen) return
const onDown = (e: MouseEvent) => {
if (!statsRef.current?.contains(e.target as Node)) setStatsOpen(false)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [statsOpen])
return (
<footer
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs"
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
>
<span>
<div className="relative" ref={statsRef}>
<button
type="button"
onClick={() => setStatsOpen((o) => !o)}
aria-haspopup="dialog"
aria-expanded={statsOpen}
className="rounded-full px-1.5 py-0.5 font-semibold transition-colors"
style={{ color: statsOpen ? 'var(--color-accent-hover)' : 'inherit' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
onMouseLeave={(e) =>
(e.currentTarget.style.color = statsOpen ? 'var(--color-accent-hover)' : 'inherit')
}
title="Writing stats"
>
{wordCount} {wordCount === 1 ? 'word' : 'words'}
</span>
</button>
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
</div>
{checking && (
<>
<span aria-hidden>·</span>

View File

@@ -0,0 +1,92 @@
// Writing statistics computed from the document's plain text. These power the
// expanded stats panel that opens when the writer clicks the word count. The
// reading-level formulas are English-centric (Flesch); a document mixing in
// Mandarin still gets sensible counts, with the level treated as approximate.
export interface WritingStats {
words: number
characters: number
charactersNoSpaces: number
sentences: number
paragraphs: number
pages: number
avgWordLength: number // letters per English word
uniqueWords: number
variety: number // type-token ratio, 01 (unique ÷ total English words)
readingTimeMin: number
gradeLevel: number // FleschKincaid grade
}
// English word tokens (letters with internal apostrophes/hyphens) — used for the
// letter-length, syllable, and variety measures, which only make sense for
// alphabetic words.
const ENGLISH_WORD_RE = /[A-Za-z]+(?:['-][A-Za-z]+)*/g
// Sentence terminators, including the CJK fullwidth forms.
const SENTENCE_RE = /[.!?。!?]+/g
// Words per page (a rough double-spaced manuscript page) and reading speed.
const WORDS_PER_PAGE = 250
const WORDS_PER_MINUTE = 200
// countSyllables is the common vowel-group heuristic: count vowel runs, drop a
// trailing silent "e"/"es"/"ed", and floor at one. Not perfect, but plenty
// accurate for an at-a-glance reading level.
function countSyllables(word: string): number {
const w = word.toLowerCase().replace(/[^a-z]/g, '')
if (w.length === 0) return 0
if (w.length <= 3) return 1
const trimmed = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '').replace(/^y/, '')
const groups = trimmed.match(/[aeiouy]{1,2}/g)
return groups ? groups.length : 1
}
export function computeStats(text: string, wordCount: number): WritingStats {
const trimmed = text.trim()
const characters = [...text].length
const charactersNoSpaces = text.replace(/\s/g, '').length
const sentenceMatches = trimmed.match(SENTENCE_RE)
const sentences = sentenceMatches ? sentenceMatches.length : trimmed ? 1 : 0
const paragraphBlocks = trimmed.split(/\n{2,}/).filter((p) => p.trim().length > 0)
const paragraphs = paragraphBlocks.length
const englishWords = trimmed.match(ENGLISH_WORD_RE) ?? []
const letters = englishWords.reduce((sum, w) => sum + w.length, 0)
const avgWordLength = englishWords.length > 0 ? letters / englishWords.length : 0
const unique = new Set(englishWords.map((w) => w.toLowerCase()))
const uniqueWords = unique.size
const variety = englishWords.length > 0 ? uniqueWords / englishWords.length : 0
const syllables = englishWords.reduce((sum, w) => sum + countSyllables(w), 0)
// FleschKincaid grade level; needs at least one sentence and word to be real.
let gradeLevel = 0
if (englishWords.length > 0 && sentences > 0) {
gradeLevel =
0.39 * (englishWords.length / sentences) + 11.8 * (syllables / englishWords.length) - 15.59
if (gradeLevel < 0) gradeLevel = 0
}
return {
words: wordCount,
characters,
charactersNoSpaces,
sentences,
paragraphs,
pages: wordCount / WORDS_PER_PAGE,
avgWordLength,
uniqueWords,
variety,
readingTimeMin: wordCount / WORDS_PER_MINUTE,
gradeLevel,
}
}
// gradeBand turns a FleschKincaid grade into a friendly bilingual descriptor —
// far more useful to an ESL writer than a bare number.
export function gradeBand(grade: number): { zh: string; en: string } {
if (grade <= 5) return { zh: '简单', en: 'Easy' }
if (grade <= 8) return { zh: '标准', en: 'Standard' }
if (grade <= 12) return { zh: '偏难', en: 'Fairly hard' }
return { zh: '较难', en: 'Advanced' }
}

View File

@@ -156,6 +156,13 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both;
}
/* --- Word lookup popover ----------------------------------------------------
Right-click a word for its dictionary definition and synonyms. Same soft card
treatment as the spelling popover; synonym pills swap the word on click. */
.petal-word-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