主题、语言与生命周期
主题
connect() 会把应用的实时主题以 CSS 变量的形式写到你页面的 <html> 上,并保持更新:
- 每个令牌对应一个
--ns-<token>:background、background-secondary、background-tertiary、foreground、muted、surface、surface-foreground、surface-secondary、surface-secondary-foreground、surface-tertiary、surface-tertiary-foreground、overlay、overlay-foreground、default、default-foreground、accent、accent-foreground、accent-soft、accent-soft-foreground、success、success-foreground、success-soft、warning、warning-foreground、warning-soft、danger、danger-foreground、danger-soft、border、border-secondary、separator、focus、link、field-background、field-foreground、field-placeholder、field-border、radius、field-radius; --ns-font-sans、--ns-font-mono——应用的字体栈(字体文件本身不共享:请自带字体或使用系统字体);<html>上的color-scheme和data-ns-scheme="dark"。
.panel {
background: var(--ns-surface);
color: var(--ns-surface-foreground);
border: 1px solid var(--ns-border);
border-radius: var(--ns-radius);
font-family: var(--ns-font-sans);
}
.panel--alert { background: var(--ns-danger-soft); color: var(--ns-danger); }在代码中,card.theme 是实时更新的 ThemeSnapshot(scheme、tokens、fontSans、fontMono);
card.on('theme.changed', …) 会在它变化时通知你,applyTheme(snapshot, element) 可以把变量写到任何你想要的地方(例如 shadow root 中)。
应用目前是深色的,所以 scheme 始终是 dark——但不要把它写死:使用这些变量,将来推出浅色主题时就能直接适配。
样式指南:UI 套件与样式。
语言
应用支持英语、俄语和简体中文,并可实时切换。card.i18n 是 { language: 'en' | 'ru' | 'zh', locale }
(locale 为 en-US、ru-RU 或 zh-CN,用于 Intl),<html lang> 会跟随它,清单中的文本(displayName、标签、描述)由应用负责解析。
对于你自己的字符串,createTranslator 会实时跟随应用语言:
import { createTranslator } from '@neurosquad/card-sdk'
const t = createTranslator(
{
en: { title: 'Tests', failed_one: '{{count}} test failed', failed_other: '{{count}} tests failed' },
ru: {
title: 'Тесты',
failed_one: '{{count}} тест упал',
failed_few: '{{count}} теста упало',
failed_many: '{{count}} тестов упало',
failed_other: '{{count}} теста упало'
},
zh: { title: '测试', failed_other: '{{count}} 个测试失败' }
},
card
)
render(t('title'), t('failed', { count: 3 }))
t.onChange(() => render(t('title'))) // re-render on a language switch
const when = new Intl.DateTimeFormat(card.i18n.locale, { timeStyle: 'short' }).format(Date.now())
render(when)- 键可以嵌套(
{ list: { empty: '…' } }→t('list.empty'));en必填并作为回退,最后回退到键本身。 {{name}}占位符由第二个参数填充;数字会按 locale 格式化。- 提供
count时,复数形式key_one、key_few、key_many、key_other由Intl.PluralRules选择——俄语四种都用,中文只用_other。 t.language、t.locale、t.onChange(fn)、t.setLanguage(lang)、t.dispose()。
生命周期与可见性
一块有 50 张卡片的画布无法让 50 个 Web 应用全速运行,所以应用会告诉你的卡片它身处何处,并在没人看得见时暂停它。
card.visibility | 含义 | 该做什么 |
|---|---|---|
visible | 在屏幕上,正文可见。 | 正常运行。 |
offscreen | 它所在的工作区正在显示,但卡片在视野之外。 | 停止动画和轮询。 |
overview | 已缩小:应用显示你的概览图块而不是正文。 | 停止动画;保持图块内容最新。 |
hidden | 它所在的工作区没有显示,或窗口已隐藏。 | 停止一切只为给人看的工作。 |
card.lifecycle.onVisibility((state) => {
if (state === 'visible') startAnimation()
else stopAnimation()
})
card.lifecycle.onSuspend(async (graceMs) => {
// The page is about to be unloaded. You have graceMs (about a second) to save.
await card.storage.set('draft', currentDraft())
})
card.lifecycle.onExpanded((expanded) => render(expanded ? 'big layout' : 'compact layout'))
card.lifecycle.onResized(({ w, h }) => render(w, h))
if (card.launch === 'resumed') render('back from a pause — state restored from storage')
declare function startAnimation(): void
declare function stopAnimation(): void
declare function currentDraft(): string暂停。 所在工作区已隐藏一分钟的卡片会被暂停:它收到 lifecycle.suspend,有大约一秒(graceMs)的时间保存,然后页面被卸载。
应用会继续显示它的标题栏和概览图块。用户再次查看时,页面重新启动,且 card.launch === 'resumed'。
整个应用同时最多运行 24 个卡片页面;超出后,最久未被看到的屏幕外或隐藏卡片也会被暂停。
拥有 background 权限的卡片在隐藏时不会被暂停(整个应用最多 8 张)。
延迟启动。 卡片页面在该卡片第一次真正出现在当前显示的工作区中时才会创建,而不是在工作区打开时。 在此之前,正文显示占位内容,标题栏显示你上次设置的内容。
其他须知
- 工具调用和请求型端口会唤醒暂停的卡片:应用会先启动它的页面(最多等待 10 秒)再送达调用。
发给暂停卡片的流式消息会被丢弃——请在输出上使用
retain,让卡片能通过ports.read补上。 - 画布被拖动或缩放时,你的卡片收不到鼠标事件。
- 在卡片内滚动鼠标滚轮只会滚动你的卡片,永远不会滚动画布。焦点在卡片内部时,应用的键盘快捷键不起作用。
- 连续 15 秒没有回应应用心跳的卡片会显示为 Not responding,并带有 Reload 按钮。SDK 会替你应答心跳;触发它的通常是你代码中长时间的同步循环。
- 每个不同的卡片包是一个浏览器进程(约 30–60 MB);同一张卡片的多个副本共享这个进程。
事件不会补发。 当卡片页面没有运行时——尚未挂载、已暂停或已卸载——它会错过 agents.turn 等事件。启动时,请根据 card.agents.list()
(status、turnStartedAt,以及 lastTurn——上一轮何时结束、如何结束)和保留的端口值重建显示内容,而不要假设自己收到了每一个事件。
工作区
const workspace = await card.getWorkspace() // { id, name, path? }
render(workspace.name, workspace.path ?? 'no fs.read — no path')card.workspace 中保存着同样的信息,并实时更新。path(绝对路径)只有在拥有 fs.read 时才提供。
运行时的权限
async function copyReport(text: string): Promise<boolean> {
if (!card.permissions.has('clipboard.write')) {
// Declared with "optional": true in the manifest. Only while the card is visible.
const granted = await card.permissions.request('clipboard.write')
if (!granted.includes('clipboard.write')) return false
}
await card.copyText(text)
return true
}
card.permissions.onChange((all) => render(all.filter((p) => p.granted).map((p) => p.id)))| 成员 | 说明 |
|---|---|
all | 每项已声明的权限:{ id, granted, optional, hosts?, reason? }。实时更新。 |
has(id) | 是否已授予,直接授予或被包含(fs.write 包含 fs.read)都算。 |
list() | 从应用获取最新列表。 |
request(...ids) | 向用户申请可选的已声明权限(在应用覆盖整个窗口的对话框中)。返回此次被授予的 id。只能在卡片可见时调用,每 5 秒一次;申请任何未声明为可选的权限都会以 PERMISSION_DENIED 失败。 |
onChange(handler) | 某项授权发生变化——用户授予了,或在设置中撤销了。 |
卡片日志
card.log.info('run started', { filter: 'checkout' })
card.log.warn('slow response', 1834, 'ms')
card.log.error(new Error('parser failed'))日志行会写入应用中该卡片包的日志(card-logs,256 KB,循环覆盖),在你运行 neurosquad-card dev 时也会输出到你的终端。
参数的拼接方式与 console.log 相同(对象转为 JSON,错误带调用栈),每行 ≤ 2 000 个字符,每秒最多 50 行——超出的行会被丢弃,
并写入一条“N lines dropped”警告。未捕获的错误和未处理的 Promise 拒绝会自动以 error 级别记录(在 connect() 中传入 forwardErrors: false 可关闭)。