Static Spa Interactions logo

Static Spa Interactions

Community
kevintsai1202
static-spa-interactions

Use this skill when a vanilla static SPA scaffold exists and needs to feel like a polished product — progress persistence, sidebar + scrollspy navigation, dark/light theme, content zoom, responsive (mobile sidebar overlay), keyboard nav, fade-in entrances, toast notifications, iframe modal viewers, quiz scoring with section back-links. Triggers on phrases like "加進度勾選", "響應式", "暗色模式", "縮放", "scrollspy", "sidebar 收合", "手機版", "RWD", "dark mode", "progress tracking", "quiz UX", "interactive polish". Always invoke AFTER `static-spa-conversion` (renders working), as a standalone enhancement layer.

Overview

Publisherkevintsai1202
Repositoryteaching-site-skills
Skill namestatic-spa-interactions
Stars
52
Forks
10
Bundled files
Instructions only
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by kevintsai1202 on GitHub. Read the source before you install it.

Installation

Install the Static Spa Interactions AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/kevintsai1202/teaching-site-skills/tree/main/static-spa-interactions
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/kevintsai1202/teaching-site-skills.git /tmp/teaching-site-skills
mkdir -p .claude/skills
cp -r /tmp/teaching-site-skills/static-spa-interactions .claude/skills/static-spa-interactions
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Static Spa Interactions in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Static Spa Interactions on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Static Spa Interactions is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Static SPA Interactions

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.

Pattern 1: localStorage Progress (Task Checkboxes + Quiz Answers)

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.

Pattern 2: Sidebar — Two Modes, Don't Merge Them

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.)

Pattern 3: ScrollSpy (Auto-highlight Current Section in Sidebar)

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.

Pattern 4: Theme Toggle (Dark / Light)

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.

Pattern 5: Content Zoom (Don't Apply to Sidebar!)

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.

Pattern 6: Accordion + Fade-in Entrance

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).

Pattern 7: Copy-to-Clipboard Buttons

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.

Pattern 8: Toast (Minimal)

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

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.

Pattern 10: iframe Modal for Material Preview

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.

Pattern 11: Body Scroll Lock When Modal Open

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.

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.

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."

Frequently asked questions

What does the Static Spa Interactions AI skill do?

Use this skill when a vanilla static SPA scaffold exists and needs to feel like a polished product — progress persistence, sidebar + scrollspy navigation, dark/light theme, content zoom, responsive (mobile sidebar overlay), keyboard nav, fade-in entrances, toast notifications, iframe modal viewers, quiz scoring with section back-links. Triggers on phrases like "加進度勾選", "響應式", "暗色模式", "縮放", "scrollspy", "sidebar 收合", "手機版", "RWD", "dark mode", "progress tracking", "quiz UX", "interactive polish". Always invoke AFTER `static-spa-conversion` (renders working), as a standalone enhancement layer.

Why use Static Spa Interactions on TypingMind?

Because you install it once and use it with any model. Static Spa Interactions is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Static Spa Interactions in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/kevintsai1202/teaching-site-skills/tree/main/static-spa-interactions. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Static Spa Interactions?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Static Spa Interactions?

As many as you like. As long as a model supports skills, you can use Static Spa Interactions with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Static Spa Interactions AI skill free?

Yes. It is published on GitHub by kevintsai1202 under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇