Builder UI and i18n
Use for apps/builder UI work. Pair with feature-scaffold for new feature
modules and pages.
UI Stack
- React 19 + Next.js app router.
- Shared components come from
@chatbotx.io/ui/*. - Builder also has local components under
apps/builder/src/components. - Forms use React Hook Form, Zod, and next-safe-action adapter.
- URL state commonly uses
nuqs. - Icons should use the project icon library when available.
i18n Rule
All user-facing strings must use translations. Do not hardcode labels, placeholders, button text, empty states, tab names, toasts, or dialog copy in builder UI.
Source of truth: apps/builder/messages/en.json. All 20 locale files in
apps/builder/messages/ must carry every key — apps/builder's own lint script is
i18n:check --source en --locales messages (apps/builder/package.json:11-12), which runs
in CI's Lint job. Adding a key to en.json + vi.json only will fail the parity check.
Before adding keys, check existing fields.*, common actions, table labels, and
feature namespaces.
Typical component pattern:
"use client"
import { useTranslations } from "next-intl"
export const ExampleButton = () => {
const t = useTranslations("features.examples")
return <Button>{t("create")}</Button>
}
Dynamic translation keys
Never pass a raw variable or an unconstrained template literal to t(). When a
runtime value selects a translation key, route it through an exhaustive literal
map:
const labelKeyByStatus = {
active: "features.examples.status.active",
paused: "features.examples.status.paused",
} as const satisfies Record<ExampleStatus, string>
t(labelKeyByStatus[status])
A helper function backed by the same kind of map is also acceptable. This keeps every possible key auditable with text search and makes TypeScript report enum members that have no translation mapping.
If the key comes from an open-ended source such as a database string, guard the lookup and provide a translated fallback:
t.has(key) ? t(key) : t("features.examples.unknown")
@lingual/i18n-check also recognizes escape-hatch comments such as
// i18n-check t('features.examples.status.active'). Use one when a genuinely
dynamic call needs to declare a specific static key as used and doing so
meaningfully narrows the namespace blind spot.
Form Pattern
- Server actions with
bindArgsSchemasmust be bound before passing to hooks:createThingAction.bind(null, workspaceId). - No-input delete actions call
execute()with no arguments, notexecute({}). - Validation schemas live near the feature, usually
schema/action.ts.
Form field component priority
When building a form field, always choose the highest-priority option that fits:
- Defined form field from
@chatbotx.io/ui/components/form/*— first choice. These components wrapFormFieldWrapperinternally, handling label, optional marker, description, andFormMessageautomatically. No manualFormField/FormItem/FormControlboilerplate needed. - Shadcn UI primitive from
@chatbotx.io/ui/components/ui/*wrapped in a manualFormField+FormItemblock — only when no defined field fits (e.g. custom composite inputs not covered by the list below). - Raw React/HTML element — last resort only.
Available defined fields (import from @chatbotx.io/ui/components/form/<name>):
| Component | Use for |
|---|---|
InputField |
Text inputs |
InputNumberField |
Numeric inputs (renders stepper) |
TextareaField |
Multi-line text |
SelectField |
Single-select dropdowns; supports allowClear, options |
ComboboxField |
Searchable single-select |
MultiSelectField |
Multi-select |
CheckboxField |
Boolean checkbox |
SwitchField |
Toggle switch |
RadioGroupField |
Radio group |
SliderField |
Range slider |
CalendarField |
Inline calendar |
DatePickerField |
Date picker popover |
ColorPickerField |
Color picker |
SelectTagsInputField |
Tag input with select |
All defined fields read control from useFormContext, so they only require a
<Form {...form}> provider ancestor.
import { InputField } from "@chatbotx.io/ui/components/form/input-field"
import { SelectField } from "@chatbotx.io/ui/components/form/select-field"
import { InputNumberField } from "@chatbotx.io/ui/components/form/input-number-field"
// CORRECT — uses defined form field
<Form {...form}>
<form
<InputField name="name" label={t("fields.name.label")} required />
<SelectField name="type" label={t("fields.type.label")} options={options} required />
<InputNumberField name="position" label={t("fields.position.label")} min={0} />
</form>
</Form>
// WRONG — manual boilerplate when a defined field exists
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("fields.name.label")}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
Layout and Components
- Mirror sibling features for tables, dialogs, toolbar actions, and columns.
- Keep server components responsible for data promises and client components responsible for interaction.
- Pages receive Promise
params/searchParams. - Client components unwrap server promises with
use(promises)where this repo already follows that pattern. - Public routes need
apps/builder/src/proxy.tspublic route registration.
Styling Guidance
- Keep operational UI dense, scan-friendly, and consistent with existing builder screens.
- Do not create landing-page style layouts for product workflows.
- Avoid nested cards and oversized hero typography inside tools.
- Ensure button and table text fits at mobile and desktop sizes.
RTL / Logical Properties
The app supports RTL locales (Arabic ships as ar). Default to Tailwind's
logical-property utilities for any new or edited class — never reach for a
physical-direction class first.
| Physical (avoid) | Logical (use) |
|---|---|
left-* |
start-* (or inset-s-* for inset-* positioning) |
right-* |
end-* (or inset-e-* for inset-* positioning) |
ml-* |
ms-* |
mr-* |
me-* |
pl-* |
ps-* |
pr-* |
pe-* |
border-l-* |
border-s-* |
border-r-* |
border-e-* |
rounded-l-* |
rounded-s-* |
rounded-r-* |
rounded-e-* |
text-left |
text-start |
text-right |
text-end |
space-x-*anddivide-x-*are already logical in Tailwind v4 (compile tomargin-inline-start/end/border-inline-start/end-width) — leave them as they are, don't rewrite them.- Directional chevrons/arrows (dropdown submenu carets, pagination prev/next,
breadcrumb separators, wizard next/prev arrows) should flip in RTL via
rtl:rotate-180, matching the existing convention incarousel.tsx,calendar.tsx, andsidebar.tsx. translate-x-*needs case-by-case judgment, not a blind swap: a centering pair (start-1/2+-translate-x-1/2) stays as-is since X-axis centering is symmetric; a genuinely directional slide (e.g. a switch thumb) needsltr:translate-x-.../rtl:translate-x-...treatment, perswitch.tsx.- Carve-outs — physical classes are correct here, do not "fix" them:
data-[side=...]selectors driven by a positioning engine (e.g. popover collision avoidance) respond to runtime layout side, not text direction.- Physical
sideprops on components likesheet.tsx/sidebar.tsxare an intentional component API; their internals already map to logical CSS. - Keyboard event key codes (
e.key === "ArrowLeft") are a browser API, not a CSS utility. - Symmetric
inset-x-0(equal both sides) has no more "logical" form. packages/mail/src/emails— email clients have poor RTL support, keep physical unless a dedicated task addresses this.
Verification
Run targeted checks for touched UI:
pnpm --filter builder check-types
pnpm --filter builder test
pnpm lint
If visual layout risk is high, start the builder dev server and inspect the page.