# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Repository layout

This is a Bun-workspaces monorepo (managed with Lerna for versioning/publishing and Nx purely for build caching/ordering). `engines` requires **Bun >=1.4** and **Node.js >=24**. The lockfile is `bun.lock` (text JSON, lockfileVersion 1) — `package-lock.json` was removed when the project migrated off npm/Jest in `#421` (Apr 2026). Workspace scripts use `bun run --filter <pattern> <script>` — the `'./packages/*'` glob runs in dependency order (built-in Nx cache), and `'*'` skips workspaces without that script (relevant for `test`). The root `workspaces` field is an explicit nine-entry list in dependency order, not a glob.

The nine packages live under `packages/` and have a strict dependency order. When something breaks "downstream" of where you edited, rebuild the upstream package first:

```
constants → types → configs ──┐
                              ├──→ dictionaries → solver → scrabble-solver (Next.js app)
word-lists ───────────────────┤
word-definitions ─────────────┘
logger (independent, used by app + dictionaries)
```

The GADDAG itself lives outside this repo, in [`@kamilmielnik/gaddag`](https://github.com/kamilmielnik/gaddag) — a flat typed-array automaton with `has`/`hasPrefix`/`getArc`, binary `serialize`/`deserialize`, and `Gaddag.fromArray(words)`. It replaced `@kamilmielnik/trie` (#164) and is consumed by `dictionaries`, `solver`, and the app as a regular npm dependency (`^2.0.0`). A local `packages/gaddag/` directory may exist — it holds git-ignored compiled output left over from before the package was extracted and is **not** a workspace.

- `solver` — pure word-finding engine. Given a `Gaddag`, `Config`, `Board`, and `Tile[]`, returns scored `ResultJson`s. Has no I/O. `solve.ts` delegates to `MoveGenerator` (a single file holding all the logic) — anchor-based GADDAG move generation with per-cell cross-check masks, run once per direction; results are scored inline and sorted deterministically so UI ties resolve identically. `MoveGenerator` throws on alphabets over 64 tiles or boards over 32×32 because move sort keys pack direction/line/start/end into one integer. `benchmarks/` measures median `solve()` times on fixed mid-game boards (en-US, en-GB, pl-PL; 0–2 blanks): `bun run benchmark` from the root reruns them and rewrites the chart SVG + results table in the package `README.md`; it needs real (downloaded) dictionaries.
- `dictionaries` — downloads/caches per-locale word lists to `$HOME/.scrabble-solver/dictionaries` and exposes them as `Gaddag`s (binary `<locale>.gaddag` files on disk; building the Polish GADDAG from its 3.2M-word list takes ~25 s, so it happens in `postbuild`/background updates, not per request; the `update-dictionaries` / `remove-dictionaries` bin scripts wrap this, and `update()` refreshes only stale locales unless forced). The `Dictionaries` class layers a `MemoryCache` over a `DiskCache` (`LayeredCache`) and uses a per-locale `createAsyncProxy` to coalesce concurrent downloads. Cache entries older than `CACHE_STALE_THRESHOLD` (1 day) are considered stale, but only `update()` (run from `postbuild` and the packaging workflows) refreshes them — `get()` serves stale entries as-is. `DiskCache.get` treats files it cannot deserialize (corrupted, or written by an app version with an incompatible format — the binary magic doubles as the format version) as cache misses: it deletes them and the dictionary is re-downloaded and re-serialized automatically; `set` also removes the pre-#164 `<locale>.txt` trie cache. The solver worker does the same client-side (`src/solver-worker/getGaddag.ts`): it returns undefined for undeserializable cached dictionaries, falling back to the server and revalidating. Only this package and `logger` perform filesystem I/O — keep other packages pure so they can run in Edge / browser contexts.
- `word-lists` — pulls raw word lists from upstream sources (one fetcher per locale in `src/languages/`). Used by `dictionaries` during downloads.
- `word-definitions` — per-locale `crawl(word) → string` and a parser for each source (Wiktionary, CNRTL, DWDS, SJP, dexonline, vajehyab, etc.). English uses the Wiktionary REST API (#431): `parse` JSON-parses the payload and uses cheerio only to strip HTML from definition strings, so the English fixtures are `.json`. Add a new locale by adding a `crawl` and a `parse` function in `src/languages/` and wiring them into `crawl.ts` / `parse.ts`. `parse.test.ts` is where you add fixture-based parser tests.
- `types` — domain model classes (`Board`, `Cell`, `Tile`, `Result`, `Config`, `Locale`, …) plus `*Json` shapes. Most of these have a `fromJson` / `toJson` round-trip — use them at the wire boundary instead of hand-rolled serialization. Free functions live one-per-file under `src/lib/` (`getBoardWords`, `getCells`, `getCollidingWords`, `getCollisions`, `isSameBoardWord`, …) and type guards under `src/type-guards/` (`isBoardJson`, `isCellJson`, `isGame`, `isLocale`, …); the class methods are thin wrappers over them, so put new board/word logic in `lib/` and expose it as a method rather than growing `Board.ts`. `Board.getWords()` returns `BoardWord[]` (`{ direction, word, x, y }`), not strings — the same word can appear twice on a board, so a word's identity is its start cell plus direction (`isSameBoardWord`), and `Board.getCollidingWords(word)` returns the perpendicular words crossing it.
- `configs` — split into **games** (`scrabble`, `superScrabble`, `scrabbleDuel`, `letterLeague`, `crossplay`, `literaki`, `kelimelik`, `wyrazy` — each defines board size, bonuses, rack size, blanks count, bingo bonus) and **languages** (`english`, `french`, …, each spreads a base game config and overrides `locale` + `tiles`). A locale config is one game × one language; e.g. `polishScrabble` = `scrabble` ⊕ Polish tiles + digraphs. Adding a language means a new config here **and** the 15-step checklist under "Add a new language" in `README.md`.
- `logger` — Winston logger writing JSON to `$HOME/.scrabble-solver/logs/{all,error}.log`. Only `error` goes to console. Used server-side by the app + dictionaries; do **not** import from browser code.
- `constants` — shared primitives (`BLANK`, `BONUS_CHARACTER`, `BONUS_WORD`, …). No runtime dependencies.

### App package (`@scrabble-solver/scrabble-solver`)

- **Routing**: Next.js Pages Router (`src/pages/`). API routes: `solve`, `verify`, `visit`, `dictionary/[locale]` (binary GADDAG download), and `dictionary/[locale]/[word]` (definitions). The path alias `@/*` resolves to `src/*` (set in `tsconfig.json`).
- **State**: Redux Toolkit + Redux-Saga. Slices in `src/state/{app,board,cellFilters,dictionary,hoveredTile,hoveredWord,i18n,rack,results,settings,solve,verify}`, each exporting `<name>Slice` (reducer + actions) and selectors. The root saga in `state/sagas.ts` reacts to slice actions: `submit` → call SDK → write results back. `solve`, `verify`, and `dictionary` use `takeLatest` (only the latest in-flight request resolves); cell/rack edits use `takeEvery`. State is intentionally **not** serializable-checked (`serializableCheck: false`) because slices hold class instances (`Board`, `Tile`). `initialize({ version })` carries the app version (from `getStaticProps`) into the `app` slice; the translations cache is keyed on it. Two slices drive board highlighting and they are mutually exclusive: `results.candidate` (a solver result being previewed) and `hoveredWord` (a created word hovered or selected in the words table) — each one's saga clears the other, and both funnel into the same `searchDictionary` helper in `state/sagas.ts` that fills the dictionary panel. The `verify` slice mirrors `results`: alongside `validWords`/`invalidWords` (both `VerifiedWord[]`) it owns the words table's own `query` and `sort`, toggled through the shared `lib/getNextSort`.
- **Board render budget**: `Cell`/`Tile` render 225+ times at once and must not subscribe to the store — every value they need flows down from `BoardPure` as memo-friendly props, and event handlers read state at event time via `useTypedStore().getState()` (see `Cell.tsx`). Anything added to a cell that subscribes via `useSelector` multiplies by 225 and shows up directly in TBT. A cell's `highlighted` flag is the OR of every highlight source (result candidate, hovered rack/remaining tile character, hovered created word) and is computed in `BoardPure`, so a new highlight means a new `boolean[][]` prop selector — never a lookup inside `Cell`.
- **Tables**: `src/components/Table/` holds the shared table chrome — `Header`, `HeaderButton` (generic over the column-id enum; sort state and `onSort` arrive as props), `Row`, `Cell`, and `Search` (the controlled RegExp filter input, formerly `ResultsInput`). Two tables use it: `components/Results` (solver results) and `modals/WordsModal/components/WordsTable` (created words). The kit owns chrome only — column widths stay in each table's own `*.module.scss`, and the single column that absorbs the leftover width is marked with the `primary` prop rather than a class. `Row` derives `cursor: pointer` from whether it was given an `onClick`, so a row never advertises a click it will ignore; that is also why `Result` passes handlers through conditionally instead of defaulting them to `noop`. Both tables read as one behaviour matrix over layout × input, and the two axes mean different things: the **layout** decides where the footer buttons live (compact shows *Preview*, plus *Insert* for results; desktop shows neither), while **touch** decides what a repeated click on the already-picked row does (previews it, since a touch user has no other way to get back to the board). Between them sits the highlight's lifetime, and both tables answer it identically: only on desktop non-touch does the pointer own it (hover and focus pick, leaving the list clears, clicking adds nothing) — everywhere else it is sticky, so clicking a row picks it and hovering one does nothing at all. `Table`'s `.row` therefore highlights on `:focus-visible`, never `:focus`: a row left focused by a click must stop looking picked the moment another row takes over. That also means a row's picked state has to come from an explicit `highlighted` prop (`highlightedIndex`), never from focus — the results sidebar passes `selectResultCandidateIndex` for exactly that reason. Both tables virtualise with `react-window` and keep their sort/filter state in their own slice (`state/results/*`, `state/verify/*`); each slice's `lib.ts` owns a `Record<ColumnId, ComparatorFactory>` that the shared `lib/createSortComparator` collapses into one comparator (`lib/getNextSort` toggles the direction, `lib/createCoordinatesComparator` + `lib/getCoordinates` cover the coordinates column). Neither table filters rows out — `groupResults`/`groupWords` float the matches to the top and render the rest dimmed and `aria-hidden`, so the row count never depends on the query. Only the words table has an empty case: with no created words it swaps the list for an `EmptyState` and hides the `Search` input.
- **SDK layer (`src/sdk/`)**: thin browser/server clients for the four API routes. `findWordDefinitions` is memoized at the saga level via `lib/memoize`. Always go through SDK — never `fetch` directly from a saga or component. `solve` and `verify` first try the local `src/solver-worker/` web worker and fall back to the API route, so their payload shapes are implemented twice — change `pages/api/verify.ts` and `solver-worker/index.worker.ts` together (both answer with `VerifiedWord[]`, i.e. `BoardWord` plus `isValid`, not `string[]`).
- **Persistence**: settings, board, and rack auto-persist to `localStorage` via `store2` under the `scrabble-solver` namespace. The `useLocalStorage` hook (mounted in `pages/index.tsx`) subscribes to the three slices and writes them out on every change (skipping each effect's first post-hydration run, which would only echo what was just read) — **adding a new field to `SettingsState` is enough; you don't need to touch any save code** (PR #321, Apr 2026). Boot state is deterministic (SSR/hydration must match — issue #412): persisted settings/board/rack are applied post-mount by `hydratePersistedState` in the `initialize()` saga, guarded so a throw leaves the deterministic defaults and `app.hydrated` still fires (`finally`) — otherwise persistence would silently die for the session. The `state/localStorage.ts` getters treat corrupt/mismatched entries as absent and remove them; the saga skips no-op `init` dispatches so an empty-storage boot changes no state identities and re-renders nothing. The active locale's translations are also cached (`translations` key, keyed by app version + locale) and hydrated synchronously so a returning user's first post-hydration paint is already translated; the entry is also discarded when it is missing any key of the statically bundled English object (`hasEveryTranslation`), so a new `TranslationKey` shipped without a version bump can't strand a returning user on a stale cache (#452). When changing settings shape, add a migration block (see `migrateLegacySettings` for the pattern — dated comment with introduction date and life expectancy).
- **Service worker**: built by `WorkboxPlugin.InjectManifest` from `src/service-worker/index.ts` to `public/service-worker.js`. It precaches the app shell and routes navigations to it — solving and verifying offline is the `src/solver-worker/` web worker's job, not the service worker's. Only generated in production builds (`!isServer && !dev`). The precache excludes `.css` — every page inlines its stylesheets (see `_document.tsx`), so the emitted CSS files are never requested.
- **i18n**: `src/i18n/languages/<lang>.json` (8 languages, mapped to `Locale` in `i18n.ts`). English is statically bundled; every other locale is a dynamic-import chunk (`loadTranslations`), loaded into the `i18n` slice — `selectTranslations` falls back to the complete English object, so missing keys are impossible mid-load. All locales preload on idle/first intent (`preloadTranslationsWhenIdle`), which is what makes switching languages apply instantly; the active locale is additionally cached in localStorage (see Persistence). The `LOCALE_FEATURES` registry in `src/i18n/constants.ts` carries per-locale UI metadata: `direction` ('ltr' | 'rtl'), `comma`/`separator` glyphs (Latin vs Arabic), language `label`/`name`, and `consonants`/`vowels` flags that drive the auto-group-tiles UI; flag icons live separately in `src/i18n/localeIcons.ts` (`LOCALE_ICONS`) so they stay out of the main bundle. The `useDirection` hook applies `direction` to `<html dir>`. Add a new locale by extending both maps plus the i18n JSON dictionary plus a `Flag<XX>.svg` icon. `useTranslate()` is the lookup hook.
- **Layout**: all layout sizing lives in CSS custom properties (`src/styles/variables.scss`: `--cell-size`, `--rack-tile-size`, `--max-board-width`, …), with the config-dependent inputs (`--board-cols`, `--board-rows`, `--rack-size`) set on `:root` twice: before first paint by an inline script in `_document.tsx` (which reads persisted settings, also sets `dir`/`lang`, and adds the `config-pending` class that hides `main` until hydration applies a persisted non-default game — see `CONFIG_PENDING_CLASS`), and post-hydration by an effect in `pages/index.tsx`. There is no JS layout hook — components size themselves via these variables (the board grid templates use `repeat(var(--board-cols), …)` so the held board has its final geometry pre-hydration); JS reads viewport state only through `useMediaQuery`. The compact/desktop split is `useIsCompactLayout()` — the desktop layout needs a viewport that is both `l` wide (1200px) and `$breakpoint-height-l` tall (800px), so a short desktop window gets the same layout as a tablet; `showResultsInModal`, `showCompactControls`, `isCompactLayout` and `selectsFirstWord` are all that same hook. SCSS says it with the `compact` / `desktop` media expressions from `styles/mixins.scss` (the split is spelled out twice — change both), never with `<l` / `>=l`. `Modal`'s footer is not size-gated at all — a modal shows one by passing `footer`, and it's up to that modal to decide when (`WordsModal` renders its *Preview* button only below the breakpoint, `ResultsModal` closes itself above it). **Boot-path DOM rules**: never read computed style in mount effects (forced reflow — compare against inline style or known defaults instead), and guard same-value writes to `<html>` attributes/variables (they invalidate style/paint document-wide and destabilize LCP). The active modal in `pages/index.tsx` is tracked as a `Record<Modal, boolean>` patched through a single `patchModals` callback; each modal mounts on first open and stays mounted (`mountedModals` latch), because a `next/dynamic({ssr: false})` component starts its chunk request during render.
- **Styling**: SCSS modules with a shared design-token system. When one module's class overrides another component's styles (e.g. `Actions` restyling `Button`), it must win on **specificity**, not stylesheet order — webpack's CSS emission order shifts with the import graph (this flipped once and widened a square button). Class names are type-checked (#16): `scripts/generate-scss-types.ts` writes a git-ignored sibling `.d.ts` for every `*.module.scss` — the `build` and `type-check` scripts run it up front, `dev` runs it in watch mode, and its sass options mirror `sassOptions` in `next.config.js`. `src/@types/scss.d.ts` only covers side-effect imports of non-module stylesheets, deliberately exporting nothing so an import of an ungenerated module fails the type-check. SCSS tokens live in `src/styles/_tokens.scss`; the same values are re-exported to TS via `:export` in `variables.module.scss`. Concrete JS constants live in `src/parameters/index.ts` (e.g. `BREAKPOINTS`, `COLOR_BLUE`, `TRANSITION_DURATION`) — that file is the only place that should read from `variables.module.scss`. Update `_tokens.scss` first → expose via `variables.module.scss` `:export` block → consume via `parameters/`. This was added in PR #228 (Apr 2026); before it, JS-side colors and breakpoints were hard-coded duplicates. Responsive helpers come from `include-media`.
- **SVGs**: imported as React components via `@svgr/webpack` (configured in `next.config.js`), typed by `src/@types/svg.d.ts`.
- **Service worker registration**: production-only. Registered by `serviceWorkerManager.ts` from the index page, deferred until load + idle (`waitForIdle`) so the install's precache downloads never compete with page resources (an eager install once caused Lighthouse's robots.txt fetch to time out). Playwright blocks registration entirely via `serviceWorkers: 'block'` in `playwright.config.ts`, and each test gets a fresh browser context, so e2e tests need no service-worker or localStorage cleanup.
- **Deferral pattern**: anything that shouldn't compete with boot goes through `lib/waitForIdleOrFirstIntent.ts` (load + `requestIdleCallback`, or first pointerdown/keydown) — translations preload, modal chunk warming, and `visit()`; service worker registration is idle-only (`waitForIdle`), and the dictionary prefetch is intent-only (`waitForFirstIntent` — the multi-MB download is pointless for passive visitors and would land in Lighthouse's payload audit). Tooltips defer their floating-ui setup one task past hydration via `useDeferredRender`.

## Common commands

All commands run from the repo root unless noted.

| Task | Command |
| --- | --- |
| Install + build everything | `bun install && bun run build` |
| Build all packages | `bun run build` (Nx-cached, respects dep order) |
| Build one package | `bun run --filter @scrabble-solver/<name> build` |
| Dev server (port 3000) | `bun run dev` |
| Production server (port 3333) | `bun start` (also waits for :3333 and opens the browser; `bun run start:app` is server-only) |
| Lint | `bun run lint` (oxlint) / `bun run lint:fix` |
| Format check / fix | `bun run format` / `bun run format:fix` (oxfmt) |
| Type-check the app | `bun run --filter @scrabble-solver/scrabble-solver type-check` (uses `tsc` from stable TypeScript 7, the native Go compiler) |
| Unit tests (all workspaces) | `bun run test-unit` |
| Unit tests (one package) | `bun run --filter @scrabble-solver/solver test` |
| One unit test file | `cd packages/solver && bun test src/solve.test.ts` |
| One unit test by name | `cd packages/solver && bun test -t "pattern"` |
| Solver benchmarks | `bun run benchmark` (rewrites the results table + chart in `packages/solver/README.md`) |
| Playwright (UI mode) | `bun run test-playwright` (expects dev server on :3000) |
| Playwright (headless) | `bun run test-playwright:run` (expects server on :3333) |
| Full test pipeline | `bun run test` (build → unit → `start-server-and-test` boots the app on :3333 → playwright test) — note: `bun test` invokes Bun's built-in test runner, not this script |

Hot reload only works for the `scrabble-solver` package. Edits to any other package require rebuilding that package before the app picks them up.

## Testing notes

- Unit tests run on **Bun's test runner**, not Jest. The API is Jest-compatible (`describe`/`it`/`expect`), which is why the oxlint config still loads the `jest` plugin for rules like `no-focused-tests`.
- Only `dictionaries`, `solver`, `word-definitions`, and `scrabble-solver` have a `test` script. Tests are auto-discovered under `src/` matching `*.test.ts(x)`. The 180s timeout is needed because some solver tests build a real `Gaddag` from a downloaded dictionary.
- `bunfig.toml` + `bun.test.preload.ts` register a SCSS loader stub (returns a `Proxy` whose keys are their own names) so component tests can import `*.scss` modules without a real compiler. If you add other non-JS imports to test-touched code (images, etc.), extend the preload.
- Each package's `tsconfig.json` excludes `**/*.test.ts` from the build output. Tests are not part of published packages.
- Playwright: specs in `e2e/` (`app.spec.ts`, `bugs/`, `features/`), shared page helpers in `e2e/lib/` (selectors return `Locator`s, actions take `page` first). Two base URLs are in play: `playwright.config.ts` defaults to `http://localhost:3000` (matches `bun run dev`); `test-playwright:run` and CI set `PLAYWRIGHT_BASE_URL=http://localhost:3333` (matches `bun start`). Pick the script that matches the server you're actually running. The config blocks service workers and runs Chromium only; browser binaries come from `bunx playwright install chromium`.

## Tooling specifics

- **Linting**: `oxlint` (Rust-based ESLint replacement) configured in `.oxlintrc.json`. Type-aware rules require `oxlint-tsgolint`. Adding a new top-level JS config file usually means adding it to `ignorePatterns`. The oxlint config still loads the `jest` plugin and `jest` global because Bun's test runner mirrors the Jest API; do not remove them.
- **Formatting**: `oxfmt` covers `*.{js,ts,tsx,scss}`.
- **Install layout**: `bunfig.toml` pins `[install] linker = "hoisted"`. With Bun's isolated layout, `next build` fails — webpack bundles `unzipper` from `word-lists` and errors on its uninstalled optional `@aws-sdk/client-s3` dependency. Don't remove it.
- **TypeScript**: stable TypeScript 7 (`typescript@^7.0.2`), whose `tsc` is the native Go compiler. It runs every package `build`, the app's `type-check`, and `next build` — the latter because Next.js runs the project-local `tsc` CLI (TS7 has no JS compiler API). Next 16.3.0 turned that on by default, so the `experimental.useTypeScriptCli` flag that pinned the app to the 16.3 preview line is gone from `next.config.js`; set it to `false` only to opt back into the JS compiler API, which TS7 cannot serve. The interim `@typescript/native-preview` (`tsgo`) setup from PR #422 was removed (Aug 2026). Root `tsconfig.json` sets `types: ["bun"]` for global test-runner types and excludes `e2e` and `playwright.config.ts` (covered by `e2e/tsconfig.json` instead); library packages extend it and additionally exclude `**/*.test.ts` from emitted output.
- **Next.js**: built with `--webpack` flag explicitly (the default Turbopack is intentionally not used). `next.config.js` registers `@svgr/webpack` for SVG-as-component imports and the Workbox `InjectManifest` plugin for the service worker, and stubs out Next's unconditionally-bundled ES2019-2022 polyfills (`polyfill-module`) with `empty-module.js` via `NormalModuleReplacementPlugin` — every browserslist target (`package.json` `browserslist`: Chrome/Edge/Firefox ≥ 100, Safari ≥ 15.4, …) supports those natively, and browserslist alone does not control that chunk. SCSS load paths are extended to `./src` and `node_modules/include-media/dist`.
- **Nx**: `nx.json` only defines a `build` target with `dependsOn: ["^build"]` and `cache: true`. It is used purely for dependency-aware build ordering and caching — there are no Nx generators or executors.

## CI workflows

`.github/workflows/`:

- `build.yml` — `bun install --frozen-lockfile && bun run build`.
- `unit.yml` — `bun run build && bun run test-unit`.
- `e2e.yml` — Playwright against the app on :3333 (via `start-server-and-test`). Uploads the HTML report and traces on failure.
- `oxlint.yml` / `oxfmt.yml` — lint and format-check. `oxlint.yml` runs `bun run build` first because type-aware rules need built packages.
- `bunx.yml` / `npx.yml` — run daily, on push to master, and via `workflow_dispatch`. They poll npm (up to 15 min) until the current version of every workspace package is published, download the published `scrabble-solver` tarball with `npm pack`, install the global binary (`bun add --global` / `npm install --global`), pre-warm the dictionaries cache, then run Playwright from the extracted tarball against that binary. The Playwright specs come from the published package, **not** from `master` — if they came from master, any spec added for a feature that's merged but not yet released would run against the older published binary that lacks the feature, and fail (#428). Catches packaging regressions in the `bin/scrabble-solver.js` launcher.
- `deploy.yml` — `workflow_dispatch` only (branch input, default `master`). SSHs into prod, pulls, builds, restarts `scrabble-solver.service`.

When adding a workflow, match the existing pattern: trigger on `push`/`pull_request` to `master`, use `actions/checkout@v6` and `oven-sh/setup-bun@v2`, install with `bun install --frozen-lockfile`.

## Versioning & publishing

`bun run release` chains `reinstall → version:bump → np → lerna publish from-package`. `version:bump` runs `lerna version --force-publish` (bumps every package in lockstep) followed by `bump-version.js` to sync any other version references, then commits. Don't hand-edit `version` fields across packages — use the script.

## Deploys

The `Deploy` GitHub workflow (`workflow_dispatch` only) SSHs into the production host, pulls the chosen branch, runs `bun install && bun run build`, and restarts `scrabble-solver.service` via `systemctl`. There's no separate staging environment.

## Runtime data

The app reads/writes user data outside the project directory:

- `$HOME/.scrabble-solver/dictionaries/` — cached serialized `Gaddag`s (binary), one per locale, refreshed when older than 1 day.
- `$HOME/.scrabble-solver/logs/{all,error}.log` — Winston JSON logs.

The `bunx scrabble-solver@latest` entry point (`bin/scrabble-solver.js`) just `cd`s to the package root and runs `bun start`. The app then serves on http://localhost:3333.

## Recent migrations to keep in mind

Look here when something seems set up oddly — the reason is usually one of these recent changes. Reference issue numbers, not dates, when grepping git log.

- **#450 — Created-words table** (Aug 2026, issues #179/#427/#452). The words modal became a virtualised, sortable, RegExp-filterable table that shares `src/components/Table/` with the results table (`ResultsInput` moved there as `Search`). Picking a row — by hover where the pointer owns the highlight, by click everywhere else — highlights that word on the board and looks it up in the dictionary together with the words it crosses. Below the `l` breakpoint the first word is selected automatically (the modal covers the board, so the in-modal dictionary is the payoff) and the footer offers *Preview*, the only exit from the modal that leaves the highlight standing — every other way out clears it. On touch, re-tapping the already-picked row previews it too. The compact-layout result-candidate picker stopped going dead while nothing is picked: it stays enabled whenever fresh results exist and shows a `results.select` placeholder, so *next* picks the first result. `Board.getWords()` returns `BoardWord[]` instead of `string[]`, `/api/verify` and the solver worker answer with `VerifiedWord[]`, and the `types` package's free functions and type guards moved into `src/lib/` and `src/type-guards/` (`readResultCells.ts` → `lib/getCells.ts` + `lib/getCollisions.ts`).
- **#447 — Lighthouse 100** (Aug 2026, issue #412). The index page is fully server-rendered (the old `isClient` client-only gate is gone), so everything at boot must be SSR/hydration-safe and deterministic. The JS layout layer (`useAppLayoutValue`, `AppLayoutContext`) was deleted in favor of CSS variables; stylesheets are inlined into the HTML by `InlineCssHead` in `_document.tsx` (the emitted `.css` files exist but nothing requests them); a pre-paint inline script in `_document.tsx` applies persisted board dimensions/direction and holds `main` hidden until hydration when the persisted game differs from the default. Board cells are store-subscription-free (see "Board render budget"), modals mount on first open, tooltips/service-worker/dictionary/translations work is deferred past load or idle, and per-locale i18n chunks replaced the bundled all-locales map (with an idle preload and a version-keyed localStorage cache for instant/flash-free language behavior). `transliteration` was replaced with Unicode normalization, all barrel files were removed for tree-shaking, and Next's bundled polyfills are stubbed out. Perf invariants to preserve: no store subscriptions in `Cell`/`Tile`, no computed-style reads or same-value `<html>` writes in boot effects, nothing heavy before load+idle, and hydration must not change state identities on an empty-storage boot.
- **#164 — GADDAG solver** (Aug 2026). `@kamilmielnik/trie` was dropped everywhere in favor of the external `@kamilmielnik/gaddag` package; `solve()` was rewritten as anchor-based GADDAG move generation (~40-60× faster). Disk-cached dictionaries changed from serialized-trie `.txt` to binary `.gaddag` files, and `/api/dictionary/[locale]` now serves `application/octet-stream`, gzipped when the client accepts it (compressed once per in-memory dictionary via a `WeakMap` cache — the route parses `Accept-Encoding` q-values so `gzip;q=0` gets identity and `*` counts as accepting, evicts failed compression promises from the cache, and sets `Vary: Accept-Encoding`). Responses are `Cache-Control: no-cache`, so clients revalidate with `If-None-Match` and unchanged re-downloads become 304s — the `ETag` comes from Next's built-in per-payload generation (it overrides any hand-set `ETag` on `send()`, so don't set one). The solver worker deserializes with `Gaddag.deserialize` (the browser undoes `Content-Encoding` before the Cache API stores the body) and memoizes the deserialized `Gaddag` per locale, keyed on the cached response's `ETag`/`Date`/`Content-Length`, until `revalidateDictionary` replaces the entry.
- **#421 — Bun migration** (Apr 2026). npm/Jest → Bun. Top-level scripts now use `bun run --filter`, lockfile is `bun.lock`, unit tests run on `bun test`, the published binary's launcher (`bin/scrabble-solver.js`) shells out to `bun start`, and the packaging workflow now runs in two variants (`bunx.yml` installs via `bun add --global`, `npx.yml` via `npm install --global`). All workflows use `oven-sh/setup-bun@v2`; `npx.yml` is the only one that also uses `setup-node`.
- **#422 — TypeScript 7** (Apr 2026). Moved build/type-check to `tsgo` (native preview). Superseded in Aug 2026 by stable `typescript@7`, where the native compiler ships as plain `tsc` — scripts call `tsc` again and `@typescript/native-preview` is gone.
- **#420 — ESLint → Oxlint**. The `eslint-plugin-*` packages still appear in devDeps because oxlint loads them as JS plugins (`jsPlugins` in `.oxlintrc.json`). Don't strip them.
- **#321 — Auto-persisted settings** (Apr 2026). Settings, board, and rack are written through a single `useLocalStorage` effect. Don't dispatch save actions manually.
- **#228 — CSS variables in JS** (Apr 2026). JS constants for colors/sizes flow from `_tokens.scss` → `variables.module.scss` `:export` → `parameters/index.ts`. Don't hard-code the same values in TS.
- **#360 — Dart Sass deprecations**. SCSS files migrated to modern syntax (`@use`, `math.div`, etc.). New SCSS should follow that style; `next.config.js` sets `sassOptions.quietDeps: true` to keep upstream warnings out of build output.
