Builder UI – AI Operative Guide

TL;DR for Agents

  • Workdir /builder-ui; default port 52002; backend must be running.
  • Run pnpm install first. Bug fixes start with pnpm test:no-coverage; lint with pnpm test:lint.
  • Styling: Tailwind for component styles; app/src/app.css only for global design system primitives (buttons, inputs, typography), then pnpm build:css (the dev watcher does this for you).
  • Lingui strings belong inside components/functions only.
  • Never edit generated files: app/src/icons/index.tsx, app/src/app.dist.css, app/src/gql/schema.json.
  • No destructive git resets; never revert user changes.

Audience: AI agents only. Assumes you can run commands, read code, and follow guardrails.

Specs Directory

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.

Prerequisites

Backend must be running (GraphQL dependency). Run pnpm install. Working directory /builder-ui.


Context & Tech Stack

Tech Stack

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.

Runtime & Tooling

  • Path aliases: @app/src, @testing/*app/src/@testing/*
  • Filename convention: kebab-case (enforced by ESLint)

Entry Points & Key Files

  • Entry: app/src/index.jsx mounts router from app/src/routes.jsx
  • Root Layout: app/src/root-layout.jsx (Apollo provider, theme, alerts, feature-flag modal, SystemError boundary, keyboard shortcuts)
  • App Config: app/src/config.jsx
  • Global Styles: app/src/app.css (source) → app/src/app.dist.css (compiled, never edit directly)
  • Routing: app/src/routes.jsx - React Router 7 route tree
  • GraphQL Setup: app/src/gql/index.jsx (Apollo Client with custom type policies)
  • GraphQL Schema: app/src/gql/schema.json (generated)
  • i18n Setup: app/src/i18n.jsx

Architecture & Structure

Data Layer

  • GraphQL: Apollo Client @ /app/api/v0/graphql
  • Auth: Bearer token from authToken cookie
  • WebSocket: Subscriptions via WSS
  • Cache: Custom type policies in app/src/gql/index.jsx (the typePolicies export, ~lines 27-172)
  • REST (Secondary): /api/v1/* for Identity Service (user management)

Backend Architecture Flow

Frontend (React)
↓ GraphQL Query/Mutation
Platform (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 generation
Identity Service can call Platform back for user extended attributes (bi-directional)

Directory Structure

Core Application Pages

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)

Core Engines

app/src/formbot/ - Form rendering engine

  • index.jsx - Main formbot instance, gadget registration, validation
  • gadgets/ - Many gadget types including Text, Dropdown, Repeater, Table, DataLookup, and more
  • engine/ - Core rendering and state management
  • decorators/ - Validation, progressive disclosure, runtime enhancements
  • Architecture: Plugin-based; each gadget has manifest, config component, runtime component, validation
  • Data-driven forms from templates

app/src/flowbot/ - Visual workflow designer and execution engine

  • Custom-built linear workflow editor with drag-and-drop (using voronoi-dnd)
  • engine/ - Visual editor, viewer, configuration panel, validation, simulation
  • steps/ - 9 step types: approval, task, notification, formfill, acknowledge, conditional, integration, echo, trigger
  • components/ - UI components (email builder, person picker, etc.)
  • Supports nested subflows (denial paths, conditional branches)
  • Integrates with formbot for form data flow

app/src/voronoi-dnd/ - Generic drag-and-drop library

  • Uses Voronoi diagrams for intelligent drop target detection
  • Framework-agnostic core in voronoi.jsx
  • draggable.jsx, drop-zone.jsx, gatherer.jsx, item.jsx, context.jsx

app/src/voronoi-dnd-formbot/ - Form-specific DND implementation

  • Adapter between voronoi-dnd and formbot
  • Handles nested containers (sections, repeaters, tables)
  • Grid snapping, empty states, gadget palette dragging

Shared Infrastructure

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

Key Architectural Patterns

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.


Configuration & Infrastructure

Environment Variables

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.

Apollo Cache Type Policies

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):

  • groupsConnection, membersConnection - Merge paginated edges, dedupe by __ref
  • Dataset - Custom key includes formVersion.id to cache different versions separately
  • Field - Disable normalization (keyFields: false) - too dynamic to cache
  • Document.viewer - Always replace (don't merge) to avoid stale data
  • ActionsPaginatedConnection - Merge edges with custom sort

If experiencing cache issues: First investigate if query/API can be fixed. Only add type policy as last resort.

Error Handling & Monitoring

Error Boundary:

  • Root layout uses SystemError component (app/src/components/system-error.jsx)
  • Catches React errors, reports to Sentry with context
  • 401 errors redirect to /auth, permission errors redirect to Forbidden component

Sentry Integration:

  • Configured in app/src/config.jsx with custom breadcrumb/error context
  • Only enabled when VITE_SENTRY_RELEASE is set

GraphQL Errors:

  • Network 401 → redirects to /auth?return_to=<current>
  • Permission errors → shows Forbidden component
  • Use ExplicitError or GraphQLError components for manual error display

Content Security Policy (CSP)

Adding new external domains:

  1. Edit scripts/generate-csp/index.js
  2. Add domain to appropriate policy object (script-src, connect-src, img-src)
  3. Run node scripts/generate-csp to verify

CSP includes third-party integrations (AnnounceKit, ChurnZero, Sentry), allows 'unsafe-inline', WebSocket across Kuali domains, S3 image buckets.

HTML Sanitization

Use app/src/components/sanitize.jsx for sanitizing user-generated HTML in rich text gadgets and email content.

Git Hooks (Husky)

Pre-commit runs Prettier + ESLint on staged files (configure via git config hooks.validatebuilderui on|off|custom). Bypass with --no-verify.

ESLint Configuration

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.


Core Development Principles

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.

1. Match Existing Patterns

Always read code before modifying it. Never propose changes to files you haven't read. Before adding or modifying code:

  • Read the target file and related files
  • Identify existing patterns (naming, structure, error handling, imports)
  • Match the established conventions exactly
  • Look for similar functionality nearby and follow that approach
  • Use the same libraries/utilities already in use (don't introduce new ones for existing problems)
  • Match the simplicity level of the surrounding code, not just its structure

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.

2. Handle Both Legacy and New Formats

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.

3. Avoid useEffect - Use Only When Necessary

Only use useEffect for external system synchronization (WebSockets, DOM manipulation, third-party libraries, analytics).

Never use useEffect for:

  • Deriving state → compute during render
  • Event handling → use onClick/onChange handlers
  • Data fetching → use Apollo queries or React Router loaders
  • Initialization → use useMemo or useState initializer

Always include cleanup functions to prevent memory leaks.

4. Minimal Diffs

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:

  • DO remove variables that become unused
  • DO simplify conditions that become trivial
  • DO remove unnecessary operations (like .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.

5. Eliminate Intermediate Variables

When a variable is simply assigned once and never reassigned, use the original value directly instead of creating an intermediate variable.

Bad:

const gadgets = anonymousDisabledGadgets
if (gadgets.has(gadget.key)) return true

Good:

if (anonymousDisabledGadgets.has(gadget.key)) return true

Exception: Keep intermediate variables when they:

  • Improve readability for complex expressions
  • Are computed values (not simple reassignments)
  • Are used multiple times in a way that would duplicate complex logic

6. Prefer TypeScript for New Files

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).

7. Comments

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.

Process & Resource Safeguards

  • Stop after 2 consecutive identical failures — report to the user instead of retrying. This applies to test runs, builds, and lint fixes alike.
  • One test runner at a time. Never run test processes in parallel or alongside other resource-intensive commands; wait for the previous run to finish, and kill orphans from a timed-out run before starting another.
  • Never use watch mode (pnpm test:watch) in an automated fix loop — it doesn't terminate.
  • If a command produces no output for 2+ minutes, terminate it and report rather than spawning more processes.

Frontend Conventions

LowkeyUI Styleguide (start here for any UI work)

  • Live styleguide (dev only): 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.
  • Source of truth for lowkey styles: 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.
  • Icons: lowkey UI always uses the new outline set — 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.
  • Gotchas: the Tailwind config REPLACES the 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.
  • HTML snapshot for visual verification outside the dev env: node scripts/export-styleguide/index.mjs (writes the workspace __drops/lowkey-components.html).

Authentication & Data Flow

  • Backend topology: See "Backend Architecture Flow" diagram above
  • Auth flow: 401 redirects to /auth; uses window.loggedInUser or fetches from /api/v1/users/current; token in authToken cookie
  • SSO users: Have ssoId field (SAML/CAS/LDAP). Cannot set/change passwords (HTTP 400)
  • Password users: Have passwordDigest (bcrypt, 10 rounds). Backend never returns password; UI enforces complexity rules
  • Institutions can mix both auth types; always check user.ssoId per-user

Use CenterModal (app/src/components/modal-centered.jsx):

  • Automatically provides role="dialog", aria-modal="true", focus trap, ESC/backdrop handling, animations
  • Required: Modal heading must use id="modal-title" (hardcoded expectation)
  • Focus management: You're responsible for returning focus to the trigger element
    const triggerRef = useRef()
    // After modal dismiss:
    setTimeout(() => triggerRef.current?.focus(), 100)
  • Animations: Uses Preserve + Transition components (300ms default)

For full-page/side-drawer modals, use modal-page.jsx with nesting support.

Alerts & Toasts

Use useAlerts() from app/src/ui/alerts.jsx:

  • type1: Dialog
  • type2: Banner
  • type3: Toast (auto-dismiss after 4 seconds)
const alerts = useAlerts()
alerts.type3(t`Password updated successfully`, 'success')

State Management

Immutable Updates: Always spread entire objects in onChange handlers (never partial updates). Use Immer for complex nested updates.

Styling Conventions

  • Local component styles: Use Tailwind CSS utilities (preferred for all component-specific styling)
  • Global styles: Edit app/src/app.css only for design system primitives (buttons, inputs, typography, etc.); never edit app/src/app.dist.css (generated)
  • Error text: text-red-500
  • Disabled inputs: cursor-not-allowed bg-light-gray-100
  • Theme colors: CSS vars like --bg, --text-default
  • Tailwind config: tailwind.config.js
  • Animations: tw-animate-css

Accessibility (a11y)

  • Interactive elements: Every control needs ARIA labeling
  • aria-describedby: Reference all descriptive content (help text, format hints, error messages) using space-separated IDs
  • Validation messages: Require role="alert" and must be referenced via aria-describedby
  • Invalid inputs: Set aria-invalid="true" when errors exist

Internationalization (i18n)

Use Lingui macros (@lingui/react/macro):

import { Trans } from '@lingui/react/macro'
import { useLingui } from '@lingui/react'
// In component
const { t, i18n } = useLingui()
<Trans>Welcome message</Trans>
const message = t`Dynamic message`
// Date/number formatting
i18n.date(new Date())
i18n.number(1234.56)

Rules:

  • Never call t() at module root - only inside components/functions
  • Supported locales: en, es, fr
  • Messages live in app/src/locales/<locale>/messages.{po/js}
  • After adding/changing strings: pnpm extract to update .po catalogs
  • Commit .po changes
  • build:i18n compiles catalogs (dev scripts watch and recompile)

GraphQL Patterns

Component Data: Colocated Fragments + 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 {
id
label
isCurrent
canRestore
}
`
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:

  • A component that renders entity data owns a fragment for exactly the fields it reads, and receives an id (plus behavior/UI props and callbacks), not the entity.
  • Compose child fragments with ...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).
  • A cross-entity/aggregate value a child can't derive from its own entity (e.g. "which row is current") is legitimately computed by the parent and passed as a prop — that's not the prop-drilling this pattern removes.
  • useFragment returns partial data (each field is T | undefined) — guard reads with ?? ….
  • Testing: readers resolve against normalized cache entries, so seed the cache (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/).

Mutation Responses (Union Types)

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 type
handleSuccess(result.data.updateUser)
}

Data Sharing Pattern

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()

Schema Updates

ESLint validates queries against the generated schema, so unknown-field errors usually mean it's stale — see Regenerate Generated Files.


Testing

Testing standards and patterns are documented in .claude/rules/testing.md. This file is automatically loaded when working with test files (*.test.* or *.spec.*).


Task-Based Recipes

Every recipe below ends the same way — see Pre-Submission Verification for the commands to run. They aren't repeated per recipe.

Add/Modify Routes

  1. Open app/src/routes.jsx (React Router 7 createBrowserRouter)
  2. Import the page component
  3. Extend the route tree:
    • Use Navigate helper to preserve search params
    • Wrap auth-required branches with Protected
    • Use layouts like AppLayout for standard nav/header
  4. For nested/modal routes, nest under parent route
{
path: '/users/:id',
element: <Protected><AppLayout><UserPage /></AppLayout></Protected>,
children: [
{ path: 'edit', element: <UserEditModal /> }
]
}

Add/Modify Pages

  1. Create component under appropriate app/src/pages* directory:
    • pages-builder/ for configuration UI
    • pages-runner/ for end-user runtime
    • pages/ for system administration
    • pages-anonymous/ for public/unauthenticated
  2. Wrap in AppLayout if it needs standard nav/header
  3. Colocate page-specific GraphQL operations near the page
  4. Wire route in app/src/routes.jsx
  5. Add Protected wrapper if authentication required
  6. Add feature-flag gating if needed

Update/Add GraphQL Operations

  1. Colocate query/mutation/fragments with the page/component. For components that render query data, declare a useFragment fragment on the component and compose it into the query (see Component Data: Colocated Fragments + useFragment) rather than passing the data down as props.
  2. If ESLint complains about unknown fields, regenerate the schema (see Regenerate Generated Files) and restart editor/ESLint
  3. Consider cache typePolicies only if a custom merge/key is needed (in app/src/gql/index.jsx)

Feature Flags

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:

ConditionResult
if (flag)keep the block's contents
if (!flag)remove the whole block
if (flag \|\| other)keep the contentstrue \|\| 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.

Writing Tests

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.

Regenerate Generated Files

  • Icons: Use scripts/import-icons/ (never edit app/src/icons/index.tsx)
  • GraphQL Schema: node scripts/generate-graphql-schema (requires backend), restart editor after
  • CSP: scripts/generate-csp (when adding external domains)
  • Browser Support: scripts/generate-browser-check

Add/Modify Gadgets

Full 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.


Debugging Playbook

GraphQL

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).

SSO vs Password

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.

Form State, Modals, Validation

  • Form state: Fields disappearing → spread entire object in onChange (see State Management)
  • Modals: Missing id="modal-title" on heading or focus not returned to trigger (see Modal Usage)
  • Validation: Use touched flags; error containers need role="alert"; reference via aria-describedby; set aria-invalid="true" on inputs with errors

Test Failures

  • Translations: Run pnpm build:i18n, add await loadLocale('en') before assertions
  • GraphQL data: Check app/src/@testing/mocks/, use api.debug() to dump mock data
  • Timezone: All tests run under TZ=Etc/UTC
  • Coverage: Use pnpm test:no-coverage for targeted runs (full suite enforces thresholds)
  • Element not found: Use find* queries (async), follow Testing Library query priority
  • Act warnings: Async state not awaited - wrap in waitFor or use find* queries

Dev Server Issues

Verify PORT (default 52002), check backend running, try pnpm dev:docked, check port conflicts (lsof -i :52002), clear cache (rm -rf node_modules/.vite).

Build Issues

  • CSS: Edit app.css not app.dist.css, run pnpm build:css
  • Translations: pnpm extractpnpm build:i18n
  • GraphQL lint: Regenerate schema
  • Build fails: Run pnpm build (CSS → i18n → JS)

Code Quality & Workflows

Git & Pull Requests

  • Main branch: master
  • Husky runs pre-commit hooks (Prettier + ESLint); bypass with --no-verify
  • StandardJS: no semicolons, 2-space indent, custom Prettier import ordering
  • pnpm run lists every script; the ones you'll want are test:no-coverage, test:lint, test:prettier, format, test:license

PR Labels (Required)

Attach 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, tooling
  • change_type/emergency - incident hotfix

Other 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"

Pre-Submission Verification

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.)

Common Pitfalls

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