普通视图
Coco AI 技术演进:Shadcn UI + Tailwind CSS v4.0 深度迁移指南 (踩坑实录)
摘要:本文深度复盘了 Coco AI 项目在引入 shadcn/ui 组件库的同时,激进升级至 Tailwind CSS 4.0 的技术细节。重点剖析了在 Vite + Tsup (Esbuild) 双构建工具链下的兼容性方案,以及如何处理
tailwind.config.js与 CSS-first 配置模式的冲突,为维护大型遗留项目的开发者提供一份“硬核”避坑指南。
前言:为什么要自找麻烦?
在 Coco AI 的开发过程中,我们面临着大多数成长期项目都会遇到的痛点:
- UI 碎片化:早期的手写 CSS 与后期的 Tailwind Utility Class 混杂,维护成本极高。
- 重复造轮子:为了一个带键盘导航的 Dropdown,我们可能写了 500 行代码,且 Bug 频出。
引入 shadcn/ui 是为了解决组件复用问题,而升级 Tailwind CSS v4.0 则是为了追求极致的构建性能(Rust 引擎)。当这两者在这个拥有大量遗留代码的项目中相遇时,一场“构建工程化的风暴”不可避免。
本文不谈虚的,直接上干货。
难点一:Vite 与 Tsup 的“双轨制”构建困局
Coco AI 不仅是一个 Web 应用,还包含一个对外提供的 SDK。这就导致我们有两套构建流程:
- Web App: 使用 Vite (Rollup)。
- Web SDK: 使用 Tsup (Esbuild)。
Tailwind v4 推荐使用 @tailwindcss/vite 插件,这在 Web App 中运行良好。但在 SDK 构建中,Esbuild 并不支持该插件。
解决方案:混合编译策略
我们被迫采用了一套“混合”方案:Web 端享受 v4 的插件红利,SDK 端则回退到 PostCSS 处理。
1. Web 端 (Vite)
一切从简,使用官方插件。
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
// 这里的 tailwindcss() 会自动扫描文件,性能极快
plugins: [tailwindcss() as any, react()],
});
2. SDK 端 (Tsup/PostCSS)
这是最坑的地方。Tsup 基于 Esbuild,而 Esbuild 默认无法解析 v4 的 @import "tailwindcss";。我们需要手动配置 PostCSS 管道。
首先,配置 postcss.config.js,显式使用 v4 的 PostCSS 插件:
// postcss.config.js
export default {
plugins: {
// ⚠️ 注意:Tailwind v4 的 PostCSS 插件包名变了
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
然后,在 tsup.config.ts 中施展“魔法”:
// tsup.config.ts
export default defineConfig({
esbuildOptions(options) {
// 🔥 关键黑魔法:启用 'style' 条件,让 esbuild 能找到 tailwindcss 的入口
(options as any).conditions = ["style", "browser", "module", "default"];
},
async onSuccess() {
// 构建后手动运行 PostCSS,处理 CSS 文件中的 @import "tailwindcss"
// ...代码略,见源码...
}
});
难点二:JS 配置与 CSS 配置的“博弈”
Tailwind v4 推崇 CSS-first,即把配置都写在 CSS 的 @theme 块中。但 shadcn/ui 强依赖 tailwindcss-animate 插件,且我们有大量复杂的自定义动画(如打字机效果、震动效果)写在 tailwind.config.js 中。
如果完全迁移到 CSS,工作量巨大且易出错。
解决方案:JS 与 CSS 共存
我们保留了 tailwind.config.js,主要用于存放插件和复杂动画,而将颜色变量迁移到 CSS 中。
保留的 tailwind.config.js (部分):
import animate from "tailwindcss-animate";
export default {
// v4 会自动检测并合并这个配置
theme: {
extend: {
// 复杂的 Keyframes 还是写在这里比较清晰
animation: {
typing: "typing 1.5s ease-in-out infinite",
shake: "shake 0.5s ease-in-out",
},
keyframes: {
typing: {
"0%": { opacity: "0.3" },
"50%": { opacity: "1" },
"100%": { opacity: "0.3" },
},
// ...
},
// 映射 border-radius 到 CSS 变量,适配 shadcn
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [animate], // shadcn 必需的插件
};
新的 src/main.css (v4 风格):
@import "tailwindcss";
/* ⚠️ 必坑点:显式指定扫描源,否则可能漏掉 HTML 或特定目录 */
@source "../index.html";
@source "./**/*.{ts,tsx}";
@theme {
/* 在 CSS 中通过变量映射颜色,不仅支持 shadcn,还能兼容旧代码 */
--color-background: var(--background);
--color-primary: var(--primary);
/* ... */
}
难点三:颜色空间与暗色模式的“大一统”
Coco AI 的旧代码使用 RGB 值(如 rgb(149, 5, 153)),而 shadcn 使用 HSL(如 222.2 84% 4.9%),Tailwind v4 默认又倾向 OKLCH。
解决方案:变量映射层
我们在 main.css 中建立了一个“中间层”,让新老变量和谐共存。
:root {
/* === Shadcn 系统 (HSL) === */
--primary: 221.2 83.2% 53.3%;
/* === Coco Legacy 系统 (RGB) === */
/* 即使是旧变量,也可以根据需要调整,或者直接硬编码保留 */
--coco-primary-color: rgb(149, 5, 153);
}
/* ⚠️ v4 暗色模式新语法:废弃了 darkMode: 'class' */
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
.dark.coco-container,
[data-theme="dark"] {
/* 重新定义 HSL 值实现暗色模式 */
--background: 222.2 84% 4.9%;
/* 同时覆盖旧系统的变量 */
--coco-primary-color: rgb(149, 5, 153);
}
难点四:Web SDK 的 CSS 变量兼容性黑科技
在开发 Web SDK 时,我们遇到一个隐蔽的问题:CSS 变量的初始值丢失。
Tailwind v4 会生成大量的 CSS Houdini @property 规则来定义变量的类型和初始值:
@property --tw-translate-x {
syntax: "*";
inherits: false;
initial-value: 0;
}
这在现代浏览器中运行完美。但由于我们的 SDK 会被嵌入到各种宿主环境中,部分环境可能不支持 @property,导致变量因为没有显式的赋值而失效(initial-value 被忽略)。
解决方案:构建后脚本补全 (Post-build Script)
为了保证“即插即用”的稳定性,我们编写了一个专门的构建后处理脚本 scripts/buildWebAfter.ts。
它的作用是:扫描生成的 CSS,提取所有 @property 的 initial-value,并将它们显式注入到 .coco-container 作用域中。
// scripts/buildWebAfter.ts (精简版)
const extractCssVars = () => {
const cssContent = readFileSync(filePath, "utf-8");
const vars: Record<string, string> = {};
// 正则提取所有 @property 的 initial-value
const propertyBlockRegex = /@property\s+(--[\w-]+)\s*\{([\s\S]*?)\}/g;
while ((match = propertyBlockRegex.exec(cssContent))) {
const initialValueMatch = /initial-value\s*:\s*([^;]+);/.exec(match[2]);
if (initialValueMatch) {
vars[match[1]] = initialValueMatch[1].trim();
}
}
// 生成标准的 CSS 变量赋值块
const cssVarsBlock =
`.coco-container {\n` +
Object.entries(vars)
.map(([k, v]) => ` ${k}: ${v};`) // 显式赋值:--var: value;
.join("\n") +
`\n}\n`;
writeFileSync(filePath, `${cssContent}\n${cssVarsBlock}`, "utf-8");
};
效果:即使浏览器不支持 @property,变量也能通过标准的 CSS 级联机制获得正确的初始值,确保 SDK 在任何环境下样式都不崩坏。
避坑清单 (Checklist)
在迁移过程中,我们踩了无数坑,以下是血泪总结:
-
样式莫名丢失?
- 原因:Tailwind v4 的自动扫描可能没覆盖到你的文件结构。
-
解法:使用
@source指令显式添加路径,如@source "./**/*.{ts,tsx}";。
-
VS Code 满屏报错?
-
原因:VS Code 的 Tailwind 插件版本过低,不认识
@theme、@source等新指令。 - 解法:升级插件到最新版,并确保设置中关联了正确的文件类型。
-
原因:VS Code 的 Tailwind 插件版本过低,不认识
-
构建时报错
Cannot find module?-
原因:
postcss.config.js中引用了不存在的插件。 -
解法:确认安装了
@tailwindcss/postcss并在配置中正确引用(注意包名变化)。
-
原因:
-
动画不生效?
-
原因:
tailwind.config.js未被 Vite 插件读取。 -
解法:在使用
@tailwindcss/vite时,它通常会自动检测根目录下的配置文件。如果位置特殊,需手动指定。
-
原因:
小结
技术债是还不完的,但每一次还债都是一次成长的机会。
通过这次适配,Coco AI 不仅拥有了更现代化的 UI 架构,也为未来的跨平台(Web/Desktop/Mobile)统一体验打下了基础。特别是 Tailwind CSS v4.0 的引入,虽然初期配置略显折腾,但其带来的构建速度提升和开发体验优化,绝对是“真香”定律的又一次验证。
如果你也想体验一下这个“整容”后的全能生产力工具,欢迎来我们的 GitHub 看看:
- GitHub: github.com/infinilabs/…
- Website: coco.rs/en
Tauri (21)——窗口缩放后的”失焦惊魂”,游戏控制权丢失了
背景
在上一篇文章中,我们分享了如何在 Coco AI 中实现丝滑的 NSPanel 窗口全屏体验。然而,全屏只是第一步,真正的挑战往往隐藏在细节之中。
Coco AI 的核心亮点之一是其日渐强大的插件生态(Extensions)。用户可以通过安装插件,在 Coco 的悬浮窗中直接运行各种 Web 应用,甚至玩 Doom 这样的小游戏。
但我们在开发过程中遇到了一个非常影响心情的 Bug:
当用户正沉浸在游戏中,觉得窗口太小而点击“全屏”后,突然发现键盘失灵了——WASD 怎么按都没反应,手动点画面也不能继续操作...
对于一款追求极致体验的生产力工具来说,这种“断触”是不可接受的。今天我们就来深度复盘这个**焦点丢失(Focus Loss)**问题,以及我们在 Coco App 中的“组合拳”解决方案。
场景复现
- 用户在 Coco AI 中启动了一个游戏插件(通过
iframe加载)。 - 初始窗口较小,用户通过 WASD 控制角色移动,一切正常。
- 用户点击右上角的“全屏”按钮,希望获得沉浸体验。
- 窗口瞬间变大铺满屏幕,但此时按下 W 键,角色纹丝不动。
- 用户拿起鼠标不断的点击一下游戏画面,控制权也未能恢复。
为什么会失焦?
这个问题的根源在于 DOM 树的重建和窗口系统的焦点管理机制,特别是在 React + Tauri 的混合架构下:
-
DOM 重绘/重排:当窗口从悬浮模式切换到全屏模式时,React 组件可能会因为状态变化(如
scale缩放系数改变、layout模式切换)而重新渲染。如果iframe在这个过程中被卸载并重新挂载,它就是一个全新的iframe,之前的焦点自然荡然无存。 -
Native 窗口焦点转移:调用 Tauri 的
setWindowSize或setFullscreen等底层 API 时,操作系统可能会暂时把焦点从 WebView 内容区域移开,转移到窗口边框或系统层级。 -
Iframe 的隔离性:
iframe内部是一个独立的window上下文。主文档(Parent)获得焦点并不意味着iframe获得焦点。你需要显式地把焦点“传递”进去。
Coco AI 的解决方案:全方位焦点守护
为了确保用户体验的连贯性,我们在 ViewExtension.tsx 组件中实施了一套多层级的焦点管理策略。
第一招:组件挂载后的自动聚焦
在 React 中,利用 ref 和 onLoad 事件,确保插件加载完毕的那一刻,焦点就自动锁定在它身上。
<div
// 绑定 Ref
ref={iframeRef}
// 任何点击外层容器的操作,都把焦点送给 iframe
onClickCapture={() => {
iframeRef.current?.focus();
}}
>
<iframe
ref={iframeRef}
src={fileUrl}
// Iframe 加载完毕瞬间聚焦
onLoad={(event) => {
event.currentTarget.focus();
try {
// 尝试深入聚焦到 iframe 内部的 window
iframeRef.current?.contentWindow?.focus();
} catch (e) {
console.warn("Failed to focus iframe content:", e);
}
}}
// 允许必要的权限:全屏、鼠标锁定(FPS游戏必备)、手柄
allow="fullscreen; pointer-lock; gamepad"
tabIndex={-1}
/>
</div>
第二招:状态变化后的延迟聚焦
当你执行全屏或缩放操作后,Native 层的窗口调整是异步的,React 的渲染也是异步的。如果你立即调用 focus(),可能 DOM 还没稳,或者窗口还在动画中,导致聚焦失败。
我们的秘诀是 setTimeout,等待一轮事件循环:
const applyFullscreen = useCallback(async (next: boolean) => {
// ... 执行窗口大小调整逻辑 ...
// 等待系统窗口调整完成且 React 渲染完毕
setTimeout(() => {
// 1. 聚焦 iframe 元素本身
iframeRef.current?.focus();
try {
// 2. 尝试聚焦 iframe 内部内容(处理跨域限制时可能报错,加 try-catch)
iframeRef.current?.contentWindow?.focus();
} catch {}
}, 0);
}, []);
第三招:显式的“焦点救生圈”
为了应对浏览器安全策略限制脚本自动聚焦等极端情况,我们在 UI 上设计了一个显式的 Focus 按钮。这不仅是一个功能补救,也是一个视觉提示。
{/* Focus helper button */}
<button
aria-label="Focus Game"
className="absolute top-2 right-12 z-10 p-2 bg-black/50 hover:bg-black/70 rounded text-white transition-colors"
onClick={() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}}
>
<FocusIcon className="size-4"/>
</button>
当用户发现控制失灵时,潜意识会寻找界面上的交互点,点击这个按钮就能瞬间找回焦点。
第四招:事件捕获(Capture Phase)
有时候用户点击了窗口边缘的空白区域(padding 或 margin),焦点会跑回主文档 body。我们可以通过在容器上监听 onMouseDownCapture 来拦截这些点击,强行把焦点按回 iframe。
<div
className="w-full h-full flex flex-col items-center justify-center"
// 捕获阶段拦截,比冒泡更早
onMouseDownCapture={() => {
iframeRef.current?.focus();
}}
onPointerDown={() => {
iframeRef.current?.focus();
}}
>
<iframe ... />
</div>
小结
焦点管理看似简单,但在构建像 Coco AI 这样复杂的桌面+Web 混合应用时,它直接关系到用户的沉浸感。通过这套 “主动出击 + 纵深防御 + 异步等待 + 兜底方案” 的组合拳,我们成功解决了跨平台、跨窗口尺寸下的焦点丢失问题。
现在,无论是在写代码时快速查阅文档,还是在休息时玩一把小游戏,Coco AI 都能提供无缝、流畅的交互体验。
如果你对我们的技术细节感兴趣,或者想体验一下这款全能的生产力工具,欢迎访问我们的开源仓库和官网:
- GitHub: github.com/infinilabs/…
- Website: coco.rs
Tauri (20)——为什么 NSPanel 窗口不能用官方 API 全屏?
在基于 Electron 或 Tauri 开发 macOS 桌面应用时,我们经常会遇到一种特殊的窗口类型:NSPanel。它通常用于 spotlight 搜索栏、悬浮工具条等场景。然而,当我们想给这种“小窗口”加上全屏能力(比如玩游戏、看大图)时,往往会撞上一堵墙:官方的全屏 API 对 NSPanel 并不友好,甚至直接失效。
项目背景:Coco AI
我们在构建 Coco AI —— 这款集成了统一搜索、协作与 AI 助手的跨平台桌面生产力工具时,遇到了一个有趣的技术挑战。
Coco AI 强大的插件系统(Extensions)允许用户在应用内直接运行各种工具(如小游戏、可视化图表、Web 应用等)。为了保持轻量和随手即用的体验,Coco 默认使用类似于 Spotlight 的悬浮窗(NSPanel)展示。但当用户想要沉浸式地使用插件(比如玩个小游戏)时,默认的小窗口就显得局促了。
我们希望实现的效果是:平时召之即来挥之即去,需要时一键变身全屏工作台。
然而,官方的窗口 API 在 NSPanel 上却频频“翻车”。今天就来复盘一下我们是如何在 Coco App 中解决这个问题的。
应用场景:一个嵌入式小游戏窗口
假设我们在开发一个类似于 Spotlight 的启动器,平时它是一个悬浮在屏幕中央的小框。但我们允许用户通过插件系统加载一个网页(比如 HTML5 游戏)。
![]()
需求很直接:
- 默认窗口大小固定(如 1200x900)。
- 用户点击“全屏”按钮,窗口瞬间铺满当前屏幕。
- 再次点击,恢复原状。
技术栈:
- Tauri (Rust + WebView)
- Frontend: React + TypeScript
-
Window Type:
NSPanel(macOS)
遇到的坑:NSPanel 与 setFullscreen 的爱恨情仇
在普通的 NSWindow 中,调用 Tauri 的 window.setFullscreen(true) 或 Electron 的 setFullScreen(true),系统会自动创建一个新的 Space,把窗口平铺进去,非常优雅。
但 Coco AI 为了追求“极致的快速启动与无感交互”,使用了 NSPanel 并设置了较高的窗口层级。当我们试图对它调用标准全屏 API 时:
- 系统动画冲突:由于没有标准标题栏,系统全屏动画可能会导致窗口消失、闪烁甚至错位。
- 多屏支持噩梦:用户在副屏唤起 Coco AI,点击全屏,结果窗口直接飞回了主屏。
- 状态不可逆:退出全屏后,窗口焦点和层级可能回不到原来的状态,打断了用户的心流。
简单来说,官方 API 是给 “标准应用窗口” 设计的,并不适配我们这种 “灵动挂件”。
解决方案:手动接管窗口布局
既然系统 API 不懂我们的心,我们就自己动手,“伪造”一个全屏效果。通过 “手动计算 + 逻辑坐标转换” 来优雅解决这个问题。
核心原理
- 精准定位:获取当前鼠标所在的显示器(Monitor),确保“在哪里唤起,就在哪里全屏”。
-
坐标系转换:macOS 使用逻辑像素(Logical Pixel),而底层屏幕信息往往是物理像素(Physical Pixel),必须通过
scaleFactor进行转换,否则窗口会巨大无比或只有四分之一大。 -
暴力美学:直接修改窗口的
x, y, width, height,使其完美覆盖目标屏幕的 Bounds。 - 状态快照:在变身前,记住原来的位置和大小,以便随时缩回那个熟悉的“小框框”。
Coco AI 的实现代码
以下是我们在 ViewExtension.tsx 中的核心实现逻辑。
const applyFullscreen = useCallback(
async (next: boolean) => {
if (next) {
// 1. 状态快照:保存当前位置、大小、是否可调整
const size = await platformAdapter.getWindowSize();
const resizable = await platformAdapter.isWindowResizable();
const pos = await platformAdapter.getWindowPosition();
fullscreenPrevRef.current = {
width: size.width,
height: size.height,
resizable,
x: pos.x,
y: pos.y,
};
// 2. 针对 macOS + Tauri (NSPanel) 的特殊处理
if (isMac && isTauri) {
// 关键步:获取鼠标所在的屏幕,实现“原位全屏”
const monitor = await platformAdapter.getMonitorFromCursor();
if (!monitor) return;
const window = await platformAdapter.getCurrentWebviewWindow();
const factor = await window.scaleFactor();
// 3. 坐标转换:物理像素 -> 逻辑像素
const { size, position } = monitor;
const { width, height } = size.toLogical(factor);
const { x, y } = position.toLogical(factor);
// 4. 手动铺满屏幕
await platformAdapter.setWindowSize(width, height);
await platformAdapter.setWindowPosition(x, y);
await platformAdapter.setWindowResizable(true); // 全屏模式下通常允许调整
await recomputeScale(); // 调整内部 Web 内容的缩放比例
} else {
// 其他平台使用标准 API 即可
await platformAdapter.setWindowFullscreen(true);
await recomputeScale();
}
} else {
// 5. 退出全屏:恢复如初
if (!isMac) {
await platformAdapter.setWindowFullscreen(false);
}
// 从配置或默认值恢复大小
const nextWidth = ui?.width ?? DEFAULT_VIEW_WIDTH;
const nextHeight = ui?.height ?? DEFAULT_VIEW_HEIGHT;
await platformAdapter.setWindowSize(nextWidth, nextHeight);
await platformAdapter.setWindowResizable(ui?.resizable ?? true);
// 关键步:居中回原来的屏幕
await platformAdapter.centerOnCurrentMonitor();
await recomputeScale();
// 6. 焦点修复(防止操作中断)
setTimeout(() => {
iframeRef.current?.focus();
}, 0);
}
},
[ui, recomputeScale]
);
为什么这样做体验更好?
- 瞬时响应:没有了系统全屏动画的拖泥带水,点击即全屏。
- 多屏友好:完美支持多显示器环境,不再发生“窗口瞬移”的灵异事件。
- UI 自由度:保留了我们自定义的 UI 控件,不受系统标题栏的干扰。
小结
在开发 Coco AI 的过程中,我们始终坚持 “不因为技术限制而妥协用户体验” 。虽然手动管理窗口状态比调用一个 API 麻烦得多,但为了让用户在使用插件时能有丝滑的体验,这一切都是值得的。
如果你对我们的技术栈(Rust + Tauri + React)感兴趣,或者想体验一下这个“既能小巧悬浮,又能全屏沉浸”的生产力工具,欢迎访问我们的 GitHub 仓库和官网:
- GitHub: github.com/infinilabs/…
- Website: coco.rs/en