static-spa-interactions

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Static SPA Interactions

静态SPA交互

Schema authority: the
Task
primitive (id-as-localStorage-key rules) and the
QuizItem
primitive (storage of selected option) come from
_shared/domain-primitives.md
§8 and §10. localStorage schema lives in §8.
Reference implementation:
d:/GitHub/ai-workshop/index.html:2900-3030
for
renderSidebar / setupScrollSpy / applyTheme / setupFadeIn
patterns.
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 权威定义
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,花费了不少时间才排查解决。请仔细阅读「设计原因」部分——这并非可选的背景信息。

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 (
-v2
). Old keys remain harmless; new visitors get fresh state. Renaming the key wipes everyone — only do that intentionally.
Why try/catch around JSON.parse: a corrupted entry (e.g. user opened devtools and edited) will crash
init()
and break the whole page.
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
position: fixed
not
sticky
: any ancestor with
overflow: hidden
breaks sticky. fixed is immune. (Bonus:
overflow-x: hidden
implicitly forces
overflow-y: auto
, making the window non-scrollable — see Pattern 9.)
侧边栏在桌面端和移动端有相反的默认状态。试图用单个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:任何带有
overflow: hidden
的祖先元素都会破坏sticky定位。fixed定位不受此影响。(额外说明:
overflow-x: hidden
会隐式强制
overflow-y: auto
,使窗口无法滚动——详见模式9。)

Pattern 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
rootMargin: '-30% 0px -60% 0px'
: it creates a 10%-tall "active zone" in the upper third of the viewport. The section centred in that zone is considered "current". Pure
threshold
alone gives jittery results.
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));
}
为什么用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 load
CSS uses
[data-theme="dark"]
attribute selector on
:root
. No
prefers-color-scheme
fallback unless the user explicitly wants it — the toggle is the source of truth.
js
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-scheme
作为回退——切换按钮是主题的唯一来源。

Pattern 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
html { zoom: X }
: CSS
zoom
multiplies across descendants. If
html
is 1.35× and
.content
is 1.25×, the actual content is 1.6875×. Restrict zoom to one layer.
Why kill zoom on mobile: phones don't need to upscale, and the desktop's
1.35
would make text comically large. The
@media (max-width: 768px) :root { --content-zoom: 1 }
only applies if no inline style overrides it — so
removeProperty()
is necessary, not just setting it back to 1.
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的
zoom
属性会在后代元素中叠加。如果html缩放1.35倍,.content缩放1.25倍,实际内容会缩放1.6875倍。应将缩放限制在单一层级。
为什么在移动端禁用缩放:手机不需要放大内容,桌面端的1.35倍缩放会让文本变得过大。
@media (max-width: 768px) :root { --content-zoom: 1 }
仅在没有内联样式覆盖时生效——因此必须使用
removeProperty()
,而不仅仅是将值设回1。

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
zoom
and IntersectionObserver gets confused about visibility, the
opacity: 0
element stays invisible forever. Use
@keyframes
instead of
opacity: 0 → 1
transitions when zoom is in play (the example workshop hit this and switched the Day hero numbers to keyframes).
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); }
注意事项:如果章节使用了
zoom
,IntersectionObserver可能会误判可见性,导致
opacity: 0
的元素永远不可见。当使用缩放时,用
@keyframes
代替
opacity: 0 → 1
的过渡动画(示例工作坊遇到过这个问题,将每日英雄数字切换为了关键帧动画)。

Pattern 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
file://
in some contexts (offline zip delivery) where
navigator.clipboard
is gated.
js
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可能在某些场景下通过
file://
协议打开(如离线zip包分发),此时
navigator.clipboard
无法使用。

Pattern 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
sourceUnit
field from
course-content-authoring
. After submission, wrong-answer rows include a "去複習 →" link that scrolls to that unit:
js
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
s >= K
. When quiz count changes, audit all five. Consider computing from
window.COURSE.quiz.length
instead — most can be derived.
每个测验项都包含来自
course-content-authoring
sourceUnit
字段。提交后,答错的题目会显示「去复习 →」链接,点击可滚动到对应章节:
js
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 >= K
这五个地方。当题目数量变化时,必须检查所有五个位置。建议通过
window.COURSE.quiz.length
计算总数——大部分内容都可以动态生成。

Pattern 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
<a href="..." download>
directly so the browser triggers a save dialog. Markdown / HTML / TXT → modal; PDF → download. This branching belongs in
getMaterialUrl()
's caller.
不要在新标签页打开素材(会丢失进度上下文),而是将其嵌入模态框:
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,绕过模态框:直接渲染
<a href="..." download>
,让浏览器触发保存对话框。Markdown/HTML/TXT文件用模态框;PDF文件直接下载。这种分支逻辑应放在
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
web-visual-verification
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.
The short version: every interaction pattern in this skill has a corresponding verify script. Don't ship interactions without one.
不要将验证脚本集成到此技能中——验证有专门的技能。**在实现上述任意交互模式后,调用
web-visual-verification
**生成对应的验证脚本(verify-rwd / verify-progress / verify-quiz / verify-modal等)。该技能记录了四种脚本角色(验证/捕获/诊断/探测)、可复用断言和多视口模式。
简而言之:此技能中的每个交互模式都有对应的验证脚本。不要在未添加验证脚本的情况下交付交互功能。

Hand-off

交付说明

Tell the user: "interaction layer complete. Open in browser, click around, then run the verify scripts. Next stage (
web-visual-assets
) fills in the artwork — your page probably has missing or placeholder images right now."
告知用户:「交互层已完成。请在浏览器中打开页面并测试各项功能,然后运行验证脚本。下一阶段(
web-visual-assets
)将添加视觉素材——当前页面可能存在缺失或占位图片。」