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.
|
||||
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/
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
|
||||
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: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",
|
||||
|
||||
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