8000 feat: add the thokr.jrnxf.co website, where every line is a thokr test you can type by jrnxf · Pull Request #40 · jrnxf/thokr · GitHub
[go: up one dir, main page]

Skip to content < 8000 div data-target="react-partial.reactRoot">

feat: add the thokr.jrnxf.co website, where every line is a thokr test you can type - #40

Merged
jrnxf merged 4 commits into
mainfrom
fm/thokr-landing-designs
Oct 25, 2026
Merged

jrnxf merged 4 commits into
mainfrom
fm/thokr-landing-designs

Conversation

@jrnxf
@jrnxf jrnxf commented Oct 25, 2026 •
Copy link
Copy Markdown
Owner

Adds thokr.jrnxf.co: one page where every line is a thokr typing test, run by thokr's own Rust compiled to WebAssembly.

For you: review and merge (about 10 min), then set up the deploy once (about 5 min). Nothing on Cloudflare exists yet:

  1. In Cloudflare, Workers & Pages > Create > Import a repository: pick jrnxf/thokr, with root directory web.
  2. Set the build command to ./build.sh and the deploy command to npx wrangler deploy (it installs Rust itself, about 1 min).
  3. The first deploy creates the thokr-web Worker and attaches thokr.jrnxf.co from wrangler.jsonc. If the domain does not appear, add it under the Worker's Domains & Routes.

Check after the first deploy (2 min):

  1. Open thokr.jrnxf.co and type the words at the top without clicking.
  2. Keep mashing keys past the end: the page moves to the next line and keeps your keys.
  3. Type the last line: the install footer grows.

Fixed on the way: mashing keys after the end of a block reset it instead of moving on, because thokr reads r and n there as "again" and "new words". Keys after the end now carry on into the next block. A Playwright test mashes past a sentence and fails without the fix.

Evidence: before and after mashing past the first line, then the built site, dark desktop and light phone.

Before: the line reset After: finished, the next line took the keys
Before: mashing past the end resets the line After: the line is finished and the page moved on
Desktop, dark Phone, light
The site on desktop in dark mode The site on a phone in light mode

Risk: two-way door. Blast radius: a new subdomain only; the crate and the TUI do not change (web is excluded from the package).


Intent

About thokr: thokr (github.com/jrnxf/thokr, Rust TUI, on crates.io and brew) is a sleek typing test in the terminal with visualized results (WPM chart) and historical logging. It has no landing page or logo yet.

The owner wrote: "could you prototype a landing page for creative fsrx and thokr landing page using the same prompt and ultimate creativity we used for building the ones for drip and okfs"

The prompt those used, for context: the okfs site leaned into its logo's retro-80s "snowbird" feel (80s travel posters, motel signage, sun-faded print, early computer catalogue) and the owner picked "Catalogue" - "super dope, I love it"; the drip site's "Instrument" direction drew the product as a physical device with its real engine inside (Archivo and Martian Mono, one orange, cream paper) - "honestly in love with instrument". Both put the real product running in the page. The owner said "NO AI FLUFF. be creative"; they found gradient-blob/glass "Aurora" pages "AI vibe coded"; they want plain, specific copy with no hype. Live references: https://okfs.jrnxf.co and https://drip.jrnxf.co.

Is it possible to use WebAssembly in the actual Docker binary using WebAssembly or something? I like D and B the best. More like that, please

i like one line the best, but i don't like that i have to click into things for the interactivity to start. can you fix that and give me more like one line? i do like the dots look but it's a lie that thokr does anything with the braille look right?

i like one-line, but it's a bit text heavy, and I'm not sure of the font

i actually also really like the moving text on ticker. curious if we could amke that an option on thokre to test out?

using the shaders and stuff is it possible ta hev the the ticker move the text slowly? also ticker should ahve text starting in the middle i think

can i get a preview of cell level fades?

i think bc we can't smooth the movement of the chars i'm okay to just not do the ticker and keep what we have. where does that leave us?

on thokr, when i'm done typing can it scroll me to the next block ready to keep going

let the typing bits on thokr actualy go full page, and no periods at the end., also don't make them type the brew part. just have a clean footer for install instructions

thokr...

[screenshot: the hero typing block, text wrapping at about 60% of the width with a words/limit/pace bar under it] is not full width

don't show the font picker DM mono is it

when teh user types the last char in the last block the install instructions should grow to be a bit bigger and more prominent

thokr

looks good! when i spammed keys some sections din't progress and just reset. fix that and then get this work started!

What Changed

  • New web/ site for thokr.jrnxf.co. web/engine compiles thokr's own src/ (the test loop, the screen and the word lists) to WebAssembly (wasm32-wasip1) behind a small C ABI (tk_key, tk_tick, tk_render, tk_state). web/public is a single DM Mono page. Every line on it is a full-width thokr prompt that takes keys right away. When a block is finished, the page scrolls to the next one. Fast typing carries keys into the next block instead of resetting the finished one. After the last block, the install footer (brew and cargo, with copy buttons) grows larger.
  • web/build.sh builds web/dist. It installs a pinned rustup (checked against its sha256) and Rust 1.99.1 when they are missing, and names the wasm by its content hash. _headers caches that file as immutable. wrangler.jsonc serves dist as static assets on the thokr.jrnxf.co custom domain.
  • A new web CI job in .github/workflows/build.yml runs npm ci, ./build.sh and npm test. The tests check the engine and kit/chart.js against thokr's insta snapshots, and type through the page in Chromium with Playwright, including a key-mashing burst test. Cargo.toml now excludes web from the crates.io package.

Risk Assessment

✅ Low: The fix round only changes when the scroll hold ends (it now also ends on a 1s timeout, a wheel or a touch). The hold can no longer stay set forever, and the hold still keeps keys on the next block while the scroll moves past the finished one. The rewritten test checks the Backspace and the ArrowLeft in separate steps, so each check now proves its own claim.

Testing

I built the site with build.sh on a machine with no Rust. rustup-init installed under its own name, so the earlier error is gone, and the wasm build succeeded. In Chromium the 4 burst tests and 10 snapshot tests pass, including the new Backspace and ArrowLeft steps for r2-2. A disposable live driver then checked: - Typing with no click, and a full-width 1200px hero in DM Mono with no font picker. - Sentences with no trailing periods and no brew command to type. - Auto-scroll to the next block after the hero. - A one-burst mash through every block with smooth scroll on: all 4 sentences finished at 100% and none reset. - The install footer growing from 17px to 40px after the last key. I simulated Safari by blocking scrollend registrations. The keyboard hold let go after about 1s when the page was moved without a wheel, and at once on a wheel event. Screenshots and logs are in the evidence directory. I removed the build outputs, node_modules and the temporary toolchains afterwards.

  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
Build on a host with no Rust: build.sh installs rustup and produces dist ✅ pass live build.sh with a fresh CARGO_HOME/RUSTUP_HOME logged 'Installing rustup 1.29.2' and finished with dist/kit/thokr.<hash>.wasm
Visitor types on page load with no click and the hero test receives the keys ✅ pass live drive.log S1: hero active and focused on load, 12 correct marks after 12 keys; 02-hero-typing.png
Hero prompt spans the full content width in DM Mono, with no font picker ✅ pass live drive.log S1: prompt width 1200 = content width 1200, body font DM Mono, no picker element; 02-hero-typing.png
Sentence blocks have no trailing periods and none asks the user to type the brew command ✅ pass live drive.log S1 sentences list; brew appears only in the install footer (05/06 screenshots)
Finishing a block shows results and scrolls to the next block with the keyboard ready ✅ pass live drive.log S2: results shown, block 0 active and focused; 03-after-hero-scrolled.png
Mashing keys (including thokr's r/n) past the end of a block moves on instead of resetting ✅ pass live burst.test.mjs mash tests pass; drive.log S3: a one-burst run through all blocks with smooth scroll on finished every sentence at 100% acc, no resets, install done
Backspace and ArrowLeft typed mid auto-scroll go to the block being typed (r1/r2-2) ✅ pass live burst.test.mjs 'keys typed while the page scrolls to the next sentence stay with it' passes in Chromium with smooth scroll
Last key of the last block grows the install footer ✅ pass live drive.log S4: footer code 17px to 40px and h2 13px to 18px; 05 vs 06 screenshots; burst.test full-run test passes
Adversarial: with no scrollend (Safari-like), the user moves away mid auto-scroll and the keyboard hold still releases (r2-1) ✅ pass live drive.log S7: without a wheel event, the hold released by about 1250ms and the hero took the keyboard; with a wheel event it released at once; S6 later scroll updated the active block

Hero typed with no click, full width, DM Mono, no font picker
Page load: the hero has the keyboard
After the hero finishes: results chart, page scrolled to the first sentence with its cursor
Install footer before the last key
Install footer after the last key (grown)
Mash run end state

Evidence: Live driver transcript
S1 active on load {"block":"hero","focused":true}
S1 hero correct marks after 12 keys w/o click 12
S1 widths prompt/wrap/viewport {"prompt":1200,"wrap":1200,"vw":1440}
S1 font picker present false
S1 body font "\"DM Mono\", ui-monospace, monospace"
S1 sentences ["thokr is a typing test that runs in your terminal","when you finish, it plots your speed for each second","each test adds a row to a log file","one flag changes the test"]
S2 hero results shown true
S2 active after hero finish {"block":0,"focused":true}
S1/S2 errors []
S3 per-block after text ["588 wpm · 100% acc · 0.00 sd · left arrow: again","624 wpm · 100% acc · 0.00 sd · left arrow: again","408 wpm · 100% acc · 0.00 sd · left arrow: again","300 wpm · 100% acc · 0.00 sd · left arrow: again"]
S3 install done true
S3 errors []
S4 before last key {"done":false,"code":"17px","h2":"13px"}
S4 after last key {"done":true,"code":"40px","h2":"18px"}
S4 errors []
S5-wheel-back-chromium hold set, auto scroll under way at scrollY 1855
S5-wheel-back-chromium scrollY before/after wheel [1903,2665]
S5-wheel-back-chromium active vs nearest-to-middle {"active":{"block":2,"focused":true},"nearest":2}
S5-wheel-back-chromium errors []
S5-wheel-back-no-scrollend hold set, auto scroll under way at scrollY 1855
S5-wheel-back-no-scrollend scrollY before/after wheel [1903,2673]
S5-wheel-back-no-scrollend active vs nearest-to-middle {"active":{"block":2,"focused":true},"nearest":2}
S5-wheel-back-no-scrollend errors []
S6 after auto-scroll {"block":1,"focused":true}
S6 after scrolling to top without wheel {"block":"hero","focused":true}
S6 errors []
S7-drag 250ms after jumping to top {"scrollY":48,"active":{"block":2,"focused":true}}
S7-drag 1250ms after jumping to top {"scrollY":48,"active":{"block":"hero","focused":true}}
S7-drag key after that went to hero 1
S7-drag errors []
S7-wheel 250ms after jumping to top {"scrollY":48,"active":{"block":"hero","focused":true}}
S7-wheel 1250ms after jumping to top {"scrollY":48,"active":{"block":"hero","focused":true}}
S7-wheel key after that went to hero 1
S7-wheel errors []
Evidence: Live driver script
// Disposable live driver for thokr.jrnxf.co: serves web/dist and drives it in Chromium.
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import { createRequire } from "node:module";
const W = "~/.no-mistakes/worktrees/805ef586b106/01M5TDYAQ6GR048QFQ0RFF92RF/web/";
const { chromium } = createRequire(W + "package.json")("playwright");
const EV = "~/.no-mistakes/evidence/01M5TDYAQ6GR048QFQ0RFF92RF/";
const dist = W + "dist";
const T = { ".html": "text/html", ".js": "text/javascript", ".wasm": "application/wasm", ".woff2": "font/woff2" };
const server = createServer(async (req, res) => {
  const p = new URL(req.url, "http://x").pathname.replace(/^\/$/, "/index.html");
  try { res.writeHead(200, { "content-type": T[extname(p)] ?? "application/octet-stream" }).end(await readFile(dist + p)); }
  catch { res.writeHead(404).end(); }
});
await new Promise((d) => server.listen(0, "127.0.0.1", d));
const url = `http://127.0.0.1:${server.address().port}/`;
const browser = await chromium.launch();
const log = [];
const out = (k, v) => { log.push(`${k}: ${JSON.stringify(v)}`); console.log(k, JSON.stringify(v)); };

async function open(opts = {}, init) {
  const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, ...opts });
  const errors = [];
  page.on("pageerror", (e) => errors.push(String(e)));
  if (init) await page.addInitScript(init);
  await page.goto(url);
  await page.waitForFunction(() => document.querySelectorAll("#prompt span").length > 0 && document.querySelector(".tk-active"));
  await page.evaluate(() => document.fonts.ready);
  return { page, errors };
}
const active = (page) => page.evaluate(() => {
  const a = document.querySelector(".tk-active");
  return { block: a.id === "hero" ? "hero" : [...document.querySelectorAll(".line")].indexOf(a), focused: a.contains(document.activeElement) };
});
const say = (page, i) => page.$$eval(".line", (els, i) => els[i].querySelector("[data-say]").dataset.say, i);

// S1: load, no click, type -> hero receives keys; layout full width, no font picker
{
  const { page, errors } = await open();
  await page.screenshot({ path: EV + "01-load.png" });
  out("S1 active on load", await active(page));
  const prompt = await page.$eval("#prompt", (el) => el.textContent);
  await page.keyboard.type(prompt.slice(0, 12), { delay: 30 });
  out("S1 hero correct marks after 12 keys w/o click", await page.$$eval("#prompt .c", (e) => e.length));
  out("S1 widths prompt/wrap/viewport", await page.evaluate(() => ({ prompt: document.getElementById("prompt").getBoundingClientRect().width, wrap: document.querySelector(".hero").getBoundingClientRect().width, vw: innerWidth })));
  out("S1 font picker present", await page.evaluate(() => !!document.querySelector("select, [data-font], #font, .fonts")));
  out("S1 body font", await page.evaluate(() => getComputedStyle(document.body).fontFamily));
  out("S1 sentences", await page.$$eval("[data-say]", (e) => e.map((x) => x.dataset.say)));
  await page.screenshot({ path: EV + "02-hero-typing.png" });
  // finish the hero at real speed with smooth scroll on
  await page.keyboard.type(prompt.slice(12), { delay: 20 });
  await page.waitForTimeout(1500);
  out("S2 hero results shown", await page.$eval("#res", (el) => !el.hidden));
  out("S2 active after hero finish", await active(page));
  await page.screenshot({ path: EV + "03-after-hero-scrolled.png" });
  await page.evaluate(() => scrollTo(0, 0));
  await page.waitForTimeout(500);
  await page.screenshot({ path: EV + "03b-hero-results.png" });
  out("S1/S2 errors", errors);
  await page.close();
}

// S3: mash keys through all blocks with smooth scrolling on, no waits
{
  const { page, errors } = await open();
  const prompt = await page.$eval("#prompt", (el) => el.textContent);
  const n = await page.$$eval(".line", (e) => e.length);
  const says = [];
  for (let i = 0; i < n; i++) says.push(await say(page, i));
  // the whole page in one burst, smooth scrolling on, no waits between blocks
  await page.keyboard.type(prompt + says.join(""), { delay: 5 });
  await page.waitForTimeout(1500);
  out("S3 per-block after text", await page.$$eval(".line .after", (e) => e.map((x) => x.textContent)));
  out("S3 install done", await page.$eval("#install", (el) => el.classList.contains("done")));
  out("S3 errors", errors);
  await page.screenshot({ path: EV + "04-mash-end.png" });
  await page.close();
}

// S4: install footer grows after the last key (reduced motion, full run, before/after shots)
{
  const { page, errors } = await open({ reducedMotion: "reduce" });
  await page.keyboard.type(await page.$eval("#prompt", (el) => el.textContent), { delay: 0 });
  const n = await page.$$eval(".line", (e) => e.length);
  for (let i = 0; i < n - 1; i++) {
    await page.waitForFunction((i) => document.querySelectorAll(".line")[i].classList.contains("tk-active"), i);
    await page.keyboard.type(await say(page, i), { delay: 0 });
  }
  await page.waitForFunction((i) => document.querySelectorAll(".line")[i].classList.contains("tk-active"), n - 1);
  const last = await say(page, n - 1);
  await page.keyboard.type(last.slice(0, -1), { delay: 0 });
  const sz = () => page.evaluate(() => ({ done: document.getElementById("install").classList.contains("done"), code: getComputedStyle(document.getElementById("i1")).fontSize, h2: getComputedStyle(document.querySelector("footer h2")).fontSize }));
  out("S4 before last key", await sz());
  await page.evaluate(() => scrollTo(0, document.body.scrollHeight));
  await page.waitForTimeout(300);
  await page.screenshot({ path: EV + "05-footer-before-last-key.png" });
  await page.keyboard.type(last.slice(-1));
  await page.waitForTimeout(1200);
  out("S4 after last key", await sz());
  await page.evaluate(() => scrollTo(0, document.body.scrollHeight));
  await page.waitForTimeout(300);
  await page.screenshot({ path: EV + "06-footer-after-last-key.png" });
  out("S4 errors", errors);
  await page.close();
}

// S5 adversarial: Safari-like (no scrollend), user wheels back up mid auto-scroll -> hold must release
async function holdCase(label, init) {
  const { page, errors } = await open({}, init);
  await page.$$eval(".line", (els) => els[1].scrollIntoView({ block: "center" }));
  await page.waitForFunction(() => document.querySelectorAll(".line")[1].classList.contains("tk-active"));
  await page.waitForTimeout(300);
  const ys = await page.evaluate(() => scrollY);
  await page.keyboard.type(await say(page, 1), { delay: 0 }); // after a pause, auto scrolls to block 2
  await page.waitForFunction((ys) => scrollY > ys + 50, ys); // the smooth scroll has started
  out(`${label} hold set, auto scroll under way at scrollY`, await page.evaluate(() => scrollY));
  await page.mouse.move(700, 450);
  const y0 = await page.evaluate(() => scrollY); for (let k = 0; k < 8; k++) { await page.mouse.wheel(0, -900); await page.waitForTimeout(30); } out(`${label} scrollY before/after wheel`, [y0, await page.evaluate(() => scrollY)]);
  await page.waitForTimeout(1500);
  const a = await active(page);
  const nearest = await page.evaluate(() => {
    const els = [document.getElementById("hero"), ...document.querySelectorAll(".line")];
    let best, bd = Infinity;
    els.forEach((e, i) => { const r = e.getBoundingClientRect(); if (r.bottom < 0 || r.top > innerHeight) return; const d = Math.abs((r.top + r.bottom) / 2 - innerHeight / 2); if (d < bd) { bd = d; best = i === 0 ? "hero" : i - 1; } });
    return best;
  });
  out(`${label} active vs nearest-to-middle`, { active: a, nearest });
  await page.screenshot({ path: EV + `07-${label}.png` });
  out(`${label} errors`, errors);
  await page.close();
}
const noScrollend = () => {
  const orig = EventTarget.prototype.addEventListener;
  EventTarget.prototype.addEventListener = function (t, ...r) { if (t === "scrollend") return; return orig.call(this, t, ...r); };
};
await holdCase("S5-wheel-back-chromium");
await holdCase("S5-wheel-back-no-scrollend", noScrollend);

// S6 adversarial: Safari-like, hold set but no wheel; after 1s timeout a manual (keyboard-free, programmatic) scroll must move the active block
{
  const { page, errors } = await open({}, noScrollend);
  await page.$$eval(".line", (els) => els[0].scrollIntoView({ block: "center" }));
  await page.waitForFunction(() => document.querySelectorAll(".line")[0].classList.contains("tk-active"));
  await page.waitForTimeout(300);
  await page.keyboard.type(await say(page, 0), { delay: 0 });
  await page.waitForTimeout(1500);
  out("S6 after auto-scroll", await active(page));
  // scrollbar-style scroll (no wheel event) up to the hero
  await page.evaluate(() => scrollTo({ top: 0, behavior: "instant" }));
  await page.waitForTimeout(400);
  out("S6 after scrolling to top without wheel", await active(page));
  out("S6 errors", errors);
  await page.close();
}

// S7 adversarial, Safari-like (no scrollend): the user drags the page back to the top mid auto-scroll
// (no wheel event). The hold must still let go (1s timeout); with a wheel it must let go at once.
for (const how of ["drag", "wheel"]) {
  const { page, errors } = await open({}, noScrollend);
  await page.$$eval(".line", (els) => els[1].scrollIntoView({ block: "center" }));
  await page.waitForFunction(() => document.querySelectorAll(".line")[1].classList.contains("tk-active"));
  await page.waitForTimeout(300);
  const ys = await page.evaluate(() => scrollY);
  await page.keyboard.type(await say(page, 1), { delay: 0 });
  await page.waitForFunc
8000
tion((ys) => scrollY > ys + 50, ys);
  await page.evaluate((how) => { scrollTo({ top: 0, behavior: "instant" }); if (how === "wheel") dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })); }, how);
  await page.waitForTimeout(250);
  out(`S7-${how} 250ms after jumping to top`, { scrollY: await page.evaluate(() => scrollY), active: await active(page) });
  await page.waitForTimeout(1000);
  out(`S7-${how} 1250ms after jumping to top`, { scrollY: await page.evaluate(() => scrollY), active: await active(page) });
  await page.keyboard.type("x");
  out(`S7-${how} key after that went to hero`, await page.$$eval("#prompt .c, #prompt .x", (e) => e.length));
  await page.screenshot({ path: EV + `08-S7-${how}.png` });
  out(`S7-${how} errors`, errors);
  await page.close();
}

await browser.close();
server.close();
Evidence: Hold release with no scrollend (Safari-like)
S7-drag 250ms after jumping to top {active: block 2 (held)}
S7-drag 1250ms after jumping to top {active: hero, focused}
S7-wheel 250ms after jumping to top {active: hero, focused}
S7-* key after that went to hero: 1
- Outcome: ⚠️ 1 info across 2 runs (9m26s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed (2) ✅
  • ⚠️ web/public/kit/engine.js:186 - While the page smooth-scrolls to the next block, the scroll handler can hand the keyboard back to the block that just finished. moveOn() focuses the next textarea, and its focus listener makes the next block active. But the next scroll event runs pick(), and for the first half of the scroll the finished block is still nearest the middle of the window. activate() then focuses the finished block again (web/public/kit/engine.js:186-189 and 196). Keys pressed in that window go to the finished block. Characters are still passed on through afterFinish/carryOn. Backspace is dropped: WasmTyper.press returns when wasDone && k === "Backspace" (engine.js:116). ArrowLeft/ArrowRight restart the finished block instead of acting on the block being typed. Concrete case: the user finishes sentence 1, keeps typing into sentence 2, mistypes, and presses Backspace within about 300-500ms. The Backspace is swallowed, so the next correct key lands after the error. The same class applies to the hero-to-first-sentence move (index.html moveOn/advance). The mash test in web/test/burst.test.mjs does not catch this: it types with delay 0 before any scroll event fires, and the full-run test uses reducedMotion. Fix at the shared boundary in autoType: while a programmatic scroll started by moveOn is running, pick() should not take focus away (for example, set a flag until scrollend), or pick() should only activate a block on scrolls the user starts.

🔧 Fix applied.
3 issues (1 warning, 2 infos) still open:

  • ⚠️ web/public/kit/engine.js:186 - While the page smooth-scrolls to the next block, the scroll handler can hand the keyboard back to the block that just finished. moveOn() focuses the next textarea, and its focus listener makes the next block active. But the next scroll event runs pick(), and for the first half of the scroll the finished block is still nearest the middle of the window. activate() then focuses the finished block again (web/public/kit/engine.js:186-189 and 196). Keys pressed in that window go to the finished block. Characters are still passed on through afterFinish/carryOn. Backspace is dropped: WasmTyper.press returns when wasDone && k === "Backspace" (engine.js:116). ArrowLeft/ArrowRight restart the finished block instead of acting on the block being typed. Concrete case: the user finishes sentence 1, keeps typing into sentence 2, mistypes, and presses Backspace within about 300-500ms. The Backspace is swallowed, so the next correct key lands after the error. The same class applies to the hero-to-first-sentence move (index.html moveOn/advance). The mash test in web/test/burst.test.mjs does not catch this: it types with delay 0 before any scroll event fires, and the full-run test uses reducedMotion. Fix at the shared boundary in autoType: while a programmatic scroll started by moveOn is running, pick() should not take focus away (for example, set a flag until scrollend), or pick() should only activate a block on scrolls the user starts.
  • ℹ️ web/public/kit/engine.js:193 - The round-1 fix adds held, and only two things clear it: pick() finding the held block nearest the middle, or a scrollend event. Safari has no scrollend up to at least 18.x. Concrete case in Safari: the user finishes a sentence, moveOn calls hold(next) and starts the smooth scroll, then the user turns the wheel or swipes the other way before next reaches the middle. held never clears. Every later pick() returns early, so .tk-active and the keyboard stay on a block that is off screen. This lasts until the user scrolls back past that block or clicks a prompt. It can also happen in any browser when scrollIntoView needs no scroll at all: no scroll event fires, held stays set, and the next manual scroll does not update the active block until it ends. Smallest fix: also clear held on input the user starts (wheel, touchstart, scroll keys), or after a short timeout set in hold().
  • ℹ️ web/test/burst.test.mjs:115 - Round-1 test: the assertion message says "the Backspace undid the mistake", but the test presses ArrowLeft on block 1 right after the Backspace. In the engine, ArrowLeft always restarts the test (engine/src/lib.rs:143 KEY_LEFT => app.restart()). That restart wipes the wrong key whether or not the Backspace reached block 1, so the [&#39;c&#39;,&#39;c&#39;,&#39;c&#39;] check does not prove the Backspace was routed correctly. Only the ArrowLeft half (block 0 stays finished) and the focus check carry evidence. Fix: check block 1's marks after the Backspace and before the ArrowLeft, or test the two keys in separate steps.

🔧 Fix applied.
✅ Re-checked - no issues remain.

⚠️ **Test** - 1 info
  • 🚨 web/build.sh:30 - On a machine with no rustup, build.sh failed with "error: unknown proxy name: 'tmp'". It saved rustup-init under a mktemp name (tmp.XXXX), and rustup-init reads its own file name. The README says that is the Cloudflare Workers Builds deploy path. GitHub CI passes only because its runners already have rustup. I fixed it in the worktree: mktemp -d, save the file as $tmp/rustup-init, and remove the directory on exit. The full build then passed.
  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
build.sh builds the site on a machine with no rustup (the Cloudflare Workers Builds path) ✅ pass live Before the fix: 'error: unknown proxy name: 'tmp''. After saving rustup-init under its own name in a mktemp -d directory, build.sh installed Rust 1.99.1 and wrote dist/ with thokr.<hash>.wasm
Visitor opens the page and types with no click: the hero test takes the keys, the prompt is full width, in DM Mono, with no font picker ✅ pass live drive-transcript.txt: active hero focused:true, 3 marks after typing; prompt width 1200 = hero width 1200; no font picker; 01-hero-typing-no-click.png
Typed sentences have no trailing periods and no brew command; install is a clean footer ✅ pass live drive-transcript.txt typed prompts list; 04-install-footer-grown.png
Mashing keys (including thokr's r/n) past the end of the hero moves on to sentence 1 instead of resetting ✅ pass live burst.test.mjs test 2; drive-transcript: results visible, active block 0 focused, 4 carried keys; 02-hero-mash-scrolled-to-sentence1.png
Mashing r/n bursts through every sentence: each one finishes with wpm and none resets ✅ pass live burst.test.mjs test 1; drive-transcript: all sentences finished with wpm [true,true,true,true]
Finishing a block scrolls to the next one, ready to keep typing; Backspace and ArrowLeft during the smooth scroll act on the right block ✅ pass live burst.test.mjs test 4 (marks checked after Backspace, ArrowLeft checked in its own step); drive-hold A: active 1 while the finished block is still nearest the middle
Adversarial: with no scrollend (Safari), the scroll hold still releases by the 1s timeout ✅ pass live drive-hold C: hold on at 150ms (active 1), released after 1s, active follows the viewport to hero
Adversarial: a user wheel releases the scroll hold at once ✅ pass live drive-wheel D: one wheel tick at ~280ms (scrollend stubbed) moved active from 1 to hero
The last key of the last block makes the install footer bigger ✅ pass live burst.test.mjs test 3; drive-transcript: #install.done true, code font-size 40px; 04-install-footer-grown.png
The WebAssembly engine and the results chart match thokr's terminal snapshots ✅ pass live node --test test/snapshots.test.mjs: 10/10
  • CARGO_HOME=/tmp/nm-thokr/cargo RUSTUP_HOME=/tmp/nm-thokr/rustup ./build.sh (fresh rustup install; failed before the build.sh fix, passed after)
  • env -u LD_LIBRARY_PATH node --test test/burst.test.mjs (4/4: mashing past the end of a sentence, mashing past the end of the test at the top, typing every block with no clicks, keys typed during the smooth scroll)
  • node --test test/snapshots.test.mjs (the WebAssembly engine and kit/chart.js against thokr's insta snapshots, 10/10)
  • node evidence/drive.mjs: page load with no click, hero full width, no font picker, prompts have no periods and no brew, hero mash, mash with r/n through all 4 sentences, install footer grows, mobile and dark screenshots
  • node evidence/drive-hold.mjs: keyboard stays on the next block mid smooth-scroll; no scrollend plus the page jumping away, released by the 1s timeout
  • node evidence/drive-wheel.mjs: one user wheel tick releases the hold before the 1s timeout (scrollend stubbed out)

🔧 Fix applied.
1 info still open:

  • ℹ️ web/public/index.html:220 - A wheel or scroll during the pause after a block finishes (before moveOn fires, about 700ms for sentences and 1400ms for the hero) still leads to an auto-scroll back down to the next block once the pause ends. This matches the requested 'scroll me to the next block' behavior. It may surprise a user who scrolls up right after finishing.
  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
Build on a host with no Rust: build.sh installs rustup and produces dist ✅ pass live build.sh with a fresh CARGO_HOME/RUSTUP_HOME logged 'Installing rustup 1.29.2' and finished with dist/kit/thokr.<hash>.wasm
Visitor types on page load with no click and the hero test receives the keys ✅ pass live drive.log S1: hero active and focused on load, 12 correct marks after 12 keys; 02-hero-typing.png
Hero prompt spans the full content width in DM Mono, with no font picker ✅ pass live drive.log S1: prompt width 1200 = content width 1200, body font DM Mono, no picker element; 02-hero-typing.png
Sentence blocks have no trailing periods and none asks the user to type the brew command ✅ pass live drive.log S1 sentences list; brew appears only in the install footer (05/06 screenshots)
Finishing a block shows results and scrolls to the next block with the keyboard ready ✅ pass live drive.log S2: results shown, block 0 active and focused; 03-after-hero-scrolled.png
Mashing keys (including thokr's r/n) past the end of a block moves on instead of resetting ✅ pass live burst.test.mjs mash tests pass; drive.log S3: a one-burst run through all blocks with smooth scroll on finished every sentence at 100% acc, no resets, install done
Backspace and ArrowLeft typed mid auto-scroll go to the block being typed (r1/r2-2) ✅ pass live burst.test.mjs 'keys typed while the page scrolls to the next sentence stay with it' passes in Chromium with smooth scroll
Last key of the last block grows the install footer ✅ pass live drive.log S4: footer code 17px to 40px and h2 13px to 18px; 05 vs 06 screenshots; burst.test full-run test passes
Adversarial: with no scrollend (Safari-like), the user moves away mid auto-scroll and the keyboard hold still releases (r2-1) ✅ pass live drive.log S7: without a wheel event, the hold released by about 1250ms and the hero took the keyboard; with a wheel event it released at once; S6 later scroll updated the active block
  • CARGO_HOME=/tmp/... RUSTUP_HOME=/tmp/... ./build.sh on a host with no rustup (installs rustup-init 1.29.2 and builds wasm into dist)
  • env -u LD_LIBRARY_PATH node --test test/burst.test.mjs test/snapshots.test.mjs (Chromium in a disposable PLAYWRIGHT_BROWSERS_PATH)
  • node drive.mjs: live Playwright driver against dist served on 127.0.0.1 (scenarios S1-S7, screenshots)
  • node trace.mjs: per-frame trace of scrollY and the active block during auto-scroll and wheel input
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

jrnxf added 4 commits October 25, 2026 21:00
… can type

The page lives in web/ and is left out of the crate. Its tests run on thokr's
own Rust: web/engine compiles src/ (the test loop, the screen and the word
lists) to WebAssembly in the build step, so the page cannot drift from thokr.

- Each block is a screen of its own, with no trailing period to type. A
  finished block hands the keyboard to the next one; after the last, the
  install footer grows. DM Mono only, self-hosted with its OFL notice.
- Mashing keys no longer resets blocks: after a test, thokr reads r and n as
  "again" and "new words", so a fast typist running past the end restarted
  the block. Keys after the end now carry on into the next block; the arrow
  keys still start over or get new words.
- CI builds the site, checks the engine and kit/chart.js against the insta
  snapshots, and types through the page in Chromium, mashing included.
- wrangler.jsonc and build.sh are ready for Cloudflare Workers Builds.
@jrnxf
jrnxf merged commit 3cd6b7a into main Oct 25, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0