Lv.03

中級編1 湘南のAI勉強会の告知ページ

縦長1ページ・手とAIで半分ずつ

琥珀

琥珀からの依頼書

AI勉強会の告知ページを、縦長1ページで作ってください。申し込みボタンは3か所に置いてください。

申し込みフォームは dialog 要素で開くようにしてください。

スマホ版のCSSはAIに起こさせ、Lighthouse の SEO とベストプラクティスの指摘を全部つぶしてください。

カンプと配布物

縦長HTMLカンプ/PNG(1440・390)/完成ZIP/公開デモ

手で書くところ

clamp()/min()・max()/position:sticky の追従CTA/scroll-margin-top/dialog要素の申し込み窓/フォームの入力補助

AIに任せるところと検品

AIに書かせる:スマホ版CSS・FAQのdetails・OGPとJSON-LD(Event)/+Lighthouse(SEO・ベストプラクティス)

依頼を受けたところから

依頼主は、藤沢でひらかれているAI勉強会の実行委員会です。今回で7回目になります。依頼書を読むと、やることは3つに割れます。縦長の1ページに上から情報を並べること申し込みボタンを3か所に置いて、どれを押しても同じ窓が開くことスマホ版のCSSはAIに起こさせること。この回から、書く量の半分がAIの担当になります。半分渡すぶん、検品を1つ増やします。

完成した告知ページを、幅1440pxで上から下まで写したもの

スマホ幅(390px)だと、こうなります。同じHTMLで、CSSだけが切り替わっています。

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

この回で前提にしているのは 01(カフェ)と 02(工務店)です。flexbox・メディアクエリ・BEM・CSSカスタムプロパティ(CSSの中で使い回せる自前の変数)・CSS Grid は、説明せずにそのまま使います。

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

この回で新しく出るもの

新しいのは、この6つだけです。

ひとことで言うとどこで使うか
clamp()「最小・伸び方・最大」を1行で書く見出しの文字サイズ、セクションの余白
min() max()2つの値のうち小さい方/大きい方を採る申し込み窓の幅と高さ
position: sticky決めた位置まで来たら、そこに張り付く画面下の追従CTAバー
scroll-margin-topページ内リンクで飛んだとき、上に余白を残すロゴから先頭へ戻るとき
<dialog>ブラウザに最初から入っている「窓」申し込みフォーム
入力補助の属性どのキーボードを出すかをブラウザに教えるフォームの各入力欄

検品には Lighthouse(ページの品質を機械が採点する道具)が1つ増えます。

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

文字と余白を clamp() で伸ばす

これは手で書く

この回の一番の変化がここに出ます。手で書いておかないと、あとで数える「メディアクエリの中身が減った」に自分で気づけません。

02までは「PCではこの大きさ、768px以下ではこの大きさ」と2つの数字を書き分けていました。clamp(最小値, 伸び方, 最大値) は、それを1行にします。変数をまとめている場所に、3行足します。

css
  /* clamp() で画面幅に連動させる。メディアクエリを増やさないための土台 */
  --section-pad: clamp(56px, 9vw, 120px);
  --h2-size: clamp(24px, 4vw, 36px);
  --h1-size: clamp(26px, 5vw, 44px);

真ん中の vw画面の横幅の1%という単位です。9vw なら画面幅の9%で、広がれば増えますが 120px で止まり、狭くなっても 56px より小さくなりません。伸びっぱなしにも潰れっぱなしにもならないので、メディアクエリの代わりになります。使う側は1回呼ぶだけです。

css
.section {
  padding: var(--section-pad) 0;
}

見出しも同じで、変数を呼ぶだけです。1回しか使わない値は、その場に直接書いても構いません。

css
.section__title {
  font-size: var(--h2-size);
  margin-bottom: clamp(24px, 4vw, 40px);
}

変数にまとめておくと、「余白が全体的に広すぎる」と言われたときに直す場所が1か所で済みます。上限と下限は自分で決めた数字です。 途中の大きさだけをブラウザに計算させています。

補足

min()max()clamp() の兄弟です。min(520px, 92vw) は「520pxか画面幅の92%か、小さい方」。申し込み窓の幅がこれで、狭い画面でははみ出さず、広い画面では520pxで止まります。

ここまでで画面がこうなっていれば OK:ブラウザの幅をゆっくり動かすと、見出しと余白がなめらかに変わる状態。

追従CTAバーを sticky で置く

これは手で書く

position: sticky は「どの箱の中にいるか」で挙動が変わります。入れ子を自分で作らないと腑に落ちない仕組みなので、ここも手で書きます。

画面の下に、日時と申し込みボタンのバーを出したままにします。fixed(画面に固定)との違いは、張り付く範囲が親の箱の中に限られることです。この性質を使えば、「ファーストビューの間は出さず、そのあとずっと出す」がJavaScriptなしで書けます。ファーストビューより後ろを1つの箱でくくり、その箱の最後にバーを置きます。

html
<!-- FVより後ろを1つの箱にまとめる。追従CTAバーはこの箱の最後の要素として
     position: sticky; bottom: 0 を使うので、箱の範囲=FVを過ぎてから終わりまでの間だけ
     画面下に張り付く(sticky は「その要素が属する箱」の中でしか張り付かないため) -->
<div class="scroll-body">

バー本体は、フッターのあと、この箱を閉じる直前です。

html
<div class="follow-bar">
  <p class="follow-bar__text">
    <span class="follow-bar__text--full">11/14(土)14:00 - 17:00 / 無料 / 残り席あり</span>
    <span class="follow-bar__text--short">11/14(土)14:00 / 無料</span>
  </p>
  <button type="button" class="btn btn--primary btn--small js-open-signup">申し込む</button>
</div>

文言を2つ入れて片方をCSSで消すのは、文字数の違う文を幅で切り替えるためです。

css
.follow-bar {
  position: sticky;
  bottom: 0;
  z-index: 3;
  background: var(--ink);
  color: var(--white);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 12px clamp(16px, 5vw, 40px);
  border-top: 1px solid rgba(255, 255, 255, 0.15);
}

bottom: 0 が「画面の下端に張り付く」の指定です。top と書くと上に張り付きます。あわせて html に1行足します。

css
html {
  /* ロゴから #top へ戻ったとき、要素が画面の一番上ぎりぎりにならないための余白 */
  scroll-margin-top: 24px;
}

scroll-margin-topページ内リンクで飛んだときだけ効く余白です。無いと、飛んだ先の見出しが画面の縁にぴったり付いて読みにくくなります。

ここまでで画面がこうなっていれば OK:ファーストビューを抜けた瞬間に画面の下へバーが現れ、最後まで付いてくる状態。

申し込み窓を dialog 要素で作る

これは手で書く

フォーカス(いまキーボードの入力を受けている場所)の行き来は、この回の学習目標そのものです。ここをAIに任せると、覚えることが何も残りません。

<dialog>showModal() で開くと、次の3つが書かなくても付いてきます。Esc キーで閉じる/後ろが操作できなくなり Tab が窓の中だけを回る/後ろを暗くする板が敷かれる(::backdrop でCSSを当てられる)。

フォームの入力補助

中身はふつうのフォームです。新しいのは、入力欄に付ける属性です。

html
      <div class="field">
        <label for="f-email">メールアドレス</label>
        <input type="email" id="f-email" name="email" autocomplete="email" inputmode="email" required>
      </div>

役割が4つに分かれています。type何を入れる欄かinputmodeスマホでどのキーボードを出すかautocomplete保存済みの自分の情報を候補に出すかrequired空のまま送信させないか。この2つが入っていると、スマホで @ のあるキーボードが最初から出ます。

<label for="f-email"><input id="f-email"> は同じ文字にします。対応していると、ラベルの文字を押しただけで入力欄に入れます。指で押す画面では、この差が効きます。ボタンは2つです。

html
      <div class="signup-form__actions">
        <button type="submit" class="btn btn--primary">送信する</button>
        <button type="submit" formmethod="dialog" class="btn-text">閉じる</button>
      </div>

2つ目の formmethod="dialog" が、送信せずに窓を閉じるという意味の書き方です。JavaScriptは要りません。

開閉のJavaScript

script.js で自分で書くのは、実質この2か所だけです(変数の宣言は完成ZIPを見てください)。

js
  function openDialog(trigger) {
    lastTrigger = trigger;
    formView.hidden = false;
    doneView.hidden = true;
    form.reset();
    dialog.showModal();
    openers.forEach((btn) => btn.setAttribute('aria-expanded', 'true'));
  }

  openers.forEach((btn) => {
    btn.setAttribute('aria-expanded', 'false');
    btn.addEventListener('click', () => openDialog(btn));
  });

  // Esc・閉じるボタンなど、どの閉じ方でも dialog の close イベントは必ず発火する
  dialog.addEventListener('close', () => {
    openers.forEach((btn) => btn.setAttribute('aria-expanded', 'false'));
    if (lastTrigger) {
      lastTrigger.focus();
      lastTrigger = null;
    }
  });

どのボタンから開いたかを覚えておくのが lastTrigger です。閉じたときにそこへフォーカスを戻さないと、Escで閉じた人は画面の先頭に放り出されます。close イベントは閉じ方によらず必ず1回起きるので、後始末をここ1か所に書けます。aria-expanded は「いま開いているか」を読み上げソフトに伝える属性です。3か所のCTAは js-open-signup クラスでまとめて拾っているので、同じ窓が3か所から開きます。もう1か所は送信です。

js
  form.addEventListener('submit', (event) => {
    // 「閉じる」ボタン(formmethod="dialog")はブラウザ標準の閉じる動作に任せる
    if (event.submitter && event.submitter.formMethod === 'dialog') {
      return;
    }
    // このサイトは練習用で送信先が無いので、送信をキャンセルして完了表示に差し替える
    event.preventDefault();
    formView.hidden = true;
    doneView.hidden = false;
  });

スマホで開くと、こう見えます。

スマホ幅で申し込み窓を開いたところ。画面いっぱいに窓が開いている

補足

この練習用サイトは、フォームを実際には送信しません。完了メッセージの下に「これは練習用のページです。実際にはメールは送信されていません」と書いてあるのはそのためです。本番にするには受け取り先を別に用意します。送信先が無いまま「送信できたように見える画面」を公開しないでください。

ここまでで画面がこうなっていれば OK:3か所のどのボタンを押しても同じ窓が開き、Escで閉じると押したボタンに枠(フォーカスの印)が戻る状態。

AIに書かせる①:スマホ版のCSS

PC版が完成したので、スマホ版を起こしてもらいます。

AIに任せる

渡すプロンプト

text
下のCSSは、PC(1440px)で完成しているイベント告知ページのものです。
これを見て、768px 以下のときのCSSだけを書いてください。PC側のCSSは変更しないでください。

【守ってほしいこと】
- 追記する形(@media (max-width: 768px) の中だけ)で書く
- 横スクロールが出ないようにする
- タップできるものは高さ44px以上にする
- 文字は14px未満にしない
- clamp() で指定済みの値は上書きしない(サイズはすでに可変になっているため)

【SPでの見え方の指示】
- ファーストビュー:見出しの改行位置を変えず、上下の余白だけ詰める。背景写真は縦位置に切り替え済み
- 「こんな人に来てほしい」の4項目:2列から1列へ
- タイムテーブル:時刻を左、内容を右の2列から、時刻を上・内容を下の縦積みへ
- 話す人の3枚:横並びから縦積みへ
- 前回までの様子の3枚:横スクロール(scroll-snap は使わない)
- 追従CTAバー:左のテキストを「11/14(土)14:00 / 無料」に短縮し、ボタンを右に固定
- dialog:画面の高さいっぱいに開き、中身をスクロールできるようにする

【いまのCSS】
<style.css を全部貼る>

疑うポイント

  • PC側のCSSを書き換えていないか(git diff か差分ツールで必ず見る)
  • clamp() の値を font-size: 20px などで上書きしていないか
  • dialog が画面からはみ出して、閉じるボタンに手が届かなくなっていないか(390px で実際に開く)

指示が長いのは、見え方をこちらが全部決めているからです。「いい感じにスマホ対応して」と書くと、決めるのがAIになります。決めるのが自分なら、出てきたものの合否も自分で言えます。

返ってきたのは11個の指定でした。ほとんどは2列を1列にする書き換えなので、写真3枚の部分だけ載せます。

css
  .gallery-scroll {
    display: flex;
    grid-template-columns: none;
    overflow-x: auto;
    scroll-snap-type: x proximity;
    padding-bottom: 8px;
  }

  .gallery-scroll__item {
    flex: 0 0 78vw;
    scroll-snap-align: start;
  }

ここを疑って直した

上は直したあとです。返ってきた直後、写真3枚の行はこう書かれていました。

css
  .gallery-scroll__item {
    flex-basis: 78vw;
  }

390pxで開くと、写真は横に流れず、3枚が画面の中に詰め込まれて並びました。指示した「横スクロール」になっていません。原因は flex-basis だけを書いたことです。flexbox の子要素は、既定で「入りきらなければ縮む」flex-shrink の初期値が 1)ので、基準が78vwでも3枚で234vw、入らないので全部縮めて収めてしまいます。

見つけ方は単純で、390pxの幅で実際に横へ払ってみる。動かなければそこが違っています。DevToolsの要素タブで写真を選べば、計算後の幅が78vwよりずっと小さいことも見えます。直したのは1行です。

css
    flex: 0 0 78vw;

「伸びない・縮まない・幅は78vw」をまとめた書き方です。縮まなくなったので、画面からはみ出して横スクロールになりました。.gallery-scrollgrid-template-columns: none; も足しています。PC側が display: grid の3列なので、flex に変えたときに列の指定が残らないよう明示的に消しました。

疑うポイントの1つ目と2つ目は、今回は問題が出ませんでした。出なかったものを直す必要はありませんが、見るのは毎回です。AIは同じプロンプトでも毎回同じものを返すとはかぎらないので、見る場所を先に決めておくことのほうが、直すこと自体より効きます。

ここまでで画面がこうなっていれば OK:390px幅で2列が1列になり、写真3枚が横に流れる状態。

AIに書かせる②:よくある質問の details 6件

質問と答えの文言は依頼主からもらっています。中身がこちらにあるものは、形だけを頼むのが一番ぶれません。

AIに任せる

渡すプロンプト

text
イベント告知ページの「よくある質問」を、HTMLとCSSで書いてください。

【守ってほしいこと】
- <details> と <summary> を使う。JavaScriptは使わない
- 最初はすべて閉じた状態にする(open 属性を付けない)
- summary の三角マーカーは消し、右端に自前の「+」「−」を CSS の ::after で出す
  (文字の+−を使う。アイコンフォントや画像は使わない)
- summary は高さ 56px 以上(指で押せる大きさにするため)
- 色・余白は下のCSS変数だけを使う
- html-validate で error 0 になること

【いま使っているCSS変数】
:root {
  --ink: #111827;
  --orange: #ff5a1f;
  --white: #ffffff;
  --line: #e5e7eb;
  --ink-mid: #4b5563;
  --radius: 6px;
}

【入れる中身(この6件だけ)】
<質問と答えの6件を、表のまま貼る>

【出力】
HTMLとCSSを別々のコードブロックで。説明は3行以内で。

疑うポイント

  • summary の中に <h3> を入れていないか(見出しの階層が壊れる。入っていたら外す)
  • 三角マーカーを消すのに list-style: none だけで済ませていないか(Safari は ::-webkit-details-marker も要る)
  • open 状態と閉じた状態で、 が入れ替わるか(details[open] summary::after

返ってきたHTMLは、6件ともこの形でした。

html
        <details class="faq__item">
          <summary>AIをまったく触ったことがありませんが、大丈夫ですか。</summary>
          <p>大丈夫です。前半の事例は、触ったことがない前提で話します。後半は手を挙げていただければ、実行委員が横につきます。</p>
        </details>

CSSで見るのは、開閉の印を出しているここです。

css
.faq__item summary::-webkit-details-marker {
  display: none;
}

.faq__item summary::after {
  content: "+";
  flex-shrink: 0;
  color: var(--orange);
  font-family: "Archivo", sans-serif;
  font-size: 20px;
}

.faq__item[open] summary::after {
  content: "-";
}

summary 側の list-style: none と、この ::-webkit-details-marker の両方が入っていました。片方だけだと、ブラウザによって三角が残ります。<summary> の中に <h3> が入っていないことも確認しました(入っていると見出しが6つ増えて階層が崩れます)。この3点は、コードを読む前に決めておいたチェック項目です。 先に決めておくと、出てきたコードの見た目のきれいさに引っぱられません。

ここまでで画面がこうなっていれば OK:6件すべてが閉じた状態で並び、押すと開いて、右端の記号が + から - に変わる状態。

AIに書かせる③:OGP と JSON-LD

最後は <head> です。OGP(SNSに貼ったときに出る画像とタイトルの指定)と JSON-LD(このページが何についてのページかを、検索エンジン向けに機械が読める形で書いたもの)。決まりごとの塊なのでAIが得意ですが、書かれていない情報まで埋めてくるのが一番出やすい場所でもあります。

AIに任せる

渡すプロンプト

text
イベント告知ページの <head> に入れる、OGPのメタタグと JSON-LD を書いてください。

【守ってほしいこと】
- OGP は og:type / og:title / og:description / og:url / og:image の5つ。Twitter Card も summary_large_image で付ける
- JSON-LD は schema.org の Event を使う。application/ld+json のスクリプトタグ1本で出す
- 存在しない情報を書かない。分からない項目は入れない(推測で住所や価格を書かない)
- URL は https://example.com/ を仮のホストとして使う

【イベントの情報】
名称:湘南AIナイト vol.7
説明:「うちの仕事のどこにAIを入れるか」を3時間で決めて帰る会。従業員30人以下の会社を回している人向け。
開始:2026-11-14T14:00+09:00
終了:2026-11-14T17:00+09:00
場所:藤沢駅から徒歩5分の貸会議室(詳しい住所は申し込み後に案内。市区町村までなら「神奈川県藤沢市」)
参加費:無料(0円)
定員:40名
主催:湘南AIナイト実行委員会
画像:https://example.com/images/ogp.webp

【出力】
<head> にそのまま貼れる形で、1つのコードブロックに。説明は3行以内で。

疑うポイント

  • 住所を勝手に補完していないか(番地まで書かれていたら消す)
  • offersprice"0"priceCurrencyJPY になっているか
  • eventAttendanceModeeventStatus を付けるなら、値が schema.org の定義どおりか。自信が無ければ外す

疑うポイントに関係するのは、返ってきた JSON-LD のこの部分です。

html
  "location": {
    "@type": "Place",
    "name": "藤沢駅から徒歩5分の貸会議室",
    "address": {
      "@type": "PostalAddress",
      "addressLocality": "藤沢市",
      "addressRegion": "神奈川県",
      "addressCountry": "JP"
    }
  },
  "offers": {
    "@type": "Offer",
    "price": "0",
    "priceCurrency": "JPY",
    "url": "https://example.com/"
  },

3つとも確かめました。住所は「神奈川県藤沢市」までで、番地は書かれていません(詳しい住所は申し込み後に案内する、という依頼主の決めごとどおりです)。price は文字列の "0" で通貨は JPYeventAttendanceModeeventStatus付いていませんでした。プロンプトに「自信が無ければ外す」と書いたぶん、素直に外れています。「分からないなら書かないで」と先に言えるかどうかで、あとの確認の量が変わります。

貼ったら、AIが書いた範囲をコメントではさんでおいてください。完成コードでは <!-- AIに書かせた部品 --><!-- /AIに書かせた部品 --> ではさんであります。半年後の自分が読み返したとき、どこを疑えばいいかが1秒で分かります

ここまでで画面がこうなっていれば OK:<head> にOGPの5行とJSON-LDが入り、ブラウザで見た目は何も変わらない状態。

検品する

新しく足す検品は1つ、Lighthouse です。URLを開いて測る道具なので、ファイルを直接開いた状態(file://)では測れません。Live Server で開いてから、そのアドレスを渡してください。準備編で http:// で開く癖を付けたのは、この日のためです。

検品

bash
npx -y lighthouse http://127.0.0.1:5500/ --only-categories=seo,best-practices --view

合格の見え方--view で結果がブラウザに開きます。SEO と Best Practices に、赤い印(不合格)の項目が1つも無いこと。点数そのものは合格条件にしません。自分の初回の点数をメモしておいて、次のレッスンでそれを上回ることを目標にしてください。

点数を目標にしないのは、測る環境(回線・パソコンの速さ・拡張機能)で数字が動くうえに、点数は上がったのに赤い項目は残ったままが起こるからです。赤い項目には「何が問題で、どう直すか」が書いてあります。そちらを潰すほうが、直した内容を自分の言葉で説明できます。累積の検品は、前の2回と同じです。まとめて通します。

検品

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

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

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

見るところ合格
3か所のCTA+追従バー4か所とも、押すと同じ窓が開く
窓を開いた直後最初の入力欄(お名前)にカーソルが入っている
Esc キー閉じて、直前に押したボタンに枠が戻る
Tab キーだけ名前 → メール →(略)→ 送信 まで届く
空のまま送信ブラウザの警告が出て、送信されない
390px幅横スクロールバーが出ない・閉じるボタンに指が届く

最後に、この回の成果を数えてください。メディアクエリの中に書いた指定の数です。

02(工務店)03(この回)
@media のブロック1つ1つ
その中に書いた指定20個11個
clamp() を使った箇所0か所12か所

ページの縦は02より長いのに、幅で書き分けた指定は半分近くまで減りました。減ったぶんを clamp() が引き受けています。「並びが変わるところ」だけをメディアクエリに書く分け方ができると、あとから直す場所を探しやすくなります。この数字は完成コードを数えた実測値です。手元の数とずれても構いません。減っていれば狙いどおりです。

ここまでで OK:html-validate0 errors、Lighthouse の SEO と Best Practices に赤い項目が無く、初回の点数を手元にメモできた状態。

次の回へ

半分をAIに渡せたのは、渡す前に「どう見えるか」を自分で決めていたからです。決めていないものは渡せません。

次は中級編2、パーソナルジムのサイトです。手は3割まで減り、代わりにスクロールで現れる動きが入ります。動くものが増えると壊れ方も増えるので、検品にアクセシビリティ(キーボードだけで全部操作できるか)が1つ足されます。大丈夫、まだ間に合います。順番はもう身に付いています。

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

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