
Static Spa Conversion
- 46 installs
- 47 repo stars
- Updated May 15, 2026
- kevintsai1202/teaching-site-skills
Helps with ai & agent building tasks.
About
static-spa-conversion is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- static-spa-conversion
- AI & Agent Building
- AI-coding skill
Static Spa Conversion by the numbers
- 46 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #7,568 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kevintsai1202/teaching-site-skills --skill static-spa-conversionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 46 |
|---|---|
| repo stars | ★ 47 |
| Last updated | May 15, 2026 |
| Repository | kevintsai1202/teaching-site-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Static SPA Conversion
Schema authority: all window.COURSE field names come from `_shared/domain-primitives.md`. When the schema example below diverges from that file, that file wins.>
Starter templates (copy these, don't re-derive):
>
- `templates/index.html` — scaffold shell with render pipeline skeleton
- `templates/course-data.template.js` — empty schema example with TODO markers for every primitive
- `../teaching-site-design-system/templates/tokens.css` — paste into<style>block, or copy asstyle.cssnext toindex.html
>
Reference implementation: d:/GitHub/ai-workshop/index.html (4125 lines, fully populated). Use as visual + render-pattern reference; do not copy course-specific content from it.>
Filename convention (English-first):course-package/,day{n}/outline.md,day{n}/content.md,materials/,overview.md. Legacy Chinese names (完整課程包/,課程大綱.md,教學素材/, etc.) are deprecated — see_shared/domain-primitives.md§0 for full mapping.
This skill turns chapter-based markdown content into a working single-page app. The architectural commitments are deliberate and worth understanding before you start.
The Three Architectural Commitments
1. Vanilla HTML + JS, no framework, no bundler. No React, no Vite, no TypeScript build. Why: course websites have multi-year lifespans and are often handed to non-developer instructors. A .html you can open and edit beats a node_modules graveyard every time. Also: the content is the value, not the tech.
2. Data and rendering live in different files. A course-data.js holds everything the course is as a single JS object (window.COURSE). An index.html holds how the course is shown. Instructors editing tomorrow's lesson never need to touch HTML.
3. localStorage for persistence, no backend. Progress, theme, zoom — all client-side. Easier hosting, no privacy headaches. The cost: state is per-browser. Accept it.
Standard File Layout
project-root/
├── index.html ← the single SPA (CSS + JS inline; ~3000 lines is normal)
├── course-data.js ← window.COURSE = { ... }
├── instructor-data.js ← optional: window.INSTRUCTOR (if scraped/external)
├── tools-data.js ← optional: window.TOOLS (if showcasing tools)
├── package.json ← just `{"scripts": {"serve": "npx serve ."}}` and scraping tooling
├── assets/ ← images, illustrations
└── course-package/ ← markdown source (read-only from SPA's perspective)
└── materials/ ← files linked from the SPA via getMaterialUrl()index.html includes data files via plain <script src="course-data.js"></script> — no module imports, no fetch. The browser loads them in order, populating window.COURSE before init() runs.
The window.COURSE Schema
The single most important artifact. Design it carefully — every render function depends on it. Standard shape:
window.COURSE = {
meta: { // top-level course metadata
title: '...',
audience: '...',
hoursTotal: 24,
days: [ // index drives sidebar chapter list
{ id: 'day1', title: 'Day 1: ...', hours: 6, schedule: '...' },
...
],
classroom: { name: '...', mapUrl: '...' }
},
sharedCase: { /* persistent fictional scenario */ },
day1: {
hero: { title, lead, illustration },
units: [
{
id: 'u-1', // stable, never renamed
title: '...',
timeRange: '14:00–14:50',
goals: [...], // learning outcomes
tasks: [ // ← these IDs are localStorage keys
{ id: 'd1-u1-t1', text: '...', done: false }
],
prompts: [ // optional: copyable templates
{ id: '...', title: '...', body: '...' }
],
materials: [ // optional: per-unit material refs
{ id: '...', name: '...', type: 'PDF 文件' }
],
illustrations: [ // 1–3 entries, see Stage 5 Coverage Floor
{ name: 'day1-u1-hero.png', kind: 'hero', alt: '...', spec: '...' },
{ name: 'day1-u1-flow.svg', kind: 'diagram', alt: '...', spec: '...' }
]
// legacy single `illustration: 'day1-u1.png'` still accepted; treat as
// illustrations: [{ name, kind: 'hero' }] and migrate when convenient
},
...
]
},
day2: { /* same shape */ },
...
materials: [ // cross-day material index for the "all materials" tab
{ id: 'm-1', name: '...', type: '...', desc: '...' }
],
quiz: [ // optional: end-of-course assessment
{ id: 'q1', question: '...', options: [...], answer: 0, sourceUnit: 'day1.u-2' }
]
};The Render Pipeline
index.html is one big <script> at the bottom. Its shape:
// 1. Utilities (top, ~50 lines)
const el = (tag, attrs, children) => { /* tiny DOM helper */ };
const store = {
load() { /* read localStorage */ },
save(state) { /* write localStorage */ },
reset() { /* clear */ }
};
// 2. Render functions, each takes data → returns/appends DOM
function renderOverview(meta) { ... }
function renderSharedCase(case_) { ... }
function renderDay(day) { ... }
function renderUnit(unit, dayId) { ... }
function renderMaterials(materials) { ... }
function renderToolbox(tools) { ... } // optional
function renderQuiz(quiz) { ... } // optional
// 3. Sidebar + scrollspy + theme — see static-spa-interactions
// 4. Entry point
function init() {
renderOverview(window.COURSE.meta);
renderSharedCase(window.COURSE.sharedCase);
for (const dayKey of ['day1', 'day2', 'day3', 'day4']) {
renderDay(window.COURSE[dayKey]);
}
renderMaterials(window.COURSE.materials);
renderQuiz(window.COURSE.quiz);
// ... then interactivity wiring (see static-spa-interactions)
}
document.addEventListener('DOMContentLoaded', init);Keep each render function pure: it receives data, it produces DOM. No reading from globals beyond its parameter. This makes adding a new section trivial.
The Material URL Router (getMaterialUrl)
The trickiest piece. The SPA needs to map a material's name and type to a real URL on disk. The router lives near the top of the script section:
function getMaterialUrl(name, type) {
const base = 'course-package/materials';
if (type === 'PDF 文件') {
// PDFs trigger download instead of inline view
if (name.includes('員工差勤')) return `${base}/pdf/員工差勤辦法.pdf`;
if (name.includes('客訴SOP')) return `${base}/pdf/客訴處理SOP.pdf`;
// ... one rule per PDF
return null; // unknown PDF → caller hides the link
}
// Other types open in new tab
if (name.includes('FAQ')) return `${base}/FAQ官方版.md`;
if (name.includes('差勤')) return `${base}/員工差勤辦法.md`;
// ...
}The three-place sync rule — when adding a material, update all of: 1. Drop the file into course-package/materials/ 2. Add an entry to course-data.js:materials[] (and any unit's materials[]) 3. Add the name.includes(...) rule to getMaterialUrl()
Forgetting any one breaks the link. Consider adding a CI check that diffs filesystem vs. data vs. router.
The Task ID Lifecycle
// Save
function toggleTask(taskId) {
const state = store.load();
state.tasks[taskId] = !state.tasks[taskId];
store.save(state);
// re-render checkbox only, not whole page
}
// Load on render
function renderTask(task) {
const state = store.load();
const checked = !!state.tasks[task.id];
return el('label', {}, [
el('input', { type: 'checkbox', checked, onchange: () => toggleTask(task.id) }),
task.text
]);
}Never change `task.id` once published. The localStorage key tasks[task.id] is what survives across visits. Renaming wipes progress for every student.
Local Development
# Required: file:// blocks localStorage in Chrome — always serve via HTTP
npx serve .
# → http://localhost:3000Add this to package.json:
{
"scripts": {
"serve": "npx serve ."
}
}Mention this prominently in any README. Double-clicking index.html produces a confusing "my progress doesn't save" bug that wastes hours.
Migration: Markdown → course-data.js
When porting an existing .md outline + content to course-data.js:
1. Parse the outline first to get the day/unit skeleton. 2. For each unit's content .md, extract tasks, prompts, materials references using grep-style passes (look for the marker patterns from course-content-authoring). 3. Generate course-data.js as one big object literal. Don't try to make it pretty programmatically — let prettier/eslint format it, or just leave it as-is. 4. Manually review: every unit ID in markdown should exist in course-data.js; the day count and hours should match meta.
When This Skill Hands Off
Tell the user:
- "SPA scaffold is live. Run
npm run serveand open http://localhost:3000." - "Next: invoke
static-spa-interactionsfor progress persistence, sidebar, theme, RWD." - "Don't rename
course-data.jspaths or task IDs from this point — students' localStorage depends on them."
// course-data.template.js
//
// Empty starter for window.COURSE — fill every TODO marker with real values.
//
// Schema authority: ../../_shared/domain-primitives.md
// Reference impl: d:/GitHub/ai-workshop/course-data.js (full populated example)
//
// Rules of thumb:
// - Every Day key (day1, day2, ...) MUST match meta.days[].id
// - Every unit.id is unique within its day, never renamed once published
// - Every task.id is a localStorage key — never rename, never reuse
// - Every unit MUST have illustrations[] with 1–3 entries (Stage 5 Coverage Floor)
// - Quiz items are append-only — renumbering forces 5+ hardcoded string updates
window.COURSE = {
// ============================================================
// meta — course-wide metadata
// ============================================================
meta: {
title: 'TODO: Course title',
subtitle: 'TODO: Optional subtitle',
program: 'TODO: Sponsoring program (or empty string)',
organizer: 'TODO: Organizer name',
dates: 'TODO: Human-readable date list, e.g. "5/13, 5/20, 5/27, 6/3 (every Wed 09:00–16:30)"',
location: 'TODO: Venue + room',
mapUrl: 'TODO: Google Maps URL or empty string',
format: 'TODO: e.g. "in-person + online" or "hybrid 30h (4h self-study)"',
instructor: 'TODO: Instructor name',
completion: [
'TODO: Attendance ≥ 80%',
'TODO: Pre-test + post-test completed'
],
objectives: [
'TODO: Use action verbs — "Use AI to ...", "Build ...", "Evaluate ..."',
'TODO: 3–5 outcomes total',
],
days: [
// id MUST match window.COURSE[id] keys below
{ id: 'day1', n: 1, date: 'TODO M/D', title: 'TODO Day 1 theme', hours: 6 },
{ id: 'day2', n: 2, date: 'TODO M/D', title: 'TODO Day 2 theme', hours: 6 },
// add more days as needed
]
},
// ============================================================
// sharedCase — recurring fictional context (optional)
// ============================================================
// Set to null if course has no shared scenario; then add an explicit
// note to course-package/overview.md: '本課無共用案例,每單元獨立舉例'
sharedCase: {
intro: 'TODO: 1-paragraph summary of the persistent scenario across all days.',
brands: [
{
id: 'A',
name: 'TODO: Brand A name',
type: 'TODO: industry / category',
rows: [
['業態 / Industry', 'TODO'],
['地點 / Location', 'TODO'],
['員工 / Headcount', 'TODO'],
['主要客群 / Target', 'TODO'],
['行政痛點 / Pain points', 'TODO']
]
}
// add Brand B if the course teaches comparison across two contexts
],
roles: [
// [name, brand, role, description]
['TODO: 角色名', 'TODO: 品牌', 'TODO: 職位', 'TODO: 在課程中扮演什麼']
],
variables: [
// [token, brandA_value, brandB_value]
['{店名}', 'TODO', 'TODO']
],
deliverables: [
// [day_label, output_description]
['Day 1', 'TODO: e.g. 個人提示詞庫']
]
},
// ============================================================
// day1 — first teaching day
// ============================================================
day1: {
id: 'day1',
title: 'TODO: Day 1 — theme',
date: 'TODO: M/D',
hours: '6 hours',
learningGoal: 'TODO: One-paragraph day-level outcome.',
schedule: [
// [time_range, segment_title, focus]
['09:00 ~ 10:00', 'TODO: Opening', 'TODO: Course intro + icebreaker'],
['10:00 ~ 12:00', 'TODO: Unit 1', 'TODO: Topic'],
['12:00 ~ 13:00', 'Lunch', '(self-arranged)'],
['13:00 ~ 15:30', 'TODO: Unit 2', 'TODO: Topic'],
['15:30 ~ 16:30', 'TODO: Unit 3', 'TODO: Topic']
],
units: [
{
id: 'u1', // unique within day; never rename
title: 'TODO: Unit 1 — title',
time: '10:00 ~ 12:00',
goals: [
'TODO: Action-verb outcome 1',
'TODO: Action-verb outcome 2',
'TODO: 3–6 goals total'
],
concepts: [
{
heading: 'TODO: Concept heading (plain text, no markdown)',
body: 'TODO: Narrative paragraph supporting **bold** and [link](url).',
illustration: 'day1-u1-concept-a', // optional: PNG-first, SVG fallback
list: [
// [key, value] pairs (definition-style)
['TODO key', 'TODO value']
],
// OR table for matrix comparisons
table: {
head: ['TODO col 1', 'TODO col 2', 'TODO col 3'],
rows: [
['TODO', 'TODO', 'TODO']
]
},
note: 'TODO: Optional callout / footnote'
}
],
prompts: [
{
id: 'd1-p1', // d{N}-p{M} format
title: 'TODO: Prompt title',
note: 'TODO: Optional one-line context',
text: `TODO: Full RTFC prompt text.
角色 (Role): ...
任務 (Task): ...
格式 (Format): ...
限制 (Constraint): ...`
}
],
tasks: [
// id is a localStorage key — never rename, never reuse
{ id: 'd1-u1-t1', label: 'TODO: First task description' },
{ id: 'd1-u1-t2', label: 'TODO: Second task' }
],
materials: [
// type: 'PDF 文件' | 'TEXT' | 'CSV' | 'YAML' | 'MD' | '音檔'
{ id: 'd1-m1', name: 'TODO: Material display name', type: 'TEXT', desc: 'TODO: One-line context' }
],
illustrations: [
// 1–3 entries — Stage 5 Coverage Floor
{ name: 'day1-u1-hero', kind: 'hero', alt: 'TODO: alt text', spec: 'TODO: hero scene description (AI-gen)' },
{ name: 'day1-u1-flow', kind: 'diagram', alt: 'TODO: alt text', spec: 'TODO: process diagram (hand-drawn SVG)' }
// OR explicit waiver: { kind: 'waived', reason: 'TODO: why no image needed' }
],
faq: [
// optional: [question, answer] pairs
['TODO: 學員常問的問題', 'TODO: 解答要點']
]
}
// add more units as the day's schedule requires
]
},
// ============================================================
// day2, day3, ... — same shape as day1
// ============================================================
// day2: { id: 'day2', ... },
// ============================================================
// materials — cross-day material index
// ============================================================
// Aggregates ALL unit.materials[] across days for the "下載檔案總覽" section.
// Same Material schema; same id namespace (d{N}-m{M}); MUST appear in
// getMaterialUrl() router in index.html (3-place sync rule).
materials: [
{ id: 'd1-m1', name: 'TODO: Material display name', type: 'TEXT', desc: 'TODO: One-line context' }
],
// ============================================================
// quiz — optional end-of-course assessment
// ============================================================
// Append-only — deleting items forces 5+ hardcoded string updates.
// Renderer maps q{N} → source day via qIndexToDay() in index.html.
quiz: [
{
id: 'q1',
type: 'single',
q: 'TODO: Question 1?',
options: [
'TODO: Option 0 (wrong)',
'TODO: Option 1 (correct)',
'TODO: Option 2 (wrong)'
],
answer: 1 // 0-indexed correct option
}
// add more quiz items as needed
]
};
<!doctype html>
<!--
index.html - Teaching Site Scaffold
Schema authority: ../../_shared/domain-primitives.md
Token reference: ../../teaching-site-design-system/templates/tokens.css
Reference impl: d:/GitHub/ai-workshop/index.html (4125-line full version)
This scaffold gives you the structural shell + working render pipeline.
Fill TODO markers and extend. For advanced features (scrollspy, theme toggle,
zoom, sidebar mobile overlay, scroll-fade), copy patterns from ai-workshop.
-->
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TODO: Course Title</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@600;700;800;900&display=swap" rel="stylesheet">
<!-- Paste tokens.css contents here, OR keep as external link -->
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app" id="app">
<!-- ===== sidebar (fixed left, navigation + progress) ===== -->
<aside class="sidebar">
<div class="brand">
<div class="brand-eyebrow">TODO: Program / Sponsor</div>
<div class="brand-title">TODO: Course Title</div>
</div>
<nav class="nav" id="nav"><!-- populated by renderSidebar() --></nav>
<div class="nav-progress" id="navProgress"><!-- populated by updateProgressUI() --></div>
</aside>
<!-- ===== main content area ===== -->
<main class="main">
<header class="topbar">
<div class="topbar-trail" id="topbarTrail"><!-- populated by scrollspy --></div>
<div class="topbar-spacer"></div>
<div class="topbar-quick">
<button class="theme-toggle" id="themeToggle" type="button" aria-label="切換主題">switch theme</button>
</div>
</header>
<div class="content" id="content">
<!-- All sections rendered here by init() -->
</div>
</main>
</div>
<!-- ===== data load order matters: course-data.js MUST load before render ===== -->
<script src="course-data.js"></script>
<!-- Optional secondary data (uncomment if you have these scraped sources): -->
<!-- <script src="instructor-data.js"></script> -->
<!-- <script src="tools-data.js"></script> -->
<script>
/* ============================================================
* Storage / state
* ============================================================ */
const STORE_KEY = 'TODO-project-prefix-progress-v1'; // never reuse old keys
const store = {
load() {
try { return JSON.parse(localStorage.getItem(STORE_KEY)) || { tasks: {}, quiz: {}, lastSection: '' }; }
catch { return { tasks: {}, quiz: {}, lastSection: '' }; }
},
save(s) { localStorage.setItem(STORE_KEY, JSON.stringify(s)); },
reset() { localStorage.removeItem(STORE_KEY); }
};
let state = store.load();
/* ============================================================
* Tiny DOM helper (no framework, no innerHTML for user content)
* ============================================================ */
const el = (tag, attrs = {}, ...children) => {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') node.className = v;
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v);
else if (v !== false && v != null) node.setAttribute(k, v);
}
for (const c of children.flat()) {
if (c == null || c === false) continue;
node.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
return node;
};
const clear = (node) => { while (node.firstChild) node.removeChild(node.firstChild); };
/* ============================================================
* Inline markdown (safe — no innerHTML for user content)
* Supports: **bold**, [text](url) — nothing else
* Reference: ai-workshop/index.html:2553 (full XSS-safe implementation)
* ============================================================ */
function inlineMarkdown(str) {
const frag = document.createDocumentFragment();
// Stub: just plain text. Replace with bold + link parser per reference impl.
frag.append(document.createTextNode(String(str)));
return frag;
}
/* ============================================================
* Render: concept block (the unit's substance)
* ============================================================ */
function renderConcept(c) {
const w = el('div', { class: 'concept' });
if (c.heading) w.append(el('div', { class: 'concept-heading' }, c.heading));
if (c.body) w.append(el('div', { class: 'concept-body' }, inlineMarkdown(c.body)));
if (c.illustration) w.append(renderIllustration(c.illustration));
if (c.list && c.list.length) {
const kv = el('div', { class: 'kv' });
c.list.forEach(([k, v]) => {
kv.append(el('div', { class: 'kv-key' }, inlineMarkdown(k)));
kv.append(el('div', { class: 'kv-val' }, inlineMarkdown(v)));
});
w.append(kv);
}
if (c.table) {
const t = el('table', { class: 't' });
const thead = el('thead');
thead.append(el('tr', {}, ...c.table.head.map(h => el('th', {}, inlineMarkdown(h)))));
t.append(thead);
const tb = el('tbody');
c.table.rows.forEach(r => tb.append(el('tr', {}, ...r.map(cell => el('td', {}, inlineMarkdown(cell))))));
t.append(tb);
w.append(t);
}
if (c.note) w.append(el('div', { class: 'note' }, inlineMarkdown(c.note)));
return w;
}
/* ============================================================
* Render: prompt card with copy-to-clipboard
* ============================================================ */
function renderPromptCard(p) {
const w = el('div', { class: 'prompt-card', id: p.id });
const head = el('div', { class: 'prompt-card-head' });
const left = el('div', {});
left.append(el('div', { class: 'prompt-card-title' }, p.title));
if (p.note) left.append(el('div', { class: 'prompt-card-note' }, p.note));
head.append(left);
const btn = el('button', { class: 'prompt-copy-btn', type: 'button' }, 'Copy');
btn.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(p.text); }
catch { /* fallback: textarea + execCommand */ }
btn.classList.add('ok');
btn.textContent = 'Copied';
setTimeout(() => { btn.classList.remove('ok'); btn.textContent = 'Copy'; }, 1800);
});
head.append(btn);
w.append(head);
w.append(el('pre', { class: 'prompt-text' }, p.text));
return w;
}
/* ============================================================
* Render: task list (id is localStorage key — never rename)
* ============================================================ */
function renderTaskList(tasks) {
const list = el('ul', { class: 'task-list' });
tasks.forEach(t => {
const li = el('li', {
class: 'task-item' + (state.tasks[t.id] ? ' done' : ''),
'data-task-id': t.id
});
li.append(el('div', { class: 'task-checkbox' }, '✓'));
li.append(el('div', { class: 'task-label' }, t.label));
li.addEventListener('click', () => toggleTask(t.id, li));
list.append(li);
});
return list;
}
function toggleTask(id, li) {
if (state.tasks[id]) delete state.tasks[id];
else state.tasks[id] = true;
li.classList.toggle('done');
store.save(state);
updateProgressUI();
}
/* ============================================================
* Render: material list (3-place sync — see getMaterialUrl below)
* ============================================================ */
function renderMaterialList(items) {
const w = el('div', { class: 'materials' });
items.forEach(m => {
const row = el('div', { class: 'material-row' });
row.append(el('div', { class: 'material-tag' }, m.type));
const center = el('div', {});
const url = getMaterialUrl(m.name, m.type);
if (url) {
const link = el('a', { class: 'material-name', href: url, target: '_blank', rel: 'noopener' }, m.name);
if (m.type === 'PDF 文件') link.setAttribute('download', '');
center.append(link);
} else {
center.append(el('div', { class: 'material-name' }, m.name));
}
if (m.desc) center.append(el('div', { class: 'material-desc' }, m.desc));
row.append(center);
row.append(el('div', { class: 'material-meta' }, 'course-package/materials/'));
w.append(row);
});
return w;
}
/* ============================================================
* Material URL router — TODO: add one rule per material
* 3-place sync: this router + course-data.js:materials[] + actual file on disk
* ============================================================ */
function getMaterialUrl(name, type) {
const base = 'course-package/materials';
if (type === 'PDF 文件') {
// TODO: if (name.includes('keyword')) return `${base}/pdf/file.pdf`;
return null;
}
// Other types open in viewer.html (or new tab as fallback)
// TODO: if (name.includes('keyword')) return `${base}/file.md`;
return null;
}
/* ============================================================
* Render: illustration (PNG-first, SVG fallback)
* ============================================================ */
function renderIllustration(name) {
const wrap = el('div', { class: 'illustration' });
const img = el('img', { src: `assets/illustrations/${name}.png`, alt: '' });
img.onerror = () => { img.src = `assets/illustrations/${name}.svg`; img.onerror = null; };
wrap.append(img);
return wrap;
}
/* ============================================================
* Render: unit accordion (fixed render order — see _shared/domain-primitives.md §5)
* ============================================================ */
function renderUnit(day, u) {
const det = el('details', { class: 'unit', id: `${day.id}-${u.id}` });
if (day.id === 'day1' && u.id === 'u1') det.open = true; // open first by default
const sum = el('summary', {});
const titleWrap = el('div', {});
titleWrap.append(el('div', { class: 'unit-title' }, u.title));
if (u.subtitle) titleWrap.append(el('div', { class: 'unit-meta' }, u.subtitle));
titleWrap.append(el('div', { class: 'unit-meta' }, u.time));
sum.append(titleWrap);
sum.append(el('span', { class: 'unit-progress-pill' }, '0 / 0'));
det.append(sum);
const body = el('div', { class: 'unit-body' });
if (u.goals?.length) {
body.append(el('h4', {}, '學習目標'));
const ul = el('ul', { class: 'list' });
u.goals.forEach(g => ul.append(el('li', {}, g)));
body.append(ul);
}
if (u.illustrations?.length) {
// Render hero illustration first (above the fold within the unit)
const hero = u.illustrations.find(i => i.kind === 'hero');
if (hero) body.append(renderIllustration(hero.name));
}
if (u.concepts?.length) {
body.append(el('h4', {}, '核心觀念'));
u.concepts.forEach(c => body.append(renderConcept(c)));
}
if (u.prompts?.length) {
body.append(el('h4', {}, 'RTFC 提示詞範例(一鍵複製)'));
u.prompts.forEach(p => body.append(renderPromptCard(p)));
}
if (u.tasks?.length) {
body.append(el('h4', {}, '任務清單'));
body.append(renderTaskList(u.tasks));
}
if (u.materials?.length) {
body.append(el('h4', {}, '本單元教學素材'));
body.append(renderMaterialList(u.materials));
}
if (u.faq?.length) {
body.append(el('h4', {}, '常見學員疑問'));
const t = el('table', { class: 't' });
const thead = el('thead');
const trh = el('tr');
trh.append(el('th', {}, '疑問'), el('th', {}, '解答要點'));
thead.append(trh);
t.append(thead);
const tb = el('tbody');
u.faq.forEach(([q, a]) => {
const tr = el('tr');
tr.append(el('td', {}, q), el('td', {}, a));
tb.append(tr);
});
t.append(tb);
body.append(t);
}
det.append(body);
return det;
}
/* ============================================================
* Render: day section (hero + units)
* ============================================================ */
function renderDay(dayId) {
const d = window.COURSE[dayId];
if (!d) return null;
const root = el('section', { class: 'chapter', id: dayId });
// Day hero with giant numeral (signature component)
const hero = el('div', { class: 'day-hero' });
const meta = window.COURSE.meta.days.find(x => x.id === dayId);
hero.append(el('div', { class: 'day-hero-numeral' }, `D${meta?.n || ''}`));
const heroMeta = el('div', { class: 'day-hero-meta' });
heroMeta.append(el('div', { class: 'eyebrow' }, d.date || ''));
heroMeta.append(el('h2', {}, d.title));
if (d.learningGoal) heroMeta.append(el('div', { class: 'lead' }, d.learningGoal));
hero.append(heroMeta);
root.append(hero);
// Units
d.units?.forEach(u => root.append(renderUnit(d, u)));
return root;
}
/* ============================================================
* Render: overview (course meta) — TODO: expand per project
* ============================================================ */
function renderOverview() {
const root = el('section', { class: 'chapter', id: 'overview' });
root.append(el('span', { class: 'eyebrow' }, window.COURSE.meta.program || ''));
root.append(el('h1', {}, window.COURSE.meta.title));
if (window.COURSE.meta.subtitle) root.append(el('div', { class: 'lead' }, window.COURSE.meta.subtitle));
return root;
}
/* ============================================================
* Render: shared case (optional)
* ============================================================ */
function renderSharedCase() {
const sc = window.COURSE.sharedCase;
if (!sc) return null;
const root = el('section', { class: 'chapter', id: 'shared-case' });
root.append(el('span', { class: 'eyebrow' }, '共用案例'));
root.append(el('h2', {}, '貫穿全程的虛構情境'));
root.append(el('div', { class: 'lead' }, sc.intro));
// TODO: render brands / roles / variables tables
return root;
}
/* ============================================================
* Render: cross-day material overview
* ============================================================ */
function renderMaterials() {
const items = window.COURSE.materials || [];
if (!items.length) return null;
const root = el('section', { class: 'chapter', id: 'materials-overview' });
root.append(el('span', { class: 'eyebrow' }, '下載檔案總覽'));
root.append(el('h2', {}, '教學素材'));
root.append(renderMaterialList(items));
return root;
}
/* ============================================================
* Render: quiz (optional)
* ============================================================ */
function renderQuiz() {
const items = window.COURSE.quiz || [];
if (!items.length) return null;
const root = el('section', { class: 'chapter', id: 'quiz' });
root.append(el('span', { class: 'eyebrow' }, '結訓測驗'));
root.append(el('h2', {}, `結訓測驗(${items.length} 題)`));
// TODO: render questions + submit + score logic. See ai-workshop/index.html:3767
return root;
}
/* ============================================================
* Sidebar render + scroll spy + theme + progress
* ============================================================ */
function renderSidebar() {
const nav = document.getElementById('nav');
clear(nav);
// TODO: build chapter list from window.COURSE.meta.days[]. See ai-workshop:3959
}
function updateProgressUI() {
// TODO: count completed tasks / total tasks. See ai-workshop pattern.
}
function initTheme() {
const t = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
document.getElementById('themeToggle').addEventListener('click', () => {
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
});
}
/* ============================================================
* init() — entry point
* ============================================================ */
function init() {
const content = document.getElementById('content');
const sections = [
renderOverview(),
renderSharedCase(),
...window.COURSE.meta.days.map(d => renderDay(d.id)),
renderMaterials(),
renderQuiz()
].filter(Boolean);
sections.forEach(s => content.append(s));
renderSidebar();
updateProgressUI();
initTheme();
// TODO: setupScrollSpy() — see ai-workshop:setupScrollSpy
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>