@zibby-run/app-kit
v0.7.5
Published
The client half of the Zibby app contract: the /api/ai streaming client, the host bridge, the dev mock engine, the image helpers and the shared UI layer.
Maintainers
Readme
@zibby-run/app-kit
The client half of the Zibby app contract, as an npm
package. Read CONTRACT.md first — it is the authority on what an embedded
app must do; this package is the reference implementation of the app side.
Consumers today: the five Zibby apps (Photo Restore, Product Photo Studio, Ad Maker, Resume Tailor, Image Extender), each on @zibby-run/app-kit from the registry, and any app a developer builds for the platform.
Starting from nothing
npx @zibby-run/app-kit init my-app
cd my-app && pnpm install && pnpm devinit writes a complete, framework-free Zibby app — vanilla TypeScript on
Vite, the system font stack, its own CSS — with one AI action on /api/ai, the
host bridge, the dev mock, both publish manifests and an end-to-end test that
runs on the mock. It takes none of this package's optional halves; the
catalogue look is one import line away and a separate decision. The directory's
name becomes the app's slug and, title-cased, its display name; a directory
that already has something in it is refused.
Install (into an app you already have)
pnpm add @zibby-run/app-kit
# or
npm i @zibby-run/app-kitThe look is yours. The platform requires the CONTRACT and nothing else —
there is no house style to adopt. The UI layer below, fonts.css, the Vite
preset, /publish and zibby-zip are five separate choices, each takeable
alone; the root entry (streamZibbyAi, installHostBridge, navigateHost) is
the one part that implements the contract itself.
Two import lines are all an app needs to start:
import "@zibby-run/app-kit/ui.css";
import { AppLayout, Button, useZibbyAi } from "@zibby-run/app-kit/react";A third line vendors Inter off the serving origin, which CONTRACT.md §1 requires and no app should copy by hand any more:
import "@zibby-run/app-kit/fonts.css";React is an optional peer dependency (>=18); only the /react and /ui
entry points need it. Node >=20.
The package is ESM-only. It ships "type": "module" and an exports map,
and there is no CommonJS build: require("@zibby-run/app-kit") is not
supported. A consumer's TypeScript needs moduleResolution set to bundler
(what Vite and Next projects use, and what all of our apps use), or to
node16/nodenext in a project that is itself ESM — a node16 project that
is CommonJS gets TS1479 on every entry point, which is the type system
telling the truth. moduleResolution: node10 is too old to read an exports
map: the root entry still resolves through main/types, but /react,
/mock, /images and /ui do not resolve at all.
Our own five apps take the kit from the registry (@zibby-run/app-kit@^0.2.1).
For coding agents
The guidance ships inside the package, so it cannot drift from the code it
describes. After installing, point your agent at
node_modules/@zibby-run/app-kit/AGENTS.md: what a Zibby app is, the contract
in ten lines, the rules that break an upload, the exported surface as a table
and the layout contract — written to be read in one go. llms.txt beside it is
the map of the package's four documents, and llms-full.txt is all four
concatenated for a single read. CHANGELOG.md carries a "For your agent"
block per version: grep-this → replace-with-that for applying a bump.
CLAUDE.md is a pointer at AGENTS.md.
What's in the box
transcribe (POST /api/transcribe, CONTRACT §8) and appData
(/api/app/data, §12, with the app_user_id of §4 behind getAppUserId())
are live on zibby.run since platform v0.4.35 (2026-09-18), the person's
Export/Erase settings page and the 90-day removal included; the speaker model
came with v0.4.36. A platform older than that answers a transcription
unknown with status: 404 and every appData call not_available, and an
app nobody switched on for user data answers data_store_disabled — so build
each as a feature the app degrades without.
New in 0.6.0 and live on zibby.run since platform v0.5.0 (2026-10-02),
the platform release after v0.4.36: files (the person's files in this app, with the
shell's file picker, CONTRACT §14), transcribeJob and jobs (a stored
recording transcribed in the background, charged once on success, §15) and
knownSpeakers on both transcribe routes (names on the diarize model's
segments, §8). On an older platform every files and jobs call answers
not_available and knownSpeakers is ignored; the recipe is in AGENTS.md.
New in 0.7.0: comped (CONTRACT §16) — a person whose runs are on Zibby,
an admin or an account an admin comped, is told so with one key,
comped: true, on the token answer, the final event, a transcription and a
job, with charged_cr: 0. The kit reads it into getComped() (root),
useComped() (/react) and ZIBBY_COMPED_EVENT; comped?: true sits on
ZibbyTerminal, TranscribeResult, TranscribeJob and useZibbyAi's
capPrompt; ZIBBY_COMPED_LINE is Zibby's own sentence, "Comped: your runs
are on Zibby"; the mocks play such a person on
window.__zibbyAiMock = { comped: true }. The platform half is live on
zibby.run since platform v0.7.0 (2026-10-03); on any older platform, comped
is never sent and everybody reads
as a regular person, exactly as before.
src/
config.ts contract constants (endpoint, credits path, CSRF meta names)
client.ts streamZibbyAi — the /api/ai lifecycle, SSE parsing, typed refusals
transcribe.ts transcribe — /api/transcribe: base64, the refusal table, the charge
appData.ts appData — /api/app/data: the name grammar, versions, quotas, refusals
files.ts files — /api/app/files: reserve, signed PUT, complete; list/get/delete; the shell's picker
transcribeJobs.ts transcribeJob / jobs — /api/transcribe/jobs: start, poll + zibby:job push, cancel
knownSpeakers.ts the known-speakers rules and wire shape, shared by both transcribe routes
hostBridge.ts hello/token postMessage bridge, trust model, avg_action_cr, app_user_id
balance.ts the zibby:balance in-page event
appUser.ts the zibby:appuser in-page event and getAppUserId()
comped.ts the zibby:comped in-page event and getComped() — runs on Zibby
mock.ts createMockStream / createTranscribeMock / createAppDataMock / createFilesMock /
createTranscribeJobsMock — the dev mocks (separate entry)
hostNav.ts the six-path shell allow-list, hostNavigate, fallbackHref
vite.ts zibbyViteConfig — base, assetsInlineLimit, the vitest keys
react/
useZibbyAi.ts one hook per AI action button
useCreditBalance.ts the listening half of a balance display
useAppUser.ts this app's own id for the signed-in person, or null
useComped.ts true for a person whose runs are on Zibby
ui/
fonts.css the two Inter @font-face rules (separate entry)
fonts/ inter-var-latin(.ext).woff2 — the vendored faces
publish/ NODE ONLY: the manifest bounds, validators and writers
bin/zibby-zip.ts NODE ONLY: the `zibby-zip` command
bin/app-kit.ts NODE ONLY: the `app-kit` command; bin/init.ts is its `init`
template/ the app `app-kit init` writes — shipped, never compiled hereThe package ships built: dist/ carries ESM with explicit .js specifiers
and its own .d.ts files, so Node's ESM loader, Vite and Next all resolve it
as published — no transpilePackages entry, no bundler special-casing. The
sources under src/ are what you read; dist/ is what you import.
The build-time half
An app's src/ imports @zibby-run/app-kit, /react and /ui. Its
scripts/, its vite.config.ts and its package.json reach for these instead.
/publish and the bin are Node code that runs BESIDE the bundle and must never
end up inside it; /vite is a plain object that imports nothing and is simply
of no use anywhere but the Vite config.
| Entry | What it is |
|---|---|
| @zibby-run/app-kit/publish | Node only. The two publish manifests: the page bounds (SLUG_RE, TITLE_MIN/MAX, DESCRIPTION_MIN/MAX, PAGE_CAP, ENTRY_FRAGMENT_MAX, PAGES_MAX_BYTES), validatePages(pages, extra?), pagesManifest, serializePages, writePagesManifest(outDir, pages, extra?), and the sample's SAMPLE_ACTION_RE, SAMPLE_REQUEST_CAP, SAMPLE_MAX_BYTES, SAMPLE_PROVIDERS, validateSample, serializeSample, writeSampleManifest(outDir, sample), plus bundleProblems / zipBundle behind the bin. The bounds are the PLATFORM's, quoted from x-mart/lib/zibby/apps/ and lib/zibby/ai/provider.ex; the writers fail the build with a list of problems, because the platform refuses the whole publish for the same reasons and leaves the previous bundle live. extra keeps an app's OWN page rule (a required slug prefix, say) running beside them. Never import it from src/. |
| zibby-zip (bin) | "zip": "zibby-zip". Reads name from the package.json in the current directory, refuses a dist/ missing index.html, zibby-pages.json or zibby-sample.json, and writes <name>.zip with dist's root as the archive root. |
| app-kit (bin) | npx @zibby-run/app-kit init my-app — copies template/ into a new directory, fills the name and the display name, pins this package's own version, and prints the three next commands. Refuses a non-empty directory, a missing name, a name that cannot become a slug, and an unknown subcommand. The bin's name is the package's unscoped name on purpose: that is the one npx <package> <args> runs. |
| @zibby-run/app-kit/vite | zibbyViteConfig — a plain object, no vite import and no peer dependency: base: "./", build.assetsInlineLimit: 0, test.environment: "happy-dom", test.include. Spread it: defineConfig({ ...zibbyViteConfig, plugins: [react()] }). The test key needs the app's own /// <reference types="vitest" />, which it already has. |
| @zibby-run/app-kit/fonts.css | The two Inter @font-face rules and the two .woff2 files behind them, emitted as bundle assets off the serving origin. ui.css still carries no face — it only names the family — so an app with its own typeface imports ui.css alone. |
Adopting an app (the short version)
- Depend on the kit.
pnpm add @zibby-run/app-kit— or, while working against an unpublished change,pnpm add @zibby-run/app-kit@file:/path/to/zibby-app-kit(afile:dependency resolves throughdist/, so runpnpm buildin the kit first). - Define your actions in one config module of your own: an action union,
a model per action (
{provider, model}with PLATFORM model ids), quoted costs, token/context budgets. - Mount the bridge once at app startup:
React.useEffect(() => installHostBridge(), []). - Wrap the hook so buttons stay one-liners:
export default (action: MyAction) => useZibbyAi(action, MY_MODELS[action], { onCharged: t => myChargeToast(t) }); - Wire the mock (optional but recommended) in a lazily-loaded module
built with
createMockStream, registered viasetMockLoader. An image app addscannedImages: () => [MOCK_IMAGE_DATA_URL]and develops its gallery offline. - Take the build-time half rather than writing it again: spread
zibbyViteConfig, import@zibby-run/app-kit/fonts.css, callwritePagesManifest/writeSampleManifestfrom your two postbuild scripts, and set"zip": "zibby-zip". See The build-time half above. - Link out through the allow-list.
hostNavigate("/billing")— not anhref— for every link out of the app, withfallbackHrefon the standalone anchor andshellPathLabelfor its text. - Build a static export that satisfies CONTRACT.md §1 (relative URLs,
nothing leaves the origin). Publishing is by invitation. A creator's route
is a GitHub repository, which Zibby builds itself — live on zibby.run
since platform v0.5.0 (2026-10-02), by invitation
(CONTRACT.md §13;
AGENTS.md→ Making this repository publishable on Zibby). When the Zibby team publishes an app for you instead,pnpm zipit and hand over the zip, the app's name, an icon and a cover (PNG/JPG/WEBP, at most 2 MB each).AGENTS.md→ What the platform checks at publish tables every limit the archive must meet (50 MB, 400 MB unpacked, 20,000 entries, no absolute or..paths, no symlinks,index.htmlat the archive root).
Images
An image action is an ordinary action whose payload carries
modalities: ["image","text"] (CONTRACT.md §2, "Images"). The generated
pictures come back on ZibbyResult.images / the hook's images as data URLs.
The platform stores none of them — offer a download while they are on
screen.
The request body is capped at 24 MB (a 12 MP photo is ~16 MB once base64), and
over it the proxy answers 413 payload_too_large, which the kit raises as a
ZibbyAiError with that code and details.max_bytes. Downscale before
sending. result.requestId is on a SUCCESSFUL call too — show it when an
answer arrives charged but unusable.
UI layer
src/ui/ is the shared look of the five apps, built from the measured Figma
frames (x-mart/docs/design/apps-2026-09-15/README.md). It is presentational
only — no network, no storage, no host bridge — so it composes with the hooks
above rather than replacing them. The one page-wide fact it reads (0.7.0) is
whether the person's runs are on Zibby (useComped()), so that CreditsChip
never quotes such a person a price. Two import lines:
import "@zibby-run/app-kit/ui.css";
import { AppLayout, Button, Dropzone } from "@zibby-run/app-kit/react";(@zibby-run/app-kit/ui is the same components without the hooks.) The stylesheet
does NOT load a font: it only NAMES the family
(--zk-font: "Inter var", Inter, system-ui, …), as CONTRACT.md §1 requires.
The faces are one import away —
import "@zibby-run/app-kit/fonts.css" — and an app with its own typeface
simply leaves that line out. Every class is zk- and every custom property --zk-, so an app
stylesheet cannot collide; the tokens live on the .zk root class, which
AppLayout already carries.
| Component | What it is |
|---|---|
| AppLayout | The two panes — controls (white, right divider) and output (page grey) — with an optional tabs slot above the output. controlsWidth is a CSS grid track, default minmax(360px, 30%). Stacks at 900 px. Owns the height and the scrolling — see The layout contract below. |
| TitleBlock | H6 app name + Body lede. |
| Section, Stack | A BodyBold label over its block (gap 10); blocks stacked (gap 32). Section takes as. |
| PaneHeader | A pane heading with an action opposite it — "Your scenes" + Download all, "Restored from this browser" + Clear. Titles in a column on the left, the action held against the right edge by space-between and never stretched, both topped out so a two-line note does not drag the button down; it wraps under the titles in a narrow pane. as picks the element (default "h2"), look the size ("h6" default, "body-bold"), id lands on the heading so a landmark can be labelled by it. note takes the WORDS, not a <Helper> — the kit wraps it in one, and a <p> in a <p> is markup the browser unnests. A falsy note or actions (undefined, null, false, "") renders no element at all, so a conditional note costs no empty line. |
| RestoredSection | "Restored from this browser" — the landmark, its three sentences, its confirm and its Clear button, as one component, so the five apps stop wording it five ways. children is the restored list (or the "shown above" helper, or nothing); onClear runs only when the confirm is accepted. It brings its own top margin when something sits above it — see The layout contract. |
| Button, ButtonRow | primary \| secondary \| tertiary, disabled, icon, credits, fullWidth; a real <button> unless as="a"/href. A button with no text child must pass aria-label — the type refuses to compile without one. |
| CreditsChip | Coin + "~N". The coin paints with currentColor, so it is white on primary, brand on secondary/tertiary and disabled grey on a disabled button, with no per-variant asset. Renders nothing for a person whose runs are on Zibby (useComped(), 0.7.0), so no Button, RadioRow or Segmented option quotes them a price. |
| Field, Input, Textarea, Select, FieldRow | Label row (+ counter) over a control; Textarea takes a fixed height, Select carries the 16 px caret, FieldRow puts fields side by side with optional fixed widths. |
| Dropzone, FileCard | idle \| uploaded \| busy, a real file input inside the label (keyboard reachable), drag-and-drop wired; the Studio file card with an 88 px thumb. disabled is the whole-run lock: label, hint and glyph at the disabled colour, cursor: not-allowed as on a disabled radio row, no focus highlight, the box keeping its ground and border. |
| InfoBox | SmallBold lead + Small body, with the credits sentence as note one blank line below. |
| Notice | The error / warn / info surface. Tone is the FACE and role is the ANNOUNCEMENT, and they are separate: error (negative tokens) and warn (amber) default to role="alert", info (tertiary tokens) to role="status", and role overrides any of them — "none" renders no role, for a panel that defers to the alert below it. |
| RadioGroup, RadioRow | Real radios in 56 px rows: label + price, selected and running states. description puts a Small ink-2 blurb inside the row, wired to the input with aria-describedby; the row grows only when one is given. |
| Segmented | The brand-5 container of equal buttons: selected primary, the rest tertiary. |
| Tabs | The 55 px role="tablist" strip, with the WAI-ARIA keyboard map (Arrow Left/Right wrap, Home/End) behind its roving tabindex. |
| Helper, ChargedLine | The Small ink-2 lines under a button and under a result. ChargedLine is a polite live region (role="status") — it is the money surface, and it replaced a toast that announced itself. Helper is not. Pass the run's own comped (terminal.comped, result.comped, a pickup's outcome.terminal.comped; 0.7.0) and it says "Comped: your runs are on Zibby" in place of its children. It never reads the page-wide value, so a run the person paid for stays paid. |
| EmptyState | Centred (or align="start") H6 + Body; tone="brand" is the working state. |
| ResultCard, SceneGrid | The scene shell — picture area, title, actions row — in a 2-column grid. The picture is shown WHOLE, centred, symmetrically letterboxed onto the card's own white: the card and the Download button beside it must be the same photograph. Square by default; mediaRatio takes any CSS aspect-ratio value ("auto" included) and lands on the picture area itself, not on the padded box. title takes as. |
| SliderTags | The "Original"/"Result" tags; the divider and handle STYLES ship in ui.css (zk-slider__divider, zk-slider__handle) and the mechanics stay in the app. |
| Chip, ChipRow | Keyword chips, positive and negative. |
| ScoreCard, CriteriaTable, GapCard, FooterBar | The Tailor analysis blocks. GapCard and FooterBar take as. |
| Icon | One exported Figma glyph, inlined from src/ui/icons/glyphs.ts. |
The glyphs are the exported SVGs, copied into src/ui/icons/ and turned into
one TypeScript module by pnpm icons — no bundler plugin, so the same import
works in the kit's tsc build and in every app's Vite build.
Headings
Section (its label), ResultCard, GapCard and FooterBar (their titles)
each take the same prop:
as?: "p" | "h2" | "h3" | "h4" // exported as LabelAs; default "p"The classes and the styling never change with it — only the element does, so a heading costs nothing. A kit component cannot know where it sits in a page's outline; the app can, and this is how it says so.
PaneHeader takes the same prop and the same values, but defaults to
"h2": a pane header IS the top of a section, so there the heading is the
common case and a <p> the exception. Its look prop is the separate
question of size — "h6" (default) or "body-bold" — so an app can have a
BodyBold <h2> (the restored section) or an H6 one ("Your scenes") without
the element and the type size being the same decision.
The layout contract
AppLayout owns the height and the scrolling, so the apps do not have to.
At 900 px and up the shell fills the height of whatever it is mounted in and each pane scrolls on its own. A control pane longer than the window scrolls inside itself; the output pane's heading stays where it is. Below 900 px the panes stack, both scrollers are released, and the page scrolls as it always did.
What the app owes the shell — a height, and nothing else:
html, body, #root { height: 100%; }
body { margin: 0; }That is the whole contract. AppLayout sets height: 100% and
flex: 1 1 auto on itself, so it fills its container whether it is mounted as
a block in a full-height #root or as the growing child of a full-height flex
column — pick either; if you pick the flex column, give that column
min-height: 0 as flex containers require, and flex-shrink: 0 to any header
or footer sibling in it, because the shell's flex basis is its full height and
the shrink weights are no longer what they were.
What the app must NOT do any more:
| Stop doing | Why |
|---|---|
| min-height: 100vh (or any min-height) on a wrapper around the shell | it makes the shell taller than the window, which is what put the output heading off screen |
| overflow-y: auto on .zk-app__controls / .zk-app__output, scoped or otherwise | the kit sets it, and a scoped override cannot release it again below 900 px |
| any app-side rule that reaches a zk-app* class to set a height, an overflow or a min-height | all three are the kit's now |
In a container that has no height of its own — a docs page, a storybook cell —
the shell behaves exactly as it did before, so nothing breaks where the
contract is not met. The two declarations get there by different routes, and
the difference matters: height: 100% against an indefinite containing block
computes to auto, while flex: 1 1 auto does not resolve to anything — it
simply has no free space to grow into when the container is content-sized. The
stacked band below 900 px therefore has to release the flex growth explicitly
(flex: 0 1 auto) alongside height: auto; releasing only the height would
leave a flex-column mount still filling its column.
EmptyState is height: 100%, so "centred, fills the pane" follows from the
same contract: with a height on the shell it fills and centres in the output
pane; without one it is as tall as its own two lines.
RestoredSection brings its own top margin. It carries
margin-top: 32px, which inside a gap-32 column — the kit's Stack, or an
app's own results column — puts 64 px between it and the block above: twice
today's gap, so the browser's leftovers read as a different thing from this
run's output (the designer, 2026-09-16). The margin is on the section rather
than on a parent so it holds in either column. An app must not add another
— no margin-top on a wrapper around it, no bigger gap on the column that
holds it.
The margin is there only when something sits above the section.
.zk-restored:first-child zeroes it, so a section that is the first child of
its column starts flush with the top of the pane. That case is real: in an
output pane whose other blocks are all conditional — Extender's is — a return
visit with saved results and nothing uploaded renders the section alone, and a
32 px margin there would push it out of line with the control pane's first
block instead of setting it apart from anything. An app needs to do nothing
for this; it needs only to not wrap the section in a <div> that would make
the wrapper, rather than the section, the column's first child.
pnpm demo renders every component in every state to ui-demo.html at the
repo root (CSS and icons inlined, no external request) for a human to open.
Development
pnpm install
pnpm typecheck
pnpm test
pnpm build # -> dist/ (+ llms-full.txt), what the package actually ships
# two tsc passes: the browser half (tsconfig.build.json) and
# the node half, src/publish + src/bin (tsconfig.node.json)
# and what a `file:` consumer resolves through; run it after
# any src change
pnpm demo # -> ui-demo.html, the UI layer in every state
pnpm icons # regenerate src/ui/icons/glyphs.ts after re-exporting from FigmaLicense
MIT — see LICENSE.
