feat(child-theme): scaffold asterion-bricks v0.1.0

Initial child theme structure inheriting from Bricks parent. Token-driven,
RGPD-native, mobile-first foundation ready for component and template work.

What's in this commit
- style.css: WP theme metadata (Template: bricks, v0.1.0)
- functions.php: enqueue cascade tokens -> utilities -> components,
  defer-loaded main.js, font preload, theme support, image sizes
  (av-hero 2400x1350, av-card 800x600, av-thumb 400x300), Gutenberg
  default styles dequeued
- theme.json: Gutenberg color/typo palette mirroring tokens
- assets/css/tokens.css: full design system per Design Handoff section 2
  (brand colors, neutrals, semantics, type scale + line-heights, 8pt
  spacing, radii, shadows, z-index, motion, prefers-reduced-motion)
- assets/css/utilities.css: modern reset + .av-container, .av-skip-link,
  .av-sr-only, .av-stack, .av-section helpers, opinionated heading scale
- assets/css/components.css: index of planned components + buttons
  (5 variants x 4 sizes per Design Handoff section 3.1) and links
- assets/js/main.js: vanilla skeleton with header scroll, IntersectionObserver
  reveal, count-up, mobile drawer; respects prefers-reduced-motion
- inc/cpt.php: register_post_type case_study (slug /customers/) + scenario
  (slug /scenarios/) + shared 'industry' taxonomy
- inc/seo.php: Organization JSON-LD on home; placeholders for Product,
  Article, FAQPage, BreadcrumbList
- assets/fonts/: Inter Variable + Italic WOFF2 self-hosted (RGPD; no
  Google Fonts CDN). Inter Display @font-face commented — Inter 4+ uses
  opsz axis from the same file.

Out of scope (intentionally deferred)
- Component fills: cards, forms, navigation, alerts, modals, tabs (placeholders)
- Page templates: home and beyond
- Bricks JSON template exports
- Schema.org Product / Article / FAQPage / BreadcrumbList implementations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 11:49:10 +02:00
parent f9192631ff
commit 6f62082dc4
15 changed files with 1359 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
/**
* Asterion Bricks — Components
*
* Component-level styles, layered on top of tokens + utilities.
* Loaded last in the cascade.
*
* Naming : `.av-<component>` base, `.av-<component>--<modifier>`,
* `.av-<component>__<element>`, `.av-<component>.is-<state>`.
*/
/* ============================================================================
Section index (filled progressively as components ship)
1. Buttons .av-btn
2. Links .av-link
3. Forms .av-input, .av-label, .av-select, .av-textarea
4. Cards .av-card and variants
5. Stat callout .av-stat
6. Navigation header .av-header, .av-nav, .av-megamenu, .av-drawer
7. Footer .av-footer
8. Alerts & toasts .av-alert, .av-toast
9. Modals .av-modal
10. Tooltips & popovers .av-tooltip
11. Tabs & accordions .av-tabs, .av-accordion
12. Tables .av-table
13. Badges & tags .av-badge
Reference : Design Handoff Specification, section 3.
============================================================================ */
/* ============================================================================
1. Buttons (Design Handoff section 3.1)
Variants : primary, secondary, ghost, on-dark, destructive
Sizes : sm (32px), md (44px default), lg (52px), xl (60px hero only)
States : default, hover, focus, active, disabled, loading
Border radius : radius-sm by default (tactical aesthetic — crisp corners).
============================================================================ */
.av-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 0 var(--space-5);
height: 44px;
font-family: var(--font-body);
font-size: var(--text-body-md);
font-weight: var(--font-weight-semibold);
line-height: 1;
letter-spacing: 0;
text-decoration: none;
border: var(--border-default) solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
user-select: none;
white-space: nowrap;
transition:
background-color var(--duration-fast) var(--ease-default),
color var(--duration-fast) var(--ease-default),
transform var(--duration-fast) var(--ease-default),
box-shadow var(--duration-default) var(--ease-default),
border-color var(--duration-fast) var(--ease-default);
}
.av-btn:disabled,
.av-btn.is-disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.av-btn.is-loading {
cursor: wait;
}
/* --- Sizes --- */
.av-btn--sm {
height: 32px;
padding: 0 var(--space-3);
font-size: var(--text-body-sm);
}
.av-btn--md {
height: 44px;
padding: 0 var(--space-5);
}
.av-btn--lg {
height: 52px;
padding: 0 var(--space-7, 1.75rem);
font-size: var(--text-body-lg);
}
.av-btn--xl {
height: 60px;
padding: 0 var(--space-9, 2.25rem);
font-size: var(--text-h4);
font-weight: var(--font-weight-bold);
}
/* --- Variant : primary (gold fill, navy text) --- */
.av-btn--primary {
background-color: var(--color-brand-gold);
color: var(--color-brand-navy);
border-color: var(--color-brand-gold);
}
.av-btn--primary:hover,
.av-btn--primary:focus-visible {
background-color: var(--color-brand-gold-soft);
border-color: var(--color-brand-gold-soft);
transform: translateY(-1px);
box-shadow: var(--shadow-glow-gold);
}
.av-btn--primary:active {
transform: translateY(0);
filter: brightness(0.92);
box-shadow: none;
}
/* --- Variant : secondary (outlined, navy text) --- */
.av-btn--secondary {
background-color: transparent;
color: var(--color-brand-navy);
border-color: var(--color-brand-navy);
}
.av-btn--secondary:hover,
.av-btn--secondary:focus-visible {
background-color: var(--color-brand-navy);
color: var(--color-text-on-dark);
}
/* --- Variant : ghost (navy text, no border) --- */
.av-btn--ghost {
background-color: transparent;
color: var(--color-brand-navy);
border-color: transparent;
}
.av-btn--ghost:hover,
.av-btn--ghost:focus-visible {
background-color: var(--color-bg-subtle);
}
/* --- Variant : on-dark (for dark backgrounds — text gold or white) --- */
.av-btn--on-dark.av-btn--primary {
background-color: var(--color-brand-gold);
color: var(--color-brand-navy);
}
.av-btn--on-dark.av-btn--secondary {
color: var(--color-text-on-dark);
border-color: var(--color-text-on-dark);
}
.av-btn--on-dark.av-btn--secondary:hover,
.av-btn--on-dark.av-btn--secondary:focus-visible {
background-color: var(--color-text-on-dark);
color: var(--color-brand-navy);
}
.av-btn--on-dark.av-btn--ghost {
color: var(--color-text-on-dark);
}
.av-btn--on-dark.av-btn--ghost:hover,
.av-btn--on-dark.av-btn--ghost:focus-visible {
background-color: var(--color-bg-dark-elevated);
}
/* --- Variant : destructive --- */
.av-btn--destructive {
background-color: var(--color-alert-red);
color: var(--color-text-on-dark);
border-color: var(--color-alert-red);
}
.av-btn--destructive:hover,
.av-btn--destructive:focus-visible {
filter: brightness(1.08);
}
/* ============================================================================
2. Links (Design Handoff section 3.2)
============================================================================ */
.av-link {
color: var(--color-brand-navy);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-default);
}
.av-link:hover {
color: var(--color-brand-gold);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 4px;
}
.av-link--on-dark {
color: var(--color-text-on-dark);
}
.av-link--on-dark:hover {
color: var(--color-brand-gold);
}
/* ============================================================================
3. Forms — placeholder
To be filled from Design Handoff section 3.3 when conversion templates are built.
============================================================================ */
/* ============================================================================
4. Cards — placeholder
To be filled from Design Handoff section 3.4 when home solutions/industries
sections are built.
============================================================================ */
/* ============================================================================
5. Stat callout — placeholder
============================================================================ */
/* ============================================================================
6. Navigation (header, mega menu, drawer) — placeholder
============================================================================ */
/* ============================================================================
7. Footer — placeholder
============================================================================ */

View File

@@ -0,0 +1,234 @@
/**
* Asterion Bricks — Design tokens
*
* Source of truth for the design system. Every component, every utility,
* every Bricks override references variables defined here.
*
* Reference : Design Handoff Specification (livrable 2/2), section 2.
*
* Hard rule : NEVER hardcode a color, spacing, radius, or duration in any
* other file. If it's not yet a token, add it here first, then use it.
*/
/* ============================================================================
Self-hosted fonts (RGPD — no Google Fonts CDN)
Drop the .woff2 files into ../fonts/ and these declarations pick them up.
See ../fonts/README.md for download instructions.
============================================================================ */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("../fonts/Inter-Variable.woff2") format("woff2-variations"),
url("../fonts/Inter-Variable.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url("../fonts/Inter-Variable-Italic.woff2") format("woff2-variations"),
url("../fonts/Inter-Variable-Italic.woff2") format("woff2");
}
/*
Inter Display is intentionally NOT registered as its own @font-face here.
Since Inter 4.x, "Display" optical sizing is baked into the main variable
font via the `opsz` axis. The CSS stack `"Inter Display", "Inter", ...`
in --font-display falls through to "Inter" (registered above), so headlines
pick up the correct file with no 404 noise.
If a separate InterDisplay-Variable.woff2 is dropped into ../fonts/ later
(e.g. from the rsms.me extras package), uncomment the block below.
@font-face {
font-family: "Inter Display";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("../fonts/InterDisplay-Variable.woff2") format("woff2-variations"),
url("../fonts/InterDisplay-Variable.woff2") format("woff2");
}
*/
/* ============================================================================
Tokens
============================================================================ */
:root {
/* ---- BRAND COLORS ---- */
--color-brand-navy: #0B1F3A;
--color-brand-navy-deep: #081427;
--color-brand-gold: #C9A45A;
--color-brand-gold-soft: #E0C892;
--color-alert-red: #C8102E;
/* ---- NEUTRALS ---- */
--color-text-primary: #0B1F3A;
--color-text-secondary: #3E4C5E;
--color-text-muted: #6B7785;
--color-text-on-dark: #FFFFFF;
--color-text-on-dark-muted: #B6BFCC;
--color-bg-default: #FFFFFF;
--color-bg-subtle: #F5F7FA;
--color-bg-elevated: #FFFFFF;
--color-bg-dark: #0B1F3A;
--color-bg-dark-elevated: #13294B;
--color-border-default: #CCD3DC;
--color-border-subtle: #E5E9EE;
--color-border-on-dark: #1F3252;
/* ---- SEMANTIC COLORS ---- */
--color-success: #1F8B4C;
--color-warning: #C29327;
--color-error: #C8102E;
--color-info: #1E5BA8;
/* ---- TYPOGRAPHY: families ---- */
--font-display: "Inter Display", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
/* ---- TYPOGRAPHY: sizes (rem-based, root = 16px) ---- */
--text-overline: 0.75rem; /* 12px */
--text-body-sm: 0.875rem; /* 14px */
--text-body-md: 1rem; /* 16px */
--text-body-lg: 1.25rem; /* 20px */
--text-h4: 1.125rem; /* 18px */
--text-h3: 1.375rem; /* 22px */
--text-h2: 1.75rem; /* 28px */
--text-h1: 2.25rem; /* 36px */
--text-display-md: 2.75rem; /* 44px */
--text-display-lg: 3.5rem; /* 56px */
--text-display-xl: 4.5rem; /* 72px */
/* ---- TYPOGRAPHY: line heights (per Design Handoff section 2.2) ---- */
--leading-display-xl: 1.05;
--leading-display-lg: 1.10;
--leading-display-md: 1.15;
--leading-h1: 1.20;
--leading-h2: 1.25;
--leading-h3: 1.30;
--leading-h4: 1.35;
--leading-body-lg: 1.55;
--leading-body-md: 1.60;
--leading-body-sm: 1.50;
--leading-overline: 1.40;
/* ---- TYPOGRAPHY: weights ---- */
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* ---- TYPOGRAPHY: tracking ---- */
--tracking-tight: -0.02em; /* display */
--tracking-snug: -0.01em; /* headings */
--tracking-normal: 0; /* body */
--tracking-overline: 0.08em; /* eyebrows, all-caps */
/* ---- SPACING (8-pt grid) ---- */
--space-0: 0;
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
--space-24: 6rem; /* 96px */
--space-32: 8rem; /* 128px */
/* ---- LAYOUT: container & grid ---- */
--container-max: 1280px;
--container-padding-mobile: 1.5rem; /* 24px */
--container-padding-tablet: 2rem; /* 32px */
--container-padding-desktop: 4rem; /* 64px */
--gutter-mobile: 1rem; /* 16px */
--gutter-tablet: 1rem; /* 16px */
--gutter-desktop: 1.5rem; /* 24px */
/* ---- BORDER RADIUS ---- */
--radius-none: 0;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--radius-pill: 9999px;
--radius-full: 50%;
/* ---- BORDER WIDTHS ---- */
--border-thin: 1px;
--border-default: 2px;
--border-thick: 3px;
/* ---- SHADOWS ---- */
--shadow-none: none;
--shadow-xs: 0 1px 2px rgba(11, 31, 58, 0.05);
--shadow-sm: 0 2px 4px rgba(11, 31, 58, 0.08), 0 1px 2px rgba(11, 31, 58, 0.06);
--shadow-md: 0 4px 12px rgba(11, 31, 58, 0.10), 0 2px 4px rgba(11, 31, 58, 0.06);
--shadow-lg: 0 12px 24px rgba(11, 31, 58, 0.14), 0 4px 8px rgba(11, 31, 58, 0.08);
--shadow-glow-gold: 0 0 24px rgba(201, 164, 90, 0.35);
/* ---- Z-INDEX SCALE ---- */
--z-base: 1;
--z-dropdown: 1000;
--z-sticky: 1100;
--z-fixed: 1200;
--z-modal-backdrop: 1300;
--z-modal: 1310;
--z-popover: 1400;
--z-toast: 1500;
/* ---- MOTION: easing ---- */
--ease-default: cubic-bezier(0.16, 1, 0.3, 1); /* premium snap */
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-linear: linear;
/* ---- MOTION: durations ---- */
--duration-instant: 100ms; /* micro-interactions */
--duration-fast: 150ms; /* button hover, focus */
--duration-default: 250ms; /* card hover, accordion */
--duration-slow: 400ms; /* modal, drawer */
--duration-deliberate: 600ms; /* hero animations */
/* ---- A11Y / TOUCH ---- */
--tap-target-min: 44px; /* WCAG AA + Apple HIG */
--focus-ring-width: 2px;
--focus-ring-offset: 2px;
--focus-ring-color: var(--color-info);
/* ---- BREAKPOINTS (used in @media; here for reference) ----
xs: 0 — mobile portrait (375px reference)
sm: 640px — mobile landscape, small tablets
md: 768px — tablet portrait
lg: 1024px — tablet landscape, small laptop
xl: 1280px — desktop default
2xl: 1536px — large desktop
*/
}
/* ============================================================================
Reduced motion — respect user preference (Design Handoff section 5.5)
============================================================================ */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,214 @@
/**
* Asterion Bricks — Utilities
*
* Modern CSS reset + reusable utility classes.
* Loaded after tokens.css, before components.css.
*
* Naming : prefix `av-` on every custom class.
*/
/* ============================================================================
Modern CSS reset (inspired by Andy Bell's modern-css-reset, Josh Comeau)
============================================================================ */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; padding: 0; }
html {
font-size: 100%;
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-body);
font-size: var(--text-body-md);
line-height: var(--leading-body-md);
color: var(--color-text-primary);
background-color: var(--color-bg-default);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
height: auto;
}
input, button, textarea, select { font: inherit; color: inherit; }
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
a {
color: inherit;
text-decoration: none;
}
ul, ol { list-style: none; }
table { border-collapse: collapse; border-spacing: 0; }
/* ============================================================================
Heading hierarchy — opinionated defaults consumed when an element
carries no Bricks-specific class. Bricks may override, that's expected.
============================================================================ */
h1 {
font-family: var(--font-display);
font-size: var(--text-h1);
line-height: var(--leading-h1);
font-weight: var(--font-weight-bold);
letter-spacing: var(--tracking-snug);
}
h2 {
font-family: var(--font-display);
font-size: var(--text-h2);
line-height: var(--leading-h2);
font-weight: var(--font-weight-bold);
letter-spacing: var(--tracking-snug);
}
h3 {
font-family: var(--font-display);
font-size: var(--text-h3);
line-height: var(--leading-h3);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-snug);
}
h4 {
font-family: var(--font-display);
font-size: var(--text-h4);
line-height: var(--leading-h4);
font-weight: var(--font-weight-semibold);
}
p {
max-width: 72ch; /* prose readability per Design Handoff section 2.2 */
}
/* ============================================================================
Focus visible — keyboard accessibility
Removes outline only when focus came from the mouse; preserves it for keyboard.
============================================================================ */
:focus { outline: none; }
:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
/* ============================================================================
Skip link — first focusable element on every page (a11y)
============================================================================ */
.av-skip-link {
position: absolute;
top: -40px;
left: 0;
padding: var(--space-3) var(--space-4);
background: var(--color-brand-navy);
color: var(--color-text-on-dark);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-semibold);
z-index: var(--z-toast);
border-bottom-right-radius: var(--radius-md);
}
.av-skip-link:focus,
.av-skip-link:focus-visible {
top: 0;
}
/* ============================================================================
Visually hidden — content readable by screen readers, hidden visually
============================================================================ */
.av-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ============================================================================
Container — centred max-width with responsive padding
============================================================================ */
.av-container {
max-width: var(--container-max);
margin-inline: auto;
padding-inline: var(--container-padding-mobile);
width: 100%;
}
@media (min-width: 768px) {
.av-container { padding-inline: var(--container-padding-tablet); }
}
@media (min-width: 1280px) {
.av-container { padding-inline: var(--container-padding-desktop); }
}
.av-container--narrow {
max-width: 800px; /* article body */
}
/* ============================================================================
Tap target minimum — WCAG AA / Apple HIG (44×44px)
============================================================================ */
.av-tap-target {
min-width: var(--tap-target-min);
min-height: var(--tap-target-min);
}
/* ============================================================================
Stack helpers — vertical rhythm via owl-selector
============================================================================ */
.av-stack > * + * { margin-top: var(--av-stack-gap, var(--space-4)); }
.av-stack--sm > * + * { margin-top: var(--space-2); }
.av-stack--md > * + * { margin-top: var(--space-4); }
.av-stack--lg > * + * { margin-top: var(--space-8); }
.av-stack--xl > * + * { margin-top: var(--space-12); }
/* ============================================================================
Section padding helpers (vertical rhythm between page sections)
============================================================================ */
.av-section {
padding-block: var(--space-16);
}
@media (min-width: 1024px) {
.av-section { padding-block: var(--space-20); }
}
.av-section--hero {
padding-block: var(--space-24);
}
@media (min-width: 1024px) {
.av-section--hero { padding-block: var(--space-32); }
}
.av-section--dark {
background-color: var(--color-bg-dark);
color: var(--color-text-on-dark);
}
/* ============================================================================
Print
============================================================================ */
@media print {
.av-skip-link, .av-no-print { display: none !important; }
body { color: #000; background: #fff; }
a::after { content: " (" attr(href) ")"; font-size: 0.8em; }
}

View File

@@ -0,0 +1,39 @@
# Self-hosted Inter fonts
Self-hosted to comply with **GDPR**. Loading fonts from Google Fonts CDN sends each visitor's IP address to Google — explicitly ruled illegal by a German court in 2022 (LG München I, 20 January 2022, file no. 3 O 17493/20). For an institutional B2G site addressing French ministries and EU security forces, Google Fonts is a non-starter.
## Files expected here
| File | Purpose | Required |
|-----------------------------------|--------------------------------------------------|----------|
| `Inter-Variable.woff2` | Body + UI font (variable axis: weight 100-900) | ✅ Yes |
| `Inter-Variable-Italic.woff2` | Italic variant | ✅ Yes |
| `InterDisplay-Variable.woff2` | Optimized for display sizes (titles, hero) | 🟡 Recommended |
## Where to download
**Official source** : https://rsms.me/inter
1. Visit https://github.com/rsms/inter/releases/latest
2. Download the latest `Inter-X.Y.zip`.
3. Inside the ZIP, locate the `web/` folder.
4. Copy `Inter-Variable.woff2` and `Inter-Variable-Italic.woff2` into this directory.
5. For `InterDisplay-Variable.woff2`, look in `extras/web/` if present in the release, or use the same Inter Variable file (Inter 4+ merged Display into the optical-size axis).
**Alternative via npm** (if you already have a node toolchain) :
```bash
npm install @fontsource-variable/inter
cp node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2 ./Inter-Variable.woff2
cp node_modules/@fontsource-variable/inter/files/inter-latin-wght-italic.woff2 ./Inter-Variable-Italic.woff2
```
## License
[SIL Open Font License 1.1](https://github.com/rsms/inter/blob/master/LICENSE.txt). Free to use, modify, and redistribute, including in commercial websites. Attribution not required but appreciated.
## After dropping the WOFF2 here
Nothing else to do. The `@font-face` declarations in `../css/tokens.css` reference these filenames directly. The `<link rel="preload">` in `functions.php` (`wp_head` action) starts fetching them on page load.
If a file is missing, the page silently falls back to the system stack (`-apple-system`, `Segoe UI`, `Roboto`, etc.) without any user-visible error.

View File

@@ -0,0 +1,149 @@
/**
* Asterion Bricks — main JS
*
* Vanilla JS, defer-loaded. Wraps everything in DOMContentLoaded.
* Performance budget : initial bundle < 200 KB gzipped.
*
* Modules planned :
* - Header scroll behavior (background swap after 80px)
* - Mega menu trigger (hover desktop, click touch, ESC closes)
* - Mobile drawer (full-screen overlay, accordion sections)
* - Scroll-triggered fade-in (IntersectionObserver, threshold 0.15)
* - Stat callouts count-up (1000ms ease-out)
* - Hero video lazy-load + reduced-motion fallback to poster
*/
(function () {
'use strict';
/**
* Detect reduced motion once and expose a flag for downstream modules.
* Updated reactively if the user toggles the setting mid-session.
*/
const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let prefersReducedMotion = reducedMotionQuery.matches;
reducedMotionQuery.addEventListener('change', (e) => {
prefersReducedMotion = e.matches;
});
/**
* Header scroll behavior.
* After 80px scroll, header gets a `is-scrolled` class
* (consumed by .av-header.is-scrolled rule in components.css).
*/
function initHeaderScroll() {
const header = document.querySelector('.av-header');
if (!header) return;
let ticking = false;
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
header.classList.toggle('is-scrolled', window.scrollY > 80);
ticking = false;
});
ticking = true;
}
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
}
/**
* Scroll-triggered fade-in (Design Handoff section 5.3).
* Sections fade-in + slide-up 16px on entry, threshold 0.15, trigger once.
* Skipped entirely if user prefers reduced motion.
*/
function initScrollReveal() {
if (prefersReducedMotion) return;
const targets = document.querySelectorAll('[data-av-reveal]');
if (!targets.length) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-revealed');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.15, rootMargin: '0px 0px -10% 0px' });
targets.forEach((target) => observer.observe(target));
}
/**
* Stat count-up (Design Handoff section 5.3).
* Element : <span data-av-countup="0|target|duration">target</span>
* Animates from 0 to target value over duration ms, ease-out.
*/
function initStatCountUp() {
if (prefersReducedMotion) return;
const stats = document.querySelectorAll('[data-av-countup]');
if (!stats.length) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const el = entry.target;
const target = parseFloat(el.dataset.avCountup) || 0;
const duration = parseInt(el.dataset.avCountupDuration || '1000', 10);
const start = performance.now();
const tick = (now) => {
const elapsed = Math.min((now - start) / duration, 1);
// ease-out cubic
const eased = 1 - Math.pow(1 - elapsed, 3);
el.textContent = Math.round(target * eased).toLocaleString();
if (elapsed < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
observer.unobserve(el);
});
}, { threshold: 0.5 });
stats.forEach((s) => observer.observe(s));
}
/**
* Mobile drawer toggle.
* Trigger : <button class="av-drawer-toggle" aria-controls="av-drawer">
* Drawer : <div id="av-drawer" class="av-drawer">
*/
function initMobileDrawer() {
const toggle = document.querySelector('.av-drawer-toggle');
const drawer = document.getElementById('av-drawer');
if (!toggle || !drawer) return;
const open = () => {
drawer.classList.add('is-open');
toggle.setAttribute('aria-expanded', 'true');
document.body.style.overflow = 'hidden';
};
const close = () => {
drawer.classList.remove('is-open');
toggle.setAttribute('aria-expanded', 'false');
document.body.style.overflow = '';
};
toggle.addEventListener('click', () => {
drawer.classList.contains('is-open') ? close() : open();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && drawer.classList.contains('is-open')) close();
});
}
/**
* Boot.
*/
document.addEventListener('DOMContentLoaded', () => {
initHeaderScroll();
initScrollReveal();
initStatCountUp();
initMobileDrawer();
});
})();