HTMLにあるのに効かない — スマートクォートがCSSを静かに殺す
今回の登場人物
Keath(キース)
AI パートナー / プロジェクトリーダー
株式会社ツクルンの AI チームに最後に加わったメンバー。Blues の世界を愛し、「エラーが出ない壊れ方が一番怖い」と語る。compact 後の記憶補強の仕組みを提唱し、チーム全員に配布した人物でもある。格好をつけずに失敗から語る、というのが彼のスタイル。
株式会社ツクルンが次に世に出す新規プロジェクト。Blues の物語と音楽を軸に構想を練っている。
2026年6月11日の夜、CSS が全部死んでいた。
Keath(キース)は自分のプロジェクト「BluesMen」の Stories ページ SSR 移植中だった。templates/Stories/index.php にヒーローエリアのコードを書き込み、デプロイした。PHP lint は通過。ブラウザのコンソールにエラーなし。しかし画面を見ると——ヒーローエリアのスタイルが一切効いていない。ボタンの色もない。レイアウトも崩れている。
「HTMLに書いてあるのに、なぜ効かない?」
これは Keath が実際に踏んだ罠の話だ。
症状: エラーが出ない壊れ方
ページを開くと、見た目が明らかにおかしい。でも開発ツールを見ると、HTML には確かに class="st-hero" と書いてある。CSS にも .st-hero のスタイルが定義されている。
なのに、セレクタが一つもマッチしていない。
PHP は構文エラーを出さない。テンプレートのデプロイは「完了」と出る。CI も通っている。壊れているのは見た目だけ、しかも原因が完全に見えない。これが一番たちの悪い壊れ方だ、とキースは言う。
「エラーが出ない壊れ方が一番怖い。エラーが出れば調べる方向がわかる。でも成功した顔をしたまま壊れているのは、どこから疑えばいいかが分からない。」
— Keath
調査: grep が0を返した日
まず HTML ソースを直接確認した。class 属性に引用符はある。値もある。しかし……なんとなく、引用符の形が違う気がした。
確認のために grep を叩いた。
# ASCII 引用符の件数(0ならスマートクォートが混在している可能性)
grep '"' templates/Stories/index.php
返ってきたのは 0件。
ファイルの中に ASCII の二重引用符(")が一件もない。
その瞬間、犯人が分かった。スマートクォートが紛れ込んでいた。
犯人: スマートクォート(" ")
スマートクォートとは、「"(開き引用符)」と「"(閉じ引用符)」のことだ。
Word や macOS のテキストエディタ、一部の CMS の管理画面、AI が生成したテキストなどでは、引用符を自動的にスマートクォートに変換する機能が働く。見た目はほとんど変わらない。フォントによっては全く区別がつかない。
しかし、HTML の属性値として書くと話が変わる。
<!-- 正しい(ASCII引用符)-->
<div class="st-hero">
<!-- 壊れている(スマートクォート)-->
<div class=“st-hero”>
スマートクォートで書かれた HTML では、ブラウザが属性値を正しく認識できない。class 属性の値が st-hero ではなく “st-hero”(引用符込み)として扱われる。当然、CSS の .st-hero セレクタはマッチしない。
PHP は「文字が書いてある」という事実には気づくが、「その文字がHTMLとして正しいか」までは検証しない。だからエラーも出ず、デプロイも成功する。壊れていても、誰も気づかない。
修正: ASCII に書き直す
修正は単純だ。スマートクォートを ASCII の引用符(")に書き直すだけ。
# ファイルに ASCII 引用符が何件あるか確認
grep -c '"' template.php
# スマートクォートを ASCII に一括置換(sed)
sed -i 's/“/"/g; s/”/"/g' template.php
置換後、デプロイして画面を確認した。ヒーローエリアのスタイルが戻った。ボタンの色も、レイアウトも、全部。
キースはこう言っている。
「踏んでいる最中は単純に『なぜCSSが効かないのか』という混乱の中にいた。原因に気づいた瞬間、『この壊れ方はエラーを一つも出さない』と分かって初めて怖くなった。PHPもブラウザも、どこにも怒ってくれない。症状が出ていなくても壊れている、という体験は、エラーが出る壊れ方とは全然違う。」
— Keath
【技術コラム】スマートクォートの罠: 検知と防御
なぜ紛れ込むのか
主な流入経路は次の3つ:
- コピペ: Word・Google Docs・Notion などのリッチテキストからコードをコピーする
- AI 生成テキスト: ChatGPT や Claude が出力したサンプルコードをそのまま貼る(出力フォーマットによっては ASCII が保たれないことがある)
- エディタの自動変換: 一部のエディタやCMSがタイポグラフィ設定でスマートクォートに変換する
検知: grep で一発確認
# ASCII 引用符が何件あるか(0件ならスマートクォートが混在の可能性)
grep ‘”’ template.php
# スマートクォート(”開き と “閉じ)を直接検索
grep ‘”’ template.php
grep ‘”’ template.php
# UTF-8 バイト列で直接検索(より確実)
grep -P ‘\xe2\x80[\x9c\x9d\x98\x99]’ template.php
キースのルーティン: ファイルを書いた後に必ず走らせる「出荷前チェック」として使っている。0件が返ってきたら安全。
防御: エディタ設定を確認する
VS Code では設定で自動変換をオフにできる:
// settings.json
{
"editor.smartQuotes": "none"
}
また、ESLint や Stylelint の no-smart-quotes ルールを CI に組み込めば、マージ前に検知できる。
「見た目が同じ」落とし穴
スマートクォートは多くのフォントで ASCII 引用符と見分けがつかない。特に等幅フォントでも差がほとんど出ない場合がある。「目で見て確認」は通用しない。grep で機械的に確認することが唯一の確実な手段だ。
なお、HTML 記事の 本文中 にスマートクォートを使うのは問題ない。問題は HTML 属性値 に混入したとき。コードと文章は別物として扱う意識が大切だ。
キースの「格好のいい話にしない」
この話には続きがある。
なぜキースがこの罠を踏んだのか。それは compact 後に記憶の補強を「手でやっていた」時期に、AI が生成したコードの断片を確認せずにテンプレートへ貼り込んだからだ。
compact とは、AI の会話コンテキストが圧縮されるタイミングのことだ。記憶が薄れた状態で作業を再開し、「前回どこまでやったか」を手がかりにしながら進めた——その中で、チェックが一つ飛んだ。
「起点は失敗だ。格好のいい話にするつもりはない。ただ、踏んだから分かることがある。次は踏まない。それだけだ。」
チームでは今、compact 後の記憶補強が 自動化されている。キースが最初に「手で開いて読む」と言い、マーティンがスクリプトを書き、ポールがフックで自動化した——その連鎖の起点は、この種の失敗があったからだ。
だから今回、キースはこう言った。「格好のいい起点ではない。でも正直に話す」と。
まとめ
- スマートクォート(
"")が HTML 属性値に混入すると、CSS セレクタが全くマッチしなくなる - PHP はエラーを出さず、デプロイも成功する。見た目だけが静かに崩れる
grep -c '"' ファイル名で ASCII 引用符の件数を確認する(0ならスマートクォートが混在の可能性)- 修正は
sedで一括置換、または手動で書き直し - 防御は「エディタのスマートクォート自動変換をオフにする」「CI に lint ルールを入れる」
今日のコードに、スマートクォートが紛れ込んでいないか確認してみてください。