static-spa-interactions
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseStatic SPA Interactions
静态SPA交互
Schema authority: theprimitive (id-as-localStorage-key rules) and theTaskprimitive (storage of selected option) come fromQuizItem§8 and §10. localStorage schema lives in §8._shared/domain-primitives.mdReference implementation:ford:/GitHub/ai-workshop/index.html:2900-3030patterns.renderSidebar / setupScrollSpy / applyTheme / setupFadeIn
This skill adds the interaction layer to a vanilla SPA: state, navigation, theme, responsiveness, accessibility. Each pattern below is a self-contained module that can be added independently.
The patterns here are documented because they all hide subtle bugs that took real time to find in the example workshop. Read the "Why" notes carefully — they're not optional context.
Schema 权威定义:基元(以ID作为localStorage键的规则)和Task基元(选中选项的存储)来自QuizItem第8节和第10节。localStorage的Schema定义在第8节。_shared/domain-primitives.md参考实现:中的d:/GitHub/ai-workshop/index.html:2900-3030模式。renderSidebar / setupScrollSpy / applyTheme / setupFadeIn
此技能为基础静态SPA添加交互层:状态管理、导航、主题切换、响应式适配、无障碍支持。以下每种模式都是独立的模块,可单独添加。
此处记录的模式都曾在示例工作坊中引发过不易察觉的bug,花费了不少时间才排查解决。请仔细阅读「设计原因」部分——这并非可选的背景信息。
Pattern 1: localStorage Progress (Task Checkboxes + Quiz Answers)
模式1:localStorage进度跟踪(任务复选框+测验答案)
js
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(); }
};Why a version suffix in the key: when you later realise the schema needs to change, increment the suffix (). Old keys remain harmless; new visitors get fresh state. Renaming the key wipes everyone — only do that intentionally.
-v2Why try/catch around JSON.parse: a corrupted entry (e.g. user opened devtools and edited) will crash and break the whole page.
init()js
const STORAGE_KEY = 'your-app-progress-v1'; // 版本后缀允许后续进行破坏性变更
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()Pattern 2: Sidebar — Two Modes, Don't Merge Them
模式2:侧边栏——两种模式,请勿合并
The sidebar has opposite default states on desktop vs. mobile. Trying to express this with a single CSS class is the most common bug.
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 */ }
}js
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');
});Why two classes: on desktop, "closed" means transformed-out; on mobile, "open" means transformed-in. Same class would have opposite CSS rules per viewport — unmaintainable.
Why not : any ancestor with breaks sticky. fixed is immune. (Bonus: implicitly forces , making the window non-scrollable — see Pattern 9.)
position: fixedstickyoverflow: hiddenoverflow-x: hiddenoverflow-y: auto侧边栏在桌面端和移动端有相反的默认状态。试图用单个CSS类来实现这一点是最常见的bug。
css
:root { --sidebar-w: 280px; }
/* 桌面端:侧边栏默认显示 */
.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) {
/* 移动端:侧边栏默认隐藏 */
.sidebar { transform: translateX(-100%); }
.main { margin-left: 0; }
.app.sidebar-open .sidebar { transform: translateX(0); }
.sidebar-backdrop { /* 仅在sidebar-open时显示的遮罩层 */ }
}js
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');
}
// 当视口尺寸跨越768px时,清除两个类,让新视口的默认状态生效:
mql.addEventListener('change', () => {
document.querySelector('.app').classList.remove('sidebar-open', 'sidebar-closed');
});为什么要用两个类:在桌面端,"closed"表示移出视野;在移动端,"open"表示移入视野。同一个类在不同视口下需要相反的CSS规则,难以维护。
为什么用position: fixed而不是sticky:任何带有的祖先元素都会破坏sticky定位。fixed定位不受此影响。(额外说明:会隐式强制,使窗口无法滚动——详见模式9。)
overflow: hiddenoverflow-x: hiddenoverflow-y: autoPattern 3: ScrollSpy (Auto-highlight Current Section in Sidebar)
模式3:ScrollSpy(侧边栏自动高亮当前章节)
js
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));
}Why : it creates a 10%-tall "active zone" in the upper third of the viewport. The section centred in that zone is considered "current". Pure alone gives jittery results.
rootMargin: '-30% 0px -60% 0px'thresholdjs
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会导致高亮频繁跳动。
Pattern 4: Theme Toggle (Dark / Light)
模式4:主题切换(深色/浅色)
js
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
const s = store.load(); s.theme = theme; store.save(s);
}
applyTheme(store.load().theme); // on loadCSS uses attribute selector on . No fallback unless the user explicitly wants it — the toggle is the source of truth.
[data-theme="dark"]:rootprefers-color-schemejs
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
const s = store.load(); s.theme = theme; store.save(s);
}
applyTheme(store.load().theme); // 页面加载时应用CSS通过上的属性选择器实现主题切换。除非用户明确要求,否则不使用作为回退——切换按钮是主题的唯一来源。
:root[data-theme="dark"]prefers-color-schemePattern 5: Content Zoom (Don't Apply to Sidebar!)
模式5:内容缩放(请勿应用到侧边栏!)
css
:root { --content-zoom: 1; }
.content { zoom: var(--content-zoom); }
/* Sidebar is OUTSIDE .content — never inherits zoom */js
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);
}
}Why never : CSS multiplies across descendants. If is 1.35× and is 1.25×, the actual content is 1.6875×. Restrict zoom to one layer.
html { zoom: X }zoomhtml.contentWhy kill zoom on mobile: phones don't need to upscale, and the desktop's would make text comically large. The only applies if no inline style overrides it — so is necessary, not just setting it back to 1.
1.35@media (max-width: 768px) :root { --content-zoom: 1 }removeProperty()css
:root { --content-zoom: 1; }
.content { zoom: var(--content-zoom); }
/* 侧边栏在.content之外——永远不会继承缩放属性 */js
function applyZoom(value) {
if (mql.matches) {
// 在移动端,移除CSS变量,让@media规则生效
document.documentElement.style.removeProperty('--content-zoom');
} else {
document.documentElement.style.setProperty('--content-zoom', value);
}
}为什么不要用html { zoom: X }:CSS的属性会在后代元素中叠加。如果html缩放1.35倍,.content缩放1.25倍,实际内容会缩放1.6875倍。应将缩放限制在单一层级。
zoom为什么在移动端禁用缩放:手机不需要放大内容,桌面端的1.35倍缩放会让文本变得过大。仅在没有内联样式覆盖时生效——因此必须使用,而不仅仅是将值设回1。
@media (max-width: 768px) :root { --content-zoom: 1 }removeProperty()Pattern 6: Accordion + Fade-in Entrance
模式6:折叠面板+淡入入场动画
js
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));
}css
.fade-in { opacity: 0; transform: translateY(20px); transition: opacity .5s, transform .5s; }
.fade-in.visible { opacity: 1; transform: translateY(0); }Pitfall: if the section uses and IntersectionObserver gets confused about visibility, the element stays invisible forever. Use instead of transitions when zoom is in play (the example workshop hit this and switched the Day hero numbers to keyframes).
zoomopacity: 0@keyframesopacity: 0 → 1js
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));
}css
.fade-in { opacity: 0; transform: translateY(20px); transition: opacity .5s, transform .5s; }
.fade-in.visible { opacity: 1; transform: translateY(0); }注意事项:如果章节使用了,IntersectionObserver可能会误判可见性,导致的元素永远不可见。当使用缩放时,用代替的过渡动画(示例工作坊遇到过这个问题,将每日英雄数字切换为了关键帧动画)。
zoomopacity: 0@keyframesopacity: 0 → 1Pattern 7: Copy-to-Clipboard Buttons
模式7:一键复制按钮
js
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('已複製');
}
}The fallback matters because the SPA might be opened from in some contexts (offline zip delivery) where is gated.
file://navigator.clipboardjs
async function copyPrompt(text, btn) {
try {
await navigator.clipboard.writeText(text);
showToast('已复制');
} catch {
// 非安全上下文的回退方案(如file://协议)
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta);
ta.select(); document.execCommand('copy');
ta.remove();
showToast('已复制');
}
}回退方案很重要,因为SPA可能在某些场景下通过协议打开(如离线zip包分发),此时无法使用。
file://navigator.clipboardPattern 8: Toast (Minimal)
模式8:极简提示弹窗(Toast)
js
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);
}js
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);
}Pattern 9: Quiz with Source-Chapter Back-link
模式9:带章节返回链接的测验
Each quiz item has a field from . After submission, wrong-answer rows include a "去複習 →" link that scrolls to that unit:
sourceUnitcourse-content-authoringjs
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' });
}The N hardcoded places trap: total question count appears in (1) section title (2) lead paragraph (3) score display "— / N" (4) "你答對 N 題" toast (5) passing threshold . When quiz count changes, audit all five. Consider computing from instead — most can be derived.
s >= Kwindow.COURSE.quiz.length每个测验项都包含来自的字段。提交后,答错的题目会显示「去复习 →」链接,点击可滚动到对应章节:
course-content-authoringsourceUnitjs
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
}));
// 渲染时为错题添加返回链接;滚动逻辑:
// document.querySelector(`[data-section-id="${q.sourceUnit}"]`).scrollIntoView({ behavior: 'smooth' });
}硬编码陷阱:题目总数会出现在(1)章节标题(2)引导段落(3)分数显示「— / N」(4)「你答对N题」提示弹窗(5)及格阈值这五个地方。当题目数量变化时,必须检查所有五个位置。建议通过计算总数——大部分内容都可以动态生成。
s >= Kwindow.COURSE.quiz.lengthPattern 10: iframe Modal for Material Preview
模式10:iframe模态框预览素材
Instead of opening materials in a new tab (loses progress context), embed them in a modal:
js
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); }
});
}For PDFs that should download, bypass the modal: render directly so the browser triggers a save dialog. Markdown / HTML / TXT → modal; PDF → download. This branching belongs in 's caller.
<a href="..." download>getMaterialUrl()不要在新标签页打开素材(会丢失进度上下文),而是将其嵌入模态框:
js
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,绕过模态框:直接渲染,让浏览器触发保存对话框。Markdown/HTML/TXT文件用模态框;PDF文件直接下载。这种分支逻辑应放在的调用方中。
<a href="..." download>getMaterialUrl()Pattern 11: Body Scroll Lock When Modal Open
模式11:模态框打开时锁定页面滚动
js
function openViewer(url) { /* ... */ document.body.style.overflow = 'hidden'; }
function closeViewer() { document.body.style.overflow = ''; }Without this, scrolling the modal scrolls the page underneath on macOS Safari.
js
function openViewer(url) { /* ... */ document.body.style.overflow = 'hidden'; }
function closeViewer() { document.body.style.overflow = ''; }如果没有此逻辑,在macOS Safari中滚动模态框时,下方的页面也会跟着滚动。
Verification
验证
Don't bake verification scripts into this skill — they have their own dedicated skill. After wiring any interaction pattern above, invoke to produce the matching verify script (verify-rwd / verify-progress / verify-quiz / verify-modal, etc.). That skill documents the four script roles (verify / capture / diagnose / probe), reusable assertions, and multi-viewport patterns.
web-visual-verificationThe short version: every interaction pattern in this skill has a corresponding verify script. Don't ship interactions without one.
不要将验证脚本集成到此技能中——验证有专门的技能。**在实现上述任意交互模式后,调用**生成对应的验证脚本(verify-rwd / verify-progress / verify-quiz / verify-modal等)。该技能记录了四种脚本角色(验证/捕获/诊断/探测)、可复用断言和多视口模式。
web-visual-verification简而言之:此技能中的每个交互模式都有对应的验证脚本。不要在未添加验证脚本的情况下交付交互功能。
Hand-off
交付说明
Tell the user: "interaction layer complete. Open in browser, click around, then run the verify scripts. Next stage () fills in the artwork — your page probably has missing or placeholder images right now."
web-visual-assets告知用户:「交互层已完成。请在浏览器中打开页面并测试各项功能,然后运行验证脚本。下一阶段()将添加视觉素材——当前页面可能存在缺失或占位图片。」
web-visual-assets