# UI (/apps/ui)

A worker is React code that draws with Poltura's components instead of HTML. It runs in a separate, secure environment, not
in the admin's or the storefront's page, and sends the platform a description of what to draw; the admin or the
theme draws it with its own components. That is why a worker cannot reach the page's DOM, cookies or network, and
why it looks native wherever it appears.

Every worker file starts the same way: import `@poltura/app-ui/worker` first, then render your root component
with `startUiWorker` from `@poltura/app-ui/worker-runtime`.

## Targets [#targets]

Admin: `admin.page`, `admin.home`, `admin.product`, `admin.collection`, `admin.order`, `admin.customer`.

Storefront templates: `storefront.home`, `storefront.products`, `storefront.product`, `storefront.cart`, `storefront.checkout`, `storefront.login`, `storefront.register`, `storefront.account`, `storefront.contact`, `storefront.page`.

Layout: `storefront.layout.header`, `storefront.layout.footer`.

Header zones are a full-width row of their own under the navigation, outside the sticky header, on mobile too:
a taller worker pushes the page down instead of covering it. Footer zones sit in the page flow as well.

App page: `storefront.app-page`.

A release holds at most 16 workers, each at most 1048576 bytes. A rendered snapshot is at most 32768 bytes, 8 levels deep, 128 nodes, and 24 children per node.

## Lifecycle [#lifecycle]

When a worker mounts, the platform sends it the session and its context (locale, settings, theme). `startUiWorker`
renders your component and sends a snapshot after each render. From then on the worker receives events (a
`press`, a `change`), context updates when settings change, and the answers to its capability calls. When the
merchant or shopper leaves, the worker is disposed. Every capability call is checked again against the
installation before the platform acts on it.

The complete example's admin page:



```tsx
export function AdminPage(): ReactNode {
  const copy = copyFor(useLocale());
  const settings = useSettings();
  const navigate = useNavigate();
  const toast = useToast();
  const pick = useResourcePicker();
  const saveFeatured = useAction('save_featured');
  const heading = String(settings.heading ?? copy.heading);
  const [picked, setPicked] = useState('');

  return (
    <Card className='offer-panel rounded-theme bg-surface text-foreground p-4'>
      <Stack>
        <Heading>{heading}</Heading>
        <Text>{copy.adminIntro}</Text>
        <TextField label={copy.featuredLabel} value={picked} onChange={setPicked} />
        <Inline>
          <Button
            label={copy.pickProducts}
            onPress={async () => {
              const ids = await pick('product', true);
              if (ids === null) return;
              setPicked(ids.join(','));
              await toast('success', copy.pickedToast);
            }}
          />
          <Button
            label={copy.save}
            onPress={async () => {
              const productIds = picked
                .split(',')
                .map((value) => value.trim())
                .filter((value) => value.length > 0);
              await saveFeatured({
                productIds,
                expectedVersion: 0,
                idempotencyKey: crypto.randomUUID(),
              });
              await toast('success', copy.savedToast);
            }}
          />
          <Button
            label={copy.openPage}
            onPress={() => navigate({ kind: 'app_page', pageId: RECOMMENDATIONS_PAGE })}
          />
        </Inline>
      </Stack>
    </Card>
  );
}
```



```tsx
export function AdminProduct(): ReactNode {
  const copy = copyFor(useLocale());
  const selected = useSelectedEntity();
  const navigate = useNavigate();
  if (selected.status !== 'ready' || selected.entity.kind !== 'product') {
    return <Text>{copy.unavailable}</Text>;
  }
  return (
    <Card className='rounded-theme bg-surface text-foreground p-4'>
      <Stack>
        <Heading>{selected.entity.product.title}</Heading>
        <Button
          label={copy.openSettings}
          onPress={() => navigate({ kind: 'app_page', pageId: RECOMMENDATIONS_PAGE })}
        />
      </Stack>
    </Card>
  );
}
```



```tsx
export function StorefrontProduct(): ReactNode {
  const copy = copyFor(useLocale());
  const settings = useSettings();
  const theme = useTheme();
  const cart = useCart();
  const toast = useToast();
  const host = useStorefrontContext();
  const recommend = useAction('recommend');
  const heading = String(settings.heading ?? copy.alsoLike);
  const storeName = theme?.settings?.store_name;
  const [contextLabel, setContextLabel] = useState(copy.alsoLike);

  useEffect(() => {
    void host.read().then(async (context) => {
      if (context.kind === 'product') setContextLabel(context.product.title);
      const suggestion = await recommend({
        productId: context.kind === 'product' ? context.product.id : null,
      });
      const title = z.object({ title: z.string().min(1) }).safeParse(suggestion);
      if (title.success) setContextLabel(title.data.title);
    });
  }, [host, recommend]);

  return (
    <Card className='offer-panel rounded-theme bg-surface text-foreground p-4'>
      <Stack>
        <Heading className='font-display'>{heading}</Heading>
        <Text>{z.string().min(1).safeParse(storeName).data ?? contextLabel}</Text>
        <Button
          label={copy.addToCart}
          className='bg-primary text-on-primary'
          onPress={async () => {
            const parsed = VariantId.safeParse(settings.variant_id);
            if (!parsed.success) {
              await toast.show({ tone: 'warning', message: copy.cartRejected });
              return;
            }
            const reply = await cart.add({ variantId: parsed.data, quantity: 1 });
            if (reply.status === 'ok') {
              await toast.show({ tone: 'success', message: copy.added });
              return;
            }
            await toast.show({ tone: 'warning', message: copy.cartRejected });
          }}
        />
      </Stack>
    </Card>
  );
}
```

## Primitives [#primitives]

Node types: `Stack`, `Inline`, `Card`, `Text`, `Heading`, `Button`, `TextField`, `Select`, `Checkbox`, `Alert`, `Divider`. `className` max 2048, no control characters. `Text`/`Heading`/`Alert` children become `content` (max 2000 characters). `Heading` `level` is 2 \| 3 \| 4. `Button` `label` max 160 characters. `Select` `options` 1 to 24. `onChange` receives a value, not a DOM event. HTML and DOM libraries are not supported.

## Hooks [#hooks]

| Hook                   | Entry             | Returns                                                                                                                          |
| ---------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `useSettings`          | admin, storefront | Resolved placement settings (`Record<string, string \| number \| boolean \| null>`, max 64 keys)                                 |
| `useLocale`            | admin, storefront | `LocaleTag` string                                                                                                               |
| `useTheme`             | admin, storefront | Optional `UiAppearance` (mode, tokens, theme, declared public settings)                                                          |
| `useExtensionContext`  | admin, storefront | Full `UiExtensionContext`                                                                                                        |
| `useSelectedEntity`    | admin             | `{ status: 'loading' \| 'unavailable' }` or `{ status: 'ready', entity }` for `product` \| `collection` \| `order` \| `customer` |
| `useHostContext`       | admin, storefront | `{ status, context?: UiTargetContext }`                                                                                          |
| `useNavigate`          | admin             | `(destination: UiNavigateDestination) => Promise<void>` (`app_page`, `host` with a host target, `storefront_path`)               |
| `useNavigate`          | storefront        | `{ to(path): Promise<void> }` (`StorefrontNavigatePath`)                                                                         |
| `useToast`             | admin             | `(tone: UiToastTone, message) => Promise<void>` (message max 200)                                                                |
| `useToast`             | storefront        | `{ show({ tone, message }) }`                                                                                                    |
| `useResourcePicker`    | admin             | `(kind: 'product' \| 'collection', multiple) => Promise<string[] \| null>` (ids max 50)                                          |
| `useCart`              | storefront        | `{ read, add, update, remove }`                                                                                                  |
| `useStorefrontContext` | storefront        | `{ read(): Promise<UiTargetContext> }`                                                                                           |

## Capabilities and policy [#capabilities-and-policy]

Each capability has a timeout, a limit on calls in flight, a request window, and the surfaces it works on:

| Action                 | timeoutMs | maxPending | windowMs | windowRequests | surfaces          |
| ---------------------- | --------- | ---------- | -------- | -------------- | ----------------- |
| `read_selected_entity` | 5000      | 2          | 10000    | 8              | admin             |
| `read_context`         | 5000      | 2          | 10000    | 8              | admin, storefront |
| `navigate`             | 3000      | 1          | 10000    | 8              | admin, storefront |
| `toast`                | 2000      | 2          | 10000    | 16             | admin, storefront |
| `pick_resource`        | 15000     | 1          | 10000    | 4              | admin             |
| `cart.add`             | 5000      | 2          | 10000    | 8              | storefront        |
| `cart.update`          | 5000      | 2          | 10000    | 8              | storefront        |
| `cart.remove`          | 5000      | 2          | 10000    | 8              | storefront        |
| `cart.read`            | 3000      | 2          | 10000    | 16             | storefront        |

A capability result is at most 16384 bytes. Cart quantity 1–99. `UiCartRejectionReason`: `out_of_stock`, `invalid_variant`, `limit`, `unavailable`. Replies: `ok`, `unavailable`, `cancelled`, `rejected`.

## Settings and translations [#settings-and-translations]

A worker's settings are its element definition's `settings`, written with `defineBlock` and `field.*` from `@poltura/app-sdk/elements` (the same format theme blocks use). `poltura app build` reads the definition statically, never runs it, and writes it into the worker's `element` (the public element definition in `@poltura/app-contracts/ui-element`) without the `type` key. The customizer shows `label`, `description`, `icon` and `category` in the picker, the settings in the inspector, `style` in the Style tab, and `presets` on insert: only a preset with the id `default` is applied by the plain tile, and every other preset is its own tile after it.

| Rule                 | Contract                                                                                                                                               |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Field types          | `text`, `textarea`, `number`, `range`, `checkbox`, `select`, `radio`, `color`, `url`, `header`, `paragraph`                                            |
| Builders             | `field.text`, `textarea`, `number`, `range`, `checkbox`, `select`, `radio`, `color`, `url`, `group` (header), `note` (paragraph)                       |
| Setting ids          | snake_case or kebab-case, max 64 characters; credential-shaped and reserved names fail                                                                 |
| Fields               | max 32; the whole definition is at most 16384 bytes                                                                                                    |
| Values               | text, textarea, url, color max 2000 characters; select and radio take an option `value`; number and range stay inside `min`/`max`; checkbox is boolean |
| Options              | 1 to 20 unique values                                                                                                                                  |
| Style tab            | `layout`, `spacing`, `size`, `typography`, `color`, `background`, `border`, `effects`, `visibility`                                                    |
| Icons and categories | Chosen from the platform's icon and category lists                                                                                                     |
| Presets              | max 8; preset settings must be valid values of declared fields                                                                                         |

Media, rich text, resource pickers, `darkDefault`, child elements (`blocks`, `slots`) and `templates` are not available: the worker target decides the template. `header` and `paragraph` carry no value. Fields without a `default` are absent until the merchant sets them. A worker without an element definition receives no settings.

Labels, descriptions, option labels and preset labels are your base-language copy; the platform translates them for each store language. Merchants translate text values per locale in `settings_i18n`, validated against the same definition on theme save.

Manifest `translations[]` cover `displayName` / `description`. Page `translations[]` cover titles. Worker copy is your JSON; `useLocale()` returns the host locale. This example loads `src/locales/en.json` and `src/locales/hu.json`:



```json
{
  "heading": "Recommendations",
  "adminIntro": "Pick catalog products, then save. Native workers cannot call your origin; useAction save_featured relays to your backend.",
  "featuredLabel": "Featured product ids",
  "pickProducts": "Pick products",
  "save": "Save",
  "savedToast": "Featured products saved.",
  "pickedToast": "Products selected.",
  "openPage": "Open app page",
  "unavailable": "No product is selected.",
  "openSettings": "Open recommendations",
  "alsoLike": "You may also like",
  "addToCart": "Add to cart",
  "added": "Added to cart.",
  "cartRejected": "The cart could not add that variant.",
  "emailLabel": "Email",
  "subscribe": "Subscribe",
  "invalidEmail": "Enter a valid email address.",
  "subscribed": "Subscription captured.",
  "pageIntro": "This storefront app page is /apps/recommendations from storefrontPages slug recommendations.",
  "shopProducts": "Browse products",
  "footerHelp": "Footer capture stays on this mount. It does not call /apps-api/v1."
}
```



```ts
export function copyFor(locale: string): CopyTable {
  const table = locale.toLowerCase().startsWith('hu') ? hu : en;
  return CopyTable.parse(table);
}
```

## Hosted frames [#hosted-frames]

`adminFrame.actions` is a subset of these bridge actions: `toast`, `set_loading`, `navigate_host`, `pick_resource`, `resize`, `get_session_token`, `get_appearance`. Hosted pages that want the same primitives without a worker use `@poltura/app-ui/dom` and `get_appearance`. See [Admin](/apps/admin).

## Next [#next]

- [Admin](/apps/admin) and [Storefront](/apps/storefront): where each target appears.
- [Styling](/apps/styling): CSS for workers.
