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
Topic
Picked
Not picked
Why
UI library
React 19
Vue / Svelte / Solid
Widest ecosystem; cubing.js / sr-puzzlegen samples are React; team familiarity.
Framework
Next.js 16 (App Router)
Remix / TanStack Start / 纯 Vite SPA
App 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).
Bundler
Turbopack
Webpack / Vite
Bundled 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-JS
Per-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 server
Hono
Express / Fastify
TS-first; declarative routing; ~5MB deps vs Express noisy stack.
Database
PostgreSQL 13
MariaDB / MongoDB
Migrated from MariaDB 2026-05. jsonb, window functions, partial indexes — a tier above MariaDB.
Monorepo
pnpm + Turbo
npm / yarn workspaces
Four 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 mgmt
Zustand
Redux Toolkit / Jotai / Context
11 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 / 单 nginx
Split-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 tokens
shadcn 命名 + hex + color-mix
oklch / Material 3 / Radix Colors
Dark/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.tsrewrites() 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.
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:
Aspect
Web
App
origin
https://cuberoot.me
capacitor://localhost / https://localhost
API calls
fetch api.cuberoot.me, 3-entry CORS allowlist
add capacitor + localhost origins · CapacitorHttp bypasses webview CORS
/stats/* /tools/*
served by nginx
not bundled (17MB too heavy); fetch wrapper rewrites to cuberoot.me