From 501fd0925d9a9c176a55f2b839564749a006f9dc Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 00:53:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(agc):=20=E5=A4=96=E5=A3=B3=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E6=94=B6=E6=95=9B=E5=88=B0=20.launcher-main=20?= =?UTF-8?q?=E5=8D=95=E4=B8=80=E5=AE=B9=E5=99=A8=EF=BC=88=E5=90=AB=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E8=81=9A=E7=84=A6=E6=8F=8F=E8=BE=B9=E4=B8=8E=E7=BB=A7?= =?UTF-8?q?=E6=89=BF=E5=BE=BD=E7=AB=A0=E6=A0=B7=E5=BC=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/src/styles.css | 70 ++++++++-- .../tests/launcherScrollContract.test.ts | 128 ++++++++++++++++++ 2 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/launcherScrollContract.test.ts diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 791026a72..713ff21f4 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -678,14 +678,6 @@ body { overflow: auto; } -.window-chrome__content .launcher-main:has(.game-project-workbench) { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; - padding-bottom: 0; -} - .window-chrome__content .game-project-workbench { flex: 1 1 auto; height: auto; @@ -1083,14 +1075,38 @@ textarea { .launcher-main { position: relative; - display: grid; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto auto auto; - align-content: start; + /* 页面滚动就落在这里:**所有** launcher 视图(首页 / 项目 / 共创 / 确认页…)都是 + `.launcher-main` 的直接子元素,因此滚动容器全应用只有一个,页面不用各自打补丁。 + + 之前是 `display: grid; grid-template-rows: auto auto auto; align-content: start`: + 行高是 `auto`,子元素撑多高行就多高,再叠加 `overflow: hidden` —— 内容一旦高于窗口就被 + 整块裁掉,既看不到也滚不到(截图里的「支持共创的作品」最后一行、确认页底部按钮够不到 + 都是这个原因)。改成 flex 列 + 纵向滚动:子元素保持自上而下排列,超出即滚。 */ + display: flex; + flex-direction: column; + align-items: stretch; min-width: 0; - min-height: 100vh; + min-height: 0; padding: 0 0 42px; - overflow: hidden; + /* 纵向滚动 / 横向裁切:横向裁切必须保留,否则窄窗口下会被内容顶出横向滚动条。 */ + overflow-x: hidden; + overflow-y: auto; +} + +/* + 视图本身是 `.launcher-main` 的 flex 子项:默认 `flex-shrink: 1` 会把「比窗口高的页面」 + 压扁到容器高度(内容溢出而不触发滚动)。这里统一 `flex: 0 0 auto`,让页面保持自然高度、 + 由 `.launcher-main` 滚动。需要占满高度的视图(工作台)在自己的规则里改回 `flex: 1 1 auto`。 +*/ +.launcher-main > * { + flex: 0 0 auto; +} + +/* 共创相关页面的键盘聚焦:与 `.launcher-projects-page` 同一套描边口径。 */ +.fork-page + :where(button, a, input, textarea, [tabindex]):focus-visible { + outline: 3px solid var(--platform-warm-text); + outline-offset: 2px; } .launcher-runs-panel { @@ -3702,6 +3718,32 @@ textarea { line-height: 1.5; } +/* 衍生作品的只读授权:徽章(档位)+ 说明两行,形态上明显不是可选控件。 */ +.game-distribution-publish-panel__fork-inherited { + display: grid; + gap: 6px; + margin: 0; + color: var(--platform-text-muted); + font-size: 11px; + font-weight: 500; + line-height: 1.5; +} + +.game-distribution-publish-panel__fork-inherited-badge { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 6px; + padding: 2px 10px; + border: 1px solid var(--platform-warm-border); + border-radius: 999px; + background: var(--platform-warm-bg); + color: var(--platform-warm-text); + font-size: 11px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + /* 工程源码公开勾选:与上面的三态同宽同位,复选框不能套用「整行输入框」样式。 */ .game-distribution-publish-panel__fields .game-distribution-publish-panel__bundle-consent { diff --git a/apps/ai-game-creator-shell/tests/launcherScrollContract.test.ts b/apps/ai-game-creator-shell/tests/launcherScrollContract.test.ts new file mode 100644 index 000000000..685fb01d3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/launcherScrollContract.test.ts @@ -0,0 +1,128 @@ +/** + * 外壳滚动链的**契约测试**(纯 CSS 断言,不需要浏览器)。 + * + * 背景:`.launcher-main` 曾经是 `display: grid` + `grid-template-rows: auto …` + `overflow: hidden`, + * 视图一旦比窗口高就被整块裁掉——「共创」列表最后一行、Fork 确认页底部操作条都够不到。 + * 修法是把 `.launcher-main` 变成**唯一的页面滚动容器**(flex 列 + `overflow-y: auto`), + * 视图保持自然高度(`flex: 0 0 auto`)。 + * + * jsdom 没有布局,量不出 `scrollHeight > clientHeight`;这里钉住的是**样式契约**(哪一层负责 + * 滚动、链路上的每一层都允许收缩),真实滚动行为由浏览器冒烟核对。 + */ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { repoPath } from './repoPath'; + +const stylesPath = repoPath('apps/ai-game-creator-shell/src/styles.css'); +const styles = readFileSync(stylesPath, 'utf8'); + +/** 取某个选择器的第一条声明块(与 `workbenchThemeContrast.test.ts` 同一套解析口径)。 */ +function getCssBlock(source: string, selector: string) { + const selectorIndex = source.indexOf(selector); + expect(selectorIndex, `${selector} should exist`).toBeGreaterThanOrEqual(0); + const openBraceIndex = source.indexOf('{', selectorIndex); + let depth = 0; + for (let index = openBraceIndex; index < source.length; index += 1) { + const char = source[index]; + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(openBraceIndex + 1, index); + } + } + } + throw new Error(`${selector} block is not closed`); +} + +/** 去掉注释再断言:注释里会引用旧写法(例如「以前是 grid-template-rows…」)。 */ +function stripComments(block: string) { + return block.replace(/\/\*[\s\S]*?\*\//gu, ''); +} + +function declaration(block: string, property: string) { + const match = block.match(new RegExp(`(^|[;{\\s])${property}:\\s*([^;]+);`)); + expect(match, `${property} should be declared`).not.toBeNull(); + return (match?.[2] ?? '').replace(/\s+/gu, ' ').trim(); +} + +describe('launcher 滚动链', () => { + it('根与外壳层都给足高度,允许收缩', () => { + const root = getCssBlock(styles, '#root {'); + expect(declaration(root, 'height')).toBe('100dvh'); + expect(declaration(root, 'min-height')).toBe('0'); + + const chromeContent = getCssBlock(styles, '.window-chrome__content {'); + expect(declaration(chromeContent, 'height')).toBe('100%'); + expect(declaration(chromeContent, 'min-height')).toBe('0'); + // 外壳内容区自己不能滚:滚动只发生在 `.launcher-main`。 + expect(declaration(chromeContent, 'overflow')).toBe('hidden'); + + const launcherShell = getCssBlock( + styles, + '.window-chrome__content > .launcher-shell', + ); + expect(declaration(launcherShell, 'height')).toBe('100%'); + expect(declaration(launcherShell, 'min-height')).toBe('0'); + }); + + it('`.launcher-main` 是唯一页面滚动容器(flex 列 + 纵向 auto)', () => { + const main = getCssBlock(styles, '\n.launcher-main {'); + expect(declaration(main, 'display')).toBe('flex'); + expect(declaration(main, 'flex-direction')).toBe('column'); + // 纵向滚动 + 横向裁切:窄窗口不会被内容顶出横向滚动条。 + expect(declaration(main, 'overflow-y')).toBe('auto'); + expect(declaration(main, 'overflow-x')).toBe('hidden'); + // 不允许再出现把内容整块裁掉的 `overflow: hidden`(横向那条是显式 overflow-x)。 + expect(main).not.toMatch(/(^|[;\s])overflow:\s*hidden;/u); + + const mainOverride = getCssBlock( + styles, + '.window-chrome__content > .launcher-shell .launcher-main {', + ); + expect(declaration(mainOverride, 'height')).toBe('100%'); + expect(declaration(mainOverride, 'min-height')).toBe('0'); + + // 视图保持自然高度:`flex-shrink: 1` 会把长页面压扁到容器高度。 + const viewItem = getCssBlock(styles, '.launcher-main > * {'); + expect(declaration(viewItem, 'flex')).toBe('0 0 auto'); + }); + + it('旧的 grid 行高与裁切不再决定滚动', () => { + const main = stripComments(getCssBlock(styles, '\n.launcher-main {')); + expect(main).not.toMatch(/grid-template-rows/u); + expect(main).not.toMatch(/grid-template-columns/u); + expect(main).not.toMatch(/align-content/u); + + // 工作台仍可特判自己占满高度,但不得再改滚动方式(滚动只有一处)。 + const workbenchRule = getCssBlock( + styles, + '.launcher-main:has(.game-project-workbench) {', + ); + expect(workbenchRule).not.toMatch(/overflow|display|height/u); + }); + + it('共创相关页面共享一套版式令牌,且正文不吃满整屏', () => { + const tokens = readFileSync( + repoPath( + 'apps/ai-game-creator-shell/src/features/platform-fork/forkPageStyles.ts', + ), + 'utf8', + ); + const column = tokens.match( + /export const FORK_PAGE_COLUMN =\s*'[^']*min\((\d+)px/u, + ); + expect( + column, + 'FORK_PAGE_COLUMN should cap the column width', + ).not.toBeNull(); + const width = Number(column?.[1]); + expect(width).toBeGreaterThanOrEqual(720); + expect(width).toBeLessThanOrEqual(880); + // 正文另有 72ch 上限:长文不铺满整个内容列。 + expect(tokens).toContain('max-w-[72ch]'); + }); +});