[{"data":1,"prerenderedAt":361},["ShallowReactive",2],{"content-/etymology-learning-ux-and-settings-sync":3,"all-pages-for-dir":340,"related-/etymology-learning-ux-and-settings-sync":341,"og-image-/etymology-learning-ux-and-settings-sync":360},{"id":4,"title":5,"body":6,"category":321,"concepts":322,"description":323,"extension":324,"meta":325,"navigation":326,"ogImage":322,"path":327,"project_name":328,"published":329,"publishedAt":330,"seo":331,"stem":332,"tags":333,"todo":322,"unpublished":329,"updatedAt":322,"__hash__":339},"pages/2026-07/2026-07-26/etymology-learning-ux-and-settings-sync.md","語源英単語コースの手触りを直した一日 - 音の鳴り方から、設定のユーザー別DB保存まで",{"type":7,"value":8,"toc":312},"minimark",[9,18,21,26,29,37,40,44,47,58,61,64,68,71,74,77,84,87,90,93,96,99,224,227,230,233,240,243,246,253,256,259,263,266,274,277,280,283,305,308],[10,11,12,17],"p",{},[13,14,16],"a",{"href":15},"/etymology-image-rework-completion","語源英単語コースの挿絵洗い直しを完走した記録","の翌日である。前日までは絵を作る側の話だった。今日は、できあがったものを自分でめくって出てきた注文を、片っ端から潰す一日になった。",[10,19,20],{},"朝いちばんは前日の後始末で、残骸のwebp 1,696枚を消し、1件だけ残っていた画像名の不一致をOpus 5のサブエージェントに委任して恒久修正させた。真因は「次回の再実行で再発する構造」ではなく、コンテンツ側のJSONが7/19のエクスポートのまま古かっただけだった。ここまでで前日の仕掛かりは尽き、そこから先が今日の本題になる。",[22,23,25],"h2",{"id":24},"単語だけ聞きたい日と例文まで聞きたい日がある","単語だけ聞きたい日と、例文まで聞きたい日がある",[10,27,28],{},"構造ビューアーを矢印キーで送ると音声が鳴る。ただ、鳴り方が固定だった。語だけ確認したいときに例文まで読み上げられると邪魔だし、逆に例文を耳で流したいときもある。四つのパターン（無音／単語だけ／例文だけ／両方）を、その場で切り替えられるようにしたかった。",[10,30,31,32,36],{},"「自動再生 □単語 □例文」のチェックボックス2つに落とせば四通りが表現できる。既存構造を確認させたところ、ページ本体はビューアーのローダーに委譲されていて、音声は ",[33,34,35],"code",{},"data-gogen-audio"," ボタンへのクリック委譲で鳴っていた。鳴らす経路はすでにあるので、ページ移動のフックからその経路を叩けばいい。",[10,38,39],{},"実装後、dev で四通りとも実機確認させた。ページを送ると単語（words/47）→ 例文（examples/47-1）の順に鳴る。この手の追加でこわいのは、チェックボックスにフォーカスが乗った瞬間に矢印キーが横取りされて、ページ送りが効かなくなることである。フォーカスをチェックボックスに置いたまま矢印を叩かせて、移動と単語のみ再生が両方生きていることを確かめてもらった。",[22,41,43],{"id":42},"_380400500","380、400、500",[10,45,46],{},"同じ流れで、差分ビューアーの画像表示幅も気になった。小さい。400pxに広げさせたが、リロードしても実測はまだ380pxのままだった。",[10,48,49,50,53,54,57],{},"セレクタが当たっていなかった。画像は ",[33,51,52],{},".theater-text"," の配下にいるつもりで書いたルールが、実際には ",[33,55,56],{},".article-body","（articleモード）の配下だったので空振りしていた。祖先チェーンを確認させて追加すると、実測が400pxに変わった。数字で診断していなければ「反映されたことにして」進んでいた場面である。",[10,59,60],{},"そして400pxを見て、これでも足りないと思った。500pxに変えてデプロイし直した。幅は議論しても仕方がなく、出して、見て、決める。",[10,62,63],{},"この日はデプロイを4回打っている。1回7〜10分かかるので、投げてから戻るまでの間に次の実装を進めることになる。完了通知が来たら本番の実物を見に行き、変更が乗っているかを確かめてから次に移る、という往復を繰り返した。",[22,65,67],{"id":66},"間違えるということはイメージがついていない","間違えるということは、イメージがついていない",[10,69,70],{},"英単語のトレーナーには、日本語を見て英語を答える形式がある。間違えると、正解と語源の解説が出る。ここに例文と情景画像も出してほしい、と注文を出した。ただし間違えたときだけ。",[10,72,73],{},"理由ははっきりしていた。間違えるということは、その語のイメージがついていないということだ。挿絵を1,576枚も作り直したのは、まさにそのイメージを絵で持たせるためだった。正解した語にまで絵を出せば画面が重くなるだけだが、外した語には絵が要る。誤答は「この語だけ絵が足りていない」という申告として使える。",[10,75,76],{},"word データの例文構造と既存のハイライトユーティリティを確認させてから、不正解時のフィードバックに例文と例文画像を差し込ませた。lintクリーン、テスト39件通過。dev でわざと誤答してみせてもらい、正解・語源・例文・情景画像が縦に並ぶところまで見た。",[10,78,79,80,83],{},"トレーナー側には、この直前に単語の自動再生も入れている。こちらは ",[33,81,82],{},"mountSuspended"," + happy-dom でコンポーネントテストまで書かせた。日→英の形式では出題時に音を鳴らさない（答えが音で漏れる）という条件があるので、鳴らない側も含めて固定した。",[22,85,86],{"id":86},"設定はブラウザの中にしかなかった",[10,88,89],{},"ここまでで、自動再生の可否、出題形式、出題数と、設定が一日で増えた。そして全部 localStorage に置いてあった。ログインの仕組みはとうに動いているのに、設定だけブラウザに閉じ込めたままだったわけだ。端末を変えれば全部リセットされるし、そもそも「この人がどう解いているか」がサーバー側から一切見えない。",[10,91,92],{},"コンテキストが残り少なかったので、実装は次に回して計画だけ立てさせることにした。ログイン済みユーザーの設定をDBに持ち、未ログインでも従来どおり使える形にする。",[10,94,95],{},"計画書はCodexにレビューさせたが、これが一度で通らなかった。往復は4回になった。",[10,97,98],{},"1回目は同期の順序。サーバーから初期値を取り終わる前にローカル値を書き戻すと設定が消えるし、PUTが失敗したまま黙ると巻き戻る。初期GETの完了まで書き戻し禁止、dirty/pending を持ってPUT成功まで再送、サーバー値を反映するのは「まだ触っていないキー」だけ、という状態遷移を計画に書き足した。",[100,101,106],"pre",{"className":102,"code":103,"language":104,"meta":105,"style":105},"language-ts shiki shiki-themes vitesse-light vitesse-light","// サーバー値で上書きしていいのは、ユーザーがまだ触っていないキーだけ\nconst applyServer = (server, dirtyKeys) =>\n  Object.fromEntries(\n    Object.entries(server).filter(([key]) => !dirtyKeys.has(key)),\n  )\n","ts","",[33,107,108,117,151,166,218],{"__ignoreMap":105},[109,110,113],"span",{"class":111,"line":112},"line",1,[109,114,116],{"class":115},"sxvE3","// サーバー値で上書きしていいのは、ユーザーがまだ触っていないキーだけ\n",[109,118,120,124,128,132,135,139,142,145,148],{"class":111,"line":119},2,[109,121,123],{"class":122},"stQ0i","const ",[109,125,127],{"class":126},"senZ8","applyServer",[109,129,131],{"class":130},"shFtX"," =",[109,133,134],{"class":130}," (",[109,136,138],{"class":137},"s4oTP","server",[109,140,141],{"class":130},",",[109,143,144],{"class":137}," dirtyKeys",[109,146,147],{"class":130},")",[109,149,150],{"class":130}," =>\n",[109,152,154,157,160,163],{"class":111,"line":153},3,[109,155,156],{"class":137},"  Object",[109,158,159],{"class":130},".",[109,161,162],{"class":126},"fromEntries",[109,164,165],{"class":130},"(\n",[109,167,169,172,174,177,180,182,185,188,191,194,197,200,203,206,208,211,213,215],{"class":111,"line":168},4,[109,170,171],{"class":137},"    Object",[109,173,159],{"class":130},[109,175,176],{"class":126},"entries",[109,178,179],{"class":130},"(",[109,181,138],{"class":137},[109,183,184],{"class":130},").",[109,186,187],{"class":126},"filter",[109,189,190],{"class":130},"(([",[109,192,193],{"class":137},"key",[109,195,196],{"class":130},"])",[109,198,199],{"class":130}," =>",[109,201,202],{"class":122}," !",[109,204,205],{"class":137},"dirtyKeys",[109,207,159],{"class":130},[109,209,210],{"class":126},"has",[109,212,179],{"class":130},[109,214,193],{"class":137},[109,216,217],{"class":130},")),\n",[109,219,221],{"class":111,"line":220},5,[109,222,223],{"class":130},"  )\n",[10,225,226],{},"2回目は、リロードを挟むと未送信の変更が消える点（永続outboxにする）、localStorage をユーザー単位に分けないと別アカウントの設定が混ざる点（旧キーからの移行フォールバック付き）、そして非同期応答が順序を追い越したときのための世代ガード。3回目は同一キーへの並行PUTの順序逆転。ここまで潰して4回目で通過した。",[10,228,229],{},"引き継ぎプロンプトをターミナルに出し、計画書をコミットして、そのセッションは閉じた。",[22,231,232],{"id":232},"別ブラウザから入っても同じ設定が戻る",[10,234,235,236,239],{},"セッションを切り直して、その計画書をそのまま実装に入らせた。マイグレーション0008で設定テーブルを作り、GET/PUTのAPIを足し、設定の読み書きを共通のコンポーザブル経由に寄せる。未ログインは ",[33,237,238],{},"anon"," スコープでブラウザ内に持ち、ログインするとその値がDBへ持ち上がってマージされる。",[10,241,242],{},"実機確認でつまずいたのは、確かめる相手がいないことだった。ログインはふだんGoogle経由なので、実D1でのラウンドトリップを試す手段がない。dev モードのときだけメール登録フォームが生きていたので、テスト用アカウントを作って通した。設定画面をリロードすると、未ログイン時に触っていた値（出題数33、形式、出題順、自動再生）が丸ごとDBへPUTされ、ログイン後の初回マージが計画どおりに走った。",[10,244,245],{},"続けて、localStorage を空にした状態から同じアカウントで入り直させた。DBから出題数55が戻り、ユーザースコープのキャッシュに書き込まれる。「別ブラウザで同じ設定が復元される」がこれで実証になった。終わったらテストユーザーは消してログアウトさせた。",[10,247,248,249,252],{},"途中、ユニットテストが12ファイル落ちた。原因は今回の変更ではなく、Vitest 4 に上げたときに ",[33,250,251],{},"maxWorkers: 1"," の指定が消えていたことだった。前から壊れていた設定を、今日たまたま踏んで直した形になる。最終的に2929件が通った。",[10,254,255],{},"コミットの前には学習ゲートが立つ。今回は既存概念に「ユーザー別の設定同期」に当たるものがなかったので、新規5概念で出題させて5問とも答えた。順序の話ばかり詰めた直後だったので、聞かれる側もそこに寄っていて、答えながら計画書の内容を一度なぞり直す形になった。デプロイ履歴CSVへの追記だけは機械的な記録なので、理由を書いてスキップした。",[10,257,258],{},"本番は、D1に0008を適用してからデプロイ。直後は設定APIだけ404を返した。デプロイの失敗を疑いかけたが、5秒待って叩き直したら401（未認証）が返ってきた。Cloudflare側が新しいルートを認識するまでの時間差だった。切り分けに走る前に、もう一度叩く。",[22,260,262],{"id":261},"不正解の3文字を消す","「不正解」の3文字を消す",[10,264,265],{},"本番反映まで終わってから、トレーナーで間違えたときの表示が気になった。",[100,267,272],{"className":268,"code":270,"language":271},[269],"language-text","× 不正解　正解：観点、遠近法\n","text",[33,273,270],{"__ignoreMap":105},[10,275,276],{},"これが1行に詰まっている。まず「不正解」の文字が要らない。×が赤い時点で外したことは分かっているので、同じことを二度言われている。そして本当に見たいのは正解のほうなのに、それが行末で小さく潰れている。",[10,278,279],{},"×だけを1行目に置き、改行して、次の行に正解を黒の太字で大きく出すように直させた。足すのではなく削る修正である。dev でわざと誤答して描画を確認させたら、選択直後にQ2へ進んでしまって、スクロールで戻って見ることになった。×が単独行、次行に「正解 側面、局面」。これで読める。",[22,281,282],{"id":282},"学び",[284,285,286,290,293,296,299,302],"ul",{},[287,288,289],"li",{},"実測しないと「反映されたつもり」が通る。幅の指定が効かなかったのは、描画幅を数字で測らせたから見つかった",[287,291,292],{},"誤答は情報として使える。間違えた語にだけ絵と例文を出すのは、画面を増やさずに手当てを厚くする方向の変更だった",[287,294,295],{},"表示の改善は、要素を足すより減らすほうが早いことがある。「不正解」の3文字を消して改行しただけで読めるようになった",[287,297,298],{},"設定の同期は、書き込みの順序で壊れる。レビューで出てきた指摘4件は全部「いつ書くか」「どのキーを上書きするか」の話だった",[287,300,301],{},"デプロイ直後の404は待つと消えることがある。新しいルートを本番が認識するまでの数秒を、実装の失敗と読み違えかけた",[287,303,304],{},"テストの失敗が全部自分の変更のせいとは限らない。今回落ちた12ファイルは、並列実行の設定が消えたまま放置されていた分だった",[10,306,307],{},"一つ残った。地図側のビューアーの自動再生設定だけ、まだブラウザの中にいる。今日のDB同期から漏れているのを実装の途中で見つけたので、次の計画書に書き残してある。あわせて、間違えた語だけを集めて解き直すページの計画も立てた。間違える＝イメージがついていない、という今日の仮説が本当かどうかは、その一覧が動き出してから確かめることになる。",[309,310,311],"style",{},"html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html pre.shiki code .stQ0i, html code.shiki .stQ0i{--shiki-default:#AB5959;--shiki-dark:#AB5959}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":105,"searchDepth":119,"depth":119,"links":313},[314,315,316,317,318,319,320],{"id":24,"depth":119,"text":25},{"id":42,"depth":119,"text":43},{"id":66,"depth":119,"text":67},{"id":86,"depth":119,"text":86},{"id":232,"depth":119,"text":232},{"id":261,"depth":119,"text":262},{"id":282,"depth":119,"text":282},"dev",null,"矢印キーでのページ移動時に単語・例文の音声を鳴らし分けるチェックボックスを付け、誤答のときだけ例文と情景画像を出すようにした。あわせてブラウザ保存だった設定をログイン済みユーザー単位でDBに移し、別ブラウザでも同じ設定が戻る状態にするまでの記録。","md",{},true,"/etymology-learning-ux-and-settings-sync","eurekapu-nuxt4",false,"2026-07-26T00:00:00.000Z",{"title":5,"description":323},"2026-07/2026-07-26/etymology-learning-ux-and-settings-sync",[334,335,336,337,338],"gogen-eitango","Nuxt","D1","UI改善","Codexレビュー","qygw0Sr4lYlkx6sd5598zdEIK2h9cxVN8jg0tJpJf_E",[],[342,346,349,352,356],{"title":343,"path":344,"publishedAt":345},"苦手な語だけを解き直すページを作った日 - 正解1回で消える穴と、自動再生設定のD1同期漏れ","/weak-words-page-and-d1-settings-sync","2026-07-27T00:00:00.000Z",{"title":347,"path":348,"publishedAt":330},"インタラクティブ教材を1枚に集約し、本文の線を減らした日","/interactive-content-consolidation",{"title":350,"path":351,"publishedAt":330},"本番切替の前に移行先サイトを仕上げた日 — 画像の謎、フォームの文字化け、トップページの作り直し","/wordpress-site-final-polish",{"title":353,"path":354,"publishedAt":355},"簿記学習サイトのクイズ入口をハブ化 — 問題文UIから矢印キー移動まで一気に整理した","/bookkeeping-quiz-hub-redesign","2026-07-25T00:00:00.000Z",{"title":357,"path":358,"publishedAt":359},"gogen-eitango Phase 4: D1本番適用と章末ミニテストの4択化 — Fable 5のrate limitをCodex引き継ぎで乗り切った日","/gogen-eitango-phase4-d1-quiz-multichoice","2026-07-16T00:00:00.000Z","https://log.eurekapu.com/og/blog/etymology-learning-ux-and-settings-sync.png?v=2026-07-26T00%3A00%3A00.000Z&title=%E8%AA%9E%E6%BA%90%E8%8B%B1%E5%8D%98%E8%AA%9E%E3%82%B3%E3%83%BC%E3%82%B9%E3%81%AE%E6%89%8B%E8%A7%A6%E3%82%8A%E3%82%92%E7%9B%B4%E3%81%97%E3%81%9F%E4%B8%80%E6%97%A5%20-%20%E9%9F%B3%E3%81%AE%E9%B3%B4%E3%82%8A%E6%96%B9%E3%81%8B%E3%82%89%E3%80%81%E8%A8%AD%E5%AE%9A%E3%81%AE%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E5%88%A5DB%E4%BF%9D%E5%AD%98%E3%81%BE%E3%81%A7&author=Kei%20Komatsu&sig=3fb318a04e12aa97",1785380006629]