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

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