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.
Import from Claude Code
Pick what to bring over.
Skills, MCP servers and instructions found in ~/.claude.
Cannot read properties of undefined (reading 'rows')
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.
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 }.
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 inerror, never as empty data. The same answer keeps its identity. - An event whose topic is in
options.topicsand whosecwd/hostId/sessionIddon't contradictoptions.scoperefetches the entry while it is read, and marks it stale otherwise. Folders match when one holds the other. watchedTopicsare topics your source only emits while watched: the store callswatch(topic, cwd)while an entry has readers, andunwatchafter.- A new reader refetches data older than
ttl(2 s).revalidateOnFocusrefetches 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.