fix(typy): make word transitions immediate and fast typing forgiving
ci / test (pull_request) Successful in 53s
ci / test (pull_request) Successful in 53s
This commit is contained in:
1 parent
5c8d0d9889
commit
7ef789f294
13 files changed
+1052
-62
No files matched your search
+5
-1
@@ -8,7 +8,8 @@ One word at a time. Type it, find the next one, settle into a rhythm.
|
||||
## Play
|
||||
|
||||
- 30 seconds, 60 seconds, 50 words, endless, and tempo.
|
||||
- The first typed character starts the run. Backspace fixes mistakes. Space advances; settings can advance on the last correct letter instead.
|
||||
- The first typed character starts the run. Backspace fixes mistakes. Space advances; settings can advance on the last correct letter instead. Automatic advancement tolerates one habitual Space immediately afterward without adding an error or extra output.
|
||||
- Word changes are immediate and stationary. Letter feedback reuses the rendered glyphs; word fitting runs only when the word or viewport changes. Idle stages do no repeated DOM work, and the optional next-word preview sits close to the current target.
|
||||
- Tempo waits for each word's beat, accelerates by 3.5% on success, and ends after three missed beats. Its starting pace comes from the device's last measured average.
|
||||
- Common words are weighted by frequency. The words menu also accepts a topic or a pasted passage, capped at 300 words.
|
||||
- Topic paths such as **/react-hooks** generate a short Venice passage and cache it for 30 days.
|
||||
@@ -36,9 +37,12 @@ Optional browser acceptance uses Playwright and a Chromium executable:
|
||||
|
||||
CHROME=/path/to/chrome node test/browser-smoke.mjs
|
||||
CHROME=/path/to/chrome node test/integration.mjs
|
||||
CHROME=/path/to/chrome node test/responsiveness.mjs
|
||||
|
||||
The integration script uses the actual local backend, creates a test result, reloads its public link, checks the preview PNG, replays it, and opens the ghost race. Set TYPY_BASE_URL to test a deployment; it creates one clearly named test replay and does not submit a synthetic daily-board score.
|
||||
|
||||
The responsiveness script keeps normal motion enabled and checks immediate readable word transitions, idle rendering, fast spaced input in both advancement modes, and phone long-word layout. Engine tests also verify exact 100/160 WPM input streams in 30/60-second runs without changing the scoring formula.
|
||||
|
||||
The build bundles the HTML and static source into Worker ES modules. Commit both the authored files and generated **src/page.js** and **src/assets.js**. Deployment refuses mismatched generated files.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:8789/",
|
||||
"sourceHash": "392ce9b1a3b4e0f433ee09ff2b0491aed25bd038d2efc157a1545398756a182a",
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
},
|
||||
"motion": "no-preference",
|
||||
"phases": [
|
||||
{
|
||||
"phase": "ready-idle",
|
||||
"durationMs": 1505.5,
|
||||
"textWrites": 0,
|
||||
"unchangedTextWrites": 0,
|
||||
"innerWrites": 0,
|
||||
"layoutReads": 0,
|
||||
"layoutReadMs": 0,
|
||||
"styleReads": 0,
|
||||
"animations": 0,
|
||||
"keyCount": 0,
|
||||
"keyHandlerMedianMs": 0,
|
||||
"keyHandlerP95Ms": 0,
|
||||
"keyHandlerMaxMs": 0,
|
||||
"keyToRafP95Ms": 0,
|
||||
"frameCount": 91,
|
||||
"framesOver25ms": 0,
|
||||
"framesOver50ms": 0,
|
||||
"firstKeyOpacity": [],
|
||||
"byId": {},
|
||||
"cdp": {
|
||||
"LayoutCount": 0,
|
||||
"LayoutDuration": 0,
|
||||
"RecalcStyleCount": 0,
|
||||
"RecalcStyleDuration": 0,
|
||||
"ScriptDuration": 0.003116,
|
||||
"TaskDuration": 0.016005
|
||||
}
|
||||
},
|
||||
{
|
||||
"phase": "native-typing",
|
||||
"targetWpm": 100,
|
||||
"displayWpm": "98",
|
||||
"durationMs": 17136.600000000093,
|
||||
"textWrites": 485,
|
||||
"unchangedTextWrites": 0,
|
||||
"innerWrites": 0,
|
||||
"layoutReads": 56,
|
||||
"layoutReadMs": 10.099999998230487,
|
||||
"styleReads": 0,
|
||||
"animations": 0,
|
||||
"keyCount": 140,
|
||||
"keyHandlerMedianMs": 0.20000000018626451,
|
||||
"keyHandlerP95Ms": 0.7999999998137355,
|
||||
"keyHandlerMaxMs": 1.2000000001862645,
|
||||
"keyToRafP95Ms": 16.299999999813735,
|
||||
"frameCount": 1029,
|
||||
"framesOver25ms": 0,
|
||||
"framesOver50ms": 0,
|
||||
"firstKeyOpacity": [
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "calm",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "river",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "moves",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "through",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "city",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "and",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "small",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "bird",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "follows",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "wind",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "calm",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "river",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "moves",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "through",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "city",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "and",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "small",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "bird",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "follows",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "wind",
|
||||
"opacity": 1
|
||||
}
|
||||
],
|
||||
"byId": {
|
||||
"run-status": 27,
|
||||
"SPAN": 113,
|
||||
"live-wpm": 272,
|
||||
"word-number": 28,
|
||||
"next-word": 28,
|
||||
"live-time": 17
|
||||
},
|
||||
"cdp": {
|
||||
"LayoutCount": 301,
|
||||
"LayoutDuration": 0.066462,
|
||||
"RecalcStyleCount": 301,
|
||||
"RecalcStyleDuration": 0.023637,
|
||||
"ScriptDuration": 0.091447,
|
||||
"TaskDuration": 0.605251
|
||||
}
|
||||
},
|
||||
{
|
||||
"phase": "native-typing",
|
||||
"targetWpm": 160,
|
||||
"displayWpm": "157",
|
||||
"durationMs": 10791.30000000028,
|
||||
"textWrites": 230,
|
||||
"unchangedTextWrites": 0,
|
||||
"innerWrites": 0,
|
||||
"layoutReads": 56,
|
||||
"layoutReadMs": 10.999999999068677,
|
||||
"styleReads": 0,
|
||||
"animations": 0,
|
||||
"keyCount": 140,
|
||||
"keyHandlerMedianMs": 0.19999999972060323,
|
||||
"keyHandlerP95Ms": 0.7999999998137355,
|
||||
"keyHandlerMaxMs": 1,
|
||||
"keyToRafP95Ms": 16.600000000093132,
|
||||
"frameCount": 647,
|
||||
"framesOver25ms": 0,
|
||||
"framesOver50ms": 0,
|
||||
"firstKeyOpacity": [
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "calm",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "river",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "moves",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "through",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "city",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "and",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "small",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "bird",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "follows",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "wind",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "calm",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "river",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "moves",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "through",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "city",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "and",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "small",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "bird",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "follows",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"word": "wind",
|
||||
"opacity": 1
|
||||
}
|
||||
],
|
||||
"byId": {
|
||||
"run-status": 27,
|
||||
"SPAN": 113,
|
||||
"word-number": 28,
|
||||
"next-word": 28,
|
||||
"live-wpm": 24,
|
||||
"live-time": 10
|
||||
},
|
||||
"cdp": {
|
||||
"LayoutCount": 168,
|
||||
"LayoutDuration": 0.03789000000000001,
|
||||
"RecalcStyleCount": 168,
|
||||
"RecalcStyleDuration": 0.019742999999999997,
|
||||
"ScriptDuration": 0.05216299999999999,
|
||||
"TaskDuration": 0.38729800000000003
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
{
|
||||
"url": "https://typy.yolopreneur.lol/",
|
||||
"sourceHash": "0d508e9af9db923759286aefcbf7a2bd0c7ddf583a75488317c15685e3ba2128",
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
},
|
||||
"motion": "no-preference",
|
||||
"phases": [
|
||||
{
|
||||
"phase": "ready-idle",
|
||||
"durationMs": 1505.8000000002794,
|
||||
"textWrites": 630,
|
||||
"unchangedTextWrites": 630,
|
||||
"innerWrites": 0,
|
||||
"layoutReads": 0,
|
||||
"layoutReadMs": 0,
|
||||
"styleReads": 0,
|
||||
"animations": 0,
|
||||
"keyCount": 0,
|
||||
"keyHandlerMedianMs": 0,
|
||||
"keyHandlerP95Ms": 0,
|
||||
"keyHandlerMaxMs": 0,
|
||||
"keyToRafP95Ms": 0,
|
||||
"frameCount": 90,
|
||||
"framesOver25ms": 0,
|
||||
"framesOver50ms": 0,
|
||||
"firstKeyOpacity": [],
|
||||
"byId": {
|
||||
"live-wpm": 90,
|
||||
"live-accuracy": 90,
|
||||
"run-status": 90,
|
||||
"SPAN": 90,
|
||||
"time-label": 90,
|
||||
"live-time": 90,
|
||||
"time-unit": 90
|
||||
},
|
||||
"cdp": {
|
||||
"LayoutCount": 90,
|
||||
"LayoutDuration": 0.016385,
|
||||
"RecalcStyleCount": 90,
|
||||
"RecalcStyleDuration": 0.002776,
|
||||
"ScriptDuration": 0.009908,
|
||||
"TaskDuration": 0.066637
|
||||
}
|
||||
},
|
||||
{
|
||||
"phase": "native-typing",
|
||||
"targetWpm": 100,
|
||||
"displayWpm": "98",
|
||||
"durationMs": 17134.299999999814,
|
||||
"textWrites": 8477,
|
||||
"unchangedTextWrites": 7943,
|
||||
"innerWrites": 140,
|
||||
"layoutReads": 280,
|
||||
"layoutReadMs": 33.799999991897494,
|
||||
"styleReads": 28,
|
||||
"animations": 56,
|
||||
"keyCount": 140,
|
||||
"keyHandlerMedianMs": 0.5,
|
||||
"keyHandlerP95Ms": 0.8000000002793968,
|
||||
"keyHandlerMaxMs": 1.2999999998137355,
|
||||
"keyToRafP95Ms": 15.800000000279397,
|
||||
"frameCount": 1027,
|
||||
"framesOver25ms": 1,
|
||||
"framesOver50ms": 0,
|
||||
"firstKeyOpacity": [
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 0.999415
|
||||
},
|
||||
{
|
||||
"word": "calm",
|
||||
"opacity": 0.819535
|
||||
},
|
||||
{
|
||||
"word": "river",
|
||||
"opacity": 0.819348
|
||||
},
|
||||
{
|
||||
"word": "moves",
|
||||
"opacity": 0.81933
|
||||
},
|
||||
{
|
||||
"word": "through",
|
||||
"opacity": 0.819312
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 0.819339
|
||||
},
|
||||
{
|
||||
"word": "city",
|
||||
"opacity": 0.739275
|
||||
},
|
||||
{
|
||||
"word": "and",
|
||||
"opacity": 0.739571
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 0.7395
|
||||
},
|
||||
{
|
||||
"word": "small",
|
||||
"opacity": 0.739039
|
||||
},
|
||||
{
|
||||
"word": "bird",
|
||||
"opacity": 0.73953
|
||||
},
|
||||
{
|
||||
"word": "follows",
|
||||
"opacity": 0.819312
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 0.739172
|
||||
},
|
||||
{
|
||||
"word": "wind",
|
||||
"opacity": 0.819276
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 0.738886
|
||||
},
|
||||
{
|
||||
"word": "calm",
|
||||
"opacity": 0.739449
|
||||
},
|
||||
{
|
||||
"word": "river",
|
||||
"opacity": 0.81924
|
||||
},
|
||||
{
|
||||
"word": "moves",
|
||||
"opacity": 0.819222
|
||||
},
|
||||
{
|
||||
"word": "through",
|
||||
"opacity": 0.819205
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 0.73905
|
||||
},
|
||||
{
|
||||
"word": "city",
|
||||
"opacity": 0.819169
|
||||
},
|
||||
{
|
||||
"word": "and",
|
||||
"opacity": 0.739275
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 0.819366
|
||||
},
|
||||
{
|
||||
"word": "small",
|
||||
"opacity": 0.739254
|
||||
},
|
||||
{
|
||||
"word": "bird",
|
||||
"opacity": 0.739234
|
||||
},
|
||||
{
|
||||
"word": "follows",
|
||||
"opacity": 0.739019
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 0.819079
|
||||
},
|
||||
{
|
||||
"word": "wind",
|
||||
"opacity": 0.738978
|
||||
}
|
||||
],
|
||||
"byId": {
|
||||
"word-number": 140,
|
||||
"next-word": 140,
|
||||
"live-wpm": 1167,
|
||||
"live-accuracy": 1167,
|
||||
"run-status": 1167,
|
||||
"SPAN": 1167,
|
||||
"time-label": 1167,
|
||||
"live-time": 1167,
|
||||
"time-unit": 1167,
|
||||
"previous-word": 28
|
||||
},
|
||||
"cdp": {
|
||||
"LayoutCount": 1307,
|
||||
"LayoutDuration": 0.238114,
|
||||
"RecalcStyleCount": 1363,
|
||||
"RecalcStyleDuration": 0.064145,
|
||||
"ScriptDuration": 0.172095,
|
||||
"TaskDuration": 1.126886
|
||||
}
|
||||
},
|
||||
{
|
||||
"phase": "native-typing",
|
||||
"targetWpm": 160,
|
||||
"displayWpm": "155",
|
||||
"durationMs": 10818.69999999972,
|
||||
"textWrites": 5831,
|
||||
"unchangedTextWrites": 5286,
|
||||
"innerWrites": 140,
|
||||
"layoutReads": 280,
|
||||
"layoutReadMs": 35.599999997299165,
|
||||
"styleReads": 28,
|
||||
"animations": 56,
|
||||
"keyCount": 140,
|
||||
"keyHandlerMedianMs": 0.5,
|
||||
"keyHandlerP95Ms": 0.8000000002793968,
|
||||
"keyHandlerMaxMs": 2.2999999998137355,
|
||||
"keyToRafP95Ms": 15.799999999813735,
|
||||
"frameCount": 649,
|
||||
"framesOver25ms": 0,
|
||||
"framesOver50ms": 0,
|
||||
"firstKeyOpacity": [
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 0.999416
|
||||
},
|
||||
{
|
||||
"word": "calm",
|
||||
"opacity": 0.549729
|
||||
},
|
||||
{
|
||||
"word": "river",
|
||||
"opacity": 0.549617
|
||||
},
|
||||
{
|
||||
"word": "moves",
|
||||
"opacity": 0.54994
|
||||
},
|
||||
{
|
||||
"word": "through",
|
||||
"opacity": 0.549642
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 0.550002
|
||||
},
|
||||
{
|
||||
"word": "city",
|
||||
"opacity": 0.549667
|
||||
},
|
||||
{
|
||||
"word": "and",
|
||||
"opacity": 0.549555
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 0.441878
|
||||
},
|
||||
{
|
||||
"word": "small",
|
||||
"opacity": 0.549878
|
||||
},
|
||||
{
|
||||
"word": "bird",
|
||||
"opacity": 0.54958
|
||||
},
|
||||
{
|
||||
"word": "follows",
|
||||
"opacity": 0.549468
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 0.549828
|
||||
},
|
||||
{
|
||||
"word": "wind",
|
||||
"opacity": 0.442174
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 0.442053
|
||||
},
|
||||
{
|
||||
"word": "calm",
|
||||
"opacity": 0.550039
|
||||
},
|
||||
{
|
||||
"word": "river",
|
||||
"opacity": 0.549928
|
||||
},
|
||||
{
|
||||
"word": "moves",
|
||||
"opacity": 0.550251
|
||||
},
|
||||
{
|
||||
"word": "through",
|
||||
"opacity": 0.441999
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 0.441717
|
||||
},
|
||||
{
|
||||
"word": "city",
|
||||
"opacity": 0.550387
|
||||
},
|
||||
{
|
||||
"word": "and",
|
||||
"opacity": 0.549654
|
||||
},
|
||||
{
|
||||
"word": "a",
|
||||
"opacity": 0.441986
|
||||
},
|
||||
{
|
||||
"word": "small",
|
||||
"opacity": 0.549977
|
||||
},
|
||||
{
|
||||
"word": "bird",
|
||||
"opacity": 0.549679
|
||||
},
|
||||
{
|
||||
"word": "follows",
|
||||
"opacity": 0.549567
|
||||
},
|
||||
{
|
||||
"word": "the",
|
||||
"opacity": 0.549928
|
||||
},
|
||||
{
|
||||
"word": "wind",
|
||||
"opacity": 0.442281
|
||||
}
|
||||
],
|
||||
"byId": {
|
||||
"word-number": 140,
|
||||
"next-word": 140,
|
||||
"live-wpm": 789,
|
||||
"live-accuracy": 789,
|
||||
"run-status": 789,
|
||||
"SPAN": 789,
|
||||
"time-label": 789,
|
||||
"live-time": 789,
|
||||
"time-unit": 789,
|
||||
"previous-word": 28
|
||||
},
|
||||
"cdp": {
|
||||
"LayoutCount": 929,
|
||||
"LayoutDuration": 0.166715,
|
||||
"RecalcStyleCount": 983,
|
||||
"RecalcStyleDuration": 0.05587,
|
||||
"ScriptDuration": 0.094722,
|
||||
"TaskDuration": 0.8065720000000001
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"checkedAt": "2026-10-24T23:25:17.465Z",
|
||||
"base": "http://127.0.0.1:8790",
|
||||
"health": {
|
||||
"ok": true,
|
||||
"rev": "local",
|
||||
"source_sha": "local",
|
||||
"storage": "ready",
|
||||
"quotas": "ready",
|
||||
"topics": false
|
||||
},
|
||||
"score": "317",
|
||||
"resultUrl": "http://127.0.0.1:8790/r/a433e1dab5e143e3aa76bf1c",
|
||||
"storedWords": 13,
|
||||
"storedEvents": 46,
|
||||
"replayWord": "river",
|
||||
"raceUrl": "http://127.0.0.1:8790/race/a433e1dab5e143e3aa76bf1c",
|
||||
"ghostVisible": true,
|
||||
"og": {
|
||||
"status": 200,
|
||||
"bytes": 3962,
|
||||
"width": 1200,
|
||||
"height": 630
|
||||
},
|
||||
"dailySame": true,
|
||||
"dailyWords": 300,
|
||||
"errors": []
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"checkedAt": "2026-10-24T23:25:22.371Z",
|
||||
"base": "http://127.0.0.1:8790",
|
||||
"motion": "no-preference",
|
||||
"checks": [
|
||||
{
|
||||
"name": "Ready stage stays still",
|
||||
"idleMutations": 0
|
||||
},
|
||||
{
|
||||
"name": "Immediate stationary target and persistent letters",
|
||||
"word": "river",
|
||||
"opacity": "1",
|
||||
"transform": "none",
|
||||
"animations": 0
|
||||
},
|
||||
{
|
||||
"name": "Normal spaced typing at 160 WPM input cadence",
|
||||
"advance": "space",
|
||||
"accuracy": 100
|
||||
},
|
||||
{
|
||||
"name": "Normal spaced typing at 160 WPM input cadence",
|
||||
"advance": "letter",
|
||||
"accuracy": 100
|
||||
},
|
||||
{
|
||||
"name": "Long phone word and instant transition fit",
|
||||
"width": 390,
|
||||
"before": {
|
||||
"viewport": 390,
|
||||
"document": 390
|
||||
},
|
||||
"after": {
|
||||
"word": "next",
|
||||
"viewport": 390,
|
||||
"document": 390,
|
||||
"opacity": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Long phone word and instant transition fit",
|
||||
"width": 360,
|
||||
"before": {
|
||||
"viewport": 360,
|
||||
"document": 360
|
||||
},
|
||||
"after": {
|
||||
"word": "next",
|
||||
"viewport": 360,
|
||||
"document": 360,
|
||||
"opacity": "1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
+94
-56
@@ -25,13 +25,27 @@ let currentPath = '/';
|
||||
let routeSerial = 0;
|
||||
let lastWordKey = '';
|
||||
let lastWordIndex = -1;
|
||||
let previousWord = '';
|
||||
let renderedWord = '';
|
||||
let wordSpans = [];
|
||||
let overflowSpan = null;
|
||||
let wordLayoutDirty = true;
|
||||
let errorAnimation = null;
|
||||
let lastClockPaint = 0;
|
||||
let lastErrorCount = 0;
|
||||
let replay = {playing:false,position:0,anchor:0,anchorPosition:0};
|
||||
let toastTimer;
|
||||
let transitioning = false;
|
||||
let isComposing = false;
|
||||
|
||||
function setText(id, value) {
|
||||
const node = $(id), text = String(value);
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
|
||||
function setClass(node, name, enabled) {
|
||||
if (node.classList.contains(name) !== enabled) node.classList.toggle(name, enabled);
|
||||
}
|
||||
|
||||
function toast(message) {
|
||||
clearTimeout(toastTimer);
|
||||
$('toast').textContent = message;
|
||||
@@ -101,7 +115,8 @@ function beginRun({freshWords = false, focus = false} = {}) {
|
||||
replay.playing = false;
|
||||
lastWordKey = '';
|
||||
lastWordIndex = -1;
|
||||
previousWord = '';
|
||||
renderedWord = '';
|
||||
wordLayoutDirty = true;
|
||||
lastErrorCount = 0;
|
||||
$('load-panel').hidden = true;
|
||||
$('play-panel').hidden = false;
|
||||
@@ -120,86 +135,103 @@ function beginRun({freshWords = false, focus = false} = {}) {
|
||||
}
|
||||
|
||||
function drawWord(word, buffer, index, nextWord, flags = null) {
|
||||
const chars = Array.from(word || '');
|
||||
const typed = Array.from(buffer || '');
|
||||
const key = word + '|' + buffer + '|' + index + '|' + (flags?.join('') || '');
|
||||
if (key === lastWordKey) return;
|
||||
const wordChanged = index !== lastWordIndex;
|
||||
if (wordChanged && lastWordIndex >= 0 && !reducedMotion) {
|
||||
$('previous-word').textContent = previousWord;
|
||||
$('previous-word').style.fontSize = getComputedStyle($('word')).fontSize;
|
||||
$('previous-word').classList.toggle('long', previousWord.length > 12);
|
||||
$('previous-word').getAnimations().forEach(animation => animation.cancel());
|
||||
$('previous-word').animate([{opacity:.18,transform:'translateX(0)'},{opacity:0,transform:'translateX(-35px)'}], {duration:180,easing:'ease-out'});
|
||||
if (key === lastWordKey && !wordLayoutDirty) return;
|
||||
const node = $('word');
|
||||
const typed = Array.from(buffer || '');
|
||||
const wordChanged = index !== lastWordIndex || word !== renderedWord;
|
||||
if (wordChanged) {
|
||||
// The next target is readable immediately. Animation must never consume
|
||||
// part of the interval in which the player is already typing this word.
|
||||
errorAnimation?.cancel();
|
||||
const chars = Array.from(word || '');
|
||||
node.classList.toggle('long', chars.length > 12);
|
||||
node.classList.toggle('very-long', chars.length > 22);
|
||||
wordSpans = chars.map(char => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = char;
|
||||
return span;
|
||||
});
|
||||
overflowSpan = document.createElement('span');
|
||||
overflowSpan.className = 'overflow';
|
||||
overflowSpan.setAttribute('aria-hidden', 'true');
|
||||
overflowSpan.hidden = true;
|
||||
node.replaceChildren(...wordSpans, overflowSpan);
|
||||
node.setAttribute('aria-label', 'Word to type: ' + word);
|
||||
node.dataset.word = word;
|
||||
$('typing-input').setAttribute('aria-label', 'Type the word: ' + word);
|
||||
setText('word-number', String(index + 1).padStart(3, '0'));
|
||||
setText('next-word', nextWord || '');
|
||||
wordLayoutDirty = true;
|
||||
}
|
||||
$('word').classList.toggle('long', chars.length > 12);
|
||||
$('word').classList.toggle('very-long', chars.length > 22);
|
||||
$('word').innerHTML = chars.map((char, i) => {
|
||||
wordSpans.forEach((span, i) => {
|
||||
let state = '';
|
||||
if (i < typed.length) state = (flags ? flags[i] : typed[i] === char) ? 'correct' : 'incorrect';
|
||||
if (i < typed.length) state = (flags ? flags[i] : typed[i] === span.textContent) ? 'correct' : 'incorrect';
|
||||
if (i === typed.length) state += ' cursor';
|
||||
return '<span class="' + state + '">' + escapeHTML(char) + '</span>';
|
||||
}).join('') + (typed.length > chars.length ? '<span class="overflow" aria-hidden="true">+' + (typed.length - chars.length) + '</span>' : '');
|
||||
$('word').setAttribute('aria-label', 'Word to type: ' + word);
|
||||
$('word').dataset.word = word;
|
||||
$('typing-input').setAttribute('aria-label', 'Type the word: ' + word);
|
||||
// A pasted token may be much longer than a normal word. Measure the actual
|
||||
// glyphs instead of letting it enlarge a phone's layout viewport.
|
||||
$('word').style.fontSize = '';
|
||||
const available = Math.max(1, $('stage').clientWidth - 28);
|
||||
if ($('word').scrollWidth > available) {
|
||||
const natural = parseFloat(getComputedStyle($('word')).fontSize);
|
||||
$('word').style.fontSize = Math.max(8, natural * available / $('word').scrollWidth * .96) + 'px';
|
||||
}
|
||||
$('word-number').textContent = String(index + 1).padStart(3, '0');
|
||||
$('next-word').textContent = nextWord || '';
|
||||
if (wordChanged && !reducedMotion) {
|
||||
$('word').getAnimations().forEach(animation => animation.cancel());
|
||||
$('word').animate([{opacity:.2,transform:'translateX(25px)'},{opacity:1,transform:'translateX(0)'}], {duration:170,easing:'ease-out'});
|
||||
if (span.className !== state) span.className = state;
|
||||
});
|
||||
const overflow = Math.max(0, typed.length - wordSpans.length);
|
||||
if (overflowSpan.hidden !== !overflow) overflowSpan.hidden = !overflow;
|
||||
if (overflow && overflowSpan.textContent !== '+' + overflow) overflowSpan.textContent = '+' + overflow;
|
||||
// Fit a long pasted word once, on word/viewport changes. Letter feedback does
|
||||
// not change glyph widths and must not force a layout read on every key.
|
||||
if (wordLayoutDirty) {
|
||||
node.style.fontSize = '';
|
||||
const available = Math.max(1, $('stage').clientWidth - 28);
|
||||
const width = node.scrollWidth;
|
||||
if (width > available) {
|
||||
const natural = parseFloat(getComputedStyle(node).fontSize);
|
||||
node.style.fontSize = Math.max(8, natural * available / width * .96) + 'px';
|
||||
}
|
||||
wordLayoutDirty = false;
|
||||
}
|
||||
lastWordKey = key;
|
||||
lastWordIndex = index;
|
||||
previousWord = word;
|
||||
renderedWord = word;
|
||||
}
|
||||
|
||||
function renderGame(state) {
|
||||
drawWord(state.word, state.buffer, state.wordIndex, state.nextWord);
|
||||
$('live-wpm').textContent = pretty(state.wpm);
|
||||
$('live-accuracy').textContent = pretty(state.accuracy);
|
||||
$('status-dot').classList.toggle('active', state.status === 'running');
|
||||
if (state.status === 'ready') $('run-status').textContent = ghost ? 'your ghost is ready' : source.kind === 'daily' ? 'same words, your rhythm' : 'a fresh start';
|
||||
else if (state.errorIndex >= 0) $('run-status').textContent = 'a little backspace, then onward';
|
||||
else if (state.waitingForBeat) $('run-status').textContent = 'right on time · wait for the beat';
|
||||
else $('run-status').textContent = state.streak >= 3 ? state.streak + ' clean words in a row' : 'one word, then the next';
|
||||
setText('live-wpm', pretty(state.wpm));
|
||||
setText('live-accuracy', pretty(state.accuracy));
|
||||
setClass($('status-dot'), 'active', state.status === 'running');
|
||||
if (state.status === 'ready') setText('run-status', ghost ? 'your ghost is ready' : source.kind === 'daily' ? 'same words, your rhythm' : 'a fresh start');
|
||||
else if (state.errorIndex >= 0) setText('run-status', 'a little backspace, then onward');
|
||||
else if (state.waitingForBeat) setText('run-status', 'right on time · wait for the beat');
|
||||
else setText('run-status', state.streak >= 3 ? state.streak + ' clean words in a row' : 'one word, then the next');
|
||||
const hintStart = $('run-hint').querySelector('.hint-start');
|
||||
hintStart.textContent = state.status === 'ready' ? 'type the word to begin' : state.errorIndex >= 0 ? 'backspace to correct' : state.waitingForBeat ? 'hold that rhythm' : 'keep your rhythm';
|
||||
const hint = state.status === 'ready' ? 'type the word to begin' : state.errorIndex >= 0 ? 'backspace to correct' : state.waitingForBeat ? 'hold that rhythm' : 'keep your rhythm';
|
||||
if (hintStart.textContent !== hint) hintStart.textContent = hint;
|
||||
if (selectedMode === '30' || selectedMode === '60') {
|
||||
$('time-label').textContent = 'remaining'; $('live-time').textContent = Math.ceil(state.remainingMs / 1000); $('time-unit').textContent = 's';
|
||||
setText('time-label', 'remaining'); setText('live-time', Math.ceil(state.remainingMs / 1000)); setText('time-unit', 's');
|
||||
} else if (selectedMode === '50') {
|
||||
$('time-label').textContent = 'remaining'; $('live-time').textContent = Math.max(0, 50 - state.completedWords); $('time-unit').textContent = 'words';
|
||||
setText('time-label', 'remaining'); setText('live-time', Math.max(0, 50 - state.completedWords)); setText('time-unit', 'words');
|
||||
} else if (selectedMode === 'tempo') {
|
||||
$('time-label').textContent = 'beat pace'; $('live-time').textContent = pretty(state.paceWpm); $('time-unit').textContent = 'wpm';
|
||||
setText('time-label', 'beat pace'); setText('live-time', pretty(state.paceWpm)); setText('time-unit', 'wpm');
|
||||
} else {
|
||||
$('time-label').textContent = 'in the flow'; $('live-time').textContent = Math.floor(state.elapsedMs / 1000); $('time-unit').textContent = 's';
|
||||
setText('time-label', 'in the flow'); setText('live-time', Math.floor(state.elapsedMs / 1000)); setText('time-unit', 's');
|
||||
}
|
||||
if (selectedMode === 'tempo') {
|
||||
const fraction = state.beatDurationMs ? Math.max(0, state.beatRemainingMs / state.beatDurationMs) : 1;
|
||||
$('tempo-progress').style.transform = 'scaleX(' + fraction + ')';
|
||||
$('tempo-caption').textContent = state.status === 'ready' ? 'type to catch the first beat' : state.waitingForBeat ? 'got it. the next beat is coming.' : 'finish the word before the line runs out';
|
||||
Array.from($('tempo-lives').children).forEach((dot, i) => dot.classList.toggle('lost', i < state.misses));
|
||||
$('tempo-lives').setAttribute('aria-label', (3 - state.misses) + ' missed beats remaining');
|
||||
setText('tempo-caption', state.status === 'ready' ? 'type to catch the first beat' : state.waitingForBeat ? 'got it. the next beat is coming.' : 'finish the word before the line runs out');
|
||||
Array.from($('tempo-lives').children).forEach((dot, i) => setClass(dot, 'lost', i < state.misses));
|
||||
const label = (3 - state.misses) + ' missed beats remaining';
|
||||
if ($('tempo-lives').getAttribute('aria-label') !== label) $('tempo-lives').setAttribute('aria-label', label);
|
||||
}
|
||||
if (ghost) {
|
||||
const completed = ghost.wordTimings.filter(timing => timing.completed && timing.end <= state.elapsedMs).length;
|
||||
$('race-you').textContent = state.completedWords;
|
||||
$('race-them').textContent = completed;
|
||||
setText('race-you', state.completedWords);
|
||||
setText('race-them', completed);
|
||||
const difference = state.completedWords - completed;
|
||||
$('race-delta').textContent = state.status === 'ready' ? '' : difference === 0 ? 'neck and neck' : Math.abs(difference) + (difference > 0 ? ' ahead' : ' behind');
|
||||
setText('race-delta', state.status === 'ready' ? '' : difference === 0 ? 'neck and neck' : Math.abs(difference) + (difference > 0 ? ' ahead' : ' behind'));
|
||||
}
|
||||
if (state.errors > lastErrorCount && !reducedMotion) {
|
||||
$('word').animate([{transform:'translateX(0)'},{transform:'translateX(-5px)'},{transform:'translateX(5px)'},{transform:'translateX(0)'}], {duration:160});
|
||||
errorAnimation?.cancel();
|
||||
errorAnimation = $('word').animate([{transform:'translateX(0)'},{transform:'translateX(-3px)'},{transform:'translateX(3px)'},{transform:'translateX(0)'}], {duration:90});
|
||||
}
|
||||
lastErrorCount = state.errors;
|
||||
lastClockPaint = now();
|
||||
if (state.status === 'finished' && !transitioning) finishDisplay();
|
||||
}
|
||||
|
||||
@@ -597,10 +629,16 @@ document.addEventListener('click', event => {
|
||||
event.preventDefault(); route(url.pathname);
|
||||
});
|
||||
window.addEventListener('popstate', () => route(location.pathname,false));
|
||||
window.addEventListener('resize', () => { lastWordKey = ''; });
|
||||
window.addEventListener('resize', () => {
|
||||
wordLayoutDirty = true;
|
||||
if (view === 'play' && game) renderGame(game.snapshot(now()));
|
||||
else if (view === 'replay') renderReplay(replay.position);
|
||||
});
|
||||
|
||||
function frame(time) {
|
||||
if (view === 'play' && game) renderGame(game.tick(time));
|
||||
// Input renders synchronously. Only the clock needs periodic refreshes; an
|
||||
// idle stage does no DOM work, while tempo keeps its smooth beat indicator.
|
||||
if (view === 'play' && game?.status === 'running' && (selectedMode === 'tempo' || time - lastClockPaint >= 100)) renderGame(game.tick(time));
|
||||
else if (view === 'replay' && replay.playing) {
|
||||
replay.position = Math.min(currentResult.run.durationMs, replay.anchorPosition + time - replay.anchor);
|
||||
renderReplay(replay.position);
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
<div class="stage" id="stage">
|
||||
<span class="word-number" id="word-number" aria-hidden="true">001</span>
|
||||
<div class="word-wrap">
|
||||
<div class="previous-word" id="previous-word" aria-hidden="true"></div>
|
||||
<div class="word" id="word" aria-live="off" aria-label="Word to type"></div>
|
||||
</div>
|
||||
<div class="upcoming" id="upcoming" aria-hidden="true"><span>up next</span><b id="next-word"></b></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
:root{--paper:#f5f3ec;--ink:#263d33;--muted:#7b8177;--faint:#b8beb2;--line:#dcded3;--orange:#e36c49;--orange-soft:#f5e0d5;--green-soft:#e7ebdd;--sans:Arial,Helvetica,sans-serif;--mono:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace}
|
||||
*{box-sizing:border-box}html{min-height:100%;background:var(--paper)}body{margin:0;color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.4}button,a,input,select,textarea{outline-offset:5px}button:focus-visible,a:focus-visible{outline:2px solid var(--orange)}button{border:0;background:none}a{color:inherit;text-decoration:none}button,a{touch-action:manipulation}input,textarea,select{border:1px solid var(--line);border-radius:8px;background:#fffdf6;padding:14px;outline:none}input:focus,textarea:focus,select:focus{border-color:var(--ink);box-shadow:0 0 0 2px #263d3312}input::placeholder,textarea::placeholder{color:#969b91}[hidden]{display:none!important}::selection{background:#e8b9a4;color:var(--ink)}.shell{max-width:1280px;margin:auto;padding:0 64px;min-height:100svh;display:flex;flex-direction:column}.masthead{height:112px;display:flex;align-items:center;border-bottom:1px solid var(--line);gap:26px;flex-shrink:0}.brand{font-size:48px;font-weight:800;letter-spacing:-4px;line-height:1;display:flex;align-items:baseline;gap:6px}.brand-dot{width:9px;height:9px;border-radius:50%;background:var(--orange)}.brand-note{font-family:var(--mono);font-size:11px;color:var(--muted);padding-top:11px;letter-spacing:-.2px}.top-nav{margin-left:auto;display:flex;align-items:center;gap:24px;font-size:13px}.top-nav a{display:flex;align-items:center;gap:8px}.top-nav a:hover,.text-button:hover{color:var(--orange)}.sun{color:var(--orange);font-size:23px}.icon-button{width:32px;height:36px;display:grid;place-items:center;padding:0}.help-button{font-family:var(--mono);font-size:17px;width:25px;height:25px;border:1px solid var(--faint);border-radius:50%}main{flex:1}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:28px 0 20px}.mode-switch{display:flex;align-items:center;gap:4px;border:1px solid var(--line);border-radius:99px;padding:5px}.mode-switch button{font-family:var(--mono);font-size:12px;line-height:30px;padding:0 16px;border-radius:99px;white-space:nowrap;color:var(--muted);transition:color .16s,background .16s}.mode-switch button[aria-pressed="true"]{background:var(--ink);color:var(--paper)}.mode-switch button:not(:disabled):hover{color:var(--ink);background:var(--green-soft)}.mode-switch button[aria-pressed="true"]:hover{background:#365446;color:var(--paper)}.mode-switch button[data-mode="endless"]{font-size:20px}.mode-divider{height:18px;border-left:1px solid var(--line);margin:0 5px}.tempo-option{display:flex;align-items:center;gap:8px}.tempo-icon{display:flex;align-items:center;gap:2px;height:14px}.tempo-icon i{width:2px;height:6px;background:currentColor}.tempo-icon i:nth-child(2){height:14px}.tempo-icon i:nth-child(3){height:10px}.source-button{font-family:var(--mono);font-size:12px;display:flex;align-items:center;gap:15px;max-width:40%;padding:12px 0 12px 12px}.source-button span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.source-button span:last-child{color:var(--orange);font-size:20px}.play-panel{padding-top:21px}.run-strip{display:flex;align-items:center;justify-content:space-between;gap:16px;font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:.3px}.run-status{display:flex;gap:9px;align-items:center}.status-dot{width:6px;height:6px;border-radius:50%;background:var(--faint)}.status-dot.active{background:var(--orange);box-shadow:0 0 0 4px #e36c4914}.best-score{font-size:10px}.metrics{display:flex;justify-content:center;gap:62px;margin:44px 0 4px;user-select:none}.metrics>div{display:flex;align-items:baseline;gap:5px;min-width:102px;position:relative}.metric-label{font-family:var(--mono);font-size:9px;position:absolute;top:-19px;left:0;color:var(--muted)}.metric-value{font-family:var(--mono);font-weight:400;font-size:27px;letter-spacing:-1.5px;font-variant-numeric:tabular-nums}.metric-unit{font-size:10px;color:var(--muted);font-family:var(--mono)}.time-metric .metric-value{color:var(--orange)}.stage{height:310px;position:relative;cursor:text;display:flex;align-items:center;justify-content:center;isolation:isolate;overflow:clip}.word-number{position:absolute;left:0;top:48%;font-family:var(--mono);font-size:10px;color:var(--faint);writing-mode:vertical-rl;transform:rotate(180deg);letter-spacing:2px}.word-wrap{position:relative;width:100%;height:180px;display:grid;place-items:center}.word,.previous-word{font-family:var(--mono);font-size:clamp(50px,8.6vw,114px);font-weight:500;line-height:1.3;letter-spacing:-.075em;white-space:nowrap;color:var(--faint);max-width:98%;text-align:center;font-variant-ligatures:none}.word.long,.previous-word.long{font-size:clamp(25px,5.2vw,68px);letter-spacing:-.06em}.word.very-long{font-size:clamp(19px,3.5vw,46px)}.word{color:#7c8875}.word .correct{color:var(--ink)}.word .incorrect{color:#b24d36;backgrouLine truncated
|
||||
*{box-sizing:border-box}html{min-height:100%;background:var(--paper)}body{margin:0;color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.4}button,a,input,select,textarea{outline-offset:5px}button:focus-visible,a:focus-visible{outline:2px solid var(--orange)}button{border:0;background:none}a{color:inherit;text-decoration:none}button,a{touch-action:manipulation}input,textarea,select{border:1px solid var(--line);border-radius:8px;background:#fffdf6;padding:14px;outline:none}input:focus,textarea:focus,select:focus{border-color:var(--ink);box-shadow:0 0 0 2px #263d3312}input::placeholder,textarea::placeholder{color:#969b91}[hidden]{display:none!important}::selection{background:#e8b9a4;color:var(--ink)}.shell{max-width:1280px;margin:auto;padding:0 64px;min-height:100svh;display:flex;flex-direction:column}.masthead{height:112px;display:flex;align-items:center;border-bottom:1px solid var(--line);gap:26px;flex-shrink:0}.brand{font-size:48px;font-weight:800;letter-spacing:-4px;line-height:1;display:flex;align-items:baseline;gap:6px}.brand-dot{width:9px;height:9px;border-radius:50%;background:var(--orange)}.brand-note{font-family:var(--mono);font-size:11px;color:var(--muted);padding-top:11px;letter-spacing:-.2px}.top-nav{margin-left:auto;display:flex;align-items:center;gap:24px;font-size:13px}.top-nav a{display:flex;align-items:center;gap:8px}.top-nav a:hover,.text-button:hover{color:var(--orange)}.sun{color:var(--orange);font-size:23px}.icon-button{width:32px;height:36px;display:grid;place-items:center;padding:0}.help-button{font-family:var(--mono);font-size:17px;width:25px;height:25px;border:1px solid var(--faint);border-radius:50%}main{flex:1}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:28px 0 20px}.mode-switch{display:flex;align-items:center;gap:4px;border:1px solid var(--line);border-radius:99px;padding:5px}.mode-switch button{font-family:var(--mono);font-size:12px;line-height:30px;padding:0 16px;border-radius:99px;white-space:nowrap;color:var(--muted);transition:color .16s,background .16s}.mode-switch button[aria-pressed="true"]{background:var(--ink);color:var(--paper)}.mode-switch button:not(:disabled):hover{color:var(--ink);background:var(--green-soft)}.mode-switch button[aria-pressed="true"]:hover{background:#365446;color:var(--paper)}.mode-switch button[data-mode="endless"]{font-size:20px}.mode-divider{height:18px;border-left:1px solid var(--line);margin:0 5px}.tempo-option{display:flex;align-items:center;gap:8px}.tempo-icon{display:flex;align-items:center;gap:2px;height:14px}.tempo-icon i{width:2px;height:6px;background:currentColor}.tempo-icon i:nth-child(2){height:14px}.tempo-icon i:nth-child(3){height:10px}.source-button{font-family:var(--mono);font-size:12px;display:flex;align-items:center;gap:15px;max-width:40%;padding:12px 0 12px 12px}.source-button span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.source-button span:last-child{color:var(--orange);font-size:20px}.play-panel{padding-top:21px}.run-strip{display:flex;align-items:center;justify-content:space-between;gap:16px;font-family:var(--mono);font-size:10px;color:var(--muted);letter-spacing:.3px}.run-status{display:flex;gap:9px;align-items:center}.status-dot{width:6px;height:6px;border-radius:50%;background:var(--faint)}.status-dot.active{background:var(--orange);box-shadow:0 0 0 4px #e36c4914}.best-score{font-size:10px}.metrics{display:flex;justify-content:center;gap:62px;margin:44px 0 4px;user-select:none}.metrics>div{display:flex;align-items:baseline;gap:5px;min-width:102px;position:relative}.metric-label{font-family:var(--mono);font-size:9px;position:absolute;top:-19px;left:0;color:var(--muted)}.metric-value{font-family:var(--mono);font-weight:400;font-size:27px;letter-spacing:-1.5px;font-variant-numeric:tabular-nums}.metric-unit{font-size:10px;color:var(--muted);font-family:var(--mono)}.time-metric .metric-value{color:var(--orange)}.stage{height:310px;position:relative;cursor:text;display:flex;align-items:center;justify-content:center;isolation:isolate;overflow:clip}.word-number{position:absolute;left:0;top:48%;font-family:var(--mono);font-size:10px;color:var(--faint);writing-mode:vertical-rl;transform:rotate(180deg);letter-spacing:2px}.word-wrap{position:relative;width:100%;height:180px;display:grid;place-items:center}.word{font-family:var(--mono);font-size:clamp(50px,8.6vw,114px);font-weight:500;line-height:1.3;letter-spacing:-.075em;white-space:nowrap;color:var(--faint);max-width:98%;text-align:center;font-variant-ligatures:none}.word.long{font-size:clamp(25px,5.2vw,68px);letter-spacing:-.06em}.word.very-long{font-size:clamp(19px,3.5vw,46px)}.word{color:#7c8875}.word .correct{color:var(--ink)}.word .incorrect{color:#b24d36;background:var(--orange-soft);border-radiusLine truncated
|
||||
@media(min-height:860px) and (min-width:701px){.stage{height:360px}.metrics{margin-top:64px}.play-panel{padding-top:32px}.input-row{padding-top:30px}.toolbar{padding-top:34px}}
|
||||
@media(max-width:900px){.shell{padding:0 36px}.brand-note{font-size:9px}.top-nav{gap:18px}.mode-switch button{padding:0 13px}.stage{height:290px}.metrics{gap:40px}.result-main>span:first-child{font-size:95px}.result-grid{gap:30px}.result-detail{padding-left:30px}}
|
||||
@media(max-width:700px){.shell{padding:0 23px}.masthead{height:84px;gap:10px}.brand{font-size:41px;letter-spacing:-3.5px}.brand-dot{width:7px;height:7px}.brand-note{display:none}.top-nav{gap:19px;font-size:12px}.help-button{display:none}.toolbar{padding:22px 0 16px;flex-direction:column;align-items:stretch;gap:8px}.mode-switch{align-self:center;max-width:100%;gap:2px;padding:4px}.mode-switch button{padding:0 13px;font-size:11px;line-height:30px}.mode-divider{margin:0 3px}.source-button{font-size:10px;max-width:100%;padding:8px 0;align-self:center;gap:11px}.source-button span:last-child{font-size:15px}.play-panel{padding-top:9px}.run-strip{font-size:8px}.best-score{font-size:8px}.metrics{gap:28px;margin:44px 0 0}.metrics>div{min-width:72px}.metric-value{font-size:24px}.metric-label{font-size:8px;top:-18px}.metric-unit{font-size:8px}.stage{height:236px}.word-wrap{height:140px}.word,.previous-word{font-size:clamp(40px,11.1vw,76px);letter-spacing:-.075em}.word.long,.previous-word.long{font-size:clamp(24px,6.4vw,42px)}.word.very-long{font-size:clamp(17px,4.2vw,29px)}.word .cursor:after{bottom:-7px;height:2px}.word-number{font-size:8px;left:0;top:43%}.upcoming{right:1%;bottom:15px;gap:10px}.upcoming b{font-size:19px}.upcoming span{font-size:7px}.input-row{padding-top:22px;min-height:135px}.typing-input{position:static;opacity:1;width:100%;height:49px;border:1px solid var(--line);border-radius:8px;padding:12px 14px;pointer-events:auto;overflow:auto;font-size:16px;text-align:center;background:#eeeee5}.typing-input:focus{background:#fdfcf7}.typing-input::placeholder{font-size:11px;color:var(--muted);font-family:var(--mono)}.run-hint{font-size:9px;gap:8px;margin-top:14px}.run-hint kbd{font-size:8px}.footer{height:auto;min-height:68px;font-size:8px;align-items:center;padding:17px 0;margin-top:18px;gap:10px}.footer>div{gap:10px}.footer a{font-size:8px}#restart-tip{display:none}.footer>.text-button{font-size:8px}.tempo-track{padding-top:0}.tempo-caption{font-size:9px}.ghost-race{margin-top:22px;margin-bottom:-16px;font-size:10px;gap:9px}.result-panel{margin:16px auto 25px}.result-panel h1{font-size:30px;margin:10px 0 20px}.result-grid{gap:25px;align-items:center;margin-bottom:25px}.result-main>span:first-child{font-size:75px;letter-spacing:-6px}.result-main>span:last-child{font-size:10px}.result-detail{padding-left:24px;gap:16px}.result-detail>div{flex-direction:column;gap:5px;align-items:flex-start}.result-detail dt{font-size:8px}.result-detail dd{font-size:13px}.chart-label{font-size:8px}.rhythm-chart svg{height:65px}.slow-words{font-size:9px;gap:15px;align-items:flex-start;padding-bottom:18px}.slow-words>div{gap:10px 15px}.slow-word small{font-size:8px}.result-actions{gap:8px;margin-top:20px}.result-actions .button{flex:1;font-size:10px;gap:8px;padding:10px 12px;min-height:44px}.result-actions>.text-button{margin-left:0;flex-basis:100%;text-align:left;margin-top:2px}.eyebrow{font-size:9px}.new-best{font-size:8px;padding:5px 7px}.result-note{font-size:10px}.replay-controls{gap:10px;margin-top:20px}.replay-time{font-size:9px}.replay-controls>.text-button{flex-basis:100%;text-align:center}.dialog{padding:30px 24px}.dialog h2{font-size:26px;margin-bottom:22px}.source-tabs{gap:20px}.source-tabs button{font-size:10px}.load-panel{min-height:430px;padding:25px 10px}.load-panel h1{font-size:27px}.load-panel>p:last-of-type{font-size:12px}.load-panel .button{font-size:11px;padding:10px 14px}.toast{bottom:16px;font-size:11px}.button-row{flex-wrap:wrap;justify-content:center}}
|
||||
@media(max-width:700px){.shell{padding:0 23px}.masthead{height:84px;gap:10px}.brand{font-size:41px;letter-spacing:-3.5px}.brand-dot{width:7px;height:7px}.brand-note{display:none}.top-nav{gap:19px;font-size:12px}.help-button{display:none}.toolbar{padding:22px 0 16px;flex-direction:column;align-items:stretch;gap:8px}.mode-switch{align-self:center;max-width:100%;gap:2px;padding:4px}.mode-switch button{padding:0 13px;font-size:11px;line-height:30px}.mode-divider{margin:0 3px}.source-button{font-size:10px;max-width:100%;padding:8px 0;align-self:center;gap:11px}.source-button span:last-child{font-size:15px}.play-panel{padding-top:9px}.run-strip{font-size:8px}.best-score{font-size:8px}.metrics{gap:28px;margin:44px 0 0}.metrics>div{min-width:72px}.metric-value{font-size:24px}.metric-label{font-size:8px;top:-18px}.metric-unit{font-size:8px}.stage{height:236px}.word-wrap{height:140px}.word{font-size:clamp(40px,11.1vw,76px);letter-spacing:-.075em}.word.long{font-size:clamp(24px,6.4vw,42px)}.word.very-long{font-size:clamp(17px,4.2vw,29px)}.word .cursor:after{bottom:-7px;height:2px}.word-number{font-size:8px;left:0;top:43%}.upcoming{top:calc(50% + 55px);gap:10px}.upcoming b{font-size:23px}.upcoming span{font-size:7px}.input-row{padding-top:22px;min-height:135px}.typing-input{position:static;opacity:1;width:100%;height:49px;border:1px solid var(--line);border-radius:8px;padding:12px 14px;pointer-events:auto;overflow:auto;font-size:16px;text-align:center;background:#eeeee5}.typing-input:focus{background:#fdfcf7}.typing-input::placeholder{font-size:11px;color:var(--muted);font-family:var(--mono)}.run-hint{font-size:9px;gap:8px;margin-top:14px}.run-hint kbd{font-size:8px}.footer{height:auto;min-height:68px;font-size:8px;align-items:center;padding:17px 0;margin-top:18px;gap:10px}.footer>div{gap:10px}.footer a{font-size:8px}#restart-tip{display:none}.footer>.text-button{font-size:8px}.tempo-track{padding-top:0}.tempo-caption{font-size:9px}.ghost-race{margin-top:22px;margin-bottom:-16px;font-size:10px;gap:9px}.result-panel{margin:16px auto 25px}.result-panel h1{font-size:30px;margin:10px 0 20px}.result-grid{gap:25px;align-items:center;margin-bottom:25px}.result-main>span:first-child{font-size:75px;letter-spacing:-6px}.result-main>span:last-child{font-size:10px}.result-detail{padding-left:24px;gap:16px}.result-detail>div{flex-direction:column;gap:5px;align-items:flex-start}.result-detail dt{font-size:8px}.result-detail dd{font-size:13px}.chart-label{font-size:8px}.rhythm-chart svg{height:65px}.slow-words{font-size:9px;gap:15px;align-items:flex-start;padding-bottom:18px}.slow-words>div{gap:10px 15px}.slow-word small{font-size:8px}.result-actions{gap:8px;margin-top:20px}.result-actions .button{flex:1;font-size:10px;gap:8px;padding:10px 12px;min-height:44px}.result-actions>.text-button{margin-left:0;flex-basis:100%;text-align:left;margin-top:2px}.eyebrow{font-size:9px}.new-best{font-size:8px;padding:5px 7px}.result-note{font-size:10px}.replay-controls{gap:10px;margin-top:20px}.replay-time{font-size:9px}.replay-controls>.text-button{flex-basis:100%;text-align:center}.dialog{padding:30px 24px}.dialog h2{font-size:26px;margin-bottom:22px}.source-tabs{gap:20px}.source-tabs button{font-size:10px}.load-panel{min-height:430px;padding:25px 10px}.load-panel h1{font-size:27px}.load-panel>p:last-of-type{font-size:12px}.load-panel .button{font-size:11px;padding:10px 14px}.toast{bottom:16px;font-size:11px}.button-row{flex-wrap:wrap;justify-content:center}}
|
||||
@media(max-width:370px){.shell{padding:0 17px}.mode-switch button{padding:0 10px}.tempo-option{gap:5px}.top-nav{gap:12px}.metrics{gap:22px}.stage{height:205px}.source-tabs{gap:16px}.footer a{display:none}}
|
||||
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const ASSETS = {"/app.js":{"body":"import { TypingGame } from '/game.js';\nimport { generateWords, normalizePassage } from '/words.js';\n\nconst $ = id => document.getElementById(id);\nconst escapeHTML = value => String(value ?? '').replace(/[&<>\"']/g, char => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[char]));\nconst now = () => performance.now();\nconst pretty = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value))) : '0';\nconst round1 = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value) * 10) / 10) : '0';\nconst modeNames = {'30':'30 seconds','60':'60 seconds','50':'50 words','endless':'endless','tempo':'tempo'};\nconst readLocal = (key, fallback) => { try { return JSON.parse(localStorage.getItem('typy.' + key)) ?? fallback; } catch { return fallback; } };\nconst writeLocal = (key, value) => { try { localStorage.setItem('typy.' + key, JSON.stringify(value)); } catch {} };\nconst settings = {...{advance:'space',preview:true}, ...readLocal('settings', {})};\nconst bests = readLocal('bests', {});\nconst reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;\nlet selectedMode = '30';\nlet source = {kind:'common'};\nlet passage = [];\nlet game = null;\nlet ghost = null;\nlet view = 'play';\nlet currentResult = null;\nlet shareId = null;\nlet shareIntent = 'result';\nlet currentPath = '/';\nlet routeSerial = 0;\nlet lastWordKey = '';\nlet lastWordIndex = -1;\nlet previousWord = '';\nlet lastErrorCount = 0;\nlet replay = {playing:false,position:0,anchor:0,anchorPosition:0};\nlet toastTimer;\nlet transitioning = false;\nlet isComposing = false;\n\nfunction toast(message) {\n clearTimeout(toastTimer);\n $('toast').textContent = message;\n $('toast').hidden = false;\n toastTimer = setTimeout(() => { $('toast').hidden = true; }, 4500);\n}\n\nasync function api(path, options = {}) {\n const response = await fetch(path, {...options, headers:{...(options.body ? {'Content-Type':'application/json'} : {}), ...(options.headers || {})}});\n let body;\n try { body = await response.json(); } catch { throw new Error('The server could not answer. Please try again.'); }\n if (!response.ok) {\n const message = typeof body.error === 'string' ? body.error : body.error?.message || body.message;\n const error = new Error(message || 'Something interrupted that request. Please try again.');\n error.status = response.status;\n throw error;\n }\n return body;\n}\n\nfunction sourceName() {\n if (ghost) return 'racing ' + (currentResult?.name || 'a friendly ghost');\n if (source.kind === 'topic') return (source.topic || '').replaceAll('-', ' ');\n if (source.kind === 'daily') return 'the daily · ' + (source.day || 'today');\n if (source.kind === 'paste') return 'your own words';\n return 'common words';\n}\n\nfunction localBestKey() { return selectedMode; }\nfunction updateOptions() {\n const locked = source.kind === 'daily' || Boolean(ghost);\n const effectiveAdvance = ghost ? currentResult.run.advance : settings.advance;\n $('mode-switch').classList.toggle('locked', locked);\n document.querySelectorAll('[data-mode]').forEach(button => {\n button.setAttribute('aria-pressed', String(button.dataset.mode === selectedMode));\n button.disabled = locked || view === 'replay';\n button.title = locked ? (ghost ? 'A race uses the original run’s mode.' : 'The daily is a shared 60-second run.') : '';\n });\n $('source-label').textContent = sourceName();\n $('advance-hint').innerHTML = selectedMode === 'tempo' ? 'finish before the beat' : effectiveAdvance === 'letter' ? 'last letter → next word' : '<kbd>space</kbd> next word';\n $('upcoming').hidden = !settings.preview;\n $('advance-setting').value = effectiveAdvance;\n $('advance-setting').disabled = Boolean(ghost);\n $('preview-setting').checked = settings.preview;\n $('footer-board').hidden = source.kind !== 'daily';\n $('daily-link').setAttribute('aria-current', source.kind === 'daily' ? 'page' : 'false');\n $('best-score').textContent = bests[localBestKey()] ? 'device best ' + pretty(bests[localBestKey()]) + ' wpm' : '';\n}\n\nfunction focusTyping() {\n if (view !== 'play' || document.querySelector('dialog[open]')) return;\n $('typing-input').focus({preventScroll:true});\n}\n\nfunction newCommonPassage() {\n const words = generateWords(300);\n words[0] = 'breathe';\n return words;\n}\n\nfunction beginRun({freshWords = false, focus = false} = {}) {\n transitioning = false;\n if (freshWords && source.kind === 'common' && !ghost) passage = newCommonPassage();\n if (!passage.length) passage = newCommonPassage();\n game = new TypingGame({words:passage, mode:selectedMode, advance:ghost ? currentResult.run.advance : settings.advance, paceWpm:readLocal('lastAverage', 40)});\n view = 'play';\n replay.playing = false;\n lastWordKey = '';\n lastWordIndex = -1;\n previousWord = '';\n lastErrorCount = 0;\n $('load-panel').hidden = true;\n $('plaLine truncated
|
||||
export const ASSETS = {"/app.js":{"body":"import { TypingGame } from '/game.js';\nimport { generateWords, normalizePassage } from '/words.js';\n\nconst $ = id => document.getElementById(id);\nconst escapeHTML = value => String(value ?? '').replace(/[&<>\"']/g, char => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[char]));\nconst now = () => performance.now();\nconst pretty = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value))) : '0';\nconst round1 = value => Number.isFinite(Number(value)) ? String(Math.round(Number(value) * 10) / 10) : '0';\nconst modeNames = {'30':'30 seconds','60':'60 seconds','50':'50 words','endless':'endless','tempo':'tempo'};\nconst readLocal = (key, fallback) => { try { return JSON.parse(localStorage.getItem('typy.' + key)) ?? fallback; } catch { return fallback; } };\nconst writeLocal = (key, value) => { try { localStorage.setItem('typy.' + key, JSON.stringify(value)); } catch {} };\nconst settings = {...{advance:'space',preview:true}, ...readLocal('settings', {})};\nconst bests = readLocal('bests', {});\nconst reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;\nlet selectedMode = '30';\nlet source = {kind:'common'};\nlet passage = [];\nlet game = null;\nlet ghost = null;\nlet view = 'play';\nlet currentResult = null;\nlet shareId = null;\nlet shareIntent = 'result';\nlet currentPath = '/';\nlet routeSerial = 0;\nlet lastWordKey = '';\nlet lastWordIndex = -1;\nlet renderedWord = '';\nlet wordSpans = [];\nlet overflowSpan = null;\nlet wordLayoutDirty = true;\nlet errorAnimation = null;\nlet lastClockPaint = 0;\nlet lastErrorCount = 0;\nlet replay = {playing:false,position:0,anchor:0,anchorPosition:0};\nlet toastTimer;\nlet transitioning = false;\nlet isComposing = false;\n\nfunction setText(id, value) {\n const node = $(id), text = String(value);\n if (node.textContent !== text) node.textContent = text;\n}\n\nfunction setClass(node, name, enabled) {\n if (node.classList.contains(name) !== enabled) node.classList.toggle(name, enabled);\n}\n\nfunction toast(message) {\n clearTimeout(toastTimer);\n $('toast').textContent = message;\n $('toast').hidden = false;\n toastTimer = setTimeout(() => { $('toast').hidden = true; }, 4500);\n}\n\nasync function api(path, options = {}) {\n const response = await fetch(path, {...options, headers:{...(options.body ? {'Content-Type':'application/json'} : {}), ...(options.headers || {})}});\n let body;\n try { body = await response.json(); } catch { throw new Error('The server could not answer. Please try again.'); }\n if (!response.ok) {\n const message = typeof body.error === 'string' ? body.error : body.error?.message || body.message;\n const error = new Error(message || 'Something interrupted that request. Please try again.');\n error.status = response.status;\n throw error;\n }\n return body;\n}\n\nfunction sourceName() {\n if (ghost) return 'racing ' + (currentResult?.name || 'a friendly ghost');\n if (source.kind === 'topic') return (source.topic || '').replaceAll('-', ' ');\n if (source.kind === 'daily') return 'the daily · ' + (source.day || 'today');\n if (source.kind === 'paste') return 'your own words';\n return 'common words';\n}\n\nfunction localBestKey() { return selectedMode; }\nfunction updateOptions() {\n const locked = source.kind === 'daily' || Boolean(ghost);\n const effectiveAdvance = ghost ? currentResult.run.advance : settings.advance;\n $('mode-switch').classList.toggle('locked', locked);\n document.querySelectorAll('[data-mode]').forEach(button => {\n button.setAttribute('aria-pressed', String(button.dataset.mode === selectedMode));\n button.disabled = locked || view === 'replay';\n button.title = locked ? (ghost ? 'A race uses the original run’s mode.' : 'The daily is a shared 60-second run.') : '';\n });\n $('source-label').textContent = sourceName();\n $('advance-hint').innerHTML = selectedMode === 'tempo' ? 'finish before the beat' : effectiveAdvance === 'letter' ? 'last letter → next word' : '<kbd>space</kbd> next word';\n $('upcoming').hidden = !settings.preview;\n $('advance-setting').value = effectiveAdvance;\n $('advance-setting').disabled = Boolean(ghost);\n $('preview-setting').checked = settings.preview;\n $('footer-board').hidden = source.kind !== 'daily';\n $('daily-link').setAttribute('aria-current', source.kind === 'daily' ? 'page' : 'false');\n $('best-score').textContent = bests[localBestKey()] ? 'device best ' + pretty(bests[localBestKey()]) + ' wpm' : '';\n}\n\nfunction focusTyping() {\n if (view !== 'play' || document.querySelector('dialog[open]')) return;\n $('typing-input').focus({preventScroll:true});\n}\n\nfunction newCommonPassage() {\n const words = generateWords(300);\n words[0] = 'breathe';\n return words;\n}\n\nfunction beginRun({freshWords = false, focus = false} = {}) {\n transitioning = false;\n if (freshWords && source.kind === 'common' && !ghost) passage = newCommonLine truncated
|
||||
@@ -112,6 +112,7 @@ export class TypingGame {
|
||||
this.bestStreak = 0;
|
||||
this.misses = 0;
|
||||
this.waitingForBeat = false;
|
||||
this.allowOptionalSeparator = false;
|
||||
this.beatDeadline = null;
|
||||
this.beatDurationMs = null;
|
||||
this.finishedReason = null;
|
||||
@@ -192,6 +193,7 @@ export class TypingGame {
|
||||
this.wordErrors = 0;
|
||||
this.typedEnd = null;
|
||||
this.waitingForBeat = false;
|
||||
this.allowOptionalSeparator = completed && !isBeat && this.advance === 'letter';
|
||||
this.wordStartedAt = now;
|
||||
if (this.mode === '50' && this.completedWords >= 50) {
|
||||
this._finish(now, 'words', false);
|
||||
@@ -245,6 +247,14 @@ export class TypingGame {
|
||||
this._start(now);
|
||||
}
|
||||
if (this.waitingForBeat) return this._snapshot(now);
|
||||
// Automatic advancement already earned one normalized separator. Let a
|
||||
// typist press that space out of habit without corrupting the next word.
|
||||
// Only the very next printable key gets this allowance; extra or mid-word
|
||||
// spaces still need correction, and an ignored separator earns no output.
|
||||
if (this.allowOptionalSeparator) {
|
||||
this.allowOptionalSeparator = false;
|
||||
if (key === ' ') return this._snapshot(now);
|
||||
}
|
||||
if (key === ' ' && this.buffer.join('') === this.word) {
|
||||
if (this.mode === 'tempo') {
|
||||
this.waitingForBeat = true;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const TEMPLATE = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">\n <meta name=\"theme-color\" content=\"#f5f3ec\">\n <!--OG-->\n <link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">\n <link rel=\"stylesheet\" href=\"/style.css\">\n <script type=\"module\" src=\"/app.js\"></script>\n</head>\n<body>\n <div class=\"shell\">\n <header class=\"masthead\">\n <a class=\"brand\" href=\"/\" aria-label=\"typy home\">typy<span class=\"brand-dot\" aria-hidden=\"true\"></span></a>\n <span class=\"brand-note\">a little more in the moment.</span>\n <nav class=\"top-nav\" aria-label=\"Main\">\n <a href=\"/daily\" id=\"daily-link\"><span class=\"sun\" aria-hidden=\"true\">✳</span> the daily</a>\n <button class=\"icon-button\" id=\"settings-open\" aria-label=\"Typing settings\" title=\"Typing settings\">\n <svg width=\"21\" height=\"21\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" aria-hidden=\"true\"><path d=\"M4 7h16M4 17h16\"/><circle cx=\"9\" cy=\"7\" r=\"3\" fill=\"var(--paper)\"/><circle cx=\"15\" cy=\"17\" r=\"3\" fill=\"var(--paper)\"/></svg>\n </button>\n <button class=\"icon-button help-button\" id=\"help-open\" aria-label=\"How to play\">?</button>\n </nav>\n </header>\n\n <main id=\"main\">\n <section class=\"toolbar\" aria-label=\"Run options\">\n <div class=\"mode-switch\" id=\"mode-switch\" aria-label=\"Run length\">\n <button data-mode=\"30\" aria-pressed=\"true\">30s</button>\n <button data-mode=\"60\" aria-pressed=\"false\">60s</button>\n <button data-mode=\"50\" aria-pressed=\"false\">50 words</button>\n <button data-mode=\"endless\" aria-pressed=\"false\" aria-label=\"Endless\">∞</button>\n <span class=\"mode-divider\" aria-hidden=\"true\"></span>\n <button data-mode=\"tempo\" class=\"tempo-option\" aria-pressed=\"false\"><span class=\"tempo-icon\" aria-hidden=\"true\"><i></i><i></i><i></i></span>tempo</button>\n </div>\n <button class=\"source-button\" id=\"source-open\"><span id=\"source-label\">common words</span><span aria-hidden=\"true\">↗</span></button>\n </section>\n\n <section id=\"load-panel\" class=\"load-panel\" hidden aria-live=\"polite\">\n <div class=\"spinner\" id=\"loading-spinner\" aria-hidden=\"true\"></div>\n <p class=\"eyebrow\" id=\"load-kicker\">one moment</p>\n <h1 id=\"load-title\">finding the words…</h1>\n <p id=\"load-description\"></p>\n <div class=\"button-row\" id=\"load-actions\" hidden><button class=\"button primary\" id=\"load-retry\">try again</button><button class=\"button\" id=\"load-common\">use common words</button></div>\n </section>\n\n <section id=\"play-panel\" class=\"play-panel\" aria-label=\"Typing stage\">\n <div class=\"run-strip\">\n <div class=\"run-status\"><span class=\"status-dot\" id=\"status-dot\"></span><span id=\"run-status\">a fresh start</span></div>\n <div id=\"best-score\" class=\"best-score\"></div>\n </div>\n <div class=\"metrics\" aria-label=\"Live score\">\n <div><span class=\"metric-label\">pace</span><span class=\"metric-value\" id=\"live-wpm\">0</span><span class=\"metric-unit\">wpm</span></div>\n <div><span class=\"metric-label\">accuracy</span><span class=\"metric-value\" id=\"live-accuracy\">100</span><span class=\"metric-unit\">%</span></div>\n <div class=\"time-metric\"><span class=\"metric-label\" id=\"time-label\">remaining</span><span class=\"metric-value\" id=\"live-time\">30</span><span class=\"metric-unit\" id=\"time-unit\">s</span></div>\n </div>\n <div id=\"ghost-race\" class=\"ghost-race\" hidden aria-live=\"off\"><span>you <b id=\"race-you\">0</b></span><span class=\"race-separator\">/</span><span class=\"ghost-label\">ghost <b id=\"race-them\">0</b></span><span id=\"race-delta\"></span></div>\n <div class=\"stage\" id=\"stage\">\n <span class=\"word-number\" id=\"word-number\" aria-hidden=\"true\">001</span>\n <div class=\"word-wrap\">\n <div class=\"previous-word\" id=\"previous-word\" aria-hidden=\"true\"></div>\n <div class=\"word\" id=\"word\" aria-live=\"off\" aria-label=\"Word to type\"></div>\n </div>\n <div class=\"upcoming\" id=\"upcoming\" aria-hidden=\"true\"><span>up next</span><b id=\"next-word\"></b></div>\n </div>\n <div id=\"tempo-track\" class=\"tempo-track\" hidden><div class=\"tempo-rail\"><div id=\"tempo-progress\"></div></div><div class=\"tempo-caption\"><span id=\"tempo-caption\">find the beat</span><span id=\"tempo-lives\" aria-label=\"Three beats remaining\"><i></i><i></i><i></i></span></div></div>\n <div class=\"input-row\" id=\"input-row\">\n <input
Line truncated
|
||||
const TEMPLATE = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">\n <meta name=\"theme-color\" content=\"#f5f3ec\">\n <!--OG-->\n <link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">\n <link rel=\"stylesheet\" href=\"/style.css\">\n <script type=\"module\" src=\"/app.js\"></script>\n</head>\n<body>\n <div class=\"shell\">\n <header class=\"masthead\">\n <a class=\"brand\" href=\"/\" aria-label=\"typy home\">typy<span class=\"brand-dot\" aria-hidden=\"true\"></span></a>\n <span class=\"brand-note\">a little more in the moment.</span>\n <nav class=\"top-nav\" aria-label=\"Main\">\n <a href=\"/daily\" id=\"daily-link\"><span class=\"sun\" aria-hidden=\"true\">✳</span> the daily</a>\n <button class=\"icon-button\" id=\"settings-open\" aria-label=\"Typing settings\" title=\"Typing settings\">\n <svg width=\"21\" height=\"21\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" aria-hidden=\"true\"><path d=\"M4 7h16M4 17h16\"/><circle cx=\"9\" cy=\"7\" r=\"3\" fill=\"var(--paper)\"/><circle cx=\"15\" cy=\"17\" r=\"3\" fill=\"var(--paper)\"/></svg>\n </button>\n <button class=\"icon-button help-button\" id=\"help-open\" aria-label=\"How to play\">?</button>\n </nav>\n </header>\n\n <main id=\"main\">\n <section class=\"toolbar\" aria-label=\"Run options\">\n <div class=\"mode-switch\" id=\"mode-switch\" aria-label=\"Run length\">\n <button data-mode=\"30\" aria-pressed=\"true\">30s</button>\n <button data-mode=\"60\" aria-pressed=\"false\">60s</button>\n <button data-mode=\"50\" aria-pressed=\"false\">50 words</button>\n <button data-mode=\"endless\" aria-pressed=\"false\" aria-label=\"Endless\">∞</button>\n <span class=\"mode-divider\" aria-hidden=\"true\"></span>\n <button data-mode=\"tempo\" class=\"tempo-option\" aria-pressed=\"false\"><span class=\"tempo-icon\" aria-hidden=\"true\"><i></i><i></i><i></i></span>tempo</button>\n </div>\n <button class=\"source-button\" id=\"source-open\"><span id=\"source-label\">common words</span><span aria-hidden=\"true\">↗</span></button>\n </section>\n\n <section id=\"load-panel\" class=\"load-panel\" hidden aria-live=\"polite\">\n <div class=\"spinner\" id=\"loading-spinner\" aria-hidden=\"true\"></div>\n <p class=\"eyebrow\" id=\"load-kicker\">one moment</p>\n <h1 id=\"load-title\">finding the words…</h1>\n <p id=\"load-description\"></p>\n <div class=\"button-row\" id=\"load-actions\" hidden><button class=\"button primary\" id=\"load-retry\">try again</button><button class=\"button\" id=\"load-common\">use common words</button></div>\n </section>\n\n <section id=\"play-panel\" class=\"play-panel\" aria-label=\"Typing stage\">\n <div class=\"run-strip\">\n <div class=\"run-status\"><span class=\"status-dot\" id=\"status-dot\"></span><span id=\"run-status\">a fresh start</span></div>\n <div id=\"best-score\" class=\"best-score\"></div>\n </div>\n <div class=\"metrics\" aria-label=\"Live score\">\n <div><span class=\"metric-label\">pace</span><span class=\"metric-value\" id=\"live-wpm\">0</span><span class=\"metric-unit\">wpm</span></div>\n <div><span class=\"metric-label\">accuracy</span><span class=\"metric-value\" id=\"live-accuracy\">100</span><span class=\"metric-unit\">%</span></div>\n <div class=\"time-metric\"><span class=\"metric-label\" id=\"time-label\">remaining</span><span class=\"metric-value\" id=\"live-time\">30</span><span class=\"metric-unit\" id=\"time-unit\">s</span></div>\n </div>\n <div id=\"ghost-race\" class=\"ghost-race\" hidden aria-live=\"off\"><span>you <b id=\"race-you\">0</b></span><span class=\"race-separator\">/</span><span class=\"ghost-label\">ghost <b id=\"race-them\">0</b></span><span id=\"race-delta\"></span></div>\n <div class=\"stage\" id=\"stage\">\n <span class=\"word-number\" id=\"word-number\" aria-hidden=\"true\">001</span>\n <div class=\"word-wrap\">\n <div class=\"word\" id=\"word\" aria-live=\"off\" aria-label=\"Word to type\"></div>\n </div>\n <div class=\"upcoming\" id=\"upcoming\" aria-hidden=\"true\"><span>up next</span><b id=\"next-word\"></b></div>\n </div>\n <div id=\"tempo-track\" class=\"tempo-track\" hidden><div class=\"tempo-rail\"><div id=\"tempo-progress\"></div></div><div class=\"tempo-caption\"><span id=\"tempo-caption\">find the beat</span><span id=\"tempo-lives\" aria-label=\"Three beats remaining\"><i></i><i></i><i></i></span></div></div>\n <div class=\"input-row\" id=\"input-row\">\n <input id=\"typing-input\" class=\"typing-input\" type=\"text\" inputmode=\"text\" enterkeyhint=\"Line truncated
|
||||
const escape = (s) => String(s ?? "").replace(/[&<>"\x27]/g, c => ({"&":"&","<":"<",">":">",'"':""","\x27":"'"}[c]));
|
||||
export function renderPage(meta = {}) {
|
||||
const title = escape(meta.title || "typy — one word at a time");
|
||||
|
||||
@@ -62,6 +62,79 @@ test('last-letter mode advances immediately with the same word output scoring',
|
||||
assert.equal(letter.result(1100).stats.bestStreak, 1);
|
||||
});
|
||||
|
||||
test('last-letter mode accepts one habitual separator without an extra attempt or output', () => {
|
||||
const game = new TypingGame({ words: ['cat', 'dog'], mode: 'endless', advance: 'letter' });
|
||||
type(game, 'cat', 0);
|
||||
const before = game.result(300);
|
||||
const state = game.press(' ', 400);
|
||||
const after = game.result(400);
|
||||
assert.equal(state.word, 'dog');
|
||||
assert.equal(state.buffer, '');
|
||||
assert.equal(state.errorIndex, -1);
|
||||
assert.equal(state.correctChars, 4);
|
||||
assert.equal(state.keystrokes, 3);
|
||||
assert.equal(state.errors, 0);
|
||||
assert.deepEqual(after.events, before.events);
|
||||
type(game, 'dog ', 400);
|
||||
game.finish(1100);
|
||||
const result = game.result(1100);
|
||||
assert.equal(result.stats.completedWords, 2);
|
||||
assert.equal(result.stats.correctChars, 8);
|
||||
assert.equal(result.stats.keystrokes, 6);
|
||||
assert.equal(result.stats.accuracy, 100);
|
||||
assert.equal(result.stats.bestStreak, 2);
|
||||
assert.deepEqual(summarizeRun(result), result.stats);
|
||||
});
|
||||
|
||||
test('last-letter separator tolerance does not hide extra or mid-word spaces', () => {
|
||||
for (const nextKeys of [' ', 'd ']) {
|
||||
const game = new TypingGame({ words: ['cat', 'dog'], mode: 'endless', advance: 'letter' });
|
||||
type(game, 'cat', 0);
|
||||
type(game, nextKeys, 300);
|
||||
const state = game.snapshot(500);
|
||||
assert.equal(state.wordIndex, 1);
|
||||
assert.equal(state.buffer, nextKeys === ' ' ? ' ' : 'd ');
|
||||
assert.equal(state.errors, 1);
|
||||
assert.equal(state.errorIndex, nextKeys === ' ' ? 0 : 1);
|
||||
game.press('Backspace', 600);
|
||||
type(game, nextKeys === ' ' ? 'dog' : 'og', 600);
|
||||
game.finish(1500);
|
||||
const result = game.result(1500);
|
||||
assert.equal(result.stats.completedWords, 2);
|
||||
assert.equal(result.stats.correctChars, 8);
|
||||
assert.equal(result.stats.errors, 1);
|
||||
assert.equal(result.stats.accuracy, 85.7);
|
||||
assert.equal(result.stats.bestStreak, 1);
|
||||
assert.deepEqual(summarizeRun(result), result.stats);
|
||||
}
|
||||
});
|
||||
|
||||
for (const advance of ['space', 'letter']) {
|
||||
for (const targetWpm of [100, 160]) {
|
||||
test(`${advance} advancement scores steady ${targetWpm} WPM spaced typing accurately`, () => {
|
||||
for (const mode of ['30', '60']) {
|
||||
const game = new TypingGame({ words: ['test'], mode, advance });
|
||||
const durationMs = Number(mode) * 1000;
|
||||
const intervalMs = 12000 / targetWpm;
|
||||
const outputChars = durationMs / intervalMs;
|
||||
const input = 'test '.repeat(outputChars / 5);
|
||||
for (let i = 0; i < input.length; i++) game.press(input[i], i * intervalMs);
|
||||
const result = game.result(durationMs);
|
||||
assert.equal(result.durationMs, durationMs);
|
||||
assert.equal(result.finishedReason, 'time');
|
||||
assert.equal(result.stats.wpm, targetWpm);
|
||||
assert.equal(result.stats.correctChars, outputChars);
|
||||
assert.equal(result.stats.completedWords, outputChars / 5);
|
||||
assert.equal(result.stats.keystrokes, outputChars * 4 / 5);
|
||||
assert.equal(result.stats.accuracy, 100);
|
||||
assert.equal(result.stats.errors, 0);
|
||||
assert.equal(game.snapshot(durationMs).wpm, targetWpm);
|
||||
assert.deepEqual(summarizeRun(result), result.stats);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('WPM measures net output and accuracy measures attempts, without deletion farming', () => {
|
||||
const game = new TypingGame({ words: ['cat'], mode: 'endless' });
|
||||
game.press('c', 0);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Optional real-browser regressions for fast typing. Motion stays enabled.
|
||||
// Start npm start, then run CHROME=/path/to/chrome node test/responsiveness.mjs.
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {writeFile, mkdir} from 'node:fs/promises';
|
||||
const require = createRequire(import.meta.url);
|
||||
const {chromium} = require(process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES
|
||||
? process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES + '/playwright' : 'playwright');
|
||||
const base = (process.env.TYPY_BASE_URL || 'http://127.0.0.1:8787').replace(/\/$/, '');
|
||||
const browser = await chromium.launch({executablePath:process.env.CHROME || chromium.executablePath(),headless:true,args:['--no-sandbox']});
|
||||
const checks = [];
|
||||
const errors = [];
|
||||
try {
|
||||
const page = await browser.newPage({viewport:{width:1440,height:1000},reducedMotion:'no-preference',ignoreHTTPSErrors:process.env.TYPY_IGNORE_HTTPS_ERRORS === '1'});
|
||||
page.on('pageerror',error=>errors.push(error.message));
|
||||
await page.goto(base + '/');
|
||||
await page.locator('#word[data-word]').waitFor();
|
||||
const idleMutations = await page.evaluate(async () => {
|
||||
let changes = 0;
|
||||
const observer = new MutationObserver(rows => { changes += rows.length; });
|
||||
observer.observe(document.getElementById('play-panel'), {subtree:true,childList:true,attributes:true,characterData:true});
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
observer.disconnect();
|
||||
return changes;
|
||||
});
|
||||
assert.equal(idleMutations,0,'The ready stage must not continuously redraw itself.');
|
||||
checks.push({name:'Ready stage stays still',idleMutations});
|
||||
|
||||
async function paste(text, advance = 'space') {
|
||||
await page.locator('[data-mode=endless]').click();
|
||||
await page.locator('#settings-open').click();
|
||||
await page.locator('#advance-setting').selectOption(advance);
|
||||
await page.locator('#settings-done').click();
|
||||
await page.locator('#source-open').click();
|
||||
await page.locator('#tab-paste').click();
|
||||
await page.locator('#paste-input').fill(text);
|
||||
await page.locator('#source-paste button[type=submit]').click();
|
||||
}
|
||||
|
||||
await paste('calm river');
|
||||
await page.evaluate(() => { window.firstLetter = document.querySelector('#word span'); });
|
||||
await page.keyboard.type('c');
|
||||
assert.equal(await page.evaluate(() => window.firstLetter === document.querySelector('#word span')),true,'A key must update existing letters, not replace the word.');
|
||||
assert.equal(await page.locator('#word .correct').count(),1);
|
||||
await page.keyboard.type('alm ');
|
||||
const transition = await page.evaluate(() => {
|
||||
const word = document.getElementById('word'), style = getComputedStyle(word);
|
||||
return {word:word.dataset.word,opacity:style.opacity,transform:style.transform,animations:word.getAnimations().length};
|
||||
});
|
||||
assert.deepEqual(transition,{word:'river',opacity:'1',transform:'none',animations:0},'The next word must be fully readable immediately, with normal motion settings.');
|
||||
await page.keyboard.type('r');
|
||||
assert.equal(await page.locator('#word .correct').count(),1);
|
||||
checks.push({name:'Immediate stationary target and persistent letters',...transition});
|
||||
|
||||
for (const advance of ['space','letter']) {
|
||||
await paste('a calm river moves through the city',advance);
|
||||
await page.keyboard.type('a calm river moves through the city ',{delay:75});
|
||||
assert.equal(await page.locator('#word').getAttribute('data-word'),'a');
|
||||
assert.equal(await page.locator('#live-accuracy').textContent(),'100');
|
||||
assert.equal(await page.locator('#word .incorrect').count(),0);
|
||||
checks.push({name:'Normal spaced typing at 160 WPM input cadence',advance,accuracy:100});
|
||||
}
|
||||
|
||||
for (const width of [390,360]) {
|
||||
await page.setViewportSize({width,height:844});
|
||||
await paste('a'.repeat(64) + ' next','letter');
|
||||
const before = await page.evaluate(() => ({viewport:innerWidth,document:document.documentElement.scrollWidth}));
|
||||
assert.ok(before.document <= before.viewport + 1);
|
||||
await page.locator('#typing-input').fill('a'.repeat(64));
|
||||
const after = await page.evaluate(() => ({word:document.getElementById('word').dataset.word,viewport:innerWidth,document:document.documentElement.scrollWidth,opacity:getComputedStyle(document.getElementById('word')).opacity}));
|
||||
assert.equal(after.word,'next');
|
||||
assert.equal(after.opacity,'1');
|
||||
assert.ok(after.document <= after.viewport + 1);
|
||||
checks.push({name:'Long phone word and instant transition fit',width,before,after});
|
||||
}
|
||||
assert.deepEqual(errors,[]);
|
||||
const report = {checkedAt:new Date().toISOString(),base,motion:'no-preference',checks,errors};
|
||||
await mkdir('evidence',{recursive:true});
|
||||
await writeFile('evidence/responsiveness.json',JSON.stringify(report,null,2) + '\n');
|
||||
console.log(JSON.stringify(report,null,2));
|
||||
} finally { await browser.close(); }
|
||||
Reference in new issue
Block a user