Lv.04

中級編2 藤沢のパーソナルジムのサイト

1ページ・スマホから先に組む

琥珀

琥珀からの依頼書

パーソナルジムのサイトを、スマホの見た目から先に作ってください。

スクロールで現れる動きと、実績の横スライダーはAIに書かせてください。ライブラリは使わないでください。

キーボードだけで全部操作できること、動きを減らす設定の人には動かないことを確認してください。

カンプと配布物

モバイル先行カンプ(390・1440)/PNG/完成ZIP/公開デモ

手で書くところ

モバイルファースト/2段階ブレークポイント(600px・1024px)/container query/:focus-visible/動かなくても読める初期状態

AIに任せるところと検品

AIに書かせる:IntersectionObserverの表示アニメ・スクロールスナップの実績スライダー・prefers-reduced-motion対応/+アクセシビリティ(axe・キーボード操作)

依頼を受けたところから

依頼主は、藤沢駅の南口から徒歩6分のところにある完全予約制のパーソナルジムです。依頼書を読み解くと、決めごとは3つあります。スマホの見た目から先に作ることスクロールで現れる動きと横スライダーはAIに書かせること(ライブラリは使わない)キーボードだけで全部操作できて、動きを減らす設定の人には動かないこと。手で書くのは3割まで減ります。減ったぶん、検品を1つ増やします。

完成したパーソナルジムのサイトを、幅1440pxで上から下まで写したもの

スマホ幅(390px)だと、こうなります。こちらが先に完成した側です。

同じページを幅390pxで写したもの。3列だった箇所が1列になっている

この回で前提にしているのは 01(カフェ)から 03(勉強会の告知)までです。flexbox・CSS Grid・BEM・CSSカスタムプロパティ(CSSの中で使い回せる自前の変数)・clamp() は、説明せずにそのまま使います。

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

この回で新しく出るもの

新しいのは、この7つです。前半3つが手、後半4つがAIと検品の担当です。

ひとことで言うとどこで使うか
モバイルファースト狭い方を既定にして、min-width で広げていくCSS全体の書き方
2段階ブレークポイント幅で書き分ける場所を600pxと1024pxの2つだけにする特徴・通い方・料金・スライダー
@container画面幅ではなく「置かれた枠の幅」で切り替える料金カード1か所だけ
IntersectionObserver要素が画面に入ったことをブラウザが教えてくれる仕組み特徴3枚のスクロール表示
scroll-snap横スクロールを1枚ずつ止める通っている人の声のスライダー
prefers-reduced-motionOSの「視差効果を減らす」設定を読む動き全部の停止
:focus-visibleいまキーボードがどこにいるかを枠で見せるボタンとリンク全部

検品には axe(アクセシビリティの問題を機械が探す道具)が1つ増えます。アクセシビリティは「目や耳や手の使い方が自分と違う人でも、同じ内容にたどり着けるか」のことです。

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

動きの無いページを、先に完成させる

これは手で書く

この回でいちばん大事な順番がここにあります。動きを足す前に、動かなくても読めるページを完成させる。 先に動きを入れると、あとで「消えているのはCSSのせいか、JavaScriptのせいか」が切り分けられなくなります。

HTMLの骨組みは全部、手で書きます。順番は、ヘッダー・ヒーロー・特徴・通い方・料金・声・場所と設備・末尾CTA・フッター。タグの選び方は01と02のままなので、ここでは説明しません。この時点では動きのコードを1行も書きません。スクロールしても何も動かない、ただ縦に長いページが正解です。

新しいのは2か所だけです。1つは、ヘッダーのナビ5項目をHTMLには最初から書いておいて、スマホでは隠すこと。開閉のハンバーガーは作りません。5項目ともページ内リンクなので、下までスクロールすれば全部通れるからです。作らないものを1つ決めると、あとで壊れる場所も1つ減ります。

もう1つは、ヒーローの背景写真です。<picture> は01で出ましたが、この回は向きが逆になります。

html
  <section id="top" class="hero">
    <picture class="hero__media" aria-hidden="true">
      <source media="(min-width: 1024px)" srcset="images/hero-fv-pc.webp">
      <img src="images/hero-fv-sp.webp" alt="" width="867" height="1300">
    </picture>

<img> に書いた縦位置の写真が既定で、1024px以上のときだけ横位置に差し替わります。01の「PC用を既定にして、狭いときにスマホ用へ」がひっくり返っています。飾りの写真なので alt=""aria-hidden="true" を付けて、読み上げソフトからは外しています。

もう1つ、骨組みの段階で入れておくものがあります。

css
:focus-visible {
  outline: 3px solid var(--lime);
  outline-offset: 2px;
}

:focus-visible は、キーボードで操作している人にだけ枠を見せる書き方です。マウスで押したときには出ません。最初に書いておくと、以降どの部品を作っても、Tabキーを押した瞬間に「いまどこにいるか」が見えます。検品で足すのではなく、作りながら見える状態にしておくほうが、その場で気づけます。

ここまでで OK:スクロールしても何も動かず、上から下まで内容が全部読める状態。

モバイルファーストで書く

これは手で書く

min-width の積み上げは、書き順そのものが学習内容です。ここをAIに渡すと、この回で身に付くものが何も残りません。

01から03までは「PCの見た目を書いて、@media (max-width: 768px) で狭いときを打ち消す」でした。この回は逆にします。メディアクエリの外に書いたものがスマホの見た目で、min-width で広い画面に足していきます。

たとえば特徴の3枚は、既定ではただの縦積みです。

css
.features {
  display: grid;
  gap: 24px;
}

ナビも既定では消えています。

css
.site-nav {
  display: none;
}

そのうえで、1段階目を足します。600px以上のブロックは全部でこれだけです。

css
@media (min-width: 600px) {
  .hero__title {
    font-size: 40px;
  }

  .section {
    padding: 64px 0;
  }

  .section__inner {
    padding: 0 24px;
  }

  .features,
  .steps {
    grid-template-columns: repeat(2, 1fr);
  }

  .access__photos {
    grid-template-columns: repeat(2, 1fr);
  }

  .voice-slider__item {
    flex-basis: calc(50% - 8px);
  }
}

書いてあるのは足す指定だけです。max-width で書いていた頃は「PCで付けた指定をスマホで元に戻す」打ち消しの行が必ず混ざりました。狭い方から始めると、それが要らなくなります。600pxでは、こう見えます。

幅600pxで写したページ。特徴と通い方が2列になり、料金カードは1列のまま金額が右に寄っている

2段階目の1024pxでは、列の数がもう一段変わります。

css
  .features,
  .steps {
    grid-template-columns: repeat(3, 1fr);
  }

  .steps {
    grid-template-columns: repeat(4, 1fr);
  }

  .price__grid {
    grid-template-columns: repeat(3, 1fr);
  }

.features.steps をまとめて3列にしたあと、次の1行で .steps だけを4列に上書きしています。通い方のステップは4つあるので、4列にすると1行にきれいに収まるからです。同じセレクタを2回書くのを避けようとして条件を細かく分けるより、こう書いたほうが読んだときに理由が分かります。

ブレークポイントは600pxと1024pxの2つだけで、3つ目は作りません。数を決めておくと「この幅だけ変」が起きたときに、疑う場所が2か所に絞られます。

ここまでで OK:ブラウザの幅を390pxから広げていくと、600pxと1024pxの2か所だけで列の数が変わる状態。

料金カードだけ container query にする

これは手で書く

@container は、書く場所(親)と効く場所(子)が離れています。1回自分の手でつないでおかないと、効かないときに探せません。

メディアクエリは画面の幅を見ます。でも、料金カードが知りたいのは画面の幅ではなく、自分がいま置かれている枠の幅です。同じカードをサイドバーに置いたら狭いし、本文いっぱいに置いたら広い。画面の幅は、そのどちらでも同じ数字です。

そこで、カードを並べている親に「測る対象になってください」と書きます。

css
.price__grid {
  container-type: inline-size;
  display: grid;
  grid-template-columns: 1fr;
  gap: 16px;
}

container-type: inline-size が「この要素の横幅を、子のCSSから問い合わせられるようにする」という宣言です。あとは @container で条件を書きます。

css
/* 枠(price__grid)の幅が380pxを超えたら、金額を右へ回す。
   画面幅ではなく「置かれた枠の幅」で切り替わるので、このカードを他の幅の場所に
   置き直しても、この1か所を直すだけで済む */
@container (min-width: 380px) {
  .plan__head,
  .plan {
    display: grid;
    grid-template-columns: 1fr auto;
    align-items: start;
    column-gap: 16px;
  }

  .plan__badge {
    grid-column: 1 / -1;
  }

  .plan__price {
    margin-top: 0;
    text-align: right;
  }

  .plan__price ~ .btn--block {
    grid-column: 1 / -1;
  }
}

境目を380pxにした理由は、実際の幅を数えると分かります。390pxの画面では左右16pxずつを引いて枠は358px。380pxに届かないので分岐に入らず、プラン名の下に金額が縦に積まれます。600pxの画面では枠が552pxになるので分岐に入り、プラン名が左・金額が右に並びます。さきほどの600pxのスクリーンショットで、カードは1列のままなのに中身の並びだけ変わっているのは、これが理由です。

使うのはこの1か所だけにします。全部を @container にすると、どの親を見て切り替わっているのかを毎回たどることになるからです。

補足

「画面ではなく枠を見る」という考え方は、部品を使い回すサイトほど効いてきます。同じカードをトップにも料金ページにも置いて、置き場所ごとにCSSを書き直さなくてよくなるからです。

ここまでで OK:390pxでは金額がプラン名の下に積まれ、600px以上では右に回っている状態。

AIに書かせる①:スクロールで現れる動き

ここから3つ、AIに渡します。1つ目は特徴3枚のスクロール表示です。

AIに任せる

渡すプロンプト

text
下のHTMLに、スクロールして画面に入ったら要素が現れる動きを付けてください。

【守ってほしいこと】
- ライブラリを使わない。素のJavaScriptとCSSだけ
- IntersectionObserver を使う。scroll イベントは使わない
- JavaScriptが動かない環境でも、内容が全部読める初期状態にする
  (opacity: 0 を初期値にして戻らなくなる書き方をしない。
   .js-ready というクラスを <html> に付けてから隠す形にする)
- 一度現れた要素は、画面外に出ても消さない(unobserve する)
- 動きはフェードと上へ12pxの移動だけ。時間は0.3秒。回転・拡大は使わない
- @media (prefers-reduced-motion: reduce) のときは、動かさずに最初から表示する
- 追加するクラス名と属性の一覧を、コードの前に書く

【HTML】
<「コアラインの3つのこと」の3枚のカード部分をそのまま貼る>

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

疑うポイント

  • JavaScript を切って開く(DevTools → 設定 → Debugger → Disable JavaScript)。3枚のカードが見えているか
  • prefers-reduced-motion の分岐が本当に入っているか。OSの「視差効果を減らす」をONにして確かめる
  • unobserve が呼ばれているか(呼ばれていないと、スクロールのたびに処理が走り続ける)

プロンプトの4行目から6行目が、この回でいちばん効いている指示です。「JavaScriptが動かない環境でも読める」と書かないと、opacity: 0 を初期値にしたコードが返ってきます。 それでも普段は動くので、気づかないまま公開できてしまいます。返ってきたCSSはこうでした。

css
.features__item {
  /* 既定(JS無し)では常に見える。.js-ready が付いたときだけ opacity で隠す */
  opacity: 1;
  transform: none;
  transition: opacity 0.3s ease, transform 0.3s ease;
}

html.js-ready .features__item {
  opacity: 0;
  transform: translateY(12px);
}

html.js-ready .features__item.is-visible {
  opacity: 1;
  transform: translateY(0);
}

読む順番は上から3つです。素の状態は見えている。JavaScriptが動いたときだけ隠す。画面に入ったらまた見せる。 js-ready はJavaScriptの1行目で <html> に付けるクラスなので、付かなければ2つ目と3つ目のルールは誰にも当たりません。JavaScriptは短いものが返ってきました。

js
// AIに書かせた部品(プロンプト:仕様 §8 部品① スクロールで現れる動き)
// 追加したクラス/属性:<html> に js-ready、各カードに is-visible(IntersectionObserver で付ける)
(() => {
  // このクラスが付いて初めて opacity:0 が効く。JSが動かない環境では付かないので、
  // カードは最初から全部見える状態のまま(CSS側の初期値が opacity:1)
  document.documentElement.classList.add('js-ready');

  const items = document.querySelectorAll('.features__item');
  if (!items.length) return;

  const observer = new IntersectionObserver(
    (entries, obs) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          entry.target.classList.add('is-visible');
          // 一度現れたら見張るのをやめる(出たり入ったりでもう消さない)
          obs.unobserve(entry.target);
        }
      });
    },
    { threshold: 0.2 }
  );

  items.forEach((item) => observer.observe(item));
})();
// /AIに書かせた部品

{ threshold: 0.2 } は「その要素の2割が画面に入ったら知らせて」という指定です。obs.unobserve(entry.target) で見張りをやめているので、上下にスクロールし直しても点滅しません。

ここは、疑ったけれど直すところが無かった

疑うポイントを3つとも確かめました。JavaScriptを切った状態でもカードは3枚とも表示されたまま(opacity1)。prefers-reduced-motion の分岐は部品③のCSSに入っていて、unobserve も呼ばれています。今回は直すところがありませんでした。

出なかったものを無理に直す必要はありません。ただし、見る場所を先に決めて毎回見るのは毎回やります。AIは同じプロンプトでも毎回同じものを返すとはかぎらないので、「今回は大丈夫だった」は次回の保証になりません。

ここまでで OK:スクロールすると3枚が下から順に現れ、JavaScriptを切ると最初から3枚とも見えている状態。

AIに書かせる②:通っている人の声の横スライダー

2つ目は横スライダーです。ここはライブラリを断る指示を最初から入れます。

AIに任せる

渡すプロンプト

text
「通っている人の声」の横スライダーを、HTMLとCSSとJavaScriptで書いてください。

【守ってほしいこと】
- ライブラリを使わない。Swiperなどを提案しないでください
- 横スクロールは CSS の scroll-snap-type: x mandatory と scroll-snap-align: start で作る
- 前後ボタンは JavaScript で element.scrollBy() を呼ぶだけにする(位置を自前で計算しない)
- JavaScriptが動かなくても、指やトラックパッドで横にスクロールできる
- ボタンには aria-label(「前のコメント」「次のコメント」)を付ける
- ボタンは Tab で到達でき、:focus-visible で枠が見える
- カードは1枚ずつ止まる。スマホは1枚、600px以上は2枚、1024px以上は3枚が見える幅にする
- scroll-behavior: smooth を使い、prefers-reduced-motion: reduce のときは auto に戻す
- スクロールバーは消してよいが、消す場合もキーボード操作は残す
- html-validate で error 0 になること

【カードの中身(6件・この文言のまま)】
<docs/sites/04-studio.md §4-6 の表をそのまま貼る>

【いま使っているCSS変数】
:root {
  --ink: #0b0d10;
  --lime: #d7ff3e;
  --face: #f2f3f5;
  --line: #d5d8dd;
  --ink-mid: #5a6069;
  --radius: 2px;
}

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

疑うポイント

  • Swiper などのライブラリを勧めてきたら、断って書き直させる(Lv04 は素のCSSでやる回)
  • 前後ボタンが、端まで来たときに何も起きないだけになっているか(エラーが出ていないか Console を見る)
  • カードの幅が 600px 1024px の2段階と合っているか(3段階目を勝手に足していないか)

CSSの中心は2つのルールだけでした。

css
.voice-slider__track {
  display: flex;
  gap: 16px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  padding-bottom: 8px;
  -webkit-overflow-scrolling: touch;
}

.voice-slider__item {
  flex: 0 0 100%;
  scroll-snap-align: start;
}

overflow-x: auto が横スクロールの本体、scroll-snap-type: x mandatory が「途中で止めずに必ずどこかのカードの位置で止める」、scroll-snap-align: start が「カードの左端で止める」です。この3行だけで、指でもトラックパッドでも1枚ずつ動きます。JavaScriptはまだ1行も要りません。 何枚見えるかは flex-basis の値だけで決まるので、既存の600pxと1024pxのブロックに1行ずつ足して済みました(calc(50% - 8px)calc(33.333% - 11px))。3段階目のブレークポイントは足されていませんでした。

JavaScriptは前後ボタンのぶんだけです。

js
// AIに書かせた部品(プロンプト:仕様 §8 部品② 横スライダー)
(() => {
  const track = document.getElementById('voice-track');
  const prevBtn = document.getElementById('voice-prev');
  const nextBtn = document.getElementById('voice-next');
  if (!track || !prevBtn || !nextBtn) return;

  // 1枚分の移動量をカードの実測幅から出す(位置そのものは自前で管理しない。
  // 移動は毎回 scrollBy() に任せる)
  function cardStep() {
    const firstItem = track.querySelector('.voice-slider__item');
    if (!firstItem) return track.clientWidth;
    const gap = parseFloat(getComputedStyle(track).gap) || 0;
    return firstItem.getBoundingClientRect().width + gap;
  }

  prevBtn.addEventListener('click', () => {
    track.scrollBy({ left: -cardStep(), behavior: 'smooth' });
  });

  nextBtn.addEventListener('click', () => {
    track.scrollBy({ left: cardStep(), behavior: 'smooth' });
  });
})();
// /AIに書かせた部品

scrollBy() は「いまの位置から、この距離だけずらして」という命令です。いま何枚目かを自分で数えていないのが要点で、指でスクロールされても、見える枚数が変わってもずれません。端まで来たら止まるだけで、エラーも出ません。

ここを疑って直した

ライブラリの提案は今回は出ませんでした(Swiperを勧められたら断って書き直させるつもりでしたが、最初から素のCSSとJavaScriptで返ってきました)。直したのは別の1点です。返ってきたHTMLの、トラックの行はこうでした。

html
  <ul class="voice-slider__track" id="voice-track">

この状態で検品のaxeにかけると、scrollable-region-focusable(重要度 serious)が1件出ました。横にスクロールする領域なのに、キーボードではそこへ行けない、という指摘です。指なら払える、マウスならホイールが使える。でもキーボードだけの人は、最初に見えている分から先へ進めません。

直したのは属性2つです。

html
        <ul class="voice-slider__track" id="voice-track" tabindex="0" aria-label="通っている人の声(横にスクロールできます)">

tabindex="0" は「本来はフォーカスできない要素を、Tabの順番に入れる」という指定です。これでスクロール領域そのものに枠が当たり、矢印キーで横に動かせます。aria-label は、その枠に止まったときに読み上げソフトが何と言うかで、無いと「リスト」としか言われません。

前後ボタンのほうには、指示したとおり aria-label(「前のコメント」「次のコメント」)が最初から付いていました。プロンプトに書いた指示は守られていて、書かなかった指示だけが抜けていたわけです。AIは指示の範囲を守るのが上手いぶん、指示していないことは出てきません。 だから機械の検品を通します。

ここまでで OK:6件が横に流れ、前後ボタンで1枚ずつ動き、Tabキーでスライダーの枠にも止まれる状態。

AIに書かせる③:動きを止めるCSS

3つ目は、いま足した動きを全部止めるためのCSSです。順番が逆に見えますが、動きを作った直後に書くのが正解です。あとで足すと、止め忘れが必ず出ます。

AIに任せる

渡すプロンプト

text
このページ全体について、OSで「視差効果を減らす」が有効な人には動きが出ないようにするCSSを書いてください。

【守ってほしいこと】
- @media (prefers-reduced-motion: reduce) の1ブロックにまとめる
- transition と animation を無効にする。ただし完全に 0s にすると
  一部のブラウザでコールバックが飛ばなくなるので、0.01ms にする書き方にする
- scroll-behavior: smooth を auto に戻す
- IntersectionObserver で付くクラス(.is-visible)に依存している要素が、
  動きなしで最初から見えるようにする
- 個別のセレクタを列挙せず、できるだけ短く書く

【いまのCSS(動きの部分だけ)】
<transition / animation / scroll-behavior を使っている箇所をまとめて貼る>

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

疑うポイント

  • * に対して !important を付ける書き方になっているか(ここは例外的に妥当)
  • 「動きなしで最初から見える」が本当に成立するか。OSの設定を変えて実際に開く
  • scroll-behavior の戻しが入っているか

返ってきたのは、この1ブロックだけでした。

css
/* ===== 動きを止める(AIに書かせた部品:プロンプト仕様 §8 部品③) =====
   *全体へのanimation/transition無効化はここだけの例外的な書き方。他の場所では真似しない。 */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }

  html.js-ready .features__item {
    opacity: 1 !important;
    transform: none !important;
  }

  .voice-slider__track {
    scroll-behavior: auto !important;
  }
}

*(すべての要素)に !important を付けるのは、ふだんは避ける書き方です。他のCSSを全部押しのけるので、あとから直したいときに効かなくなります。ここだけは例外にします。「動きを止めてほしい」という利用者の設定より優先していい指定は、このページに1つも無いからです。他の場所でこの書き方を真似しないでください。

0.01ms にも意味があります。0s にすると、アニメーションの終了を待って動くコードが「終わった」の合図を受け取れないブラウザがあります。ほぼ一瞬で終わる長さにしておけば、見た目は止まったまま、合図だけは飛びます。

真ん中のルールが、部品①との待ち合わせです。.is-visible が付くのを待たずに opacity: 1 にしているので、動かさないまま最初から3枚とも見えます。 動きを止めた結果として内容が消えたら、止めた意味がありません。3つ目は scroll-behaviorauto(一瞬で移動)に戻しています。

疑うポイントは3つとも確かめました。OSの「視差効果を減らす」がONの状態でカードを測ると opacity1scroll-behavior の戻しも入っています。この部品も直すところがありませんでした。

ここまでで OK:OSの「視差効果を減らす」をONにして開くと、動きが1つも出ないのに内容は全部見えている状態。

検品する

新しく足す検品は1つ、axeです。Lighthouseと同じでURLを開いて測る道具なので、Live Serverで開いてからそのアドレスを渡します。

検品

bash
npx -y @axe-core/cli http://127.0.0.1:5500/

合格の見え方0 violations と出る。incomplete は「機械では判断できなかった」項目なので、件数が出ていても不合格ではありません。1件ずつ自分の目で見て、大丈夫かどうかを自分で決めます。

この完成コードでは、初回に violations が2件出ました。 2件とも直して0件にしています。何が出たかを書いておきます。

出た指摘意味どう直したか
ランドマークの外に本文があるヒーローの見出しと本文が、役割の付いた大きな箱(main など)の外に置かれていたヒーローを <main> の中の最初のセクションに移した
scrollable-region-focusable横スクロールする領域に、キーボードで到達できないスライダーのトラックに tabindex="0"aria-label を足した

1つ目は、読み上げソフトの「本文へ飛ぶ」がヒーローを素通りしてしまう状態でした。完成コードのHTMLには、そのときのメモがコメントで残してあります。

html
  <!-- axe: ページ内容はランドマーク(main)の中に置く。ヒーローも main の最初のセクションにする -->

incomplete は1件でした。写真の上に載せた白い文字のコントラスト(読みやすさ) です。背景が写真だと、機械には「文字の後ろの色」が1つに決まらないので、判断を人に返してきます。実際に開いて、黒の重ね(rgba(11, 13, 16, 0.55))が効いていて読めることを目で確かめました。機械が黙ったところが、人が見るところです。

補足

通い方の通し番号の色を、薄い透過ではなく --ink-mid の実色にしてあるのも、この検査のためです。薄い灰色の数字は軽く見えて気持ちいいのですが、コントラストで引っかかる代表格です。

コマンドで測れないものは、手と目で見ます。この回はキーボードです。Tabキーだけを押し続けて、この順に回れることを確かめます。

順番止まる場所確認すること
1ロゴライムの枠が見える
2〜6ナビ5項目(1024px以上)押すとその見出しへ飛ぶ
7ヘッダーの「体験を予約する」枠が見える
8ヒーローのCTA枠が見える
9「料金を見る」押すと料金へ飛ぶ
10〜12プランのボタン3つ3つとも飛ばされない
13スライダーの枠そのもの矢印キーで横に動く
14〜15前・次のボタン1枚ずつ動く
16〜17末尾の電話・メール枠が見える

13番目が、さきほど足した tabindex="0" です。足す前は、この行がまるごと飛ばされていました。 表にして順番に押していくと、飛ばされている場所は必ず見つかります。

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

検品

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

合格の見え方:1つ目は 0 errors。2つ目は撮れた shots/mine.png を配布の compare.html でカンプPNGに重ねて、白い帯(ずれている場所)が出ないこと。3つ目は SEO と Best Practices に赤い印の項目が無いこと。

この回だけの数え方も2つあります。モバイルファーストで書けたかどうかは、機械で数えられます。

検品

bash
grep -c "@media (max-width" style.css
grep -c "@media (min-width" style.css
grep -c "@container" style.css

合格の見え方:上から順に 0 2 1max-width のメディアクエリが0個で、min-width が600pxと1024pxの2つ、@container が料金カードの1か所だけ、という意味です。

max-width という言葉そのものは、max-width: 1120px のようにコンテンツ幅の上限として今回も使っています。探しているのは @media (max-width のほうだけなので、そこまで含めて数えてください。

最後に、DevToolsの設定から Disable JavaScript をONにして読み込み直します。特徴の3枚も、声の6件も、全部見えていれば合格です。 動きが無いだけの、この回の出発点として先に作ったページに戻ります。動きは足しものであって、土台ではない。 これがこの回のいちばん大きな結論です。

ここまでで OK:axeが 0 violations、Tabキーで17か所すべてに枠が出て、JavaScriptを切っても内容が全部読める状態。

次の回へ

7割を渡せたのは、渡す前に「動かなくても読めるページ」を自分の手で完成させていたからです。土台があると、AIの出力は足し算にしかなりません。

次は中級編3、動画背景と音のあるメディアサイトです。手は2割まで減りますが、代わりにブラウザ側の制限(音は勝手に鳴らせない・動画は重い)が主役になります。検品には「ページの重さ」が1つ足されます。大丈夫、まだ間に合います。分けて渡す順番は、もう身に付いています。

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

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