AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
0

Configure Feed

Select the types of activity you want to include in your feed.

Make tile results legible without relying on colour alone

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).

+623 -26
+28 -6
DECISIONS.md
··· 68 68 12. **Type = Overused Grotesk (display/body) + IBM Plex Mono (data/provenance)**, self-hosted 69 69 (both OFL). 70 70 71 - 13. **Board palette:** teal `#0F766E` (correct), burnt amber `#B45309` (present), slate `#5B6776` 72 - (absent). The same saturated chips are used in both 73 - light and dark themes, which guarantees AA contrast for the white tile letters in either theme. 74 - Secondary non-hue cue: an inner ring on "present" tiles plus per-tile aria labels announcing the 75 - state, so colour-blind and screen-reader players read the board clearly. The shared emoji grid 76 - stays standard `🟩🟨⬛`. 71 + 13. **Board palette:** teal `#0F766E` (correct), gold `#A16207` (present), recessive slate (absent). 72 + Deliberately distinct from the usual green/yellow word-game scheme. Two refinements came out of early player feedback: 73 + the present colour was nudged from the original 74 + burnt amber `#B45309` (which read as *red* — the classic red/green colour-blind trap) to a clearer 75 + gold; and the absent tone is now **theme-aware and shared with the keyboard** (light: slate 76 + `#5B6776`; dark: `#232B35`) so the "not in word" state looks identical on the board tile, the 77 + keyboard key, and the legend. The dark absent stays readable as a tile yet recessive. Per-tile 78 + aria labels announce the state regardless of colour. The shared emoji grid stays standard 79 + `🟩🟨⬛`. 80 + 81 + 13a. **Colour-blind mode is opt-in, off by default.** Rather than always drawing a corner glyph 82 + (`✓` right spot / `○` wrong spot / `✕` not in word) — which read as visual clutter — the glyphs 83 + appear only when the player enables "Colour-blind mode" from a footer toggle. The preference is 84 + stored in `localStorage` and applied as a `cb-safe` class on `<html>`; the glyph CSS keys off 85 + that class, so it lights up the board, the legend, and the help-modal examples at once. The 86 + first-visit help modal points colour-blind players at the toggle (only while it's still off). 87 + 88 + 13b. **Onboarding: a persistent legend + a first-visit help modal.** New players had no way to learn 89 + what the colours mean (also from the same feedback). A compact colour/symbol legend sits under the 90 + board during play; a `?` header button opens a native `<dialog>` "How to play" with worked 91 + examples, shown automatically once on first visit (guarded by `localStorage`) and re-openable any 92 + time. Original copy, bilingual, no login/email upsell. 77 93 78 94 14. **Dark mode follows `prefers-color-scheme` only** (no toggle), via semantic CSS custom 79 95 properties, per spec. 96 + 97 + 14a. **Keyboard keys: grey when untried, dark when ruled out.** The original 98 + keyboard did the opposite in dark mode — untried keys were dark and a tried-absent key turned a 99 + *lighter* slate. Untried keys are now a clear neutral grey (`#3A424F` in dark) and an absent key 100 + drops to the recessive shared "not in word" tone (darker than untried), so eliminated letters 101 + recede instead of standing out. Correct/present keys keep the board's teal/gold. 80 102 81 103 15. **Vanilla TypeScript, no UI framework** — keeps the static bundle tiny (~94 KB gzip), matching 82 104 the "lightest fit for a client-side game" rationale behind choosing atcute.
+45 -2
src/i18n.ts
··· 1 1 import type { Lang } from './config.js'; 2 + import type { TileState } from './engine/types.js'; 3 + 4 + /** A worked example shown in the "How to play" modal: a sample word with one 5 + * tile highlighted in a given state, plus a caption explaining that state. */ 6 + export interface HowToExample { 7 + word: string; 8 + /** Index (0–4) of the highlighted tile within `word`. */ 9 + index: number; 10 + state: TileState; 11 + caption: string; 12 + } 2 13 3 14 /** UI copy, per language. Keys are stable; values are end-user facing. */ 4 15 interface Strings { ··· 6 17 play: string; 7 18 howToTitle: string; 8 19 howTo: string[]; 20 + howToExamples: HowToExample[]; 21 + helpOpen: string; 22 + helpClose: string; 23 + helpColorBlindHint: string; 24 + colorBlindMode: string; 25 + legendCorrect: string; 26 + legendPresent: string; 27 + legendAbsent: string; 9 28 signIn: string; 10 29 signInPrompt: string; 11 30 signInPlaceholder: string; ··· 55 74 howTo: [ 56 75 'Guess the 5-letter word in 6 tries.', 57 76 'Each guess must be a real word.', 58 - 'Tiles show how close you were: right letter & spot, right letter wrong spot, or not in the word.', 77 + 'After each guess, the tiles change colour to show how close you were.', 78 + ], 79 + howToExamples: [ 80 + { word: 'CRANE', index: 0, state: 'correct', caption: 'C is in the word and in the right spot.' }, 81 + { word: 'TABLE', index: 1, state: 'present', caption: 'A is in the word but in the wrong spot.' }, 82 + { word: 'MOIST', index: 0, state: 'absent', caption: 'M is not in the word.' }, 59 83 ], 84 + helpOpen: 'How to play', 85 + helpClose: 'Close', 86 + helpColorBlindHint: 'Colour-blind? Turn on “Colour-blind mode” in the footer to add a symbol to each tile.', 87 + colorBlindMode: 'Colour-blind mode', 88 + legendCorrect: 'Right spot', 89 + legendPresent: 'Wrong spot', 90 + legendAbsent: 'Not in word', 60 91 signIn: 'Sign in', 61 92 signInPrompt: 'Sign in with your atproto handle to save your streak and share results.', 62 93 signInPlaceholder: 'you.bsky.social or you.com', ··· 106 137 howTo: [ 107 138 'Devinez le mot de 5 lettres en 6 essais.', 108 139 'Chaque proposition doit être un vrai mot.', 109 - 'Les cases indiquent votre progression : bonne lettre bien placée, bonne lettre mal placée, ou absente du mot.', 140 + 'Après chaque essai, les cases changent de couleur pour montrer votre progression.', 141 + ], 142 + howToExamples: [ 143 + { word: 'PORTE', index: 0, state: 'correct', caption: 'Le P est dans le mot et bien placé.' }, 144 + { word: 'TABLE', index: 1, state: 'present', caption: 'Le A est dans le mot mais mal placé.' }, 145 + { word: 'MOULE', index: 0, state: 'absent', caption: 'Le M n’est pas dans le mot.' }, 110 146 ], 147 + helpOpen: 'Comment jouer', 148 + helpClose: 'Fermer', 149 + helpColorBlindHint: 'Daltonien ? Activez le « Mode daltonien » dans le pied de page pour ajouter un symbole à chaque case.', 150 + colorBlindMode: 'Mode daltonien', 151 + legendCorrect: 'Bien placée', 152 + legendPresent: 'Mal placée', 153 + legendAbsent: 'Absente', 111 154 signIn: 'Se connecter', 112 155 signInPrompt: 'Connectez-vous avec votre identifiant atproto pour enregistrer votre série et partager vos résultats.', 113 156 signInPlaceholder: 'vous.bsky.social ou vous.fr',
+4
src/main.ts
··· 14 14 import { renderPlayView } from './ui/playView.js'; 15 15 import { renderPuzzlePage } from './ui/puzzlePage.js'; 16 16 import { renderAboutPage } from './ui/aboutPage.js'; 17 + import { initColorBlind } from './ui/colorblind.js'; 17 18 import { toast } from './ui/toast.js'; 18 19 19 20 const root = document.getElementById('app')!; 21 + 22 + // Apply the saved colour-blind preference before the first render. 23 + initColorBlind(); 20 24 21 25 const LANG_KEY = 'atmot:lang'; 22 26 function initialLang(): Lang {
+39
src/ui/colorblind.ts
··· 1 + /** 2 + * Colour-blind mode: an opt-in preference that adds a non-colour symbol 3 + * (✓ / ○ / ✕) to each scored tile and legend chip. Off by default — the tiles 4 + * are colour-only — so the glyphs don't clutter the board for players who 5 + * don't need them. The preference is stored locally and applied as a `cb-safe` 6 + * class on <html>, which the glyph CSS keys off. 7 + */ 8 + const KEY = 'atmot:cb-safe'; 9 + const CLASS = 'cb-safe'; 10 + 11 + /** Whether the player has opted into colour-blind mode. */ 12 + export function colorBlindEnabled(): boolean { 13 + try { 14 + return localStorage.getItem(KEY) === '1'; 15 + } catch { 16 + return false; 17 + } 18 + } 19 + 20 + /** Reflect the given state onto <html> so the glyph CSS applies (or doesn't). */ 21 + function applyColorBlind(enabled: boolean): void { 22 + document.documentElement.classList.toggle(CLASS, enabled); 23 + } 24 + 25 + /** Persist and apply the preference. */ 26 + export function setColorBlind(enabled: boolean): void { 27 + try { 28 + if (enabled) localStorage.setItem(KEY, '1'); 29 + else localStorage.removeItem(KEY); 30 + } catch { 31 + /* storage unavailable — still apply for this session */ 32 + } 33 + applyColorBlind(enabled); 34 + } 35 + 36 + /** Apply the saved preference. Call once on boot, before the first render. */ 37 + export function initColorBlind(): void { 38 + applyColorBlind(colorBlindEnabled()); 39 + }
+18
src/ui/footer.ts
··· 1 1 import { t } from '../i18n.js'; 2 2 import { el } from './dom.js'; 3 3 import { navTo } from './header.js'; 4 + import { colorBlindEnabled, setColorBlind } from './colorblind.js'; 4 5 import type { Ctx } from './context.js'; 5 6 6 7 const PROFILE_URL = 'https://bsky.app/profile/jeremy.herve.bzh'; ··· 19 20 ); 20 21 } 21 22 23 + const cbToggle = el( 24 + 'button', 25 + { 26 + class: 'cb-toggle', 27 + type: 'button', 28 + 'aria-pressed': String(colorBlindEnabled()), 29 + title: t(ctx.lang).colorBlindMode, 30 + }, 31 + t(ctx.lang).colorBlindMode, 32 + ); 33 + cbToggle.addEventListener('click', () => { 34 + const next = !colorBlindEnabled(); 35 + setColorBlind(next); 36 + cbToggle.setAttribute('aria-pressed', String(next)); 37 + }); 38 + 22 39 lines.push( 23 40 el( 24 41 'div', 25 42 { class: 'line links', style: puzzleNumber != null ? 'margin-top:6px' : undefined }, 26 43 el('a', { href: '/about', onClick: navTo(ctx, '/about') }, t(ctx.lang).about), 44 + cbToggle, 27 45 el('span', { class: 'spacer' }), 28 46 el( 29 47 'a',
+14 -1
src/ui/header.ts
··· 1 1 import { LANGS } from '../config.js'; 2 2 import { t } from '../i18n.js'; 3 3 import { el } from './dom.js'; 4 + import { openHelp } from './help.js'; 4 5 import type { Ctx } from './context.js'; 5 6 6 7 export function renderHeader(ctx: Ctx): HTMLElement { ··· 18 19 ); 19 20 } 20 21 21 - const actions = el('div', { class: 'bar-actions' }, langswitch); 22 + const help = el( 23 + 'button', 24 + { 25 + class: 'btn small icon', 26 + type: 'button', 27 + title: t(ctx.lang).helpOpen, 28 + ariaLabel: t(ctx.lang).helpOpen, 29 + onClick: () => openHelp(ctx.lang), 30 + }, 31 + '?', 32 + ); 33 + 34 + const actions = el('div', { class: 'bar-actions' }, help, langswitch); 22 35 23 36 if (ctx.session) { 24 37 const who = ctx.handle ? `@${ctx.handle}` : 'signed in';
+73
src/ui/help.ts
··· 1 + import type { Lang } from '../config.js'; 2 + import { t, type HowToExample } from '../i18n.js'; 3 + import { el } from './dom.js'; 4 + import { colorBlindEnabled } from './colorblind.js'; 5 + 6 + /** localStorage flag: set once the visitor has seen the first-run help modal. */ 7 + const SEEN_KEY = 'atmot:help-seen'; 8 + 9 + /** One worked example: a sample word row with a single highlighted tile. */ 10 + function exampleRow(ex: HowToExample): HTMLElement { 11 + const row = el('div', { class: 'board-row', role: 'row' }); 12 + for (let i = 0; i < ex.word.length; i++) { 13 + const cls = i === ex.index ? `tile ${ex.state}` : 'tile'; 14 + row.append(el('div', { class: cls, 'aria-hidden': 'true' }, ex.word[i])); 15 + } 16 + return el('div', { class: 'help-example' }, row, el('div', { class: 'caption' }, ex.caption)); 17 + } 18 + 19 + /** Build (but don't open) the "How to play" dialog for `lang`. */ 20 + function buildHelpDialog(lang: Lang): HTMLDialogElement { 21 + const s = t(lang); 22 + const dialog = el('dialog', { class: 'help', ariaLabel: s.helpOpen }); 23 + 24 + const close = el( 25 + 'button', 26 + { class: 'help-close', type: 'button', ariaLabel: s.helpClose, onClick: () => dialog.close() }, 27 + '×', 28 + ); 29 + 30 + const list = el('ul', {}); 31 + for (const line of s.howTo) list.append(el('li', {}, line)); 32 + 33 + const examples = el('div', { class: 'help-examples' }); 34 + for (const ex of s.howToExamples) examples.append(exampleRow(ex)); 35 + 36 + dialog.append(el('div', { class: 'help-head' }, el('h2', {}, s.helpOpen), close), list, examples); 37 + 38 + // Point colour-blind players at the footer toggle (only when it's still off). 39 + if (!colorBlindEnabled()) { 40 + dialog.append(el('p', { class: 'help-hint' }, s.helpColorBlindHint)); 41 + } 42 + 43 + // Click on the backdrop (the dialog box itself, outside any child) dismisses. 44 + dialog.addEventListener('click', (e) => { 45 + if (e.target === dialog) dialog.close(); 46 + }); 47 + // Tidy up the DOM once closed — by button, Escape, or backdrop. 48 + dialog.addEventListener('close', () => dialog.remove()); 49 + return dialog; 50 + } 51 + 52 + /** Open the "How to play" modal. No-op if one is already open. */ 53 + export function openHelp(lang: Lang): void { 54 + if (document.querySelector('dialog.help[open]')) return; 55 + const dialog = buildHelpDialog(lang); 56 + document.body.append(dialog); 57 + dialog.showModal(); 58 + } 59 + 60 + /** 61 + * Show the help modal once, on a visitor's first ever play view. The flag is 62 + * written before opening so a storage-blocked browser doesn't re-prompt every 63 + * load (we'd rather under-show than nag). 64 + */ 65 + export function maybeShowFirstVisitHelp(lang: Lang): void { 66 + try { 67 + if (localStorage.getItem(SEEN_KEY) === '1') return; 68 + localStorage.setItem(SEEN_KEY, '1'); 69 + } catch { 70 + return; // storage unavailable — skip the auto-open entirely 71 + } 72 + openHelp(lang); 73 + }
+32
src/ui/legend.ts
··· 1 + import type { Lang } from '../config.js'; 2 + import { t } from '../i18n.js'; 3 + import type { TileState } from '../engine/types.js'; 4 + import { el } from './dom.js'; 5 + 6 + /** The three scored states, each with a sample letter and its short label key. */ 7 + const ITEMS: { state: TileState; sample: string; key: 'legendCorrect' | 'legendPresent' | 'legendAbsent' }[] = [ 8 + { state: 'correct', sample: 'A', key: 'legendCorrect' }, 9 + { state: 'present', sample: 'B', key: 'legendPresent' }, 10 + { state: 'absent', sample: 'C', key: 'legendAbsent' }, 11 + ]; 12 + 13 + /** 14 + * Compact colour + symbol key shown under the board during play, so a new 15 + * player can read the tiles without having to open the help modal. The sample 16 + * tiles are decorative (the label carries the meaning), hence aria-hidden. 17 + */ 18 + export function renderLegend(lang: Lang): HTMLElement { 19 + const s = t(lang); 20 + const legend = el('div', { class: 'legend', role: 'list', ariaLabel: s.helpOpen }); 21 + for (const item of ITEMS) { 22 + legend.append( 23 + el( 24 + 'div', 25 + { class: 'legend-item', role: 'listitem' }, 26 + el('div', { class: `tile ${item.state}`, 'aria-hidden': 'true' }, item.sample), 27 + el('span', {}, s[item.key]), 28 + ), 29 + ); 30 + } 31 + return legend; 32 + }
+8
src/ui/playView.ts
··· 15 15 import { renderHeader, navTo } from './header.js'; 16 16 import { renderFooter } from './footer.js'; 17 17 import { renderBoard, REVEAL_DURATION_MS } from './board.js'; 18 + import { renderLegend } from './legend.js'; 19 + import { maybeShowFirstVisitHelp } from './help.js'; 18 20 import { renderKeyboard } from './keyboard.js'; 19 21 import { renderDistribution, renderShareGrid, renderCountdown, stopCountdown } from './widgets.js'; 20 22 import type { Ctx } from './context.js'; ··· 223 225 if (isFinished(state)) { 224 226 children.push(resultsPanel()); 225 227 } else { 228 + // A compact colour/symbol key under the board, so the tiles are readable 229 + // without opening the help modal. 230 + children.push(renderLegend(ctx.lang)); 226 231 if (!ctx.session) { 227 232 children.push(signInBar(ctx, t(ctx.lang).signInPrompt)); 228 233 } ··· 247 252 248 253 rerenderApp(); 249 254 renderContent(); 255 + 256 + // First-time visitors get the "How to play" modal once (guarded by localStorage). 257 + maybeShowFirstVisitHelp(ctx.lang); 250 258 251 259 // If we arrived already-finished with a session, record once. 252 260 if (isFinished(state) && ctx.session && !recordTried) void maybeRecord();
+220 -17
src/ui/theme.css
··· 3 3 Direction: clean minimal-nerdy (Linear / atproto-docs feel). 4 4 Type: Overused Grotesk (display+body) + IBM Plex Mono (data/provenance), 5 5 matching skypress.blog. Dark mode follows prefers-color-scheme only. 6 - Board uses AT Mot's OWN brand palette (teal correct / amber present / 7 - slate absent) — deliberately NOT Wordle's green/yellow. The shared emoji 8 - grid (standard squares) is unaffected. 6 + Board uses AT Mot's OWN brand palette (teal correct / gold present / 7 + recessive slate absent — the absent tone is theme-aware and shared with the 8 + keyboard's "ruled out" key) — deliberately distinct from the usual green/yellow 9 + word-game scheme. Each state also 10 + carries a non-colour glyph (✓ / ○ / ✕) so the result is legible without 11 + relying on hue (colour-blind safe). The shared emoji grid (standard 12 + squares) is unaffected. 9 13 =========================================================================== */ 10 14 11 15 @font-face { ··· 48 52 /* Brand tile palette — saturated chips that read on either background, each 49 53 meeting AA with white text. Same in both themes for guaranteed contrast. */ 50 54 --tile-correct: #0f766e; /* teal */ 51 - --tile-present: #b45309; /* burnt amber (clearly not Wordle yellow) */ 55 + --tile-present: #a16207; /* gold (warm, reads clearly not red) */ 52 56 --tile-absent: #5b6776; /* slate */ 53 57 --tile-fg: #ffffff; 54 58 --tile-empty-border: #c7cfda; 55 59 --tile-edge: #2b3340; /* outline on a filled-but-unsubmitted tile */ 56 60 61 + /* Keyboard: untried keys read as a neutral grey; a key turns dark + recedes 62 + once it's been tried and ruled out (absent) — the opposite of a tried key 63 + getting lighter. correct/present keep the board's teal/gold. */ 57 64 --key-bg: #e4e9f0; 58 65 --key-fg: #11161d; 66 + --key-absent: #5b6776; 67 + --key-absent-fg: #ffffff; 59 68 60 69 --radius: 10px; 61 70 --radius-sm: 7px; ··· 75 84 --accent-fg: #06101d; 76 85 --tile-empty-border: #39424e; 77 86 --tile-edge: #5b6776; 78 - --key-bg: #232b35; 87 + /* "Not in word" is one recessive dark tone shared by the board tile and the 88 + keyboard key, yet still lighter than the page so the tile stays readable 89 + and darker than an untried key so the keyboard reads as "ruled out". */ 90 + --tile-absent: #232b35; 91 + --key-bg: #3a424f; /* clear grey: an untried, still-available key */ 79 92 --key-fg: #e6eaf0; 93 + --key-absent: #232b35; /* matches the board's "not in word" tile */ 94 + --key-absent-fg: #9aa7b5; 80 95 } 81 96 } 82 97 ··· 273 288 border-radius: var(--radius-sm); 274 289 color: var(--fg); 275 290 user-select: none; 291 + position: relative; 276 292 } 277 293 @media (max-width: 380px) { 278 294 .tile { ··· 290 306 .tile.absent { 291 307 color: var(--tile-fg); 292 308 border-color: transparent; 293 - /* --reveal-bg / --reveal-ring carry the destination colour so the flip 294 - keyframe can apply it at the midpoint; static rows just use them directly. */ 309 + /* --reveal-bg carries the destination colour so the flip keyframe can apply 310 + it at the midpoint; static rows just use it directly. */ 295 311 background: var(--reveal-bg); 296 - box-shadow: var(--reveal-ring, none); 297 312 } 298 313 .tile.correct { 299 314 --reveal-bg: var(--tile-correct); 300 315 } 301 316 .tile.present { 302 317 --reveal-bg: var(--tile-present); 303 - /* secondary non-hue cue: inner ring distinguishes present from correct */ 304 - --reveal-ring: inset 0 0 0 2px rgba(255, 255, 255, 0.45); 305 318 } 306 319 .tile.absent { 307 320 --reveal-bg: var(--tile-absent); 308 321 } 322 + 323 + /* Non-colour cue (opt-in via "Colour-blind mode" — the `cb-safe` class on 324 + <html>): a small corner glyph on every scored tile, so the three states stay 325 + distinguishable without relying on hue. ✓ right spot, ○ wrong spot, ✕ not in 326 + word — mirrored by the on-board legend and the help modal. Off by default so 327 + the glyphs don't clutter the board for players who don't need them. */ 328 + .cb-safe .tile.correct::after, 329 + .cb-safe .tile.present::after, 330 + .cb-safe .tile.absent::after { 331 + position: absolute; 332 + top: 3px; 333 + left: 5px; 334 + font-size: 13px; 335 + line-height: 1; 336 + font-weight: 700; 337 + color: rgba(255, 255, 255, 0.92); 338 + pointer-events: none; 339 + } 340 + .cb-safe .tile.correct::after { 341 + content: '✓'; 342 + } 343 + .cb-safe .tile.present::after { 344 + content: '○'; 345 + } 346 + .cb-safe .tile.absent::after { 347 + content: '✕'; 348 + } 349 + 309 350 /* Freshly-submitted row: flip each tile in turn (delay set per tile in board.ts) 310 351 and keep the colour hidden until the tile is edge-on at the flip's midpoint. */ 311 352 .tile.reveal { 312 353 animation: flip 0.5s ease both; 313 354 animation-delay: var(--reveal-delay, 0ms); 314 355 } 356 + /* Keep the corner glyph hidden until the flip reaches its midpoint, so it 357 + reveals together with the colour rather than spoiling the pre-flip tile. */ 358 + .cb-safe .tile.reveal::after { 359 + animation: glyph-reveal 0.5s ease both; 360 + animation-delay: var(--reveal-delay, 0ms); 361 + } 362 + @keyframes glyph-reveal { 363 + 0%, 364 + 50% { 365 + opacity: 0; 366 + } 367 + 50.01%, 368 + 100% { 369 + opacity: 1; 370 + } 371 + } 315 372 316 373 @keyframes pop { 317 374 0% { ··· 327 384 background: transparent; 328 385 border-color: var(--tile-edge); 329 386 color: var(--fg); 330 - box-shadow: none; 331 387 } 332 388 50% { 333 389 transform: rotateX(90deg); 334 390 background: transparent; 335 391 border-color: var(--tile-edge); 336 392 color: var(--fg); 337 - box-shadow: none; 338 393 } 339 394 50.01% { 340 395 background: var(--reveal-bg); 341 396 border-color: transparent; 342 397 color: var(--tile-fg); 343 - box-shadow: var(--reveal-ring, none); 344 398 } 345 399 100% { 346 400 transform: rotateX(0); 347 401 background: var(--reveal-bg); 348 402 border-color: transparent; 349 403 color: var(--tile-fg); 350 - box-shadow: var(--reveal-ring, none); 351 404 } 352 405 } 353 406 .row-shake { ··· 417 470 color: var(--tile-fg); 418 471 } 419 472 .key.absent { 420 - background: var(--tile-absent); 421 - color: var(--tile-fg); 422 - opacity: 0.85; 473 + background: var(--key-absent); 474 + color: var(--key-absent-fg); 423 475 } 424 476 425 477 /* ---- cards / panels ------------------------------------------------------- */ ··· 572 624 gap: 14px; 573 625 } 574 626 627 + /* ---- legend (on-board colour/symbol key) ---------------------------------- */ 628 + 629 + .legend { 630 + display: flex; 631 + flex-wrap: wrap; 632 + justify-content: center; 633 + gap: 8px 16px; 634 + margin: 2px 0 12px; 635 + font-size: 13px; 636 + color: var(--fg-muted); 637 + } 638 + .legend-item { 639 + display: inline-flex; 640 + align-items: center; 641 + gap: 7px; 642 + } 643 + .legend .tile { 644 + width: 26px; 645 + height: 26px; 646 + font-size: 14px; 647 + border-width: 2px; 648 + animation: none; 649 + } 650 + .cb-safe .legend .tile.correct::after, 651 + .cb-safe .legend .tile.present::after, 652 + .cb-safe .legend .tile.absent::after { 653 + font-size: 8px; 654 + top: 1px; 655 + left: 2px; 656 + } 657 + 658 + /* ---- help modal ----------------------------------------------------------- */ 659 + 660 + .btn.icon { 661 + width: 34px; 662 + padding: 6px 0; 663 + text-align: center; 664 + font-size: 16px; 665 + font-weight: 700; 666 + } 667 + 668 + dialog.help { 669 + width: min(440px, calc(100vw - 32px)); 670 + border: 1px solid var(--border); 671 + border-radius: var(--radius); 672 + background: var(--surface); 673 + color: var(--fg); 674 + padding: 22px 22px 24px; 675 + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); 676 + } 677 + dialog.help::backdrop { 678 + background: rgba(8, 12, 18, 0.55); 679 + } 680 + .help-head { 681 + display: flex; 682 + align-items: flex-start; 683 + justify-content: space-between; 684 + gap: 12px; 685 + } 686 + .help-head h2 { 687 + margin: 0; 688 + } 689 + .help-close { 690 + appearance: none; 691 + border: 0; 692 + background: transparent; 693 + color: var(--fg-muted); 694 + font-size: 24px; 695 + line-height: 1; 696 + cursor: pointer; 697 + padding: 2px 6px; 698 + border-radius: var(--radius-sm); 699 + } 700 + .help-close:hover { 701 + color: var(--fg); 702 + } 703 + .help ul { 704 + margin: 10px 0 18px; 705 + padding-left: 20px; 706 + } 707 + .help ul li { 708 + margin: 4px 0; 709 + } 710 + .help-examples { 711 + display: flex; 712 + flex-direction: column; 713 + gap: 14px; 714 + } 715 + .help-example .board-row { 716 + display: grid; 717 + grid-template-columns: repeat(5, 36px); 718 + gap: 5px; 719 + margin-bottom: 6px; 720 + } 721 + .help-example .tile { 722 + width: 36px; 723 + height: 36px; 724 + font-size: 19px; 725 + animation: none; 726 + } 727 + .cb-safe .help-example .tile.correct::after, 728 + .cb-safe .help-example .tile.present::after, 729 + .cb-safe .help-example .tile.absent::after { 730 + font-size: 9px; 731 + top: 2px; 732 + left: 3px; 733 + } 734 + .help-example .caption { 735 + font-size: 14px; 736 + color: var(--fg-muted); 737 + } 738 + .help-hint { 739 + font-size: 13px; 740 + color: var(--fg-muted); 741 + margin: 16px 0 0; 742 + } 743 + 575 744 /* ---- toast ---------------------------------------------------------------- */ 576 745 577 746 .toast-wrap { ··· 646 815 height: 18px; 647 816 border-radius: 50%; 648 817 display: block; 818 + } 819 + 820 + /* Colour-blind mode toggle (footer). The leading glyph doubles as a preview of 821 + what the mode does: a hollow ○ when off, a filled ✓ when on. */ 822 + .cb-toggle { 823 + appearance: none; 824 + font-family: var(--font-mono); 825 + font-size: 11.5px; 826 + color: var(--fg-muted); 827 + background: transparent; 828 + border: 1px solid var(--border); 829 + border-radius: 999px; 830 + padding: 2px 9px; 831 + cursor: pointer; 832 + display: inline-flex; 833 + align-items: center; 834 + gap: 6px; 835 + } 836 + .cb-toggle::before { 837 + content: '○'; 838 + font-size: 11px; 839 + line-height: 1; 840 + } 841 + .cb-toggle:hover { 842 + color: var(--fg); 843 + border-color: var(--fg-muted); 844 + } 845 + .cb-toggle[aria-pressed='true'] { 846 + color: var(--fg); 847 + border-color: var(--tile-correct); 848 + } 849 + .cb-toggle[aria-pressed='true']::before { 850 + content: '✓'; 851 + color: var(--tile-correct); 649 852 } 650 853 651 854 .center {
+36
tests/colorblind.test.ts
··· 1 + // @vitest-environment happy-dom 2 + import { describe, expect, it, beforeEach } from 'vitest'; 3 + import { colorBlindEnabled, setColorBlind, initColorBlind } from '../src/ui/colorblind.js'; 4 + 5 + beforeEach(() => { 6 + localStorage.clear(); 7 + document.documentElement.className = ''; 8 + }); 9 + 10 + describe('colour-blind preference', () => { 11 + it('is off by default — no cb-safe class', () => { 12 + expect(colorBlindEnabled()).toBe(false); 13 + initColorBlind(); 14 + expect(document.documentElement.classList.contains('cb-safe')).toBe(false); 15 + }); 16 + 17 + it('enabling persists and adds the cb-safe class', () => { 18 + setColorBlind(true); 19 + expect(colorBlindEnabled()).toBe(true); 20 + expect(document.documentElement.classList.contains('cb-safe')).toBe(true); 21 + }); 22 + 23 + it('disabling clears the flag and the class', () => { 24 + setColorBlind(true); 25 + setColorBlind(false); 26 + expect(colorBlindEnabled()).toBe(false); 27 + expect(document.documentElement.classList.contains('cb-safe')).toBe(false); 28 + }); 29 + 30 + it('initColorBlind re-applies a previously-saved preference', () => { 31 + setColorBlind(true); 32 + document.documentElement.className = ''; // simulate a fresh page load 33 + initColorBlind(); 34 + expect(document.documentElement.classList.contains('cb-safe')).toBe(true); 35 + }); 36 + });
+76
tests/help.test.ts
··· 1 + // @vitest-environment happy-dom 2 + import { describe, expect, it, beforeEach } from 'vitest'; 3 + import { openHelp, maybeShowFirstVisitHelp } from '../src/ui/help.js'; 4 + import { setColorBlind } from '../src/ui/colorblind.js'; 5 + 6 + // happy-dom doesn't fully implement <dialog>; make showModal/close deterministic 7 + // so the open/close lifecycle (and the [open] guard) is exercisable in tests. 8 + beforeEach(() => { 9 + document.body.replaceChildren(); 10 + localStorage.clear(); 11 + document.documentElement.className = ''; 12 + HTMLDialogElement.prototype.showModal = function (this: HTMLDialogElement) { 13 + this.setAttribute('open', ''); 14 + }; 15 + HTMLDialogElement.prototype.close = function (this: HTMLDialogElement) { 16 + this.removeAttribute('open'); 17 + this.dispatchEvent(new Event('close')); 18 + }; 19 + }); 20 + 21 + describe('openHelp', () => { 22 + it('mounts a dialog with the rules and one example per scored state', () => { 23 + openHelp('en'); 24 + const dialog = document.querySelector('dialog.help'); 25 + expect(dialog).not.toBeNull(); 26 + expect(dialog!.querySelectorAll('ul li')).toHaveLength(3); 27 + expect(dialog!.querySelector('.tile.correct')).not.toBeNull(); 28 + expect(dialog!.querySelector('.tile.present')).not.toBeNull(); 29 + expect(dialog!.querySelector('.tile.absent')).not.toBeNull(); 30 + expect(dialog!.textContent).toContain('How to play'); 31 + }); 32 + 33 + it('does not stack a second dialog while one is open', () => { 34 + openHelp('en'); 35 + openHelp('en'); 36 + expect(document.querySelectorAll('dialog.help')).toHaveLength(1); 37 + }); 38 + 39 + it('removes the dialog from the DOM when closed', () => { 40 + openHelp('en'); 41 + const dialog = document.querySelector('dialog.help') as HTMLDialogElement; 42 + const close = dialog.querySelector('.help-close') as HTMLButtonElement; 43 + close.click(); 44 + expect(document.querySelector('dialog.help')).toBeNull(); 45 + }); 46 + 47 + it('renders French copy in fr', () => { 48 + openHelp('fr'); 49 + expect(document.querySelector('dialog.help')!.textContent).toContain('Comment jouer'); 50 + }); 51 + 52 + it('shows the colour-blind hint when the mode is off', () => { 53 + openHelp('en'); 54 + expect(document.querySelector('.help-hint')).not.toBeNull(); 55 + }); 56 + 57 + it('hides the colour-blind hint once the mode is on', () => { 58 + setColorBlind(true); 59 + openHelp('en'); 60 + expect(document.querySelector('.help-hint')).toBeNull(); 61 + }); 62 + }); 63 + 64 + describe('maybeShowFirstVisitHelp', () => { 65 + it('opens once, then never again once the flag is stored', () => { 66 + maybeShowFirstVisitHelp('en'); 67 + expect(document.querySelector('dialog.help')).not.toBeNull(); 68 + 69 + // Dismiss, then a second visit must not re-open it. 70 + (document.querySelector('.help-close') as HTMLButtonElement).click(); 71 + expect(document.querySelector('dialog.help')).toBeNull(); 72 + 73 + maybeShowFirstVisitHelp('en'); 74 + expect(document.querySelector('dialog.help')).toBeNull(); 75 + }); 76 + });
+30
tests/legend.test.ts
··· 1 + // @vitest-environment happy-dom 2 + import { describe, expect, it } from 'vitest'; 3 + import { renderLegend } from '../src/ui/legend.js'; 4 + 5 + describe('renderLegend', () => { 6 + it('shows a tile + label for each of the three states', () => { 7 + const legend = renderLegend('en'); 8 + expect(legend.querySelectorAll('.legend-item')).toHaveLength(3); 9 + expect(legend.querySelector('.tile.correct')).not.toBeNull(); 10 + expect(legend.querySelector('.tile.present')).not.toBeNull(); 11 + expect(legend.querySelector('.tile.absent')).not.toBeNull(); 12 + expect(legend.textContent).toContain('Right spot'); 13 + expect(legend.textContent).toContain('Wrong spot'); 14 + expect(legend.textContent).toContain('Not in word'); 15 + }); 16 + 17 + it('uses the French labels in fr', () => { 18 + const legend = renderLegend('fr'); 19 + expect(legend.textContent).toContain('Bien placée'); 20 + expect(legend.textContent).toContain('Mal placée'); 21 + expect(legend.textContent).toContain('Absente'); 22 + }); 23 + 24 + it('marks the decorative sample tiles aria-hidden', () => { 25 + const legend = renderLegend('en'); 26 + legend.querySelectorAll('.tile').forEach((tile) => { 27 + expect(tile.getAttribute('aria-hidden')).toBe('true'); 28 + }); 29 + }); 30 + });