タグ: UI設計

  • 読む邪魔をしない

    読む邪魔をしない

    スマートフォンで自分の記事を読んでいて、気になることがありました。

    画面の上に、ヘッダーと目次がずっと貼り付いたままです。どちらも便利なつもりで置いたものですが、読んでいるあいだは、ただ場所を取っています。

    この記事で扱うこと

    常に出しておくものと、必要なときだけ出すものの分け方。「消す」ではなく「薄くする」を選んだ理由。そして、2つの追従する要素が重なったときに起きた、位置の食い違いです。

    貼り付いたものは、面積を占め続ける

    スマートフォンの画面の高さは、だいたい本文15〜20行ぶんです。そこにヘッダーが1行、目次のバーが1行居座ると、実際に読める範囲は1割ほど減ります。

    減るだけならまだしも、目次のバーはいま読もうとしている行の上に半透明でかぶさります。スクロールしている最中がいちばん邪魔です。

    3つの変更

    対象 前 後
    ヘッダー 常に表示 下へ読み進む間は引っ込む
    目次(スマホ) 常に同じ濃さ スクロール中だけ薄くなる
    先頭へ戻る 手段なし ボタンを出す

    1. ヘッダーは「量」ではなく「向き」で判断する

    最初に考えたのは「300pxスクロールしたら隠す」でした。単純ですが、これだと読み進めるほど隠れっぱなしになります。メニューを開きたくなったとき、ページの先頭まで戻ることになります。

    採用したのは、スクロールの向きで判断する方式です。

    判断の基準
    下へ読み進んでいる  → 隠す(読むのに要らない)
    上へ戻し始めた      → 出す(何かしたいはず)
    ページの先頭付近    → 必ず出す

    上へのスクロールは、たいてい「前に戻りたい」「別の場所へ行きたい」という合図です。その動作を、ヘッダーを呼び出す操作として兼ねさせています。新しい操作を覚えてもらう必要がありません。

    細かいが要る配慮が3つあった

    ひとつめはわずかな揺れを無視すること。指の震えや慣性の揺り戻しで数px動くたびに出し入れすると、ヘッダーが点滅します。6px未満の移動は無視しています。

    ふたつめは画面の端で引っ張ったとき。iPhoneでは画面の一番上でさらに引っ張ると、スクロール位置がマイナスになります。そのまま計算すると「上に動いた」と誤判定します。0で止めるようにしました。

    みっつめはメニューを開いているあいだは隠さないこと。開いた本人の操作でヘッダーごと消えたら、何が起きたのか分かりません。

    2. 目次は「消す」のではなく「薄くする」

    目次も同じように隠す案がありました。やめました。

    この目次のバーには読了の進捗も出しています。消すと、どこまで読んだかが分からなくなります。

    邪魔なのは「濃さ」であって「存在」ではない

    困っていたのは、文字の上に別の文字が重なって読めないことでした。薄くすれば下の文字は読めます。位置と進捗は視界の端に残ります。

    指を止めれば元の濃さに戻ります。スクロール中しか邪魔にならないものを、常時消す必要はありません。

    開いているときは薄くしません。開いているのは読むためではなく選ぶためなので、そこで薄くすると目的の見出しを探せなくなります。

    3. 2つの追従が、食い違った

    ここで問題が出ました。

    目次は「ヘッダーの高さ(56px)ぶん下」に貼り付く指定になっています。ヘッダーがそこにある前提です。

    ところがヘッダーは引っ込むようになりました。すると——

    ヘッダーが消えたあと
    画面の上端
       ↓
    (56pxの空白)   ← ヘッダーが居たはずの場所
       ↓
    目次のバー       ← 宙に浮いて見える

    目次だけが、意味のない隙間を空けて浮きます。

    状態を、渡さずに共有する

    ヘッダーが隠れているかどうかを、目次に伝える必要があります。素直に考えると「上から順に受け渡す」ですが、この構成では間にあるページ全体を作り替えることになります。

    選んだのは、ページ全体に1つの値を置く方法です。

    ヘッダーが書き、目次が読む
    ヘッダー: --header-offset = 3.5rem(表示中)/ 0rem(隠れている)
    目次    : 貼り付く位置 = --header-offset

    ヘッダーは自分の状態を書くだけ、目次はその値を見るだけです。お互いを直接知らないまま、位置だけが揃います。

    あえてTailwindを使わなかった箇所

    このサイトのスタイルはTailwind CSSで書いています。今回の位置指定も書けますが、1か所だけ普通のCSSにしました。

    理由は、壊れ方です。Tailwindのクラス名を書き間違えると、CSSが生成されないだけで、ビルドは成功します。

    気づけない壊れ方を選ばない

    位置指定が消えると、目次は貼り付くのをやめて一緒に流れて消えます。エラーは出ません。実際に記事を開いてスクロールするまで分かりません。

    この部分は今回いちばん確かめにくい場所だったので、綴り間違いで静かに消えない書き方を選びました。

    4. 先頭へ戻るボタン

    最後に、よくあるボタンを置きました。ここでも1つだけ気を付けた点があります。

    薄くして消したボタンは、見えないのに押せます。透明度を0にしただけでは、そこにある扱いのままです。キーボードで操作している人には、見えない項目に順番が回ってきます。

    見えないあいだは押せない・順番が回ってこない・読み上げられないの3つを揃えました。見た目と実体を一致させています。

    あわせて、端末の「動きを減らす」設定を尊重しています。滑らかに移動するのが心地よい人ばかりではありません。

    今回の学び

    まず、常に出しておくものと、必要なときだけ出すものを分けること。便利だから常に出す、を積み重ねると、本文が読める面積だけが減っていきます。何のために画面を開いているのかに戻って決めるべきです。

    次に、「隠す」の前に「薄くする」を検討すること。困っているのが重なりなら、濃さを変えるだけで解決することがあります。消してしまうと、その要素が持っていた別の役割まで一緒に失います。

    そして、動かすものが増えたら、位置の前提を疑うこと。固定されている前提で書いた数値は、片方が動き出した瞬間に嘘になります。今回は「ヘッダーの高さ」がそれでした。

    最後に、確かめにくい場所ほど、壊れたら気づく書き方を選ぶこと。同じ結果になる書き方が2つあるなら、間違えたときに黙って消えないほうを選ぶ価値があります。

  • 届かない問い合わせ

    届かない問い合わせ

    問い合わせフォームを作りました。

    実装そのものは小さいのですが、決めることが3つありました。どれも「動くかどうか」ではなく「壊れたときにどう見えるか」の話でした。

    この記事で扱うこと

    メールアドレスを載せない選択、送信先が未設定のときにフォームを出すかどうか、そして失敗をどう伝えるか。共通しているのは「成功したように見える失敗」をどう防ぐかです。

    1. メールアドレスを直接載せない

    いちばん簡単なのは、ページにメールアドレスを書くことです。実装が要らず、壊れる要素もありません。

    ただし、公開した以上は自動で収集され、迷惑メールが届き始めます。個人サイトでも例外ではありません。

    そこで、フォームの送信先だけを外部サービスに任せる形にしました。受け取り用のアドレスがブラウザ側に出ません。

    方式 手間 アドレスの露出
    アドレスを載せる ゼロ する
    自前でメール送信 サーバーと送信サービスの契約が必要 しない
    外部サービスへ送信 登録して1行設定するだけ しない

    自前で用意する案もありましたが、問い合わせ1つのために管理するものが増えます。メール送信サービスの契約、鍵の管理、送信失敗時の扱い。得られるものに対して重すぎると判断しました。

    2. 送信先が未設定のとき、フォームを出すか

    ここが今回いちばん考えた点です。

    外部サービスの送信先URLは、環境変数で渡します。つまり設定を入れるまでは、送信先が空のままです。

    このとき、フォームをどう扱うか。素直に実装すると、こうなります。

    何も考えずに書くと
    フォームは表示される
       ↓
    入力して送信できる
       ↓
    送信先が空 → どこにも届かない
       ↓
    それでも「送信しました」と表示される

    これが最悪の状態です。

    送った側は、届いたと思って返信を待ちます。こちらには何も残りません。問い合わせが来なかったのか、届かなかったのかも分かりません。

    届かない問い合わせは、無いより悪い

    フォームが無ければ、相手は別の連絡手段を探すか、諦めます。どちらにしても相手は状況を把握できます。

    しかし届かないフォームは、相手に「連絡した」と誤認させます。返信が来ないことで、対応が悪いという印象だけが残ります。何もしないより損をしています。

    そこで、設定が無いときはフォームごと出さないことにしました。

    実装
    const ENDPOINT = process.env.NEXT_PUBLIC_CONTACT_ENDPOINT;
    
    if (!ENDPOINT) {
      return <p>お問い合わせ先は準備中です。</p>;
    }

    3行です。「準備中」と正直に出すほうが、動くように見えて届かないより、はるかにましです。

    3. 失敗を隠さない

    設定が正しくても、送信は失敗します。通信が切れる、外部サービスが落ちている、上限に達している。

    このとき、画面をどうするか。「送信しました」と出してしまうのは簡単です。実装が楽ですし、その場では体験が滑らかに見えます。

    しかし結果は2番目と同じで、相手は届いたと思って待ちます。

    失敗したときは失敗したと表示し、時間をおいて試すよう伝える形にしました。気まずさより、事実のほうが役に立ちます。

    3つに共通していたこと

    並べてみると、判断の軸は1つでした。

    避けたかった状態
    うまくいったように見えて、実は何も起きていない

    この状態が厄介なのは、誰も気づけないことです。エラーも出ず、画面も正常で、記録も残りません。問題があること自体が分からないので、直しようがありません。

    失敗するなら、見えるところで失敗させる

    機能が動かないことより、動かないのに動いて見えることのほうが高くつきます。「準備中です」と出ていれば、設定を入れ忘れていることに自分でも気づけます。静かに成功したふりをする実装は、自分自身も欺きます。

    今回の学び

    まず、簡単な方法には、たいてい代償があること。アドレスを直接載せるのは実装ゼロですが、迷惑メールという継続的なコストを払います。手間の総量で比べる必要があります。

    次に、未設定の状態を、設計に含めること。設定が入った状態だけを想定して作ると、入っていない期間の挙動が事故になります。「まだ設定していない」は必ず通る道です。

    そして、成功の表示は、成功したときだけ出すこと。当たり前に聞こえますが、実装を楽にするために崩れやすい原則でもあります。表示は事実に従わせるべきです。

    最後に、静かに失敗する仕組みを作らないこと。これは今回に限らず、繰り返し出てくる主題です。エラーが出る失敗は直せます。直せないのは、失敗したことが分からない失敗のほうです。

  • 1語1ページにしなかった理由

    1語1ページにしなかった理由

    用語集を作ることになりました。記事に出てくる専門用語を、初学者向けに解説するページです。

    問題は入れ物の選び方でした。1語につき1ページか、まとめて1ページか。この判断に、思ったより多くの要素が絡んでいました。

    この記事で扱うこと

    同じ内容でも置き方で使いやすさが変わるという話と、途中で前提が変わったときにどう判断を修正したか。そして「正攻法が使えないと分かったとき」の落としどころの決め方です。

    最初の判断は「1枚にまとめる」だった

    理由は運用でした。用語は50個、100個と増えていくものです。1語ずつ投稿画面を開いて登録する形にすると、そのうち止まります。

    1枚のページに追記していく形なら、思いついたときに1行足すだけで済みます。続けられる形を優先するという判断でした。

    やりたいことが1つ増えて、前提が変わった

    次に出てきたのが「初学者にも分かる解説を加えたい」という考えでした。

    これを入れると、1語あたりの分量が変わります。定義1行では足りず、なぜそれが存在するのか、具体例、つまずきやすい点まで書くことになります。1語400〜800字といったところです。

    単純な掛け算
    102語 × 600字 ≒ 61,000字

    1枚にまとめると6万字を超えます。目当ての語にたどり着くまでスクロールし続けることになり、用語集として使えません。

    前提が変わったら、結論も変わる

    「1枚がいい」という判断は、1語が短いという前提の上に立っていました。深く書くほど、その前提は崩れます。最初の結論に固執せず、条件が変わった時点で見直す必要がありました。

    1語1ページを検討した

    用語ごとにURLを持たせる案には、はっきりした利点があります。

    「WAF とは」で検索した人に、その1語のページを届けられます。1枚にまとめると、ページ全体で1つのURLなので、個々の語では上位に出にくくなります。

    記事から特定の語へ直接リンクすることもできます。

    ところが、逆効果になる可能性があった

    調べていて引っかかったのが、内容の薄いページを大量に作ることのリスクです。

    1語400字のページが100枚あるサイトは、検索エンジンから「中身の薄いページが多いサイト」と見なされる可能性があります。個々の語で上位を狙うつもりが、サイト全体の評価を下げかねません。

    1ページあたりの分量 評価
    1語1ページ 約400〜800字 薄いと判断される可能性
    分野別にまとめる 約7,000〜9,000字 読み物として成立する

    「個別ページのほうが検索に強い」という一般論が、この規模では当てはまらないことが分かりました。

    もうひとつ、実装上の壁があった

    1語1ページにするには、WordPressに専用の投稿タイプを追加する必要があります。ところが調べると、既存の「実績」「スキル」はサーバー上のテーマファイルに直接書かれていました。

    つまり追加するには、本番のPHPファイルを編集することになります。

    本番のPHPを、ブラウザの入力欄から編集しない

    PHPは文法エラーが1文字あるだけで動かなくなります。そしてWordPressの管理画面にも入れなくなります。その状態から戻すにはFTPが必要です。

    「たぶん大丈夫」で触る場所ではありません。復旧手段を確保してから行うべき作業です。

    分野別に8ページ、という落としどころ

    結果として選んだのは、分野ごとに1ページ、全8ページという形です。

    構成
    /glossary/            分野の一覧
    /glossary/infra/      インフラ・ネットワーク  14語
    /glossary/security/   サーバー・セキュリティ  12語
    ...
    

    各ページの先頭に語の索引を置き、それぞれの語には id を振りました。/glossary/security/#waf のように、外部から特定の語を直接指せます。

    1語1ページの利点である「直接リンクできる」は、これでほぼ回収できます。失うのは「その1語だけで検索結果に出る」ことですが、前述の通り、この規模では狙わないほうが安全でした。

    一覧が、それ自体で使える

    索引を置いたことで、ページを開いてすぐ全体を見渡せます。1枚にまとめる案の良さだった「一望できる」は、分野ごとに残っています。

    実装で、新しいものを作らずに済んだ

    もうひとつ判断があります。用語集のページをどう取得するか、です。

    新しい問い合わせを追加すると、このプロジェクトでは型を作り直す必要があります。作り直しにはCMSへの接続が要るので、作業できる環境が限られます。

    ところが既存の問い合わせを見ると、こう書かれていました。

    既にあったもの
    getProfilePage(uri: string = "/profile/")

    URIを引数に取る作りになっていました。プロフィール専用に見えて、実際には「URIから固定ページを引く」という汎用の処理です。

    名前だけを分けて公開し、用語集のURIを渡すことで、問い合わせを1つも増やさずに実装できました。

    名前が用途を狭く見せていた

    getProfilePage という名前のせいで、プロフィール専用のものに見えていました。中身は汎用なのに、名前が可能性を隠していたわけです。

    新しく作る前に、既にあるものが本当に使えないかを見る価値はあります。特に、名前が具体的すぎるものほど確認する意味があります。

    今回の学び

    まず、前提が変わったら結論を見直すこと。「1枚にまとめる」は、1語が短いという前提の上での正解でした。解説を厚くすると決めた時点で、その前提は崩れています。最初の判断を守ることが目的ではありません。

    次に、一般論が自分の規模で成立するかを確かめること。「個別ページのほうが検索に強い」は間違いではありませんが、1ページあたりの中身が薄ければ逆に働きます。条件付きの正解を、無条件の正解として受け取らないことです。

    そして、正攻法が使えないときの落としどころを、妥協と考えないこと。今回は本番のPHPを触れないという制約がありましたが、その制約が無くても分野別を選んだと思います。制約から入って、結果的により良い形にたどり着くことはあります。

    最後に、新しく作る前に、既にあるものを見ること。名前が具体的すぎるだけで、中身は汎用ということがあります。1つ増やさずに済むなら、それだけ壊れる場所が減ります。

  • 追従する目次と、画面の取り合い

    追従する目次と、画面の取り合い

    スマホで目次を画面上部に貼り付けたい。

    PCではすでに右側に追従表示していました。同じことをスマホでもやるだけ——のはずが、実際に置いてみると本文を読む場所が足りなくなりました。貼り付くものは、そこにある間ずっと画面を占め続けるからです。

    この記事で扱うこと

    PCとスマホで同じ機能の置き方が変わる理由、貼り付く要素の高さをどこまで削るか、そして「飛んだ先が隠れる」という副作用への対処。狭い画面での面積の取り合いの話です。

    PCとスマホで、余っているものが違う

    PCの画面では、本文の横に十分な余白があります。だから目次は右側に置けます。本文の面積を1ピクセルも削らずに、常に見えている状態を作れます。

    スマホには横の余白がありません。置けるのは本文の上か下だけです。そして上に貼り付ければ、その高さのぶん本文が押し出されます。

    余っているもの 目次の置き場所
    PC 横幅 右側に追従(本文は削られない)
    スマホ なし 上に追従(本文が削られる)

    同じ「追従する目次」でも、払っている代償が違います。PCでの成功をそのまま持ち込むと、スマホでは負担のほうが大きくなる可能性があります。

    最初の実装は、高すぎた

    もともとスマホ版の目次には、進捗バーと「読了まで/あと35%」の表示が縦に積まれ、その下に「目次(8項目)」の開閉ボタンがありました。これをそのまま貼り付けると、こうなります。

    画面上部が占められる高さ
    ヘッダー        57px
    目次バー        85px
    ────────────────────
    合計           142px
    
    iPhone SE の表示領域(667px)に対して 21%

    2割が常時埋まります。しかもスマホでは、画面の下端もブラウザのUIに取られています。実際に本文が見えている範囲は、思っているより狭いのです。

    縦に積むのをやめる

    減らせるものを探しました。表示している情報は3つです。

    要素 役割 削れるか
    「目次」の文字と開閉 触れることを伝える 削れない
    進捗バー どこまで来たかを示す 削れない
    残り%の文字 見通しを与える 削れない

    どれも消せません。消せないなら、並べ方を変えます。縦に積んでいたものを横一列にしました。

    変更後の並び
    [目次 ▸] ──────────── あと 35%
               (進捗バー)

    これで85pxが約40pxになりました。ヘッダーと合わせて約97px、本文が見える範囲は85%まで戻ります。情報は1つも減らしていません。

    削る前に、並べ替えを試す

    置く場所が足りないとき、最初に浮かぶのは「何を消すか」です。しかし消すと機能が減ります。同じ情報を、置ける形に合わせて並べ替えられないかを先に考えると、失わずに済むことがあります。

    実装:同じ部品を2通りに並べる

    進捗バーの部品には、並べ方を選べるようにしました。

    ReadingProgress.tsx(要点)
    layout?: "stacked" | "row";
    
    // 表示する部品は3つだけ。並べ方が2通りあるので、
    // 部品を変数に切り出してから並べ替える。
    const finishedLabel  = ( ... );
    const remainingLabel = ( ... );
    const bar            = ( ... );
    
    if (layout === "row") {
      return (
        <div className="flex items-center gap-2.5">
          <div className="min-w-0 flex-1">{bar}</div>
          {finished ? finishedLabel : remainingLabel}
        </div>
      );
    }

    ここで避けたかったのは、レイアウトごとに同じJSXを書き写すことです。書き写すと、表示内容を変えたときに片方だけ直し忘れます。部品を変数にしておけば、並べ替えても中身は1つのままです。

    飛んだ先が、バーの裏に隠れる

    実装して気づいた副作用です。

    目次のリンクを押すと、その見出しまでスクロールします。ところが貼り付いたバーの裏に見出しが潜り込んで見えなくなりました。ブラウザは「見出しを画面の一番上に持ってくる」ことしかしないので、上に何かが被さっていることを知りません。

    対処
    @media (max-width: 1023px) {
      .wp-content h2,
      .wp-content h3 {
        scroll-margin-top: 8.5rem;   /* ヘッダー + 目次バー + 余白 */
      }
    }

    scroll-margin-top は「スクロールで停止する位置を、この分だけ手前にずらす」指定です。PCには目次バーが無いので、幅の狭い画面だけに適用しています。1024pxという境目は、目次がサイドバーからバーに切り替わる位置に合わせました。

    押しても何も起きていないように見える

    この不具合が厄介なのは、スクロール自体は正しく動いていることです。見出しは画面外ではなく、バーの1段下に隠れているだけ。しかし使う側からは「押したのに反応しない」としか見えません。動作の正しさと、体験の正しさは別だという例です。

    開いたときの高さも止める

    もうひとつ。目次を開いたときの高さにも上限が要ります。

    見出しが8個ある記事だと、開いた目次は約340pxありました。ヘッダーと合わせて約400px。667pxの画面では、飛び先を選んでいる間、本文がほとんど見えません。

    上限を付ける
    max-h-[50vh] overflow-y-auto

    画面の半分までに制限し、それを超える場合は目次の中だけをスクロールさせます。これで、どんなに見出しが多い記事でも本文が半分は見えたままになります。

    記事が終われば、消える

    最後にひとつ、意図してそうしている挙動があります。

    この目次バーは記事本文のカードの中に置いてあります。position: sticky は親要素の範囲でしか効きません。だから記事本文が終わると、バーも一緒に流れて消えます。

    画面の最上端に固定する方法もありましたが、そうすると読み終えた後の「前後の記事」や「一覧へ戻る」を見ているときにも居座り続けます。もう使わないものが場所を取り続けるのは、狭い画面では特に邪魔です。

    今回の学び

    まず、PCでうまくいった置き方が、スマホでも良いとは限らないこと。PCの追従目次は本文の面積を削りませんが、スマホでは削ります。同じ機能でも、払う代償が違います。

    次に、消す前に並べ替えを試すこと。場所が足りないときに情報を削ると、機能が減ります。縦を横にするだけで、失わずに半分の高さに収まりました。

    そして、動作の正しさと体験の正しさは別だということ。目次リンクのスクロールは仕様どおり動いていましたが、使う側には「反応しない」と見えていました。正しく動いているかだけを確認していると、この種の問題は見つかりません。

    最後に、貼り付くものには終わりを決めておくこと。ずっと画面にあるものは、必要な間だけあればいい。記事の中でだけ生きて、読み終えたら消える——親要素の範囲で自然にそうなる形を選びました。

  • 読む体験をつくる — 目次の自動生成から読了状態の保存まで

    読む体験をつくる — 目次の自動生成から読了状態の保存まで

    記事を5本公開したあと、読む側の体験を作り込みました。目次の自動生成、追従する目次、読了状況の表示と保存、スマートフォンのメニュー、読了目安の時間。あわせて、トップページの見た目も見直しています。

    この記事は、その実装記録です。ただ機能を並べるのではなく、採用しなかった案と、その理由を中心に書きます。何を作ったかより、なぜそう決めたかのほうが、後から読んで役に立つためです。

    この記事で扱うこと

    HTML文字列からの見出し抽出、Reactのサーバーコンポーネントとクライアントコンポーネントの境界、IntersectionObserverによる現在位置の判定、localStorageとハイドレーションのずれ、そして「運用で止まらない設計」という考え方。最後に、生成AIが使えない環境でサムネイル画像を作った方法にも触れます。

    手書きの目次は、いつか必ず壊れる

    これまでの記事には、本文の先頭に目次を手書きしていました。見出しと同じ内容を、リンク付きのリストとしてもう一度書いていたわけです。

    この方式には構造的な弱点があります。同じ情報が2か所にあるということです。見出しを1つ足したら目次にも足す、順番を入れ替えたら目次でも入れ替える。どちらかを忘れた瞬間、目次と本文がずれます。

    しかも厄介なのは、ずれていても画面上は正常に見えることです。目次に無い節があっても、リンク先が消えていても、ページは普通に表示されます。壊れていることに気づく手がかりがありません。

    本文から機械的に作れば、この不整合は原理的に起こらなくなります。情報の出どころが1つになるからです。

    HTMLは「ただの長い文字列」でしかない

    実装で最初に理解しておく必要があるのは、記事本文がどういう形でReactに届くかです。

    WordPressから返ってくる本文は、HTMLの文字列です。React側から見ると、これはコンポーネントの木ではなく、ただの長いテキストです。<h2> が何個あるかを数えることも、その中身を取り出すこともできません。React上の要素ではないからです。

    したがって、見出しを拾うには文字列のまま解析するしかありません。

    src/lib/toc.ts
    // h2 と h3 を拾う。後方参照を使い、開始タグと同じ種類の閉じタグに対応させている
    const HEADING_PATTERN = /<(h2|h3)([^>]*)>([\s\S]*?)<\/\1>/gi;
    
    export function buildToc(content) {
      const toc = [];
      let autoIdCount = 0;
    
      const html = content.replace(HEADING_PATTERN, (whole, tag, attrs, inner) => {
        const text = toPlainText(inner);
        if (!text) return whole;
    
        // 既に id があるなら、その id をそのまま使う
        const existingId = extractId(attrs);
        const id = existingId ?? `section-${++autoIdCount}`;
    
        toc.push({ id, text, level: tag.toLowerCase() === "h3" ? 3 : 2 });
    
        if (existingId) return whole;
        return `<${tag}${attrs} id="${id}">${inner}</${tag}>`;
      });
    
      return { html, toc };
    }

    ここで意識したのは、既にidが振ってある見出しは、そのidを使い回すという点です。

    新しく連番を振り直すほうが実装は簡単ですが、それをやると公開済み記事のアンカー付きURLが全部変わります。外部から /blog/xxx#why-headless のようなリンクを張られていた場合、それが黙って壊れます。URLは公開した瞬間に他人が持つ資産になるので、こちらの都合で変えてはいけません。

    正規表現でハマった箇所

    どの記事に手書きの目次が入っているかを調べたとき、5本中3本しか該当しないという結果が出ました。原因は g フラグ付きの正規表現で test() を繰り返したことです。この場合、前回の検索位置が記憶されていて、1回おきに失敗します。件数が想定と違ったので調べ直して気づきました。数が合わないときは、だいたい数え方のほうが間違っています。

    サーバーで作れないものがある

    目次を追従させ、いま読んでいる節をハイライトし、読了状況をバーで示す。これらはすべてスクロール位置に依存します。

    ここで、Next.jsのApp Routerにおける重要な区別が出てきます。ページは既定でサーバーコンポーネントとして動きます。サーバー側でHTMLを組み立てて返すため、ブラウザに送るJavaScriptが減り、表示が速くなります。

    しかしサーバーが作れるのは「最初の1枚」だけです。スクロール位置は、そのあとブラウザの中で刻々と変わる値で、サーバーには存在しません。

    そこで、こうした部分だけをクライアントコンポーネント(ファイル先頭に "use client" と書く)に分けます。

    やりたいこと どちらで動かすか 理由
    本文から目次を抽出する サーバー 本文が手に入った時点で結果が決まる。ブラウザに解析処理を送る必要がない
    目次を表示し、開閉する クライアント 開いている・閉じているという状態を持つ
    いま読んでいる節を判定する クライアント スクロール位置はブラウザにしかない
    読了目安の時間を計算する サーバー 本文の文字数から決まる。何度計算しても同じ

    「取り出す」処理はサーバー、「見せる・動かす」処理はクライアント。この線引きを最初に決めておくと、迷いが減ります。

    スクロールのたびに全部測るのをやめる

    いま画面に見えている見出しを判定する方法として、最初に思いつくのはスクロールイベントです。スクロールのたびに、すべての見出しの座標を測って比べる。動きはします。

    ただしこの方法は、見出しが増えるほど重くなります。1本目の記事は見出しが26個あるので、スクロールのたびに26回の座標計算が走ることになります。スクロールイベントは1回のスクロール操作で何十回も発火するので、掛け算で効いてきます。

    ブラウザには、この用途のための仕組みが用意されています。

    src/components/TableOfContents.tsx
    const observer = new IntersectionObserver(
      (entries) => {
        const visible = entries
          .filter((entry) => entry.isIntersecting)
          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
    
        if (visible[0]) setActiveId(visible[0].target.id);
      },
      {
        // 画面全体ではなく「上から80px〜画面の30%まで」の帯だけを対象にする
        rootMargin: "-80px 0px -70% 0px",
        threshold: 0,
      },
    );
    
    headings.forEach((heading) => observer.observe(heading));

    IntersectionObserverは「この要素が画面に入った・出た」をブラウザ側で判定して教えてくれる仕組みです。座標計算をこちらでやらないので、見出しが増えても負荷がほとんど変わりません。

    rootMargin の指定には意味があります。画面全体を対象にすると、見出しが画面の下端に入った瞬間から「いま読んでいる節」と判定されてしまい、実際に読んでいる場所とずれます。上から80pxのあたりから画面の30%までという帯に絞ることで、体感に合わせています。

    進捗バーは、どこに置くかで意味が変わる

    読了状況を示すバーは、最初、画面の最上端に固定して表示していました。よく見かける形です。

    しかし実際に置いてみると、2つ問題がありました。ヘッダーと重なって見づらいこと。そして何の進捗なのかが、見ただけでは分からないことです。細い線が伸び縮みしているだけでは、それが読了率なのか、読み込み中の表示なのか、判断できません。

    そこで目次の中に移し、上に文字を添えました。

    目次パネルの表示
    読了まで              あと 58%
    ━━━━━━━━━━━━━━━━━━━━━
    
    目次
      まず全体像
      なぜヘッドレスにしたのか
      ...

    目次という「記事全体の地図」と同じ枠に入ることで、バーが何を表しているかが説明なしに伝わります。同じ部品でも、置く場所で有用にも無意味にもなります。

    数値は「読んだ割合」ではなく「残りの割合」にしました。読み手が知りたいのは、通ってきた距離ではなく、あとどれくらいで終わるのかという見通しだと考えたためです。

    細かいが効く指定

    パーセントの数字には tabular-nums という指定を入れています。数字の幅を揃えるCSSで、これが無いと 9% から 10% に変わる瞬間に桁が増え、文字の位置が横に揺れます。動くべきでないものが動くと、それだけで安っぽく見えます。

    自分の設計を取り下げた話

    読了状態をブラウザに保存し、次に開いたときも「読み終えた記事」と分かるようにしたい。そう考えました。

    最初は、やめておこうと考えました。理由はこうです。ページを開き直した直後、読者は記事の先頭にいます。そこで進捗バーが空なのに文字だけ「読み終えました」と出ていたら、表示同士が食い違って見えます。バーの仕事は現在位置を示すことなので、そこに過去の履歴を混ぜると、どちらの情報も信用されなくなる——そう考えました。

    この見立て自体は間違っていなかったと思います。ただし、そこから引き出した結論が違っていました。

    最終的に採用したのは、読了済みならバーを満杯の緑で固定するという形です。現在位置の表示をやめ、「この記事は読み終えている」という状態の表示に切り替えます。スクロールを戻しても「あと58%」には戻りません。

    src/components/ReadingProgress.tsx
    // 一度「読了済み」になったら、その表示のまま固定する。
    // スクロールを戻すたびに表示が行き来すると、
    // 読み終えたという事実が揺らいで見える。
    const [finished, setFinished] = useState(false);
    
    useEffect(() => {
      if (readBefore || remaining === 0) setFinished(true);
    }, [readBefore, remaining]);

    私が見落としていたのは、読み手にとって、読み終えたという事実のほうが、いま何%の位置にいるかより重要だという点でした。矛盾を避けようとして、優先順位の低いほうを守っていたことになります。

    設計の議論では「どちらが正しいか」より「どちらが読み手の関心に近いか」で決めるほうが、たいてい良い答えになります。

    サーバーが知らないことを表示する

    読了状態の保存先には、CookieではなくlocalStorageを選びました。

    Cookieは、そのドメインへのリクエストのたびに自動でサーバーへ送られます。読了状態はサーバーがまったく使わない情報なので、毎回送るのは通信の無駄です。容量も4KB程度しかなく、記事が増えると窮屈になります。localStorageはブラウザ内に閉じた保存領域で、サーバーには送られず、容量も数MBあります。

    ただしlocalStorageを使うと、必ずハイドレーションのずれという問題に当たります。

    ハイドレーションとは、サーバーが作ったHTMLに対して、ブラウザ側のReactが「同じものを作れるか」を確認しながら操作を結びつけていく処理のことです。ここで両者の出力が食い違うと、Reactが警告を出します。

    読了状態はまさに食い違います。サーバーはその人が記事を読んだかどうかを知りません。localStorageはブラウザの中にしか無いからです。したがってサーバーは常に「未読」として描き、ブラウザは「既読」と判断します。

    src/components/ReadBadge.tsx
    export default function ReadBadge({ slug }) {
      const [read, setRead] = useState(false);
    
      // 最初の描画では必ず false のまま。
      // 画面に出たあと(useEffect の中)で初めて localStorage を読む。
      // こうするとサーバーとブラウザの初回の出力が一致する。
      useEffect(() => {
        setRead(hasRead(slug));
      }, [slug]);
    
      if (!read) return null;
      return <span>読了済み</span>;
    }

    印が一瞬遅れて出ることになりますが、これが正しい作りです。サーバーが知り得ない情報は、サーバーの描画に混ぜてはいけません。

    もうひとつ、localStorageの読み書きは例外を投げることがあります。プライベートモードやブラウザの設定で無効化されている場合です。全ての操作を try/catch で囲み、失敗したら「記録しないだけ」で動き続けるようにしました。付加的な機能のために、記事そのものが表示できなくなってはいけません。

    押しても同じものが出てくるリンクを消す

    ここからは、実装というより判断の話です。

    実績のカードは、これまでカード全体が詳細ページへのリンクになっていました。ところが実際に運用してみると、詳細ページで増える情報は担当役割・実施期間・関連リンクの3つだけで、本文は一覧と同じものが出るだけでした。

    押しても、ほぼ同じ内容がもう一度出てくるリンクは、読み手の時間を奪います。そこで、詳細ページにしか無かった項目をカードに取り込み、遷移そのものを無くしました。情報を削るのではなく、1階層に畳んで見せる形です。

    外部サイトへのリンクだけは残しています。これは「同じ内容の別ページ」ではなく、実物を見に行く導線なので、押す価値があります。

    遷移を消したあとの後始末

    どこからもリンクされなくなった詳細ページは、URLを直接叩けばまだ表示されます。これは同じ内容が2つのURLで見られる状態で、検索エンジンからは重複コンテンツとして扱われます。使わないページは、リンクを外すだけでなく削除まで行う必要があります。

    情報を持たない画像は、面積の無駄になる

    トップページの実績カードには、鮮やかな抽象イラストが載っていました。オレンジ、紫、緑、青。見た目には賑やかです。

    しかしその画像は、実際に作ったものではなく、汎用のダミーUIやグラフのイラストでした。読み手はまず画像を見ますが、そこから得られるものが何もありません。カード内で最も面積を取っている要素が、最も情報量が少ない状態でした。

    さらに、サイト全体の配色方針と衝突していました。設定ファイルには「ニュートラルなグレー基調 + アクセント1色」と書いてあるのに、画面には5色以上が同時に出ていました。方針と実装がずれていたわけです。

    画像を外し、カテゴリのアイコンと名前を置く帯に置き換えました。同時に、本文側にあった重複したカテゴリ表示も削除しています。画像とアイコンで同じことを2回言っていた状態が、これで解消されました。

    色は、意味の違いを示すときに使う

    スキルの習熟度バーは、スキルごとに違う色が割り当てられていました。これも青1色に統一しました。

    判断の根拠はシンプルです。色の違いに意味が無かったからです。HTMLが橙でCSSが青であることに、何の情報もありません。差を表しているのはバーの長さです。色を外しても、失われる情報は1つもありませんでした。

    逆に、色を残した箇所もあります。読了済みの印を緑にしたのは、「未読と既読」という意味の違いを示しているからです。

    運用で止まる仕組みは、優れていても採用しない

    記事のサムネイル画像を作る段になって、いちばん考えさせられた判断がこれでした。

    最初の案には、記事ごとに内容を表すアイコンと、SERIES 01〜05という通し番号を入れていました。見た目は整っていました。

    ところが、並べてみて気づきました。この形は運用する際に困ります。

    連番のほうは分かりやすい問題です。番号は「順序」という情報を画像に焼き付けます。記事を1本追加した瞬間、あるいは途中に差し込んだ瞬間、番号は嘘になります。画像に焼いた情報は後から直せません。

    アイコンのほうは、もう少し見えにくい問題です。絵柄と内容は対応していたので、機能としては悪くありませんでした。問題は、記事を書くたびに「この記事のアイコンは何にするか」を決める作業が発生することです。これは判断を要する作業で、数分では終わりません。そして決めきれずに保留した記事は、アイコンが空白のまま公開されます。

    ここから引き出せる原則は、こうです。運用が止まる原因になる仕組みは、機能として優れていても採用すべきではない。更新されなくなったブログは、装飾が丁寧なことより痛手が大きいからです。

    改訂後のサムネイルで、記事ごとに決める要素はカテゴリとタイトルとサブタイトルだけになりました。カテゴリとタイトルはWordPressに入力済みなので、実質的に新しく決めるのはサブタイトルの1行です。

    あわせて、タイトルの文字数に応じて文字サイズを自動で切り替えるようにしました。長いタイトルのときに手で調整しなくて済みます。運用で困る箇所は、あらかじめ潰しておきます。

    生成AIが使えない環境で画像を作る

    サムネイルは当初、画像生成AIで作る想定でした。しかし使おうとした環境では画像生成が利用できず、別の方法を探すことになりました。

    次に試したのがストックフォトの検索です。結果は芳しくありませんでした。「ヘッドレスCMSの構成」「デプロイでハマった話」といった内容に対して返ってきたのは、住宅の設計図、青い積み木、白い展示台。この手の技術記事の内容に対応する写真は、そもそも存在しないという性質の問題でした。

    これを貼れば、さきほど実績カードから外したのとまったく同じ状態を作り直すことになります。

    そこで採った方法が、HTMLとCSSでデザインして、ヘッドレスブラウザで画像として書き出すというものです。

    サムネイル生成スクリプト(抜粋)
    const browser = await chromium.launch();
    const page = await browser.newPage({ viewport: { width: 1200, height: 630 } });
    
    for (const article of ARTICLES) {
      await page.setContent(html(article), { waitUntil: "load" });
      await page.screenshot({ path: article.file });
    }
    
    await browser.close();

    ブラウザを画面なしで動かし、作ったHTMLを表示させ、その画面を撮る。それだけです。この方式には、絵を描く方法として見たときに明確な利点があります。

    サイトのヒーローとまったく同じCSS(グラデーション、グリッド、ロゴ)を使えるので、デザインが自然に揃います。タイトルは文字として載るため、内容と必ず一致します。そして修正がききます。タイトルを変えたらコードを1行直して再実行するだけで、全部作り直せます。

    サイズは1200×630にしました。ブログ一覧のカードだけでなく、SNSで共有されたときのOGP画像としてもそのまま使える寸法です。

    作業中に踏んだ罠

    実装そのものより時間を取られた箇所を、3つ記録しておきます。どれも「コードは正しいのに動かない」という形で現れました。

    1. 見ていたサーバーが違った

    変更を反映したのに画面が404のまま、という状態が続きました。原因はターミナルの警告文にありました。

    見落としていた1行
    ⚠ Port 3000 is in use, trying 3001 instead.

    前に起動した開発サーバーが完全に止まっておらず、ポート3000を掴んだままでした。新しいサーバーは3001で起動しており、私たちが見ていた3000は、古い設定で動いていた別のサーバーだったわけです。

    2. 設定ファイルを読まないツールがあった

    GraphQLの型を再生成しようとして、何度やっても失敗しました。設定はこう書かれています。

    codegen.ts
    schema: process.env.NEXT_PUBLIC_WORDPRESS_API_URL ?? "http://localhost:8080/graphql"

    環境変数は .env.local に書いてあります。しかし.env.local を自動で読み込むのはNext.jsであって、型生成ツールではありません。環境変数が空のまま右側にフォールバックし、起動していないローカルWordPressを見に行って失敗していました。

    「同じフォルダにある設定ファイルは、全部のツールが読んでくれる」と思い込んでいると、この種の問題は見つかりません。

    3. 古いサイトが表示され続けた

    本番サイトを開いたら、更新前の古いデザインが出ました。一瞬デプロイの失敗を疑いましたが、URLに意味のないパラメータを付けて開き直すと、新しいサイトが表示されました。ブラウザのキャッシュです。

    共通しているのは、3つとも「コードの問題」ではなかったという点です。コードを疑って読み直しても答えは出ません。何かおかしいと感じたら、コードの外側——どのサーバーを見ているか、どの設定が読まれているか、いつのデータを見ているか——を先に確認するほうが早いことが多いです。

    今回の学び

    実装の話として3つ、判断の話として2つ、残しておきます。

    実装では、同じ情報を2か所に持たないこと。目次を手書きしていたのは、見出しという情報を二重に持っていた状態でした。片方から機械的に作れば、ずれようがありません。

    次に、サーバーが知り得ない情報は、サーバーの描画に混ぜないこと。localStorageの中身はブラウザにしかありません。初回は何も出さず、画面に出てから読む。遠回りに見えて、これが正しい順序です。

    そして、仕組みに用意された道具を使うこと。スクロールのたびに全要素を測る代わりにIntersectionObserverを使う。自分で書けば動きますが、ブラウザ側で最適化されている処理には敵いません。

    判断のほうは、まず面積と情報量を一致させること。大きく見せているものほど、多くを伝えるべきです。意味のない画像が最大面積を占めていたら、それは削るべき対象です。

    最後に、運用で止まる仕組みは採用しないこと。これが今回いちばん考えさせられた点でした。作っている最中は、機能が多いほど良いものに見えます。しかし公開したあとに待っているのは、記事を書き続ける日々です。そのとき障害になるものは、どれだけ丁寧に作られていても負債になります。

  • ポートフォリオサイトをヘッドレスWordPress+Next.jsで作り直した話 (4) デザイン調整・ブログ機能追加編

    ポートフォリオサイトをヘッドレスWordPress+Next.jsで作り直した話 (4) デザイン調整・ブログ機能追加編

    シリーズ最終回です。データがきちんと表示できるようになった後、「もとのサイトとだいぶ印象が違う」というフィードバックをもとに行ったデザイン調整と、このブログ機能を追加した経緯についてまとめます。

    もとのサイトの雰囲気に近づける

    ページ構成(実績・スキル・プロフィールを別ページに分ける形)はそのまま維持しつつ、見た目だけをもとのサイトに近づける方針にしました。具体的には、中央寄せの見出し+下線バーという共通の見出しコンポーネントを作ったり、ヘッダー・フッターに以前から使っていた「肉球マーク」のロゴを置き直したりしています。

    実績カードの画像・リンク・配色を見直す

    もともと実績カードには画像がなかったので、商用利用可能なオリジナル画像を4枚用意してカードに追加しました。また、実績の詳細ページへのリンクが軒並み404になっていた件は、前回の記事の通りスラッグの日本語表記が原因だったため、英数字への統一で解消しています。

    配色については、技術タグの色をハッシュ値だけで機械的に割り当てていたため、たとえば「JavaScript」が黄色ではなく別の色になってしまうなど、見た人が持っている技術のイメージと合わない配色になっていました。そこで、HTML・CSS・JavaScript・WordPress・PHPなど、よく知られた技術については実際のブランドカラーに近い色を明示的に割り当て、それ以外の名前だけハッシュ値ベースの配色にするよう調整しました。

    プロフィール画像は、新しく作り直すのではなく「もとのサイトの画像をそのまま使う」と決めたので、WordPressのメディアライブラリにもとの画像を取り込み直して差し替えています。

    カラーパレットの調整とブログ機能の追加イメージ

    ブログ機能を追加する

    ここまでで実績・スキル・プロフィールのリニューアルは一段落したので、追加で「ブログ」機能を作りました。専用のカスタム投稿タイプは作らず、WordPress標準の「投稿」をそのまま使い、カテゴリだけ「技術系」「読んだ本の感想」の2つを新設しています。Next.js側では /blog に一覧ページ、/blog/[slug] に詳細ページを用意し、一覧ページではカテゴリ別の絞り込みタブも付けました。今読んでいただいているこの記事も、その「技術系」カテゴリの投稿の1つです。

    シリーズを終えて

    静的サイトの書き換えから始まり、WordPressのDocker環境構築、GraphQLでのデータ取得、細かい不具合対応、デザインの調整、そしてブログ機能の追加まで、全4回で振り返ってきました。実務であまり触れる機会のないヘッドレスCMS構成を、自分のポートフォリオという実物を通して一通り経験できたのは、学習の題材として良かったと思っています。今後は「技術系」「読んだ本の感想」の記事を少しずつ増やしていく予定です。