<feed xmlns='http://www.w3.org/2005/Atom'>
<title>typerpunk/web/src/app.js, branch main</title>
<subtitle>Rust + WASM typing game.
</subtitle>
<id>https://srdusr.com/git/typerpunk/atom?h=main</id>
<link rel='self' href='https://srdusr.com/git/typerpunk/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/'/>
<updated>2026-02-27T23:17:00+00:00</updated>
<entry>
<title>Sell merchandise, put the sprites on the track, and add the page margins</title>
<updated>2026-02-27T23:17:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-02-27T23:17:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=5af72f55bb7fb620af740c9da394e093d7f1280a'/>
<id>urn:sha1:5af72f55bb7fb620af740c9da394e093d7f1280a</id>
<content type='text'>
Merchandise. Shirts, mugs and deskmats, sold through the same hosted
checkout. Physical goods differ from cosmetics in three ways the schema had
to carry: they have a size to choose, they need an address, and payment
unlocks nothing. What a paid order produces is an obligation to pack and post
something.

The address is collected by Stripe on its own page and arrives here on the
webhook, so no postal detail is ever entered on this site. Postage is a
separate line item rather than folded into the price. A size is checked
against the sizes the item actually comes in, on the server as well as in the
browser, so a request naming anything else is refused rather than quietly
posted as a medium.

/api/admin/orders lists what has been paid for and not yet sent, and the
Contribute screen shows an administrator the same list with the address and a
button to mark each one posted. Without that the list of what is owed lives
only in the processor's dashboard. Shipping is limited to 21 countries, which
is a list of places somebody is willing to post to, not a technical limit.

The race view. The sprite sat still at the left of a 6px bar, which made the
one thing a player owns and can see the least visible part of the race. It
rides the track now, moving with progress, on a dashed line that reads as
road ahead with the trail behind it. The field is 1040px wide rather than
680, the sprites are 30px rather than 18, and each row carries its own
percentage as well as its speed.

Advertising. Two 160x600 rails in the page margins, shown only above 1424px,
which is the width at which they fit beside the widest content column without
crowding it. Below that they do not exist. They follow the same rules as the
banner: never while typing, never for a supporter.

--border-color was used in five places and defined in none. An undefined
custom property invalidates the whole declaration at computed-value time, so
every one of those borders fell back to currentColor: bundle cards, merch
cards, the size buttons and the settings checkbox all had borders that were
either invisible or faint text-coloured lines. It is defined now, derived
from each theme's neutral so it tracks the palette.

.settings-hint caps itself at 340px, which is right under a heading and wrong
for a line introducing a full-width grid.

Verified against a real database: the catalogue endpoint, a shirt refused
without a size, a shirt refused with a size it does not come in, a valid size
and a no-variant mug both reaching checkout, 404 on an unknown item, 401
unauthenticated, and 401 on the admin orders list without the role.

24 Rust tests, 3 browser tests, and all 21 screen and viewport combinations
clean.
</content>
</entry>
<entry>
<title>Give TyperPunk a mark, and fix the settings and custom text dialogs</title>
<updated>2026-02-25T07:23:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-02-25T07:23:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=d7c1c7b35e9d0995eed41d437404d5255529cf17'/>
<id>urn:sha1:d7c1c7b35e9d0995eed41d437404d5255529cf17</id>
<content type='text'>
Branding. The name was set in the body typeface and that was the whole of it.
There is a mark now: a chevron, a baseline, and a block cursor resting on it,
which reads as text being entered at a prompt and stays legible at 16 pixels.
The chevron is drawn twice, the lower copy offset and in the secondary
colour, which is the one stylistic note. The wordmark is split so the accent
falls on the second half. Everything is drawn in the theme's own variables,
so the mark follows the palette rather than carrying its own, and the
favicon is the same drawing.

The menu carried the name twice, as its own title and as the fixed corner
mark. The corner mark exists for screens with no title of their own, so the
menu keeps only the title, now the full lockup at size.

The settings panel. Measured at 1280x900 it opened at y=506 with a height of
437, so it ran 140px past the bottom of a 900px window, and .app is
overflow:hidden so there was nothing to scroll. It had no background either,
so the fixed footer links rendered through the middle of it and, sitting
higher in the stacking order, took the clicks: Playwright could not press the
panel's own Close button because the footer intercepted every attempt.

Both it and the custom text panel are dialogs now. Centred, opaque, above the
fixed chrome, scrolling internally, with the page dimmed behind them.

Their controls are buttons, but .quiet removes a button's border, so a panel
of them read as a list of plain text labels with nothing to suggest any of it
could be clicked. They keep an outline inside these panels. The checkbox was
the browser's default control in a panel where nothing else was, and its
border was too dark to see against the panel.

Escape did not close either of them. The handler looked for
.rail-settings-panel, which is a different panel on a different screen.

A Dev section with a button that jumps straight to the results screen with
invented figures was shipping to every visitor. It is limited to localhost.

The top rail. The account control has moved into the icon row, right of
Friends, and is the account's own picture once signed in: there is no avatar
upload, so it is the initial over a colour hashed from the whole username,
which gives every account a stable and distinct mark. Sign In and Sign Up sit
on their own row beneath. The friends count is a badge under its icon rather
than the words "2 online" beside it.

That took the rail from 307px wide to 189px, which is what had been
overlapping the wordmark by 75px at 390px. But it also made the rail taller,
which put the close button back underneath it, so the rail now publishes its
measured height and the content column starts below whatever that is. The
rail is one row signed in and two signed out, so this is not a constant the
stylesheet could hold.

The wordmark was inline-flex, which blockified its two halves, so the name
read as two lines to innerText: a screen reader, a copy-paste, or a test.

The advertising slot has moved from inside the results screen to a banner
across the top of the page, where a navigation bar usually goes. The header
moves down to make room rather than being covered by it. It stays off the
typing screen, the passive reader and the race lobby.

Custom text: the panel had no title, and its hint was centred at 340px inside
a 480px panel, which broke one sentence into three ragged lines.

3 browser tests pass. The store test now asserts the avatar replaces the
sign-in links rather than expecting a button that has moved.
</content>
</entry>
<entry>
<title>Make every passage long enough to be worth timing, and credit all of them</title>
<updated>2026-01-14T20:23:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-01-14T20:23:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=4933f669f8338ca5e4c64cf0294818c5d15ed122'/>
<id>urn:sha1:4933f669f8338ca5e4c64cf0294818c5d15ed122</id>
<content type='text'>
The dataset's median passage was 69 characters, about twelve words, which is
over in twelve seconds at an ordinary speed. Some packs were worse: shell had
a median of 46 and a shortest entry of 17. That is the reason packs felt
small. It was not the number of entries, which was 15 to 37 per pack, but the
length of each one.

Multiplayer had already hit this and worked around it: load_race_texts
filters to 120 characters or more. That filter left only 61 of 350 passages
eligible, 38 of them from two packs, so races repeated constantly and four
packs could never come up at all.

Both halves are fixed.

Content: 104 longer passages added across every pack. The command packs get
whole pipelines rather than single flags, which is how the tools are actually
used and what the explanations were always for. Prose packs get passages that
run 150 to 320 characters.

Selection: a floor of 120 characters. Prose packs draw from their long
entries where a pack holds at least five, so no pack is reduced to the same
few passages. The command packs chain consecutive entries into one drill,
which is the natural shape for them, and the explanations are collected so
each line is still described.

Measured over 400 draws per pack, every category now runs a median of 146 to
218 characters with a shortest draw of 120. The race pool went from 61 of 350
to 130 of 454.

Attribution: 24 passages had none and displayed nothing at all under the
text. They now say Unknown, which is the honest answer for a fact written for
the pack, and the pangram is credited as one. The results and typing screens
both fall back to Unknown rather than rendering an empty line, and the pack
name is shown beside the source.

Multiplayer results, three fixes:

- PB never appeared. A race has no mode of its own, so modeKey was undefined,
  so recordResult never ran. Races share one key, because the passage is
  whatever the server dealt and a per-passage best would never be beaten.
- Play Again started a solo test. A race carries standings and a solo run does
  not, so the results screen can tell them apart and now queues for another
  race. The end screen's own cleanup leaves the old room first.
- CONSISTENCY was the longest label on the screen and made the accuracy
  column wider than the WPM column opposite it. It reads CON.

RaceText carries the pack name so the results can show it for a race the same
way single player does.

24 Rust tests and 3 browser tests pass.
</content>
</entry>
<entry>
<title>Add community text submissions, and make custom text usable for study</title>
<updated>2025-12-14T07:24:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-12-14T07:24:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=3dbebfbc9345d2603908f32c0dabebc0ff21feb3'/>
<id>urn:sha1:3dbebfbc9345d2603908f32c0dabebc0ff21feb3</id>
<content type='text'>
Submissions
- POST /api/texts proposes a passage; nothing reaches players until a
  moderator approves it. GET /api/texts serves the approved set, which the
  client merges on top of its bundled packs at startup.
- Validation the server enforces rather than trusts: category from a fixed
  list, 40 to 600 characters, no control characters (a newline makes a
  passage untypeable in a single-line input), attribution length, and a
  unique index on md5(lower(btrim(content))) so the same passage cannot be
  submitted twice under different whitespace or casing.
- Moderation is a flag on users. The queue and the review endpoint both
  refuse a non-moderator, and reviewing an already-reviewed submission is a
  404 rather than a silent second write.
- Submissions are rate limited per user: enough for a real contributor, not
  enough to fill the queue from a script.
- A Contribute screen carries the form, your own submissions with their
  status, and - for moderators only - the review queue.

This is the half of TypeRacer's model the packs could not reach by authoring:
their corpus is roughly twelve thousand passages, grown by submission.

Custom text as a study tool
- Imported documents are kept between visits, with how far through each one
  you are. Custom text lived only in memory, so importing a set of notes and
  reloading the page lost them - fine for pasting a paragraph to race,
  useless for working through a file over several sittings.
- Position is recorded when a segment is finished, not when the next is
  started, so closing the tab after a segment does not lose it.
- Markdown is chunked as markdown: fenced code blocks are kept whole and
  typed line by line, and the decoration - hashes, asterisks, backticks,
  link brackets, table pipes - is stripped so what you retype is the
  material rather than the punctuation around it.
- Everything stays on the device. Notes are not uploaded anywhere.

Fixed while doing it: a chunk could contain a newline, which cannot be typed
in a single-line input at all. Any paragraph with a line break inside it --
ordinary in notes and in wrapped prose - produced an unfinishable segment.
Whitespace inside a chunk is now flattened.
</content>
</entry>
<entry>
<title>Fix content packs, seed the leaderboard, and add the missing site furniture</title>
<updated>2025-12-05T22:58:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-12-05T22:58:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=24f1eb6cc611f458c45f0ac4046efce51211d7d3'/>
<id>urn:sha1:24f1eb6cc611f458c45f0ac4046efce51211d7d3</id>
<content type='text'>
Naming
- "Typing Test" removed from the browser tab, the crate description and the
  READMEs. The scope outgrew it: solo practice, live races, code drills and
  adaptive weak-key training.

Content
- Movies was prose *about* film, not film quotes - the same mistake the
  programming pack had. Replaced with 28 attributed lines. New anime pack,
  28 lines across 24 series.
- This follows the model TypeRacer states outright ("type quotes from popular
  music, songs, anime, comic books and more"): short excerpts attributed to
  the work they came from. The scalable half of their approach is user
  submission with moderation, which is a feature this does not have yet.

Leaderboard
- An empty board tells a new player nobody is here. Bots now race the eight
  fixed-length leaderboard modes, seeded with a fortnight of backdated results
  on first run and one new result every 90 seconds after.
- They are ordinary users carrying is_bot, returned by the same query and
  labelled "bot" in the UI. Seeding a board is reasonable; passing synthetic
  scores off as human results is not, so the flag travels with the row.
- Seeding is checked per mode. A single result from the live ticker used to
  satisfy an "any bot results" guard and leave every other mode empty forever.

Stats
- The per-key accuracy data Practice mode is built on was computed, used to
  generate text, and never shown. The screen now ranks your weakest keys with
  the error rate and pause length behind each one.
- Added recent form against your lifetime average, best accuracy, and tests
  this week.

Multiplayer standings
- Now place, racer, WPM, accuracy and time, with column labels - the columns
  TypeRacer and 10FastFingers both show.

Site furniture
- Share (Web Share where available, clipboard otherwise - no third-party
  button, no tracking script), a GitHub link, and a real privacy page written
  from what the code actually stores rather than from a template.

Button hierarchy
- Everything was an outlined box of roughly equal weight, so a screen's one
  real action, a settings toggle and a filter chip looked alike. Three tiers
  now: primary (filled, one per screen), default (outlined), quiet (toggles
  and filters, bordered only when hovered or active).
</content>
</entry>
<entry>
<title>Rework the end screen figures, mode picker, corners and race view</title>
<updated>2025-09-13T19:11:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-09-13T19:11:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=0d3864bcebcdba1b1743367d0f84389fd53e37e1'/>
<id>urn:sha1:0d3864bcebcdba1b1743367d0f84389fd53e37e1</id>
<content type='text'>
End screen
- Every figure is now a dim label directly over its value, the two centred on
  each other, grouped by what they qualify: RAW and PB under WPM, ERR and
  CONSISTENCY under ACC, KEYSTROKES and CHARACTERS either side of TIME. The
  row is a grid so TIME sits on the graph's exact centre line rather than
  drifting with its neighbours' widths.
- Errors go back onto the wpm line. Their own y axis implied a magnitude a
  mistake does not have; what matters is when one happened.
- Graph hover reads "wpm ... raw ... time" - the figures first, the second
  they happened in as the qualifier.

Mode picker
- Opens from the Single Player button itself, and choosing a mode starts it:
  picking what to type and starting it are one action. The control has now
  been a chevron notched into that button, a caption between the two buttons,
  a pill above them and a chip row below them; as the button's own menu it
  needs no separate real estate at all.

Corners
- Settings and Store move to the bottom-left. The top-left is the wordmark's
  alone.
- The global racer count is gone from the home screen - it is not something
  you can act on there. The Friends control carries "N online" instead, which
  is.
- Presence: users gain a last_seen column, touched at most once a minute per
  active user on any authenticated request, and the friends list reports who
  has been seen inside a five-minute window.

Multiplayer race view
- The standings move to the middle of the screen, the space the end screen's
  graph occupies, and now include your own row rather than opponents only.
  Pinned to the top-left corner they put what you are racing against in your
  peripheral vision and left out the one bar you most need to see.

Programming mode
- Snippets carry an explanation of what the code does. Shown under the passage
  while you type in single player; in multiplayer that space belongs to the
  standings, so it waits for the end screen, where it appears either way.
</content>
</entry>
<entry>
<title>Add multiplayer bots, typing languages, and rework the UI layout</title>
<updated>2025-09-11T19:53:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-09-11T19:53:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=a726d9f5fb56e1fd7983c5ac806d408ad78daa86'/>
<id>urn:sha1:a726d9f5fb56e1fd7983c5ac806d408ad78daa86</id>
<content type='text'>
Multiplayer
- Quick match: POST /api/multiplayer/quickmatch returns whichever room is
  still filling, or opens one. Players never see a room code; joining by
  code stays for racing specific people.
- Bots fill quick-match rooms after a short wait so a new game is never an
  empty lobby. They only ever join quick-match rooms, never a room opened
  by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so
  two bots are never near each other's pace, and they stall to correct
  mistakes rather than typing a clean straight line.
- Live player count via GET /api/multiplayer/online, shown on the
  Multiplayer control and under the main menu's Multiplayer button.
- Per-racer colours: you are the theme accent, opponents take distinct hues
  that stay the same from lobby to race.
- The countdown no longer holds the room lock for its full three seconds,
  which is what reset clients mid-countdown.

Typing languages
- 16 languages for the generated-word modes, each with its own
  high-frequency vocabulary rather than a translation of the English list.
- Picker in the top-right rail; non-English uses its own list at every
  difficulty tier instead of falling back to English words.

Fix UTF-8 accuracy in the game core
- update_game_state mixed byte and character counts: total_characters_typed
  accumulated byte-length deltas while total_correct_characters compared a
  char index against that byte count. Equal on ASCII, so it went unnoticed;
  a correctly typed Spanish passage scored 6%. The old byte slicing would
  also have panicked if an index landed inside a multi-byte character.
  Rewritten char-based, with regression tests.

Programming mode
- Replaced prose about programming with real code: 26 syntax-highlighted
  snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line
  by necessity, since the typing input is a single-line field.

Layout and readability
- One icon rail arrangement on every screen: Settings/Store under the
  wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/
  Multiplayer bottom-right.
- Main menu: mode picker moved out of the Single Player button, which it was
  notching a divider through and pushing the label off-centre.
- Escape returns to the menu, closing any open popover first, and confirms
  before abandoning a live race.
- Split --text-color and --sub-color per theme; they shared one value that
  measured 3.65:1 against the background, below the 4.5:1 body-text floor.
- Semantic colours used in exactly one place each: gold for a personal best,
  amber for the race countdown and the mobile-result badge.
- Passage now sits in the same place on the typing and end screens, and its
  column is a whole number of characters wide so wrapping cannot leave a
  permanent gap on the right.
- End screen: keystrokes and a correct/wrong/extra/missed split, attribution
  carried over from the typing screen, and a graph with a separate error
  axis, axis titles including seconds, and smoothed lines.
</content>
</entry>
</feed>
