首次提交:脑晴测 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:
5
brain-h5/src/App.tsx
Normal file
5
brain-h5/src/App.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import Prototype from "./Prototype";
|
||||
|
||||
export default function App() {
|
||||
return <Prototype />;
|
||||
}
|
||||
988
brain-h5/src/Prototype.tsx
Normal file
988
brain-h5/src/Prototype.tsx
Normal 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
12
brain-h5/src/main.tsx
Normal 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>,
|
||||
);
|
||||
136
brain-h5/src/mobile/BottomSheet.tsx
Normal file
136
brain-h5/src/mobile/BottomSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
brain-h5/src/mobile/COMPONENTS.md
Normal file
31
brain-h5/src/mobile/COMPONENTS.md
Normal 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.
|
||||
257
brain-h5/src/mobile/Carousel.tsx
Normal file
257
brain-h5/src/mobile/Carousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
brain-h5/src/mobile/Device.tsx
Normal file
115
brain-h5/src/mobile/Device.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
234
brain-h5/src/mobile/FlowStack.tsx
Normal file
234
brain-h5/src/mobile/FlowStack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
brain-h5/src/mobile/Keyboard.tsx
Normal file
241
brain-h5/src/mobile/Keyboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
brain-h5/src/mobile/MobileCursor.tsx
Normal file
69
brain-h5/src/mobile/MobileCursor.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
}
|
||||
50
brain-h5/src/mobile/MobileRuntime.tsx
Normal file
50
brain-h5/src/mobile/MobileRuntime.tsx
Normal 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;
|
||||
}
|
||||
466
brain-h5/src/mobile/MobileScroll.tsx
Normal file
466
brain-h5/src/mobile/MobileScroll.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
brain-h5/src/mobile/PhoneFrame.tsx
Normal file
144
brain-h5/src/mobile/PhoneFrame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
brain-h5/src/mobile/assets.ts
Normal file
6
brain-h5/src/mobile/assets.ts
Normal 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;
|
||||
109
brain-h5/src/mobile/components.tsx
Normal file
109
brain-h5/src/mobile/components.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
brain-h5/src/mobile/geometry.ts
Normal file
68
brain-h5/src/mobile/geometry.ts
Normal 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;
|
||||
18
brain-h5/src/mobile/index.ts
Normal file
18
brain-h5/src/mobile/index.ts
Normal 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
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
19
brain-h5/src/styles.css
Normal 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
1
brain-h5/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user