Lv.05

中級編3 片瀬のウェブマガジンのトップページ

1ページ・動画背景と音のボタン

琥珀

琥珀からの依頼書

海と暮らしのメディアサイトのトップを作ってください。1画面目は動画背景です。

動画・音声・遅延読み込みはAIに書かせ、動画が使えない環境では静止画に落ちるようにしてください。

1ページの転送量が上限を超えていないか、DevToolsで測って報告してください。

カンプと配布物

フルスクリーンカンプ/PNG(1440・390)/完成ZIP/公開デモ

手で書くところ

video要素の背景(muted・playsinline・poster)/clamp()の大胆なタイポ/article要素の記事一覧

AIに任せるところと検品

AIに書かせる:動画背景と静止画フォールバック・音のボタン再生・srcsetと遅延読み込み/+性能(転送量・LCP・DevTools Network)

依頼を受けたところから

依頼主は、藤沢市片瀬海岸にある小さな編集部です。週に2本、海のそばの暮らしについて書いているウェブマガジンで、そのトップページを1枚だけ作ります。依頼書を読み解くと、決めごとは3つあります。1画面目を動画背景にすること動画・音声・遅延読み込みはAIに書かせて、動画が使えない環境では静止画に落ちることページの転送量を自分で測って報告すること。手で書くのは2割まで減ります。減ったぶん、検品を1つ増やします。

完成したウェブマガジンのトップページを、幅1440pxで上から下まで写したもの

スマホ幅(390px)だと、こうなります。見出しの大きさが変わっているだけで、並び順は同じです。

同じページを幅390pxで写したもの。記事一覧が3列から1列になっている

この回で前提にしているのは 01 から 04 までです。clamp()・モバイルファースト・2段階のブレークポイント・IntersectionObserver(要素が画面に入ったことをブラウザが教えてくれる仕組み)は、説明せずにそのまま使います。

ここまでで OK:依頼書の3つを、自分の言葉で言い直せる状態。

この回で新しく出るもの

新しいのは、この7つです。半分が「ブラウザ側の決まりごと」で、CSSの書き方ではありません。

ひとことで言うとどこで使うか
<video autoplay muted loop playsinline poster>音の出ない動画を、背景として勝手に流すための属性の組ファーストビュー
自動再生ポリシー音の出る再生は、人が押すまで始めさせない、というブラウザ側の決まり動画と音のボタン
play() が返す Promise再生できたか・できなかったかを、あとから受け取る仕組み動画と音の両方
100svhアドレスバーが出ている状態での画面の高さヒーローの高さ
srcset sizes画面幅に合う大きさの画像を、ブラウザに選ばせる記事カード6枚
loading="lazy" decoding="async" fetchpriority="high"後ろの画像を後回しにして、最初の1枚だけ先に読む記事カード
DevTools の Network タブページ全体で何バイト運んだかの実測検品

検品には 性能(ページの重さ) が1つ増えます。ここまでの4つ(構文・見た目・Lighthouse・アクセシビリティ)は、壊れていないかを見る検査でした。5つ目だけは壊れていなくても落ちる検査です。

ここまでで OK:この7つのうち、名前を知らないものがどれか分かった状態。

見出しだけで成立する画面を、先に作る

これは手で書く

動画を敷く前に、動画が無くても読める画面を自分の手で完成させます。ここが手元にあるかどうかで、あとの「動画が落ちても壊れない」が、対策ではなく初期状態になります。

04では「動きの無いページを先に完成させる」ところから始めました。この回も同じ順番です。まず、文字と余白だけのファーストビューを作ります。

html
    <div class="hero__content">
      <p class="hero__eyebrow">2026年11月号 / 湘南タイド</p>
      <h1 class="hero__title">海は、<br>毎日ちがう。</h1>
      <p class="hero__lead">
        片瀬から江の島まで歩くと、砂の色が3回変わります。<br>
        その日の海のことだけを、週に2本書いています。
      </p>
      <div class="hero__actions">
        <a class="button button--primary" href="#latest">今週の記事を読む</a>
        <a class="button button--ghost" href="#sound">海の音を聞く</a>
      </div>
      <p class="hero__scroll">スクロール</p>
    </div>

<h1> の中に <br> を入れて、改行の位置を自分で決めています。字が大きく文が短いので、折り返しをブラウザに任せると幅によって1文字だけが下に落ちます。読ませる文ではなく絵として置く文字なので、こちらで割ります。

見出しを画面幅いっぱいまで伸ばす

03で使った clamp(最小値, 伸び方, 最大値) を、今回は桁の違う値で使います。

css
.hero__title {
  font-size: clamp(44px, 13vw, 160px);
  line-height: 0.95;
  margin-bottom: 20px;
}

真ん中の 13vw は画面幅の13%です。390pxの画面なら約51px、幅が1231pxを超えたところで160pxに達して、そこから先は伸びません。03で書いた clamp(26px, 5vw, 44px) と比べると上限が4倍近くあります。ここが「雑誌の表紙のように、見出しが画面を占める」の正体です。

line-height: 0.95 は行の高さを文字の大きさより小さくする指定です。本文でやると読めなくなりますが、2行しかない大きな見出しでは、行と行が詰まって1つの塊に見えます。

高さの単位に 100svh を使う

ヒーローの高さは 100vh ではなく 100svh にします。

css
.hero {
  position: relative;
  height: 100svh;
  min-height: 480px;
  display: flex;
  align-items: flex-end;
  overflow: hidden;
  color: var(--paper);
}

vh は画面の高さの1%ですが、スマホにはアドレスバーが出ている高さと、隠れた高さの2つがあります。100vh は隠れたほうの高さを指すので、アドレスバーが出ている最初の表示では下がはみ出し、少しスクロールしてバーが隠れた瞬間に高さが変わって画面が跳ねます。svhsmall viewport height の略で、バーが出ている、狭いほうの高さです。最初から最後まで数字が変わらないので、跳ねません。

align-items: flex-end で中身を下に寄せているのは、見出しの上に余白を取って、写真や動画を見せる場所を作るためです。

ここまでで画面がこうなっていれば OK:背景がまだ黒一色でも、白い大きな見出しが画面いっぱいに出て、下に2つのボタンが並んでいる状態。

記事一覧を article で並べる

これは手で書く

器の形はこちらで決めます。次の Step でAIに足させるのは img の属性だけなので、入れ物が先に無いと指示が書けません。

記事カードは6枚、中身は 写真 → カテゴリ → 見出し → 抜粋 → 日付 の順です。2枚目のカードで形を見ます。

html
      <article class="article-card">
        <a class="article-card__link" href="#">
          <img
            class="article-card__image"
            src="images/article-2-1280.webp"
            srcset="images/article-2-640.webp 640w, images/article-2-1280.webp 1280w"
            sizes="(min-width: 1024px) 33vw, (min-width: 600px) 50vw, calc(100vw - 32px)"
            width="1280" height="1024"
            alt="物干しに並んで掛けられたウェットスーツ"
            loading="lazy" decoding="async"
          >
          <p class="article-card__category" id="category-tools">道具</p>
          <h3 class="article-card__title">濡れたまま持ち帰る道具の、置き場所の話</h3>
        </a>
        <p class="article-card__excerpt">ウェットスーツとタオルを、玄関のどこに置くか。5軒に聞きました。</p>
        <p class="article-card__date">2026.11.02</p>
      </article>

img に付いている srcset sizes loading は、次の Step でAIに足させたものです。手で決めたのは2つだけ。<article> の入れ子の形と、リンクをどこまでにするかです。

リンクにしたのは、写真・カテゴリ・見出しの3つまでです。抜粋と日付は <a> の外に出してあります。カード全部をリンクで包むと、読み上げソフトはリンクの名前として中の文字を全部つなげて読むので、「ウェットスーツと、タオルを、玄関の……」まで含んだ長いリンク名になります。押せる面積は少し狭くなりますが、名前が短いほうを採りました。

href="#" なのは、この練習では記事の詳細ページを作らないからです。カテゴリ名に付いている id="category-tools" は、ヘッダーのナビとページ下のカテゴリ一覧からの飛び先です。一覧ページの代わりに、その分類の最初の記事へ飛ばしています。

並びは 600px で2列、1024px で3列。04と同じ2段階のブレークポイントなので、CSSは省きます。

ここまでで画面がこうなっていれば OK:6枚が縦に並び、幅を広げると2列・3列に変わる状態。

AIに書かせる①:動画背景と、動画が無いときの落とし先

ここからが本題です。動画背景は「敷く」より「落ちたときにどうするか」のほうが行数が多くなります。プロンプトも2本に分けて出します。1本目は動画を敷くところまで。

AIに任せる

渡すプロンプト

text
Webページのファーストビューに敷く、動画背景を実装してください。

【守ってほしいこと】
- <video> の属性は autoplay muted loop playsinline poster を付ける(音は出さない)
- poster に静止画を指定し、動画が読めないときは静止画だけが見える状態にする
- 動画は object-fit: cover で画面いっぱいに敷く。文字が読めるよう、上に半透明の黒を重ねる
- @media (prefers-reduced-motion: reduce) のときは動画を再生せず、静止画だけにする
  (CSSで隠すのではなく、JavaScriptで再生を始めない形にする)
- 動画が画面の外に出たら pause()、戻ってきたら play() する(IntersectionObserver)
- play() は Promise を返し、ブラウザにブロックされると reject される。必ず catch して、
  失敗しても画面が壊れないようにする
- ライブラリを使わない
- html-validate で error 0 になること

【ファイル】
動画:video/hero.mp4
静止画:video/hero-poster.webp

【HTML】
<ヒーローのHTMLをそのまま貼る>

【出力】
HTML・CSS・JavaScript の順に、別々のコードブロックで。説明は3行以内で。

疑うポイント

  • muted が付いているか(付いていないと、多くのブラウザで自動再生が止まる
  • playsinline が付いているか(iOS で全画面に飛ばないため)
  • play()catch が書かれているか。書かれていないと、ブロックされたときに Console にエラーが出続ける
  • 動画ファイルを一時的にリネームしてposter だけで成立するか確かめる

返ってきたHTMLは、そのまま採用できました。

html
      <video
        class="hero__video"
        id="hero-video"
        autoplay
        muted
        loop
        playsinline
        poster="video/hero-poster.webp"
        aria-hidden="true"
      >
        <source src="video/hero.mp4" type="video/mp4">
      </video>

属性が5つ並んでいますが、役割は全部違います。autoplay勝手に始めるmuted音を出さないloop終わったら最初へ戻るplaysinlineスマホで全画面に飛ばさず、その場で流すposter再生が始まるまで(そして始められないとき)に見せる静止画。このうち muted が抜けると、autoplay は無効になります。音が出るかもしれない再生を、人の操作なしに始めることをブラウザが許していないからです。aria-hidden="true" は「これは飾りなので読み上げなくてよい」の印です。

CSSは3つだけです。

css
.hero__media {
  position: absolute;
  inset: 0;
  background: var(--ink);
}

.hero__video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.hero__media に黒(--ink)の背景色が入っているところに注目してください。動画も静止画も無いときに残るのが、この色です。3段構えの一番下です。

ここを疑って直した(動画)

疑うポイントの4つ目、video/hero.mp4 の名前を変えて開いてみたところ、poster の静止画は出ました。出ましたが、それだけでした。 最初の出力には、読み込みの失敗を拾う処理が入っていません。今は静止画がたまたま表示されただけで、静止画も無い場合や、あとから背景を差し替えたい場合に手がありません。そこで、落とし先だけを別の依頼にしました。

AIに任せる

渡すプロンプト

text
動画ファイル(video/hero.mp4)が存在しない環境でも、
ファーストビューが成立するようにしてください。

【守ってほしいこと】
- HTMLを書き換えず、CSSとJavaScriptだけで対応する
- video 要素の error イベントを拾い、失敗したら video を非表示にして
  poster と同じ静止画を背景として敷く
- 静止画も無い場合は、背景を単色(#0a0a0a)にして文字だけで成立させる
- Console にエラーを出したままにしない
- ライブラリを使わない

【出力】
CSS・JavaScript の順に、別々のコードブロックで。説明は3行以内で。

疑うポイント

  • error イベントは <video> ではなく <source> に飛ぶことがある。両方拾っているか
  • 静止画の背景が background-size: cover になっているか
  • 実際にファイル名を変えて3パターン試す(動画なし/動画も静止画もなし/両方あり)

返ってきたCSSは、そのまま入りました。

css
.hero__media--fallback .hero__video {
  display: none;
}

.hero__media--fallback {
  background-image: var(--poster-url, none);
  background-size: cover;
  background-position: center;
}

JavaScript は2か所を足して採用しました。足した1つ目は、疑うポイントの1つ目です。返ってきた直後は videoerror だけを拾っていて、<source> 側の error を拾っていませんでした。動画の指定が <source> の中にあるので、ブラウザによっては失敗の通知がそちらへ行きます。1行足します。

js
  video.addEventListener("error", fallbackToStillImage);
  var source = video.querySelector("source");
  if (source) {
    source.addEventListener("error", fallbackToStillImage);
  }

足した2つ目は、この2行を書いても直らなかったぶんです。ファイル名を変えて自動操作のブラウザで開き直しても、フォールバックが動かないことがありました。調べると、video.errornull のまま、video.networkState だけが 3NETWORK_NO_SOURCE)になっています。原因は時間の順番でした。<video autoplay> はHTMLを読んでいる途中ですぐ読み込みを始めるので、失敗の通知が、外の script.js が読み込まれて addEventListener を実行するより先に起きてしまいます。誰も聞いていないときに鳴ったベルなので、あとから待っても届きません。

js
  if (video.error || video.networkState === 3) {
    fallbackToStillImage();
  }

イベントを待つのをやめて、いまの状態を直接見る。これが対処です。イベントは「これから起きること」しか受け取れないので、スクリプトより先に起きうるものについては、過ぎたことを聞く経路も用意しておく必要があります。この形は動画に限らず、画像の onerror でも同じことが起こります。

再生を始める側は、こうなりました。

js
  function tryPlay() {
    if (reduceMotionQuery.matches) {
      return;
    }
    var playPromise = video.play();
    if (playPromise && typeof playPromise.catch === "function") {
      playPromise.catch(function () {
        // ブラウザに自動再生をブロックされても、poster が表示されたままなので何もしない
      });
    }
  }

prefers-reduced-motion(OSの「視差効果を減らす」設定)のときは、CSSで隠すのではなく play() を呼びません。CSSで隠すやり方だと、見えないところで動画が読み込まれて再生され続けます。設定した人が減らしたいのは「動き」で、動画の通信もそこに含まれます。

catch の中が空なのは、書くことが無いからです。ブロックされても poster が出たままなので、画面は成立しています。何もしないと決めたことを、空の catch とコメントで残しておくのと、catch を書き忘れるのとでは、意味が正反対です。

video/hero.mp4 の名前を変えて開いた画面が、これです。

動画ファイルの名前を変えた状態のファーストビュー。静止画に切り替わり、見出しとボタンはそのまま表示されている

3パターン試した結果も、記録の形で書いておきます。動画だけ消すと静止画になり、静止画も消すと黒一色になって文字は読めたまま、両方あれば動画が流れます。3段とも、文が読めることは変わりません。

ここまでで画面がこうなっていれば OK:動画が流れ、hero.mp4 の名前を変えても静止画で成立し、Console にエラーが1つも出ていない状態。

AIに書かせる②:音のボタン再生

音の出る再生を、ユーザーの操作なしに始めることを、いまのブラウザは許していません。ボタンを1回押してもらうまで待つ、というのが決まりです。ページを開いた瞬間に音が鳴る体験が嫌われた結果、ブラウザ側で止めるようになったという経緯があります。だから「鳴らないようにする」ではなく、「押されるまで待つ形でしか作れない」が正しい理解です。ここを分かっていないと、鳴らないのをバグだと思って直そうとしてしまいます。

AIに任せる

渡すプロンプト

text
「今日の海の音」を、ボタンを押したときだけ再生する仕組みを作ってください。

【守ってほしいこと】
- ページを開いただけでは絶対に鳴らさない(autoplay を使わない)
- <audio> 要素は1つ。ボタンで play() / pause() を切り替える
- ボタンのラベルは「再生する」と「停止する」で入れ替える
- ボタンには aria-pressed を付け、再生中は true にする
- 再生が終わったら(ended)、ボタンを「再生する」に戻す
- play() は Promise を返す。reject されたときは、ボタンのラベルを元に戻し、
  ボタンの下に「お使いのブラウザでは再生できませんでした」と表示する
- 音量の初期値は 0.5 にする
- ライブラリを使わない

【ファイル】
音声:audio/sea.mp3

【出力】
HTML・CSS・JavaScript の順に、別々のコードブロックで。説明は3行以内で。

疑うポイント

  • autoplay が付いていないか(付いていたら消す)
  • ボタンを連打したときに、play()pause() が競合してエラーにならないか
  • 音声ファイルが無い状態で押したとき、エラーメッセージが出るか(実際にファイル名を変えて試す

HTMLはそのまま採用しました。

html
    <button class="sound__button" id="sound-button" type="button" aria-pressed="false">
      再生する
    </button>
    <p class="sound__status" id="sound-status" role="status" aria-live="polite"></p>
    <p class="sound__note">音量にご注意ください。</p>
    <audio id="sound-audio" preload="none">
      <source src="audio/sea.mp3" type="audio/mpeg">
    </audio>

aria-pressed は「押しっぱなしになるボタン」の状態を持つ属性です。見た目は文字が「再生する」「停止する」で入れ替わりますが、それだけだと画面を見ていない人に状態が伝わりません。role="status"aria-live="polite" が付いた空の <p> は、あとから文字が入ったときに、読み上げソフトがその場で読んでくれる置き場所です。preload="none" は「ボタンが押されるまで音声ファイルを読みに行かない」という指定で、この回の主題である重さにも直接効きます。

音源をどうするか、を先に決める

この練習サイトには音声ファイルを同梱していません。決め方の手順が残っているので、そのまま出します。まず、落としてきた動画に音が入っているかを調べます。

bash
ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 video/hero.mp4

audio と表示されたので、音声トラックはありました。ここで「あった」と判断してしまうと危ないので、音量も測ります

bash
ffmpeg -i video/hero.mp4 -af volumedetect -f null -

結果は mean_volume max_volume とも -91dB。数字としては音が入っていますが、耳では何も聞こえない、実質の無音です。上空から撮った映像で、風の音を消す処理の過程で音の帯域がほとんど削られたのだと考えられます。「トラックがあるか」を機械的に見るだけでは、押しても何も聞こえないボタンができあがります。 ここで、音源を同梱しないと決めました。

ボタンと文章はそのまま残し、押すと「音源を用意していません。」と出す形にします。押しても鳴らないブロックを残すのは中途半端に見えますが、この回で覚えることは再生が失敗したときの扱いなので、失敗するほうが練習になります。

ここを疑って直した(音)

まず、疑うポイントの3つ目です。返ってきた catch の中の文言は「お使いのブラウザでは再生できませんでした」でした。これはブラウザにブロックされたときの言い方で、ファイルがそもそも無いときの状況とは合いません。読んだ人は自分のブラウザを疑って、別のブラウザで開き直します。原因の場所を間違えさせる文言は、無いのと同じか、それより悪いです。「音源を用意していません。」に書き換えました。

次が本題です。書き換えたのに、ボタンを押しても何も表示されませんでした。 自動操作のブラウザで調べると、file:// でファイルが存在しないとき、play() が返す Promise が resolve も reject もせず、そのまま止まったままになることがありました。audio.readyState はずっと 0audio.errornull のままです。約束が返ってこないので、catchthen も一生呼ばれません。

catch に頼りきると、こういう「返事が来ない」に対して何も打てません。2つ足しました。

js
  // <audio> 自体が失敗を検知したとき(ファイルが無い・形式が非対応など)
  audio.addEventListener("error", function () {
    reset("音源を用意していません。");
  });

  button.addEventListener("click", function () {
    if (isPlaying) {
      audio.pause();
      reset("");
      return;
    }

    // play() が resolve も reject もしないまま止まる場合があるため、
    // 3秒で決着しなければ失敗として扱う(見つかった不具合への対処)
    pendingTimer = setTimeout(function () {
      reset("音源を用意していません。");
    }, 3000);

    var playPromise = audio.play();
    if (playPromise && typeof playPromise.then === "function") {
      playPromise.then(markPlaying).catch(function () {
        reset("音源を用意していません。");
      });
    } else {
      // 古いブラウザなど、Promise を返さない実装への保険
      markPlaying();
    }
  });

1つ目は <audio>error イベントを直接拾う経路。2つ目が3秒のタイムアウトです。play() を呼んだら時計を回し、3秒たっても成功も失敗も返ってこなければ、失敗として扱ってメッセージを出します。成功したら markPlaying() の中で時計を止めます。

補足

3秒という数字に根拠はありません。返事が来ないときに、人を待たせておく上限として決めた値です。長くすると壊れているのか読み込み中なのか分からず、短くすると回線の遅い人が読み込めた音を止められてしまいます。こういう数字は、根拠が無いことを書き残しておくと、あとで変えやすくなります。

疑うポイントの1つ目(autoplay が付いていないか)と2つ目(連打)は、今回は問題が出ませんでしたisPlaying という状態のフラグで、押すたびにどちらか片方しか呼ばれないようになっています。出なかったものを直す必要はありませんが、見るのは毎回です。

ここまでで画面がこうなっていれば OK:ページを開いても音が鳴らず、ボタンを押すと「音源を用意していません。」が赤い字で出て、ボタンの表示が「再生する」に戻る状態。

AIに書かせる③:記事カードの srcset と遅延読み込み

写真6枚は、この回で一番簡単に軽くできる場所です。器はもうできているので、属性だけを足させます

AIに任せる

渡すプロンプト

text
記事一覧の6枚のカードについて、画像の読み込みを軽くしてください。

【守ってほしいこと】
- srcset で 640w と 1280w の2つを指定し、sizes で表示幅を伝える
- 1枚目の画像だけは loading="eager" fetchpriority="high"、2枚目以降は loading="lazy" decoding="async"
- すべての img に width と height 属性を入れる(読み込み時のガタつきを防ぐため)
- alt は写真の内容を日本語で書く。記事タイトルをそのまま入れない
- HTML以外は変更しない(CSSは触らない)
- html-validate で error 0 になること

【表示幅】
スマホ(〜599px):画面幅から左右16pxを引いた幅いっぱい
600〜1023px:2列
1024px以上:3列(コンテンツ幅の上限は 1200px)

【いまのHTML】
<記事一覧の6枚をそのまま貼る>

【出力】
HTMLを1つのコードブロックで。説明は3行以内で。

疑うポイント

  • sizes の値が実際のレイアウトと合っているか(合っていないと、大きい画像が選ばれて意味がなくなる)
  • 1枚目に loading="lazy" が付いていないか(最初に見える画像を遅らせると逆に遅くなる
  • alt が記事タイトルのコピーになっていないか

1枚目は、こうなりました。

html
          <img
            class="article-card__image"
            src="images/article-1-1280.webp"
            srcset="images/article-1-640.webp 640w, images/article-1-1280.webp 1280w"
            sizes="(min-width: 1024px) 33vw, (min-width: 600px) 50vw, calc(100vw - 32px)"
            width="1280" height="854"
            alt="早朝、店先の看板を出す店員"
            loading="eager" fetchpriority="high"
          >

srcset は「この画像には640px幅と1280px幅の2つがあります」という品揃えの申告で、sizes は「その画像がこの画面では何px幅で表示されます」という注文です。ブラウザはこの2つを突き合わせて、自分で片方を選びます。どちらを選ぶかを決めるのはブラウザで、こちらではありません。widthheight は表示サイズの指定ではなく、縦横の比率をブラウザに先に教えるための属性です。これがあると、写真が届く前に場所を空けて待てるので、読み込みの途中で下の文章が飛び跳ねません。

ここを疑って直した(画像)

疑うポイントの1つ目で止まりました。返ってきた直後の sizes は、こう書かれていました。

text
sizes="100vw"

100vw は「画面の幅いっぱいで表示します」という注文です。1440pxの画面でこう伝えると、ブラウザは1440px幅ぶんの画質が要ると考えて、迷わず 1280w のほうを選びます。ところが実際のレイアウトは3列なので、1枚が占めるのは約400pxです。400pxの場所に1280pxの画像を運んでいることになり、srcset を書いた意味がまるごと消えます。軽くするために足した属性が、間違った申告のせいで重くする側に回っていたわけです。

指示し直して、3段階に直させました。

text
sizes="(min-width: 1024px) 33vw, (min-width: 600px) 50vw, calc(100vw - 32px)"

左から順に読みます。1024px以上なら画面幅の33%(3列だから)、600px以上なら50%(2列だから)、どちらでもなければ画面幅から左右の余白32pxを引いた幅(1列だから)。CSSに書いたブレークポイントと同じ数字を、HTML側にもう一度書いているのがポイントです。sizes はCSSを読んでくれないので、レイアウトを変えたらここも直す必要があります。直し忘れが起きやすい場所なので、列数を変えたら sizes を見ると決めておいてください。

残る2つは問題ありませんでした。1枚目は loading="eager" fetchpriority="high"、2枚目以降は loading="lazy" decoding="async" で出し分けられています。alt も「早朝、店先の看板を出す店員」と、写真に写っているものが書かれていました。見出しの「朝6時に開く店が、片瀬に3軒あります」をコピーしていたら、読み上げで同じ文が2回続きます。

ここまでで画面がこうなっていれば OK:Network タブを開いたまま読み込むと、記事の写真は最初の1枚だけが読まれ、下へスクロールすると残りが1枚ずつ増えていく状態。

検品する

新しく足す検品は1つ、性能(ページの重さ) です。手順は4つだけです。

  1. DevTools を開いて Network タブを選ぶ
  2. 「Disable cache」にチェックを入れる(2回目以降の読み込みで、手元に残った分が数えられないようにするため)
  3. ページを再読み込みする
  4. 一番下に出る Transferred の値を読む

検品

bash
du -h video/hero.mp4
du -ch images/*.webp | tail -1

合格の見え方:動画が 960K(上限 2.0MB 以内)、写真の合計が 344K(上限 400KB 以内)。du はディスクの区画単位で切り上げるので、実際のバイト数の合計(318.9KB)より少し大きく出ます。この2つが上限内でないなら、Network を見る前に素材を削るところからです。

この完成コードで測った結果が、これです。

Networkタブで測った転送量の一覧。hero.mp4が957.0KB、合計が1.91MBと表示されている

リソース転送量
hero.mp4(動画)957.0 KB
hero-poster.webp(静止画)189.6 KB
写真(srcset の小さいほう=640wが選ばれた)1枚 8〜36 KB
日本語フォント分割された woff2 が40件ほど
合計(Transferred)1.91 MB

合格の見え方は 2.5MB 以内です。1.91MB なので通りました。ただし、この2.5MBはこの教材が決めた上限で、世の中の基準ではありません。「一般的に何MB以下」という数字を探すより、自分の案件で上限を先に決めて、それを超えたかどうかで話すほうが使えます。

この一覧には、この回で一番覚えてほしいことが出ています。上から2つ、動画と静止画だけで 1.15MB。ページ全体の6割です。 写真6枚を全部足しても動画1本に届きません。srcset と遅延読み込みで削れたのは数十KB単位で、削り方としては正しいのですが、削り代が大きいのは動画のほうです。この動画も、1920×1080のままでは 2.0MB の上限に収まらず、1280×720・8秒・画質を落とす、の順で削ってこの大きさになっています。

もう1つ、日本語フォントが40件ほど並んでいるのに気づいたと思います。日本語は文字の種類が多いので、フォントは細かく分割されて配信され、そのページで実際に使った文字を含む分だけが読み込まれます。ここを削りたいなら、フォントの本数を減らすのが効きます。

Lighthouse でも測ります。こちらは「もっと軽くできる場所」を指摘してくれる側です。

検品

bash
npx -y lighthouse http://127.0.0.1:5500/ --only-categories=performance --view

合格の見え方Properly size images Serve images in modern formats Efficiently encode images の3項目が緑(節約できる容量が 0 KiB と出る)。総合点は見ません。

総合点を合格条件にしないのは、測る環境(回線・パソコンの速さ・拡張機能)で数字が動くからです。上の3項目は画像の作り方の話だけなので、環境が変わっても答えが変わりません。

累積の検品は、前の4回と同じです。まとめて通します。

検品

bash
npx -y html-validate index.html
npx -y playwright screenshot --viewport-size=1440,900 --full-page http://127.0.0.1:5500/ shots/mine.png
npx -y lighthouse http://127.0.0.1:5500/ --only-categories=seo,best-practices --view
npx -y @axe-core/cli http://127.0.0.1:5500/

合格の見え方0 errors / 撮れた shots/mine.png をカンプPNGに重ねて白い帯が出ない / SEO と Best Practices に赤い項目が無い / 0 violations

ルールを1つだけ切った、その理由

1つ目の html-validateerror 0 で通りますが、このサイトには設定ファイルを置いてあります。

json
{
  "extends": ["html-validate:recommended"],
  "rules": {
    "no-autoplay": "off"
  }
}

no-autoplay は「autoplay を使うな」というルールです。音が勝手に鳴ることを防ぐためのルールなので、方向としては正しい指摘です。ただしこのヒーロー動画は muted playsinline poster を必ず伴う、音の出ない背景動画で、音のない自動再生は許容される実装です。だからこの1つだけを切りました。

検査のルールを切るのは、切る理由を1行で書けるときだけです。 赤が出たからという理由で切ると、検査そのものが飾りになります。切ったなら、切った理由を設定ファイルの近く(この教材では README.md)に残してください。半年後に読み返すのは自分です。

コマンドで測れないものは、手と目で見ます。この回はここです。

見るところ合格
ページを開いた直後音が鳴らない(この回でいちばん大事)
video/hero.mp4 の名前を変える静止画だけになり、見出しとボタンが読める
動画と静止画の両方の名前を変える背景が黒一色になり、それでも文字が読める
OSの「視差効果を減らす」をON動画が再生されず、静止画のまま止まっている
Consoleエラーも警告も0
Tab キー音のボタンに枠が出て、Enterで押せる
音のボタンを押したあとaria-pressed が切り替わり、下にメッセージが出る
Network タブ記事の2枚目以降が、スクロールするまで読まれない

上から3つは、わざと壊して確かめる検査です。ファイルの名前を変えて開き、確認したら戻す。準備編でわざと壊したHTMLを html-validate にかけたのと同じことを、今度は動画でやっています。壊れたときの姿を見ていないものは、壊れないと言えません。

ここまでで OK:転送量が 2.5MB 以内、html-validate0 errors、axe が 0 violations、そして動画と静止画の両方を消しても文字が読める状態。

次の回へ

8割を渡せたのは、渡す前に「無くても成立する画面」を自分の手で決めていたからです。動画も音も、あとから足したもので、外れても文は残ります。

次は上級編、6ページの企業サイトです。手は1割まで減り、構成そのものをAIに考えさせて、あなたは採否を決める側に回ります。作ったあとに Git・公開・GA4 まで通すので、検品には「本当に公開できたか」が足されます。大丈夫、まだ間に合います。分けて渡す順番は、もう身に付いています。

もっと聞きたいことがあれば、琥珀の公式LINEで質問できます。

琥珀の公式LINEで質問する(無料)