关于 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 交互
Schema 权威来源:
Task原语(以 id 作为 localStorage 键的规则)和QuizItem原语(所选选项的存储)来自_shared/domain-primitives.md§8 和 §10。localStorage Schema 见 §8。参考实现:
d:/GitHub/ai-workshop/index.html:2900-3030中的renderSidebar / setupScrollSpy / applyTheme / setupFadeIn模式。
此技能为原生 SPA 添加交互层:状态、导航、主题、响应式设计和无障碍性。以下每种模式都是可独立添加的自包含模块。
之所以记录这些模式,是因为它们都隐藏着微妙的 bug,在示例工作坊中花了大量时间才找到。请仔细阅读 “原因” 注释——它们不是可有可无的背景信息。
模式 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(); }
};
为什么键名要带版本后缀:之后发现 Schema 需要更改时,可以递增后缀(-v2)。旧键会继续保留,不会造成影响;新访客则会获得全新状态。重命名键会清除所有人的数据——只有在确有意图时才这样做。
为什么要在 JSON.parse 外使用 try/catch:损坏的条目(例如用户打开开发者工具并编辑了数据)会导致 init() 崩溃,并使整个页面无法运行。
模式 2:侧边栏——两种模式,不要合并
侧边栏在桌面端和移动端的默认状态相反。试图用一个 CSS 类来表达这两种状态,是最常见的 bug。
: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');
});
为什么要使用两个类:在桌面端,“关闭”表示移出视野;在移动端,“打开”表示移入视野。同一个类在不同视口下需要应用相反的 CSS 规则,难以维护。
为什么用 position: fixed 而不是 sticky:任何带有 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':它会在视口上方三分之一区域创建一个高度为 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.35 会让文字大得离谱。@media (max-width: 768px) :root { --content-zoom: 1 } 只有在没有内联样式覆盖时才会生效——因此必须使用 removeProperty(),不能只把值设回 1。
模式 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 时,应使用 @keyframes,而不是使用 opacity: 0 → 1 过渡(示例工作坊曾遇到此问题,因此将 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:Toast(最简版)
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' });
}
硬编码 N 处的陷阱:题目总数会出现在 (1) 小节标题 (2) 引导段落 (3) 分数显示“— / N” (4)“你答对 N 题”提示 (5) 及格阈值 s >= K 中。更改测验题目数量时,要检查全部五处。可以考虑改为从 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:模态框打开时锁定页面滚动
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 等)。该技能介绍了四种脚本用途(verify / capture / diagnose / probe)、可复用断言和多视口模式。
简而言之:此技能中的每种交互模式都有对应的验证脚本。没有验证脚本,就不要发布交互功能。
交接
告诉用户:“交互层已完成。请在浏览器中打开并点击体验,然后运行验证脚本。下一阶段(web-visual-assets)将补上图像资源——你的页面现在可能还缺少图片,或使用了占位图。”