diff --git a/skills/ui-owners/SKILL.md b/skills/ui-owners/SKILL.md
index 83edad0..ff82321 100644
--- a/skills/ui-owners/SKILL.md
+++ b/skills/ui-owners/SKILL.md
@@ -223,6 +223,15 @@ Props: `code`, `verificationUri`, `expiresAt?`, `hint`, `retryLoading?`,以及
不要合并。已知同形状旧写法:bots ACP 面板、onboarding Step4 —— 迁移前先做各自的
行为盘点(ACP 有真 cancel API 和错误态)。
+**CodePanel** · `@felinic/ui`(`src/components/code-panel`)
+只读代码块 + 复制按钮:聊天代码块、要粘到终端运行的命令、配置片段都用它。外框是
+menu-shell 圆角(与 SettingsSection / SurfaceCard 同档,放在设置卡片旁边对得齐)。
+Props: `code`(复制的原文)、`copyLabel` / `copiedLabel` / `copyFailedMessage`(caller
+i18n)、`width: 'fit' | 'full'`(fit 贴合代码,聊天用;full 撑满行,对话框用)、`wrap`
+(一行长命令折行而不是横向滚动,含不可断的 key 也不会撑宽宿主)。语法高亮归 caller:
+把渲染好的内容放进默认 slot,不放则原样显示 `code`。不要再手写 `pre` + 绝对定位复制按钮。
+复制按钮单独是 **CopyButton**(同目录 `copy-button`),图标按钮 + 复制后打勾动效。
+
**LabelSwap**(`@felinic/ui` 原语,非 apps/web owner,顺带登记防重造)
会"换字"的内容槽:每个候选 label 一个具名 slot,`active` 指名当前项;各 label 保持
自然宽度,切换时外壳宽度按 house spring 平滑过渡、label 交叉淡出。给 Connect↔Cancel
@@ -415,6 +424,7 @@ A stat / number tile? → MetricReadout (you own the g
A vertical, centered entity/add tile? → PersonaTile
An error / warning / info notice? → CalloutBanner (bare inside a host surface)
A device-code "enter this code" moment? → DeviceCodePanel
+Read-only code / a command to copy? → CodePanel
A label that swaps with state (Connect↔Cancel)? → LabelSwap (@felinic/ui)
The page frame itself? → PageShell
A pane with nothing to show (loading/empty)? → PanePlaceholder (centered fill)
diff --git a/src/components/code-panel/CodePanel.vue b/src/components/code-panel/CodePanel.vue
new file mode 100644
index 0000000..4acafb1
--- /dev/null
+++ b/src/components/code-panel/CodePanel.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
{{ code }}
+
+
+
+
+
diff --git a/src/components/code-panel/index.ts b/src/components/code-panel/index.ts
new file mode 100644
index 0000000..e4b3f30
--- /dev/null
+++ b/src/components/code-panel/index.ts
@@ -0,0 +1 @@
+export { default as CodePanel } from './CodePanel.vue'
diff --git a/src/components/copy-button/CopyAnimatedCheckIcon.vue b/src/components/copy-button/CopyAnimatedCheckIcon.vue
new file mode 100644
index 0000000..c31e7dc
--- /dev/null
+++ b/src/components/copy-button/CopyAnimatedCheckIcon.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/copy-button/CopyButton.vue b/src/components/copy-button/CopyButton.vue
new file mode 100644
index 0000000..1c1ae60
--- /dev/null
+++ b/src/components/copy-button/CopyButton.vue
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/copy-button/index.ts b/src/components/copy-button/index.ts
new file mode 100644
index 0000000..22b919c
--- /dev/null
+++ b/src/components/copy-button/index.ts
@@ -0,0 +1 @@
+export { default as CopyButton } from './CopyButton.vue'
diff --git a/src/index.ts b/src/index.ts
index 3949377..86bece8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -10,6 +10,7 @@ export * from './components/button-group/index'
export * from './components/callout-banner/index'
export * from './components/card/index'
export * from './components/checkbox/index'
+export * from './components/code-panel/index'
export * from './components/collapsible/index'
export * from './components/command/index'
export { menuItemClass, menuLabelClass, menuContentClass, menuViewportClass, menuChromeClass, virtualListboxClass, menuAlignOffset, menuSearchHeaderClass, menuSearchInputClass, menuSeparatorClass, menuSlideClass } from './lib/menu'
@@ -18,6 +19,7 @@ export { useClipboard } from './lib/clipboard'
export * from './components/confirm-delete-dialog/index'
export * from './components/confirm-popover/index'
export * from './components/context-menu/index'
+export * from './components/copy-button/index'
export * from './components/copy-connected-icon/index'
export * from './components/date-range-picker/index'
export * from './components/device-code-panel/index'
diff --git a/src/lib/surface.ts b/src/lib/surface.ts
index 1e8ded8..0837f21 100644
--- a/src/lib/surface.ts
+++ b/src/lib/surface.ts
@@ -11,6 +11,11 @@
// Only the edge WIDTH is set here. The color is `--border` from the base layer
// (`* { @apply border-border }`), and ActionCard animates that color in
// style.css — a `border-border` utility here would outrank and freeze it.
+//
+// `border-1` rather than the bare `border` token: markstream-vue ships an
+// unlayered `.markstream-vue .border { border-width: 1px }`, which outranks
+// every layered utility and re-adds the dark-mode edge to any card rendered
+// inside markdown (the chat code fence). Same CSS, a token it does not match.
export function surfaceEdgeClass(bordered = false): string {
- return bordered ? 'border' : 'border dark:border-0'
+ return bordered ? 'border-1' : 'border-1 dark:border-0'
}