From ae2b697e02a7cbb2ce0a7f45b51da5bf7b8124e1 Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 4 Aug 2026 12:02:24 +0800 Subject: [PATCH 1/9] feat(home): let the entry card carry secondary links The canvas entry has to settle which project the character belongs to before a workflow run exists, so a single destination is not enough for that card. Add an optional actions prop. When it is present the card renders as a section instead of a link, and the action row swaps to those links on hover or keyboard focus. Cards without actions keep the previous single-link shape, and the split links stay reachable by keyboard because they are dimmed rather than unmounted. --- frontend/src/pages/home/choice-card.tsx | 90 +++++++++++++++++++------ 1 file changed, 70 insertions(+), 20 deletions(-) diff --git a/frontend/src/pages/home/choice-card.tsx b/frontend/src/pages/home/choice-card.tsx index d110be4..2cd08ee 100644 --- a/frontend/src/pages/home/choice-card.tsx +++ b/frontend/src/pages/home/choice-card.tsx @@ -2,6 +2,12 @@ import { Link } from 'react-router' export type HomeChoiceCardTone = 'light' | 'dark' +/** 悬停后展开的次级入口;两个都指向真实路由,卡片本身不再是链接。 */ +export interface HomeChoiceCardAction { + to: string + label: string +} + export interface HomeChoiceCardProps { to: string eyebrow: string @@ -10,6 +16,12 @@ export interface HomeChoiceCardProps { description: string actionLabel: string tone?: HomeChoiceCardTone + /** + * 给出时,底部动作条在悬停或键盘聚焦后由 actionLabel 换成这几个入口, + * 卡片随之从 Link 降级为 section——链接不能嵌套链接。 + * 不给时卡片整体是一个链接,指向 to。 + */ + actions?: HomeChoiceCardAction[] } /** Home 专用入口卡;只接收显示内容与目标路由,不持有业务状态。 */ @@ -21,18 +33,19 @@ export function HomeChoiceCard({ description, actionLabel, tone = 'light', + actions, }: HomeChoiceCardProps) { const dark = tone === 'dark' + const split = actions !== undefined && actions.length > 0 - return ( - + const shell = `group relative flex min-h-56 flex-col overflow-hidden rounded-[1.35rem] border p-5 text-left transition duration-200 motion-reduce:transform-none ${ + dark + ? 'border-[#191b18] bg-[#191b18] text-white hover:-translate-y-0.5 hover:bg-[#242622]' + : 'border-[#cfd1ca] bg-[#f4f3ed] text-[#191b18] hover:-translate-y-0.5 hover:border-[#8f958b] hover:bg-white' + } focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#263f2d]` + + const body = ( + <> + + ) - - {actionLabel} + const actionBorder = dark ? 'border-white/18 text-white' : 'border-[#dfe1da] text-[#263f2d]' + + if (!split) { + return ( + + {body} - - + + ) + } + + return ( +
+ {body} + +
+ {/* 两层叠在一起换位,不用 display:none——隐藏的链接无法聚焦,键盘用户就够不到次级入口。 */} +
+ {actionLabel} + +
+ +
+ {actions.map((action) => ( + + {action.label} + + ))} +
+
+
) } From c88dd99dc1d18abdf329ac6e63ae5c93982c0a37 Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 4 Aug 2026 12:02:34 +0800 Subject: [PATCH 2/9] feat(home): name both entries after their interaction shape The two cards contrasted having a project against not having one, while the product tells them apart by control granularity, so nothing on the second card mentioned the canvas it leads to. Rename the second entry to the workflow canvas, rewrite both descriptions and the section note, and hang the new-project and existing-project links off its action row. The starting point section now states how each path works instead of where the user comes from. --- frontend/src/pages/home/index.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx index b8adab9..eae909b 100644 --- a/frontend/src/pages/home/index.tsx +++ b/frontend/src/pages/home/index.tsx @@ -71,7 +71,7 @@ export function HomePage() {

- 快速开始负责连续创作,项目工作台适合逐步掌控。 + 一句话交给系统跑完,或在画布上逐个节点确认。

@@ -81,17 +81,26 @@ export function HomePage() { eyebrow="ONE COMMAND" index="01" title="快速开始" - description="用自然语言描述角色和动作,从同一套制作流程开始。" + description="用自然语言描述角色和动作,系统按同一套流程连续跑完。" actionLabel="写下角色设定" tone="dark" /> + {/* + 画布入口必须先确定角色挂在哪个项目下,所以动作条悬停后分成新建与继续两条。 + 两条现在都落在 /projects 占位页;建项目、建 workflow run 再进 + /workflow-editor/:runId 的真实链路等后端接口对齐后接上。 + */} From 2cf927725b6ec920397c8e0202350457172b9267 Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 4 Aug 2026 12:02:34 +0800 Subject: [PATCH 3/9] test(home): cover the renamed entries and the split action row The home test asserted the old card titles and the second cards href, both of which the rename removed. Assert the two new titles and the two secondary links, and drop the href check on the second card since it is no longer a link. The suite pins the entry naming and the split destinations. --- frontend/src/pages/home/index.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/home/index.test.tsx b/frontend/src/pages/home/index.test.tsx index 6b9655c..ce7257f 100644 --- a/frontend/src/pages/home/index.test.tsx +++ b/frontend/src/pages/home/index.test.tsx @@ -8,7 +8,7 @@ import { HomePage } from './index' afterEach(cleanup) describe('HomePage', () => { - it('提供快速开始和项目工作台两个既有入口', () => { + it('按交互形态提供快速开始与工作流画布两个入口', () => { render( @@ -17,7 +17,11 @@ describe('HomePage', () => { expect(screen.getByRole('heading', { name: /真正登场/ })).toBeTruthy() expect(screen.getByRole('link', { name: /快速开始/ }).getAttribute('href')).toBe('/quick-start') - expect(screen.getByRole('link', { name: /从项目开始/ }).getAttribute('href')).toBe('/projects') + expect(screen.getByText('工作流画布')).toBeTruthy() + expect(screen.getByRole('link', { name: '创建新项目' }).getAttribute('href')).toBe('/projects') + expect(screen.getByRole('link', { name: '继续已有项目' }).getAttribute('href')).toBe( + '/projects', + ) expect(screen.getByTestId('home-brand-bird').tagName).toBe('CANVAS') }) }) From 20986cb79e55542866ac76ab3762445ef9321fa8 Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 4 Aug 2026 12:02:47 +0800 Subject: [PATCH 4/9] feat(layout): add the floating product header The shell ships a plain in-flow nav bar, while the 07-31 design keeps a floating one that leaves the full-bleed screens untouched. Add AppHeader, fixed above the content, with a brand plate that names the current workspace and three links that highlight by pathname. The Playtest item from the design is left out because the preview route needs a character and an outfit id, so the header has no constant address for it. --- frontend/src/app/layout/app-header.tsx | 111 +++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 frontend/src/app/layout/app-header.tsx diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx new file mode 100644 index 0000000..4ad4db7 --- /dev/null +++ b/frontend/src/app/layout/app-header.tsx @@ -0,0 +1,111 @@ +import { Link, useLocation } from 'react-router' + +interface ProductNavigationItem { + to: string + label: string + compactLabel?: string + isActive: (pathname: string) => boolean +} + +/** + * 三个入口对应三种去处:回首页、看已有资产、做新东西。 + * 07-31 定稿版还有一个 Playtest 项,这里没有搬——本仓库的预览路由是 + * /playtest/:characterId/:outfitId,没有角色和造型就构造不出可用地址, + * 顶栏给不出一个恒定的链接。等预览有了落地入口再加回来。 + */ +const productNavigation: ProductNavigationItem[] = [ + { + to: '/', + label: '首页', + isActive: (pathname) => pathname === '/', + }, + { + to: '/projects', + label: '项目资产', + compactLabel: '项目', + isActive: (pathname) => pathname.startsWith('/projects'), + }, + { + to: '/quick-start', + label: '创作', + isActive: (pathname) => + pathname.startsWith('/quick-start') || pathname.startsWith('/workflow-editor'), + }, +] + +/** 左侧标牌上的第二行,随所在区域变化,让用户知道自己在哪一片。 */ +function getWorkspaceLabel(pathname: string): { title: string; detail: string } { + if (pathname.startsWith('/projects') || pathname.startsWith('/playtest')) { + return { title: '项目资产', detail: '角色、造型与动作' } + } + + if (pathname.startsWith('/quick-start') || pathname.startsWith('/workflow-editor')) { + return { title: '创作工作流', detail: '设定、生成与审核' } + } + + return { title: '角色资产工作台', detail: 'Windup' } +} + +/** + * 跨页面悬浮 Bar 知道产品路由,因此属于 app 外壳,不下沉到 shared/ui。 + * 它读 pathname 只用于高亮当前项与切换标牌文案,不据此决定自己出不出现—— + * 谁带外壳是路由表的事,见 app.tsx。 + * 悬浮不占布局高度,页面顶部留白由页面或 PageContainer 自己让出。 + */ +export function AppHeader() { + const { pathname } = useLocation() + const workspace = getWorkspaceLabel(pathname) + + return ( +
+
+ + + Windup + + + + {workspace.title} + {workspace.detail} + +
+ + +
+ ) +} From 7c9f85c3265c1e3c2c86e7262b4cba7cbdeca8ef Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 4 Aug 2026 12:02:47 +0800 Subject: [PATCH 5/9] test(layout): cover the floating header navigation The header decides its highlight from the pathname and drops one item from the reference design, neither of which was pinned by a test. Assert that the editor route highlights the creation entry, that the home route highlights only itself, and that no Playtest link is rendered. A later attempt to restore the Playtest entry has to update the test on purpose. --- frontend/src/app/layout/app-header.test.tsx | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 frontend/src/app/layout/app-header.test.tsx diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx new file mode 100644 index 0000000..33c363b --- /dev/null +++ b/frontend/src/app/layout/app-header.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router' + +import { AppHeader } from './app-header' + +afterEach(cleanup) + +describe('AppHeader', () => { + it('保留三个产品入口,并将工作流路由归入创作', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: '返回 Windup 首页' }).getAttribute('href')).toBe('/') + expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('href')).toBe('/projects') + expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBe('page') + expect(screen.queryByRole('link', { name: 'Playtest' })).toBeNull() + }) + + it('在首页只高亮首页一项', () => { + render( + + + , + ) + + expect(screen.getByRole('link', { name: '首页' }).getAttribute('aria-current')).toBe('page') + expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('aria-current')).toBeNull() + expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBeNull() + }) +}) From 3d8d19881e68621a0162817fd4be92a476ea6003 Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 4 Aug 2026 12:02:59 +0800 Subject: [PATCH 6/9] feat(layout): mount the floating header on every route The root route stayed outside the shell so the first screen could run full bleed, which left the home page without any way to reach projects and the asset library. Replace the nav bar with AppHeader and move the root route inside AppShellRoute. Home keeps its full-bleed composition because the header floats above the content instead of taking layout height. --- frontend/src/app/app.tsx | 5 +++-- frontend/src/app/layout/index.tsx | 25 +++++++++---------------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 06268ea..8c869ab 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -13,14 +13,15 @@ import { AppShellRoute } from './layout' /** * 路由表与全局外壳。 * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 - * 外壳的边界画在这张表上:根路由是满幅首屏,自带入口卡片,不进外壳;其余页面共用常驻导航。 + * 外壳的边界画在这张表上:全部路由都在里面,包括根路由——顶栏悬浮不占高度, + * 首屏仍是满幅,同时首页也才有通往项目资产的常驻入口。 */ export function App() { return ( - } /> }> + } /> } /> } /> } /> diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx index b797b33..1ebace9 100644 --- a/frontend/src/app/layout/index.tsx +++ b/frontend/src/app/layout/index.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from 'react' -import { Link, Outlet } from 'react-router' +import { Outlet } from 'react-router' + +import { AppHeader } from './app-header' /** 跨页面常驻导航属于应用外壳,由 app 层统一承载。 */ @@ -12,21 +14,12 @@ export interface AppShellProps { export function AppShell({ children }: AppShellProps) { return (
- - {/* 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间。 */} + + {/* + 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间, + 也不按 pathname 分支给不同页面配不同容器。 + 顶栏悬浮不占布局高度,内容页的避让由 PageContainer 统一让出,满幅页面自己让。 + */}
{children}
) From a5a413b02ce2b40ac26a23400b2b7f1c47ba1fe5 Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 4 Aug 2026 12:02:59 +0800 Subject: [PATCH 7/9] fix(shared): clear the floating header in the page container The header no longer occupies layout height, so content pages started under it. Raise the top padding of the container to six rem and keep the other three sides at two. Content pages clear the bar with room to spare, and the note records that the value tracks the header size. --- frontend/src/shared/ui/page-container.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/shared/ui/page-container.tsx b/frontend/src/shared/ui/page-container.tsx index 157c8cd..6cc25a2 100644 --- a/frontend/src/shared/ui/page-container.tsx +++ b/frontend/src/shared/ui/page-container.tsx @@ -10,7 +10,10 @@ export interface PageContainerProps { * 最大宽度 1280px(`max-w-7xl`)取自应用型界面 1200–1440px 的常见区间, * 与以正文阅读为主的 640–720px 不是一档——首屏、Playtest 这类满幅页面不套本组件。 * 只提供一种尺寸:出现第二种真实需求时再加参数,不预留档位。 + * + * 顶部留白比其余三边大,是给悬浮顶栏让位:顶栏 fixed 在 top-3.5、最小高 3.625rem, + * 合计约 4.5rem,pt-24(6rem)留出余量。改顶栏尺寸时这里要一起改。 */ export function PageContainer({ children }: PageContainerProps) { - return
{children}
+ return
{children}
} From 6c1454215c95bc650f5023702009c2a31750c3fe Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 4 Aug 2026 12:02:59 +0800 Subject: [PATCH 8/9] docs(architecture): update the shell boundary The layering note stated that the root route stays outside the shell, which stopped being true once the header started floating. Record that every route now sits inside the shell, why the first screen survives it, and that the header reads the pathname only to highlight the current item. The document matches the route table again. --- frontend-architecture-v3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend-architecture-v3.md b/frontend-architecture-v3.md index 50f46bc..13e7a06 100644 --- a/frontend-architecture-v3.md +++ b/frontend-architecture-v3.md @@ -41,7 +41,7 @@ pages -> features -> entities -> shared `app` 只做启动和路由,不构造服务、不向下注入。 -外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏,根路由留在外面。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`。外壳也不统一夹居中容器,宽度与留白由页面自己决定。 +外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏。目前全部路由都在里面,根路由也是——顶栏悬浮在内容之上、不占布局高度,首屏仍是满幅,而首页同样需要通往项目资产的常驻入口。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`;顶栏内部读 pathname 只为高亮当前项,与此无关。外壳也不统一夹居中容器,宽度与留白由页面自己决定:顶栏既然悬浮,避让由页面负责,内容页统一走 `PageContainer`。 ### 依赖规则 From 75ec675ae7445a999f31c902891072529afb557b Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 4 Aug 2026 13:38:16 +0800 Subject: [PATCH 9/9] fix(home): make split card actions reachable on touch devices Tailwind v4 wraps hover variants in @media (hover: hover), so the reveal that exposes the two secondary entries never fires on a touchscreen, and the section wrapper is neither focusable nor clickable. Show both action links persistently under any-pointer: coarse and hide the collapsed label row there, keeping the hover and focus-within reveal for fine pointers. The workflow-canvas card is now tappable on phones and on hybrid devices whose primary pointer reports fine. --- frontend/src/pages/home/choice-card.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/home/choice-card.tsx b/frontend/src/pages/home/choice-card.tsx index 2cd08ee..e9b99d4 100644 --- a/frontend/src/pages/home/choice-card.tsx +++ b/frontend/src/pages/home/choice-card.tsx @@ -2,7 +2,7 @@ import { Link } from 'react-router' export type HomeChoiceCardTone = 'light' | 'dark' -/** 悬停后展开的次级入口;两个都指向真实路由,卡片本身不再是链接。 */ +/** 展开后的次级入口;两个都指向真实路由,卡片本身不再是链接。 */ export interface HomeChoiceCardAction { to: string label: string @@ -17,8 +17,8 @@ export interface HomeChoiceCardProps { actionLabel: string tone?: HomeChoiceCardTone /** - * 给出时,底部动作条在悬停或键盘聚焦后由 actionLabel 换成这几个入口, - * 卡片随之从 Link 降级为 section——链接不能嵌套链接。 + * 给出时,底部动作条由 actionLabel 换成这几个入口:能 hover 的设备悬停或键盘聚焦后展开, + * 触屏设备常显。卡片随之从 Link 降级为 section——链接不能嵌套链接。 * 不给时卡片整体是一个链接,指向 to。 */ actions?: HomeChoiceCardAction[] @@ -113,13 +113,17 @@ export function HomeChoiceCard({ {body}
- {/* 两层叠在一起换位,不用 display:none——隐藏的链接无法聚焦,键盘用户就够不到次级入口。 */} -
+ {/* 两层叠在一起换位,不用 display:none——隐藏的链接无法聚焦,键盘用户就够不到次级入口。 + any-pointer-coarse 那一份是触屏的基线:Tailwind v4 把 hover: 包在 @media (hover: hover) 里, + 触屏既没有 hover 也没有 Tab,只留 hover 展开的话这两个入口在手机上根本触发不到。 + 用 any-pointer 而非 pointer,是因为触屏笔记本、配鼠标的 iPad 主指针报 fine, + 只判主指针那类设备用手指仍然点不动;代价是它们两个分支同时命中,按钮常显。 */} +
{actionLabel}
-
+
{actions.map((action) => (