TL;DR for Agents
/builder-ui; default port 52002; backend must be running.pnpm install first. Bug fixes start with pnpm test:no-coverage; lint with pnpm test:lint.app/src/app.css only for global design system primitives (buttons, inputs, typography), then pnpm build:css (the dev watcher does this for you).app/src/icons/index.tsx, app/src/app.dist.css, app/src/gql/schema.json.Audience: AI agents only. Assumes you can run commands, read code, and follow guardrails.
The specs/ directory is a symlink to ../specs (a separate repository). If the symlink is broken or the specs repo is not available, continue without specs access. specs/archive/ is off limits — enforced by deny rules in .claude/settings.json.
Backend must be running (GraphQL dependency). Run pnpm install. Working directory /builder-ui.
React + React Router, Vite, Tailwind CSS (prefer it for new code), Apollo Client + Immer for state,
Vitest + Cypress + React Testing Library, Lingui (en, es, fr). JavaScript/JSX with TypeScript
support, StandardJS style. Versions live in package.json — read them there.
@ → app/src, @testing/* → app/src/@testing/*app/src/index.jsx mounts router from app/src/routes.jsxapp/src/root-layout.jsx (Apollo provider, theme, alerts, feature-flag modal, SystemError boundary, keyboard shortcuts)app/src/config.jsxapp/src/app.css (source) → app/src/app.dist.css (compiled, never edit directly)app/src/routes.jsx - React Router 7 route treeapp/src/gql/index.jsx (Apollo Client with custom type policies)app/src/gql/schema.json (generated)app/src/i18n.jsx/app/api/v0/graphqlauthToken cookieapp/src/gql/index.jsx (the typePolicies export, ~lines 27-172)/api/v1/* for Identity Service (user management)Frontend (React)↓ GraphQL Query/MutationPlatform (Elixir/Phoenix, `/app/api/v0/graphql`)├─→ MongoDB (tenant/customer data, basic integrations)├─→ Postgres (Oban jobs, Bridge/Lasso integrations, internal data)├─→ Identity Service (Node.js/Express, `/api/v1/users/*`) ⇄ MongoDB (user auth data)├─→ Forms API (Node.js/Express) - form processing/validation├─→ Workflows API (Node.js/Express) - workflow execution└─→ PDF API - PDF generationIdentity Service can call Platform back for user extended attributes (bi-directional)
app/src/pages-builder/ - Builder/configuration interface (form designer, workflow, dashboard, permissions, publishing)
app/src/pages-runner/ - End-user runtime (run, edit, view forms, workflow actions, document history)
app/src/pages/ - System administration (home, identity management, integrations, audit, spaces, settings, usage, permissions)
app/src/pages-anonymous/ - Public form submission (unauthenticated, separate Apollo client)
app/src/formbot/ - Form rendering engine
index.jsx - Main formbot instance, gadget registration, validationgadgets/ - Many gadget types including Text, Dropdown, Repeater, Table, DataLookup, and moreengine/ - Core rendering and state managementdecorators/ - Validation, progressive disclosure, runtime enhancementsapp/src/flowbot/ - Visual workflow designer and execution engine
engine/ - Visual editor, viewer, configuration panel, validation, simulationsteps/ - 9 step types: approval, task, notification, formfill, acknowledge, conditional, integration, echo, triggercomponents/ - UI components (email builder, person picker, etc.)app/src/voronoi-dnd/ - Generic drag-and-drop library
voronoi.jsxdraggable.jsx, drop-zone.jsx, gatherer.jsx, item.jsx, context.jsxapp/src/voronoi-dnd-formbot/ - Form-specific DND implementation
app/src/components/ - Shared UI components: layouts, modals (modal-centered.jsx, modal-page.jsx), data tables, identity pickers, feature flags, error boundaries, spinners
app/src/ui/ - Complex system primitives and utilities: alerts, popovers, tabs, tooltips, lookup, theme utilities, a11y helpers, and shadcn components (dropdown-menu, sheet, sidebar, skeleton)
app/src/icons/ - Auto-generated SVG icons (never edit index.tsx manually, use import scripts)
app/src/illustrations/ - Decorative SVGs for empty states and success screens
app/src/gql/ - GraphQL config: Apollo Client setup (index.jsx), schema (schema.json), type policies
app/src/@testing/ - Test utilities: Mockley, test helpers, fixtures. Setup in .test/setup.js
State management: server state in Apollo Client, local UI state in React state, immutable updates via Immer, cross-cutting concerns via Context API.
Data sharing: use a dedicated Context or the Apollo cache. useOutletContext survives from the
React Router 7 migration — read it in existing code, don't reach for it in new code.
VITE_SENTRY_RELEASE (its presence is what enables Sentry at all), VITE_SENTRY_DSN,
VITE_SENTRY_ENV, PUBLIC_URL (base path for static assets, used for icon paths), PORT (dev
server, default 52002 from build.service.json5), NO_HMR.
app/src/config.jsx holds Sentry setup (custom beforeBreadcrumb/beforeSend), Apollo Client
config, the AnnounceKit widget, and window.loggedInUser — which is read throughout the app for
user context.
Location: app/src/gql/index.jsx (the typePolicies export, ~lines 27-172)
Why they exist: Workarounds for caching issues caused by API design/query patterns. Ideally would be addressed at API level.
Existing policies (understand these when reading code, avoid adding new ones if possible):
__refformVersion.id to cache different versions separatelykeyFields: false) - too dynamic to cacheIf experiencing cache issues: First investigate if query/API can be fixed. Only add type policy as last resort.
Error Boundary:
SystemError component (app/src/components/system-error.jsx)/auth, permission errors redirect to Forbidden componentSentry Integration:
app/src/config.jsx with custom breadcrumb/error contextVITE_SENTRY_RELEASE is setGraphQL Errors:
/auth?return_to=<current>Forbidden componentExplicitError or GraphQLError components for manual error displayAdding new external domains:
scripts/generate-csp/index.jsscript-src, connect-src, img-src)node scripts/generate-csp to verifyCSP includes third-party integrations (AnnounceKit, ChurnZero, Sentry), allows 'unsafe-inline', WebSocket across Kuali domains, S3 image buckets.
Use app/src/components/sanitize.jsx for sanitizing user-generated HTML in rich text gadgets and email content.
Pre-commit runs Prettier + ESLint on staged files (configure via git config hooks.validatebuilderui on|off|custom). Bypass with --no-verify.
eslint.config.mjs (ESLint 9 flat config) enforces kebab-case filenames, GraphQL schema validation,
and StandardJS style. Two things worth knowing: test:lint runs with --max-warnings 112, a flat
ceiling on total warnings across the repo — so adding warnings can fail the build even though the
current tree passes; and many a11y rules are disabled, so lint passing does not mean the a11y
conventions below were followed.
Prioritize thoroughness over speed. Correctness and consistency matter more than quick completion. Always read existing code, understand patterns, write tests, and verify changes work properly. Taking time to do it right prevents technical debt.
Always read code before modifying it. Never propose changes to files you haven't read. Before adding or modifying code:
Example: If modifying a GraphQL mutation file and all existing mutations use mapValues(keyBy(...)) for error handling, use that same pattern - don't introduce a new approach.
Support both old and new shapes during a transition, and transform at the edges rather than
threading both formats through the codebase. Related: keep field names semantic (type, parts)
and don't overload one field for several purposes. When walking nested structures, check for the
nested case before handling leaf nodes.
Only use useEffect for external system synchronization (WebSockets, DOM manipulation, third-party libraries, analytics).
Never use useEffect for:
Always include cleanup functions to prevent memory leaks.
Only change what's necessary for your task. Don't rename variables, reformat code, fix typos, add/remove whitespace, or refactor unrelated logic. Keep diffs focused on the actual functional change.
However, when your change makes something obsolete:
.filter(Boolean) on non-conditional arrays)These are not "extra" changes—they're completing the work. A variable that's assigned-but-never-used after your change should be removed as part of the same change. Every line touched increases review burden and merge conflict risk, but leaving dead code is worse.
When a variable is simply assigned once and never reassigned, use the original value directly instead of creating an intermediate variable.
Bad:
const gadgets = anonymousDisabledGadgetsif (gadgets.has(gadget.key)) return true
Good:
if (anonymousDisabledGadgets.has(gadget.key)) return true
Exception: Keep intermediate variables when they:
Completely new files should be TypeScript (.ts for utilities, .tsx for components) when it makes sense. It makes sense when the file's contracts benefit from types (data shapes, module APIs) and its imports are typed enough to satisfy strict mode honestly. It does not make sense when the file would need pervasive any to compile — e.g. components consuming untyped repo-wide prop shapes — in which case .js/.jsx is the honest choice until those shapes are typed. Never convert existing files as a side effect of another change (see Minimal Diffs).
Default to none. Add one only when the code genuinely can't speak for itself — a non-obvious constraint, a gotcha, a surprising choice. The test: would an engineer reading only this code be lost without it? If not, delete it. Never restate what the code shows, narrate self-evident logic, or explain a line's history — why a past bug is now fixed, what a change replaced, what a test is "guarding." That is commit-message and PR context, not code. Keep any comment to one line; a paragraph is a smell.
Never reference ticket/issue numbers (PLT-1234, JIRA keys, PR numbers) either — provenance lives in git blame, the commit message, and the PR.
// Wrong — cites a ticket and narrates history// PLT-5040: compare mode previously crashed when the diff element// was passed through getMaskedValue.// Right — a present-tense constraint, and only because it isn't obvious// getMaskedValue can't take a diff element; unwrap it first.
pnpm test:watch) in an automated fix loop — it doesn't terminate.https://local.kualibuild.com:4001/styleguide
(port = 4000 + af container index; in-container: https://local.kualibuild.com/styleguide).
Renders the real lowkey components with per-section source-of-truth pointers.app/src/app.css (the .lowkey /
.lowkey-forms blocks), app/src/ui/, app/src/ui/shadcn/,
tailwind.config.js. Never copy styles from reference docs or snapshots —
import the components or use the kp-* classes.import * as NewIcons from 'app/src/icons/index-new' (raw-lowkey-ui/).
Never the legacy app/src/icons set in new lowkey work. App icons and
dataset icons are separate systems — do not replace those.textColor/borderColor
palettes with tokens (text-default, border-default…) — text-stone-*
doesn't exist. Plain utilities lose to lowkey component rules — use !
important (e.g. !border-red-500) for state overrides on kp-* controls.
:has() selectors get strict parsing — no trailing commas in :is() lists.node scripts/export-styleguide/index.mjs (writes the workspace
__drops/lowkey-components.html)./auth; uses window.loggedInUser or fetches from /api/v1/users/current; token in authToken cookiessoId field (SAML/CAS/LDAP). Cannot set/change passwords (HTTP 400)passwordDigest (bcrypt, 10 rounds). Backend never returns password; UI enforces complexity rulesuser.ssoId per-userUse CenterModal (app/src/components/modal-centered.jsx):
role="dialog", aria-modal="true", focus trap, ESC/backdrop handling, animationsid="modal-title" (hardcoded expectation)const triggerRef = useRef()// After modal dismiss:setTimeout(() => triggerRef.current?.focus(), 100)
Preserve + Transition components (300ms default)For full-page/side-drawer modals, use modal-page.jsx with nesting support.
Use useAlerts() from app/src/ui/alerts.jsx:
const alerts = useAlerts()alerts.type3(t`Password updated successfully`, 'success')
Immutable Updates: Always spread entire objects in onChange handlers (never partial updates). Use Immer for complex nested updates.
app/src/app.css only for design system primitives (buttons, inputs, typography, etc.); never edit app/src/app.dist.css (generated)text-red-500cursor-not-allowed bg-light-gray-100--bg, --text-defaulttailwind.config.jstw-animate-cssrole="alert" and must be referenced via aria-describedbyaria-invalid="true" when errors existUse Lingui macros (@lingui/react/macro):
import { Trans } from '@lingui/react/macro'import { useLingui } from '@lingui/react'// In componentconst { t, i18n } = useLingui()<Trans>Welcome message</Trans>const message = t`Dynamic message`// Date/number formattingi18n.date(new Date())i18n.number(1234.56)
Rules:
t() at module root - only inside components/functionsapp/src/locales/<locale>/messages.{po/js}pnpm extract to update .po catalogsbuild:i18n compiles catalogs (dev scripts watch and recompile)useFragment (preferred for new code)Any component that renders data from a query declares that data itself, in a colocated GraphQL
fragment, and reads it from the Apollo cache with useFragment — it takes an id, never the data
object as a prop. This is the pattern to reach for in new work; migrate nearby code to it when you
touch it.
import { gql, useFragment } from '@apollo/client'// Colocated with the component: exactly the fields it reads, nothing more.export const VERSION_ITEM_MENU_FRAGMENT = gql`fragment VersionItemMenuData on PublishHistoryEntry {idlabelisCurrentcanRestore}`export const VersionItemMenu = ({ entryId }: { entryId: string }) => {const { data: entry } = useFragment({fragment: VERSION_ITEM_MENU_FRAGMENT,fragmentName: 'VersionItemMenuData',from: { __typename: 'PublishHistoryEntry', id: entryId }})// …render from `entry`}
A parent composes children by spreading their fragments into its query — it never re-lists the children's fields:
export const PUBLISH_HISTORY_QUERY = gql`query PublishHistory($input: PublishHistoryInput!) {publishHistory(input: $input) {...VersionHistoryItemData # the row's fragment (which itself composes the menu's)...CopyConfirmationData# Select a field directly ONLY when this query's own component reads it and no child fragment# already carries it — e.g. list-level metadata owned by no row component.historySince}}${VERSION_HISTORY_ITEM_FRAGMENT}${COPY_CONFIRMATION_FRAGMENT}`
Why: remove a field from a fragment and it disappears from every query that composes it — no
orphaned over-fetching. No prop-drilling; the normalized cache (keyed by __typename + id) is the
single source of truth, so a mutation that updates the entity re-renders every reader automatically.
Rules:
id (plus behavior/UI props and callbacks), not the entity....Name + ${CHILD_FRAGMENT} interpolation. Do not re-list a
field in the parent query that a composed fragment already selects — that silently defeats the
self-cleaning property (drop it from the fragment and the manual copy keeps it in the query).useFragment returns partial data (each field is T | undefined) — guard reads with ?? ….cache.writeFragment on an InMemoryCache, then render inside <MockedProvider cache={cache}>),
or use MockedProvider with the default addTypename and __typename on mock data.
addTypename={false} leaves entries un-normalized and useFragment finds nothing.Reference implementations: app/src/pages/spaces/components/product-style-link.jsx,
app/src/components/branding.tsx, app/src/components/user-menu.jsx, and the version-history panel
(app/src/pages-builder/publish/components/).
Expect union responses and check __typename:
const result = await updateUser({ variables: { id, input } })if (result.data.updateUser.__typename === 'InvalidFieldErrors') {// Error structure: { errors: [{ field, reason }] }const errorMap = mapValues(keyBy(result.data.updateUser.errors, 'field'),'reason')setErrors(errorMap)} else {// Success: result.data.updateUser is the User typehandleSuccess(result.data.updateUser)}
For new code: Use dedicated Context API or Apollo cache for sharing data between components.
Existing pattern (legacy from React Router 7 migration - avoid in new code):
// Some existing routes use useOutletContext - when reading existing code:const { initialUser, onChange } = useOutletContext()
ESLint validates queries against the generated schema, so unknown-field errors usually mean it's stale — see Regenerate Generated Files.
Testing standards and patterns are documented in .claude/rules/testing.md. This file is automatically loaded when working with test files (*.test.* or *.spec.*).
Every recipe below ends the same way — see Pre-Submission Verification for the commands to run. They aren't repeated per recipe.
app/src/routes.jsx (React Router 7 createBrowserRouter)Navigate helper to preserve search paramsProtectedAppLayout for standard nav/header{path: '/users/:id',element: <Protected><AppLayout><UserPage /></AppLayout></Protected>,children: [{ path: 'edit', element: <UserEditModal /> }]}
app/src/pages* directory:pages-builder/ for configuration UIpages-runner/ for end-user runtimepages/ for system administrationpages-anonymous/ for public/unauthenticatedAppLayout if it needs standard nav/headerapp/src/routes.jsxProtected wrapper if authentication requireduseFragment fragment on the component and compose it into the query (see Component Data: Colocated Fragments + useFragment) rather than passing the data down as props.typePolicies only if a custom merge/key is needed (in app/src/gql/index.jsx)Flags are defined in app/src/components/feature-flags.tsx (id, default, description) and exposed
as booleans/helpers. The toggle modal is at ?center-modal=feature-flags. In tests, use
setFlag/clearFlag from @/components/feature-flags and cover both states; update
.test/setup.js if a new flag should default on.
Removing a permanently-enabled flag is the error-prone case. Evaluate each conditional with the flag TRUE:
| Condition | Result |
|---|---|
if (flag) | keep the block's contents |
if (!flag) | remove the whole block |
if (flag \|\| other) | keep the contents — true \|\| other is always true |
if (!flag && other) | remove the whole block |
if (flag && other) | simplifies to if (other) |
if (!flag \|\| other) | simplifies to if (other) |
Then clean up what the removal orphaned: variables used only in deleted conditions, simple
reassignments, and .filter(Boolean) on arrays that no longer hold conditional elements —
[A, ...(flag ? [B] : []), C].filter(Boolean) becomes [A, B, C]. See Minimal Diffs; finishing
this cleanup is part of the change, not extra scope.
See also the working-with-feature-flags skill.
Add Vitest/Testing Library specs near components or under app/src/__tests__, importing helpers
from @testing/*. .claude/rules/testing.md carries the standards — query priority, renderPage
vs mountApp, Mockley, gadget testing, i18n in tests, fake timers — and auto-loads when you open a
*.test.* or *.spec.* file.
scripts/import-icons/ (never edit app/src/icons/index.tsx)node scripts/generate-graphql-schema (requires backend), restart editor afterscripts/generate-csp (when adding external domains)scripts/generate-browser-checkFull guide: app/src/formbot/gadgets/gadgets.mdx (requirements checklist, examples, three-repo structure)
Gadgets live in app/src/formbot/gadgets/<name>/, one directory per gadget: manifest.jsx ties it
together, plus edit.jsx, view.jsx, config.jsx, validation.jsx, utils.js, icon.svg.jsx,
and optionally progressive-disclosure.jsx and filters.jsx. Register in
app/src/formbot/index.jsx via formbot.registerGadget('MyGadget', MyGadget).
Use the working-with-formbot-gadgets skill for manifest structure, component architecture,
validation display, DND integration, subfields, and testing patterns. Text is the simplest reference
gadget; Currency shows current best practice (functional components, Tailwind, business logic
extracted into pure functions in utils.js).
Third-party libraries: MIT/BSD/Apache 2.0 only — no GPL/AGPL/copyleft. Check the
package.json license field before adding a dependency.
Multi-repo: a gadget spans three repos — UI here, server validation in
platform/lib/formbot/gadgets (Elixir), form processing in forms-api/server/lib/gadgets (Node).
Field reference detection: If your gadget/feature stores references to meta.createdBy or meta.submittedBy fields (including extended attributes), update app/src/pages-builder/form/extended-attribute-usage-utils.ts to detect those references. This prevents admins from removing fields that are in use. See existing patterns in that file.
Check DevTools Network → inspect __typename for union types. Verify query includes required fields. Regenerate schema if lint errors (node scripts/generate-graphql-schema). Check Apollo cache. Common: missing __typename checks, stale schema, cache merge issues (see type policies section).
Check user.ssoId: present = SSO-only, absent = password auth. Hide password fields when user.ssoId exists. Backend returns HTTP 400 if SSO user attempts password change.
id="modal-title" on heading or focus not returned to trigger (see Modal Usage)touched flags; error containers need role="alert"; reference via aria-describedby; set aria-invalid="true" on inputs with errorspnpm build:i18n, add await loadLocale('en') before assertionsapp/src/@testing/mocks/, use api.debug() to dump mock dataTZ=Etc/UTCpnpm test:no-coverage for targeted runs (full suite enforces thresholds)find* queries (async), follow Testing Library query prioritywaitFor or use find* queriesVerify PORT (default 52002), check backend running, try pnpm dev:docked, check port conflicts (lsof -i :52002), clear cache (rm -rf node_modules/.vite).
app.css not app.dist.css, run pnpm build:csspnpm extract → pnpm build:i18npnpm build (CSS → i18n → JS)master--no-verifypnpm run lists every script; the ones you'll want are test:no-coverage, test:lint, test:prettier, format, test:licenseAttach exactly one — a PR without one fails the label check (require-labels.yml):
change_type/application - service code: components, hooks, features, bug fixes, tests (the usual choice)change_type/infrastructure - CI, build config, Dockerfile, toolingchange_type/emergency - incident hotfixOther labels can be used, but don't affect this check.
Add labels when creating PRs:
gh pr create --title "Title" --body "Description" --label "change_type/application"
Always run: pnpm test:no-coverage, pnpm test:lint, pnpm test:prettier
Check ESLint on changed files as you work: After modifying or creating files, run pnpm eslint "path/to/changed/file.jsx" before moving on.
Task-specific requirements covered in Task Recipes sections above (GraphQL schema regen, i18n extract, CSS build, etc.)
Don't: edit generated files, call t() at module root, create unnecessary files, add unrequested features, partially update state, test implementation details, assume methods exist