首次提交:脑晴测 H5 项目

包含前端源码、预构建产物(dist/)、零依赖 Node.js 服务器(server.mjs)、
Cloudflare Worker 版本(worker/)、部署文档(DEPLOY.md)和交接文档。

技术总监可直接 clone 后运行 node server.mjs 启动,无需安装 React 或 build。
DEEPSEEK_API_KEY 通过环境变量注入,未纳入仓库。
This commit is contained in:
MingNian
2026-07-28 10:24:47 +08:00
commit b0fce840a3
152 changed files with 8536 additions and 0 deletions

5
brain-h5/src/App.tsx Normal file
View File

@@ -0,0 +1,5 @@
import Prototype from "./Prototype";
export default function App() {
return <Prototype />;
}

988
brain-h5/src/Prototype.tsx Normal file
View File

@@ -0,0 +1,988 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { LayoutGroup, motion } from "motion/react";
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ChartLineUp,
Check,
CheckCircle,
ClockCountdown,
Heart,
LockKey,
Minus,
Path,
Sparkle,
Speedometer,
SquaresFour,
Star,
Target,
Timer,
X,
} from "@phosphor-icons/react";
type Screen = "home" | "profile" | "overview" | "symbol" | "trail" | "attention" | "memory" | "money" | "complete" | "report";
type Game = "symbol" | "trail" | "attention" | "memory" | "money";
type Direction = "up" | "right" | "down" | "left";
type MetricKey = "speed" | "executive" | "attention" | "memory" | "calculation";
type Metric = { score: number; correct: number; total: number; errors: number; completion: number };
type Plane = { id: number; color: "blue" | "orange"; direction: Direction; lane: number; duration: number; delay: number; target: boolean };
const SYMBOL_IMAGES = [
"/assets/brain/symbols/camera.png",
"/assets/brain/symbols/sun.png",
"/assets/brain/symbols/squares.png",
"/assets/brain/symbols/lock.png",
"/assets/brain/symbols/lightning.png",
"/assets/brain/symbols/moon.png",
"/assets/brain/symbols/gear.png",
"/assets/brain/symbols/star.png",
"/assets/brain/symbols/puzzle.png",
"/assets/brain/symbols/coffee.png",
] as const;
const FOODS = [
{ name: "全麦面包", image: "/assets/brain/memory/bread.png", tone: "amber" },
{ name: "胡萝卜", image: "/assets/brain/memory/carrot.png", tone: "coral" },
{ name: "奶酪", image: "/assets/brain/memory/cheese.png", tone: "yellow" },
{ name: "咖啡", image: "/assets/brain/memory/coffee.png", tone: "violet" },
{ name: "曲奇", image: "/assets/brain/memory/cookie.png", tone: "pink" },
{ name: "鸡蛋", image: "/assets/brain/memory/egg.png", tone: "blue" },
{ name: "鲜鱼", image: "/assets/brain/memory/fish.png", tone: "cyan" },
{ name: "橙子", image: "/assets/brain/memory/orange.png", tone: "orange" },
] as const;
const PLANE_IMAGE = {
blue: "/assets/brain/planes/teal.png",
orange: "/assets/brain/planes/coral.png",
} as const;
const COMPLETION_IMAGES: Record<Game, string> = {
symbol: "/assets/ui/finish-symbol.png",
trail: "/assets/ui/finish-trail.png",
attention: "/assets/ui/finish-attention.png",
memory: "/assets/ui/finish-memory.png",
money: "/assets/ui/finish-all.png",
};
const DIRECTIONS: Direction[] = ["up", "right", "down", "left"];
const DIRECTION_ICONS = { up: ArrowUp, right: ArrowRight, down: ArrowDown, left: ArrowLeft };
const DIRECTION_LABELS = { up: "向上", right: "向右", down: "向下", left: "向左" };
const DIRECTION_ANGLE = { up: -90, right: 0, down: 90, left: 180 };
const EMPTY_METRIC: Metric = { score: 0, correct: 0, total: 0, errors: 0, completion: 0 };
const MONEY_OPTIONS = [
{ value: 0.5, image: "/assets/money/money-05.png" },
{ value: 1, image: "/assets/money/money-1.png" },
{ value: 2, image: "/assets/money/money-2.png" },
{ value: 5, image: "/assets/money/money-5.png" },
{ value: 10, image: "/assets/money/money-10.png" },
{ value: 20, image: "/assets/money/money-20.png" },
] as const;
const MEMORY_ROUND_SECONDS = [0, 35, 40, 45];
const MONEY_ROUNDS = [
{ price: 13, payment: 20, target: 7, seconds: 40 },
{ price: 26.5, payment: 40, target: 13.5, seconds: 45 },
{ price: 41.5, payment: 60, target: 18.5, seconds: 50 },
] as const;
function moneyImage(value: number) {
return MONEY_OPTIONS.find((option) => option.value === value)?.image ?? MONEY_OPTIONS[0].image;
}
const TRAIL_NODES = Array.from({ length: 20 }, (_, index) => ({
kind: index % 2 === 0 ? ("number" as const) : ("shape" as const),
rank: Math.floor(index / 2) + 1,
}));
const TRAIL_PATH = [
{ x: 42, y: 58 }, { x: 57, y: 43 }, { x: 74, y: 54 }, { x: 66, y: 69 },
{ x: 50, y: 75 }, { x: 34, y: 65 }, { x: 18, y: 78 }, { x: 11, y: 58 },
{ x: 24, y: 43 }, { x: 14, y: 25 }, { x: 31, y: 16 }, { x: 45, y: 29 },
{ x: 57, y: 15 }, { x: 69, y: 31 }, { x: 80, y: 17 }, { x: 91, y: 31 },
{ x: 84, y: 48 }, { x: 92, y: 68 }, { x: 78, y: 82 }, { x: 58, y: 85 },
];
function shuffle<T>(items: readonly T[]) {
const copy = [...items];
for (let i = copy.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function randomDirection(): Direction {
return DIRECTIONS[Math.floor(Math.random() * DIRECTIONS.length)];
}
function scoreMetric(correct: number, total: number, errors: number, expected: number): Metric {
const completion = Math.min(1, total / expected);
const attempts = Math.max(total, correct + errors);
const accuracy = attempts ? correct / attempts : 0;
const score = Math.max(20, Math.min(98, Math.round(accuracy * 72 + completion * 28)));
return { score, correct, total, errors, completion: Math.round(completion * 100) };
}
function createTrailLayout() {
const flipX = Math.random() > 0.5;
const flipY = Math.random() > 0.5;
return TRAIL_PATH.map((point, index) => ({
x: (flipX ? 100 - point.x : point.x) + (index % 3 - 1) * 0.7,
y: (flipY ? 100 - point.y : point.y) + ((index + 1) % 3 - 1) * 0.6,
}));
}
function createPlanes(trial: number, targetColor: "blue" | "orange", targetDirection: Direction): Plane[] {
const count = trial < 3 ? 1 : trial < 10 ? 3 : trial < 20 ? 5 : 7;
const targetIndex = Math.floor(Math.random() * count);
return Array.from({ length: count }, (_, index) => {
const target = index === targetIndex;
// Keep the requested color unique. Distractors are always the opposite color.
const color = target ? targetColor : targetColor === "blue" ? "orange" : "blue";
return {
id: Date.now() + index,
target,
direction: target ? targetDirection : randomDirection(),
color,
lane: 12 + ((index * 17 + Math.floor(Math.random() * 10)) % 72),
duration: 6.7 + Math.random() * 1.5,
delay: index * 0.38,
};
});
}
function Brand() {
return (
<div className="brand">
<span><SquaresFour size={24} weight="fill" /></span>
<b></b>
<em>COGNITIVE STUDIO</em>
</div>
);
}
function PrimaryButton({ children, onClick, disabled, secondary = false }: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
secondary?: boolean;
}) {
return <button className={`primary-button ${secondary ? "secondary" : ""}`} onClick={onClick} disabled={disabled}>{children}</button>;
}
function GameHeader({ seconds, onBack }: { seconds: number; onBack: () => void }) {
return (
<header className="game-header">
<button className="round-button" onClick={onBack} aria-label="退出当前测试"><ArrowLeft weight="bold" /></button>
<div className="timer"><Timer weight="fill" /><b>{String(Math.floor(seconds / 60)).padStart(2, "0")}:{String(seconds % 60).padStart(2, "0")}</b></div>
</header>
);
}
function GameIntro({ game, onStart }: { game: Game; onStart: () => void }) {
const info = {
symbol: { no: "01", title: "符号速配", text: "记住固定符号表,看到目标后选择对应数字。", rules: ["映射表全程固定", "准确优先,再提高速度", "完成练习后开始"] },
trail: { no: "02", title: "交替连线", text: "按照数字与绿色扇区交替、从小到大的顺序连接。", rules: ["1 到最小扇区再到 2", "扇区面积逐级增加", "错误不会推进进度"] },
attention: { no: "03", title: "动态方向追踪", text: "根据顶部目标颜色,在飞行中的机群里判断目标飞机头朝向。", rules: ["飞机持续移动", "目标颜色每题变化", "三次错误后结束"] },
memory: { no: "04", title: "购物记忆", text: "先记住清单,再从移动的传送带上选中对应商品。", rules: ["商品依次移动出现", "每轮难度逐渐增加", "中途回看会影响评分"] },
money: { no: "05", title: "零钱计算", text: "根据应找金额,组合出完全一致的钱币数值。", rules: ["直接点击钱币", "点击已选钱币可撤回", "完成两轮精确找零"] },
}[game];
const [practised, setPractised] = useState(false);
const [practiceStep, setPracticeStep] = useState(0);
const [practicePick, setPracticePick] = useState<number | null>(null);
const finishPractice = () => {
setPractised(true);
};
const practiceContent = {
symbol: (
<div className="practice-panel symbol-practice">
<div className="coach-hint"><span><b></b> </span><ArrowDown weight="bold" /></div>
<img src={SYMBOL_IMAGES[2]} alt="" />
<div className="practice-key-row">{[1, 2, 3].map((number) => <button key={number} className={practicePick === number ? "picked" : ""} onClick={() => { setPracticePick(number); if (number === 2) finishPractice(); }}>{number}</button>)}</div>
</div>
),
trail: (
<div className="practice-panel trail-practice">
<div className="coach-hint"><span><b></b> 1 2</span><ArrowDown weight="bold" /></div>
{[0, 1, 2].map((index) => <button key={index} className={practiceStep > index ? "done" : ""} onClick={() => { if (practiceStep === index) { const next = practiceStep + 1; setPracticeStep(next); if (next === 3) finishPractice(); } }}>{index === 1 ? <i /> : index === 0 ? "1" : "2"}</button>)}
</div>
),
attention: (
<div className="practice-panel attention-practice">
<div className="coach-hint"><span><b></b> </span><ArrowDown weight="bold" /></div>
<img src={PLANE_IMAGE.blue} alt="" />
<div className="practice-directions">{DIRECTIONS.map((direction) => { const Icon = DIRECTION_ICONS[direction]; return <button className={`direction-${direction}`} key={direction} onClick={() => { if (direction === "right") finishPractice(); }}><Icon weight="bold" /></button>; })}</div>
</div>
),
memory: (
<div className="practice-panel memory-practice">
<div className="coach-hint"><span><b>{practiceStep === 0 ? "先记住清单" : "现在动手选择"}</b> {practiceStep === 0 ? "点击任意商品进入选择练习" : "点击清单里的曲奇"}</span><ArrowDown weight="bold" /></div>
<div>{[1, 4, 7].map((index) => <button key={index} className={practicePick === index ? "picked" : ""} onClick={() => { if (practiceStep === 0) { setPracticeStep(1); return; } setPracticePick(index); if (index === 4) finishPractice(); }}><img src={FOODS[index].image} alt="" /></button>)}</div>
</div>
),
money: (
<div className="practice-panel memory-practice">
<div className="coach-hint"><span><b> 7 </b> 5 2 </span><ArrowDown weight="bold" /></div>
<div>{MONEY_OPTIONS.filter((option) => option.value === 5 || option.value === 2).map((option) => <button key={option.value} onClick={() => finishPractice()}><img src={option.image} alt={`${option.value}`} /></button>)}</div>
</div>
),
}[game];
return (
<div className="intro-backdrop">
<section className={`intro-card tutorial-card tutorial-${game}`}>
<header className="tutorial-head"><span> {info.no}</span><h2>{info.title}</h2><p>{info.text}</p></header>
{practiceContent}
<div className={`practice-trigger ${practised ? "done" : ""}`}>
{practised ? <><Check weight="bold" /> </> : <><ArrowUp weight="bold" /> </>}
</div>
<PrimaryButton disabled={!practised} onClick={onStart}> <ArrowRight weight="bold" /></PrimaryButton>
</section>
</div>
);
}
function CoachOverlay({ target, text, onTargetClick }: { target: string; text: string; onTargetClick: () => void }) {
const [rect, setRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
useLayoutEffect(() => {
const update = () => {
const element = document.querySelector<HTMLElement>(target);
if (!element) return;
const bounds = element.getBoundingClientRect();
const padding = 9;
setRect({
left: Math.max(4, bounds.left - padding),
top: Math.max(4, bounds.top - padding),
width: Math.min(window.innerWidth - 8, bounds.width + padding * 2),
height: Math.min(window.innerHeight - 8, bounds.height + padding * 2),
});
};
update();
const observer = new ResizeObserver(update);
const element = document.querySelector<HTMLElement>(target);
if (element) observer.observe(element);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("resize", update);
};
}, [target]);
if (!rect) return null;
const bubbleWidth = Math.min(292, window.innerWidth - 24);
const below = rect.top < window.innerHeight * 0.42;
const bubbleLeft = Math.max(12, Math.min(window.innerWidth - bubbleWidth - 12, rect.left + rect.width / 2 - bubbleWidth / 2));
const bubbleTop = below ? Math.min(window.innerHeight - 132, rect.top + rect.height + 22) : Math.max(12, rect.top - 118);
return (
<div className="coach-layer" aria-live="polite">
<div className="coach-shade coach-shade-top" style={{ height: rect.top }} />
<div className="coach-shade coach-shade-left" style={{ top: rect.top, width: rect.left, height: rect.height }} />
<div className="coach-shade coach-shade-right" style={{ top: rect.top, left: rect.left + rect.width, height: rect.height }} />
<div className="coach-shade coach-shade-bottom" style={{ top: rect.top + rect.height }} />
<div className="coach-spotlight" style={rect} />
<button className="coach-hit-target" style={rect} onClick={onTargetClick} aria-label="点击高亮位置" />
<div className={`coach-bubble ${below ? "below" : "above"}`} style={{ left: bubbleLeft, top: bubbleTop, width: bubbleWidth }}>
<b></b>
<span>{text}</span>
</div>
</div>
);
}
function DemoReadyOverlay({ onStart }: { onStart: () => void }) {
return (
<div className="demo-ready-layer">
<CheckCircle weight="fill" />
<p></p>
<h2></h2>
<PrimaryButton onClick={onStart}> <ArrowRight weight="bold" /></PrimaryButton>
</div>
);
}
export default function Prototype() {
const [screen, setScreen] = useState<Screen>("home");
const [intro, setIntro] = useState<Game | null>(null);
const [profile, setProfile] = useState({ age: "", gender: "", education: "" });
const [seconds, setSeconds] = useState(60);
const [feedback, setFeedback] = useState<"correct" | "wrong" | null>(null);
const [exitConfirm, setExitConfirm] = useState(false);
const [coachStep, setCoachStep] = useState(0);
const [completedGame, setCompletedGame] = useState<Game>("symbol");
const [completionNext, setCompletionNext] = useState<Screen>("trail");
const [metrics, setMetrics] = useState<Record<MetricKey, Metric>>({
speed: EMPTY_METRIC,
executive: EMPTY_METRIC,
attention: EMPTY_METRIC,
memory: EMPTY_METRIC,
calculation: EMPTY_METRIC,
});
const [symbolTrial, setSymbolTrial] = useState(0);
const [symbolTarget, setSymbolTarget] = useState(0);
const [symbolMap, setSymbolMap] = useState(() => shuffle(Array.from({ length: 10 }, (_, index) => index)));
const symbolCorrect = useRef(0);
const symbolErrors = useRef(0);
const [trailIndex, setTrailIndex] = useState(0);
const [trailPositions, setTrailPositions] = useState(createTrailLayout);
const trailErrors = useRef(0);
const [attentionTrial, setAttentionTrial] = useState(0);
const [attentionDirection, setAttentionDirection] = useState<Direction>("right");
const [targetColor, setTargetColor] = useState<"blue" | "orange">("blue");
const [attentionLives, setAttentionLives] = useState(3);
const [planes, setPlanes] = useState<Plane[]>(() => createPlanes(0, "blue", "right"));
const attentionCorrect = useRef(0);
const attentionErrors = useRef(0);
const [memoryRound, setMemoryRound] = useState(1);
const [memoryPhase, setMemoryPhase] = useState<"study" | "conveyor">("study");
const [memoryTargets, setMemoryTargets] = useState<number[]>(() => shuffle([0, 1, 2, 3, 4, 5, 6, 7]).slice(0, 2));
const [memorySelected, setMemorySelected] = useState<number[]>([]);
const [memoryRetired, setMemoryRetired] = useState<number[]>([]);
const [beltOrder, setBeltOrder] = useState<number[]>(() => shuffle([0, 1, 2, 3, 4, 5, 6, 7]));
const [listOpen, setListOpen] = useState(false);
const [moneyRound, setMoneyRound] = useState(1);
const [moneyAttempts, setMoneyAttempts] = useState(0);
const [cash, setCash] = useState<number[]>([]);
const memoryCorrect = useRef(0);
const memoryTotal = useRef(0);
const memoryErrors = useRef(0);
const memoryListViews = useRef(0);
const memoryListViewMs = useRef(0);
const listOpenedAt = useRef<number | null>(null);
const beltRetireTimers = useRef<number[]>([]);
const calculationCorrect = useRef(0);
const calculationTotal = useRef(0);
const calculationErrors = useRef(0);
const activeGame = ["symbol", "trail", "attention", "memory", "money"].includes(screen) && !intro && !exitConfirm;
const timerActive = activeGame && !(screen === "memory" && (memoryPhase === "study" || listOpen));
useEffect(() => {
if (!timerActive || seconds <= 0) return;
const timer = window.setInterval(() => setSeconds((value) => value - 1), 1000);
return () => window.clearInterval(timer);
}, [timerActive, seconds]);
useEffect(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, [screen]);
const flash = (value: "correct" | "wrong") => {
setFeedback(value);
window.setTimeout(() => setFeedback(null), 620);
};
const closeMemoryList = () => {
if (intro !== "memory" && listOpenedAt.current !== null) {
memoryListViewMs.current += Date.now() - listOpenedAt.current;
listOpenedAt.current = null;
}
setListOpen(false);
};
const toggleMemoryList = () => {
if (listOpen) {
closeMemoryList();
return;
}
if (intro !== "memory") {
memoryListViews.current += 1;
listOpenedAt.current = Date.now();
}
setListOpen(true);
};
const memoryMetric = () => {
const base = scoreMetric(memoryCorrect.current, memoryTotal.current, memoryErrors.current, 9);
const viewSeconds = Math.round(memoryListViewMs.current / 1000);
const reviewPenalty = Math.min(20, memoryListViews.current * 4 + Math.floor(viewSeconds / 5));
return { ...base, score: Math.max(20, base.score - reviewPenalty) };
};
const openGame = (game: Game) => {
setScreen(game);
setIntro(game);
setCoachStep(0);
setListOpen(false);
};
const clearBeltRetireTimers = () => {
beltRetireTimers.current.forEach((timer) => window.clearTimeout(timer));
beltRetireTimers.current = [];
};
const startGame = (game: Game) => {
setIntro(null);
setSeconds(game === "trail" ? 90 : game === "memory" ? MEMORY_ROUND_SECONDS[1] : 60);
if (game === "symbol") {
let nextTarget = Math.floor(Math.random() * 10);
while (nextTarget === symbolTarget) nextTarget = Math.floor(Math.random() * 10);
setSymbolTrial(0);
setSymbolTarget(nextTarget);
setSymbolMap(shuffle(Array.from({ length: 10 }, (_, index) => index)));
symbolCorrect.current = 0;
symbolErrors.current = 0;
}
if (game === "trail") {
let nextLayout = createTrailLayout();
let attempts = 0;
while (nextLayout.every((point, index) => point.x === trailPositions[index].x && point.y === trailPositions[index].y) && attempts < 6) {
nextLayout = createTrailLayout();
attempts += 1;
}
setTrailIndex(0);
setTrailPositions(nextLayout);
trailErrors.current = 0;
}
if (game === "attention") {
const direction = DIRECTIONS[(DIRECTIONS.indexOf(attentionDirection) + 1 + Math.floor(Math.random() * 3)) % DIRECTIONS.length];
const color = Math.random() > 0.5 ? "blue" : "orange";
setAttentionTrial(0);
setAttentionLives(3);
setAttentionDirection(direction);
setTargetColor(color);
setPlanes(createPlanes(0, color, direction));
attentionCorrect.current = 0;
attentionErrors.current = 0;
}
if (game === "memory") {
let nextTargets = shuffle([0, 1, 2, 3, 4, 5, 6, 7]).slice(0, 2);
const demoKey = [...memoryTargets.slice(0, 2)].sort().join("-");
while ([...nextTargets].sort().join("-") === demoKey) {
nextTargets = shuffle([0, 1, 2, 3, 4, 5, 6, 7]).slice(0, 2);
}
setMemoryRound(1);
setMemoryPhase("study");
setMemoryTargets(nextTargets);
setMemorySelected([]);
setMemoryRetired([]);
setBeltOrder(shuffle([0, 1, 2, 3, 4, 5, 6, 7]));
setListOpen(false);
setCash([]);
memoryCorrect.current = 0;
memoryTotal.current = 0;
memoryErrors.current = 0;
memoryListViews.current = 0;
memoryListViewMs.current = 0;
listOpenedAt.current = null;
}
if (game === "money") {
setMoneyRound(1);
setMoneyAttempts(0);
setCash([]);
setSeconds(MONEY_ROUNDS[0].seconds);
calculationCorrect.current = 0;
calculationTotal.current = 0;
calculationErrors.current = 0;
}
};
const finishGame = (key: MetricKey, metric: Metric, next: Screen) => {
setMetrics((current) => ({ ...current, [key]: metric }));
setCompletedGame(screen as Game);
setCompletionNext(next);
setScreen("complete");
setIntro(null);
};
const finishMoneyRound = (correct: boolean) => {
calculationTotal.current += 1;
if (correct) calculationCorrect.current += 1;
setCash([]);
setMoneyAttempts(0);
if (moneyRound < MONEY_ROUNDS.length) {
const nextRound = moneyRound + 1;
setMoneyRound(nextRound);
setSeconds(MONEY_ROUNDS[nextRound - 1].seconds);
return;
}
finishGame("calculation", scoreMetric(calculationCorrect.current, calculationTotal.current, calculationErrors.current, MONEY_ROUNDS.length), "report");
};
const continueAfterTime = () => {
if (screen === "symbol") finishGame("speed", scoreMetric(symbolCorrect.current, symbolTrial, symbolErrors.current, 30), "trail");
if (screen === "trail") finishGame("executive", scoreMetric(trailIndex, trailIndex, trailErrors.current, 20), "attention");
if (screen === "attention") finishGame("attention", scoreMetric(attentionCorrect.current, attentionTrial, attentionErrors.current, 20), "memory");
if (screen === "memory" && memoryPhase === "conveyor") finishMemoryRound(memorySelected);
if (screen === "money") {
calculationErrors.current += 1;
finishMoneyRound(false);
}
};
useEffect(() => {
if (!timerActive || seconds !== 0) return;
continueAfterTime();
}, [timerActive, seconds]);
const resetAll = () => {
setMetrics({ speed: EMPTY_METRIC, executive: EMPTY_METRIC, attention: EMPTY_METRIC, memory: EMPTY_METRIC, calculation: EMPTY_METRIC });
setSymbolTrial(0);
setSymbolMap(shuffle(Array.from({ length: 10 }, (_, index) => index)));
setSymbolTarget(Math.floor(Math.random() * 10));
symbolCorrect.current = 0;
symbolErrors.current = 0;
setTrailIndex(0);
setTrailPositions(createTrailLayout());
trailErrors.current = 0;
setAttentionTrial(0);
setAttentionLives(3);
setAttentionDirection("right");
setTargetColor("blue");
setPlanes(createPlanes(0, "blue", "right"));
attentionCorrect.current = 0;
attentionErrors.current = 0;
setMemoryRound(1);
setMemoryPhase("study");
setMemoryTargets(shuffle([0, 1, 2, 3, 4, 5, 6, 7]).slice(0, 2));
setMemorySelected([]);
setMemoryRetired([]);
setBeltOrder(shuffle([0, 1, 2, 3, 4, 5, 6, 7]));
setCash([]);
memoryCorrect.current = 0;
memoryTotal.current = 0;
memoryErrors.current = 0;
memoryListViews.current = 0;
memoryListViewMs.current = 0;
listOpenedAt.current = null;
setMoneyRound(1);
setMoneyAttempts(0);
setCash([]);
calculationCorrect.current = 0;
calculationTotal.current = 0;
calculationErrors.current = 0;
};
const answerSymbol = (answer: number) => {
if (intro === "symbol") {
if (answer !== symbolMap.indexOf(symbolTarget)) return;
flash("correct");
if (coachStep >= 1) {
setCoachStep(2);
return;
}
let next = Math.floor(Math.random() * 10);
while (next === symbolTarget) next = Math.floor(Math.random() * 10);
setSymbolTarget(next);
setSymbolMap((current) => shuffle(current));
setCoachStep(1);
return;
}
const correct = symbolMap.indexOf(symbolTarget) === answer;
if (correct) symbolCorrect.current += 1;
else symbolErrors.current += 1;
flash(correct ? "correct" : "wrong");
if (symbolTrial >= 29) {
finishGame("speed", scoreMetric(symbolCorrect.current, 30, symbolErrors.current, 30), "trail");
return;
}
let next = Math.floor(Math.random() * 10);
while (next === symbolTarget) next = Math.floor(Math.random() * 10);
setSymbolTarget(next);
setSymbolMap((current) => shuffle(current));
setSymbolTrial((value) => value + 1);
};
const tapTrail = (index: number) => {
if (intro === "trail") {
if (index !== coachStep) return;
flash("correct");
setCoachStep((value) => value >= 3 ? 4 : value + 1);
return;
}
if (index !== trailIndex) {
trailErrors.current += 1;
flash("wrong");
return;
}
flash("correct");
if (index === 19) {
finishGame("executive", scoreMetric(20, 20, trailErrors.current, 20), "attention");
return;
}
setTrailIndex(index + 1);
};
const answerAttention = (direction: Direction) => {
if (intro === "attention") {
if (direction !== attentionDirection) return;
flash("correct");
if (coachStep >= 1) {
setCoachStep(2);
return;
}
const nextDirection = DIRECTIONS[(DIRECTIONS.indexOf(attentionDirection) + 1 + Math.floor(Math.random() * 3)) % DIRECTIONS.length];
setAttentionDirection(nextDirection);
setTargetColor((color) => color === "blue" ? "orange" : "blue");
setCoachStep(1);
return;
}
const correct = direction === attentionDirection;
if (correct) attentionCorrect.current += 1;
else {
attentionErrors.current += 1;
setAttentionLives((value) => Math.max(0, value - 1));
}
flash(correct ? "correct" : "wrong");
const nextLives = correct ? attentionLives : attentionLives - 1;
if (attentionTrial >= 19 || nextLives <= 0) {
finishGame("attention", scoreMetric(attentionCorrect.current, attentionTrial + 1, attentionErrors.current, 20), "memory");
return;
}
const nextTrial = attentionTrial + 1;
const nextDirection = randomDirection();
const nextColor = Math.random() > 0.5 ? "blue" : "orange";
setAttentionTrial(nextTrial);
setAttentionDirection(nextDirection);
setTargetColor(nextColor);
setPlanes(createPlanes(nextTrial, nextColor, nextDirection));
};
const finishMemoryRound = (selected: number[]) => {
clearBeltRetireTimers();
const hits = memoryTargets.filter((item) => selected.includes(item)).length;
const wrong = selected.filter((item) => !memoryTargets.includes(item)).length;
memoryCorrect.current += hits;
memoryTotal.current += memoryTargets.length;
memoryErrors.current += wrong + memoryTargets.length - hits;
flash(wrong === 0 && hits === memoryTargets.length ? "correct" : "wrong");
if (memoryRound < 3) {
const nextRound = memoryRound + 1;
setMemoryRound(nextRound);
setMemoryTargets(shuffle([0, 1, 2, 3, 4, 5, 6, 7]).slice(0, nextRound + 1));
setMemorySelected([]);
setMemoryRetired([]);
setBeltOrder(shuffle([0, 1, 2, 3, 4, 5, 6, 7]));
setMemoryPhase("study");
setSeconds(MEMORY_ROUND_SECONDS[nextRound]);
} else {
closeMemoryList();
finishGame("memory", memoryMetric(), "money");
}
};
const selectMemoryItem = (index: number, element?: HTMLButtonElement) => {
if (memoryTargets.every((target) => memorySelected.includes(target))) return;
if (memorySelected.includes(index)) return;
if (!memoryTargets.includes(index)) {
memoryErrors.current += 1;
flash("wrong");
return;
}
const next = [...memorySelected, index];
setMemorySelected(next);
flash("correct");
if (memoryTargets.every((target) => next.includes(target))) {
window.setTimeout(() => finishMemoryRound(next), 180);
return;
}
if (!element) return;
const timer = window.setInterval(() => {
if (!document.body.contains(element)) {
window.clearInterval(timer);
return;
}
if (element.getBoundingClientRect().left <= window.innerWidth) return;
window.clearInterval(timer);
setMemoryRetired((current) => current.includes(index) ? current : [...current, index]);
}, 120);
beltRetireTimers.current.push(timer);
};
const moneyQuestion = MONEY_ROUNDS[moneyRound - 1];
const changeTarget = moneyQuestion.target;
const cashTotal = cash.reduce((sum, item) => sum + item, 0);
const submitChange = () => {
const correct = Math.abs(cashTotal - changeTarget) < 0.01;
flash(correct ? "correct" : "wrong");
if (correct) {
finishMoneyRound(true);
return;
}
calculationErrors.current += 1;
if (moneyAttempts === 0) {
setMoneyAttempts(1);
setCash([]);
return;
}
finishMoneyRound(false);
};
const completeMemoryDemo = (index: number) => {
const demoTarget = memoryTargets[coachStep - 2];
if (intro !== "memory" || coachStep < 2 || coachStep > 3 || index !== demoTarget) return;
flash("correct");
setCoachStep((value) => value + 1);
};
const completeMoneyDemo = (value: number) => {
if (intro !== "money") return;
if (coachStep === 0 && value === 1) {
setCash([1]);
setCoachStep(1);
} else if (coachStep === 1 && value === 2) {
setCash([1, 2]);
setCoachStep(2);
}
};
const removeCash = (index: number) => {
setCash((items) => items.filter((_, itemIndex) => itemIndex !== index));
};
const continueFromCompletion = () => {
if (completionNext === "report") {
setScreen("report");
return;
}
openGame(completionNext as Game);
};
const overall = Math.round(Object.values(metrics).reduce((sum, metric) => sum + (metric.score || 70), 0) / 5);
const reportRows: Array<[MetricKey, string, typeof Speedometer]> = [
["speed", "处理速度", Speedometer],
["executive", "执行功能", Path],
["attention", "注意力", Target],
["memory", "工作记忆", SquaresFour],
["calculation", "计算能力", ChartLineUp],
];
const rankedMetrics = [...reportRows].sort((a, b) => metrics[b[0]].score - metrics[a[0]].score);
const strongestMetric = rankedMetrics[0][1];
const improvementMetric = rankedMetrics[rankedMetrics.length - 1][1];
const totalCorrect = Object.values(metrics).reduce((sum, metric) => sum + metric.correct, 0);
const totalErrors = Object.values(metrics).reduce((sum, metric) => sum + metric.errors, 0);
const averageCompletion = Math.round(Object.values(metrics).reduce((sum, metric) => sum + metric.completion, 0) / 5);
const memoryReviewSeconds = Math.round(memoryListViewMs.current / 1000);
const completedGameName = {
symbol: "符号速配",
trail: "交替连线",
attention: "方向追踪",
memory: "购物记忆",
money: "零钱计算",
}[completedGame];
const demoReady = intro === "symbol" ? coachStep >= 2
: intro === "trail" ? coachStep >= 4
: intro === "attention" ? coachStep >= 2
: intro === "memory" ? coachStep >= 4
: intro === "money" ? coachStep >= 2
: false;
const coach = !demoReady && intro === "symbol"
? {
target: `.keypad button[data-number="${symbolMap.indexOf(symbolTarget)}"]`,
text: coachStep === 0 ? "完成第一次试玩:找到目标图标对应的数字并点击。" : "图标已经换位,再完成一次匹配。",
onTargetClick: () => answerSymbol(symbolMap.indexOf(symbolTarget)),
}
: !demoReady && intro === "trail"
? {
target: `.trail-node[data-index="${coachStep}"]`,
text: coachStep === 0 ? "先点击数字 1。" : coachStep === 1 ? "接着点击最小的绿色扇区。" : coachStep === 2 ? "然后点击数字 2。" : "再点击第二小的绿色扇区,完成试玩。",
onTargetClick: () => tapTrail(coachStep),
}
: !demoReady && intro === "attention"
? {
target: `.direction-console button[data-direction="${attentionDirection}"]`,
text: `${coachStep === 0 ? "第一次试玩" : "再试一次"}:目标是${targetColor === "blue" ? "青绿色" : "珊瑚色"}飞机,点击机头对应的方向键。`,
onTargetClick: () => answerAttention(attentionDirection),
}
: !demoReady && intro === "memory"
? coachStep === 0
? {
target: ".memory-demo-stage .shopping-tab",
text: "先点击购物清单,把需要寻找的商品图片展开。",
onTargetClick: () => {
setListOpen(true);
setCoachStep(1);
},
}
: coachStep === 1
? {
target: ".memory-demo-stage .shopping-tab",
text: "记住清单后先收起,传送带才会继续。",
onTargetClick: () => {
setListOpen(false);
setCoachStep(2);
},
}
: coachStep < 4
? {
target: `.memory-demo-stage .demo-belt-item[data-index="${memoryTargets[coachStep - 2]}"]`,
text: coachStep === 2 ? "点击清单中的第一个商品。" : "再点击清单中的第二个商品。",
onTargetClick: () => completeMemoryDemo(memoryTargets[coachStep - 2]),
}
: null
: !demoReady && intro === "money"
? {
target: `.demo-change-stage .money-pad button[data-value="${coachStep === 0 ? 1 : 2}"]`,
text: coachStep === 0 ? "试玩目标是 3 元,先选择 1 元。" : "再选择 2 元,组成 3 元,完成试玩。",
onTargetClick: () => completeMoneyDemo(coachStep === 0 ? 1 : 2),
}
: null;
return (
<div className={`web-app screen-${screen}`}>
{screen === "home" && (
<main className="landing-shell">
<nav><Brand /><span className="home-duration"><Timer weight="fill" /> 10 </span></nav>
<section className="home-start">
<header className="home-copy">
<img className="home-visual" src="/assets/ui/home-compass.png" alt="" />
<p className="eyebrow"><Sparkle weight="fill" /> DAILY CHECK-IN</p>
<h1><br /><span></span></h1>
<p></p>
</header>
<div className="home-fields">
<label><span></span><select value={profile.age} onChange={(e) => setProfile({ ...profile, age: e.target.value })}><option value=""></option><option>18-29 </option><option>30-44 </option><option>45-59 </option><option>60-69 </option><option>70 </option></select></label>
<label><span></span><select value={profile.gender} onChange={(e) => setProfile({ ...profile, gender: e.target.value })}><option value=""></option><option></option><option></option><option>便</option></select></label>
<label><span></span><select value={profile.education} onChange={(e) => setProfile({ ...profile, education: e.target.value })}><option value=""></option><option></option><option> / </option><option> / </option><option></option></select></label>
</div>
<div className="home-actions">
<PrimaryButton disabled={!profile.age || !profile.gender || !profile.education} onClick={() => { resetAll(); openGame("symbol"); }}> <ArrowRight weight="bold" /></PrimaryButton>
<small><LockKey weight="bold" /> </small>
</div>
</section>
<p className="legal"></p>
</main>
)}
{screen === "complete" && (
<main className={`completion-screen completion-${completedGame}`}>
<img className="completion-art" src={COMPLETION_IMAGES[completedGame]} alt="" />
<p>{completedGameName}</p>
<h1></h1>
<PrimaryButton onClick={continueFromCompletion}>
{completionNext === "report" ? "查看结果" : "进入下一个游戏"} <ArrowRight weight="bold" />
</PrimaryButton>
</main>
)}
{screen === "profile" && (
<main className="center-shell">
<section className="profile-card">
<button className="back-link" onClick={() => setScreen("home")}><ArrowLeft /> </button>
<div className="profile-grid">
<div><p className="eyebrow">BEFORE WE START</p><h1></h1><p></p><div className="privacy-panel"><LockKey size={26} /><span><b></b><small></small></span></div></div>
<div className="form-panel">
<label><select value={profile.age} onChange={(e) => setProfile({ ...profile, age: e.target.value })}><option value=""></option><option>18-29 </option><option>30-44 </option><option>45-59 </option><option>60-69 </option><option>70 </option></select></label>
<label><select value={profile.gender} onChange={(e) => setProfile({ ...profile, gender: e.target.value })}><option value=""></option><option></option><option></option><option>便</option></select></label>
<label><select value={profile.education} onChange={(e) => setProfile({ ...profile, education: e.target.value })}><option value=""></option><option></option><option> / </option><option> / </option><option></option></select></label>
<PrimaryButton disabled={!profile.age || !profile.gender || !profile.education} onClick={() => setScreen("overview")}> <ArrowRight /></PrimaryButton>
</div>
</div>
</section>
</main>
)}
{screen === "overview" && (
<main className="overview-shell">
<nav><Brand /><span></span></nav>
<header><p className="eyebrow">YOUR ASSESSMENT</p><h1></h1><p></p></header>
<section className="task-grid">
{[
{ Icon: Speedometer, no: "01", title: "符号速配", domain: "处理速度", time: "60 秒", tone: "violet" },
{ Icon: Path, no: "02", title: "交替连线", domain: "执行功能", time: "180 秒", tone: "mint" },
{ Icon: Target, no: "03", title: "动态追踪", domain: "注意力", time: "60 秒", tone: "blue" },
{ Icon: SquaresFour, no: "04", title: "购物记忆", domain: "工作记忆", time: "180 秒", tone: "coral" },
{ Icon: ChartLineUp, no: "05", title: "零钱计算", domain: "计算能力", time: "约 2 分钟", tone: "blue" },
].map(({ Icon, no, title, domain, time, tone }) => (
<article className={`task-card ${tone}`} key={no}><span>{no}</span><i><Icon size={34} weight="duotone" /></i><small>{domain}</small><h3>{title}</h3><b><ClockCountdown /> {time}</b></article>
))}
</section>
<div className="overview-action"><span><Heart weight="fill" /> </span><PrimaryButton onClick={() => { resetAll(); openGame("symbol"); }}> <ArrowRight /></PrimaryButton></div>
</main>
)}
{screen === "symbol" && (
<main className={`game-shell symbol-shell ${intro === "symbol" ? "tutorial-active" : ""}`}>
<GameHeader seconds={seconds} onBack={() => setExitConfirm(true)} />
<section className="symbol-layout">
<div className="target-zone"><div className="target-object" key={symbolTrial}><img src={SYMBOL_IMAGES[symbolTarget]} alt="" /></div></div>
<div className="symbol-console">
<LayoutGroup id="symbol-map"><div className="symbol-map">{symbolMap.map((symbol, number) => <div key={number} data-symbol={symbol}><b>{number}</b><motion.img layoutId={`symbol-${symbol}`} transition={{ type: "spring", stiffness: 240, damping: 25 }} src={SYMBOL_IMAGES[symbol]} alt="" /></div>)}</div></LayoutGroup>
<div className="keypad">{[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((number) => <button data-number={number} key={number} onClick={() => answerSymbol(number)}>{number}</button>)}</div>
</div>
</section>
</main>
)}
{screen === "trail" && (
<main className={`game-shell trail-shell ${intro === "trail" ? "tutorial-active" : ""}`}>
<GameHeader seconds={seconds} onBack={() => setExitConfirm(true)} />
<section className="trail-board">
<svg className="trail-lines" viewBox="0 0 100 100" preserveAspectRatio="none"><polyline points={trailPositions.slice(0, Math.max(1, intro === "trail" ? coachStep : trailIndex)).map((point) => `${point.x},${point.y}`).join(" ")} /></svg>
{TRAIL_NODES.map((node, index) => {
const position = trailPositions[index];
const visibleIndex = intro === "trail" ? coachStep : trailIndex;
return <button data-index={index} key={index} onClick={() => tapTrail(index)} className={`trail-node ${node.kind} ${index < visibleIndex ? "done" : ""} ${index === visibleIndex ? "current" : ""}`} style={{ left: `${position.x}%`, top: `${position.y}%`, "--slice": `${node.rank * 10}%` } as React.CSSProperties}>{node.kind === "number" ? node.rank : <span />}</button>;
})}
</section>
</main>
)}
{screen === "attention" && (
<main className={`game-shell attention-shell ${intro === "attention" ? "tutorial-active" : ""}`}>
<GameHeader seconds={seconds} onBack={() => setExitConfirm(true)} />
<section className="sky-stage">
<div className="attention-title"><span></span><strong className={targetColor}>{targetColor === "blue" ? "青绿色飞机" : "珊瑚色飞机"}</strong><small>{[0, 1, 2].map((life) => <Heart key={life} weight={life < attentionLives ? "fill" : "regular"} />)}</small></div>
<div className="cloud cloud-a" /><div className="cloud cloud-b" />
{intro === "attention"
? <img className={`coach-plane ${targetColor}`} src={PLANE_IMAGE[targetColor]} alt="教学目标飞机" style={{ "--angle": `${DIRECTION_ANGLE[attentionDirection]}deg` } as React.CSSProperties} />
: planes.map((plane) => <img key={plane.id} className={`moving-plane ${plane.color} fly-${plane.direction}`} src={PLANE_IMAGE[plane.color]} alt={plane.target ? "目标飞机" : "干扰飞机"} style={{ "--lane": `${plane.lane}%`, "--angle": `${DIRECTION_ANGLE[plane.direction]}deg`, animationDuration: `${plane.duration}s`, animationDelay: `${plane.delay}s` } as React.CSSProperties} />)}
<div className="direction-console">{DIRECTIONS.map((direction) => { const Icon = DIRECTION_ICONS[direction]; return <button data-direction={direction} className={`direction-${direction}`} key={direction} onClick={() => answerAttention(direction)} aria-label={DIRECTION_LABELS[direction]}><Icon weight="bold" /><span>{DIRECTION_LABELS[direction]}</span></button>; })}</div>
</section>
</main>
)}
{screen === "memory" && (
<main className={`game-shell memory-shell ${intro === "memory" ? "tutorial-active" : ""}`}>
<GameHeader seconds={seconds} onBack={() => setExitConfirm(true)} />
{intro === "memory" && <section className={`market-stage memory-demo-stage ${listOpen ? "list-open" : ""}`}>
<button className={`shopping-tab ${listOpen ? "open" : ""}`}><b></b><span>{listOpen ? "收起" : "点击查看"}</span>{listOpen && <div className="shopping-list-preview">{memoryTargets.slice(0, 2).map((index) => <span key={index}><img src={FOODS[index].image} alt="" /></span>)}</div>}</button>
{listOpen && <button className="market-list-shade" aria-label="收起购物清单" onClick={() => setListOpen(false)} />}
<div className="conveyor memory-demo-belt"><div className="belt-lines" />{[...memoryTargets.slice(0, 2), ...beltOrder.filter((item) => !memoryTargets.slice(0, 2).includes(item)).slice(0, 1)].map((index) => { const item = FOODS[index]; return <button data-index={index} className="demo-belt-item" key={index} onClick={() => completeMemoryDemo(index)}><img src={item.image} alt={item.name} /></button>; })}</div>
</section>}
{intro !== "memory" && memoryPhase === "study" && <section className="study-stage"><div className="memory-study-copy"><p className="eyebrow">SHOPPING LIST · ROUND {memoryRound}</p><h2></h2><p></p></div><div className="memory-study-board"><header><b> {memoryRound} </b><span>{memoryTargets.length} </span></header><div>{memoryTargets.map((index) => <img key={index} src={FOODS[index].image} alt="" />)}</div><PrimaryButton onClick={() => { setMemorySelected([]); setSeconds(MEMORY_ROUND_SECONDS[memoryRound]); setMemoryPhase("conveyor"); }}> <ArrowRight /></PrimaryButton></div></section>}
{intro !== "memory" && memoryPhase === "conveyor" && <section className={`market-stage ${listOpen ? "list-open" : ""}`}>
<button className={`shopping-tab ${listOpen ? "open" : ""}`} onClick={toggleMemoryList}><b></b><span>{listOpen ? "收起" : "回看会影响评分"}</span>{listOpen && <div className="shopping-list-preview">{memoryTargets.map((index) => { const selected = memorySelected.includes(index); return <span className={selected ? "selected" : ""} key={index}><img src={FOODS[index].image} alt="" />{selected && <i><Check weight="bold" /></i>}</span>; })}</div>}</button>
{listOpen && <button className="market-list-shade" aria-label="收起购物清单" onClick={closeMemoryList} />}
<div className="conveyor"><div className="belt-lines" />{beltOrder.map((index, order) => { if (memoryRetired.includes(index)) return null; const item = FOODS[index]; const selected = memorySelected.includes(index); return <button className={`belt-item ${item.tone} ${selected ? "selected" : ""}`} key={index} style={{ animationDelay: `${order * 3.5}s` }} onClick={(event) => selectMemoryItem(index, event.currentTarget)}><img src={item.image} alt={item.name} />{selected && <i><Check weight="bold" /></i>}</button>; })}</div>
</section>}
</main>
)}
{screen === "money" && (
<main className={`game-shell money-shell ${intro === "money" ? "tutorial-active" : ""}`}>
<GameHeader seconds={seconds} onBack={() => setExitConfirm(true)} />
<section className={`change-stage ${intro === "money" ? "demo-change-stage" : ""}`}>
<div className="change-summary"><p className="eyebrow">{intro === "money" ? "找零试玩" : `EXACT CHANGE · ROUND ${moneyRound} / ${MONEY_ROUNDS.length}`}</p><h2></h2><div><span> <b>¥{intro === "money" ? 7 : moneyQuestion.price}</b></span><span> <b>¥{intro === "money" ? 10 : moneyQuestion.payment}</b></span></div><strong>{intro === "money" ? "应找零 ¥3.00" : "请自行计算应找金额"}</strong></div>
<div className="cash-desk"><div className="cash-display"><span></span><b>¥{cashTotal.toFixed(2)}</b><div>{cash.map((value, index) => <button data-cash-index={index} key={`${value}-${index}`} onClick={() => removeCash(index)} aria-label={`移除 ${value}`}><img src={moneyImage(value)} alt="" /><i className="cash-remove"><Minus weight="bold" /></i></button>)}</div></div><div className="money-pad">{MONEY_OPTIONS.map((option) => <button data-value={option.value} key={option.value} onClick={() => intro === "money" ? completeMoneyDemo(option.value) : setCash((items) => [...items, option.value])} aria-label={`${option.value}`}><img src={option.image} alt="" /></button>)}</div>{intro !== "money" && <PrimaryButton disabled={!cash.length} onClick={submitChange}></PrimaryButton>}</div>
</section>
</main>
)}
{screen === "report" && (
<main className="report-shell">
<nav><Brand /><span className="report-chip"><Sparkle weight="fill" /> AI </span></nav>
<header className="report-hero"><div><p className="eyebrow">YOUR COGNITIVE SNAPSHOT</p><h1></h1><p></p></div><img className="report-complete-art" src="/assets/ui/finish-all.png" alt="" /><div className="score-orb"><small></small><strong>{overall}</strong><span>/100</span></div></header>
<section className="report-facts"><span><small></small><b>{averageCompletion}%</b></span><span><small></small><b>{totalCorrect}</b></span><span><small></small><b>{totalErrors}</b></span></section>
<section className="metric-grid">{reportRows.map(([key, label, Icon]) => { const metric = metrics[key]; const value = metric.score || 70; return <article key={key}><div><Icon weight="duotone" /><span><small>{label}</small><b>{value}</b></span></div><i><span style={{ width: `${value}%` }} /></i><div className="metric-data"><span><small></small><b>{metric.correct}/{metric.total}</b></span><span><small></small><b>{metric.errors}</b></span><span><small></small><b>{metric.completion}%</b></span></div><p>{value >= 80 ? "表现稳定,速度和准确度配合较好。" : value >= 60 ? "整体平稳,仍有进一步提升空间。" : "波动较明显,建议休息后再尝试。"}</p></article>; })}</section>
<section className="insight-panel"><div><img className="report-insight-art" src="/assets/ui/report-insight.png" alt="" /><span><small>AI </small><h2>{overall >= 80 ? "整体状态稳定" : overall >= 60 ? "整体状态平稳" : "本次表现存在波动"}</h2></span></div><p><strong>{strongestMetric}</strong><strong>{improvementMetric}</strong> <strong>{memoryListViews.current} </strong> <strong>{memoryReviewSeconds} </strong></p><div><span><Star weight="fill" /> {strongestMetric}</span><span><ChartLineUp /> {improvementMetric}</span></div></section>
<div className="report-actions"><PrimaryButton secondary onClick={() => window.print()}></PrimaryButton><PrimaryButton onClick={() => { resetAll(); openGame("symbol"); }}> <ArrowRight /></PrimaryButton></div>
<p className="legal"></p>
</main>
)}
{feedback && <div className={`feedback-pop ${feedback}`} aria-label={feedback === "correct" ? "正确" : "错误"}>{feedback === "correct" ? <Check weight="bold" /> : <X weight="bold" />}</div>}
{coach && <CoachOverlay target={coach.target} text={coach.text} onTargetClick={coach.onTargetClick} />}
{intro && demoReady && <DemoReadyOverlay onStart={() => startGame(intro)} />}
{exitConfirm && <div className="dialog-backdrop"><section className="dialog-card"><h2>退</h2><p></p><PrimaryButton onClick={() => setExitConfirm(false)}></PrimaryButton><button onClick={() => { setExitConfirm(false); setIntro(null); setScreen("home"); }}>退</button></section></div>}
</div>
);
}

12
brain-h5/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@fontsource/roboto/latin-500.css";
import App from "./App";
import "./styles.css";
import "./prototype.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,136 @@
import { type PropsWithChildren, useEffect, useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { useDrag } from "@use-gesture/react";
import { AnimatePresence, motion } from "motion/react";
import { useKeyboard, useKeyboardInsets } from "./Keyboard";
import { useScreenPortal } from "./PhoneFrame";
import { useMobileDevice } from "./Device";
type BottomSheetProps = PropsWithChildren<{
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
snap?: number;
}>;
export function BottomSheet({
open,
onOpenChange,
title,
description,
snap = 0.72,
children,
}: BottomSheetProps) {
const { device } = useMobileDevice();
const { screenRef } = useScreenPortal();
const keyboard = useKeyboard();
const { keyboardHeight } = useKeyboardInsets();
const [dragY, setDragY] = useState(0);
useEffect(() => {
if (open) keyboard.hide();
}, [open]);
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
keyboard.hide();
}
onOpenChange(nextOpen);
};
const bindDrag = useDrag(
(state) => {
const [, movementY] = state.movement;
const [, velocityY] = state.velocity;
const [, directionY] = state.direction;
const nextY = Math.max(0, movementY);
if (!state.last) {
setDragY(nextY);
return;
}
const shouldClose = nextY > 96 || (velocityY > 0.55 && directionY > 0);
setDragY(0);
if (shouldClose) {
onOpenChange(false);
}
},
{
axis: "y",
filterTaps: true,
},
);
const sheetHeight = Math.round(device.geometry.screen.height * snap);
const effectiveHeight = Math.max(260, sheetHeight - Math.min(keyboardHeight, 180));
const sheetBottom =
device.platform === "android"
? Math.max(device.geometry.safeArea.bottom, keyboardHeight)
: keyboardHeight;
const portalContainer = screenRef.current ?? undefined;
return (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
{/* Keep the portal mounted after `open` flips so AnimatePresence can run
the sheet and overlay exit animations before Radix removes them. */}
<Dialog.Portal container={portalContainer} forceMount>
<AnimatePresence>
{open ? (
<>
<Dialog.Overlay asChild forceMount>
<motion.div
className="sheet-overlay"
data-testid="sheet-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16 }}
/>
</Dialog.Overlay>
<Dialog.Content asChild forceMount>
<motion.div
className="bottom-sheet"
data-testid="bottom-sheet"
style={{
bottom: sheetBottom,
maxHeight: effectiveHeight,
}}
initial={{ y: effectiveHeight + 36 }}
animate={{ y: dragY }}
exit={{
y: effectiveHeight + 36,
transition: {
type: "spring",
stiffness: 250,
damping: 30,
mass: 1.05,
},
}}
transition={{
type: "spring",
stiffness: 500,
damping: 43,
mass: 0.9,
}}
>
<div className="sheet-handle-zone" data-testid="sheet-handle" {...bindDrag()}>
<div className="sheet-handle" />
</div>
<div className="sheet-header">
<Dialog.Title className="sheet-title">{title}</Dialog.Title>
{description ? <Dialog.Description className="sheet-description">{description}</Dialog.Description> : null}
</div>
<div className="sheet-content">{children}</div>
</motion.div>
</Dialog.Content>
</>
) : null}
</AnimatePresence>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,31 @@
# Mobile runtime components
## Carousel
`Carousel` is the standard component for horizontal collections: cards, images, media, swipeable items, and chip or filter rails. Place it directly inside `MobileScroll`; consumers should not add gesture wrappers or pointer handlers.
```tsx
<MobileScroll>
<section>
<Carousel
ariaLabel="Event details"
className="event-carousel"
contentClassName="event-carousel-track"
>
{cards}
</Carousel>
</section>
</MobileScroll>
```
The runtime resolves nested gestures by axis. Horizontal intent stays with `Carousel`; vertical intent is handed to the parent `MobileScroll`. Slight vertical drift after a horizontal gesture is claimed does not move, rubber-band, or add momentum to the parent. Taps remain clickable, while a completed drag suppresses the item click.
Do not use `data-scroll-drag="ignore"` for carousels or ordinary rails. It is a hard opt-out that prevents parent scrolling in every direction. Do not layer CSS scroll snapping over the runtime's JavaScript momentum. If snapping is added later, it should be a component option so one system owns release motion.
## Keyboard-linked surfaces
Use `KeyboardInput`, `KeyboardTextarea`, or `MobileTextField` for all text entry. Position a composer, search surface, or other keyboard-linked UI from `useKeyboardInsets().bottomInset`. The inset is relative to the app viewport: Android's closed-keyboard viewport already ends above its navigation bar, while iOS still needs its overlaid home-indicator inset; both platforms return the keyboard height while the keyboard is open. Never pin those surfaces to only `keyboardHeight`. When that surface closes, call `keyboard.hide()` in the same event before updating its own open state.
## BottomSheet
`BottomSheet` dismisses the keyboard before opening and animates both in and out by default. Keep its `open` state controlled through `onOpenChange`; no consumer exit-animation wrapper is needed.

View File

@@ -0,0 +1,257 @@
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type PropsWithChildren,
} from "react";
export type CarouselProps = PropsWithChildren<{
className?: string;
contentClassName?: string;
ariaLabel?: string;
showScrollbar?: boolean;
draggingEnabled?: boolean;
}>;
const physics = {
friction: 2.1,
velocityScale: 890,
velocityTolerance: 18,
bounceTension: 200,
bounceFriction: 40,
overdragScale: 0.5,
maxOverdrag: 96,
sampleWindow: 100,
dragThreshold: 8,
} as const;
type Sample = { value: number; time: number };
type DragSession = {
pointerId: number;
startPrimary: number;
startCross: number;
startOffset: number;
captured: boolean;
dragged: boolean;
};
export function Carousel({
className,
contentClassName,
ariaLabel,
showScrollbar = false,
draggingEnabled = true,
children,
}: CarouselProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const sessionRef = useRef<DragSession | null>(null);
const samplesRef = useRef<Sample[]>([]);
const frameRef = useRef<number | null>(null);
const overdragRef = useRef(0);
const suppressClickRef = useRef(false);
const [dragging, setDragging] = useState(false);
const [overdrag, setOverdrag] = useState(0);
const [thumb, setThumb] = useState({ visible: false, offset: 0, size: 0 });
const offset = useCallback((node: HTMLDivElement) => node.scrollLeft, []);
const setOffset = useCallback((node: HTMLDivElement, value: number) => {
node.scrollLeft = value;
}, []);
const clientSize = useCallback((node: HTMLDivElement) => node.clientWidth, []);
const scrollSize = useCallback((node: HTMLDivElement) => node.scrollWidth, []);
const maxOffset = useCallback((node: HTMLDivElement) => Math.max(0, scrollSize(node) - clientSize(node)), [clientSize, scrollSize]);
const stopMotion = useCallback(() => {
if (frameRef.current !== null) window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}, []);
const setRubberBand = useCallback((value: number) => {
const next = Math.max(-physics.maxOverdrag, Math.min(physics.maxOverdrag, value));
overdragRef.current = next;
setOverdrag(next);
}, []);
const updateThumb = useCallback((visible = true) => {
if (!showScrollbar || !scrollRef.current) return;
const node = scrollRef.current;
const viewport = clientSize(node);
const content = scrollSize(node);
const enabled = content > viewport + 2;
const size = enabled ? Math.max(36, (viewport / content) * viewport) : 0;
const track = Math.max(0, viewport - size - 8);
const progress = offset(node) / Math.max(1, content - viewport);
setThumb({ visible: visible && enabled, size, offset: enabled ? 4 + progress * track : 0 });
}, [clientSize, offset, scrollSize, showScrollbar]);
const springBack = useCallback((initialVelocity = 0) => {
stopMotion();
let position = overdragRef.current;
let velocity = Math.max(-1400, Math.min(1400, initialVelocity));
let previous: number | null = null;
const tick = (time: number) => {
const seconds = Math.min(0.034, ((previous === null ? 16 : time - previous) || 16) / 1000);
previous = time;
velocity += (-physics.bounceTension * position - physics.bounceFriction * velocity) * seconds;
position += velocity * seconds;
if (Math.abs(position) < 0.5 && Math.abs(velocity) < 0.5) {
setRubberBand(0);
frameRef.current = null;
return;
}
setRubberBand(position);
frameRef.current = window.requestAnimationFrame(tick);
};
frameRef.current = window.requestAnimationFrame(tick);
}, [setRubberBand, stopMotion]);
const momentum = useCallback((node: HTMLDivElement, initialVelocity: number) => {
let velocity = initialVelocity;
let previous: number | null = null;
const tick = (time: number) => {
const seconds = (previous === null ? 16 : Math.min(34, time - previous)) / 1000;
previous = time;
velocity *= Math.exp(-physics.friction * seconds);
if (Math.abs(velocity) < physics.velocityTolerance) {
frameRef.current = null;
updateThumb(true);
return;
}
const next = offset(node) + velocity * seconds;
const maximum = maxOffset(node);
if (next < 0 || next > maximum) {
setOffset(node, Math.max(0, Math.min(maximum, next)));
// Match MobileScroll's edge convention: positive displacement at the
// leading edge, negative displacement at the trailing edge.
setRubberBand((next < 0 ? -next : maximum - next) * physics.overdragScale);
springBack(velocity * physics.overdragScale);
return;
}
setOffset(node, next);
updateThumb(true);
frameRef.current = window.requestAnimationFrame(tick);
};
if (Math.abs(velocity) >= physics.velocityTolerance) frameRef.current = window.requestAnimationFrame(tick);
}, [maxOffset, offset, setOffset, setRubberBand, springBack, updateThumb]);
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const onScroll = () => updateThumb(true);
const observer = new ResizeObserver(() => updateThumb(false));
node.addEventListener("scroll", onScroll, { passive: true });
observer.observe(node);
if (node.firstElementChild) observer.observe(node.firstElementChild);
updateThumb(false);
return () => {
node.removeEventListener("scroll", onScroll);
observer.disconnect();
stopMotion();
};
}, [stopMotion, updateThumb]);
const primary = (event: ReactPointerEvent<HTMLDivElement>) => event.clientX;
const cross = (event: ReactPointerEvent<HTMLDivElement>) => event.clientY;
const record = (value: number) => {
const time = performance.now();
samplesRef.current = [...samplesRef.current, { value, time }].filter((sample) => time - sample.time <= physics.sampleWindow);
};
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
const node = scrollRef.current;
if (!draggingEnabled || !node || maxOffset(node) <= 2 || (event.pointerType === "mouse" && event.button !== 0)) return;
stopMotion();
samplesRef.current = [];
record(primary(event));
setRubberBand(0);
sessionRef.current = { pointerId: event.pointerId, startPrimary: primary(event), startCross: cross(event), startOffset: offset(node), captured: false, dragged: false };
};
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const node = scrollRef.current;
const session = sessionRef.current;
if (!node || !session || session.pointerId !== event.pointerId) return;
const delta = primary(event) - session.startPrimary;
const crossDelta = cross(event) - session.startCross;
if (!session.dragged) {
// Keep the gesture pending until it clears tap slop. Pointer-down and
// these early moves must bubble so a parent MobileScroll can still win.
if (Math.max(Math.abs(delta), Math.abs(crossDelta)) < physics.dragThreshold) return;
if (Math.abs(crossDelta) > Math.abs(delta)) {
// The cross axis won. Abandon this session without capturing or
// canceling the event so the parent can handle this move and release.
sessionRef.current = null;
return;
}
// The scroller owns the gesture from this move onward. Capture keeps
// delivery stable outside its bounds; stopping propagation prevents the
// parent from accumulating vertical drift or release momentum.
event.currentTarget.setPointerCapture(event.pointerId);
session.captured = true;
}
event.preventDefault();
event.stopPropagation();
session.dragged = true;
setDragging(true);
suppressClickRef.current = true;
record(primary(event));
const desired = session.startOffset - delta;
const maximum = maxOffset(node);
setOffset(node, Math.max(0, Math.min(maximum, desired)));
setRubberBand(desired < 0 ? -desired * physics.overdragScale : desired > maximum ? -(desired - maximum) * physics.overdragScale : 0);
updateThumb(true);
};
const finish = (event: ReactPointerEvent<HTMLDivElement>) => {
const node = scrollRef.current;
const session = sessionRef.current;
if (!node || !session || session.pointerId !== event.pointerId) return;
if (session.captured) event.currentTarget.releasePointerCapture(event.pointerId);
const samples = samplesRef.current;
const first = samples[0];
const last = samples[samples.length - 1];
const velocity = first && last ? -((last.value - first.value) / Math.max(1, last.time - first.time)) * physics.velocityScale : 0;
sessionRef.current = null;
setDragging(false);
if (session.dragged) event.preventDefault();
if (Math.abs(overdragRef.current) > 0.1) springBack(velocity * physics.overdragScale);
else momentum(node, velocity);
};
const onClickCapture = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!suppressClickRef.current) return;
suppressClickRef.current = false;
event.preventDefault();
event.stopPropagation();
};
const style = { "--mobile-carousel-overdrag": `${overdrag}px` } as CSSProperties;
const thumbStyle = { width: thumb.size, transform: `translateX(${thumb.offset}px)` };
return (
<div
ref={scrollRef}
className={`mobile-carousel ${className ?? ""}`}
data-dragging={dragging}
data-overscroll={overdrag.toFixed(2)}
aria-label={ariaLabel}
role={ariaLabel ? "region" : undefined}
style={style}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={finish}
onPointerCancel={finish}
onClickCapture={onClickCapture}
>
<div className={`mobile-carousel-content ${contentClassName ?? ""}`}>{children}</div>
{showScrollbar ? (
<div className="mobile-carousel-scrollbar" data-visible={thumb.visible} aria-hidden="true">
<div className="mobile-carousel-scrollbar-thumb" style={thumbStyle} />
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { createContext, type PropsWithChildren, useContext, useMemo, useState } from "react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons";
import { mobileAssets } from "./assets";
import { iphoneGeometry, pixelGeometry, type MobileDeviceGeometry } from "./geometry";
export type MobileDeviceId = "iphone" | "pixel-10";
type MobileDevicePreset = {
id: MobileDeviceId;
label: string;
platform: "ios" | "android";
bezel: string;
bezelLayer: "above-screen" | "below-screen";
geometry: MobileDeviceGeometry;
camera?: {
size: number;
top: number;
};
};
export const mobileDevices: Record<MobileDeviceId, MobileDevicePreset> = {
iphone: {
id: "iphone",
label: "iPhone",
platform: "ios",
bezel: mobileAssets.iphoneBezel,
bezelLayer: "above-screen",
geometry: iphoneGeometry,
},
"pixel-10": {
id: "pixel-10",
label: "Pixel 10",
platform: "android",
bezel: mobileAssets.pixel10Bezel,
bezelLayer: "below-screen",
geometry: pixelGeometry,
camera: {
size: 32,
top: 23,
},
},
};
type MobileDeviceContextValue = {
device: MobileDevicePreset;
deviceId: MobileDeviceId;
setDeviceId: (deviceId: MobileDeviceId) => void;
};
const MobileDeviceContext = createContext<MobileDeviceContextValue | null>(null);
export function MobileDeviceProvider({ children }: PropsWithChildren) {
const [deviceId, setDeviceId] = useState<MobileDeviceId>("iphone");
const value = useMemo(
() => ({ device: mobileDevices[deviceId], deviceId, setDeviceId }),
[deviceId],
);
return <MobileDeviceContext.Provider value={value}>{children}</MobileDeviceContext.Provider>;
}
export function useMobileDevice() {
const context = useContext(MobileDeviceContext);
if (!context) {
throw new Error("useMobileDevice must be used inside MobileDeviceProvider");
}
return context;
}
export function DevicePicker() {
const { device, deviceId, setDeviceId } = useMobileDevice();
return (
<DropdownMenu.Root>
<div className="device-menu-bar" data-testid="device-menu-bar">
<DropdownMenu.Trigger asChild>
<button
className="device-picker-trigger"
data-testid="device-picker"
aria-label={`Preview device: ${device.label}`}
type="button"
>
<span>{device.label}</span>
<ChevronDownIcon aria-hidden="true" />
</button>
</DropdownMenu.Trigger>
</div>
<DropdownMenu.Portal>
<DropdownMenu.Content className="device-picker-menu" align="end" sideOffset={8} collisionPadding={12}>
<DropdownMenu.RadioGroup
value={deviceId}
onValueChange={(value) => setDeviceId(value as MobileDeviceId)}
>
{Object.values(mobileDevices).map((option) => (
<DropdownMenu.RadioItem
key={option.id}
className="device-picker-item"
value={option.id}
data-testid={`device-option-${option.id}`}
>
<span>{option.label}</span>
<DropdownMenu.ItemIndicator className="device-picker-check">
<CheckIcon aria-hidden="true" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -0,0 +1,234 @@
import {
createContext,
type CSSProperties,
type PropsWithChildren,
type ReactNode,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { AnimatePresence, motion } from "motion/react";
import { useDrag } from "@use-gesture/react";
import { useMobileDevice } from "./Device";
import { useKeyboard, useKeyboardDismissDrag, useKeyboardInsets } from "./Keyboard";
export type FlowScreen = {
id: string;
title?: string;
header?: (flow: FlowControls) => ReactNode;
headerHeight?: number;
footer?: (flow: FlowControls) => ReactNode;
footerHeight?: number;
render: (flow: FlowControls) => ReactNode;
};
type FlowEntry = FlowScreen & {
key: string;
};
export type FlowControls = {
current: FlowEntry;
previous: FlowEntry | null;
stack: FlowEntry[];
canGoBack: boolean;
push: (screen: FlowScreen) => void;
pop: () => void;
replace: (screen: FlowScreen) => void;
};
const FlowContext = createContext<FlowControls | null>(null);
export function useFlow() {
const context = useContext(FlowContext);
if (!context) {
throw new Error("useFlow must be used inside FlowStack");
}
return context;
}
function FlowProvider({ value, children }: PropsWithChildren<{ value: FlowControls }>) {
return <FlowContext.Provider value={value}>{children}</FlowContext.Provider>;
}
export function FlowStack({ initial }: { initial: FlowScreen }) {
const { device } = useMobileDevice();
const keyboard = useKeyboard();
const { bottomInset, keyboardDragging } = useKeyboardInsets();
const dismissKeyboardDrag = useKeyboardDismissDrag();
const sequence = useRef(1);
const gestureStartedAtEdge = useRef(false);
const initialEntry = useRef<FlowEntry>({ ...initial, key: `${initial.id}-0` });
const [stack, setStack] = useState<FlowEntry[]>(() => [initialEntry.current]);
const [direction, setDirection] = useState(1);
const [swipeX, setSwipeX] = useState(0);
const toEntry = useCallback((screen: FlowScreen): FlowEntry => {
const next = sequence.current;
sequence.current += 1;
return { ...screen, key: `${screen.id}-${next}` };
}, []);
const pop = useCallback(() => {
keyboard.hide();
setDirection(-1);
setStack((currentStack) => {
if (currentStack.length <= 1) return currentStack;
return currentStack.slice(0, -1);
});
}, [keyboard]);
const controls = useMemo<FlowControls>(() => {
const current = stack[stack.length - 1];
const previous = stack.length > 1 ? stack[stack.length - 2] : null;
return {
current,
previous,
stack,
canGoBack: stack.length > 1,
push: (screen) => {
keyboard.hide();
setDirection(1);
setSwipeX(0);
setStack((currentStack) => [...currentStack, toEntry(screen)]);
},
pop,
replace: (screen) => {
keyboard.hide();
setDirection(1);
setSwipeX(0);
setStack((currentStack) => {
const next = currentStack.slice(0, -1);
return [...next, toEntry(screen)];
});
},
};
}, [keyboard, pop, stack, toEntry]);
const bindEdgeSwipe = useDrag(
(state) => {
if (!controls.canGoBack) return;
if (state.first) {
const target = state.event.currentTarget as HTMLElement;
const bounds = target.getBoundingClientRect();
gestureStartedAtEdge.current = state.initial[0] - bounds.left < 28;
}
if (!gestureStartedAtEdge.current) return;
const [movementX] = state.movement;
const [velocityX] = state.velocity;
const [directionX] = state.direction;
const nextX = Math.max(0, Math.min(movementX, device.geometry.screen.width));
if (!state.last) {
setSwipeX(nextX);
return;
}
const shouldPop = nextX > 92 || (velocityX > 0.45 && directionX > 0);
setSwipeX(0);
gestureStartedAtEdge.current = false;
if (shouldPop) {
controls.pop();
}
},
{
axis: "x",
filterTaps: true,
pointer: { touch: true },
},
);
const screenWidth = device.geometry.screen.width;
const topIndex = stack.length - 1;
const parkedX = -screenWidth * 0.28;
const header = controls.current.header?.(controls);
const headerHeight = controls.current.headerHeight ?? 0;
const headerSafeArea = header ? device.geometry.safeArea.top : 0;
const totalHeaderHeight = header ? headerSafeArea + headerHeight : 0;
const footer = controls.current.footer?.(controls);
const footerHeight = controls.current.footerHeight ?? 0;
const screenVariants = {
enter: (animationDirection: number) => ({
x: animationDirection > 0 ? screenWidth : parkedX,
scale: animationDirection > 0 ? 1 : 0.985,
}),
exit: (animationDirection: number) => ({
x: animationDirection < 0 ? screenWidth : parkedX,
scale: animationDirection < 0 ? 1 : 0.985,
}),
};
return (
<FlowProvider value={controls}>
<div
className="flow-stack"
data-testid="flow-stack"
data-keyboard-dragging={keyboardDragging ? "true" : "false"}
style={
{
"--flow-header-height": `${totalHeaderHeight}px`,
"--flow-header-content-height": `${headerHeight}px`,
"--flow-header-safe-area": `${headerSafeArea}px`,
"--flow-footer-height": `${footer ? footerHeight : 0}px`,
"--keyboard-height": `${bottomInset}px`,
} as CSSProperties
}
{...bindEdgeSwipe()}
>
{header ? (
<header className="flow-fixed-header" data-testid="flow-fixed-header">
{header}
</header>
) : null}
<div className="flow-scenes">
<AnimatePresence initial={false} custom={direction}>
{stack.map((entry, index) => {
const isTop = index === topIndex;
const isVisible = index >= topIndex - 1;
return (
<motion.div
key={entry.key}
className="flow-screen"
data-flow-current={isTop ? "true" : "false"}
data-testid={isTop ? "flow-current" : undefined}
custom={direction}
variants={screenVariants}
initial={isTop ? "enter" : false}
animate={{
x: isTop ? swipeX : parkedX,
scale: isTop ? 1 : 0.985,
}}
exit="exit"
transition={{ type: "spring", stiffness: 360, damping: 38, mass: 0.9 }}
style={{
opacity: isVisible ? 1 : 0,
pointerEvents: isTop ? "auto" : "none",
visibility: isVisible ? "visible" : "hidden",
zIndex: isTop ? 2 : 1,
}}
>
{entry.render(controls)}
</motion.div>
);
})}
</AnimatePresence>
</div>
{footer ? (
<footer className="flow-fixed-footer" data-testid="flow-fixed-footer" {...dismissKeyboardDrag}>
{footer}
</footer>
) : null}
</div>
</FlowProvider>
);
}

View File

@@ -0,0 +1,241 @@
import {
createContext,
type InputHTMLAttributes,
type PointerEvent as ReactPointerEvent,
type PropsWithChildren,
type Ref,
type TextareaHTMLAttributes,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { motion } from "motion/react";
import { mobileAssets } from "./assets";
import { useMobileDevice } from "./Device";
type KeyboardContextValue = {
visible: boolean;
height: number;
fullHeight: number;
progress: number;
dragOffset: number;
isDragging: boolean;
focusedElement: HTMLElement | null;
setDragOffset: (offset: number) => void;
setDragging: (dragging: boolean) => void;
show: (element?: HTMLElement | null) => void;
hide: () => void;
};
type KeyboardInputProps = InputHTMLAttributes<HTMLInputElement> & {
ref?: Ref<HTMLInputElement>;
};
const KeyboardContext = createContext<KeyboardContextValue | null>(null);
export function KeyboardProvider({ children }: PropsWithChildren) {
const { device } = useMobileDevice();
const [visible, setVisible] = useState(false);
const [dragOffset, setRawDragOffset] = useState(0);
const [isDragging, setDragging] = useState(false);
const [focusedElement, setFocusedElement] = useState<HTMLElement | null>(null);
const fullHeight = device.geometry.keyboard.height;
const setDragOffset = (offset: number) => {
setRawDragOffset(Math.max(0, Math.min(fullHeight, offset)));
};
const value = useMemo<KeyboardContextValue>(
() => ({
visible,
height: visible ? Math.max(0, fullHeight - dragOffset) : 0,
fullHeight,
dragOffset,
isDragging,
progress: visible ? 1 : 0,
focusedElement,
setDragOffset,
setDragging,
show: (element) => {
setRawDragOffset(0);
setDragging(false);
setFocusedElement(element ?? null);
setVisible(true);
},
hide: () => {
focusedElement?.blur();
setDragging(false);
setFocusedElement(null);
setVisible(false);
},
}),
[dragOffset, focusedElement, fullHeight, isDragging, visible],
);
return <KeyboardContext.Provider value={value}>{children}</KeyboardContext.Provider>;
}
export function useKeyboard() {
const context = useContext(KeyboardContext);
if (!context) {
throw new Error("useKeyboard must be used inside KeyboardProvider");
}
return context;
}
export function useKeyboardInsets() {
const keyboard = useKeyboard();
const { device } = useMobileDevice();
const reservesAndroidNavigation = device.platform === "android" && !keyboard.visible;
return {
keyboardHeight: keyboard.height,
keyboardFullHeight: keyboard.fullHeight,
keyboardDragging: keyboard.isDragging,
bottomInset: reservesAndroidNavigation
? 0
: device.platform === "android"
? keyboard.height
: Math.max(device.geometry.safeArea.bottom, keyboard.height),
availableHeight:
device.geometry.screen.height -
keyboard.height -
(reservesAndroidNavigation ? device.geometry.safeArea.bottom : 0),
isKeyboardVisible: keyboard.visible,
};
}
export function useKeyboardDismissDrag() {
const keyboard = useKeyboard();
const dragRef = useRef({
pointerId: null as number | null,
startY: 0,
lastY: 0,
lastTime: 0,
velocityY: 0,
});
const endDismissDrag = (event: ReactPointerEvent<HTMLElement>) => {
const drag = dragRef.current;
if (drag.pointerId !== event.pointerId) return;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Capture may already be gone after pointer cancel.
}
const nextY = Math.max(0, event.clientY - drag.startY);
const shouldDismiss = nextY > 76 || drag.velocityY > 0.45;
drag.pointerId = null;
if (shouldDismiss) {
keyboard.setDragOffset(nextY);
keyboard.hide();
return;
}
keyboard.setDragging(false);
keyboard.setDragOffset(0);
};
return {
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {
if (!keyboard.visible) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
if (
event.target instanceof Element &&
event.target.closest('button, input, textarea, select, a, [role="button"], [contenteditable="true"]')
) {
return;
}
keyboard.setDragging(true);
dragRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
lastY: event.clientY,
lastTime: performance.now(),
velocityY: 0,
};
event.currentTarget.setPointerCapture(event.pointerId);
},
onPointerMove: (event: ReactPointerEvent<HTMLElement>) => {
const drag = dragRef.current;
if (drag.pointerId !== event.pointerId) return;
const now = performance.now();
const elapsed = Math.max(1, now - drag.lastTime);
drag.velocityY = (event.clientY - drag.lastY) / elapsed;
drag.lastY = event.clientY;
drag.lastTime = now;
keyboard.setDragOffset(Math.max(0, event.clientY - drag.startY));
},
onPointerUp: endDismissDrag,
onPointerCancel: endDismissDrag,
};
}
export function KeyboardInput(props: KeyboardInputProps) {
const keyboard = useKeyboard();
const { ref, ...inputProps } = props;
return (
<input
{...inputProps}
ref={ref}
onFocus={(event) => {
keyboard.show(event.currentTarget);
inputProps.onFocus?.(event);
}}
/>
);
}
export function KeyboardTextarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
const keyboard = useKeyboard();
return (
<textarea
{...props}
onFocus={(event) => {
keyboard.show(event.currentTarget);
props.onFocus?.(event);
}}
/>
);
}
export function KeyboardDock() {
const keyboard = useKeyboard();
const { device } = useMobileDevice();
const dismissDrag = useKeyboardDismissDrag();
const keyboardTransition = keyboard.isDragging
? { duration: 0 }
: { duration: 0.26, ease: [0.2, 0.8, 0.2, 1] as [number, number, number, number] };
return (
<motion.div
className="keyboard-dock"
data-platform={device.platform}
data-testid="keyboard-dock"
data-visible={keyboard.visible ? "true" : "false"}
initial={{ y: keyboard.fullHeight }}
animate={{ y: keyboard.visible ? keyboard.dragOffset : keyboard.fullHeight }}
aria-hidden={keyboard.visible ? undefined : "true"}
style={{ height: keyboard.fullHeight }}
transition={keyboardTransition}
{...dismissDrag}
>
<img
className="keyboard-asset"
src={device.platform === "android" ? mobileAssets.androidKeyboard : mobileAssets.iphoneKeyboard}
alt=""
aria-hidden="true"
draggable={false}
/>
</motion.div>
);
}

View File

@@ -0,0 +1,69 @@
import { useState, type PointerEvent as ReactPointerEvent } from "react";
type CursorState = {
visible: boolean;
active: boolean;
x: number;
y: number;
};
export function useMobileCursor() {
const debug =
typeof window !== "undefined" && new URLSearchParams(window.location.search).has("cursorDebug");
const [cursor, setCursor] = useState<CursorState>({
visible: false,
active: false,
x: 0,
y: 0,
});
const updatePosition = (event: ReactPointerEvent<HTMLElement>) => {
const bounds = event.currentTarget.getBoundingClientRect();
const localX = bounds.width === 0 ? 0 : ((event.clientX - bounds.left) / bounds.width) * event.currentTarget.offsetWidth;
const localY =
bounds.height === 0 ? 0 : ((event.clientY - bounds.top) / bounds.height) * event.currentTarget.offsetHeight;
setCursor((current) => ({
...current,
visible: true,
x: localX,
y: localY,
}));
};
return {
cursorHandlers: {
onPointerEnter: updatePosition,
onPointerMove: updatePosition,
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {
updatePosition(event);
setCursor((current) => ({ ...current, active: true }));
},
onPointerUp: (event: ReactPointerEvent<HTMLElement>) => {
updatePosition(event);
setCursor((current) => ({ ...current, active: false }));
},
onPointerCancel: () => {
setCursor((current) => ({ ...current, active: false, visible: false }));
},
onPointerLeave: () => {
setCursor((current) => ({ ...current, active: false, visible: false }));
},
},
cursorDebug: debug,
cursorElement: (
<div
className="mobile-cursor"
data-active={cursor.active ? "true" : "false"}
data-debug={debug ? "true" : "false"}
data-visible={cursor.visible ? "true" : "false"}
data-testid="mobile-cursor"
style={{
transform: `translate3d(${cursor.x}px, ${cursor.y}px, 0) translate(-50%, -50%)`,
}}
>
{debug ? <span className="mobile-cursor-hotspot" aria-hidden="true" /> : null}
</div>
),
};
}

View File

@@ -0,0 +1,50 @@
import { useEffect, type PropsWithChildren } from "react";
import { MobileDeviceProvider, useMobileDevice } from "./Device";
import { KeyboardDock, KeyboardProvider, useKeyboard } from "./Keyboard";
import { PhoneFrame } from "./PhoneFrame";
import { HomeIndicator, StatusBar } from "./components";
export function MobileRuntime({ children }: PropsWithChildren) {
return (
<MobileDeviceProvider>
<PhoneFrame>
<KeyboardProvider>
<KeyboardPreview />
<StatusBar />
<MobileAppViewport>{children}</MobileAppViewport>
<HomeIndicator />
<KeyboardDock />
</KeyboardProvider>
</PhoneFrame>
</MobileDeviceProvider>
);
}
function MobileAppViewport({ children }: PropsWithChildren) {
const { device } = useMobileDevice();
const keyboard = useKeyboard();
return (
<div
className="mobile-app-viewport"
data-keyboard-visible={keyboard.visible ? "true" : "false"}
data-platform={device.platform}
data-testid="mobile-app-viewport"
>
{children}
</div>
);
}
function KeyboardPreview() {
const keyboard = useKeyboard();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("keyboard") === "1") {
keyboard.show();
}
}, [keyboard]);
return null;
}

View File

@@ -0,0 +1,466 @@
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type PropsWithChildren,
} from "react";
import { useKeyboardInsets } from "./Keyboard";
type MobileScrollProps = PropsWithChildren<{
className?: string;
}>;
const scrollPhysics = {
momentumFriction: 2.1,
momentumVelocityScale: 890,
momentumTolerance: 18,
bounceTension: 200,
bounceFriction: 40,
bounceTolerance: 0.5,
overdragScale: 0.5,
maxOverdrag: 96,
velocitySampleWindow: 100,
tapSlop: 8,
} as const;
function shouldIgnoreScrollDrag(target: EventTarget | null) {
return (
target instanceof Element &&
Boolean(target.closest('[data-scroll-drag="ignore"]'))
);
}
type DragSample = {
y: number;
time: number;
};
type DragSession = {
active: boolean;
captured: boolean;
pointerId: number | null;
startY: number;
startScrollTop: number;
hasDragged: boolean;
};
export function MobileScroll({ className, children }: MobileScrollProps) {
const { isKeyboardVisible, keyboardHeight, keyboardDragging } = useKeyboardInsets();
const scrollRef = useRef<HTMLDivElement | null>(null);
const hideTimerRef = useRef<number | null>(null);
const inertiaFrameRef = useRef<number | null>(null);
const lastInertiaTimeRef = useRef<number | null>(null);
const overscrollRef = useRef(0);
const dragSamplesRef = useRef<DragSample[]>([]);
const isDraggingRef = useRef(false);
const suppressNextClickRef = useRef(false);
const suppressClickTimerRef = useRef<number | null>(null);
const dragSessionRef = useRef<DragSession>({
active: false,
captured: false,
pointerId: null,
startY: 0,
startScrollTop: 0,
hasDragged: false,
});
const [isDragging, setIsDragging] = useState(false);
const [overscrollY, setOverscrollY] = useState(0);
const [thumb, setThumb] = useState({
visible: false,
top: 0,
height: 0,
enabled: false,
});
const stopInertia = useCallback(() => {
if (inertiaFrameRef.current !== null) {
window.cancelAnimationFrame(inertiaFrameRef.current);
inertiaFrameRef.current = null;
}
lastInertiaTimeRef.current = null;
}, []);
const suppressUpcomingClick = useCallback(() => {
suppressNextClickRef.current = true;
if (suppressClickTimerRef.current !== null) {
window.clearTimeout(suppressClickTimerRef.current);
}
suppressClickTimerRef.current = window.setTimeout(() => {
suppressNextClickRef.current = false;
suppressClickTimerRef.current = null;
}, 180);
}, []);
const setRubberBand = useCallback((value: number) => {
const clamped = Math.max(-scrollPhysics.maxOverdrag, Math.min(scrollPhysics.maxOverdrag, value));
overscrollRef.current = clamped;
setOverscrollY(clamped);
}, []);
const maxScrollTop = useCallback((scroll: HTMLDivElement) => {
return Math.max(0, scroll.scrollHeight - scroll.clientHeight);
}, []);
const rubberBand = useCallback((distance: number) => {
return distance * scrollPhysics.overdragScale;
}, []);
const pushDragSample = useCallback((y: number) => {
const time = performance.now();
const samples = [...dragSamplesRef.current, { y, time }].filter(
(sample) => time - sample.time <= scrollPhysics.velocitySampleWindow,
);
dragSamplesRef.current = samples;
}, []);
const releaseVelocity = useCallback(() => {
const samples = dragSamplesRef.current;
if (samples.length < 2) return 0;
const first = samples[0];
const last = samples[samples.length - 1];
const elapsed = Math.max(1, last.time - first.time);
const pointerVelocity = (last.y - first.y) / elapsed;
return -pointerVelocity * scrollPhysics.momentumVelocityScale;
}, []);
const springBack = useCallback((initialVelocity = 0) => {
stopInertia();
let position = overscrollRef.current;
let velocity = Math.max(-1400, Math.min(1400, initialVelocity));
let lastTime: number | null = null;
const step = (time: number) => {
const deltaTime = Math.min(0.034, ((lastTime === null ? time : time - lastTime) || 16) / 1000);
lastTime = time;
const acceleration = -scrollPhysics.bounceTension * position - scrollPhysics.bounceFriction * velocity;
velocity += acceleration * deltaTime;
position += velocity * deltaTime;
if (Math.abs(position) < scrollPhysics.bounceTolerance && Math.abs(velocity) < scrollPhysics.bounceTolerance) {
setRubberBand(0);
inertiaFrameRef.current = null;
return;
}
setRubberBand(position);
inertiaFrameRef.current = window.requestAnimationFrame(step);
};
inertiaFrameRef.current = window.requestAnimationFrame(step);
}, [setRubberBand, stopInertia]);
const updateThumb = useCallback((visible = true) => {
const scroll = scrollRef.current;
if (!scroll) return;
const { clientHeight, scrollHeight, scrollTop } = scroll;
const enabled = scrollHeight > clientHeight + 2;
const height = enabled ? Math.max(36, (clientHeight / scrollHeight) * clientHeight) : 0;
const maxThumbTop = Math.max(0, clientHeight - height - 8);
const maxScrollTop = Math.max(1, scrollHeight - clientHeight);
const top = enabled ? 4 + (scrollTop / maxScrollTop) * maxThumbTop : 0;
setThumb({ visible: visible && enabled, top, height, enabled });
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
}
if (visible && enabled && !isDraggingRef.current) {
hideTimerRef.current = window.setTimeout(() => {
setThumb((current) => ({ ...current, visible: false }));
}, 650);
}
}, []);
const applyDragPosition = useCallback((startScrollTop: number, movementY: number, scroll: HTMLDivElement) => {
const desiredScrollTop = startScrollTop - movementY;
const maxTop = maxScrollTop(scroll);
if (desiredScrollTop < 0) {
scroll.scrollTop = 0;
setRubberBand(rubberBand(-desiredScrollTop));
} else if (desiredScrollTop > maxTop) {
scroll.scrollTop = maxTop;
setRubberBand(-rubberBand(desiredScrollTop - maxTop));
} else {
scroll.scrollTop = desiredScrollTop;
setRubberBand(0);
}
updateThumb(true);
}, [maxScrollTop, rubberBand, setRubberBand, updateThumb]);
useEffect(() => {
updateThumb(false);
const scroll = scrollRef.current;
if (!scroll) return;
const handleScroll = () => updateThumb(true);
const resizeObserver = new ResizeObserver(() => updateThumb(false));
scroll.addEventListener("scroll", handleScroll, { passive: true });
resizeObserver.observe(scroll);
if (scroll.firstElementChild) {
resizeObserver.observe(scroll.firstElementChild);
}
return () => {
scroll.removeEventListener("scroll", handleScroll);
resizeObserver.disconnect();
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
}
if (suppressClickTimerRef.current !== null) {
window.clearTimeout(suppressClickTimerRef.current);
}
stopInertia();
};
}, [stopInertia, updateThumb]);
useEffect(() => {
updateThumb(false);
}, [keyboardHeight, updateThumb]);
const startMomentum = useCallback((scroll: HTMLDivElement, initialVelocity: number) => {
let velocity = initialVelocity;
const step = (time: number) => {
const deltaTime =
lastInertiaTimeRef.current === null ? 16 : Math.min(34, time - lastInertiaTimeRef.current);
const deltaSeconds = deltaTime / 1000;
lastInertiaTimeRef.current = time;
velocity *= Math.exp(-scrollPhysics.momentumFriction * deltaSeconds);
if (Math.abs(velocity) < scrollPhysics.momentumTolerance) {
updateThumb(true);
lastInertiaTimeRef.current = null;
inertiaFrameRef.current = null;
return;
}
const maxTop = maxScrollTop(scroll);
const attempted = scroll.scrollTop + velocity * deltaSeconds;
const before = scroll.scrollTop;
if (attempted < 0) {
scroll.scrollTop = 0;
setRubberBand(rubberBand(-attempted));
springBack(velocity * scrollPhysics.overdragScale);
return;
}
if (attempted > maxTop) {
scroll.scrollTop = maxTop;
setRubberBand(-rubberBand(attempted - maxTop));
springBack(velocity * scrollPhysics.overdragScale);
return;
}
scroll.scrollTop = attempted;
updateThumb(true);
if (scroll.scrollTop === before) {
lastInertiaTimeRef.current = null;
inertiaFrameRef.current = null;
return;
}
inertiaFrameRef.current = window.requestAnimationFrame(step);
};
if (Math.abs(velocity) > scrollPhysics.momentumTolerance) {
lastInertiaTimeRef.current = null;
inertiaFrameRef.current = window.requestAnimationFrame(step);
} else {
updateThumb(true);
}
}, [maxScrollTop, rubberBand, setRubberBand, springBack, updateThumb]);
const endDrag = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const scroll = scrollRef.current;
const session = dragSessionRef.current;
if (!scroll || !session.active || session.pointerId !== event.pointerId) return;
// A nested horizontal scroller can let pointer-down bubble, then claim
// pointer-move. Its pointer-up may still reach this parent. Without a
// completed parent drag there is no valid release velocity or click to
// suppress, so discard the pending session without starting motion.
if (!session.hasDragged) {
dragSamplesRef.current = [];
dragSessionRef.current = {
active: false,
captured: false,
pointerId: null,
startY: 0,
startScrollTop: 0,
hasDragged: false,
};
setIsDragging(false);
isDraggingRef.current = false;
return;
}
if (session.captured) {
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Pointer capture can already be gone after a browser-level cancel.
}
}
if (session.hasDragged) {
event.preventDefault();
suppressUpcomingClick();
}
pushDragSample(event.clientY);
const velocity = releaseVelocity();
dragSessionRef.current = {
active: false,
captured: false,
pointerId: null,
startY: 0,
startScrollTop: 0,
hasDragged: false,
};
setIsDragging(false);
isDraggingRef.current = false;
if (Math.abs(overscrollRef.current) > 0.1) {
springBack(velocity * scrollPhysics.overdragScale);
return;
}
startMomentum(scroll, velocity);
}, [pushDragSample, releaseVelocity, springBack, startMomentum, suppressUpcomingClick]);
const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const scroll = scrollRef.current;
if (!scroll || scroll.scrollHeight <= scroll.clientHeight) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
stopInertia();
if (shouldIgnoreScrollDrag(event.target)) {
setRubberBand(0);
return;
}
isDraggingRef.current = false;
dragSamplesRef.current = [];
pushDragSample(event.clientY);
setRubberBand(0);
setThumb((current) => ({ ...current, visible: current.enabled }));
dragSessionRef.current = {
active: true,
captured: false,
pointerId: event.pointerId,
startY: event.clientY,
startScrollTop: scroll.scrollTop,
hasDragged: false,
};
}, [pushDragSample, setRubberBand, stopInertia]);
const handlePointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const scroll = scrollRef.current;
const session = dragSessionRef.current;
if (!scroll || !session.active || session.pointerId !== event.pointerId) return;
const movementY = event.clientY - session.startY;
if (!session.hasDragged && Math.abs(movementY) < scrollPhysics.tapSlop) return;
if (!session.captured) {
try {
event.currentTarget.setPointerCapture(event.pointerId);
session.captured = true;
} catch {
// Pointer capture can fail if the browser has already canceled the pointer.
}
}
event.preventDefault();
session.hasDragged = true;
suppressUpcomingClick();
isDraggingRef.current = true;
setIsDragging(true);
pushDragSample(event.clientY);
applyDragPosition(session.startScrollTop, movementY, scroll);
}, [applyDragPosition, pushDragSample, suppressUpcomingClick]);
const suppressClickAfterDrag = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (!suppressNextClickRef.current) return;
suppressNextClickRef.current = false;
if (suppressClickTimerRef.current !== null) {
window.clearTimeout(suppressClickTimerRef.current);
suppressClickTimerRef.current = null;
}
event.preventDefault();
event.stopPropagation();
}, []);
const style = {
"--keyboard-height": `${keyboardHeight}px`,
} as CSSProperties;
return (
<section
className={`mobile-page ${className ?? ""}`}
data-keyboard-dragging={keyboardDragging ? "true" : "false"}
data-keyboard-visible={isKeyboardVisible ? "true" : "false"}
style={style}
>
<div
ref={scrollRef}
className="mobile-scroll"
data-testid="mobile-scroll"
data-dragging={isDragging ? "true" : "false"}
data-overscroll={overscrollY.toFixed(2)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClickCapture={suppressClickAfterDrag}
>
<div
className="mobile-scroll-content"
data-testid="mobile-scroll-content"
style={{ transform: `translateY(${overscrollY}px)` }}
>
{children}
</div>
</div>
<div
className="mobile-scrollbar"
data-testid="mobile-scrollbar"
data-visible={thumb.visible ? "true" : "false"}
aria-hidden="true"
>
<div
className="mobile-scrollbar-thumb"
style={{
height: thumb.height,
transform: `translateY(${thumb.top}px)`,
}}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,144 @@
import {
createContext,
type CSSProperties,
type DragEvent,
type PropsWithChildren,
type RefObject,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { DevicePicker, useMobileDevice } from "./Device";
import { useMobileCursor } from "./MobileCursor";
type ScreenPortalContextValue = {
screenRef: RefObject<HTMLDivElement | null>;
};
const ScreenPortalContext = createContext<ScreenPortalContextValue | null>(null);
function suppressNativeDrag(event: DragEvent<HTMLElement>) {
if (event.target instanceof Element && event.target.closest('[data-native-drag="true"]')) {
return;
}
event.preventDefault();
}
export function useScreenPortal() {
const context = useContext(ScreenPortalContext);
if (!context) {
throw new Error("useScreenPortal must be used inside PhoneFrame");
}
return context;
}
function getDeviceScale(deviceWidth: number, deviceHeight: number) {
if (typeof window === "undefined") return 1;
const horizontal = (window.innerWidth - 48) / deviceWidth;
const vertical = (window.innerHeight - 48) / deviceHeight;
return Math.max(0.42, Math.min(horizontal, vertical, 1));
}
function useDeviceScale(deviceWidth: number, deviceHeight: number) {
const [scale, setScale] = useState(() => getDeviceScale(deviceWidth, deviceHeight));
useEffect(() => {
const update = () => setScale(getDeviceScale(deviceWidth, deviceHeight));
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, [deviceHeight, deviceWidth]);
return scale;
}
export function PhoneFrame({ children }: PropsWithChildren) {
const { device } = useMobileDevice();
const { geometry } = device;
const scale = useDeviceScale(geometry.device.width, geometry.device.height);
const screenRef = useRef<HTMLDivElement | null>(null);
const contextValue = useMemo(() => ({ screenRef }), []);
const mobileCursor = useMobileCursor();
return (
<ScreenPortalContext.Provider value={contextValue}>
<div className="phone-stage">
<DevicePicker />
<div
className="phone-scale-box"
style={{
width: geometry.device.width * scale,
height: geometry.device.height * scale,
}}
>
<div
className="phone-device"
data-device={device.id}
data-platform={device.platform}
data-testid="phone-frame"
onDragStartCapture={suppressNativeDrag}
style={{
width: geometry.device.width,
height: geometry.device.height,
transform: `scale(${scale})`,
}}
>
<img
className="phone-bezel"
src={device.bezel}
alt=""
aria-hidden="true"
draggable={false}
style={{ zIndex: device.bezelLayer === "above-screen" ? 2 : 1 }}
/>
<div
ref={screenRef}
className="device-screen"
data-cursor-debug={mobileCursor.cursorDebug ? "true" : "false"}
data-device={device.id}
data-phone-screen
data-testid="device-screen"
{...mobileCursor.cursorHandlers}
style={
{
"--device-safe-area-bottom": `${geometry.safeArea.bottom}px`,
left: geometry.screen.x,
top: geometry.screen.y,
width: geometry.screen.width,
height: geometry.screen.height,
borderRadius: geometry.screen.radius,
zIndex: device.bezelLayer === "above-screen" ? 1 : 2,
} as CSSProperties
}
>
{children}
{device.camera ? (
<span
className="device-camera"
data-testid="device-camera"
aria-hidden="true"
style={{
width: device.camera.size,
height: device.camera.size,
top: device.camera.top,
left: `calc(50% - ${device.camera.size / 2}px)`,
}}
/>
) : null}
{mobileCursor.cursorElement}
</div>
</div>
</div>
</div>
</ScreenPortalContext.Provider>
);
}

View File

@@ -0,0 +1,6 @@
export const mobileAssets = {
iphoneBezel: "/assets/iphone/Bezel.png",
iphoneKeyboard: "/assets/iphone/Keyboard.png",
androidKeyboard: "/assets/android/Keyboard.png",
pixel10Bezel: "/assets/android/Pixel10.png",
} as const;

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { KeyboardInput, useKeyboard } from "./Keyboard";
import { useMobileDevice } from "./Device";
export function StatusBar() {
const [now, setNow] = useState(() => new Date());
const { device } = useMobileDevice();
useEffect(() => {
const syncToMinute = window.setTimeout(() => {
setNow(new Date());
}, (60 - now.getSeconds()) * 1000 - now.getMilliseconds());
const interval = window.setInterval(() => setNow(new Date()), 60_000);
return () => {
window.clearTimeout(syncToMinute);
window.clearInterval(interval);
};
}, [now]);
return (
<div className="status-bar" aria-label="Device status bar">
<span className="status-time" data-testid="status-time">
{formatStatusTime(now)}
</span>
<div className="status-indicators" aria-hidden="true">
<StatusIndicators platform={device.platform} />
</div>
</div>
);
}
export function HomeIndicator() {
const { device } = useMobileDevice();
const keyboard = useKeyboard();
if (device.platform === "android") {
if (keyboard.visible) return null;
return (
<img
className="android-navigation-bar"
data-testid="android-navigation-bar"
src="/assets/android/navigation-bar.svg"
alt=""
aria-hidden="true"
draggable={false}
/>
);
}
return (
<svg
className="home-indicator-svg"
data-testid="home-indicator"
width="393"
height="34"
viewBox="0 0 393 34"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="129.5" y="21" width="134" height="5" rx="2.5" fill="black" />
</svg>
);
}
export function MobileTextField({
id,
label,
placeholder,
testId,
}: {
id: string;
label: string;
placeholder?: string;
testId?: string;
}) {
return (
<label className="mobile-field" htmlFor={id}>
<span className="field-label">{label}</span>
<KeyboardInput id={id} data-testid={testId} placeholder={placeholder} />
</label>
);
}
function formatStatusTime(date: Date) {
const hours = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours % 12 || 12}:${minutes}`;
}
function StatusIndicators({ platform }: { platform: "ios" | "android" }) {
return (
<img
className="status-indicator-svg"
data-testid="status-indicators"
data-platform={platform}
src={
platform === "android"
? "/assets/status/status-icons.svg"
: "/assets/status/ios-status-icons.svg"
}
alt=""
aria-hidden="true"
draggable={false}
/>
);
}

View File

@@ -0,0 +1,68 @@
export type MobileDeviceGeometry = {
device: {
width: number;
height: number;
};
screen: {
x: number;
y: number;
width: number;
height: number;
radius: number;
};
safeArea: {
top: number;
bottom: number;
};
keyboard: {
height: number;
};
};
export const iphoneGeometry = {
// CSS-space coordinates for the 3x iPhone assets. Keep source PNG dimensions
// divisible by 3 so the rendered phone frame lands on whole CSS pixels.
device: {
width: 511,
height: 968,
},
screen: {
x: 59,
y: 58,
width: 393,
height: 852,
radius: 42,
},
safeArea: {
top: 54,
bottom: 34,
},
keyboard: {
height: 338,
},
} as const satisfies MobileDeviceGeometry;
export const pixelGeometry = {
// Pixel10.png is a 2x asset. Its 854 x 1904 screen opening renders at
// exactly 427 x 952 CSS pixels inside the 566 x 1022 asset canvas.
device: {
width: 566,
height: 1022,
},
screen: {
x: 70,
y: 35,
width: 427,
height: 952,
radius: 58,
},
safeArea: {
top: 64,
bottom: 48,
},
keyboard: {
height: 316,
},
} as const satisfies MobileDeviceGeometry;
export type IPhoneGeometry = typeof iphoneGeometry;

View File

@@ -0,0 +1,18 @@
export { BottomSheet } from "./BottomSheet";
export { Carousel, type CarouselProps } from "./Carousel";
export { DevicePicker, mobileDevices, useMobileDevice, type MobileDeviceId } from "./Device";
export { FlowStack, useFlow, type FlowControls, type FlowScreen } from "./FlowStack";
export {
KeyboardDock,
KeyboardInput,
KeyboardProvider,
KeyboardTextarea,
useKeyboard,
useKeyboardInsets,
} from "./Keyboard";
export { MobileScroll } from "./MobileScroll";
export { MobileRuntime } from "./MobileRuntime";
export { PhoneFrame, useScreenPortal } from "./PhoneFrame";
export { HomeIndicator, MobileTextField, StatusBar } from "./components";
export { mobileAssets } from "./assets";
export { iphoneGeometry, pixelGeometry, type IPhoneGeometry, type MobileDeviceGeometry } from "./geometry";

1684
brain-h5/src/prototype.css Normal file

File diff suppressed because it is too large Load Diff

19
brain-h5/src/styles.css Normal file
View File

@@ -0,0 +1,19 @@
:root {
color: #172033;
background: #f4f8ff;
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* { box-sizing: border-box; }
html, body, #root { width: 100%; min-width: 320px; min-height: 100%; margin: 0; }
body { min-height: 100vh; overflow-x: hidden; }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:focus-visible, select:focus-visible {
outline: 3px solid rgba(38, 124, 255, .28);
outline-offset: 3px;
}
h1, h2, h3, p { margin: 0; }

1
brain-h5/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />