# App layer

> The modal layer every surface opens on (dialogs, full-window screens, confirmations), and a store of reads that stay current.

`zidane-ui/app` holds what an app shares across its areas: `AppHostProvider` (one wrap for every area's host, see [UI kit](https://zidane.si/docs/ui-kit.md), and a key command registry: register shortcuts with `useKeyCommands`, see [Keybindings](https://zidane.si/docs/ui-keybindings.md)), the modal layer, and `useResource` over a resource store from `zidane-ui/app/headless`.

## The modal layer

Wrap the app once in `ModalProvider`. One surface fills the slot at a time: opening another replaces it. Esc and a scrim click close it, and the rest of the page is `inert` while anything is open.

```tsx
import { ModalProvider, ModalShell, useConfirm, useModal } from 'zidane-ui/app'

function RenameButton() {
  const modal = useModal()
  const confirm = useConfirm()
  return (
    <button
      type="button"
      onClick={() => modal.open(
        <ModalShell title="Rename session" onClose={() => modal.close()}>
          <RenameForm onDone={() => modal.close()} />
        </ModalShell>,
      )}
    >
      Rename
    </button>
  )
}

export function Root() {
  return (
    <ModalProvider>
      <App />
    </ModalProvider>
  )
}
```

| `useModal()` member | Does |
| - | - |
| `open(node, { variant?, id?, scrim? })` | Shows `node` in the slot. `variant: 'screen'` fills the window instead of centring a dialog. `scrim: 'dim'` darkens without the blur, for a surface that comes and goes with a key |
| `close(id?)` | Closes the slot. With `id`, only while that surface still owns it, so a stale self-close never closes the modal that replaced it |
| `isOpen`, `activeId` | Something is up; whose surface fills the slot. Gate global shortcuts on `isOpen` |
| `openLayer(node, { onDismiss, labelledBy })`, `closeLayer()` | One short layer above the slot (a confirmation asked from inside Settings) |
| `setDismissHandler(fn)` | What Esc and the scrim call instead of `close`. `ModalShell` and `ScreenShell` register their `onClose` |

`useConfirm()` returns `confirm(options)`, a promise of the choice: the design system's `ConfirmDialog` on the stacked layer. Esc, the scrim, Cancel or the slot closing all resolve `false`, and focus returns where it was. `useOptionalConfirm()` is the same where a `ModalProvider` may be missing: `null` there, so the caller leaves out what must ask first (the Files tree's Delete). `useOptionalModal()` reads the layer the same way.

A surface that throws shows a crash panel in its place with the message, Try again and Close. The page behind is never left under a scrim over nothing.

## Shells

| Shell | For | Props |
| - | - | - |
| `ModalShell` | A dialog: title row, body, optional footer | `title`, `description`, `onClose`, `onBack` (a back chevron), `footer`, `size` (`medium` 560, `large` 760, `xlarge` 920, `xxlarge` 1040 px), `scrollBody` |
| `ScreenShell` | A full-window screen (`variant: 'screen'`): a left column as wide as the sidebar, then a heading row over the content | `title`, `description`, `screenLabel` (its back button), `onClose`, `aside: { search?, nav }`, `asideFooter`, `headerActions` |

Both take `className` and `style`, and stamp `MODAL_TITLE_ID` on their title for the layer's `aria-labelledby`.

A window whose native controls sit over the page (the desktop app's traffic lights) passes `chromeInset={{ top, leading }}` to `ModalProvider`, in device-independent pixels. `ScreenShell` keeps that band clear, and divides it by `--zg-zoom` when your app sets it, so the band stays put under page zoom. Without it the band is 44px with nothing kept clear.

## Reads that stay current

`createResourceStore(options)` (`zidane-ui/app/headless`) keeps reads current for every view that asks for the same data. Declare each kind once as a spec: `{ key, fetcher, options }`.

```tsx
import { useResource } from 'zidane-ui/app'
import { createResourceStore } from 'zidane-ui/app/headless'

const resources = createResourceStore({ invalidation: () => myEvents })

const skills = (cwd: string) => ({
  key: ['skills', cwd],
  fetcher: () => api.listSkills(cwd),
  options: { topics: ['skills'], scope: { cwd } },
})

function Skills({ cwd }: { cwd: string }) {
  const { data, error, status, stale, refresh } = useResource(resources, skills(cwd))
  // …
}
```

- One request per key at a time. Readers of the same key share it.
- The last data stays while a refetch runs (`stale`) and when one fails. An error lands in `error`, never as empty data. The same answer keeps its identity.
- An event whose topic is in `options.topics` and whose `cwd` / `hostId` / `sessionId` don't contradict `options.scope` refetches the entry while it is read, and marks it stale otherwise. Folders match when one holds the other.
- `watchedTopics` are topics your source only emits while watched: the store calls `watch(topic, cwd)` while an entry has readers, and `unwatch` after.
- A new reader refetches data older than `ttl` (2 s). `revalidateOnFocus` refetches on window focus. Entries without readers go after a minute.

Outside React: `store.subscribe(spec, onChange)`, `store.fetch(spec)` (a fresh value is reused, an in-flight request joined) and `store.subscribeInvalidation(options, listener)`. `resourceEventMatches(event, options)` is the matching rule.

## The busy indicator

`Throbber` (`zidane-ui/chat`) is the terminal's busy shimmer: glyphs cycling through the theme's throbber gradient, then an ellipsis. `variant: 'warn'` is the amber one, `size` the glyph count, `hideEllipsis` for inline use. With reduced motion it is a static gradient and a slow dot.
