<feed xmlns='http://www.w3.org/2005/Atom'>
<title>typerpunk/README.md, 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>Charge for store items, and price them individually</title>
<updated>2026-01-12T19:19:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-01-12T19:19:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=a19c03bc6394ab08cc5a1a9fbabf5eb46b6791f7'/>
<id>urn:sha1:a19c03bc6394ab08cc5a1a9fbabf5eb46b6791f7</id>
<content type='text'>
The store had 26 items, a price on each and a working equip flow, but the
purchase endpoint granted ownership without taking any money. Anyone signed
in could take the whole catalogue for nothing. That endpoint is now gone.

Payment goes through Stripe Checkout, which is hosted by Stripe. The buyer is
redirected there and comes back, so no card details reach this server and it
stays outside PCI scope.

Three rules hold the money path together:

- The price comes from the server's own catalogue row. The client sends an
  item id and never an amount.
- Nothing is granted at checkout. The item appears only when a webhook
  arrives with a valid HMAC-SHA256 signature, checked in constant time
  against a 5 minute timestamp window.
- Fulfilment keys off the processor's session id, which is UNIQUE, so a
  webhook delivered twice cannot grant the same item twice.

Prices now vary by item. Every caret cost the same as every other because
they are the same thing in a different colour, which left nothing to save
for. Carets run 149 to 349, flair 129 to 299, and sprites 249 to 399, since a
sprite is the one cosmetic every other racer sees.

Four bundles sit above the catalogue, each priced below the sum of its parts:
Starter Kit, Neon Set, Racer Set and The Lot. The saving is computed from the
current item prices rather than asserted, so it cannot drift. The Lot is
defined as every cosmetic rather than a fixed list, so it stays complete as
items are added.

Three bugs found while wiring this up:

- Sprites never showed as equipped. The store compared the equipped caret and
  flair but not the sprite.
- Supporter status was a stored boolean that was set on payment and never
  cleared, so a 30 day subscription lasted forever. Both read paths now
  derive it from the expiry.
- sqlx::migrate! reads the migrations directory at compile time, but cargo
  watches source files only. Adding a migration did not trigger a rebuild, so
  the binary shipped the old migration set and the schema change never ran.
  A build.rs now declares the dependency.

Verified against a real database: bundle maths, the grant statement and its
replay, 401 unauthenticated, 404 on unknown ids, 501 with no Stripe keys, and
both already-owned refusals.
</content>
</entry>
<entry>
<title>Fix the end screen layout, make the app responsive, document secrets</title>
<updated>2025-12-18T17:21:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-12-18T17:21:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=b371f21989bfca13fe8cc9bd59e95f5614f63b0d'/>
<id>urn:sha1:b371f21989bfca13fe8cc9bd59e95f5614f63b0d</id>
<content type='text'>
End screen
- There was an auto-fit routine that forced this screen into one viewport: it
  shrank the graph to a 120px floor, trimmed the Play Again margin, then
  capped the passage box at 80px with its own scrollbar. On a 650px window
  that left the passage 80px tall and clipped, and the graph 120px, which are
  the two things the screen exists to show. Removed. The page scrolls
  instead, which is the right trade for a screen that is read rather than
  acted on under time pressure.
- The screen is a flex column, so its children also shrank by default once
  the content was taller than the viewport. The passage, graph, stat row and
  standings no longer shrink, and the chart has a floor below which it stops
  carrying information.

Bottom chrome
- The keyboard hint and the footer links were both fixed at bottom centre and
  overlapped at every window size. The hint now sits above the footer.
- Normal-flow content could end up underneath the fixed footer and the corner
  rails. One --bottom-chrome variable reserves that space on every screen.
- On a narrow screen the footer grows to the full width once its links wrap,
  so at 375px it ran through both corner rails and covered Play Again, which
  made the button unclickable. The chrome stacks there instead: rails on the
  bottom line, footer above them, hint above that.

Mode picker
- It ran to the last pixel of the window at every size. It now keeps clear of
  the bottom edge, and opens upward when a short window leaves more room
  above than below.

Responsive
- Checked at nine viewports from 1920x1080 down to 375x667: no horizontal
  overflow and nothing off-screen on the menu, the typing screen or the end
  screen.

Configuration
- dotenvy searches upward from the working directory, so crates/server/.env
  was only found when starting the server from that directory. The repository
  root is tried as well, which is where it is usually started from.
- .env.example and the README explain where secrets belong: the environment,
  a gitignored .env for local work, and EnvironmentFile or a platform secret
  store in production. Also what to do if one is exposed.
- TYPERPUNK_ADMIN_USERNAME and TYPERPUNK_ENV are documented rather than left
  to be discovered in the source.
</content>
</entry>
<entry>
<title>Harden for production: dependencies, headers, admin roles, docs</title>
<updated>2025-12-15T18:44:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-12-15T18:44:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=1740327557074df0c8b99635ee949e2540ac94d0'/>
<id>urn:sha1:1740327557074df0c8b99635ee949e2540ac94d0</id>
<content type='text'>
Dependencies
- The server build carried 37 known advisories, including RUSTSEC-2024-0363
  in sqlx 0.7, which is the database layer. sqlx moved to 0.8 with
  default-features off, which also drops the MySQL and SQLite drivers and
  with them rsa and RUSTSEC-2023-0071. reqwest moved to 0.12, which brings
  hyper 1.x and was the sole source of every remaining advisory: h2 0.3,
  rustls-webpki 0.101, rustls-pemfile 1.0 and idna 0.3.
- The server build now reports no known vulnerabilities against OSV. cargo
  audit itself would not compile, so the check queries OSV with the crate
  versions cargo tree reports for the server binary.
- Cargo.lock is committed. This workspace produces binaries, so the lockfile
  is what makes a deployed build reproducible and the audit above meaningful.

Headers
- The application sent no security headers at all. The static server now
  sends a Content-Security-Policy, nosniff, frame options, a referrer policy
  and a permissions policy; the API sends a policy of its own, since it
  serves JSON and should load and frame nothing.
- The one inline script in index.html moved to a file so script-src needs no
  unsafe-inline. WebAssembly needs wasm-unsafe-eval, without which nothing
  types at all, so that is present and explained.
- Five style attributes moved to the CSSOM rather than adding unsafe-inline
  for styles. A style attribute in markup is refused by the policy; the same
  property set through element.style is not.

Production configuration
- With TYPERPUNK_ENV=production the server refuses to start if COOKIE_SECURE
  is off, if DATABASE_URL is still the development default, or if
  FRONTEND_ORIGIN is http on a non-local host. These were warnings, and a
  warning in a log nobody reads is not a safeguard.

Administration
- Moderators were appointed with psql. There is now an admin role,
  bootstrapped from TYPERPUNK_ADMIN_USERNAME at startup, and a UI to appoint
  and remove moderators. An administrator's own role cannot be changed
  through the API, so a mistake cannot lock everyone out of moderation.

Corpus
- scripts/export_approved.js writes approved submissions back into
  data/packs/community-*.json. Approved passages are served from the database
  and merged at startup, so without this the repository dataset and the live
  corpus drift apart, and a fresh checkout or the TUI sees only what shipped.

Documentation
- README rewritten for the repository: what it does, how to run it, the pack
  format, the server variables, deployment, and what the security posture
  actually is. Plain English, no em dashes, no emoji.

Checked and found already correct: every private endpoint refuses anonymous
callers, session cookies are HttpOnly and SameSite=Lax, CORS names a single
origin, internal errors are logged rather than returned, and every query is
parameterised.
</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>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>
<entry>
<title>Improved README with a few added details and improved some of the formatting</title>
<updated>2025-07-22T07:29:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-07-22T07:29:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=c0e22de0d853b609bb20f43ef0650b969afe49f0'/>
<id>urn:sha1:c0e22de0d853b609bb20f43ef0650b969afe49f0</id>
<content type='text'>
</content>
</entry>
<entry>
<title>Update README</title>
<updated>2025-07-08T21:26:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2025-07-08T21:26:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=28165c69ca4c13d74b386cb6411ff1a71edaa77c'/>
<id>urn:sha1:28165c69ca4c13d74b386cb6411ff1a71edaa77c</id>
<content type='text'>
</content>
</entry>
<entry>
<title>Initial commit of empty README.md</title>
<updated>2024-02-20T20:57:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2024-02-20T20:57:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=53084745691f7bf0391a64751cc6e9c3d58619ab'/>
<id>urn:sha1:53084745691f7bf0391a64751cc6e9c3d58619ab</id>
<content type='text'>
</content>
</entry>
</feed>
