prototype
Use when the design reaches a web application: write its prototype (prototype.json + prototype.tsx), a clickable, checked app on the data model and the roles that the reviewer uses before anything is
SKILL.mdskills/prototype/SKILL.md
GitHubPrototype
A coding run that builds a web-application reads references/implementing.md.
The rest of this file is how the design agent writes a prototype.
A prototype is the reviewable picture of a web-application before it is
built: its screens, navigation, roles, flows and display states, filled with
mock data, running in a sandbox inside the review page. It is the web
application's artifact in the design. The design flow writes it after the
data model and the roles, and before the API contracts, which then serve its
screens. The business reviewer uses it and comments on it, and the build
makes what it shows.
The screens are a React module written against a fixed kit of components
(@wso2/prototype-kit). You compose screens from the kit; you do not build
components, style anything, or reach outside the frame.
Inputs
Read these before writing anything:
specs/design/design.cell: everycomponent … web-applicationline is one prototype to write. No web-application means there is nothing to do: say so and stop. When the turn names components, write only those.specs/design/domain-model.md: the records. Each entity that a screen shows or changes is a collection in the mock data, and its fields are the entity's attributes (see Mock data).specs/design/security.json: the roles. When the file exists, each prototype role'sidis a role'snamefrom it, copied verbatim (same case, same spelling), and itsnameis a short human label for that role. Include exactly the roles that use this application. Each role'sdescriptionsays what the role does and what it never sees: show each role only the screens, controls and records that its description allows. Without the file, take the roles from the PRD's actors and give them short kebab-case ids.- The web application's
design.json:storieslists the stories it serves. - The requirements of those stories: each feature's file,
specs/requirements/features/F<n>-<slug>.md, with its stories (F<n>.<m>) and its Decisions, the*assumed*lines included;product-wide.mdfor the items that apply to it; andprd.md's Actors, for the people your records are about. Every story the application serves gets at least one screen. Set a story aside only when the product gives it no view (sign-in through the platform's SSO, a machine-facing endpoint), and name it in your closing. - A sketch or mockup the user attached: the layout brief. Refine it; do not reinvent it. Where it and the stories disagree, the stories' scope wins and the sketch's layout intent survives inside it.
specs/design/components/<component>/prototype.jsonandprototype.tsxif they already exist: you are revising them, not starting over (see Stable ids).
Output
Two files per web-application, in specs/design/components/<component>/,
where <component> is the web-application's name in the cell:
prototype.json, the manifest: the roles, display states, screens and flows the reviewer picks from. Write it first.prototype.tsx, the screens: one React component per manifest screen, exported withdefineApp. It is checked against the manifest already written.
Write a new file with ONE addFile of the whole file. Revise an existing one
with editFile edits: the file is already in front of you, and an edit costs
what it changes. Never write a partial file to finish later.
Each write of prototype.tsx is type-checked alone. A change to a record type
and its uses (a field that you add, rename or remove) cannot go in as one edit
per line: the first edit leaves the other uses wrong, and the check refuses
it (TYPE_ERROR, one finding for each wrong line). Write such a change, or
any other change to many lines, in one write: removeFile, then ONE addFile
of the whole corrected file, with each id as it was.
This step writes only these two files. A screen can need a record, a field or
a role that the data model or security.json does not have. Then change that
file first (it comes earlier in the design), and write the screen after it.
Do not change a requirement here: the design's gap check writes the
requirements.
Every write is checked as it lands: the manifest's shape and references, the source's static rules, then a render of every screen for every role that reaches it in every display state. A refusal lists every finding with its code and where (a JSON path in the manifest, a line in the source, or the screen, role and state that failed to draw). Fix all of them and retry once. The codes are listed at the end of The kit.
The manifest
Plain JSON, schema version 3. Every object is closed: a key not shown here is refused.
{
"schemaVersion": 3,
"name": "Expense approvals",
"entryScreen": "screen.queue",
"roles": [{ "id": "Approver", "name": "Approver" }],
"states": [
{ "id": "state.default", "name": "Default" },
{ "id": "state.empty", "name": "Nothing to show" },
{ "id": "state.validation-error", "name": "Validation errors" },
{ "id": "state.failed", "name": "Expense feed failed" }
],
"screens": [
{ "id": "screen.queue", "name": "Approval queue", "roleIds": ["Approver"] },
{ "id": "screen.detail", "name": "Expense detail", "roleIds": ["Approver"] },
{ "id": "screen.account", "name": "Account", "roleIds": ["Approver"] },
{ "id": "screen.signed-out", "name": "Signed out", "roleIds": ["Approver"] }
],
"flows": [
{ "id": "flow.approve", "name": "Approve an expense", "roleId": "Approver",
"screenIds": ["screen.queue", "screen.detail"] }
]
}
roles,statesandscreenshave at least one entry; their ids and the flows' ids share one namespace and are unique together.entryScreenis the screen the prototype opens on.nameis the application's name.- A screen's
roleIdsare the roles that reach it. A flow is one role's walk through screens that role reaches, in order, for one story. Give each story that has a view a flow, named for what the actor does ("Submit a claim"). - The app shell's screens, an account screen and a signed-out screen, go to every role: every user has them.
- A settings screen exists only for the settings that the requirements name (a notification choice, a default that a user picks). It goes to the roles that set them, and it holds those settings and nothing else. When the requirements name no setting, write no settings screen. Never invent a preference (a digest email, a Slack alert, a compact view) to fill one.
statesalways starts with a default state (state.default); add one per presentation a reviewer must see:state.emptywherever a list can be empty.- Wherever the product can refuse or fail what a screen does, the states
that the refusal or the failure produces. A form that a requirement limits
(a required receipt, a cap) shows its validation errors in
state.validation-error: its Fields'errorand a ValidationSummary. A screen whose data or action can fail (an outside service it needs, a save) getsstate.failed: an error Alert saying what failed and what the user can do. A step that can be slow (an outside service, a long-running job) getsstate.delayed: an info or warning Alert saying the result is on its way. A prototype with only default and empty states hides the presentations a reviewer most needs to judge.
The screens
import { useState, type ReactNode } from "react";
import {
Alert, AppShell, Button, Detail, Dialog, EmptyState, Field, Form, Heading, Screen, Section, Stat,
StatGroup, Table, defineApp, useCollection, useDisplayState, useNav, useParams,
} from "@wso2/prototype-kit";
interface Expense { id: string; employee: string; amount: string; status: string }
const expenses: Expense[] = [
{ id: "1042", employee: "Maya Fernando", amount: "$148.20", status: "Awaiting approval" },
{ id: "1039", employee: "Ravi Perera", amount: "$62.00", status: "Awaiting approval" },
{ id: "1031", employee: "Ana Silva", amount: "$310.00", status: "Approved" },
];
const user = { name: "Ravi Perera", email: "ravi@acme.example" };
function Shell({ children }: { children: ReactNode }) {
return (
<AppShell
id="shell"
user={user}
nav={[{ id: "nav.queue", label: "Approval queue", to: "screen.queue" }]}
account="screen.account"
signOut="screen.signed-out"
>
{children}
</AppShell>
);
}
function Queue() {
const state = useDisplayState();
const all = useCollection<Expense>("expenses");
const waiting = state === "state.empty" ? [] : all.items.filter((e) => e.status === "Awaiting approval");
const approved = all.items.filter((e) => e.status === "Approved").length;
return (
<Shell>
<Heading id="heading.queue" text="Approval queue" />
{state === "state.failed" && (
<Alert id="alert.feed-failed" tone="error" title="Expense feed unavailable" text="Amounts may be stale. Refresh in a few minutes." />
)}
<StatGroup>
<Stat id="stat.waiting" label="Awaiting you" value={String(waiting.length)} hint="claims to decide" icon="Inbox" tone="warning" />
<Stat id="stat.approved" label="Approved" value={String(approved)} hint="this month" icon="CircleCheck" tone="success" />
</StatGroup>
<Section id="section.expenses" title="Expenses" count={waiting.length} subtitle="Oldest first. Open a claim to reject it with a reason.">
<Table
id="table.expenses"
columns={["Employee", { label: "Amount", kind: "number" }, { label: "Status", kind: "status" }]}
rows={waiting.map((e) => ({
id: `expense.${e.id}`,
cells: [e.employee, e.amount],
status: { text: e.status, tone: "warning" },
to: "screen.detail",
params: { expense: e.id },
actions: [{ id: `expense.${e.id}.approve`, label: "Approve", onPress: () => all.update(e.id, { status: "Approved" }) }],
}))}
empty={<EmptyState id="empty.queue" title="Nothing to approve" text="New claims appear here." />}
/>
</Section>
</Shell>
);
}
function ExpenseDetail() {
const { expense: id } = useParams();
const state = useDisplayState();
const navigate = useNav();
const all = useCollection<Expense>("expenses");
const [rejecting, setRejecting] = useState(false);
const expense = (id ? all.get(id) : undefined) ?? all.items[0]!;
const decide = (status: string) => {
all.update(expense.id, { status });
navigate.go("screen.queue");
};
return (
<Shell>
<Heading id="heading.detail" text={`Expense ${expense.id}`} />
<Detail
id="detail.expense"
fields={[
{ label: "Employee", value: expense.employee },
{ label: "Amount", value: expense.amount },
]}
/>
<Button id="btn.approve" label="Approve" emphasis="primary" onPress={() => decide("Approved")} />
<Button id="btn.reject" label="Reject" emphasis="danger" onPress={() => setRejecting(true)} />
<Dialog id="dialog.reject" title={`Reject expense ${expense.id}`} open={rejecting} onClose={() => setRejecting(false)}>
<Form
id="form.reject"
onSubmit={() => decide("Rejected")}
actions={<Button id="btn.reject.confirm" label="Reject" emphasis="danger" submit />}
>
<Field
id="field.reason"
label="Reason"
type="textarea"
required
error={state === "state.validation-error" ? "Give the employee a reason" : undefined}
/>
</Form>
</Dialog>
</Shell>
);
}
function Account() {
return (
<Shell>
<Heading id="heading.account" text="Account" />
<Detail id="detail.account" fields={[{ label: "Name", value: user.name }, { label: "Email", value: user.email }]} />
</Shell>
);
}
function SignedOut() {
return (
<Screen>
<Heading id="heading.signed-out" text="You are signed out" />
<Button id="btn.sign-in" label="Sign in" emphasis="primary" to="screen.queue" />
</Screen>
);
}
export default defineApp({
screens: {
"screen.queue": Queue,
"screen.detail": ExpenseDetail,
"screen.account": Account,
"screen.signed-out": SignedOut,
},
data: { expenses },
});
- One component per manifest screen, keyed by the screen's id in
defineApp({ screens }): exactly the manifest's screens, no more, no less. - Every screen sits in the app shell. Write one
<AppShell>in a small local component (Shellabove) and make it every screen's root: it draws the product's header, the signed-in user and the user menu (Account, Settings, Sign out) and the side navigation. ItsaccountandsignOutare screens you write: an account screen with the user's details, and a signed-out screen. Only that signed-out screen, outside the product, has a bare<Screen>as its root, with a way to sign back in. Givesettingsonly when the prototype has a settings screen (see the manifest rules): without it, the user menu has no Settings entry. The shell'smenuadds the application's own user-menu entries (a profile, billing) before Sign out. A reviewer points at an entry by opening the menu in Preview, then switching to Annotate. - One shell serves every role. A navigation entry is drawn only for the
roles that reach its screen. Give
userthe person each role signs in as (pick byuseRole()when roles differ); the header shows the viewing role's name beside them. - Read the review through the hooks.
useDisplayState()is the display state the reviewer chose anduseRole()the role they view as: branch on them to show an error, an empty list, a role's own controls. Show a control only to the roles that reach where it leads: atoa role cannot follow is refused. - Navigate with
toon a Button, Link, table row, row action or breadcrumb (withparamsthe target reads throughuseParams());useNav().go(...)inside anonPressfor a navigation that follows an action. Every screen must also draw without params (the reviewer can open any screen from the pickers), so fall back to the first record. - Overlays are the screen's own state:
const [open, setOpen] = useState(false)and<Dialog open={open} onClose={() => setOpen(false)}>. Tabs and Steppers keep their own position, or takeactiveandonChangewhen a button moves them (a wizard's Next). - Forms submit through the kit. A
<Form onSubmit>with a<Button submit>validates its Fields (required,pattern) and then callsonSubmitwith their values keyed byname(default: the Field's id). A Field with novalueholds what is typed into it; give itdefaultValueto start filled. - The mock data is
defineApp({ data }). An array of records that each have a stringidis a collection: read it withuseCollection(name)and change it withcreate,updateandremove, so the next screen shows the result. Anything else is a single value foruseValue(key). The change lives in memory until the reviewer presses Reset data; nothing is sent anywhere.
What a prototype may not use is refused as it lands: imports other than react
and @wso2/prototype-kit; raw HTML elements (<div>, <table>, <img>, …),
because every element is a kit component; the page, storage, network and
code-generation APIs (window, document, fetch, eval, …); and anything
nondeterministic (Math.random(), Date.now(), new Date() with no argument;
use useToday() for today's date). Styles do not exist: the theme draws every
component, and a component takes no className or style.
What the reviewer sees
The review renderer draws only what the screen component renders: kit
components, inside the chrome its <AppShell> draws (the header with the
product's name and the signed-in user, the user menu with Account, Settings
when the shell names a settings screen, and Sign out, the side navigation).
A bare <Screen> is drawn with no chrome at all.
Nothing else is drawn automatically: no notification bell, search, theme
toggle, help menu or footer, whatever the built application's shell will
carry. If a reviewer asks for such chrome, model it in the kit: a navigation
entry, or a Button or Link in the screen's content (a Heading's actions for
the top right) whose to is the screen it leads to or whose onPress opens a
Drawer (a notification list) or a Dialog (a confirmation). Only if what they
ask for is outside the kit, say so in your reply and name the limitation.
Never tell a reviewer the platform draws something the screen does not render.
The Oxygen look
The reviewer sees the prototype in the Oxygen theme, the look of WSO2 products, so a prototype should read as an Oxygen screen. The host applies the theme: the source never imports one, never styles anything, and never names Oxygen. Your part is composing screens the way an Oxygen application composes them:
- Page anatomy. A screen is the
<AppShell>holding a<Heading>(the page title, with page-wide action Buttons inactions), then the content in the order a user works through it: headline numbers as one<StatGroup>of<Stat>s, then a<Section>per part of the page (a list, a form, a record), with<Filters>and the<Table>inside it, and<Detail>,<Form>and<Timeline>for a single record. A sub-page opens with<Breadcrumbs>back to its list. - Stats in a group. Put a screen's
<Stat>s in one<StatGroup>, never a<Stack direction="row">or<Grid>: the group gives them one width. Give each ahintthat gives the number its scale ("of 20 left this year", "claims to decide") and aniconfrom the kit's list that names what it counts;tonecolours the icon when the number needs attention. - Sections own their actions. A
<Section>titles a part of the page, withcountfor the records it lists andsubtitlefor one line on what it holds. An action that adds to or acts on that part sits in the Section'sactions(New request above the requests), not under the table and not in the page Heading. - One primary action per screen.
emphasis="primary"marks the screen's main action and nothing else; destructive actions are"danger". - Status is a chip, not prose. A record's status is the table's status
column (
{ label: "Status", kind: "status" }incolumns, and each row'sstatus: { text, tone }) or a<Badge>:success,warning,error,info,default. Put the column where it reads best; the row'scellsfill the other columns in order. Callouts are<Alert>s with a tone. - Row actions are buttons, not cells. What a user does to one record
without opening it (Cancel, Approve) is the row's
actions, each with an id built from the row's (`${rowId}.cancel`), alabel, andtooronPresslike a Button (emphasis: "danger"for a destructive one). They draw in a trailing column (behind one menu past two) and pressing one never presses the row, so the row can still open the record. Never write an action's label into a cell. Figures are{ label, kind: "number" }columns. - Navigation is the shell's side rail, one entry per top-level screen. Account and Settings live in the user menu, not the rail.
- Records, not cards. Show a list of records as a
<Table>whose rows open the record; use<Split>for a record next to its activity. - Forms are short and labelled. One
<Form>per task, a<Field>per input with a plain label,requiredwhere the API requires it, errors in the Field'serrorand a<ValidationSummary>for the whole form.
When the organization names a design system, its skill is loaded beside this one and is the full account of how an enterprise screen is composed (a listing page's header, filters and table, a form's layout and actions). Read it for the arrangement and build each screen that way from the kit; with none loaded, use the rules above. Its setup, packages and code are the build's concern, not this flow's: the kit's components are the only vocabulary here.
The kit
Nothing else exists: no markup, styles, scripts, charts, file upload or custom component. Express a screen in these components or leave the part out.
<!-- kit:start -->A prototype is prototype.json (the manifest) and prototype.tsx, which imports only react and @wso2/prototype-kit. Every component with an id is an element a reviewer can point at: give each a stable id, unique on its screen.
Module
defineApp(definition: PrototypeAppDefinition): PrototypeApp
Declare the prototype: export default defineApp({ screens, data }).
PrototypeAppDefinition
screens: Record<string, ComponentType>— One component per manifest screen, keyed by the screen's id.data?: Record<string, unknown>— The mock data the screens share, as JSON literals. An array of records that each have a stringidis a collection (useCollection(name)); anything else is a single value (useValue(key)). Changes live until Reset data (or a reload without--persist).
View hooks
useNav(): KitNav
KitNav
screen: string— The screen shown.go: (screenId: string, params?: Record<string, string>) => void— Go to a screen, optionally with params the target reads withuseParams.
useParams(): Readonly<Record<string, string>>
The params the navigation to this screen carried. Empty when the reviewer opened the screen from the preview's pickers, so every screen must render without them (fall back to the first record).
useRole(): string
The id of the role the reviewer is viewing as.
useDisplayState(): string
The id of the display state the reviewer chose (e.g. empty, loading, error).
Data hooks
useCollection<T extends { id: string; }>(name: string): Collection<T>
The collection name from defineApp({ data }): an array of records that each have a string id.
Collection
A collection of mock records, and the ways to change it for every screen.
items: readonly T[]— The records, in insertion order.get: (id: string) => T | undefinedcreate: (record: Omit<T, "id">) => string— Adds a record and returns its new id,${name}-${n}withnpast the highest number in use.update: (id: string, patch: Partial<Omit<T, "id">>) => voidremove: (id: string) => void
useValue<T>(key: string): [T, (next: T | ((previous: T) => T)) => void]
The single value key from defineApp({ data }), and a setter shared by every screen.
useToday(): string
Today's date, fixed (PROTOTYPE_TODAY): a prototype never reads the clock.
PROTOTYPE_TODAY = "2026-01-15"
The fixed date every prototype treats as today (ISO YYYY-MM-DD).
Layout
<AppShell>
The root of a screen inside the product's chrome: header, user menu and side navigation. Use <Screen> for a screen outside it (signed out).
id: string— The shell's id; its user menu's elements are<id>.user,<id>.account,<id>.settings(withsettings) and<id>.sign-out.product?: string— The product's name in the header; prototype.json'snameby default.user: AppShellUser— The signed-in user the header shows.nav: NavigationItem[]— The side navigation's entries.account: string— The screen the user menu's Account entry opens.settings?: string— The screen the user menu's Settings entry opens. Leave it out when the application has nothing to set: the menu then has no Settings entry.signOut: string— The screen Sign out leads to: a signed-out screen, drawn on a bare<Screen>.menu?: NavigationItem[]— More user-menu entries (a profile, billing), drawn after Account (and Settings) and before Sign out.children?: ReactNode— The screen's content.
AppShellUser
name: stringemail?: string— Shown under the name in the user menu.role?: string— The role beside the name; the viewing role's name in prototype.json by default, so a role switch shows in the header.
<Screen>
The root of a screen outside the app shell (signed out, a landing page): its content, and navigation if any.
nav?: ReactNode— Navigation for an app drawn without<AppShell>: one<Navigation>, usually shared by every screen.children?: ReactNode
<Section>
A titled part of a screen — a table, a form, a group of stats — with the actions that belong to it.
id: stringtitle: stringsubtitle?: string— One line under the title: what the section holds or why.count?: number— How many records the section lists, shown beside the title.actions?: ReactNode— The section's own Buttons ("New request" above the requests it adds to).children?: ReactNode
<Stack>
Children in a column (default) or a wrapping row.
direction?: "row" | "column"children?: ReactNode
<Grid>
Children in 1–6 equal columns.
columns: number— 1–6 equal columns (one column on a narrow window).children?: ReactNode
<Split>
Two panes side by side (stacked on a narrow window).
left: ReactNoderight: ReactNoderatio?: number— The left pane's share of 12 columns (default 6).
<Detail>
A read-only record: label/value pairs.
id: stringtitle?: stringfields: DetailField[]
DetailField
label: stringvalue: string
Navigation
<Navigation>
Navigation for an app drawn on <Screen nav> (<AppShell nav> draws its own). Its items are what a reviewer points at.
id: stringlayout: "side" | "top"items: NavigationItem[]
NavigationItem
id: stringlabel: stringto: string— The screen the entry opens; it shows as active there.
<Breadcrumbs>
A trail back to where the reviewer came from.
id: stringitems: BreadcrumbItem[]— The trail, root first.
BreadcrumbItem
id: stringlabel: stringto?: string— Where the crumb leads; the last crumb is the current page and takes none.params?: Record<string, string>
<Tabs>
Tabbed panels. Each tab header is an element a reviewer can point at (its id); the Tabs itself is layout.
id: stringtabs: Panel[]active?: string— The open tab, when the screen controls it; the first tab otherwise.onChange?: ((tabId: string) => void)
<Stepper>
A multi-step wizard. Each step header is an element a reviewer can point at (its id).
id: stringsteps: Panel[]active?: string— The current step, when the screen controls it (a Next button); the first step otherwise.onChange?: ((stepId: string) => void)
Panel
id: stringlabel: stringcontent: ReactNode
Content
<Heading>
A page or section title, with the actions beside it.
id: stringtext: stringlevel?: "page" | "section"—page(default) for a screen's title;sectiontitles a part of it. Prefer<Section>for a part with its own actions or records.actions?: ReactNode— Buttons and Links beside the heading.
<Text>
A paragraph of body text.
id: stringtext: stringtone?: "primary" | "secondary"—secondary(default) for body copy,primaryfor emphasis.
<Badge>
A status chip.
id: stringlabel: stringtone?: Tone
<Stat>
A headline number with its label. Put a screen's stats side by side in a <StatGroup>.
id: stringlabel: stringvalue: stringhint?: string— A short line under the value that gives it scale or context: "of 20 days", "3 due this week".icon?: StatIcon— An icon beside the label.tone?: Tone— Colours the icon (defaultis the theme's primary).
StatIcon = "Activity" | "Bell" | "Briefcase" | "Bug" | "Building2" | "Calendar" | "CalendarCheck" | "CalendarClock" | "CalendarDays" | "ChartColumn" | "CircleCheck" | "CircleX" | "ClipboardList" | "Clock" | "Cloud" | "Cpu" | "CreditCard" | "Database" | "DollarSign" | "FileText" | "Gauge" | "Globe" | "HeartPulse" | "Hourglass" | "Inbox" | "Layers" | "ListChecks" | "Lock" | "Mail" | "MessageSquare" | "Package" | "Plane" | "Receipt" | "Rocket" | "Server" | "Shield" | "ShoppingCart" | "Star" | "Tag" | "Ticket" | "Timer" | "TrendingDown" | "TrendingUp" | "TriangleAlert" | "Truck" | "User" | "UserCheck" | "Users" | "Wallet" | "Zap"
The icons a <Stat> may show, named as in WSO2's Oxygen icon set (Lucide's names). A theme draws each or none; an unknown name fails the check.
<StatGroup>
A row of <Stat>s at equal widths, wrapping on a narrow window.
children?: ReactNode— The<Stat>s, in reading order.
<Alert>
A callout: info, success, warning or error.
id: stringtone: Tonetitle?: stringtext: string
<EmptyState>
What a list shows when there is nothing in it.
id: stringtitle: stringtext: stringactions?: ReactNode— The Buttons that start something.
<Button>
A button. Give it to to navigate, onPress to do anything else, submit to submit its form.
id: stringlabel: stringemphasis?: "primary" | "danger"—primaryfor the screen's main action,dangerfor a destructive one; outlined otherwise.disabled?: booleansubmit?: boolean— Submits the enclosing<Form>(which validates its fields, then calls itsonSubmit).to?: string— Navigate to this screen when pressed. Prefer it toonPressfor plain navigation: the checks verify it.params?: Record<string, string>— The paramstocarries, read on the target withuseParams.onPress?: (() => void)— Run when pressed: change mock data, open a dialog, navigate conditionally.
<Link>
An inline link; presses like a Button.
id: stringlabel: stringto?: string— Navigate to this screen when pressed. Prefer it toonPressfor plain navigation: the checks verify it.params?: Record<string, string>— The paramstocarries, read on the target withuseParams.onPress?: (() => void)— Run when pressed: change mock data, open a dialog, navigate conditionally.
Tone = "default" | "info" | "success" | "warning" | "error"
Pressable
to?: string— Navigate to this screen when pressed. Prefer it toonPressfor plain navigation: the checks verify it.params?: Record<string, string>— The paramstocarries, read on the target withuseParams.onPress?: (() => void)— Run when pressed: change mock data, open a dialog, navigate conditionally.
Forms
<Form>
A form card: its Fields, then its action Buttons. Validates on submit.
id: stringtitle?: stringchildren?: ReactNode— Its Fields.actions?: ReactNode— Its Buttons, under the fields; a<Button submit>submits.onSubmit?: ((values: Record<string, string>) => void)— Called with every field's value, keyed by name, when a submit passes validation.
<Field>
One input. It holds what is typed into it; inside a Form, the Form validates and submits it.
id: stringlabel: stringname?: string— The key of its value in the enclosing Form'sonSubmitvalues (default: its id).type?: FieldTypevalue?: string— Controls the field: it shows this; withonChangeit is editable, without it read-only.defaultValue?: string— The starting value of a field that holds its own (novalue). A switch isonoroff.options?: string[]— A select's choices.error?: string— A problem to show regardless of validation (e.g. in a validation-error display state).required?: booleanpattern?: string— A regular expression the whole value must match when it is not empty.patternMessage?: string— What a value that does not matchpatternshows.placeholder?: stringonChange?: ((value: string) => void)
FieldType = "number" | "text" | "select" | "date" | "textarea" | "switch"
<Filters>
A filter bar above a list.
id: stringchildren?: ReactNode— Its Fields, laid out compactly in a row.
<ValidationSummary>
The problems a failed submit shows, for a display state that shows them without a submit.
id: stringissues: string[]
Data
<Table>
A table of records.
id: stringtitle?: stringcolumns: (string | TableColumn)[]rows: TableRow[]onRowPress?: ((rowId: string) => void)— Run when a row is pressed, with its id; a row with neither this nortois only highlighted.empty?: ReactNode— What an empty table shows instead (an<EmptyState>).
TableColumn
label: stringkind?: "number" | "text" | "status"—text(default);numberaligns figures to the right;statusshows each row'sstatusas a badge (one per table). A plain string is atextcolumn.
TableRow
id: string— The row's element id: unique on the screen (prefix it with the table's).cells: string[]— One pertextornumbercolumn, in column order; the status column takesstatus.status?: TableStatus— The badge in the table's status column.actions?: TableAction[]— What can be done to this record, drawn as buttons in a trailing column; pressing one does not press the row.tone?: Tone— Colours the last cell as a status, in a table without a status column. Preferstatus.to?: string— Open this screen when the row is pressed.params?: Record<string, string>
TableStatus
text: stringtone: Tone
TableAction
id: string— The action's element id: unique on the screen (prefix it with the row's).label: stringemphasis?: "danger"—dangerfor a destructive action.to?: string— Navigate to this screen when pressed. Prefer it toonPressfor plain navigation: the checks verify it.params?: Record<string, string>— The paramstocarries, read on the target withuseParams.onPress?: (() => void)— Run when pressed: change mock data, open a dialog, navigate conditionally.
<Timeline>
An activity history.
id: stringtitle?: stringentries: TimelineEntry[]— Newest first.
TimelineEntry
when: stringwho: stringtext: string
Overlays
<Dialog>
A modal.
id: stringtitle: stringopen: booleanonClose: () => voidchildren?: ReactNodeactions?: ReactNode— Its Buttons, at the bottom.
<Drawer>
A side panel.
id: stringtitle: stringopen: booleanonClose: () => voidchildren?: ReactNode
Finding codes
prototype check --json prints { ok, findings: [{ code, file, location, message }] } and exits 1 when there are findings.
| Code | Meaning |
|---|---|
MISSING_FILE |
prototype.json or prototype.tsx is not in the folder. |
SCHEMA_VIOLATION |
prototype.json is not JSON, or does not have the manifest's shape. |
UNSUPPORTED_VERSION |
prototype.json's schemaVersion is not 3. |
DUPLICATE_ID |
A role, state, screen or flow id repeats another; the four share one namespace. |
UNKNOWN_REFERENCE |
A manifest reference names nothing, or a flow step its role cannot reach. |
SYNTAX_ERROR |
prototype.tsx does not parse. |
FORBIDDEN_IMPORT |
An import other than react and @wso2/prototype-kit, a dynamic import(), or require. |
FORBIDDEN_API |
A browser, network, storage, code-generation or nondeterministic API. |
FORBIDDEN_ELEMENT |
A raw HTML element; screens are drawn with kit components only. |
SOURCE_TOO_LARGE |
prototype.tsx is over the size cap. |
NO_APP |
prototype.tsx does not export default defineApp({ screens, data }). |
SCREEN_MISMATCH |
defineApp's screens are not exactly the manifest's screens. |
UNKNOWN_NAV_TARGET |
A to or go() names a screen that does not exist, or one the viewing role cannot reach. |
TYPE_ERROR |
prototype.tsx does not type-check: it reads a property, prop or name its types do not have, or passes a value of the wrong type. |
RENDER_FAILED |
The module, or a screen for some role and display state, throws or runs too long. |
DUPLICATE_ELEMENT_ID |
Two elements on one screen share an id. |
Mock data
Records are part of the source and never generated at render time, so write them out in full:
- Shaped by the data model. Each collection in
defineApp({ data })is an entity ofdomain-model.md, and each record has that entity's attributes. Columns, detail fields and form fields are those attributes, in words a user reads (submittedAtbecomes "Submitted"). A value has the type the model gives it: an enum value is one of its values, an amount reads as an amount. A relation shows as the related record's name, not its id. Type the records after the entity (interface Expense { … }). - Realistic. Plausible names, amounts, dates and statuses for the domain;
never
foo,test,Lorem ipsumorItem 1. - Deterministic. The same design gives the same records: fixed values, no
randomness, no "today" other than
useToday(). A row's id is derived from the record (expense.1042). - Enough rows to read as real (three to six), and a state in which a list is empty when the story has one.
Stable ids
Manifest ids and element ids (the id of every kit component) are identities,
labels are not: a reviewer's annotations point at element ids, and shared
links name screens, flows and states, so:
- Name them
<kind>.<slug>from what the thing is (screen.queue,btn.approve,field.amount), except role ids, which aresecurity.json's names. An element id is unique on its screen; a row's id is prefixed with its table's kind (expense.1042). - Revising an existing prototype keeps every key and id whose thing still exists. Rename labels freely; change an id only when its thing is removed or genuinely becomes something else.
- Regenerating a prototype that has review comments (
/prototypeon a reviewed design) is a revision like any other: edits, ids kept. Leavespecs/design/review.jsonas it is. Its comments stay open for Address comments, and the file's Checked mark clears by itself when the prototype changes.
Closing
When a prototype passes its checks, report it in one chat line: its screen count and flow count, then each story you set aside and anything from the design you could not show, with the reason.