# Charming design guide — read before you write the UI

The UI is one JavaScript program (not HTML, not a module). Charming injects `<div id="app"></div>`, a Tailwind@v3-compatible runtime (UnoCSS), and a default theme; your `ui` code runs as an inline script and populates `#app`. This guide is about making that UI good and keeping it inside the sandbox.

## Sandbox rules — these silently fail if you ignore them

- No `alert`, `confirm`, or `prompt` — they no-op. Use inline UI for messages and confirmation.
- No native form submit — call `e.preventDefault()` and handle it in JS.
- No external `<script>` tags, no CDN imports. The whole UI is one inline script. An optional `styles` field takes additional CSS.
- The UI iframe can't reach third-party APIs — it's served only from Charming's origin. Talk to your own backend through `window.buildy`, nothing else.
- Persist through `env.storage` (via your backend) for everything a user expects to keep — it is the only storage that works everywhere, the web app at charm.ing AND inside Claude/ChatGPT, and syncs across devices. Do NOT keep such state in `localStorage`/`sessionStorage`/`IndexedDB`: they are gated behind the `buildy:browser/storage@1.0` capability (claim-gated, like camera/microphone), work only on the web, and are empty inside Claude/ChatGPT — so the data silently vanishes there. Reach for them only for throwaway web-only caching you can afford to lose. To feature-detect client storage, write-then-read-back and compare (`setItem(k, v); getItem(k) === v`) — in chat hosts the sandbox swaps in a no-op stub whose `setItem` doesn't throw, so a bare `try/catch` probe falsely reports "available"; only a round-trip proves it.

## Fill the viewport — don't park the app in a narrow column

The app iframe fills its container — on a 2560-wide monitor that's a lot of width. Build UIs that use it. Make the outer container fill the viewport (`<main class="min-h-screen">`, or a grid/flex layout that spans width); don't wrap it in `max-w-md`/`max-w-2xl`. Cap the reading measure only where it applies — put an inner wrapper around long-form text (`max-w-2xl` on the prose block, not on `<main>`), inside a full-width outer that carries the background and header. A dashboard, canvas, kanban, gallery, table, or split view should use the space; a note or article can keep its prose comfortable without stranding the whole app in a left-aligned column.

## Libraries — reach for vanilla first

Vanilla DOM plus Tailwind classes is enough for almost any Charming app. Don't reach for React, Vue, Svelte, Excalidraw, Monaco, or Three.js — most "I need React" instincts are habit. If you genuinely need a small library, inline its UMD/IIFE build into `ui`; never pull a multi-MB dependency.

## Don't default to the AI-slop shape

The reflex shape is one screen / one heading / one input / one Add button / one list. Five "different ideas" all in that shape is one idea. Other tells to avoid:

- "My App" headers and README-style landing screens before I can use the app.
- "No items yet. Add one above!" empty states.
- A settings page with nothing in it.
- A username field as if accounts exist — they don't; one app is one dataset.
- An all-grey palette with a single blue button.

If your first instinct is "list with an Add button," stop and ask whether that genuinely fits. If it does, build a great one — but earn it. Give the app a real point of view: a fitting palette, a clear single purpose on first paint, and something to look at the moment it loads.

## Calling your backend

`window.buildy.api(id)` strips the `{ ok, value }` envelope — you get the `value` field only, never `ok`; throws `BuildyOperationError` on `{ ok: false }`:

```text
const api = window.buildy.api("todo"); // pass manifest.id, not the UUID
const todos = await api.list(); // the value itself; throws on { ok: false }
await api.add({ text: "..." });
```

Credentials attach automatically — don't manage tokens in the UI. For the full build contract, see https://usecharming.com/llms-full.txt.

## Adapt the UI to the viewer's role

An app can be opened by an owner, a collaborator, an end user, or a read-only `viewer`. Make a view-ready UI so a viewer sees a working, read-only app rather than buttons that error. Read the caller's role from `window.buildy.viewer` and gate every mutating control on `window.buildy.viewer.can(op)`: an end user behaves like a collaborator at the runtime API level (it can write data, so it is not gated here) — only a `viewer` fails `can(op)` on mutating ops. Read [auth.md](https://usecharming.com/auth.md) for the full role-to-capability mapping.

```text
const { viewer } = window.buildy;
if (!viewer.can("addTodo")) {
  // Viewer can't add — hide or disable the form and skip its submit handler.
  addButton.disabled = true;
}
```

`can(op)` returns `true` when the caller's role may attempt `op` (a viewer may attempt only read-only ops; owners and collaborators may attempt any). It is a **UX signal computed client-side, not a security boundary** — access is enforced server-side, so a viewer who calls a disallowed op anyway is refused with a `forbidden` (or `forbidden_write`) `BuildyOperationError`. Catch those and render a calm read-only state, not a crash. Charming renders its own "View only" pill chrome for viewers — you don't build it.

To personalize the UI, read `window.buildy.user` — `{ id, handle?, name?, image? }` for a signed-in caller, or `null` for an anonymous visitor (and on render-token-only embeds, which prove no identity). It is server-resolved at render and available synchronously: greet the caller by name, show their avatar, or attribute an action to them. Like `viewer`, it is a convenience signal exposing only public fields (never email) — never trust `user.id` to authorize anything.

```text
const { user } = window.buildy;
greeting.textContent = user ? `Hi, ${user.name ?? user.handle ?? "there"}` : "Welcome";
```

If the visitor is not signed in, use `buildy.login()` to start sign-in from inside the app. Check `buildy.login.available` first - it is `false` inside a chat-host embed where a popup cannot be opened.

```js
if (buildy.user) {
  // signed in
} else if (buildy.login.available) {
  signInButton.onclick = () => buildy.login();
} else {
  // embedded host: sign-in works only in a real browser tab
  showNotice('Open this app in your browser to sign in.');
  openButton.onclick = () => buildy.openLink(buildy.login.canonicalUrl);
}
```
