# Theme, language & lifecycle

> Following the app's theme and language live, visibility and suspension, the workspace, optional permissions at runtime, and the card log.

Source: https://docs.neurosquad.ai/en/card-sdk/api/environment

## 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>`.

```css
.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](https://docs.neurosquad.ai/en/card-sdk/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:

```ts
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.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](https://docs.neurosquad.ai/en/card-sdk/api/card-ui#overview) 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. |

```ts
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](https://docs.neurosquad.ai/en/card-sdk/api/card-ui#overview). 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`](https://docs.neurosquad.ai/en/card-sdk/permissions#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

```ts
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

```ts
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

```ts
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).
