Frontend Guidelines
Frontend code is TypeScript, React, and the rendered Angee experience. It owns presentation, routes, menus, widgets, layouts, resource-view state, and interaction.
Follow the shared development process and coding principles in docs/guidelines.md for every task; the rules below are the frontend-specific layer applied during the Build step.
Stack
The opinionated stack in docs/stack.md is the source of truth for frontend libraries and what each one owns. Check it before adding a dependency or hand-rolling a concern. TypeScript dependency setup belongs in package.json, pnpm-workspace.yaml, and pnpm-lock.yaml.
Package layering
The frontend workspace is a strict one-way stack. Each package owns one concern and depends only on packages below it. docs/stack.md says which rented library owns what; this section says which Angee package wraps it and who may import whom.
Open for architect confirmation. This is the target contract for the in-flight Refine package split. Two placements are architect calls that the later code waves depend on and must not be acted on until confirmed: (1) the
@angee/datadata hooks land in@angee/refineas metadata-free dialect hooks, with theresourceOperationTargetresolved at the caller edge and passed in as{ root }(sorefinestays belowresourcesin the DAG); (2) the auth provider and the i18n provider both land in@angee/app. These are the plan's recommended defaults; the doc encodes them so the contract exists, but the code slices STOP for confirmation before relying on them.
Target DAG
Dependencies point down only. A package never imports a package above it.
rented libs @refinedev/core · @refinedev/hasura · graphql-request/ws ·
TanStack Router/Table · react-hook-form/zod · i18next · lucide · Base UI
│
@angee/refine Hasura-dialect Refine binding — zero domain/metadata knowledge
│
@angee/resources metadata (angee.resources) → Refine config bridge
│
@angee/ui the single rendered binding + headless view-state
│
@angee/app composition + app shell — the only package depending on all above
│
@angee/<domain> addons: pages + codegen documents| Package | Owns |
|---|---|
@angee/refine | the parts of a Refine+Hasura app every project shares, with zero domain/metadata knowledge: data/transport/live providers, the router bridge, typed-document contracts, and the dialect/ hooks (action, aggregate, groupBy, facets, deletePreview, revisions) over useCustom. |
@angee/resources | the only consumer of angee.resources metadata: artifact load/validate, projection to Refine resources[] + meta, the one field kind/scalar/widget classifier, group/facet/drill-down dimension specs, and per-action capabilities → accessControl. |
@angee/ui | the single rendered binding + headless view-state: views/{list,form,record,relation,visualizations} + headless view-models, chrome (rail/topbar/breadcrumb/spotlight), widgets, feedback (toast), the Base UI primitives binding, and the runtime/ contracts it consumes — the AppRuntime registry context + its lookup hooks, makeContext, and the menu/slot/preview/widget/form contribution types (the binding owns the runtime it renders against; @angee/app only mounts the provider). |
@angee/app | assembles the app: define-addon, defineBaseAddon, createApp, the providers/{auth,i18n,notification,accessControl}, addon-route → TanStack tree routing, the slot/widget/form/preview/icon registries, and the app shell. |
@angee/<domain> | a domain addon: its pages and codegen documents*.ts. |
Current → target
Where each concern lives today versus where it lives after the split. The old shells (@angee/base, @angee/data, @angee/sdk) are deleted once their last importer flips.
| Concern | Current owner | Target owner |
|---|---|---|
| Data/transport/live providers, router bridge, custom-op hooks | @angee/data | @angee/refine |
| Typed-document / operation-document contracts, stable-deps | @angee/data (already moved) | @angee/refine |
@angee/data data hooks (aggregate/action/deletePreview/facets/groupBy, revisions, authored-hooks) | @angee/data | @angee/refine dialect (metadata-free; target resolved at the caller edge as { root }) |
| Metadata artifact, resource projection, field classifier, dimensions, capabilities, row contracts | @angee/data → physical @angee/resources (already moved) | @angee/resources |
| Invalidation: resource targets vs authored-query metadata | @angee/data → split (already moved) | @angee/resources (resource targets) + @angee/refine (authored-query metadata) |
| Rendered views / chrome / widgets / feedback / primitives | @angee/base | @angee/ui |
lib/ styling helpers (cn/tv/tones/dnd) | @angee/base | @angee/ui |
Runtime contracts the binding consumes — the AppRuntime registry + its useWidget/useSlot/usePreviews/useT/useNamespaceT lookups, the makeContext factory, and the menu/slot/preview/widget/form contribution contracts | @angee/sdk (runtime.ts / make-context.ts + the contribution types in define-addon.ts) | @angee/ui (the binding OWNS the runtime it consumes; @angee/app only mounts the provider) |
defineAddon / composeAddons (addon-manifest composition) | @angee/sdk (define-addon.ts) | @angee/app |
createApp / defineBaseAddon + app shell (the single <Refine>/cache/live owner) | @angee/base | @angee/app |
| Auth provider | @angee/data (auth.tsx) | @angee/app providers/auth |
| i18n provider | @angee/base + @angee/data + @angee/sdk (parallel paths) | @angee/app i18n provider (collapse the parallel path) |
One-way rules
These are the dependency invariants; a violation is a layering bug, not a convenience.
@angee/refineimports only rented libs — never@angee/resources,@angee/ui,@angee/app, and never anyangee.resourcesmetadata.@angee/resourcesmust NOT import@angee/refine.@angee/uimay import@angee/refine+@angee/resources, but not@angee/app.@angee/appis the ONLY package that may import compose /createApp-level concerns. TheAppRuntimecontext and its lookup hooks live in@angee/ui(the binding owns the runtime it renders against);@angee/appcomposes the addon manifests (composeAddons) and mounts thatAppRuntimeProviderwith the merged value.@angee/uireads it via the context — never by importing@angee/app.- After the split, no addon imports
@angee/base,@angee/data, or@angee/sdk— those shells are deleted.
Carried debts
Two known defects are relocated as-is by the split (moving them green is the priority; fixing them inside the move would break the green-at-every-step guarantee). Each is tracked as a separate follow-up, not entrenched:
- Local-rows engine — the hand-rolled
_bool_expevaluator inlocal-rows.ts(the shared client filter/sort/paginate engine). Relocated into@angee/ui's views as-is; the evaluator hardening is a tracked follow-up. - Parallel i18n path — i18n interpolation/fallback exists in more than one shell today (
@angee/base,@angee/data,@angee/sdk). Relocated as-is into@angee/app; collapsing onto one i18next-native provider path is a tracked follow-up.
Rules
- Python ships schema and operations. TypeScript ships UX.
- Authored operations are typed, never hand-mirrored. A bespoke (non-CRUD) operation is a
graphql()document imported from@angee/gql/<schema>; its result/variables types come from the generatedTypedDocumentNode(useDocumentType<typeof Doc>for named result types andDocumentVariables<typeof Doc>from@angee/datafor named variable types) — never a hand-written…Data/…Variablesinterface, and never call-site<TData,TVars>generics on theuseAuthored*hooks. The operation's file name picks its schema:documents.ts/documents.console.ts→ console,documents.public.ts→ public. An op must live in adocuments*.tsfile (the codegen glob does not scan inline ops), and a console op placed in adocuments.public.ts(or vice versa) fails codegen loudly against the wrong schema. Keep valibot only to narrow aJSON-scalar field the schema leaves opaque (parse, do not assert). - Single-id action mutations are derived, not authored. For a
<field>(id: ID!): ActionResultmutation, calluseActionMutation<ActionFieldName>("field")from@angee/datain headless code, oruseRecordActionMutation<ActionFieldName>("field")for a rendered@angee/base<Action run={...}>bound to the open record.ActionFieldNamecomes from@angee/gql/<schema>/actions; no document, result type, or variables are authored.@angee/dataowns deriving the Hasura custom mutation and running it through refineuseCustomMutation; base owns adapting it toActionContext(record id, refresh, missing-record handling, success hooks). Don't hand-author these asgraphql()documents or page-localctx.record.id → mutate → refreshcallbacks. - React does not own business logic, permissions, models, or persistence.
- React state has one owner. Keep canonical facts in the smallest owner: route/search facts in TanStack Router/nuqs, server facts in refine data hooks and react-query, resource-view facts in
ResourceViewProvider, form facts in@refinedev/react-hook-form/FormView, and ephemeral interaction state in the component that handles it. Lift state when siblings coordinate; do not keep parallel local copies. - Derive during render. Do not store
filteredRows, selected records, labels, options, variables, column lists, or capability booleans in state when they can be computed from props, route search, GraphQL results, model metadata, or existing Angee state. Useconstfirst anduseMemoonly for expensive work or referential stability; never useuseEffect+setStateto mirror render data. - Effects are for external synchronization. Use
useEffectto sync with browser APIs, storage, subscriptions, timers, navigation after async data, or imperative libraries like CodeMirror. Event logic belongs in handlers, and render-derived data belongs in render. - Use
defineAddonfor headless addon composition,defineBaseAddonfor rendered addon composition, andcreateAppfor the project's host composition. One greppable seam per addon — never annotate a bareconst x: BaseAddon = {…}. These contracts and the packages that own them are described under "Package layering" below. - Compose addon capabilities at build time through the manifest +
composeAddons(widgets, i18n, icons, forms, slots, previews, and menu declarations); never register or mutate a module-global at runtime.usePreviews/useWidget/useSlotread the composedAppRuntime; menu declarations project into refine resources and chrome renders refineuseMenu. - Shell-published surfaces (
usePrimaryPane,useChatterContent) are effect publishers. Publish memoized nodes/content, and keep any callbacks they close over stable; when a callback wraps Refine/query mutation objects that may refresh identity, expose a stable callback that reads the latest execution context from a ref. - Routed page components are code-split. In an addon manifest give each routed page
component: lazyRouteComponent(() => import("./views/Page"), "Page")(the stack-native helper from@tanstack/react-router, already a direct addon dep) — never an eagerimport { Page }+component: Page, which pulls every page into the entry graph. The router owns the route-loading fallback once:createAppsetsdefaultPendingComponent(aLoadingPanel), which wraps every non-root match in Suspense inside its layout's<Outlet/>, so the chrome stays mounted. Do not hand-rollReact.lazy+ a manual<Suspense>around a route's<Outlet/>. Split only routed pages — lighter manifest content (slot/section content, forms, glyphs) stays eager; where a route needs a provider wrapper (e.g. operator's transport), wrap thelazyRouteComponentresult in the thin route component, and the dynamicimport()still splits the view. - One component tree. Extend or register; do not fork.
- Slots are additive extension points. Use them before copying a component.
- Tokens beat color props and one-off variants. Theme by overriding tokens.
- Color is two orthogonal axes (
lib/tones.tsis the owner):tone(the palette —neutral/brand/info/success/warning/danger) ×variant/fill (solid/soft/surface/outline/ghost). Drive recipe color throughtoneClass(tone, fill); never hand-type a soft/solid tone triple, and never use the retireddefault/errornames (they areneutral/danger). - Status → tone is owned once by the shared
STATUS_TONESvocabulary (widgets/status-tones.ts, the domain layer over the domain-freelib/tones.ts). ThestatusBadge(pill) andcolorDot(dot) widgets and every console status surface (StateTag) resolve a value throughstatusTone(value, override?): an explicit<Column tone>map wins, then the shared convention, elsebrand. Never add a private status→tone map (the operator console kept one and drifted). A run state — stopped/running/error/warning — renders ascolorDot(grey/green/red/amber); a value the vocabulary doesn't know takes an explicit<Column tone>(e.g. a task'sblocked→danger). Keep the run state a separate field from a lifecycle/state enum rather than overloading one column with both axes. - Route every user-facing string through i18n:
useBaseT()in@angee/base,use<Addon>T()in an addon (both built on the SDK'suseNamespaceT(ns, fallback)), with the English in the namespace bundle. A prop whose default is a label defaults toundefinedand resolves?? t("key")in the body — never callt()in a default parameter. No hardcoded copy in a component. Two boundaries stay plain English: an addon's declarative manifest menu/routelabel:(chrome data, not in-component copy — none are routed), and a form registered viaforms:(a statically parsed element, never rendered as a component, so a hook cannot reach its<Field label>). - Every icon is a registered glyph rendered via
<Glyph name="…">(or therenderGlyph(icon)slot adapter). A component never importslucide-reactdirectly: base glyphs live inchrome/icon-registry.ts; an addon contributes its own lucide components through the manifesticons:field (the registry seam), not by rendering them. Glyph ids are lowercase/kebab-case; the lookup normalizes requested names to lowercase, so camelCase registry keys do not resolve. - Use shared page, resource, form, table, widget, and layout primitives before adding new local state. Never hand-roll a resource list (grid/list/group/board), form, or detail in an addon — compose the shared resource actions (
ResourceList/ResourceCreate/ResourceEdit/ResourceShow),List/Formdeclarations, and record fragments (RecordHeader/MetaGrid/MetricStrip); for a linked cell, composeTextLink/Chip/MetricTile, never a bespoke link class. If a shared view lacks what your case needs, extend it in@angee/base(the owner) so every addon gets it. The principle and what a hand-rolled copy silently drops live inAGENTS.md→ "Compose, never re-implement, at the addon level". - Routes and pages stay thin. A route declares URL, layout, menu/chrome, refine resource, action, and component. A resource-backed page composes the standard resource action components with
ListandFormdeclarations; a daemon/remote/in-memory collection composesRowsListViewor a named shared owner; a grouped or board-capable resource composesListViewwith grouping and the matching backend aggregate/filter contract. Page components may add small action controls or hooks, but they do not own table mechanics, duplicate route params, cache state, bespoke loading/error surfaces, or local copies of shared resource-view state. - The data view's client/server boundary is a row-model choice, not a fork. Where list operations (filter/sort/paginate/group) resolve follows the established data-grid pattern — AG Grid's named row models, TanStack's built-in client row models vs
manual*flags (Angee's grid is TanStack Table), MUI's*Mode. Choose the boundary by dataset size, not data origin: default to client-side for small, bounded, computed collections (one fetch, then filter/sort/paginate/group in the browser over the loaded set), and server-side for large model-backed resources (Hasurawhere/order_by/limit+ the_groupsaggregate). Grouping is a client-side row model by default (it needs the whole set); the server_groupssurface is the escalation only when the data is too large to hold in memory. A computed/non-model source is exposed once as a Hasura resource (hasura_pydantic_resource) for the uniform fetch + metadata + MCP surface, and its admin list processes client-side over the fetched set. Do not hand-roll a new client filter/sort/paginate engine — compose the one shared client engine (local-rows.ts, applied byuseClientResourceViewSurfaceover the fetched set for arowModel:"client"resource);RowsListViewremains the renderer for the genuinely non-resource in-memory case (the operator-daemon quarantine). - A recipe's icon-button size keys are
iconSm/iconMd/iconLg(one spelling across recipes). A defaultsizeis a visual contract — do not flip it without a requester (differing defaults likeSwitch/ToggleGroupsmvsTogglemdare intentional, not drift). - Primitive export convention: a primitive exports flat per-part consts; a compound primitive exposes a bare-name parts-namespace object (
Dialog.Root, …); a primitive that ships a composed convenience component takes the bare name for it (Select,Tooltip) and exposes its parts under a*Primitivesuffix only where a consumer compounds them (SelectPrimitive). Don't add a*Primitivenamespace nobody compounds. - State surfaces are shared fragments — never hand-roll an empty/loading/error block. The titled surfaces (
EmptyState,ErrorBanner) take the one{title, description, icon?, actions?}vocabulary; the single-line ones keep their own slot (InlineEmptylabel,LoadingPanelmessage). For a full-height empty panel passEmptyState fill(it centers an intrinsic-size card) instead of wrapping it in agrid place-content-centerdiv;LoadingPanelalready self-centers. A renderer owns its own loading/error so callers describe only the happy path (cf.preview/builtins.tsxFileText). - Forms are declarative even when they branch: a
<Field showWhen={(values) => …}>predicate (mirroringAction.visibleWhen) drives a discriminated form — akindselect that swaps the body — and a hidden field is never submitted. Reach for a custom form component only when the declarative DSL genuinely cannot express it. - A long form opts into tabs with
<Form layout="tabs">(default"stacked"): each labelled<Group>becomes a tab panel, while the title/body/status and any ungrouped fields stay above the tab strip. It is per-form — existing stacked forms are untouched — and reuses the same<Group>declarations, so no field metadata is duplicated. Group your fields for the stacked layout and tabbing is one prop away. - A relation field is a link, not a dead end. A routed collection page tags its refine resource on the route —
{ name, path, component, resource: "OAuthClient" }(one route per resource, build-time fail-fast) — and the relation widget resolves it throughuseResourceRoute(resource)to show a "follow" arrow to the selected record's detail page (breadcrumbs come from refine). A resource with no routed page simply shows no arrow. - Register a resource's create form once via
defineAddon'sforms: { Model: <…Field/Group children…> }; the standard renderer uses it wherever that resource is created, including the relation-picker inline create. Use it when the create input diverges from the read projection (write-only secrets, scalar-id pickers, a kind discriminator). With a registered form,RelationPicker'screateneeds only{ resource }(the override supersedes any passedfieldson create); pass inlinefieldsonly for a data-dependent form whose options are fetched at runtime and so cannot be a static registration.RelationPickeralso offers inline edit (a pencil beside the picker opens the selected record in a form dialog) — wired byRelationFieldWidgetfrom the related model's fields, so a relation is created, edited, and followed without leaving the parent form. The create-form override stays create-only: an edit dialog renders the passedfields(the registered form is not reused for edit). - A labeled control is a page element or a
FieldRoot. Reach forFieldRoot/FieldLabel(the stacked label-over-control owner, e.g. for an ephemeral composer not bound to a model record) before hand-rolling a<label>wrapper. A native input pairsFieldLabel htmlForwith the controlid; a button-trigger control (aSelect) labels viaFieldLabel nativeLabel={false} render={<span/>}- the control's
aria-labelledby.
- the control's
- Base exposes seams for product chrome; it does not hardcode product affordances. Record-level chrome (star/share/follow) is host-contributed into
FORM_VIEW_RECORD_CHROME_SLOTvia the manifestslots:; render contributions with the sharedSlotOutlet. - Never poll for data freshness. Live updates ride GraphQL subscriptions through refine's live provider and react-query invalidation, not a
setIntervalrefetch loop. Opt a model into live cross-actor refresh by declaringchanges(Model, field="<model>Changed")in itsschema.py; local writes invalidate through refine mutations, and subscription pushes invalidate the affected refine resources. Stream foreign-system state (e.g. the operator daemon'sonWorkspaceStatusChange/onServiceLogs) over its own subscriptions. A timedsetIntervalis only ever for non-data UI motion (a carousel) or rotating a short-lived credential before it expires — never to re-read a resolver hoping it changed. If a foreign system publishes no change subscription, add one there rather than polling it from the client. - Client-side gates are UX only. The server is the authorization boundary.
- No Python view DSL, no frontend metadata hidden in backend decorators.
Pitfalls
Hard-won traps — the wise learn from others' mistakes (docs/guidelines.md).
- A filtered
pnpm typecheck/testskips the rootpretypecheck: codegenhook. The roottypecheck/testscripts runpnpm codegenfirst;pnpm --filter <pkg> typecheck(and filtered vitest) does not. After any SDL change it then runs against stale generated@angee/gqltypes and fails with spuriousCannot find module '@angee/gql/console'or implicit-anyerrors indocuments.tsconsumers — not real defects. After a schema change, regenerate in order:manage.py schema(the SDL — see the backend "Regenerate the SDL afterangee build" pitfall) →pnpm codegen→ then the filtered typecheck/test. - Relation widgets follow the SDL field kind — a nested object FK (
kind:"relation") auto-wires to a creatablemany2onepicker; a bareIDscalar (kind:"scalar") is not auto-detected and must usewidget:"select"(many2oneselects<field>.id, invalid on a scalar id). - An enum field reads UPPERCASE but writes lowercase — a
StateField/ImplClassFieldcolumn serializes the enum member name on read (GITHUB,ACTIVE) yet its create/patch input is aStringkeyed by the lowercase value (github,active). A bare metadata-drivenselectsubmits the member name, which the String input rejects. On a create form passoptionswith lower-cased values (the member name iskey.upper(), sovalue.toLowerCase()) and mark the fieldcreateOnly, so the read-side casing never has to round-trip back through the select. To keep the field editable instead, theselect/comboboxwidgets reconcile the UPPERCASE read back to the authored option viacanonicalOptionValue(a case-insensitive unique match), so lower-casedoptionsround-trip correctly withoutcreateOnly. For status verbs prefer an<Action set={{status:"disabled"}}>over an editable status field. - A server-backed typeahead is not a
RelationField—RelationField/RelationPickerown their query state and filter a fixedoptionslist client-side, so they cannot drive a remote search. For one (e.g. a host repo search), build a thin control on the dialog/Inputprimitives whose debounced query feeds@angee/data's refine-backeduseAuthoredQuery, and refresh the affected list withuseModelInvalidation(model)after the write. - A FormView create dialog under the console layout needs
<ControlBandProvider host={undefined}>to keep its Save band inline instead of portaling into the layout's band. - Layouts bind their own schema (
RefineLayoutConfig.schema): console-only fields need the console client — setdefaultSchema: "console"and pin the public/login layout topublic. - Keep urql out of app data paths. The only remaining urql owner is the operator daemon quarantine. Django-backed app resources use refine data hooks, react-query invalidation, and the Hasura provider; do not reintroduce a second app cache/live engine.
- react-query freshness rides invalidation, not mount-refetch.
createAppsets an app-widestaleTime(via refine'sreactQuery.clientConfig, which layersrefetchOnWindowFocus:false+placeholderData:keepPreviousDataunderneath — do not restate them), so cross-actor edits surface through the live provider'schanges()subscription and mutation invalidation, not every remount. A model with nochanges()subscription only reflects cross-actor edits on explicit invalidation or oncestaleTimeexpires; a query that must be always-fresh sets its own per-hookqueryOptions, not a new app default. - Route code-splitting touches three things. (1)
defaultPendingComponentis the app-wide pending surface — it renders for every non-root match while its chunk loads, and (afterdefaultPendingMs) for any futureloader-bearing route, not just lazy pages. (2) The addon-index imports inruntime/web/app.tsstay eager — manifests compose synchronously; only each manifest's page imports go throughlazyRouteComponent. (3) A test that renders a routed page through the router (createApp/RouterProvider) must await the lazy boundary (findBy*); a test that imports the page component directly is unaffected, and a manifest assertion (componentis a function) still holds for a lazy component. - Generate the operator console's types from the Go daemon's introspected SDL (
operator_schema→ codegen), never by hand; daemon actions returnMutationResult{status}, not{ok}. - Add every new addon web package to the app CSS
@source— its unique arbitrary Tailwind classes silently fail to generate otherwise. - Shared/generic icon glyphs live in the base
chrome/icon-registry.ts— composition is fail-fast on id, so an addon cannot re-register another's glyph, and adding a name tobaseIconscollides with any addon already contributing it (base composes first). This throws only at app boot —typecheck/buildmiss it — so the full addon set is composed inexamples/notes-angee/web/src/addon-composition.test.tsx; runpnpm run test(not justtsc) after touchingbaseIconsor an addon'sicons. - A new web package needs
pnpm install+ a Vite restart (Vite snapshots workspace packages at start) plus registration in the hostmain.tsxaddons andpackage.json. ResourceListstill needs a form declaration, even for read-only records. Give discovered/read-only resources a<Form>child orformFieldswith read-only fields; an all-read-only form never assembles an update mutation. Delete affordances are schema-capability gated: if the resource has nodeleteroot,ResourceList/ListViewomit record and bulk delete instead of requiring a delete-onlycrud(...).- An addon contributes one rail (app) root (
group:"platform"); its children are the top-bar menus, and a child that itself has children renders as a dropdown. A route referenced by more than one menu item must setroute.menu(the owning item's id) or the chrome derivation throws "referenced by multiple menu items" — or make the root route-less so it inherits its target through a descendant and the leaf is the route's sole reference. - Group by a to-one relation with the camel group-key field. A server group-by axis may traverse a forward FK/OneToOne (e.g.
group_by_fields=["oauth_client__is_enabled"]inschema.py; to-many stays refused). The backend emits the group-key field in camel form (oauthClient_IsEnabled) and the groupable enum in__SNAKE_UPPER (OAUTH_CLIENT__IS_ENABLED). AResourceToolbarGroupOption'sgroup.fieldis the camel key ("oauthClient_IsEnabled") —resourceViewGroupToAggregateDimensionreads it verbatim as the bucket key andfieldToSnake-uppercases it to the enum (a_<Capital>restores the Django__). Use the camel key, not the snake path. - Live cross-actor refresh requires a
changes()subscription. A list/picker auto-invalidates from<model>Changedon the subscription schema, gated on the schema actually declaring it — so a model withoutchanges(Model, field="<model>Changed")in itsschema.pyrefreshes on local writes only (no live push, no error). Add the subscription to opt a model into live updates; omit it and you simply get local-write invalidation. createDefaultsneeds a submittable field, neverreadOnly.ResourceList'screateDefaultsseeds the create form, butFormView.mutationDatadrops everyreadOnlyfield from the payload — so areadOnlyfield pinned bycreateDefaultsis silently not sent, failing a required create input. UsecreateOnly(editable on create carrying the seed, locked on edit) or a plain field; reservereadOnlyfor values the create input does not accept.- A storybook
meta.args/argTypesis dead only if no story consumes it. A bareexport const X: Story = {}(or arender: (args) => …) AUTO-RENDERS frommeta.args— those args are live; only a file whose every story is a zero-paramrender: () => …has dead meta args. Removing them whenmeta.componenthas a required prop breaksStoryObj<typeof meta>(it still demands the arg) — type the self-rendering stories as bareStoryObj(keepcomponent:for autodocs). A data-bound view story uses the sharedruntime-fixturesowner (RuntimeFixture+storySchema(fetch)+jsonResponse), not a hand-rolled provider stack; global providers (ToastProvider, router, runtime, client) come from the preview decorator — don't nest a second one. Workbench(layouts/Workbench.tsx) is the collapsible inner-shell owner;Exploreris removed. Every multi-pane content region (console body, storage, knowledge, iam schema, agents sessions) composesWorkbenchoverpage/SplitPanes(v4) —primaryis the navigator pane,childrenthe content,secondarythe aside, with size/collapse persistence viaautoSave. Do not hand-roll a fixedgrid/w-60multi-pane shell or a pointer/arrow resize handle; the library owns sizing/collapse/persistence and Workbench owns the composition.barVariants(layouts/bar.ts) owns bar chrome. Bar height/edge/pad/tone/ justify/text live once;TopBar/Breadcrumb/ControlBand/PageToolbar/PageHeader/PageFooter/Statusline/ChatBarcompose it. Never hand-spell a bar'sh-*/px-*/py-*/border-b|t/bg-sheet*again — route it through the recipe so the bars stay in lockstep.- Form controls
extendwidget-control; never re-hand-roll invalid/readOnly/disabled.widgetControlSurfaceVariants(over theinteractiveSurfaceVariantsbase) owns the control surface — focus ring, invalid, readOnly, disabled. Inputs/textarea/number-field/select/checkboxextendit (tvextend); a control that re-spells those states drifts from the owner. toneText(tone)(lib/tones.ts) owns per-tone text color. It is wired intotoneFillso each tone'stext-*-textliteral lives once; never re-spell atext-<tone>-textmap or a phantomtext-brand-text(usetext-brand/text-brand-soft-text). A*-texttoken is a foreground color, never a background — usetoneSolidBg/bg-<tone>for fills.- One radius scale:
rounded-N(the pixel-token scale2/4/6/8/10/12). Do not introduce the legacyrounded/rounded-sm|md|lg|xlaliases in new or rewritten markup. - Pane / Aside / primary-secondary / Panel — one name per concept. A Pane is a
SplitPanessplit region; an Aside is page side content (PageAside); primary/ secondary are the Workbench sidebars; a Panel is a contentCard. Don't reuse one term for another's concept across components, props, or slots. - Layout slot ids use the
@angee/ui.*symbol namespace. Register new slots asSymbol.for("@angee/ui.<name>-slot")(seelayouts/slots.ts); the legacy@angee/base.*prefix is retired.
Checks
Run package-scoped commands while editing, then the broad checks before handoff:
pnpm run typecheck
pnpm run test
pnpm run buildRun the package vitest suite — not just tsc and a story render, which miss stale assertion drift. When verifying data-bound views, wait for the async query to load before asserting. Use browser verification for meaningful UI changes.
For page/addon changes, run a primitive-drift scan and explain every hit outside @angee/base:
rg -n '<table\b|<thead\b|<tbody\b|<tr\b|<td\b|<th\b|role="grid"|useReactTable|manualPagination' addons examples/notes-angee/web -g '*.tsx'
rg -n 'useAuthored(Query|Mutation)<|interface .*Data|interface .*Variables|fetch\([^)]*graphql|gql`' addons examples/notes-angee/web packages -g '*.ts' -g '*.tsx'A hit is not automatically wrong, but it must either compose the shared primitive or identify the owning base/SDK gap to fix first.