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 the Development Guidelines for every task; the rules below are the frontend-specific layer applied during the Build step.
Stack
The opinionated stack 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. The opinionated stack says which rented library owns what; this section says which Angee package wraps it and who may import whom.
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/metadata 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/metadata | 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: resource list/form/record/relation/visualization surfaces under views/, chrome (rail/topbar/breadcrumb/spotlight), widgets, feedback (toast), the Base UI primitives binding, and the runtime/ contracts it consumes — the AppRuntime registry/session 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. |
Target Decisions
- Auth, preferences, and runtime i18n are app-owned providers under
@angee/app/src/providers/{auth,i18n}. Transport auth headers stay in@angee/refine; chrome reads only the app-supplied session surface on@angee/ui'sAppRuntime. - Dialect data hooks live in
@angee/refineas metadata-free hooks. Callers resolveresourceOperationTargetat the metadata edge and pass the root as{ root }. - Runtime i18n has one app-owned i18next instance. Addon bundles are namespace-relative, and the rendered binding namespace is
ui. - Record display representation is a backend-emitted metadata fact in the
angee.resourcesartifact. Frontend code reads that field and keeps only theidfloor; it does not probe candidate display fields. - Layering and unconsumed-owner guardrails are Vitest checks over the locked package graph. Do not add a boundary-lint dependency without updating the opinionated stack and the manifests together.
Current → target
Where each concern lives today versus where the open refactor waves move the remaining debt. Deleted shell packages are historical only; new code uses the real package names below.
| Concern | Current owner | Target owner |
|---|---|---|
| Data/transport/live providers, router bridge, typed-document contracts, stable-deps | @angee/refine | @angee/refine |
| Dialect data hooks (aggregate/action/deletePreview/facets/groupBy, revisions, authored-hooks) | @angee/ui rendered-data hooks | @angee/refine dialect hooks (metadata-free; target resolved at the caller edge as { root }) |
| Metadata artifact, resource projection, field classifier, dimensions, capabilities, row contracts | @angee/metadata | @angee/metadata |
| Invalidation: resource targets vs authored-query metadata | @angee/metadata resource targets + @angee/ui authored-query hooks | @angee/metadata resource targets + @angee/refine authored-query metadata |
| Rendered views / chrome / widgets / feedback / primitives | @angee/ui | @angee/ui |
lib/ styling helpers (cn/tv/tones/dnd) | @angee/ui | @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/ui | @angee/ui |
defineAddon / composeAddons (addon-manifest composition) | @angee/app | @angee/app |
createApp / defineBaseAddon + app shell (the single <Refine>/cache/live owner) | @angee/app | @angee/app |
| Auth provider | @angee/app providers/auth | @angee/app providers/auth |
| i18n provider | @angee/app i18n provider | @angee/app i18n provider |
One-way rules
These are the dependency invariants; a violation is a layering bug, not a convenience.
@angee/refineimports only rented libs — never@angee/metadata,@angee/ui,@angee/app, and never anyangee.resourcesmetadata.- Import provider-adjacent Refine bindings (providers, dialect hooks, typed operation contracts) through
@angee/refine; import ordinary framework hooks such asuseInvalidateanduseListdirectly from@refinedev/core. Do not widen@angee/refineinto a general Refine re-export surface. @angee/metadatamust NOT import@angee/refine.@angee/uimay import@angee/refine+@angee/metadata, 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.- No addon imports deleted shell packages; addon web code composes the real framework owners directly.
Carried debts
No shared frontend engine is intentionally carried as debt: client-side filtering, sorting, grouping, expansion, row selection, and pagination compose TanStack Table row models. Angee keeps only the thin lookup evaluator that lets TanStack apply the URL-owned filter object to in-memory rows.
Rules
- Python ships schema and operations. TypeScript ships UX.
- Schema dependence stops at the composition boundary.
@angee/refine,@angee/metadata, and@angee/uistay schema-independent; when they need a bespoke operation they hand-author a typedTypedDocumentNode(seeui/views/resource/documents.ts) rather than importing a project runtime.@angee/appis the sanctioned schema-dependent composition package, and it and addon web packages consume generated documents from the composed stack's@angee/gql. - Generated authored operations are typed, never hand-mirrored. In a schema-dependent package, 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/refinefor 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. An opaqueJSON-scalar value is parsed at its domain owner with a valibot schema (safeParse), never asserted into an application shape; a recursive shape a declarative schema cannot express may wrap its type guard inv.custom, keeping the parse boundary in the schema. - Record-targeted action mutations are derived, not authored. For a
<field>(id: ID!, ...required scalar arguments): ActionResultmutation, calluseActionMutation<ActionFieldName>("field")from@angee/uiin headless rendered-view code, oruseRecordActionMutation<ActionFieldName>("field")for a rendered@angee/ui<Action run={...}>bound to the open record.ActionFieldNamecomes from@angee/gql/<schema>/actions; no document, result type, or variables are authored. The shared hook owns deriving the Hasura custom mutation and running it through refineuseCustomMutation; the rendered record adapter owns binding it toActionContext(record id, refresh, missing-record handling, success hooks). Don't hand-author these asgraphql()documents or page-localctx.record.id → mutate → refreshcallbacks. ActionResultverbs settle throughuseActionResultRun(@angee/ui) — it fires the verb, toasts the outcome (danger with the in-band non-field reasons; success with the message), and deep-links to a created record vialinkTo+ the routed resource page when the outcome carries anid. It serves authored verbs (extract the outcome field insidefire) and derived verbs (useActionMutation's mutate resolves the fullActionOutcome) alike; never hand-roll the fire → toast → navigate ceremony in a chrome.- 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. - Rendered resource pages use
resourcePageRoutes(name, path, component, resource?)from@angee/app; the helper owns the list +$idchild pair and the default"console"layout. Addon manifest tests callexpectValidBaseAddon(manifest)from@angee/app/testingand keep only genuinely addon-specific assertions. - Route declarations are the only place a URL path is spelled. Menus name their target with
route:; components resolve addon-local routes withuseRouteHref, and resource-backed links use the runtime resource lookup. Cross-addon render/event links use the non-throwing route probe and disappear or disable when their dependency is not composed; full app composition still fails fast on invalid declarations. Keep query-string codecs addon-local.resourcePageRoutesnames record children${collectionName}.recordby default; usedetailNameonly when preserving a deliberate established name. - 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. - A resource registry key is the emitted canonical
modelLabel(for example"integrate.OAuthClient"). Addon composition may accept a unique bare or lowercase spelling only becausecreateAppcanonicalizes it fail-fast against the merged schema inventory; runtime render lookups degrade with a development warning when a spelling is unavailable in the active schema. Durable manifests, authored-operation labels, routes, forms, and runtime maps store the canonical qualified label. - Shell-published surfaces (
usePrimaryPane/PrimaryPanePublisher,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 (useLatestRef). Explorer pages composeScopedExplorerPane, which owns the primary-pane navigator publication plus the root loading/empty gate; addons provide row projection, route transitions, DnD policy, and domain actions. Page tests useShellPageTestProviders,PrimaryPaneTestHost, andChatterTabsTestHostfrom@angee/app/testinginstead of hand-rolled shell provider wrappers. - 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. A slot entry is uniquely keyed by
(slot, model?, impl?, id)and a second addon claiming one collides at composition — it is never a silent override decided by addon array order. So an addon contributes only to a key it owns. To vary a contribution per row, key it on the fact the row already carries (anImplClassFieldvalue), never on a probe of the record inside the component: a contribution that inspects the row to decide whether it should have rendered is contributed to the wrong key. Record-verb slots resolve canonical → model → model+impl by specificity (formViewRecordActionsSlot), a more specific entry replacing the same id from a less specific one, then order bysequence. That is how the addon owning an MTI parent contributes a verb once for every subtype, and how two vendors specialize the same verb on one model without colliding. Contributing a vendor's verb to a model-scoped key the vendor does not own displaces it for every row of that model and caps the model at one vendor. - 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:
useUiT()in@angee/ui,use<Addon>T()in an addon (created withcreateNamespaceT(ns, fallback)), with namespace-relative English keys 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. Three boundaries stay plain English: an addon's declarative manifest menu/routelabel:and chatter/drawer contribution labels (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/ui(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. - A row verb is a
rowActionsdeclaration onListView/RowsListView, never a hand-rolled trailing column with localuseConfirm/toast.dangerceremony. - Two-collection settings pages are a sanctioned family, not a double toolbar. A
SettingsShellmay stack severalSettingsSections, each wrapping its ownResourceList/DrawerResourceList(integrate Templates: template sources + templates; storage settings: drives + backends). Each section is a distinct collection and owns its own data-controls/toolbar row — that is correct uniformity, not the double-toolbar defect. The defect is two chrome rows stacked over the same collection; one collection gets exactly one controls row. - 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 TanStack Table's row models throughuseClientResourceViewSurfaceover the fetched set for arowModel:"client"resource;RowsListViewremains the renderer for the genuinely non-resource in-memory case — the operator-daemon quarantine, and an explorer-scoped collection that is not a Hasura resource. Storage'sFileBrowserContentis the contrasting server-backed example: it composesListoverstorage.Filewith drive/folder base filters and server-side folder grouping because a drive can contain hundreds of thousands of rows. - 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: "integrate.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: { "integrate.OAuthClient": <…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). - Toolbar/action dialogs with ordinary field inputs compose
MutationDialogfrom@angee/ui. It owns theDialogFormscaffold, value reset, required gating, submit busy/error state, and FieldDescriptor widget rendering; addons provide fields, mutation variables, and domain result handling. Decode raw controls at that boundary withparseValuesandmutationDialogValueCodecs; ordinary text trims and maps empty input tonull, while explicit required/verbatim codecs guard their authored field contracts. A record action that collects typed args — relation pickers, a relation list prefilled from the invoking selection/record, scalars — instead declaresargs+submiton its<Action>;RecordActionBaropensActionFormDialog, which fires the authored mutation and binds the in-bandActionOutcome.validationErrorsto the args, staying open untilok. Declare args, don't hand-roll the dialog. - 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. - Authored mutation result envelopes are decoded at the hook boundary. Pass
errorFromtouseAuthoredMutationfor{error, error_code}payloads; the hook throws before invalidating, so pages do not repeat result-error checks or accidentally refresh failed writes. - 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 (Development Guidelines).
- Plural copy uses native i18next suffixes: declare
key_one/key_otherin the bundle and callt("key", { count })with a numeric count;createNamespaceTapplies the sameIntl.PluralRulesselection in provider-less renders. - Server preference writes are live but not transactional across tabs: each delivered
changes()event rebases later patches immediately, while whole-document writes already in flight can still be accepted in server order and the last accepted write wins. - Effect cleanup must not permanently kill a memoized resource: StrictMode's simulated mount → cleanup → remount leaves it dead; own the resource inside the effect or explicitly re-arm it on mount, as the preference patch queue does.
- A render callback may only read fields some column declares or the
ListView fields={[…]}extras name: the selection owner (requestedFieldPaths) fetches column-declared paths plus those extras and nothing else — an undeclared read isundefinedon every row (a link built from it throws, a caption silently blanks). Still null-guard values a row may legitimately lack. - A nested list must pass
scope="local"to keep its ownpageSizeand view; the default inherited scope intentionally reuses the ambient resource-view state. - 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 to-one FK a node projects as a bareIDscalar auto-wires too, but as a scalar-id relation: the backend classifies itkind:"scalar",scalar:"ID",widget:"select"while keeping itsrelationModelLabel, sorelationFieldInfostill resolves the picker and label (see the ID-scalar to-one pitfall below). A bareIDscalar with no relation target (a record's own id) stays a leaf with no widget. - An
ID-scalar to-one is selected as a leaf, not an object. Any FK a node projects as a bareID!rather than a nested object is a scalar on the wire: FormView must select it without a sub-selection, or the detail query fails to build with a "must not have a selection" GraphQL error. The owner is the field-classification/metadata layer (angee.graphql.data.field_classification+ the metadata projection): a to-one relation the node projects as a bare scalar id classifies as ascalarLEAF (so the form/detail query selects it as a scalar) carrying aselectscalar-id widget and the relation target, and the frontendrelationFieldInfo(@angee/ui'smodel-metadata-defaults) resolves that scalar-id shape to the same relation picker/label as an object relation. The scalar-id form reads/writes the flat id; the object form reads the nested{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. The F6 lines composer applies the same rule per cell but at the diff boundary, not withcreateOnly:editable-lines.ts'slineFieldValuelower-cases an enum line cell's write (the child line input types the choices column asString), so even an untouched UPPERCASE read serializes as the lowercase model value. The same boundary owns the blank-cell rule (lineToInput): a blank non-String cell (""seed or widget-clearednull) is omitted on a created row so input/model defaults apply — Strawberry rejects""for Decimal/Int/Date — and shipsnullon an existing row (the honest "cleared" value); only a String-scalar cell's""is a real wire value and ships verbatim. - An M2M line cell is a relation multi-select, not a
tagInput— akind:"list"child field that carries a relation target (an M2M, e.g. a line'staxes) renders throughrelationListFieldInfo+RelationMultiFieldWidget(fetched options, chips) and reads/writes an array of public sqids; the diff serializes it viarelationIdList. Akind:"list"field with no relation target (a plain string array) stays thetagInput. This mirrors the to-onerelationFieldInfo+RelationFieldWidgetcell — compose those, never hand-roll a lines cell. - 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/ui's refine-backeduseAuthoredQuery, and run the write throughuseAuthoredMutation(..., { invalidateModels: [...] })or the matching refine invalidation owner 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. Project TypeScript configs must allow importing.ts/.tsxextensions because the generated runtime imports addon index source files by their package export paths. - 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}. - Expose every addon web package through the composed web manifest — the composer emits
runtime/web/tailwind.sources.cssfrom declared package sources. Do not hand-edit runtime CSS; a package missing from the manifest will miss its unique arbitrary Tailwind classes. - 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 verify the full composed app still boots (angee dev, or the stack CI lane's composed render) after touchingbaseIconsor an addon'sicons;tscalone cannot catch the collision. - A new web package needs
pnpm install+ a Vite restart (Vite snapshots workspace packages at start) plus registration in the hostmain.tsxaddons andpackage.json. - Install JS dependencies once at the monorepo root. Never run
pnpm installinsidepackages/,addons/, orexamples/: a nested install forks linkedvite/vitestidentities and can produceExcessive stack depth comparing types 'UserConfig' and 'UserConfig'invitest.shared.ts. Fix that environment by removing the nested packagenode_modulesand reinstalling at the root; do not weaken the types. Every workspace package must resolve through the one root virtual store. - Prebundled
@angee/*source edits are cache-busted by source signature, not a manual wipe. A project that consumes@angee/*as installed packages (prebundleAngeePackages: true) prebundles them; Vite's optimizer hash comes from the lockfile + manifests, never package source, so a workspace edit to a linked@angee/*package (same version) would otherwise be served stale (the slice-1 live-verify trap).defineAngeeWebViteConfig(@angee/app/vite) hashes each package's on-disk source mtimes and setsoptimizeDeps.forcewhen it changed vs a persisted marker — a source edit re-optimizes, an unchanged tree stays cached. The in-repo example excludes@angee/*(linked source, HMR) so this never applies there. - Start new addon web packages from
templates/addons/web. The Copier template owns the current ceremony:defineBaseAddon,resourcePageRoutes, lazy routed pages,createNamespaceT,expectValidBaseAddon, and package/test wiring. Don't copy an older addon and then manually chase drift. - Architecture guardrails are tests, not review folklore. After changing a framework package edge, addon package dependency, or newly exported shared owner, update
packages/app/src/architecture-guardrails.test.tsdeliberately. It owns both the package tree and the explicit addon/example roots. Record the intended edge or public-export allowance; do not bypass it with an undeclared import or a second local implementation. 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 menu root. The app rail is the one navigation column: compact domain icons collapse into, and expand in place as, their descendant accordion tree. A root with
group:"platform"contributes to the shared Settings place instead: the rail and chooser expose one synthetic Settings entry, and the expanded rail swaps to the platform tree with a back header. The rail's one scrolling list is domain roots, a separator, then Settings; the expansion toggle sits in the rail's own footer, outside that scroll, and the rail is viewport-sticky so neither ever scrolls away. A plain second activation of a nav link that already points at the current page toggles the expansion instead of re-navigating (activeLinkTogglePropsinchrome/app-rail-model.tsis the one owner of that contract; modified clicks keep the browser default). Workbench primary panes are reserved for page-published explorers;TopMenuTabsis reserved for explicit collection-view state, not derived menu children. 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. - A
createDefaultsseed submits on create even whenreadOnly.ResourceList'screateDefaultsseeds the create form, andform-view-model.ts'smutationDatasubmits a create seed even for areadOnly/createOnlyfield — whether the seed is the field's owndefaultValueor a page-levelcreateDefaultsentry — so a seeded read-only field is no longer silently dropped from the create payload. PrefercreateOnly(editable on create carrying the seed, locked on edit) when the value should stay visible-but-fixed;readOnly+createDefaultsalso works for a value the form never renders editable. (editOnlyfields stay excluded on create.) - 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(including its inline 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 rendered-binding 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/ui:
rg -n '<table\b|<thead\b|<tbody\b|<tr\b|<td\b|<th\b|role="grid"|useReactTable|manualPagination' packages addons examples ../angee-messaging-bridges/addons -g '*.tsx'
rg -n 'useAuthored(Query|Mutation)<|interface .*Data|interface .*Variables|fetch\([^)]*graphql|gql`' packages addons examples ../angee-messaging-bridges/addons -g '*.ts' -g '*.tsx'Run the architecture guardrail when changing package layering, public shared owners, or addon manifests:
pnpm --filter @angee/app run test -- architecture-guardrailsA hit is not automatically wrong, but it must either compose the shared primitive or identify the owning framework gap to fix first.