阅读视图

发现新文章,点击刷新页面。

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 的开发过程中,我们面临着大多数成长期项目都会遇到的痛点:

  1. UI 碎片化:早期的手写 CSS 与后期的 Tailwind Utility Class 混杂,维护成本极高。
  2. 重复造轮子:为了一个带键盘导航的 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,提取所有 @propertyinitial-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)

在迁移过程中,我们踩了无数坑,以下是血泪总结:

  1. 样式莫名丢失?

    • 原因:Tailwind v4 的自动扫描可能没覆盖到你的文件结构。
    • 解法:使用 @source 指令显式添加路径,如 @source "./**/*.{ts,tsx}";
  2. VS Code 满屏报错?

    • 原因:VS Code 的 Tailwind 插件版本过低,不认识 @theme@source 等新指令。
    • 解法:升级插件到最新版,并确保设置中关联了正确的文件类型。
  3. 构建时报错 Cannot find module

    • 原因postcss.config.js 中引用了不存在的插件。
    • 解法:确认安装了 @tailwindcss/postcss 并在配置中正确引用(注意包名变化)。
  4. 动画不生效?

    • 原因tailwind.config.js 未被 Vite 插件读取。
    • 解法:在使用 @tailwindcss/vite 时,它通常会自动检测根目录下的配置文件。如果位置特殊,需手动指定。

小结

技术债是还不完的,但每一次还债都是一次成长的机会。

通过这次适配,Coco AI 不仅拥有了更现代化的 UI 架构,也为未来的跨平台(Web/Desktop/Mobile)统一体验打下了基础。特别是 Tailwind CSS v4.0 的引入,虽然初期配置略显折腾,但其带来的构建速度提升和开发体验优化,绝对是“真香”定律的又一次验证。

如果你也想体验一下这个“整容”后的全能生产力工具,欢迎来我们的 GitHub 看看:

Tauri (21)——窗口缩放后的”失焦惊魂”,游戏控制权丢失了

背景

在上一篇文章中,我们分享了如何在 Coco AI 中实现丝滑的 NSPanel 窗口全屏体验。然而,全屏只是第一步,真正的挑战往往隐藏在细节之中。

Coco AI 的核心亮点之一是其日渐强大的插件生态Extensions)。用户可以通过安装插件,在 Coco 的悬浮窗中直接运行各种 Web 应用,甚至玩 Doom 这样的小游戏。

但我们在开发过程中遇到了一个非常影响心情的 Bug:

当用户正沉浸在游戏中,觉得窗口太小而点击“全屏”后,突然发现键盘失灵了——WASD 怎么按都没反应,手动点画面也不能继续操作...

对于一款追求极致体验的生产力工具来说,这种“断触”是不可接受的。今天我们就来深度复盘这个**焦点丢失(Focus Loss)**问题,以及我们在 Coco App 中的“组合拳”解决方案。

场景复现

  1. 用户在 Coco AI 中启动了一个游戏插件(通过 iframe 加载)。
  2. 初始窗口较小,用户通过 WASD 控制角色移动,一切正常。
  3. 用户点击右上角的“全屏”按钮,希望获得沉浸体验。
  4. 窗口瞬间变大铺满屏幕,但此时按下 W 键,角色纹丝不动。
  5. 用户拿起鼠标不断的点击一下游戏画面,控制权也未能恢复。

为什么会失焦?

这个问题的根源在于 DOM 树的重建和窗口系统的焦点管理机制,特别是在 React + Tauri 的混合架构下:

  1. DOM 重绘/重排:当窗口从悬浮模式切换到全屏模式时,React 组件可能会因为状态变化(如 scale 缩放系数改变、layout 模式切换)而重新渲染。如果 iframe 在这个过程中被卸载并重新挂载,它就是一个全新的 iframe,之前的焦点自然荡然无存。
  2. Native 窗口焦点转移:调用 Tauri 的 setWindowSizesetFullscreen 等底层 API 时,操作系统可能会暂时把焦点从 WebView 内容区域移开,转移到窗口边框或系统层级。
  3. Iframe 的隔离性iframe 内部是一个独立的 window 上下文。主文档(Parent)获得焦点并不意味着 iframe 获得焦点。你需要显式地把焦点“传递”进去。

Coco AI 的解决方案:全方位焦点守护

为了确保用户体验的连贯性,我们在 ViewExtension.tsx 组件中实施了一套多层级的焦点管理策略。

第一招:组件挂载后的自动聚焦

在 React 中,利用 refonLoad 事件,确保插件加载完毕的那一刻,焦点就自动锁定在它身上。

<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 都能提供无缝、流畅的交互体验。

如果你对我们的技术细节感兴趣,或者想体验一下这款全能的生产力工具,欢迎访问我们的开源仓库和官网:

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 游戏)。

image.png

需求很直接:

  1. 默认窗口大小固定(如 1200x900)。
  2. 用户点击“全屏”按钮,窗口瞬间铺满当前屏幕。
  3. 再次点击,恢复原状。

技术栈:

  • 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 时:

  1. 系统动画冲突:由于没有标准标题栏,系统全屏动画可能会导致窗口消失、闪烁甚至错位。
  2. 多屏支持噩梦:用户在副屏唤起 Coco AI,点击全屏,结果窗口直接飞回了主屏。
  3. 状态不可逆:退出全屏后,窗口焦点和层级可能回不到原来的状态,打断了用户的心流。

简单来说,官方 API 是给 “标准应用窗口” 设计的,并不适配我们这种 “灵动挂件”

解决方案:手动接管窗口布局

既然系统 API 不懂我们的心,我们就自己动手,“伪造”一个全屏效果。通过 “手动计算 + 逻辑坐标转换” 来优雅解决这个问题。

核心原理

  1. 精准定位:获取当前鼠标所在的显示器(Monitor),确保“在哪里唤起,就在哪里全屏”。
  2. 坐标系转换:macOS 使用逻辑像素(Logical Pixel),而底层屏幕信息往往是物理像素(Physical Pixel),必须通过 scaleFactor 进行转换,否则窗口会巨大无比或只有四分之一大。
  3. 暴力美学:直接修改窗口的 x, y, width, height,使其完美覆盖目标屏幕的 Bounds。
  4. 状态快照:在变身前,记住原来的位置和大小,以便随时缩回那个熟悉的“小框框”。

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 仓库和官网:

❌