Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions skills/ui-owners/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
77 changes: 77 additions & 0 deletions src/components/code-panel/CodePanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<script setup lang="ts">
// CodePanel — a read-only block of code with its copy button: the chat code
// fence, a command the user must paste into a terminal, a config snippet.
// Lifted from the host's chat code block (apps/web/src/pages/home/components/
// chat-code-block.vue, now a caller of this) so every code surface shares one
// frame. It owns ONLY the frame — the menu-shell radius (same rung as
// SettingsSection / SurfaceCard, so a code block next to a settings card lines
// up), fill, the shared card edge rule (surfaceEdgeClass: no edge in dark
// mode unless `bordered`), inset, mono type and the copy button. Highlighting is the
// caller's: pass rendered markup through the default slot; without one the
// `code` text is shown as-is.
//
// Layout is a flex row — the code sits in its own column and the copy button
// is a stable trailing sibling (top-aligned), so the block stays
// fit-to-content without a reserved gap and the button does not jitter while
// streaming content grows the width.
import { surfaceEdgeClass } from '#/lib/surface'
import { CopyButton } from '../copy-button'

withDefaults(defineProps<{
/** The exact text the copy button writes to the clipboard. */
code: string
/** Copy button accessible name at rest (caller i18n). */
copyLabel: string
/** Copy button accessible name while the check is shown (caller i18n). */
copiedLabel: string
/** Toast shown when the clipboard write fails (caller i18n). */
copyFailedMessage: string
/**
* `fit` hugs the code (chat fences, capped at the container width);
* `full` fills the row (a command in a dialog).
*/
width?: 'fit' | 'full'
/**
* Wrap long lines instead of scrolling them. Use it for a one-line shell
* command that must be read in full: `wrap-anywhere` lets an unbroken
* token (a key, a URL) break so it never widens the host. Wrapped rows are
* one logical line, so they take relaxed leading instead of the 1.8 used
* to separate real code lines.
*/
wrap?: boolean
/** Keep the edge in dark mode when nested inside another card-colored surface (a dialog body). */
bordered?: boolean
}>(), {
width: 'full',
wrap: false,
bordered: false,
})
</script>

<template>
<div
data-slot="code-panel"
class="flex max-w-full items-start gap-1.5 overflow-hidden rounded-menu-shell bg-card py-1 pl-3.5 pr-1.5"
:class="[surfaceEdgeClass(bordered), width === 'fit' ? 'w-fit' : 'w-full']"
>
<div
class="min-w-0 py-1.5 font-mono text-label text-foreground"
:class="[width === 'full' && 'flex-1', wrap ? 'whitespace-pre-wrap wrap-anywhere leading-relaxed' : 'overflow-x-auto leading-[1.8]']"
>
<slot>
<pre
class="m-0 font-mono"
:class="wrap ? 'whitespace-pre-wrap wrap-anywhere' : 'whitespace-pre'"
><code>{{ code }}</code></pre>
</slot>
</div>
<CopyButton
:text="code"
:label="copyLabel"
:copied-label="copiedLabel"
:failed-message="copyFailedMessage"
icon-class="size-3.5"
class="shrink-0 text-muted-foreground hover:text-foreground"
/>
</div>
</template>
1 change: 1 addition & 0 deletions src/components/code-panel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as CodePanel } from './CodePanel.vue'
53 changes: 53 additions & 0 deletions src/components/copy-button/CopyAnimatedCheckIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!-- Copy confirmation check (moved here from apps/web/src/pages/home/components/copy-animated-check-icon.vue), ported from Arkloop
src/apps/web/src/components/AnimatedCheck.tsx (c9ca6979e). Kept separate from
the shared check-draw-icon on purpose: the copy button's timing is tuned
around this exact draw. The path is lucide's check (length ≈ 21.3) but the
dash is 30, so the first ~30% of the 150ms draw shows nothing; that hidden
stretch overlaps the copy button's invisible "entering" phase, and the stroke
finishes as the glyph finishes growing back. Do not normalize it with
pathLength — that removes the delay the copy timing relies on. -->
<template>
<svg
class="copy-animated-check"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M4 13l4 4L20 7"
stroke-dasharray="30"
stroke-dashoffset="30"
/>
</svg>
</template>

<script setup lang="ts">
withDefaults(defineProps<{ strokeWidth?: number }>(), { strokeWidth: 2 })
</script>

<style scoped>
.copy-animated-check path {
animation: copy-draw-check 0.15s ease-out forwards;
}

@keyframes copy-draw-check {
from {
stroke-dashoffset: 30;
}
to {
stroke-dashoffset: 0;
}
}

@media (prefers-reduced-motion: reduce) {
.copy-animated-check path {
animation: none;
stroke-dashoffset: 0;
}
}
</style>
148 changes: 148 additions & 0 deletions src/components/copy-button/CopyButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<!-- Icon button that copies `text` and confirms with a drawn check. Motion follows
Arkloop's CopyIconButton (c9ca6979e): the glyph shrinks out (60ms), the next
one stays hidden for 75ms and then grows in (75ms scale / 50ms fade); the
return to the copy glyph is the same swap. The check is held while the
pointer rests on the button and returns only after it leaves; clicks are
ignored until the copy glyph is back.
`busy` (v-model) covers that whole span, so a caller that wraps the button
in a tooltip can keep it closed while the check is the confirmation. The
root is the Button itself, so it can sit under a TooltipTrigger as-child.
Lifted from the host (apps/web/src/pages/home/components/copy-action-button.vue,
now an i18n adapter over this). Library adaptations: copy comes in through
`label` / `copiedLabel` / `failedMessage` props (the library holds no i18n),
imports are relative, and the default icon size is the size-4.5 scale step
instead of size-[18px]. -->
<template>
<Button
type="button"
variant="ghost"
size="icon-sm"
:aria-label="copied ? copiedLabel : label"
@click="handleCopy"
@pointerenter="hovered = true"
@pointerleave="handlePointerLeave"
>
<!-- Two layers: a caller may scale this outer span (e.g. a press), while
the inner keyed span owns the swap transition, so neither overrides
the other's `transition`. -->
<span class="flex items-center justify-center">
<Transition
name="copy-glyph"
mode="out-in"
@after-enter="handleGlyphEntered"
>
<span
:key="copied ? 'check' : 'copy'"
class="flex items-center justify-center"
>
<CopyAnimatedCheckIcon
v-if="copied"
:class="iconClass"
:stroke-width="1.75"
/>
<!-- Mirrored so the stacked squares read top-left over bottom-right. -->
<CopyConnectedIcon
v-else
:class="[iconClass, '-scale-x-100']"
:stroke-width="1.75"
/>
</span>
</Transition>
</span>
</Button>
</template>

<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { Button } from '../button'
import { CopyConnectedIcon } from '../copy-connected-icon'
import { toast } from '../sonner'
import { useClipboard } from '../../lib/clipboard'
import CopyAnimatedCheckIcon from './CopyAnimatedCheckIcon.vue'

const props = withDefaults(defineProps<{
text: string
/** Accessible name at rest (caller i18n). */
label: string
/** Accessible name while the check is shown (caller i18n). */
copiedLabel: string
/** Toast shown when the clipboard write fails (caller i18n). */
failedMessage: string
iconClass?: string
}>(), { iconClass: 'size-4.5' })
Comment on lines +71 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Constrain copy icons to the shared size ladder

The generic 32px icon-sm button defaults its glyph to size-4.5 (18px), which is outside the shared 16/14/12px icon ladder, while the public free-text iconClass prop lets every caller invent another size; CodePanel already has to override it to 14px. Replace this escape hatch with an enumerated size mapped to the supported rungs and use the standard 16px default.

AGENTS.md reference: AGENTS.md:L590-L597

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这条不改。iconClass 有意保留:同一个复制按钮在不同位置是不同的视觉层级,消息操作栏和旁边的重试、更多按钮统一为 18px,代码块里是 14px,不应该强制成一个枚举档位。默认值 size-4.5 等于宿主 #1497 原有的 size-[18px],本 PR 只是原样搬入,不改变任何调用方的尺寸。


// From the click until the copy glyph has fully re-entered.
const busy = defineModel<boolean>('busy', { default: false })

const { copyText } = useClipboard()

const copied = ref(false)
const hovered = ref(false)
// The hold ran out while the pointer was still on the button.
let returnPending = false
let holdTimer: ReturnType<typeof setTimeout> | null = null

async function handleCopy() {
if (busy.value) return
busy.value = true
if (!await copyText(props.text)) {
busy.value = false
toast.error(props.failedMessage)
return
}
copied.value = true
}

function handleGlyphEntered() {
if (!copied.value) {
busy.value = false
return
}
// The 1.5s hold counts from the moment the check is fully in.
holdTimer = setTimeout(() => {
holdTimer = null
if (hovered.value) returnPending = true
else copied.value = false
}, 1500)
}

function handlePointerLeave() {
hovered.value = false
if (!returnPending) return
returnPending = false
copied.value = false
}

onBeforeUnmount(() => {
if (holdTimer) clearTimeout(holdTimer)
})
</script>

<style scoped>
.copy-glyph-leave-active {
transition:
scale 60ms ease-in,
opacity 60ms ease-in;
}

/* The 75ms delay is the hidden gap before the incoming glyph grows in; the
check's stroke is already drawing during it. */
.copy-glyph-enter-active {
transition:
scale 75ms ease-out 75ms,
opacity 50ms ease-out 75ms;
}

.copy-glyph-enter-from,
.copy-glyph-leave-to {
scale: 0.5;
opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
.copy-glyph-enter-active,
.copy-glyph-leave-active {
transition: none;
}
}
</style>
1 change: 1 addition & 0 deletions src/components/copy-button/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as CopyButton } from './CopyButton.vue'
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
7 changes: 6 additions & 1 deletion src/lib/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Loading