Foundation 是我维护的一个 Wails v3 桌面应用脚手架,Go 后端加 React 前端,目标是把窗口 主题 i18n 持久化 托盘这些基础设施一次做好,业务开发直接从页面写起。

到了 v0.0.3,有三个问题越来越明显:样式写在 MUI 的 sx 工厂函数里,每个 View 都要先 useTheme() 再算一遍样式;外壳写死成“标题栏 + 左侧图标栏 + 内容区”,想把导航挪个位置就得改 AppLayout;界面行为没有自动化测试,改完只能手工点一遍。

因此这次连发了两个大版本。v1.0.0 换掉 UI 底座,从 MUI 9 迁到 Mantine 9,同时跟进 Wails v3.1.0 正式版;v2.0.0 在新底座上把外壳重写成 CSS Grid,加上九种布局预设 悬浮 Dock 和 E2E 测试。两个版本同一天发布,本文合在一起讲。

仓库:FxRayHughes/foundation 从 v0.0.3 升级请先看 迁移指南,第 1–8 节对应 v1.0.0,第 9 节对应 v2.0.0。

版本全貌

首先我列一张表,把 v0.0.3 和 v2.0.0 的技术栈放在一起:

项 v0.0.3 v2.0.0
Wails / Go v3.0.0-beta.9 / 1.25 v3.1.0 / 1.26
UI 库 @mui/material 9 + Emotion @mantine/core 9.7.5
样式写法 <Name>.styles.ts + sx <Name>.module.css + var(--fd-*)
图标 @mui/icons-material @tabler/icons-react 3.50
图表 @mui/x-charts @mantine/charts(recharts 3)
表格 @mui/x-data-grid mantine-datatable 9.4
应用外壳 Flex,固定左侧图标栏 CSS Grid,9 种布局预设
测试 无 E2E Playwright + Wails server 模式

React 19 TypeScript 6 Vite 8 GORM 这些版本都没有动。v1.0.0 → v2.0.0 一共改了 123 个文件,新增约 3200 行代码和文档,另有 20 张自动生成的截图。

UI 底座:从 MUI 9 换到 Mantine 9

这一节的改动发生在 v1.0.0,v2.0.0 的布局系统全部建立在它之上。

依赖变化

简化后的 package.json diff 如下:

- @emotion/react        ^11.14.0
- @emotion/styled       ^11.14.1
- @mui/material         ^9.0.1
- @mui/system           ^7.3.11
- @mui/icons-material   ^9.0.1
- @mui/x-charts         ^9.3.0
- @mui/x-data-grid      ^9.3.0
+ @mantine/core         9.7.5
+ @mantine/hooks        9.7.5
+ @mantine/charts       9.7.5
+ recharts              3.10.1
+ mantine-datatable     9.4.0
+ @tabler/icons-react   3.50.0
+ clsx                  ^2.1.1

Mantine 相关的包全部锁死精确版本,@wailsio/runtime 也锁在 3.1.0。Emotion 这层 CSS-in-JS 运行时随之移除,样式回到 CSS Modules。

样式:从 sx 工厂到 CSS Modules

v0.0.3 的样式层是一个工厂函数。每个组件有一份 Foo.styles.ts,接收 theme 返回一组 SxProps,View 里先 useTheme() 再调用它;选中态没法叠加,只能把 item 整份复制成 itemActive 再改颜色。

简化代码如下:

// v0.0.3:Sidebar.styles.ts
export const sidebarStyles = (theme: Theme) => {
  const fp = theme.palette.foundation;
  return {
    item: { width: 48, height: 48, borderRadius: 1.5, color: fp.text.secondary,
            '&:hover': { backgroundColor: fp.bg.hover } },
    // 与 item 重复一遍,只改颜色
    itemActive: { width: 48, height: 48, borderRadius: 1.5, color: fp.accent,
                  backgroundColor: fp.bg.active },
  };
};

v1.0.0 改成 CSS Modules,状态交给 data 属性:

/* 双类选择器提高权重,覆盖 ActionIcon 的全局 subtle 样式 */
.item.item {
  width: 48px;
  height: 48px;
  border-radius: 6px;
  color: var(--fd-text-secondary);
}
.item.item:hover {
  background-color: var(--fd-bg-hover);
}
.item.item[data-active] {
  color: var(--fd-accent);
  background-color: var(--fd-bg-active);
}

View 侧只剩 className={classes.item} data-active={isActive || undefined},不再需要 useTheme()。覆盖 Mantine 默认样式时用双类选择器或 classNames={{ root, input }},不用 !important。

迁移时最容易错的是单位换算。sx 里的数字会按主题自动换算,写进 CSS 必须换成显式 px:

sx 写法 换算规则 例子
p m gap 等间距 × 8px py: 1.5 → padding-block: 12px
borderRadius × 4px borderRadius: 1.5 → 6px
width height 等尺寸 直接加 px width: 64 → 64px
'&:hover' '&.Mui-selected' 独立 CSS 规则 选中态改用 [data-active]

v0.0.3 的 Sidebar.styles.ts 里有一行 borderRadius: 1.5, // 12px,注释是错的,实际渲染是 1.5 × 4 = 6px。❗ 迁移时按规则换算,不要照抄注释。

主题:调色板编译成 CSS 变量

Foundation 的主题预设是一份 FoundationPalette,16 个语义槽位:背景 7 个 文字 3 个 分隔线 强调色 强调色悬停 以及 3 个状态色。这份结构在迁移中一个字段都没改,变的是它怎么交给 UI 库。

这张图回答一个问题:同一份调色板,怎么同时驱动自己写的样式和 Mantine 的原生组件。

主题编译链路.chart

buildMantineTheme 做了三件事:

  1. 调色板写成 :root 上的 --fd-* 变量,组件样式只引用变量。
  2. accent 分别与白 黑混色,生成 10 级色阶 foundation 并设为 primaryColor;shade 6 是 accent,shade 7 是 accentHover,正好对上 Mantine filled 按钮取 primaryShade + 1 作为悬停色的规则。
  3. Mantine 自己的语义变量全部指向 --fd-*,比如 --mantine-color-body → --fd-bg-content,没写任何样式的 Mantine 组件也跟随预设。

这一步的关键是:切换主题只是替换变量值,组件不需要重新渲染。v0.0.3 的 sx 是渲染时按 theme 计算的,主题一变,所有调用了 useTheme() 的组件都要重算。

业务代码取色按优先级分三种:

  • CSS 里写 var(--fd-xxx),覆盖绝大多数场景;
  • JS 只需要引用颜色时(图表的 color 内联 style),用 FOUNDATION_CSS_VARS.xxx,值就是字符串 'var(--fd-xxx)';
  • 必须拿到真实色值时(canvas 颜色计算),用 useFoundationPalette()。

全局默认值也收在这一层:ActionIcon 默认 variant: 'subtle',Tooltip 默认带箭头且延迟 300ms,圆角档位是 xs 2px / sm 4px / md 8px / lg 12px,按钮用 4–6px,容器用 8px。

组件 图标与图表

v0.0.3 实际用到的 MUI 组件不多,常用的映射关系如下:

MUI Mantine 要注意的差异
Button Button contained/outlined/text → filled/outline/subtle,startIcon → leftSection
IconButton ActionIcon 必须带 aria-label={t(...)}
Typography Text / Title 颜色属性是 c,项目里一般直接用 class
TextField TextInput helperText → description
Tooltip title= Tooltip label= placement → position
ButtonBase UnstyledButton 设置页的卡片和列表项

图标统一换成 Tabler,写法固定为 <IconXxx size={20} stroke={1.75} />,尺寸只用 14 16 18 20 24 几档。路由表的图标类型也从 ComponentType<{ fontSize }> 改成了 Tabler 的 Icon。

图表换成 @mantine/charts,数据结构从“series[].data + 坐标轴配置”变成“行数组 + dataKey + series”。这里有一个方向陷阱:MUI 的 layout="horizontal"(类目在 Y 轴的横向条形图)对应 Mantine 的 orientation="vertical",两边的命名正好相反。

X-Pro 组件重写

上一个版本为了不买 MUI X 的 Pro / Premium 授权,自己实现了一套 DataGrid 增强和 8 种图表。底座一换,这一层也要跟着重写。

DataGridPro / DataGridPremium 改为基于 mantine-datatable,不再继承 MUI 的 DataGridProps,变成带泛型的独立 props。列定义随之改名:

// v0.0.3
const columns: GridColDef[] = [
  { field: 'salary', headerName: t('xpro.col.salary'), type: 'number', width: 120 },
];

// v1.0.0:field → accessor,renderCell → render(row, index),valueGetter 只接收行对象
const columns: DataGridColumn<DemoRow>[] = [
  { accessor: 'salary', headerName: t('xpro.col.salary'), type: 'number', width: 120 },
];

表头新增了列菜单(排序 钉住 隐藏 管理列)和列宽拖拽。mantine-datatable 自带的列宽调整依赖 storeColumnsKey,底层写的是 localStorage,而 Foundation 规定跨会话状态只能走 SQLite,因此列宽由 useColumnResizing 自己管理:指针捕获加 rAF 节流,松手后吞掉浏览器补发的那次 click 避免误触发排序,双击手柄恢复默认宽度。需要记住列宽时,在 onColumnWidthChange 回调里写进 PreferencesService 即可。

这是 X-Pro 演示页里重写后的表格:

page-xpro-grid

导出也换了路径。v0.0.3 的 Excel 导出会动态 import('xlsx'),但这个包根本不在依赖里,功能实际不可用;v1.0.0 改成原生“另存为”对话框选路径,再由 Go 侧 exportsvc 写文件。CSV 带 UTF-8 BOM,Excel 直接双击打开中文不乱码;XLSX 用 excelize 生成,表头加粗 冻结首行 数字保持数值类型,落盘统一走 filex.WriteAtomic。

8 种图表的组件名不变,大多数改为封装 @mantine/charts。横向漏斗和甘特图例外:Recharts 的 Funnel 不支持横向,@mantine/charts 也没有甘特图,这两种用 SVG 自绘;横向漏斗的 Tooltip 仍复用 ChartTooltip,和纵向保持一致。

跨窗口主题同步

v0.0.3 的子窗口入口固定使用亮色预设,主窗口切到暗色后,弹出的确认框还是白的。v1.0.0 给 appsettings 的写入加了一次广播:写库成功后发出 appsettings:changed,payload 是变更的字段名。

主窗口 子窗口 托盘面板都订阅这个事件。FoundationThemeProvider 收到 themeChoice 或 customTheme 后防抖 200ms 再重新拉取,I18nProvider 收到 localeChoice 后同理;三个窗口不共享 React 树,状态只经过后端这一个来源。

外壳:从固定 Flex 到 CSS Grid

问题

v1.0.0 的 AppLayout 还是一个固定结构:

<>
  <TitleBar />
  <div className={classes.root}>  {/* display: flex; flex-direction: row */}
    <Sidebar />
    <main className={classes.main}><RouterOutlet /></main>
  </div>
</>

想把导航放到右边 顶部,或者嵌进标题栏,只能在外壳里加分支;每加一种形态,AppLayout 就多一层 if。

布局预设:三个维度

v2.0.0 把“外壳长什么样”抽成一份预设数据,只声明三件事:

interface LayoutPreset {
  id: string;
  labelKey: string;        // 设置页卡片标题
  descriptionKey: string;  // 设置页卡片说明
  nav: { variant: NavVariant; placement: NavPlacement };
  frame: LayoutFrame;
}
type NavVariant   = 'rail' | 'sidebar' | 'bar' | 'dock' | 'none';
type NavPlacement = 'left' | 'right' | 'top' | 'titlebar' | 'bottom' | 'float' | 'drawer';
type LayoutFrame  = 'flush' | 'inset';

variant 是导航组件的形态,placement 是导航放在哪,frame 是内容区贴边还是做成浮动卡片。

这张矩阵回答:九种内置布局分别落在哪个组合上,哪些组合是空的。

布局预设组合矩阵.chart

内置预设在 presets.ts 里各占一行:

export const BUILTIN_LAYOUTS: LayoutPreset[] = [
  p('classic', 'rail', 'left'),           // 经典:左侧图标栏(默认)
  p('sidebar', 'sidebar', 'left'),        // 展开侧栏:图标 + 文字,可折叠
  p('topnav', 'bar', 'top'),              // 顶部导航
  p('titlebar', 'bar', 'titlebar'),       // 融合标题栏
  p('right', 'rail', 'right'),            // 右侧栏
  p('dock', 'dock', 'bottom'),            // 底部 Dock
  p('floating', 'dock', 'float'),         // 悬浮 Dock
  p('inset', 'sidebar', 'left', 'inset'), // 浮动卡片
  p('focus', 'none', 'drawer'),           // 专注:菜单按钮呼出抽屉
];

业务方不想改脚手架文件的话,在 App.tsx 里调用 layoutRegistry.register(preset) 即可,设置页的卡片和缩略图会自动出现。读取时走 layoutRegistry.resolve(id),旧版本存下的值或者被卸载的自定义预设都会回落到 classic。

网格区域

AppLayout 本身只做三件事:把 data-layout data-placement data-frame 写到根节点,决定导航放进网格还是标题栏插槽,再渲染 <RouterOutlet />。真正的排版全部在 CSS 里按属性切换。

这张图对照六类 placement 各自生成的网格区域:

网格区域映射.chart

以左右两种为例:

.root {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  grid-template-areas: 'title' 'main';
}
.root[data-placement='left'] {
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-areas: 'title title' 'nav main';
}
.root[data-placement='right'] {
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas: 'title title' 'main nav';
}

列和行一律写 minmax(0, 1fr),而不是 1fr。1fr 的下限是内容的最小宽度,长表格或者不换行的代码块会把网格撑破;minmax(0, 1fr) 允许它收缩,溢出交给内容区自己滚动。

bottom 和 float 两种比较特殊:导航不单独占一格,而是和 main 共用同一个网格单元,浮在内容之上。底部 Dock 用 align-self: end 贴底居中,内容区底部让出 80px;悬浮 Dock 的那一层铺满内容区,自身 pointer-events: none,只有 Dock 本体能接收点击。

inset 外框则把根背景换成侧栏色,给 main 加 8px 外边距 1px 边框和 8px 圆角,贴着导航的那一侧外边距归零。

九种布局的实际效果如下,截图都由 E2E 脚本生成:

经典(默认) 展开侧栏 顶部导航
layout-classic layout-sidebar layout-topnav
融合标题栏 右侧栏 底部 Dock
layout-titlebar layout-right layout-dock
悬浮 Dock 浮动卡片 专注(抽屉展开)
layout-floating layout-inset layout-focus-drawer

导航组件与标题栏插槽

原来的 Sidebar 拆成了 components/Navigation 下的五个组件:

  • NavRail:64px 图标栏,左右两用,激活指示条 分隔线 Tooltip 方向跟着 placement 翻转;
  • NavSidebar:图标 + 文字,可折叠为图标栏,折叠状态存在偏好 navCollapsed 里;
  • NavBar:水平导航条,放在标题栏下方时 44px,带品牌和底部分隔线,嵌进标题栏时不超过 28px;
  • NavDock:底部横向胶囊,或者竖向悬浮加拖拽手柄;
  • NavigationDrawerButton:专注布局的菜单按钮,点开是 Mantine Drawer 加抽屉版侧栏。

它们共用一个 ViewModel useNavigation(),条目直接来自路由表的 primary 和 footer 两段,不接收 items 或 onNavigate 这类 props。Navigation.tsx 只是一个分发器,按 variant 选组件,把 placement 透传下去。旧的 @/components/Sidebar 保留为 NavRail 的别名,存量代码照样能 import。

标题栏为此多了两个插槽:leading 放在标题前(专注布局的菜单按钮),center 占据标题与窗口按钮之间的剩余宽度(融合标题栏的导航)。整条标题栏是窗口拖拽区,插槽外层会自动声明 --wails-draggable: no-drag,按钮可以点击,插槽之外的空白仍然能拖动窗口。

设置页与持久化

布局在 设置 -> 布局 里切换,每个预设一张卡片,缩略图用纯 CSS 按 variant placement frame 摆出来,整组是一个可以用键盘操作的 radiogroup。

这是布局设置页的样子:

page-settings-layout

选择写进 SQLite 的 preferences 表,新增四个键:

键 默认值 含义
layout 'classic' 当前预设 id
navCollapsed false 展开侧栏是否折叠
floatDockSide 'right' 悬浮 Dock 吸附的边
floatDockY 0.5 悬浮 Dock 中心占内容区高度的比例

PreferencesProvider 读回数据时会和默认值合并,未知键和类型不一致的值直接丢弃。旧数据库里没有这些键,读出来就是默认值,所以升级不需要写迁移脚本。

路由也顺带支持了参数:navigate('settings', { section: 'layout' }) 可以直达设置子页,首页新增的“当前布局”卡片就是这么跳过去的。

悬浮 Dock:拖拽的边界

悬浮 Dock 是九种布局里交互最复杂的一种:它竖向浮在内容区一侧,拖动顶部手柄可以移到任意高度,松手吸附到最近的左边或右边。

为什么不能进标题栏

在 macOS 上,窗口顶部 40pt 是原生拖拽区,左上角还有红绿灯。Dock 一旦被拖进这块区域,手柄上的指针事件会被系统的窗口拖拽吞掉,之后就再也拖不动了。

因此第一条约束是:Dock 必须完整地待在内容区里。这张图标出了它能停留的区间,以及三个阶段各自的夹取方式:

悬浮Dock夹取区间.chart

拖拽中:按中心点夹取

按下手柄时,先记录指针相对 Dock 中心的偏移和内容区的矩形;移动时把中心点夹在合法区间里,x 是 [halfW, 宽 - halfW],y 在此基础上上下各多留 12px(SAFE_GAP)。超出范围的位置直接截断到边界,Dock 不会跟着指针越界。

指针一旦移出手柄,pointermove 就收不到了,所以手柄调用 setPointerCapture 把后续事件锁在自己身上,同时在 window 上也挂一份监听作为兜底。

结束拖拽的条件不只是 pointerup:

// 正常松手 / 取消 / 失去捕获 / 窗口失焦 —— 一律以最后一个合法位置提交
handle.addEventListener('pointerup', finish);
window.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
handle.addEventListener('lostpointercapture', finish);
window.addEventListener('blur', finish);

blur 这一条是专门留的。如果用户在 macOS 的原生拖拽区松手,webview 收不到 pointerup,只会失焦;不监听它,Dock 就会卡在“拖拽中”的状态。

静止时:用 CSS clamp() 定位

松手后存的不是像素,而是比例:floatDockSide 按中心点落在左半还是右半决定,floatDockY 是中心点 y 除以内容区高度。窗口缩放之后,比例依然成立。

渲染时再把比例换回位置,夹取交给 CSS:

top: `clamp(${safeGap}px, calc(${y * 100}% - var(--dock-half-h)), calc(100% - 2 * var(--dock-half-h) - ${safeGap}px))`

--dock-half-h 是 Dock 实测高度的一半,由 ResizeObserver 写入。路由条目增减时 Dock 变高变矮,变量跟着更新,不需要在 JS 里监听窗口尺寸。

键盘与遮挡

手柄是一个 <button>,键盘也能操作:上下方向键每次移动 5%,左右方向键换边,双击手柄复位到右侧居中。拖拽期间 Tooltip 会被禁用,避免跟着指针闪烁。

Dock 常驻在内容区一侧,所以外壳会根据 data-dock-side 给 main 在吸附侧加 88px 内边距(Dock 宽 56px + 边距 16px + 留白),页面内容不会被它挡住。

启动防跳变

现象

偏好存在 SQLite 里,前端要等 Go 侧读回来才知道用户选了哪种布局。v1.0.0 的启动骨架在 React 首帧之后就淡出了,这时偏好往往还没回来:界面先按默认的经典布局渲染,随后再跳成用户选的悬浮 Dock。

方案

这张图对比了两种时序:

启动就绪时序.chart

v2.0.0 让骨架一直盖到偏好就位:

  1. index.html 内联的静态骨架是一个 position: fixed 的覆盖层,React 在它下面渲染;
  2. PreferencesProvider 读回偏好后把 ready 置为 true,读失败也置 true,表示“不会再变了”;
  3. useAppLayout 看到 ready 后派发 foundation:app-ready 事件;
  4. main.tsx 收到事件,等两帧 requestAnimationFrame 确认 React 已经提交并绘制,再淡出骨架。
window.addEventListener(APP_READY_EVENT, scheduleSkeletonRemoval, { once: true });
// 兜底:后端异常时也不会一直挡住
window.setTimeout(scheduleSkeletonRemoval, 1500);

用户看到的第一帧真实界面就是自己选的布局,中间没有跳变。

macOS 红绿灯对齐

现象

Foundation 在 macOS 上保留系统红绿灯,隐藏原生标题栏背景,由前端在红绿灯右侧画标题。v1.0.0 的标题栏是 36px,标题文字的中线在 18px,红绿灯却明显偏下,而且进出全屏 换系统版本时位置还会变。

原因

红绿灯的位置由 NSToolbar 的高度决定,Wails 没有直接设置坐标的 API。MacTitleBarHiddenInset 预设的 ToolbarStyle 是 Automatic,系统按窗口配置推断样式,多数情况落到 52pt 的 Unified 工具栏,红绿灯中心约 26pt。

方案

既然不能设坐标,就把工具栏样式固定下来,再让前端去对齐它。这张图把前后两种情况画在同一把刻度尺上:

红绿灯垂直对齐.chart

Go 侧显式指定紧凑工具栏:

func macTitleBar() application.MacTitleBar {
	tb := application.MacTitleBarHiddenInset
	// 紧凑工具栏:红绿灯中心稳定在约 19.5pt
	tb.ToolbarStyle = application.MacToolbarStyleUnifiedCompact
	return tb
}

前端在 macOS 上把标题栏改成 40px,中线 20px,与红绿灯对齐;InvisibleTitleBarHeight 也设成 40,整条标题栏都能拖动窗口。高度只通过 CSS 变量 --fd-titlebar-h 控制,index.html 里有一段内联脚本在首帧前写入 data-platform="darwin",启动骨架和 React 标题栏用的是同一个高度。

❗ 这个对齐涉及三处:window_darwin.go 的 ToolbarStyle 和 InvisibleTitleBarHeight,global.css 的 --fd-titlebar-h,index.html 的骨架样式。改任意一处都要同步另外两处。

效果

实测红绿灯与标题文字 标题栏中线的偏差不超过 1pt,这是 macOS 上的原生截图:

macos-native

E2E 测试与自动截图

现象

v2.0.0 要在 README 里放九种布局加三套主题的截图。手工截图既费时,界面一改又会整批过期;悬浮 Dock 这种拖拽交互,靠手工点一遍也很难覆盖到边界情况。

方案

Wails 支持 server 模式:用 -tags server 构建出的二进制不开原生窗口,直接以 HTTP 提供同一套前端,加上全部 Go service 和事件。Playwright 驱动浏览器访问它,测到的就是真实后端,而不是 mock。

这张图展示从构建到测试的整条链路:

E2E测试链路.chart

数据隔离靠一个新的环境变量 FOUNDATION_DATA_DIR。设置后,数据库 主密钥 日志三者的根目录都改到这个路径,不再使用系统用户目录:

func userDataDir() (string, error) {
	// 便携版 / E2E 测试 / server 模式隔离数据
	if v := os.Getenv(DataDirEnv); v != "" {
		return v, nil
	}
	// 否则按平台规范:%AppData% / Application Support / XDG
	// ...
}

playwright.config.ts 的 webServer 每次先清空 .e2e-data/,再带着 FOUNDATION_DATA_DIR 启动后端,所以测试永远从一个空库开始,也碰不到开发者自己的真实数据。测试共用一个后端和一个数据库,workers 固定为 1 串行执行;浏览器默认用本机的 Edge,免去下载。

两类用例

floating-dock.spec.ts 是悬浮 Dock 的回归测试,按用户操作的路径走:

  1. 进入 设置 -> 布局,选择悬浮 Dock;
  2. 把手柄拖到窗口左上角(红绿灯的位置),断言 Dock 顶边在标题栏下方,并且吸附到了左侧;
  3. 再拖到右下角,断言手柄仍然可拖 吸附到右侧 没有越过底边;
  4. 刷新页面,断言位置和刷新前相差不到 4px。

screenshots.spec.ts 负责生成 README 截图。它不点界面,而是直接用 sqlite3 把布局和主题写进 E2E 数据库再刷新页面,保证每张图可复现;同时把 navigator.platform 伪装成 Win32,让标题栏按 Windows 样式渲染。上面九张布局截图都是这样生成的。

wails3 task build:server        # 产物 bin/foundation-server
cd frontend && pnpm e2e         # 全部用例
pnpm e2e:screenshots            # 只重新生成 docs/screenshots/

浏览器里没有原生窗口,托盘图标 红绿灯 原生对话框这三样仍然要在桌面构建里手动验证。FOUNDATION_DATA_DIR 同样可以用来做便携版,把数据目录指到程序旁边即可。

升级要点

从 v1.0.0 升级到 v2.0.0,破坏性变更只有三条:

  • AppLayout 重写为 CSS Grid,fork 里改过外壳的,需要对照新的 useAppLayout() 合并,它现在还负责发出启动就绪信号;
  • components/Sidebar 的实现迁到了 components/Navigation/NavRail,Sidebar 仍可 import,但 Sidebar.module.css 已删除;
  • macOS 标题栏从 36px 改为 40px,写死过高度的地方要改成 var(--fd-titlebar-h)。

从 v0.0.3 直接升级,要先按迁移指南第 1–8 节完成 MUI 到 Mantine 的迁移,再看第 9 节。AI 协作文档也跟着换了:11 份 foundation-mui* skill 合并为 5 份 foundation-mantine*,v2.0.0 又新增了 foundation-layout,记录上面这套预设和约束。


把会变的东西抽成数据和变量,把容易坏的地方交给真实后端的测试去守,脚手架才扛得住下一次大改。