Compare commits

3 Commits
sub-1 ... main

Author SHA1 Message Date
km2023
356ab3c3a0 docs(readme): sync adaptive section to authoritative AGENTS.md + audit result 2026-06-30 16:18:20 +08:00
km2023
8e86e22ad2 docs(agents): rewrite AGENTS.md as the single authoritative adaptive-layout spec
Unify the layered-decoupling approach (art in the 9:16 canvas, UI anchored to the device-stage screen container) from docs/adaptive-architecture.md, fold in the dialog fix (cd83c35), drop the wrong 'canvas is deprecated' framing, add a 3-step element-placement decision and the dialog anti-pattern. Codex/teammates read this file to adapt the remaining pages consistently.
2026-06-30 15:34:29 +08:00
km2023
cd83c358cf fix(home): move dialog out of 9:16 canvas so it stays screen-fixed
The home dialog used bottom-[15.8%] inside the 9:16 canvas, whose height scales with width (h = w*16/9), so the dialog drifted vertically as viewport width changed. Moved the dialog block to the screen-container layer beside the tab bar, so it now shares the tab's screen-relative anchoring and no longer moves with width.
2026-06-30 15:15:46 +08:00
3 changed files with 147 additions and 117 deletions

View File

@@ -48,17 +48,17 @@ Android 打 APK 需要 JDK 17 + Android Studio + `ANDROID_HOME`,详细步骤见
## 当前进度(自适应改造)
正在把全站从「固定比例画布 + 百分比绝对定位」改造为「540 宽标准移动布局」,解决**手机宽度变化导致布局错乱**的问题。
全站自适应采用**分层解耦**:背景美术留在 9:16 画布(`object-cover` 铺满、边缘可裁),UI(对话框 / tab / 图标 / 头像)锚定视口容器、不随宽度漂。解决**手机宽度变化导致 UI 错位 / 漂移**的问题。
-**首页** `src/pages/home-page.tsx`:540 容器居中、去手机外壳、底部 tab 贴屏幕底
- ⚠️ 首页 tab 样式仍在和需求对齐中(早期方案绕复杂了,正收敛到最简逻辑)
- ✅ **自适应规范**已沉淀到 `vtubermini/AGENTS.md`(三条规则 + 两个亲测的坑 + 验证方法)——后续同事用 codex 调其他页照此执行,保证全站做法统一
-**其他页面**(anniversary / shop / my / album / pet …)待逐页按规范调整
-**首页** `src/pages/home-page.tsx`:背景视频 + 立绘在 9:16 画布;tab + 对话框已锚屏幕(画布外)——**已改对的样板**
- ✅ **自适应规范**已沉淀为权威版 `vtubermini/AGENTS.md`(分层心法 + 判断 3 步 + 反面教材 + 验证法)——同事用 codex 调任何页照此执行
-**全部页面已审查**:其余 18 页均合规(只有 home 曾用 9:16 画布致 UI 漂、已修);后续新页面 / 改动遵循 `AGENTS.md` 即可
核心规则(详见 `vtubermini/AGENTS.md`):
1. 容器宽 `min(100vw, 540px)` 居中;
2. **不用百分比绝对定位**(宽度一变就乱)——用 flex / 文档流 + 贴边;
3. 导航 tab 贴屏幕底。
1. **分层**:美术(背景 / 立绘)留 9:16 画布 `object-cover` 铺满;UI(对话框 / tab / 图标)锚视口容器(`.device-stage` / `h-full`),**绝不挂进画布**;
2. 别在 9:16 画布里用百分比纵向定位(`bottom-[X%]` 随宽度漂);
3. 关键尺寸写 inline style 用 `vw/vh/min()`;
4. 改完换 360 / 400 / 540 实测 UI 距屏幕底 px 不变。
## 关键文档

View File

@@ -1,43 +1,73 @@
# AGENTS.md — VTuber Mini
# AGENTS.md — VTuber Mini 自适应规则(权威 · 统一)
> 给 codex / AI agent:本项目调整页面**自适应布局**时,遵循本文件
> 目标:任何同事用 codex 微调任意页面,做法都统一、不再各搞一套
> 给 codex / AI agent / 同事:本项目调整**任意页面**自适应布局,**以本文件为唯一权威**
> 本文件 = `docs/adaptive-architecture.md` 的分层方案 + 实战修正(对话框 `cd83c35`)整合后的最终版
> 旧版"别用 9:16 画布 / 架构文档已废弃"的说法**作废**——画布要用(放美术),只是 UI 别挂进去。
## 这是什么项目
540px 宽移动端 H5(单 VTuber 粉丝养成 app)。React 19 + Vite + Tailwind 4 + Capacitor(Android)。
桌面浏览器里 = 一540 宽的竖条居中显示
## 项目形态
540px 宽移动端 H5(单 VTuber 养成 app)。React 19 + Vite + Tailwind 4 + Capacitor(Android)。
真实像素、无 rem 缩放;桌面浏览器里 = 一条 ≤540 宽竖屏居中。包管理用 **pnpm**
## 自适应三条规则(就这么简单,别搞复杂)
1. **容器**:页面根容器宽 = `min(100vw, 540px)`,水平居中,高 = `100dvh`。窄屏(<540)自动占满 100%。
- 全局 `.device-stage` 已是这个规则,页面挂在它里面即可。
2. **不要用百分比绝对定位**。元素位置别用 `style={{ left: 'X%', top: 'Y%' }}` 这种绝对定位——手机宽度一变就错乱(这是本项目布局乱的根因)。
- 用正常 flex / 文档流;或贴边的 `absolute`(`bottom-0` / `top-0` / `inset-x-0`)。
3. **导航 / tab 贴屏幕底**:`absolute inset-x-0 bottom-0` + 内部 flex 等分。背景美术用 `object-cover` 铺满容器。
## 一、核心心法:分层解耦(整套体系就这一句)
把页面里每个元素归到两类之一,**各用各的坐标系,别焊死在一起**:
## 两个必避的坑(亲测,栽过)
1. **Tailwind 4 会静默丢弃自定义 CSS 类里的 `cqw / cqh / container-type`**(Lightning CSS 把整条规则删掉,不报错)。
→ 关键尺寸**别写自定义 CSS 类,直接写 inline style,用 `vw/vh`**。例:
```tsx
<div style={{ width: 'min(100vw, 540px)', aspectRatio: '9 / 16' }} />
```
2. **`npm run build` 通过 ≠ 生效**。自定义 CSS 可能编译过但运行时根本没渲染(本项目曾让画布 `height` 塌成 0 却一直没发现,误以为成功)。
→ 改完**必须实测元素几何**,不能只看编译过。
| 层 | 是什么 | 挂哪个容器 | 怎么定位 |
|---|---|---|---|
| **美术层** | 背景视频 / 底图 / 立绘 | **9:16 画布**`.reference-fit-frame` 或 inline `aspectRatio:'9 / 16'` | `object-cover` 铺满,边缘允许裁切 |
| **UI 层** | 对话框 / 底栏 tab / 图标 / 头像 / 按钮——自带完整视觉的独立 PNG、要可点 | **真实屏幕容器**`.device-stage`(`height:100dvh`)或页面级 `h-full`/`100dvh` 容器 | 锚屏幕边(`absolute inset-x-0 bottom-0` 等)+ `safe-area`**绝不挂进画布** |
## 改完怎么验证(别跳过)
一句话:**背景管「好看」可裁UI 管「可用」(锚屏幕、永不裁)。**
## 二、为什么必须这样(第一性 · 记牢这条因果)
- 9:16 画布高度 = `宽 × 16/9`**随宽度变**。
- 元素挂画布里、用百分比纵向定位(`bottom-[X%]`)→ 实际像素 = `X% × 画布高`**随宽度上下漂**
- `.device-stage` 高 = `100dvh`(视口高),**不随宽度联动** → UI 挂这里才钉得住。
## 三、三类页面 → 三种做法
| 类型 | 页面 | 做法 |
|---|---|---|
| 沉浸·交互页 | `home``anniversary` | 美术留画布 + UI 锚屏幕(见 §一)← 重点 |
| 沉浸·演出页 | `gacha``heart-moments` | 整段全屏演出,按设计稿**整体等比 `scale`**,可接受少量裁切 |
| 功能·流式页 | `my`/`shop`/`album`/各 ledger…约 14 个) | flex/grid 文档流 + `safe-area` + `clamp()` 字号,天然较好,只补 token 死角 |
## 四、铁律
**✅ 要这么做**
- UI 元素挂屏幕容器(`.device-stage` 或页面级 `h-full`),贴边 `absolute inset-x-0 bottom-0` / flex 等分。
- 背景 `object-cover` 铺满画布(**不是 `object-fill`**,那会把图标拉胖)。
- 关键尺寸写 **inline style**,用 `vw/vh/dvh/min()`
**❌ 绝不这么做**
- **UI 挂 9:16 画布里用百分比纵向定位**`bottom-[15.8%]`)→ 必随宽度漂。← 本项目布局乱、对话框漂的根因。
- 自定义 CSS 类里写 `cqw / cqh / container-type` → Tailwind 4Lightning CSS**静默删整条规则、不报错**。改用 inline `vw/vh`
- 在有 `transform` 的祖先内用 `position: fixed` → fixed 被该祖先接管、相对它而非屏幕,照样漂。
## 五、判断一个元素该放哪3 步 · 套到任意页任意元素)
1. **它是美术还是 UI** 自带完整视觉的独立 PNG / 要可点 = UI只是画面氛围 = 美术。
2. **查它的定位祖先链**:最近一个 `position:relative/absolute` 的祖先,高度是 **9:16 画布派生**`aspectRatio` / 套在 `.reference-fit-frame` 里)还是 **视口高**`100dvh` / `.device-stage`
3. **若是 UI 却挂在画布派生高度上 → 移出来挂屏幕容器层。** 定位类基本不用动——`bottom-[X%]` 移到屏幕容器后X% 就相对视口高,自然不漂。
## 六、反面教材(真实案例 · 别重蹈)
首页对话框原本 `<div absolute inset-x-[4%] bottom-[15.8%]>` 挂在 9:16 画布的 `flex-1` 容器里 → 拖窄屏对话框下移、和 tab 重叠。
**修复 = 把整个 `{activeDialog ? …}` 块移出画布、挪到 tab`aspectRatio:108/25`)旁边(屏幕容器层)**,它就和 tab 共享屏幕锚定、不再随宽度动。见 `home-page.tsx` + commit `cd83c35`
(注:`docs/adaptive-architecture.md` §4.1 曾说「对话框为对齐角色保留画布百分比」——此条已被本案例推翻,对话框也要锚屏幕。)
## 七、验证别跳过——build 过 ≠ 生效)
本项目栽过「画布 height 塌成 0、编译却过、一直没发现」。改完**必须实测几何**
```bash
npm run build # 1) 先编译过
CI=true npm run dev # 2) 跑起来(CI=true 避免后台进程因 stdin 退出)
pnpm build # 1) 先编译过
pnpm dev # 2) 跑起来
```
然后在浏览器实测关键元素的 `getBoundingClientRect()`,确认:
- 容器宽 = `min(屏宽, 540)`、`height ≠ 0`;
- tab bar 的 `bottom` ≈ 屏幕高(真的贴底)、宽 = 屏幕宽;
- 换几个宽度(360 / 400 / 540)布局都不乱
浏览器**360 / 400 / 540** 三个宽度,实测关键元素 `getBoundingClientRect()`
- UI 距屏幕底的 px`innerHeight - rect.bottom`**三个宽度下不变** = 没漂 ✓
- tab 贴屏幕底、宽 = 屏幕宽
- 整体不乱、UI 不重叠
> 提示:首页有登录 gate(`!isLoggedIn` → LoginPage,见 `App.tsx`),临时验证可把 `isLoggedIn` 初值改 `true`,**验证还原**。
> 首页有登录 gate`!isLoggedIn`→LoginPage见 `App.tsx`)。临时验证可把 `isLoggedIn` 初值改 `true`**验证还原**。
## 参考实现
- `src/pages/home-page.tsx`:首页已按上面三条改过(540 容器、去手机外壳、底部 tab 贴屏幕底)——可作为其他页的样板,但**以本文件规则为准,不要照搬其中的临时痕迹**。
- 其他页(anniversary / shop / my / album / pet …)尚未逐页核对,按本规则调整即可。
## 八、参考实现 & 现状
- `src/pages/home-page.tsx` = **已改对的样板**:背景视频 + 立绘在 9:16 画布tab + 对话框已锚屏幕(画布外)。可照抄分层方式,但**以本文件规则为准,别照搬临时痕迹**。
- 其余 18 页:沉浸页(`anniversary`)按 §三①;演出页(`gacha`/`heart-moments`)按 §三②;流式页维持。**凡发现 UI 还挂在画布内用百分比定位的,按 §五 移出。**
## 已废弃
`docs/adaptive-architecture.md` 是早期的复杂方案(9:16 固定画布 / scale 缩放 / 分层 / cqw),**已废弃、勿参考**,一切以本文件为准。
---
*来源整合:`docs/adaptive-architecture.md`(分层方案调研 2026-06-29+ 对话框实战修正(`cd83c35`2026-06-30。本文件取代旧 AGENTS.md。*

View File

@@ -488,82 +488,6 @@ export function HomePage({
</button>
) : null}
{activeDialog ? (
<div
className={`absolute inset-x-[4%] bottom-[15.8%] z-40 ${
activeDialog.kind === 'idle'
? 'pointer-events-auto'
: 'pointer-events-none'
}`}
key={`${activeDialog.kind}-${activeDialog.text}`}
>
{activeDialog.kind === 'idle' ? (
<button
className="absolute right-[13%] top-0 z-30 border-0 bg-transparent p-0 text-[10px] font-normal leading-none text-white drop-shadow-[0_1px_4px_rgba(8,22,64,0.82)] transition hover:text-white active:scale-[0.98]"
onClick={(event) => {
event.stopPropagation()
setActiveDialog(null)
}}
type="button"
>
</button>
) : null}
<div
className={`relative min-h-[128px] overflow-hidden rounded-[10px] px-7 pb-5 pt-7 text-left [font-family:'Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif] ${
activeDialog.kind === 'idle'
? 'animate-[home-idle-dialog-enter_360ms_cubic-bezier(0.2,0.82,0.24,1)]'
: 'animate-[home-dialog-float_2500ms_ease-out_both]'
}`}
>
<img
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 h-full w-full select-none object-fill opacity-75"
draggable={false}
src={homeDialogBoxImage}
/>
<span
className={`z-10 block text-[14px] font-normal leading-[1.75] tracking-[0.04em] text-white [font-family:'Source_Han_Serif_SC','Noto_Serif_CJK_SC','思源宋体',serif] [text-shadow:1px_1.5px_0_rgba(0,0,0,0.72),0_0_1px_rgba(0,0,0,0.38)] ${
activeDialog.kind === 'idle'
? 'relative mt-4 max-w-[82%]'
: 'absolute left-7 top-1/2 w-[80%] -translate-y-1/2'
}`}
>
{activeDialog.text}
</span>
</div>
{activeDialog.kind === 'idle' ? (
<div className="mx-auto mt-1.5 flex w-[78%] animate-[home-idle-dialog-enter_360ms_cubic-bezier(0.2,0.82,0.24,1)] flex-col gap-1.5">
{activeDialog.choices.map((choice) => (
<button
className="group relative min-h-[31px] overflow-hidden rounded-[8px] border-0 bg-transparent px-0 py-0 text-left font-normal text-white shadow-[0_8px_18px_rgba(0,0,0,0.2)] transition active:scale-[0.99] [font-family:'Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif]"
key={choice.label}
onClick={(event) => {
event.stopPropagation()
handleDialogChoice(choice)
}}
type="button"
>
<img
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 h-full w-full select-none object-fill opacity-85 transition group-hover:opacity-85"
draggable={false}
src={homeChoiceBoxImage}
/>
<span
className="absolute z-10 flex items-center justify-center overflow-hidden text-center text-[12px] leading-none tracking-[0.04em] drop-shadow-[0_1px_4px_rgba(0,0,0,0.72)] [font-family:'Source_Han_Serif_SC','Noto_Serif_CJK_SC','思源宋体',serif]"
style={homeChoiceTextBoxStyle}
>
<span className="block w-full truncate">{choice.label}</span>
</span>
</button>
))}
</div>
) : null}
</div>
) : null}
<HomeLayoutBox
className="z-30"
elementId="profile"
@@ -711,6 +635,82 @@ export function HomePage({
})}
</div>
</div>
{activeDialog ? (
<div
className={`absolute inset-x-[4%] bottom-[15.8%] z-40 ${
activeDialog.kind === 'idle'
? 'pointer-events-auto'
: 'pointer-events-none'
}`}
key={`${activeDialog.kind}-${activeDialog.text}`}
>
{activeDialog.kind === 'idle' ? (
<button
className="absolute right-[13%] top-0 z-30 border-0 bg-transparent p-0 text-[10px] font-normal leading-none text-white drop-shadow-[0_1px_4px_rgba(8,22,64,0.82)] transition hover:text-white active:scale-[0.98]"
onClick={(event) => {
event.stopPropagation()
setActiveDialog(null)
}}
type="button"
>
</button>
) : null}
<div
className={`relative min-h-[128px] overflow-hidden rounded-[10px] px-7 pb-5 pt-7 text-left [font-family:'Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif] ${
activeDialog.kind === 'idle'
? 'animate-[home-idle-dialog-enter_360ms_cubic-bezier(0.2,0.82,0.24,1)]'
: 'animate-[home-dialog-float_2500ms_ease-out_both]'
}`}
>
<img
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 h-full w-full select-none object-fill opacity-75"
draggable={false}
src={homeDialogBoxImage}
/>
<span
className={`z-10 block text-[14px] font-normal leading-[1.75] tracking-[0.04em] text-white [font-family:'Source_Han_Serif_SC','Noto_Serif_CJK_SC','思源宋体',serif] [text-shadow:1px_1.5px_0_rgba(0,0,0,0.72),0_0_1px_rgba(0,0,0,0.38)] ${
activeDialog.kind === 'idle'
? 'relative mt-4 max-w-[82%]'
: 'absolute left-7 top-1/2 w-[80%] -translate-y-1/2'
}`}
>
{activeDialog.text}
</span>
</div>
{activeDialog.kind === 'idle' ? (
<div className="mx-auto mt-1.5 flex w-[78%] animate-[home-idle-dialog-enter_360ms_cubic-bezier(0.2,0.82,0.24,1)] flex-col gap-1.5">
{activeDialog.choices.map((choice) => (
<button
className="group relative min-h-[31px] overflow-hidden rounded-[8px] border-0 bg-transparent px-0 py-0 text-left font-normal text-white shadow-[0_8px_18px_rgba(0,0,0,0.2)] transition active:scale-[0.99] [font-family:'Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif]"
key={choice.label}
onClick={(event) => {
event.stopPropagation()
handleDialogChoice(choice)
}}
type="button"
>
<img
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-0 h-full w-full select-none object-fill opacity-85 transition group-hover:opacity-85"
draggable={false}
src={homeChoiceBoxImage}
/>
<span
className="absolute z-10 flex items-center justify-center overflow-hidden text-center text-[12px] leading-none tracking-[0.04em] drop-shadow-[0_1px_4px_rgba(0,0,0,0.72)] [font-family:'Source_Han_Serif_SC','Noto_Serif_CJK_SC','思源宋体',serif]"
style={homeChoiceTextBoxStyle}
>
<span className="block w-full truncate">{choice.label}</span>
</span>
</button>
))}
</div>
) : null}
</div>
) : null}
<div className="absolute inset-x-0 bottom-0 z-30 overflow-hidden" style={{ aspectRatio: "108 / 25" }}><img src={bottomIconBar} alt="" aria-hidden="true" draggable={false} className="pointer-events-none absolute inset-0 h-full w-full object-fill scale-x-[1.12]" />{[{ route: "shop", icon: shopIcon, left: "8.1%" }, { route: "moments", icon: momentsIcon, left: "33.7%" }, { route: "pet", icon: petIcon, left: "56.6%" }, { route: "album", icon: albumIcon, left: "80%" }].map((t) => (<button key={t.route} type="button" aria-label={t.route} onClick={() => onNavigate(t.route as AppRoute)} style={{ left: t.left, width: "13.3%" }} className="absolute top-1/2 flex -translate-y-1/2 items-center justify-center active:scale-95">{t.route === "moments" ? (<span className="pointer-events-none absolute -top-1 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap rounded-full border border-white/40 bg-[#f4d79b] px-1.5 py-px text-[8px] font-bold leading-tight text-[#5e3717] shadow-[0_2px_6px_rgba(0,0,0,0.3)]"></span>) : null}<img src={t.icon} alt="" draggable={false} className="w-full select-none object-contain" /></button>))}</div>
</div>
)