Skip to content
2 changes: 1 addition & 1 deletion frontend-architecture-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pages -> features -> entities -> shared

`app` 只做启动和路由,不构造服务、不向下注入。

外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏,根路由留在外面。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`。外壳也不统一夹居中容器,宽度与留白由页面自己决定。
外壳套在哪些页面上也是路由决策:`AppShellRoute` 写在 `app.tsx` 的路由表里,谁在里面谁就有顶栏。目前全部路由都在里面,根路由也是——顶栏悬浮在内容之上、不占布局高度,首屏仍是满幅,而首页同样需要通往项目资产的常驻入口。外壳组件自身不读 pathname,不判断自己该不该出现——那种写法每多一个特殊页面就多一条 `if`;顶栏内部读 pathname 只为高亮当前项,与此无关。外壳也不统一夹居中容器,宽度与留白由页面自己决定:顶栏既然悬浮,避让由页面负责,内容页统一走 `PageContainer`

### 依赖规则

Expand Down
5 changes: 3 additions & 2 deletions frontend/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import { AppShellRoute } from './layout'
/**
* 路由表与全局外壳。
* 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。
* 外壳的边界画在这张表上:根路由是满幅首屏,自带入口卡片,不进外壳;其余页面共用常驻导航。
* 外壳的边界画在这张表上:全部路由都在里面,包括根路由——顶栏悬浮不占高度,
* 首屏仍是满幅,同时首页也才有通往项目资产的常驻入口。
*/
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route element={<AppShellRoute />}>
<Route path="/" element={<HomePage />} />
<Route path="/quick-start" element={<QuickStartPage />} />
<Route path="/quick-start/:runId" element={<QuickStartPage />} />
<Route path="/projects" element={<ProjectsPage />} />
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/app/layout/app-header.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={['/workflow-editor/run-1']}>
<AppHeader />
</MemoryRouter>,
)

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(
<MemoryRouter initialEntries={['/']}>
<AppHeader />
</MemoryRouter>,
)

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()
})
})
111 changes: 111 additions & 0 deletions frontend/src/app/layout/app-header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="pointer-events-none fixed inset-x-0 top-3.5 z-50 flex items-start justify-between gap-2 px-3 text-[#1c231e] sm:gap-4 sm:px-[18px]">
<div className="pointer-events-auto flex min-h-[3.625rem] min-w-0 items-center gap-3 rounded-xl border border-[#171817]/14 bg-[#dfe3df] px-2.5 py-[7px] sm:min-w-[min(26rem,42vw)] sm:px-3.5">
<Link
to="/"
aria-label="返回 Windup 首页"
className="flex shrink-0 items-center gap-2 text-[#1c231e] focus-visible:rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#284331] md:border-r md:border-[#2d3b31]/12 md:pr-3"
>
<img src="/windup-mark.svg" alt="" className="h-[1.6875rem] w-[1.6875rem]" />
<strong className="font-serif text-base leading-none">Windup</strong>
</Link>

<span className="hidden min-w-0 gap-0.5 md:grid">
<strong className="truncate text-[11px] font-semibold">{workspace.title}</strong>
<small className="truncate text-[8px] text-[#737d75]">{workspace.detail}</small>
</span>
</div>

<nav
aria-label="产品导航"
className="pointer-events-auto flex min-h-[3.625rem] items-center gap-[3px] rounded-xl border border-[#171817]/14 bg-[#dfe3df] p-[7px_9px]"
>
{productNavigation.map((item) => {
const active = item.isActive(pathname)

return (
<Link
key={item.to}
to={item.to}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
style={{ fontSize: '13px', fontWeight: 600 }}
className={`inline-flex min-h-[2.125rem] items-center rounded-[0.5625rem] px-2.5 whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#284331] ${
active
? 'bg-[#dce9df] text-[#284331]'
: 'text-[#5b655d] hover:bg-[#e7eee8] hover:text-[#26372c]'
}`}
>
{item.compactLabel ? (
<>
<span className="hidden sm:inline">{item.label}</span>
<span className="sm:hidden">{item.compactLabel}</span>
</>
) : (
item.label
)}
</Link>
)
})}
</nav>
</header>
)
}
25 changes: 9 additions & 16 deletions frontend/src/app/layout/index.tsx
Original file line number Diff line number Diff line change
@@ -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 层统一承载。 */

Expand All @@ -12,21 +14,12 @@ export interface AppShellProps {
export function AppShell({ children }: AppShellProps) {
return (
<div className="min-h-screen bg-white text-slate-900">
<nav className="flex items-center justify-between border-b border-slate-200 px-6 py-3">
{/* 首屏不带这条顶栏,站名是其余页面唯一的回程入口,必须是链接。 */}
<Link to="/" className="font-semibold tracking-tight hover:text-slate-600">
Windup
</Link>
<div className="flex gap-4 text-sm text-slate-600">
<Link to="/quick-start" className="hover:text-slate-900">
快速开始
</Link>
<Link to="/projects" className="hover:text-slate-900">
项目
</Link>
</div>
</nav>
{/* 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间。 */}
<AppHeader />
{/*
外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间,
也不按 pathname 分支给不同页面配不同容器。
顶栏悬浮不占布局高度,内容页的避让由 PageContainer 统一让出,满幅页面自己让。
*/}
<main className="w-full">{children}</main>
</div>
)
Expand Down
94 changes: 74 additions & 20 deletions frontend/src/pages/home/choice-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -10,6 +16,12 @@ export interface HomeChoiceCardProps {
description: string
actionLabel: string
tone?: HomeChoiceCardTone
/**
* 给出时,底部动作条由 actionLabel 换成这几个入口:能 hover 的设备悬停或键盘聚焦后展开,
* 触屏设备常显。卡片随之从 Link 降级为 section——链接不能嵌套链接。
* 不给时卡片整体是一个链接,指向 to。
*/
actions?: HomeChoiceCardAction[]
}

/** Home 专用入口卡;只接收显示内容与目标路由,不持有业务状态。 */
Expand All @@ -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 (
<Link
to={to}
className={`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 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 = (
<>
<span
aria-hidden="true"
className={`absolute -right-10 -top-12 h-36 w-36 rounded-full border transition-transform duration-300 group-hover:scale-110 motion-reduce:transform-none ${
Expand Down Expand Up @@ -71,20 +84,61 @@ export function HomeChoiceCard({
{description}
</span>
</span>
</>
)

<span
className={`relative mt-5 flex items-center justify-between border-t pt-4 text-xs font-semibold ${
dark ? 'border-white/18 text-white' : 'border-[#dfe1da] text-[#263f2d]'
}`}
>
{actionLabel}
const actionBorder = dark ? 'border-white/18 text-white' : 'border-[#dfe1da] text-[#263f2d]'

if (!split) {
return (
<Link to={to} className={shell}>
{body}
<span
aria-hidden="true"
className="transition-transform duration-200 group-hover:translate-x-1 motion-reduce:transform-none"
className={`relative mt-5 flex items-center justify-between border-t pt-4 text-xs font-semibold ${actionBorder}`}
>
{actionLabel}
<span
aria-hidden="true"
className="transition-transform duration-200 group-hover:translate-x-1 motion-reduce:transform-none"
>
</span>
</span>
</span>
</Link>
</Link>
)
}

return (
<section className={shell}>
{body}

<div className={`relative mt-5 border-t pt-4 text-xs font-semibold ${actionBorder}`}>
{/* 两层叠在一起换位,不用 display:none——隐藏的链接无法聚焦,键盘用户就够不到次级入口。
any-pointer-coarse 那一份是触屏的基线:Tailwind v4 把 hover: 包在 @media (hover: hover) 里,
触屏既没有 hover 也没有 Tab,只留 hover 展开的话这两个入口在手机上根本触发不到。
用 any-pointer 而非 pointer,是因为触屏笔记本、配鼠标的 iPad 主指针报 fine,
只判主指针那类设备用手指仍然点不动;代价是它们两个分支同时命中,按钮常显。 */}
<div className="flex items-center justify-between transition-opacity duration-200 group-hover:opacity-0 group-focus-within:opacity-0 any-pointer-coarse:hidden">
{actionLabel}
<span aria-hidden="true">→</span>
</div>

<div className="pointer-events-none absolute inset-x-0 bottom-0 flex gap-2 opacity-0 transition-opacity duration-200 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 any-pointer-coarse:static any-pointer-coarse:opacity-100 any-pointer-coarse:pointer-events-auto">
{actions.map((action) => (
<Link
key={action.to + action.label}
to={action.to}
className={`flex-1 rounded-full border px-3 py-2 text-center transition-colors duration-150 ${
dark
? 'border-white/25 hover:border-white hover:bg-white/10'
: 'border-[#cfd1ca] bg-white hover:border-[#263f2d] hover:bg-[#eef0ea]'
} focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#263f2d]`}
>
{action.label}
</Link>
))}
</div>
</div>
</section>
)
}
8 changes: 6 additions & 2 deletions frontend/src/pages/home/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { HomePage } from './index'
afterEach(cleanup)

describe('HomePage', () => {
it('提供快速开始和项目工作台两个既有入口', () => {
it('按交互形态提供快速开始与工作流画布两个入口', () => {
render(
<MemoryRouter>
<HomePage />
Expand All @@ -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')
})
})
Loading