Commits
Fixes user feedback: on iOS, pressing backspace more than once
triggered Safari's double-tap-zoom, because rapid taps on the same
element are read as a zoom gesture.
The board already set touch-action: manipulation, but the on-screen
keyboard keys and general buttons did not, leaving them exposed.
Add touch-action: manipulation to .key and .btn. This disables only
the double-tap-zoom delay on those controls; pinch-to-zoom still
works, so no accessibility loss. A viewport-meta fix
(maximum-scale/user-scalable) was rejected — iOS ignores it since
iOS 10 and it would disable pinch-zoom entirely.
The French list's `source` metadata (and the generator's header comment)
disclaimed any relation to another word-game project by naming it. The
name adds nothing and is better left out; keep only the positive
"Independently sourced." statement.
Updated the generator and the committed words.fr.json together so the
reproducible build still matches the shipped file. Only the `source`
string changed — answers, encoding, and counts are untouched.
Early player feedback on Bluesky flagged two problems with the board:
there was no explainer, so a first-time player couldn't tell whether a
coloured tile meant "right spot" or "right letter"; and the present
colour read as red, making the palette feel like the red/green pairing
that colour-blind players struggle with.
Address both, plus two consistency issues the rework surfaced:
- Onboarding: a persistent colour/symbol legend under the board, and
a "?" header button opening a "How to play" modal (native <dialog>,
bilingual, original copy) shown once on first visit. Wires up the
previously-unused howTo strings.
- Colour-blind mode: an opt-in footer toggle adds a per-tile symbol
(right spot / wrong spot / not in word) to the board, legend, and
modal. Off by default so the glyphs don't clutter the board for
players who don't need them; persisted in localStorage via a
cb-safe class on <html>. Per-tile aria labels already covered
screen readers and are unchanged.
- Present colour nudged from burnt amber #B45309 (reads red) to gold
#A16207, clearly distinct from teal and slate.
- Keyboard inversion: untried keys are a neutral grey, a ruled-out key
drops to a darker recessive tone. It was the opposite before -- in
dark mode an absent key turned lighter than an untried one.
- "Not in word" is one shared, theme-aware tone across the board tile,
keyboard key, and legend (light: slate #5B6776; dark: #232B35),
rather than slate on the board but dark on the keyboard.
Decisions recorded in DECISIONS.md (13, 13a, 13b, 14a).
AT Mot only ever writes three record types, but it was requesting the broad
transition:generic scope — blanket read/write over the user's whole repo.
Switch to least-privilege granular scopes that grant write access to exactly
those collections:
repo:bzh.herve.atmot.result?action=create
repo:bzh.herve.atmot.stats?action=create&action=update
repo:app.bsky.feed.post?action=create
This yields a clearer consent screen (per-resource descriptions instead of
"full account access") and lets privacy-strict users who refuse
transition:generic authorize the app. Verified end-to-end against a reference
PDS (v0.4.5006): consent, token grant, and createRecord/putRecord all succeed
under repo: scopes alone — no rpc: scope needed.
Add tests/oauth-scope.test.ts as a regression guard so the scope can't
silently widen back to transition:generic or reach unlisted collections.
The contact email was served in the client-side bundle to every
visitor. The repo link satisfies microcosm's etiquette ask (descriptive
UA + contact channel) without exposing a personal address, and is
self-documenting for operators inspecting their logs.
# Conflicts:
# DECISIONS.md
decodeAnswers('') returned [''] (length 1) instead of [], breaking the
round-trip for an empty list. This is a latent issue: loadedLangs() in
words.ts treats answers.length > 0 as a real state, so a language shipping
zero answers would be wrongly reported as loaded and dailyAnswer would
return ''. Short-circuit the empty blob to [] and add a regression test.
The ordered answer list shipped as plaintext English words in
src/data/words.*.json, so anyone could open the JSON — in the repo or
the JS bundle / network tab — find their puzzle number, and read today's
or any future day's word. answers[puzzleNumber-1] was literally the
word.
Ship the sequence as `answersEnc` instead: the ordered list joined,
XOR'd against a fixed key, and base64-encoded (src/engine/obfuscate.ts),
decoded once at module load in words.ts. One isomorphic module is shared
by the build script and the runtime so the two can never drift.
This is a deliberate speed bump, not encryption. With no backend the
full list must reach every browser to compute the word offline, and the
XOR key ships in the bundle, so a determined reader can still recover it.
The goal is only to defeat the trivial "read the list / grep for the
word" cheat — encoded, the list is no longer human-readable or
greppable.
The encoding decodes back to byte-identical words, so the per-puzzle
mapping is unchanged: recorded results (keyed by <lang>-<puzzleNumber>)
and in-progress local games from before the change still match. A new
guard in tests/words.test.ts pins known puzzle->word pairs so any future
renumbering of history fails loudly.
The committed JSON was re-emitted from the existing answers, not
re-fetched from the upstream sources, so word-list drift can't silently
renumber history as part of this change.
Remove the bold "not something else" framing and sales-style copy from
README, NOTICE, and DECISIONS: the no-database/no-backend tails, the
"Why it's interesting" heading, the not-a-clone paragraph, the
wordle-fr / Le Mot comparisons, the skypress.blog mentions, the
no-git-remote handoff note, and the redundant 0-vulnerabilities aside.
Also remove the tangled.org source link (and its inlined mark) from the
app footer, along with the now-unused sourceCode i18n strings, and
update the footer tests accordingly.
Add the PWA <link>/<meta> tags to index.html so the document is fully
installable, with a regression test asserting they are present.
Also add "node" to the tsconfig types array so the PWA test file's
node:fs / node:url imports typecheck — these imports were introduced in
the icons task but the tsconfig was never updated, leaving npm run
typecheck failing on tests/pwa.test.ts.
The language toggle did nothing on /p/<lang>/<n> pages: the router
re-derives the language from the URL path on every render, so flipping
ctx.lang alone left the page on its original language while only the
chrome strings changed.
setLang now routes to the equivalent puzzle in the new language via a
pure pathForLangSwitch() helper, keeping the URL and ctx.lang in sync.
Reopening a finished puzzle re-ran createRecord for the deterministic,
write-once result rkey on every load. Since the record already exists,
the PDS rejects the duplicate create with an opaque 500
(InternalServerError) that the isAlreadyExists error-string guard cannot
match, so writeResult threw on every reload.
Check existence first via getRecord and skip the doomed create, returning
a no-op replay. If the create still fails (a tab/device won the race),
re-check existence and treat that as a no-op too; only a genuine failure
on a truly-absent record throws.
Add tests/records.test.ts covering replay, fresh create, lost-race
tolerance, and genuine-failure propagation.
The result page has nothing left to type into, so the on-screen keyboard
was just dead UI taking up space below the stats. Skip rendering it when
the game is finished; the results panel takes its place.
The physical keyboard handler stays attached but is already a no-op once
the game is finished, so input behaviour is unchanged.
The on-screen keyboard was hardcoded to QWERTY. French players type on
AZERTY, so show an AZERTY layout when the page language is French and
keep QWERTY for English.
renderKeyboard now takes the current lang and picks the row layout from
a per-language map; the language switcher already re-renders the UI, so
the keyboard flips live with no extra UI. Physical-keyboard typing was
already layout-agnostic, so only the on-screen keys change.
The git remote is on tangled.org, so Cloudflare's git-connected builds
aren't available. Add a deploy script that builds locally and pushes dist/
to the atmot Pages project via Wrangler direct upload.
Pin --branch=trunk so deployments always land in production regardless of
the local working branch; without it, deploying from a feature branch
produces a preview URL and leaves the live site unchanged. Pin wrangler as
a devDependency for reproducibility, ignore .wrangler/ state, and document
the flow in the README.
The canvas-rendered WebP turned out broken in the browser, so the
size win wasn't worth chasing further. Point the avatar back at the
original transparent SVG, which renders correctly and blends into the
footer on any background.
This trades 6.8 KB for 703 KB on an every-page asset, but a working
image beats a broken one; revisit the optimisation later with a proper
SVG rasteriser if the weight matters.
The first WebP was rasterised through macOS Quick Look, which composites
SVGs onto an opaque white canvas. With border-radius the avatar then read
as a face floating on a white disc instead of blending into the footer —
glaring in dark mode.
Re-render the source SVG through an HTML canvas, which starts transparent,
and export WebP with its alpha channel intact (confirmed Alpha: 1). Still
tiny at 6.8 KB, and it now sits cleanly on any footer background.
The status-line footer was carrying low-level plumbing on every page:
the result collection NSID, the Constellation backlink read, the data
note, and the lexicon authority + licence. That is reference material a
visitor reads once, not standing footer furniture — and it left no room
for the things people actually click.
Replace those lines with two icon links, mirroring skypress.blog's
footer: the tangled.org mark (inlined as SVG so it inherits the muted
footer colour via currentColor and tracks light/dark mode) linking to
the source repo, and a round avatar linking to the Bluesky profile. The
About page already explains the PDS/Constellation/no-database design and
the licence, so nothing is lost — it just moves to where it belongs.
The avatar is shipped as a 4.9 KB WebP rather than the 703 KB traced
SVG skypress uses; at 18 px the two are indistinguishable, and an
every-page asset has no business weighing 193 KB gzipped.
The "drop obvious plurals" heuristic in build-wordlists.ts checked each
singular against allowedSet, which only ever holds 5-letter words. The
singular of a 5-letter plural is shorter (ROOMS -> ROOM), so it could
never match and the filter silently did nothing: 657 of 2500 English
answers (26%) ended in S, with ROOMS landing at index 0.
Extract a pure isInflectedSForm() helper (scripts/plurals.ts) that tests
the singular against the full ENABLE dictionary of every length, covering
regular plurals/3rd-person verbs (ROOMS, WALKS), -es after sibilant/CH/SH
stems (BOXES, ASHES), and Y->IES (FLIES). Rebuild words.en.json: S-ending
answers fall to 53 (2.1%), all genuine non-plurals (CHESS, VIRUS, YIKES).
French is untouched. Adds unit tests for the helper plus regression guards
on the shipped data.
The result/share preview dropped the emoji grid into a single text node
with rows joined by newlines, but .share-grid had no white-space rule, so
the browser collapsed the newlines and rendered every guess on one line.
Add white-space: pre so the newlines are honoured (and the fixed-width
grid never wraps).
Resolve the identifier -> DID -> DID document -> #atproto_pds endpoint so
publishing works for self-hosted PDSs. ATMOT_PDS still overrides.
On the final guess, submit() started the staggered flip and then
maybeRecord() saved to the PDS and re-rendered, rebuilding the board and
snapping the still-flipping tiles straight to their colours. A fast save
made the last tiles jump instantly, so the reveal looked like it sped up
at the end.
Export REVEAL_DURATION_MS from board.ts and have maybeRecord await it
before the post-save renderContent, so the board is only rebuilt once the
reveal has played out. The save still runs concurrently, so there's no
added latency when it's slower than the animation. Skipped under
prefers-reduced-motion, where the reveal is near-instant.
Add isEditableTarget() and guard the global keydown handler with it, plus a test.
Flip-reveal previously replayed on every board rebuild (each keystroke,
language switch, restored game). Track the submitted row index and pass it to
renderBoard so only that row animates. Add a happy-dom test covering tile
states, the one-shot animation, and per-tile aria labels.
AT Mot is a static client-side word game native to the AT Protocol. There is
no application database and no bespoke backend: player data lives in each
player's PDS, and the leaderboard is read from the public Constellation
backlink index.
What's included:
- Lexicons under bzh.herve.atmot.* (result, stats, defs) following the Lexicon
Style Guide, with a format linter and an idempotent publish script.
- Deterministic UTC daily word engine for EN + FR, with self-curated word lists
from public-domain/MIT sources (ENABLE, an-array-of-french-words, hermitdave
frequency) — French sourced independently of wordle-fr. Frozen epoch
2026-06-23, frozen puzzleTarget format via a single shared helper.
- Browser OAuth (atcute), write-once result records + a mutable self stats /
declaration record, all written client-side to the player's PDS.
- "Share to atproto": app.bsky.feed.post with a standard emoji grid and the
puzzle permalink as a real link facet + embed (so Constellation can discover
it). Leaderboard + per-puzzle pages aggregate Constellation backlinks in the
browser with caching and graceful degradation; live likes/reposts/replies.
- Clean minimal-nerdy UI: own brand board palette (teal/amber/slate, not
Wordle's), SkyPress fonts (Overused Grotesk + IBM Plex Mono), prefers-color-
scheme dark mode, a11y (per-tile aria, non-hue cues), mobile-first.
- Cloudflare Pages target (SPA fallback, hosted client-metadata.json).
- Dual MIT/Apache-2.0, README/NOTICE/DECISIONS, 41 passing tests, 0 audit
vulnerabilities.
Fixes user feedback: on iOS, pressing backspace more than once
triggered Safari's double-tap-zoom, because rapid taps on the same
element are read as a zoom gesture.
The board already set touch-action: manipulation, but the on-screen
keyboard keys and general buttons did not, leaving them exposed.
Add touch-action: manipulation to .key and .btn. This disables only
the double-tap-zoom delay on those controls; pinch-to-zoom still
works, so no accessibility loss. A viewport-meta fix
(maximum-scale/user-scalable) was rejected — iOS ignores it since
iOS 10 and it would disable pinch-zoom entirely.
The French list's `source` metadata (and the generator's header comment)
disclaimed any relation to another word-game project by naming it. The
name adds nothing and is better left out; keep only the positive
"Independently sourced." statement.
Updated the generator and the committed words.fr.json together so the
reproducible build still matches the shipped file. Only the `source`
string changed — answers, encoding, and counts are untouched.
Early player feedback on Bluesky flagged two problems with the board:
there was no explainer, so a first-time player couldn't tell whether a
coloured tile meant "right spot" or "right letter"; and the present
colour read as red, making the palette feel like the red/green pairing
that colour-blind players struggle with.
Address both, plus two consistency issues the rework surfaced:
- Onboarding: a persistent colour/symbol legend under the board, and
a "?" header button opening a "How to play" modal (native <dialog>,
bilingual, original copy) shown once on first visit. Wires up the
previously-unused howTo strings.
- Colour-blind mode: an opt-in footer toggle adds a per-tile symbol
(right spot / wrong spot / not in word) to the board, legend, and
modal. Off by default so the glyphs don't clutter the board for
players who don't need them; persisted in localStorage via a
cb-safe class on <html>. Per-tile aria labels already covered
screen readers and are unchanged.
- Present colour nudged from burnt amber #B45309 (reads red) to gold
#A16207, clearly distinct from teal and slate.
- Keyboard inversion: untried keys are a neutral grey, a ruled-out key
drops to a darker recessive tone. It was the opposite before -- in
dark mode an absent key turned lighter than an untried one.
- "Not in word" is one shared, theme-aware tone across the board tile,
keyboard key, and legend (light: slate #5B6776; dark: #232B35),
rather than slate on the board but dark on the keyboard.
Decisions recorded in DECISIONS.md (13, 13a, 13b, 14a).
AT Mot only ever writes three record types, but it was requesting the broad
transition:generic scope — blanket read/write over the user's whole repo.
Switch to least-privilege granular scopes that grant write access to exactly
those collections:
repo:bzh.herve.atmot.result?action=create
repo:bzh.herve.atmot.stats?action=create&action=update
repo:app.bsky.feed.post?action=create
This yields a clearer consent screen (per-resource descriptions instead of
"full account access") and lets privacy-strict users who refuse
transition:generic authorize the app. Verified end-to-end against a reference
PDS (v0.4.5006): consent, token grant, and createRecord/putRecord all succeed
under repo: scopes alone — no rpc: scope needed.
Add tests/oauth-scope.test.ts as a regression guard so the scope can't
silently widen back to transition:generic or reach unlisted collections.
decodeAnswers('') returned [''] (length 1) instead of [], breaking the
round-trip for an empty list. This is a latent issue: loadedLangs() in
words.ts treats answers.length > 0 as a real state, so a language shipping
zero answers would be wrongly reported as loaded and dailyAnswer would
return ''. Short-circuit the empty blob to [] and add a regression test.
The ordered answer list shipped as plaintext English words in
src/data/words.*.json, so anyone could open the JSON — in the repo or
the JS bundle / network tab — find their puzzle number, and read today's
or any future day's word. answers[puzzleNumber-1] was literally the
word.
Ship the sequence as `answersEnc` instead: the ordered list joined,
XOR'd against a fixed key, and base64-encoded (src/engine/obfuscate.ts),
decoded once at module load in words.ts. One isomorphic module is shared
by the build script and the runtime so the two can never drift.
This is a deliberate speed bump, not encryption. With no backend the
full list must reach every browser to compute the word offline, and the
XOR key ships in the bundle, so a determined reader can still recover it.
The goal is only to defeat the trivial "read the list / grep for the
word" cheat — encoded, the list is no longer human-readable or
greppable.
The encoding decodes back to byte-identical words, so the per-puzzle
mapping is unchanged: recorded results (keyed by <lang>-<puzzleNumber>)
and in-progress local games from before the change still match. A new
guard in tests/words.test.ts pins known puzzle->word pairs so any future
renumbering of history fails loudly.
The committed JSON was re-emitted from the existing answers, not
re-fetched from the upstream sources, so word-list drift can't silently
renumber history as part of this change.
Remove the bold "not something else" framing and sales-style copy from
README, NOTICE, and DECISIONS: the no-database/no-backend tails, the
"Why it's interesting" heading, the not-a-clone paragraph, the
wordle-fr / Le Mot comparisons, the skypress.blog mentions, the
no-git-remote handoff note, and the redundant 0-vulnerabilities aside.
Also remove the tangled.org source link (and its inlined mark) from the
app footer, along with the now-unused sourceCode i18n strings, and
update the footer tests accordingly.
Add the PWA <link>/<meta> tags to index.html so the document is fully
installable, with a regression test asserting they are present.
Also add "node" to the tsconfig types array so the PWA test file's
node:fs / node:url imports typecheck — these imports were introduced in
the icons task but the tsconfig was never updated, leaving npm run
typecheck failing on tests/pwa.test.ts.
The language toggle did nothing on /p/<lang>/<n> pages: the router
re-derives the language from the URL path on every render, so flipping
ctx.lang alone left the page on its original language while only the
chrome strings changed.
setLang now routes to the equivalent puzzle in the new language via a
pure pathForLangSwitch() helper, keeping the URL and ctx.lang in sync.
Reopening a finished puzzle re-ran createRecord for the deterministic,
write-once result rkey on every load. Since the record already exists,
the PDS rejects the duplicate create with an opaque 500
(InternalServerError) that the isAlreadyExists error-string guard cannot
match, so writeResult threw on every reload.
Check existence first via getRecord and skip the doomed create, returning
a no-op replay. If the create still fails (a tab/device won the race),
re-check existence and treat that as a no-op too; only a genuine failure
on a truly-absent record throws.
Add tests/records.test.ts covering replay, fresh create, lost-race
tolerance, and genuine-failure propagation.
The result page has nothing left to type into, so the on-screen keyboard
was just dead UI taking up space below the stats. Skip rendering it when
the game is finished; the results panel takes its place.
The physical keyboard handler stays attached but is already a no-op once
the game is finished, so input behaviour is unchanged.
The on-screen keyboard was hardcoded to QWERTY. French players type on
AZERTY, so show an AZERTY layout when the page language is French and
keep QWERTY for English.
renderKeyboard now takes the current lang and picks the row layout from
a per-language map; the language switcher already re-renders the UI, so
the keyboard flips live with no extra UI. Physical-keyboard typing was
already layout-agnostic, so only the on-screen keys change.
The git remote is on tangled.org, so Cloudflare's git-connected builds
aren't available. Add a deploy script that builds locally and pushes dist/
to the atmot Pages project via Wrangler direct upload.
Pin --branch=trunk so deployments always land in production regardless of
the local working branch; without it, deploying from a feature branch
produces a preview URL and leaves the live site unchanged. Pin wrangler as
a devDependency for reproducibility, ignore .wrangler/ state, and document
the flow in the README.
The canvas-rendered WebP turned out broken in the browser, so the
size win wasn't worth chasing further. Point the avatar back at the
original transparent SVG, which renders correctly and blends into the
footer on any background.
This trades 6.8 KB for 703 KB on an every-page asset, but a working
image beats a broken one; revisit the optimisation later with a proper
SVG rasteriser if the weight matters.
The first WebP was rasterised through macOS Quick Look, which composites
SVGs onto an opaque white canvas. With border-radius the avatar then read
as a face floating on a white disc instead of blending into the footer —
glaring in dark mode.
Re-render the source SVG through an HTML canvas, which starts transparent,
and export WebP with its alpha channel intact (confirmed Alpha: 1). Still
tiny at 6.8 KB, and it now sits cleanly on any footer background.
The status-line footer was carrying low-level plumbing on every page:
the result collection NSID, the Constellation backlink read, the data
note, and the lexicon authority + licence. That is reference material a
visitor reads once, not standing footer furniture — and it left no room
for the things people actually click.
Replace those lines with two icon links, mirroring skypress.blog's
footer: the tangled.org mark (inlined as SVG so it inherits the muted
footer colour via currentColor and tracks light/dark mode) linking to
the source repo, and a round avatar linking to the Bluesky profile. The
About page already explains the PDS/Constellation/no-database design and
the licence, so nothing is lost — it just moves to where it belongs.
The avatar is shipped as a 4.9 KB WebP rather than the 703 KB traced
SVG skypress uses; at 18 px the two are indistinguishable, and an
every-page asset has no business weighing 193 KB gzipped.
The "drop obvious plurals" heuristic in build-wordlists.ts checked each
singular against allowedSet, which only ever holds 5-letter words. The
singular of a 5-letter plural is shorter (ROOMS -> ROOM), so it could
never match and the filter silently did nothing: 657 of 2500 English
answers (26%) ended in S, with ROOMS landing at index 0.
Extract a pure isInflectedSForm() helper (scripts/plurals.ts) that tests
the singular against the full ENABLE dictionary of every length, covering
regular plurals/3rd-person verbs (ROOMS, WALKS), -es after sibilant/CH/SH
stems (BOXES, ASHES), and Y->IES (FLIES). Rebuild words.en.json: S-ending
answers fall to 53 (2.1%), all genuine non-plurals (CHESS, VIRUS, YIKES).
French is untouched. Adds unit tests for the helper plus regression guards
on the shipped data.
The result/share preview dropped the emoji grid into a single text node
with rows joined by newlines, but .share-grid had no white-space rule, so
the browser collapsed the newlines and rendered every guess on one line.
Add white-space: pre so the newlines are honoured (and the fixed-width
grid never wraps).
On the final guess, submit() started the staggered flip and then
maybeRecord() saved to the PDS and re-rendered, rebuilding the board and
snapping the still-flipping tiles straight to their colours. A fast save
made the last tiles jump instantly, so the reveal looked like it sped up
at the end.
Export REVEAL_DURATION_MS from board.ts and have maybeRecord await it
before the post-save renderContent, so the board is only rebuilt once the
reveal has played out. The save still runs concurrently, so there's no
added latency when it's slower than the animation. Skipped under
prefers-reduced-motion, where the reveal is near-instant.
AT Mot is a static client-side word game native to the AT Protocol. There is
no application database and no bespoke backend: player data lives in each
player's PDS, and the leaderboard is read from the public Constellation
backlink index.
What's included:
- Lexicons under bzh.herve.atmot.* (result, stats, defs) following the Lexicon
Style Guide, with a format linter and an idempotent publish script.
- Deterministic UTC daily word engine for EN + FR, with self-curated word lists
from public-domain/MIT sources (ENABLE, an-array-of-french-words, hermitdave
frequency) — French sourced independently of wordle-fr. Frozen epoch
2026-06-23, frozen puzzleTarget format via a single shared helper.
- Browser OAuth (atcute), write-once result records + a mutable self stats /
declaration record, all written client-side to the player's PDS.
- "Share to atproto": app.bsky.feed.post with a standard emoji grid and the
puzzle permalink as a real link facet + embed (so Constellation can discover
it). Leaderboard + per-puzzle pages aggregate Constellation backlinks in the
browser with caching and graceful degradation; live likes/reposts/replies.
- Clean minimal-nerdy UI: own brand board palette (teal/amber/slate, not
Wordle's), SkyPress fonts (Overused Grotesk + IBM Plex Mono), prefers-color-
scheme dark mode, a11y (per-tile aria, non-hue cues), mobile-first.
- Cloudflare Pages target (SPA fallback, hosted client-metadata.json).
- Dual MIT/Apache-2.0, README/NOTICE/DECISIONS, 41 passing tests, 0 audit
vulnerabilities.