@himz-genui/core
v2.0.0
Published
Contract-driven UI for coding agents. Ship component contracts, let the agent materialize them once, compose screens from them, and gate every step with a deterministic check.
Downloads
1,317
Maintainers
Readme
genui-fw
Contract-driven UI for coding agents.
genui-fw ships contracts for UI components, not code. Your coding agent — Claude Code, Cursor,
Codex or GitHub Copilot — implements each contract once for your platform, then composes screens
from those implementations. Three deterministic checks keep the agent inside the lines, so the UI
it produces is consistent across the whole project.
Phase A materialize contract ──▶ agent writes ui/Button.tsx ──▶ fw verify Button
Phase B compose your screen description ──▶ agent writes screens/home.ui.json ──▶ fw check screens/home.ui.json
──▶ agent composes src/screens/HomeScreen.tsx from ui/ ──▶ fw check src/screens/HomeScreen.tsxTwo packages:
| package | what |
|---|---|
| @himz-genui/core | the fw CLI, the t type system and define* helpers |
| @himz-genui/rules | 76 platform-neutral component contracts |
Status: 1.2.1 on npm (
@himz-genui/core,@himz-genui/rules), React and Flutter, 76 contracts. Behavioural test generation is on the roadmap (see Limitations).
Table of contents
- Why
- Install
- Quick start
- How it works
- Project layout
- Describing your app (
ui-spec/) - Languages
- Flutter
- Compatibility
- Theming
- Integrations
- Contracts
- Screen specs
- Commands
- Agent rules
- Registering existing components
- CI
- Limitations
- FAQ
- Example
- Contributing
- License
Why
Letting an agent write UI freely gives you a different Button on every screen, hard-coded colors,
invented routes and components nobody can find. Rules files (CLAUDE.md, .cursorrules) help a
little, but they are soft: the agent reads them and still drifts.
genui-fw moves the hard rules out of prose and into validators, and gives the agent a small,
fixed set of building blocks:
- A contract per component — props, events, states, behaviour rules, accessibility, composition constraints — written once in TypeScript, valid for any platform.
- One implementation per component per project, written by the agent from the contract into
ui/, verified against the contract, then reused. Never rewritten per screen. - A JSON spec per screen that may only reference catalog components, checked before any code exists.
- A lint on screen code: compose from
ui/only, no raw markup, no foreign component libraries. - Navigation declared per screen (
goTo,back,params), so the agent cannot invent routes.
The promise is precise: within one project, every component and screen the agent produces matches its contract and is consistent with the others. It does not promise that two different projects produce identical code.
Compared to alternatives:
| | ships | agent does | consistency comes from |
|---|---|---|---|
| Runtime renderers (json-render, A2UI) | renderer + real components | emits JSON at runtime | the renderer |
| shadcn/ui | component source for one platform | composes | copied code |
| Rules files | prose | everything | hoping the agent reads |
| genui-fw | contracts + validators | implements once, composes many | fw verify / fw check |
Install
Requires Node ≥ 20 and a React + TypeScript project (or an empty folder — see --create).
npm i -D @himz-genui/core @himz-genui/rules
npx fw init --agent claude --platform react --name "My App"--agent is one of claude, cursor, codex, copilot. Add --create vite to scaffold a Vite React TS
app first. On React, fw init also adds what fw verify runs behavioural checks with (vitest, jsdom, Testing
Library) to devDependencies: run npm install once afterwards. fw check needs none of them.
Quick start
1. Describe the app in ui-spec/ (you write these; no code, no JSON). Start with the outline, the whole
app by name, then fill in details:
// ui-spec/app.ts: written first
export default defineApp({
screens: {
Home: 'Browse and search the card catalog',
CardDetail: 'One card with all details',
Cart: 'Review items and go to checkout',
},
components: ['Container', 'Stack', 'Grid', 'TopBar', 'Card', 'Image', 'Heading', 'Text', 'Button', 'Pagination'],
});
// ui-spec/domain.ts
export default defineDomain({
Card: t.object({ id: t.string(), name: t.string(), imageUrl: t.string(), priceLabel: t.string(), stock: t.number() }),
});
// ui-spec/screens/home.ts: one file per screen, the file name is the screen name
export default defineScreen({
shows: [
'Top bar with the shop name and a cart action showing the item count',
'Responsive grid of cards: image, name, rarity badge, price, add-to-cart button',
'Pagination below the grid',
],
local: { search: 'search cards by name', changePage: 'go to another page', addToCart: 'add a card to the cart' },
when: { 'nothing matches': 'show an empty state that clears the search' },
data: { cards: 'Card[]', cartCount: 'number', page: 'number', pageCount: 'number' },
goTo: { CardDetail: 'tap a card', Cart: 'press the cart action' },
});
// ui-spec/screens/card-detail.ts
export default defineScreen({
shows: ['Large card image with name, rarity, price', 'Add-to-cart button'],
params: { cardId: 'string' },
goTo: { Cart: 'press the cart action' },
back: 'Home', // back to the previous screen; Home when opened from a link
});Not sure what to put in a screen file or in domain.ts? Write only the outline and ask the agent
("Draft the Cart screen for me to review"): it drafts ui-spec/screens/<name>.ts plus any domain types
the screen needs, runs fw check, and shows you both before building anything. Waiting for your approval is
an instruction in the agent rules, not something fw check can enforce.
2. Ask your agent for a screen:
Build the Home screen from ui-spec/screens/home.ts
The agent follows the generated rules: fw docs Button → writes ui/Button.tsx → fw verify Button
until it passes; writes screens/home.ui.json → fw check screens/home.ui.json until it passes; composes
src/screens/HomeScreen.tsx from ui/ → fw check src/screens/HomeScreen.tsx.
3. Check everything before you commit:
npx fw checkBesides findings, fw check prints what is left for every outline entry:
Outline ui-spec/app.ts: 3 screens, 10 components
described 3/3
spec 1/3 todo: CardDetail, Cart (screens/<name>.ui.json)
code 1/3 todo: CardDetail, Cart (src/screens/<Name>Screen.tsx)
in ui/ 8/10 todo: Pagination, Image (fw docs <Name>, write ui/<Name>, fw verify <Name>)
Navigation
Home → CardDetail, Cart [no back] (first screen)
CardDetail → Cart [back, else Home]
Cart → Checkout [back]How it works
Contracts are the source of truth. One defineComponent({...}) file produces three things: the
markdown the agent reads (fw docs), the types the spec checker validates against, and the assertions
fw verify runs on the implementation. Nothing is duplicated, so docs, validation and verification can't
disagree.
A contract is a shared UI/UX commitment; code is a per-platform implementation. Two platforms may — should — differ in code and idiom, but must satisfy the same assertions:
| shared commitment (must match) | platform freedom (may differ) |
|---|---|
| props, types, defaults, events, states | <button> vs FilledButton |
| behaviour rules (loading disables and keeps size…) | ripple, hover, haptics |
| sizes and spacing via project tokens | font rendering |
| accessibility meaning (role, name, focus) | aria-* vs Semantics |
| composition (what may contain what) | animation defaults |
Hard rules live in validators, prose only describes the process. The generated agent rules file is ~40
lines. It tells the agent what order to work in and which command to run; every name, type and
structural rule is enforced by fw check / fw verify.
Materialize once, compose many. ui/ is owned by the project. A component is written when the first
screen needs it and reused by every later screen. If the agent writes a component anywhere else, or writes
raw markup in a screen, fw check fails.
Project layout
my-app/
├─ ui-rules/ shipped contracts (copied by fw init; do not edit — override instead)
├─ ui-spec/ ← YOU write this
│ ├─ app.ts the outline: every screen and component, by name (write it first)
│ ├─ project.ts name, platform, agent, design tokens, guard names
│ ├─ domain.ts business types (Card, Cart, Order…)
│ ├─ screens/*.ts one per screen: what it shows, what the user can do, where it leads
│ ├─ components/*.rule.ts extra contracts (yours or the agent's); same-name overrides ui-rules/
│ └─ added/*.rule.ts contracts generated by `fw add`
├─ ui/ ← AGENT writes: one implementation per contract, verified
├─ screens/*.ui.json ← AGENT writes: one spec per screen, checked
├─ src/screens/*Screen.tsx ← AGENT writes: screens composed from ui/, linted
├─ src/ app shell (router, store, main) — hand-written, not linted
├─ ui.catalog.json generated: every contract + where its implementation lives
└─ CLAUDE.md / AGENTS.md / .cursor/rules/ui.mdc / .github/copilot-instructions.md generated rulesDescribing your app (ui-spec/)
app.ts: the outline
The whole app on one screen: every screen with a one-line purpose, and every component it uses. Write it before anything else. Every other file may only use names listed here:
import { defineApp } from '@himz-genui/core';
export default defineApp({
name: 'PokéCards Shop',
screens: {
Home: 'Browse and search the card catalog, open a card, jump to the cart',
Cart: 'Review items, change quantities, proceed to checkout',
},
components: ['Container', 'Stack', 'TopBar', 'List', 'ListItem', 'Button', 'EmptyState', 'Stat'],
});| check | level |
|---|---|
| an outline component has no contract | error |
| a screen description, goTo / back target or spec uses a screen not in the outline | error |
| a spec or screen file uses a component not in the outline | error |
| ui/X exists but X is not in the outline | warning |
| an outline screen or component with no detail yet | not an error: listed as todo in the progress lines |
Every unknown name (component, screen, prop, event, action, domain type, guard) comes with a suggestion when one is close, including wrong case and swapped words:
unknown component "itemlisst", not in the catalog. Did you mean "ListItem"?
action "opencard" is not declared in this screen's actions [...]. Wrong case: it is "openCard".
Button is not in ui-spec/app.ts components, which lists "Buton" (no such contract). Fix the typo in ui-spec/app.ts.app.ts is optional: without it fw check skips outline checks and prints one hint, so 1.0 projects keep
working.
project.ts
import { defineProject } from '@himz-genui/core';
export default defineProject({
name: 'PokéCards Shop',
platforms: ['react'],
agent: 'claude', // which rules file to generate
tokens: { // three layers and modes: see Theming
primitives: { color: { red: { 700: '#D12F0C' }, gray: { 800: '#1F2937' }, white: '#FFFFFF' } },
semantic: {
color: { primary: '{color.red.700}', onPrimary: '{color.white}', surface: '{color.white}', text: '{color.gray.800}' },
space: [0, 4, 8, 12, 16, 24, 32, 48], // specs say "gap": "4" → 16px
radius: { sm: 4, md: 8, lg: 16, full: 9999 },
font: { body: 'Inter', heading: 'Inter' },
},
},
guards: ['requireCartNotEmpty'], // names screens may use as guard; bodies are hand-written
stateLibrary: 'zustand', // convention: one store library for the project
screenDirs: ['src/screens', 'app/(shop)'], // code here is composed from ui/ only (default: src/screens, screens)
freeformDirs: ['app/(marketing)'], // deliberately free: never checked (a landing page, a hand-styled layout)
});Implementations read tokens through the generated ui/tokens.g.ts (web) or context.ui (Flutter); change a color here, not in twenty files.
screenDirs / freeformDirs decide where the screen rules (compose from ui/, no raw markup, no hard-coded
text) apply. The defaults are src/screens and screens (React) and lib/screens (Flutter). When a common UI
folder (src/pages, app, src/app, src/views, src/routes, lib/pages, lib/views) has code but is in
neither list, fw check says so instead of skipping it silently.
domain.ts
export default defineDomain({
Rarity: t.enum(['common', 'uncommon', 'rare', 'holo', 'ultra']),
Card: t.object({ id: t.string(), name: t.string(), rarity: t.ref('Rarity'), priceLabel: t.string(), stock: t.number() }),
Cart: t.object({ items: t.array(t.ref('CartItem')), subtotalLabel: t.string(), count: t.number() }),
});Screens reference these by name: "Card[]", "Cart".
The t type system
| | |
|---|---|
| t.string() t.number() t.boolean() | primitives |
| t.text() | a string the user reads (label, title…): translated in multi-language projects |
| .int() | on a number: whole numbers only (page, index, count); int in Dart |
| t.enum(['a', 'b']) | one of |
| t.ref('Card') | a domain type |
| t.array(x) t.object({ … }) | containers |
| t.void() | event with no payload |
| t.node() | slot content (children) |
| .opt() | optional |
| .def(value) | optional with default |
| .desc('…') | description shown to the agent |
screens/*.ts
One file per screen. The file name is the screen name (card-detail.ts → CardDetail), the purpose is
in app.ts. Every field answers one question:
// ui-spec/screens/cart.ts
export default defineScreen({
// What does the user see here?
shows: ['Each item: thumbnail, name, quantity, line total, remove', 'Subtotal and a checkout button'],
// What can the user do here that does NOT change screen? action → what it does
local: { changeQty: 'change the quantity of an item', removeItem: 'remove an item' },
// Special cases: situation → what the screen does
when: { 'cart is empty': 'show an empty state with "Continue shopping"; checkout is disabled' },
// Which data does the screen receive? Types come from domain.ts
data: { cart: 'Cart' },
// Where can the user go from here, and how? target screen → what the user does
goTo: {
CardDetail: 'tap an item',
Checkout: 'press Checkout',
Home: { how: 'press Continue shopping', replace: true },
},
});| field | meaning | required |
|---|---|---|
| shows | what the user sees, plain language | yes |
| local | actions that stay on the screen: name → what it does | no |
| when | special cases: situation → what the screen does | no |
| data | data the screen receives: name → type string ("Card[]") | no |
| goTo | target screen → how: a string, or { how, action?, replace?, modal? } | no |
| back | omit: back to the previous screen · false: no back · 'Home': back, to Home when there is no previous screen | no |
| params | what the screen receives when opened: name → type string. Declared here only, never by the caller | no |
| guard | a guard from project.ts that must pass to open the screen | no |
Navigation is part of each screen. There is no flow file:
- Each
goTotarget becomes an actiongo<Target>(goCheckout). Name it yourself withactionwhen it does more than navigate:OrderSuccess: { how: 'press Place order', action: 'placeOrder', replace: true }. - Every screen has
goBack, except the first screen of the outline and screens withback: false. - The screen's actions, the only ones a spec may bind, are the
goToactions, thelocalkeys andgoBack. The spec does not repeat them. - The first screen in
app.tsis where the app starts. Screens nogoToleads to are reported as unreachable. shows,localdescriptions andwhenare for the agent to read;fw checkchecks the structure and every name (targets, actions, types, guards), with Did you mean …? on typos.
1.0 projects: descriptions with name / purpose / actions / needs plus ui-spec/flows/*.ts
(defineFlow) are still accepted and checked as before. Don't mix them for one screen: a screen described
with goTo must not also appear in a flow.
Languages
Single-language apps need nothing. For several languages:
// ui-spec/project.ts
languages: ['en', 'vi'], // the first is the default
i18nLibrary: 'i18next', // convention: how code translates (a library, or your own file)
// ui-spec/strings/en.ts (one file per language, same keys)
export default defineStrings({
cart: { title: 'Your cart', line: '{set} · {condition} · {price} each' },
});- Contracts mark the props a user reads with
t.text()(label,title,description,placeholder,alt,Text.value…).fw docsshows them as (text). - In specs a text prop takes
{ "i18n": "cart.title" };{placeholders}are filled fromparams. - In screen code the agent calls the project's i18n function with the same key.
- The agent adds new keys to the default language, drafts the others, and shows you the new strings.
fw check (and fw check ui-spec/strings):
| check | level |
|---|---|
| a language in languages has no strings file | error |
| a key of the default language missing in another language, or an extra key | error |
| {placeholders} differ between languages | error |
| a spec key that does not exist (with Did you mean), a missing or unknown param | error |
| { "i18n": … } on a prop that is not text, or in a project without languages | error |
| hard-coded text on a text prop, in a spec or in screen code (string or template literal with letters, at any depth; `${a} × ${b}` passes) | error |
| text written directly between JSX tags in a screen | error |
| a key no spec or source file uses | warning |
Progress gains one line per extra language: strings vi 34/35 todo: cart.oneLess.
Not caught: text that reaches a prop through a variable or setState('…'), and the unused-key warning is a hint only (any quoted word in src/ that equals a key counts as a use); and contract defaults such as
SearchBox.placeholder = 'Search' (pass the prop). Currency, dates and numbers are formatted by the app
(domain values are pre-formatted strings such as priceLabel); data from an API is not translated.
Upgrading a 1.0 project: ui-rules/ is a copy made by fw init, so it does not get t.text() by
itself. Copy the new contracts from node_modules/@himz-genui/rules/src/ into ui-rules/ (they change no
props, so implementations stay verified).
Flutter
fw init --platform flutter (in a Flutter project, or --create flutter to scaffold one) sets up the same
ui-spec/ and screen specs; only the code side differs. Everything is checked the same way.
fw itself runs on Node, so a Flutter project also needs a package.json with @himz-genui/core and
@himz-genui/rules as dev dependencies (npm i -D @himz-genui/core @himz-genui/rules); ui-spec/*.ts imports them.
| | React | Flutter |
|---|---|---|
| component | ui/ListItem.tsx, export function ListItem | lib/ui/list_item.dart, class UiListItem |
| screen | src/screens/CartScreen.tsx | lib/screens/cart_screen.dart, class CartScreen |
| props / events / children | props type, onPress, children | named constructor params, VoidCallback? onPress / ValueChanged<T>? onChange, List<Widget> children |
| tokens | ui/tokens.g.ts + ui/tokens.css (CSS variables, modes via setMode), generated | lib/ui/theme.g.dart (context.ui.color.primary, context.ui.space(4), uiTheme(colorScheme: …)), generated |
| strings | the project's i18n function | lib/l10n/strings.g.dart (UiStrings.cartEmptyTitle), generated; or, with i18nLibrary: 'flutter_localizations', ARB files lib/l10n/app_<lang>.arb read through AppLocalizations |
| type check | tsc | flutter analyze |
Every Flutter class has the Ui prefix. 19 contract names collide with Flutter widgets (Text,
Card, Switch, Table…) and List collides with dart:core, so one rule for all names.
fw docs <Name> prints the exact Dart signature the agent fills in, and fw verify checks exactly it:
enum UiButtonVariant { primary, secondary, ghost, danger }
enum UiImageRatio { v1x1, v4x3, v3x4, v16x9, v5x7 } // '5:7' → v5x7, 'oldest-first' → oldestFirst
class UiSelectOption { const UiSelectOption({required this.value, required this.label}); … }
class UiSelect extends StatelessWidget {
const UiSelect({super.key, required this.label, required this.value, required this.options,
this.placeholder, this.disabled = false, this.error, this.onChange});
final String label; final String value; final List<UiSelectOption> options;
final String? placeholder; final bool disabled; final String? error;
final ValueChanged<String>? onChange;
…
}fw verify (Flutter) reports a missing class, constructor parameter, field or callback; required where the
contract says optional (and the reverse); a wrong kind (bool where the contract says string); an optional
without default that is not nullable; missing enum values or item-class fields; children on a component
that takes none. It reads Dart by these conventions (no Dart SDK needed); flutter analyze stays the real
type check.
Screens import lib/ui/ui.dart (generated barrel), their packages, and Flutter libraries with show
(package:flutter/widgets.dart show StatelessWidget, Widget, BuildContext, MediaQuery, Navigator). As on the web,
only constructing a widget from outside lib/ui/ is an error: fw check reads the imported libraries (the
Flutter SDK and pub packages, through .dart_tool/package_config.json) to know which classes are widgets and
which calls construct them. So Text('…'), Padding(…), Navigator(…) or a package's ReactiveTextField(…) fail,
while MediaQuery.sizeOf(context), Theme.of(context), Navigator.of(context).pushNamed(…),
GoRouter.of(context), Get.toNamed(…), AppLocalizations.of(context)!, GetIt.I<Repo>(), route classes and
EdgeInsets pass. A Flutter import without show, a second widget class in the screen file and, with several
languages, string literals with letters on text props (label: 'Checkout'; '$a × $b' passes) are errors too.
Before flutter pub get has run, the check falls back to base classes only.
Generated files (lib/ui/ui.dart, lib/ui/tokens.g.dart, lib/ui/theme.g.dart, lib/l10n/strings.g.dart or the
ARB files) are rewritten by every fw command; do not edit them.
Flutter recipes:
| package | do |
|---|---|
| go_router / auto_route / Navigator | routes and Scaffold in the shell (ShellRoute, AutoTabsScaffold); screens call context.go(…), context.router.push(CartRoute()), Navigator.of(context) |
| flutter_bloc · provider · riverpod · GetX · MobX · signals | stateLibrary lists it; BlocBuilder, Consumer, Obx, Observer, Watch wrap Ui… widgets; context.read, ref.watch, Get.find are free; screens may extend ConsumerWidget / HookConsumerWidget |
| flutter_hooks | useState, useTextEditingController in the screen; HookBuilder is a wrapper |
| reactive_forms | ReactiveForm + ReactiveValueListenableBuilder(builder: (_, control, _) => UiInput(value: control.value ?? '', onChange: (v) => control.value = v)); not ReactiveTextField (draws its own input) |
| flutter_form_builder | FormBuilder + FormBuilderField(builder: (field) => UiInput(…, error: field.errorText, onChange: field.didChange)) |
| flutter_localizations / intl | i18nLibrary: 'flutter_localizations', flutter: generate: true in pubspec; fw writes the ARB files and l10n.yaml; screens read AppLocalizations.of(context)!.cartTitle |
| easy_localization | 'cart.title'.tr(); keys from ui-spec/strings |
| Material / Cupertino / UI packages | inside lib/ui/, or fw add a widget you already have; the shell uses uiTheme() |
tests/compat/flutter* hold a screen per pattern against the real packages; npm run test:flutter runs them.
examples/gallery-flutter and examples/gallery-react implement every shipped contract on both platforms from the same ui-spec/, with a test per component on each side. npm run test:parity fails if they drift apart; the gallery READMEs list where the two still differ.
Compatibility
fw checks the UI layer only, with one rule on both platforms: a screen may not construct UI from outside
ui/ (lib/ui/). Hooks, calls, static accessors and non-visual wrappers are free, so most libraries work as they
are; UI kits live inside ui/ or are registered with fw add.
tested = a screen in tests/compat/ uses the pattern and fw check gives the expected answer on every
npm test (web: static check, the library is not installed; Flutter: against the real package sources) ·
works = allowed by a preset or because it is plain hooks / calls, no dedicated test · recipe = works when
used as shown in Integrations · no = not supported.
React
| area | library | status |
|---|---|---|
| state | Zustand, Redux (react-redux), TanStack Query | tested |
| | Redux Toolkit, Jotai, Recoil, Valtio, XState, MobX (observer, Observer) | works |
| data | Apollo Client, SWR | tested |
| | urql, Relay, RTK Query | works |
| forms | react-hook-form (Controller, FormProvider), TanStack Form (form.Field), Formik (Formik) | tested |
| | react-hook-form {...register()}, Formik <Field> | recipe (use Controller / render props) |
| | react-final-form, Zod, Yup, Valibot | works |
| router | React Router (Navigate, useNavigate) | tested |
| | router <Link>, next/link in a screen | recipe (wrap in ui/Link; blocked in screens, tested) |
| | Next.js App Router, TanStack Router | recipe (page.tsx renders a screen; layout.tsx in freeformDirs) |
| i18n | react-intl | tested |
| | i18next / react-i18next, next-intl, Lingui | works |
| UI | MUI, shadcn/ui (fw add, props through the type checker) | tested by hand with the real packages |
| | Ant Design, Chakra, Mantine, Radix, Headless UI | recipe (inside ui/ or fw add; importing a kit in a screen is blocked, tested) |
| styling | Tailwind (tailwindPreset), MUI theme (muiTheme, checked with the real createTheme), CSS variables | works |
| | className / style / sx on a component in a screen | blocked (tested) |
| platform | React Native, Expo | no |
Flutter
| area | package | status |
|---|---|---|
| state | flutter_bloc, GetX (Obx, Get.find, Get.toNamed), riverpod + hooks_riverpod (HookConsumerWidget), flutter_hooks | tested |
| | provider, MobX, signals | works |
| router | go_router (context.go, GoRouter.of), auto_route (context.router.push), Navigator (Navigator.of) | tested |
| forms | reactive_forms (builders + UiInput), flutter_form_builder (FormBuilderField) | tested |
| | a package's own input widgets (ReactiveTextField…) in a screen | blocked (tested): use them inside lib/ui/ |
| i18n | flutter_localizations / intl (ARB generated by fw, AppLocalizations.of), easy_localization (.tr()) | tested |
| DI | get_it | tested |
| UI | Material, Cupertino, UI packages | recipe (inside lib/ui/ or fw add; uiTheme() in the shell) |
| layout | MediaQuery.of, Theme.of, ScaffoldMessenger.of in a screen | tested (allowed) |
| | Padding, Text (tested), Scaffold, any Flutter widget in a screen | blocked: the shell owns Scaffold; layout comes from lib/ui |
Libraries not listed are handled by the same rule: hooks and calls need nothing; a wrapper that draws nothing goes
in screenWrappers; a component that draws goes inside ui/ or through fw add.
Theming
Design tokens have three layers and any number of mode axes; every platform switches modes at run time, and fw checks that nothing escapes the theme.
| layer | example | used by |
|---|---|---|
| primitives | color.blue.600 = '#2563EB', color.slate.900 | the semantic layer only |
| semantic | color.primary = '{color.blue.600}', color.onPrimary, color.surface, space, radius.md, size.controlMd, font.body | components, contracts, checks |
| semantic.component (optional) | component.button.primaryBg = '{color.primary}' | one component's fine-tuning |
// ui-spec/project.ts
tokens: {
primitives: { color: { blue: { 400: '#60A5FA', 600: '#2563EB' }, slate: { 100: '#F1F5F9', 900: '#0F172A' }, white: '#FFFFFF' } },
semantic: {
color: { primary: '{color.blue.600}', onPrimary: '{color.white}', surface: '{color.white}', text: '{color.slate.900}' },
space: [0, 4, 8, 12, 16, 24, 32, 48], radius: { sm: 4, md: 8, lg: 16, full: 9999 },
size: { controlSm: 32, controlMd: 40, controlLg: 48 }, font: { body: 'Inter', heading: 'Inter' },
},
modes: {
colorScheme: { // light / dark also answer the OS preference ("system")
light: {},
dark: { color: { primary: '{color.blue.400}', onPrimary: '{color.slate.900}', surface: '{color.slate.900}', text: '{color.slate.100}' } },
},
density: { comfortable: {}, compact: { size: { controlMd: 36 } } }, // any axis: brand, contrast, density…
},
contrast: [['text', 'surface'], ['primary', 'surface']], // onX / X pairs are implied
}A mode value lists only what differs; the first value of each axis is the default. References are
'{group.path}' and may point at primitives or at other semantic tokens.
Generated on every fw command
| platform | file | what it gives |
|---|---|---|
| web | ui/tokens.css | CSS custom properties: :root (defaults), [data-ui-color-scheme="dark"], [data-ui-density="compact"], @media (prefers-color-scheme: dark) for "system". A reference to another semantic token stays var(--…), so it follows every mode |
| web | ui/tokens.g.ts | tokens.color.primary = 'var(--ui-color-primary)', sp(4), alpha(tokens.color.primary, 0.12) (color-mix), setMode({ colorScheme: 'dark' }), getMode(), onModeChange(), modeScript (put in <head> for SSR: no flash), values (resolved defaults for canvas / charts) |
| Flutter | lib/ui/theme.g.dart | an enum per axis (UiColorScheme), UiTheme (a ThemeExtension: color, space(n), radius, size, font, component) as a const per mode combination with lerp (animated switches), uiTheme(colorScheme: …) → ThemeData with a ColorScheme built from the tokens, and context.ui |
// web: import './ui/tokens.css' once; components use tokens.* and restyle without a render
<button onClick={() => setMode({ colorScheme: 'dark' })} />// Flutter shell
MaterialApp(theme: uiTheme(), darkTheme: uiTheme(colorScheme: UiColorScheme.dark), themeMode: ThemeMode.system, …);
// components
final c = context.ui.color; Container(color: c.surface, child: Text('…', style: TextStyle(color: c.text)));Checks
| check | where | what |
|---|---|---|
| token integrity | fw check | broken references, cycles, a mode overriding a token the semantic layer lacks |
| WCAG contrast | fw check | every onX / X pair (4.5:1) and the declared pairs, in every mode combination |
| no raw values in ui/ | fw verify | React: hex / rgb() / hsl() / named colors in strings, a hex alpha appended to a token (use alpha()); Flutter: Color(0x…), Colors.x (except transparent), UiTokens.color* and any UiTokens group a mode changes |
| tokenColor per mode | fw verify | Flutter renders every colorScheme value under uiTheme(...); React checks the style names the token's variable |
Interchange: toDesignTokens(project.tokens) / fromDesignTokens(json) from @himz-genui/core convert to and
from W3C Design Tokens JSON (one token set per layer and mode value: primitives, semantic,
mode/colorScheme/dark…), which Tokens Studio (Figma) and Style Dictionary read.
The 1.x flat shape ({ color, spacing, radius, font }) is still read, as one mode without checks as errors;
fw check warns until it is moved.
Integrations: state, data, forms, router, i18n, UI kits
fw owns the UI layer only. Components are controlled (values in through props, changes out through onX),
so any state or validation library works: the screen reads state with the library's hooks or calls and
passes validation results to the error prop of the input.
const email = useCheckout((s) => s.email); // zustand, Redux, Jotai, TanStack Query…
const error = z.string().email().safeParse(email).success ? undefined : t('checkout.emailInvalid'); // zod, yup…
<Input label={t('checkout.email')} value={email} error={error} onChange={setEmail} />Hooks and calls need nothing. Wrappers that libraries put around UI (BlocBuilder, Obx, <FormProvider>)
are allowed in screens when stateLibrary in project.ts names the library, or when listed in screenWrappers:
| stateLibrary contains | allowed in screens |
|---|---|
| bloc | BlocBuilder, BlocListener, BlocConsumer, BlocSelector, BlocProvider, MultiBlocProvider, MultiBlocListener, RepositoryProvider |
| getx | Obx, GetBuilder, GetX |
| mobx / mobx-react(-lite) | Observer |
| riverpod / hooks_riverpod | Consumer (HookConsumer), ProviderScope |
| provider | Consumer, Selector, ChangeNotifierProvider, MultiProvider |
| signals | Watch |
| react-hook-form | FormProvider, Controller |
| react-redux / redux / @reduxjs/toolkit, jotai | Provider |
| recoil | RecoilRoot |
| @tanstack/react-query | QueryClientProvider |
| @apollo/client · urql · swr · react-relay | ApolloProvider · Provider · SWRConfig · RelayEnvironmentProvider |
| @tanstack/react-form | form.Field, form.Subscribe (any x.Field / x.Subscribe) |
| formik · react-final-form | Formik, FieldArray · Form, FormSpy |
| react-router / react-router-dom | Navigate |
i18nLibrary adds its provider the same way: i18next / react-i18next → I18nextProvider, react-intl →
IntlProvider, next-intl → NextIntlClientProvider, lingui → I18nProvider.
stateLibrary: 'bloc', // or 'zustand, react-hook-form, @apollo/client, react-router'
screenWrappers: ['MyStoreScope'], // your own non-visual wrappersWhat a wrapper renders is still screen code: BlocBuilder(builder: (_, s) => Text('…')) still fails on Text.
Components that draw inputs themselves (Formik's Field) are not wrappers; put them behind a contract or use
the library's hooks (useField).
Recipes (React):
| library | do | don't |
|---|---|---|
| react-hook-form | <Controller render={({ field, fieldState }) => <Input value={field.value} onChange={field.onChange} error={fieldState.error?.message} />} /> | <Input {...register('email')} />: it spreads DOM props (name, onBlur, ref), not the contract's value / onChange(value) |
| TanStack Form | <form.Field name="email" children={(f) => <Input value={f.state.value} onChange={f.handleChange} />} /> | |
| Formik | <Formik>{({ values, setFieldValue }) => <Input … />}</Formik> or useFormik | <Field> (draws its own input) |
| React Router / TanStack Router | routes and <Outlet> in the shell; screens call useNavigate() or render <Navigate> | <Link> from the router in a screen: wrap it in ui/Link.tsx (the Link contract says so) |
| Next.js App Router | app/**/page.tsx renders <CartScreen /> from src/screens, or add app to screenDirs; layout.tsx (with <html> / <body>) goes in freeformDirs | next/link in a screen: wrap it in ui/Link.tsx |
| MUI, Ant Design, Chakra, Mantine, shadcn | components inside ui/, or fw add your existing ones; theme from the tokens (below) | importing the kit in a screen |
| Tailwind, CSS Modules, styled-components | inside ui/; Tailwind theme from the tokens (below) | className / style on a component in a screen |
Screens pass contract props only. fw check rejects any prop that is not in the component's contract (or an
onX handler of its events): className, style, sx included. Styling lives inside ui/<Name>; spacing
between components comes from layout components (Stack, Inline, Grid, Container, Spacer).
One source for design tokens. Styling libraries read the same tokens (and follow the modes, see Theming):
// tailwind.config.ts: every value points at the CSS variables of ui/tokens.css, so modes switch Tailwind classes too
import project from './ui-spec/project';
import { tailwindPreset } from '@himz-genui/core';
export default { content: ['./src/**/*.tsx', './ui/**/*.tsx'], presets: [tailwindPreset(project)] };
// bg-primary, text-on-primary, rounded-md, p-ui-4, h-ui-control-md
// ui/theme.ts (MUI computes with real colors: build the theme of the active mode)
import { createTheme } from '@mui/material/styles';
import { muiTheme } from '@himz-genui/core';
export const light = createTheme(muiTheme(project));
export const dark = createTheme(muiTheme(project, { colorScheme: 'dark' }));tests/compat/react holds a screen per library pattern with the answer fw check must give; npm test runs them.
Contracts
A contract describes one component for every platform. Everything machine-checkable is a type; prose is for behaviour.
// ui-rules/Button.rule.ts (shipped)
import { defineComponent, t } from '@himz-genui/core';
export default defineComponent({
name: 'Button', category: 'action',
purpose: 'Triggers an action. Not for navigation to another screen — use Link.',
props: {
label: t.string(),
variant: t.enum(['primary', 'secondary', 'ghost', 'danger']).def('primary'),
size: t.enum(['sm', 'md', 'lg']).def('md'),
disabled: t.boolean().def(false),
loading: t.boolean().def(false),
icon: t.string().opt().desc('icon name shown before the label'),
fullWidth: t.boolean().def(false),
},
events: { press: t.void() },
states: ['default', 'hover', 'pressed', 'focused', 'disabled', 'loading'],
rules: [
'loading=true implies disabled, replaces the label with a spinner, and keeps the same width and height.',
'Never emits press while disabled or loading.',
'Height by size: sm 32, md 40, lg 48 logical pixels.',
],
a11y: ['Role button.', 'Label is the accessible name.', 'Visible focus ring.'],
composition: { canContain: [], cannotBeInside: ['Button', 'Link'] },
platform: {
react: ['use <button type="button">, never a div with onClick', 'aria-busy while loading'],
flutter: ['FilledButton / OutlinedButton / TextButton by variant', 'onPressed null when disabled or loading'],
},
examples: [{ label: 'Add to cart' }, { label: 'Saving…', loading: true }],
checks: [
{ kind: 'size', byProp: 'size', height: { sm: 32, md: 40, lg: 48 } },
{ kind: 'keepsSize', props: { loading: true }, like: { loading: false } },
{ kind: 'emits', event: 'press', on: ['press', 'enter', 'space'] },
{ kind: 'neverEmits', event: 'press', props: { disabled: true }, on: ['press', 'enter'] },
{ kind: 'role', role: 'button', name: { fromProp: 'label' } },
],
});| field | meaning |
|---|---|
| props | typed props; .def() / .opt() mark optional |
| events | emitted events; the React implementation exposes onPress for press |
| children | true if it accepts child elements |
| states | visual/interaction states the implementation must have |
| rules | platform-neutral behaviour every implementation must honour |
| a11y | accessibility meaning (not API) |
| composition.canContain | allowed direct children ([] = none) |
| composition.cannotBeInside | forbidden ancestors, any depth |
| platform.<name> | advisory hints for one platform — never a shared rule |
| checks | the machine-tested part of rules / a11y: fw verify generates a test per check for every platform and runs it (see below) |
| version | bump to force re-verification of existing implementations |
Checks
rules and a11y are prose for the agent; checks are the commitments a machine can test, written once and
run on every platform. fw verify turns each check into a test (test/fw/<Name>.contract.test.tsx with vitest +
Testing Library, test/fw/<snake>_contract_test.dart with flutter_test) and runs it.
| kind | what the generated test does |
|---|---|
| size | renders each value of an enum prop and measures height / width (a number, or { token: 'size.x' } from project.ts) |
| keepsSize | renders with props and with like; the size must not change |
| emits | presses the component (or the visible target text), or Tabs to it and presses Enter / Space; the event fires once per activation |
| neverEmits | same actions with props (e.g. disabled: true); the event never fires |
| role | the role exists, with the accessible name given or taken from a prop |
| key | Tab, then a key (Escape, arrows…); the event fires |
| rendersNothing | with props (e.g. open: false) nothing is drawn |
| minTarget | the pointer target is at least N in both directions (warning by default) |
| types | types text into the component's text field; the event fires |
| selects | opens the component (or presses the visible open text) and chooses the option with that visible text; the event fires (a native <select> is chosen directly) |
| tokenColor | the root or a part inside it is painted with a color token: background (surfaces, and the color property of Material controls / accent-color of native inputs) or text (warning by default) |
Render props are the first example merged with the check's props (null leaves an optional prop out).
level: 'warn' reports without failing; warnings run only under fw verify, so a plain vitest / flutter test
stays green on them. React runs in jsdom, which has no layout: size, keepsSize and minTarget are reported
as skipped there and measured on Flutter. fw check validates checks statically (unknown kinds, props, events,
enum values, wrong value types) but never runs tests. 54 of the 76 shipped contracts carry checks (137 in total);
the rest have nothing a current kind can measure.
Shipped contracts (@himz-genui/rules, 76):
| category | components | |---|---| | layout | Stack, Inline, Grid, Container, Spacer, Divider, SectionHeader, HorizontalScroll, PullToRefresh, InfiniteScroll | | typography | Text, Heading, RichText | | action | Button, IconButton, Link, FloatingActionButton | | input | Input, Textarea, Select, SearchBox, Checkbox, RadioGroup, Switch, Slider, NumberInput, DatePicker, FileUpload, Rating, Combobox, ChipGroup, PinInput, DateRangePicker | | form | FormField | | data | Card, Badge, Tag, Stat, List, ListItem, Table, Avatar, Accordion, DescriptionList, Timeline, SwipeActions, AvailabilityCalendar | | media | Image, Icon, Carousel, Video, ImageViewer | | feedback | EmptyState, Skeleton, Alert, Toast, Spinner, ProgressBar, Tooltip | | navigation | Pagination, Tabs, TopBar, BottomNav, SegmentedControl, Sidebar, Breadcrumbs, Stepper, SiteHeader, SiteFooter | | overlay | Modal, Drawer, Menu, ConfirmDialog | | chart | LineChart, BarChart, PieChart |
Your own contracts go in ui-spec/components/<Name>.rule.ts. A file with the same name as a shipped
contract overrides it. Never edit ui-rules/ directly — upgrades would clobber it.
Implementation conventions (React): ui/<Name>.tsx, export function <Name>(props: <Name>Props),
props interface named <Name>Props, event x → prop onX, children via children?: ReactNode.
Screen specs
The agent writes one JSON file per screen before any code, and fw check verifies it. People do not
write or read this file: to see what the agent built, run fw docs Home (below). Flat map, parent–child by id:
{
"screen": "Home",
"root": "page",
"elements": {
"page": { "type": "Container", "props": { "maxWidth": "xl" }, "children": ["bar", "grid", "pager"] },
"bar": { "type": "TopBar", "props": { "title": { "i18n": "shop.name" } }, "on": { "actionPress": "goCart" } },
"grid": { "type": "Grid", "props": { "minItemWidth": 240 }, "children": ["tile"] },
"tile": { "type": "Card", "repeat": { "path": "/cards", "as": "card" }, "props": { "pressable": true }, "on": { "press": "goCardDetail" }, "children": ["img", "name", "add"] },
"img": { "type": "Image", "props": { "src": { "path": "card/imageUrl" }, "alt": "", "ratio": "5:7" } },
"name": { "type": "Heading", "props": { "value": { "path": "card/name" }, "level": "3", "size": "sm" } },
"add": { "type": "Button", "props": { "label": { "i18n": "common.addToCart" }, "size": "sm" }, "on": { "press": "addToCart" } },
"pager": { "type": "Pagination", "props": { "page": { "path": "/page" }, "pageCount": { "path": "/pageCount" } }, "on": { "change": "changePage" } }
}
}dataand actions come fromui-spec/screens/home.ts; the spec does not repeat them (1.0 specs that declare"data"/"actions"still work).A prop value is one of:
| value | meaning | |---|---| | a literal |
"xl",240,true,[{ … }]| |{ "path": "/card/set/name" }| screen data; every segment is typed throughdomain.ts| |{ "path": "card/name" }| the current item of arepeat(no leading slash) | |{ "i18n": "cart.line", "params": { "price": { "path": "line/card/priceLabel" } } }| translated text (text props only, see Languages) |These work at any depth:
TopBar.actions[0].labelcan be{ "i18n": … }."repeat": { "path": "/cards", "as": "card" }renders the element once per item. Inside it (and its children)card/…reads the item. A list is never indexed into:/cart/items/0is an error that points torepeat.An enum value may go into a string prop (
Badge.label←card/rarity).onmaps a component event to one of the screen's action names (goCardDetail,addToCart,goBack…). Never code.An element has one parent; reuse means a second element with its own id.
fw docs <Screen> prints a screen for people: its description, the layout as a tree, and where it leads.
Container
├─ TopBar title "Your cart" · back → goHome
└─ Stack
├─ List
│ └─ ListItem for each /cart/items as line: title line/card/name · trailing line/lineTotalLabel · press → goCardDetail
├─ EmptyState title "Your cart is empty" · actionLabel "Continue shopping" · action → goHome
└─ Inline
├─ Stat label "Subtotal" · value /cart/subtotalLabel
└─ Button label "Checkout" · press → goCheckoutfw check <spec> reports, with a location for every finding:
| finding | example location |
|---|---|
| unknown component / prop / event | elements.hero.type |
| missing required prop, wrong literal type | elements.add.props.label |
| binding to undeclared or mistyped data, unknown field (with Did you mean) | elements.txt.props.value |
| repeat over a non-list, repeat variable out of scope or clashing | elements.row.repeat.path |
| unknown string key, missing / extra {placeholder} param, hard-coded text in a multi-language project | elements.bar.props.title.i18n |
| action not declared by the screen | elements.inner.on.press |
| composition violation (Button in Button, Text in List) | elements.inner |
| cycle, unreachable element, missing child id | elements.page |
| component not yet materialized for the platform | elements.add.type |
Commands
Five commands. No flags except on init and add. Every command refreshes ui.catalog.json and the
agent rules first, so there is nothing to keep in sync by hand.
fw init --agent <claude|cursor|codex|copilot> --platform <react|flutter> [--name "App"] [--create vite|next|flutter]
fw add <file.tsx | file.dart> [--name X]
fw check [<path>...]
fw docs <Name>
fw verify [<Name>...]fw init
Copies shipped contracts to ui-rules/, creates the ui-spec/ skeleton (never overwrites existing
files), generates the rules file for your agent. --create scaffolds the app first.
fw add <file.tsx | file.dart>
Registers a component you already have and writes a minimal contract to ui-spec/added/<Name>.rule.ts whose
impl points at the original file, then verifies it. The original file is not touched. Fill in purpose,
rules and checks afterwards.
- React: props come from the TypeScript type checker, so function components,
forwardRef,memo, and props thatextends/ intersect library types (MUI'sButtonProps,React.ButtonHTMLAttributes, cva'sVariantProps) all resolve. Props declared in your code go into the contract; props inherited from a library are listed, not copied: add the few screens may use, andfw verifyresolves them through the library type. - Flutter: the first public widget in the file (or
--name), from its named constructor:String,int,double,bool,Widget, enums declared in the file,List<…>,onXcallbacks (VoidCallback,ValueChanged<T>) andList<Widget> children, with literal defaults as.def(). The widget keeps its own class and enum names (PriceTag,PriceTone);fw verify, the generated tests andfw checkon screens accept them.
fw check
| invocation | checks |
|---|---|
| fw check | everything: the outline, every implementation in ui/, every spec in screens/, screen descriptions and navigation, screen code in src/screens/ and screens/, then the progress lines |
| fw check ui-spec/app.ts | the outline |
| fw check screens/home.ui.json | that spec |
| fw check src/screens/HomeScreen.tsx | that screen file |
| fw check screens/ src/screens/ | every spec / screen file under those folders |
| fw check ui-spec/screens | screen descriptions and navigation: targets, actions, back, params, guards, reachability |
Paths are classified by extension: *.ui.json → spec, *.tsx/*.jsx → screen code, anything under
ui-spec/screens (or 1.0 ui-spec/flows) → descriptions and navigation, ui-spec/app.ts → outline, folders recurse. Mix freely.
Output: one PASS/FAIL line per file, findings with locations, then a summary. Exit 0 clean, 1
findings, 2 usage error.
fw docs <Name>
With a screen name, prints the screen for people (description, layout tree, navigation). With a component name:
Prints a contract as markdown: props table, events, states, rules, a11y, composition, platform hints,
examples. The agent reads this before writing ui/<Name>.tsx.
fw verify [<Name>...] (agent-side)
Checks implementations against contracts with the TypeScript compiler API:
| check | level |
|---|---|
| an export with the contract's name (function / const / forwardRef / memo) | error |
| a props type on the first parameter (interface, alias, intersection, extends) | error |
| every contract prop present with the right kind; enums with every member; required not made optional | error |
| every event x has an onX function prop | error |
| children present iff the contract accepts children | error |
| extra props not in the contract (e.g. className) | warning |
| a React hint like use <button whose tag is absent from the source | warning |
On pass, the catalog records the implementation path; on fail it is removed, so fw check will flag every
spec that uses the component. Without names, verifies every contract that has an implementation.
Then the behavioural checks: for every component whose surface passed and whose contract has checks,
fw verify writes the tests to test/fw/ and runs them with the platform's runner (vitest run on React,
flutter test on Flutter). Each failing check is reported at checks[i] with the expected and actual values:
error test/fw/button_contract_test.dart checks[1]
keepsSize failed: Expected: 187.1 (±0.5) / Actual: <50.0> / widthReact projects need vitest jsdom @testing-library/react @testing-library/user-event (fw says so when one is
missing; a project without its own vite / vitest config gets a minimal one in test/fw/); Flutter projects
need the Flutter SDK on PATH. The files in test/fw/ are regenerated on every run:
never edit them, change the contract's checks.
Agent rules
fw generates one short rules file for the agent you chose:
| agent | file |
|---|---|
| Claude Code | CLAUDE.md (pointer) + .claude/skills/ui/SKILL.md |
| Cursor | .cursor/rules/ui.mdc |
| Codex and other AGENTS.md readers | AGENTS.md |
| GitHub Copilot | .github/copilot-instructions.md |
Content is wrapped in <!-- fw:start --> … <!-- fw:end -->; anything you write outside the markers is
preserved. The file is ~40 lines: eight rules, the catalog names grouped by category, and the commands.
Contract details are not inlined — the agent reads them on demand with fw docs.
The rules, in short:
ui-spec/app.tsis the outline. New screen or component? Add it there first. Did you mean …? on a typo means fix the name.- Screen in the outline with no description? Draft
ui-spec/screens/<name>.ts, show it to the user, wait for approval. - Need a component not in
ui/?fw docs→ writeui/<Name>.tsx→fw verifyuntil pass. Never elsewhere, never rewrite. - Building a screen? Write
screens/<name>.ui.jsonfirst. - Specs use catalog names and the screen's actions only.
fw check <spec>until pass. - Compose
src/screens/<Name>Screen.tsxfromui/only.fw check <file>. - Tokens from
ui-spec/project.ts, never hard-coded. - Navigation from
goTo/backinui-spec/screens/, never invented.
Registering existing components
Most teams already have components. Don't rewrite them:
npx fw add src/components/PriceTag.tsx # or lib/widgets/price_tag.dart// generated: ui-spec/added/PriceTag.rule.ts
export default defineComponent({
name: 'PriceTag', category: 'composite',
purpose: 'TODO: what it is for, and what not to use it for.',
props: { amount: t.number(), currency: t.enum(['USD', 'EUR']), strike: t.boolean().opt() },
events: { click: t.void() },
rules: [],
impl: { react: 'src/components/PriceTag.tsx' },
});From here the agent uses PriceTag like any shipped component, and fw check accepts imports from the
registered path. Type mappings fw add cannot infer are left as t.string() /* TODO */ and listed in the
output, together with the props inherited from a library:
created ui-spec/added/MuiBtn.rule.ts (component MuiBtn)
todo 32 prop(s) inherited from @mui/material are not in the contract: children, color, disabled, variant, size, …
todo 267 HTML / React attribute(s) inherited (id, className, aria-*, DOM events…) are not in the contract.Projects with a design system (MUI, shadcn…) register their components with fw add instead of writing
ui/ from the shipped contracts, and keep marketing pages or hand-styled layouts in freeformDirs.
CI
- run: npm ci
- run: npx fw check
- run: npx fw verify # behavioural checks; Flutter projects need the Flutter SDK on the runnerfw check is static and fast: specs, screen code, component surfaces. fw verify also generates a test per contract
check into test/fw/ and runs it (vitest + Testing Library on React, flutter test on Flutter). The generated files
are rebuilt on every run and git-ignored (fw init adds test/fw/ to .gitignore), so a fresh clone only needs
npx fw verify. On React, fw init sets "test": "fw verify" when the project has no test script; otherwise chain
it yourself: "test": "fw verify && vitest run". Both exit non-zero on any finding. Pair them with tsc --noEmit
and your build.
Limitations
fw addreads one file. Flutter props whose types are classes from other files, and item classes, come out asTODO; generated tests for a registered Flutter widget cover its own enums, not item classes. No adapter maps the shipped contracts onto MUI / shadcn yet: register your own components instead.- Web and Flutter only. React Native / Expo are not supported. Flutter screens do not build their own
Scaffold: the shell (aShellRoute,AutoTabsScaffoldorMaterialApphome) owns it and screens fill it fromlib/ui(adefineShellcontract is designed, not implemented). - UI kits are wrapped, not imported in screens. MUI, Ant Design, Material widgets… live inside
ui/(lib/ui/) or are registered withfw add; see Compatibility. - Behavioural checks cover what eleven kinds can express. Dragging and swiping, pressing an icon-only control
(no visible text to target), ARIA states (expanded, selected, sort), list / menu / combobox roles, focus rings,
radius, and sizes keyed by a boolean prop are not expressible yet; those commitments stay in
rules/a11yas prose. React has no browser in the loop, so layout checks run on Flutter only, andtokenColoron React reads the computed style jsdom knows (inline styles andaccent-color), not stylesheets it cannot apply. - No shell contracts yet.
defineShell(tabs / sidebar layout) anddefineSources(where data comes from, loading/error states) are designed but not implemented. The app shell is hand-written. fw init --createshells out tonpm create vite/create-next-app/flutter create. The Flutter one is tested (the project name is made a valid package name); the npm ones are not.- Your spec files are TypeScript.
fwis prebuilt JavaScript; it registerstsxin-process only to loadui-spec/andui-rules/, sotsxstays a dependency. No specialtsconfigflag is needed to include those folders in your own type check. - Consistency is per project. Two projects materializing the same contract will get different code. That is by design.
FAQ
Why not just ship the components? Then we would maintain one implementation per platform and you would inherit our styling decisions. Contracts are written once, and the implementation matches your tokens and your platform idioms while still passing the same assertions.
Why does the agent write a JSON spec before code? The spec is the checkpoint. It is cheap to validate exhaustively (names, types, composition), it is easy to review, and it stays next to the code as the screen's documentation.
What if I need a component that isn't in the 76? Write a contract in ui-spec/components/, composing
from existing primitives where possible. The agent can do this too. The catalog grows with the project.
Can I change a shipped contract? Override it: same name in ui-spec/components/. Don't edit
ui-rules/.
Where do routes, stores and API calls go? In src/ outside src/screens/, hand-written. fw deliberately
does not check them; goTo / back declare what navigates where, the shell decides how.
Does it work with Next.js? Yes for the framework's part (contracts, specs, ui/, screens). Wire screens
into the App Router yourself; the Navigation lines of fw check give you the graph.
Example
examples/pokemon-shop is a trading-card shop built entirely through the loop:
an outline, 5 screen descriptions with their navigation, 5 specs, 22 materialized components, 5 composed screens, a hand-written Vite
shell with router and store, and a .fixtures/ folder with deliberately broken inputs.
git clone https://github.com/Himz-Hung/Gen-UI && cd genui-fw && npm install
cd examples/pokemon-shop
npm run check # fw check: 42 passed
npx vite && open http://localhost:5173
node ../../packages/core/bin/fw.js check .fixtures # 2 failed, on purposeContributing
npm install
npm test # typecheck + full check + tsc + vite build on the example- Contracts live in
packages/rules/src/*.rule.ts. Keeprulesplatform-neutral; put platform-specific wording underplatform.<name>. Add the name topackages/rules/src/index.ts. - CLI and library live in
packages/core/src.index.tsis the browser-safe surface (it ends up in app bundles viaui/tokens.ts); Node-only code is exported fromnode.ts. - Docs in Vietnamese:
docs/vi/.
License
MIT © Himz
