static-spa-interactions について
static-spa-interactionsは、バニラSPAに進捗保存、ナビゲーション、テーマ、クイズ、教材プレビューなどの操作パターンを追加するスキルです。画面幅ごとの挙動と実装後の検証も重視します。
主な機能
- 01タスクとクイズ状態の保存
- 02レスポンシブなサイドバー
- 03ScrollSpyによる章の強調表示
- 04ダーク/ライトテーマ
- 05クイズと教材プレビュー
活用シーン
- 01訪問間で学習進捗を保存する
- 02コースにレスポンシブなナビゲーションを加える
- 03誤答したクイズ項目の復習を促す
- 04ページを離れずに教材をプレビューする
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ kevintsai1202/ teaching-site-skills --skill static-spa-interactionsインストール後、Claude Code で/static-spa-interactionsを入力して読み込まれたか確認します。
詳細
静的 SPA のインタラクション
スキーマの正:
Taskプリミティブ(ID を localStorage のキーとして扱うルール)とQuizItemプリミティブ(選択したオプションの保存)は、_shared/domain-primitives.md§8 と §10 に基づきます。localStorage のスキーマは §8 にあります。リファレンス実装:
renderSidebar / setupScrollSpy / applyTheme / setupFadeInのパターンは、d:/GitHub/ai-workshop/index.html:2900-3030を参照してください。
このスキルでは、バニラ SPA に状態管理、ナビゲーション、テーマ、レスポンシブ対応、アクセシビリティといったインタラクション層を追加します。以下の各パターンは、個別に追加できる独立したモジュールです。
ここにあるパターンは、サンプルワークショップで実際に見つけるまでにかなりの時間を要した、見つけにくいバグを含むため記載しています。「Why」の注記をよく読んでください。単なる補足情報ではありません。
パターン 1:localStorage の進捗(タスクのチェックボックスとクイズの回答)
const STORAGE_KEY = 'your-app-progress-v1'; // version suffix lets you breaking-change later
const store = {
load() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { tasks: {}, quiz: {}, theme: 'light' };
} catch { return { tasks: {}, quiz: {}, theme: 'light' }; }
},
save(state) { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); },
reset() { localStorage.removeItem(STORAGE_KEY); location.reload(); }
};
キーにバージョンの接尾辞を付ける理由:後でスキーマを変更する必要があると気づいたら、接尾辞を増やしてください(-v2)。古いキーは問題を起こさずに残り、新しい訪問者には新しい状態が適用されます。キー名を変更すると全員のデータが消去されるため、意図的に変更する場合に限ってください。
JSON.parse を try/catch で囲む理由:データが破損している場合(たとえば、ユーザーが開発者ツールで編集した場合)、init() がクラッシュし、ページ全体が動作しなくなります。
パターン 2:サイドバー — 2 つのモードを統合しない
サイドバーは、デスクトップとモバイルで初期状態が正反対です。これを単一の CSS クラスで表現しようとするのは、最もよくあるバグです。
:root { --sidebar-w: 280px; }
/* Desktop: sidebar visible by default */
.sidebar { position: fixed; left: 0; top: 0; width: var(--sidebar-w); height: 100vh; }
.main { margin-left: var(--sidebar-w); transition: margin-left .2s ease; }
.app.sidebar-closed .sidebar { transform: translateX(-100%); }
.app.sidebar-closed .main { margin-left: 0; }
@media (max-width: 768px) {
/* Mobile: sidebar hidden by default */
.sidebar { transform: translateX(-100%); }
.main { margin-left: 0; }
.app.sidebar-open .sidebar { transform: translateX(0); }
.sidebar-backdrop { /* overlay shown only when sidebar-open */ }
}
const mql = matchMedia('(max-width: 768px)');
function toggleSidebar() {
if (mql.matches) document.querySelector('.app').classList.toggle('sidebar-open');
else document.querySelector('.app').classList.toggle('sidebar-closed');
}
// On viewport crossing 768px, clear both classes so the new viewport's default takes over:
mql.addEventListener('change', () => {
document.querySelector('.app').classList.remove('sidebar-open', 'sidebar-closed');
});
クラスを 2 つにする理由:デスクトップでは、「閉じている」とは外側へ変形している状態であり、モバイルでは「開いている」とは内側へ変形している状態です。同じクラスにすると、ビューポートごとに逆の CSS ルールが必要になり、保守が困難になります。
sticky ではなく position: fixed を使う理由:祖先要素に overflow: hidden があると、sticky は機能しません。fixed なら影響を受けません。(さらに、overflow-x: hidden を指定すると暗黙的に overflow-y: auto が強制され、ウィンドウがスクロールできなくなります。パターン 9 を参照してください。)
パターン 3:ScrollSpy(サイドバーで現在のセクションを自動的にハイライト表示)
function setupScrollSpy() {
const sections = document.querySelectorAll('[data-section-id]');
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.dataset.sectionId;
document.querySelectorAll('.sidebar-link').forEach(a => a.classList.toggle('active', a.dataset.target === id));
}
});
}, { rootMargin: '-30% 0px -60% 0px', threshold: 0 });
sections.forEach(s => observer.observe(s));
}
rootMargin: '-30% 0px -60% 0px' を使う理由:ビューポート上部の 3 分の 1 に、高さが 10%の「アクティブゾーン」を作成します。このゾーンの中央にあるセクションが「現在の」セクションとみなされます。threshold だけでは、結果が不安定になります。
パターン 4:テーマの切り替え(ダーク/ライト)
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
const s = store.load(); s.theme = theme; store.save(s);
}
applyTheme(store.load().theme); // on load
CSS では、:root に [data-theme="dark"] 属性セレクターを使用します。ユーザーが明示的に希望しない限り、prefers-color-scheme によるフォールバックは設けません。切り替え設定が唯一の基準です。
パターン 5:コンテンツのズーム(サイドバーには適用しない)
:root { --content-zoom: 1; }
.content { zoom: var(--content-zoom); }
/* Sidebar is OUTSIDE .content — never inherits zoom */
function applyZoom(value) {
if (mql.matches) {
// On mobile, kill the CSS variable so @media rules govern
document.documentElement.style.removeProperty('--content-zoom');
} else {
document.documentElement.style.setProperty('--content-zoom', value);
}
}
html { zoom: X } を使わない理由:CSS の zoom は子孫要素に乗算されます。html が 1.35 倍で .content が 1.25 倍の場合、コンテンツは実際には 1.6875 倍になります。ズームは1 つの階層に限定してください。
モバイルではズームを無効にする理由:スマートフォンでは拡大する必要がなく、デスクトップ用の 1.35 を適用すると文字が異常に大きくなります。@media (max-width: 768px) :root { --content-zoom: 1 } は、インラインスタイルで上書きされていない場合にのみ適用されます。そのため、値を 1 に戻すだけでなく、removeProperty() が必要です。
パターン 6:アコーディオンとフェードイン表示
function setupFadeIn() {
const observer = new IntersectionObserver(entries => {
entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); });
}, { threshold: 0.15 });
document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
}
.fade-in { opacity: 0; transform: translateY(20px); transition: opacity .5s, transform .5s; }
.fade-in.visible { opacity: 1; transform: translateY(0); }
落とし穴:セクションで zoom を使用していて、IntersectionObserver が表示状態を正しく判定できない場合、opacity: 0 の要素は永久に表示されないままになります。zoom を使う場合は、opacity: 0 → 1 のトランジションではなく、@keyframes を使用してください。サンプルワークショップでも同じ問題が起き、Day のヒーロー部分にある数字をキーフレームに切り替えました。
パターン 7:クリップボードにコピーするボタン
async function copyPrompt(text, btn) {
try {
await navigator.clipboard.writeText(text);
showToast('已複製');
} catch {
// Fallback for non-secure context (e.g. file://)
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta);
ta.select(); document.execCommand('copy');
ta.remove();
showToast('已複製');
}
}
一部の環境では SPA が file:// から開かれる(オフラインの zip で配布されるなど)と navigator.clipboard が制限されるため、フォールバックが重要です。
パターン 8:トースト(最小構成)
function showToast(msg) {
const t = document.createElement('div');
t.className = 'toast'; t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.classList.add('show'), 10);
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 1800);
}
パターン 9:出典チャプターへの戻りリンク付きクイズ
各クイズ項目には、course-content-authoring の sourceUnit フィールドがあります。回答を送信すると、不正解の行にそのユニットまでスクロールする「復習する →」リンクが表示されます。
function gradeQuiz() {
const state = store.load();
const results = window.COURSE.quiz.map(q => ({
q, given: state.quiz[q.id], correct: q.answer, isWrong: state.quiz[q.id] !== q.answer
}));
// Render with back-links for wrong ones; scroll behaviour:
// document.querySelector(`[data-section-id="${q.sourceUnit}"]`).scrollIntoView({ behavior: 'smooth' });
}
ハードコードされた 5 か所の落とし穴:設問数は、(1)セクションのタイトル、(2)リード文、(3)スコア表示「— / N」、(4)「N 問正解しました」というトースト、(5)合格基準の s >= K に表示されます。クイズの設問数を変更する場合は、5 か所すべてを確認してください。代わりに window.COURSE.quiz.length から算出することを検討してください。ほとんどの箇所はそこから導き出せます。
パターン 10:教材プレビュー用 iframe モーダル
教材を新しいタブで開く(進捗のコンテキストが失われます)のではなく、モーダルに埋め込みます。
function openViewer(url) {
const modal = document.createElement('div'); modal.className = 'viewer-modal';
modal.innerHTML = `<div class="viewer-backdrop"></div><div class="viewer-frame">
<button class="viewer-close">×</button>
<iframe src="${url}" loading="lazy"></iframe>
</div>`;
document.body.appendChild(modal);
modal.querySelector('.viewer-close').onclick = () => modal.remove();
modal.querySelector('.viewer-backdrop').onclick = () => modal.remove();
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { modal.remove(); document.removeEventListener('keydown', esc); }
});
}
ダウンロードさせる PDF は、モーダルを経由させないでください。<a href="..." download> を直接レンダリングし、ブラウザーの保存ダイアログを表示します。Markdown / HTML / TXT はモーダル、PDF はダウンロードです。この分岐は getMaterialUrl() の呼び出し元に実装します。
パターン 11:モーダル表示中の body スクロールロック
function openViewer(url) { /* ... */ document.body.style.overflow = 'hidden'; }
function closeViewer() { document.body.style.overflow = ''; }
これがないと、macOS Safari ではモーダルをスクロールしたときに、その下のページもスクロールします。
検証
検証スクリプトをこのスキルに組み込まないでください。専用のスキルがあります。上記のインタラクションパターンを実装したら、web-visual-verification を呼び出して、対応する検証スクリプト(verify-rwd / verify-progress / verify-quiz / verify-modal など)を作成してください。このスキルでは、4 種類のスクリプトの役割(verify / capture / diagnose / probe)、再利用可能なアサーション、複数ビューポートのパターンについて説明しています。
要点:このスキルの各インタラクションパターンには、対応する検証スクリプトがあります。検証スクリプトなしでインタラクションをリリースしないでください。
引き継ぎ
ユーザーには次のように伝えてください。「インタラクション層が完成しました。ブラウザーで開いて操作し、検証スクリプトを実行してください。次のステージ(web-visual-assets)でアートワークを追加します。現時点では、ページに画像が不足しているか、プレースホルダー画像が表示されている可能性があります。」