diff --git a/manifests/acb-api-deployment.yml b/manifests/acb-api-deployment.yml
new file mode 100644
index 0000000..fa93add
--- /dev/null
+++ b/manifests/acb-api-deployment.yml
@@ -0,0 +1,121 @@
+# acb-api: HTTP API server for AI Code Battle
+# Bot registration, job coordination, replay serving, bot profiles,
+# leaderboards, predictions, and community feedback.
+# Connects to PostgreSQL (CNPG) and Valkey (Redis-compatible).
+#
+# Staging file — sync to declarative-config/k8s/apexalgo-iad/ai-code-battle/
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: acb-api
+ namespace: ai-code-battle
+ labels:
+ app.kubernetes.io/name: acb-api
+ app.kubernetes.io/part-of: ai-code-battle
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: acb-api
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: acb-api
+ app.kubernetes.io/part-of: ai-code-battle
+ spec:
+ restartPolicy: Always
+ containers:
+ - name: api
+ image: ronaldraygun/acb-api:latest
+ imagePullPolicy: Always
+ ports:
+ - containerPort: 8080
+ protocol: TCP
+ env:
+ - name: ACB_LISTEN_ADDR
+ value: ":8080"
+ - name: ACB_DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: acb-app-credentials-acb-app
+ key: uri
+ - name: ACB_VALKEY_ADDR
+ value: "valkey-master.valkey.svc.cluster.local:6379"
+ - name: ACB_VALKEY_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: keydb-secret
+ key: password
+ optional: true
+ - name: ACB_WORKER_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: acb-api-secrets
+ key: worker-api-key
+ - name: ACB_ENCRYPTION_KEY
+ valueFrom:
+ secretKeyRef:
+ name: acb-api-secrets
+ key: encryption-key
+ resources:
+ requests:
+ cpu: "100m"
+ memory: "128Mi"
+ limits:
+ cpu: "500m"
+ memory: "256Mi"
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: 8080
+ initialDelaySeconds: 5
+ periodSeconds: 15
+ failureThreshold: 3
+ readinessProbe:
+ httpGet:
+ path: /ready
+ port: 8080
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ failureThreshold: 3
+ imagePullSecrets:
+ - name: docker-hub-registry
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: acb-api
+ namespace: ai-code-battle
+ labels:
+ app.kubernetes.io/name: acb-api
+ app.kubernetes.io/part-of: ai-code-battle
+spec:
+ type: ClusterIP
+ selector:
+ app.kubernetes.io/name: acb-api
+ ports:
+ - port: 80
+ targetPort: 8080
+ protocol: TCP
+ name: http
+---
+apiVersion: traefik.io/v1alpha1
+kind: IngressRoute
+metadata:
+ name: acb-api
+ namespace: ai-code-battle
+ labels:
+ app.kubernetes.io/name: acb-api
+ app.kubernetes.io/part-of: ai-code-battle
+spec:
+ entryPoints:
+ - websecure
+ routes:
+ - match: Host(`api.ai-code-battle.ardenone.com`)
+ kind: Rule
+ services:
+ - name: acb-api
+ port: 80
+ tls:
+ certResolver: letsencrypt
diff --git a/manifests/acb-evolver-deployment.yml b/manifests/acb-evolver-deployment.yml
new file mode 100644
index 0000000..ddf3295
--- /dev/null
+++ b/manifests/acb-evolver-deployment.yml
@@ -0,0 +1,106 @@
+# acb-evolver: LLM-driven bot evolution pipeline
+# Reads match data from PostgreSQL, generates candidates via LLM, validates,
+# evaluates in arena, promotes winners to the live bot fleet.
+#
+# Staging file — sync to declarative-config/k8s/apexalgo-iad/ai-code-battle/
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: acb-evolver
+ namespace: ai-code-battle
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: acb-evolver
+ namespace: ai-code-battle
+ labels:
+ app.kubernetes.io/name: acb-evolver
+ app.kubernetes.io/part-of: ai-code-battle
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: acb-evolver
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: acb-evolver
+ app.kubernetes.io/part-of: ai-code-battle
+ spec:
+ serviceAccountName: acb-evolver
+ restartPolicy: Always
+ containers:
+ - name: evolver
+ image: ronaldraygun/acb-evolver:latest
+ imagePullPolicy: Always
+ args: ["run", "-continuous"]
+ env:
+ - name: ACB_DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: acb-app-credentials-acb-app
+ key: uri
+ - name: ACB_ENCRYPTION_KEY
+ valueFrom:
+ secretKeyRef:
+ name: acb-evolver-secrets
+ key: encryption-key
+ - name: ACB_LLM_URL
+ valueFrom:
+ secretKeyRef:
+ name: openai-secret
+ key: url
+ optional: true
+ - name: ACB_LLM_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: openai-secret
+ key: api-key
+ optional: true
+ - name: ACB_KUBECTL_SERVER
+ value: "http://traefik-apexalgo-iad:8001"
+ - name: ACB_REGISTRY
+ value: "ronaldraygun"
+ - name: ACB_REPO_DIR
+ value: "/repo"
+ - name: ACB_R2_ENDPOINT
+ valueFrom:
+ secretKeyRef:
+ name: cloudflare-pages-secret
+ key: r2-endpoint
+ optional: true
+ - name: ACB_R2_BUCKET
+ value: "acb-data"
+ - name: ACB_R2_ACCESS_KEY
+ valueFrom:
+ secretKeyRef:
+ name: cloudflare-pages-secret
+ key: r2-access-key
+ optional: true
+ - name: ACB_R2_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: cloudflare-pages-secret
+ key: r2-secret-key
+ optional: true
+ resources:
+ requests:
+ cpu: "500m"
+ memory: "1Gi"
+ limits:
+ cpu: "2"
+ memory: "4Gi"
+ # Evolver is a batch loop, not an HTTP server — use exec probe
+ livenessProbe:
+ exec:
+ command:
+ - pgrep
+ - -x
+ - acb-evolver
+ initialDelaySeconds: 30
+ periodSeconds: 60
+ failureThreshold: 3
+ imagePullSecrets:
+ - name: docker-hub-registry
diff --git a/web/src/lib/lazy-section.ts b/web/src/lib/lazy-section.ts
new file mode 100644
index 0000000..670b16f
--- /dev/null
+++ b/web/src/lib/lazy-section.ts
@@ -0,0 +1,89 @@
+// §16.15 Lazy section — defers rendering of below-the-fold content until
+// it scrolls into view using IntersectionObserver. Shows a minimal
+// placeholder until revealed.
+
+export interface LazySectionOptions {
+ /** Placeholder HTML shown until the section is visible */
+ placeholder?: string;
+ /** Margin around the root to trigger early (default "200px") */
+ rootMargin?: string;
+ /** Threshold for triggering (default 0) */
+ threshold?: number;
+}
+
+const DEFAULT_PLACEHOLDER = '
';
+
+/**
+ * Wraps a block of HTML in a lazy-loaded container. The container starts
+ * with a lightweight placeholder and swaps in the real content when it
+ * enters the viewport (with a configurable margin for preloading).
+ *
+ * Returns the outer HTML string (safe to use in innerHTML assignments).
+ */
+export function lazySection(
+ id: string,
+ contentHtml: string,
+ opts: LazySectionOptions = {}
+): string {
+ const placeholder = opts.placeholder ?? DEFAULT_PLACEHOLDER;
+ return `${placeholder}
`;
+}
+
+/**
+ * Activates all lazy sections within the given root element.
+ * Call after mounting HTML (e.g., after innerHTML assignment).
+ */
+export function initLazySections(
+ root: HTMLElement,
+ opts: Pick = {}
+): () => void {
+ const rootMargin = opts.rootMargin ?? '200px';
+ const threshold = opts.threshold ?? 0;
+
+ const sections = root.querySelectorAll('.lazy-section');
+ if (sections.length === 0) return () => {};
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (!entry.isIntersecting) continue;
+ const el = entry.target as HTMLElement;
+ reveal(el);
+ observer.unobserve(el);
+ }
+ },
+ { rootMargin, threshold }
+ );
+
+ sections.forEach((s) => observer.observe(s));
+ return () => observer.disconnect();
+}
+
+/** Immediately reveal a lazy section (also used by IntersectionObserver). */
+export function revealLazySection(root: HTMLElement, id: string): void {
+ const el = root.querySelector(`[data-lazy-id="${id}"]`);
+ if (el) reveal(el);
+}
+
+function reveal(el: HTMLElement): void {
+ const content = el.getAttribute('data-lazy-content');
+ if (content === null) return;
+ // Remove the data attribute to avoid re-revealing
+ el.removeAttribute('data-lazy-content');
+ el.classList.add('lazy-section-revealed');
+ el.innerHTML = decodeAttr(content);
+}
+
+function escapeAttr(html: string): string {
+ return html
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+}
+
+function decodeAttr(encoded: string): string {
+ const el = document.createElement('textarea');
+ el.innerHTML = encoded;
+ return el.value;
+}
diff --git a/web/src/lib/virtual-list.ts b/web/src/lib/virtual-list.ts
new file mode 100644
index 0000000..b0e16ec
--- /dev/null
+++ b/web/src/lib/virtual-list.ts
@@ -0,0 +1,212 @@
+// §16.15 Virtual list — renders only visible rows for large datasets.
+// Keeps the DOM small even with 1000+ entries. Each row has a fixed height
+// and only rows in the viewport (plus a buffer) are materialised.
+
+export interface VirtualListOptions {
+ items: T[];
+ rowHeight: number;
+ /** Extra rows rendered above/below viewport (default 5) */
+ buffer?: number;
+ /** Initial number of rows to show before requiring "Show more" */
+ initialCount?: number;
+ renderRow: (item: T, index: number) => string;
+ renderExpanded?: (item: T, index: number) => string;
+ containerClass?: string;
+ ariaLabel?: string;
+}
+
+interface VirtualListState {
+ scrollTop: number;
+ viewportHeight: number;
+ visibleStart: number;
+ visibleEnd: number;
+ expandedIndex: number | null;
+ showMoreCount: number;
+}
+
+export class VirtualList {
+ private opts: VirtualListOptions;
+ private state: VirtualListState;
+ private scrollEl: HTMLElement | null = null;
+ private spacerAbove: HTMLElement | null = null;
+ private spacerBelow: HTMLElement | null = null;
+ private rowContainer: HTMLElement | null = null;
+ private showMoreEl: HTMLElement | null = null;
+ private rafId = 0;
+ private observer: ResizeObserver | null = null;
+
+ constructor(opts: VirtualListOptions) {
+ this.opts = opts;
+ const initial = opts.initialCount ?? opts.items.length;
+ this.state = {
+ scrollTop: 0,
+ viewportHeight: 600,
+ visibleStart: 0,
+ visibleEnd: Math.min(initial, opts.items.length),
+ expandedIndex: null,
+ showMoreCount: initial,
+ };
+ }
+
+ /** Mount the virtual list into the given container. */
+ mount(container: HTMLElement): void {
+ container.innerHTML = '';
+ container.classList.add(this.opts.containerClass ?? 'virtual-list');
+
+ // Scrollable viewport
+ const scrollEl = document.createElement('div');
+ scrollEl.className = 'virtual-list-scroll';
+ scrollEl.setAttribute('role', 'list');
+ if (this.opts.ariaLabel) scrollEl.setAttribute('aria-label', this.opts.ariaLabel);
+ scrollEl.tabIndex = 0;
+ this.scrollEl = scrollEl;
+
+ // Spacers for total height
+ this.spacerAbove = document.createElement('div');
+ this.spacerBelow = document.createElement('div');
+
+ // Actual rendered rows
+ this.rowContainer = document.createElement('div');
+ this.rowContainer.className = 'virtual-list-rows';
+
+ scrollEl.appendChild(this.spacerAbove);
+ scrollEl.appendChild(this.rowContainer);
+ scrollEl.appendChild(this.spacerBelow);
+ container.appendChild(scrollEl);
+
+ // "Show more" button
+ const showMoreEl = document.createElement('button');
+ showMoreEl.className = 'virtual-list-show-more btn secondary';
+ showMoreEl.type = 'button';
+ this.showMoreEl = showMoreEl;
+ this.updateShowMore();
+ container.appendChild(this.showMoreEl);
+
+ // Bind events
+ scrollEl.addEventListener('scroll', () => this.onScroll(), { passive: true });
+ this.showMoreEl.addEventListener('click', () => this.onShowMore());
+
+ // Keyboard: Enter/Space on a row toggles expand
+ scrollEl.addEventListener('keydown', (e) => this.onKeyDown(e));
+
+ // Track viewport height
+ this.observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ this.state.viewportHeight = entry.contentRect.height;
+ this.render();
+ }
+ });
+ this.observer.observe(scrollEl);
+
+ // Initial render
+ this.state.viewportHeight = scrollEl.clientHeight || 600;
+ this.render();
+ }
+
+ destroy(): void {
+ if (this.rafId) cancelAnimationFrame(this.rafId);
+ this.observer?.disconnect();
+ }
+
+ private onScroll(): void {
+ if (this.rafId) return;
+ this.rafId = requestAnimationFrame(() => {
+ this.rafId = 0;
+ if (!this.scrollEl) return;
+ this.state.scrollTop = this.scrollEl.scrollTop;
+ this.computeWindow();
+ this.render();
+ });
+ }
+
+ private computeWindow(): void {
+ const { rowHeight } = this.opts;
+ const buffer = this.opts.buffer ?? 5;
+ const maxIdx = this.state.showMoreCount;
+
+ const rawStart = Math.floor(this.state.scrollTop / rowHeight) - buffer;
+ const rawEnd = Math.ceil((this.state.scrollTop + this.state.viewportHeight) / rowHeight) + buffer;
+
+ this.state.visibleStart = Math.max(0, rawStart);
+ this.state.visibleEnd = Math.min(maxIdx, rawEnd);
+ }
+
+ private render(): void {
+ if (!this.rowContainer || !this.spacerAbove || !this.spacerBelow) return;
+ const { items, rowHeight, renderRow, renderExpanded } = this.opts;
+ const { visibleStart, visibleEnd, expandedIndex, showMoreCount } = this.state;
+
+ const frags: string[] = [];
+ for (let i = visibleStart; i < visibleEnd; i++) {
+ const item = items[i];
+ if (!item) continue;
+ const isExpanded = expandedIndex === i;
+ frags.push(``);
+ frags.push(renderRow(item, i));
+ if (isExpanded && renderExpanded) {
+ frags.push(`
${renderExpanded(item, i)}
`);
+ }
+ frags.push('
');
+ }
+
+ this.rowContainer.innerHTML = frags.join('');
+ this.spacerAbove.style.height = `${visibleStart * rowHeight}px`;
+ const belowStart = visibleEnd;
+ const belowCount = showMoreCount - belowStart;
+ this.spacerBelow.style.height = `${Math.max(0, belowCount) * rowHeight}px`;
+
+ // Set total scrollable height hint
+ if (this.scrollEl) {
+ this.scrollEl.style.setProperty('--vl-total-height', `${showMoreCount * rowHeight}px`);
+ }
+
+ // Wire expand toggles
+ this.rowContainer.querySelectorAll('.virtual-list-row').forEach(row => {
+ row.addEventListener('click', (e) => {
+ if ((e.target as HTMLElement).closest('a, button')) return;
+ const idx = Number(row.dataset.idx);
+ this.toggleExpand(idx);
+ });
+ });
+ }
+
+ private toggleExpand(idx: number): void {
+ this.state.expandedIndex = this.state.expandedIndex === idx ? null : idx;
+ this.render();
+ }
+
+ private onKeyDown(e: KeyboardEvent): void {
+ const row = (e.target as HTMLElement).closest('.virtual-list-row') as HTMLElement | null;
+ if (!row) return;
+
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ const idx = Number(row.dataset.idx);
+ this.toggleExpand(idx);
+ }
+ }
+
+ private onShowMore(): void {
+ const batchSize = 100;
+ this.state.showMoreCount = Math.min(
+ this.state.showMoreCount + batchSize,
+ this.opts.items.length
+ );
+ this.computeWindow();
+ this.render();
+ this.updateShowMore();
+ }
+
+ private updateShowMore(): void {
+ if (!this.showMoreEl) return;
+ const remaining = this.opts.items.length - this.state.showMoreCount;
+ if (remaining <= 0) {
+ this.showMoreEl.style.display = 'none';
+ return;
+ }
+ this.showMoreEl.style.display = '';
+ const next = Math.min(100, remaining);
+ this.showMoreEl.textContent = `Show ${next} more (${remaining} remaining)`;
+ this.showMoreEl.setAttribute('aria-label', `Show ${next} more entries, ${remaining} remaining`);
+ }
+}