feat(frontend): update portal layout and API environments

This commit is contained in:
david
2026-07-31 16:26:54 +08:00
parent 7e0cd360e9
commit 88c61b9be4
65 changed files with 434 additions and 8589 deletions

View File

@@ -1,3 +1,4 @@
VITE_API_BASE_URL=http://localhost:12426/heqi/delivery/v1
# 可选覆盖;开发默认 http://localhost:12426/heqi/delivery/v1,生产构建默认 http://rest.heqiapp.com/heqi/delivery/v1。
VITE_API_BASE_URL=
# 受保护 API 使用 BSM JWT本地联调时由认证服务签发不要提交真实令牌。
VITE_DEV_JWT=

View File

@@ -1,6 +1,6 @@
# 和气配送点管理系统
配送点管理端使用 Vue 3、TypeScript、Vite、Pinia 和 Arco Design开发端口为 `5176`默认 API 为 `http://localhost:12426/heqi/delivery/v1`
配送点管理端使用 Vue 3、TypeScript、Vite、Pinia 和 Arco Design开发端口为 `5176`。开发默认 API 为 `http://localhost:12426/heqi/delivery/v1`,生产构建默认 API 为 `http://rest.heqiapp.com/heqi/delivery/v1`
```bash
pnpm install

View File

@@ -5,7 +5,7 @@ import { resolve } from 'path';
import visualizer from 'rollup-plugin-visualizer';
import { ArcoResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite';
import { defineConfig, type PluginOption } from 'vite';
import { defineConfig, loadEnv, type PluginOption } from 'vite';
import compressPlugin from 'vite-plugin-compression';
import svgLoader from 'vite-svg-loader';
@@ -29,6 +29,10 @@ function manualChunks(id: string) {
export default defineConfig(({ command, mode }) => {
const isDev = command === 'serve';
const isReport = mode === 'report';
const env = loadEnv(mode, process.cwd(), '');
const apiBaseURL =
env.VITE_API_BASE_URL ||
`${isDev ? 'http://localhost:12426' : 'http://rest.heqiapp.com'}/heqi/delivery/v1`;
const plugins: PluginOption[] = [
vue(),
@@ -60,6 +64,9 @@ export default defineConfig(({ command, mode }) => {
}
return {
define: {
'import.meta.env.VITE_API_BASE_URL': JSON.stringify(apiBaseURL),
},
plugins,
resolve: {
alias: [

View File

@@ -1,3 +1,4 @@
VITE_API_BASE_URL=http://localhost:12426/heqi/gas/v1
# 可选覆盖;开发默认 http://localhost:12426/heqi/gas/v1,生产构建默认 http://rest.heqiapp.com/heqi/gas/v1。
VITE_API_BASE_URL=
# 受保护 API 使用 BSM JWT本地联调时由认证服务签发不要提交真实令牌。
VITE_DEV_JWT=

View File

@@ -23,5 +23,5 @@ pnpm build
| 变量 | 说明 |
| --- | --- |
| `VITE_API_BASE_URL` | 后端 API 地址默认 `http://localhost:12426/heqi/gas/v1` |
| `VITE_API_BASE_URL` | 可选覆盖后端 API 地址;开发默认 `http://localhost:12426/heqi/gas/v1`,生产构建默认 `http://rest.heqiapp.com/heqi/gas/v1` |
| `VITE_ERROR_REPORT_URL` | 可选的前端错误上报地址 |

View File

@@ -5,7 +5,7 @@ import { resolve } from 'path';
import visualizer from 'rollup-plugin-visualizer';
import { ArcoResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite';
import { defineConfig, type PluginOption } from 'vite';
import { defineConfig, loadEnv, type PluginOption } from 'vite';
import compressPlugin from 'vite-plugin-compression';
import svgLoader from 'vite-svg-loader';
@@ -29,6 +29,10 @@ function manualChunks(id: string) {
export default defineConfig(({ command, mode }) => {
const isDev = command === 'serve';
const isReport = mode === 'report';
const env = loadEnv(mode, process.cwd(), '');
const apiBaseURL =
env.VITE_API_BASE_URL ||
`${isDev ? 'http://localhost:12426' : 'http://rest.heqiapp.com'}/heqi/gas/v1`;
const plugins: PluginOption[] = [
vue(),
@@ -60,6 +64,9 @@ export default defineConfig(({ command, mode }) => {
}
return {
define: {
'import.meta.env.VITE_API_BASE_URL': JSON.stringify(apiBaseURL),
},
plugins,
resolve: {
alias: [

View File

@@ -1,3 +1,4 @@
VITE_API_BASE_URL=http://localhost:12426/heqi/v1
# 可选覆盖;开发默认 http://localhost:12426/heqi/platform/v1生产构建默认 http://rest.heqiapp.com/heqi/platform/v1。
VITE_API_BASE_URL=
# 受保护 API 使用 BSM JWT本地联调时由认证服务签发不要提交真实令牌。
VITE_DEV_JWT=

View File

@@ -36,5 +36,5 @@ pnpm build
| 变量 | 说明 |
| --- | --- |
| `VITE_API_BASE_URL` | 后端 API 地址默认 `http://localhost:12426/heqi/platform/v1` |
| `VITE_API_BASE_URL` | 可选覆盖后端 API 地址;开发默认 `http://localhost:12426/heqi/platform/v1`,生产构建默认 `http://rest.heqiapp.com/heqi/platform/v1` |
| `VITE_ERROR_REPORT_URL` | 可选的前端错误上报地址 |

View File

@@ -5,7 +5,7 @@ import { resolve } from 'path';
import visualizer from 'rollup-plugin-visualizer';
import { ArcoResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite';
import { defineConfig, type PluginOption } from 'vite';
import { defineConfig, loadEnv, type PluginOption } from 'vite';
import compressPlugin from 'vite-plugin-compression';
import svgLoader from 'vite-svg-loader';
@@ -29,6 +29,10 @@ function manualChunks(id: string) {
export default defineConfig(({ command, mode }) => {
const isDev = command === 'serve';
const isReport = mode === 'report';
const env = loadEnv(mode, process.cwd(), '');
const apiBaseURL =
env.VITE_API_BASE_URL ||
`${isDev ? 'http://localhost:12426' : 'http://rest.heqiapp.com'}/heqi/platform/v1`;
const plugins: PluginOption[] = [
vue(),
@@ -60,6 +64,9 @@ export default defineConfig(({ command, mode }) => {
}
return {
define: {
'import.meta.env.VITE_API_BASE_URL': JSON.stringify(apiBaseURL),
},
plugins,
resolve: {
alias: [

View File

@@ -11,6 +11,8 @@ Build app UI in `src/`. Keep `.openai/hosting.json`, `worker/index.js`, `scripts
## 和气门户设计约束
- 本项目是品牌化管理入口门户,不承载统一登录、运营数据或业务编辑。
- 门户使用常规三行布局:头部展示 Logo、名称与口号中间为广告语与滚动图 Banner底部为三个管理系统入口。
- Banner 保持紧凑,不占满页面剩余高度;三张轮播图分别对应平台、气站和配送场景。
- 桌面端优先一屏完成,移动端允许自然纵向滚动。
- 视觉语言延续三套管理系统登录页的半写实等距插画,并使用平台蓝、气站橙、配送青绿区分入口。
- 三个入口始终在新标签页打开,分别指向 `p.heqiapp.com``z.heqiapp.com``d.heqiapp.com`

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

View File

@@ -1,9 +1,34 @@
import { useEffect, useState } from "react";
import {
ArrowRight,
ArrowSquareOut,
ShieldCheck,
} from "@phosphor-icons/react";
const slides = [
{
eyebrow: "平台协同",
title: "一屏统筹,安全连接每一程",
description: "连接智能瓶阀、气站运营与配送履约,让治理更清晰、协同更高效。",
image: "/assets/banner-platform.png",
position: "center center",
},
{
eyebrow: "智慧气站",
title: "让每一次供气更稳、更安心",
description: "以数字化能力贯通站点经营、安全服务与履约管理。",
image: "/assets/banner-gas-station.png",
position: "center center",
},
{
eyebrow: "高效配送",
title: "从调度到送达,全程清晰可追溯",
description: "智能连接配送资源与服务现场,让每一程都有迹可循。",
image: "/assets/banner-delivery.png",
position: "center center",
},
];
const portals = [
{
number: "01",
@@ -37,64 +62,104 @@ function PortalLink({ portal }) {
rel="noreferrer"
aria-label={`进入${portal.title},将在新标签页打开`}
>
<div className="portal__heading">
<span className="portal__number" aria-hidden="true">
{portal.number}
</span>
<span className="portal__divider" aria-hidden="true" />
<div>
<h2>{portal.title}</h2>
<p>{portal.description}</p>
</div>
</div>
<span className="portal__number" aria-hidden="true">
{portal.number}
</span>
<span className="portal__content">
<strong>{portal.title}</strong>
<small>{portal.description}</small>
</span>
<span className="portal__action">
进入系统
<ArrowRight size={22} weight="bold" aria-hidden="true" />
<ArrowRight size={20} weight="bold" aria-hidden="true" />
</span>
</a>
);
}
export function App() {
const [activeSlide, setActiveSlide] = useState(0);
useEffect(() => {
const timer = window.setInterval(() => {
setActiveSlide((current) => (current + 1) % slides.length);
}, 5000);
return () => window.clearInterval(timer);
}, []);
return (
<main className="site-shell">
<img
className="ecosystem-scene"
src="/assets/heqi-ecosystem-hero.jpg"
alt=""
aria-hidden="true"
/>
<header className="site-header">
<a className="brand" href="/" aria-label="和气首页">
<img src="/assets/heqi-logo.svg" alt="" />
<span>和气</span>
<span className="brand__name">和气</span>
</a>
<div className="trust-line">
<ShieldCheck size={20} weight="regular" aria-hidden="true" />
<div className="brand-slogan">
<ShieldCheck size={21} weight="regular" aria-hidden="true" />
<strong>安全连接每一程</strong>
<span>安全可靠 · 稳定高效 · 智能互联</span>
</div>
</header>
<section className="hero" aria-labelledby="hero-title">
<p className="hero__eyebrow">HEQI SMART GAS PLATFORM</p>
<h1 id="hero-title">安全连接每一程</h1>
<p className="hero__description">
<span>智能瓶阀气站运营与配送履约</span>
<span>的一体化管理入口</span>
</p>
<section className="banner" aria-roledescription="轮播图" aria-label="和气平台服务介绍">
<div
className="banner__track"
style={{ transform: `translateX(-${activeSlide * 100}%)` }}
>
{slides.map((slide, index) => (
<article
className="banner__slide"
key={slide.title}
aria-hidden={index !== activeSlide}
>
<img
src={slide.image}
alt=""
style={{ objectPosition: slide.position }}
/>
<div className="banner__shade" />
<div className="banner__copy">
<p>{slide.eyebrow}</p>
<h1>{slide.title}</h1>
<span>{slide.description}</span>
</div>
</article>
))}
</div>
<div className="banner__dots" aria-label="选择轮播内容">
{slides.map((slide, index) => (
<button
type="button"
key={slide.title}
className={index === activeSlide ? "is-active" : ""}
onClick={() => setActiveSlide(index)}
aria-label={`显示第 ${index + 1} 张:${slide.title}`}
aria-current={index === activeSlide ? "true" : undefined}
/>
))}
</div>
</section>
<nav className="portal-grid" aria-label="管理系统入口">
{portals.map((portal) => (
<PortalLink key={portal.href} portal={portal} />
))}
</nav>
<footer className="site-footer">
<div className="footer-heading">
<div>
<p>MANAGEMENT PORTALS</p>
<h2>选择管理系统</h2>
</div>
<span>
<ArrowSquareOut size={17} weight="regular" aria-hidden="true" />
系统将在新标签页中打开
</span>
</div>
<p className="external-note">
<ArrowSquareOut size={17} weight="regular" aria-hidden="true" />
系统将在新标签页中打开
</p>
<nav className="portal-grid" aria-label="管理系统入口">
{portals.map((portal) => (
<PortalLink key={portal.href} portal={portal} />
))}
</nav>
</footer>
</main>
);
}

View File

@@ -4,15 +4,12 @@
font-family:
"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #202936;
background: #f7faff;
background: #f4f7fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
--platform: #1265e8;
--platform-deep: #0c4fbd;
--station: #dc7100;
--station-deep: #b85a00;
--delivery: #068f8b;
--delivery-deep: #08746f;
--delivery: #078e89;
}
* {
@@ -21,54 +18,42 @@
html {
min-width: 320px;
background: #f7faff;
background: #f4f7fb;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
margin: 0;
color: #202936;
background: #f7faff;
background: #f4f7fb;
-webkit-font-smoothing: antialiased;
}
#root {
min-height: 100vh;
button,
a {
font: inherit;
}
a {
color: inherit;
#root,
.site-shell {
min-height: 100vh;
}
.site-shell {
position: relative;
isolation: isolate;
min-height: 100vh;
min-height: 100dvh;
padding: 38px clamp(28px, 4.6vw, 72px) 30px;
overflow: hidden;
background: #f7faff;
}
.ecosystem-scene {
position: absolute;
inset: 108px 0 0;
z-index: -1;
width: 100%;
height: calc(100% - 108px);
object-fit: cover;
object-position: center bottom;
user-select: none;
pointer-events: none;
display: grid;
grid-template-rows: auto clamp(230px, 32vh, 300px) auto;
gap: clamp(18px, 2.4vh, 28px);
width: min(100%, 1440px);
margin: 0 auto;
padding: clamp(22px, 3.2vh, 38px) clamp(24px, 4.5vw, 68px)
clamp(24px, 3.4vh, 40px);
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
width: min(100%, 1320px);
margin: 0 auto;
}
.brand {
@@ -76,401 +61,397 @@ a {
gap: 12px;
align-items: center;
color: #17212d;
font-weight: 650;
font-size: 27px;
line-height: 1;
letter-spacing: 0.04em;
text-decoration: none;
}
.brand img {
width: 43px;
height: 43px;
width: 44px;
height: 44px;
}
.trust-line {
display: inline-flex;
gap: 9px;
.brand__name {
font-weight: 700;
font-size: 29px;
line-height: 1;
letter-spacing: 0.08em;
}
.brand-slogan {
display: grid;
grid-template-columns: auto auto;
gap: 2px 9px;
align-items: center;
color: #687586;
font-size: 14px;
line-height: 22px;
letter-spacing: 0.02em;
color: #27394e;
text-align: right;
}
.hero {
.brand-slogan svg {
grid-row: 1 / 3;
color: var(--platform);
}
.brand-slogan strong {
font-size: 15px;
line-height: 21px;
}
.brand-slogan span {
color: #7b8796;
font-size: 11px;
line-height: 17px;
letter-spacing: 0.04em;
}
.banner {
position: relative;
min-height: 230px;
overflow: hidden;
background: #dfe8f4;
border: 1px solid rgba(103, 126, 154, 0.14);
border-radius: 22px;
box-shadow: 0 22px 60px rgba(44, 66, 92, 0.12);
}
.banner__track {
display: flex;
height: 100%;
transition: transform 650ms cubic-bezier(0.22, 1, 0.36, 1);
}
.banner__slide {
position: relative;
min-width: 100%;
min-height: 230px;
overflow: hidden;
}
.banner__slide img,
.banner__shade {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.banner__slide img {
object-fit: cover;
transform: scale(1.01);
}
.banner__shade {
background:
linear-gradient(90deg, rgba(12, 27, 47, 0.74) 0%, rgba(14, 36, 65, 0.42) 40%, rgba(17, 40, 65, 0.04) 72%),
linear-gradient(0deg, rgba(8, 27, 48, 0.16), transparent 55%);
}
.banner__copy {
position: relative;
z-index: 1;
width: min(100%, 900px);
margin: clamp(34px, 4.5vh, 46px) auto 0;
text-align: center;
display: flex;
width: min(54%, 650px);
height: 100%;
min-height: 230px;
flex-direction: column;
justify-content: center;
padding: clamp(34px, 5vw, 74px);
color: #fff;
}
.hero__eyebrow {
margin: 0 0 12px;
color: #6d7f95;
font-weight: 620;
.banner__copy p {
margin: 0 0 11px;
color: #bcd8ff;
font-weight: 650;
font-size: 13px;
line-height: 20px;
letter-spacing: 0.16em;
}
.banner__copy h1 {
margin: 0;
font-weight: 700;
font-size: clamp(30px, 3.2vw, 46px);
line-height: 1.22;
letter-spacing: 0.025em;
text-wrap: balance;
}
.banner__copy span {
max-width: 520px;
margin-top: 17px;
color: rgba(255, 255, 255, 0.82);
font-size: clamp(14px, 1.25vw, 17px);
line-height: 1.8;
}
.banner__dots {
position: absolute;
bottom: 24px;
left: clamp(34px, 5vw, 74px);
z-index: 2;
display: flex;
gap: 8px;
}
.banner__dots button {
width: 24px;
height: 4px;
padding: 0;
cursor: pointer;
background: rgba(255, 255, 255, 0.46);
border: 0;
border-radius: 999px;
transition:
width 180ms ease,
background-color 180ms ease;
}
.banner__dots button.is-active {
width: 48px;
background: #fff;
}
.banner__dots button:focus-visible {
outline: 3px solid rgba(255, 255, 255, 0.55);
outline-offset: 4px;
}
.site-footer {
display: grid;
gap: 14px;
}
.footer-heading {
display: flex;
align-items: end;
justify-content: space-between;
}
.footer-heading p,
.footer-heading h2 {
margin: 0;
}
.footer-heading p {
color: #8793a3;
font-weight: 650;
font-size: 10px;
line-height: 16px;
letter-spacing: 0.17em;
}
.footer-heading h2 {
margin-top: 2px;
color: #253244;
font-weight: 680;
font-size: 20px;
line-height: 30px;
}
.footer-heading > span {
display: inline-flex;
gap: 7px;
align-items: center;
color: #7d8998;
font-size: 12px;
line-height: 20px;
letter-spacing: 0.2em;
}
.hero h1 {
margin: 0;
color: #202936;
font-weight: 680;
font-size: clamp(48px, 4.2vw, 66px);
line-height: 1.16;
letter-spacing: 0.035em;
}
.hero__description {
margin: 18px auto 0;
color: #637083;
font-size: clamp(16px, 1.35vw, 19px);
line-height: 30px;
letter-spacing: 0.025em;
}
.hero__description span {
display: inline;
}
.portal-grid {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: clamp(30px, 4vw, 64px);
width: min(100%, 1280px);
margin: clamp(48px, 6.5vh, 66px) auto 0;
gap: 14px;
}
.portal {
--accent: var(--platform);
--accent-deep: var(--platform-deep);
display: flex;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 15px;
align-items: center;
min-width: 0;
flex-direction: column;
gap: 24px;
padding: 8px 10px 12px;
background: rgba(255, 255, 255, 0.62);
min-height: 92px;
padding: 17px 18px;
color: inherit;
background: #fff;
border: 1px solid rgba(93, 117, 145, 0.14);
border-radius: 14px;
box-shadow: 0 9px 24px rgba(48, 68, 91, 0.06);
text-decoration: none;
outline: none;
transition:
background-color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
.portal--station {
--accent: var(--station);
--accent-deep: var(--station-deep);
}
.portal--delivery {
--accent: var(--delivery);
--accent-deep: var(--delivery-deep);
}
.portal:hover {
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 46px rgba(37, 58, 84, 0.12);
transform: translateY(-4px);
.portal:hover,
.portal:focus-visible {
border-color: color-mix(in srgb, var(--accent) 38%, transparent);
box-shadow: 0 14px 34px color-mix(in srgb, var(--accent) 13%, transparent);
transform: translateY(-3px);
}
.portal:focus-visible {
background: rgba(255, 255, 255, 0.9);
box-shadow:
0 0 0 4px rgba(18, 101, 232, 0.18),
0 18px 46px rgba(37, 58, 84, 0.12);
}
.portal__heading {
display: grid;
grid-template-columns: auto 1px minmax(0, 1fr);
gap: 20px;
align-items: center;
min-height: 68px;
0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent),
0 14px 34px color-mix(in srgb, var(--accent) 13%, transparent);
}
.portal__number {
color: var(--accent);
font-weight: 660;
font-size: clamp(46px, 4vw, 60px);
font-weight: 700;
font-size: 28px;
line-height: 1;
letter-spacing: -0.035em;
}
.portal__divider {
width: 1px;
height: 54px;
background: color-mix(in srgb, var(--accent) 35%, transparent);
.portal__content {
display: grid;
min-width: 0;
gap: 4px;
}
.portal h2 {
margin: 0;
color: #202936;
font-weight: 650;
font-size: clamp(20px, 1.7vw, 25px);
line-height: 1.35;
.portal__content strong {
color: #253244;
font-size: 17px;
line-height: 24px;
}
.portal p {
margin: 7px 0 0;
color: #6b7787;
font-size: 13px;
line-height: 21px;
.portal__content small {
overflow: hidden;
color: #7b8796;
font-size: 11px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal__action {
display: inline-flex;
width: min(100%, 194px);
min-height: 50px;
gap: 18px;
gap: 8px;
align-items: center;
justify-content: center;
margin-left: 78px;
color: #fff;
background: var(--accent);
border-radius: 9px;
color: var(--accent);
font-weight: 600;
font-size: 15px;
line-height: 22px;
box-shadow: 0 9px 22px color-mix(in srgb, var(--accent) 24%, transparent);
transition: background-color 180ms ease;
font-size: 13px;
white-space: nowrap;
}
.portal:hover .portal__action,
.portal:focus-visible .portal__action {
background: var(--accent-deep);
}
.external-note {
position: absolute;
right: clamp(28px, 4.6vw, 72px);
bottom: 24px;
z-index: 2;
display: inline-flex;
gap: 7px;
align-items: center;
margin: 0;
color: rgba(60, 76, 94, 0.72);
font-size: 12px;
line-height: 20px;
}
@media (max-width: 1020px) {
@media (max-width: 980px) {
.site-shell {
padding-inline: 28px;
grid-template-rows: auto clamp(240px, 34vh, 310px) auto;
}
.portal-grid {
gap: 20px;
.banner__copy {
width: 64%;
}
.portal {
padding-inline: 4px;
}
.portal__heading {
gap: 13px;
}
.portal__number {
font-size: 44px;
}
.portal h2 {
font-size: 19px;
}
.portal p {
font-size: 12px;
grid-template-columns: auto minmax(0, 1fr);
}
.portal__action {
margin-left: 58px;
display: none;
}
}
@media (max-width: 760px) {
@media (max-width: 720px) {
.site-shell {
display: flex;
min-height: 100dvh;
flex-direction: column;
padding: 24px 18px 0;
overflow: hidden;
background: #f7faff;
}
.ecosystem-scene {
position: relative;
inset: auto;
order: 5;
width: calc(100% + 36px);
height: auto;
margin: 34px -18px 0;
object-fit: contain;
}
.brand {
gap: 9px;
font-size: 22px;
gap: 18px;
padding: 20px 16px 24px;
}
.brand img {
width: 36px;
height: 36px;
width: 38px;
height: 38px;
}
.trust-line {
.brand__name {
font-size: 23px;
}
.brand-slogan {
display: flex;
gap: 7px;
}
.brand-slogan strong {
font-size: 13px;
}
.brand-slogan span {
display: none;
}
.hero {
margin-top: 48px;
.banner,
.banner__slide,
.banner__copy {
min-height: 310px;
}
.hero__eyebrow {
margin-bottom: 9px;
font-size: 10px;
letter-spacing: 0.15em;
.banner {
border-radius: 17px;
}
.hero h1 {
font-size: clamp(37px, 11vw, 48px);
line-height: 1.18;
.banner__slide img {
object-position: center center !important;
}
.hero__description {
max-width: 330px;
margin-top: 14px;
font-size: 15px;
line-height: 25px;
.banner__shade {
background: linear-gradient(0deg, rgba(10, 28, 49, 0.82) 0%, rgba(13, 34, 58, 0.32) 72%, rgba(13, 34, 58, 0.08) 100%);
}
.hero__description span {
display: block;
.banner__copy {
width: 100%;
justify-content: end;
padding: 36px 26px 62px;
}
.banner__copy h1 {
font-size: clamp(29px, 9vw, 38px);
}
.banner__copy span {
font-size: 14px;
}
.banner__dots {
bottom: 25px;
left: 26px;
}
.footer-heading > span {
display: none;
}
.portal-grid {
grid-template-columns: 1fr;
gap: 12px;
margin-top: 38px;
gap: 10px;
}
.portal {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
padding: 15px;
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(104, 132, 164, 0.14);
border-radius: 12px;
box-shadow: 0 10px 28px rgba(35, 60, 88, 0.08);
min-height: 82px;
padding: 15px 17px;
}
.portal:hover {
transform: none;
}
.portal__heading {
grid-template-columns: auto 1px minmax(0, 1fr);
gap: 12px;
min-height: auto;
}
.portal__number {
font-size: 32px;
}
.portal__divider {
height: 44px;
}
.portal h2 {
font-size: 17px;
}
.portal p {
margin-top: 4px;
overflow: hidden;
font-size: 11px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal__action {
width: 46px;
min-height: 46px;
margin: 0;
border-radius: 50%;
font-size: 0;
}
.portal__action svg {
width: 20px;
height: 20px;
}
.external-note {
position: static;
order: 4;
justify-content: center;
margin: 18px 0 0;
font-size: 11px;
}
}
@media (max-width: 380px) {
.site-shell {
padding-inline: 14px;
}
.ecosystem-scene {
width: calc(100% + 28px);
margin-inline: -14px;
}
.portal {
padding-inline: 12px;
}
.portal__heading {
gap: 9px;
}
.portal__number {
font-size: 29px;
}
.portal h2 {
font-size: 16px;
}
}
@media (max-height: 790px) and (min-width: 761px) {
.site-shell {
padding-top: 26px;
}
.hero {
margin-top: 38px;
}
.portal-grid {
margin-top: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.portal,
.portal__action {
.banner__track,
.banner__dots button,
.portal {
transition: none;
}
}

5
ui/.gitignore vendored
View File

@@ -1,5 +0,0 @@
node_modules/
dist/
test-results/
playwright-report/
.design-qa/

View File

@@ -1,2 +0,0 @@
fund=false
audit=false

View File

@@ -1,4 +0,0 @@
{
"d1": null,
"r2": null
}

View File

@@ -1,71 +0,0 @@
# Mobile Prototype Agent Guide
## Prototype Instructions
In ChatGPT Work Mode, run `sites-preview start "$PWD"`, open `http://terminal.local:4173/` in the cloud browser, and verify the rendered app and its primary interactions. Keep that preview open and tell the user to inspect it in the cloud browser; do not present the local URL as a user-facing chat link. In Codex Desktop, run the local server yourself, open the preview in the in-app browser, and provide the clickable local URL. Do not deploy to Sites unless the user explicitly asks to share, publish, or deploy. Do not give the user server-start instructions when you can run it.
Before planning or implementing any mobile-app change, read this `AGENTS.md` in full. It is the source of truth for the template's runtime and component guidance.
Before making substantial visual changes, use the Product Design plugin's `get-context` skill when the visual source is unclear or no longer matches the current goal. When the user gives durable prototype-specific design feedback, preferences, or decisions, record them in `AGENTS.md`.
When implementing from a selected generated mock, treat that image as the source of truth for layout, component anatomy, density, spacing, color, typography, visible content, and hierarchy.
## Editing Boundary
- Build app-specific UI in `src/Prototype.tsx` and `src/prototype.css`.
- Treat `src/App.tsx`, `src/main.tsx`, `src/styles.css`, `src/mobile/`, `public/assets/iphone/`, `public/assets/android/`, `public/assets/status/`, `vite.config.ts`, `worker/index.js`, and `scripts/prepare-sites-build.mjs` as protected runtime files. Do not edit, replace, remove, or recreate them unless the user explicitly asks to change the mobile runtime itself. For an explicit runtime change, update the affected lock hashes only after verifying the new runtime behavior.
- Run `npm run check:runtime` before preview or handoff. If it fails, restore the protected runtime instead of weakening or bypassing the check.
- `npm run build` preserves the mobile runtime and prepares the static Cloudflare Worker output required by Sites. Before a Sites handoff, confirm `dist/client/index.html`, `dist/server/index.js`, `dist/.openai/hosting.json`, and source `.openai/hosting.json` exist, then run `npm run test:sites`. Do not replace this project with a Vinext starter.
## Runtime Contract
- Preserve the mobile device runtime unless the user's task explicitly asks otherwise. Do not replace it with a standalone page. Visual fidelity applies to app-owned content inside the device screen, not to template-owned device chrome.
- Keep `App` composed around `PhoneFrame` -> `KeyboardProvider`, with `StatusBar`, app content, `HomeIndicator`, and `KeyboardDock` mounted inside the phone frame. `StatusBar` and the iOS home indicator are overlaid device chrome. When the Android keyboard is closed, the app viewport reserves the protected navigation-bar region instead of painting behind it. When the Android keyboard is open, preserve the current full-screen keyboard layout: its asset includes the IME navigation strip and the separate black navigation bar is hidden. iOS screens continue to paint behind the home-indicator area and own their safe-area content padding.
- Preserve the `iPhone` / `Pixel 10` device picker and both calibrated device presets. The Pixel screen is `427 x 952`; its `32 x 32` camera circle and `public/assets/android/navigation-bar.svg` bottom navigation bar are protected device chrome, not app content.
- Preserve the device picker's intentionally lightweight Codex styling in the top-right corner: its trigger wrapper is borderless and transparent, its trigger sizes to content, and its right-aligned menu uses the compact 3px inset plus the specified hairline and elevation shadow layers. Keep the prototype root and default app screen white.
- Preserve `StatusBar` as live device chrome, including its platform-specific typography, source status-icon assets, and spacing. Pixel 10 uses Roboto, Android indicators, and 32px top, left, and right padding. iPhone uses its iOS indicators, system typography, and calibrated spacing. Do not hardcode screenshot times like `9:41` into the status bar, replace its real-time clock, or move status bar content into app markup unless the user explicitly asks for a fixed/mock device time.
- `PhoneFrame` owns the calibrated device frame, screen portal, device picker, camera cutout, and custom cursor. Keep device assets in `public/assets/iphone/` and `public/assets/android/`; if an asset fails to load, repair the asset path or restore the asset instead of removing the frame, keyboard, or image render.
- Use `MobileScroll` directly for simple single-screen prototypes. Use `FlowStack` for conventional multi-screen flows whose routes can own their fixed header and footer; when using it, define each route as a `FlowScreen`: `{ id, header?, headerHeight?, footer?, footerHeight?, render }`, and use `flow.push(screen)`, `flow.pop()`, and `flow.replace(screen)` from `FlowStack` render callbacks or `useFlow()` instead of introducing another router.
- Use `Carousel` for a carousel, horizontal rail, swipeable cards, image or media strip, horizontally scrollable cards, chip rail, or other horizontal collection.
- For a layered app shell—such as a persistent composer, independently presented sheet, pushed/peek sidebar, or app-wide transition—compose directly in `Prototype.tsx` rather than forcing it through `FlowStack`. Keep app-owned fixed chrome as sibling layers outside `MobileScroll`.
- When using `FlowScreen`, put route-owned fixed headers or footers in `FlowScreen.header` or `FlowScreen.footer`. Set `headerHeight` to the visible app-toolbar height; `FlowStack` adds the device's top safe-area/status-bar inset automatically. Do not include `StatusBar` or its height in the header. Set `footerHeight` to the full app-footer height. `FlowScreen.footer` is an overlay, not reserved layout space; screens using it must add their own bottom content padding such as `padding-bottom: calc(var(--flow-footer-height) + var(--mobile-safe-area-height) + 24px)` so final content can scroll above the footer while still painting behind it.
- Render only scrollable content inside `MobileScroll`; it is for content that should move with scroll and rubber-band overscroll. Keep app-owned headers, nav bars, tabs, composers, and overlays outside it. This keeps scroll physics, safe areas, keyboard insets, scrollbars, and drag click suppression active without letting content paint under fixed chrome.
- Buttons, links, cards, and images inside `MobileScroll` should still allow drag scrolling when the pointer moves beyond tap slop. Use `data-scroll-drag="ignore"` only for rare controls that must own the drag gesture themselves.
- Do not add `var(--keyboard-height)` to ordinary screen/content padding inside `MobileScroll`; the scroll viewport already shrinks above the simulated keyboard. For custom fixed composers, search bars, or toast chrome, use `useKeyboardInsets().bottomInset`. It is relative to the app viewport: Android returns `0` while the closed-keyboard viewport already reserves navigation, then returns the keyboard height while open; iOS continues to clear the home indicator while closed and ride directly above the keyboard while open. Do not pin custom bottom chrome to `bottom: 0` or only `keyboardHeight`.
- Use `KeyboardInput`, `KeyboardTextarea`, or `MobileTextField` for every text-entry control. A raw `input` or `textarea` disconnects focus, keyboard animation, safe-area insets, and attached surfaces.
- Use `BottomSheet` for phone-scoped sheets. Its props are `open`, `onOpenChange`, `title`, optional `description`, optional `snap`, and `children`; it renders through the phone screen portal and dismisses the keyboard before opening.
## Horizontal Carousels
- Use `Carousel` for horizontally draggable cards, images, media, chips, or other horizontal collections. Do not recreate these with `overflow-x`, custom pointer handlers, or a generic div.
- `Carousel` can be nested directly inside `MobileScroll`. It owns horizontal gestures and automatically yields vertical gestures to the parent.
- Never put `data-scroll-drag="ignore"` on or around a `Carousel`; doing so prevents vertical parent scrolling when a gesture begins inside it.
- Do not add CSS scroll snapping to `Carousel`; its runtime owns momentum and release motion.
- Use `data-scroll-drag="ignore"` only when a control must prevent parent scrolling in every drag direction.
See `src/mobile/COMPONENTS.md` for the full component and gesture contract.
## Keyboard Rule
The simulated keyboard is a separate top-layer component. Before presenting anything that behaves like iOS navigation or modal UI, dismiss it first.
Call `keyboard.hide()` before:
- pushing, popping, or replacing FlowStack routes
- opening bottom sheets, action sheets, dialogs, menus, or navigation sheets
- starting transitions where the destination should not inherit text-input focus
`FlowStack` already hides the keyboard for `push`, `pop`, and `replace`. `BottomSheet` already hides it before opening. If you add new modal/sheet/navigation primitives, follow the same rule.
When a composer, search surface, or other keyboard-attached component closes, call `keyboard.hide()` in the same event before changing that component's open state. Position attached surfaces from `useKeyboardInsets()` rather than a separate timer or visibility flag so both dismiss together.
When any text-entry control loses focus, dismiss the simulated keyboard. If the control is custom or does not use the runtime's keyboard-aware fields, handle its blur event and call `keyboard.hide()` explicitly. Keep the keyboard open only when focus is moving directly to another text-entry control that should share the same keyboard session.
## Interaction Rules
- Do not trigger buttons or inputs after a pointer has become a drag. Preserve the drag suppression behavior in `MobileScroll`.
- Do not allow native browser image/file dragging inside the phone frame. Preserve the phone-level `dragstart` suppression and non-draggable image styles so scroll drags that begin on images still scroll the prototype.
- Use `KeyboardInput`, `KeyboardTextarea`, or `MobileTextField` for text entry so the simulated keyboard and safe-area insets stay connected.
- Fixed phone chrome should not animate with pushed screens. Screen content can animate; the status bar, camera cutout, and preview chrome should stay put.
- Keep the keyboard below the home indicator/safe area layer in z-index, and above ordinary app UI while visible.
- Keep the home indicator as the topmost safe-area layer in the z-index above everything else in the prototype.

View File

@@ -1,38 +0,0 @@
# 瓶安芯移动端 UI 原型
本目录是依据 `doc/*产品设计` 原型图制作的 iOS/Android 交互原型,用于确认信息架构、视觉语言和关键流程,不是生产 Flutter 工程,也不连接真实 API、IoT、支付、定位或对象存储。
## 预览入口
启动本地预览:
```bash
npm install
npm run dev -- --host 127.0.0.1 --port 4173
```
入口参数:
| 入口 | 查询参数 | 主要内容 |
| --- | --- | --- |
| 用户端 | `/?app=user` | 瓶阀控制、商城、订单、个人中心 |
| 配送员 | `/?app=service&role=delivery` | 配送任务、到场、气瓶扫描、随瓶安检、签收 |
| 安装维修员 | `/?app=service&role=installer` | 使用条件、备料、安装、测试、安检、用户确认 |
| 安检员 | `/?app=service&role=inspector` | 现场检查、取证、风险等级、隐患闭环 |
右上角设备选择器可切换 iPhone 与 Pixel 10用于检查 iOS/Android 安全区、底部系统区域和键盘行为。
## 原型边界
- 所有数据均为演示数据,按钮只模拟界面状态,不表示后端业务已实现。
- 设备命令先进入“等待回执”,不能用请求成功冒充设备执行成功。
- 工作人员角色切换只改变当前工作上下文;生产实现必须由服务端重新校验岗位、组织、资质、培训、上班状态和服务区域。
- 现场照片、签名、定位和弱网补传在原型中只展示交互,生产实现须遵循 `docs/04-服务端App需求.md` 的 Flutter 开发说明。
- 生产 Flutter 工程按仓库规划分别放入 `apps/user_app``apps/service_app`;在明确进入实现阶段前,不从本原型复制出空壳工程。
## 视觉基线
- 用户端:安全蓝为主色,瓶阀状态是首页第一层级,危险与待确认状态必须同时使用图标和文字。
- 服务端:紫色为统一作业框架,安检关键动作使用安全绿色;配送、安装维修、安检共享组件但不共享任务状态机。
- 中文界面最小正文建议不低于 12sp触控目标不小于 44×44 logical pixels金额、时间、单位和更新时间必须明确。
- 图标使用统一图标库,禁止使用 emoji、字符画或自行绘制的业务图标替代正式资产。

View File

@@ -1,42 +0,0 @@
# Design QA
final result: passed
## Comparison target
- User source: `doc/用户端APP-产品设计/4.jpg`
- Delivery source: `doc/服务端APP-配送端-产品设计/f777dcf4d618a0f9664d126b32f73682.jpg`
- Installation source: `doc/服务端APP-安装端-产品设计/202c904481a03ff644a8ac0c0d1c9ae4.jpg`
- Inspection source: `doc/服务端APP-安全检查端-产品设计/ff7edcd4f78721beb5f0953ab08c23c4.jpg`
- Implementation: local `/ui` mobile prototype at the matching `app` and `role` query states.
- Reference pixels: 1344×2772. Each reference was proportionally normalized onto a 393×852 comparison canvas.
- Implementation viewport: 393×852 CSS pixels at device scale factor 1, captured from the app-owned phone screen.
- Device-frame, status-bar, home-indicator and Android system navigation differences were excluded because they are template-owned runtime chrome.
## Pass 1
- P2 — User valve hero used a dark navy surface while the source uses a light neutral control panel with a dominant red action. Fixed by changing the panel to a light neutral surface, enlarging the valve control and moving the automatic-close status into a light inset strip.
- P1 — Pixel 10 could retain an open simulated keyboard after a device change during review. Fixed by dismissing the runtime keyboard whenever the selected device changes.
- P3 — The original user screen centers the page title in a mini-program header; the implementation uses a native-app leading title and live iOS/Android status bar. Kept as a deliberate native-shell adaptation.
## Pass 2
- User valve screen: hierarchy, status density, red control emphasis, light panel, bottom navigation and safety feedback match the source direction.
- Delivery screen: purple task shell, schedule context, user/address facts, prominent primary action and fixed four-tab navigation match the source.
- Installation screen: purple workbench, time/shift state, urgent work item, task card and role-specific action match the source.
- Inspection screen: green safety identity, task list, time/shift state and inspection action match the source.
- iPhone and Pixel 10: no content overflow, fixed navigation obstruction, open keyboard, browser console error or missing primary action remained.
- No actionable P0, P1 or P2 differences remained.
## Interaction checks
- Valve control: confirmation sheet → waiting for device receipt → confirmed open state.
- User navigation: valve, shop, favorites, orders and profile tabs are reachable.
- Service work: task list → task detail → next required step.
- Role switch: delivery, installation/repair and inspection reload the role-specific workbench.
- Evidence sheet: photo type selection and simulated capture close correctly.
## Remaining P3 notes
- Source screenshots contain mini-program chrome and older iconography; the prototype intentionally uses the protected native device runtime and a single Phosphor icon family.
- Mock content is consolidated from multiple source screens, so exact task numbers and timestamps vary while layout, hierarchy and workflow remain aligned.

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>瓶安芯移动端 UI 原型</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,30 +0,0 @@
{
"scripts/check-mobile-runtime.mjs": "ee85738cc2ef889c0b457d8ccac61e017b8c5d16f393e766ec9fb7d11c9aff7f",
"scripts/prepare-sites-build.mjs": "b6a6adaa4fab3234676116dd1c9cb6611275ab9d92dd26f5bf402393e3744bf6",
"scripts/update-mobile-runtime-lock.mjs": "b4e1aa41133f19ba8bb32685b3ec05798b1ceabaf72c328419d545aa60fe1f0b",
"vite.config.ts": "da36eba05b845c3b0d69d823a515f6f7d9612992126ed2fe32c2809815ec73ba",
"src/App.tsx": "7054b4e14304ab33153d3a2e860aa43d2a4f69e8a69630b4a37a2cbcd4ad2f16",
"src/main.tsx": "01b7049715148ab645637905ccbbc91fc3f87659198a309541c2d1f03bb537d0",
"src/styles.css": "844c67b27cf5e38ba395153145d058c929c6479199d245cd046f3646badd719d",
"src/mobile/BottomSheet.tsx": "f9c376dc78b8b5feb1c8752aa23414993ab66013d11e81b2ebb8342c570032ac",
"src/mobile/Carousel.tsx": "242f4a7488e18c35e9b43651c9d5877288655a6f9f1734e03e56108f17526bf7",
"src/mobile/Device.tsx": "867de9c65220f2ac7202f895d2b10a64c747f211a4b22cd8f81a1a1d66e52fc2",
"src/mobile/FlowStack.tsx": "1e85744c5f1c1138dd5e8b7b27fbb6c05139d5aac93d94c45084a018eddd4ebc",
"src/mobile/Keyboard.tsx": "862587264375499846385ce817ea1b813db8f118140cdb6da163f989a6946cac",
"src/mobile/MobileCursor.tsx": "be2353205e05d823bc23ecae88179d712781ee9ede8fa317057c194c7c23ee7d",
"src/mobile/MobileRuntime.tsx": "1ffe80a9582460005d66f10fc73deaff08b81431785a506887cc0d8320be747f",
"src/mobile/MobileScroll.tsx": "ea3e3691f587ea13753162b9e64c1981263e5b9beddebea21a6a1551dce76430",
"src/mobile/PhoneFrame.tsx": "73015590b468acbb4b58e3f6fe5eef99c5f68f67722caffd8f00d271ba00a91d",
"src/mobile/assets.ts": "d40cb8e5390fd2b54618266e7694e40349006dfe480f65d60559316ca8c9c234",
"src/mobile/components.tsx": "20b02f6c6de4cfd08aa6a49bf9bc261c0ea000864b37e1001afdcd892afe4bc8",
"src/mobile/geometry.ts": "f1382e5d6cc9adfd7ac142304f2f9a5a2330db1d74b3f0848adbaf311676bf16",
"src/mobile/index.ts": "ee69d9341571db5dee84dd2d7452a62eef09085a84a85f198a3edc48047e0412",
"public/assets/iphone/Bezel.png": "ee2514d74d2f75f3541405fa2f20811f159879a66586778cfd8c9c84c44959df",
"public/assets/iphone/Keyboard.png": "292e2d83d69adaeb663d6a840ce8db8d0a5257710b472c20713e03f56b08ea88",
"public/assets/android/Pixel10.png": "a87c1250ba20756f5d020b5351a7ae71d663dcfc64aca3a1cd56452e8a05233e",
"public/assets/android/Keyboard.png": "6085e50be17ca2705b4351283187be2c5b01b58262c70b90d74b3376efb4adfe",
"public/assets/android/navigation-bar.svg": "077a782a3b6c2915fc518dfb4bba825c8c5861b2572f9522a7024902b9cb2850",
"public/assets/status/status-icons.svg": "1a8bd4207cf34532ae514bdcdca693acc9a2b8ebc9d3182dc163c3e9ddcc4823",
"public/assets/status/ios-status-icons.svg": "40e218b274d0973d519b50f42884e6e05ef0f2b6a0e46e102fda54218784466d",
"worker/index.js": "2dd0615a445143933d88d4271f54f5d63ee951421fcd08c5a7617bb09c564389"
}

2295
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
{
"name": "ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"check:runtime": "node scripts/check-mobile-runtime.mjs",
"update:runtime-lock": "node scripts/update-mobile-runtime-lock.mjs",
"test:runtime": "playwright test",
"test:sites": "node --test tests/sites-worker.test.mjs",
"predev": "npm run check:runtime",
"dev": "vite",
"prebuild": "npm run check:runtime",
"build": "tsc && vite build && node scripts/prepare-sites-build.mjs"
},
"dependencies": {
"@fontsource/roboto": "5.2.10",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-dialog": "1.1.19",
"@radix-ui/react-dropdown-menu": "2.1.20",
"@radix-ui/react-icons": "1.3.2",
"@use-gesture/react": "10.3.1",
"motion": "12.42.2",
"react": "19.2.7",
"react-dom": "19.2.7"
},
"devDependencies": {
"@playwright/test": "1.61.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.3",
"typescript": "7.0.2",
"vite": "8.1.3"
}
}

View File

@@ -1,18 +0,0 @@
import { defineConfig } from "@playwright/test";
const testPort = Number(process.env.MOBILE_RUNTIME_TEST_PORT ?? 4174);
export default defineConfig({
testDir: "./tests",
testMatch: "**/*.spec.ts",
timeout: 20_000,
use: {
baseURL: `http://127.0.0.1:${testPort}`,
viewport: { width: 1100, height: 1100 },
},
webServer: {
command: `npm run dev -- --port ${testPort}`,
url: `http://127.0.0.1:${testPort}/tests/runtime-fixture.html`,
reuseExistingServer: process.env.MOBILE_RUNTIME_TEST_PORT == null,
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

View File

@@ -1,6 +0,0 @@
<svg width="428" height="48" viewBox="0 0 428 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="427" height="48" transform="translate(1)" fill="#0F0F0F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M119.492 16.0134C120.159 15.6203 121 16.1009 121 16.8748V31.1254C121 31.8993 120.159 32.3799 119.492 31.9867L107.41 24.8615C106.753 24.4746 106.753 23.5256 107.41 23.1387L119.492 16.0134Z" fill="white"/>
<circle cx="223" cy="24" r="8" fill="white"/>
<rect x="325" y="17" width="14" height="14" rx="2" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 542 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -1,7 +0,0 @@
<svg width="79" height="13" viewBox="0 0 79 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.35" x="51.4333" y="0.5" width="24" height="12" rx="3" stroke="black"/>
<path opacity="0.4" d="M76.9333 4.66667V8.66667C77.7381 8.32789 78.2614 7.5398 78.2614 6.66667C78.2614 5.79353 77.7381 5.00544 76.9333 4.66667Z" fill="black"/>
<rect x="52.9333" y="2" width="21" height="9" rx="1.33333" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.1006 3.06735C37.5671 3.06745 39.9394 3.96498 41.727 5.57443C41.8616 5.69869 42.0767 5.69712 42.2093 5.57092L43.4961 4.3411C43.5632 4.27709 43.6006 4.19038 43.6001 4.10017C43.5995 4.00996 43.561 3.92367 43.4931 3.86041C38.8012 -0.397806 31.3992 -0.397806 26.7073 3.86041C26.6393 3.92363 26.6007 4.00988 26.6001 4.1001C26.5995 4.19031 26.6368 4.27704 26.7039 4.3411L27.9911 5.57092C28.1236 5.69731 28.3389 5.69888 28.4734 5.57443C30.2613 3.96488 32.6338 3.06735 35.1006 3.06735ZM35.0988 7.17524C36.4449 7.17516 37.743 7.67327 38.7408 8.57279C38.8757 8.70045 39.0883 8.69768 39.2199 8.56655L40.4965 7.28238C40.5637 7.21502 40.601 7.12364 40.6001 7.02869C40.5991 6.93373 40.5599 6.84313 40.4914 6.77715C37.4528 3.96328 32.7473 3.96328 29.7088 6.77715C29.6402 6.84313 29.601 6.93378 29.6001 7.02876C29.5992 7.12375 29.6366 7.21512 29.704 7.28238L30.9803 8.56655C31.1119 8.69768 31.3244 8.70045 31.4594 8.57279C32.4565 7.67386 33.7536 7.1758 35.0988 7.17524ZM37.6 9.8944C37.6019 9.99694 37.5652 10.0958 37.4984 10.1676L35.3398 12.557C35.2765 12.6272 35.1902 12.6667 35.1002 12.6667C35.0102 12.6667 34.9239 12.6272 34.8606 12.557L32.7016 10.1676C32.6349 10.0958 32.5982 9.99686 32.6002 9.89431C32.6022 9.79177 32.6426 9.69466 32.712 9.62592C34.0906 8.34702 36.1097 8.34702 37.4884 9.62592C37.5577 9.69472 37.5981 9.79185 37.6 9.8944Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.2 1.79175C19.2 1.17043 18.7224 0.666748 18.1333 0.666748H17.0667C16.4776 0.666748 16 1.17043 16 1.79175V11.5417C16 12.1631 16.4776 12.6667 17.0667 12.6667H18.1333C18.7224 12.6667 19.2 12.1631 19.2 11.5417V1.79175ZM11.7667 3.06675H12.8333C13.4224 3.06675 13.9 3.58252 13.9 4.21875V11.5147C13.9 12.151 13.4224 12.6667 12.8333 12.6667H11.7667C11.1776 12.6667 10.7 12.151 10.7 11.5147V4.21875C10.7 3.58252 11.1776 3.06675 11.7667 3.06675ZM7.43333 5.66675H6.36667C5.77756 5.66675 5.3 6.18908 5.3 6.83341V11.5001C5.3 12.1444 5.77756 12.6667 6.36667 12.6667H7.43333C8.02244 12.6667 8.5 12.1444 8.5 11.5001V6.83341C8.5 6.18908 8.02244 5.66675 7.43333 5.66675ZM2.13333 8.06675H1.06667C0.477563 8.06675 0 8.58162 0 9.21675V11.5167C0 12.1519 0.477563 12.6667 1.06667 12.6667H2.13333C2.72244 12.6667 3.2 12.1519 3.2 11.5167V9.21675C3.2 8.58162 2.72244 8.06675 2.13333 8.06675Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1,5 +0,0 @@
<svg width="50" height="14" viewBox="0 0 50 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M36.3722 5.48652C36.7266 5.10328 36.7295 4.50844 36.3477 4.15255C35.3799 3.25059 34.284 2.51985 33.1002 1.98957C31.6417 1.33625 30.0786 1 28.5 1C26.9214 1 25.3583 1.33626 23.8998 1.98957C22.716 2.51985 21.6201 3.25059 20.6523 4.15255C20.2705 4.50844 20.2734 5.10329 20.6278 5.48653L28.5 14L36.3722 5.48652Z" fill="#1C1B14"/>
<path d="M14 13V2.41421C14 1.52331 12.9229 1.07714 12.2929 1.7071L1.70711 12.2929C1.07714 12.9229 1.52331 14 2.41421 14H13C13.5523 14 14 13.5523 14 13Z" fill="#1C1B14"/>
<path d="M49 2C49.5523 2 50 2.44772 50 3V13C50 13.5523 49.5523 14 49 14H43C42.4477 14 42 13.5523 42 13V3C42 2.44772 42.4477 2 43 2H44C44 1.44772 44.4477 1 45 1H47C47.5523 1 48 1.44772 48 2H49Z" fill="#1C1B14"/>
</svg>

Before

Width:  |  Height:  |  Size: 824 B

View File

@@ -1,33 +0,0 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const lockPath = path.join(root, "mobile-runtime.lock.json");
const lockedFiles = JSON.parse(readFileSync(lockPath, "utf8"));
const failures = [];
for (const [relativePath, expectedHash] of Object.entries(lockedFiles)) {
const filePath = path.join(root, relativePath);
if (!existsSync(filePath)) {
failures.push(`${relativePath} is missing`);
continue;
}
const actualHash = createHash("sha256").update(readFileSync(filePath)).digest("hex");
if (actualHash !== expectedHash) {
failures.push(`${relativePath} was modified`);
}
}
if (failures.length > 0) {
console.error("Mobile runtime integrity check failed:\n");
for (const failure of failures) console.error(`- ${failure}`);
console.error("\nRestore the protected runtime. Put app UI in src/Prototype.tsx and src/prototype.css.");
process.exit(1);
}
console.log(`Mobile runtime integrity check passed (${Object.keys(lockedFiles).length} protected files).`);

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env node
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const dist = path.join(root, "dist");
const index = path.join(dist, "client", "index.html");
const worker = path.join(root, "worker", "index.js");
const hosting = path.join(root, ".openai", "hosting.json");
for (const file of [index, worker, hosting]) {
if (!existsSync(file)) throw new Error("Missing Sites build input: " + file);
}
mkdirSync(path.join(dist, "server"), { recursive: true });
mkdirSync(path.join(dist, ".openai"), { recursive: true });
copyFileSync(worker, path.join(dist, "server", "index.js"));
copyFileSync(hosting, path.join(dist, ".openai", "hosting.json"));
console.log("Prepared Sites build: dist/server/index.js and dist/.openai/hosting.json");

View File

@@ -1,48 +0,0 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const lockPath = path.join(root, "mobile-runtime.lock.json");
const protectedFiles = [
"scripts/check-mobile-runtime.mjs",
"scripts/prepare-sites-build.mjs",
"scripts/update-mobile-runtime-lock.mjs",
"vite.config.ts",
"src/App.tsx",
"src/main.tsx",
"src/styles.css",
"src/mobile/BottomSheet.tsx",
"src/mobile/Carousel.tsx",
"src/mobile/Device.tsx",
"src/mobile/FlowStack.tsx",
"src/mobile/Keyboard.tsx",
"src/mobile/MobileCursor.tsx",
"src/mobile/MobileRuntime.tsx",
"src/mobile/MobileScroll.tsx",
"src/mobile/PhoneFrame.tsx",
"src/mobile/assets.ts",
"src/mobile/components.tsx",
"src/mobile/geometry.ts",
"src/mobile/index.ts",
"public/assets/iphone/Bezel.png",
"public/assets/iphone/Keyboard.png",
"public/assets/android/Pixel10.png",
"public/assets/android/Keyboard.png",
"public/assets/android/navigation-bar.svg",
"public/assets/status/status-icons.svg",
"public/assets/status/ios-status-icons.svg",
"worker/index.js",
];
const hashes = {};
for (const relativePath of protectedFiles) {
const filePath = path.join(root, relativePath);
if (!existsSync(filePath)) throw new Error(`Protected runtime file is missing: ${relativePath}`);
hashes[relativePath] = createHash("sha256").update(readFileSync(filePath)).digest("hex");
}
writeFileSync(lockPath, `${JSON.stringify(hashes, null, 2)}\n`);
console.log(`Updated mobile-runtime.lock.json (${protectedFiles.length} protected files).`);

View File

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

View File

@@ -1,728 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
Bank,
BatteryHigh,
Bell,
Bluetooth,
Buildings,
CalendarCheck,
Camera,
CaretRight,
ChartBar,
CheckCircle,
ClipboardText,
Clock,
CloudSlash,
Fire,
FirstAid,
Gauge,
GearSix,
HardHat,
Heart,
HouseLine,
Lifebuoy,
ListChecks,
MagnifyingGlass,
MapPin,
NavigationArrow,
Package,
PhoneCall,
Plus,
Power,
QrCode,
Receipt,
ShieldCheck,
ShoppingCart,
Signature,
Storefront,
ThermometerSimple,
Truck,
User,
UserCircle,
Wallet,
WarningCircle,
WarningDiamond,
WifiHigh,
Wrench,
XCircle,
} from "@phosphor-icons/react";
import { BottomSheet, MobileScroll, useKeyboard, useMobileDevice } from "./mobile";
type UserTab = "valve" | "shop" | "saved" | "orders" | "profile";
type ServiceTab = "tasks" | "records" | "hazards" | "profile";
type ServiceRole = "delivery" | "installer" | "inspector";
type ValveState = "closed" | "pending" | "open";
const roleMeta: Record<
ServiceRole,
{
label: string;
title: string;
icon: typeof Truck;
accent: string;
taskLabel: string;
taskId: string;
taskType: string;
address: string;
steps: string[];
checklist: string[];
}
> = {
delivery: {
label: "配送员",
title: "配送工作台",
icon: Truck,
accent: "#7c3aed",
taskLabel: "待配送",
taskId: "D001",
taskType: "气瓶配送与空瓶回收",
address: "武侯区红牌楼街道袖海巷 5 栋",
steps: ["订单确认", "导航到场", "扫描气瓶", "随瓶安检", "收款签收"],
checklist: ["核对配送规格与数量", "蓝牙或扫码录入气瓶", "完成随瓶安检照片", "确认线上支付结果"],
},
installer: {
label: "安装维修员",
title: "安装维修工作台",
icon: Wrench,
accent: "#7c3aed",
taskLabel: "待处理",
taskId: "W001",
taskType: "智能瓶阀安装",
address: "武侯区红牌楼街道袖海巷 5 栋",
steps: ["使用条件", "准备材料", "执行安装", "测试安装", "前期安检", "用户确认"],
checklist: ["通风与安装位置合规", "管道与连接件已备齐", "测漏与压力测试通过", "设备回执与用户签字完整"],
},
inspector: {
label: "安检员",
title: "安全检查工作台",
icon: ShieldCheck,
accent: "#16a34a",
taskLabel: "待检查",
taskId: "S004",
taskType: "常规入户安检",
address: "武侯区红牌楼街道袖海巷 4 栋",
steps: ["任务详情", "现场执行", "安全检查", "结果确认"],
checklist: ["燃气具与报警器状态", "智能瓶阀与管道连接", "通风及易燃物环境", "照片水印与用户签字"],
},
};
function IconBadge({
children,
tone = "blue",
}: {
children: React.ReactNode;
tone?: "blue" | "green" | "orange" | "purple" | "red";
}) {
return <span className={`icon-badge icon-badge--${tone}`}>{children}</span>;
}
function StatusPill({
children,
tone = "success",
}: {
children: React.ReactNode;
tone?: "success" | "warning" | "danger" | "info" | "neutral";
}) {
return <span className={`status-pill status-pill--${tone}`}>{children}</span>;
}
function MetricCard({
icon,
label,
value,
tone = "blue",
}: {
icon: React.ReactNode;
label: string;
value: string;
tone?: "blue" | "green" | "orange" | "purple";
}) {
return (
<article className={`metric-card metric-card--${tone}`}>
<div className="metric-card__label">
{icon}
<span>{label}</span>
</div>
<strong>{value}</strong>
</article>
);
}
const userNav: Array<{ id: UserTab; label: string; icon: typeof Wrench }> = [
{ id: "valve", label: "角阀控制", icon: Wrench },
{ id: "shop", label: "商城", icon: ShoppingCart },
{ id: "saved", label: "收藏", icon: Heart },
{ id: "orders", label: "订单", icon: ClipboardText },
{ id: "profile", label: "我的", icon: User },
];
function UserValveScreen({
valveState,
onControl,
onRepair,
}: {
valveState: ValveState;
onControl: () => void;
onRepair: () => void;
}) {
const isPending = valveState === "pending";
const isOpen = valveState === "open";
return (
<main className="screen-page user-page" data-testid="user-valve-screen">
<header className="mobile-header mobile-header--light">
<div>
<span className="eyebrow"></span>
<h1></h1>
</div>
<button className="icon-button" aria-label="通知">
<Bell size={20} weight="bold" />
<span className="notification-dot" />
</button>
</header>
<section className="connection-strip">
<div><WifiHigh size={16} weight="bold" /> 线</div>
<span> 16:28:02</span>
</section>
<section className="metric-grid" aria-label="设备实时状态">
<MetricCard icon={<BatteryHigh size={17} weight="fill" />} label="电池电量" value="85%" tone="green" />
<MetricCard icon={<Gauge size={17} weight="bold" />} label="环境压力" value="101.3 kPa" />
<MetricCard icon={<ThermometerSimple size={17} weight="bold" />} label="温度" value="24℃" tone="orange" />
<MetricCard icon={<Package size={17} weight="fill" />} label="气瓶余量" value="15 kg" tone="purple" />
</section>
<section className={`valve-hero valve-hero--${valveState}`}>
<div className="valve-copy">
<span className="eyebrow"> · </span>
<h2>{isPending ? "等待设备回执" : isOpen ? "瓶阀已开启" : "瓶阀已关闭"}</h2>
<p>{isPending ? "命令已发送,请勿重复操作" : isOpen ? "安全检查正常,可随时关阀" : "当前处于安全关闭状态"}</p>
</div>
<button
className="valve-control"
data-testid="valve-control"
aria-label={isOpen ? "关闭瓶阀" : "开启瓶阀"}
onClick={onControl}
disabled={isPending}
>
{isPending ? <Clock size={38} weight="fill" /> : isOpen ? <Power size={40} weight="bold" /> : <ShieldCheck size={40} weight="fill" />}
<span>{isPending ? "待确认" : isOpen ? "点击关阀" : "点击开阀"}</span>
</button>
<div className="valve-meta">
<span></span>
<strong> 22:00</strong>
</div>
</section>
<section className="section-block">
<div className="section-heading">
<div>
<span className="eyebrow"></span>
<h2></h2>
</div>
<StatusPill tone="success"></StatusPill>
</div>
<div className="safety-list">
<div><CheckCircle size={18} weight="fill" /> </div>
<div><CheckCircle size={18} weight="fill" /> </div>
<div><CheckCircle size={18} weight="fill" /> </div>
</div>
</section>
<section className="emergency-card">
<div>
<span className="eyebrow"></span>
<h2></h2>
<p></p>
</div>
<button className="primary-button primary-button--blue" onClick={onRepair} data-testid="repair-button">
<Wrench size={19} weight="bold" />
</button>
<div className="emergency-links">
<button><Fire size={18} weight="fill" /> 119</button>
<button><FirstAid size={18} weight="fill" /> 120</button>
</div>
</section>
</main>
);
}
function UserShopScreen() {
const [category, setCategory] = useState("全部");
const categories = ["全部", "燃气灶具", "热水器", "软管"];
return (
<main className="screen-page user-page">
<header className="mobile-header mobile-header--light">
<div><span className="eyebrow"></span><h1></h1></div>
<button className="icon-button" aria-label="购物车"><ShoppingCart size={21} weight="bold" /></button>
</header>
<button className="search-surface"><MagnifyingGlass size={18} /> </button>
<div className="chip-row" aria-label="商品分类">
{categories.map((item) => (
<button key={item} className={category === item ? "chip chip--active" : "chip"} onClick={() => setCategory(item)}>{item}</button>
))}
</div>
<section className="product-grid">
{[
{ name: "嵌入式燃气灶", price: "¥1,299", note: "一级能效", icon: Fire },
{ name: "智能恒温热水器", price: "¥899", note: "熄火保护", icon: ThermometerSimple },
{ name: "不锈钢燃气软管", price: "¥89", note: "防鼠咬", icon: Wrench },
{ name: "家用燃气报警器", price: "¥199", note: "联动关阀", icon: WarningCircle },
].map((product) => {
const ProductIcon = product.icon;
return (
<article className="product-card" key={product.name}>
<button className="favorite-button" aria-label={`收藏${product.name}`}><Heart size={17} /></button>
<div className="product-visual"><ProductIcon size={48} weight="duotone" /></div>
<h3>{product.name}</h3>
<p>{product.note} · </p>
<div><strong>{product.price}</strong><button aria-label={`添加${product.name}`}><Plus size={17} weight="bold" /></button></div>
</article>
);
})}
</section>
</main>
);
}
function UserOrdersScreen() {
return (
<main className="screen-page user-page">
<header className="mobile-header mobile-header--light">
<div><span className="eyebrow"></span><h1></h1></div>
<button className="icon-button" aria-label="筛选"><ListChecks size={20} /></button>
</header>
<div className="segmented-control">
<button className="is-active"></button><button></button><button></button><button></button>
</div>
<article className="order-card">
<div className="order-card__top">
<span> 1785159018310</span><StatusPill tone="info"></StatusPill>
</div>
<div className="order-summary">
<IconBadge><Package size={23} weight="fill" /></IconBadge>
<div><h3>50kg×115kg×1</h3><p> 17:3020:00</p></div>
<strong>¥1,000</strong>
</div>
<div className="timeline">
<div className="is-done"><CheckCircle size={18} weight="fill" /><span><strong></strong><small>16:31</small></span></div>
<div className="is-done"><Truck size={18} weight="fill" /><span><strong></strong><small>16:42</small></span></div>
<div className="is-current"><NavigationArrow size={18} weight="fill" /><span><strong></strong><small> 18:20 </small></span></div>
</div>
<button className="secondary-button"></button>
</article>
<article className="order-card order-card--compact">
<div className="order-card__top"><span> 178505012501</span><StatusPill tone="success"></StatusPill></div>
<div className="order-summary">
<IconBadge tone="green"><Wrench size={23} weight="bold" /></IconBadge>
<div><h3></h3><p> · 2026/07/25</p></div>
<strong>¥299</strong>
</div>
</article>
</main>
);
}
function UserProfileScreen() {
return (
<main className="screen-page user-page">
<header className="mobile-header mobile-header--light">
<div><span className="eyebrow"></span><h1></h1></div>
<button className="icon-button" aria-label="设置"><GearSix size={21} /></button>
</header>
<section className="profile-card profile-card--user">
<div className="avatar"><UserCircle size={42} weight="fill" /></div>
<div><h2>6078</h2><p>139****5078 · </p></div>
<CaretRight size={20} />
</section>
<section className="wallet-card">
<div><span></span><strong>¥21,901</strong></div>
<button></button><button className="wallet-card__light"></button>
</section>
<section className="menu-list">
{[
[Receipt, "我的记录", "订单、押金、报修"],
[ChartBar, "用气统计", "月度与年度"],
[PhoneCall, "紧急联系人", "1 位联系人"],
[ShieldCheck, "安全与隐私", "协议与授权"],
[Lifebuoy, "服务与帮助", "客服、知识库"],
].map(([Icon, label, note]) => (
<button key={String(label)}>
<IconBadge><Icon size={19} /></IconBadge>
<span><strong>{String(label)}</strong><small>{String(note)}</small></span>
<CaretRight size={17} />
</button>
))}
</section>
</main>
);
}
function UserApp() {
const [tab, setTab] = useState<UserTab>("valve");
const [valveState, setValveState] = useState<ValveState>("closed");
const [sheet, setSheet] = useState<"valve" | "repair" | null>(null);
const confirmValve = () => {
setSheet(null);
setValveState("pending");
window.setTimeout(() => setValveState((current) => (current === "pending" ? "open" : current)), 1400);
};
const content = tab === "valve"
? <UserValveScreen valveState={valveState} onControl={() => setSheet("valve")} onRepair={() => setSheet("repair")} />
: tab === "shop" || tab === "saved"
? <UserShopScreen />
: tab === "orders"
? <UserOrdersScreen />
: <UserProfileScreen />;
return (
<div className="prototype-shell prototype-shell--user">
<MobileScroll className="app-screen">{content}</MobileScroll>
<nav className="bottom-nav" aria-label="用户端主导航">
{userNav.map((item) => {
const Icon = item.icon;
return <button key={item.id} className={tab === item.id ? "is-active" : ""} onClick={() => setTab(item.id)} aria-label={item.label}><Icon size={21} weight={tab === item.id ? "fill" : "regular"} /><span>{item.label}</span></button>;
})}
</nav>
<BottomSheet
open={sheet === "valve"}
onOpenChange={(open) => setSheet(open ? "valve" : null)}
title={valveState === "open" ? "确认关闭瓶阀" : "确认开启瓶阀"}
description="平台校验通过后会下发命令,执行结果以设备回执为准。"
snap={0.46}
>
<div className="sheet-callout">
<ShieldCheck size={22} weight="fill" />
<div><strong></strong><span>线</span></div>
</div>
<div className="sheet-actions">
<button className="secondary-button" onClick={() => setSheet(null)}></button>
<button className="primary-button primary-button--blue" data-testid="confirm-valve" onClick={confirmValve}></button>
</div>
</BottomSheet>
<BottomSheet
open={sheet === "repair"}
onOpenChange={(open) => setSheet(open ? "repair" : null)}
title="一键报修"
description="请选择最接近的故障类型,现场信息将在提交前再次确认。"
snap={0.62}
>
<div className="repair-grid">
<button><WarningCircle size={24} weight="duotone" /><span></span></button>
<button><Wrench size={24} weight="duotone" /><span></span></button>
<button><Bell size={24} weight="duotone" /><span></span></button>
<button><Plus size={24} weight="duotone" /><span></span></button>
</div>
<div className="location-preview"><MapPin size={19} weight="fill" /><span><strong></strong></span></div>
<button className="primary-button primary-button--blue full-width" onClick={() => setSheet(null)}></button>
</BottomSheet>
</div>
);
}
const serviceNav: Array<{ id: ServiceTab; label: string; icon: typeof ClipboardText }> = [
{ id: "tasks", label: "任务", icon: ClipboardText },
{ id: "records", label: "记录", icon: Clock },
{ id: "hazards", label: "隐患", icon: WarningDiamond },
{ id: "profile", label: "我的", icon: User },
];
function ServiceTaskList({
role,
onOpenTask,
onOpenRole,
}: {
role: ServiceRole;
onOpenTask: () => void;
onOpenRole: () => void;
}) {
const meta = roleMeta[role];
const RoleIcon = meta.icon;
return (
<main className="screen-page service-page" data-testid="service-task-screen">
<header className="service-header">
<div>
<span className="eyebrow"></span>
<h1>{meta.title}</h1>
</div>
<button className="role-switcher" onClick={onOpenRole} data-testid="role-switcher"><RoleIcon size={18} weight="fill" />{meta.label}<CaretRight size={14} /></button>
</header>
<section className="work-status-card">
<div><span></span><strong>16:28:07</strong><small> · </small></div>
<StatusPill tone="success"></StatusPill>
<button></button>
</section>
<section className="training-card">
<IconBadge tone="purple"><ShieldCheck size={20} weight="fill" /></IconBadge>
<div><strong></strong><span> v2026.07 · 5/5 </span></div>
<CheckCircle size={22} weight="fill" />
</section>
<section className="section-block service-section">
<div className="section-heading">
<div><span className="eyebrow"></span><h2>{meta.taskLabel} 3</h2></div>
<button className="text-button">线</button>
</div>
<article className="service-task-card">
<div className="task-card__top">
<IconBadge tone={role === "inspector" ? "green" : "purple"}><RoleIcon size={23} weight="fill" /></IconBadge>
<div><span>{meta.taskId}</span><h3>{meta.taskType}</h3></div>
<StatusPill tone={role === "inspector" ? "info" : "warning"}>{meta.taskLabel}</StatusPill>
</div>
<div className="task-person"><UserCircle size={20} weight="fill" /><span> · 138****1234</span><PhoneCall size={18} weight="fill" /></div>
<p className="task-address"><MapPin size={17} weight="fill" />{meta.address}</p>
<div className="task-facts">
<div><span></span><strong> 17:30</strong></div>
<div><span></span><strong className="danger-text"></strong></div>
<div><span></span><strong>¥180</strong></div>
</div>
<button className={`primary-button ${role === "inspector" ? "primary-button--green" : "primary-button--purple"} full-width`} onClick={onOpenTask} data-testid="open-service-task">
{role === "delivery" ? <NavigationArrow size={19} weight="fill" /> : role === "installer" ? <Wrench size={19} weight="bold" /> : <ShieldCheck size={19} weight="fill" />}
</button>
</article>
<article className="service-task-card service-task-card--muted">
<div className="task-card__top">
<IconBadge tone="orange"><Clock size={23} weight="fill" /></IconBadge>
<div><span>{role === "delivery" ? "R002" : role === "installer" ? "W002" : "S002"}</span><h3>{role === "delivery" ? "空瓶回收" : role === "installer" ? "阀门维修" : "专项安全检查"}</h3></div>
<StatusPill tone="neutral">14:00</StatusPill>
</div>
<p className="task-address"><MapPin size={17} weight="fill" /> 12 </p>
</article>
</section>
<section className="offline-banner"><CloudSlash size={19} weight="fill" /><span><strong></strong></span></section>
</main>
);
}
function ServiceTaskDetail({
role,
step,
onBack,
onNext,
onEvidence,
}: {
role: ServiceRole;
step: number;
onBack: () => void;
onNext: () => void;
onEvidence: () => void;
}) {
const meta = roleMeta[role];
const done = step >= meta.steps.length;
return (
<main className="screen-page service-page service-detail" data-testid="service-task-detail">
<header className={`detail-header ${role === "inspector" ? "detail-header--green" : ""}`}>
<button className="icon-button icon-button--inverse" onClick={onBack} aria-label="返回任务列表"><ArrowLeft size={21} weight="bold" /></button>
<div><span></span><strong>{meta.taskId}</strong></div>
<StatusPill tone={done ? "success" : "info"}>{done ? "待确认" : "执行中"}</StatusPill>
</header>
<div className="stepper" aria-label="任务进度">
{meta.steps.map((label, index) => (
<div className={index < step ? "is-done" : index === step ? "is-active" : ""} key={label}>
<span>{index < step ? <CheckCircle size={18} weight="fill" /> : index + 1}</span>
<small>{label}</small>
</div>
))}
</div>
{done ? (
<section className="completion-card">
<CheckCircle size={58} weight="fill" />
<span className="eyebrow"></span>
<h2></h2>
<p></p>
<button className={`primary-button ${role === "inspector" ? "primary-button--green" : "primary-button--purple"} full-width`} onClick={onBack}></button>
</section>
) : (
<>
<section className="task-overview">
<div className="section-heading"><div><span className="eyebrow"></span><h2>{meta.steps[step]}</h2></div><StatusPill tone="warning"></StatusPill></div>
<div className="overview-grid">
<div><span></span><strong>{meta.taskType}</strong></div>
<div><span></span><strong></strong></div>
<div className="span-two"><span></span><strong>{meta.address}</strong></div>
</div>
</section>
<section className="section-block checklist-card">
<div className="section-heading"><div><span className="eyebrow"></span><h2></h2></div><ListChecks size={24} weight="fill" /></div>
{meta.checklist.map((item, index) => (
<label key={item} className="check-row">
<input type="checkbox" defaultChecked={index < Math.min(step, 2)} />
<span>{item}</span>
</label>
))}
</section>
<section className="evidence-card">
<div><IconBadge><Camera size={21} weight="fill" /></IconBadge><span><strong></strong><small></small></span><StatusPill tone="neutral">0/6</StatusPill></div>
<button className="secondary-button full-width" onClick={onEvidence} data-testid="evidence-button"><Camera size={19} weight="fill" /></button>
</section>
<section className="signature-card">
<div><IconBadge tone="purple"><Signature size={21} weight="fill" /></IconBadge><span><strong></strong><small></small></span></div>
<button className="text-button"></button>
</section>
{role === "inspector" && step >= 2 ? (
<section className="risk-card">
<div className="section-heading"><div><span className="eyebrow"></span><h2></h2></div><WarningDiamond size={23} weight="fill" /></div>
<div className="risk-options"><button> · </button><button> · </button><button className="is-selected"> · </button></div>
</section>
) : null}
<button className={`primary-button detail-next ${role === "inspector" ? "primary-button--green" : "primary-button--purple"}`} onClick={onNext} data-testid="next-service-step">
{step === meta.steps.length - 1 ? "提交并等待确认" : "保存并进入下一步"}<CaretRight size={18} weight="bold" />
</button>
</>
)}
</main>
);
}
function ServiceRecords({ role }: { role: ServiceRole }) {
const meta = roleMeta[role];
return (
<main className="screen-page service-page">
<header className="service-header"><div><span className="eyebrow"></span><h1></h1></div><button className="icon-button icon-button--inverse" aria-label="筛选"><ListChecks size={20} /></button></header>
<section className="record-stats"><MetricCard icon={<CheckCircle size={18} />} label="本月完成" value="18 单" tone="green" /><MetricCard icon={<ChartBar size={18} />} label="及时完成率" value="96%" tone="purple" /></section>
<section className="menu-list service-menu">
{[1, 2, 3].map((item) => (
<button key={item}>
<IconBadge tone={role === "inspector" ? "green" : "purple"}><meta.icon size={20} /></IconBadge>
<span><strong>{meta.taskType}</strong><small>{`2026/07/${28 - item} · ${item === 1 ? "已完成" : "待结算"}`}</small></span>
<CaretRight size={17} />
</button>
))}
</section>
</main>
);
}
function ServiceHazards() {
return (
<main className="screen-page service-page">
<header className="service-header"><div><span className="eyebrow"></span><h1></h1></div><button className="icon-button icon-button--inverse" aria-label="筛选"><ListChecks size={20} /></button></header>
<section className="hazard-summary"><WarningDiamond size={30} weight="fill" /><div><strong>2 </strong><span> 16:20</span></div></section>
{[
{ id: "H001", title: "管道老化", status: "处理中", tone: "warning" as const },
{ id: "H002", title: "阀门泄漏", status: "待复检", tone: "danger" as const },
].map((item) => (
<article className="hazard-card" key={item.id}>
<div><span>{item.id}</span><StatusPill tone={item.tone}>{item.status}</StatusPill></div>
<h3>{item.title}</h3><p><MapPin size={16} weight="fill" /></p>
<button className="secondary-button"></button>
</article>
))}
</main>
);
}
function ServiceProfile({ role, onOpenRole }: { role: ServiceRole; onOpenRole: () => void }) {
const meta = roleMeta[role];
return (
<main className="screen-page service-page">
<header className="service-header"><div><span className="eyebrow"></span><h1></h1></div><button className="icon-button icon-button--inverse" aria-label="设置"><GearSix size={20} /></button></header>
<section className="profile-card profile-card--service">
<div className="avatar avatar--service"><HardHat size={38} weight="fill" /></div>
<div><h2></h2><p> 1023 · </p></div><QrCode size={26} weight="duotone" />
</section>
<button className="current-role-card" onClick={onOpenRole}>
<IconBadge tone="purple"><meta.icon size={21} weight="fill" /></IconBadge>
<span><small></small><strong>{meta.label}</strong></span><CaretRight size={18} />
</button>
<section className="income-card">
<div><span></span><strong>¥588.00</strong></div><button></button>
<div className="income-stats"><span><strong>¥382</strong></span><span><strong>¥588</strong></span><span><strong>¥180</strong></span></div>
</section>
<section className="menu-list service-menu">
{[[Wallet, "账户管理"], [Bank, "银行卡"], [Buildings, "资质与单位"], [GearSix, "设置"], [Lifebuoy, "帮助中心"]].map(([Icon, label]) => (
<button key={String(label)}><IconBadge tone="purple"><Icon size={19} /></IconBadge><span><strong>{String(label)}</strong></span><CaretRight size={17} /></button>
))}
</section>
</main>
);
}
function ServiceApp({ initialRole }: { initialRole: ServiceRole }) {
const [role, setRole] = useState<ServiceRole>(initialRole);
const [tab, setTab] = useState<ServiceTab>("tasks");
const [detail, setDetail] = useState(false);
const [step, setStep] = useState(0);
const [sheet, setSheet] = useState<"role" | "evidence" | null>(null);
const chooseRole = (nextRole: ServiceRole) => {
setRole(nextRole);
setDetail(false);
setStep(0);
setTab("tasks");
setSheet(null);
};
let content: React.ReactNode;
if (detail) {
content = <ServiceTaskDetail role={role} step={step} onBack={() => setDetail(false)} onNext={() => setStep((value) => value + 1)} onEvidence={() => setSheet("evidence")} />;
} else if (tab === "tasks") {
content = <ServiceTaskList role={role} onOpenTask={() => { setStep(0); setDetail(true); }} onOpenRole={() => setSheet("role")} />;
} else if (tab === "records") {
content = <ServiceRecords role={role} />;
} else if (tab === "hazards") {
content = <ServiceHazards />;
} else {
content = <ServiceProfile role={role} onOpenRole={() => setSheet("role")} />;
}
return (
<div className={`prototype-shell prototype-shell--service prototype-shell--${role}`}>
<MobileScroll className="app-screen">{content}</MobileScroll>
<nav className="bottom-nav bottom-nav--service" aria-label="服务端主导航">
{serviceNav.map((item) => {
const Icon = item.icon;
return <button key={item.id} className={!detail && tab === item.id ? "is-active" : ""} onClick={() => { setDetail(false); setTab(item.id); }} aria-label={item.label}><Icon size={21} weight={!detail && tab === item.id ? "fill" : "regular"} /><span>{item.label}</span></button>;
})}
</nav>
<BottomSheet open={sheet === "role"} onOpenChange={(open) => setSheet(open ? "role" : null)} title="切换服务角色" description="角色切换会重新加载组织、服务区域、资质和待办。" snap={0.52}>
<div className="role-options">
{(Object.keys(roleMeta) as ServiceRole[]).map((item) => {
const itemMeta = roleMeta[item];
const RoleIcon = itemMeta.icon;
return <button key={item} className={role === item ? "is-selected" : ""} onClick={() => chooseRole(item)}><IconBadge tone={item === "inspector" ? "green" : "purple"}><RoleIcon size={22} weight="fill" /></IconBadge><span><strong>{itemMeta.label}</strong><small>{itemMeta.taskType}</small></span>{role === item ? <CheckCircle size={22} weight="fill" /> : <CaretRight size={18} />}</button>;
})}
</div>
</BottomSheet>
<BottomSheet open={sheet === "evidence"} onOpenChange={(open) => setSheet(open ? "evidence" : null)} title="现场取证" description="采集的原始时间、定位、任务号和完整性标记将一并保存。" snap={0.58}>
<div className="capture-preview"><Camera size={44} weight="duotone" /><strong></strong><span> 16 </span></div>
<div className="evidence-types"><button className="is-selected"></button><button></button><button></button><button></button></div>
<div className="sheet-actions"><button className="secondary-button" onClick={() => setSheet(null)}></button><button className="primary-button primary-button--purple" onClick={() => setSheet(null)}><Camera size={18} weight="fill" /></button></div>
</BottomSheet>
</div>
);
}
export default function Prototype() {
const params = useMemo(() => new URLSearchParams(window.location.search), []);
const keyboard = useKeyboard();
const { device } = useMobileDevice();
const app = params.get("app") === "service" ? "service" : "user";
const roleParam = params.get("role");
const role: ServiceRole = roleParam === "installer" || roleParam === "inspector" ? roleParam : "delivery";
useEffect(() => {
keyboard.hide();
}, [device.id]);
return app === "service" ? <ServiceApp initialRole={role} /> : <UserApp />;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
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";

File diff suppressed because it is too large Load Diff

View File

@@ -1,740 +0,0 @@
:root {
color: #151515;
background: #ffffff;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
}
.mobile-carousel {
position: relative;
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: none;
user-select: none;
}
.mobile-carousel::-webkit-scrollbar { display: none; }
.mobile-carousel { overflow-x: auto; overflow-y: hidden; touch-action: pan-y; }
.mobile-carousel[data-dragging="true"] { cursor: none; }
.mobile-carousel-content { display: flex; width: max-content; min-width: 100%; transform: translateX(var(--mobile-carousel-overdrag)); will-change: transform; }
.mobile-carousel-scrollbar { position: absolute; z-index: 4; inset: 0; opacity: 0; pointer-events: none; transition: opacity 150ms ease; }
.mobile-carousel-scrollbar[data-visible="true"] { opacity: 1; }
.mobile-carousel-scrollbar-thumb { position: absolute; right: 4px; bottom: 4px; height: 3px; border-radius: 999px; background: rgba(0, 0, 0, 0.35); }
body {
min-width: 320px;
overflow: hidden;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.phone-stage {
position: relative;
display: grid;
width: 100%;
height: 100%;
place-items: center;
padding: 24px;
}
.device-menu-bar {
position: absolute;
z-index: 100;
top: 18px;
right: 18px;
padding: 3px;
border-radius: 12px;
}
.device-picker-trigger {
display: flex;
gap: 10px;
align-items: center;
height: 34px;
padding: 0 10px 0 12px;
border: 0;
border-radius: 9px;
background: transparent;
color: #151515;
font: 600 14px/1 ui-sans-serif, system-ui, sans-serif;
}
.device-picker-trigger:hover,
.device-picker-trigger[data-state="open"] {
background: rgba(0, 0, 0, 0.055);
}
.device-picker-trigger:focus-visible {
outline: 2px solid rgba(0, 0, 0, 0.72);
outline-offset: 2px;
}
.device-picker-menu {
z-index: 200;
min-width: 168px;
padding: 3px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 0 1px rgba(0, 0, 0, 0.06),
0 0 0 1px rgba(0, 0, 0, 0.08),
0 4px 4px rgba(0, 0, 0, 0.05);
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
animation: device-menu-in 120ms ease-out;
}
.device-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 34px;
padding: 0 9px 0 10px;
border-radius: 8px;
color: #181818;
font: 500 14px/1 ui-sans-serif, system-ui, sans-serif;
outline: none;
user-select: none;
}
.device-picker-item[data-highlighted] {
background: #f0f0f0;
}
.device-picker-item[data-state="checked"] {
font-weight: 600;
}
.device-picker-check {
display: grid;
place-items: center;
color: #181818;
}
@keyframes device-menu-in {
from {
opacity: 0;
transform: scale(0.98) translateY(-2px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.phone-scale-box {
position: relative;
}
.phone-device {
position: absolute;
left: 0;
top: 0;
transform-origin: top left;
-webkit-user-select: none;
user-select: none;
}
.phone-bezel {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: contain;
z-index: 2;
-webkit-user-drag: none;
pointer-events: none;
}
.device-screen {
position: absolute;
overflow: hidden;
border-radius: 42px;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
cursor: none;
z-index: 1;
-webkit-user-select: none;
user-select: none;
}
.device-camera {
position: absolute;
z-index: 75;
display: block;
border-radius: 50%;
background: #050505;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.device-screen,
.device-screen * {
cursor: none !important;
}
.device-screen img,
.keyboard-asset {
-webkit-user-drag: none;
user-select: none;
}
.device-screen input,
.device-screen textarea,
.device-screen select,
.device-screen option,
.device-screen [contenteditable="true"] {
-webkit-user-select: auto;
user-select: auto;
}
.device-screen:active,
.device-screen:active * {
cursor: none !important;
}
.device-screen input,
.device-screen textarea,
.device-screen button,
.device-screen a,
.device-screen [role="button"] {
cursor: none !important;
}
.device-screen[data-cursor-debug="true"],
.device-screen[data-cursor-debug="true"] * {
cursor: default !important;
}
.device-screen *:focus,
.device-screen *:focus-visible {
outline: 0 !important;
box-shadow: none !important;
}
.flow-stack,
.flow-scenes,
.flow-screen {
position: absolute;
inset: 0;
}
.mobile-app-viewport {
position: absolute;
inset: 0;
}
.mobile-app-viewport[data-platform="android"][data-keyboard-visible="false"] {
bottom: var(--device-safe-area-bottom, 48px);
}
.flow-stack {
--flow-header-height: 0px;
--flow-header-content-height: 0px;
--flow-header-safe-area: 0px;
--flow-footer-height: 0px;
--keyboard-height: 0px;
overflow: hidden;
background: transparent;
touch-action: pan-y;
}
.flow-fixed-header {
position: absolute;
z-index: 12;
top: 0;
right: 0;
left: 0;
height: var(--flow-header-height);
padding-top: var(--flow-header-safe-area);
box-sizing: border-box;
background: transparent;
border-bottom: 0;
backdrop-filter: none;
}
.flow-fixed-footer {
position: absolute;
z-index: 12;
right: 0;
bottom: var(--keyboard-height);
left: 0;
height: var(--flow-footer-height);
background: transparent;
transition: bottom 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.flow-scenes {
top: var(--flow-header-height);
bottom: 0;
overflow: hidden;
}
.flow-screen {
overflow: hidden;
background: transparent;
will-change: transform;
}
.mobile-page {
position: absolute;
inset: 0;
--mobile-safe-area-height: var(--device-safe-area-bottom, 34px);
--keyboard-height: 0px;
}
.mobile-page[data-keyboard-visible="true"] {
--mobile-safe-area-height: 0px;
}
.mobile-app-viewport[data-platform="android"] .mobile-page {
--mobile-safe-area-height: 0px;
}
.mobile-scroll {
position: absolute;
top: 0;
right: 0;
bottom: var(--keyboard-height);
left: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
scrollbar-width: none;
touch-action: none;
user-select: none;
transition: bottom 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mobile-scroll::-webkit-scrollbar {
display: none;
}
.mobile-scroll[data-dragging="true"] {
cursor: none;
}
.mobile-cursor {
position: absolute;
z-index: 80;
top: 0;
left: 0;
width: 48px;
height: 48px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
border-radius: 999px;
background:
radial-gradient(circle at 50% 44%, rgba(255, 255, 255, 0.7) 0 34%, rgba(255, 255, 255, 0.54) 58%, rgba(238, 238, 238, 0.48) 100%);
box-shadow:
inset 0 1px 2px rgba(255, 255, 255, 0.6),
inset 0 -5px 10px rgba(0, 0, 0, 0.05),
inset 0 0 0 1px rgba(0, 0, 0, 0.025);
opacity: 0;
pointer-events: none;
transition:
opacity 90ms ease,
background 80ms ease,
box-shadow 80ms ease;
will-change: transform;
}
.mobile-cursor::before {
position: absolute;
top: 3px;
left: 50%;
width: 32px;
height: 17px;
transform: translateX(-50%);
border-radius: 999px;
background: linear-gradient(rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0));
content: "";
pointer-events: none;
}
.mobile-cursor::after {
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow:
inset 1px 0 3px rgba(255, 255, 255, 0.28),
inset -1px 0 3px rgba(0, 0, 0, 0.02);
content: "";
pointer-events: none;
}
.mobile-cursor-hotspot {
position: absolute;
left: 50%;
top: 50%;
width: 9px;
height: 9px;
transform: translate(-50%, -50%);
border: 1px solid rgba(0, 122, 255, 0.95);
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.75),
0 0 0 3px rgba(0, 122, 255, 0.16);
pointer-events: none;
}
.mobile-cursor-hotspot::before,
.mobile-cursor-hotspot::after {
position: absolute;
left: 50%;
top: 50%;
background: rgba(0, 122, 255, 0.95);
content: "";
transform: translate(-50%, -50%);
}
.mobile-cursor-hotspot::before {
width: 17px;
height: 1px;
}
.mobile-cursor-hotspot::after {
width: 1px;
height: 17px;
}
.mobile-cursor[data-visible="true"] {
opacity: 1;
}
.mobile-cursor[data-active="true"] {
background:
radial-gradient(circle at 50% 55%, rgba(232, 232, 232, 0.58) 0 30%, rgba(248, 248, 248, 0.5) 62%, rgba(255, 255, 255, 0.68) 100%);
box-shadow:
inset 0 3px 7px rgba(0, 0, 0, 0.08),
inset 0 -4px 8px rgba(255, 255, 255, 0.34),
inset 0 0 0 1px rgba(0, 0, 0, 0.03);
}
.mobile-cursor[data-active="true"]::before {
background: linear-gradient(rgba(0, 0, 0, 0.055), rgba(0, 0, 0, 0));
}
.mobile-cursor[data-active="true"]::after {
box-shadow:
inset 0 5px 9px rgba(0, 0, 0, 0.045),
inset 0 -3px 7px rgba(255, 255, 255, 0.28);
}
.mobile-scroll-content {
display: grid;
gap: 0;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
padding: 0;
will-change: transform;
}
.app-screen {
background: #ffffff;
}
.mobile-scrollbar {
position: absolute;
z-index: 9;
top: 4px;
right: 5px;
bottom: calc(var(--mobile-safe-area-height) + var(--keyboard-height) + 4px);
width: 4px;
opacity: 0;
pointer-events: none;
transition:
bottom 260ms cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 180ms ease;
}
.mobile-page[data-keyboard-dragging="true"] .mobile-scroll,
.mobile-page[data-keyboard-dragging="true"] .mobile-scrollbar,
.flow-stack[data-keyboard-dragging="true"] .flow-fixed-footer {
transition: none;
}
.mobile-scrollbar[data-visible="true"] {
opacity: 1;
}
.mobile-scrollbar-thumb {
width: 4px;
min-height: 36px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
transition: background 120ms ease;
}
.status-bar {
position: absolute;
z-index: 72;
top: 0;
right: 0;
left: 0;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
height: 54px;
padding: 10px 34px 0 36px;
background: transparent;
color: #050505;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
pointer-events: none;
}
.device-screen[data-device="pixel-10"] .status-bar {
align-items: start;
height: auto;
padding: 32px 32px 0;
font-family: "Roboto", Arial, sans-serif;
}
.status-time {
justify-self: start;
min-width: 58px;
text-align: center;
font-size: 15px;
font-weight: 600;
letter-spacing: 0;
line-height: 1;
}
.status-indicators {
display: flex;
align-items: center;
justify-self: end;
}
.status-indicator-svg {
display: block;
}
.status-indicator-svg[data-platform="ios"] {
width: 79px;
height: 13px;
}
.status-indicator-svg[data-platform="android"] {
width: 50px;
height: 14px;
}
.device-screen[data-device="pixel-10"] .status-time {
font-weight: 500;
transform: translateY(2px);
}
.home-indicator-svg {
position: absolute;
z-index: 70;
right: 0;
bottom: 0;
left: 0;
display: block;
width: 100%;
height: var(--device-safe-area-bottom, 34px);
pointer-events: none;
}
.android-navigation-bar {
position: absolute;
z-index: 70;
bottom: -1px;
left: -1px;
display: block;
width: 428px;
height: var(--device-safe-area-bottom, 48px);
pointer-events: none;
-webkit-user-drag: none;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 26px;
line-height: 1.08;
letter-spacing: 0;
}
h2 {
font-size: 15px;
}
p {
color: #555555;
font-size: 14px;
line-height: 1.45;
}
.field-label {
color: #555555;
font-size: 13px;
font-weight: 800;
}
.mobile-field {
display: grid;
gap: 8px;
}
.mobile-field input {
width: 100%;
min-height: 44px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
background: #fff;
color: #151515;
padding: 0 12px;
outline: 0;
}
.mobile-field input:focus {
border-color: rgba(0, 0, 0, 0.16);
box-shadow: none;
}
.device-screen .mobile-field input:focus,
.device-screen .mobile-field input:focus-visible {
border-color: rgba(0, 0, 0, 0.12);
outline: 0 !important;
box-shadow: none !important;
}
.sheet-overlay {
position: absolute;
z-index: 20;
inset: 0;
background: rgba(0, 0, 0, 0.34);
}
.bottom-sheet {
position: absolute;
z-index: 21;
right: 0;
left: 0;
display: flex;
flex-direction: column;
min-height: 260px;
overflow: hidden;
border-radius: 22px 22px 0 0;
background: #fff;
box-shadow: 0 -16px 48px rgba(0, 0, 0, 0.2);
touch-action: none;
}
.sheet-handle-zone {
display: grid;
height: 30px;
place-items: center;
touch-action: none;
}
.sheet-handle {
width: 42px;
height: 5px;
border-radius: 999px;
background: #c9c9c4;
}
.sheet-header {
display: grid;
gap: 4px;
padding: 0 18px 14px;
}
.sheet-title {
margin: 0;
font-size: 18px;
font-weight: 900;
}
.sheet-description {
margin: 0;
color: #737373;
font-size: 13px;
line-height: 1.35;
}
.sheet-content {
overflow: auto;
padding: 0 18px 24px;
}
.keyboard-dock {
position: absolute;
z-index: 30;
right: 0;
bottom: -1px;
left: 0;
height: 339px;
padding: 0;
overflow: hidden;
border-radius: 32px 32px 0 0;
background: #d8d9de;
box-shadow: none;
pointer-events: none;
will-change: transform;
}
.keyboard-dock[data-platform="android"] {
border-radius: 0;
background: #f3f2f6;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.08);
}
.keyboard-dock[data-visible="true"] {
pointer-events: auto;
}
.keyboard-asset {
position: relative;
z-index: 1;
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
object-position: center bottom;
-webkit-user-drag: none;
pointer-events: none;
}
.keyboard-dock[data-platform="android"] .keyboard-asset {
border-radius: 0;
object-fit: fill;
}

View File

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

View File

@@ -1,242 +0,0 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
async function drag(page: Page, locator: Locator, deltaX: number, deltaY: number, steps = 8) {
const box = await locator.boundingBox();
if (!box) throw new Error("Drag target has no bounding box");
const startX = box.x + box.width / 2;
const startY = box.y + box.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
for (let step = 1; step <= steps; step += 1) {
await page.mouse.move(
startX + (deltaX * step) / steps,
startY + (deltaY * step) / steps,
);
await page.waitForTimeout(8);
}
await page.mouse.up();
}
test.beforeEach(async ({ page }) => {
await page.goto("/tests/runtime-fixture.html");
});
test("horizontal intent stays in Carousel and cannot create parent momentum", async ({ page }) => {
const carousel = page.locator(".fixture-carousel");
const card = page.locator(".carousel-card").nth(1);
const parent = page.getByTestId("mobile-scroll");
await expect(carousel).not.toHaveAttribute("data-scroll-drag", "ignore");
await drag(page, card, -130, 14, 5);
const afterRelease = await carousel.evaluate((element) => element.scrollLeft);
expect(afterRelease).toBeGreaterThan(40);
expect(await parent.evaluate((element) => element.scrollTop)).toBe(0);
await page.waitForTimeout(250);
expect(await parent.evaluate((element) => element.scrollTop)).toBe(0);
expect(await page.getByTestId("tap-count").textContent()).toBe("0");
});
test("vertical intent over a carousel is handed to MobileScroll in both directions", async ({ page }) => {
const card = page.locator(".carousel-card").nth(1);
const carousel = page.locator(".fixture-carousel");
const parent = page.getByTestId("mobile-scroll");
await drag(page, card, 4, -150);
expect(await parent.evaluate((element) => element.scrollTop)).toBeGreaterThan(60);
expect(await carousel.evaluate((element) => element.scrollLeft)).toBe(0);
await parent.evaluate((element) => {
element.scrollTop = 80;
});
await drag(page, card, -3, 110);
expect(await parent.evaluate((element) => element.scrollTop)).toBeLessThan(80);
});
test("tap activates a card but a completed drag does not", async ({ page }) => {
const firstCard = page.locator(".carousel-card").first();
await firstCard.click();
await expect(page.getByTestId("tap-count")).toHaveText("1");
await drag(page, firstCard, -100, 6);
await expect(page.getByTestId("tap-count")).toHaveText("1");
});
test("Carousel preserves momentum and edge rubber-banding", async ({ page }) => {
const carousel = page.locator(".fixture-carousel");
const card = page.locator(".carousel-card").nth(1);
await drag(page, card, -100, 5, 3);
const releasedOffset = await carousel.evaluate((element) => element.scrollLeft);
await page.waitForTimeout(120);
expect(await carousel.evaluate((element) => element.scrollLeft)).toBeGreaterThan(releasedOffset);
await carousel.evaluate((element) => {
element.scrollLeft = 0;
});
const box = await card.boundingBox();
if (!box) throw new Error("Card has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 90, box.y + box.height / 2, { steps: 4 });
expect(Number(await carousel.getAttribute("data-overscroll"))).toBeGreaterThan(0);
await page.mouse.up();
await page.waitForTimeout(900);
expect(Math.abs(Number(await carousel.getAttribute("data-overscroll")))).toBeLessThan(1);
});
test("BottomSheet remains mounted while its default exit animation plays", async ({ page }) => {
await page.locator(".sheet-trigger").click();
await expect(page.getByTestId("bottom-sheet")).toBeVisible();
await page.getByTestId("sheet-overlay").click({ position: { x: 8, y: 8 } });
await expect(page.getByTestId("bottom-sheet")).toHaveCount(1);
await page.waitForTimeout(500);
await expect(page.getByTestId("bottom-sheet")).toHaveCount(0);
});
test("keyboard and its attached footer dismiss on the same transition", async ({ page }) => {
await page.goto("/tests/runtime-fixture.html?fixture=keyboard");
const input = page.getByLabel("Message");
const footer = page.getByTestId("flow-fixed-footer");
const keyboard = page.getByTestId("keyboard-dock");
await input.click();
await expect(keyboard).toHaveAttribute("data-visible", "true");
await drag(page, footer, 0, 120, 5);
await expect(keyboard).toHaveAttribute("data-visible", "false");
await page.waitForTimeout(100);
const progress = await page.evaluate(() => {
const footerElement = document.querySelector<HTMLElement>('[data-testid="flow-fixed-footer"]')!;
const keyboardElement = document.querySelector<HTMLElement>('[data-testid="keyboard-dock"]')!;
const fullHeight = Number.parseFloat(keyboardElement.style.height);
const footerRemaining = Number.parseFloat(getComputedStyle(footerElement).bottom);
const matrix = new DOMMatrixReadOnly(getComputedStyle(keyboardElement).transform);
return {
footer: footerRemaining / fullHeight,
keyboard: 1 - matrix.m42 / fullHeight,
};
});
expect(Math.abs(progress.footer - progress.keyboard)).toBeLessThan(0.18);
await page.waitForTimeout(300);
expect(await footer.evaluate((element) => getComputedStyle(element).bottom)).toBe("34px");
});
test("switching to Pixel keeps the composer above Android navigation", async ({ page }) => {
await page.goto("/tests/runtime-fixture.html?fixture=keyboard");
const input = page.getByLabel("Message");
await input.evaluate((element: HTMLInputElement) => {
element.value = "Draft message";
});
await page.getByTestId("device-picker").click();
await page.getByTestId("device-option-pixel-10").click();
const frame = page.getByTestId("phone-frame");
const screen = page.getByTestId("device-screen");
const statusIndicators = page.getByTestId("status-indicators");
const navigation = page.getByTestId("android-navigation-bar");
const footer = page.getByTestId("flow-fixed-footer");
await expect(frame).toHaveAttribute("data-device", "pixel-10");
await expect(screen).toHaveAttribute("data-device", "pixel-10");
await expect(page.locator(".phone-bezel")).toHaveAttribute(
"src",
"/assets/android/Pixel10.png",
);
await expect(statusIndicators).toHaveAttribute("data-platform", "android");
await expect(statusIndicators).toHaveAttribute(
"src",
"/assets/status/status-icons.svg",
);
await expect(navigation).toBeVisible();
await expect(page.getByTestId("home-indicator")).toHaveCount(0);
await expect(input).toHaveValue("Draft message");
await page.waitForTimeout(300);
const layout = await page.evaluate(() => {
const footerElement = document.querySelector<HTMLElement>(
'[data-testid="flow-fixed-footer"]',
)!;
const navigationElement = document.querySelector<HTMLElement>(
'[data-testid="android-navigation-bar"]',
)!;
const appViewportElement = document.querySelector<HTMLElement>(
'[data-testid="mobile-app-viewport"]',
)!;
return {
footerBottom: footerElement.getBoundingClientRect().bottom,
appViewportBottom: appViewportElement.getBoundingClientRect().bottom,
navigationTop: navigationElement.getBoundingClientRect().top,
navigationHeight: Number.parseFloat(getComputedStyle(navigationElement).height),
safeAreaBottom: Number.parseFloat(
getComputedStyle(document.querySelector<HTMLElement>('[data-testid="device-screen"]')!).getPropertyValue(
"--device-safe-area-bottom",
),
),
};
});
expect(layout.safeAreaBottom).toBe(layout.navigationHeight);
expect(Math.abs(layout.appViewportBottom - layout.navigationTop)).toBeLessThanOrEqual(1);
expect(Math.abs(layout.footerBottom - layout.navigationTop)).toBeLessThanOrEqual(1);
await input.click();
await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "true");
await expect(navigation).toHaveCount(0);
await page.waitForTimeout(300);
const keyboardLayout = await page.evaluate(() => {
const screen = document.querySelector<HTMLElement>('[data-testid="device-screen"]')!;
const viewport = document.querySelector<HTMLElement>('[data-testid="mobile-app-viewport"]')!;
const scroll = document.querySelector<HTMLElement>('[data-testid="mobile-scroll"]')!;
const footerElement = document.querySelector<HTMLElement>('[data-testid="flow-fixed-footer"]')!;
const keyboard = document.querySelector<HTMLElement>('[data-testid="keyboard-dock"]')!;
return {
screenBottom: screen.getBoundingClientRect().bottom,
viewportBottom: viewport.getBoundingClientRect().bottom,
scrollBottom: scroll.getBoundingClientRect().bottom,
footerBottom: footerElement.getBoundingClientRect().bottom,
keyboardTop: keyboard.getBoundingClientRect().top,
keyboardBottom: keyboard.getBoundingClientRect().bottom,
};
});
expect(keyboardLayout.viewportBottom).toBeCloseTo(keyboardLayout.screenBottom, 0);
expect(Math.abs(keyboardLayout.keyboardBottom - keyboardLayout.screenBottom)).toBeLessThanOrEqual(1);
expect(Math.abs(keyboardLayout.scrollBottom - keyboardLayout.keyboardTop)).toBeLessThanOrEqual(1);
expect(Math.abs(keyboardLayout.footerBottom - keyboardLayout.keyboardTop)).toBeLessThanOrEqual(1);
});
test("FlowStack pushes and pops screens while dismissing the keyboard", async ({ page }) => {
await page.goto("/tests/runtime-fixture.html?fixture=flow");
await page.getByLabel("Flow message").click();
await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "true");
await page.getByRole("button", { name: "Push level 2" }).click();
await expect(page.getByRole("heading", { name: "Screen stacking works" })).toBeVisible();
await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "false");
const safeHeaderPlacement = await page.evaluate(() => {
const screen = document.querySelector<HTMLElement>('[data-testid="device-screen"]')!;
const toolbar = document.querySelector<HTMLElement>(".flow-fixture-header")!;
return toolbar.getBoundingClientRect().top - screen.getBoundingClientRect().top;
});
expect(safeHeaderPlacement).toBeGreaterThanOrEqual(54);
await page.getByRole("button", { name: "Push level 3" }).click();
await expect(page.getByRole("heading", { name: "Nested view level 3" })).toBeVisible();
await page.getByRole("button", { name: "Push level 4" }).click();
await expect(page.getByRole("heading", { name: "Nested view level 4" })).toBeVisible();
await page.getByRole("button", { name: "Done" }).click();
await expect(page.getByRole("heading", { name: "Nested view level 3" })).toBeVisible();
await page.getByRole("button", { name: " Back" }).click();
await expect(page.getByRole("heading", { name: "Screen stacking works" })).toBeVisible();
await page.getByRole("button", { name: "Done" }).click();
await expect(page.getByRole("heading", { name: "Flow root" })).toBeVisible();
});

View File

@@ -1,82 +0,0 @@
.fixture-screen {
background: #f6f6f6;
}
.fixture-content {
min-height: 1200px;
padding: 76px 24px 120px;
}
.fixture-content h1 {
margin: 0 0 20px;
font-size: 24px;
}
.sheet-trigger {
margin-bottom: 24px;
}
.fixture-carousel {
width: 100%;
}
.fixture-carousel-track {
gap: 12px;
}
.carousel-card {
width: 180px;
height: 132px;
flex: 0 0 auto;
border: 0;
border-radius: 18px;
background: #d9e7ff;
}
[data-testid="tap-count"] {
display: block;
margin-top: 12px;
}
.fixture-tall-content {
min-height: 900px;
padding-top: 160px;
}
.keyboard-footer {
height: 84px;
padding: 14px 20px;
background: white;
border-top: 1px solid #ddd;
}
.keyboard-footer input {
width: 100%;
height: 48px;
padding: 0 16px;
border: 1px solid #bbb;
border-radius: 24px;
}
.flow-fixture-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
height: var(--flow-header-content-height);
padding: 0 18px;
background: white;
border-bottom: 1px solid #ddd;
}
.flow-fixture-header button {
justify-self: start;
}
.flow-fixture-footer {
display: flex;
justify-content: flex-end;
height: 64px;
padding: 10px 18px;
background: white;
border-top: 1px solid #ddd;
}

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mobile runtime interaction fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/tests/runtime-fixture.tsx"></script>
</body>
</html>

View File

@@ -1,152 +0,0 @@
import { StrictMode, useState } from "react";
import { createRoot } from "react-dom/client";
import {
BottomSheet,
Carousel,
FlowStack,
KeyboardInput,
MobileRuntime,
MobileScroll,
type FlowScreen,
} from "../src/mobile";
import "../src/styles.css";
import "./runtime-fixture.css";
function CarouselFixture() {
const [tapCount, setTapCount] = useState(0);
const [sheetOpen, setSheetOpen] = useState(false);
return (
<MobileRuntime>
<MobileScroll className="fixture-screen">
<main className="fixture-content">
<h1>Runtime fixture</h1>
<button className="sheet-trigger" type="button" onClick={() => setSheetOpen(true)}>
Open sheet
</button>
<Carousel
ariaLabel="Featured cards"
className="fixture-carousel"
contentClassName="fixture-carousel-track"
>
{Array.from({ length: 7 }, (_, index) => (
<button
className="carousel-card"
type="button"
onClick={() => setTapCount((count) => count + 1)}
key={index}
>
Card {index + 1}
</button>
))}
</Carousel>
<output data-testid="tap-count">{tapCount}</output>
<div className="fixture-tall-content">Scrollable parent content</div>
</main>
</MobileScroll>
<BottomSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title="Animated sheet"
description="Exit motion regression fixture."
>
<p>Sheet content</p>
</BottomSheet>
</MobileRuntime>
);
}
function KeyboardFixture() {
const screen: FlowScreen = {
id: "keyboard",
footerHeight: 84,
footer: () => (
<div className="keyboard-footer">
<KeyboardInput aria-label="Message" placeholder="Message" />
</div>
),
render: () => (
<MobileScroll className="fixture-screen">
<main className="fixture-content fixture-tall-content">Keyboard fixture</main>
</MobileScroll>
),
};
return (
<MobileRuntime>
<FlowStack initial={screen} />
</MobileRuntime>
);
}
function stackedScreen(level: number): FlowScreen {
return {
id: `flow-level-${level}`,
headerHeight: 56,
header: (flow) => (
<div className="flow-fixture-header">
<button type="button" onClick={flow.pop}> Back</button>
<strong>Level {level}</strong>
<span />
</div>
),
footerHeight: 64,
footer: (flow) => (
<div className="flow-fixture-footer">
<button type="button" onClick={flow.pop}>Done</button>
</div>
),
render: (flow) => (
<MobileScroll className="fixture-screen">
<main className="fixture-content">
<h1>{level === 2 ? "Screen stacking works" : `Nested view level ${level}`}</h1>
{level < 4 ? (
<button type="button" onClick={() => flow.push(stackedScreen(level + 1))}>
Push level {level + 1}
</button>
) : null}
</main>
</MobileScroll>
),
};
}
function FlowFixture() {
const screen: FlowScreen = {
id: "flow-root",
footerHeight: 84,
footer: () => (
<div className="keyboard-footer">
<KeyboardInput aria-label="Flow message" placeholder="Message" />
</div>
),
render: (flow) => (
<MobileScroll className="fixture-screen">
<main className="fixture-content">
<h1>Flow root</h1>
<button type="button" onClick={() => flow.push(stackedScreen(2))}>
Push level 2
</button>
</main>
</MobileScroll>
),
};
return (
<MobileRuntime>
<FlowStack initial={screen} />
</MobileRuntime>
);
}
const fixture = new URLSearchParams(window.location.search).get("fixture");
const fixtureElement =
fixture === "keyboard"
? <KeyboardFixture />
: fixture === "flow"
? <FlowFixture />
: <CarouselFixture />;
createRoot(document.getElementById("root")!).render(
<StrictMode>{fixtureElement}</StrictMode>,
);

View File

@@ -1,68 +0,0 @@
import assert from "node:assert/strict";
import { access } from "node:fs/promises";
import test from "node:test";
import worker from "../worker/index.js";
test("serves existing static assets without a fallback", async () => {
const calls = [];
const response = await worker.fetch(new Request("https://example.test/assets/app.js"), {
ASSETS: {
fetch: async (request) => {
calls.push(new URL(request.url).pathname);
return new Response("asset", { status: 200 });
},
},
});
assert.equal(response.status, 200);
assert.deepEqual(calls, ["/assets/app.js"]);
});
test("falls back to index.html for an unknown app route", async () => {
const calls = [];
const response = await worker.fetch(
new Request("https://example.test/flow/step-two?source=share", {
headers: { accept: "text/html" },
}),
{
ASSETS: {
fetch: async (request) => {
const url = new URL(request.url);
calls.push(url.pathname + url.search);
return new Response(url.pathname === "/index.html" ? "app" : "missing", {
status: url.pathname === "/index.html" ? 200 : 404,
});
},
},
},
);
assert.equal(response.status, 200);
assert.deepEqual(calls, ["/flow/step-two?source=share", "/index.html"]);
});
test("does not turn missing API or write requests into the app shell", async () => {
for (const request of [
new Request("https://example.test/api/missing", { headers: { accept: "application/json" } }),
new Request("https://example.test/flow", { method: "POST", headers: { accept: "text/html" } }),
]) {
let calls = 0;
const response = await worker.fetch(request, {
ASSETS: {
fetch: async () => {
calls += 1;
return new Response("missing", { status: 404 });
},
},
});
assert.equal(response.status, 404);
assert.equal(calls, 1);
}
});
test("emits the files required by Sites packaging", async () => {
await access(new URL("../dist/client/index.html", import.meta.url));
await access(new URL("../dist/server/index.js", import.meta.url));
await access(new URL("../dist/.openai/hosting.json", import.meta.url));
});

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

View File

@@ -1,13 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
build: {
outDir: "dist/client",
},
server: {
host: "0.0.0.0",
allowedHosts: ["terminal.local"],
},
plugins: [react()],
});

View File

@@ -1,15 +0,0 @@
export default {
async fetch(request, env) {
const response = await env.ASSETS.fetch(request);
const acceptsHtml = request.headers.get("accept")?.includes("text/html");
if (response.status !== 404 || !acceptsHtml || !["GET", "HEAD"].includes(request.method)) {
return response;
}
const indexUrl = new URL(request.url);
indexUrl.pathname = "/index.html";
indexUrl.search = "";
return env.ASSETS.fetch(new Request(indexUrl, request));
},
};