name: gkill-client-foundation description: "gkill フロントエンド(src/client/)全域の約束。他の gkill-client-* スキルに該当するかに関わらず src/client を触るなら読む。コンポーザブル規約と CRUD 中継束(kyou-view-relay.ts)、Kyou の再読込手順(kyou-reload.ts)、タグ変更は updated_kyou を出さない、コンテキストメニュー位置・チェックツリー単一パス・端末種別・自動フォーカス・行高さ・HTML リテラル禁止・設定の適用、Web Share Target の二重保存対策、check_auth のログイン画面ガードを扱う。src/client/ の .vue / classes/*.ts を編集・新規作成するとき必読。「タグを足しても表示が変わらない」「Android 共有が2件保存される」の調査でも必読。"
クライアント全域の約束(src/client/)
対象: src/client/** すべて。他の gkill-client- スキルに該当する場合も、このファイルの約束はすべて効く。*
このファイルは全文が、実際に起きた事故の再発防止である。該当作業では飛ばさずに読むこと。 多くは「例外もエラーも出さずに静かに壊れる」種類で、破っても目の前ではエラーにならない。
Frontend (Vue 3 + TypeScript) — src/client/
Stack: Vue 3 + Vuetify 4 + Vue Router 5 + vue-i18n 11 + Vite 8 + TypeScript 6 + PWA (vite-plugin-pwa + Workbox)
router/index.ts— 13 page routes (login, kftl, mi, rykv, kyou, mkfl, playing, saihate, dashboard, rudbeckia, set_new_password, register_first_account, shared_page) + 2 redirect-only routes(/regist_first_account→/register_first_account、/shared_mi→/shared_page。どちらも query を引き継ぐ)。旧パスの吸収は redirect でやること ―― コンポーネントの setup からrouter.replaceすると、<script setup>に top-level await があるページでは初回ナビゲーションが完了しなくなる(/shared_miが実際にそうなっていた。share_id 無しで throw して setup ごと落ちていたため、redirect が一度も走らず露見していなかった)pages/views/— 202 view components,pages/dialogs/— 116 dialog components (Escape key closes viauseFloatingDialog), including ZIP contents browser, plugin HTML views (plugin-html-view.vue,plugin-html-context-menu.vue,plugin-config-dialog.vue), and Dnote trend/correlation graph components (client-side aggregation, no server API)classes/api/gkill-api.ts— SingletonGkillAPIclass (~3,400 lines), client-side API wrapperclasses/cascade-delete-kyou.ts— cascade delete for Kyou. The attached Tag / Text / Notification and the ReKyou / MiReKyou that reference the Kyou are looked up in reverse viaGetReKyousByTargetID/GetMiReKyousByTargetIDand logically deleted together with it. Depth cap 32 (max_cascade_depth), 16 lookups in flight per level (request_chunk_size). Discovery runs to completion before any write (deleting the Kyou first makes the server'sFindKyousdrop the referencing records from its results, so the reverse lookup can no longer find them); if discovery fails nothing is written. All updates are staged with onetx_idand confirmed bycommit_tx(gkill-tx.tsrun_in_tx, 2026-09-15, ADR-0410):commit_txis one SQLite transaction (ADR-0219), so either everything is deleted or nothing is —deleted_idsis every visited id on success and empty on failure. On failure: ERR900093cascade_delete_depth_exceeded/ ERR900094cascade_delete_failed, i18n keyFAILED_DELETE_KYOU_NOTHING_DELETED_MESSAGEserviceWorker.ts— PWA service worker (Workbox precaching, POST caching, push notifications, Web Share Target;/zip_cache/.*on NavigationRoute denylist)
State management: Props/Emit only. No Pinia/Vuex. GkillAPI singleton for backend communication. Pinia/Vuex を入れない理由と却下案は ADR-0408。
Android共有(Web Share Target)の二重保存対策(2026-08-16)。POST /share-target は serviceWorker.ts が
respondWith で丸ごと処理し、その場で add_urlog / add_kmemo を叩いてから /saihate へ 303 で送る。
Android はタスク(アプリ履歴)から復帰すると同じ共有インテントを再配送するので、初回とビット単位で同じ
multipart POST がもう一度届き、素直に保存すると2件目ができる。台帳は classes/share-target-dedup.ts。守るべき約束:
- 再配送と意図的な再共有は内容から区別できない。 見分ける手は「保存済みの内容を覚えておく」以外に無いので、
台帳(
gkill-share-dedup-cacheの/__gkill_share_dedup/ledger、直近100件・24時間)と内容の完全一致で判定する - 台帳へ載せるのは保存が成功したときだけ。 応答を見ずに載せると、保存できていないのに次の共有が24時間弾かれる
(
is_successful_gkill_responseで HTTP ok とerrors空を確認する) - 重複は黙って捨てず最果てで確認を出す(
confirm-save-duplicated-shared-data-dialog.vue)。 黙って捨てると意図的な再共有が理由なく消える - 「それでも保存する」も
/share-targetを通す(フォームにgkill_forceを立て、応答は 303 ではなく JSON)。 ページ側でadd_urlog/add_kmemoを組み立て直すと保存が2実装に割れる - 共有由来のクエリ(
is_saved/share_result)は最果てに入った瞬間に落とす。 残したままアプリ履歴から 開き直されると「保存しました」がもう一度出て、保存していないのに二重保存に見える - 台帳は Kyou キャッシュとは別のキャッシュに置く(
activateがKYOU_CACHE_NAMEを丸ごと消すので、 同居させると版が上がるたびに台帳が飛ぶ) - 守るテスト:
share-target-dedup.test.ts
Composable pattern: ロジックを持つ view/dialog は必ず対応する classes/use-*.ts にコンポーネントのロジックを置き、.vue の <script setup> は「import・defineProps・defineEmits・コンポーザブル呼び出しの分割代入・defineExpose」だけにする(dialogs は 116/116 がこの形)。例外はロジックが1行も無いコンポーネント ―― props と emits を宣言してテンプレートから直接 emit するだけのsidebar-header.vue / manage-share-task-list-view.vue / confirm-delete-share-task-list-view.vue は、空の return {} を返すだけのコンポーザブルを置かない(実際に3本そういう死んだファイルが残っていた)。 Props and emits are defined in -props.ts / -emits.ts files alongside the .vue file (many components share a common props/emits type instead of having their own pair). Template refs to child components use the ComponentRef type (classes/component-ref.ts). Never hand-write the CRUD relay handler bundle that only forwards child events to the parent: call build_kyou_view_relay(emits, overrides?) (view layer, 18 events) or build_kyou_dialog_relay(emits, overrides?) (dialog layer, those 18 plus focused_kyou / clicked_kyou = 20 events) from classes/kyou-view-relay.ts, assign it as const crudRelayHandlers = build_kyou_view_relay(emits), and pass it to the template as v-on="crudRelayHandlers" (one line replaces the whole @… list). Swap out only the events whose behavior differs via overrides. Of the 21 events in KyouViewEmits, requested_close_dialog is not relayed (the dialog wires it to itself with @requested_close_dialog="hide()"), and focused_kyou / clicked_kyou are not relayed by the view layer (a nested view would fire them twice — only the dialog layer relays them). When adding an event, add it to both KyouViewRelayArgs and kyou_view_relay_event_names; adding it to only one breaks the build through the Exclude exhaustiveness check. どちらの束を使うかの基準は「ダイアログかどうか」ではなく 「自分がフォーカスの発火源かどうか」。子が上げてきた focused_kyou / clicked_kyou を素通しするだけの中間層(dnote-item-list-view 等)は自分では発火しないので build_kyou_dialog_relay が正しい(名前に dialog と付いているせいで誤読されやすい)。ページ最上位の RykvDialogHost には build_kyou_dialog_host_handlers(required, overrides?) を使う ―― ページには emit 先の親がいないので未指定イベントは no-op で埋まるが、updated_kyou / deleted_kyou / requested_reload_kyou / requested_open_rykv_dialog / closed の5件は型で必須にしてあり、書き忘れるとコンパイルエラーになる。__tests__/unit/classes/relay-bundle-source-scan.test.ts が「v-on で渡した束と @中継イベント を同じ要素に併記していないか(両方登録されて二重に発火する)」をソース走査で検査する。
Kyou の再読込: classes/kyou-reload.ts の refresh_kyou(kyou, query?) / refresh_kyou_in_list(list, kyou, options?) を使い、手順を手書きしない。正しい手順は「SWキャッシュ削除 → reload(true, query) → is_typed_data_loaded = false → load_all(query, true)」の4つで、1つでも欠けると引き直しに失敗する。とくに load_all の第2引数(force_attached)を落とすと、Kyou.clone() が is_attached_tags_loaded を引き継ぐせいで InfoBase.load_attached_tags(false) が早期returnし、添付タグを一度も引き直さない(「タグを足しても表示が変わらない」の正体)。書き戻し方は呼び出し元が選ぶ。既定の in-place splice を使うこと ―― model_value が親の配列そのものになるダイアログで必須なだけでなく、rykv/mi の列でも必須。列の配列を作り直すと focused_kyous_list(= match_kyous_list[focused_column_index] へのエイリアス)が黙って切れ、件数カレンダーと Dnote がフォーカス列に追随しなくなる(2026-08-18 に rykv/mi を replace から in-place へ戻した。守るテストは rykv-view-search-routing.test.ts の「reload_kyouは列の配列を作り直さない」)。replace による copy-on-write が正しいのは、配列そのものを Ref に持ち他からエイリアスされない dashboard / playing / shared-mi だけ。書き戻す位置は refresh_kyou_in_list が await のあとに id で取り直す(待っている間に局所挿入や削除でリストが動くため、待つ前のインデックスで splice すると別の行を潰す)。
引き直しの合流には new_reload_batch() の値を渡す。1回の更新でリスト・focused・開いているダイアログが独立に引き直すので、reload_kyou の先頭で1回だけ取って全部に同じ値を渡すと往復が1回で済む(refresh_kyou の第3引数 / refresh_kyou_in_list の options.requested_at)。渡さないと呼び出し時刻が使われ、飛行中の引き直しには相乗りしない。無条件に合流させてはいけない: 先行して飛行中の引き直し(かつては open_rykv_dialog がダイアログを開くたびに投げていた)に保存後の引き直しがぶら下がると、更新前の Kyou を掴んで列・focused・ダイアログを一斉に古い内容へ戻す(「ダイアログを閉じたら一覧が更新されない」の正体)。ダイアログを開いたときには引き直さない(2026-09-14)―― アプリ内の編集は updated_kyou / requested_reload_kyou で行が既に最新化されており、id キーの「引き直し中」表示が同じ Kyou を出している一覧の行にも点いて「ダイアログを開くと親の一覧が読み込み中になる」ように見えていた。守るテストは use-kyou-list-view-dialog.test.ts の「開いた直後には引き直さない」。 列とダイアログのループは同期で全部呼び出してから Promise.all で待つ(use-rykv-view.ts / use-mi-view.ts の reload_kyou)。refresh_kyou は最初の await より前に飛行中の表へ登録するので、同じ tick で呼べば2本目以降が合流する。1本ずつ await すると先発が決着して表から消えたあとに次が始まり、例外もエラーも出ないまま対象が載っている列の数だけフルの往復が増える(rykv-view-search-routing.test.ts / mi-view-search-routing.test.ts の「同じtickで全部呼び出す」が守る)。
実行中 TimeIs は発生元が読み込み済みのときだけ引き直す(fetch_refreshed_kyou の load_all(query, true, { include_timeis }))。付随データの中で唯一「検索」(/api/get_kyous、SW キャッシュ対象外)で、一覧の行は表示しない(show_attached_timeis=false)のに、以前は行の引き直しのたびに撃っていた。読んでいなければ is_attached_timeis_loaded=false のまま返り、詳細ペイン・ダイアログの KyouView の遅延読み込みが必要なときに取る。タグ/テキスト/通知は従来どおり無条件に強制引き直しする(ここを条件付きにすると「タグを足しても表示が変わらない」が戻る)。経緯は ADR-0218。 版履歴(Kyou.load_attached_histories)は load_attached_datas / load_all / 引き直しで先読みしない(2026-09-14)。attached_histories を読むのは履歴ダイアログ(use-kyou-histories-view.ts)だけで、そこは開いたときに自前で引く。先読みしていたころは引き直し1回につき /api/get_kyou(版履歴を集める、往復の中でいちばん重い API)が reload() と合わせて2回飛んでいた。
引き直しは呼び出し元のダイアログや行より長生きさせる。fetch_refreshed_kyou は作業用クローンに専用の AbortController を入れ直しており、KyouView の onUnmounted abort で保存直後の引き直しが道連れになることはない。引き直し中は is_kyou_reloading(id) が真になり、KyouView が中身を残したまま右上にスピナーを重ねる(.kyou_reloading。中身を差し替える show_loading_indicator とは別物で、消すと行がちらつき詳細ビューでは高さが跳ねる)。状態は id キーのモジュールレベルに置く ―― KyouView は id が同じなら再マウントされず props が差し替わるだけなので、コンポーネントローカルに持つと倒せない。
タグ/テキスト/通知の変更は updated_kyou を出さない。 唯一の信号が requested_reload_kyou なので、Kyou を抱えて表示するコンポーネントは必ずこれを処理すること。
ログイン画面ではセッション無効の飛ばしを止める(is_on_login_page、gkill-api.ts の check_auth)。
check_auth はセッション無効系のエラーコード(ERR000013 / ERR000002 / ERR000238 / ERR000373)を見つけると
clear_browser_datas() してから location.replace("/") する。ところがログイン失敗も同じコード帯を通る
(存在しないユーザIDは ERR000002 AccountNotFoundError、無効化されたアカウントは ERR000238)。
素直に飛ばすとページごと作り直され、login-page.vue がいま出したばかりのエラー表示が消える。
利用者からは「画面が一瞬光って、理由も出ないまま元のまま」に見える。
- 行き先は同じ
/なので、飛ばさないことで失うものは無い。 セッションIDのクリアだけは行う - 判定は
pathname === '/'(と空文字)だけ。共有ページを足してはいけない ―― 共有ページは セッションではなくshare_idで認証するので、そもそもこのコード帯を通らない - ガードは
location.replaceの手前に置く。順序はcheck-auth-login-page.test.tsがソース走査で固定する (check_authの本体を切り出し、ガードの位置が replace より前であることを添字で比べる) - 守るテスト:
check-auth-login-page.test.ts/e2e/login.spec.tsの 「login with invalid credentials shows error」
UI 実装の共通規約
Context menus: never compute the popup position by hand. Call useContextMenuPosition() (classes/use-context-menu-position.ts) for is_show / menu_target / open_at(e), and bind the template as <v-menu v-model="is_show" :target="menu_target" location="bottom start">. Vuetify's connected location strategy measures the rendered menu and flips/shifts it to stay inside the viewport. The previous approach copy-pasted left: min(innerWidth - 130, x); top: min(max(50, innerHeight - (8 + 48 * N)), y) into all 25 context-menu composables — the 130px width was unrelated to the real list width, and N had to be hand-synced with the template's item count (the struct-family menus sat at N=2 while actually having 5 items). .gkill_context_menu_list { max-height: 70vh; overflow-y: scroll } in App.vue still caps very long menus. 手計算をやめた経緯(実測しない限り正しい定数は書けない)は ADR-0409。
チェックツリーへの適用は単一パスで: サイドバーのチェック状態をツリーへ書き戻すときは classes/foldable-struct-check.ts の apply_check_state_to_struct(root, items, state, pre_uncheck_all) を使い、「項目1つごとにツリー全体を再帰走査」を書かない。旧実装は O(項目数×ノード数) で、全ノード訪問が Vue の deep reactive proxy 越しになるため、rep 376個の実環境では列フォーカス切替の click が同期6.5秒(トレース実測)に達していた(2026-08-10 修正。rep/tag/timeis の3コンポーザブルと apply_rep_summary_to_detaul が対象。旧実装との等価性は foldable-struct-check.test.ts が担保)。なお InfoBase 系データクラスに TS private フィールドを足してはいけない —— ref() の UnwrapRef が private を落とし Ref<Array<Kyou>> への代入が全所で型エラーになる(ES # も reactive Proxy 越しの this で壊れる)。内部フィールドは underscore 公開 + getter/setter にする。
端末種別の判定: 'ontouchstart' in window || navigator.maxTouchPoints > 0 を手書きしない。useDeviceKind()(classes/use-device-kind.ts)の device_kind / is_pc / is_tablet / is_smart_phone / has_touch を使う。ドラッグ&ドロップの可否は is_pc、タッチ端末向けの代替導線(ロングプレスでの contextmenu 補完など)は has_touch と、見る値が違う。以前は「タッチできるか」の2値を両用途に兼用していたため、タッチパネル搭載 Windows ノートで D&D が無効になっていた。判定は「UAでスマホ確定 → any-pointer: fine かつ any-hover: hover なら PC → UAでタブレット確定 → 画面短辺600px」の順で、この順序自体が仕様(iPad + トラックパッドは PC 扱い、スタイラス対応スマホはスマホ扱い)。状態はモジュールレベルのシングルトンで、foldable-struct.vue のようにノード数ぶん再帰インスタンス化されるコンポーネントから呼んでも購読は2本のまま。純関数 classify_device_kind(env) が判定の実体で、テストはここを突く。D&D を無効化してもコンテキストメニューの「上へ / 下へ / フォルダへ移動」(classes/foldable-struct-move.ts)で操作できる。
ダイアログの自動フォーカス: ダイアログを開いたら本文の最初のテキスト入力欄にカーソルが載る。実装は useFloatingDialog の autofocus オプション(既定 true)1箇所で、候補選びは classes/dialog-autofocus.ts の純関数 find_autofocus_target()。view 側に autofocus を撒いてはいけない ―― 入力欄を持つ view はサイドバーやページ直下でも使われるので、ページ読込時にフォーカスを奪う。探索は .gkill-floating-dialog__body の中だけ(ヘッダには透過トグルの v-checkbox と×が必ず先頭にあり、ルートから探すと必ずそれを掴む)。既に autofocus を書いた要素があるダイアログでは何もせず Vuetify に任せる。readonly(日付ピッカーの見せかけ入力)・disabled・非表示・.v-selection-control 配下・inputmode="none" の v-select は候補から外す(v-autocomplete / v-combobox は打ち込めるので対象)。入力欄が内側の v-if で遅れて生えるダイアログのために MutationObserver で2秒だけ見張り、一度当てたら切る。自前でフォーカス先を決めているダイアログ(save-clipboard-to-file-dialog は保存ボタン)は autofocus: false で切る。
行判定の高さにパーセントを渡さない: classes/kyou-row-height.ts の is_row_height() は高さを Number.parseFloat して 120 未満なら一覧の行とみなす。parseFloat('80%') は 80 なので、詳細ペインやダイアログでパーセントを渡すと行扱いになり、mi-re-kyou-view.vue の参照先ブロック(v-if="!is_compact")が丸ごと消える。行ではない場所は 'unset' か 'auto' を渡すこと。例外は画像一覧(kyou-list-view.vue の is_image_only)だけで、200pxのセルに詰めるため意図的に '100%' を渡している。__tests__/unit/classes/kyou-view-height-source-scan.test.ts がソース走査で守る。
表示用の文字列に HTML タグのリテラルを埋めない(2026-08-21)。表示側は {{ }} 補間で Vue がエスケープするので、剥がし忘れた画面ではタグが文字としてそのまま見える。実例: classes/format-date-time.ts の format_duration() が "23時間 6分 <br>(23.1時間)" を返し、3つの描画点が銘々 .replace("<br>", …) で剥がす一方、Dnote の集計リスト(aggregated-list-item.vue)と相関グラフ(dnote-correlation-graph-view.vue の point_description)は剥がしておらず <br> が見えていた。
- 区切りが要るなら本物の改行(
format-date-time.tsのDURATION_LINE_SEPARATOR)を使う。white-spaceが既定のままの場所では空白1個へ畳まれるので、剥がし忘れの壊れ方が「今までどおり1行で出る」に変わるのが要点 - 改行として見せたい場所だけが
white-space: pre-lineで opt-in する(今はaggregated-list-item.vueの値 span1つだけ)。値が2行になると<td>既定のvertical-align: middleで前後の prefix/suffix が中間へ浮くので、同じ scoped style でtopに揃えてある - 1行で見せる場所は
to_single_line()に通す。.replace(文字列, …)を手書きしない(最初の1個しか置換しないので区切りが増えると黙って壊れる)。相関グラフの SVG<title>はネイティブのツールチップとして改行を実際に描画するので、ここだけは CSS 任せにできず畳む処理が必須 - 守るテスト:
format-date-time.test.ts/aggregate-targets.test.ts(TimeIs の合計・平均)/convention-source-scan.test.tsの「表示文字列に HTML タグのリテラルを埋めていない」「集計リストの値が pre-line で改行を見せている」。走査の対象はclasses/**/*.tsだけ(.vueのテンプレートにはtime-is-view.vueの<br />のような正当な用例がある)
設定は「適用」を押すまでサーバへ送らない: ApplicationConfig の子ダイアログ(tag/rep/rep_type/device/kftl_template/mi_board の struct 系6つ + Dnote / Ryuu / Dashboard / PlayingTimeIs)の「適用」は組み立てだけで、API を1つも呼ばない。送信は use-application-config-view.ts の update_application_config() の1箇所に閉じている。子の適用先は cloned_application_config だけで、props.application_config を直接書いてはいけない(書くと設定画面のキャンセルが効かなくなる)。props の差し替え(板ツリー/タグツリーの追随)で未適用の編集が消えないようにするのは has_pending_child_edits の役目。ロケールとダークテーマは選ばせるために即時プレビューするので、cancel_pending_changes() で開いた時点の値へ戻す(×・Escape・キャンセルのどれでも通るよう application-config-dialog.vue の hide() から呼ぶ)。例外は ServerConfigDialog ―― 別エンティティ(update_server_config)なので自分の「適用」で送る。
関連スキル
- gkill-client-columns — rykv/mi/dashboard の列と検索
- gkill-client-kftl — KFTL(メモ帳)
- gkill-client-tags — 追加/編集画面のタグ欄
- gkill-client-rudbeckia — ポートとフローティングウィンドウ
- gkill-plugin — プラグイン本文(
plugin-html-view.vue)の iframe 注入の正本 - gkill-find-query —
FindQueryの null 意味論(TS 側のundefined禁止を含む)
詳しい設計と却下案(ADR)
Next.js App Router Expert
Development
A skill that turns Claude into a Next.js App Router expert.
README Generator
Development
Creates professional and comprehensive README.md files for your projects.
API Documentation Writer
Development
Generates comprehensive API documentation in OpenAPI/Swagger format.