snapkitty
developer-tools
c++
hyperkitty-chromium / index.html
SNAPKITTYWEST's picture
Add covenant SPDX headers to every remaining unmarked source file
0196580 verified
Raw History Blame Contribute Delete
24.6 kB
<!DOCTYPE html>
<!-- SPDX-License-Identifier: LicenseRef-Sovereign-Leviathan-SL-AGPL3-001
Licensing: see LICENSE in the repository root. -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HyperKitty Chromium β€” Erlang/OTP Browser Agent Platform</title>
<style>
:root {
--bg0: #0a0a0f;
--bg1: #0f0f1a;
--bg2: #141420;
--border: #2a2a45;
--green: #00ff88;
--blue: #00aaff;
--orange: #ffaa00;
--purple: #cc66ff;
--red: #ff4444;
--dim: #666;
--text: #ccc;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg0);
color: var(--text);
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 14px;
line-height: 1.6;
}
/* Nav */
nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
background: rgba(10,10,15,0.95);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 0;
padding: 0 32px; height: 52px;
backdrop-filter: blur(8px);
}
.nav-logo { color: var(--green); font-weight: bold; font-size: 16px; margin-right: 32px; }
.nav-logo span { color: var(--text); font-weight: normal; }
.nav-links { display: flex; gap: 0; }
.nav-links a {
color: var(--dim); text-decoration: none; font-size: 13px;
padding: 0 16px; height: 52px; display: flex; align-items: center;
border-bottom: 2px solid transparent; transition: all 0.15s;
}
.nav-links a:hover { color: var(--text); }
.nav-links a.active { color: var(--green); border-bottom-color: var(--green); }
.nav-right { margin-left: auto; display: flex; gap: 10px; }
.badge {
padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: bold;
border: 1px solid;
}
.badge.green { color: var(--green); border-color: var(--green); }
.badge.orange { color: var(--orange); border-color: var(--orange); }
/* Hero */
#hero {
padding: 120px 32px 80px;
max-width: 900px; margin: 0 auto;
}
.hero-title {
font-size: 42px; font-weight: bold; line-height: 1.1;
color: #fff; margin-bottom: 16px;
}
.hero-title .hl { color: var(--green); }
.hero-sub {
font-size: 16px; color: var(--dim); margin-bottom: 32px;
max-width: 600px; line-height: 1.7;
}
.hero-stats {
display: flex; gap: 32px; flex-wrap: wrap;
}
.stat { }
.stat-num { font-size: 28px; font-weight: bold; color: var(--green); }
.stat-label { font-size: 12px; color: var(--dim); }
/* Sections */
section {
padding: 80px 32px;
max-width: 1100px; margin: 0 auto;
}
.section-label {
font-size: 11px; color: var(--green); text-transform: uppercase;
letter-spacing: 2px; margin-bottom: 8px;
}
.section-title {
font-size: 28px; font-weight: bold; color: #fff; margin-bottom: 16px;
}
.section-sub { font-size: 14px; color: var(--dim); margin-bottom: 40px; max-width: 600px; }
/* Supervision tree */
#tree-container {
background: var(--bg1);
border: 1px solid var(--border);
border-radius: 8px;
padding: 32px;
overflow-x: auto;
}
.tree { font-family: inherit; font-size: 12px; line-height: 1.8; }
.tree-root { color: var(--orange); font-weight: bold; }
.tree-sup { color: var(--blue); }
.tree-worker { color: var(--green); }
.tree-line { color: var(--dim); }
.tree-note { color: var(--dim); font-style: italic; }
/* Cards */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.card {
background: var(--bg1);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
transition: border-color 0.15s;
}
.card:hover { border-color: var(--blue); }
.card-icon { font-size: 24px; margin-bottom: 10px; }
.card-title { color: #fff; font-weight: bold; margin-bottom: 6px; font-size: 15px; }
.card-body { color: var(--dim); font-size: 12px; line-height: 1.6; }
.card-tag {
display: inline-block; margin-top: 10px;
padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: bold;
border: 1px solid;
}
.tag-green { color: var(--green); border-color: var(--green); }
.tag-blue { color: var(--blue); border-color: var(--blue); }
.tag-orange { color: var(--orange); border-color: var(--orange); }
.tag-purple { color: var(--purple); border-color: var(--purple); }
/* Test results */
.test-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
}
.test-item {
background: var(--bg1);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
display: flex; align-items: center; gap: 10px;
}
.test-item .check { color: var(--green); font-size: 16px; }
.test-item .test-name { color: var(--text); font-size: 12px; }
.test-item .test-type { color: var(--dim); font-size: 10px; margin-left: auto; }
/* API table */
.api-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.api-table th {
text-align: left; padding: 8px 12px;
background: var(--bg2); color: var(--dim);
border-bottom: 1px solid var(--border);
font-size: 11px; text-transform: uppercase; letter-spacing: 1px;
}
.api-table td {
padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: top;
}
.api-table tr:last-child td { border-bottom: none; }
.method {
padding: 2px 8px; border-radius: 4px; font-weight: bold; font-size: 10px;
display: inline-block;
}
.method.GET { background: rgba(0,255,136,0.1); color: var(--green); }
.method.POST { background: rgba(0,170,255,0.1); color: var(--blue); }
.method.DELETE { background: rgba(255,68,68,0.1); color: var(--red); }
.method.WS { background: rgba(204,102,255,0.1);color: var(--purple); }
.endpoint { color: var(--text); font-family: inherit; }
.ep-desc { color: var(--dim); }
/* Code block */
.code-block {
background: var(--bg0);
border: 1px solid var(--border);
border-radius: 6px;
padding: 20px;
overflow-x: auto;
font-size: 12px;
line-height: 1.7;
}
.code-block .kw { color: var(--orange); }
.code-block .fn { color: var(--blue); }
.code-block .str { color: var(--green); }
.code-block .cmt { color: var(--dim); }
.code-block .op { color: var(--purple); }
/* FSM */
.fsm-states {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
}
.fsm-state {
padding: 6px 14px; border-radius: 20px; font-size: 12px; font-weight: bold;
border: 1px solid;
}
.fsm-state.start { color: var(--green); border-color: var(--green); background: rgba(0,255,136,0.05); }
.fsm-state.middle { color: var(--blue); border-color: var(--blue); background: rgba(0,170,255,0.05); }
.fsm-state.end-ok { color: var(--green); border-color: var(--green); background: rgba(0,255,136,0.1); }
.fsm-state.end-fail{color: var(--red); border-color: var(--red); background: rgba(255,68,68,0.1); }
.fsm-arrow { color: var(--dim); font-size: 18px; }
/* Divider */
hr { border: none; border-top: 1px solid var(--border); }
/* Footer */
footer {
padding: 40px 32px;
text-align: center;
color: var(--dim);
font-size: 12px;
border-top: 1px solid var(--border);
}
footer a { color: var(--dim); }
footer a:hover { color: var(--green); }
/* Responsive */
@media (max-width: 600px) {
.hero-title { font-size: 28px; }
nav { padding: 0 16px; }
section { padding: 60px 16px; }
}
</style>
</head>
<body>
<!-- Nav -->
<nav>
<div class="nav-logo">HyperKitty <span>Chromium</span></div>
<div class="nav-links">
<a href="#architecture" class="active">Architecture</a>
<a href="#supervision">Supervision</a>
<a href="#agent">Agent FSM</a>
<a href="#api">API</a>
<a href="#tests">Tests</a>
<a href="#quickstart">Quickstart</a>
</div>
<div class="nav-right">
<span class="badge green">24 tests βœ“</span>
<span class="badge orange">Erlang/OTP</span>
</div>
</nav>
<!-- Hero -->
<div id="hero">
<div class="hero-title">
<span class="hl">HyperKitty</span> Chromium
</div>
<div class="hero-sub">
An Erlang/OTP-native browser agent platform. Headless Chromium wrapped in an explicit supervision tree β€” so browser control, agent orchestration, search, and messaging are separate, independently-supervised subsystems rather than library calls inside one monolithic process.
</div>
<div class="hero-stats">
<div class="stat"><div class="stat-num">24</div><div class="stat-label">tests passing</div></div>
<div class="stat"><div class="stat-num">7</div><div class="stat-label">OTP supervisors</div></div>
<div class="stat"><div class="stat-num">11</div><div class="stat-label">search pipeline stages</div></div>
<div class="stat"><div class="stat-num">14</div><div class="stat-label">browser capability ops</div></div>
<div class="stat"><div class="stat-num">6</div><div class="stat-label">REST + WS API domains</div></div>
</div>
</div>
<hr>
<!-- Architecture -->
<section id="architecture">
<div class="section-label">Overview</div>
<div class="section-title">The core principle</div>
<div class="section-sub">An LLM-driven agent is a planning function that proposes tool calls. It is never trusted with direct, unchecked access to a browser process. Every tool call crosses an explicit, typed capability boundary β€” enforced by Erlang code, not by the prompt β€” before it reaches Chromium.</div>
<div class="cards">
<div class="card">
<div class="card-icon">🌐</div>
<div class="card-title">HK-BROWSER</div>
<div class="card-body">Launches a real headless Chromium OS process per session. Drives it over CDP (via <code>gun</code>) and Chrome's HTTP control endpoint. 14-operation typed capability interface with auth + argument validation.</div>
<span class="card-tag tag-green">gen_server</span>
</div>
<div class="card">
<div class="card-icon">πŸ€–</div>
<div class="card-title">HK-AGENT</div>
<div class="card-body">A <code>gen_statem</code> agent loop enforcing configurable action, time, network, and search-iteration limits before any tool call reaches the browser. 7-state FSM from created β†’ completed/failed/terminated.</div>
<span class="card-tag tag-blue">gen_statem</span>
</div>
<div class="card">
<div class="card-icon">πŸ”</div>
<div class="card-title">HK-SEARCH</div>
<div class="card-body">An 11-stage pipeline where each stage is a literal state. Emits an ordered event per stage with a pluggable provider behaviour and a mock reference implementation.</div>
<span class="card-tag tag-orange">pipeline</span>
</div>
<div class="card">
<div class="card-icon">πŸ“‘</div>
<div class="card-title">HK-MSG</div>
<div class="card-body">Append-only event bus with WebSocket streaming. Every action, state transition, and result is published before it is acted on β€” making the system fully observable.</div>
<span class="card-tag tag-purple">event bus</span>
</div>
<div class="card">
<div class="card-icon">πŸ”Œ</div>
<div class="card-title">HK-API</div>
<div class="card-body">6-domain REST + WebSocket API built on Cowboy. Agents, browsers, search, messages, events, health β€” all independently addressable. OpenAPI-compatible route layout.</div>
<span class="card-tag tag-blue">Cowboy + REST</span>
</div>
<div class="card">
<div class="card-icon">β™₯</div>
<div class="card-title">HK-HEALTH</div>
<div class="card-body">Aggregate subsystem health β€” all 7 subsystems up or down in one call. Used by the observability frontend and external monitoring. No external dependency.</div>
<span class="card-tag tag-green">monitoring</span>
</div>
</div>
</section>
<!-- Supervision tree -->
<section id="supervision">
<div class="section-label">OTP</div>
<div class="section-title">Supervision tree</div>
<div class="section-sub"><code>rest_for_one</code> at the root β€” if the event bus dies, everything that depends on it restarts too. Each browser and agent is its own isolated process with its own restart budget.</div>
<div id="tree-container">
<pre class="tree"><span class="tree-root">hyperkitty_sup</span> <span class="tree-note">(rest_for_one, intensity 8/10)</span>
<span class="tree-line"> β”œβ”€β”€</span> <span class="tree-sup">hk_event_sup</span> <span class="tree-note">(one_for_one)</span>
<span class="tree-line"> β”‚ └──</span> <span class="tree-worker">hk_event_bus</span> <span class="tree-note">[gen_server] append-only audit log + pub/sub</span>
<span class="tree-line"> β”œβ”€β”€</span> <span class="tree-sup">hk_messaging_sup</span> <span class="tree-note">(one_for_one)</span>
<span class="tree-line"> β”‚ └──</span> <span class="tree-worker">hk_messaging_portal</span> <span class="tree-note">[gen_server] HK-MSG</span>
<span class="tree-line"> β”œβ”€β”€</span> <span class="tree-sup">hk_browser_sup</span> <span class="tree-note">(one_for_one)</span>
<span class="tree-line"> β”‚ β”œβ”€β”€</span> <span class="tree-worker">hk_browser_registry</span> <span class="tree-note">[gen_server] session_id β†’ pid</span>
<span class="tree-line"> β”‚ └──</span> <span class="tree-sup">hk_browser_session_sup</span> <span class="tree-note">(simple_one_for_one, transient)</span>
<span class="tree-line"> β”‚ └──</span> <span class="tree-worker">hk_browser_session</span> <span class="tree-note">[gen_server] owns a Chrome OS process per session</span>
<span class="tree-line"> β”œβ”€β”€</span> <span class="tree-sup">hk_agent_sup</span> <span class="tree-note">(one_for_one)</span>
<span class="tree-line"> β”‚ β”œβ”€β”€</span> <span class="tree-worker">hk_agent_registry</span> <span class="tree-note">[gen_server] agent_id β†’ pid</span>
<span class="tree-line"> β”‚ └──</span> <span class="tree-sup">hk_agent_fsm_sup</span> <span class="tree-note">(simple_one_for_one, transient)</span>
<span class="tree-line"> β”‚ └──</span> <span class="tree-worker">hk_agent_fsm</span> <span class="tree-note">[gen_statem] one per agent, 7-state FSM</span>
<span class="tree-line"> β”œβ”€β”€</span> <span class="tree-sup">hk_search_sup</span> <span class="tree-note">(one_for_one)</span>
<span class="tree-line"> β”‚ β”œβ”€β”€</span> <span class="tree-worker">hk_search_registry</span>
<span class="tree-line"> β”‚ └──</span> <span class="tree-sup">hk_search_job_sup</span> <span class="tree-note">(simple_one_for_one)</span>
<span class="tree-line"> β”‚ └──</span> <span class="tree-worker">hk_search_job</span> <span class="tree-note">[gen_statem] 11-stage pipeline per job</span>
<span class="tree-line"> β”œβ”€β”€</span> <span class="tree-sup">hk_api_sup</span>
<span class="tree-line"> β”‚ β”œβ”€β”€</span> <span class="tree-worker">hk_api_listener</span> <span class="tree-note">[Cowboy] REST + WS (trap_exit for clean teardown)</span>
<span class="tree-line"> β”‚ └──</span> <span class="tree-worker">hk_frontend_*</span> <span class="tree-note">connected client registry</span>
<span class="tree-line"> └──</span> <span class="tree-worker">hk_health</span> <span class="tree-note">[gen_server] aggregate subsystem health</span></pre>
</div>
</section>
<!-- Agent FSM -->
<section id="agent">
<div class="section-label">gen_statem</div>
<div class="section-title">Agent state machine</div>
<div class="section-sub">Every agent is a <code>gen_statem</code> process. No tool call reaches the browser without passing through this FSM β€” configurable action limits, time limits, and network limits are enforced structurally, not by the LLM.</div>
<div style="margin-bottom: 32px;">
<div class="fsm-states">
<div class="fsm-state start">created</div>
<div class="fsm-arrow">β†’</div>
<div class="fsm-state middle">ready</div>
<div class="fsm-arrow">β†’</div>
<div class="fsm-state middle">planning</div>
<div class="fsm-arrow">β†’</div>
<div class="fsm-state middle">executing</div>
<div class="fsm-arrow">β†’</div>
<div class="fsm-state middle">waiting</div>
<div class="fsm-arrow">β†’</div>
<div class="fsm-state end-ok">completed</div>
</div>
<div style="margin-top: 12px; display: flex; gap: 12px; flex-wrap: wrap;">
<div class="fsm-state end-fail" style="font-size:11px">failed</div>
<div class="fsm-state end-fail" style="font-size:11px">terminated</div>
<span style="color:var(--dim);font-size:12px;align-self:center">β€” reachable from any state on limit exceeded</span>
</div>
</div>
<div class="cards">
<div class="card">
<div class="card-title">Bugs found and fixed</div>
<div class="card-body" style="margin-top:8px">
<div style="margin-bottom:8px"><span style="color:var(--red)">Bug 1:</span> <code>ready</code> state's tool-call handler routed to wrong next state β€” silently dropping the action. Fixed: correct state transition target.</div>
<div><span style="color:var(--red)">Bug 2:</span> OTP race where a mid-tree supervisor killed abruptly outran its children's cleanup. Fixed: <code>hk_sup_util</code> retry-on-death-wait helper + <code>trap_exit</code> on <code>hk_api_listener</code>.</div>
</div>
</div>
<div class="card">
<div class="card-title">Chrome PUT fix</div>
<div class="card-body" style="margin-top:8px">Modern Chrome requires <code>PUT</code> for <code>/json/new</code> β€” newer Chrome dropped support for <code>GET</code> on that endpoint. <code>hk_chrome_http.erl</code> updated accordingly.</div>
<span class="card-tag tag-orange">integration fix</span>
</div>
</div>
</section>
<!-- API -->
<section id="api">
<div class="section-label">HTTP + WebSocket</div>
<div class="section-title">API reference</div>
<div class="section-sub">All routes served by Cowboy. WebSocket at <code>/api/events/ws</code> streams every event in real time.</div>
<div style="background:var(--bg1);border:1px solid var(--border);border-radius:8px;overflow:hidden">
<table class="api-table">
<thead>
<tr><th>Method</th><th>Path</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><span class="method GET">GET</span></td><td class="endpoint">/api/health</td><td class="ep-desc">All 7 subsystems up/down in one call</td></tr>
<tr><td><span class="method POST">POST</span></td><td class="endpoint">/api/agents</td><td class="ep-desc">Create agent with config (limits, browser_session_id)</td></tr>
<tr><td><span class="method GET">GET</span></td><td class="endpoint">/api/agents/:id</td><td class="ep-desc">Agent state + current FSM state</td></tr>
<tr><td><span class="method DELETE">DELETE</span></td><td class="endpoint">/api/agents/:id</td><td class="ep-desc">Terminate agent (transitions to terminated)</td></tr>
<tr><td><span class="method POST">POST</span></td><td class="endpoint">/api/browsers</td><td class="ep-desc">Spawn headless Chromium session</td></tr>
<tr><td><span class="method POST">POST</span></td><td class="endpoint">/api/browsers/:id/capability</td><td class="ep-desc">Execute typed browser capability (14 ops)</td></tr>
<tr><td><span class="method DELETE">DELETE</span></td><td class="endpoint">/api/browsers/:id</td><td class="ep-desc">Kill Chromium process, clean up session</td></tr>
<tr><td><span class="method POST">POST</span></td><td class="endpoint">/api/search</td><td class="ep-desc">Start 11-stage search pipeline job</td></tr>
<tr><td><span class="method GET">GET</span></td><td class="endpoint">/api/search/:id</td><td class="ep-desc">Search job state + stage progress</td></tr>
<tr><td><span class="method GET">GET</span></td><td class="endpoint">/api/messages</td><td class="ep-desc">Recent messages from event bus</td></tr>
<tr><td><span class="method GET">GET</span></td><td class="endpoint">/api/events/recent</td><td class="ep-desc">Last N events across all subsystems</td></tr>
<tr><td><span class="method WS">WS</span></td><td class="endpoint">/api/events/ws</td><td class="ep-desc">Real-time event stream (all subsystems)</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Tests -->
<section id="tests">
<div class="section-label">EUnit</div>
<div class="section-title">24 tests β€” 0 failures</div>
<div class="section-sub">Includes real headless Chromium integration tests and chaos tests that kill live processes and verify recovery. Run with <code>rebar3 eunit</code>.</div>
<div class="test-grid">
<div class="test-item"><span class="check">βœ“</span><span class="test-name">hk_agent_fsm_tests</span><span class="test-type">FSM</span></div>
<div class="test-item"><span class="check">βœ“</span><span class="test-name">hk_browser_capability_tests</span><span class="test-type">integration</span></div>
<div class="test-item"><span class="check">βœ“</span><span class="test-name">hk_browser_session_tests</span><span class="test-type">integration</span></div>
<div class="test-item"><span class="check">βœ“</span><span class="test-name">hk_messaging_portal_tests</span><span class="test-type">unit</span></div>
<div class="test-item"><span class="check">βœ“</span><span class="test-name">hk_search_job_tests</span><span class="test-type">pipeline</span></div>
<div class="test-item"><span class="check">βœ“</span><span class="test-name">hk_search_pipeline_tests</span><span class="test-type">pipeline</span></div>
<div class="test-item"><span class="check">βœ“</span><span class="test-name">hk_supervision_tests</span><span class="test-type">chaos</span></div>
<div class="test-item"><span class="check">βœ“</span><span class="test-name">hk_test_helper</span><span class="test-type">harness</span></div>
</div>
<div style="margin-top:24px;background:var(--bg1);border:1px solid var(--green);border-radius:6px;padding:16px;max-width:500px">
<div style="color:var(--green);font-weight:bold;margin-bottom:8px">Test output</div>
<div style="font-size:12px;color:var(--dim)">
<div>24/24 tests pass</div>
<div>SUPERVISOR/CRASH/ERROR REPORTs in output are expected β€”</div>
<div>chaos tests deliberately kill live processes to verify recovery.</div>
</div>
</div>
</section>
<!-- Quickstart -->
<section id="quickstart">
<div class="section-label">Setup</div>
<div class="section-title">Quickstart</div>
<div class="section-sub">Requires Erlang/OTP 25+, rebar3, and Chromium. Bootstrap script handles the rest.</div>
<div class="code-block"><span class="cmt"># Bootstrap environment (macOS via Homebrew)</span>
<span class="kw">bash</span> bin/bootstrap.sh
<span class="cmt"># Build</span>
<span class="kw">rebar3</span> compile
<span class="cmt"># Run tests</span>
<span class="kw">rebar3</span> eunit
<span class="cmt"># Start</span>
<span class="kw">rebar3</span> shell
<span class="cmt"># Health check</span>
<span class="kw">curl</span> http://localhost:8080/api/health</div>
<div style="margin-top: 24px;">
<a href="https://github.com/SNAPKITTYWEST/hyperkitty-chromium" style="
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 20px; border-radius: 6px;
background: rgba(0,255,136,0.1); border: 1px solid var(--green);
color: var(--green); text-decoration: none; font-size: 13px; font-weight: bold;
transition: background 0.15s;
">View on GitHub β†’</a>
</div>
</section>
<footer>
HyperKitty Chromium β€” Erlang/OTP Browser Agent Platform<br>
<a href="https://github.com/SNAPKITTYWEST/hyperkitty-chromium">SNAPKITTYWEST/hyperkitty-chromium</a> Β·
BSL-1.1 / AGPL-3.0 / MPL-2.0 Β· Patent Pending β€” Bel Esprit D'Accord Irrevocable Trust
</footer>
<script>
// Smooth scroll + active nav
document.querySelectorAll('a[href^="#"]').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
document.querySelector(a.getAttribute('href'))?.scrollIntoView({ behavior: 'smooth' });
});
});
const sections = document.querySelectorAll('section[id], div[id="hero"]');
const navLinks = document.querySelectorAll('.nav-links a');
const obs = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
navLinks.forEach(l => l.classList.remove('active'));
const id = e.target.id;
const link = document.querySelector(`.nav-links a[href="#${id}"]`);
if (link) link.classList.add('active');
}
});
}, { threshold: 0.3 });
sections.forEach(s => obs.observe(s));
</script>
</body>
</html>