第8回 試験用キッチンタイマー制作|外観テーマ切り替え編

この記事は約145分で読めます。

前回の第7回では、累積時間/個別時間の切り替え、上段・下段ごとの終了表示、通常試験全体の終了表示、個別延長タイマーの増減などを追加しました。

機能面はかなり完成形に近づいてきたので、今回は少し寄り道して「見た目」で遊んでみます。

第2回では、いくつかのデザイン案を比較したあと、青いガラス・ネオン風のデザインを標準として採用しました。今回はそのときに作った案も参考にしながら、設定画面から外観テーマを切り替えられるようにします。

今回は実用性を最優先にしつつ、機能がほぼ完成してきたので少し遊び心も入れています。見た目が派手でも、タイマーの数字が読みにくくなるテーマにはしません。

今回追加するもの

  • 設定画面へ「外観テーマ」を追加する
  • ラジオボタンで7種類のテーマを切り替えられるようにする
  • テーマを選んだ瞬間に設定画面そのものも切り替える
  • 本番画面、タイマーパネル、終了画面、個別延長、操作ボタンまで画面全体へテーマを反映する
  • 時計フォントは個別設定にせず、各テーマの雰囲気に合わせてテーマ側で変更する
  • スチームパンクは「純CSS」「CSS+SVG」「CSS+画像」の3方式を実際に作り、違いを比較する

今回用意した7テーマ

テーマ特徴
ブルー・ガラスこれまで使ってきた標準デザイン
ブラック・インダストリアル黒い機械パネルをイメージした実用寄りデザイン
ミニマル装飾を抑えた落ち着いたデザイン
サイバーパンク青・紫のネオンとHUD風表現を使った近未来デザイン
スチームパンク A純CSSだけで真鍮・金属風の雰囲気を表現
スチームパンク BCSSにSVGの歯車装飾を追加
スチームパンク CCSSに画像テクスチャを追加

最初からどれか1つへ絞るのではなく、まず全部実装して実際に動かしてから考えることにしました。コンセプト画像はめちゃくちゃきれいですが、今回はそこまでの作りこみはしません。

AIとの分担制作について

このシリーズでは、必要な機能、画面の動き、使い方、修正内容を私が考え、HTML・CSS・JavaScriptのソースコード作成をAIのノンへ依頼しています。私は完成したコードを実際に動かし、確認しながら修正内容を決めています。

第1回で詳しく紹介しているため、今回も説明は簡単にしています。コードを書かない側でも、目的や動作を具体的に伝え、実際に確認しながら調整していくことでWebアプリを少しずつ作り上げています。

まずは完成したソースコード

今回も、先に完成版のソースコードを掲載します。第7回の機能はそのまま残し、外観テーマ切り替えを追加しています。

第8回のソースコード一式をZIPでダウンロード

今回はスチームパンクCで画像ファイル、スチームパンクBでSVGファイルも使用します。index.html、style.css、script.jsだけでなく、assetsフォルダも同じ構成で配置してください。

index.html

index.html のソースコードを表示
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>試験用キッチンタイマー</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="app-shell">
    <section id="setup-screen" class="screen setup-screen" aria-labelledby="setup-title">
      <header class="page-header">
        <h1 id="setup-title">試験用キッチンタイマー</h1>
        <p>上段・下段のタイマーを設定してください</p>
      </header>

      <form id="setup-form" class="setup-form" novalidate>
        <section class="setup-group board-title-setup" aria-labelledby="board-title-setup-heading">
          <h2 id="board-title-setup-heading" class="setup-subheading">画面タイトル</h2>
          <div class="field-block">
            <label for="board-title-input">一番上に表示するタイトル</label>
            <input id="board-title-input" name="boardTitle" type="text" value="試験用キッチンタイマー" maxlength="40">
          </div>
        </section>

        <section class="setup-group theme-setup" aria-labelledby="theme-setup-heading">
          <div class="theme-setup-heading-row">
            <div>
              <h2 id="theme-setup-heading" class="setup-subheading">外観テーマ</h2>
              <p class="theme-help">テーマを選ぶと、設定画面を含む画面全体がその場で切り替わります。</p>
            </div>
            <p class="theme-experiment-note">第8回:見た目の実験</p>
          </div>
          <fieldset class="theme-fieldset">
            <legend class="visually-hidden">外観テーマを選択</legend>
            <label class="theme-option theme-option-blue">
              <input type="radio" name="app-theme" value="blue" checked>
              <span class="theme-swatch" aria-hidden="true"></span>
              <span class="theme-option-text"><strong>ブルー・ガラス</strong><small>現在の標準テーマ</small></span>
            </label>
            <label class="theme-option theme-option-industrial">
              <input type="radio" name="app-theme" value="industrial">
              <span class="theme-swatch" aria-hidden="true"></span>
              <span class="theme-option-text"><strong>ブラック・インダストリアル</strong><small>機械パネル風</small></span>
            </label>
            <label class="theme-option theme-option-minimal">
              <input type="radio" name="app-theme" value="minimal">
              <span class="theme-swatch" aria-hidden="true"></span>
              <span class="theme-option-text"><strong>ミニマル</strong><small>落ち着いた実用重視</small></span>
            </label>
            <label class="theme-option theme-option-cyber">
              <input type="radio" name="app-theme" value="cyber">
              <span class="theme-swatch" aria-hidden="true"></span>
              <span class="theme-option-text"><strong>サイバーパンク</strong><small>ネオンHUD風</small></span>
            </label>
            <label class="theme-option theme-option-steam-css">
              <input type="radio" name="app-theme" value="steam-css">
              <span class="theme-swatch" aria-hidden="true"></span>
              <span class="theme-option-text"><strong>スチームパンク A</strong><small>純CSSのみ</small></span>
            </label>
            <label class="theme-option theme-option-steam-svg">
              <input type="radio" name="app-theme" value="steam-svg">
              <span class="theme-swatch" aria-hidden="true"></span>
              <span class="theme-option-text"><strong>スチームパンク B</strong><small>CSS+SVG装飾</small></span>
            </label>
            <label class="theme-option theme-option-steam-image">
              <input type="radio" name="app-theme" value="steam-image">
              <span class="theme-swatch" aria-hidden="true"></span>
              <span class="theme-option-text"><strong>スチームパンク C</strong><small>CSS+画像装飾</small></span>
            </label>
          </fieldset>
        </section>

        <section class="setup-group" aria-labelledby="upper-setup-heading">
          <div class="setup-group-header">
            <div class="field-block title-field-block">
              <label for="upper-title">上段タイトル</label>
              <input id="upper-title" name="upperTitle" type="text" value="上段タイトル" maxlength="30">
            </div>
            <div class="field-block count-field-block">
              <label for="upper-count">タイマー数</label>
              <select id="upper-count" name="upperCount">
                <option value="1">1個</option><option value="2">2個</option><option value="3">3個</option><option value="4" selected>4個</option>
              </select>
              <p class="count-note">上段に、一番タイマー数の多い段を設定してください。</p>
            </div>
          </div>
          <fieldset class="time-mode-fieldset">
            <legend>時間の表示方法</legend>
            <label class="radio-option"><input type="radio" name="upper-time-mode" value="cumulative" checked> 累積時間</label>
            <label class="radio-option"><input type="radio" name="upper-time-mode" value="individual"> 個別時間</label>
          </fieldset>
          <h2 id="upper-setup-heading" class="visually-hidden">上段タイマー設定</h2>
          <div id="upper-timer-settings" class="timer-settings-list"></div>
        </section>

        <section class="setup-group" aria-labelledby="lower-setup-heading">
          <div class="setup-group-header">
            <div class="field-block title-field-block">
              <label for="lower-title">下段タイトル</label>
              <input id="lower-title" name="lowerTitle" type="text" value="下段タイトル" maxlength="30">
            </div>
            <div class="field-block count-field-block">
              <label for="lower-count">タイマー数</label>
              <select id="lower-count" name="lowerCount">
                <option value="0">0個</option><option value="1">1個</option><option value="2">2個</option><option value="3" selected>3個</option><option value="4">4個</option>
              </select>
              <p class="count-note">下段は、上段のタイマー数以下から選択できます。</p>
            </div>
          </div>
          <fieldset class="time-mode-fieldset">
            <legend>時間の表示方法</legend>
            <label class="radio-option"><input type="radio" name="lower-time-mode" value="cumulative" checked> 累積時間</label>
            <label class="radio-option"><input type="radio" name="lower-time-mode" value="individual"> 個別時間</label>
          </fieldset>
          <h2 id="lower-setup-heading" class="visually-hidden">下段タイマー設定</h2>
          <div id="lower-timer-settings" class="timer-settings-list"></div>
        </section>

        <p id="setup-error" class="setup-error" aria-live="polite"></p>
        <div class="setup-actions">
          <button class="primary-button create-board-button" type="submit">タイマーボードを作成</button>
        </div>
      </form>
    </section>

    <section id="board-screen" class="screen board-screen" aria-labelledby="board-title" hidden>
      <header class="page-header board-header">
        <h1 id="board-title">試験用キッチンタイマー</h1>
      </header>

      <section id="upper-timer-section" class="timer-section timer-section-upper" aria-labelledby="upper-heading">
        <div id="upper-group-flip" class="group-flip-card">
          <div class="group-face group-face-front">
            <div class="section-heading"><h2 id="upper-heading">上段タイトル</h2></div>
            <div id="upper-timer-grid" class="timer-grid"></div>
          </div>
          <div class="group-face group-face-back" aria-live="polite">
            <p id="upper-finished-title" class="group-finished-title">上段タイトル</p>
            <p class="group-finished-text">終了</p>
          </div>
        </div>
      </section>

      <section id="lower-timer-section" class="timer-section timer-section-lower" aria-labelledby="lower-heading">
        <div id="lower-group-flip" class="group-flip-card">
          <div class="group-face group-face-front">
            <div class="section-heading"><h2 id="lower-heading">下段タイトル</h2></div>
            <div id="lower-timer-grid" class="timer-grid"></div>
          </div>
          <div class="group-face group-face-back" aria-live="polite">
            <p id="lower-finished-title" class="group-finished-title">下段タイトル</p>
            <p class="group-finished-text">終了</p>
          </div>
        </div>
      </section>

      <section id="extension-timer-section" class="timer-section timer-section-extension" aria-labelledby="extension-heading" hidden>
        <div class="section-heading"><h2 id="extension-heading">個別延長</h2></div>
        <div id="extension-timer-grid" class="timer-grid"></div>
      </section>

      <div id="board-finish-overlay" class="board-finish-overlay" aria-live="polite" aria-hidden="true">
        <div class="board-finish-message">
          <p id="board-finished-title" class="board-finished-title">試験用キッチンタイマー</p>
          <p class="board-finished-text">終了</p>
        </div>
      </div>

      <div id="floating-controls" class="floating-controls is-visible" aria-label="操作パネル">
        <button id="start-all-button" class="primary-button control-button" type="button">▶ 全体スタート</button>
        <button id="reset-all-button" class="secondary-button control-button" type="button">↺ 全体リセット</button>
        <button id="add-extension-button" class="secondary-button control-button" type="button" disabled>+ 個別延長</button>
        <button id="finish-button" class="secondary-button control-button finish-button" type="button">終了</button>
      </div>
    </section>
  </main>

  <dialog id="extension-dialog" class="extension-dialog">
    <form id="extension-form" class="extension-form" method="dialog" novalidate>
      <h2 id="extension-dialog-title">個別延長タイマーを作成</h2>

      <div id="extension-name-field" class="field-block">
        <label for="extension-name">表示名</label>
        <input id="extension-name" type="text" maxlength="30" placeholder="受験者名・座席番号など">
      </div>

      <div id="extension-base-field" class="field-block">
        <label for="extension-base-group">基準にする段</label>
        <select id="extension-base-group"></select>
      </div>

      <div id="extension-current-total" class="extension-current-total" hidden></div>

      <fieldset id="extension-adjust-fieldset" class="extension-adjust-fieldset" hidden>
        <legend>時間の調整</legend>
        <label class="radio-option"><input type="radio" name="extension-adjust-mode" value="increase" checked> 増やす</label>
        <label class="radio-option"><input type="radio" name="extension-adjust-mode" value="decrease"> 減らす</label>
      </fieldset>

      <div class="extension-duration-row">
        <div class="field-block compact-field-block">
          <label for="extension-minutes">追加する分</label>
          <input id="extension-minutes" type="number" min="0" step="1" value="0" inputmode="numeric">
        </div>
        <div class="field-block compact-field-block">
          <label for="extension-seconds">追加する秒</label>
          <input id="extension-seconds" type="number" min="0" max="59" step="1" value="0" inputmode="numeric">
        </div>
      </div>

      <p id="extension-error" class="setup-error" aria-live="polite"></p>
      <div class="dialog-actions">
        <button id="extension-cancel-button" class="secondary-button" type="button">キャンセル</button>
        <button id="extension-submit-button" class="primary-button" type="submit">作成</button>
      </div>
    </form>
  </dialog>

  <template id="setting-row-template">
    <div class="timer-setting-row">
      <div class="field-block name-field-block">
        <label class="timer-name-label"></label>
        <input class="timer-name-input" type="text" maxlength="30">
      </div>
      <div class="duration-fields">
        <div class="field-block compact-field-block"><label>分</label><input class="minutes-input" type="number" min="0" step="1" inputmode="numeric"></div>
        <div class="field-block compact-field-block"><label>秒</label><input class="seconds-input" type="number" min="0" max="59" step="1" inputmode="numeric"></div>
      </div>
    </div>
  </template>

  <template id="timer-panel-template">
    <article class="timer-panel">
      <div class="timer-panel-inner">
        <div class="timer-face timer-face-front">
          <p class="timer-title"></p>
          <div class="time-display" aria-label="残り時間">
            <span class="minutes-display">00</span><span class="time-colon" aria-hidden="true">:</span><span class="seconds-display">00</span>
          </div>
          <div class="extension-info" hidden>
            <p class="extension-history"></p>
            <p class="extension-total"></p>
          </div>
          <div class="extension-panel-actions interaction-controls" hidden>
            <button class="secondary-button extension-add-button" type="button">+ 追加</button>
            <button class="secondary-button extension-delete-button" type="button">削除</button>
          </div>
        </div>
        <div class="timer-face timer-face-back" aria-live="polite">
          <div class="finished-message">
            <p class="finished-title"></p>
            <p class="finished-text">終了</p>
          </div>
          <div class="extension-panel-actions interaction-controls finished-actions" hidden>
            <button class="secondary-button extension-add-button" type="button">+ 追加</button>
            <button class="secondary-button extension-delete-button" type="button">削除</button>
          </div>
        </div>
      </div>
    </article>
  </template>

  <script src="script.js"></script>
</body>
</html>

style.css

style.css のソースコードを表示
* { box-sizing: border-box; }

:root {
  color-scheme: dark;
  font-family: "Segoe UI", "Yu Gothic UI", "Meiryo", sans-serif;
}

html, body { margin: 0; min-width: 320px; min-height: 100%; }
body {
  min-height: 100vh;
  background: radial-gradient(circle at center, #17243a 0%, #070b12 60%, #000 100%);
  color: #f7f9ff;
  overflow-x: hidden;
}
button, input, select { font: inherit; -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
input, select {
  width: 100%; min-height: 48px; border: 1px solid rgba(139,183,245,.5); border-radius: 12px;
  background: rgba(8,17,31,.9); color: #f7f9ff; outline: none;
}
input { padding: 10px 12px; }
select { padding: 10px 38px 10px 12px; }
input:focus, select:focus { border-color:#8fbaff; box-shadow:0 0 0 3px rgba(93,147,255,.2); }

.app-shell { width:min(1920px,100%); min-height:100vh; margin:0 auto; padding:clamp(14px,2vw,32px); }
.screen[hidden], .timer-section[hidden], .timer-settings-list[hidden], [hidden] { display:none !important; }

.page-header { margin-bottom:clamp(18px,2vw,30px); text-align:center; }
.page-header h1, .page-header p, .section-heading h2 { margin:0; }
.page-header h1 { font-size:clamp(1.8rem,3vw,3.1rem); }
.page-header p { margin-top:7px; color:#aebfd8; font-size:clamp(.9rem,1.2vw,1.15rem); }
.board-header { margin-bottom:clamp(10px,1.2vw,18px); }

.setup-form { display:grid; gap:clamp(20px,2.4vw,34px); }
.setup-group { padding:clamp(16px,2vw,28px); border:1px solid rgba(151,190,245,.35); border-radius:22px; background:linear-gradient(145deg,rgba(23,39,63,.88),rgba(7,13,24,.93)); box-shadow:0 0 28px rgba(85,139,226,.12); }
.setup-group-header { display:grid; grid-template-columns:minmax(0,1fr) minmax(140px,200px); gap:18px; margin-bottom:18px; }
.field-block { display:grid; gap:7px; }
.field-block label { color:#dbe8fb; font-weight:700; }
.count-note { margin:2px 0 0; color:#9fb2ce; font-size:.78rem; line-height:1.35; }
.timer-settings-list { display:grid; gap:12px; }
.timer-setting-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:16px; padding:14px; border:1px solid rgba(131,169,224,.22); border-radius:16px; background:rgba(5,12,23,.48); }
.duration-fields, .extension-duration-row { display:grid; grid-template-columns:repeat(2,minmax(90px,110px)); gap:10px; }
.setup-error { min-height:1.5em; margin:-2px 0 0; color:#ff9b9b; font-weight:700; text-align:center; }
.setup-actions { display:flex; justify-content:center; padding-bottom:20px; }
.setup-subheading { margin:0 0 14px; font-size:clamp(1.2rem,1.8vw,1.6rem); }
.board-title-setup { background:linear-gradient(145deg,rgba(22,36,58,.9),rgba(7,13,24,.94)); }

.primary-button, .secondary-button { min-height:48px; padding:10px 20px; border:1px solid #84b6ff; border-radius:14px; color:#eef5ff; font-weight:700; }
.primary-button { background:linear-gradient(145deg,#376cff,#1541b8); box-shadow:0 0 18px rgba(71,126,255,.35), inset 0 0 10px rgba(255,255,255,.1); }
.secondary-button { border-color:rgba(151,184,227,.58); background:rgba(21,34,54,.95); box-shadow:inset 0 0 10px rgba(122,169,236,.08); }
.primary-button:hover:not(:disabled), .primary-button:focus-visible:not(:disabled), .secondary-button:hover:not(:disabled), .secondary-button:focus-visible:not(:disabled) { filter:brightness(1.14); outline:3px solid rgba(155,198,255,.32); outline-offset:3px; }
button:disabled { cursor:not-allowed; opacity:.35; }
.create-board-button { width:min(440px,100%); min-height:56px; font-size:clamp(1.05rem,1.7vw,1.45rem); }

.timer-section { margin-bottom:clamp(12px,1.4vw,22px); padding:clamp(10px,1vw,18px); border:1px solid rgba(166,198,241,.18); border-radius:clamp(16px,1.5vw,24px); }
.timer-section-upper { background:linear-gradient(145deg,rgba(38,88,150,.14),rgba(7,13,24,.38)); }
.timer-section-lower { background:linear-gradient(145deg,rgba(92,57,137,.14),rgba(7,13,24,.38)); }
.timer-section-extension { background:linear-gradient(145deg,rgba(170,117,28,.18),rgba(30,21,6,.46)); border-color:rgba(239,188,87,.24); }

.extension-timer-panel.extension-base-upper .timer-face {
  background:linear-gradient(145deg,rgba(35,70,118,.96),rgba(8,18,34,.97));
  border-color:rgba(132,181,247,.62);
  box-shadow:0 0 24px rgba(71,137,225,.18), inset 0 0 24px rgba(111,170,245,.09);
}

.extension-timer-panel.extension-base-lower .timer-face {
  background:linear-gradient(145deg,rgba(78,50,112,.96),rgba(18,10,30,.97));
  border-color:rgba(178,135,226,.55);
  box-shadow:0 0 24px rgba(129,79,184,.18), inset 0 0 24px rgba(171,119,224,.09);
}
.section-heading { margin-bottom:8px; }
.section-heading h2 { font-size:clamp(1.35rem,2vw,2.2rem); line-height:1.1; overflow-wrap:anywhere; }
.timer-grid { display:grid; grid-template-columns:repeat(var(--timer-count,4),minmax(0,1fr)); gap:clamp(8px,1vw,16px); }
.timer-panel { min-width:0; perspective:1200px; transition:opacity .35s ease; }
.timer-panel-inner { display:grid; width:100%; min-height:clamp(180px,18vw,300px); transform-style:preserve-3d; transition:transform .7s ease-in-out; }
.timer-panel.is-finished { opacity:.7; }
.timer-panel.is-finished .timer-panel-inner { transform:rotateY(180deg); }
.timer-face { grid-area:1/1; display:grid; width:100%; padding:clamp(10px,1vw,18px); border:1px solid rgba(182,211,255,.55); border-radius:clamp(16px,1.5vw,26px); background:linear-gradient(145deg,rgba(32,50,78,.92),rgba(6,12,23,.96)); box-shadow:0 0 24px rgba(116,168,255,.16), inset 0 0 24px rgba(158,200,255,.08); text-align:center; backface-visibility:hidden; -webkit-backface-visibility:hidden; }
.timer-face-front { align-content:center; gap:clamp(8px,1vw,16px); }
.timer-face-back { align-content:center; justify-items:center; gap:14px; transform:rotateY(180deg); }
.timer-title { max-width:100%; margin:0; color:#eef5ff; font-size:clamp(1.15rem,2vw,2.25rem); font-weight:800; line-height:1.05; text-shadow:0 0 16px rgba(159,198,255,.36); overflow-wrap:anywhere; }
.time-display { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; width:100%; }
.minutes-display, .seconds-display, .time-colon { color:#fff; font-family:Consolas,"Roboto Mono","Courier New",monospace; font-weight:700; line-height:.9; text-shadow:0 0 11px rgba(255,255,255,.52),0 0 26px rgba(153,194,255,.22); transition:color .2s ease,text-shadow .2s ease; }
.minutes-display, .seconds-display { min-width:0; font-size:clamp(3.2rem,6.7vw,7.3rem); letter-spacing:-.09em; }
.time-colon { padding:0 .02em .08em; font-size:clamp(2.4rem,4.3vw,4.8rem); }
.time-display.is-warning .minutes-display, .time-display.is-warning .seconds-display, .time-display.is-warning .time-colon { color:#ff4b4b; text-shadow:0 0 11px rgba(255,75,75,.68),0 0 28px rgba(255,0,0,.24); }
.finished-message { display:grid; justify-items:center; gap:8px; width:100%; }
.finished-title, .finished-text { max-width:100%; margin:0; color:#fff; font-weight:800; line-height:1.05; overflow-wrap:anywhere; text-shadow:0 0 14px rgba(255,255,255,.4),0 0 28px rgba(153,194,255,.24); }
.finished-title { font-size:clamp(1.5rem,2.4vw,2.8rem); }
.finished-text { font-size:clamp(2.8rem,4.7vw,5.2rem); letter-spacing:.08em; }
.extension-info { display:grid; gap:2px; }
.extension-history,
.extension-total { margin:0; color:#f6d88d; font-size:clamp(.68rem,.9vw,.88rem); font-weight:700; line-height:1.25; overflow-wrap:anywhere; }
.extension-total { color:#ffe6a8; }
.extension-panel-actions { display:flex; justify-content:center; gap:8px; }
.extension-panel-actions .secondary-button { min-height:36px; padding:6px 12px; font-size:.85rem; }
.interaction-controls { opacity:0; pointer-events:none; transition:opacity .18s ease; }
.show-interaction-controls .interaction-controls { opacity:1; pointer-events:auto; }

.floating-controls { position:fixed; left:50%; bottom:0; z-index:50; display:flex; gap:10px; width:min(1000px,calc(100% - 24px)); padding:10px 12px; border:1px solid rgba(154,191,245,.35); border-bottom:0; border-radius:16px 16px 0 0; background:rgba(5,11,22,.96); box-shadow:0 -8px 30px rgba(0,0,0,.34); transform:translate(-50%,calc(100% - 5px)); opacity:.05; transition:transform .28s ease,opacity .2s ease; }
.floating-controls.is-visible { transform:translate(-50%,0); opacity:1; }
.control-button { flex:1 1 0; min-width:0; min-height:48px; white-space:nowrap; }
.finish-button { flex:.65 1 0; }

.extension-dialog { width:min(560px,calc(100% - 28px)); border:1px solid rgba(148,187,245,.45); border-radius:20px; background:linear-gradient(145deg,#15233a,#070d18); color:#f7f9ff; box-shadow:0 20px 60px rgba(0,0,0,.58); }
.extension-dialog::backdrop { background:rgba(0,0,0,.68); backdrop-filter:blur(3px); }
.extension-form { display:grid; gap:16px; padding:4px; }
.extension-form h2 { margin:0 0 2px; font-size:1.45rem; }
.extension-current-total { padding:10px 12px; border:1px solid rgba(241,194,95,.3); border-radius:12px; background:rgba(142,91,12,.16); color:#f6d88d; font-weight:700; }
.dialog-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:4px; }

.visually-hidden { position:absolute!important; width:1px!important; height:1px!important; padding:0!important; margin:-1px!important; overflow:hidden!important; clip:rect(0,0,0,0)!important; white-space:nowrap!important; border:0!important; }

/* PCで3段をできるだけ1画面に収める */
@media (min-width:901px) {
  .board-screen { padding-bottom:10px; }
  .board-screen .page-header h1 { font-size:clamp(1.5rem,2vw,2.4rem); }
  .board-screen .timer-panel-inner { min-height:clamp(165px,17vh,255px); }
  .board-screen .minutes-display, .board-screen .seconds-display { font-size:clamp(3rem,5.4vw,6.2rem); }
  .board-screen .time-colon { font-size:clamp(2.1rem,3.4vw,4rem); }
  body.board-compact .app-shell { padding-top:10px; padding-bottom:8px; }
  body.board-compact .board-header { margin-bottom:8px; }
  body.board-compact .timer-section { margin-bottom:8px; padding:8px 10px; }
  body.board-compact .section-heading { margin-bottom:5px; }
  body.board-compact .section-heading h2 { font-size:clamp(1.15rem,1.55vw,1.7rem); }
  body.board-compact .timer-panel-inner { min-height:clamp(135px,15.5vh,210px); }
  body.board-compact .timer-title { font-size:clamp(1rem,1.45vw,1.55rem); }
  body.board-compact .minutes-display, body.board-compact .seconds-display { font-size:clamp(2.6rem,4.7vw,5rem); }
  body.board-compact .time-colon { font-size:clamp(1.9rem,3vw,3.4rem); }
  body.board-extra-compact .board-header { margin-bottom:4px; }
  body.board-extra-compact .board-header h1 { font-size:1.35rem; }
  body.board-extra-compact .timer-section { margin-bottom:5px; padding:6px 8px; }
  body.board-extra-compact .timer-panel-inner { min-height:120px; }
  body.board-extra-compact .timer-title { font-size:.95rem; }
  body.board-extra-compact .minutes-display, body.board-extra-compact .seconds-display { font-size:clamp(2.2rem,4.2vw,4.3rem); }
  body.board-extra-compact .time-colon { font-size:2.4rem; }
  body.board-extra-compact .extension-history, body.board-extra-compact .extension-total { font-size:.64rem; }
}

@media (max-width:1100px) and (min-width:701px) {
  .timer-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
}

@media (max-width:900px) {
  .board-screen { padding-bottom:74px; }
  .timer-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
  .timer-panel-inner { min-height:250px; }
  .minutes-display, .seconds-display { font-size:clamp(4rem,12vw,7rem); }
  .floating-controls { width:calc(100% - 12px); gap:6px; padding:8px; }
  .control-button { font-size:.82rem; padding:8px 6px; }
}

@media (max-width:700px) {
  .setup-group-header, .timer-setting-row { grid-template-columns:1fr; }
  .duration-fields, .extension-duration-row { grid-template-columns:repeat(2,minmax(0,1fr)); }
}

@media (max-width:560px) {
  .app-shell { padding:10px; }
  .timer-grid { grid-template-columns:1fr; }
  .timer-panel-inner { min-height:300px; }
  .minutes-display, .seconds-display { font-size:clamp(5.2rem,29vw,9rem); }
  .time-colon { font-size:clamp(3.6rem,15vw,5.4rem); }
  .floating-controls { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
  .finish-button { grid-column:span 2; }
  .dialog-actions { display:grid; grid-template-columns:1fr 1fr; }
}

@media (prefers-reduced-motion:reduce) {
  * { scroll-behavior:auto!important; transition:none!important; }
}

/* 第7回:累積時間/個別時間の選択 */
.time-mode-fieldset {
  display:flex;
  flex-wrap:wrap;
  align-items:center;
  gap:10px 18px;
  margin:0 0 18px;
  padding:12px 14px;
  border:1px solid rgba(131,169,224,.22);
  border-radius:14px;
  background:rgba(5,12,23,.36);
}
.time-mode-fieldset legend {
  padding:0 6px;
  color:#dbe8fb;
  font-weight:700;
}
.radio-option {
  display:inline-flex;
  align-items:center;
  gap:7px;
  color:#eef5ff;
  font-weight:700;
  cursor:pointer;
}
.radio-option input[type="radio"] {
  width:18px;
  min-height:auto;
  height:18px;
  margin:0;
  accent-color:#5d93ff;
}

/* 第7回:段全体の終了回転 */
.timer-section-upper,
.timer-section-lower {
  perspective:1600px;
}
.group-flip-card {
  display:grid;
  width:100%;
  transform-style:preserve-3d;
  transition:transform .7s ease-in-out;
}
.group-flip-card.is-group-finished {
  transform:rotateY(180deg);
}
.group-face {
  grid-area:1 / 1;
  width:100%;
  backface-visibility:hidden;
  -webkit-backface-visibility:hidden;
}
.group-face-back {
  display:grid;
  place-content:center;
  justify-items:center;
  min-height:100%;
  padding:clamp(18px,2vw,32px);
  transform:rotateY(180deg);
  text-align:center;
}

.timer-section-upper .group-face-back {
  background:linear-gradient(145deg,rgba(38,88,150,.14),rgba(7,13,24,.38));
}
.timer-section-lower .group-face-back {
  background:linear-gradient(145deg,rgba(92,57,137,.14),rgba(7,13,24,.38));
}
.group-finished-title,
.group-finished-text {
  max-width:100%;
  margin:0;
  color:#fff;
  font-weight:800;
  overflow-wrap:anywhere;
  text-shadow:0 0 14px rgba(255,255,255,.42),0 0 30px rgba(153,194,255,.24);
}
.group-finished-title {
  font-size:clamp(1.8rem,3vw,3.6rem);
}
.group-finished-text {
  margin-top:12px;
  font-size:clamp(3.6rem,6vw,7rem);
  letter-spacing:.1em;
}

/* 第7回:通常タイマー全終了時の1秒待機+1.5秒フェード */
.board-finish-overlay {
  position:fixed;
  inset:0;
  z-index:40;
  pointer-events:none;
  opacity:0;
  visibility:hidden;
  transition:opacity var(--board-finish-fade-ms,1500ms) ease, visibility 0s linear var(--board-finish-fade-ms,1500ms);
}
.board-finish-overlay.is-visible {
  opacity:1;
  visibility:visible;
  transition:opacity var(--board-finish-fade-ms,1500ms) ease;
}
.board-finish-message {
  position:absolute;
  left:50%;
  top:var(--board-finish-center-y,50%);
  width:min(760px,90vw);
  padding:clamp(18px,3vw,38px);
  border:0;
  border-radius:22px;
  background:rgba(3,8,17,.64);
  box-shadow:0 18px 60px rgba(0,0,0,.34), inset 0 0 34px rgba(120,170,255,.06);
  backdrop-filter:blur(3px);
  transform:translate(-50%,-50%);
  text-align:center;
}
.board-finished-title,
.board-finished-text {
  margin:0;
  color:#fff;
  font-weight:800;
  line-height:1.05;
  overflow-wrap:anywhere;
  text-shadow:0 0 16px rgba(255,255,255,.48),0 0 34px rgba(141,189,255,.28);
}
.board-finished-title { font-size:clamp(2rem,4vw,4.4rem); }
.board-finished-text { margin-top:14px; font-size:clamp(4rem,8vw,8rem); letter-spacing:.12em; }

/* 第7回:個別延長の増減選択 */
.extension-adjust-fieldset {
  display:flex;
  align-items:center;
  gap:10px 18px;
  margin:0;
  padding:11px 13px;
  border:1px solid rgba(241,194,95,.28);
  border-radius:12px;
  background:rgba(142,91,12,.12);
}
.extension-adjust-fieldset legend {
  padding:0 6px;
  color:#f6d88d;
  font-weight:700;
}

@media (max-width:560px) {
  .time-mode-fieldset, .extension-adjust-fieldset { align-items:flex-start; flex-direction:column; }
  .board-finish-message { padding:22px 14px; }
}

/* =========================================================
   第8回:外観テーマ切り替え
   7テーマ(標準 / 工業 / ミニマル / サイバー / スチーム3方式)
   ========================================================= */

body {
  --theme-bg: radial-gradient(circle at center, #17243a 0%, #070b12 60%, #000 100%);
  --theme-text: #f7f9ff;
  --theme-muted: #aebfd8;
  --theme-panel: linear-gradient(145deg,rgba(23,39,63,.88),rgba(7,13,24,.93));
  --theme-panel-soft: rgba(5,12,23,.48);
  --theme-border: rgba(151,190,245,.35);
  --theme-border-strong: rgba(182,211,255,.55);
  --theme-input: rgba(8,17,31,.9);
  --theme-accent: #5d93ff;
  --theme-accent-2: #84b6ff;
  --theme-upper: linear-gradient(145deg,rgba(38,88,150,.14),rgba(7,13,24,.38));
  --theme-lower: linear-gradient(145deg,rgba(92,57,137,.14),rgba(7,13,24,.38));
  --theme-extension: linear-gradient(145deg,rgba(170,117,28,.18),rgba(30,21,6,.46));
  --theme-button-primary: linear-gradient(145deg,#376cff,#1541b8);
  --theme-button-secondary: rgba(21,34,54,.95);
  --theme-timer-face: linear-gradient(145deg,rgba(32,50,78,.92),rgba(6,12,23,.96));
  --theme-timer-glow: 0 0 24px rgba(116,168,255,.16), inset 0 0 24px rgba(158,200,255,.08);
  --theme-digit-font: Consolas,"Roboto Mono","Courier New",monospace;
  --theme-digit-shadow: 0 0 11px rgba(255,255,255,.52),0 0 26px rgba(153,194,255,.22);
}

body[data-theme] {
  background: var(--theme-bg);
  color: var(--theme-text);
}
body[data-theme] .page-header p { color:var(--theme-muted); }
body[data-theme] .setup-group,
body[data-theme] .board-title-setup { background:var(--theme-panel); border-color:var(--theme-border); }
body[data-theme] .timer-setting-row { background:var(--theme-panel-soft); border-color:var(--theme-border); }
body[data-theme] input,
body[data-theme] select { background:var(--theme-input); color:var(--theme-text); border-color:var(--theme-border); }
body[data-theme] input:focus,
body[data-theme] select:focus { border-color:var(--theme-accent-2); box-shadow:0 0 0 3px color-mix(in srgb, var(--theme-accent) 24%, transparent); }
body[data-theme] .field-block label,
body[data-theme] .time-mode-fieldset legend { color:var(--theme-text); }
body[data-theme] .count-note { color:var(--theme-muted); }
body[data-theme] .time-mode-fieldset { background:var(--theme-panel-soft); border-color:var(--theme-border); }
body[data-theme] .radio-option input[type="radio"] { accent-color:var(--theme-accent); }
body[data-theme] .primary-button { background:var(--theme-button-primary); border-color:var(--theme-accent-2); }
body[data-theme] .secondary-button { background:var(--theme-button-secondary); border-color:var(--theme-border); }
body[data-theme] .timer-section-upper { background:var(--theme-upper); }
body[data-theme] .timer-section-lower { background:var(--theme-lower); }
body[data-theme] .timer-section-extension { background:var(--theme-extension); }
body[data-theme] .timer-face { background:var(--theme-timer-face); border-color:var(--theme-border-strong); box-shadow:var(--theme-timer-glow); }
body[data-theme] .minutes-display,
body[data-theme] .seconds-display,
body[data-theme] .time-colon { font-family:var(--theme-digit-font); text-shadow:var(--theme-digit-shadow); }
body[data-theme] .floating-controls { background:color-mix(in srgb, var(--theme-input) 94%, black 6%); border-color:var(--theme-border); }
body[data-theme] .extension-dialog { background:var(--theme-panel); border-color:var(--theme-border); }

/* テーマ選択UI */
.theme-setup { overflow:hidden; position:relative; }
.theme-setup-heading-row { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:14px; }
.theme-help { margin:4px 0 0; color:var(--theme-muted); line-height:1.55; }
.theme-experiment-note { margin:0; padding:7px 11px; border:1px solid var(--theme-border); border-radius:999px; color:var(--theme-muted); font-size:.78rem; white-space:nowrap; }
.theme-fieldset { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin:0; padding:0; border:0; }
.theme-option { position:relative; display:grid; grid-template-columns:auto 44px 1fr; align-items:center; gap:11px; min-height:72px; padding:10px 12px; border:1px solid var(--theme-border); border-radius:14px; background:var(--theme-panel-soft); cursor:pointer; transition:transform .18s ease,border-color .18s ease,box-shadow .18s ease; }
.theme-option:hover { transform:translateY(-1px); border-color:var(--theme-accent-2); }
.theme-option:has(input:checked) { border-color:var(--theme-accent-2); box-shadow:0 0 0 2px color-mix(in srgb,var(--theme-accent) 30%,transparent),0 8px 20px rgba(0,0,0,.16); }
.theme-option input { width:18px; height:18px; min-height:auto; margin:0; accent-color:var(--theme-accent); }
.theme-swatch { width:42px; height:42px; border-radius:10px; border:1px solid rgba(255,255,255,.2); box-shadow:inset 0 0 12px rgba(255,255,255,.08); }
.theme-option-text { display:grid; gap:2px; min-width:0; }
.theme-option-text strong { color:var(--theme-text); line-height:1.25; }
.theme-option-text small { color:var(--theme-muted); }
.theme-option-blue .theme-swatch { background:linear-gradient(145deg,#315ea9,#07111f 58%,#1b3460); }
.theme-option-industrial .theme-swatch { background:linear-gradient(145deg,#4a4a47,#0c0d0d 55%,#28231b); }
.theme-option-minimal .theme-swatch { background:linear-gradient(145deg,#4f5962,#1d2328 58%,#2c343a); }
.theme-option-cyber .theme-swatch { background:linear-gradient(135deg,#00d9ff,#07121e 45%,#8a27ff 78%,#ff2bd6); }
.theme-option-steam-css .theme-swatch { background:repeating-linear-gradient(45deg,#3a2412 0 6px,#8c5b24 6px 8px,#24150b 8px 14px); }
.theme-option-steam-svg .theme-swatch { background:#2b1a0d url("assets/steam-gears.svg") center/80px 80px no-repeat; }
.theme-option-steam-image .theme-swatch { background:#2b1a0d url("assets/steam-metal-texture.png") center/cover; }

/* 1. 標準:ブルー・ガラス */
body[data-theme="blue"] {
  --theme-digit-font: Consolas,"Roboto Mono","Courier New",monospace;
}

/* 2. ブラック・インダストリアル:CSSのみ */
body[data-theme="industrial"] {
  color-scheme:dark;
  --theme-bg:radial-gradient(circle at 50% 20%,#262727 0%,#0e0f10 42%,#030404 100%);
  --theme-text:#f3f3ef;
  --theme-muted:#a9aaa4;
  --theme-panel:linear-gradient(155deg,rgba(47,48,47,.96),rgba(14,15,15,.97));
  --theme-panel-soft:linear-gradient(155deg,rgba(32,33,32,.92),rgba(11,12,12,.94));
  --theme-border:rgba(176,178,169,.28);
  --theme-border-strong:rgba(216,218,207,.42);
  --theme-input:rgba(13,14,14,.96);
  --theme-accent:#c49337;
  --theme-accent-2:#d5b66d;
  --theme-upper:linear-gradient(145deg,rgba(76,78,74,.34),rgba(9,10,10,.76));
  --theme-lower:linear-gradient(145deg,rgba(61,57,51,.34),rgba(9,10,10,.76));
  --theme-extension:linear-gradient(145deg,rgba(98,72,31,.26),rgba(18,14,8,.72));
  --theme-button-primary:linear-gradient(145deg,#4e7a2d,#233d17);
  --theme-button-secondary:linear-gradient(145deg,#3c3d3b,#151616);
  --theme-timer-face:linear-gradient(145deg,#252624,#080909 76%);
  --theme-timer-glow:inset 0 0 0 2px rgba(255,255,255,.035),inset 0 0 28px rgba(0,0,0,.55),0 9px 18px rgba(0,0,0,.35);
  --theme-digit-font:"Courier New",Consolas,monospace;
  --theme-digit-shadow:0 0 5px rgba(255,255,255,.32),0 0 15px rgba(255,255,255,.12);
}
body[data-theme="industrial"] .app-shell { position:relative; }
body[data-theme="industrial"] .setup-group,
body[data-theme="industrial"] .timer-section,
body[data-theme="industrial"] .timer-face,
body[data-theme="industrial"] .floating-controls { box-shadow:inset 0 0 0 1px rgba(0,0,0,.9),0 8px 18px rgba(0,0,0,.34); }
body[data-theme="industrial"] .timer-face::before,
body[data-theme="industrial"] .setup-group::before { content:""; position:absolute; inset:8px; border:1px solid rgba(255,255,255,.035); border-radius:inherit; pointer-events:none; }
body[data-theme="industrial"] .timer-face,
body[data-theme="industrial"] .setup-group { position:relative; }
body[data-theme="industrial"] .primary-button,
body[data-theme="industrial"] .secondary-button { border-radius:8px; box-shadow:inset 0 1px 0 rgba(255,255,255,.12),inset 0 -3px 0 rgba(0,0,0,.35),0 4px 10px rgba(0,0,0,.32); }
body[data-theme="industrial"] .finish-button { background:linear-gradient(145deg,#8b2f2a,#4a1412); border-color:#b1544d; }

/* 3. ミニマル:実用性優先 */
body[data-theme="minimal"] {
  color-scheme:dark;
  --theme-bg:linear-gradient(180deg,#242a2f,#15191d 70%,#111417);
  --theme-text:#f6f7f7;
  --theme-muted:#aab1b7;
  --theme-panel:#20262b;
  --theme-panel-soft:#1a2024;
  --theme-border:#3d464d;
  --theme-border-strong:#4a555d;
  --theme-input:#171c20;
  --theme-accent:#8ea9b8;
  --theme-accent-2:#b5c5ce;
  --theme-upper:#20262b;
  --theme-lower:#20262b;
  --theme-extension:#20262b;
  --theme-button-primary:linear-gradient(145deg,#3c8f63,#24603f);
  --theme-button-secondary:#343b40;
  --theme-timer-face:#171c20;
  --theme-timer-glow:none;
  --theme-digit-font:"Segoe UI",Arial,sans-serif;
  --theme-digit-shadow:none;
}
body[data-theme="minimal"] .setup-group,
body[data-theme="minimal"] .timer-section,
body[data-theme="minimal"] .timer-face,
body[data-theme="minimal"] .floating-controls { box-shadow:none; }
body[data-theme="minimal"] .timer-section,
body[data-theme="minimal"] .setup-group { border-radius:14px; }
body[data-theme="minimal"] .timer-face { border-radius:12px; }
body[data-theme="minimal"] .timer-title,
body[data-theme="minimal"] .finished-title,
body[data-theme="minimal"] .finished-text,
body[data-theme="minimal"] .group-finished-title,
body[data-theme="minimal"] .group-finished-text { text-shadow:none; }
body[data-theme="minimal"] .finish-button { background:#a93434; border-color:#bd5454; }

/* 4. サイバーパンク:CSSのみで強めのネオン */
body[data-theme="cyber"] {
  color-scheme:dark;
  --theme-bg:
    linear-gradient(rgba(0,222,255,.035) 1px,transparent 1px),
    linear-gradient(90deg,rgba(0,222,255,.035) 1px,transparent 1px),
    radial-gradient(circle at 18% 15%,rgba(0,226,255,.18),transparent 30%),
    radial-gradient(circle at 84% 82%,rgba(191,37,255,.16),transparent 32%),#020712;
  background-size:32px 32px,32px 32px,auto,auto,auto;
  --theme-text:#f3fbff;
  --theme-muted:#8cc9d8;
  --theme-panel:linear-gradient(145deg,rgba(2,22,39,.94),rgba(2,7,18,.98));
  --theme-panel-soft:rgba(2,16,30,.78);
  --theme-border:rgba(0,215,255,.44);
  --theme-border-strong:#00d7ff;
  --theme-input:rgba(1,10,21,.96);
  --theme-accent:#00d7ff;
  --theme-accent-2:#63efff;
  --theme-upper:linear-gradient(145deg,rgba(0,161,213,.13),rgba(2,7,18,.68));
  --theme-lower:linear-gradient(145deg,rgba(137,40,219,.15),rgba(2,7,18,.68));
  --theme-extension:linear-gradient(145deg,rgba(236,178,0,.11),rgba(19,13,1,.6));
  --theme-button-primary:linear-gradient(145deg,#00a63d,#006b2d);
  --theme-button-secondary:linear-gradient(145deg,#06324a,#031522);
  --theme-timer-face:linear-gradient(145deg,rgba(1,19,34,.98),rgba(2,5,14,.99));
  --theme-timer-glow:0 0 0 1px rgba(0,217,255,.72),0 0 18px rgba(0,217,255,.18),inset 0 0 24px rgba(0,217,255,.06);
  --theme-digit-font:"Segoe UI",Consolas,sans-serif;
  --theme-digit-shadow:0 0 8px rgba(255,255,255,.65),0 0 24px rgba(0,215,255,.42);
}
body[data-theme="cyber"] .page-header h1 { letter-spacing:.08em; text-shadow:0 0 8px #fff,0 0 24px rgba(0,215,255,.75); }
body[data-theme="cyber"] .timer-section-lower .timer-face { border-color:#8e43e6; box-shadow:0 0 0 1px rgba(142,67,230,.72),0 0 20px rgba(142,67,230,.2),inset 0 0 20px rgba(142,67,230,.06); }
body[data-theme="cyber"] .timer-section-extension .timer-face { border-color:#e4b719; }
body[data-theme="cyber"] .primary-button,
body[data-theme="cyber"] .secondary-button { border-radius:4px; clip-path:polygon(9px 0,calc(100% - 9px) 0,100% 9px,100% calc(100% - 9px),calc(100% - 9px) 100%,9px 100%,0 calc(100% - 9px),0 9px); }
body[data-theme="cyber"] .finish-button { background:linear-gradient(145deg,#c6273e,#721020); border-color:#ff546b; box-shadow:0 0 16px rgba(255,45,78,.22); }

/* 5. スチームパンクA:純CSS */
body[data-theme="steam-css"] {
  color-scheme:dark;
  --theme-bg:
    repeating-linear-gradient(90deg,rgba(255,193,92,.022) 0 1px,transparent 1px 5px),
    radial-gradient(circle at 18% 12%,rgba(216,140,53,.13),transparent 28%),
    radial-gradient(circle at 82% 88%,rgba(216,140,53,.1),transparent 30%),
    linear-gradient(180deg,#201309,#0c0704 72%,#050302);
  --theme-text:#f3dfb1;
  --theme-muted:#bda778;
  --theme-panel:linear-gradient(145deg,rgba(75,48,24,.92),rgba(20,12,7,.98));
  --theme-panel-soft:linear-gradient(145deg,rgba(54,34,17,.9),rgba(18,11,6,.96));
  --theme-border:rgba(186,125,52,.44);
  --theme-border-strong:rgba(213,154,72,.66);
  --theme-input:rgba(24,14,7,.96);
  --theme-accent:#b77a31;
  --theme-accent-2:#d4a155;
  --theme-upper:linear-gradient(145deg,rgba(109,72,34,.3),rgba(20,12,7,.78));
  --theme-lower:linear-gradient(145deg,rgba(82,51,25,.3),rgba(20,12,7,.78));
  --theme-extension:linear-gradient(145deg,rgba(119,72,22,.34),rgba(24,13,4,.78));
  --theme-button-primary:linear-gradient(145deg,#526029,#252d15);
  --theme-button-secondary:linear-gradient(145deg,#6c451d,#321e0e);
  --theme-timer-face:linear-gradient(145deg,#3a2412,#100a05 72%);
  --theme-timer-glow:inset 0 0 0 2px #120a04,inset 0 0 0 3px rgba(205,143,65,.24),0 8px 18px rgba(0,0,0,.36);
  --theme-digit-font:"Courier New",Consolas,monospace;
  --theme-digit-shadow:0 0 7px rgba(255,207,112,.55),0 0 20px rgba(231,145,38,.36);
}
body[data-theme="steam-css"] .minutes-display,
body[data-theme="steam-css"] .seconds-display,
body[data-theme="steam-css"] .time-colon { color:#ffd889; }
body[data-theme="steam-css"] .setup-group,
body[data-theme="steam-css"] .timer-section,
body[data-theme="steam-css"] .timer-face { border-width:2px; box-shadow:inset 0 0 0 1px #1a0f07,0 8px 18px rgba(0,0,0,.35); }
body[data-theme="steam-css"] .timer-face { position:relative; border-radius:10px; }
body[data-theme="steam-css"] .timer-face::after { content:""; position:absolute; inset:8px; pointer-events:none; border:1px dashed rgba(220,159,76,.18); border-radius:5px; }
body[data-theme="steam-css"] .primary-button,
body[data-theme="steam-css"] .secondary-button { border-radius:7px; box-shadow:inset 0 1px 0 rgba(255,221,154,.14),inset 0 -3px 0 rgba(41,21,7,.8),0 4px 8px rgba(0,0,0,.3); }
body[data-theme="steam-css"] .finish-button { background:linear-gradient(145deg,#8f3920,#4c1b0f); border-color:#bd704a; }

/* 6. スチームパンクB:CSS + SVG装飾 */
body[data-theme="steam-svg"] {
  color-scheme:dark;
  --theme-bg:
    url("assets/steam-gears.svg") -70px -80px/310px 310px no-repeat fixed,
    url("assets/steam-gears.svg") calc(100% + 70px) calc(100% + 70px)/350px 350px no-repeat fixed,
    radial-gradient(circle at center,#2d1c0e,#090503 72%);
  --theme-text:#f4ddb0;
  --theme-muted:#baa273;
  --theme-panel:linear-gradient(145deg,rgba(69,43,21,.93),rgba(18,10,5,.98));
  --theme-panel-soft:linear-gradient(145deg,rgba(49,29,13,.9),rgba(16,9,4,.96));
  --theme-border:rgba(190,128,55,.48);
  --theme-border-strong:rgba(226,165,83,.72);
  --theme-input:rgba(21,12,6,.97);
  --theme-accent:#bf7f32;
  --theme-accent-2:#e0ad61;
  --theme-upper:linear-gradient(145deg,rgba(102,66,31,.3),rgba(17,10,5,.8));
  --theme-lower:linear-gradient(145deg,rgba(77,46,23,.3),rgba(17,10,5,.8));
  --theme-extension:linear-gradient(145deg,rgba(127,77,24,.34),rgba(23,12,4,.8));
  --theme-button-primary:linear-gradient(145deg,#56622d,#2b3418);
  --theme-button-secondary:linear-gradient(145deg,#724a20,#321d0d);
  --theme-timer-face:linear-gradient(145deg,rgba(58,35,16,.98),rgba(12,7,3,.99));
  --theme-timer-glow:inset 0 0 0 2px #0b0603,inset 0 0 0 3px rgba(219,157,75,.28),0 8px 18px rgba(0,0,0,.4);
  --theme-digit-font:Georgia,"Times New Roman",serif;
  --theme-digit-shadow:0 0 7px rgba(255,207,112,.55),0 0 20px rgba(231,145,38,.36);
}
body[data-theme="steam-svg"] .app-shell::before,
body[data-theme="steam-svg"] .app-shell::after { content:""; position:fixed; z-index:-1; width:220px; height:220px; background:url("assets/steam-gears.svg") center/contain no-repeat; opacity:.2; pointer-events:none; }
body[data-theme="steam-svg"] .app-shell::before { left:-55px; top:34%; }
body[data-theme="steam-svg"] .app-shell::after { right:-50px; bottom:8%; transform:rotate(28deg); }
body[data-theme="steam-svg"] .minutes-display,
body[data-theme="steam-svg"] .seconds-display,
body[data-theme="steam-svg"] .time-colon { color:#ffd78a; font-weight:800; }
body[data-theme="steam-svg"] .setup-group,
body[data-theme="steam-svg"] .timer-section,
body[data-theme="steam-svg"] .timer-face { border-width:2px; }
body[data-theme="steam-svg"] .finish-button { background:linear-gradient(145deg,#913a20,#4d1d10); border-color:#c6774f; }

/* 7. スチームパンクC:CSS + 画像装飾 */
body[data-theme="steam-image"] {
  color-scheme:dark;
  --theme-bg:
    linear-gradient(rgba(8,4,2,.56),rgba(8,4,2,.72)),
    url("assets/steam-metal-texture.png") center/512px 512px repeat fixed;
  --theme-text:#f5dfb0;
  --theme-muted:#c0a978;
  --theme-panel:linear-gradient(145deg,rgba(77,49,24,.84),rgba(18,10,5,.93)),url("assets/steam-metal-texture.png") center/380px 380px;
  --theme-panel-soft:linear-gradient(rgba(31,18,8,.78),rgba(18,10,5,.84)),url("assets/steam-metal-texture.png") center/360px 360px;
  --theme-border:rgba(199,139,63,.5);
  --theme-border-strong:rgba(230,171,89,.76);
  --theme-input:rgba(20,11,5,.96);
  --theme-accent:#c78534;
  --theme-accent-2:#e3b364;
  --theme-upper:linear-gradient(rgba(56,34,15,.74),rgba(23,13,6,.86)),url("assets/steam-metal-texture.png") center/420px 420px;
  --theme-lower:linear-gradient(rgba(49,28,13,.76),rgba(20,11,5,.88)),url("assets/steam-metal-texture.png") center/420px 420px;
  --theme-extension:linear-gradient(rgba(70,42,14,.72),rgba(24,13,4,.88)),url("assets/steam-metal-texture.png") center/420px 420px;
  --theme-button-primary:linear-gradient(145deg,#59642e,#2c3518);
  --theme-button-secondary:linear-gradient(rgba(102,64,25,.76),rgba(48,28,12,.82)),url("assets/steam-metal-texture.png") center/220px 220px;
  --theme-timer-face:linear-gradient(rgba(36,20,8,.68),rgba(12,7,3,.86)),url("assets/steam-metal-texture.png") center/420px 420px;
  --theme-timer-glow:inset 0 0 0 2px #090502,inset 0 0 0 3px rgba(230,171,89,.24),0 10px 22px rgba(0,0,0,.42);
  --theme-digit-font:"Courier New",Consolas,monospace;
  --theme-digit-shadow:0 0 8px rgba(255,211,120,.62),0 0 22px rgba(235,149,44,.42);
}
body[data-theme="steam-image"] .minutes-display,
body[data-theme="steam-image"] .seconds-display,
body[data-theme="steam-image"] .time-colon { color:#ffda8d; }
body[data-theme="steam-image"] .setup-group,
body[data-theme="steam-image"] .timer-section,
body[data-theme="steam-image"] .timer-face,
body[data-theme="steam-image"] .floating-controls { border-width:2px; box-shadow:inset 0 0 0 1px rgba(20,10,4,.82),0 9px 20px rgba(0,0,0,.38); }
body[data-theme="steam-image"] .primary-button,
body[data-theme="steam-image"] .secondary-button { border-radius:7px; box-shadow:inset 0 1px 0 rgba(255,222,152,.14),inset 0 -3px 0 rgba(35,18,7,.72),0 4px 9px rgba(0,0,0,.32); }
body[data-theme="steam-image"] .finish-button { background:linear-gradient(rgba(128,48,25,.82),rgba(70,23,12,.9)),url("assets/steam-metal-texture.png") center/240px 240px; border-color:#ca794d; }

/* 全テーマ共通:操作ボタンの役割色を保つ */
body[data-theme] #start-all-button { background:linear-gradient(145deg,#3f8a43,#215527); border-color:#6eba74; }
body[data-theme="cyber"] #start-all-button { background:linear-gradient(145deg,#00b65a,#006735); border-color:#00ff84; box-shadow:0 0 18px rgba(0,255,132,.18); }
body[data-theme] #add-extension-button:not(:disabled) { border-color:#c89b48; }
body[data-theme] #finish-button { color:#fff; }

/* テーマごとに終了面も同系統にする */
body[data-theme] .timer-section-upper .group-face-back { background:var(--theme-upper); }
body[data-theme] .timer-section-lower .group-face-back { background:var(--theme-lower); }
body[data-theme] .board-finish-message { background:color-mix(in srgb,var(--theme-input) 74%,transparent); box-shadow:0 18px 60px rgba(0,0,0,.38),inset 0 0 34px color-mix(in srgb,var(--theme-accent) 8%,transparent); }

@media (max-width:760px) {
  .theme-fieldset { grid-template-columns:1fr; }
  .theme-setup-heading-row { display:grid; }
  .theme-experiment-note { justify-self:start; }
}

/* 第8回:テーマ内にUIフォントも含める(個別フォント切替は作らない) */
body[data-theme] { font-family:var(--theme-ui-font,"Segoe UI","Yu Gothic UI","Meiryo",sans-serif); }
body[data-theme="blue"] { --theme-ui-font:"Segoe UI","Yu Gothic UI","Meiryo",sans-serif; }
body[data-theme="industrial"] { --theme-ui-font:"Arial Narrow","Yu Gothic UI","Meiryo",sans-serif; }
body[data-theme="minimal"] { --theme-ui-font:"Segoe UI","Yu Gothic UI","Meiryo",sans-serif; }
body[data-theme="cyber"] { --theme-ui-font:"Arial","Yu Gothic UI","Meiryo",sans-serif; }
body[data-theme="steam-css"],
body[data-theme="steam-svg"],
body[data-theme="steam-image"] { --theme-ui-font:Georgia,"Yu Mincho","Hiragino Mincho ProN","Meiryo",serif; }

script.js

script.js のソースコードを表示
const DEFAULT_MINUTES = 5;
const DEFAULT_SECONDS = 0;
const UPDATE_INTERVAL = 200;
const WARNING_THRESHOLD_MS = 60 * 1000;
const CONTROL_HIDE_DELAY = 3000;
const MAX_EXTENSION_TIMERS = 4;
const BOARD_FINISH_FADE_MS = 1500;
const BOARD_FINISH_DELAY_MS = 1000;

const setupScreen = document.getElementById("setup-screen");
const boardScreen = document.getElementById("board-screen");
const setupForm = document.getElementById("setup-form");
const setupError = document.getElementById("setup-error");
const boardTitleInput = document.getElementById("board-title-input");
const upperTitleInput = document.getElementById("upper-title");
const lowerTitleInput = document.getElementById("lower-title");
const upperCountSelect = document.getElementById("upper-count");
const lowerCountSelect = document.getElementById("lower-count");
const upperSettingsContainer = document.getElementById("upper-timer-settings");
const lowerSettingsContainer = document.getElementById("lower-timer-settings");
const settingRowTemplate = document.getElementById("setting-row-template");
const upperHeading = document.getElementById("upper-heading");
const upperGroupFlip = document.getElementById("upper-group-flip");
const lowerGroupFlip = document.getElementById("lower-group-flip");
const upperFinishedTitle = document.getElementById("upper-finished-title");
const lowerFinishedTitle = document.getElementById("lower-finished-title");
const lowerHeading = document.getElementById("lower-heading");
const boardTitle = document.getElementById("board-title");
const lowerTimerSection = document.getElementById("lower-timer-section");
const extensionTimerSection = document.getElementById("extension-timer-section");
const upperTimerGrid = document.getElementById("upper-timer-grid");
const lowerTimerGrid = document.getElementById("lower-timer-grid");
const extensionTimerGrid = document.getElementById("extension-timer-grid");
const timerPanelTemplate = document.getElementById("timer-panel-template");
const startAllButton = document.getElementById("start-all-button");
const resetAllButton = document.getElementById("reset-all-button");
const addExtensionButton = document.getElementById("add-extension-button");
const finishButton = document.getElementById("finish-button");
const floatingControls = document.getElementById("floating-controls");
const extensionDialog = document.getElementById("extension-dialog");
const extensionForm = document.getElementById("extension-form");
const extensionDialogTitle = document.getElementById("extension-dialog-title");
const extensionNameField = document.getElementById("extension-name-field");
const extensionBaseField = document.getElementById("extension-base-field");
const extensionNameInput = document.getElementById("extension-name");
const extensionBaseGroup = document.getElementById("extension-base-group");
const extensionMinutesInput = document.getElementById("extension-minutes");
const extensionSecondsInput = document.getElementById("extension-seconds");
const extensionError = document.getElementById("extension-error");
const extensionCurrentTotal = document.getElementById("extension-current-total");
const extensionAdjustFieldset = document.getElementById("extension-adjust-fieldset");
const extensionSubmitButton = document.getElementById("extension-submit-button");
const extensionCancelButton = document.getElementById("extension-cancel-button");
const boardFinishOverlay = document.getElementById("board-finish-overlay");
const boardFinishedTitle = document.getElementById("board-finished-title");
const themeRadios = [...setupForm.querySelectorAll('input[name="app-theme"]')];

const settingsState = {
  theme: "blue",
  boardTitle: "試験用キッチンタイマー",
  upper: { title: "上段タイトル", count: 4, timeMode: "cumulative", timers: createDefaultTimerSettings(4, 1) },
  lower: { title: "下段タイトル", count: 3, timeMode: "cumulative", timers: createDefaultTimerSettings(3, 5) }
};

let timers = [];
let extensionTimers = [];
let boardStarted = false;
let controlHideTimerId = null;
let extensionDialogMode = "create";
let editingExtensionTimer = null;
let groupFinishedState = { upper: false, lower: false };
let boardFinishShown = false;
let boardFinishTimerId = null;

function createDefaultTimerSettings(count, startNumber) {
  return Array.from({ length: count }, (_, index) => ({
    name: `タイマー${startNumber + index}`,
    minutes: DEFAULT_MINUTES,
    seconds: DEFAULT_SECONDS
  }));
}

function padNumber(value) { return String(value).padStart(2, "0"); }

function formatTime(milliseconds) {
  const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000));
  return { minutes: padNumber(Math.floor(totalSeconds / 60)), seconds: padNumber(totalSeconds % 60) };
}

function formatDuration(milliseconds) {
  const totalSeconds = Math.max(0, Math.round(milliseconds / 1000));
  return `${Math.floor(totalSeconds / 60)}分${padNumber(totalSeconds % 60)}秒`;
}

function formatSignedDuration(milliseconds, includePlus = false) {
  const absolute = formatDuration(Math.abs(milliseconds));
  if (milliseconds < 0) return `- ${absolute}`;
  if (includePlus) return `+ ${absolute}`;
  return absolute;
}

function getGroupDisplayTitle(groupKey) {
  if (groupKey === "upper") {
    return settingsState.upper.title.trim() || "上段";
  }

  return settingsState.lower.title.trim() || "下段";
}

function buildExtensionDisplayName(baseGroup, enteredName) {
  return `${getGroupDisplayTitle(baseGroup)}|${enteredName}`;
}

function updateLowerCountOptions() {
  const upperCount = Number(upperCountSelect.value);

  [...lowerCountSelect.options].forEach((option) => {
    const optionCount = Number(option.value);
    option.disabled = optionCount > upperCount;
  });

  if (Number(lowerCountSelect.value) > upperCount) {
    saveCurrentSetupInputs();
    lowerCountSelect.value = String(upperCount);
    normalizeTimerSettings("lower", upperCount);
    renderSettingRows("lower", lowerSettingsContainer);
    lowerSettingsContainer.hidden = upperCount === 0;
  }
}

function getInputInteger(input, minimum, maximum = null) {
  const value = Number(input.value);
  if (!Number.isInteger(value) || value < minimum) return null;
  if (maximum !== null && value > maximum) return null;
  return value;
}

function normalizeTimerSettings(groupKey, count) {
  const group = settingsState[groupKey];
  const startNumber = groupKey === "upper" ? 1 : settingsState.upper.count + 1;
  while (group.timers.length < count) {
    const index = group.timers.length;
    group.timers.push({ name: `タイマー${startNumber + index}`, minutes: DEFAULT_MINUTES, seconds: DEFAULT_SECONDS });
  }
  group.count = count;
}

function readVisibleSettingRows(groupKey, container) {
  [...container.querySelectorAll(".timer-setting-row")].forEach((row, index) => {
    const timerSetting = settingsState[groupKey].timers[index];
    timerSetting.name = row.querySelector(".timer-name-input").value;
    timerSetting.minutes = row.querySelector(".minutes-input").value;
    timerSetting.seconds = row.querySelector(".seconds-input").value;
  });
}

function applyTheme(themeName) {
  const allowedThemes = ["blue", "industrial", "minimal", "cyber", "steam-css", "steam-svg", "steam-image"];
  const theme = allowedThemes.includes(themeName) ? themeName : "blue";
  settingsState.theme = theme;
  document.body.dataset.theme = theme;
}

function saveCurrentSetupInputs() {
  settingsState.theme = setupForm.querySelector('input[name="app-theme"]:checked')?.value || "blue";
  applyTheme(settingsState.theme);
  settingsState.boardTitle = boardTitleInput.value;
  settingsState.upper.title = upperTitleInput.value;
  settingsState.lower.title = lowerTitleInput.value;
  settingsState.upper.timeMode = setupForm.querySelector('input[name="upper-time-mode"]:checked')?.value || "cumulative";
  settingsState.lower.timeMode = setupForm.querySelector('input[name="lower-time-mode"]:checked')?.value || "cumulative";
  readVisibleSettingRows("upper", upperSettingsContainer);
  readVisibleSettingRows("lower", lowerSettingsContainer);
}

function renderSettingRows(groupKey, container) {
  const group = settingsState[groupKey];
  container.replaceChildren();
  group.timers.slice(0, group.count).forEach((timerSetting, index) => {
    const row = settingRowTemplate.content.firstElementChild.cloneNode(true);
    row.querySelector(".timer-name-label").textContent = `タイマー${index + 1}の名前`;
    row.querySelector(".timer-name-input").value = timerSetting.name;
    row.querySelector(".minutes-input").value = timerSetting.minutes;
    row.querySelector(".seconds-input").value = timerSetting.seconds;
    container.appendChild(row);
  });
}

function handleCountChange(groupKey, selectElement, container) {
  saveCurrentSetupInputs();
  const count = Number(selectElement.value);
  normalizeTimerSettings(groupKey, count);
  renderSettingRows(groupKey, container);
  if (groupKey === "lower") container.hidden = count === 0;
}

class TimerPanel {
  constructor({
    name,
    totalMilliseconds,
    group,
    isExtension = false,
    extensionTotalMilliseconds = 0,
    extensionHistoryMilliseconds = [],
    baseGroup = null
  }, panelElement) {
    this.name = name;
    this.totalMilliseconds = totalMilliseconds;
    this.group = group;
    this.isExtension = isExtension;
    this.extensionTotalMilliseconds = extensionTotalMilliseconds;
    this.extensionHistoryMilliseconds = [...extensionHistoryMilliseconds];
    this.baseGroup = baseGroup;
    this.panel = panelElement;
    this.remainingMilliseconds = totalMilliseconds;
    this.endTime = 0;
    this.intervalId = null;
    this.isRunning = false;
    this.isFinished = false;
    this.titleElement = panelElement.querySelector(".timer-title");
    this.minutesDisplay = panelElement.querySelector(".minutes-display");
    this.secondsDisplay = panelElement.querySelector(".seconds-display");
    this.timeDisplay = panelElement.querySelector(".time-display");
    this.finishedTitle = panelElement.querySelector(".finished-title");
    this.extensionInfoElement = panelElement.querySelector(".extension-info");
    this.extensionHistoryElement = panelElement.querySelector(".extension-history");
    this.extensionTotalElement = panelElement.querySelector(".extension-total");
    this.titleElement.textContent = this.name;
    this.finishedTitle.textContent = this.name;
    this.setupExtensionUi();
    this.updateDisplay(this.remainingMilliseconds);
  }

  setupExtensionUi() {
    if (!this.isExtension) return;

    this.panel.classList.add(
      "extension-timer-panel",
      this.baseGroup === "upper" ? "extension-base-upper" : "extension-base-lower"
    );

    this.extensionInfoElement.hidden = false;
    this.updateExtensionInfoText();

    this.panel.querySelectorAll(".extension-panel-actions").forEach((actions) => actions.hidden = false);
    this.panel.querySelectorAll(".extension-add-button").forEach((button) => button.addEventListener("click", () => openExtensionDialogForUpdate(this)));
    this.panel.querySelectorAll(".extension-delete-button").forEach((button) => button.addEventListener("click", () => deleteExtensionTimer(this)));
  }

  updateExtensionInfoText() {
    if (this.extensionHistoryElement) {
      const historyText = this.extensionHistoryMilliseconds
        .map((milliseconds, index) => formatSignedDuration(milliseconds, index > 0))
        .join(" ");

      this.extensionHistoryElement.textContent = `延長時間:${historyText}`;
    }

    if (this.extensionTotalElement) {
      this.extensionTotalElement.textContent = `延長時間合計:${formatDuration(this.extensionTotalMilliseconds)}`;
    }
  }

  canStart() { return !this.isRunning && !this.isFinished; }

  start() {
    if (!this.canStart()) return;
    this.endTime = Date.now() + this.remainingMilliseconds;
    this.isRunning = true;
    this.panel.classList.add("is-running");
    this.updateFromClock();
    this.intervalId = window.setInterval(() => this.updateFromClock(), UPDATE_INTERVAL);
  }

  startFromEndTime(endTime) {
    this.endTime = endTime;
    this.remainingMilliseconds = Math.max(0, endTime - Date.now());
    this.isFinished = false;
    this.isRunning = this.remainingMilliseconds > 0;
    this.panel.classList.remove("is-finished");
    this.panel.classList.toggle("is-running", this.isRunning);
    this.updateDisplay(this.remainingMilliseconds);
    window.clearInterval(this.intervalId);
    if (this.isRunning) this.intervalId = window.setInterval(() => this.updateFromClock(), UPDATE_INTERVAL);
    else this.finish();
  }

  adjustExtension(milliseconds, mode = "increase") {
    const delta = mode === "decrease" ? -milliseconds : milliseconds;
    this.extensionHistoryMilliseconds.push(delta);
    this.extensionTotalMilliseconds += delta;
    this.updateExtensionInfoText();
    const baseEndTime = getBaseGroupEndTime(this.baseGroup);
    const newEndTime = baseEndTime + this.extensionTotalMilliseconds;
    this.startFromEndTime(newEndTime);
  }

  updateFromClock() {
    if (!this.isRunning) return;
    this.remainingMilliseconds = Math.max(0, this.endTime - Date.now());
    this.updateDisplay(this.remainingMilliseconds);
    if (this.remainingMilliseconds <= 0) this.finish();
  }

  updateDisplay(milliseconds) {
    const formatted = formatTime(milliseconds);
    this.minutesDisplay.textContent = formatted.minutes;
    this.secondsDisplay.textContent = formatted.seconds;
    const warning = milliseconds > 0 && milliseconds < WARNING_THRESHOLD_MS;
    this.timeDisplay.classList.toggle("is-warning", warning);
  }

  finish() {
    window.clearInterval(this.intervalId);
    this.intervalId = null;
    this.isRunning = false;
    this.isFinished = true;
    this.remainingMilliseconds = 0;
    this.updateDisplay(0);
    this.panel.classList.remove("is-running");
    this.panel.classList.add("is-finished");
    updateControlStates();
    if (!this.isExtension) handleNormalTimerFinished(this);
  }

  reset() {
    window.clearInterval(this.intervalId);
    this.intervalId = null;
    this.remainingMilliseconds = this.totalMilliseconds;
    this.endTime = 0;
    this.isRunning = false;
    this.isFinished = false;
    this.panel.classList.remove("is-running", "is-finished");
    this.updateDisplay(this.remainingMilliseconds);
  }

  stopAndDispose() {
    window.clearInterval(this.intervalId);
    this.intervalId = null;
    this.isRunning = false;
  }
}

function validateGroup(groupKey, container) {
  const group = settingsState[groupKey];
  const rows = [...container.querySelectorAll(".timer-setting-row")];
  const parsedTimers = [];
  for (let index = 0; index < rows.length; index += 1) {
    const row = rows[index];
    const minutes = getInputInteger(row.querySelector(".minutes-input"), 0);
    const seconds = getInputInteger(row.querySelector(".seconds-input"), 0, 59);
    const label = groupKey === "upper" ? "上段" : "下段";
    if (minutes === null) return { error: `${label}タイマー${index + 1}の分は、0以上の整数で入力してください。` };
    if (seconds === null) return { error: `${label}タイマー${index + 1}の秒は、0~59の整数で入力してください。` };
    if (minutes === 0 && seconds === 0) return { error: `${label}タイマー${index + 1}は、0分0秒では作成できません。` };
    const fallbackNumber = groupKey === "upper" ? index + 1 : settingsState.upper.count + index + 1;
    const name = row.querySelector(".timer-name-input").value.trim() || `タイマー${fallbackNumber}`;
    parsedTimers.push({ name, minutes, seconds, durationMilliseconds: (minutes * 60 + seconds) * 1000 });
  }
  return { timers: parsedTimers };
}

function makeDisplayTimers(groupKey, parsedTimers) {
  if (settingsState[groupKey].timeMode === "individual") {
    return parsedTimers.map((timer) => ({
      name: timer.name,
      totalMilliseconds: timer.durationMilliseconds,
      group: groupKey
    }));
  }

  let cumulativeMilliseconds = 0;
  return parsedTimers.map((timer) => {
    cumulativeMilliseconds += timer.durationMilliseconds;
    return { name: timer.name, totalMilliseconds: cumulativeMilliseconds, group: groupKey };
  });
}

function createTimerPanel(timerData) {
  const panel = timerPanelTemplate.content.firstElementChild.cloneNode(true);
  const timer = new TimerPanel(timerData, panel);
  return { panel, timer };
}

function resetFinishPresentation() {
  groupFinishedState = { upper: false, lower: false };
  boardFinishShown = false;
  window.clearTimeout(boardFinishTimerId);
  boardFinishTimerId = null;
  upperGroupFlip.classList.remove("is-group-finished");
  lowerGroupFlip.classList.remove("is-group-finished");
  boardFinishOverlay.classList.remove("is-visible");
  boardFinishOverlay.setAttribute("aria-hidden", "true");
}

function finishGroup(groupKey) {
  if (groupFinishedState[groupKey]) return;
  const groupTimers = getGroupTimers(groupKey);
  if (!groupTimers.length || !groupTimers.every((timer) => timer.isFinished)) return;

  groupFinishedState[groupKey] = true;
  if (groupKey === "upper") upperGroupFlip.classList.add("is-group-finished");
  if (groupKey === "lower") lowerGroupFlip.classList.add("is-group-finished");
  checkBoardFinished();
}

function positionBoardFinishOverlay() {
  const upperRect = document.getElementById("upper-timer-section").getBoundingClientRect();
  const lowerRequired = settingsState.lower.count > 0 && !lowerTimerSection.hidden;
  const lowerRect = lowerRequired ? lowerTimerSection.getBoundingClientRect() : upperRect;
  const centerY = lowerRequired
    ? (upperRect.top + lowerRect.bottom) / 2
    : (upperRect.top + upperRect.bottom) / 2;

  boardFinishOverlay.style.setProperty("--board-finish-center-y", `${Math.round(centerY)}px`);
}

function showBoardFinished() {
  const upperDone = groupFinishedState.upper;
  const lowerRequired = settingsState.lower.count > 0;
  const lowerDone = !lowerRequired || groupFinishedState.lower;
  if (!upperDone || !lowerDone || boardFinishShown) return;

  boardFinishShown = true;
  boardFinishTimerId = null;
  boardFinishedTitle.textContent = settingsState.boardTitle.trim() || "試験用キッチンタイマー";
  boardFinishOverlay.style.setProperty("--board-finish-fade-ms", `${BOARD_FINISH_FADE_MS}ms`);
  positionBoardFinishOverlay();
  boardFinishOverlay.setAttribute("aria-hidden", "false");
  requestAnimationFrame(() => boardFinishOverlay.classList.add("is-visible"));
}

function checkBoardFinished() {
  const upperDone = groupFinishedState.upper;
  const lowerRequired = settingsState.lower.count > 0;
  const lowerDone = !lowerRequired || groupFinishedState.lower;
  if (!upperDone || !lowerDone || boardFinishShown || boardFinishTimerId !== null) return;

  boardFinishTimerId = window.setTimeout(showBoardFinished, BOARD_FINISH_DELAY_MS);
}

function handleNormalTimerFinished(timer) {
  finishGroup(timer.group);
}

function renderBoard(upperTimers, lowerTimers) {
  [...timers, ...extensionTimers].forEach((timer) => timer.stopAndDispose());
  timers = [];
  extensionTimers = [];
  boardStarted = false;
  upperTimerGrid.replaceChildren();
  lowerTimerGrid.replaceChildren();
  extensionTimerGrid.replaceChildren();
  extensionTimerSection.hidden = true;
  resetFinishPresentation();
  const referenceColumnCount = upperTimers.length;
  upperTimerGrid.style.setProperty("--timer-count", String(referenceColumnCount));
  lowerTimerGrid.style.setProperty("--timer-count", String(referenceColumnCount));
  extensionTimerGrid.style.setProperty("--timer-count", String(referenceColumnCount));

  upperTimers.forEach((timerData) => {
    const { panel, timer } = createTimerPanel(timerData);
    upperTimerGrid.appendChild(panel); timers.push(timer);
  });
  lowerTimers.forEach((timerData) => {
    const { panel, timer } = createTimerPanel(timerData);
    lowerTimerGrid.appendChild(panel); timers.push(timer);
  });

  boardTitle.textContent = settingsState.boardTitle.trim() || "試験用キッチンタイマー";
  upperHeading.textContent = settingsState.upper.title.trim() || "上段タイトル";
  lowerHeading.textContent = settingsState.lower.title.trim() || "下段タイトル";
  upperFinishedTitle.textContent = settingsState.upper.title.trim() || "上段タイトル";
  lowerFinishedTitle.textContent = settingsState.lower.title.trim() || "下段タイトル";
  lowerTimerSection.hidden = lowerTimers.length === 0;
  updateControlStates();
  requestAnimationFrame(updateBoardDensity);
}

function getGroupTimers(groupKey) { return timers.filter((timer) => timer.group === groupKey); }

function getBaseGroupEndTime(groupKey) {
  const groupTimers = getGroupTimers(groupKey);
  if (!groupTimers.length) return Date.now();
  return Math.max(...groupTimers.map((timer) => timer.endTime || (Date.now() + timer.remainingMilliseconds)));
}

function updateControlStates() {
  startAllButton.disabled = boardStarted || !timers.some((timer) => timer.canStart());
  addExtensionButton.disabled = !boardStarted || extensionTimers.length >= MAX_EXTENSION_TIMERS;
}

function startAllTimers() {
  if (boardStarted) return;
  const startTime = Date.now();
  timers.forEach((timer) => {
    timer.endTime = startTime + timer.remainingMilliseconds;
    timer.isRunning = true;
    timer.panel.classList.add("is-running");
    timer.updateFromClock();
    timer.intervalId = window.setInterval(() => timer.updateFromClock(), UPDATE_INTERVAL);
  });
  boardStarted = true;
  updateControlStates();
}

function resetAllTimers() {
  const confirmed = window.confirm("すべてのタイマーをリセットします。個別延長タイマーも削除されます。よろしいですか?");
  if (!confirmed) return;
  timers.forEach((timer) => timer.reset());
  extensionTimers.forEach((timer) => timer.stopAndDispose());
  extensionTimers = [];
  extensionTimerGrid.replaceChildren();
  extensionTimerSection.hidden = true;
  boardStarted = false;
  resetFinishPresentation();
  updateControlStates();
  requestAnimationFrame(updateBoardDensity);
}

function populateBaseGroupOptions() {
  extensionBaseGroup.replaceChildren();
  const upperOption = new Option(settingsState.upper.title.trim() || "上段", "upper");
  extensionBaseGroup.add(upperOption);
  if (settingsState.lower.count > 0) {
    const lowerOption = new Option(settingsState.lower.title.trim() || "下段", "lower");
    extensionBaseGroup.add(lowerOption);
  }
}

function resetExtensionDialogInputs() {
  extensionMinutesInput.value = "0";
  extensionSecondsInput.value = "0";
  extensionError.textContent = "";
  const increaseRadio = extensionForm.querySelector('input[name="extension-adjust-mode"][value="increase"]');
  if (increaseRadio) increaseRadio.checked = true;
}

function openExtensionDialogForCreate() {
  if (!boardStarted || extensionTimers.length >= MAX_EXTENSION_TIMERS) return;
  extensionDialogMode = "create";
  editingExtensionTimer = null;
  extensionDialogTitle.textContent = "個別延長タイマーを作成";
  extensionSubmitButton.textContent = "作成";
  extensionNameField.hidden = false;
  extensionBaseField.hidden = false;
  extensionCurrentTotal.hidden = true;
  extensionAdjustFieldset.hidden = true;
  extensionNameInput.value = "";
  populateBaseGroupOptions();
  resetExtensionDialogInputs();
  extensionDialog.showModal();
  extensionNameInput.focus();
}

function openExtensionDialogForUpdate(timer) {
  extensionDialogMode = "update";
  editingExtensionTimer = timer;
  extensionDialogTitle.textContent = `${timer.name} の延長時間を調整`;
  extensionSubmitButton.textContent = "更新";
  extensionNameField.hidden = true;
  extensionBaseField.hidden = true;
  extensionCurrentTotal.hidden = false;
  extensionAdjustFieldset.hidden = false;
  extensionCurrentTotal.textContent = `現在の延長合計:${formatDuration(timer.extensionTotalMilliseconds)} / 残り時間:${formatDuration(timer.remainingMilliseconds)}`;
  resetExtensionDialogInputs();
  extensionDialog.showModal();
  extensionMinutesInput.focus();
}

function closeExtensionDialog() {
  if (extensionDialog.open) extensionDialog.close();
  editingExtensionTimer = null;
}

function createExtensionTimer(enteredName, baseGroup, extensionMilliseconds) {
  const baseEndTime = getBaseGroupEndTime(baseGroup);
  const endTime = baseEndTime + extensionMilliseconds;
  const remainingMilliseconds = Math.max(0, endTime - Date.now());
  const displayName = buildExtensionDisplayName(baseGroup, enteredName);

  const { panel, timer } = createTimerPanel({
    name: displayName,
    totalMilliseconds: remainingMilliseconds,
    group: "extension",
    isExtension: true,
    extensionTotalMilliseconds: extensionMilliseconds,
    extensionHistoryMilliseconds: [extensionMilliseconds],
    baseGroup
  });
  timer.startFromEndTime(endTime);
  extensionTimerGrid.appendChild(panel);
  extensionTimers.push(timer);
  extensionTimerGrid.style.setProperty("--timer-count", String(settingsState.upper.count));
  extensionTimerSection.hidden = false;
  updateControlStates();
  showInteractionControls();
  requestAnimationFrame(updateBoardDensity);
}

function deleteExtensionTimer(timer) {
  const confirmed = window.confirm(`${timer.name} の延長タイマーを削除しますか?`);
  if (!confirmed) return;
  timer.stopAndDispose();
  timer.panel.remove();
  extensionTimers = extensionTimers.filter((item) => item !== timer);
  extensionTimerGrid.style.setProperty("--timer-count", String(settingsState.upper.count));
  extensionTimerSection.hidden = extensionTimers.length === 0;
  updateControlStates();
  requestAnimationFrame(updateBoardDensity);
}

function handleExtensionSubmit(event) {
  event.preventDefault();
  const minutes = getInputInteger(extensionMinutesInput, 0);
  const seconds = getInputInteger(extensionSecondsInput, 0, 59);
  if (minutes === null) { extensionError.textContent = "分は0以上の整数で入力してください。"; return; }
  if (seconds === null) { extensionError.textContent = "秒は0~59の整数で入力してください。"; return; }
  if (minutes === 0 && seconds === 0) { extensionError.textContent = "延長時間を入力してください。"; return; }
  const extensionMilliseconds = (minutes * 60 + seconds) * 1000;

  if (extensionDialogMode === "create") {
    if (extensionTimers.length >= MAX_EXTENSION_TIMERS) { extensionError.textContent = "個別延長タイマーは最大4個までです。"; return; }
    const enteredName = extensionNameInput.value.trim() || `個別延長${extensionTimers.length + 1}`;
    createExtensionTimer(enteredName, extensionBaseGroup.value, extensionMilliseconds);
  } else if (editingExtensionTimer) {
    const adjustMode = extensionForm.querySelector('input[name="extension-adjust-mode"]:checked')?.value || "increase";

    if (adjustMode === "decrease") {
      const remainingMilliseconds = Math.max(0, editingExtensionTimer.remainingMilliseconds);
      if (remainingMilliseconds <= 0) {
        extensionError.textContent = "終了したタイマーから時間を減らすことはできません。";
        return;
      }
      if (extensionMilliseconds >= remainingMilliseconds) {
        extensionError.textContent = `残り時間(${formatDuration(remainingMilliseconds)})以上は減らせません。`;
        return;
      }
      if (extensionMilliseconds > editingExtensionTimer.extensionTotalMilliseconds) {
        extensionError.textContent = `現在の延長合計(${formatDuration(editingExtensionTimer.extensionTotalMilliseconds)})を超えて減らすことはできません。`;
        return;
      }
    }

    editingExtensionTimer.adjustExtension(extensionMilliseconds, adjustMode);
  }
  closeExtensionDialog();
}

function showInteractionControls() {
  if (boardScreen.hidden) return;
  floatingControls.classList.add("is-visible");
  document.body.classList.add("show-interaction-controls");
  window.clearTimeout(controlHideTimerId);
  controlHideTimerId = window.setTimeout(hideInteractionControls, CONTROL_HIDE_DELAY);
}

function hideInteractionControls() {
  floatingControls.classList.remove("is-visible");
  document.body.classList.remove("show-interaction-controls");
}

function updateBoardDensity() {
  if (boardScreen.hidden) return;
  const isDesktop = window.matchMedia("(min-width: 901px)").matches;
  document.body.classList.remove("board-compact", "board-extra-compact");
  if (!isDesktop) return;
  const viewportHeight = window.innerHeight;
  if (viewportHeight < 950 || !extensionTimerSection.hidden) document.body.classList.add("board-compact");
  if (viewportHeight < 760) document.body.classList.add("board-extra-compact");
}

function showBoardScreen() {
  setupScreen.hidden = true;
  boardScreen.hidden = false;
  window.scrollTo({ top: 0, behavior: "auto" });
  showInteractionControls();
  requestAnimationFrame(updateBoardDensity);
}

function showSetupScreen() {
  boardScreen.hidden = true;
  setupScreen.hidden = false;
  window.clearTimeout(controlHideTimerId);
  document.body.classList.remove("show-interaction-controls", "board-compact", "board-extra-compact");
  window.scrollTo({ top: 0, behavior: "auto" });
}

setupForm.addEventListener("submit", (event) => {
  event.preventDefault();
  saveCurrentSetupInputs();
  const upperResult = validateGroup("upper", upperSettingsContainer);
  if (upperResult.error) { setupError.textContent = upperResult.error; return; }
  const lowerResult = validateGroup("lower", lowerSettingsContainer);
  if (lowerResult.error) { setupError.textContent = lowerResult.error; return; }

  settingsState.upper.timers = upperResult.timers.map(({ name, minutes, seconds }) => ({ name, minutes, seconds }));
  settingsState.lower.timers = lowerResult.timers.map(({ name, minutes, seconds }) => ({ name, minutes, seconds }));
  setupError.textContent = "";
  renderBoard(makeDisplayTimers("upper", upperResult.timers), makeDisplayTimers("lower", lowerResult.timers));
  showBoardScreen();
});

themeRadios.forEach((radio) => {
  radio.addEventListener("change", () => {
    if (radio.checked) applyTheme(radio.value);
  });
});

upperCountSelect.addEventListener("change", () => {
  handleCountChange("upper", upperCountSelect, upperSettingsContainer);
  updateLowerCountOptions();
});

lowerCountSelect.addEventListener("change", () => handleCountChange("lower", lowerCountSelect, lowerSettingsContainer));
startAllButton.addEventListener("click", startAllTimers);
resetAllButton.addEventListener("click", resetAllTimers);
addExtensionButton.addEventListener("click", openExtensionDialogForCreate);
extensionForm.addEventListener("submit", handleExtensionSubmit);
extensionCancelButton.addEventListener("click", closeExtensionDialog);
finishButton.addEventListener("click", () => {
  const confirmed = window.confirm("設定画面へ戻ります。タイマーは終了します。よろしいですか?");
  if (!confirmed) return;
  [...timers, ...extensionTimers].forEach((timer) => timer.stopAndDispose());
  showSetupScreen();
});

["mousemove", "mousedown", "keydown", "touchstart"].forEach((eventName) => {
  window.addEventListener(eventName, showInteractionControls, { passive: true });
});
window.addEventListener("resize", () => {
  showInteractionControls();
  updateBoardDensity();
  if (boardFinishShown || boardFinishTimerId !== null) positionBoardFinishOverlay();
});

applyTheme(settingsState.theme);
renderSettingRows("upper", upperSettingsContainer);
renderSettingRows("lower", lowerSettingsContainer);
updateLowerCountOptions();
lowerSettingsContainer.hidden = settingsState.lower.count === 0;

assets/steam-gears.svg

スチームパンクBで使う歯車装飾です。assetsフォルダを作成し、その中へ保存します。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
  <defs>
    <radialGradient id="g" cx="50%" cy="45%" r="60%">
      <stop offset="0" stop-color="#d5a552"/>
      <stop offset="0.5" stop-color="#8b5a24"/>
      <stop offset="1" stop-color="#3d2410"/>
    </radialGradient>
    <filter id="shadow"><feDropShadow dx="0" dy="4" stdDeviation="5" flood-opacity=".55"/></filter>
  </defs>
  <g fill="url(#g)" stroke="#e4bf73" stroke-width="4" filter="url(#shadow)" opacity=".9">
    <g transform="translate(115 120)">
      <circle r="74"/>
      <circle r="40" fill="#24170d"/>
      <circle r="16" fill="#b47a33"/>
      <g>
        <rect x="-12" y="-95" width="24" height="36" rx="4"/>
        <rect x="-12" y="59" width="24" height="36" rx="4"/>
        <rect x="59" y="-12" width="36" height="24" rx="4"/>
        <rect x="-95" y="-12" width="36" height="24" rx="4"/>
        <rect x="-12" y="-95" width="24" height="36" rx="4" transform="rotate(45)"/>
        <rect x="-12" y="59" width="24" height="36" rx="4" transform="rotate(45)"/>
        <rect x="59" y="-12" width="36" height="24" rx="4" transform="rotate(45)"/>
        <rect x="-95" y="-12" width="36" height="24" rx="4" transform="rotate(45)"/>
      </g>
    </g>
    <g transform="translate(285 260) scale(.78)">
      <circle r="74"/>
      <circle r="40" fill="#24170d"/>
      <circle r="16" fill="#b47a33"/>
      <g>
        <rect x="-12" y="-95" width="24" height="36" rx="4"/>
        <rect x="-12" y="59" width="24" height="36" rx="4"/>
        <rect x="59" y="-12" width="36" height="24" rx="4"/>
        <rect x="-95" y="-12" width="36" height="24" rx="4"/>
        <rect x="-12" y="-95" width="24" height="36" rx="4" transform="rotate(45)"/>
        <rect x="-12" y="59" width="24" height="36" rx="4" transform="rotate(45)"/>
        <rect x="59" y="-12" width="36" height="24" rx="4" transform="rotate(45)"/>
        <rect x="-95" y="-12" width="36" height="24" rx="4" transform="rotate(45)"/>
      </g>
    </g>
  </g>
</svg>

assets/steam-metal-texture.png

スチームパンクCでは金属テクスチャ画像も使っています。PNGファイルなのでソースコードとしては掲載せず、assetsフォルダへ配置します。

設定画面へ外観テーマを追加

今回、設定画面の「画面タイトル」の下へ「外観テーマ」を追加しました。

テーマはプルダウンではなくラジオボタンにしました。7種類を一度に見渡せるため、今どのテーマを選んでいるのか分かりやすく、何度も切り替えて比較しやすいためです。

ラジオボタンを変更するとすぐに反映

JavaScriptでは、テーマ用ラジオボタンのchangeイベントを監視しています。

themeRadios.forEach((radio) => {
  radio.addEventListener("change", () => {
    if (radio.checked) applyTheme(radio.value);
  });
});

選択された値はapplyTheme()へ渡し、body要素のdata-theme属性へ設定します。

function applyTheme(themeName) {
  const allowedThemes = ["blue", "industrial", "minimal", "cyber", "steam-css", "steam-svg", "steam-image"];
  const theme = allowedThemes.includes(themeName) ? themeName : "blue";
  settingsState.theme = theme;
  document.body.dataset.theme = theme;
}

たとえばサイバーパンクを選択すると、bodyはdata-theme=”cyber”になります。CSS側ではこの値を見てテーマごとの設定を適用します。

今回のテーマ切り替えは、設定画面を作成し直してから反映するのではありません。ラジオボタンを押した時点でbodyの属性が変わるため、その場で設定画面全体の見た目も切り替わります。

CSS変数で共通部分をまとめる

7テーマすべてについて、ボタンやパネルのCSSを最初から全部書き直す方法もありますが、それでは同じ設定が大量に重複します。

そこで今回はCSSカスタムプロパティ、いわゆるCSS変数を使って、背景色、文字色、枠線、ボタン、タイマーパネル、時計フォントなどをまとめています。

body {
  --theme-bg: radial-gradient(circle at center, #17243a 0%, #070b12 60%, #000 100%);
  --theme-text: #f7f9ff;
  --theme-muted: #aebfd8;
  --theme-panel: linear-gradient(145deg,rgba(23,39,63,.88),rgba(7,13,24,.93));
  --theme-border: rgba(151,190,245,.35);
  --theme-accent: #5d93ff;
  --theme-digit-font: Consolas,"Roboto Mono","Courier New",monospace;
}

通常の各部品では、直接色を書くのではなく、この変数を使います。

body[data-theme] {
  background: var(--theme-bg);
  color: var(--theme-text);
}

body[data-theme] .timer-face {
  background: var(--theme-timer-face);
  border-color: var(--theme-border-strong);
  box-shadow: var(--theme-timer-glow);
}

body[data-theme] .minutes-display,
body[data-theme] .seconds-display,
body[data-theme] .time-colon {
  font-family: var(--theme-digit-font);
  text-shadow: var(--theme-digit-shadow);
}

テーマごとに変数の中身だけを変更することで、同じHTMLのまま画面全体の印象を変えられます。

テーマごとに時計フォントも変更

当初は「テーマ」と「時計フォント」を別々に選べるようにする案もありましたが、今回は分けませんでした。

フォントまで自由に組み合わせられるようにすると選択肢が増えすぎるため、各テーマに合うフォントをテーマの一部として設定しています。

ミニマルでは普通の読みやすいフォント、機械風では等幅系、スチームパンクBでは少しクラシックな雰囲気のフォントというように、テーマ側で調整しています。

サイバーパンクはCSSだけでもかなり遊べる

サイバーパンクテーマの設定画面

サイバーパンクテーマの本番画面

サイバーパンクでは画像を使わず、CSSだけでネオン、グリッド、発光、斜めに切れたボタンなどを作っています。

背景には複数のlinear-gradient()とradial-gradient()を重ね、タイマー枠にはbox-shadowを使って青や紫の発光を追加しています。

body[data-theme="cyber"] {
  --theme-bg:
    linear-gradient(rgba(0,222,255,.035) 1px,transparent 1px),
    linear-gradient(90deg,rgba(0,222,255,.035) 1px,transparent 1px),
    radial-gradient(circle at 18% 15%,rgba(0,226,255,.18),transparent 30%),
    radial-gradient(circle at 84% 82%,rgba(191,37,255,.16),transparent 32%),
    #020712;
}

今回試した中では、サイバーパンク系はCSSだけでもかなり雰囲気を出せることが分かりました。

スチームパンクを3方式で比較してみる

今回いちばん実験らしい部分です。

スチームパンクは、真鍮、古い金属、歯車、配管、リベットなどの細かい装飾が多いため、CSSだけでどこまで雰囲気を作れるのか気になりました。

A:純CSSだけ

Aでは外部素材を一切使わず、グラデーション、枠線、点線、影などだけで金属板のような雰囲気を作っています。

本物の歯車が見えるわけではありませんが、「古い機械っぽい雰囲気」まではCSSだけでもかなり作れます。

B:CSS+SVG

BではCSSに加えて、SVGで作った歯車を背景装飾として使用しました。

SVGは拡大しても輪郭が荒れにくく、単純な形状なら比較的軽いまま使えます。CSSだけよりも「歯車のある機械」という印象が分かりやすくなりました。

C:CSS+画像テクスチャ

Cでは金属テクスチャのPNG画像を背景へ重ねています。

CSSだけでは出しにくい細かな表面のムラや金属感を追加できますが、画像ファイルも一緒に管理する必要があります。

「画像を使えば必ず一番良い」というわけではありません。実用性、見やすさ、ファイル管理、画面の雰囲気を比べながら、必要なところだけ素材を使う方が良さそうです。

7テーマを実際に確認

今回は最初から完成形を決めず、7種類すべてを実装した状態で確認しました。

ブルー・ガラス 本番画面

ブラック・インダストリアル 本番画面

ミニマル 本番画面

サイバーパンク 本番画面

スチームパンクA 本番画面

スチームパンクB 本番画面

スチームパンクC 本番画面

スマートフォン表示、終了演出、個別延長タイマーを出した状態、設定画面へ戻ったときの表示も確認しました。

今のところ大きな問題はなく、第8回では7テーマをそのまま残すことにしました。

見た目で遊んでも、実用性は最優先

今回のテーマ切り替えは、かなり趣味の領域に入っています(笑)。

ただし、このタイマーは実際に試験会場で大型ディスプレイへ表示して使う予定です。

そのため、どのテーマでも「残り時間が一番目立つ」「上段・下段・個別延長の区別が分かる」「終了表示を見落としにくい」という基本部分は崩さないようにしています。

もっと装飾を増やせば、サイバーパンクやスチームパンクはさらに派手にできます。ただ、今回は「実際に使えるタイマー」という範囲で止めています。遊びすぎて時間が読めなくなったら本末転倒です(笑)。

今回新しく使った考え方

data-*属性

HTML要素へ独自の情報を持たせる仕組みです。今回はbodyのdata-themeへ現在のテーマ名を入れています。

JavaScriptでdata-themeを書き換え、CSSでbody[data-theme=”cyber”]のように指定することで、HTMLを作り直さずにデザインを切り替えています。

CSSカスタムプロパティ

–theme-bgや–theme-accentのように、自分で値へ名前を付けて再利用できる仕組みです。

同じ色を何十か所も直接書き換える必要がなく、テーマごとの差分をまとめやすくなります。

複数背景の重ね合わせ

CSSのbackgroundには、複数のグラデーションや画像をカンマで区切って重ねることができます。

サイバーパンクのグリッドや光、スチームパンクCの金属テクスチャは、この仕組みを使っています。

第8回でできたこと

  • 外観テーマを設定画面から選択できるようになった
  • テーマ変更を設定画面へ即時反映できるようになった
  • 画面全体を7種類のデザインへ切り替えられるようになった
  • 時計フォントもテーマに合わせて変更した
  • サイバーパンクをCSSだけで表現した
  • スチームパンクを純CSS・SVG・画像の3方式で比較できるようにした
  • 第7回までのタイマー機能をそのまま維持した
  • PC・スマートフォン・終了表示・個別延長を確認した

完成版を動かしてみる

第8回の完成版はこちらです。

第8回の完成版を別画面で開く

次回は実運用向けの仕上げへ

見た目までかなり遊べるようになったので、次は実際の試験運用で必要になる機能へ戻ります。

候補になっているのは、設定や実行状態の保存・復元、全画面表示、スリープ防止などです。

ブラウザを再読み込みしてしまった場合や、教室の大型ディスプレイで長時間表示する場合を考えながら、実運用向けに少しずつ仕上げていきます。