diff --git a/.gitignore b/.gitignore
index 4a795d9..22efc23 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,10 @@ node_modules/
# Generated by scripts/sync-www.sh from reference/ - never edit by hand.
www/
+# Vite build output for the Phase 2 React app. Kept separate from www/ so the
+# Phase 1 APK stays installable until cutover.
+dist/
+
# Capacitor's generated Android project. Tracked selectively: the few files we
# hand-edit (MainActivity, manifest, styles, variables.gradle) are force-added.
android/.gradle/
diff --git a/CLAUDE.md b/CLAUDE.md
index 3bea162..01e0c3b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -254,10 +254,15 @@ panel 8 + 36 + 8 + 124 + 10 + 134 + 10 + 187 + 8 = 525
pads (187 - 15) / 4 = 43px rows
```
-**Still open:** the typeface (mockups use Space Grotesk + Space Mono; Martian
-Mono and Archivo are the alternates on the specimen board) and whether the accent
-is `#FF4A00` or the current, softer `#ff7a1a`. Neither blocks the data and logic
-work, only the UI.
+**Typeface: Space Grotesk + Space Mono.** Space Grotesk for labels and headings,
+Space Mono for numerals and technical annotations. Self-hosted as woff2 under
+`public/fonts/` — the app must work with no network, as Barlow did. Martian Mono
+was the alternate and was rejected on measurement: it is a wide mono, and key
+labels already clear their buttons by only 2.3px at 7px.
+
+**Accent: `#FF4A00`.** TE's orange, not the softer `#ff7a1a` Phase 1 used. It
+holds against both the light body and the dark mode's near-black, which matters
+because the accent has exactly one job — marking a control as live.
## Phase 2 must carry these UI changes
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..cbac5ee
--- /dev/null
+++ b/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+ SP-404MK2 layers
+
+
+
+
+
+
diff --git a/package.json b/package.json
index 26e2547..26b652a 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,10 @@
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
- "extract:data": "node scripts/extract-reference-data.mjs"
+ "extract:data": "node scripts/extract-reference-data.mjs",
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
},
"dependencies": {
"@capacitor/android": "^8.5.2",
diff --git a/public/fonts/space-grotesk-var-latin.woff2 b/public/fonts/space-grotesk-var-latin.woff2
new file mode 100644
index 0000000..0f3474e
Binary files /dev/null and b/public/fonts/space-grotesk-var-latin.woff2 differ
diff --git a/public/fonts/space-mono-400-latin.woff2 b/public/fonts/space-mono-400-latin.woff2
new file mode 100644
index 0000000..2752f60
Binary files /dev/null and b/public/fonts/space-mono-400-latin.woff2 differ
diff --git a/public/fonts/space-mono-700-latin.woff2 b/public/fonts/space-mono-700-latin.woff2
new file mode 100644
index 0000000..6563bad
Binary files /dev/null and b/public/fonts/space-mono-700-latin.woff2 differ
diff --git a/src/App.module.css b/src/App.module.css
new file mode 100644
index 0000000..38a9dcf
--- /dev/null
+++ b/src/App.module.css
@@ -0,0 +1,101 @@
+/* Heights come from the tokens, which carry the measured 360x735 budget:
+ 8 + 44 + 8 + 525 + 8 + 84 + 8 + 50 = 735, with no bottom padding. */
+
+.app {
+ height: 100dvh;
+ padding: var(--gap) var(--gap) 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap);
+}
+
+.strip {
+ height: var(--strip-h);
+ flex: 0 0 auto;
+ display: flex;
+ gap: var(--gap);
+}
+
+.picker {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ background: var(--card);
+ border: var(--outline);
+ box-shadow: var(--shadow-sm);
+ padding: 0 10px;
+ font-size: 12px;
+ letter-spacing: 0.06em;
+ text-align: left;
+}
+
+.picker b {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.count {
+ flex: 0 0 auto;
+ font-family: var(--mono);
+ font-size: 10px;
+ color: var(--accent);
+}
+
+.panel {
+ height: var(--panel-h);
+ flex: 0 0 auto;
+ background: var(--body);
+ border: var(--outline);
+ box-shadow: var(--shadow);
+ display: grid;
+ place-items: center;
+}
+
+.placeholder {
+ margin: 0;
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+}
+
+.info {
+ height: var(--info-h);
+ flex: 0 0 auto;
+ background: var(--card);
+ border: var(--outline);
+ box-shadow: var(--shadow-sm);
+}
+
+.nav {
+ height: var(--nav-h);
+ flex: 0 0 auto;
+ display: flex;
+ background: var(--card);
+ border: var(--outline);
+}
+
+.tab,
+.tabOn {
+ flex: 1;
+ background: none;
+ border: 0;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+}
+
+.nav button + button {
+ border-left: var(--outline);
+}
+
+.tabOn {
+ background: var(--accent);
+ color: var(--on-accent);
+ font-weight: 700;
+}
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..abe98b4
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,88 @@
+/**
+ * App shell: the two pickers, a placeholder panel, and the tab bar.
+ *
+ * Angular: useState is roughly a component field plus change detection. Setting
+ * it re-runs this function, so `entries` and `counts` below are recomputed each
+ * render — cheap here, and `useMemo` is the explicit opt-out if it stops being.
+ */
+import { useMemo, useState } from "react";
+import { ENTRIES } from "./domain/entries";
+import { CTX } from "./domain/labels";
+import { controlName } from "./domain/format";
+import { layerCounts, orderLayerKeys } from "./domain/layers";
+import type { ContextCode } from "./domain/types";
+import styles from "./App.module.css";
+
+const TABS = ["Explore", "Drill", "Find", "Rules"] as const;
+
+/** Drawn, not typed: the caret glyphs sit outside every Latin font subset. */
+function Caret() {
+ return (
+
+ );
+}
+
+const layerLabel = (key: string): string =>
+ key === "" ? "NO MODIFIER" : key.split("+").map(controlName).join(" + ");
+
+export function App() {
+ const [ctx, setCtx] = useState("S");
+ const [layer, setLayer] = useState("");
+ const [tab, setTab] = useState<(typeof TABS)[number]>("Explore");
+
+ const counts = useMemo(() => layerCounts(ENTRIES, ctx), [ctx]);
+ const layers = useMemo(() => orderLayerKeys(counts.keys()), [counts]);
+
+ // Changing mode can strip the held layer out from under us.
+ const activeLayer = counts.has(layer) ? layer : "";
+
+ return (
+
+
+
+
+
+
+
+
+
+ {counts.get(activeLayer) ?? 0} functions on this layer
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..0d5ff81
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./App";
+import "./styles/base.css";
+
+const root = document.getElementById("root");
+if (!root) throw new Error("index.html is missing #root");
+
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/src/styles/base.css b/src/styles/base.css
new file mode 100644
index 0000000..fea38a4
--- /dev/null
+++ b/src/styles/base.css
@@ -0,0 +1,102 @@
+/* Design tokens and reset. The visual spec lives in CLAUDE.md. */
+
+/* Latin subsets only. Space Grotesk is variable, so one file covers 400-700. */
+@font-face {
+ font-family: "Space Grotesk";
+ font-weight: 400 700;
+ font-style: normal;
+ font-display: swap;
+ src: url("/fonts/space-grotesk-var-latin.woff2") format("woff2");
+}
+@font-face {
+ font-family: "Space Mono";
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+ src: url("/fonts/space-mono-400-latin.woff2") format("woff2");
+}
+@font-face {
+ font-family: "Space Mono";
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+ src: url("/fonts/space-mono-700-latin.woff2") format("woff2");
+}
+
+:root {
+ --font: "Space Grotesk", system-ui, sans-serif;
+ --mono: "Space Mono", ui-monospace, monospace;
+
+ /* Direction A, manual. */
+ --page: #e9e9e6;
+ --body: #d2d2ce;
+ --card: #f5f5f2;
+ --ink: #111111;
+ --muted: #6e6e69;
+ --rule: #b5b5b0;
+ --key: #f5f5f2;
+ --pad: #1b1b1b;
+ --pad-ink: #ededeb;
+ --lcd: #c9d2c4;
+
+ /* One accent, one meaning: this control is live. */
+ --accent: #ff4a00;
+ --on-accent: #111111;
+
+ --outline: 2px solid var(--ink);
+ --shadow: 4px 4px 0 var(--ink);
+ --shadow-sm: 3px 3px 0 var(--ink);
+
+ /* The measured vertical budget at 360x735. Nothing may exceed it. */
+ --gap: 8px;
+ --strip-h: 44px;
+ --panel-h: 525px;
+ --info-h: 84px;
+ --nav-h: 50px;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --page: #1a1a17;
+ --body: #2e2e2a;
+ --card: #2e2e2a;
+ --ink: #ededeb;
+ --muted: #8a8a85;
+ --rule: #4a4a46;
+ --key: #3e3e3a;
+ --pad: #0e0e0c;
+ --outline: 2px solid #000000;
+ --shadow: 5px 5px 0 #000000;
+ --shadow-sm: 3px 3px 0 #000000;
+ }
+}
+
+* {
+ box-sizing: border-box;
+ -webkit-tap-highlight-color: transparent;
+}
+
+html,
+body {
+ margin: 0;
+ overscroll-behavior: none;
+}
+
+body {
+ background: var(--page);
+ color: var(--ink);
+ font-family: var(--font);
+ font-size: 16px;
+ line-height: 1.35;
+}
+
+button {
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+}
+
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..ea98ddd
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,24 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+/**
+ * Builds to dist/, deliberately not www/.
+ *
+ * www/ is still produced by scripts/sync-www.sh for the Phase 1 APK, which stays
+ * installable while the port is in progress. Cutover is a one-line change to
+ * webDir in capacitor.config.json once the React app replaces it.
+ *
+ * base is relative because Capacitor serves the bundle from the APK, not a
+ * domain root; absolute asset paths would 404 there.
+ */
+export default defineConfig({
+ plugins: [react()],
+ base: "./",
+ build: {
+ outDir: "dist",
+ // The panel is one dense screen; a single bundle beats chunk round-trips
+ // from a file:// style origin.
+ assetsInlineLimit: 0,
+ target: "es2022",
+ },
+});