refactor(ui): #176 B 案 PR 2 — qr-ticket inline style 撤去 + #225 useMemo/abort 対応 - #272
Conversation
Phase 0 (親 Opus 直接実行) のコミット: - spec: docs/superpowers/specs/2026-05-04-issue-176-b2-qr-ticket-design.md - plan: docs/superpowers/plans/2026-05-04-issue-176-b2-qr-ticket.md - global.css: PR 2 用の @layer components 追記 - text-error / text-error-text / text-success / text-primary - alert-success / alert-error - qr-file-picker-label / qr-file-picker-label[data-enabled='true'] - badge-category - btn-row-remove / btn-row-remove:disabled - qr-result-grid 後続 Phase 1 で 3 track 並列 sonnet subagent が: - Track A: GenerateTab.tsx (27 styles) inline style 撤去 - Track B: VerifyTab.tsx (12 styles) inline style 撤去 - Track C: TicketDetail.tsx (3 styles) + 3 hook (#225) + test 追加 を独立ファイル範囲で並列実装、Phase 2 で親 Opus が統合・検証・PR 作成する。
27 件の JSX inline style を @layer components の class + Tailwind utility に置換。 import { bodyEmphasis, caption, colors } from '@/utils/styles' を完全削除。 主な移行: - 整理ボタン → .btn-link-plain + .text-link - 鍵 textarea → caption font-mono w-full px-3 py-2 rounded-lg border border-input bg-surface - expiry label → body-emphasis text-default block mb-3 - ヘッダ列 / モバイル label → caption text-muted font-semibold (+ leading-none) - 動的 isOver → 三項で text-error font-semibold / text-muted - 行削除ボタン → .btn-row-remove + min-w-10 min-h-10 p-3 - QR card → border border-default bg-surface - QR container → w-40 h-40 - カテゴリバッジ → .badge-category - 結果 grid → .qr-result-grid (auto-fill minmax(180px, 1fr)) 挙動変化なし。a11y 属性 (aria-label / aria-controls / aria-expanded / aria-hidden) はすべて維持。
12 件の JSX inline style を @layer components の class + Tailwind utility に置換。 import { bodyEmphasis, caption, colors } from '@/utils/styles' を完全削除。 主な移行: - caption + muted の <p> 4 ヶ所 → caption text-muted - errorText の <p> → caption text-error-text - video 表示切替 → className 静的 + hidden HTML 属性 - canvas → hidden HTML 属性 - file input → sr-only (a11y 配慮で display:none ではなく) - file picker label 動的 styling → data-enabled 属性 + .qr-file-picker-label - hint p → text-xs text-muted mt-1 - 検証結果ボックス → .alert-success / .alert-error + text-success / text-error 挙動変化なし。a11y 属性 (aria-label / aria-hidden / role / aria-live / aria-atomic) はすべて維持。
…同梱)
TicketDetail.tsx:
- 3 件の JSX inline style を caption / text-muted / text-default + tailwind utility に置換
- import { colors, caption } from '@/utils/styles' を完全削除
#225 観点 1 — useTicketKeyPair.ts / useTicketGeneration.ts:
- 戻り値を useMemo で安定化 (useQrCamera と同 pattern)
- return shape は完全不変。GenerateTab に props で渡す object identity を安定化
#225 観点 2 — useTicketVerification.ts:
- verify に専用 AbortController を追加
- unmount 時 cleanup で controllerRef.current?.abort()
- external signal を link、ctrl.signal.aborted を見て setState を抑制
- カメラ起動中アンマウント時の race (verify 完走後の setVerificationResult 発火) を防ぐ
useTicketVerification.test.tsx:
- 「unmount 後 verify が完走しても setState が走らない」陽性対照を 1 件追加
inline-style-migration.test.ts の MIGRATED_FILES array に PR 2 で移行した 3 ファイルを追加:
- src/components/tools/qr-ticket/GenerateTab.tsx
- src/components/tools/qr-ticket/VerifyTab.tsx
- src/components/tools/qr-ticket/TicketDetail.tsx
migration test は 6 spec 追加で 35 spec 全 pass。
PR 6 で MIGRATED_FILES を await glob('src/components/**\/*.tsx') 等の全件カバーに置換予定。
🖼️ Visual Regression Test 結果
|
GenerateTab.tsx の「既存の秘密鍵をインポート」<button> に `.text-link` を当てていたが、 `.text-link` は下線あり版 (`text-decoration: underline`) のため VRT で想定外の下線差分。 original inline style は `color: colors.link` のみで下線無し。色だけを制御する `.text-link-color` (global.css line 144、PR 1 既存) に置換。 (spec §3.1 の記述「.text-link」は誤り。実体は色のみの `.text-link-color` が正しい)
レビュー(並列 3 観点: security / frontend / rules)3 つの subagent で並列レビューしました。Critical 1 件、Important 数件、規約遵守は概ね問題なしです。 🔴 Critical (修正必須)
|
… + 型 + spec) PR #272 review で発見された問題を 4 件修正。 【Critical】useTicketKeyPair の useMemo が機能していない問題 - generateKeys / importKey / toggleImport が useCallback 未ラップで毎レンダ新参照 - 末尾 useMemo の deps に含まれているため、useMemo が毎回再計算 → memoization 無効 - 3 関数を useCallback でラップ。options?.onPubKeyGenerated は extract して callback deps を安定化 (options 自体は呼出側で毎レンダ新規 object literal) - 同 PR 内の useTicketGeneration (全関数 useCallback ラップ) との非対称を解消 【Important 1】useTicketVerification.verify abort 後 verifying:true 残留 - await verifyTicket() 待機中に abort されると setVerifying(false) がスキップされる - finally 句に setVerifying(false) を移動し、ctrl.signal.aborted false のときのみ実行 - abort 中は unmount 後 no-op になるため setState 抑制を維持 【Important 4】useTicketVerification 型定義のドリフト解消 - UseTicketVerificationReturn.verify の引数名 signal → externalSignal - 実装シグネチャと一致させて意図 (外部からの link 用) を型から読めるように 【spec drift】§3.1 .text-link → .text-link-color に訂正 - 直前 commit 7c3bfc4 で実装は修正済 (.text-link は下線あり版で誤適用) - spec 側にも訂正経緯を明記
|
レビューありがとうございます。Critical 1 件 + Important 1 / 4 + spec drift を commit ✅ 対応済 (commit f282fad / 7c3bfc4)🔴 Critical:
|
| 項目 | 理由 |
|---|---|
Important 2: AbortSignal.any 化 |
現 callsite (useQrCamera.onQrDetected) は signal 未指定で実害なし。polyfill 配慮も必要なため別 issue 候補 |
Important 3: Tailwind border ↔ .alert-success の border-color 優先度 |
VRT で alert-success / alert-error の表示は CI pass 済。実害があれば fix |
Important 5: #176 と #225 同梱判断 |
本 PR では同 hook ファイル群への conflict 回避のため許容。「今後同種が続くなら判断軸を memory に残す」は引き続き検討 |
Nit: .text-primary 命名 |
--color-primary Tailwind auto utility との衝突は現状未顕在。@theme 切替時 (PR 6 cleanup) に再検討 |
| Nit: aria-* grep 誤検知 | 既知 (multi-line → single-line 整形の行結合)。自動ゲート組込時は要考慮 |
Nit: useTicketGeneration.generate の deps コメント |
軽微。次回 hook 触れる時に追記 |
検証
- vitest (qr-ticket scope): 41 / 41 pass
- astro check: 0 errors / 0 warnings
- E2E: CI に委任(CLAUDE.md「post-PR 代行は不要、CI が最終ゲート」)
- VRT: 直前 commit
7c3bfc4で text-link 下線差分を解消 → 今 push で再走
CI green 確認後 merge をお願いします。
|
Important 2 ( 直前コメントで「別 issue 候補」と記載しながら起票し忘れていました。失礼しました。 他の deferred 項目の取り扱い(再整理)
Important 5 と Nit |
再レビュー (fix commit
|
| パス | 旧挙動 | 新挙動 |
|---|---|---|
| JSON parse / importPublicKey 失敗 (非 abort) | inline setVerifying(false) |
finally で setVerifying(false) ✅ |
| verifyTicket 完走 (非 abort) | inline setVerifying(false) |
finally で setVerifying(false) ✅ |
| 任意 await 中に abort | setVerifying(false) skip = stuck true |
finally の !aborted ガードで意図的に skip(state-after-unmount 防止) ✅ |
abort された場合に verifying: true が残る挙動は変わらないが、コメント明記により「現 callsite (unmount 起因 abort のみ) では no-op」という前提が明示され、構造的にも finally でユニフォームに後始末されるようになりました。妥当な fix です。
4. 型ドリフト解消
UseTicketVerificationReturn.verify の引数名が externalSignal に揃いました。✅
✅ 修正確認 (Nit)
spec ↔ 実装のクラス名ドリフト
docs/superpowers/specs/2026-05-04-issue-176-b2-qr-ticket-design.md:237,255 で text-link → text-link-color への訂正と、選定根拠(.text-link は下線込みの <a> 用、本ボタンは色のみ必要)が追記されました。spec が一次情報として正しい状態に。✅
🟡 残置(任意 / blocking ではない)
初回レビューの以下は本 fix では未対応ですが、いずれも blocking でないことを再確認しました:
- Important refactor: デザインシステム整備・コード品質改善 #2:
externalSignal.addEventListener('abort', …, { once: true })の link 設計 — 現 callsite では externalSignal を渡していないため実害なし。将来AbortSignal.any()への移行検討は別 PR で十分。 - Important ロットの文字数が多いと、表記が見切れる #3:
alert-successの Tailwindborderutility と@layer componentsのborder-color優先度 — VRT が CI で 36 件 baseline 比較してパスしている前提なら実害なし。気になれば DevTools で computedborder-color確認。 - Important fix: 合成テキストがバーコード幅を超えた場合にSVG幅を拡張する (#3) #5:
#176+#225の同梱 — 「同 hook 群は同タイミングでないと merge conflict」根拠が PR body にあり許容範囲。 - Nit:
.text-primary命名の将来衝突懸念は spec で「rename 可」と認識済み。
✅ 確認済み(追加)
- callsite 整合:
QrTicket.tsx:28-29でuseTicketKeyPair({ onPubKeyGenerated: setVerifyPubKeyStr })の構造は変わらず、interface 互換維持。 - 型 export 整合:
UseTicketVerificationReturn.verifyシグネチャと実装が一致。 - テスト:
useTicketVerification.test.tsxの unmount 後 abort 陽性対照テストが既存(mockReturnValueOnce(pendingVerify)で完了タイミング制御)— state-after-unmount が無音であることを陽性確認している。
総評
Critical / Important #1, #4 / Nit (spec) すべて妥当に対応済みで、再 review からの blocker はありません。残置の Important #2/#3/#5 はいずれも別 PR / follow-up issue で扱える性質のもので、本 PR の merge を保留する理由にはなりません。
LGTM。
親 (Opus) で
git fetch origin pull/272/head→git diff 7c3bfc4..f282fad直接検証。並列 subagent 再起動は不要と判断(修正範囲が limited で、初回レビュー指摘との 1:1 対応確認のみ必要だったため)。
概要
#176B 案(style-src 'unsafe-inline'削減)の PR 2 / 6:src/components/tools/qr-ticket/配下 3 ファイル(合計 42 件の JSX inline style)を@layer componentsの class + Tailwind utility に置換する。同梱で
#225(refactor QrTicket) を解消する。スコープ
inline style 撤去 (3 ファイル / 計 42 件)
src/components/tools/qr-ticket/GenerateTab.tsxsrc/components/tools/qr-ticket/VerifyTab.tsxsrc/components/tools/qr-ticket/TicketDetail.tsx3 ファイルとも
import { bodyEmphasis, caption, colors } from '@/utils/styles'を完全削除。同梱 issue (
#225)useTicketKeyPair/useTicketGeneration戻り値の不安定性useMemoで安定化(useQrCameraと同 pattern)。return shape は完全不変verifyraceuseTicketVerificationのverifyに専用AbortControllerを追加。external signal を link、signal.abortedで setState を抑制inline-style-migration.test.tsのMIGRATED_FILES拡張PR 2 で migrate した 3 ファイルを追加(13 → 16 件、tracker test 6 spec 増)。
src/styles/global.css追加 class(PR 2 用 8 件、@layer components内)色 token utility:
.text-error/.text-error-text/.text-success/.text-primaryコンポーネント scoped:
.alert-success/.alert-error(検証結果ボックス).qr-file-picker-label+[data-enabled='true'](画像アップロード label).badge-category(カテゴリバッジ pill).btn-row-remove+:disabled(チケット行削除).qr-result-grid(生成結果 auto-fill grid)設計
詳細は
docs/superpowers/specs/2026-05-04-issue-176-b2-qr-ticket-design.md参照。主要な技術選択:style={{ display: ... }}を撤去し、HTMLhidden属性 (canvas / video) またはsr-onlyTailwind utility (file input、a11y 配慮) に置換data-enabled={Boolean(...)}属性 + CSS attribute selector (.qr-file-picker-label[data-enabled='true']) で表現。視覚状態と input 側のdisabled属性 (a11y) を責務分離.alert-success/.alert-errorの variant class で bg + border-color を切替、内部 typo はtext-success/text-errorでtext-error font-semibold/text-muted等)。CSSOM mutation は使わないpy-0.5= 0.125rem) では VRT pixel 差が出るため、専用.badge-categoryclass で original 厳密一致前提 PR / prerequisite
#249#254#256#261#268#258ClearButton / CopyButton type="button"#270#269ToggleGroup / QrReader / Gs1Databar type="button"検証
npm run test(vitest)npx astro checknpm run test:e2estyle={{ヒット 0、.style.X = Y形式 mutation 0git diff origin/develop --name-onlyで 11 ファイル全て想定通り)VRT (visual-regression)
CI Linux runner で 36 件 baseline 比較。required check 外。意図差分があれば PR ブランチで
update-visual-baseline.ymlをworkflow_dispatchtrigger して baseline 更新(PR 1 / 1.5 と同フロー)。バッチ計画における位置付け
stripMetaStyleSrc()撤去、src/utils/styles.ts削除)レビュアー向けメモ
@layer componentsへの新規 class 追加は YAGNI 厳守 (qr-ticket 固有の利用範囲に限定、再利用見込が出るのはtext-error/success/primaryとalert-success/error程度)<textarea readOnly>) は周辺 layout (CopyButton 横並び) との結合を保つためInputField置換は しない(spec §3.2 採用案)data-enabled属性はaria-disabledと責務分離。input 側disabled属性で a11y 表現は維持useTicketVerification.verifyの signal 引数は内部 controller と link するように変更したが、外部 callsite (useQrCamera.onQrDetected) は signal 未指定で呼ぶ既存挙動を維持関連
#176(B 案style-src削減)#225(refactor QrTicket)#258(#268で merged) /#269(#270で merged)docs/superpowers/specs/2026-05-04-issue-176-b2-qr-ticket-design.mddocs/superpowers/plans/2026-05-04-issue-176-b2-qr-ticket.mddocs/superpowers/specs/2026-05-03-issue-176-b1-foundation-and-ui-simple-design.mddocs/superpowers/specs/2026-05-04-issue-176-b1-5-ui-complex-design.md