Skip to Content
Card SDKAPI referenceTheme, language & lifecycle

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-scheme and data-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')); en is 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 forms key_one, key_few, key_many, key_other are chosen by Intl.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.visibilityMeaningWhat to do
visibleOn screen, body shown.Run.
offscreenIts workspace is shown, the card is outside the view.Stop animations and polling.
overviewZoomed out: the app shows your overview tile instead of the body.Stop animations; keep the tile current.
hiddenIts 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(): string

Suspension. 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 retain on outputs so a card can catch up with ports.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)))
MemberWhat
allEvery 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).