タグ: UI設計

  • リンク先は存在した。それでも404だった

    リンク先は存在した。それでも404だった

    連載の記事に「次回:〇〇」というリンクを書いています。そこを押すと404でした。

    リンク切れは事前に確認していました。結果は0件でした。確認の仕方が間違っていたのです。

    この記事で扱うこと

    存在するのに辿り着けないリンクの話です。確認が通ってしまった理由、直し方を3つ比べて1つを選んだ過程、そして表示するときに判定する実装を書きます。

    何を数えていたか

    確認の中身はこうでした。全記事の本文から /blog/ で始まるリンクを抜き出し、その行き先のスラッグがCMSに存在するかを見る。存在しないものがあれば、それがリンク切れです。

    この方法で、112本ぶんを突き合わせて0件でした。

    存在するかどうかを見ていた

    CMSの中には、公開済みの記事と、予約投稿の記事が両方入っています。

    連載の第1回を公開した時点で、第2回はまだ予約投稿の状態です。CMSには在るので、確認は通ります。しかしサイトに出ているのは公開済みのものだけなので、押すと404に着きます。

    2つの集合がずれている

    整理すると、こうなっていました。

    含まれるもの
    CMSにある記事 公開済み + 予約投稿 + 下書き
    サイトに出ている記事 公開済みだけ

    確認していたのは上の集合で、読む人が見ているのは下の集合でした。差の部分が、そのまま404になります。

    書いている側からは見えません。管理画面では第2回が普通に並んでいますし、編集もできます。書き手の視界には、公開前の記事も入っているからです。

    直し方を3つ比べた

    案 やめた理由 / 選んだ理由
    公開のたびに本文からリンクを外す 連載は5本あり、回が進むたびに手を入れる。忘れた回だけ404が残る
    「次回」を書かない 404は消えるが、次があることも伝わらなくなる。連載として損
    表示するときに判定する 採用。書く側は常に「次回」を書ける。公開日が来れば自動でリンクに戻る

    1つめを外したのは、人が覚えていることを前提にした手順だからです。今回そもそも見落としたのは、覚えていることを前提にした確認をしていたからでした。同じ形の対策を足すと、同じ理由で破れます。

    本文を通す場所が、もう1段増えた

    このサイトの本文は、表示する前にいくつかの処理を順番に通ります。商品カードを差し込み、リンクの末尾の形を揃え、見出しに目印を付ける。そこに1段足しました。

    const posts = await getPosts();
    
    // getPosts() は公開済みのものだけを返す。
    // ここに無いスラッグは「CMSには在るが、まだ出ていない」記事になる。
    const publishedSlugs = new Set(
      posts.map((p) => p?.slug).filter((s): s is string => Boolean(s)),
    );
    
    const rawContent = softenPendingLinks(
      normalizeInternalLinks(injectProducts(post.content)),
      publishedSlugs,
    );
    

    判定の材料はもともと手元にありました。前後の記事と関連記事を出すために、記事一覧をすでに取っています。取得の順番を入れ替えただけで、通信は1回も増えていません。

    リンクを消さずに、文字にした

    return html.replace(POST_LINK, (whole, _before, slug, _after, text) => {
      if (publishedSlugs.has(slug)) return whole;
      return `<span class="pending-link">${text}<span class="pending-link-note">(近日公開)</span></span>`;
    });
    

    行き先が無いなら丸ごと消す、という手もありました。選ばなかったのは、予告そのものには意味があるからです。

    押せないことと、無いことは違う

    「次回:打ち方が違っても、同じ言葉として扱う」という一文は、続きがあることと、その内容を伝えています。押せるかどうかとは別の仕事です。

    消すと、その情報まで一緒に消えます。薄い文字にして「(近日公開)」を添えれば、押せないことは分かり、何が来るかは残ります。

    「(近日公開)」をCSSの装飾ではなく本文の一部として出しているのは、読み上げや、文字を選んでコピーしたときに落ちないようにするためです。押せない理由は、飾りではなく中身として持たせることにしました。

    置き換えで壊しそうな場所

    本文のHTMLを書き換える処理は、意図しないものまで巻き込みます。6つ試しました。

    試したこと 期待
    公開済みへのリンク そのまま残る
    1つの行に「前回」と「次回」が並ぶ 前回は残り、次回だけ文字になる
    class や title が前後に付く 拾える
    連載の目次(/series/)へのリンク 触らない
    外部サイトのURL 触らない
    リンクの文字に <strong> が入る 中のタグを保つ

    5つめは見落としやすい形をしています。外部のURLにも /blog/ という並びが現れることがあるので、行き先がサイト内かどうかを先に判定する必要がありました。

    確認そのものを直す

    今回いちばん効いたのは、コードの修正ではありません。確認の条件を書き直したことです。

    見ていたもの
    前 リンク先のスラッグが、CMSに存在するか
    後 リンク先のスラッグが、公開済みの一覧に在るか

    条件を書き直して数え直したら、公開中の57本のうち、公開前を指しているリンクは1件でした。指摘を受けたその1件です。数が合ったことで、見落としの範囲が確定しました。

    公開日が来たら、本当に戻るのか

    「公開日が来れば自動でリンクに戻る」と書きましたが、これは作った側の見込みでしかありません。確かめる機会は、2日後に来ました。

    第2回の公開日に、第1回と第2回を両方開いて、連載の案内を見比べました。

    記事 「次回」の行き先 表示
    第1回 第2回(その日に公開) リンクに戻っていた。押すと開く
    第2回 第3回(まだ予約) 文字のまま、「(近日公開)」付き

    第1回の「次回」は、2日前まで押せない文字でした。その日の朝、誰も何も触らないまま押せるリンクになっていて、代わりに第2回の「次回」が押せない文字になっていました。1本ずつ、順送りに開いていく形です。

    戻ることまで確かめて、はじめて直ったと言える

    押せなくする側だけを見て終わりにしていたら、「公開後もずっと押せないまま」という別の壊れ方に気づけませんでした。

    表示を止める仕組みは、止めたものを再開させる側まで確認してようやく半分ではなくなります。

    今回の学び

    まず、存在することと、辿り着けることは別だということ。手元のデータで「在る」と確認できても、読む人に配っている集合に入っていなければ意味がありません。確認は、読む人が見るほうの集合でやる必要がありました。

    次に、書き手の視界は広すぎること。管理画面には公開前のものも並びます。そこを基準に考えると、自分にだけ見えているものを、全員に見えていると錯覚します。

    そして、人が覚えていることを前提にした対策を足さないこと。見落としの原因が「覚えていること前提の確認」だったのに、対策も同じ形にしたら、同じ理由で破れます。表示するときに判定する形にしたのは、そこを繰り返さないためでした。

  • フォームは作ってあった。行き方が無かった

    フォームは作ってあった。行き方が無かった

    このサイトには、問い合わせフォームが置いてあります。プロフィールページの一番下です。

    リンクを探したところ、サイトのどこにもありませんでした。

    この記事で扱うこと

    作ってあるのに辿り着けなかったフォームの話です。なぜ気づかなかったのか、フォームを増やさずに直した理由、そして同じ原因で他にも抜けが見つかった話を書きます。

    辿り着ける人が限られていた

    フォームに行く方法は、実質ひとつしかありませんでした。プロフィールページを最後まで読むことです。

    場所 フォームへのリンク
    ヘッダー 無し
    フッター 無し
    トップページ 無し
    記事ページ 無し
    プロフィールの末尾 フォームそのもの

    プロフィールは経歴と対応範囲が並ぶ長いページです。そこを下まで読み切った人だけが、フォームの存在を知ることになります。

    トップページが、記事一覧へのリンクで終わっていた

    もうひとつ、流れの問題がありました。

    トップページは、実績 → スキル → プロフィール → ブログ、という順に並んでいます。そして最後が「ブログ記事をすべて見る →」で終わります。

    一通り見た人の行き先が無い

    実績を見て、できることを見て、書いているものを見て、「頼めるか聞いてみよう」と思った人が、そこから行ける場所を持っていませんでした。

    案内があるのは記事一覧だけです。読み物としては続きますが、相談したい人にとっては話が逸れます。

    なぜ気づかなかったか

    作った本人は、フォームの場所を知っています。確認するときは /profile を開いて下までスクロールすればよく、「どうやってそこへ行くか」を試す必要がありません。

    動いているかどうかは何度も確認しました。送信できるか、エラーが出るか、文面は適切か。全部見ています。見ていなかったのは、その手前でした。

    フォームは増やさなかった

    直し方として、トップページにもフォームを置く案がありました。すぐ書けます。

    やめました。同じものが2か所にあると、直すときに片方だけ直ります。

    案内と、実体を分ける

    トップページに置いたのは案内だけです。何を相談できるか、返事がどう来るかを書いて、フォームのある位置へ送ります。

    フォームは1つのまま。文面や項目を直すときに、探す場所が1か所で済みます。

    フッターにもリンクを足しました。記事を下まで読み終えた人が、そのまま相談に移れる位置です。

    同じ原因で、他にも抜けていた

    フッターを触ったついでに気づいたのですが、フッターのリンク一覧には連載と用語集が入っていませんでした。

    どちらも後から足したページで、ヘッダーには入れたのにフッターには入れ忘れていました。記事を最後まで読んだ人が次へ移ろうとすると、いったん上まで戻らないと辿り着けない場所があったことになります。

    足す側は覚えているが、足した先は2か所ある

    新しいページを作ったとき、意識は「そのページ」に向いています。ヘッダーに入れた時点で、行けるようになったと感じてしまいます。

    入口は複数あるので、片方だけ更新された状態はそのまま残ります。

    「#」で飛ぶ処理が、1ページ専用だった

    もうひとつ、直しが必要になった箇所があります。

    フォームへのリンクは /profile#contact の形です。ところがこのサイトは画面を読み込み直さずに中身だけ差し替えるため、URLの「#」を見てブラウザが動いてはくれません。その位置まで運ぶ処理を自分で書いてあります。

    その処理を、トップページの中にだけ置いていました。行き先が /#projects のようにトップの中しか無かったからです。プロフィールが行き先になった時点で、足りなくなりました。

    置き場所を全ページ共通の場所へ移して、どのページでも効くようにしています。行き先が増えたときに、仕組みの置き場所も見直す必要があったということでした。

    今回の学び

    まず、動くことと、辿り着けることは別だということ。フォームは何度も動作確認をしていましたが、確認していたのは中身だけで、そこへ行く道は一度も試していませんでした。

    次に、作った人は道を探さないこと。場所を知っている人は直接開きます。行き方が無いことは、知らない人の立場に立たないと見えません。

    そして、同じものを2か所に置かないこと。案内は何か所あってもよく、むしろ多いほうがよい。ただし実体は1つにしておかないと、直したつもりの片方が残ります。

  • 印を付けただけでは、探せるようにならなかった

    印を付けただけでは、探せるようにならなかった

    記事一覧のカードには、前から「読了済み」の印を付けていました。

    付けた当時は57本でした。いまは110本を超えています。印は役に立たなくなっていました。

    この記事で扱うこと

    読了の印だけでは足りず、未読だけを出す絞り込みを足した話です。URLに入れなかった理由、読んだ人にだけボタンを出した理由、そして0件の見せ方を書きます。

    見分けることと、探すことは違う

    印が付いていれば、目の前のカードが既読かどうかは分かります。見分けることはできます。

    けれど「まだ読んでいないものを見たい」ときにやることは、100枚のカードを上から順に見て、印の付いていないものを拾う作業になります。これは探す作業で、印はそれを助けていません。

    それは一覧の仕事だった

    並んでいるものから条件に合うものを取り出すのは、一覧の側がやることです。

    印だけ出して残りを目でやらせるのは、判定の材料は渡したが、判定はそちらでやってくれと言っているのと同じでした。

    URLには入れなかった

    この一覧には、すでにカテゴリとタグの絞り込みがあります。どちらも /blog?category=tech のようにURLに入ります。

    未読の絞り込みは、同じ形にしませんでした。

    絞り込み 誰にとっても同じか URLに入れるか
    カテゴリ・タグ 同じ。誰が開いても同じ記事が出る 入れる
    未読 人によって違う 入れない

    URLに入れるということは、その状態を他の人に渡せるということです。カテゴリなら渡せます。「技術系の記事はここ」と共有できます。

    未読は渡せません。渡された側にとっては、自分と関係のない条件で記事が消えている一覧になります。共有したつもりの人も、渡された人も、何が起きたか分かりません。

    読んだことがある人にだけ出す

    ボタンは、1本でも読んだ記録がある人にだけ出しています。

    初めて来た人にとっては、全部が未読です。押しても何も変わりません。

    効かないものが置いてあると、その場所ごと見なくなる

    押して何も起きない経験を一度すると、そのボタンは「効かないもの」として記憶されます。

    後日ちゃんと効く状態になっても、もう押されません。意味を持つまで出さないほうが、意味を持ったときに使われます。

    0件を、失敗のように見せない

    未読だけにして0件になる状態は、全部読み終えたという意味です。良い状態です。

    ところが検索の0件と同じ文面を出すと、何かを間違えたように見えます。そこで分けました。

    状況 出す文面
    検索で0件 「〜に当てはまる記事はありませんでした」+用語集への案内
    未読が0件 「記事はすべて読み終えています」+絞り込みを解除するボタン

    解除のボタンを添えているのは、行き止まりにしないためです。0件の画面で、絞り込みを外す手段がその場に無いと、戻り方を探すことになります。

    戻ってきたときに、数が合わない

    実装して最初に気になったのが、ここでした。

    一覧から記事を開いて読み、ブラウザの「戻る」で一覧に帰ってくる。このときページは作り直されません。離れたときの画面がそのまま出てきます。

    いま読んだ記事が、未読のまま残る

    読了の記録は更新されているのに、一覧は古いままです。「未読だけ」と書いてあるのに、たったいま読んだ記事が並びます。

    戻ってきたときに発生する合図(pageshow)で読み直すようにしました。結果として、戻った瞬間にいま読んだ記事が一覧から消えます。目の前で消えるのは少し驚きますが、「未読だけ」と書いてある以上、消えるほうが正しいと判断しました。

    読了の記録は、こちらには届いていない

    念のため書いておくと、どの記事を読んだかという記録はその人のブラウザの中にだけあります。こちらのサーバーには送っていませんし、見ることもできません。

    だからこの絞り込みは、サーバー側では掛けられません。ブラウザ側でやっているのはそのためです。

    今回の学び

    まず、材料を出すことと、仕事をすることは違うということ。印は判定の材料でしたが、判定そのものは読む人に残っていました。数が増えると、その差がそのまま手間になりました。

    次に、渡せる条件と渡せない条件があること。URLに入れるかどうかは、実装の都合ではなく「他の人に渡して意味があるか」で決まりました。

    そして、0件には種類があること。見つからなかった0件と、やり終えた0件は別のものです。同じ文面で済ませると、良い知らせが悪い知らせに見えます。

  • 勉強法(4) 教える側に回ると、穴が見える

    勉強法(4) 教える側に回ると、穴が見える

    プログラミングを教えていると、質問が2種類に分かれます。

    片方は調べれば答えが出ます。もう片方は、自分が何を根拠に答えているのか分からなくなります。

    この記事で扱うこと

    教える側に回って見えた、自分の理解の穴。答えられなかった質問の種類と、そのあと説明の仕方をどう変えたかです。

    答えられない質問は、いつも同じ形だった

    質問 答え方
    この書き方が分からない 調べれば出る。一緒に調べればいい
    エラーが出た 読めば書いてある。読み方を渡せばいい
    どっちがいいですか 答えが1つに決まらない

    3つめで詰まります。「そっちでいいと思います」とは言える。ただ、なぜそう思うのかを説明しようとすると、経験からそう思う、としか言えませんでした。

    自分が使えていることと、人に渡せることは別

    自分で書くときは迷いません。手が勝手に選んでいます。

    ところが理由を言葉にしようとすると出てこない。選べているのに、選び方を説明できない状態でした。使えてはいるが、分かってはいなかったわけです。

    教えやすいことと、実務で差が出ることがずれている

    教材にしやすいのは、正解のあるものです。文法、関数の使い方、エラーの読み方。手順が決まっていて、できたかどうかも判定できます。

    ところが、仕事で差が出るのはそこではありませんでした。

    教えやすいこと 実際に差が出ること
    書き方を覚える どこまで作り込むかを決める
    エラーを直す 同じエラーが起きない形にする
    言われたとおりに作る 言われていない前提を確認する

    右の列は、教科書の形になりにくい。正解が状況によって変わるので、「こうしなさい」と言えないからです。

    結論ではなく、後で起きることを渡す

    説明の仕方を変えました。それまでは「こう書くといい」と結論だけを渡していました。いまは「こうすると後で何が起きるか」を先に話します。

    たとえば、同じ設定値を2か所に書いた場合。その場では動きます。問題が出るのは、片方だけ変えたときです。

    「重複は避けましょう」だと、禁止として覚えて終わります。「今は動くが、変えるときに壊れる」だと、理由として残ります。次に似た場面が来たとき、自分で判断できる形になります。

    判断の材料を渡すと、質問が変わる

    この形にしてから、「どっちがいいですか」が「こっちだと後でこうなりますよね」に変わることが増えました。

    答えを渡していたときは、次も同じ質問が来ていました。材料を渡すと、途中まで自分で進んでから聞きに来ます。

    自分の側にも効いた

    説明のために言葉にしたことが、自分の作業にも戻ってきました。

    このサイトのコードには、処理の説明ではなく「なぜその選択をしたか」を書いています。1か所にまとめた理由、別のファイルに切り出した理由、選ばなかった案。

    これは人に説明するときに必要だった形と同じです。半年後の自分は、今の自分が何に困っていたかを覚えていません。困りごとごと残しておかないと、また同じ場所で悩みます。

    残す内容 あとで役に立つか
    何をしているか コードを読めば分かる
    なぜそうしたか コードからは分からない
    選ばなかった案 直すときに、戻って考えずに済む

    教える相手が、自分の穴を教えてくれる

    答えられなかった質問は、そのまま自分が言葉にできていない部分でした。

    1人で書いているかぎり、そこは表に出てきません。動いているからです。聞かれて初めて、説明できないことに気づきます。

    本を読んで穴を埋めたこともありますが、穴の場所を教えてくれたのは、いつも質問のほうでした。

    今回の学び

    まず、選べることと、選び方を説明できることは別だということ。手が勝手に選んでいるうちは、使えてはいても分かってはいませんでした。

    次に、結論より、後で起きることを渡すこと。「こう書きなさい」は覚えて終わりますが、「こうしないと何が起きるか」は判断の材料になります。渡す形を変えただけで、返ってくる質問が変わりました。

    そして、自分の穴は、自分では見つけにくいこと。1人で書いていると、動いている場所は疑いません。答えに詰まった質問の一覧が、そのまま埋めるべき場所の一覧でした。

  • 勉強法(2) 測らないと、分かったことにならない

    勉強法(2) 測らないと、分かったことにならない

    「分かった」と思った直後に、間違っていたことが何度かありました。

    いちばん効いたのは、自分の理解を外から確かめる手順を持つことでした。今回はその話です。

    この記事で扱うこと

    このサイトで実際にやった「確かめ方」を3つ。どれも、確かめていなかったら間違ったまま進んでいたものです。

    1. 数字を出したら、始まりと終わりを見る

    記事の読み進み具合を出すバーを作りました。動いています。そう思っていたら、開いた瞬間の数字が目に入りました。

    ページを開いた瞬間に9.5%を指していました。まだ何もしていません。

    調べると、計算そのものは正しかった。画面の下端を基準にしていたので、開いた時点で本文の一部がすでに映っていたのです。「画面に映った本文の割合」としては9.5%が正確な値でした。

    式が答えている問いと、表示が名乗っている問いが違った

    表示が名乗っていたのは「読み進み具合」です。読み進み具合なら、読み始める前は0のはずでした。

    計算間違いではなく、意味のずれです。数字が合っているかを確かめても見つかりません。「0から始まっているか」「最後で100になるか」という、両端を見る確かめ方でしか出てきませんでした。

    2. 試した環境が、確かめたい条件を満たしているか

    同じ読了判定で、もっと危ない間違いをしました。

    ブラウザを自動で動かして、記事を最後までスクロールさせました。読了として記録されません。「記録されない不具合がある」と結論を出しかけました。

    念のため、スクロールのイベントが何回起きたかを数えました。

    発火回数: 0

    自動で動かしているタブでは、スクロールのイベントがそもそも発生していませんでした。不具合が無くても、同じ結果になります。

    結論を取り下げるほうが、直すより先だった

    この時点で分かったのは「読了が記録されない」ではなく、「この方法では確かめられない」ということだけです。

    ここで直しにかかっていたら、動いているものを壊していました。試した環境が条件を満たしているかは、結果より先に確かめる必要がありました。

    そのあとは、ブラウザで試すのをやめて、実際の寸法を使って計算で確かめました。

    3. 原因が分からないときは、半分に割る

    コードを含む記事を投稿しようとしたら、サーバーに弾かれました。403が返るだけで、理由は何も出ません。

    本文は4500字あります。どこが原因かは分かりません。

    そこで、本文を4つに割って1つずつ試しました。

    試した範囲 結果
    1/4ずつ(4回) 2番目と3番目が弾かれる
    そのうち1つを8分割 2か所に絞れた
    怪しい行を単体で 2つの書き方に特定

    最終的に、引っかかっていたのは2つの書き方だけだと分かりました。理由を推測せずに済んだのは、割って試したからです。

    推測で当てにいくと、外れたことにも気づけない

    最初は「コードの記号が多いからだろう」と考えて、記号を別の表記に置き換えました。それでも弾かれました。

    置き換えが効かなかったこと自体が情報で、相手が表記を戻してから見ていると分かりました。推測が外れたと分かったのは、確かめる手順があったからです。

    確かめ方は、覚えるものではなかった

    3つ並べてみると、覚えている知識はほとんど出てきません。

    場面 やったこと
    数字を出した 両端の値を見た
    試したら失敗した 試し方が成立しているか数えた
    原因が分からない 範囲を半分に割った

    どれも技術というより手順です。そして3つとも、自分の理解を疑うところから始まっています。

    今回の学び

    まず、「動いた」は「合っている」ではないということ。進捗バーは最初から最後まで動いていました。動きを見ているかぎり、9.5%から始まっていることには気づけません。

    次に、確かめた結果より先に、確かめ方を確かめること。0回しか起きていないイベントを待っていたら、どんな実装でも同じ結果になります。出た結果を信じる前に、その試し方で違いが出るのかを見る必要がありました。

    そして、分からないときは推測ではなく分割すること。4500字から2つの書き方まで絞るのに、知識は要りませんでした。要ったのは、半分に割って試す根気だけでした。

  • 見た目で区別が付いているものは、機械には区別が付いていない

    見た目で区別が付いているものは、機械には区別が付いていない

    ブログ一覧を見て、見出しの構造を確かめました。ページの題名にあたる見出しが、1つもありませんでした。

    画面ではちゃんと「ブログ」と大きく出ています。その下に記事の題名が並んでいます。誰が見ても、どちらが上でどちらが中身かは分かります。分からないのは、字の大きさが見えない側だけでした。

    この記事で扱うこと

    見た目で伝わっている区別が、機械には伝わっていなかった話です。何が起きていたか、どう直したか、そして「見えるから伝わっている」という思い込みについて書きます。

    全部が同じ格で並んでいた

    見出しには段階があります。h1 がそのページの題名、h2 がその中の章、というように、番号が入れ子の深さを表します。

    一覧ページを調べたときの中身は、こうなっていました。

    画面での見え方 実際の見出し
    ブログ(大きい文字) h2
    記事の題名 h2
    記事の題名 h2
    記事の題名 h2

    h1 はどこにもありません。そして「ブログ」と個々の記事が、同じ h2 で横並びになっていました。

    画面上は、文字の大きさが違うので迷いません。「ブログ」は太くて大きく、記事の題名はそれより小さい。人は大きさの差を、上下関係として読み取ります。

    大きさは、機械には渡っていない

    文字の大きさは見た目の指定であって、構造の指定ではありません。読み上げソフトが見出しをたどるとき、参照するのは番号のほうです。

    番号が同じなら同じ格です。したがって、たどっていくと「ブログ」も1本の記事も、並列の項目として現れます。このページが何のページなのかを名乗る見出しが、存在しないことになります。

    見出しは飾りではなく、目次として使われる

    見出しに番号を付ける意味は、画面を上から順に見られない人にとってはっきり出ます。

    読み上げソフトには、見出しだけを拾って移動する機能があります。本文を全部聞かずに、章の一覧から目的の場所へ飛べます。見出しの番号は、そのまま目次の階層になります。

    階層が平らだと、この目次は「同じ深さの項目が延々と並んだもの」になります。どれがページの題名で、どれが中身なのかが分かりません。全部を聞いて中身から判断することになります。

    検索エンジンから見ても事情は似ています。ページの主題を示す位置に何も無く、記事の題名だけが同じ格で並んでいる状態です。

    直すのは1か所ではなかった

    見出しは共通の部品で描いていました。ここが便利であると同時に、問題が広がった理由でもあります。

    部品の中で h2 を決め打ちしていたので、どのページで使っても必ず h2 になります。ページの題名として使いたい場合でも h2 でした。

    そこで、部品の側に「どの格で出すか」を渡せるようにしました。既定は今までどおり h2 のままにして、ページの題名として使うところだけ h1 を指定します。

    <SectionTitle as="h1">ブログ</SectionTitle>

    見た目は変えていません。字の大きさも余白もそのままです。変えたのは、機械が読む側の情報だけです。

    他のページも同じだった

    1か所で起きているなら、他でも起きています。全ページを取り寄せて h1 の数を数えました。

    ページ 直す前 直した後
    トップ 1つ 1つ
    ブログ一覧 0 1つ(ブログ)
    用語集 0 1つ(用語集)
    用語集の分類 0 1つ(分類名)
    プロフィール 0 1つ(プロフィール)
    プライバシーポリシー 0 1つ(ポリシー名)

    トップだけが正しく、残りは全滅でした。理由も同じで、トップは共通部品を使わずに直接書いていたからです。共通化した側だけが揃って間違っていました。

    数えれば分かる種類の不具合

    この確認は難しくありません。各ページのHTMLを取り寄せて、h1 がいくつあるかを数えるだけです。0でも2以上でも、何かおかしい合図になります。

    難しかったのは、確かめようと思うことのほうでした。画面上は最初から正しく見えていたので、疑う理由がありませんでした。

    今回の学び

    まず、見た目で付けた区別は、見た目の外には出ないということ。字を大きくすれば人には上位だと伝わりますが、それは番号にも構造にも変換されません。人に伝わっているかどうかと、機械に伝わっているかどうかは別に確かめる必要がありました。

    次に、共通部品は、正しさも間違いも同じだけ広げること。見出しの部品を1つにまとめたおかげで、直すときは1か所で済みました。同じ理由で、間違いも5ページに均等に配られていました。まとめた部分ほど、最初の設計を疑う価値があります。

    そして、「壊れていない」と「正しい」は違うということ。h1 が無くてもページは表示され、リンクも動き、誰も困った報告をしません。症状の出ない不具合は、症状を待っていても見つかりません。数えて確かめるまで、無いことに気づけませんでした。

  • ソース解説(2) 目次は本文から作る

    ソース解説(2) 目次は本文から作る

    記事の目次は、以前は手で書いていました。見出しを1つ足すたびに目次も直す。直し忘れると、目次に無い節ができます。

    しかも壊れていても画面は普通に見えます。リンクが切れているか、節が抜けているかは、上から順に確かめないと分かりません。

    いまは本文から機械的に作っています。今回はその処理を、実際のコードで読んでいきます。

    この記事で扱うこと

    本文が「文字列」として届くこと、そこから見出しを拾う方法、既にあるidを勝手に変えてはいけない理由、そして読んでいる位置をどう追いかけているかです。

    本文は、部品の木ではなく1本の文字列で届く

    前提から確認します。記事の本文はWordPressからHTMLの文字列として届きます。

    画面に出す側から見ると、これは部品の集まりではありません。<h2>…</h2> という文字の並びが入った、長い1本の文字列です。

    だから見出しを数えるにも、リンクを張るにも、文字列のまま扱うしかありません。ここが出発点です。

    見出しを拾う

    使っているのはこの1行です。

    const HEADING_PATTERN = /<(h2|h3)([^>]*)>([\s\S]*?)<\/\1>/gi;

    3つに分けて読みます。

    部分 拾うもの
    (h2|h3) 見出しの種類
    ([^>]*) タグに付いている属性
    ([\s\S]*?) 見出しの中身

    最後の \1 が効いています。これは「1つめで拾ったのと同じ文字」という意味です。h2 で始まったなら </h2> で終わるものだけを拾います。ここを (h2|h3) と書いてしまうと、<h2> で始まって </h3> で終わる、ありえない組み合わせまで拾ってしまいます。

    [\s\S]*? は「改行を含む、なんでも」。見出しが複数行に分かれていても拾えます。末尾の ? は「できるだけ短く」で、これが無いと最初の見出しから最後の見出しまでを丸ごと1つとして拾ってしまいます。

    中身からタグを落とす

    見出しの中に <code> や <strong> が入っていることがあります。目次には文字だけ欲しいので、タグを落とします。

    このとき、実体参照も戻します。&amp; のままだと、目次に「&amp;」と見えてしまうからです。

    順番 戻す対象 戻した結果
    1 &nbsp; 空白
    2 &lt; <
    3 &gt; >
    4 &quot; 二重引用符
    5 &#39; 単引用符
    6 &rarr; →
    最後 &amp; &
    順番を変えると壊れる

    &amp; を先に戻すと、&amp;lt; がまず &lt; になり、次の行で < になります。

    元の文字列は「&lt; という文字を表示したい」という意味でした。2回変換したせいで、本物のタグの記号に化けています。置き換えの順番だけで結果が変わる例です。

    既にあるidは、絶対に変えない

    見出しにはリンク先としてのidが要ります。無ければ振りますが、既にあるものは触りません。

    const existingId = extractId(attributes);
    const id = existingId ?? ("section-" + (++autoIdCount));

    ?? は「左が無いときだけ右を使う」。ここが分かれ目です。

    idを振り直すと、外から張られたリンクが切れる

    公開済みの記事の見出しに、誰かが #その見出し 付きのURLで言及しているかもしれません。

    順番でidを振り直すと、記事の途中に節を1つ足しただけで、それ以降のidが全部ずれます。ページは開けるので404にもならず、ただ違う場所に着地します。壊れたことに気づく手段がありません。

    そして、idを書き足すときは既にある場合と分けています。

    if (existingId) {
      // 属性をそのまま使い、id は書き足さない
    } else {
      // 属性のうしろに id を1つ書き足す
    }

    属性をそのまま持ち回しているので、既にidがある側で書き足すと id が2つ並びます。分けているのはそのためです。

    見出しへのリンクを、その場で差し込む

    同じ処理のついでに、見出しの末尾へリンクを足しています。

    <a class="heading-anchor" href="#見出しのid"
       aria-label="この見出しへのリンク">
      <span aria-hidden="true">#</span>
    </a>

    「#」は見る人には意味が通じますが、読み上げると「シャープ」としか聞こえません。そこで記号は読み上げの対象から外し、説明を別に持たせています。

    ブラウザ側で後から差し込むこともできますが、ここで一緒に組み立てれば、訪問者に送るJavaScriptが1行も増えません。

    読んでいる位置を追いかける

    ここからはブラウザ側です。いま画面に見えている見出しを光らせています。

    スクロールのたびに全見出しの座標を測る方法もありますが、それは見出しが多い記事ほど重くなります。代わりに、ブラウザ側で交差判定をしてくれる仕組みを使っています。

    rootMargin: "-80px 0px -70% 0px"

    この1行が効いています。画面全体ではなく、上から80pxより下、かつ画面の上から30%までの帯だけを対象にしています。

    なぜ画面全体ではないのか

    画面全体を対象にすると、見出しが画面のいちばん下に入った瞬間から「いま読んでいる節」と判定されます。まだ目は前の節を読んでいるのに、目次の光る場所だけ先に進みます。

    帯を上のほうに絞ると、判定の位置と目の位置が近くなります。実感に合わせるための調整が、この数字です。

    今回の学び

    まず、手で二重に持つものは、いずれずれるということ。目次と本文は同じ情報を2か所に持っていました。片方から作れば、ずれようがありません。今回の処理は、正確に言えば目次を作っているのではなくずれる可能性を消しているほうが本題でした。

    次に、既に外へ出したものは、勝手に動かせないこと。idを振り直せばコードは簡単になりますが、外から張られたリンクが静かに壊れます。自分の都合で変えられるものと、変えられないものがあります。

    そして、正しい判定と、実感に合う判定は別だということ。見出しが画面に入ったかどうかの判定は、どちらの帯でも「正しく」動きます。合うほうを選ぶには、数字ではなく自分で読んでみるしかありませんでした。

  • 同じ「丸み」でも、比べる相手が違えば別の表に置く

    同じ「丸み」でも、比べる相手が違えば別の表に置く

    フッターの上の角を丸めています。ページの下に別の面が敷かれている、という見え方を作るためです。

    ところが、実機で見返すと「もう少し丸くていい、50pxでもいいくらい」と感じました。当時は14pxでした。

    この記事で扱うこと

    丸みを1つの表で持っている構成に、表へ入れられない値が出てきたときの扱いです。1か所だけ大きくするか、表そのものを直すか、という話ではありませんでした。

    丸みは1つの表で持っている

    このサイトでは、角の丸さを6段階の表で持っています。既定値より意識的に小さくしてあります。

    名前 この表の値 もとの既定値
    DEFAULT 3px 4px
    md 4px 6px
    lg 6px 8px
    xl 8px 12px
    2xl 10px 16px
    3xl 14px 24px

    表で持っている理由は、調整を1か所で終わらせるためです。「全体的にもう少し角を立てたい」と思ったとき、この表の数字を書き換えれば、丸みを指定しているすべての場所が同じ方向へ動きます。個別に書いていたら、直し漏れた場所だけが取り残されます。

    一番大きい値を50pxにすればいい、ではなかった

    最初に考えたのは、表の一番大きい値を50pxにすることでした。フッターはその値を使っているので、これで目的は果たせます。

    しかもこの値は、調べたらサイト全体でフッター1か所しか使っていませんでした。他に影響が出ないので、書き換えても壊れません。

    それでもやめました。

    「いまは1か所しか使っていない」は、変えてよい理由にならない

    壊れないことと、正しいことは別です。表の一番大きい値を50pxにすると、この表の意味そのものが変わります。

    いまは1か所でも、次に誰かがカードの角を「一番大きく」したくなったとき、受け取る値は50pxになります。そのときには「なぜ一番大きい丸みだけ極端なのか」を説明できる人がいません。壊れるのは今日ではなく、次にこの表を使うときです。

    比べる相手が違うものは、同じ表に置けない

    考え直して分かったのは、この表が何のためにあるかでした。

    DEFAULTから一番大きい値までは、1本の段階になっています。小さいものから大きいものへ並んでいて、ボタン、入力欄、タグ、カードのように本文の中に置かれる部品のためにあります。

    これらの部品には共通点があります。画面の中で隣り合って並ぶので、互いの丸みを見比べられるということです。ボタンよりカードのほうが少し丸い、という関係が意味を持ちます。だから同じ段階の上に居る必要があります。

    フッターは違います。幅が画面いっぱいあり、隣に並ぶものがありません。比べる相手は画面の縁だけです。

    部品の丸み 面の丸み
    対象 ボタン・入力欄・カード フッターのような大きな面
    置かれる場所 本文の中 ページの下に敷かれる
    比べる相手 隣り合う他の部品 画面の縁
    決め方 段階の中での前後関係 その面だけで見て伝わるか
    段階から出して、名前を付けた

    表の6段階はそのままにして、panel という名前で50pxを別に足しました。段階の外です。

    こうすると、表を書き換えたときに動くのは部品だけになります。面の丸みは別の判断で決まるので、一緒に動かないほうが正しい。同じ「丸み」という言葉で呼んでいても、決め方が違うものは別の場所に置くということです。

    なぜ14pxでは足りなかったのか

    数字の話も書いておきます。

    同じ半径でも、対象が横に広いほど、角の丸みは相対的に小さく見えます。幅300pxのカードの14pxと、幅1200pxのフッターの14pxでは、目に入る割合が違います。

    フッターの角を丸めているのは、そこに背景がわずかに覗いて「下に別の面が重なっている」と伝えるためです。角が丸いことに気づけない大きさでは、その役割を果たしません。14pxは、部品としては十分でも、面としては足りませんでした。

    今回の学び

    まず、壊れないことは、変えてよい理由にはならないこと。使っている場所が1か所しかなければ、書き換えても何も起きません。ただし表の意味は変わっていて、次にその表を使う人が想定外の値を受け取ります。影響が出るのが今日でないだけです。

    次に、同じ言葉で呼んでいるものが、同じ決め方とは限らないこと。部品の丸みは隣と比べて決まり、面の丸みはそれ単体で伝わるかで決まります。決め方が違うものを1本の段階に押し込むと、どちらかが無理をすることになります。

    そして、例外は、例外だと分かる場所に置くこと。段階の中に極端な値を混ぜると、段階のほうが読めなくなります。外へ出して名前を付ければ、なぜそこにあるのかを書き残せます。書き残せる形にすること自体が、判断を残す手段になります。

  • 進捗バーが0から始まらないのは、測る場所を1つ間違えていたからだった

    進捗バーが0から始まらないのは、測る場所を1つ間違えていたからだった

    記事を開いた瞬間、読み進み具合のバーが9.5%を指していました。まだ何もしていません。

    「あと90%」と出ているのを見て、手が止まりました。進み具合を名乗るものが0から始まらないのは、やはり変です。

    この記事で扱うこと

    なぜ0から始まらなかったのか、どこを基準に測るかで何が変わるのか。そして、終わりの位置を動かさずに始まりだけ直す方法です。

    分母を本文の高さに変えたところまでは合っていた

    このバーは一度直しています。

    最初はページ全体の高さを分母にしていました。ところがページの下には、前後の記事へのリンク、一覧へ戻るリンク、フッターが続きます。実測するとその部分だけで1205pxありました。

    つまり本文を読み終えても90%前後にしかならず、100%にするには読むもののないフッターまでスクロールする必要がありました。勢いよく動かす人は慣性で最下端まで行くので満たせてしまい、文章を追って読む人ほど100%にならない。そこで分母を本文の高さに変えました。

    ここまでは正しい判断でした。それでも、始まりの値は0になりませんでした。

    下端で測ると、開いた時点ですでに進んでいる

    計算はこうなっていました。

    画面の下端が、本文の上端からどれだけ入ったか。それを本文の高さで割る。

    この式だと、ページを開いた時点で本文の一部がすでに画面に映っています。実測した記事では、本文の上端がページの297px地点にあり、画面の高さが745pxでした。開いた瞬間に本文の448pxぶんが映っていることになります。本文の高さが4731pxなので、9.5%です。

    式は正しく、意味だけが合っていなかった

    「画面に映った本文の割合」としては、9.5%は正確な値です。計算間違いではありません。

    ただし、この数字が名乗っていたのは「読み進み具合」でした。読み進み具合なら、読み始める前は0です。式が答えていた問いと、表示が名乗っていた問いが、別のものでした。

    上端で測り、分母を移動量に合わせる

    基準を画面の下端から上端に変えました。

    あわせて分母も変えています。上端で測ると、本文の末尾まで読み切ったときの移動量は「本文の高さ − 画面の高さ」になります。本文の高さのままだと、最後まで読んでも100%に届きません。

    スクロール位置 旧(下端で測る) 新(上端で測る)
    0(先頭) 9.5% 0.0%
    297(本文の上端) 15.7% 0.0%
    2000 51.7% 42.7%
    4283(本文の末尾) 100.0% 100.0%
    100%になる位置は動いていない

    直したのは始まりの側だけに見えますが、実際には分子と分母を同時に取り替えています。それでも100%になる位置は同じ4283pxのままでした。

    分子から画面1つぶんを引いたなら、分母からも画面1つぶんを引く。両方に同じ量を効かせているので、終点は動きません。片方だけ直していたら、今度は末尾で100%に届かなくなっていました。

    割り算の正しさは、分子だけを見ても分母だけを見ても判断できません。

    そしてこの4283pxは、読了として記録される位置でもあります。バーが満杯になるのと「読了済み」に変わるのが同じ瞬間になりました。

    本文が画面に収まる記事はどうなるか

    短い記事では「本文の高さ − 画面の高さ」が0以下になります。割り算が壊れる形です。

    この場合は100%を返しています。動かす余地が無く、開いた時点で最後まで見えているからです。読了の判定も同じ扱いになっていて、開いた時点で全区画が埋まります。2つの表示が同じ答えを出すので、ここでも食い違いません。

    今回の学び

    まず、0から始まらない指標は、測る対象と名乗りがずれている合図だということ。9.5%という値そのものは正確でした。式が答えていたのは「画面に映った割合」で、表示が名乗っていたのは「読み進み具合」です。数字が合っているかより先に、何を名乗っているかを確かめる必要がありました。

    次に、割り算は分子と分母を組で見ること。基準を1つずらすと、分子と分母の両方に同じ量が効きます。片方だけ直すと、始まりが直って終わりが壊れます。今回は両方に効かせたので、終点を動かさずに始点だけ直せました。

    そして、同じことを指す表示は、同じ場所で切り替わるべきだということ。バーの100%と読了の記録は、以前は別の位置にありました。いまは同じ4283pxで揃っています。揃っていれば、片方を見て他方を推測できます。ずれていると、どちらが本当なのかを毎回確かめることになります。

  • 「画面に入った」で数えたら、17%残して読了になった

    「画面に入った」で数えたら、17%残して読了になった

    記事を最後まで読んだかどうかを、サイト側で判定しています。その作りを一度入れ替えたあとで、まだ食い違っていることに気づきました。

    調べたら、本文をまだ17%残している位置で読了が成立していました。

    この記事で扱うこと

    「画面に入った」で数えると何が起きるか、それを「通り過ぎた」に変えると何が揃うか。そして、速く動かしたときに何を数えていいのかです。

    本文を区画に分けて、通った跡を見ている

    判定の仕組みはこうです。本文の高さを6つの区画に分け、画面に映った区画に印を付けます。全部に印が付いたら読了とします。

    時間では測っていません。開いて放置しても時間は進むので、読まずに読了になってしまうからです。通った経路そのものを見れば、連続して読んだ人は必ず全部を通り、途中を飛ばした人には穴が残ります。

    ここまでは意図どおりに動いていました。問題は「映った」をどう数えるかのほうにありました。

    最後の区画は、画面に入った瞬間に埋まる

    最初の数え方は「画面と少しでも重なった区画に印を付ける」でした。素直な書き方に見えます。

    この数え方だと、最後の区画は画面の下から顔を出した瞬間に埋まります。まだ読んでいない部分が画面の下に残っていても、区画としては「映った」ことになります。

    実際の記事で測りました。本文の高さが4731px、画面の高さが745px、1区画は788pxです。最後の区画が画面に入るのは、本文の83%まで進んだところでした。

    数え方 最後の区画が埋まる位置 そのとき画面の下に残っている量
    画面に入ったら 本文の83% 17%
    下端まで通り過ぎたら 本文の100% 0%

    読み終わりを指す数字が、2つとも違う場所にあった

    同じページには、読み進み具合を出す細いバーもあります。こちらは「本文の末尾が画面の下端に来たら100%」で計算していました。

    つまり同じ「読み終わり」を指しているはずの2つが、17%ずれた場所を指していました。バーはまだ「あと17%」と言っているのに、記録のほうはもう読了になっている。どちらも動いていて、どちらも例外を出しません。

    同じ言葉で呼んでいたので、違うものだと気づけなかった

    どちらも「読み終わり」と呼んでいました。呼び名が同じだと、中身も同じだと思ってしまいます。

    実際には片方は「画面に入ったか」、もう片方は「画面を通り過ぎたか」を見ていました。言葉のほうが先に一致してしまうと、数字がずれていても照らし合わせる動機が生まれません。

    「通り過ぎた」に変えると、位置が揃う

    区画に印を付ける条件を、下端まで通り過ぎたかどうかに変えました。

    最後の区画の下端は、本文の末尾と同じ位置です。そこを画面の下端が越えるのは、本文を読み切ったときになります。バーが100%になるのと、読了が成立するのが、同じ瞬間になりました。

    直したのは条件式の不等号ひとつぶんですが、意味はかなり変わっています。「見えた」を「見終えた」にしたので、読む側の実感と一致するようになりました。

    速く動かしたときに、何を数えていいのか

    もうひとつ直したところがあります。

    目次から飛んだときに、途中を読んだことにしてはいけません。かといって、速くスクロールしただけの人を弾いてもいけません。この2つを分けるのに、以前は移動量のしきい値を置くことを考えていました。

    置かずに済む書き方がありました。前回の画面の下端と、いまの画面の上端の、大きいほうを下限にします。

    「表示された範囲」は、1本の式で書ける

    ふつうにスクロールしているあいだは、前回の下端のほうが大きくなります。1回の移動が画面の高さに収まるので、通り過ぎた範囲は全部目に入っています。

    大きく飛んだときは、いまの画面の上端のほうが大きくなります。飛んだ距離のうち画面の高さを超えた部分は一度も表示されていないので、そこは数えません。いま映っている1画面ぶんだけが残ります。

    場合分けが要らないだけでなく、処理が詰まって移動が飛び飛びに見えたときも、表示された範囲だけが自然に残ります。しきい値を決めていたら、その値の妥当性を考え続けることになっていました。

    実際のジオメトリで確かめました。連続して100px、300px、700px、744pxずつ動かした場合とPageDownでは、いずれも読了になります。一気に最下部へ飛んだ場合は成立しません。目次から中ほどへ飛んで最後まで読んだ場合は、手前に穴が残って成立しません。飛んだあとに戻って全部読めば成立します。

    今回の学び

    まず、同じ名前で呼んでいるものが、同じものだとは限らないこと。「読み終わり」を2か所で判定していて、どちらもそう呼んでいました。呼び名が一致していると、中身を照らし合わせる機会が来ません。ずれていたのは実装ではなく、言葉のほうが先に揃ってしまったことでした。

    次に、「見えた」と「見終えた」は別の状態だということ。画面に入った時点で数えると、まだ読んでいない部分が残ります。人が読み終えたと感じるのは通り過ぎたときなので、そちらに合わせるほうが実感と一致します。

    そして、しきい値を置く前に、置かずに書ける形を探すこと。今回は下限の取り方を変えるだけで場合分けが消えました。しきい値は、決めた瞬間から「その値でよいのか」を考え続ける対象になります。要らないなら、無いほうが軽い。