diff --git a/apps/agent-forge/src/ui/Field.tsx b/apps/agent-forge/src/ui/Field.tsx
new file mode 100644
index 000000000..f7ba04b84
--- /dev/null
+++ b/apps/agent-forge/src/ui/Field.tsx
@@ -0,0 +1,100 @@
+import { createContext, useContext, useId, type ReactNode } from 'react';
+import './form.css';
+
+/** Ids a control inside a {@link Field} picks up automatically. */
+export interface FieldIds {
+ /** The control id the Field's label points at. */
+ id: string;
+ /** Id of the hint `
`, present only when the Field has a hint. */
+ hintId?: string;
+ /** Id of the error `
`, present only when the Field has an error. */
+ errorId?: string;
+ /** True while the Field carries an error; controls become `aria-invalid`. */
+ invalid: boolean;
+ /** True when the Field is required; controls become `aria-required`. */
+ required: boolean;
+}
+
+const FieldContext = createContext(undefined);
+
+/** Props for {@link Field}. */
+export interface FieldProps {
+ /** Visible label, linked to the control through `htmlFor`. */
+ label: ReactNode;
+ /** Muted help text under the control; its id joins `aria-describedby`. */
+ hint?: ReactNode;
+ /** Danger text under the hint; marks the control `aria-invalid`. */
+ error?: string;
+ /** Draws a faint `*` after the label and sets `aria-required` on the control. */
+ required?: boolean;
+ /** Control id; defaults to a `useId` value. */
+ id?: string;
+ /** Label left, control right (switch rows); hint/error wrap full-width below. */
+ inline?: boolean;
+ className?: string;
+ children: ReactNode;
+}
+
+/**
+ * A labelled, described control row. Controls rendered inside read their id,
+ * `aria-describedby`, `aria-invalid` and `aria-required` from the enclosing
+ * Field via {@link useFieldIds}, so screens stop hand-wiring ids.
+ */
+export function Field({
+ label,
+ hint,
+ error,
+ required = false,
+ id,
+ inline,
+ className,
+ children,
+}: FieldProps): JSX.Element {
+ const autoId = useId();
+ const controlId = id ?? autoId;
+ const ids: FieldIds = {
+ id: controlId,
+ hintId: hint != null ? `${controlId}-hint` : undefined,
+ errorId: error != null ? `${controlId}-error` : undefined,
+ invalid: error != null,
+ required,
+ };
+ const classes = [
+ 'field',
+ inline ? 'field-inline' : undefined,
+ error != null ? 'field-invalid' : undefined,
+ className,
+ ]
+ .filter(Boolean)
+ .join(' ');
+ return (
+
+
+
+ {children}
+ {hint != null ? (
+
+ {hint}
+
+ ) : null}
+ {error != null ? (
+
+ {error}
+
+ ) : null}
+
+
+ );
+}
+
+/** The enclosing {@link Field}'s ids, or `undefined` outside a Field. */
+export function useFieldIds(): FieldIds | undefined {
+ return useContext(FieldContext);
+}
diff --git a/apps/agent-forge/src/ui/__tests__/controls.test.tsx b/apps/agent-forge/src/ui/__tests__/controls.test.tsx
new file mode 100644
index 000000000..0793229eb
--- /dev/null
+++ b/apps/agent-forge/src/ui/__tests__/controls.test.tsx
@@ -0,0 +1,153 @@
+import { act } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { render as mount } from './render';
+
+import { Field } from '../Field';
+import { Checkbox, Input, Select, Switch, Textarea } from '../controls';
+
+let container: HTMLDivElement;
+const render = (node: React.ReactNode) => {
+ container = mount(node);
+};
+
+const click = (el: Element) => act(() => (el as HTMLElement).click());
+const noop = () => {};
+
+describe('Field', () => {
+ it('labels, describes, invalidates and requires the control it wraps', () => {
+ render(
+
+
+ ,
+ );
+ const input = container.querySelector('input')!;
+ const label = container.querySelector('label.field-label')!;
+ expect(label.getAttribute('for')).toBe(input.id);
+
+ const describedBy = input.getAttribute('aria-describedby')!.split(' ');
+ expect(describedBy).toHaveLength(2);
+ const [hintId, errorId] = describedBy;
+ expect(document.getElementById(hintId)!.textContent).toBe('Shown on the card.');
+ expect(document.getElementById(errorId)!.textContent).toBe('Required.');
+ expect(document.getElementById(errorId)!.className).toBe('hint field-error');
+
+ expect(input.getAttribute('aria-invalid')).toBe('true');
+ expect(input.getAttribute('aria-required')).toBe('true');
+
+ const required = container.querySelector('.field-required')!;
+ expect(required.textContent).toBe('*');
+ expect(required.getAttribute('aria-hidden')).toBe('true');
+
+ expect(container.firstElementChild!.classList.contains('field-invalid')).toBe(true);
+ });
+
+ it('lets an explicit id and aria-describedby on the control win over the Field defaults', () => {
+ render(
+
+
+ ,
+ );
+ const input = container.querySelector('input')!;
+ expect(input.id).toBe('x');
+ expect(input.getAttribute('aria-describedby')).toBe('mine');
+ });
+
+ it('names a Switch inside an inline Field through the label, no aria-label needed', () => {
+ render(
+
+
+ ,
+ );
+ const field = container.querySelector('.field.field-inline')!;
+ const label = field.querySelector('label.field-label')!;
+ const button = field.querySelector('button[role="switch"]')!;
+ expect(button.id).toBeTruthy();
+ expect(button.id).toBe(label.getAttribute('for'));
+ expect(button.getAttribute('aria-label')).toBeNull();
+ });
+});
+
+describe('Select', () => {
+ it('renders a disabled placeholder option first, keeps option order and supports controlSize="sm"', () => {
+ render(
+ ,
+ );
+ const select = container.querySelector('select')!;
+ const options = [...select.querySelectorAll('option')];
+ expect(options[0].value).toBe('');
+ expect(options[0].disabled).toBe(true);
+ expect(options[0].textContent).toBe('Pick a model');
+ expect(options.slice(1).map((o) => o.textContent)).toEqual(['Alpha', 'Beta']);
+ expect(options[2].disabled).toBe(true);
+ expect(select.value).toBe('');
+ expect(select.classList.contains('select-sm')).toBe(true);
+ });
+});
+
+describe('Textarea', () => {
+ it('is mono by default and picks up prose/autoGrow classes when asked', () => {
+ render();
+ expect(container.querySelector('textarea')!.className).toBe('textarea');
+
+ render();
+ expect(container.querySelector('textarea')!.className).toBe('textarea textarea-prose textarea-autogrow');
+ });
+});
+
+describe('Switch', () => {
+ it('is a button[role=switch] that toggles on click and does nothing when disabled', () => {
+ const spy = vi.fn();
+ render();
+ const button = container.querySelector('button[role="switch"]')!;
+ expect(button.getAttribute('aria-checked')).toBe('false');
+ expect(button.getAttribute('aria-label')).toBe('Enable support-bot');
+ click(button);
+ expect(spy).toHaveBeenCalledWith(true);
+
+ const disabledSpy = vi.fn();
+ render();
+ click(container.querySelector('button[role="switch"]')!);
+ expect(disabledSpy).not.toHaveBeenCalled();
+ });
+
+ it('draws an aria-hidden On/Off state label and a switch-sm size class', () => {
+ render();
+ const state = container.querySelector('.switch-state')!;
+ expect(state.classList.contains('overline')).toBe(true);
+ expect(state.textContent).toBe('Off');
+ expect(state.getAttribute('aria-hidden')).toBe('true');
+ expect(container.querySelector('.switch')!.classList.contains('switch-sm')).toBe(true);
+
+ render();
+ expect(container.querySelector('.switch-state')!.textContent).toBe('On');
+ });
+});
+
+describe('Checkbox', () => {
+ it('sets the indeterminate property and toggles when its label is clicked', () => {
+ const spy = vi.fn();
+ render();
+ const input = container.querySelector('input')! as HTMLInputElement;
+ expect(input.type).toBe('checkbox');
+ expect(input.indeterminate).toBe(true);
+ click(container.querySelector('label.checkbox-row')!);
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it('renders a muted description line and marks a disabled row', () => {
+ render();
+ expect(container.querySelector('.checkbox-description')!.textContent).toBe('Existing files are replaced.');
+ expect(container.querySelector('.checkbox-row')!.classList.contains('checkbox-row-disabled')).toBe(true);
+ expect((container.querySelector('input')! as HTMLInputElement).disabled).toBe(true);
+ });
+});
diff --git a/apps/agent-forge/src/ui/controls.tsx b/apps/agent-forge/src/ui/controls.tsx
new file mode 100644
index 000000000..547cc2fa0
--- /dev/null
+++ b/apps/agent-forge/src/ui/controls.tsx
@@ -0,0 +1,271 @@
+import {
+ forwardRef,
+ useEffect,
+ useRef,
+ type InputHTMLAttributes,
+ type ReactNode,
+ type SelectHTMLAttributes,
+ type TextareaHTMLAttributes,
+} from 'react';
+import { useFieldIds } from './Field';
+import './form.css';
+
+/** Control density: `'md'` is the Studio's 32px default, `'sm'` a 28px variant. */
+export type ControlSize = 'sm' | 'md';
+
+interface ControlA11yProps {
+ id?: string;
+ invalid?: boolean;
+ 'aria-describedby'?: string;
+ 'aria-invalid'?: InputHTMLAttributes['aria-invalid'];
+ 'aria-required'?: InputHTMLAttributes['aria-required'];
+}
+
+/**
+ * Merges an enclosing {@link Field}'s ids and state into a control's a11y
+ * props. Explicit props always win; outside a Field only `invalid` applies.
+ */
+function useControlA11y(props: ControlA11yProps): ControlA11yProps {
+ const field = useFieldIds();
+ const described = [field?.hintId, field?.errorId].filter((v): v is string => v != null).join(' ');
+ return {
+ id: props.id ?? field?.id,
+ 'aria-describedby': props['aria-describedby'] ?? (described || undefined),
+ 'aria-invalid': props['aria-invalid'] ?? (props.invalid || field?.invalid ? true : undefined),
+ 'aria-required': props['aria-required'] ?? (field?.required ? true : undefined),
+ };
+}
+
+/** Props for {@link Input}. */
+// fallow-ignore-next-line unused-type -- prop contract; adopted by the form screens in the next tickets.
+export interface InputProps extends InputHTMLAttributes {
+ /** Danger border + ring; also set automatically by an enclosing Field error. */
+ invalid?: boolean;
+ /** `'sm'` renders the 28px variant; default is the Studio's 32px control. */
+ controlSize?: ControlSize;
+}
+
+/**
+ * The `.input` text control. Inside a {@link Field} it picks up the label id,
+ * `aria-describedby`, `aria-invalid` and `aria-required` automatically.
+ */
+export const Input = forwardRef(function Input(props, ref) {
+ const {
+ invalid,
+ controlSize,
+ className,
+ id,
+ 'aria-describedby': describedBy,
+ 'aria-invalid': ariaInvalid,
+ 'aria-required': ariaRequired,
+ ...rest
+ } = props;
+ const a11y = useControlA11y({
+ id,
+ invalid,
+ 'aria-describedby': describedBy,
+ 'aria-invalid': ariaInvalid,
+ 'aria-required': ariaRequired,
+ });
+ const classes = ['input', controlSize === 'sm' ? 'input-sm' : undefined, className].filter(Boolean).join(' ');
+ return ;
+});
+
+/** Props for {@link Textarea}. */
+// fallow-ignore-next-line unused-type -- prop contract; adopted by the form screens in the next tickets.
+export interface TextareaProps extends TextareaHTMLAttributes {
+ /** Danger border + ring; also set automatically by an enclosing Field error. */
+ invalid?: boolean;
+ /** UI font at 13px for prose (instructions); default keeps the mono 12px shell. */
+ prose?: boolean;
+ /** `field-sizing: content` auto-grow, clamped between 64px and 320px. */
+ autoGrow?: boolean;
+}
+
+/** The `.textarea` control; same Field wiring as {@link Input}. */
+export const Textarea = forwardRef(function Textarea(props, ref) {
+ const {
+ invalid,
+ prose,
+ autoGrow,
+ className,
+ id,
+ 'aria-describedby': describedBy,
+ 'aria-invalid': ariaInvalid,
+ 'aria-required': ariaRequired,
+ ...rest
+ } = props;
+ const a11y = useControlA11y({
+ id,
+ invalid,
+ 'aria-describedby': describedBy,
+ 'aria-invalid': ariaInvalid,
+ 'aria-required': ariaRequired,
+ });
+ const classes = [
+ 'textarea',
+ prose ? 'textarea-prose' : undefined,
+ autoGrow ? 'textarea-autogrow' : undefined,
+ className,
+ ]
+ .filter(Boolean)
+ .join(' ');
+ return ;
+});
+
+/** One option row for {@link Select}. */
+export interface SelectOption {
+ value: string;
+ label: string;
+ disabled?: boolean;
+}
+
+/** Props for {@link Select}. */
+// fallow-ignore-next-line unused-type -- prop contract; adopted by the form screens in the next tickets.
+export interface SelectProps extends Omit, 'children'> {
+ /** Options rendered in order after the optional placeholder. */
+ options: SelectOption[];
+ /** First option: `