Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .serena/memories/project-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
- 匯出/匯入存檔:2026-07-22 拍板**暫緩至 Phase 2**(Phase 1 純單機,改 localStorage 本來就行;三筆要認的帳見設計文件 §16)。別再當待辦。
- Phase 1.5 PWA 已實作(2026-07-22,設計文件 §15 Phase 1.5):§16 原本的「❌ 不做 PWA」已翻案,理由與快取策略在 §2/§16。**Web 版的離線可玩到這一步才第一次真的成立**(在此之前斷網是白屏)。§17 的 Phase 1.5 那 6 條也已在正式站真機驗完並打勾(2026-07-25),已找人試玩過。**還沒打勾的只剩三批**:上一條提到的「查詞提示」那幾條(2026-07-25 新加)、分享那 3 條(2026-07-27 新加,見下一條),以及 UI 補丁那批(2026-07-28 新加,見下一條),除此之外 Phase 1 + 1.5 全綠。
- 分享(2026-07-27):修掉「電腦版分享只剩網址」,同批移除 `?code=` 兌換連結。機制與取捨在 UI 文件 §4-D(Windows 分支、提示規則)與設計文件 §9(為什麼分享不能送 `location.href`),別在這裡再抄一份。§17 那 3 條分享驗收待真機(Windows 桌機 + iOS/macOS 預覽卡片)。
- UI 補丁四項(2026-07-28):金幣進帳時頂列數字彈一下、`.screen` 收成 480px 一欄(桌機/平板才生效)、金幣不足時提示鈕的燈泡與價格一起轉暗、重玩舊關時開場閃一次「重玩不再獲得金幣」。細節在 UI 文件 §2/§3(含「為什麼重玩不在頂列常駐標記」),§17 新增的 5 條待真機/VoiceOver 驗。
- UI 補丁四項(2026-07-28):金幣進帳時頂列數字彈一下、`.screen` 收成 480px 一欄(桌機/平板才生效)、金幣不足時提示鈕的燈泡與價格一起轉暗、重玩舊關時開場閃一次 `replayNote`。細節在 UI 文件 §2/§3(含「為什麼重玩不在頂列常駐標記」),§17 新增的 5 條待真機/VoiceOver 驗。文案本身以 UI 文件 §6 那張表為準,不在這裡抄第二份(2026-07-29 補上「重複」二字時就漏改過這裡一次)。
- App 內的 PWA 安裝引導:2026-07-27 做過一版(Chrome 系 `beforeinstallprompt` 按鈕 + iOS 文字步驟,放在設定卡)**又整個拿掉**,程式碼已還原、別再當待辦。理由與重做前提在設計文件 §15 Phase 1.5-4(§16 有一句話版)——一句話:壓掉瀏覽器自己的招攬去換一顆沒人打得開的按鈕,而安裝率沒有分析可量。
- Phase 2(Capacitor)**因成本暫緩、不是取消**:需要一台 Mac + Apple $99/年 + Google Play $25,且純 WebView 有 Apple 4.2 被拒風險。等有留存數據或要收錢再啟動;存檔搬遷的做法寫在設計文件 §15 Phase 2。好友邀請碼也押在這一步(要 `Device.getId()` 才成立,理由見設計文件 §16,別在網頁版重試)。
- `bun run fetch-data` 只需跑一次(輸出在 tools/data/,已 gitignore);之後只需 `bun run gen`。
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ Every command lives in [package.json](package.json) `scripts` — [mise.toml](mi

```sh
bun run test # unit tests (bun test)
bun run lint # Biome lint + format check (biome.json); bun run fix auto-fixes
bun run lint # Biome lint + format check (biome.json), warnings included; bun run fix auto-fixes
bun run check # lint + test — the local hook prek runs, alongside the upstream file checks
bun run serve # dev server on :8080 (ES modules need http; opening index.html from file:// won't work)
bun run fetch-data # download generator inputs → tools/data/ (ENABLE word list; wordfreq+WordNet via uv/python)
bun run gen # regenerate data/levels/ (requires fetch-data first)
```

`lint` carries **`--error-on-warnings`**, and that flag is load-bearing: `biome check` exits 0 on warnings by default, so without it every rule set to `"warn"` in biome.json is a rule that prints and lets the commit through. Five of them are (`noExcessiveCognitiveComplexity`, `noAccumulatingSpread`, `noAwaitInLoops`, `useTopLevelRegex`, `noProcessGlobal`) — measured 2026-07-29 with a throwaway top-level-regex violation: `biome check` exit 0 + "Found 1 warning", `biome check --error-on-warnings` exit 1. Same failure family as the prek catches below, one layer down: **a check can be present, green, and doing nothing.** The tree was already warning-clean when the flag went on, so it cost nothing; the price is that `noExcessiveCognitiveComplexity` now blocks commits (`game.js`'s `createGame` already carries a `biome-ignore` for it). If a warn-level rule ever becomes more annoying than useful, set it to `"off"` and say why — don't drop the flag, that silently re-disarms the other four.

Checks run through [prek](https://prek.j178.dev/) (a Rust pre-commit, pinned in `mise.toml`): run `prek install` once per clone to write `.git/hooks/pre-commit`; `prek run --all-files` runs everything by hand, `git commit --no-verify` skips it. [.pre-commit-config.yaml](.pre-commit-config.yaml) holds the upstream `pre-commit-hooks` file checks (large files, case/Windows-name conflicts, merge conflicts, symlinks, YAML syntax, private keys, end-of-file newlines, mixed line endings, trailing whitespace — the file keeps them in `id` alphabetical order, and this list mirrors it), the `gitleaks` secret scanner, and one `local`/`system` hook running `bun run check`. **CI runs `prek run --all-files`, not `bun run check`** — the point is that hooks which can't be a package.json script (whitespace, secret scanning, YAML lint, …) get enforced in CI the moment they're added to that file, with no workflow edit and no second list to keep in sync. Adding a hook there is the whole change; pr-check.yml should stay untouched — with three catches, all of the same family: **a hook can be present, green, and doing nothing.** First, `prek run` only runs the `pre-commit` stage, so a hook copied in with `stages: [pre-push]` (some upstream secret scanners ship that way) is silently skipped in CI. Leave hooks on the default stage. Second, **`gitleaks` is a commit-time gate only, not a CI one**: its upstream entry is `gitleaks git --pre-commit --redact --staged --verbose`, and under `--all-files` nothing is staged, so in CI it scans an empty diff and always passes (measured 2026-07-29: a `ghp_…` token fails the hook when staged, passes under `prek run --all-files`). Don't read a green PR Check as "no secrets". Making it real means `gitleaks dir`, which needs an allowlist config — a bare `gitleaks dir .` walks `node_modules` + `data/levels/` (70MB, 36s, plus a false positive), so it is not a drop-in. Third, `check-added-large-files` does the same thing one layer down — its source intersects the filenames prek hands it with `added_files()` (`git diff --staged --diff-filter=A`), so under `--all-files` it also measures nothing. That one *is* fixable, and is fixed: it carries `args: [--enforce-all]` in the config. **When adding any upstream hook, read its source for a `--staged`/`added_files()` default before trusting its green tick.** One caveat that is *not* about hooks: pr-check.yml's job `name:` is the required-status-check context in the repo ruleset (`bun check`) — renaming the job leaves `main` requiring a check that never reports, so every PR sits pending and unmergeable. Change the ruleset first.

Run a single test: `bun test --test-name-pattern='<name>' tests/game.test.mjs`. There is no build step. Runtime is Bun, not Node — `node:*` built-ins (fs, child_process, url) still work since Bun implements them natively.
Expand All @@ -47,7 +49,7 @@ wheel.js ──gesture ends(word)──▶ game.submit(word) ──result object
- [grid.js](src/grid.js), [dictionary-card.js](src/dictionary-card.js) — DOM rendering only. grid.js takes an injected `isFound` (`game.isFound`) and marks every cell whose target word is complete with `role="button"` + an `aria-label` — that one attribute is a **three-way contract**: it is the press-feedback hook in style.css (`.cell[role]:active`), the VoiceOver button semantics, and the only thing that says "this cell opens the dictionary card" (UI doc §4-E). Swapping it for a class breaks the first two silently, and both only show up on a real device. The `role` must never appear without the `aria-label`: with just the role VoiceOver reads "A, button" — which says nothing about what pressing it does, and the same letter on the wheel reads "A" (wheel letters are `<div>`s, not buttons — they are `pointer-events: none` and never receive their own `click`, so button semantics would promise an interaction that does not exist). grid.js also exports `snapshotText` (emoji grid for the text-only share — board *shape* only, two colors, no progress; share targets tend to drop text when files are attached, so sharing sends no image) and `snapshotBlob` (canvas PNG for the manual download button — the button is currently `hidden` in index.html, code kept for later: board as spoiler-free colored blocks plus the letter wheel; colors read from CSS variables so it follows the theme). dictionary-card.js also owns `speechSynthesis` pronunciation (`speak`/`stopSpeech`), used by both its speaker button and main.js's auto-pronounce on target hit (§6.1). Its iOS unlock rule is **not** the same as the `<audio>` one below — only a `click` wakes the TTS engine (`touchend`/`pointerdown` were both measured failing) and a drag never produces one, re-locking on every page load. That is why index.html opens with the `#gate` tap-to-continue screen: it is the only guaranteed `click` in normal play, so without it a pure-drag player hears no pronunciation for the whole page load, and the lock returns on every reload. It once carried the sfx unlock too; that half is gone with the sfx, this half is not — do not remove the gate as decoration. Design doc §7 has the measurements, don't re-test.
- [redeem.js](src/redeem.js) — redemption-code verification: JWT (alg pinned to ES256) checked with WebCrypto against the `PUBLIC_KEYS` kid→JWK whitelist; single-use tracked by `jti` in the save. A payload may carry a `uid`, which binds the code to one player (`tools/make-code.mjs --uid`); codes without one keep working for everybody. Codes are minted locally with `tools/make-code.mjs` (private keys in gitignored `tools/keys/` — never commit them).
- [storage.js](src/storage.js) — single-key JSON save. `normalizeSave` is pure: any corrupt/unrecognized data resets to a fresh save. Also owns the player id (`newUid`/`isUid`/`normalizeUid`/`formatUid`; make-code.mjs imports `isUid`/`normalizeUid` to validate the id a player reports) — 12 chars of Crockford Base32, 10 random + 2 salted-checksum, backfilled in `loadSave` alongside `firstOpenAt`. The salt ships with the frontend, so the checksum only stops typos and randomly-typed strings; it is not a defence. Design doc §9 has the rationale (and the redemption-code mechanism the `redeem.js`/`make-code.mjs` comments cite), and the invite-code idea it keeps attracting is answered in design doc §16 — a browser has no device id, so nothing offline can tell a friend's device from an incognito window.
- [bridge.js](src/bridge.js) — platform abstraction (save/load/share/copy/ads/IAP). **Game code must go through `bridge`, never touch `localStorage` or native APIs directly** — this is the one module that gets swapped for native implementations in Phase 2.
- [bridge.js](src/bridge.js) — platform abstraction (save/load/share/copy). **Game code must go through `bridge`, never touch `localStorage` or native APIs directly** — this is the one module that gets swapped for native implementations in Phase 2. It carries only methods with a live caller: the `showAd()`/`buy()` Phase 3 stubs were deleted on 2026-07-29 (zero callers for their whole life, and `buy()`'s bare `Promise.reject()` would have been an unhandled rejection if anything had called it). Design doc §13 keeps the interface *shape* for Phase 3 and records why writing the bodies early bought nothing.
- [strings.js](src/strings.js) — all UI copy in one object. No i18n framework.

[sw.js](sw.js) + [manifest.webmanifest](manifest.webmanifest) at the repo root make it a PWA (design doc §2 for the caching strategy, §16 for why the old "no service worker" call was reversed). The service worker is **stale-while-revalidate for everything, no second rule** — a stale asset self-heals on the next load, which is what makes "forgot to bump `CACHE`" cost one load instead of trapping players on a broken version forever (the revalidate is a plain `fetch`, so GitHub Pages' `max-age=600` delays that heal by up to 10 minutes — design doc §2). Its `SHELL` precache list names every `src/*.js` file: **adding or renaming a module means editing three lists** — the import in `main.js`, `index.html`'s `modulepreload`, and `sw.js`'s `SHELL`. A test in `tests/game.test.mjs` walks `src/` on disk and enforces the latter two against it (it does not parse `main.js`'s imports — an unimported module still has to be listed), because `cache.addAll` is all-or-nothing: one path that 404s — or two that resolve to the same URL, which `addAll`'s single batch rejects with `InvalidStateError` — fails the whole `install`, so the cost of a bad entry is *no offline at all*, reported only in the service worker console. The test covers both: every listed path exists on disk, and no two resolve to the same URL. `SHELL` also carries `assets/licenses.txt` — the license texts have to travel with every copy (design doc §14), offline included. Navigation requests **whose pathname is the scope root** are keyed as `./` on both read and write, so `/?fbclid=…` entry points (tracking params platforms append to shared links) still hit the precached shell offline *and* refresh it (keying writes by full URL would pile up entries that are never read while leaving `./` frozen at install time). The pathname check is not optional — the About section's `assets/licenses.txt` link is also a navigation, and aliasing it to `./` would serve the game HTML instead of the license text *and* overwrite the shell entry with `text/plain`. Root files are published by an explicit `cp` list in `.github/workflows/deploy.yml`: **a new file at the repo root that the site needs must be added there or it simply won't exist in production** — a second test in `tests/game.test.mjs` checks that list against `SHELL`, because the failure is completely silent (`bun run serve` is fine, `register()`'s `.catch()` swallows the 404).
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Wordscapes 風格的英文單字拼圖遊戲:從字母轉盤滑選字母拼出
bun install # 安裝 devDependencies(Biome)
bun run serve # 開發伺服器 http://localhost:8080(ES modules 需經 http,直接開 index.html 不行)
bun run test # 單元測試
bun run lint # Biome 靜態檢查 + 格式檢查(bun run fix 可自動修正)
bun run lint # Biome 靜態檢查 + 格式檢查,warning 也會擋(bun run fix 可自動修正)
bun run check # lint + test,prek 那個 local hook(本地與 CI 都跑它)
```

Expand Down Expand Up @@ -49,7 +49,7 @@ src/
dictionary-card.js 查詢單字卡片
storage.js 單一 key JSON 存檔(normalizeSave 壞資料一律重置)+玩家編號 uid 產生/驗證
redeem.js 兌換碼驗證(JWT ES256 公鑰驗簽;tools/make-code.mjs 簽發,--uid 可綁定單一玩家)
bridge.js 平台抽象層(存檔/分享/複製/廣告/IAP)— Phase 2 換 native 實作
bridge.js 平台抽象層(存檔/分享/複製)— Phase 2 換 native 實作
strings.js 所有 UI 文案
style.css 全站樣式
tools/ 關卡產生 pipeline(fetch-data → build-wordinfo.py → generate-levels.mjs)+ make-code.mjs 兌換碼簽發
Expand Down
Loading
Loading