Repository navigation
Stop rebuilding the 500-button level list, and make speech logging lazy - #90
Conversation
Three separate costs, all paid on paths a player hits repeatedly. - renderLevelList() rebuilt all 500 buttons on every open of the level picker, each with its own click closure just to remember its own id. Now: one delegated listener on #level-list reading data-id, and the render is memoized on `currentLevel/levelCount` — the only two inputs that change what the list looks like. levelCount is in the key because it is 0 until boot()'s index.json lands; keying on currentLevel alone would mark that empty list as drawn and never repaint it. Buttons are built into a fragment and swapped in with replaceChildren(), and the icon span is createElement'd instead of asking the HTML parser to parse the same markup 500 times. - .level-btn gets content-visibility: auto, so unhiding #screen-levels only lays out and paints the dozen buttons actually on screen instead of all 500 (7.3ms -> 2.8ms on the same machine). The memoization above stops the DOM rebuild; it cannot stop the layout pass that comes with display:none -> visible, which is what this covers. contain-intrinsic-size deliberately leaves the inline axis as `none`: a skipped element gets size containment, so an intrinsic width would become its min-content contribution, and the columns are 1fr — i.e. minmax(auto, 1fr) — so it would set a floor under every column. The measured 78px would floor the grid at 5*80 + 4*12 + 2*16 = 470px and overflow horizontally on every phone narrower than .screen's 480px cap. The block axis is the one that matters anyway (scroll length and where scrollIntoView lands). Its 18px is only correct if the line box is pinned, so line-height is 1.125 rather than font-dependent `normal`; 1px per row over 100 rows is 100px of scroll error. min-height: 48px is a backstop — content-visibility and contain-intrinsic-size are independent declarations, and a browser that honours only the first would size skipped buttons to 0 and collapse the list. - dictionary-card's debug log now takes a thunk, and main.js only wires the sink when ?debug is set. The two diagnostics read getVoices() / speaking / pending / paused, which are synchronous trips into the TTS daemon on iOS, and speak() sits on the correct-word hot path — passing built strings meant paying for them on every word just to hand them to a noop. Both halves are needed: the thunk stops argument evaluation, and the DEBUG guard stops a non-null sink from calling the thunk at all (dbg() returning early is too late by then). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoOptimize level picker rendering and lazy-load speech diagnostics
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo
1. style.css adds non-English comments
|
| /* 500 顆按鈕裡同時看得到的只有十幾顆,其餘的版面與繪製(每顆各一層 .icon 的 mask)純屬白算。 | ||
| 省的是「每次開關卡選擇」都要付的那筆:screen 從 display:none 翻回來時 500 顆全部重新算, | ||
| main.js 的記憶化擋得掉重建 DOM,擋不掉這一次——實測同一台機器 7.3ms → 2.8ms。 | ||
| 寬度**一定要 none**,不能填實測的 80−2(border)=78:被略過的元素會進 size containment, | ||
| 那個寬度就變成它的 min-content 貢獻,而欄是 1fr=minmax(auto, 1fr),等於幫每一欄設下 | ||
| 80px 下限——5×80 + 4×12(gap) + 2×16(padding) = 470px,比 .screen 的 480px 上限窄的 | ||
| 每一支手機(390/375/360…)都撐不下,關卡格會橫向溢出、右欄被切掉。寬度交給 grid 決定。 | ||
| 高度才是這裡真正要給的:捲動長度與 scrollIntoView 的落點都靠它。18 是**內容框** | ||
| =48(整顆) − 28(padding) − 2(border),而 48 只有在行高被釘住時才成立——normal 行高隨字型 | ||
| 而異(ui-rounded/Segoe UI Rounded/system-ui 各一個值),所以 line-height 寫死 1.125 | ||
| (16px × 1.125 = 18px)。差 1px × 100 列就是捲軸與「捲到目前關卡」差 100px。 | ||
| 用比例不用 px:玩家把系統字級調大時整顆要跟著長高,min-height 只保底不封頂。 | ||
| 前面的 auto =渲染過一次就記住真實尺寸,估值只在首次掠過時用到。改 padding/border/ | ||
| line-height 時三個數字要一起維護。 | ||
| min-height 是保命索,不是排版:content-visibility 與 contain-intrinsic-size 是兩條獨立 | ||
| 宣告,不會一起被忽略——只認得前者的瀏覽器(Chrome 111 以前不吃 contain-intrinsic-size | ||
| 的 auto 前綴)會把被略過的按鈕算成 0 高,整份清單塌掉。順便也是 48px 觸控目標下限。 */ |
There was a problem hiding this comment.
1. style.css adds non-english comments 📘 Rule violation ⚙ Maintainability
Newly added source-code comments in src/style.css, src/dictionary-card.js, and src/main.js are written in Traditional Chinese, including documentation for .level-btn, lazy speech logging, delegated level selection, memoization, and fragment rendering. Because CSS block comments and JavaScript comments are source-code comments, they violate the English-only requirement.
Agent Prompt
## Issue description
Translate the newly added Traditional Chinese source-code comments in `style.css`, `dictionary-card.js`, and `main.js` into English without changing their technical meaning, including the `.level-btn` sizing and browser-compatibility rationale and the performance rationale in `main.js`.
## Issue Context
PR Compliance ID 2366288 requires all added or modified line and block comments in source files, including stylesheets, to be written in English.
## Fix Focus Areas
- src/style.css[512-528]
- src/dictionary-card.js[15-18]
- src/main.js[156-157]
- src/main.js[609-621]
- src/main.js[627-628]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| // 鎖住的關卡是 disabled,本來就不發 click,不必在這裡再判一次。 | ||
| $('level-list').addEventListener('click', (e) => { | ||
| const b = e.target.closest('.level-btn'); | ||
| if (b) startLevel(Number(b.dataset.id)); |
There was a problem hiding this comment.
2. Locked levels remain clickable 🐞 Bug ≡ Correctness
The delegated handler starts any matching .level-btn without checking disabled; in affected browsers, clicking the icon inside a disabled button bubbles to the list and loads that locked level. Since startLevel() accepts every in-range ID, this bypasses linear progression.
Agent Prompt
## Issue description
The delegated level-list click handler can activate a disabled locked button when a click originating from its child icon bubbles to `#level-list`. Add an enabled-state guard, and ensure the level-loading boundary cannot bypass progression.
## Issue Context
Locked buttons contain child spans, and descendant-click behavior for disabled controls varies by browser. `startLevel()` currently accepts any ID up to `levelCount`, including levels beyond `save.currentLevel`.
## Fix Focus Areas
- src/main.js[612-615]
- src/main.js[321-349]
- src/main.js[639-643]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
Three separate costs, all on paths a player hits repeatedly.
Level picker no longer rebuilds 500 buttons per open
renderLevelList()wiped and rebuilt the whole list every time the pickeropened, giving each button its own click closure just to remember its own id.
#level-listreadingdata-idreplaces the 500closures.
currentLevel/levelCount— the only two inputsthat decide what the list looks like.
levelCountis in the key because itis
0untilboot()'sindex.jsonlands; keying oncurrentLevelalonewould mark that empty list as drawn and never repaint it.
DocumentFragmentand swapped in withreplaceChildren(), and the icon<span>iscreateElement'd instead ofasking the HTML parser to parse the same markup 500 times.
.level-btngetscontent-visibility: autoUnhiding
#screen-levelsused to lay out and paint all 500 buttons (each withits own
.iconmask); now only the dozen actually on screen. 7.3ms → 2.8ms onthe same machine. The memoization stops the DOM rebuild — it cannot stop the
layout pass that comes with
display:none→ visible, which is what this covers.contain-intrinsic-sizedeliberately leaves the inline axis asnone: askipped element gets size containment, so an intrinsic width becomes its
min-content contribution, and the columns are
1fr(=minmax(auto, 1fr)), soit would set a floor under every column. The measured
78pxwould floor thegrid at
5*80 + 4*12 + 2*16 = 470pxand overflow horizontally on every phonenarrower than
.screen's 480px cap.The block axis is the one that matters anyway — scroll length, and where
showLevels()'sscrollIntoView({block:'center'})lands. Its18pxonly holdsif the line box is pinned, so
line-heightis1.125instead of font-dependentnormal(ui-rounded/Segoe UI Rounded/system-uieach give a differentvalue); 1px per row over 100 rows is 100px of scroll error.
min-height: 48pxis a backstop —
content-visibilityandcontain-intrinsic-sizeareindependent declarations, and a browser honouring only the first would size
skipped buttons to 0 and collapse the list. It doubles as the 48px touch-target
floor.
Speech debug logging is lazy, and only wired under
?debugThe two diagnostics in
speak()readgetVoices()/speaking/pending/paused— synchronous trips into the TTS daemon on iOS — andspeak()sits onthe correct-word hot path. Passing built strings meant paying for them on every
word just to hand them to a noop; the
speak() syncmeasurement inmain.jswas partly measuring cost the logging itself created.
lognow takes a thunk, andsetSpeechDebug(dbg)is guarded byDEBUG. Bothhalves are needed: the thunk stops argument evaluation, and the guard stops a
non-null sink from invoking the thunk at all (
dbg()returning early is toolate by then).
Testing
mise run check(Biome + 48 unit tests) passes. Layout and scroll behaviour ofthe level picker are manual-check items (design doc §12): open the picker at a
late level on a narrow viewport and confirm no horizontal overflow and that the
current level lands centred.
🤖 Generated with Claude Code