@picoframe/frame
v0.7.0
Published
Reusable Tauri v2 app-frame React shell: sidebar, top bar, hash routing, nav and slot composition, theme
Readme
@picoframe/frame
The reusable Tauri v2 app-frame React shell: collapsible grouped sidebar, top bar, hash routing, forward/back navigation, slot composition, and theming. Apps are assembled by composing full-stack plugins into the frame.
npm install @picoframe/frameimport { AppFrame } from "@picoframe/frame";
import { plugins } from "./app.plugins";
createRoot(root).render(<AppFrame plugins={plugins} title="My app" />);What this package exports
| Export | What it is |
| --- | --- |
| AppFrame | The application shell component |
| framePlugin | Built-in plugin (home route launcher) |
| useFrame, useNavigationStack, useDrawer, useSidebarState, useHideSidebar | Hooks |
| ThemeProvider, useTheme | Theming |
| DrawerProvider, DrawerHost, Drawer | Drawer building blocks, and the controlled form |
| Slot | Named slot for plugin contributions |
| cn | Class-name merge helper |
| Button, Input | The only UI primitives shipped here (see below) |
| FramePlugin, NavGroup, NavItem, defineCommand, … | Re-exported plugin-sdk contract |
UI components: frame vs. the registry
picoframe has two separate @picoframe/ channels — don't confuse them:
| | @picoframe/frame (this package) | @picoframe/registry |
| --- | --- | --- |
| How you get it | import { Button } from "@picoframe/frame" | npx shadcn@latest add @picoframe/select |
| Mechanism | compiled JS/.d.ts you import from npm | shadcn source registry — copies .tsx into your app |
| UI components | only Button and Input | select, input, textarea, label, checkbox, radio-group, switch, slider, form, dialog, tooltip, popover, collapsible, … |
Button and Input are exported here because npm-published plugins need
importable primitives (a plugin can't use the consumer app's copied-in registry
files). Every other component is app-level and lives in the registry, where the app
author owns the copied source. Select/Checkbox/Textarea/etc. will never be
exports of this package — use shadcn add @picoframe/<name>. See
../registry/README.md and the repo AGENTS.md.
Drawer
One frame-managed drawer, opened from anywhere via useDrawer(). open() replaces the current
content; close() dismisses it. Esc and a backdrop click close it too.
const { open } = useDrawer();
open({ title: "Details", content: <Details /> });open() options:
| Option | Default | What it does |
| --- | --- | --- |
| content | — | The drawer body (required). |
| direction | "right" | "left" / "right" side sheet, or "bottom" bottom sheet. |
| size | "md" | "sm" / "md" / "lg" / "full" — a width (side) or height (bottom). |
| width / height | — | Explicit CSS size overriding size for that axis. |
| container | provider default → document.body | Portal target (see below). |
| title, description | — | Accessible name/description; omit to keep them screen-reader-only. |
Container targeting
By default the drawer portals to document.body and covers the whole window (modal: scroll-locked,
focus-trapped). Pass a container — an HTMLElement, a () => HTMLElement | null (resolved lazily,
for targets that mount after the provider), or null — to portal into a bounded region instead. When
contained, the drawer switches to absolute positioning and a non-modal dialog, so its overlay is
scoped to that element and the rest of the app (e.g. the sidebar) stays interactive.
The container must be position: relative (and typically overflow: hidden, so the panel and scrim
respect its rounded corners):
open({ direction: "bottom", size: "lg", container: () => panelRef.current, content: <Panel /> });Set a provider-level default for every drawer via <AppFrame drawer={{ container }} />; a per-open
container overrides it. See the demo's "Drawer lab" page for a worked example.
Controlled drawers, and drawers outside the frame
useDrawer() takes a snapshot of content when you call open(), so it can't express content
that changes while the drawer is open. <Drawer> is the controlled form for that: async loading
states, an error and its retry, a multi-step form. It takes the same options as props, plus
open / onOpenChange, and renders its children live.
<Drawer open={open} onOpenChange={setOpen} title="Details" size="lg">
{loading ? <Spinner /> : <Details data={data} />}
</Drawer>It owns its own dialog rather than the frame's single global one, so it needs no provider and several can coexist.
For the imperative API outside AppFrame (a secondary window, or an app adopting the frame a
piece at a time), mount the building blocks yourself. useDrawer() then works anywhere under
the provider:
<DrawerProvider container={panelRef.current}>
<App />
<DrawerHost />
</DrawerProvider>Native <select> caveat
The dialog focus trap fights the browser's native <select> popup, so a native <select> inside a
drawer misbehaves. Use the registry select component (npx shadcn add @picoframe/select) instead.
Toasts & sidecar progress
The frame mounts a theme-aware toast surface (Toaster, built on sonner) automatically, and re-exports toast so a plugin can raise notifications without importing sonner or a registry component:
import { toast } from "@picoframe/frame";
toast.success("Saved");For plugins backed by a sidecar (a long-lived local server), useSidecarProgress drives a single self-updating toast from the sidecar's streamed progress:
useSidecarProgress("picoframe://sidecar/worker"); // listens on ".../progress"This progress primitive lives in the frame (not the registry) because npm-published plugins can only import from @picoframe/frame.
Settings footer badge
The frame's Settings footer link can carry an indicator so an app can surface "something in
settings needs you" (e.g. a stale integration credential). useSettingsBadge() reads and sets
it from anywhere inside <AppFrame>:
import { useSettingsBadge } from "@picoframe/frame";
const { setBadge } = useSettingsBadge();
useEffect(() => setBadge(credentialStale), [credentialStale]);The badge value is true (an attention dot), a number (a count bubble), any other node
(rendered in the bubble), or false/null (nothing). It's a single shared slot — one
app-level owner, last writer wins.
Sidebar collapse
The frame owns the docked sidebar's collapse state. useSidebarState() exposes it so an
app-level command surface (command palette, keyboard shortcut) can drive it:
import { useSidebarState } from "@picoframe/frame";
const { collapsed, setCollapsed, toggle } = useSidebarState();
// wire `toggle` into a "Toggle sidebar" commandIt is backed by the frame's shared persistent store, so this hook and the top-bar toggle
stay in sync live. This controls the docked rail; in popover layout mode the sidebar is
a separate overlay, so toggle has no visible effect there.
The sidebar.collapseWhenNarrow layout option adds a width-driven variant of the same
overlay. Below the breakpoint the docked rail gives way to the top bar's menu button, which
opens the nav fullscreen beneath the top bar. It leaves the persisted collapse state alone, so
widening the window restores the rail exactly as the user left it.
The breakpoint defaults to 640px. An app whose content needs more room can raise it:
<AppFrame
layout={{
sidebar: {
collapseWhenNarrow: { default: true, userConfigurable: true },
narrowBreakpoint: 900,
},
}}
/>narrowBreakpoint is app-author config only, so it is not exposed as a user setting.
Hiding the sidebar on one page
A page that wants the full width (a focus editor, a wizard, a canvas) calls useHideSidebar().
The docked rail goes for as long as that component is mounted:
import { useHideSidebar } from "@picoframe/frame";
function CanvasPage() {
useHideSidebar();
return <Canvas />;
}Pass a boolean to make it conditional on page state, so a page can drop the sidebar only in its own full-screen mode:
const [zen, setZen] = useState(false);
useHideSidebar(zen);The nav is never lost. The frame falls back to its popover presentation, so the top bar's
menu button still opens the full nav as an overlay. Like collapseWhenNarrow, it leaves the
persisted collapse state and width alone, so navigating away restores the rail exactly as the
user had it. Requests from several mounted components compose: the rail comes back when the
last one unmounts or turns its request off.
Forcing light or dark on one route
Most routes should follow the user's theme setting. A page whose content is inherently one appearance is the exception: a photo or video canvas, a map, a print preview. Left alone, the top bar and sidebar keep the user's setting and clash with it.
Set appearance on the route and the whole window follows, chrome included:
routes: [
{ path: "gallery", lazy: () => import("./Gallery"), appearance: "dark" },
];It is a route-level declaration rather than something the page does on mount, so the frame knows the appearance before the page renders and there is no flash of the old one on the way in. The user's stored setting is untouched, so the Appearance panel still shows what they chose and navigating away restores it. Nested routes inherit it, and the most specific matching route wins, so a child can opt back out.
Theming
The frame's design tokens ship as @picoframe/frame/theme.css (mode / accent / base axes).
See THEMING.md for the token model, a copy-pasteable app-local token overlay
snippet (semantic status/link tokens layered on without forking the theme), guidance on when an
overlay should track the accent/base, and how to consume theme.css standalone before adopting
AppFrame.
License
MIT
