Compare commits
4 Commits
phase-10-o
...
feat/petal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2487e73551 | ||
|
|
0a1ea225dd | ||
|
|
69ecb79da1 | ||
|
|
f3c4fe2c22 |
165
scripts/gen_sounds.py
Normal file
165
scripts/gen_sounds.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Petal's cute UI sound palette as small WAV assets.
|
||||
|
||||
These are warm, rounded little sounds — bubble pops, water drops, soft bells —
|
||||
played when suggestions and companion bubbles appear. We synthesize them here
|
||||
(rather than ship someone else's clips) so they match the app's gentle aesthetic
|
||||
exactly and stay tiny. Output lands in web/src/assets/sounds/ where Vite bundles
|
||||
and fingerprints them into dist (and so into the embedded Go binary).
|
||||
|
||||
Run: python3 scripts/gen_sounds.py
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import wave
|
||||
|
||||
SR = 44100
|
||||
OUT = os.path.join(os.path.dirname(__file__), "..", "web", "src", "assets", "sounds")
|
||||
|
||||
|
||||
def env(n, attack, decay, total):
|
||||
"""Smooth attack + exponential decay envelope over `total` seconds."""
|
||||
a = max(1, int(attack * SR))
|
||||
out = []
|
||||
for i in range(n):
|
||||
t = i / SR
|
||||
if i < a:
|
||||
amp = i / a
|
||||
else:
|
||||
amp = math.exp(-(t - attack) / decay)
|
||||
out.append(amp)
|
||||
return out
|
||||
|
||||
|
||||
def sine(freq_at, n):
|
||||
"""Sine with a per-sample frequency function freq_at(t)->Hz (phase-accurate)."""
|
||||
out = []
|
||||
phase = 0.0
|
||||
for i in range(n):
|
||||
t = i / SR
|
||||
f = freq_at(t)
|
||||
phase += 2 * math.pi * f / SR
|
||||
out.append(math.sin(phase))
|
||||
return out
|
||||
|
||||
|
||||
def mix(*layers):
|
||||
n = max(len(l) for l in layers)
|
||||
out = [0.0] * n
|
||||
for l in layers:
|
||||
for i, v in enumerate(l):
|
||||
out[i] += v
|
||||
return out
|
||||
|
||||
|
||||
def soft_clip(s):
|
||||
return [math.tanh(v * 1.2) for v in s]
|
||||
|
||||
|
||||
def normalize(s, peak=0.9):
|
||||
m = max(1e-9, max(abs(v) for v in s))
|
||||
g = peak / m
|
||||
return [v * g for v in s]
|
||||
|
||||
|
||||
def write_wav(name, samples):
|
||||
samples = soft_clip(samples)
|
||||
samples = normalize(samples, 0.85)
|
||||
# 4ms fade-out tail so nothing clicks at the end.
|
||||
tail = int(0.004 * SR)
|
||||
for i in range(tail):
|
||||
samples[-1 - i] *= i / tail
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
path = os.path.join(OUT, name)
|
||||
with wave.open(path, "w") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(SR)
|
||||
frames = b"".join(struct.pack("<h", int(max(-1, min(1, v)) * 32767)) for v in samples)
|
||||
w.writeframes(frames)
|
||||
print(f"wrote {name} ({len(samples)/SR*1000:.0f} ms, {len(frames)} bytes)")
|
||||
|
||||
|
||||
def apply_env(sig, e):
|
||||
return [s * a for s, a in zip(sig, e)]
|
||||
|
||||
|
||||
def bubble_pop():
|
||||
"""A cute bubble 'bloop' — pitch swoops up fast then a rounded body."""
|
||||
dur = 0.16
|
||||
n = int(dur * SR)
|
||||
# Rising swoop is the signature of a bubble.
|
||||
body = sine(lambda t: 360 + 720 * (1 - math.exp(-t * 38)), n)
|
||||
body = apply_env(body, env(n, 0.004, 0.05, dur))
|
||||
# A soft octave shimmer on top.
|
||||
shimmer = sine(lambda t: 1080 + 400 * (1 - math.exp(-t * 38)), n)
|
||||
shimmer = apply_env(shimmer, env(n, 0.003, 0.03, dur))
|
||||
shimmer = [v * 0.25 for v in shimmer]
|
||||
return mix(body, shimmer)
|
||||
|
||||
|
||||
def droplet():
|
||||
"""A clean little water-drop ping — high, downward, watery tail."""
|
||||
dur = 0.22
|
||||
n = int(dur * SR)
|
||||
main = sine(lambda t: 1500 * math.exp(-t * 3.2) + 760, n)
|
||||
main = apply_env(main, env(n, 0.003, 0.06, dur))
|
||||
# Tiny resonant echo for a wet feel.
|
||||
echo = sine(lambda t: 980, n)
|
||||
echo = apply_env(echo, env(n, 0.05, 0.07, dur))
|
||||
echo = [v * 0.2 for v in echo]
|
||||
return mix(main, droop_delay(echo, 0.04))
|
||||
|
||||
|
||||
def droop_delay(sig, delay_s):
|
||||
d = int(delay_s * SR)
|
||||
return [0.0] * d + sig
|
||||
|
||||
|
||||
def bell(freq, dur, partials=((1, 1.0), (2.01, 0.5), (2.78, 0.28), (4.1, 0.12))):
|
||||
"""A soft inharmonic bell tone (sum of slightly detuned partials)."""
|
||||
n = int(dur * SR)
|
||||
layers = []
|
||||
for ratio, amp in partials:
|
||||
s = sine(lambda t, f=freq * ratio: f, n)
|
||||
s = apply_env(s, env(n, 0.005, dur * 0.45, dur))
|
||||
layers.append([v * amp for v in s])
|
||||
return mix(*layers)
|
||||
|
||||
|
||||
def chime():
|
||||
"""Two gentle bell notes a soft fifth apart."""
|
||||
a = bell(784, 0.42) # G5
|
||||
b = bell(1175, 0.40) # D6
|
||||
b = droop_delay(b, 0.10)
|
||||
return mix(a, [v * 0.85 for v in b])
|
||||
|
||||
|
||||
def shimmer():
|
||||
"""Three quick ascending sparkles — glockenspiel-ish."""
|
||||
notes = [988, 1319, 1976] # B5, E6, B6
|
||||
layers = []
|
||||
for i, f in enumerate(notes):
|
||||
s = bell(f, 0.30, partials=((1, 1.0), (2.7, 0.3), (5.1, 0.1)))
|
||||
layers.append(droop_delay([v * 0.8 for v in s], 0.06 * i))
|
||||
return mix(*layers)
|
||||
|
||||
|
||||
def cheer():
|
||||
"""A happy little major arpeggio C5-E5-G5-C6 on bells."""
|
||||
notes = [523, 659, 784, 1047]
|
||||
layers = []
|
||||
for i, f in enumerate(notes):
|
||||
s = bell(f, 0.36)
|
||||
layers.append(droop_delay([v * 0.8 for v in s], 0.075 * i))
|
||||
return mix(*layers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
write_wav("pop.wav", bubble_pop())
|
||||
write_wav("droplet.wav", droplet())
|
||||
write_wav("chime.wav", chime())
|
||||
write_wav("shimmer.wav", shimmer())
|
||||
write_wav("cheer.wav", cheer())
|
||||
print("done →", os.path.normpath(OUT))
|
||||
364
web/package-lock.json
generated
364
web/package-lock.json
generated
@@ -28,7 +28,8 @@
|
||||
"dictionary-en": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1178,6 +1179,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
|
||||
@@ -1934,6 +1942,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -2010,12 +2036,135 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.9",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.40",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||
@@ -2084,6 +2233,16 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -2176,6 +2335,13 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -2240,6 +2406,26 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2746,12 +2932,33 @@
|
||||
"is-buffer": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3103,6 +3310,13 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -3113,6 +3327,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
|
||||
@@ -3134,6 +3362,23 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -3151,6 +3396,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tippy.js": {
|
||||
"version": "6.3.7",
|
||||
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
||||
@@ -3295,12 +3550,119 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.9",
|
||||
"@vitest/mocker": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/runner": "4.1.9",
|
||||
"@vitest/snapshot": "4.1.9",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/browser-preview": "4.1.9",
|
||||
"@vitest/browser-webdriverio": "4.1.9",
|
||||
"@vitest/coverage-istanbul": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-character-count": "^2.11.5",
|
||||
@@ -29,6 +31,7 @@
|
||||
"dictionary-en": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||
import { PetalFall } from './effects/PetalFall'
|
||||
import { playSuggestionSound } from './audio/sounds'
|
||||
|
||||
export default function App() {
|
||||
const updateAvailable = useVersionWatch()
|
||||
@@ -309,8 +311,34 @@ export default function App() {
|
||||
[removeSuggestion],
|
||||
)
|
||||
|
||||
// Play a soft sound when freshly-checked suggestions arrive — one per distinct
|
||||
// new type, lightly staggered so a batch reads as a little melody rather than
|
||||
// a pile-up. We track which ids we've already chimed for, and only chime for
|
||||
// recently-created suggestions so opening a doc with old pending advice stays
|
||||
// silent (the existing set was created in a past session).
|
||||
const chimedRef = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
const fresh = suggestions.filter((s) => !chimedRef.current.has(s.id))
|
||||
fresh.forEach((s) => chimedRef.current.add(s.id))
|
||||
const justMade = fresh.filter(
|
||||
(s) => Date.now() - new Date(s.created_at).getTime() < 12_000,
|
||||
)
|
||||
if (justMade.length === 0) return
|
||||
const types = [...new Set(justMade.map((s) => s.type))].slice(0, 3)
|
||||
const timers = types.map((t, i) =>
|
||||
window.setTimeout(() => playSuggestionSound(t), i * 150),
|
||||
)
|
||||
return () => timers.forEach((id) => window.clearTimeout(id))
|
||||
}, [suggestions])
|
||||
|
||||
// Forget chimed ids when switching documents so the set can't grow unbounded.
|
||||
useEffect(() => {
|
||||
chimedRef.current = new Set()
|
||||
}, [currentDoc?.id])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PetalFall />
|
||||
<header
|
||||
onMouseDown={handleChromeDown}
|
||||
className="petal-no-print flex h-12 shrink-0 items-center gap-2 px-5"
|
||||
@@ -449,6 +477,7 @@ export default function App() {
|
||||
saveStatus={status}
|
||||
editTick={editTick}
|
||||
acceptTick={acceptTick}
|
||||
text={docText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
BIN
web/src/assets/sounds/cheer.wav
Normal file
BIN
web/src/assets/sounds/cheer.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/chime.wav
Normal file
BIN
web/src/assets/sounds/chime.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/droplet.wav
Normal file
BIN
web/src/assets/sounds/droplet.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/pop.wav
Normal file
BIN
web/src/assets/sounds/pop.wav
Normal file
Binary file not shown.
BIN
web/src/assets/sounds/shimmer.wav
Normal file
BIN
web/src/assets/sounds/shimmer.wav
Normal file
Binary file not shown.
174
web/src/audio/sounds.ts
Normal file
174
web/src/audio/sounds.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// Petal's cute sound palette. The actual tones live as tiny WAV assets in
|
||||
// ./assets (generated by scripts/gen_sounds.py — warm bubble pops, water drops,
|
||||
// and soft bells tuned to match the app's gentle look). Vite fingerprints and
|
||||
// bundles them into dist, so they ride along inside the single Go binary; here
|
||||
// we just fetch + decode them once and play them through a Web Audio bus with a
|
||||
// soft master volume. The whole thing is opt-out-able and the mute choice
|
||||
// persists in localStorage so it survives reloads.
|
||||
|
||||
import popUrl from '../assets/sounds/pop.wav'
|
||||
import dropletUrl from '../assets/sounds/droplet.wav'
|
||||
import chimeUrl from '../assets/sounds/chime.wav'
|
||||
import shimmerUrl from '../assets/sounds/shimmer.wav'
|
||||
import cheerUrl from '../assets/sounds/cheer.wav'
|
||||
|
||||
const STORAGE_KEY = 'petal.sound'
|
||||
|
||||
// Master volume — deliberately gentle. These are background delights, not alerts.
|
||||
const MASTER_GAIN = 0.5
|
||||
|
||||
export type SoundName =
|
||||
| 'pop' // a soft bubble pop — the signature "something arrived"
|
||||
| 'droplet' // a single rounded water-drop ping
|
||||
| 'chime' // a gentle two-note bell
|
||||
| 'shimmer' // three quick ascending sparkles
|
||||
| 'cheer' // a happy little major arpeggio (accepts, milestones)
|
||||
|
||||
const SOURCES: Record<SoundName, string> = {
|
||||
pop: popUrl,
|
||||
droplet: dropletUrl,
|
||||
chime: chimeUrl,
|
||||
shimmer: shimmerUrl,
|
||||
cheer: cheerUrl,
|
||||
}
|
||||
|
||||
let ctx: AudioContext | null = null
|
||||
let master: GainNode | null = null
|
||||
const buffers = new Map<SoundName, AudioBuffer>()
|
||||
let enabled = readEnabled()
|
||||
const listeners = new Set<(on: boolean) => void>()
|
||||
|
||||
function readEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) !== 'off'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function isSoundEnabled(): boolean {
|
||||
return enabled
|
||||
}
|
||||
|
||||
export function setSoundEnabled(on: boolean): void {
|
||||
enabled = on
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off')
|
||||
} catch {
|
||||
/* private mode — choice just won't persist */
|
||||
}
|
||||
listeners.forEach((fn) => fn(on))
|
||||
// Touching the context on enable doubles as a user-gesture unlock + warm-up.
|
||||
if (on) void ensureContext()
|
||||
}
|
||||
|
||||
export function onSoundEnabledChange(fn: (on: boolean) => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
// Lazily create the AudioContext (browsers require a user gesture before audio
|
||||
// can play; the first play() after a click will succeed, earlier ones resume).
|
||||
async function ensureContext(): Promise<AudioContext | null> {
|
||||
if (typeof window === 'undefined') return null
|
||||
const AC: typeof AudioContext | undefined =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
if (!AC) return null
|
||||
if (!ctx) {
|
||||
ctx = new AC()
|
||||
master = ctx.createGain()
|
||||
master.gain.value = MASTER_GAIN
|
||||
// A gentle soft-clip so stacked sounds never crackle.
|
||||
const shaper = ctx.createWaveShaper()
|
||||
shaper.curve = softClipCurve()
|
||||
master.connect(shaper)
|
||||
shaper.connect(ctx.destination)
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
try {
|
||||
await ctx.resume()
|
||||
} catch {
|
||||
/* will retry on the next gesture */
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function softClipCurve(): Float32Array<ArrayBuffer> {
|
||||
const n = 1024
|
||||
const curve = new Float32Array(new ArrayBuffer(n * 4))
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = (i / (n - 1)) * 2 - 1
|
||||
curve[i] = Math.tanh(x * 1.2)
|
||||
}
|
||||
return curve
|
||||
}
|
||||
|
||||
// Fetch + decode one sound, caching the decoded buffer for instant replay.
|
||||
async function load(name: SoundName, audio: AudioContext): Promise<AudioBuffer | null> {
|
||||
const cached = buffers.get(name)
|
||||
if (cached) return cached
|
||||
try {
|
||||
const res = await fetch(SOURCES[name])
|
||||
const bytes = await res.arrayBuffer()
|
||||
const buf = await audio.decodeAudioData(bytes)
|
||||
buffers.set(name, buf)
|
||||
return buf
|
||||
} catch {
|
||||
return null // asset missing / decode failed — sound just won't play
|
||||
}
|
||||
}
|
||||
|
||||
// Warm the cache so the first real play is instant.
|
||||
async function prime(): Promise<void> {
|
||||
const audio = await ensureContext()
|
||||
if (!audio) return
|
||||
await Promise.all((Object.keys(SOURCES) as SoundName[]).map((n) => load(n, audio)))
|
||||
}
|
||||
|
||||
// Fire a sound. No-op when muted or audio is unavailable. Never throws — a
|
||||
// delight that fails should fail silently.
|
||||
export function playSound(name: SoundName): void {
|
||||
if (!enabled) return
|
||||
void (async () => {
|
||||
const audio = await ensureContext()
|
||||
if (!audio || !master || audio.state !== 'running') return
|
||||
const buf = await load(name, audio)
|
||||
if (!buf) return
|
||||
try {
|
||||
const src = audio.createBufferSource()
|
||||
src.buffer = buf
|
||||
src.connect(master)
|
||||
src.start()
|
||||
} catch {
|
||||
/* audio glitch — ignore */
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// Map an editor suggestion type to its sound, so each kind of help has its own
|
||||
// little voice (grammar pops like a bubble, idioms chime, etc.).
|
||||
const SUGGESTION_SOUND: Record<string, SoundName> = {
|
||||
grammar: 'pop',
|
||||
phrasing: 'droplet',
|
||||
idiom: 'chime',
|
||||
clarity: 'shimmer',
|
||||
voice: 'droplet',
|
||||
}
|
||||
|
||||
export function playSuggestionSound(type: string): void {
|
||||
playSound(SUGGESTION_SOUND[type] ?? 'pop')
|
||||
}
|
||||
|
||||
// Unlock + warm audio on the first user gesture so the very first sound isn't
|
||||
// dropped by the browser's autoplay policy. Self-removing.
|
||||
if (typeof window !== 'undefined') {
|
||||
const unlock = () => {
|
||||
void prime()
|
||||
window.removeEventListener('pointerdown', unlock)
|
||||
window.removeEventListener('keydown', unlock)
|
||||
}
|
||||
window.addEventListener('pointerdown', unlock)
|
||||
window.addEventListener('keydown', unlock)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
saveStatus: SaveStatus
|
||||
editTick: number
|
||||
acceptTick: number
|
||||
text: string
|
||||
}
|
||||
|
||||
// Emoji placeholder per mood, used for any mood a companion has no Lottie for.
|
||||
@@ -26,8 +27,14 @@ const STORAGE_KEY = 'petal.companion'
|
||||
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
|
||||
// break reminders. Clicking the mascot opens a picker to switch companions
|
||||
// (the choice persists in localStorage).
|
||||
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: Props) {
|
||||
const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick })
|
||||
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Props) {
|
||||
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
|
||||
wordCount,
|
||||
saveStatus,
|
||||
editTick,
|
||||
acceptTick,
|
||||
text,
|
||||
})
|
||||
|
||||
const [companionId, setCompanionId] = useState<string>(() => {
|
||||
try {
|
||||
@@ -136,6 +143,8 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
||||
<div
|
||||
role="status"
|
||||
onClick={dismiss}
|
||||
onMouseEnter={holdBubble}
|
||||
onMouseLeave={releaseBubble}
|
||||
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
|
||||
250
web/src/components/Companion/prose.test.ts
Normal file
250
web/src/components/Companion/prose.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { analyzeProse } from './prose'
|
||||
|
||||
// These tests pin the rules-based prose checker's behavior. For an ESL writer a
|
||||
// *wrong* nudge costs trust faster than a missed one earns it, so every rule is
|
||||
// tested in two directions: a true-positive case that must fire, and at least
|
||||
// one near-miss "guard" case that must stay silent. Sentences are padded past
|
||||
// the 8-word floor that analyzeProse needs before it offers advice.
|
||||
|
||||
const rulesFor = (text: string) => analyzeProse(text).map((h) => h.rule)
|
||||
|
||||
// Assert a given rule appears among the hints for this text.
|
||||
function expectRule(text: string, rule: string) {
|
||||
expect(rulesFor(text), `expected "${rule}" for: ${text}`).toContain(rule)
|
||||
}
|
||||
|
||||
// Assert the text produces no hints at all (false-positive guard).
|
||||
function expectClean(text: string) {
|
||||
expect(analyzeProse(text), `expected no hints for: ${text}`).toEqual([])
|
||||
}
|
||||
|
||||
describe('analyzeProse — floor', () => {
|
||||
it('stays quiet on very short drafts (under 8 English words)', () => {
|
||||
expect(analyzeProse('he go now')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns hints highest-priority first', () => {
|
||||
// This 36-word run-on also contains a lowercase "i"; run-on must lead.
|
||||
const hints = analyzeProse(
|
||||
'yesterday i walked all the way to the market and i bought apples and oranges and i saw my friend there and we talked and laughed and walked back home together in the warm afternoon sun.',
|
||||
)
|
||||
expect(hints[0].rule).toBe('runon')
|
||||
expect(hints.map((h) => h.rule)).toContain('cap-i')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run-ons', () => {
|
||||
it('flags a long, comma-heavy sentence', () => {
|
||||
expectRule(
|
||||
'I went to the market and I bought some apples and then I saw my friend and we talked for a while and after that we went to the cafe and ordered coffee and cake together.',
|
||||
'runon',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a normal sentence alone', () => {
|
||||
expectClean('The garden was quiet in the morning and the rain fell softly on the leaves.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('comma splices', () => {
|
||||
it('flags two independent clauses joined by a comma', () => {
|
||||
expectRule('I finished the report, I sent it to my boss right away today.', 'splice')
|
||||
})
|
||||
|
||||
it('catches a splice with a verb not on any list', () => {
|
||||
expectRule('The garden was quiet in the morning, she poured a cup of tea slowly.', 'splice')
|
||||
})
|
||||
|
||||
it('does not flag an introductory transition (However, …)', () => {
|
||||
expectClean('However, we decided to stay home and rest for the entire weekend.')
|
||||
})
|
||||
|
||||
it('does not flag an introductory phrase (After dinner, …)', () => {
|
||||
expectClean('After dinner, I went home and read a book until I fell asleep.')
|
||||
})
|
||||
|
||||
it('does not flag a pronoun list after a comma', () => {
|
||||
expectClean('We invited my brother, you and your sister to the dinner party.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unclear antecedents', () => {
|
||||
it('flags a vague "This + verb" opener after a prior sentence', () => {
|
||||
expectRule(
|
||||
'We changed the whole schedule last week. This makes everyone confused about the new times.',
|
||||
'antecedent',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves "This" alone when it names its noun', () => {
|
||||
expectClean(
|
||||
'We changed the whole schedule last week. This new schedule confuses everyone about the times.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('oxford comma', () => {
|
||||
it('flags a 3-item noun list missing the serial comma', () => {
|
||||
expectRule('My mother, my father and my sister came to visit us last summer.', 'oxford')
|
||||
})
|
||||
|
||||
it('flags a verb-phrase list missing the serial comma', () => {
|
||||
expectRule('We cleaned the kitchen, washed the dishes and took out the trash.', 'oxford')
|
||||
})
|
||||
|
||||
it('does not flag a list that already has the serial comma', () => {
|
||||
expectClean('I bought apples, bananas, and oranges at the store this morning.')
|
||||
})
|
||||
|
||||
it('does not flag a compound predicate as a list', () => {
|
||||
expectClean('After dinner, I went home and read a book until I fell asleep.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a / an', () => {
|
||||
it('flags "a" before a vowel sound', () => {
|
||||
expectRule('She found a umbrella and a old book on the table by the window.', 'article')
|
||||
})
|
||||
|
||||
it('flags "an" before a consonant sound', () => {
|
||||
expectRule('He gave me an book and an pencil to use during the long exam.', 'article')
|
||||
})
|
||||
|
||||
it('respects sound exceptions (a university, an hour)', () => {
|
||||
expectClean('He is a university student and it took an hour to arrive here.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('uncountable plurals', () => {
|
||||
it('flags a pluralized mass noun', () => {
|
||||
expectRule('She gave me many informations about the new job opening today.', 'uncountable')
|
||||
})
|
||||
|
||||
it('leaves the singular mass noun alone', () => {
|
||||
expectClean('He shared some useful advice and helped me with my homework tonight.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('capitalization of proper nouns', () => {
|
||||
it('flags lowercase languages and days', () => {
|
||||
expectRule('I am learning english and french during my free time every week.', 'propercap')
|
||||
expectRule('We will meet on monday to discuss the final project together soon.', 'propercap')
|
||||
})
|
||||
|
||||
it('does not flag ambiguous homographs (may, march)', () => {
|
||||
expectClean('They may visit in the spring and march through the old town square.')
|
||||
})
|
||||
|
||||
it('leaves already-capitalized proper nouns alone', () => {
|
||||
expectClean('I will study English and Chinese together this coming summer break here.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('subject–verb agreement', () => {
|
||||
it('flags a bare verb after he/she/it', () => {
|
||||
expectRule('My brother is busy but he go to the gym every single morning.', 'sva')
|
||||
expectRule('She have a lovely garden full of roses behind her small house.', 'sva')
|
||||
})
|
||||
|
||||
it('flags a sentence-initial subject too', () => {
|
||||
expectRule('It make me happy whenever the sun comes out after the rain.', 'sva')
|
||||
})
|
||||
|
||||
it('does not flag causative/perception frames (let it go, make it work)', () => {
|
||||
expectClean('Please let it go and try to enjoy the rest of your day.')
|
||||
expectClean('We should make it work before the big meeting tomorrow afternoon.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plural after a number', () => {
|
||||
it('flags a singular noun after a cardinal number', () => {
|
||||
expectRule('I bought three apple and some bread at the market this morning.', 'plural')
|
||||
})
|
||||
|
||||
it('does not flag an adjective between number and noun', () => {
|
||||
expectClean('We adopted two small dogs from the shelter near our apartment today.')
|
||||
})
|
||||
|
||||
it('does not flag irregular plurals (five people)', () => {
|
||||
expectClean('There were five people waiting patiently outside in the cold morning rain.')
|
||||
})
|
||||
|
||||
it('does not flag an already-plural noun', () => {
|
||||
expectClean('She has two cats and they sleep all day long on the couch.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('double determiner', () => {
|
||||
it('flags a stacked article + possessive', () => {
|
||||
expectRule('Please put the my book back on the shelf when you finish it.', 'doubledet')
|
||||
})
|
||||
})
|
||||
|
||||
describe('there is + plural', () => {
|
||||
it('flags "there is" with a plural quantifier', () => {
|
||||
expectRule('There is many reasons to visit the coast during the warm months.', 'thereis')
|
||||
})
|
||||
|
||||
it('does not flag "there is some" with a mass noun', () => {
|
||||
expectClean('There is some water left in the bottle if you are thirsty now.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('its / it’s', () => {
|
||||
it("flags it's used as a possessive", () => {
|
||||
expectRule("The cat licked it's own paw while sitting quietly by the window.", 'its')
|
||||
})
|
||||
|
||||
it('flags its used where "it is" is meant', () => {
|
||||
expectRule('I think its a wonderful idea to travel together next year sometime.', 'its')
|
||||
})
|
||||
|
||||
it('leaves correct possessive "its" alone', () => {
|
||||
expectClean('The dog wagged its tail happily when we came home that evening.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('than / then', () => {
|
||||
it('flags a comparative followed by "then"', () => {
|
||||
expectRule('This cake tastes much better then the one we made last weekend.', 'than')
|
||||
})
|
||||
|
||||
it('leaves sequential "then" alone', () => {
|
||||
expectClean('We ate dinner and then we watched a long movie on the sofa.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('comma after a transition', () => {
|
||||
it('flags a sentence-initial transition with no comma', () => {
|
||||
expectRule('However we decided to stay home and rest for the whole weekend.', 'introcomma')
|
||||
})
|
||||
|
||||
it('does not flag a sequence word (Then …)', () => {
|
||||
expectClean('Then we walked to the park and fed the ducks by the pond.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('spacing around punctuation', () => {
|
||||
it('flags a missing space after a comma', () => {
|
||||
expectRule('I like apples,bananas and grapes from the little shop down the street.', 'space-after')
|
||||
})
|
||||
|
||||
it('flags a space before punctuation', () => {
|
||||
expectRule('She smiled warmly , then walked away without saying a single word today.', 'space-punct')
|
||||
})
|
||||
|
||||
it('does not flag thousands separators in numbers', () => {
|
||||
expectClean('The list had 1,000 items and cost us about 2,500 dollars in total.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('capitalization basics', () => {
|
||||
it('flags a lowercase standalone "i"', () => {
|
||||
expectRule('Yesterday i walked to the store and bought milk, eggs, and bread.', 'cap-i')
|
||||
})
|
||||
|
||||
it('flags a sentence that does not start with a capital', () => {
|
||||
expectRule('The sun set slowly. it was a beautiful evening down by the river.', 'cap-sentence')
|
||||
})
|
||||
})
|
||||
577
web/src/components/Companion/prose.ts
Normal file
577
web/src/components/Companion/prose.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
// Rules-based prose checker — NO LLM. This is the companion's "smart" side: it
|
||||
// reads the writer's actual text and surfaces one gentle, context-aware grammar
|
||||
// or style note at a time, the way an attentive tutor leaning over her shoulder
|
||||
// would. Every rule here is intentionally conservative: a wrong nudge erodes
|
||||
// trust far faster than a missed one earns it, so we only speak up when a
|
||||
// pattern is a high-confidence, genuinely-common English mistake.
|
||||
//
|
||||
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting
|
||||
// a short slice of her own sentence so the advice clearly belongs to *this*
|
||||
// paragraph and not a generic tip jar.
|
||||
|
||||
import type { Line } from './tips'
|
||||
|
||||
export interface ProseHint extends Line {
|
||||
// Stable, content-derived id so the same untouched sentence isn't re-flagged
|
||||
// every cadence; the companion remembers recently-shown ids.
|
||||
id: string
|
||||
// Rule family, used to vary advice (don't show the same kind twice in a row).
|
||||
rule: string
|
||||
}
|
||||
|
||||
// ── small text helpers ──────────────────────────────────────────────────────
|
||||
|
||||
const ENGLISH_WORD_RE = /[A-Za-z]+(?:['’-][A-Za-z]+)*/g
|
||||
|
||||
// Split into sentence-ish chunks (text + leading punctuation trimmed). Mirrors
|
||||
// the SENTENCE_RE terminators used elsewhere, including CJK fullwidth forms.
|
||||
function sentences(text: string): string[] {
|
||||
const out: string[] = []
|
||||
const re = /[^.!?。!?]+[.!?。!?]*/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text))) {
|
||||
const s = m[0].trim()
|
||||
if (s) out.push(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// First few words of a sentence, as an anchor we can quote back to her.
|
||||
function anchor(s: string, n = 6): string {
|
||||
const words = s.split(/\s+/).slice(0, n).join(' ')
|
||||
return words.length < s.length ? `${words}…` : words
|
||||
}
|
||||
|
||||
// Cheap, stable hash → short id suffix, so ids stay stable across recomputes but
|
||||
// change the moment she edits the offending text.
|
||||
function key(s: string): string {
|
||||
return s.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 48)
|
||||
}
|
||||
|
||||
// ── individual rules ────────────────────────────────────────────────────────
|
||||
// Each rule pushes at most a couple of findings; the caller shows one at a time.
|
||||
|
||||
// Run-on / overly long sentences — the single most common readability problem
|
||||
// for ESL writers, who often chain clauses that a period would serve better.
|
||||
function runOns(text: string, out: ProseHint[]) {
|
||||
let found = 0
|
||||
for (const s of sentences(text)) {
|
||||
if (found >= 2) break
|
||||
const words = s.match(ENGLISH_WORD_RE)?.length ?? 0
|
||||
const commas = (s.match(/,/g) ?? []).length
|
||||
const longRun = words >= 32
|
||||
const commaHeavy = words >= 24 && commas >= 2
|
||||
if (longRun || commaHeavy) {
|
||||
found++
|
||||
out.push({
|
||||
id: `runon:${key(s)}`,
|
||||
rule: 'runon',
|
||||
zh: '这句话有点长啦,分成两三句会更清楚 🌸',
|
||||
en: `This one runs long — try splitting it: “${anchor(s)}”`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comma splice — two independent clauses joined by only a comma. We look for a
|
||||
// comma immediately followed by a subject pronoun + a finite verb, which is a
|
||||
// strong, low-false-positive signal of a spliced second clause.
|
||||
// A comma, then a subject pronoun starting a fresh clause, then its verb. We no
|
||||
// longer whitelist verbs (that lost too many real splices) — instead we take any
|
||||
// word after the pronoun and reject the function words that signal it *isn't* a
|
||||
// new clause (a conjunction/preposition list like “she and I”, “it of course”).
|
||||
const SPLICE_RE = /[a-z]\w*,\s+(I|we|they|he|she|it|you)\s+([a-z]+)\b/g
|
||||
const SPLICE_NONVERB = new Set([
|
||||
'and', 'or', 'but', 'nor', 'yet', 'so', 'to', 'too', 'also', 'of', 'in', 'on',
|
||||
'at', 'for', 'with', 'as', 'who', 'whom', 'which', 'that', 'than', 'then',
|
||||
'will', // “…, I will, …” is usually an aside, not a clean splice
|
||||
])
|
||||
|
||||
// First words that signal the lead is an *introductory* element (transition,
|
||||
// subordinate clause, or adverbial phrase), where a following “, subject verb”
|
||||
// is correct punctuation — not a splice. Guards against “However, we left.” and
|
||||
// “After dinner, I went home.”
|
||||
const INTRO_LEAD = new Set([
|
||||
'after', 'before', 'when', 'while', 'although', 'though', 'because', 'since',
|
||||
'if', 'as', 'until', 'unless', 'whenever', 'wherever', 'whereas', 'however',
|
||||
'therefore', 'moreover', 'furthermore', 'nevertheless', 'nonetheless',
|
||||
'meanwhile', 'consequently', 'otherwise', 'besides', 'instead', 'finally',
|
||||
'first', 'firstly', 'second', 'secondly', 'next', 'then', 'later', 'eventually',
|
||||
'afterwards', 'anyway', 'yesterday', 'today', 'tomorrow', 'honestly',
|
||||
'actually', 'suddenly', 'sometimes', 'usually', 'often', 'also', 'still',
|
||||
'now', 'here', 'there', 'well', 'yes', 'no', 'fortunately', 'unfortunately',
|
||||
'surprisingly', 'clearly', 'obviously', 'basically', 'generally', 'initially',
|
||||
'recently', 'currently', 'sadly', 'luckily', 'hopefully', 'frankly',
|
||||
'personally', 'overall', 'soon', 'once', 'during', 'in', 'on', 'at', 'for',
|
||||
'with', 'without', 'by', 'from', 'despite', 'throughout', 'given', 'regarding',
|
||||
])
|
||||
|
||||
// A genuine comma splice has an *independent clause* before the comma. We
|
||||
// approximate that by requiring the lead to be ≥3 words and not begin with an
|
||||
// introductory word — short or transition-led leads are intro phrases, not
|
||||
// spliced clauses. Precision over recall: a wrong nudge costs more than a miss.
|
||||
function commaSplices(text: string, out: ProseHint[]) {
|
||||
let found = 0
|
||||
for (const s of sentences(text)) {
|
||||
if (found >= 2) break
|
||||
SPLICE_RE.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = SPLICE_RE.exec(s)) && found < 2) {
|
||||
const commaIdx = s.indexOf(',', m.index)
|
||||
const lead = s.slice(0, commaIdx).trim()
|
||||
const words = lead.split(/\s+/).filter(Boolean)
|
||||
const firstWord = words[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? ''
|
||||
if (words.length < 3 || INTRO_LEAD.has(firstWord)) continue
|
||||
if (SPLICE_NONVERB.has(m[2].toLowerCase())) continue
|
||||
found++
|
||||
out.push({
|
||||
id: `splice:${key(m[0])}`,
|
||||
rule: 'splice',
|
||||
zh: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。',
|
||||
en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unclear antecedent — a sentence that opens with This/That/These/Those riding
|
||||
// straight into a verb, with no noun naming what it points back to. Only flag
|
||||
// when there's a prior sentence (so an antecedent is actually in question).
|
||||
const ANTECEDENT_RE =
|
||||
/^(This|That|These|Those)\s+(is|are|was|were|will|would|can|could|should|makes?|made|means?|shows?|showed|gives?|gave|causes?|caused|creates?|created|leads?|led|results?|happens?|happened|helps?|helped)\b/
|
||||
|
||||
function antecedents(text: string, out: ProseHint[]) {
|
||||
const list = sentences(text)
|
||||
let found = 0
|
||||
for (let i = 1; i < list.length && found < 2; i++) {
|
||||
const s = list[i]
|
||||
const m = s.match(ANTECEDENT_RE)
|
||||
if (m) {
|
||||
found++
|
||||
out.push({
|
||||
id: `antecedent:${key(s)}`,
|
||||
rule: 'antecedent',
|
||||
zh: `“${m[1]}” 指代不太清楚,最好点明它指的是什么(比如 “${m[1]} idea / change…”)。`,
|
||||
en: `“${m[1]}” here is a little vague — name what it refers to.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Oxford comma — a 3+ item list ending “… X and Y” with no comma before the
|
||||
// conjunction. Guarded by a clause-starter stoplist so we don't mistake a
|
||||
// compound clause (“…, but she and I…”) for a list.
|
||||
const OXFORD_RE = /(\w+),\s+([\w'’-]+(?:\s+[\w'’-]+){0,2})\s+(and|or)\s+[\w'’-]+/g
|
||||
const CLAUSE_STARTERS = new Set([
|
||||
'but', 'so', 'because', 'which', 'who', 'that', 'when', 'while', 'if',
|
||||
'although', 'though', 'since', 'as', 'and', 'or', 'nor', 'yet', 'then',
|
||||
'however', 'whereas',
|
||||
// Subject pronouns signal a clause, not a list item (“…, I went home and…”).
|
||||
'i', 'you', 'he', 'she', 'it', 'we', 'they',
|
||||
])
|
||||
|
||||
function oxford(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
let found = 0
|
||||
OXFORD_RE.lastIndex = 0
|
||||
while ((m = OXFORD_RE.exec(text)) && found < 1) {
|
||||
const firstWord = m[2].split(/\s+/)[0]?.toLowerCase() ?? ''
|
||||
if (CLAUSE_STARTERS.has(firstWord)) continue
|
||||
found++
|
||||
out.push({
|
||||
id: `oxford:${key(m[0])}`,
|
||||
rule: 'oxford',
|
||||
zh: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。',
|
||||
en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// a vs. an — chosen by the *sound* of the next word. We work only on lowercase
|
||||
// words (sidestepping proper nouns / acronyms) and keep sound-exception lists.
|
||||
const A_BEFORE_VOWEL_RE = /\ba\s+([aeiou][a-z]+)\b/g
|
||||
// Vowel-spelled but consonant-sounding → “a” is correct, don't flag.
|
||||
const CONSONANT_SOUND_RE = /^(uni|use|usu|util|euro?|eul|ewe|once|one|ubiqu|unanim)/
|
||||
const AN_BEFORE_CONSONANT_RE = /\ban\s+([b-df-hj-np-tv-z][a-z]+)\b/g
|
||||
// Consonant-spelled but vowel-sounding (silent h) → “an” is correct, don't flag.
|
||||
const VOWEL_SOUND_RE = /^(hour|honest|honou?r|heir|homage)/
|
||||
|
||||
function articles(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
let found = 0
|
||||
A_BEFORE_VOWEL_RE.lastIndex = 0
|
||||
while ((m = A_BEFORE_VOWEL_RE.exec(text)) && found < 1) {
|
||||
if (CONSONANT_SOUND_RE.test(m[1])) continue
|
||||
found++
|
||||
out.push({
|
||||
id: `article-an:${key(m[1])}`,
|
||||
rule: 'article',
|
||||
zh: `元音开头的词前用 “an”:“an ${m[1]}”。`,
|
||||
en: `Before a vowel sound, use “an”: “an ${m[1]}”.`,
|
||||
})
|
||||
}
|
||||
AN_BEFORE_CONSONANT_RE.lastIndex = 0
|
||||
while ((m = AN_BEFORE_CONSONANT_RE.exec(text)) && found < 2) {
|
||||
if (VOWEL_SOUND_RE.test(m[1])) continue
|
||||
found++
|
||||
out.push({
|
||||
id: `article-a:${key(m[1])}`,
|
||||
rule: 'article',
|
||||
zh: `辅音开头的词前用 “a”:“a ${m[1]}”。`,
|
||||
en: `Before a consonant sound, use “a”: “a ${m[1]}”.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Doubled word — “the the”, “is is”. Excludes the handful of doublings that can
|
||||
// be legitimate (“the fact that that happened”, “she had had enough”).
|
||||
const DOUBLE_RE = /\b([A-Za-z]+)\s+\1\b/gi
|
||||
const LEGIT_DOUBLES = new Set(['that', 'had'])
|
||||
|
||||
function doubles(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
let found = 0
|
||||
DOUBLE_RE.lastIndex = 0
|
||||
while ((m = DOUBLE_RE.exec(text)) && found < 1) {
|
||||
const w = m[1].toLowerCase()
|
||||
if (LEGIT_DOUBLES.has(w)) continue
|
||||
found++
|
||||
out.push({
|
||||
id: `double:${key(m[0])}`,
|
||||
rule: 'double',
|
||||
zh: `“${m[1]}” 好像写了两遍,检查一下哦。`,
|
||||
en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Lowercase standalone “I”. Conservative: only when bounded by spaces / line
|
||||
// edge, to avoid tangling with “i.e.” and the like.
|
||||
const LOWER_I_RE = /(?:^|\s)i(?=\s|$)/m
|
||||
|
||||
function pronounI(text: string, out: ProseHint[]) {
|
||||
if (LOWER_I_RE.test(text)) {
|
||||
out.push({
|
||||
id: 'cap-i',
|
||||
rule: 'cap-i',
|
||||
zh: '英文里的 “I”(我)任何时候都要大写哦。',
|
||||
en: 'In English, “I” is always written as a capital letter.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sentence not starting with a capital. We look after a terminator, and ignore
|
||||
// CJK sentences (she writes Mandarin too — those don't take Latin capitals).
|
||||
const LOWER_START_RE = /[.!?]\s+([a-z])/
|
||||
|
||||
function sentenceCaps(text: string, out: ProseHint[]) {
|
||||
if (LOWER_START_RE.test(text)) {
|
||||
out.push({
|
||||
id: 'cap-sentence',
|
||||
rule: 'cap-sentence',
|
||||
zh: '每个句子的开头,用大写字母开始吧。',
|
||||
en: 'Start each new sentence with a capital letter.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A stray space *before* punctuation (a common habit carried from CJK spacing).
|
||||
const SPACE_BEFORE_PUNCT_RE = /[A-Za-z] +([,;:!?]|\.(?!\.))/
|
||||
|
||||
function spaceBeforePunct(text: string, out: ProseHint[]) {
|
||||
if (SPACE_BEFORE_PUNCT_RE.test(text)) {
|
||||
out.push({
|
||||
id: 'space-punct',
|
||||
rule: 'space-punct',
|
||||
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
||||
en: 'No space before punctuation — it tucks right against the word.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chinese-L1 interference rules (Tier 1) ──────────────────────────────────
|
||||
// Mandarin lacks plural inflection, articles, and verb agreement, so these
|
||||
// patterns are the predictable places that grammar "leaks" into her English.
|
||||
// All are closed-list or strong-signal to keep false positives near zero.
|
||||
|
||||
// Mass nouns that Mandarin speakers very commonly pluralize. These words are
|
||||
// essentially never valid with a trailing “s”, so the list is its own guard.
|
||||
const UNCOUNTABLE_PLURAL_RE =
|
||||
/\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/i
|
||||
|
||||
function uncountables(text: string, out: ProseHint[]) {
|
||||
const m = UNCOUNTABLE_PLURAL_RE.exec(text)
|
||||
UNCOUNTABLE_PLURAL_RE.lastIndex = 0
|
||||
if (m) {
|
||||
const singular = m[1].replace(/s$/i, '')
|
||||
out.push({
|
||||
id: `uncountable:${m[1].toLowerCase()}`,
|
||||
rule: 'uncountable',
|
||||
zh: `“${m[1]}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
||||
en: `“${m[1]}” is uncountable — drop the “s”: just “${singular}”.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Languages, nationalities, days, and months are proper nouns in English but
|
||||
// not in Mandarin, so they're routinely left lowercase. We list only the
|
||||
// unambiguous ones — “may/march/august/china/turkey/polish” are skipped because
|
||||
// they're also ordinary lowercase words and would misfire.
|
||||
const PROPER_NOUNS =
|
||||
'monday|tuesday|wednesday|thursday|friday|saturday|sunday|' +
|
||||
'january|february|april|june|july|september|october|november|december|' +
|
||||
'english|chinese|mandarin|cantonese|japanese|korean|french|spanish|german|' +
|
||||
'italian|russian|portuguese|arabic|vietnamese|thai|american|british|canadian|' +
|
||||
'australian|mexican|brazilian|european'
|
||||
// Case-sensitive (lowercase-only) so already-capitalized words aren't flagged.
|
||||
const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`)
|
||||
|
||||
function properCaps(text: string, out: ProseHint[]) {
|
||||
const m = PROPER_NOUN_RE.exec(text)
|
||||
if (m) {
|
||||
const fixed = m[1][0].toUpperCase() + m[1].slice(1)
|
||||
out.push({
|
||||
id: `propercap:${m[1]}`,
|
||||
rule: 'propercap',
|
||||
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
|
||||
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Third-person singular verb form, for the agreement rule's suggestion.
|
||||
function third(verb: string): string {
|
||||
const v = verb.toLowerCase()
|
||||
const irregular: Record<string, string> = { have: 'has', do: 'does', go: 'goes' }
|
||||
if (irregular[v]) return irregular[v]
|
||||
if (/[^aeiou]y$/.test(v)) return v.slice(0, -1) + 'ies'
|
||||
if (/(s|x|z|ch|sh)$/.test(v)) return v + 'es'
|
||||
return v + 's'
|
||||
}
|
||||
|
||||
// Subject–verb agreement after he/she/it: a bare (base-form) verb where the
|
||||
// third-person “-s” form is required. We skip the causative/perception frames
|
||||
// (“let it go”, “make it work”, “help it grow”) where the bare verb is correct.
|
||||
const SVA_BASE =
|
||||
'go|have|do|make|like|want|need|know|think|say|see|feel|get|take|come|give|' +
|
||||
'run|play|work|live|look|seem|become|bring|buy|find|keep|leave|tell|try|call|' +
|
||||
'move|turn|put|mean|show|use|love|hope|wish|believe|understand|remember|enjoy'
|
||||
// The preceding word is optional so a sentence-initial subject (“She have…”)
|
||||
// still matches; when present it lets us skip causative/perception frames.
|
||||
const SVA_RE = new RegExp(`(?:(\\w+)\\s+)?\\b(he|she|it)\\s+(${SVA_BASE})\\b`, 'gi')
|
||||
const CAUSATIVE = new Set([
|
||||
'let', 'lets', 'make', 'makes', 'made', 'help', 'helps', 'helped', 'watch',
|
||||
'watches', 'watched', 'see', 'sees', 'saw', 'hear', 'heard', 'have', 'has',
|
||||
'had', 'let’s', "let's",
|
||||
])
|
||||
|
||||
function subjectVerbAgreement(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
let found = 0
|
||||
SVA_RE.lastIndex = 0
|
||||
while ((m = SVA_RE.exec(text)) && found < 2) {
|
||||
if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue
|
||||
found++
|
||||
const fixed = third(m[3])
|
||||
out.push({
|
||||
id: `sva:${m[2].toLowerCase()}:${m[3].toLowerCase()}`,
|
||||
rule: 'sva',
|
||||
zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`,
|
||||
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Singular noun after a cardinal number (“three apple”). Adjectives between the
|
||||
// number and noun would misfire (“two small dogs”), so we only flag when the
|
||||
// candidate is followed by a clause boundary — a verb, preposition, article, or
|
||||
// punctuation — which means it's the noun itself, not a modifier.
|
||||
const NUMBER_NOUN_RE =
|
||||
/\b(two|three|four|five|six|seven|eight|nine|ten)\s+([a-z]{3,})\b(?=\s*(?:[.,!?;:]|$|\s(?:is|are|was|were|and|or|but|that|which|who|in|on|at|to|for|with|will|can|could|would|should|the|a|an)\b))/g
|
||||
// Irregular plurals / mass nouns that are correct without an “s”.
|
||||
const NON_S_PLURAL = new Set([
|
||||
'people', 'children', 'men', 'women', 'fish', 'deer', 'sheep', 'feet', 'teeth',
|
||||
'mice', 'geese', 'police', 'staff', 'series', 'species', 'aircraft', 'cattle',
|
||||
'hundred', 'thousand', 'million', 'billion', 'dozen', 'percent',
|
||||
])
|
||||
|
||||
function pluralAfterNumber(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
let found = 0
|
||||
NUMBER_NOUN_RE.lastIndex = 0
|
||||
while ((m = NUMBER_NOUN_RE.exec(text)) && found < 1) {
|
||||
const noun = m[2].toLowerCase()
|
||||
if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue
|
||||
found++
|
||||
out.push({
|
||||
id: `plural:${key(m[0])}`,
|
||||
rule: 'plural',
|
||||
zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
|
||||
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Two stacked determiners (“the my book”, “a the”) — Mandarin uses a bare
|
||||
// possessive, so the article often gets stacked on top. One of the two must go.
|
||||
const DOUBLE_DET_RE =
|
||||
/\b(a|an|the)\s+(a|an|the|my|your|his|her|its|our|their)\b/gi
|
||||
|
||||
function doubleDeterminer(text: string, out: ProseHint[]) {
|
||||
const m = DOUBLE_DET_RE.exec(text)
|
||||
DOUBLE_DET_RE.lastIndex = 0
|
||||
if (m) {
|
||||
out.push({
|
||||
id: `doubledet:${key(m[0])}`,
|
||||
rule: 'doubledet',
|
||||
zh: `“${m[1]} ${m[2]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
|
||||
en: `“${m[1]} ${m[2]}” stacks two determiners — keep just one.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// “there is” with a plural quantifier → should be “there are”. We trigger only
|
||||
// on clearly-plural cues (skip “some/any”, which are fine with singular mass
|
||||
// nouns: “there is some water”).
|
||||
const THERE_IS_RE =
|
||||
/\bthere(?:\s+is|'s|’s)\s+(many|several|numerous|various|few|two|three|four|five|six|seven|eight|nine|ten)\b/i
|
||||
|
||||
function thereIsPlural(text: string, out: ProseHint[]) {
|
||||
const m = THERE_IS_RE.exec(text)
|
||||
THERE_IS_RE.lastIndex = 0
|
||||
if (m) {
|
||||
out.push({
|
||||
id: `thereis:${m[1].toLowerCase()}`,
|
||||
rule: 'thereis',
|
||||
zh: `后面是复数时用 “there are”:“there are ${m[1]}…”。`,
|
||||
en: `With a plural, use “there are”: “there are ${m[1]}…”.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── confusables (Tier 2) ─────────────────────────────────────────────────────
|
||||
|
||||
// its / it's — only the two unambiguous directions: “it's own” (always its own)
|
||||
// and “its a/an” (the possessive can't take an article → it's a/an).
|
||||
const ITS_OWN_RE = /\bit's\s+own\b/i
|
||||
const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/i
|
||||
|
||||
function itsConfusion(text: string, out: ProseHint[]) {
|
||||
if (ITS_OWN_RE.test(text)) {
|
||||
out.push({
|
||||
id: 'its-own',
|
||||
rule: 'its',
|
||||
zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
||||
en: '“it’s” means “it is” — the possessive is “its”: “its own”.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const m = ITS_ARTICLE_RE.exec(text)
|
||||
ITS_ARTICLE_RE.lastIndex = 0
|
||||
if (m) {
|
||||
out.push({
|
||||
id: 'its-article',
|
||||
rule: 'its',
|
||||
zh: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`,
|
||||
en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Comparative + “then” where “than” is meant (“better then”, “more then”). We
|
||||
// use an explicit comparative list rather than a generic “-er” pattern, which
|
||||
// would snag “after then”, “however”, “remember”, etc.
|
||||
const COMPARATIVES =
|
||||
'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' +
|
||||
'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' +
|
||||
'earlier|later|sooner|longer|shorter|taller|richer|poorer|happier|safer'
|
||||
const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})\\s+then\\b`, 'i')
|
||||
|
||||
function thanThen(text: string, out: ProseHint[]) {
|
||||
const m = THAN_THEN_RE.exec(text)
|
||||
if (m) {
|
||||
out.push({
|
||||
id: `than:${m[1].toLowerCase()}`,
|
||||
rule: 'than',
|
||||
zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`,
|
||||
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A transition word opening a sentence with no comma after it (“However we…”).
|
||||
// Limited to conjunctive adverbs that strongly want the comma — sequence words
|
||||
// like “Then/First/Finally” are left out (their comma is optional).
|
||||
const INTRO_RE =
|
||||
/^(However|Therefore|Moreover|Furthermore|Nevertheless|Nonetheless|Meanwhile|Consequently|In addition|In conclusion|As a result|On the other hand|For example|For instance)\s+[A-Za-z]/
|
||||
|
||||
function introComma(text: string, out: ProseHint[]) {
|
||||
let found = false
|
||||
for (const s of sentences(text)) {
|
||||
const m = s.match(INTRO_RE)
|
||||
if (m) {
|
||||
out.push({
|
||||
id: `introcomma:${m[1].toLowerCase()}`,
|
||||
rule: 'introcomma',
|
||||
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
|
||||
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
|
||||
})
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// Missing space *after* a comma or sentence period (“apple,banana”, “end.Next”).
|
||||
// The comma case requires letters on both sides so numbers like 1,000 are safe;
|
||||
// the period case requires a real word boundary so “e.g.”/“U.S.” are skipped.
|
||||
const NO_SPACE_COMMA_RE = /[A-Za-z],[A-Za-z]/
|
||||
const NO_SPACE_PERIOD_RE = /[a-z]{2,}\.[A-Z][a-z]/
|
||||
|
||||
function spaceAfterPunct(text: string, out: ProseHint[]) {
|
||||
if (NO_SPACE_COMMA_RE.test(text) || NO_SPACE_PERIOD_RE.test(text)) {
|
||||
out.push({
|
||||
id: 'space-after',
|
||||
rule: 'space-after',
|
||||
zh: '逗号、句号后面要空一格,再接下一个词。',
|
||||
en: 'Add a space after a comma or period before the next word.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── orchestration ───────────────────────────────────────────────────────────
|
||||
|
||||
// Rules run in priority order — the ones the writer cares most about first, so
|
||||
// that when several fire at once the companion leads with the weightiest note.
|
||||
const RULES: Array<(text: string, out: ProseHint[]) => void> = [
|
||||
runOns,
|
||||
commaSplices,
|
||||
antecedents,
|
||||
oxford,
|
||||
articles,
|
||||
uncountables,
|
||||
properCaps,
|
||||
subjectVerbAgreement,
|
||||
pluralAfterNumber,
|
||||
doubleDeterminer,
|
||||
thereIsPlural,
|
||||
itsConfusion,
|
||||
thanThen,
|
||||
introComma,
|
||||
doubles,
|
||||
pronounI,
|
||||
sentenceCaps,
|
||||
spaceBeforePunct,
|
||||
spaceAfterPunct,
|
||||
]
|
||||
|
||||
// analyzeProse returns context-aware hints, highest-priority first. It bails on
|
||||
// text too short to advise on (mid-thought drafts shouldn't get picked apart).
|
||||
export function analyzeProse(text: string): ProseHint[] {
|
||||
const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0
|
||||
if (englishWords < 8) return []
|
||||
const out: ProseHint[] = []
|
||||
for (const rule of RULES) rule(text, out)
|
||||
return out
|
||||
}
|
||||
@@ -17,7 +17,8 @@ export const ENCOURAGEMENTS: Line[] = [
|
||||
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' },
|
||||
]
|
||||
|
||||
// Gentle, periodic writing/ESL tips. Surfaced only when nothing else is showing.
|
||||
// Gentle, generic writing/ESL tips — the fallback when the rules-based prose
|
||||
// checker (prose.ts) finds nothing concrete to point at in her current text.
|
||||
export const TIPS: Line[] = [
|
||||
{ zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
|
||||
{ zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
pick,
|
||||
type Line,
|
||||
} from './tips'
|
||||
import { analyzeProse } from './prose'
|
||||
import { playSound } from '../../audio/sounds'
|
||||
|
||||
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
||||
// otherwise to an emoji placeholder (see PetalCompanion).
|
||||
@@ -28,6 +30,9 @@ interface Signals {
|
||||
// suggestion — lets the companion react without a full event bus.
|
||||
editTick: number
|
||||
acceptTick: number
|
||||
// The document's plain text, used by the rules-based prose checker to offer
|
||||
// context-aware writing notes instead of only generic tips.
|
||||
text: string
|
||||
}
|
||||
|
||||
// Timing knobs (ms). Tuned to feel present but never naggy.
|
||||
@@ -35,14 +40,29 @@ const IDLE_MS = 75_000 // no edits → kitten naps
|
||||
const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
|
||||
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
|
||||
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
|
||||
const BUBBLE_MS = 6_500 // how long a bubble lingers
|
||||
const CHEER_MS = 4_500 // shorter for quick cheers
|
||||
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
|
||||
// how much there is to read, since she reads both the Mandarin and the English
|
||||
// (and tips now quote a slice of her own sentence, so they run longer).
|
||||
const BUBBLE_MS = 9_000 // baseline for a tip / break
|
||||
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages
|
||||
const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read
|
||||
const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever
|
||||
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
||||
const now = () => Date.now()
|
||||
|
||||
// Reading time for a bubble: a base floor by tone, stretched by the combined
|
||||
// length of the Mandarin + English lines so denser advice stays up long enough
|
||||
// to actually finish reading.
|
||||
function readBubbleMs(b: Bubble): number {
|
||||
const base = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
|
||||
const chars = b.zh.length + b.en.length
|
||||
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
|
||||
}
|
||||
|
||||
// useCompanion is the behavior engine: it watches writing signals and decides
|
||||
// when the kitten speaks, what mood it shows, and how to pace itself so the
|
||||
// companion feels alive without interrupting. UI-agnostic — returns state only.
|
||||
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Signals) {
|
||||
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Signals) {
|
||||
const [mood, setMood] = useState<Mood>('idle')
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null)
|
||||
|
||||
@@ -54,6 +74,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
const nextMilestone = useRef(0) // index into MILESTONES
|
||||
const sleeping = useRef(false)
|
||||
|
||||
// Latest text for the prose checker, read lazily by the heartbeat (kept in a
|
||||
// ref so per-keystroke changes don't re-arm the interval).
|
||||
const textRef = useRef(text)
|
||||
textRef.current = text
|
||||
// Recently-surfaced hint ids, so the same untouched sentence isn't re-flagged
|
||||
// each cadence. A small ring — old ids age out and may resurface later.
|
||||
const recentHints = useRef<string[]>([])
|
||||
const lastRule = useRef<string>('')
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const moodTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
@@ -67,15 +96,39 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
lastProactive.current = t
|
||||
}
|
||||
sleeping.current = false
|
||||
// A soft sound to match the bubble's mood — celebrations cheer, gentle
|
||||
// cheers sparkle, break nudges chime, and tips give a little bubble pop.
|
||||
playSound(
|
||||
opts?.celebrate ? 'cheer' : b.tone === 'cheer' ? 'shimmer' : b.tone === 'break' ? 'chime' : 'pop',
|
||||
)
|
||||
setBubble(b)
|
||||
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
const dur = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
|
||||
const dur = readBubbleMs(b)
|
||||
bubbleTimer.current = setTimeout(() => setBubble(null), dur)
|
||||
moodTimer.current = setTimeout(() => setMood('idle'), dur)
|
||||
}, [bubble])
|
||||
|
||||
// Choose the next spontaneous tip. Prefer a real, context-aware note from the
|
||||
// rules-based prose checker (it's actionable and clearly about *her* writing);
|
||||
// fall back to a generic encouragement-style tip when the text reads clean or
|
||||
// every finding was shown recently. Avoids repeating a finding or the same
|
||||
// rule family back-to-back so the companion never feels like a broken record.
|
||||
const nextTip = useCallback((): Bubble => {
|
||||
const hints = analyzeProse(textRef.current)
|
||||
const fresh = hints.find(
|
||||
(h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current,
|
||||
)
|
||||
const hint = fresh ?? hints.find((h) => !recentHints.current.includes(h.id))
|
||||
if (hint) {
|
||||
lastRule.current = hint.rule
|
||||
recentHints.current = [hint.id, ...recentHints.current].slice(0, 8)
|
||||
return { zh: hint.zh, en: hint.en, tone: 'tip' }
|
||||
}
|
||||
return { ...pick(TIPS), tone: 'tip' }
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
@@ -83,6 +136,21 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
setMood('idle')
|
||||
}, [])
|
||||
|
||||
// Pause the auto-dismiss while she hovers a bubble — so a longer note never
|
||||
// vanishes mid-read. Releasing leaves it up for a short, fixed grace so it
|
||||
// doesn't linger forever once she looks away.
|
||||
const holdBubble = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
}, [])
|
||||
|
||||
const releaseBubble = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
bubbleTimer.current = setTimeout(() => setBubble(null), HOVER_GRACE_MS)
|
||||
moodTimer.current = setTimeout(() => setMood('idle'), HOVER_GRACE_MS)
|
||||
}, [])
|
||||
|
||||
// Opening hello (once), after a short beat so it doesn't race the first paint.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
|
||||
@@ -161,14 +229,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise an occasional tip.
|
||||
// Otherwise an occasional tip — context-aware when her text gives us
|
||||
// something concrete to gently point at, generic warmth otherwise.
|
||||
if (t - lastTip.current > TIP_MIN_GAP) {
|
||||
lastTip.current = t
|
||||
say({ ...pick(TIPS), tone: 'tip' }, { proactive: true })
|
||||
say(nextTip(), { proactive: true })
|
||||
}
|
||||
}, 10_000)
|
||||
return () => clearInterval(id)
|
||||
}, [bubble, say])
|
||||
}, [bubble, say, nextTip])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -178,5 +247,5 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si
|
||||
[],
|
||||
)
|
||||
|
||||
return { mood, bubble, dismiss }
|
||||
return { mood, bubble, dismiss, holdBubble, releaseBubble }
|
||||
}
|
||||
|
||||
37
web/src/components/StatusBar/SoundToggle.tsx
Normal file
37
web/src/components/StatusBar/SoundToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSoundEnabled, onSoundEnabledChange, playSound, setSoundEnabled } from '../../audio/sounds'
|
||||
|
||||
// A tiny mute toggle for Petal's cute sounds, tucked at the right of the status
|
||||
// bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
|
||||
// when sounds are turned back on so the choice is audible.
|
||||
export function SoundToggle() {
|
||||
const [on, setOn] = useState(isSoundEnabled)
|
||||
|
||||
// Stay in sync if the setting is flipped elsewhere.
|
||||
useEffect(() => onSoundEnabledChange(setOn), [])
|
||||
|
||||
const toggle = () => {
|
||||
const next = !on
|
||||
setSoundEnabled(next)
|
||||
setOn(next)
|
||||
if (next) playSound('pop') // a little hello when re-enabled
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-pressed={on}
|
||||
title={on ? '声音开 · Sounds on' : '声音关 · Sounds off'}
|
||||
aria-label={on ? 'Mute Petal sounds' : 'Unmute Petal sounds'}
|
||||
className="rounded-full px-1.5 py-0.5 transition-colors"
|
||||
style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = on ? 'var(--color-accent)' : 'var(--color-muted)')
|
||||
}
|
||||
>
|
||||
<span aria-hidden>{on ? '🔔' : '🔕'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import { StatsPanel } from './StatsPanel'
|
||||
import { SoundToggle } from './SoundToggle'
|
||||
|
||||
interface Props {
|
||||
wordCount: number
|
||||
@@ -120,6 +121,9 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<SoundToggle />
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
267
web/src/effects/PetalFall.tsx
Normal file
267
web/src/effects/PetalFall.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// PetalFall is the cozy ambient layer: a full-window WebGL canvas that drifts a
|
||||
// handful of soft flower petals down the page. It sits behind everything,
|
||||
// ignores pointer events, and stays sparse so it reads as "occasional petals on
|
||||
// a breeze" rather than confetti. Honors prefers-reduced-motion by rendering
|
||||
// nothing. No external libraries — a single tiny shader program animates every
|
||||
// petal entirely on the GPU, so the CPU just spins a clock.
|
||||
|
||||
// Each petal is a quad whose corners we expand in the vertex shader. We pack one
|
||||
// quad as 6 vertices; per-vertex we send the corner offset (which corner) plus
|
||||
// the petal's static randomness (lane, speed, size, sway, tint, spin).
|
||||
const PETAL_COUNT = 26
|
||||
|
||||
// Soft Petal-palette tints (rose / blush / lavender), as RGB 0..1.
|
||||
const TINTS: [number, number, number][] = [
|
||||
[0.91, 0.63, 0.75], // accent rose #E8A0BF
|
||||
[1.0, 0.94, 0.96], // near-white blush
|
||||
[0.96, 0.72, 0.63], // peach #F4B8A0
|
||||
[0.77, 0.71, 0.91], // lavender #C5B4E8
|
||||
[0.85, 0.55, 0.69], // deeper rose
|
||||
]
|
||||
|
||||
const VERT = `
|
||||
precision mediump float;
|
||||
attribute vec2 a_corner; // quad corner in [-0.5,0.5]
|
||||
attribute vec4 a_rand; // x: lane(0..1), y: speed, z: size, w: phase
|
||||
attribute vec4 a_rand2; // x: swayAmp, y: swayFreq, z: spin, w: tintIdx
|
||||
uniform float u_time;
|
||||
uniform vec2 u_res;
|
||||
varying vec2 v_uv;
|
||||
varying float v_tint;
|
||||
varying float v_alpha;
|
||||
|
||||
void main() {
|
||||
v_uv = a_corner + 0.5;
|
||||
v_tint = a_rand2.w;
|
||||
|
||||
float lane = a_rand.x;
|
||||
float speed = a_rand.y;
|
||||
float size = a_rand.z;
|
||||
float phase = a_rand.w;
|
||||
|
||||
// Vertical fall: loop 0..1 over time, offset per petal so they don't sync.
|
||||
float fall = fract(u_time * speed + phase);
|
||||
// Map to clip space: start above the top (1.15) fall to below bottom (-1.15).
|
||||
float y = 1.15 - fall * 2.3;
|
||||
|
||||
// Horizontal: a base lane plus a gentle sway as it descends.
|
||||
float sway = a_rand2.x * sin(u_time * a_rand2.y + phase * 6.2831);
|
||||
float x = (lane * 2.0 - 1.0) + sway;
|
||||
|
||||
// Spin the quad over time.
|
||||
float ang = u_time * a_rand2.z + phase * 6.2831;
|
||||
float c = cos(ang), s = sin(ang);
|
||||
vec2 corner = a_corner * size;
|
||||
// Keep petals visually square despite aspect ratio.
|
||||
corner.x *= u_res.y / u_res.x;
|
||||
vec2 rot = vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
|
||||
|
||||
// Fade in near the top and out near the bottom so they don't pop.
|
||||
v_alpha = smoothstep(0.0, 0.12, fall) * (1.0 - smoothstep(0.86, 1.0, fall));
|
||||
|
||||
gl_Position = vec4(x + rot.x, y + rot.y, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `
|
||||
precision mediump float;
|
||||
varying vec2 v_uv;
|
||||
varying float v_tint;
|
||||
varying float v_alpha;
|
||||
uniform vec3 u_tints[5];
|
||||
|
||||
// A soft petal/teardrop alpha mask centered in the quad.
|
||||
float petalMask(vec2 uv) {
|
||||
vec2 p = uv - vec2(0.5);
|
||||
// Teardrop: pinch the top, round the bottom.
|
||||
float r = length(vec2(p.x * 1.65, p.y * 1.15 + 0.12));
|
||||
float body = smoothstep(0.5, 0.16, r);
|
||||
// A subtle crease down the middle for a petal feel.
|
||||
float crease = 1.0 - 0.18 * exp(-pow(p.x * 7.0, 2.0));
|
||||
return body * crease;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float m = petalMask(v_uv);
|
||||
if (m < 0.01) discard;
|
||||
int idx = int(v_tint + 0.5);
|
||||
vec3 col = u_tints[0];
|
||||
if (idx == 1) col = u_tints[1];
|
||||
else if (idx == 2) col = u_tints[2];
|
||||
else if (idx == 3) col = u_tints[3];
|
||||
else if (idx == 4) col = u_tints[4];
|
||||
gl_FragColor = vec4(col, m * v_alpha * 0.55);
|
||||
}
|
||||
`
|
||||
|
||||
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null {
|
||||
const sh = gl.createShader(type)
|
||||
if (!sh) return null
|
||||
gl.shaderSource(sh, src)
|
||||
gl.compileShader(sh)
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
console.error('petal shader compile failed', gl.getShaderInfoLog(sh))
|
||||
gl.deleteShader(sh)
|
||||
return null
|
||||
}
|
||||
return sh
|
||||
}
|
||||
|
||||
// Deterministic-ish pseudo random (we can't use Math.random at module scope for
|
||||
// SSR safety, but here in an effect it's fine; kept simple).
|
||||
function rand(): number {
|
||||
return Math.random()
|
||||
}
|
||||
|
||||
export function PetalFall() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const reduce =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
if (reduce) return // respect users who don't want ambient motion
|
||||
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: true })
|
||||
if (!gl) return // no WebGL — silently skip the effect
|
||||
|
||||
const vs = compile(gl, gl.VERTEX_SHADER, VERT)
|
||||
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG)
|
||||
if (!vs || !fs) return
|
||||
const prog = gl.createProgram()!
|
||||
gl.attachShader(prog, vs)
|
||||
gl.attachShader(prog, fs)
|
||||
gl.linkProgram(prog)
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||||
console.error('petal program link failed', gl.getProgramInfoLog(prog))
|
||||
return
|
||||
}
|
||||
gl.useProgram(prog)
|
||||
|
||||
// Build interleaved vertex data: 6 verts per petal, each with corner(2),
|
||||
// rand(4), rand2(4) = 10 floats.
|
||||
const FLOATS_PER_VERT = 10
|
||||
const corners = [
|
||||
[-0.5, -0.5],
|
||||
[0.5, -0.5],
|
||||
[-0.5, 0.5],
|
||||
[-0.5, 0.5],
|
||||
[0.5, -0.5],
|
||||
[0.5, 0.5],
|
||||
]
|
||||
const data = new Float32Array(PETAL_COUNT * 6 * FLOATS_PER_VERT)
|
||||
let o = 0
|
||||
for (let i = 0; i < PETAL_COUNT; i++) {
|
||||
const lane = rand()
|
||||
const speed = 0.018 + rand() * 0.03 // slow, gentle fall
|
||||
const size = 0.05 + rand() * 0.06
|
||||
const phase = rand()
|
||||
const swayAmp = 0.04 + rand() * 0.09
|
||||
const swayFreq = 0.4 + rand() * 0.7
|
||||
const spin = (rand() - 0.5) * 1.2
|
||||
const tint = Math.floor(rand() * TINTS.length)
|
||||
for (let c = 0; c < 6; c++) {
|
||||
data[o++] = corners[c][0]
|
||||
data[o++] = corners[c][1]
|
||||
data[o++] = lane
|
||||
data[o++] = speed
|
||||
data[o++] = size
|
||||
data[o++] = phase
|
||||
data[o++] = swayAmp
|
||||
data[o++] = swayFreq
|
||||
data[o++] = spin
|
||||
data[o++] = tint
|
||||
}
|
||||
}
|
||||
|
||||
const buf = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
|
||||
|
||||
const stride = FLOATS_PER_VERT * 4
|
||||
const aCorner = gl.getAttribLocation(prog, 'a_corner')
|
||||
const aRand = gl.getAttribLocation(prog, 'a_rand')
|
||||
const aRand2 = gl.getAttribLocation(prog, 'a_rand2')
|
||||
gl.enableVertexAttribArray(aCorner)
|
||||
gl.vertexAttribPointer(aCorner, 2, gl.FLOAT, false, stride, 0)
|
||||
gl.enableVertexAttribArray(aRand)
|
||||
gl.vertexAttribPointer(aRand, 4, gl.FLOAT, false, stride, 2 * 4)
|
||||
gl.enableVertexAttribArray(aRand2)
|
||||
gl.vertexAttribPointer(aRand2, 4, gl.FLOAT, false, stride, 6 * 4)
|
||||
|
||||
const uTime = gl.getUniformLocation(prog, 'u_time')
|
||||
const uRes = gl.getUniformLocation(prog, 'u_res')
|
||||
const uTints = gl.getUniformLocation(prog, 'u_tints')
|
||||
gl.uniform3fv(uTints, TINTS.flat())
|
||||
|
||||
gl.enable(gl.BLEND)
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
gl.disable(gl.DEPTH_TEST)
|
||||
|
||||
// Non-null aliases so the render closures keep the narrowed types.
|
||||
const view = canvas
|
||||
const glc = gl
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
function resize() {
|
||||
const w = Math.floor(window.innerWidth * dpr)
|
||||
const h = Math.floor(window.innerHeight * dpr)
|
||||
if (view.width !== w || view.height !== h) {
|
||||
view.width = w
|
||||
view.height = h
|
||||
}
|
||||
glc.viewport(0, 0, view.width, view.height)
|
||||
glc.uniform2f(uRes, view.width, view.height)
|
||||
}
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
let raf = 0
|
||||
let running = true
|
||||
const start = performance.now()
|
||||
function frame() {
|
||||
if (!running) return
|
||||
const t = (performance.now() - start) / 1000
|
||||
glc.clearColor(0, 0, 0, 0)
|
||||
glc.clear(glc.COLOR_BUFFER_BIT)
|
||||
glc.uniform1f(uTime, t)
|
||||
glc.drawArrays(glc.TRIANGLES, 0, PETAL_COUNT * 6)
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
// Pause when the tab is hidden to save the battery.
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
running = false
|
||||
cancelAnimationFrame(raf)
|
||||
} else if (!running) {
|
||||
running = true
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
raf = requestAnimationFrame(frame)
|
||||
|
||||
return () => {
|
||||
running = false
|
||||
cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', resize)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
gl.deleteBuffer(buf)
|
||||
gl.deleteProgram(prog)
|
||||
gl.deleteShader(vs)
|
||||
gl.deleteShader(fs)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden
|
||||
className="petal-fall pointer-events-none fixed inset-0"
|
||||
style={{ zIndex: 20 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -382,7 +382,31 @@ button, a, input {
|
||||
only the title and the writing itself reach the page. This is Petal's PDF
|
||||
path — it uses the browser's own fonts, so CJK renders correctly with no
|
||||
server-side font embedding. */
|
||||
/* --- Ambient falling petals -------------------------------------------------
|
||||
The WebGL petal layer is a fixed, non-interactive overlay that drifts soft
|
||||
blossoms over the page. Kept gentle (low opacity) so it never competes with
|
||||
the writing. It hides itself in print and is skipped entirely for users who
|
||||
prefer reduced motion (the component renders nothing in that case). */
|
||||
.petal-fall {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Honor reduced-motion globally: still the looping ambient animations and drop
|
||||
the petal layer. One-shot entrance/feedback animations are left intact. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.petal-fall {
|
||||
display: none !important;
|
||||
}
|
||||
.petal-bob,
|
||||
.petal-bob-slow,
|
||||
.petal-zzz,
|
||||
.petal-checkpoint-dot {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.petal-fall,
|
||||
.petal-no-print,
|
||||
.petal-sidebar,
|
||||
.petal-suggestion-card,
|
||||
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user