Skip to content

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, and a key command registry: register shortcuts with useKeyCommands, see Keybindings), the modal layer, and useResource over a resource store from zidane-ui/app/headless.

ModalShell: title, back, body and footer

Import from Claude Code

Pick what to bring over.

Skills, MCP servers and instructions found in ~/.claude.

ErrorFallback: a region that threw
Live: the slot and the stacked confirm layer

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() memberDoes
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, activeIdSomething 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#

ShellForProps
ModalShellA dialog: title row, body, optional footertitle, description, onClose, onBack (a back chevron), footer, size (medium 560, large 760, xlarge 920, xxlarge 1040 px), scrollBody
ScreenShellA full-window screen (variant: 'screen'): a left column as wide as the sidebar, then a heading row over the contenttitle, 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.

Throbber: the theme's gradient
f€^$(B$6+£+DA4=
Throbber: warn, compact
806€%%£1