diff --git a/packages/dashboard/app/components/TaskFieldsSection.css b/packages/dashboard/app/components/TaskFieldsSection.css
new file mode 100644
index 0000000000..4a4bc08f48
--- /dev/null
+++ b/packages/dashboard/app/components/TaskFieldsSection.css
@@ -0,0 +1,214 @@
+/* Schema-driven custom-field form section (U13 / KTD-14). */
+
+.task-fields-section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin: 12px 0;
+}
+
+.task-field-row {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.task-field-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary, #8a8f98);
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.task-field-required {
+ color: var(--accent-danger, #e5484d);
+}
+
+.task-field-control {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.task-field-input,
+.task-field-textarea,
+.task-field-select {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 6px 8px;
+ border: 1px solid var(--border-color, #2a2d34);
+ border-radius: 6px;
+ background: var(--input-bg, #16181d);
+ color: var(--text-primary, #e6e6e6);
+ font-size: 13px;
+ font-family: inherit;
+}
+
+.task-field-textarea {
+ resize: vertical;
+ min-height: 56px;
+}
+
+.task-field-input:disabled,
+.task-field-textarea:disabled,
+.task-field-select:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+/* Chips (enum single + multi-enum) */
+.task-field-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.task-field-chip {
+ padding: 3px 10px;
+ border: 1px solid var(--border-color, #2a2d34);
+ border-radius: 999px;
+ background: var(--chip-bg, #1c1f26);
+ color: var(--text-secondary, #b4b8c0);
+ font-size: 12px;
+ cursor: pointer;
+ transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
+}
+
+.task-field-chip:hover:not(:disabled) {
+ border-color: var(--accent, #4f7cff);
+}
+
+.task-field-chip.is-active {
+ background: var(--accent, #4f7cff);
+ border-color: var(--accent, #4f7cff);
+ color: #fff;
+}
+
+.task-field-chip:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+/* Radio group */
+.task-field-radio-group {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.task-field-radio {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: var(--text-primary, #e6e6e6);
+ cursor: pointer;
+}
+
+/* Boolean toggle */
+.task-field-toggle {
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+}
+
+.task-field-toggle input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.task-field-toggle-track {
+ display: inline-block;
+ width: 34px;
+ height: 18px;
+ border-radius: 999px;
+ background: var(--border-color, #2a2d34);
+ position: relative;
+ transition: background 0.15s ease;
+}
+
+.task-field-toggle-track::after {
+ content: "";
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: #fff;
+ transition: transform 0.15s ease;
+}
+
+.task-field-toggle input:checked + .task-field-toggle-track {
+ background: var(--accent, #4f7cff);
+}
+
+.task-field-toggle input:checked + .task-field-toggle-track::after {
+ transform: translateX(16px);
+}
+
+.task-field-toggle input:disabled + .task-field-toggle-track {
+ opacity: 0.6;
+}
+
+/* Inline validation error */
+.task-field-error {
+ font-size: 12px;
+ color: var(--accent-danger, #e5484d);
+}
+
+.task-field-row.has-error .task-field-input,
+.task-field-row.has-error .task-field-textarea,
+.task-field-row.has-error .task-field-select {
+ border-color: var(--accent-danger, #e5484d);
+}
+
+/* Collapsible detail-section group */
+.task-fields-group,
+.task-fields-orphaned {
+ border-top: 1px solid var(--border-color, #2a2d34);
+ padding-top: 8px;
+}
+
+.task-fields-group-header,
+.task-fields-orphaned-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ padding: 4px 0;
+ background: none;
+ border: none;
+ color: var(--text-secondary, #8a8f98);
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+}
+
+.task-fields-group-body,
+.task-fields-orphaned-body {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-top: 8px;
+}
+
+.task-fields-orphaned-count {
+ margin-left: auto;
+ background: var(--chip-bg, #1c1f26);
+ border-radius: 999px;
+ padding: 0 8px;
+ font-size: 11px;
+}
+
+.task-field-orphaned-value {
+ font-size: 13px;
+ color: var(--text-secondary, #b4b8c0);
+ word-break: break-word;
+}
diff --git a/packages/dashboard/app/components/TaskFieldsSection.tsx b/packages/dashboard/app/components/TaskFieldsSection.tsx
new file mode 100644
index 0000000000..4343d3becb
--- /dev/null
+++ b/packages/dashboard/app/components/TaskFieldsSection.tsx
@@ -0,0 +1,412 @@
+/**
+ * Schema-driven custom-field form section (U13 / KTD-14).
+ *
+ * Renders a task's workflow-defined custom fields ({@link WorkflowFieldDefinition})
+ * as editable widgets, grouped by `render.placement`:
+ * - `detail` (and the default when unset) → inline, near the description.
+ * - `detail-section` → inside a collapsible group.
+ * Card-placed fields (`placement: "card"`) are intentionally NOT rendered here —
+ * those surface as badges on {@link TaskCard}.
+ *
+ * Widget selection (per `type` + optional `render.widget`):
+ * - enum → select (default) | radio | chips (single-select)
+ * - multi-enum → chips (multi-select)
+ * - boolean → toggle
+ * - date → date input
+ * - url/number → validated
+ * - string → text input
+ * - text → textarea
+ *
+ * Editing is per-field, save-on-commit (blur for inputs, change for
+ * toggles/selects/chips/radio). Each save calls `onSave({ [fieldId]: value })`;
+ * on a 400 the caller surfaces the typed rejection through `error`, which this
+ * component renders inline beneath the offending field.
+ *
+ * Orphaned values — keys in `customFields` with no matching definition — render
+ * read-only under a collapsed "Orphaned fields" disclosure (never destroyed,
+ * KTD-13).
+ *
+ * Zero field definitions AND zero orphaned values → the component renders
+ * nothing (null), so a task on a field-less workflow is byte-identical to
+ * today's UI (snapshot-guarded by the test suite).
+ */
+import { useCallback, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ChevronRight, ChevronDown } from "lucide-react";
+import type {
+ WorkflowFieldDefinition,
+ WorkflowFieldOption,
+ CustomFieldRejection,
+} from "../api";
+import "./TaskFieldsSection.css";
+
+export interface TaskFieldsSectionProps {
+ /** The task's workflow field definitions (from board-workflows payload). */
+ fieldDefs: WorkflowFieldDefinition[];
+ /** Current custom field values, keyed by field id. */
+ customFields: Record
;
+ /**
+ * Persist a single-field patch. Resolves on success; the caller is expected
+ * to throw / reject with the server's typed rejection so it can flow into
+ * `error`. May be omitted to render read-only (e.g. archived tasks).
+ */
+ onSave?: (patch: Record) => Promise;
+ /**
+ * The most recent typed rejection from a failed save (400), surfaced inline
+ * beneath the matching field. Cleared by the caller on a successful save.
+ */
+ error?: CustomFieldRejection | null;
+ /** When true, fields render read-only (no edit affordances). */
+ readOnly?: boolean;
+}
+
+function optionLabel(field: WorkflowFieldDefinition, value: string): string {
+ return field.options?.find((o) => o.value === value)?.label ?? value;
+}
+
+function optionColor(field: WorkflowFieldDefinition, value: string): string | undefined {
+ return field.options?.find((o) => o.value === value)?.color;
+}
+
+/** Resolve the effective widget for a field, applying the per-type default. */
+function resolveWidget(field: WorkflowFieldDefinition): NonNullable["widget"] {
+ const explicit = field.render?.widget;
+ if (explicit) return explicit;
+ switch (field.type) {
+ case "enum":
+ return "select";
+ case "multi-enum":
+ return "chips";
+ case "boolean":
+ return "toggle";
+ case "text":
+ return "textarea";
+ default:
+ return "input";
+ }
+}
+
+interface FieldRowProps {
+ field: WorkflowFieldDefinition;
+ value: unknown;
+ onSave?: (patch: Record) => Promise;
+ error?: CustomFieldRejection | null;
+ readOnly: boolean;
+}
+
+function FieldRow({ field, value, onSave, error, readOnly }: FieldRowProps) {
+ const { t } = useTranslation("app");
+ const widget = resolveWidget(field);
+ const fieldError = error && error.fieldId === field.id ? error : null;
+ const disabled = readOnly || !onSave;
+
+ const commit = useCallback(
+ (next: unknown) => {
+ if (!onSave) return;
+ void onSave({ [field.id]: next });
+ },
+ [onSave, field.id],
+ );
+
+ const labelId = `task-field-label-${field.id}`;
+ const controlId = `task-field-${field.id}`;
+
+ const renderControl = () => {
+ // enum → select / radio / chips (single)
+ if (field.type === "enum") {
+ const current = typeof value === "string" ? value : "";
+ if (widget === "radio") {
+ return (
+
+ {(field.options ?? []).map((opt: WorkflowFieldOption) => (
+
+ ))}
+
+ );
+ }
+ if (widget === "chips") {
+ return (
+
+ {(field.options ?? []).map((opt) => {
+ const active = current === opt.value;
+ return (
+
+ );
+ })}
+
+ );
+ }
+ // default: select
+ return (
+
+ );
+ }
+
+ // multi-enum → chips (multi-select)
+ if (field.type === "multi-enum") {
+ const current = Array.isArray(value) ? (value as string[]) : [];
+ return (
+
+ {(field.options ?? []).map((opt) => {
+ const active = current.includes(opt.value);
+ return (
+
+ );
+ })}
+
+ );
+ }
+
+ // boolean → toggle
+ if (field.type === "boolean") {
+ const checked = value === true;
+ return (
+
+ );
+ }
+
+ // date → date input
+ if (field.type === "date") {
+ const current = typeof value === "string" ? value.slice(0, 10) : "";
+ return (
+ {
+ const next = e.target.value;
+ if (next === current) return;
+ commit(next === "" ? null : next);
+ }}
+ />
+ );
+ }
+
+ // text → textarea
+ if (field.type === "text") {
+ const current = typeof value === "string" ? value : "";
+ return (
+