diff --git a/Documentation/TODO.md b/Documentation/TODO.md
index 16045d7..4ef2f51 100644
--- a/Documentation/TODO.md
+++ b/Documentation/TODO.md
@@ -13,6 +13,10 @@
- [ ] Proxy
- [ ] Local-Prod-NGINX-Proxy
- [ ] Static-Frontend-Serving
+- [ ] First-Request-Web-Loader
+ - [ ] Bootstrap-Document
+ - [ ] Route-Intent-Handoff
+ - [ ] Tiny-First-Paint-Budget
- [ ] Dev-and-Prod-Builds
- [x] Local-Dev-Just-Commands
- [x] Local-Dev-Docker-Compose
diff --git a/Loader/README.md b/Loader/README.md
new file mode 100644
index 0000000..af94bfb
--- /dev/null
+++ b/Loader/README.md
@@ -0,0 +1,35 @@
+# Loader
+
+Tiny first-request bootstrap document for Moku.
+
+## Purpose
+
+The loader is intended to be the smallest possible first paint shown before the
+real application is handed off.
+
+Long term responsibilities:
+
+- capture original route intent
+- collect lightweight client capability hints
+- make a tiny boot decision
+- hand off to login or app
+
+## Route intent contract
+
+The loader writes bootstrap state to `sessionStorage`:
+
+- `moku.loader.intent` — the intended route path
+- `moku.loader.meta` — JSON metadata for the current bootstrap event
+
+The loader also writes a short-lived cookie:
+
+- `moku_loader_seen=1`
+
+The proxy uses that cookie to decide whether to keep serving the loader or to
+serve the real app document on the next request.
+
+## Current handoff behavior
+
+For now, the loader simply hands off to the originally requested route. That
+keeps the first implementation tiny while leaving a clear place to add auth or
+perf-tier decisions later.
diff --git a/Loader/index.html b/Loader/index.html
new file mode 100644
index 0000000..65f79df
--- /dev/null
+++ b/Loader/index.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+ Moku Loader
+
+
+
+
+
+
+ Moku bootstrap
+ Starting Moku
+ Capturing route intent and preparing the lightest possible handoff.
+
+
+
+
+
+
+
+
+
+
Status
+ Initializing loader
+
+
+
Intent
+ Waiting for capture
+
+
+
+
+
+
+
+ JavaScript is required.
+ Moku's bootstrap loader needs JavaScript to hand off to the app.
+
+
+
+
diff --git a/Loader/scripts/config.js b/Loader/scripts/config.js
new file mode 100644
index 0000000..138538b
--- /dev/null
+++ b/Loader/scripts/config.js
@@ -0,0 +1,9 @@
+// Path: Loader/scripts/config.js
+
+export const LOADER_INTENT_STORAGE_KEY = "moku.loader.intent";
+export const LOADER_META_STORAGE_KEY = "moku.loader.meta";
+export const LOADER_SEEN_COOKIE_NAME = "moku_loader_seen";
+export const LOADER_DEFAULT_INTENT = "/";
+export const LOADER_ROOT_PREFIX = "/__loader";
+export const LOADER_HANDOFF_DELAY_MS = 720;
+export const LOADER_SEEN_COOKIE_MAX_AGE_SECONDS = 1800;
diff --git a/Loader/scripts/dom.js b/Loader/scripts/dom.js
new file mode 100644
index 0000000..11ec1e0
--- /dev/null
+++ b/Loader/scripts/dom.js
@@ -0,0 +1,25 @@
+// Path: Loader/scripts/dom.js
+
+export const getStatusElement = () => {
+ return document.getElementById("loader-status");
+};
+
+export const getIntentElement = () => {
+ return document.getElementById("loader-intent");
+};
+
+export const setStatus = (value) => {
+ const statusElement = getStatusElement();
+
+ if (statusElement instanceof HTMLElement) {
+ statusElement.textContent = value;
+ }
+};
+
+export const setIntentPreview = (value) => {
+ const intentElement = getIntentElement();
+
+ if (intentElement instanceof HTMLElement) {
+ intentElement.textContent = value;
+ }
+};
diff --git a/Loader/scripts/handoff.js b/Loader/scripts/handoff.js
new file mode 100644
index 0000000..9cef851
--- /dev/null
+++ b/Loader/scripts/handoff.js
@@ -0,0 +1,13 @@
+// Path: Loader/scripts/handoff.js
+
+import { LOADER_HANDOFF_DELAY_MS } from "./config.js";
+
+export const scheduleHandoff = (target, onBeforeHandoff) => {
+ window.setTimeout(() => {
+ if (typeof onBeforeHandoff === "function") {
+ onBeforeHandoff();
+ }
+
+ window.location.assign(target);
+ }, LOADER_HANDOFF_DELAY_MS);
+};
diff --git a/Loader/scripts/hints.js b/Loader/scripts/hints.js
new file mode 100644
index 0000000..c636619
--- /dev/null
+++ b/Loader/scripts/hints.js
@@ -0,0 +1,19 @@
+// Path: Loader/scripts/hints.js
+
+export const collectClientHints = () => {
+ const navigatorConnection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
+
+ return {
+ capturedAt: new Date().toISOString(),
+ language: navigator.language,
+ languages: Array.isArray(navigator.languages) ? navigator.languages : [],
+ hardwareConcurrency: navigator.hardwareConcurrency ?? null,
+ deviceMemory: navigator.deviceMemory ?? null,
+ networkType: navigatorConnection?.effectiveType ?? null,
+ saveData: navigatorConnection?.saveData ?? null,
+ viewport: {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ },
+ };
+};
diff --git a/Loader/scripts/intent.js b/Loader/scripts/intent.js
new file mode 100644
index 0000000..8d8ebef
--- /dev/null
+++ b/Loader/scripts/intent.js
@@ -0,0 +1,32 @@
+// Path: Loader/scripts/intent.js
+
+import { LOADER_DEFAULT_INTENT, LOADER_ROOT_PREFIX } from "./config.js";
+
+export const getExplicitIntent = () => {
+ const params = new URLSearchParams(window.location.search);
+ const next = params.get("next");
+
+ if (typeof next === "string" && next.startsWith("/")) {
+ return next;
+ }
+
+ return null;
+};
+
+export const getCurrentPathIntent = () => {
+ const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
+
+ if (current === "/" || current === LOADER_ROOT_PREFIX || current === `${LOADER_ROOT_PREFIX}/` || current.startsWith(`${LOADER_ROOT_PREFIX}/`)) {
+ return LOADER_DEFAULT_INTENT;
+ }
+
+ return current;
+};
+
+export const resolveIntent = () => {
+ return getExplicitIntent() ?? getCurrentPathIntent();
+};
+
+export const resolveHandoffTarget = (intent) => {
+ return intent || LOADER_DEFAULT_INTENT;
+};
diff --git a/Loader/scripts/main.js b/Loader/scripts/main.js
new file mode 100644
index 0000000..722dd43
--- /dev/null
+++ b/Loader/scripts/main.js
@@ -0,0 +1,26 @@
+// Path: Loader/scripts/main.js
+
+import { setIntentPreview, setStatus } from "./dom.js";
+import { scheduleHandoff } from "./handoff.js";
+import { resolveHandoffTarget, resolveIntent } from "./intent.js";
+import { markLoaderSeen, persistLoaderState } from "./storage.js";
+
+const bootLoader = () => {
+ const intent = resolveIntent();
+ const handoffTarget = resolveHandoffTarget(intent);
+
+ setStatus("Capturing route intent");
+ setIntentPreview(intent);
+ persistLoaderState(intent);
+
+ window.setTimeout(() => {
+ setStatus("Preparing handoff");
+ }, 180);
+
+ scheduleHandoff(handoffTarget, () => {
+ setStatus("Handing off to application");
+ markLoaderSeen();
+ });
+};
+
+bootLoader();
diff --git a/Loader/scripts/storage.js b/Loader/scripts/storage.js
new file mode 100644
index 0000000..8c8c490
--- /dev/null
+++ b/Loader/scripts/storage.js
@@ -0,0 +1,24 @@
+// Path: Loader/scripts/storage.js
+
+import { LOADER_INTENT_STORAGE_KEY, LOADER_META_STORAGE_KEY, LOADER_SEEN_COOKIE_MAX_AGE_SECONDS, LOADER_SEEN_COOKIE_NAME } from "./config.js";
+import { collectClientHints } from "./hints.js";
+
+export const persistLoaderState = (intent) => {
+ const payload = {
+ intent,
+ hints: collectClientHints(),
+ };
+
+ try {
+ window.sessionStorage.setItem(LOADER_INTENT_STORAGE_KEY, intent);
+ window.sessionStorage.setItem(LOADER_META_STORAGE_KEY, JSON.stringify(payload));
+ } catch (error) {
+ console.warn("[Loader] Unable to persist bootstrap state.", error);
+ }
+
+ return payload;
+};
+
+export const markLoaderSeen = () => {
+ document.cookie = `${LOADER_SEEN_COOKIE_NAME}=1; Path=/; Max-Age=${LOADER_SEEN_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
+};
diff --git a/Loader/styles.css b/Loader/styles.css
new file mode 100644
index 0000000..a76a101
--- /dev/null
+++ b/Loader/styles.css
@@ -0,0 +1,177 @@
+/* Path: Loader/styles.css */
+
+:root {
+ color-scheme: dark;
+ --loader-bg: #07111f;
+ --loader-panel: rgba(15, 23, 42, 0.92);
+ --loader-panel-border: rgba(148, 163, 184, 0.18);
+ --loader-panel-highlight: rgba(255, 255, 255, 0.04);
+ --loader-text: #f8fafc;
+ --loader-text-muted: #94a3b8;
+ --loader-accent: #60a5fa;
+ --loader-accent-soft: rgba(96, 165, 250, 0.16);
+ --loader-shadow: 0 24px 64px rgba(2, 6, 23, 0.42);
+ --loader-radius: 1.25rem;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ min-height: 100%;
+}
+
+body {
+ min-height: 100dvh;
+ font-family:
+ Inter,
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+ background: radial-gradient(circle at top, rgba(37, 99, 235, 0.18), transparent 36%), linear-gradient(180deg, #091528 0%, var(--loader-bg) 100%);
+ color: var(--loader-text);
+}
+
+.loader-shell {
+ min-height: 100dvh;
+ display: grid;
+ place-items: center;
+ padding: 1.5rem;
+}
+
+.loader-panel {
+ width: min(100%, 30rem);
+ padding: 1.5rem;
+ border: 1px solid var(--loader-panel-border);
+ border-radius: var(--loader-radius);
+ background: linear-gradient(180deg, var(--loader-panel-highlight), transparent 16%), var(--loader-panel);
+ box-shadow: var(--loader-shadow);
+ backdrop-filter: blur(18px);
+}
+
+.loader-eyebrow {
+ margin: 0 0 0.625rem;
+ color: var(--loader-text-muted);
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.loader-title {
+ margin: 0;
+ font-size: clamp(1.75rem, 5vw, 2.25rem);
+ line-height: 1.05;
+}
+
+.loader-copy {
+ margin: 0.875rem 0 0;
+ max-width: 30ch;
+ color: var(--loader-text-muted);
+ font-size: 0.975rem;
+ line-height: 1.55;
+}
+
+.loader-indicators {
+ margin-top: 1.25rem;
+ display: inline-flex;
+ gap: 0.5rem;
+}
+
+.loader-dot {
+ width: 0.625rem;
+ height: 0.625rem;
+ border-radius: 999px;
+ background: var(--loader-accent-soft);
+ animation: loader-pulse 1.4s ease-in-out infinite;
+}
+
+.loader-dot:nth-child(2) {
+ animation-delay: 0.18s;
+}
+
+.loader-dot:nth-child(3) {
+ animation-delay: 0.36s;
+}
+
+.loader-meta {
+ margin: 1.5rem 0 0;
+ padding-top: 1rem;
+ border-top: 1px solid rgba(148, 163, 184, 0.12);
+}
+
+.loader-meta-row {
+ display: grid;
+ grid-template-columns: 4.5rem minmax(0, 1fr);
+ gap: 0.75rem;
+ align-items: start;
+}
+
+.loader-meta-row + .loader-meta-row {
+ margin-top: 0.625rem;
+}
+
+.loader-meta dt {
+ color: var(--loader-text-muted);
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.loader-meta dd {
+ margin: 0;
+ min-width: 0;
+ color: var(--loader-text);
+ font-size: 0.925rem;
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+}
+
+.loader-noscript {
+ position: fixed;
+ right: 1rem;
+ bottom: 1rem;
+ left: 1rem;
+ display: grid;
+ gap: 0.375rem;
+ padding: 0.875rem 1rem;
+ border: 1px solid rgba(248, 113, 113, 0.28);
+ border-radius: 0.875rem;
+ background: rgba(69, 10, 10, 0.92);
+ color: #fecaca;
+}
+
+@keyframes loader-pulse {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ background: var(--loader-accent-soft);
+ }
+
+ 50% {
+ transform: translateY(-0.125rem);
+ background: var(--loader-accent);
+ }
+}
+
+@media (max-width: 47.99rem) {
+ .loader-shell {
+ padding: 1rem;
+ }
+
+ .loader-panel {
+ padding: 1.25rem;
+ }
+
+ .loader-meta-row {
+ grid-template-columns: 1fr;
+ gap: 0.25rem;
+ }
+}
diff --git a/Proxy/Local/Dockerfile b/Proxy/Local/Dockerfile
index 8826ed4..15a81cb 100644
--- a/Proxy/Local/Dockerfile
+++ b/Proxy/Local/Dockerfile
@@ -1,5 +1,10 @@
# syntax=docker/dockerfile:1.7
+## Frontend
+## =========================
+
+ARG NGINX_VERSION=1.29.8
+
FROM node:22-alpine AS frontend-dependencies
WORKDIR /workspace/Frontend
@@ -17,10 +22,77 @@ COPY Frontend/ ./
RUN pnpm build:static
-FROM nginx:1.29-alpine AS production
+## Proxy
+## =========================
+FROM alpine:3.22 AS precompressed-assets
+
+RUN apk add --no-cache brotli gzip
+
+WORKDIR /workspace
+
+COPY Loader ./html/__loader
+COPY --from=frontend-build /workspace/Frontend/dist/client ./html
+
+RUN find ./html -type f \( \
+ -name '*.html' -o \
+ -name '*.css' -o \
+ -name '*.js' -o \
+ -name '*.json' -o \
+ -name '*.svg' -o \
+ -name '*.txt' -o \
+ -name '*.xml' -o \
+ -name '*.webmanifest' \
+\) -exec gzip -kf -9 {} \; \
+ && find ./html -type f \( \
+ -name '*.html' -o \
+ -name '*.css' -o \
+ -name '*.js' -o \
+ -name '*.json' -o \
+ -name '*.svg' -o \
+ -name '*.txt' -o \
+ -name '*.xml' -o \
+ -name '*.webmanifest' \
+\) -exec brotli -kf -Z {} \;
+
+FROM alpine:3.22 AS brotli-modules
+
+ARG NGINX_VERSION
+
+RUN apk add --no-cache \
+ alpine-sdk \
+ cmake \
+ git \
+ linux-headers \
+ openssl-dev \
+ pcre2-dev \
+ wget \
+ zlib-dev
+
+WORKDIR /tmp
+
+RUN wget -O "nginx-${NGINX_VERSION}.tar.gz" "https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" \
+ && tar -xzf "nginx-${NGINX_VERSION}.tar.gz" \
+ && git clone --recurse-submodules https://github.com/google/ngx_brotli.git
+
+WORKDIR /tmp/nginx-${NGINX_VERSION}
+
+RUN cd /tmp/ngx_brotli/deps/brotli \
+ && mkdir -p out \
+ && cd out \
+ && cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DCMAKE_C_FLAGS="-fPIC" -DCMAKE_CXX_FLAGS="-fPIC" .. \
+ && cmake --build . --config Release -j"$(getconf _NPROCESSORS_ONLN)" \
+ && cd /tmp/nginx-${NGINX_VERSION} \
+ && ./configure --with-compat --add-dynamic-module=/tmp/ngx_brotli \
+ && make modules
+
+FROM nginx:${NGINX_VERSION}-alpine AS production
+
+COPY Proxy/Local/nginx.conf /etc/nginx/nginx.conf
COPY Proxy/Local/default.conf /etc/nginx/conf.d/default.conf
-COPY --from=frontend-build /workspace/Frontend/dist/client /usr/share/nginx/html
+COPY --from=brotli-modules /tmp/nginx-${NGINX_VERSION}/objs/ngx_http_brotli_filter_module.so /usr/lib/nginx/modules/
+COPY --from=brotli-modules /tmp/nginx-${NGINX_VERSION}/objs/ngx_http_brotli_static_module.so /usr/lib/nginx/modules/
+COPY --from=precompressed-assets /workspace/html /usr/share/nginx/html
EXPOSE 80
diff --git a/Proxy/Local/default.conf b/Proxy/Local/default.conf
index 449892a..adfa1ca 100644
--- a/Proxy/Local/default.conf
+++ b/Proxy/Local/default.conf
@@ -1,3 +1,8 @@
+map $http_cookie $moku_bootstrap_document {
+ default /__loader/index.html;
+ "~*(^|; )moku_loader_seen=1($|;)" /index.html;
+}
+
server {
listen 80;
server_name _;
@@ -5,8 +10,19 @@ server {
root /usr/share/nginx/html;
index index.html;
+ location = / {
+ add_header Cache-Control "no-store";
+ rewrite ^ $moku_bootstrap_document last;
+ }
+
+ location ^~ /__loader/ {
+ access_log off;
+ add_header Cache-Control "no-store";
+ try_files $uri =404;
+ }
+
location / {
- try_files $uri $uri/ /index.html;
+ try_files $uri $uri/ $moku_bootstrap_document;
}
location /favicon.ico {
diff --git a/Proxy/Local/nginx.conf b/Proxy/Local/nginx.conf
new file mode 100644
index 0000000..71ecfb6
--- /dev/null
+++ b/Proxy/Local/nginx.conf
@@ -0,0 +1,60 @@
+load_module /usr/lib/nginx/modules/ngx_http_brotli_filter_module.so;
+load_module /usr/lib/nginx/modules/ngx_http_brotli_static_module.so;
+
+user nginx;
+worker_processes auto;
+
+error_log /var/log/nginx/error.log notice;
+pid /run/nginx.pid;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ sendfile on;
+ keepalive_timeout 65;
+
+ gzip on;
+ gzip_static on;
+ gzip_vary on;
+ gzip_proxied any;
+ gzip_min_length 1024;
+ gzip_comp_level 6;
+ gzip_types
+ text/plain
+ text/css
+ text/javascript
+ application/javascript
+ application/json
+ application/manifest+json
+ application/xml
+ application/rss+xml
+ image/svg+xml;
+
+ brotli on;
+ brotli_static on;
+ brotli_comp_level 5;
+ brotli_min_length 1024;
+ brotli_types
+ text/plain
+ text/css
+ text/javascript
+ application/javascript
+ application/json
+ application/manifest+json
+ application/xml
+ application/rss+xml
+ image/svg+xml;
+
+ include /etc/nginx/conf.d/*.conf;
+}