Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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.csspublic/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)
Diagram

Build pipeline — summary

┌─────────────────────────────────────────────────────────────┐
│ DEVELOPER MACHINE                                           │
│                                                             │
│  content/**/*.md ──┐                                        │
│  css/style.css ────┤   node scripts/workflow.js             │
│  js/*.js ──────────┤   ├─ build-toc.js    → toc.json       │
│                    ├──→├─ build-static.jspublic/*.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

JS 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">   │
│ 280pxflex: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

4 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;
}

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: '### '; }

Code blocks

  • Background var(--bg-secondary), border, border-radius 6px
  • Copy button: opacity: 0 → hover .code-blockopacity: 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() + MutationObserver on data-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; }

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 */
}

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; }

≤480px

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, accessible
  • aria-expanded: on all .toc-section-title toggles
  • role="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 # ## ### */
  }
}

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); }

A10. Performance

  • GPU: will-change: transform, opacity on prefetch bar and watermark
  • Scroll throttle: requestAnimationFrame + ticking flag = 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


Each <a class="toc-item"> link: its own instance in Map<href, Entry>.

Diagram
  • hover(): if idle → fetch. Otherwise no-op.
  • click(): if loaded → cache. If loading → waiter Promise. If idle → fetch + waiter.

B2. SPA Navigation (13 steps)

Diagram

B3. TOC Section Collapse/Expand

Diagram

tocExpand (data-nav): click title → navigateTo() instead of toggle.


B4. Tree Items (Parent/Children)

Diagram
  • click expand → stopPropagation (does not navigate)
  • click parent label → navigateTo(page)

B5. Inner TOC (Headings Panel)

Diagram

B6. Scroll Spy

Diagram

Walk-up slug if h4+: a--b--ca--ba. Dispatch scrollspy-active, update .toc-heading.active.


B7. TOC User Scroll Control

Diagram

B8. TOC Animation (Staggered Entrance)

Diagram

B9. Active Pulse

Diagram

Also fires on the active heading when toc-headings-rendered is dispatched.


B10. TOC Tooltip (Hover)

Diagram

B11. Sidebar Resize (Drag)

Diagram

Auto-size (if !userHasSetWidth): min(max(180, 22vw), 450)px


B12. Mobile Sidebar (≤768px)

Diagram

B13. Guided Tour

Diagram

Affordance (first visit)

Diagram

Spotlight: box-shadow: 0 0 0 9999px rgba(0,0,0,0.7). Focus trap: Tab cycles Prev-Next-Close.


B14. Image/Diagram Overlay

Diagram

B15. CV Prefetch (PDF)

Diagram

B16. Search (Search Modal)

Diagram

B17. Theme Dark/Light

Diagram

Cascade: withThemeTransition → add .theme-switchingapplyColorModesetAttributelocalStorageapplyAccentColormermaid.initializereRenderMermaidMutationObserverupdateMermaidTheme → swap img src → body transitionend → remove .theme-switching


B18. High Contrast

Diagram

B19. Accent Palette

Diagram

Click swatch → applyAccentColor(key) → set 6 inline CSS vars → re-init Mermaid → close palette.


B20. OS Selector

Diagram

data-os → dots/btns style + terminal title + localStorage. Auto-detect via navigator.userAgent.


Diagram

B22. Hire Modal

Diagram

B23. Help Modal

Diagram

B24. CV Tray + CV Modal

Diagram

B25. Content Fade

Diagram

Force-layout trick: getBoundingClientRect() between transition:none and transition:0.08s.


B26. Copy Flash

Diagram

B27. Escape Chain (descending priority)

Diagram

PART C — CROSS-CUTTING PATTERNS


C1. Anti-FOUC (Flash of Unstyled Content)

PHASE 1Inline <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 3User 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 elements

Why 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-switching

C3. 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.ico

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

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"

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      │
                                      └──────────────┘

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)

Theme Change

click dark/light
  │
  ├→ withThemeTransition: add .theme-switching
  ├→ applyColorMode → applyAccentColor → getMermaidConfig
  │                                            │
  │                                      mermaid.initialize
  │                                            │
  │                                   MutationObserver
  │                                            │
  │                                   updateMermaidTheme
  │                                            │
  │                                   swap img[data-src-*]
  │
  └→ body transitionend → remove .theme-switching

Final 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
Print @media print A8
Scrollbar Custom webkit A9
Performance GPU, throttle, lazy, prefetch A10

Verification

  1. DevTools → Network → hover TOC link → observe fetch (prefetch)
  2. DevTools → Elements → classes (.open, .active, .expanded, .prefetch-*)
  3. Console → dispatchEvent(new Event('toc-active-ready')) → headings
  4. Performance tab → navigation → transitions 60fps
  5. Test Escape chain with tour + simultaneous modals
  6. Theme cascade: dark→light → Mermaid SVG swap + accent recalculation
  7. Anti-FOUC: clear localStorage → reload → no flash
  8. HC mode → palette "Overridden" notice
  9. Search: "schneider" → experience first (company=5)
  10. Favicon: navigate between pages → tab icon changes
  11. Font size: click A+/A- → text resizes, persists on reload
  12. Font size: Ctrl+= / Ctrl+- → same as buttons, browser zoom prevented
  13. 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; }

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>

CSS:

.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]);
}

Anti-FOUC (inline <head>):

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-loading visible during fetch
  • AC6: CSS class .prefetch-loaded after success (with fade-out)
  • AC7: CSS class .prefetch-error after 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')

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

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

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

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

Integration tests:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. Prefetch state machine (8 tests)
  2. Tour state machine (8 tests)
  3. Search scoring (7 tests)
  4. Font size controls (8 tests)
  5. 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)

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