← /code
OverviewFlowDecisionsHistory
Architecture · Decisions

Why these picks

Every tech pick has alternatives. Here's what was chosen, what wasn't, and why — plus a few engineering details worth knowing.

07

Decision table

TopicPickedNot pickedWhy
UI libraryReact 19Vue / Svelte / SolidWidest ecosystem; cubing.js / sr-puzzlegen samples are React; team familiarity.
FrameworkNext.js 16 (App Router)Remix / TanStack Start / 纯 Vite SPAApp Router + RSC + server streaming in one; Turbopack dev/build; one codebase deploys to both systemd standalone and Vercel. Cut over from React Router SPA in Phase 4 (2026-05).
BundlerTurbopackWebpack / ViteBundled with Next.js 16, drives both dev incremental compile and prod build; first cold compile 30-90s, then sub-second incremental.
Styling手写语义化 CSS + Tailwind 4 base纯 Tailwind / CSS-in-JSPer-page hand-written semantic CSS is the primary style layer (compare.css / stack_landing.css etc., page-prefixed names like .compare-card). Tailwind 4 is wired via @tailwindcss/postcss + a single @import "tailwindcss" in app/globals.css — it supplies preflight + a utility namespace as the base layer, but className="flex p-4" is not the idiom. Theme tokens use shadcn naming + CSS custom properties.
API serverHonoExpress / FastifyTS-first; declarative routing; ~5MB deps vs Express noisy stack.
DatabasePostgreSQL 13MariaDB / MongoDBMigrated from MariaDB 2026-05. jsonb, window functions, partial indexes — a tier above MariaDB.
Monorepopnpm + Turbonpm / yarn workspacesFour core workspaces (client / server / shared / stats-build), one pnpm-lock. Hard-linked node_modules saves disk; Turbo runs only changed packages. The underlying registry is still npm (registry.npmjs.org) — pnpm is just a faster client.
State mgmtZustandRedux Toolkit / Jotai / Context11 stores (6 global + 5 page-local). No Provider — create() returns a hook, components select slices. auth syncs across tabs via the storage event; settings/sessions persist to localStorage via middleware. ~1 KB bundle cost.
Hosting自有 VM nginx + Vercel (DNS 分线路)单 Vercel / 单 nginxSplit-horizon DNS, same Next.js codebase on both. One line → self-hosted VM nginx → systemd Next standalone (reverse-proxy :3002, deploy_next.yml CI auto scp + atomic swap); the other line → Vercel Hobby edge (push-to-GitHub auto-deploy). Backend Hono+PG stays on the same VM; Vercel side hits it via api.cuberoot.me.
Theme tokensshadcn 命名 + hex + color-mixoklch / Material 3 / Radix ColorsDark/light across 8 pages. Naming follows shadcn (OSS standard, friendly to AI code-gen); hex values (surveyed 30+ big-co incl. Anthropic console — zero use oklch as primary brand tokens); derivations via color-mix(in srgb) aligning with Anthropic CDS (644 production uses).
08

Engineering details worth knowing

SharedArrayBuffer · COOP/COEP

/scramble/solver and /scramble/analyzer run cubeopt-wasm and require SharedArrayBuffer. Only those two routes get nginx-injected COOP=same-origin + COEP=require-corp for cross-origin isolation. Every other page stays clean — login callbacks unaffected.

apiUrl() 是唯一的 fetch 入口

Client never hardcodes origin. lib/api-base.ts uses import.meta.env.DEV: dev → next.config.ts rewrites() proxy to api.cuberoot.me, prod → direct api.cuberoot.me. hostname checks get fooled by Tailscale / LAN IP — banned.

cubing.js + sr-puzzlegen + visualcube 三件套

cubing.js for animation (TwistyPlayer) and 3x3/4x4 solvers. sr-puzzlegen for sq1 / megaminx / pyraminx / skewb SVGs. visualcube for NxN state images (F2L / OLL / PLL / ZBLL). Three libs, three lanes — hand-written cube SVG is banned.

i18n — 两种 pattern 并存

Long blocks → t() + en.json/zh.json; inline strings → isZh ? 'X' : 'Y' ternary. LangToggle sits top-right on every page. Chinese comp names live in a separate comp_names_zh.json.

Theme — dark / light / system 三态

shadcn-style tokens (--background --foreground --muted-foreground --accent --signal-*) live in :root, light defaults + @media (prefers-color-scheme: dark) + html[data-theme] dual override. Derivations always go through color-mix(in srgb, var(--base) X%, transparent) so changing one base ripples to all. ThemeToggle sits top-right and cycles system → light → dark, persists to localStorage.theme, applied via bootstrapTheme() at startup. 8 pages support switching (3 dual-theme + 4 dark-locked + 1 light-locked); legacy pages still use the old --bg-primary --text-primary tokens untouched.

WCA 统计的脆弱三角

Adding a stat table needs three coordinated edits: stats-build/src/bin/*.ts (writes TSV), .github/workflows/stats.yml (scp manifest), ops/sql/load.sql (\copy reference). Miss one and the server table silently empties — nginx still caches 24h. The only safety net: a 30-second grep dry-run across all three.

fork / port / own 三种治理

fork (csTimer / Solver / Alg Trainers) = upstream assets hosted as-is, only the outer shell is ours. port (Calc / Battle / Mosaic) = someone else's React / HTML, rewritten in this repo. own (the other 11) = designed and built here. Touching a fork or port? Check upstream first.

状态管理 — Zustand 11 个 store

Global stores live in src/stores/: auth_store (WCA OAuth user), settingsStore (theme / lang, persisted), sessionStore (active solve session, persisted), statsStore (WCA stats query), trainerStore (drill state, persisted), recon_store (recon cache). Page-local stores live next to their pages (battle / calc / mosaic / viz). One pattern throughout: create() returns a hook — no Provider, no reducer. auth listens to the window 'storage' event for cross-tab sync.

npm registry — 我们用 pnpm 但拉的是 npm

pnpm install still fetches tarballs from registry.npmjs.org. yarn / pnpm / bun are different clients of the same registry, all sharing the package.json + semver + lockfile protocol that npm defined. We pick pnpm for hard-linked store (disk savings), Turbo-cache friendliness, and good workspaces — but the moat (4M+ packages, hundreds of billions of weekly downloads) is at npm's end.

10

Mobile: one SPA, two webview shells

2026-05 wrapped the SPA with Capacitor 8 — same dist/ hosted inside iOS WKWebView + Android WebView, side-loaded to personal devices, not published to either store. CI runs two runners in parallel: ubuntu builds APK via gradle (~3.5min), macOS builds IPA via xcodebuild (~3min), auto-triggered on push to main when client/shared/visualcube change. iOS uses Sideloadly + free Apple ID for 7-day signing (no $99/yr), Android installs directly with no expiry.

static bundle (Next build)pnpm exec cap syncCapacitor 8 套壳appId me.cuberoot.app · webDir distubuntu-latest runnergradle assembleDebug · ~3.5minmacos-latest runnerxcodebuild archive · ~3mincuberoot.apk~40MB · debug signedcuberoot-unsigned.ipa~34MB · 待 Sideloadly 签Android 直装允许未知来源 · 数据持久iPhone · Sideloadly 自签免费 Apple ID · 7 天证书

But the app isn't just "the site, again, inside a webview" — its origin is capacitor://localhost (iOS) / https://localhost (Android), not cuberoot.me. CORS, routing, static assets, and OAuth each need their own fallback. Here's how web and app actually differ at runtime:

AspectWebApp
originhttps://cuberoot.mecapacitor://localhost / https://localhost
API callsfetch api.cuberoot.me, 3-entry CORS allowlistadd capacitor + localhost origins · CapacitorHttp bypasses webview CORS
/stats/* /tools/*served by nginxnot bundled (17MB too heavy); fetch wrapper rewrites to cuberoot.me
back buttonbrowser ◁ uses history.back@capacitor/app intercepts backButton → React Router navigate(-1) (webView.canGoBack ignores pushState)
WCA OAuthredirect_uri = https://cuberoot.me/auth/callbackcustom-scheme deep link me.cuberoot.app://auth-callback · @capacitor/browser opens + appUrlOpen catches token
update pathnginx deploy = instantpush → CI build → manual reinstall (artifact retained 14d)

Install steps + known webview limitations (cstimer iframe / SAB / WebCodecs) live in core/packages/client/MOBILE.md.

Overview·Flow·History