<feed xmlns='http://www.w3.org/2005/Atom'>
<title>typerpunk, 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-08-21T21:35:00+00:00</updated>
<entry>
<title>Fix license</title>
<updated>2026-08-21T21:35:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-08-21T21:35:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=00ffb544588e4d9f2bf2b3d0b50af3b21b46ba9c'/>
<id>urn:sha1:00ffb544588e4d9f2bf2b3d0b50af3b21b46ba9c</id>
<content type='text'>
</content>
</entry>
<entry>
<title>Ship the store catalogue with the client</title>
<updated>2026-03-13T21:44:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-03-13T21:44:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=e37b76136e49ac90bef841e318bceeef76a07129'/>
<id>urn:sha1:e37b76136e49ac90bef841e318bceeef76a07129</id>
<content type='text'>
The store read its catalogue from the server and rendered nothing without
one: a heading, a sign-in prompt and an empty page, which reads as a fault
rather than as a shop that needs an account.

It now falls back to a copy that ships with the client, the same arrangement
the text packs already use: bundled here, served from the database, and the
served copy wins. With no server the store shows all 26 cosmetics, 4 bundles
and 6 merchandise items at their real prices.

Nothing pretends to work. The buy controls become a "Soon" label rather than
a button, because offline there is nothing to buy with and nowhere to sign in
to: sending someone to the account screen would be sending them to another
screen that needs the server they have not got. A line at the top says the
shop is not open yet.

scripts/export_store.js regenerates the file. Run it after changing prices or
adding items. It shells out to psql so it needs no dependencies, matching the
other scripts here.

Verified both ways. With no backend: 26 items, 4 bundles, 6 merch, 36 Soon
labels, no live buy controls, no page errors. With the backend running: the
same catalogue, 37 live buy controls, no Soon labels, no offline notice. 3
browser tests pass.
</content>
</entry>
<entry>
<title>Declare hmac, sha2 and subtle at the workspace root</title>
<updated>2026-03-06T22:37:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-03-06T22:37:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=4ffaeda5cf0da756ea05b6d13913ce3d775990d6'/>
<id>urn:sha1:4ffaeda5cf0da756ea05b6d13913ce3d775990d6</id>
<content type='text'>
crates/server takes all three with workspace = true for the Stripe
webhook signature check, but the root manifest never defined them, so a
clean checkout did not resolve. They were already in the tree through
argon2; this only names them.
</content>
</entry>
<entry>
<title>Stop tracking the nightshift working notes</title>
<updated>2026-03-06T21:12:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-03-06T21:12:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=4ebea6b2cab0a09b65e5b22188af0b4025c2342f'/>
<id>urn:sha1:4ebea6b2cab0a09b65e5b22188af0b4025c2342f</id>
<content type='text'>
These are a log of how the work got done, not part of the project. They also
carry running commentary from the sessions that produced them, which does not
belong in a public repository.

The files stay on disk. This removes them from tracking and ignores them from
here on; it does not remove them from earlier commits.
</content>
</entry>
<entry>
<title>Put the header back in its corners, and give sound a control</title>
<updated>2026-03-05T14:54:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-03-05T14:54:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=e3e7969c793fe6e20614e770e9ca5021246333a2'/>
<id>urn:sha1:e3e7969c793fe6e20614e770e9ca5021246333a2</id>
<content type='text'>
The banner. It was a full-width bar with its own background and border, which
read as a second layer of chrome stretching past the content on both sides,
and because it reserved height at the top of the page it pushed the wordmark
out of the top-left corner and the icon rail down out of the top-right. It is
just the unit now, centred, sharing that line with both of them.

It is also thinner: a 468x60 full banner rather than a 728x90 leaderboard,
and 320x50 rather than 320x100 on a phone. Both are standard units. At 90px
the banner was deeper than the icon rail beside it, so it decided how far
down the whole page began; at 60px it is shorter than the rail and the
content offset is the rail's, not the banner's. The page below is unaffected
by whether an advert is there at all.

Below 980px the wordmark, the unit and the rail no longer fit on one line, so
the banner drops beneath them and the content clears both.

The mark. It was five shapes in a 32 pixel square: a chevron, an offset ghost
copy of that chevron, a full-width baseline, and a large filled block. That
is a drawing, not a mark, and the parts crowded each other so none of them
read. It is two shapes now, a chevron and the underscore cursor after it,
which is a shell prompt and the gesture anyone who has used a terminal
recognises.

The cursor blink faded to 0.15 opacity over an uneven 45/55 split, so it
ghosted rather than blinked. A terminal cursor is a hard square wave: fully
on, fully off, 530ms each way. Only the menu's title blinks now, because
something blinking in the corner of every screen is a distraction.

The menu title sat 16px right of the buttons underneath it. Centring the mark
and the name together as one box put the name off-centre, and the
absolutely-positioned mark overflowed the menu's padding, which moved the box
itself. It is a three column grid now, the third empty and the same width as
the first, so the wordmark is in the exact centre by construction with the
mark to its left. Measured 0px offset at six widths.

The corner wordmark is back on the menu. Leaving it off to avoid showing the
name twice made it look like it had gone missing, and it is the way back to
the menu from everywhere else, so it should be in the same place on every
screen.

Sound. The engine worked: one oscillator per keystroke, verified by counting
them. It was off by default and its only control was a line inside the
settings dialog, so the usual way to find it was to be told it existed. It is
a speaker in the top rail now, cycling off, click and mechanical, and it
defaults to mechanical. A browser will not start an audio context before a
user gesture and the first keystroke is one, so the first tone plays with the
first character: confirmed with a fresh profile under the default autoplay
policy, context running, six tones for six keystrokes.

The copies in the two settings panels are gone. One setting, one control.

3 browser tests pass, all 21 screen and viewport combinations clean, and the
margin rails still appear only above 1424px.
</content>
</entry>
<entry>
<title>Add long passages for people who want a longer sit</title>
<updated>2026-03-04T09:18:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-03-04T09:18:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=d79f429d801b7cac077c5573070610563f92bbf1'/>
<id>urn:sha1:d79f429d801b7cac077c5573070610563f92bbf1</id>
<content type='text'>
Eleven passages of 320 to 600 characters, which is two to four minutes at an
ordinary speed. Five are public domain openings that run long by nature; the
rest were written to run long rather than padded.

The floor in getRandomTextItem stops a test being too short. Nothing stopped
one being long, and the dataset simply had none: before this the longest
passage was 320 characters and there were three of them.

465 passages, longest now 500 characters.
</content>
</entry>
<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>Fix the store showing every item as free, and six layout defects</title>
<updated>2026-01-13T19:14:00+00:00</updated>
<author>
<name>srdusr</name>
<email>99972264+srdusr@users.noreply.github.com</email>
</author>
<published>2026-01-13T19:14:00+00:00</published>
<link rel='alternate' type='text/html' href='https://srdusr.com/git/typerpunk/commit/?id=695bf0df2ec48e67d716dad25711cdc2e96bf7f4'/>
<id>urn:sha1:695bf0df2ec48e67d716dad25711cdc2e96bf7f4</id>
<content type='text'>
The catalogue served every price as $0.00. The Cosmetic struct typed
price_cents as i64 against an INTEGER column, so sqlx refused the decode and
unwrap_or_default turned the failure into a zero. Nothing was logged. The
whole store read as free while the database held the real prices.

Decode errors are now returned rather than defaulted away, and create_session
refuses any amount at or below zero, so a price that fails to decode cannot
become a session that grants an item without charging.

Layout, all measured in a real browser at 1280, 820 and 390 pixels:

- The close button sat underneath the fixed top rail. The rail reaches into
  the content column on any viewport under about 1350px, so the overlap was
  there for nearly every visitor. The button starts below the rail now.
- The wordmark and the top rail overlapped by 75px at 390px. Both are fixed
  to the top of the viewport and neither knew about the other. Both give
  ground on narrow screens, and Sign Up drops out of the rail because Sign In
  reaches the same screen.
- Store rows were 93px tall for one line of content, because .menu-button
  carries a vertical margin meant for a stacked menu. Twenty six items came
  to 2400px of scrolling. Rows are 47px and the page is 2467px rather than
  3784px.
- .stats-screen centres its children, so any child without a declared width
  shrink-wraps. That left the sign-in box at 415px, the bundle grid at 488px
  (which collapsed it to a single column) and the leaderboard table at 498px,
  all inside a 636px column. Every screen was swept for the same defect.
  .account-panel is the one narrow child that is deliberate: it declares
  max-width 360px because a sign-in form should not be 636px wide.
- Leaderboard rows had a hard 461px minimum from fixed column widths, so at
  390px the row ran from x=-36 to x=426 and the date column was cut off the
  side of the screen. The date is hidden on narrow viewports.
- The profile links in the leaderboard were 21px tall, under the 24x24
  minimum target size, and they are the only route to a player's profile.

Sprites never showed as equipped: the store compared the equipped caret and
flair but not the sprite.

The store test bought items by clicking Buy, which used to grant them for
nothing. It now asserts that Buy does not grant, then grants the items the
way a signature-verified webhook would, and goes on to check that an equipped
caret colour reaches the typing screen.

All 21 screen and viewport combinations are clean for overlap, overflow,
clipped content and target size. 3/3 browser tests pass.
</content>
</entry>
</feed>
