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", + }, +});