Theme, language & lifecycle
Theme
connect() writes the app’s live theme onto your page’s <html> as CSS variables and keeps them
current:
--ns-<token>for each 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— the app’s font stacks (the fonts themselves are not shared: ship your own or use system fonts);color-schemeanddata-ns-scheme="dark"on<html>.
.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); }In code, card.theme is the ThemeSnapshot (scheme, tokens, fontSans, fontMono), kept
current; card.on('theme.changed', …) tells you when it changes, and applyTheme(snapshot, element)
writes the variables anywhere you like (for example into a shadow root).
The app is dark today, so scheme is always dark — but do not hard-code it: use the variables
and a light theme will just work when it arrives. Styling guide: UI kit & styling.
Language
The app speaks English, Russian and Simplified Chinese and switches live. card.i18n is
{ language: 'en' | 'ru' | 'zh', locale } (locale is en-US, ru-RU or zh-CN, for Intl),
<html lang> follows it, and texts in your manifest (displayName, labels, descriptions) are
resolved by the app.
For your own strings, createTranslator follows the app’s language live:
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)- Keys can be nested (
{ list: { empty: '…' } }→t('list.empty'));enis required and is the fallback, then the key itself. {{name}}placeholders are filled from the second argument; numbers are formatted for the locale.- With
count, plural formskey_one,key_few,key_many,key_otherare chosen byIntl.PluralRules— Russian uses all four, Chinese only_other. t.language,t.locale,t.onChange(fn),t.setLanguage(lang),t.dispose().
Lifecycle and visibility
A 50-card canvas cannot run 50 web apps at full speed, so the app tells your card where it is and pauses it when nobody can see it.
card.visibility | Meaning | What to do |
|---|---|---|
visible | On screen, body shown. | Run. |
offscreen | Its workspace is shown, the card is outside the view. | Stop animations and polling. |
overview | Zoomed out: the app shows your overview tile instead of the body. | Stop animations; keep the tile current. |
hidden | Its workspace is not shown, or the window is hidden. | Stop everything that is only for the eyes. |
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(): stringSuspension. A card whose workspace has been hidden for a minute is suspended: it gets
lifecycle.suspend, has about a second (graceMs) to save, then its page is unloaded. The app
keeps showing its header and overview tile. When the user looks
again, the page starts fresh with card.launch === 'resumed'. At most 24 card pages run at once
across the app; beyond that, the least recently seen offscreen or hidden cards are suspended too.
Cards with the background permission are not suspended when
hidden (up to 8 app-wide).
Lazy start. A card’s page is created the first time the card is actually visible in the shown workspace, not when the workspace opens. Until then the body shows a placeholder and the header shows what you set last time.
Other things to know
- Tools and request ports wake a suspended card: the app starts its page (waiting up to 10
seconds) before delivering the call. Stream messages to a suspended card are dropped — use
retainon outputs so a card can catch up withports.read. - While the canvas is being dragged or zoomed, your card does not get mouse events.
- The mouse wheel inside your card scrolls your card, never the canvas. App keyboard shortcuts do not work while focus is inside a card.
- A card that stops answering the app’s heartbeat for 15 seconds is shown as Not responding with a Reload button. The SDK answers heartbeats for you; a long synchronous loop in your code is what triggers it.
- Every distinct card package is one browser process (~30–60 MB); copies of the same card share it.
Events are not replayed. While a card’s page is not running — not yet mounted, suspended,
or unloaded — it misses events such as agents.turn. On start, rebuild what you show from
card.agents.list() (status, turnStartedAt, and lastTurn — when the last turn ended and
how) and from retained port values rather than assuming you saw every event.
Workspace
const workspace = await card.getWorkspace() // { id, name, path? }
render(workspace.name, workspace.path ?? 'no fs.read — no path')card.workspace holds the same, kept current. path (absolute) is there only with fs.read.
Permissions at runtime
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)))| Member | What |
|---|---|
all | Every declared permission: { id, granted, optional, hosts?, reason? }. Kept current. |
has(id) | Granted, directly or implied (fs.write implies fs.read). |
list() | A fresh list from the app. |
request(...ids) | Asks the user for optional declared permissions (in the app’s window-wide dialog). Resolves with the ids granted now. Only while the card is visible, once every 5 seconds; asking for anything not declared as optional fails with PERMISSION_DENIED. |
onChange(handler) | A grant changed — the user granted, or revoked in Settings. |
The card log
card.log.info('run started', { filter: 'checkout' })
card.log.warn('slow response', 1834, 'ms')
card.log.error(new Error('parser failed'))Lines go to the package’s log in the app (card-logs, 256 KB, rotating) and, while you run
neurosquad-card dev, to your terminal. Arguments are joined like console.log (objects as JSON,
errors with their stack), ≤ 2 000 characters a line, at most 50 lines a second — beyond that lines
are dropped and one “N lines dropped” warning is written. Uncaught errors and unhandled promise
rejections are logged as error automatically (forwardErrors: false in connect() to turn that
off).