Phase 2: React scaffolding, self-hosted fonts, design tokens

Vite builds to dist/, deliberately not www/. www/ is still produced by
sync-www.sh for the Phase 1 APK, which stays installable throughout the
port; cutover is a one-line webDir change in capacitor.config.json.
base is relative because Capacitor serves the bundle from the APK, where
absolute asset paths would 404.

index.html omits viewport-fit=cover on purpose. That string is what
makes Capacitor's SystemBars plugin pass insets through to the page;
without it the plugin pads the decor view and the layout is always
between the status and gesture bars. Phase 1 learned this the hard way.

Fonts are self-hosted for offline use, latin subsets only: three files
at 68K, against the 133K Barlow costs today. Space Grotesk is variable,
so one file covers 400-700. Dropped latin-ext and vietnamese.

Carets are drawn as SVG, not typed. U+25BE and U+23EE sit outside every
Latin subset and would fall back to a system font mid-label.

base.css carries the settled palette and the measured vertical budget as
tokens, so a component cannot quietly exceed it:
  8 + 44 + 8 + 525 + 8 + 84 + 8 + 50 = 735

Verified: typecheck clean, 103 tests, vite build emits with the fonts
copied, and www/ is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 01:05:09 +03:00
parent ad847faa2e
commit 956dc57763
13 changed files with 369 additions and 5 deletions

101
src/App.module.css Normal file
View File

@@ -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;
}

88
src/App.tsx Normal file
View File

@@ -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 (
<svg width="9" height="6" viewBox="0 0 9 6" aria-hidden="true">
<path d="M1 1l3.5 3.5L8 1" fill="none" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
const layerLabel = (key: string): string =>
key === "" ? "NO MODIFIER" : key.split("+").map(controlName).join(" + ");
export function App() {
const [ctx, setCtx] = useState<ContextCode>("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 (
<div className={styles.app}>
<div className={styles.strip}>
<button
className={styles.picker}
onClick={() => {
const order = Object.keys(CTX) as ContextCode[];
setCtx(order[(order.indexOf(ctx) + 1) % order.length]!);
}}
>
<b>{CTX[ctx].toUpperCase()}</b>
<Caret />
</button>
<button
className={styles.picker}
onClick={() => {
const i = layers.indexOf(activeLayer);
setLayer(layers[(i + 1) % layers.length]!);
}}
>
<b>{layerLabel(activeLayer)}</b>
<span className={styles.count}>{counts.get(activeLayer) ?? 0}</span>
</button>
</div>
<div className={styles.panel}>
<p className={styles.placeholder}>
{counts.get(activeLayer) ?? 0} functions on this layer
</p>
</div>
<div className={styles.info} />
<nav className={styles.nav}>
{TABS.map((name) => (
<button
key={name}
className={name === tab ? styles.tabOn : styles.tab}
onClick={() => setTab(name)}
>
{name.toUpperCase()}
</button>
))}
</nav>
</div>
);
}

13
src/main.tsx Normal file
View File

@@ -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(
<StrictMode>
<App />
</StrictMode>,
);

102
src/styles/base.css Normal file
View File

@@ -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;
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />