Complete Front-End Design Document — serard.dev
Context
Exhaustive document of the front-end architecture of the serard.dev site: an interactive CV/portfolio with a terminal aesthetic, built as a progressive static SPA (works without JS, enhanced with JS). Covers: architecture, CSS, DOM events, state machines, animations, accessibility, and cross-machine interactions.
PART A — ARCHITECTURE & FOUNDATIONS
A1. Tech Stack & Files
Build pipeline
- Source: Markdown + YAML frontmatter → Node.js scripts → static HTML
- CSS:
css/style.css(~2450 lines) +css/github-dark.min.css→public/app.min.css(CleanCSS) - JS: 7 modules →
public/app.min.js(esbuild + terser, versioned?v=timestamp) - Deployment: local build → commit → push main → Vercel serves static files
- Libraries: marked.js (markdown), highlight.js (syntax), mermaid.js (diagrams)
Build pipeline — summary
┌─────────────────────────────────────────────────────────────┐
│ DEVELOPER MACHINE │
│ │
│ content/**/*.md ──┐ │
│ css/style.css ────┤ node scripts/workflow.js │
│ js/*.js ──────────┤ ├─ build-toc.js → toc.json │
│ ├──→├─ build-static.js → public/*.html │
│ │ ├─ CleanCSS → app.min.css │
│ │ ├─ esbuild+terser → app.min.js │
│ │ └─ mermaid SVGs → mermaid-svg/* │
│ │ │
│ └──→ public/ (complete static site) │
│ │ │
│ git push main │
└─────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ GITHUB → main branch │
└─────────────────────┬───────────────────────────────────────┘
│ webhook
▼
┌─────────────────────────────────────────────────────────────┐
│ VERCEL │
│ ├─ Deploys static files (no build step, "build": "exit 0")│
│ ├─ Edge CDN (global) │
│ ├─ Cache: CSS/JS immutable 1y, HTML 1h+SWR, images 1y │
│ └─ SSL/TLS automatic │
└─────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ NAMECHEAP DNS │
│ serard.dev CNAME → cname.vercel-dns.com │
│ (Vercel manages SSL certificate for custom domain) │
└─────────────────────────────────────────────────────────────┘
│
▼
🌐 https://serard.dev┌─────────────────────────────────────────────────────────────┐
│ DEVELOPER MACHINE │
│ │
│ content/**/*.md ──┐ │
│ css/style.css ────┤ node scripts/workflow.js │
│ js/*.js ──────────┤ ├─ build-toc.js → toc.json │
│ ├──→├─ build-static.js → public/*.html │
│ │ ├─ CleanCSS → app.min.css │
│ │ ├─ esbuild+terser → app.min.js │
│ │ └─ mermaid SVGs → mermaid-svg/* │
│ │ │
│ └──→ public/ (complete static site) │
│ │ │
│ git push main │
└─────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ GITHUB → main branch │
└─────────────────────┬───────────────────────────────────────┘
│ webhook
▼
┌─────────────────────────────────────────────────────────────┐
│ VERCEL │
│ ├─ Deploys static files (no build step, "build": "exit 0")│
│ ├─ Edge CDN (global) │
│ ├─ Cache: CSS/JS immutable 1y, HTML 1h+SWR, images 1y │
│ └─ SSL/TLS automatic │
└─────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ NAMECHEAP DNS │
│ serard.dev CNAME → cname.vercel-dns.com │
│ (Vercel manages SSL certificate for custom domain) │
└─────────────────────────────────────────────────────────────┘
│
▼
🌐 https://serard.devJS Files
| File | Lines | Role |
|---|---|---|
js/app-shared.js |
1054 | Utilities: scroll spy, overlays, prefetch, parallax, sidebar resize, keyboard |
js/app-static.js |
577 | Static SPA: navigation, TOC, headings panel |
js/app-dev.js |
854 | Dev mode: dynamic markdown loading |
js/theme-switcher.js |
357 | Dark/light themes, accents, OS style |
js/tour.js |
486 | Interactive guided tour (state machine) |
js/search.js |
203 | Client-side fuzzy search |
js/markdown-renderer.js |
386 | marked.js configuration + Mermaid rendering |
Main Layout
┌─────────────────────────────────────────────────┐
│ <header id="terminal-bar"> 38px fixe, z:100 │
│ [dots/btns OS] [titre] [theme] [search] [menu] │
├────────────┬────────────────────────────────────┤
│ <nav> │ <main id="content"> │
│ #sidebar │ <article id="markdown-output"> │
│ 280px │ flex:1, overflow-y:auto │
│ resizable │ padding: 32px 48px │
│ │ #code-watermark (parallax) │
├────────────┤ │
│ footer: │ │
│ CTA, links │ │
└────────────┴────────────────────────────────────┘┌─────────────────────────────────────────────────┐
│ <header id="terminal-bar"> 38px fixe, z:100 │
│ [dots/btns OS] [titre] [theme] [search] [menu] │
├────────────┬────────────────────────────────────┤
│ <nav> │ <main id="content"> │
│ #sidebar │ <article id="markdown-output"> │
│ 280px │ flex:1, overflow-y:auto │
│ resizable │ padding: 32px 48px │
│ │ #code-watermark (parallax) │
├────────────┤ │
│ footer: │ │
│ CTA, links │ │
└────────────┴────────────────────────────────────┘#app:display: flex, fixed position below the topbar#sidebar: width 280px (min 180, max 50vw), resizable by drag#content:flex: 1, custom scrollbar
A2. Theme System — CSS Variables
Root Variables (:root)
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas'
--font-sans: system fonts fallback
--sidebar-width: 280px
--topbar-height: 38px
--transition-speed: 0.25s
--transition-fast: 0.15s
--transition-slow: 0.3s--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas'
--font-sans: system fonts fallback
--sidebar-width: 280px
--topbar-height: 38px
--transition-speed: 0.25s
--transition-fast: 0.15s
--transition-slow: 0.3s4 Theme Variants
| Variant | CSS Selector | Background | Text |
|---|---|---|---|
| Dark (default) | :root |
#0d1117 | #e6edf3 |
| Light | [data-color-mode="light"] |
#ffffff | #1f2328 |
| HC Dark | [data-color-mode="dark"][data-color-theme="highcontrast"] |
#000000 | #ffffff |
| HC Light | [data-color-mode="light"][data-color-theme="highcontrast"] |
#ffffff | #000000 |
Accent Colors (8 + default)
green (#3fb950), blue (#58a6ff), purple (#bc8cff), orange (#d29922), red (#f85149), cyan (#56d4dd), pink (#f778ba), yellow (#e3b341)
Each accent redefines a harmonious palette of 6 variables (--accent-green to --accent-cyan) with different values for dark and light. The "green" slot becomes the primary accent (H1, prompt, CTA).
Theme Transition (on-demand via .theme-switching)
Transitions are not permanently active. The class .theme-switching is added by JS only during a theme toggle, then removed on transitionend. This avoids performance overhead during normal interaction.
html.theme-switching,
html.theme-switching body,
html.theme-switching #terminal-bar,
html.theme-switching #sidebar,
html.theme-switching #content,
html.theme-switching #markdown-output,
html.theme-switching .toc-item,
html.theme-switching .theme-btn,
html.theme-switching pre,
html.theme-switching code {
transition: background-color 0.2s ease, color 0.2s ease;
}html.theme-switching,
html.theme-switching body,
html.theme-switching #terminal-bar,
html.theme-switching #sidebar,
html.theme-switching #content,
html.theme-switching #markdown-output,
html.theme-switching .toc-item,
html.theme-switching .theme-btn,
html.theme-switching pre,
html.theme-switching code {
transition: background-color 0.2s ease, color 0.2s ease;
}Why not html.theme-ready *? The previous approach applied transitions to every DOM node permanently, causing visible lag on theme switch. The on-demand approach has zero cost during navigation/scroll and only animates ~10 targeted elements for 0.2s during the toggle.
A3. Rendered Markdown Content
Terminal Aesthetic
#markdown-output h1 { color: var(--accent-green); }
#markdown-output h1::before { content: '# '; color: var(--text-muted); }
#markdown-output h2 { color: var(--accent-blue); }
#markdown-output h2::before { content: '## '; }
#markdown-output h3 { color: var(--accent-purple); }
#markdown-output h3::before { content: '### '; }#markdown-output h1 { color: var(--accent-green); }
#markdown-output h1::before { content: '# '; color: var(--text-muted); }
#markdown-output h2 { color: var(--accent-blue); }
#markdown-output h2::before { content: '## '; }
#markdown-output h3 { color: var(--accent-purple); }
#markdown-output h3::before { content: '### '; }Code blocks
- Background
var(--bg-secondary), border, border-radius 6px - Copy button:
opacity: 0→ hover.code-block→opacity: 0.7(0.15s) - Raw content in
<template class="code-raw">for clean copy (not the colorized HTML) - Inline code:
var(--accent-cyan), tertiary bg, 0.88em
Mermaid (diagrams)
- Static mode: pre-rendered as SVG at build time (
/public/mermaid-svg/) <img class="mermaid-static" data-src-dark="...-dark.svg" data-src-light="...-light.svg">- Automatic swap via
updateMermaidTheme()+MutationObserverondata-color-mode - Click → fullscreen overlay with zoom/pan +
backdrop-filter: blur(2px)
Light mode code overrides
[data-color-mode="light"] #markdown-output pre { background: #f6f8fa; }
[data-color-mode="light"] #markdown-output code { color: #0550ae; }[data-color-mode="light"] #markdown-output pre { background: #f6f8fa; }
[data-color-mode="light"] #markdown-output code { color: #0550ae; }A4. CSS Animations — Complete Catalog
Keyframes
| Name | Duration | Effect | Applied to |
|---|---|---|---|
prefetch-bar |
0.8s ease-in-out infinite | scaleX(0→0.7→1), opacity pulse | .toc-item.prefetch-loading::after |
prefetch-done |
0.3s ease-out forwards | scaleX(1), opacity 0.5→0 | .toc-item.prefetch-loaded::after |
blink |
1s step-end infinite | opacity toggle 50% | .cursor.blink, .palette-title::after |
pulse-glow |
1.5s ease-in-out infinite | box-shadow expand/contract (color-mix) | .tour-pulse, .hire-pulse |
tour-tooltip-in |
— | scale(0.92)+translateY(8px)→normal | .tour-tooltip appearance |
Transitions by Component
| Component | Properties | Duration | Trigger |
|---|---|---|---|
| Buttons | background, color | 0.15s | :hover, :focus-visible |
| TOC items | all | 0.15s | :hover, .active |
| Expand arrows | transform (rotate 0→90deg) | 0.2s | click toggle |
| Tooltips | opacity, transform (translateY) | 0.25s | show/hide class |
| Modals | opacity, visibility | 0.25s | .open class |
| Overlays | opacity, visibility | 0.3s | show/hide |
| Grid collapse | grid-template-rows (0fr↔1fr) | 0.3s ease | .open class |
| Sidebar mobile | left (-100%↔0) | 0.3s ease | .open class |
| Backdrop | opacity (0↔1) | 0.3s ease | .visible class |
| Content fade out | opacity, transform | 0.08s ease | JS navigation |
| Content fade in | opacity, transform | 0.15s ease | JS navigation |
| TOC slide-in | opacity, transform (translateX) | 0.3s ease | animateTocStaggered() |
| Sidebar width | width | 0.4s ease | init only |
| Tour spotlight | top, left, width, height | 0.35s ease | step change |
| Tour tooltip | top, left, transform | 0.35s ease | step change |
| CTA hire | background, transform(-1px) | 0.15-0.2s | :hover |
| Palette swatch | border-color, transform(scale 1.2) | 0.15s | :hover, :focus-visible |
| Code copy btn | opacity, visibility | 0.15s | parent :hover |
| Scrollbar thumb | background (muted→secondary) | — | :hover |
Grid Collapse Pattern (key technique)
.toc-items {
overflow: hidden;
display: grid;
grid-template-rows: 0fr; /* closed */
transition: grid-template-rows 0.3s ease;
}
.toc-items > div { min-height: 0; } /* allows shrink to 0 */
.toc-items.open {
grid-template-rows: 1fr; /* open */
}.toc-items {
overflow: hidden;
display: grid;
grid-template-rows: 0fr; /* closed */
transition: grid-template-rows 0.3s ease;
}
.toc-items > div { min-height: 0; } /* allows shrink to 0 */
.toc-items.open {
grid-template-rows: 1fr; /* open */
}Why grid? No need to measure height in JS, GPU animation, handles variable heights automatically. Used for: TOC sections, TOC children, headings panel.
CSS State Classes
| Class | Elements | Effect |
|---|---|---|
.active |
.toc-item, .toc-heading, .os-btn | bg blue 10%, color blue, border-left blue |
.open |
.toc-section-title, .toc-items, .toc-children, .toc-headings, modals | grid 1fr, rotate arrow, display:flex |
.expanded |
.toc-item-parent | rotate expand btn 90deg |
.visible |
tooltips, backdrop | opacity 1, pointer-events auto |
.prefetch-loading/loaded/error |
.toc-item | animated bar / fade / red |
.dragging |
resize handle | visual feedback |
.sidebar-resizing |
body | cursor: col-resize, user-select: none |
.tour-active |
body | overflow: hidden |
.theme-switching |
html | enables color transitions during theme toggle only |
A5. Complete DOM Events Catalog
Click Events (30+)
| Element | Handler | Action |
|---|---|---|
.toc-section-title |
wireTocInteractivity() |
Toggle section expand/collapse |
.toc-expand-btn |
wireTocInteractivity() |
Toggle children (stopPropagation) |
.toc-item (link) |
wireTocNavigation() |
SPA navigation + prefetch |
.toc-expand-heading |
wireTocNavigation() |
Scroll to heading on same page |
.toc-heading |
headings panel | Scroll to internal heading |
.toc-heading-copy |
inline | Copy heading link → clipboard (1.5s flash) |
.toc-item-copy |
inline | Copy page link → clipboard |
.code-copy |
wireContentInteractivity() |
Copy code block → clipboard (1.5s flash) |
.heading-copy |
wireContentInteractivity() |
Copy deep link → clipboard |
img (content) |
wireContentInteractivity() |
Fullscreen overlay (lazy) |
.mermaid-container svg/img |
wireContentInteractivity() |
Zoom/pan overlay |
.mermaid-close |
overlay closure | Close overlay |
.mermaid-zoom-in/out/reset |
overlay closure | Zoom controls |
#btn-color-mode |
theme-switcher.js |
Toggle dark/light |
#btn-color-theme |
theme-switcher.js |
Toggle high contrast |
#btn-color-theme (contextmenu) |
theme-switcher.js |
Open accent palette |
.palette-swatch |
theme-switcher.js |
Change accent color |
.os-btn |
theme-switcher.js |
Change OS style |
#btn-help |
setupCommonKeyboard() |
Toggle help modal |
#btn-tour |
tour.js |
Start guided tour |
#btn-cv |
setupCommonKeyboard() |
Toggle CV tray menu |
.cv-tray-item |
setup | CV download/view |
#btn-cv-view |
setup | Open CV in iframe modal |
#cv-modal-close |
setup | Close CV modal |
#btn-hire-me |
setup | Open contact modal |
.help-modal-close (hire) |
setup | Close hire modal |
#hire-form (submit) |
setup | mailto: with subject+body |
.contact-link[data-email] |
setup | mailto: handler |
.contact-copy |
setup | Copy email → clipboard |
#sidebar-toggle |
setupCommonKeyboard() |
Toggle mobile sidebar |
#sidebar-backdrop |
setup | Close mobile sidebar |
Links a[href^="#"] |
wireContentNavigation() |
Smooth scroll to anchor |
Internal links .html |
wireContentNavigation() |
SPA navigation |
.tour-tooltip-close |
tour.js |
End tour |
#tour-prev/next |
tour.js |
Prev/next step |
#tour-overlay (backdrop) |
tour.js |
End tour |
.search-result-item |
search.js |
Navigate to result |
#search-modal (backdrop) |
search.js |
Close search |
Keyboard Events (15+)
| Key | Context | Action |
|---|---|---|
Escape |
Global | Chain: tour→cv modal→cv tray→help→hire→sidebar |
[ / Alt+S |
Global | Focus sidebar |
] / Alt+C |
Global | Focus content |
? |
Global (not input) | Toggle help |
Ctrl+K / Cmd+K |
Global | Toggle search |
Ctrl+= / Cmd+= |
Global | Increase font size (prevents browser zoom) |
Ctrl+- / Cmd+- |
Global | Decrease font size (prevents browser zoom) |
ArrowDown |
Search | Next result (wrap modulo) |
ArrowUp |
Search | Prev result (wrap modulo) |
Enter |
Search | Select active result |
Escape |
Search | Close |
ArrowRight/Down |
Tour | Next step / Finish |
ArrowLeft/Up |
Tour | Prev step |
Escape |
Tour | End tour (stopPropagation) |
Tab |
Tour | Focus trap (cycle buttons) |
Escape |
Accent palette | Close palette |
Enter/Space |
.toc-section-title | Click (keydown) |
Enter/Space |
img/svg (focusable) | Toggle overlay |
Scroll Events (5)
| Element | Type | Mechanism |
|---|---|---|
#content |
scroll (passive) | Scroll spy: active heading (throttle rAF + ticking flag) |
#content |
scroll (passive) | Parallax: --scroll-y on #code-watermark |
#toc |
wheel/touch | Set _tocUserScrolled = true |
#toc |
scrollend | After smooth scroll → render headings |
.mermaid-zoom-container |
wheel | Custom zoom (preventDefault), x1.2/÷1.2 |
Mouse Events (5)
| Element | Event | Delay | Action |
|---|---|---|---|
.toc-item/.toc-heading |
pointerenter/leave | 650ms | Tooltip description |
a.toc-item |
mouseenter | immediate | prefetchManager.hover(href) |
#btn-hire-me |
mouseenter/leave | 300ms | Tooltip "hire" |
#btn-cv |
mouseenter | immediate | cvPrefetch.hover() |
#sidebar-resize-handle |
mousedown→mousemove→mouseup | — | Drag resize sidebar |
Transition Events (5)
| Source | When | Subsequent Action |
|---|---|---|
#markdown-output |
End of fade-out (0.08s) | Resolves Promise, swap content |
#toc |
End of slide-in (0.3s) | Scroll, dispatch toc-active-ready + toc-animation-done |
.toc-headings |
End of grid expand (0.3s) | scrollTocToActiveItem, dispatch toc-headings-rendered |
#sidebar |
End of width anim (0.4s) | Clear inline transition |
#hire-modal |
End of open | Focus #hire-subject + select |
Custom Events (6)
| Event | Emitter | Listeners | Payload |
|---|---|---|---|
scrollspy-active |
scroll spy | tocExpand heading update | {slug, path} |
toc-active-ready |
animateTocStaggered | headings panel render | — |
toc-animation-done |
animateTocStaggered | scroll spy, app-ready | — |
toc-headings-rendered |
headings panel | scroll spy re-sync | — |
mermaid-config-ready |
app-shared.js load | markdown-renderer.js | — |
app-ready |
toc-animation-done | tocReadyPulse, tour affordance | — |
Resize Events (2)
| Source | Handler | Action |
|---|---|---|
window resize |
SidebarResize |
Auto-size if no manual drag |
window resize |
tour.js |
Reposition spotlight + tooltip |
Other
| Type | Source | Action |
|---|---|---|
MutationObserver |
data-color-mode attr |
updateMermaidTheme() (swap SVG src) |
popstate |
window | navigateTo(href, false) |
error (capture) |
window | IMG load errors → debug bar |
unhandledrejection |
window | Fetch/script errors → debug bar |
A6. Responsive Design
≤768px (mobile)
#sidebar { position: fixed; left: -100%; transition: left 0.3s ease; }
#sidebar.open { left: 0; }
#sidebar-backdrop { display: block; opacity: 0; transition: opacity 0.3s; }
#sidebar-backdrop.visible { opacity: 1; }
#content { padding: 24px 16px; }
.os-selector, #btn-color-theme { display: none !important; }#sidebar { position: fixed; left: -100%; transition: left 0.3s ease; }
#sidebar.open { left: 0; }
#sidebar-backdrop { display: block; opacity: 0; transition: opacity 0.3s; }
#sidebar-backdrop.visible { opacity: 1; }
#content { padding: 24px 16px; }
.os-selector, #btn-color-theme { display: none !important; }≤480px
html { font-size: 14px; }
#content { padding: 16px 12px; }html { font-size: 14px; }
#content { padding: 16px 12px; }A7. Accessibility
- Skip-to-content:
<a class="sr-only">at the top of the page sr-only:position:absolute; width:1px; height:1px; clip:rect(0,0,0,0)— invisible, accessiblearia-expanded: on all .toc-section-title togglesrole="button": on clickable non-button elements (section titles, images)aria-label: on icon buttons (zoom, close, copy, overlay)role="dialog"+aria-modal="true": tour overlay, search modal:focus-visible: visible styles (background + color transition) on all interactive elements- Focus trap: Tab cycle in tour tooltip (Prev↔Next↔Close, wrap)
- Focus restoration: overlay/modal closes →
returnFocusEl.focus() - Focus on open: help → closeBtn, hire → subject input, search → input, overlay → closeBtn
- High Contrast: explicit borders instead of shadows, stronger opacity, no subtle transparency
tabindex="0": on clickable images/SVGs for keyboard navigation- Keyboard-navigable: entire interface accessible without a mouse
A8. Print Styles
@media print {
#sidebar, #terminal-bar, #sidebar-backdrop, .search-modal {
display: none !important;
}
body {
background: white; color: black;
overflow: visible; height: auto;
}
#markdown-output h1::before,
#markdown-output h2::before,
#markdown-output h3::before {
display: none; /* remove # ## ### */
}
}@media print {
#sidebar, #terminal-bar, #sidebar-backdrop, .search-modal {
display: none !important;
}
body {
background: white; color: black;
overflow: visible; height: auto;
}
#markdown-output h1::before,
#markdown-output h2::before,
#markdown-output h3::before {
display: none; /* remove # ## ### */
}
}A9. Custom Scrollbar
::-webkit-scrollbar { width: 12px; }
::-webkit-scrollbar-track { background: var(--bg-secondary); }
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 6px;
border: 3px solid var(--bg-secondary); /* visual margin */
}
::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }::-webkit-scrollbar { width: 12px; }
::-webkit-scrollbar-track { background: var(--bg-secondary); }
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 6px;
border: 3px solid var(--bg-secondary); /* visual margin */
}
::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }A10. Performance
- GPU:
will-change: transform, opacityon prefetch bar and watermark - Scroll throttle:
requestAnimationFrame+tickingflag = max 60fps - Passive listeners:
{ passive: true }on all scroll events - Lazy overlay: DOM created only on first click (not on load)
- Prefetch on hover: content prefetched on hover → instant click
pointer-events: none: hidden tooltips do not block clicks- Force-layout trick:
getBoundingClientRect()before transition to prevent browser batching - CSS variable parallax:
transform: translateY(calc(var(--scroll-y) * -0.15))(JS sets the var, CSS animates)
PART B — STATE MACHINES
B1. Per-TOC-Link Prefetch
Each <a class="toc-item"> link: its own instance in Map<href, Entry>.
hover(): if idle → fetch. Otherwise no-op.click(): if loaded → cache. If loading → waiter Promise. If idle → fetch + waiter.
B2. SPA Navigation (13 steps)
B3. TOC Section Collapse/Expand
tocExpand (data-nav): click title → navigateTo() instead of toggle.
B4. Tree Items (Parent/Children)
- click expand →
stopPropagation(does not navigate) - click parent label →
navigateTo(page)
B5. Inner TOC (Headings Panel)
B6. Scroll Spy
Walk-up slug if h4+: a--b--c → a--b → a. Dispatch scrollspy-active, update .toc-heading.active.
B7. TOC User Scroll Control
B8. TOC Animation (Staggered Entrance)
B9. Active Pulse
Also fires on the active heading when toc-headings-rendered is dispatched.
B10. TOC Tooltip (Hover)
B11. Sidebar Resize (Drag)
Auto-size (if !userHasSetWidth): min(max(180, 22vw), 450)px
B12. Mobile Sidebar (≤768px)
B13. Guided Tour
Affordance (first visit)
Spotlight: box-shadow: 0 0 0 9999px rgba(0,0,0,0.7). Focus trap: Tab cycles Prev-Next-Close.
B14. Image/Diagram Overlay
B15. CV Prefetch (PDF)
B16. Search (Search Modal)
B17. Theme Dark/Light
Cascade: withThemeTransition → add .theme-switching → applyColorMode → setAttribute → localStorage → applyAccentColor → mermaid.initialize → reRenderMermaid → MutationObserver → updateMermaidTheme → swap img src → body transitionend → remove .theme-switching
B18. High Contrast
B19. Accent Palette
Click swatch → applyAccentColor(key) → set 6 inline CSS vars → re-init Mermaid → close palette.
B20. OS Selector
data-os → dots/btns style + terminal title + localStorage. Auto-detect via navigator.userAgent.
B21. Analytics Consent
B22. Hire Modal
B23. Help Modal
B24. CV Tray + CV Modal
B25. Content Fade
Force-layout trick: getBoundingClientRect() between transition:none and transition:0.08s.
B26. Copy Flash
B27. Escape Chain (descending priority)
PART C — CROSS-CUTTING PATTERNS
C1. Anti-FOUC (Flash of Unstyled Content)
PHASE 1 — Inline <head> script (BEFORE render)
→ localStorage → setAttribute data-color-mode/os-style/color-theme
→ localStorage → style.fontSize (font-size preference)
→ CSS :root[data-color-mode="..."] active immediately
→ No transitions at this point (no .theme-switching class)
PHASE 2 — ThemeSwitcher.init() (synchronous)
→ applyColorMode + applyColorTheme + applyOS + applyAccentColor + applyFontSize
→ All applied instantly, no animation
PHASE 3 — User clicks theme toggle later
→ withThemeTransition() adds .theme-switching class
→ Theme change applied (setAttribute, CSS vars)
→ body transitionend fires → .theme-switching removed
→ Transition only active for ~0.2s, only on targeted elementsPHASE 1 — Inline <head> script (BEFORE render)
→ localStorage → setAttribute data-color-mode/os-style/color-theme
→ localStorage → style.fontSize (font-size preference)
→ CSS :root[data-color-mode="..."] active immediately
→ No transitions at this point (no .theme-switching class)
PHASE 2 — ThemeSwitcher.init() (synchronous)
→ applyColorMode + applyColorTheme + applyOS + applyAccentColor + applyFontSize
→ All applied instantly, no animation
PHASE 3 — User clicks theme toggle later
→ withThemeTransition() adds .theme-switching class
→ Theme change applied (setAttribute, CSS vars)
→ body transitionend fires → .theme-switching removed
→ Transition only active for ~0.2s, only on targeted elementsWhy on-demand instead of permanent html.theme-ready *? The previous approach applied transition: 0.3s to every DOM node via * selector permanently. This caused visible lag on theme switch (hundreds of elements animating). The on-demand approach has zero cost during normal interaction and only animates ~10 targeted elements for 0.2s during the toggle.
C2. Theme Cascade
TRIGGER: click dark/light │ click HC │ swatch accent
│
▼
applyColorMode/Theme/AccentColor
│
├→ setAttribute on <html>
├→ localStorage
├→ applyAccentColor (re-compute for dark/light)
│ HC active? → remove all inline vars
│ HC off? → set 6 vars for the current mode
▼
getMermaidConfig(isDark)
│ Reads inline CSS vars for themeVariables
▼
mermaid.initialize() → reRenderMermaid() (dev)
│
▼
MutationObserver (static) → updateMermaidTheme() → swap img[data-src-*]
│
▼
CSS: .theme-switching on ~10 elements { transition: bg/color 0.2s }
→ transitionend on body → remove .theme-switchingTRIGGER: click dark/light │ click HC │ swatch accent
│
▼
applyColorMode/Theme/AccentColor
│
├→ setAttribute on <html>
├→ localStorage
├→ applyAccentColor (re-compute for dark/light)
│ HC active? → remove all inline vars
│ HC off? → set 6 vars for the current mode
▼
getMermaidConfig(isDark)
│ Reads inline CSS vars for themeVariables
▼
mermaid.initialize() → reRenderMermaid() (dev)
│
▼
MutationObserver (static) → updateMermaidTheme() → swap img[data-src-*]
│
▼
CSS: .theme-switching on ~10 elements { transition: bg/color 0.2s }
→ transitionend on body → remove .theme-switchingC3. Favicon Swap Trick
updateFavicon(path)
1. Remove all <link rel="icon">
2. Insert blank: href="data:,"
→ forces browser to "forget" the old one
3. rAF (browser displays the blank)
4. Remove blank
5. Insert new: href=resolved?v=Date.now()
→ cache-bust with timestamp
→ type by extension (png/svg/ico)
→ no favicon? fallback /favicon.icoupdateFavicon(path)
1. Remove all <link rel="icon">
2. Insert blank: href="data:,"
→ forces browser to "forget" the old one
3. rAF (browser displays the blank)
4. Remove blank
5. Insert new: href=resolved?v=Date.now()
→ cache-bust with timestamp
→ type by extension (png/svg/ico)
→ no favicon? fallback /favicon.icoC4. Safety Guards (setTimeout fallbacks)
| Location | Guard | Protects | Risk without guard |
|---|---|---|---|
| buildHeadingsPanel | 550ms | grid expand (0.3s) | headings never scrolled |
| SidebarResize.init | 500ms | width anim (0.4s) | inline transition persists |
| scrollSpy.pause | 2x rAF | scrollend event | spy stays paused indefinitely |
Common pattern:
let fired = false;
const onEnd = () => { if (fired) return; fired = true; /* action */ };
el.addEventListener('transitionend', onEnd);
setTimeout(onEnd, DURATION + MARGIN);let fired = false;
const onEnd = () => { if (fired) return; fired = true; /* action */ };
el.addEventListener('transitionend', onEnd);
setTimeout(onEnd, DURATION + MARGIN);Force-layout trick:
el.style.transition = 'none';
el.style.opacity = '1'; // "from"
el.getBoundingClientRect(); // FORCE LAYOUT — browser notes the "from"
el.style.transition = '0.08s';
el.style.opacity = '0'; // "to" — browser animates from "from" to "to"el.style.transition = 'none';
el.style.opacity = '1'; // "from"
el.getBoundingClientRect(); // FORCE LAYOUT — browser notes the "from"
el.style.transition = '0.08s';
el.style.opacity = '0'; // "to" — browser animates from "from" to "to"C5. setTimeout Audit (zero setInterval)
| File | Delay | Why |
|---|---|---|
| app-shared.js (copy) | 1500ms | Flash check mark before reset to clipboard icon |
| app-shared.js (TOC tooltip) | 650ms | Prevents flash on quick hover |
| app-shared.js (sidebar resize) | 500ms | Guard transitionend |
| app-shared.js (hire tooltip) | 300ms | Prevents micro-hover |
| app-static.js (headings panel) | 550ms | Guard grid transitionend |
| tour.js (affordance) | 8000ms | Auto-hide tooltip on first visit |
| tour.js (show affordance) | 1000ms | Wait for app-ready + animations |
Architecture is 100% event-driven. Zero setInterval. The theme transition uses transitionend instead of setTimeout. |
C6. Wiring Diagram — Cross-Machine Interactions
┌──────────┐ hover ┌──────────┐ html ┌────────────┐
│ TOC Item │ ───────→ │ Prefetch │ ──────→ │ Navigation │
│ (mouse) │ click │ Manager │ │ SPA │
└──────────┘ ───────→ └──────────┘ └─────┬──────┘
│
┌─────────────┬────────────────┼──────────┐
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Content │ │ TOC │ │ Headings │ │ Sidebar │
│ Fade │ │ .active │ │ Panel │ │ Mobile │
│ out→in │ │ update │ │ build │ │ close │
└──────────┘ └──────────┘ └────┬─────┘ └──────────┘
│ transitionend
▼
┌──────────────┐
│ toc-headings │
│ -rendered │
└──────┬───────┘
▼
┌──────────────┐
│ Scroll Spy │ ←── scroll content
│ re-sync │
└──────┬───────┘
│ scrollspy-active
▼
┌──────────────┐
│ TocExpand │
│ .active │
└──────────────┘┌──────────┐ hover ┌──────────┐ html ┌────────────┐
│ TOC Item │ ───────→ │ Prefetch │ ──────→ │ Navigation │
│ (mouse) │ click │ Manager │ │ SPA │
└──────────┘ ───────→ └──────────┘ └─────┬──────┘
│
┌─────────────┬────────────────┼──────────┐
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Content │ │ TOC │ │ Headings │ │ Sidebar │
│ Fade │ │ .active │ │ Panel │ │ Mobile │
│ out→in │ │ update │ │ build │ │ close │
└──────────┘ └──────────┘ └────┬─────┘ └──────────┘
│ transitionend
▼
┌──────────────┐
│ toc-headings │
│ -rendered │
└──────┬───────┘
▼
┌──────────────┐
│ Scroll Spy │ ←── scroll content
│ re-sync │
└──────┬───────┘
│ scrollspy-active
▼
┌──────────────┐
│ TocExpand │
│ .active │
└──────────────┘Boot Sequence
ThemeSwitcher.init() → anti-FOUC (instant, no transition)
DOMContentLoaded
├── SidebarResize.init()
├── TocTooltip.init()
├── AnalyticsConsent.init()
├── TourModule.init()
└── AppStatic.init()
├── wire everything (sync)
├── animateTocStaggered() → toc-active-ready → headings panel
│ → toc-animation-done → app-ready
│ ├→ pulse
│ └→ tour (+1s)
└── fetch(toc.json) ──async──→ SearchModule.init()
+ headings (if ready)ThemeSwitcher.init() → anti-FOUC (instant, no transition)
DOMContentLoaded
├── SidebarResize.init()
├── TocTooltip.init()
├── AnalyticsConsent.init()
├── TourModule.init()
└── AppStatic.init()
├── wire everything (sync)
├── animateTocStaggered() → toc-active-ready → headings panel
│ → toc-animation-done → app-ready
│ ├→ pulse
│ └→ tour (+1s)
└── fetch(toc.json) ──async──→ SearchModule.init()
+ headings (if ready)Theme Change
click dark/light
│
├→ withThemeTransition: add .theme-switching
├→ applyColorMode → applyAccentColor → getMermaidConfig
│ │
│ mermaid.initialize
│ │
│ MutationObserver
│ │
│ updateMermaidTheme
│ │
│ swap img[data-src-*]
│
└→ body transitionend → remove .theme-switchingclick dark/light
│
├→ withThemeTransition: add .theme-switching
├→ applyColorMode → applyAccentColor → getMermaidConfig
│ │
│ mermaid.initialize
│ │
│ MutationObserver
│ │
│ updateMermaidTheme
│ │
│ swap img[data-src-*]
│
└→ body transitionend → remove .theme-switchingFinal Summary
All State Machines (27)
| # | Feature | Transitions | Section Ref |
|---|---|---|---|
| 1 | Prefetch hover | idle→loading→loaded/error | B1 |
| 2 | SPA Navigation | 13 sequential steps | B2 |
| 3 | Section collapse | toggle grid 0fr↔1fr | B3 |
| 4 | Tree expand | .expanded + children.open | B4 |
| 5 | Inner TOC headings | close→build→expand→events | B5 |
| 6 | Scroll spy | active/paused, rAF throttle | B6 |
| 7 | User scroll control | flag auto↔manual | B7 |
| 8 | TOC entrance anim | slide-in 0.3s→events | B8 |
| 9 | Active pulse | in→out→cleanup | B9 |
| 10 | TOC tooltip | 650ms delay→visible | B10 |
| 11 | Sidebar resize | idle→dragging→idle | B11 |
| 12 | Mobile sidebar | closed↔open | B12 |
| 13 | Guided tour | idle→running→completed/skipped | B13 |
| 14 | Tour affordance | pulse+tooltip→hide | B13 |
| 15 | Overlay zoom/pan | null→closed↔open | B14 |
| 16 | CV prefetch | idle→loading→loaded/error | B15 |
| 17 | Search modal | closed↔open, scoring | B16 |
| 18 | Theme dark/light | dark↔light + cascade | B17 |
| 19 | High contrast | default↔highcontrast | B18 |
| 20 | Accent palette | closed↔open (lazy) | B19 |
| 21 | OS selector | macos/windows/linux | B20 |
| 22 | Analytics consent | null→banner→accept/reject | B21 |
| 23 | Hire modal | closed↔open, mailto: | B22 |
| 24 | Help modal | closed↔open | B23 |
| 25 | CV tray + modal | dropdown + iframe viewer | B24 |
| 26 | Content fade | out 0.08s→swap→in 0.15s | B25 |
| 27 | Escape chain | tour→cv→tray→help→hire→sidebar | B27 |
| 28 | Font size controls | steps + clamp + localStorage | B28 |
Cross-Cutting Patterns (6)
| Pattern | Description | Section Ref |
|---|---|---|
| Anti-FOUC | inline script→instant apply, on-demand .theme-switching | C1 |
| Theme cascade | mode→accent→mermaid→SVG + .theme-switching | C2 |
| Favicon swap | blank→rAF→real (cache-bust) | C3 |
| Safety guards | transitionend+setTimeout | C4 |
| setTimeout audit | 0 setInterval, 7 timers, theme uses transitionend | C5 |
| Wiring diagram | interactions + boot + theme | C6 |
Architecture (10 sections)
| Section | Content | Section Ref |
|---|---|---|
| Stack & files | Build, layout, JS modules | A1 |
| CSS themes | 4 variants, vars, accents | A2 |
| Markdown content | H1-H3 terminal, code, Mermaid | A3 |
| CSS animations | 5 keyframes, 19 transitions | A4 |
| DOM events | 37 click, 16 kb, 5 scroll, 5 mouse, 5 transition, 6 custom | A5 |
| Responsive | 768px, 480px breakpoints | A6 |
| Accessibility | ARIA, focus, HC, sr-only, trap | A7 |
| @media print | A8 | |
| Scrollbar | Custom webkit | A9 |
| Performance | GPU, throttle, lazy, prefetch | A10 |
Verification
- DevTools → Network → hover TOC link → observe fetch (prefetch)
- DevTools → Elements → classes (.open, .active, .expanded, .prefetch-*)
- Console →
dispatchEvent(new Event('toc-active-ready'))→ headings - Performance tab → navigation → transitions 60fps
- Test Escape chain with tour + simultaneous modals
- Theme cascade: dark→light → Mermaid SVG swap + accent recalculation
- Anti-FOUC: clear localStorage → reload → no flash
- HC mode → palette "Overridden" notice
- Search: "schneider" → experience first (company=5)
- Favicon: navigate between pages → tab icon changes
- Font size: click A+/A- → text resizes, persists on reload
- Font size: Ctrl+= / Ctrl+- → same as buttons, browser zoom prevented
- Theme switch: no lag — .theme-switching added/removed in ~0.2s
B28. Font Size Controls (NEW FEATURE)
State Machine
┌──────┐ click A− ┌──────┐ click A− ┌──────┐
│ XL │ ──────────→ │ L │ ──────────→ │ M │ (default: 15px)
│ 18px │ ←────────── │ 17px │ ←────────── │ 15px │
└──────┘ click A+ └──────┘ click A+ └──────┘
│ click A−
▼
┌──────┐ click A− ┌──────┐
│ S │ ──────────→ │ XS │
│ 13px │ ←────────── │ 12px │
└──────┘ click A+ └──────┘
Persistence: localStorage('cv-font-size') = 12|13|15|17|18
Restoration: inline <head> script (anti-FOUC)
Application: html { font-size: Xpx; }┌──────┐ click A− ┌──────┐ click A− ┌──────┐
│ XL │ ──────────→ │ L │ ──────────→ │ M │ (default: 15px)
│ 18px │ ←────────── │ 17px │ ←────────── │ 15px │
└──────┘ click A+ └──────┘ click A+ └──────┘
│ click A−
▼
┌──────┐ click A− ┌──────┐
│ S │ ──────────→ │ XS │
│ 13px │ ←────────── │ 12px │
└──────┘ click A+ └──────┘
Persistence: localStorage('cv-font-size') = 12|13|15|17|18
Restoration: inline <head> script (anti-FOUC)
Application: html { font-size: Xpx; }Implementation Plan
Files to modify:
| File | Modification |
|---|---|
public/index.html |
Add 2 buttons in #theme-switcher + LS restoration in <head> |
public/content/*.html |
Same addition (build-static generates them) |
css/style.css |
.font-size-btn styles + [data-font-size] overrides |
js/theme-switcher.js |
STORAGE_FONT_SIZE, applyFontSize(), event listeners |
scripts/build-static.js |
HTML template with the new buttons |
HTML (in #theme-switcher, after #btn-color-theme):
<button id="btn-font-decrease" class="theme-btn font-size-btn"
title="Decrease font size (A−)" aria-label="Decrease font size">
<span aria-hidden="true">A−</span>
</button>
<button id="btn-font-increase" class="theme-btn font-size-btn"
title="Increase font size (A+)" aria-label="Increase font size">
<span aria-hidden="true">A+</span>
</button><button id="btn-font-decrease" class="theme-btn font-size-btn"
title="Decrease font size (A−)" aria-label="Decrease font size">
<span aria-hidden="true">A−</span>
</button>
<button id="btn-font-increase" class="theme-btn font-size-btn"
title="Increase font size (A+)" aria-label="Increase font size">
<span aria-hidden="true">A+</span>
</button>CSS:
.font-size-btn { font-size: 11px; font-weight: 700; letter-spacing: -0.5px; }
/* Steps: 12, 13, 15 (default), 17, 18 */.font-size-btn { font-size: 11px; font-weight: 700; letter-spacing: -0.5px; }
/* Steps: 12, 13, 15 (default), 17, 18 */JS (in theme-switcher.js):
const STORAGE_FONT_SIZE = 'cv-font-size';
const FONT_STEPS = [12, 13, 15, 17, 18]; // px
const DEFAULT_FONT_SIZE = 15;
function getStoredFontSize() {
try { return parseInt(localStorage.getItem(STORAGE_FONT_SIZE)) || DEFAULT_FONT_SIZE; }
catch { return DEFAULT_FONT_SIZE; }
}
function applyFontSize(size) {
document.documentElement.style.fontSize = size + 'px';
try { localStorage.setItem(STORAGE_FONT_SIZE, size); } catch {}
}
function changeFontSize(delta) {
const current = getStoredFontSize();
const idx = FONT_STEPS.indexOf(current);
const nextIdx = Math.max(0, Math.min(FONT_STEPS.length - 1, idx + delta));
applyFontSize(FONT_STEPS[nextIdx]);
}const STORAGE_FONT_SIZE = 'cv-font-size';
const FONT_STEPS = [12, 13, 15, 17, 18]; // px
const DEFAULT_FONT_SIZE = 15;
function getStoredFontSize() {
try { return parseInt(localStorage.getItem(STORAGE_FONT_SIZE)) || DEFAULT_FONT_SIZE; }
catch { return DEFAULT_FONT_SIZE; }
}
function applyFontSize(size) {
document.documentElement.style.fontSize = size + 'px';
try { localStorage.setItem(STORAGE_FONT_SIZE, size); } catch {}
}
function changeFontSize(delta) {
const current = getStoredFontSize();
const idx = FONT_STEPS.indexOf(current);
const nextIdx = Math.max(0, Math.min(FONT_STEPS.length - 1, idx + delta));
applyFontSize(FONT_STEPS[nextIdx]);
}Anti-FOUC (inline <head>):
var f=localStorage.getItem('cv-font-size');
if(f)document.documentElement.style.fontSize=f+'px';var f=localStorage.getItem('cv-font-size');
if(f)document.documentElement.style.fontSize=f+'px';PART D — ACCEPTANCE CRITERIA & TESTS
D1. AC & Tests per Feature
B1. Prefetch hover
AC:
- AC1: Hover on a TOC link triggers a network fetch
- AC2: A second hover does not trigger a second fetch
- AC3: Click returns cached content if already loaded
- AC4: Click during loading waits for the fetch to complete
- AC5: CSS class
.prefetch-loadingvisible during fetch - AC6: CSS class
.prefetch-loadedafter success (with fade-out) - AC7: CSS class
.prefetch-errorafter failure (red bar)
Unit tests:
test('hover on idle starts fetch')
test('hover on loading is noop')
test('hover on loaded is noop')
test('click on loaded returns cached result')
test('click on loading waits for fetch')
test('click on idle starts fetch and waits')
test('error state flushes waiters with null')
test('onStateChange called on each transition')test('hover on idle starts fetch')
test('hover on loading is noop')
test('hover on loaded is noop')
test('click on loaded returns cached result')
test('click on loading waits for fetch')
test('click on idle starts fetch and waits')
test('error state flushes waiters with null')
test('onStateChange called on each transition')Integration tests:
test('hover TOC link → network request appears')
test('hover then click → single network request')
test('click without hover → fetch + navigation')
test('CSS class lifecycle: idle → loading → loaded')
test('prefetch-bar animation visible during loading')test('hover TOC link → network request appears')
test('hover then click → single network request')
test('click without hover → fetch + navigation')
test('CSS class lifecycle: idle → loading → loaded')
test('prefetch-bar animation visible during loading')B2. SPA Navigation
AC:
- AC1: Click on TOC item loads the page without full reload
- AC2: Content fades out then fades in
- AC3: URL updated in the address bar
- AC4: Back/Forward works (popstate)
- AC5: Active TOC item updated (.active)
- AC6: Section/tree parent opened automatically
- AC7: Headings panel rendered after navigation
- AC8: Mobile sidebar closed after navigation
- AC9: Scroll to top (or to hash if present)
- AC10: Deep link with hash scrolls to the heading
Unit tests:
test('navigateTo swaps innerHTML')
test('navigateTo updates currentPath')
test('navigateTo with hash scrolls to element')
test('navigateTo pushes history state')
test('navigateTo with pushState=false does not push')
test('same page + hash does smoothScroll only')test('navigateTo swaps innerHTML')
test('navigateTo updates currentPath')
test('navigateTo with hash scrolls to element')
test('navigateTo pushes history state')
test('navigateTo with pushState=false does not push')
test('same page + hash does smoothScroll only')Integration tests:
test('full navigation: click TOC → content changes → URL updates')
test('back button restores previous page')
test('deep link on load scrolls to heading')
test('mobile sidebar closes after navigation')
test('headings panel appears after page load')test('full navigation: click TOC → content changes → URL updates')
test('back button restores previous page')
test('deep link on load scrolls to heading')
test('mobile sidebar closes after navigation')
test('headings panel appears after page load')B3-B4. Section Collapse & Tree Expand
AC:
- AC1: Click section title toggles .open class
- AC2: aria-expanded reflects state
- AC3: Grid animates 0fr↔1fr smoothly (0.3s)
- AC4: Expand button rotates 0↔90deg
- AC5: Tree expand stopPropagation (no navigation)
Unit tests:
test('click section title toggles open class')
test('aria-expanded matches open state')
test('click expand btn toggles children')
test('expand btn stopPropagation')
test('tocExpand section navigates instead of toggling')test('click section title toggles open class')
test('aria-expanded matches open state')
test('click expand btn toggles children')
test('expand btn stopPropagation')
test('tocExpand section navigates instead of toggling')Integration tests:
test('section expand/collapse animation completes')
test('nested tree expand shows children')
test('keyboard Enter/Space triggers toggle')test('section expand/collapse animation completes')
test('nested tree expand shows children')
test('keyboard Enter/Space triggers toggle')B5. Inner TOC Headings
AC:
- AC1: Headings panel appears below the active item
- AC2: Previous panel closes before the new one opens
- AC3: Grid animation 0fr→1fr visible
- AC4: Click heading → smooth scroll to heading
- AC5: Copy button → clipboard + flash 1.5s
- AC6: Scroll spy updates the active heading in the panel
- AC7: tocExpand items do not display a headings panel
Unit tests:
test('buildHeadingsPanel creates correct DOM')
test('cleanupHeadingWrappers removes all wrappers')
test('heading click pauses scroll spy')
test('heading click scrolls to target')
test('copy button copies URL to clipboard')
test('tocExpand items skip headings panel')test('buildHeadingsPanel creates correct DOM')
test('cleanupHeadingWrappers removes all wrappers')
test('heading click pauses scroll spy')
test('heading click scrolls to target')
test('copy button copies URL to clipboard')
test('tocExpand items skip headings panel')Integration tests:
test('navigate to page → headings panel appears')
test('navigate away → old panel closes, new opens')
test('scroll content → active heading updates in panel')
test('grid transition completes → toc-headings-rendered fires')test('navigate to page → headings panel appears')
test('navigate away → old panel closes, new opens')
test('scroll content → active heading updates in panel')
test('grid transition completes → toc-headings-rendered fires')B6-B7. Scroll Spy & User Scroll Control
AC:
- AC1: Scrolling content → nearest heading to the top (60px) becomes active
- AC2: .toc-heading.active updated in the sidebar
- AC3: Paused during programmatic scroll
- AC4: Resumes after scrollend or 2x rAF
- AC5: User wheel on TOC → auto-scroll disabled
- AC6: New page navigation → auto-scroll re-enabled
- AC7: history.replaceState updated with the slug
Unit tests:
test('onScroll finds heading within 60px of top')
test('pause() sets paused flag')
test('resume via scrollend event')
test('resume via 2xRAF fallback')
test('h4 slug walks up to parent h2/h3')
test('markTocUserScrolled sets flag')
test('resetTocUserScrolled clears flag')
test('scrollTocToActiveItem noop when user scrolled')test('onScroll finds heading within 60px of top')
test('pause() sets paused flag')
test('resume via scrollend event')
test('resume via 2xRAF fallback')
test('h4 slug walks up to parent h2/h3')
test('markTocUserScrolled sets flag')
test('resetTocUserScrolled clears flag')
test('scrollTocToActiveItem noop when user scrolled')Integration tests:
test('scroll content → sidebar heading highlights')
test('click heading → spy paused → spy resumes after scroll')
test('user scrolls TOC → auto-scroll disabled')
test('navigate → auto-scroll re-enabled')test('scroll content → sidebar heading highlights')
test('click heading → spy paused → spy resumes after scroll')
test('user scrolls TOC → auto-scroll disabled')
test('navigate → auto-scroll re-enabled')B8-B9. TOC Animation & Pulse
AC:
- AC1: TOC slides in from the left (translateX -12px→0)
- AC2: Opacity 0→1 over 0.3s
- AC3: All sections open after animation
- AC4: toc-active-ready dispatched after slide-in
- AC5: Active item blue pulse visible briefly
- AC6: Active heading also pulses after headings panel
Unit tests:
test('animateTocStaggered opens all sections')
test('animateTocStaggered dispatches toc-active-ready')
test('animateTocStaggered dispatches toc-animation-done')
test('pulseElement applies then removes styles')test('animateTocStaggered opens all sections')
test('animateTocStaggered dispatches toc-active-ready')
test('animateTocStaggered dispatches toc-animation-done')
test('pulseElement applies then removes styles')B10. TOC Tooltip
AC:
- AC1: Tooltip appears after 650ms of hover
- AC2: Tooltip disappears immediately on leave
- AC3: Quick hover (<650ms) does not show the tooltip
- AC4: Tooltip positioned to the right of the item
- AC5: Description shown if data-tooltip-desc is present
Unit tests:
test('pointerenter starts 650ms timer')
test('pointerleave cancels timer')
test('tooltip positioned at rect.right + 10px')
test('tooltip shows title and description')test('pointerenter starts 650ms timer')
test('pointerleave cancels timer')
test('tooltip positioned at rect.right + 10px')
test('tooltip shows title and description')B11-B12. Sidebar Resize & Mobile
AC:
- AC1: Drag handle resizes the sidebar
- AC2: Width clamped between 180px and 50vw
- AC3: Width saved in localStorage
- AC4: Width restored on reload
- AC5: Mobile: sidebar slides from the left
- AC6: Mobile: semi-transparent backdrop
- AC7: Mobile: Escape/backdrop/navigation closes the sidebar
Unit tests:
test('mousedown starts dragging')
test('mousemove updates sidebar width')
test('mouseup saves to localStorage')
test('width clamped 180-50vw')
test('autoSize when no user width')
test('mobile toggle adds open class')
test('backdrop click removes open class')test('mousedown starts dragging')
test('mousemove updates sidebar width')
test('mouseup saves to localStorage')
test('width clamped 180-50vw')
test('autoSize when no user width')
test('mobile toggle adds open class')
test('backdrop click removes open class')B13. Guided Tour
AC:
- AC1: Tour starts with 8 steps
- AC2: Spotlight covers everything except the target
- AC3: Tooltip positioned with auto-flip
- AC4: Next/Prev/Escape work
- AC5: Focus trap in the tooltip
- AC6: Tour saved as completed in localStorage
- AC7: Affordance (pulse) shown for first visit
- AC8: Affordance auto-hides after 8s
Unit tests:
test('start() transitions to running')
test('next() increments stepIndex')
test('prev() decrements stepIndex')
test('end(true) transitions to completed')
test('end(false) transitions to skipped')
test('markCompleted writes localStorage')
test('showAffordance returns false if not first visit')
test('focus trap cycles through buttons')test('start() transitions to running')
test('next() increments stepIndex')
test('prev() decrements stepIndex')
test('end(true) transitions to completed')
test('end(false) transitions to skipped')
test('markCompleted writes localStorage')
test('showAffordance returns false if not first visit')
test('focus trap cycles through buttons')Integration tests:
test('full tour: start → 8 steps → complete')
test('Escape at any step → skipped')
test('first visit shows pulse + tooltip')
test('second visit no affordance')
test('mobile: sidebar opens for sidebar targets')test('full tour: start → 8 steps → complete')
test('Escape at any step → skipped')
test('first visit shows pulse + tooltip')
test('second visit no affordance')
test('mobile: sidebar opens for sidebar targets')B14-B15. Overlay & CV Prefetch
AC:
- AC1: First click creates the overlay (lazy)
- AC2: Zoom wheel x1.2/÷1.2 (clamp 0.1-5)
- AC3: Pan by drag
- AC4: Reset returns zoom=1, pan=0,0
- AC5: Escape/x/outside closes
- AC6: Focus restored after close
- AC7: CV prefetch starts on hover
- AC8: Download waits for the blob then creates ObjectURL
Unit tests:
test('createOverlay returns DOM element')
test('toggleOverlay shows/hides')
test('zoom clamp 0.1 to 5')
test('pan updates transform')
test('reset resets zoom and pan')
test('CV hover starts fetch')
test('CV request returns blob when loaded')
test('CV request waits when loading')test('createOverlay returns DOM element')
test('toggleOverlay shows/hides')
test('zoom clamp 0.1 to 5')
test('pan updates transform')
test('reset resets zoom and pan')
test('CV hover starts fetch')
test('CV request returns blob when loaded')
test('CV request waits when loading')B16. Search
AC:
- AC1: Ctrl+K opens search
- AC2: Real-time input filters results
- AC3: Scoring: title=10, tag=5, company=5, role=4, stack=3, desc=2, section=1
- AC4: Results sorted by descending score
- AC5: Max 20 results displayed
- AC6: Arrow keys navigate results (wrap)
- AC7: Enter selects and navigates
- AC8: Matched terms highlighted with
<mark>
Unit tests:
test('matchScore returns 0 for no match')
test('matchScore title > tag > desc')
test('highlight wraps matching text in <mark>')
test('navigateResults wraps at boundaries')
test('selectActive navigates and closes')
test('empty query shows first 20 items')
test('buildIndex creates entries from tocData')test('matchScore returns 0 for no match')
test('matchScore title > tag > desc')
test('highlight wraps matching text in <mark>')
test('navigateResults wraps at boundaries')
test('selectActive navigates and closes')
test('empty query shows first 20 items')
test('buildIndex creates entries from tocData')B17-B20. Theme Switcher (Dark/Light, HC, Accent, OS)
AC:
- AC1: Dark/light toggle changes data-color-mode
- AC2: HC toggle changes data-color-theme
- AC3: HC active → inline accent vars removed
- AC4: Accent swatch changes 6 CSS variables
- AC5: OS changes dots/btns + terminal title
- AC6: Everything persisted in localStorage (5 keys incl. font-size)
- AC7: Anti-FOUC: no flash on reload (instant apply, no permanent transitions)
- AC8: Mermaid re-rendered after every theme change
- AC9: .theme-switching class added on-demand (0.2s), removed on transitionend
- AC10: Ctrl+= / Ctrl+- change font size (prevent browser zoom)
Unit tests:
test('toggleColorMode flips dark/light')
test('applyColorMode sets attribute and localStorage')
test('applyColorTheme sets attribute')
test('applyAccentColor sets 6 CSS vars')
test('applyAccentColor removes vars in HC mode')
test('applyOS updates terminal title')
test('detectOS returns correct platform')
test('buildPaletteDOM creates 9 swatches')test('toggleColorMode flips dark/light')
test('applyColorMode sets attribute and localStorage')
test('applyColorTheme sets attribute')
test('applyAccentColor sets 6 CSS vars')
test('applyAccentColor removes vars in HC mode')
test('applyOS updates terminal title')
test('detectOS returns correct platform')
test('buildPaletteDOM creates 9 swatches')Integration tests:
test('dark→light → all colors transition smoothly')
test('HC mode → accent palette shows override notice')
test('accent change → Mermaid diagrams re-render')
test('reload → same theme restored (no flash)')
test('OS change → terminal dots/title update')test('dark→light → all colors transition smoothly')
test('HC mode → accent palette shows override notice')
test('accent change → Mermaid diagrams re-render')
test('reload → same theme restored (no flash)')
test('OS change → terminal dots/title update')B21. Analytics Consent
AC:
- AC1: No analytics script by default
- AC2: Banner shown if no choice made
- AC3: "OK" → script injected + banner removed
- AC4: "Opt out" → no script + banner removed
- AC5: Choice persisted in localStorage
- AC6: track() no-op if consent !== 'yes'
Unit tests:
test('getConsent returns null initially')
test('setConsent yes enables script')
test('setConsent no removes banner')
test('track noop without consent')
test('showBanner does not show if consent exists')test('getConsent returns null initially')
test('setConsent yes enables script')
test('setConsent no removes banner')
test('track noop without consent')
test('showBanner does not show if consent exists')B22-B24. Modals (Hire, Help, CV)
AC:
- AC1: Hire modal opens with focus on subject input
- AC2: Submit builds correct mailto: URL
- AC3: Help modal toggles with ? or click
- AC4: CV tray opens/closes
- AC5: CV view opens iframe with blob URL
- AC6: All closable via Escape/x/backdrop
- AC7: Escape chain respects priority
Unit tests:
test('hire modal opens on button click')
test('hire form builds correct mailto URL')
test('help modal toggles on ? key')
test('CV tray toggles on button click')
test('CV modal sets iframe src from blob')
test('escape chain priority order correct')test('hire modal opens on button click')
test('hire form builds correct mailto URL')
test('help modal toggles on ? key')
test('CV tray toggles on button click')
test('CV modal sets iframe src from blob')
test('escape chain priority order correct')B28. Font Size Controls (NEW)
AC:
- AC1: A+ button increases size by one step
- AC2: A- button decreases size by one step
- AC3: Clamped between 12px (XS) and 18px (XL)
- AC4: Size persisted in localStorage
- AC5: Size restored on reload without flash
- AC6: Buttons visually disabled at limits
- AC7: Works with all themes
- AC8: Responsive: buttons hidden on mobile ≤480px if insufficient space
Unit tests:
test('changeFontSize(+1) increments step')
test('changeFontSize(-1) decrements step')
test('changeFontSize clamps at min 12px')
test('changeFontSize clamps at max 18px')
test('applyFontSize sets documentElement.style.fontSize')
test('applyFontSize saves to localStorage')
test('getStoredFontSize returns default when no LS')
test('getStoredFontSize returns stored value')test('changeFontSize(+1) increments step')
test('changeFontSize(-1) decrements step')
test('changeFontSize clamps at min 12px')
test('changeFontSize clamps at max 18px')
test('applyFontSize sets documentElement.style.fontSize')
test('applyFontSize saves to localStorage')
test('getStoredFontSize returns default when no LS')
test('getStoredFontSize returns stored value')Integration tests:
test('click A+ → font size increases visually')
test('click A− → font size decreases visually')
test('reload → font size restored from localStorage')
test('no FOUC: font size applied before first paint')
test('font size works with dark/light/HC themes')
test('A+ at max → no change')
test('A− at min → no change')test('click A+ → font size increases visually')
test('click A− → font size decreases visually')
test('reload → font size restored from localStorage')
test('no FOUC: font size applied before first paint')
test('font size works with dark/light/HC themes')
test('A+ at max → no change')
test('A− at min → no change')PART E — FEATURE COMPLIANCE REPORT
E1. Coverage Matrix
| # | Feature | Feature Status | AC | Unit Tests | Integration Tests | Tests Status |
|---|---|---|---|---|---|---|
| 1 | Prefetch hover | BUILT | 7 | 8 | 5 | tests pending |
| 2 | SPA Navigation | BUILT | 10 | 6 | 5 | tests pending |
| 3 | Section collapse | BUILT | 3 | 3 | 2 | tests pending |
| 4 | Tree expand | BUILT | 2 | 2 | 1 | tests pending |
| 5 | Inner TOC headings | BUILT | 7 | 6 | 4 | tests pending |
| 6 | Scroll spy | BUILT | 4 | 5 | 3 | tests pending |
| 7 | User scroll control | BUILT | 3 | 3 | 1 | tests pending |
| 8 | TOC entrance anim | BUILT | 4 | 4 | 0 | tests pending |
| 9 | Active pulse | BUILT | 2 | 1 | 0 | tests pending |
| 10 | TOC tooltip | BUILT | 5 | 4 | 0 | tests pending |
| 11 | Sidebar resize | BUILT | 4 | 5 | 0 | tests pending |
| 12 | Mobile sidebar | BUILT | 3 | 2 | 0 | tests pending |
| 13 | Guided tour | BUILT | 8 | 8 | 5 | tests pending |
| 14 | Overlay zoom/pan | BUILT | 6 | 8 | 0 | tests pending |
| 15 | CV prefetch | BUILT | 2 | 4 | 0 | tests pending |
| 16 | Search | BUILT | 8 | 7 | 0 | tests pending |
| 17 | Theme dark/light | BUILT | 3 | 3 | 2 | tests pending |
| 18 | High contrast | BUILT | 2 | 2 | 1 | tests pending |
| 19 | Accent palette | BUILT | 3 | 2 | 1 | tests pending |
| 20 | OS selector | BUILT | 2 | 2 | 1 | tests pending |
| 21 | Analytics consent | BUILT | 6 | 5 | 0 | tests pending |
| 22 | Hire modal | BUILT | 3 | 2 | 0 | tests pending |
| 23 | Help modal | BUILT | 2 | 1 | 0 | tests pending |
| 24 | CV tray + modal | BUILT | 3 | 2 | 0 | tests pending |
| 25 | Content fade | BUILT | 2 | 0 | 2 | tests pending |
| 26 | Copy flash | BUILT | 2 | 1 | 1 | tests pending |
| 27 | Escape chain | BUILT | 1 | 1 | 1 | tests pending |
| 28 | Font size controls | BUILT | 8 | 8 | 7 | tests pending |
| TOTALS | 28/28 | 119 | 104 | 42 | 0/146 |
E2. Metrics
- Total features: 28 — all implemented
- Acceptance criteria: 119
- Specified unit tests: 104
- Specified integration tests: 42
- Total tests: 146
- AC coverage: 100% (every feature has ACs)
- Test specification coverage: 100% (every feature has at least unit tests)
- Implemented features: 28/28 (all built)
- Implemented tests: 0/146 (all pending)
E3. Test Implementation Priority
P0 — Critical (pure logic, testable without DOM):
- Prefetch state machine (8 tests)
- Tour state machine (8 tests)
- Search scoring (7 tests)
- Font size controls (8 tests)
- CV prefetch (4 tests)
P1 — Important (DOM but isolatable): 6. Scroll spy (5+3 tests) 7. SPA Navigation (6+5 tests) 8. Section/tree collapse (5+3 tests) 9. Theme switcher (8+5 tests) 10. Analytics consent (5 tests)
P2 — Nice-to-have (visual, hard to unit test): 11. TOC animation (4 tests) 12. Active pulse (1 test) 13. TOC tooltip (4 tests) 14. Sidebar resize (5 tests) 15. Modals (5 tests) 16. Escape chain (1+1 tests)
E4. Recommended Test Stack
Unit tests : vitest (already Node.js, fast, native ESM)
DOM tests : @testing-library/dom + jsdom
Integration : Playwright (real browser, screenshots)
Assertions : vitest built-in (expect, vi.fn())Unit tests : vitest (already Node.js, fast, native ESM)
DOM tests : @testing-library/dom + jsdom
Integration : Playwright (real browser, screenshots)
Assertions : vitest built-in (expect, vi.fn())File structure:
test/
├── unit/
│ ├── prefetch-manager.test.js
│ ├── tour-state.test.js
│ ├── search-scoring.test.js
│ ├── font-size.test.js
│ ├── scroll-spy.test.js
│ ├── theme-switcher.test.js
│ └── analytics-consent.test.js
├── integration/
│ ├── navigation.test.js
│ ├── toc-interactions.test.js
│ ├── theme-cascade.test.js
│ ├── tour-flow.test.js
│ └── font-size-persistence.test.js
└── setup.jstest/
├── unit/
│ ├── prefetch-manager.test.js
│ ├── tour-state.test.js
│ ├── search-scoring.test.js
│ ├── font-size.test.js
│ ├── scroll-spy.test.js
│ ├── theme-switcher.test.js
│ └── analytics-consent.test.js
├── integration/
│ ├── navigation.test.js
│ ├── toc-interactions.test.js
│ ├── theme-cascade.test.js
│ ├── tour-flow.test.js
│ └── font-size-persistence.test.js
└── setup.js