Files
BoosAPI/desktop/libs/markstream-vue/design/architecture.md
T
boos e0a5f57793 feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
2026-06-22 11:32:57 +08:00

18 KiB
Raw Blame History

样式架构方案

核心决策:带默认样式 + CSS 变量全量可定制,非 headless。 shadcn 兼容:继承宿主变量,不覆盖;无宿主时回退到内置默认值。


设计原则

  1. 开箱即用 — 引入即有完整合理的 Markdown 渲染样式
  2. 变量换肤 — 用户只需覆盖 CSS 变量即可定制全部视觉表现
  3. 零硬编码 — 组件内不允许出现硬编码的色值、间距等魔法数字
  4. Light/Dark 内建 — 两套主题开关切换,暗色模式完整覆盖所有组件
  5. 不污染宿主 — 所有样式限定在 .markstream-vue 作用域内
  6. 不覆盖宿主 — 不重定义 shadcn 同名变量,通过继承 + 回退兼容

变量不冲突策略

问题

如果我们在 .markstream-vue 上直接定义 --background,会覆盖宿主从 :root 继承下来的值:

宿主 :root { --background: 210 20% 98% }   ← 用户自定义的暖灰
我们 .markstream-vue { --background: 0 0% 100% }  ← 覆盖成了纯白 ❌

解决方案:Bridge Tokens

引入 --ms-* 前缀的桥接层,用 var() 继承宿主变量,未定义时回退到默认值:

.markstream-vue {
  --ms-background: var(--background, 0 0% 100%);
  /*                    ↑ 宿主有则用宿主的   ↑ 没有则用回退值 */
}

shadcn 宿主:

:root { --background: 210 20% 98% }     ← 宿主定义
  └─ .markstream-vue 继承 --background = 210 20% 98%
       └─ --ms-background: var(--background, 0 0% 100%)
            = 210 20% 98%  ✅ 宿主主题穿透生效

独立使用(无 shadcn):

:root 未定义 --background
  └─ .markstream-vue 未继承到 --background
       └─ --ms-background: var(--background, 0 0% 100%)
            = 0 0% 100%  ✅ 回退值生效,开箱即用

分层架构

┌──────────────────────────────────────────────┐
│  Layer 4: Dark Theme                         │  仅覆盖 Layer 1 bridge + ext 的回退值
│  .dark .markstream-vue { --ms-bg: var(...) } │
├──────────────────────────────────────────────┤
│  Layer 3: Component Styles                   │  引用 Layer 2 变量,不允许硬编码
│  .heading { color: var(--blockquote-fg) }    │
├──────────────────────────────────────────────┤
│  Layer 2: Semantic Tokens                    │  组件级变量,引用 Layer 1
│  --code-bg: hsl(var(--ms-background));       │
├──────────────────────────────────────────────┤
│  Layer 1: Base Tokens                        │
│  ├─ Bridge (--ms-*): 继承 shadcn + 回退     │
│  └─ Extension (--*): 本项目独有             │
└──────────────────────────────────────────────┘

Layer 1: Base Tokens

1.1 Bridge Tokens(--ms-*,继承 shadcn 同名变量)

内部桥接用。用户定制时直接覆盖 shadcn 变量名(如 --background)或覆盖 semantic token 即可。

Light(默认)

.markstream-vue {

  /* ── 背景 / 前景 ── */
  --ms-background:            var(--background,            0 0% 100%);
  --ms-foreground:            var(--foreground,            222.2 84% 4.9%);

  /* ── 弱化 ── */
  --ms-muted:                 var(--muted,                 210 40% 96.1%);
  --ms-muted-foreground:      var(--muted-foreground,      215.4 16.3% 46.9%);

  /* ── 次要 ── */
  --ms-secondary:             var(--secondary,             210 40% 96.1%);
  --ms-secondary-foreground:  var(--secondary-foreground,  222.2 47.4% 11.2%);

  /* ── 强调 ── */
  --ms-accent:                var(--accent,                210 40% 96.1%);
  --ms-accent-foreground:     var(--accent-foreground,     222.2 47.4% 11.2%);

  /* ── 主要操作 ── */
  --ms-primary:               var(--primary,               222.2 47.4% 11.2%);
  --ms-primary-foreground:    var(--primary-foreground,     210 40% 98%);

  /* ── 危险 ── */
  --ms-destructive:           var(--destructive,           0 84.2% 60.2%);
  --ms-destructive-foreground: var(--destructive-foreground, 210 40% 98%);

  /* ── 边框 / Focus ── */
  --ms-border:                var(--border,                214.3 31.8% 91.4%);
  --ms-ring:                  var(--ring,                  222.2 84% 4.9%);

  /* ── 弹出层 ── */
  --ms-popover:               var(--popover,               0 0% 100%);
  --ms-popover-foreground:    var(--popover-foreground,     222.2 84% 4.9%);

  /* ── 圆角 ── */
  --ms-radius:                var(--radius,                0.5rem);
}

Dark

.dark .markstream-vue,
.markstream-vue.dark {

  --ms-background:            var(--background,            222.2 84% 4.9%);
  --ms-foreground:            var(--foreground,            210 40% 98%);

  --ms-muted:                 var(--muted,                 217.2 32.6% 17.5%);
  --ms-muted-foreground:      var(--muted-foreground,      215 20.2% 65.1%);

  --ms-secondary:             var(--secondary,             217.2 32.6% 17.5%);
  --ms-secondary-foreground:  var(--secondary-foreground,  210 40% 98%);

  --ms-accent:                var(--accent,                217.2 32.6% 17.5%);
  --ms-accent-foreground:     var(--accent-foreground,     210 40% 98%);

  --ms-primary:               var(--primary,               210 40% 98%);
  --ms-primary-foreground:    var(--primary-foreground,     222.2 47.4% 11.2%);

  --ms-destructive:           var(--destructive,           0 62.8% 30.6%);
  --ms-destructive-foreground: var(--destructive-foreground, 210 40% 98%);

  --ms-border:                var(--border,                217.2 32.6% 17.5%);
  --ms-ring:                  var(--ring,                  212.7 26.8% 83.9%);

  --ms-popover:               var(--popover,               222.2 84% 4.9%);
  --ms-popover-foreground:    var(--popover-foreground,     210 40% 98%);
}

工作原理:

  • shadcn 宿主有 .dark { --background: X } → var(--background, fallback) 取 X → 宿主暗色穿透
  • 独立使用,无 --background → 取 fallback 中的暗色值 → 内置暗色生效

1.2 Extension Tokens(本项目独有,无冲突风险)

这些变量名 shadcn 不存在,直接定义即可。

Light

.markstream-vue {

  /* ── 语义色 ── */
  --ms-info:                  217 91% 60%;          /* 信息/备注 (#448aff) */
  --ms-info-foreground:       210 40% 98%;

  --ms-success:               168 100% 37.5%;       /* 成功/提示 (#00bfa5) */
  --ms-success-foreground:    210 40% 98%;

  --ms-warning:               34 100% 50%;          /* 警告 (#ff9100) */
  --ms-warning-foreground:    0 0% 100%;

  /* ── Diff ── */
  --ms-diff-added:            174 60% 51%;          /* 新增 (#14b8a6) */
  --ms-diff-removed:          350 100% 60%;         /* 删除 (#ff3658) */

  /* ── 其他 ── */
  --ms-highlight:             54 100% 62%;          /* 文本高亮 */
  --ms-highlight-foreground:  0 0% 0%;

  --ms-link:                  212 100% 42%;         /* 链接色 (#0366d6) */

  /* ── 排版 ── */
  --ms-font-sans:  ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
  --ms-font-mono:  ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}

Dark

.dark .markstream-vue,
.markstream-vue.dark {

  --ms-diff-added:            174 72% 70%;          /* 暗色更亮 (#5eead4) */
  --ms-diff-removed:          0 92% 82%;            /* 暗色更柔 (#fda4af) */

  --ms-highlight:             54 80% 42%;           /* 暗色降低亮度 */
  --ms-highlight-foreground:  0 0% 100%;

  --ms-link:                  213 94% 68%;          /* 暗色更亮链接 */

  /* info / success / warning 在暗色下保持不变,仍然醒目 */
}

1.3 圆角推算规则

sm   = calc(var(--ms-radius) - 2px)    → 6px
base = var(--ms-radius)                → 8px (0.5rem)
md   = calc(var(--ms-radius) + 2px)    → 10px
lg   = calc(var(--ms-radius) + 4px)    → 12px
full = 9999px

Layer 2: Semantic Tokens

全部引用 Layer 1 的 --ms-* 变量。Dark 模式下 Layer 1 变化 → Layer 2 自动跟随,无需重复定义。

.markstream-vue {

  /* ── 行内代码 ── */
  --inline-code-bg:             hsl(var(--ms-secondary));
  --inline-code-fg:             hsl(var(--ms-foreground));
  --inline-code-border:         hsl(var(--ms-border) / 0.9);

  /* ── 代码块 ── */
  --code-bg:                    hsl(var(--ms-background));
  --code-fg:                    hsl(var(--ms-foreground));
  --code-border:                hsl(var(--ms-border));
  --code-header-bg:             hsl(var(--ms-muted));
  --code-selection-bg:          hsl(var(--ms-accent) / 0.3);
  --code-line-number:           hsl(var(--ms-muted-foreground));

  /* ── 代码块操作栏 ── */
  --code-action-fg:             hsl(var(--ms-muted-foreground));
  --code-action-hover-bg:       hsl(var(--ms-accent));
  --code-action-hover-fg:       hsl(var(--ms-accent-foreground));
  --code-action-active-bg:      hsl(var(--ms-primary));
  --code-action-active-fg:      hsl(var(--ms-primary-foreground));

  /* ── Diff ── */
  --diff-added-fg:              hsl(var(--ms-diff-added));
  --diff-removed-fg:            hsl(var(--ms-diff-removed));
  --diff-added-bg:              hsl(var(--ms-diff-added) / 0.1);
  --diff-added-inline-bg:       hsl(var(--ms-diff-added) / 0.2);
  --diff-removed-bg:            hsl(var(--ms-diff-removed) / 0.1);
  --diff-removed-inline-bg:     hsl(var(--ms-diff-removed) / 0.2);

  /* ── 引用块 ── */
  --blockquote-border:          hsl(var(--ms-border));
  --blockquote-fg:              hsl(var(--ms-foreground));

  /* ── 提示框 (Admonition) ── */
  --admonition-bg:              hsl(var(--ms-muted));
  --admonition-border:          hsl(var(--ms-border));
  --admonition-fg:              hsl(var(--ms-foreground));
  --admonition-muted:           hsl(var(--ms-muted-foreground));
  --admonition-header-bg:       hsl(var(--ms-muted) / 0.5);
  --admonition-note:            hsl(var(--ms-info));
  --admonition-tip:             hsl(var(--ms-success));
  --admonition-warning:         hsl(var(--ms-warning));
  --admonition-danger:          hsl(var(--ms-destructive));

  /* ── 表格 ── */
  --table-border:               hsl(var(--ms-border));
  --table-header-bg:            hsl(var(--ms-muted));
  --table-header-fg:            hsl(var(--ms-foreground));
  --table-row-hover-bg:         hsl(var(--ms-accent) / 0.5);

  /* ── 链接 ── */
  --link-color:                 hsl(var(--ms-link));

  /* ── 列表 ── */
  --list-marker:                hsl(var(--ms-border));
  --list-counter-marker:        hsl(var(--ms-muted-foreground));

  /* ── 分割线 ── */
  --hr-border:                  hsl(var(--ms-border));

  /* ── 高亮 ── */
  --highlight-bg:               hsl(var(--ms-highlight));
  --highlight-fg:               hsl(var(--ms-highlight-foreground));

  /* ── 脚注 ── */
  --footnote-border:            hsl(var(--ms-border));

  /* ── Tooltip ── */
  --tooltip-bg:                 hsl(var(--ms-popover));
  --tooltip-fg:                 hsl(var(--ms-popover-foreground));
  --tooltip-border:             hsl(var(--ms-border));

  /* ── Modal ── */
  --modal-overlay:              hsl(0 0% 0% / 0.7);
  --modal-bg:                   hsl(var(--ms-popover));
  --modal-fg:                   hsl(var(--ms-popover-foreground));

  /* ── 图表容器 (Mermaid / D2 / Infographic) ── */
  --diagram-bg:                 hsl(var(--ms-muted));
  --diagram-border:             hsl(var(--ms-border));
  --diagram-header-bg:          hsl(var(--ms-muted));

  /* ── 数学公式 ── */
  --math-fg:                    hsl(var(--ms-foreground));

  /* ── 加载态 ── */
  --loading-spinner:            hsl(var(--ms-muted-foreground));
  --loading-shimmer:            hsl(var(--ms-muted) / 0.5);

  /* ── 图片 ── */
  --image-placeholder-bg:       hsl(var(--ms-muted));

  /* ── Focus ring ── */
  --focus-ring:                 hsl(var(--ms-ring));
}

Dark 模式下无需重复定义 Layer 2 — 因为 Layer 1 的 --ms-* 值变了,这里自动跟随。


Layer 3: Component Styles

组件中只引用 Layer 2 语义变量:

/* ❌ 之前 */
.blockquote { border-left: 0.25rem solid #e2e8f0; }
.admonition-note { border-left-color: #448aff; }
.highlight-node { background-color: #ffff00; }

/* ✅ 之后 */
.blockquote { border-left: 0.25rem solid var(--blockquote-border); }
.admonition-note { border-left-color: var(--admonition-note); }
.highlight-node { background: var(--highlight-bg); }

暗色模式统一方案

去掉组件级 .is-dark CSS,统一为:

之前:isDark prop → 每个组件 .is-dark → 30+ 组件各自覆盖 CSS 变量
之后:.dark 类 → Layer 1 bridge 回退值切换 → Layer 2 自动跟随 → 全部组件适配

保留 isDark prop 仅用于 JS 逻辑(不用于 CSS):

  • Monaco Editor 主题名切换
  • Mermaid 主题名切换
  • prefers-color-scheme 检测兜底

CodeBlockNode .is-dark 例外

CodeBlockNode 保留 .is-dark CSS 块。此处 .is-dark 的语义是**"编辑器表面为暗色"** (由 Monaco 主题检测 resolvedSurfaceIsDark 决定),与页面级 .dark 无关。

  • 一个亮色页面上可以出现暗色主题的代码块
  • .is-dark 块包含 ~15 个 diff 视图的阴影/透明度/渐变差异值
  • 这些值已全部引用 --ms-* 语义令牌,只是 opacity 系数不同

Admonition header-bg 例外

提示框标题背景需要在 dark 下使用更高透明度(6% → 12%),因此 --admonition-*-header-bg 在 .dark 区块中重新定义。这是 Layer 2 中 唯一需要 dark 覆盖的语义令牌。


shadcn 兼容矩阵

变量来源 我们的处理 用户如何定制
shadcn 同名(--background 等 14 个) --ms-* 桥接继承 + 回退 在 :root 设置 shadcn 变量,自动穿透
本项目独有(--ms-info 等 8 个) 直接定义在 .markstream-vue 在 .markstream-vue 上覆盖
组件语义(--code-bg 等 ~35 个) 引用 --ms-*,定义一次 在 .markstream-vue 上覆盖

shadcn 未采用(本项目不需要)

Token 原因
--card / --card-foreground 无 card 组件
--input 无输入框
--sidebar-* 无侧边栏
--chart-* Mermaid 自带主题

本项目扩展(shadcn 没有)

Token 用途
--ms-info / --ms-success / --ms-warning Admonition 语义色
--ms-diff-added / --ms-diff-removed Diff 视图
--ms-highlight / --ms-highlight-foreground ==高亮== 语法
--ms-link 链接色

当前控件清单

控件 出现组件 涉及 tokens
操作按钮(复制/折叠/全屏/导出) CodeBlock, Mermaid, D2, Infographic, Admonition --code-action-*, --focus-ring
字号调节按钮组 CodeBlock, MarkdownCodeBlock --code-action-*
模式切换(Preview/Source) CodeBlock, Mermaid, D2, Infographic --code-action-active-*
缩放控制 Mermaid, Infographic --code-action-*
Tooltip 全局 --tooltip-*
Modal CodeBlock, Mermaid, Infographic --modal-*
Checkbox(只读) CheckboxNode --ms-foreground
Spinner Image, Table, MathBlock --loading-spinner
Shimmer 骨架屏 CodeBlock, Table --loading-shimmer
错误态 ImageNode --ms-destructive
可点击引用 FootnoteRef, ReferenceNode --link-color

用户定制示例

场景 1:shadcn 项目(零配置接入)

// 只需引入样式,shadcn 变量自动穿透
import 'markstream-vue/dist/index.css'

宿主 :root 上的 --background、--foreground 等自动继承进 .markstream-vue。

如需补充 Markdown 扩展色:

.markstream-vue {
  --ms-info: 217 91% 60%;
  --ms-success: 142 71% 45%;
  --ms-link: 221 83% 53%;
}

场景 2:独立项目,换整体配色

/* 方式 A:覆盖 bridge 识别的 shadcn 变量名 */
:root {
  --background: 0 0% 98%;
  --foreground: 240 10% 10%;
  --border: 240 5% 85%;
}

/* 方式 B:直接覆盖 --ms-* bridge token */
.markstream-vue {
  --ms-background: 0 0% 98%;
  --ms-foreground: 240 10% 10%;
  --ms-border: 240 5% 85%;
}

场景 3:只改代码块(覆盖 semantic token)

.markstream-vue {
  --code-bg: #1e1e2e;
  --code-fg: #cdd6f4;
  --code-border: transparent;
}

场景 4:只改提示框色彩

.markstream-vue {
  --ms-info: 221 83% 53%;
  --ms-success: 142 71% 45%;
  --ms-warning: 38 92% 50%;
  --ms-destructive: 0 72% 51%;
}

已知边界情况

shadcn 宿主 + 仅对 markstream-vue 启用 dark:

<!-- 宿主全局 light,但 markstream-vue 想用 dark -->
<div class="markstream-vue dark">...</div>

此时宿主的 light --background 仍会继承进来,bridge 的 dark 回退值不会生效。 解决:shadcn 宿主应在文档级切换 .dark,或通过覆盖 semantic token 单独控制。


迁移策略

分步进行,每步可独立验证:

阶段 内容 影响范围
1 定义 Bridge Tokens + Extension Tokens(Layer 1) 仅新增变量,不破坏现有
2 定义 Semantic Tokens(Layer 2),引用 --ms-* 仅新增变量,不破坏现有
3 逐组件替换硬编码值为 Semantic Token 引用 逐组件改,可逐个验证
4 补全 Dark 的 Bridge Token 回退值 暗色模式完整
5 移除组件内 .is-dark CSS 覆盖 简化暗色逻辑
6 清理:删除废弃变量、统一动画变量 最终打磨