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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,6 +3,10 @@ node_modules/
|
|||||||
# Generated by scripts/sync-www.sh from reference/ - never edit by hand.
|
# Generated by scripts/sync-www.sh from reference/ - never edit by hand.
|
||||||
www/
|
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
|
# Capacitor's generated Android project. Tracked selectively: the few files we
|
||||||
# hand-edit (MainActivity, manifest, styles, variables.gradle) are force-added.
|
# hand-edit (MainActivity, manifest, styles, variables.gradle) are force-added.
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
|
|||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -254,10 +254,15 @@ panel 8 + 36 + 8 + 124 + 10 + 134 + 10 + 187 + 8 = 525
|
|||||||
pads (187 - 15) / 4 = 43px rows
|
pads (187 - 15) / 4 = 43px rows
|
||||||
```
|
```
|
||||||
|
|
||||||
**Still open:** the typeface (mockups use Space Grotesk + Space Mono; Martian
|
**Typeface: Space Grotesk + Space Mono.** Space Grotesk for labels and headings,
|
||||||
Mono and Archivo are the alternates on the specimen board) and whether the accent
|
Space Mono for numerals and technical annotations. Self-hosted as woff2 under
|
||||||
is `#FF4A00` or the current, softer `#ff7a1a`. Neither blocks the data and logic
|
`public/fonts/` — the app must work with no network, as Barlow did. Martian Mono
|
||||||
work, only the UI.
|
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
|
## Phase 2 must carry these UI changes
|
||||||
|
|
||||||
|
|||||||
23
index.html
Normal file
23
index.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<!--
|
||||||
|
No viewport-fit=cover, deliberately.
|
||||||
|
|
||||||
|
Capacitor's SystemBars plugin switches on this exact string: with it, insets
|
||||||
|
pass through, the WebView grows to full height and the page must place
|
||||||
|
itself from injected CSS variables — a handshake that raced in Phase 1 and
|
||||||
|
left the app drawing under the status and gesture bars. Without it the
|
||||||
|
plugin pads the decor view and the layout is always between the bars.
|
||||||
|
See the system-bars decision in CLAUDE.md.
|
||||||
|
-->
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>SP-404MK2 layers</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -18,7 +18,10 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit",
|
"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": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^8.5.2",
|
"@capacitor/android": "^8.5.2",
|
||||||
|
|||||||
BIN
public/fonts/space-grotesk-var-latin.woff2
Normal file
BIN
public/fonts/space-grotesk-var-latin.woff2
Normal file
Binary file not shown.
BIN
public/fonts/space-mono-400-latin.woff2
Normal file
BIN
public/fonts/space-mono-400-latin.woff2
Normal file
Binary file not shown.
BIN
public/fonts/space-mono-700-latin.woff2
Normal file
BIN
public/fonts/space-mono-700-latin.woff2
Normal file
Binary file not shown.
101
src/App.module.css
Normal file
101
src/App.module.css
Normal 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
88
src/App.tsx
Normal 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
13
src/main.tsx
Normal 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
102
src/styles/base.css
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user