feat: implement definition-aware forms runtime

This commit is contained in:
2026-08-01 17:48:35 +02:00
parent 396d6b0c90
commit 9dc49fe27b
25 changed files with 4180 additions and 79 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/forms-runtime-webui",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/forms-runtime.css": "./src/styles/forms-runtime.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+171
View File
@@ -0,0 +1,171 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type InstitutionalReference = {
kind: string;
owner_module: string;
object_id: string;
tenant_id: string;
version?: string | null;
valid_at?: string | null;
label?: string | null;
};
export type EvidenceReference = {
kind: string;
owner_module: string;
evidence_id: string;
tenant_id: string;
version?: string | null;
};
export type FormFieldDefinition = {
key: string;
label: string;
value_type: "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list";
required: boolean;
help_text?: string | null;
options: string[];
constraints: Record<string, unknown>;
default_value?: unknown;
};
export type FormDefinition = {
reference: InstitutionalReference;
key: string;
temporal: { revision: string; recorded_at?: string | null };
title: string;
description?: string | null;
fields: FormFieldDefinition[];
publication_state: "draft" | "published" | "retired";
allow_drafts: boolean;
max_attachments: number;
signature_requirement: "none" | "optional" | "required";
policy_refs: string[];
handoff_kinds: string[];
};
export type ValidationResult = {
field?: string | null;
severity: "warning" | "error";
code: string;
message: string;
};
export type FormInstance = {
reference: InstitutionalReference;
tenant_id: string;
instance_id: string;
revision: number;
status: string;
definition_ref: InstitutionalReference;
values: Record<string, unknown>;
validation_results: ValidationResult[];
attachment_refs: EvidenceReference[];
signature_refs: EvidenceReference[];
handoff_refs: InstitutionalReference[];
service_ref?: InstitutionalReference | null;
receipt_id?: string | null;
recorded_at: string;
change_reason: string;
created_by: string;
changed_by: string;
replayed: boolean;
};
export type FormInstanceEvent = {
event_id: string;
event_type: string;
instance_revision: number;
status: string;
occurred_at: string;
actor_id: string;
payload: Record<string, unknown>;
};
export function listFormInstances(
settings: ApiSettings,
options: { statuses?: string[]; definitionId?: string; offset?: number; limit?: number } = {},
signal?: AbortSignal
): Promise<{ instances: FormInstance[]; total: number; offset: number; limit: number }> {
return apiFetch(settings, apiPath("/api/v1/forms-runtime/instances", {
status: options.statuses,
definition_id: options.definitionId,
offset: options.offset ?? 0,
limit: options.limit ?? 100
}), { signal });
}
export function getFormInstance(
settings: ApiSettings,
instanceId: string,
signal?: AbortSignal
): Promise<FormInstance> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}`, { signal });
}
export function getFormDefinition(
settings: ApiSettings,
instanceId: string,
signal?: AbortSignal
): Promise<FormDefinition> {
return apiFetch(
settings,
`/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/definition`,
{ signal }
);
}
export function getFormInstanceHistory(
settings: ApiSettings,
instanceId: string,
signal?: AbortSignal
): Promise<{ revisions: FormInstance[] }> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/history`, { signal });
}
export function getFormInstanceEvents(
settings: ApiSettings,
instanceId: string,
signal?: AbortSignal
): Promise<{ events: FormInstanceEvent[] }> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/events`, { signal });
}
export function saveFormDraft(
settings: ApiSettings,
instance: FormInstance,
values: Record<string, unknown>,
changeReason: string
): Promise<FormInstance> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}`, {
method: "PATCH",
body: JSON.stringify({
expected_revision: instance.revision,
values,
attachment_refs: instance.attachment_refs,
signature_refs: instance.signature_refs,
idempotency_key: crypto.randomUUID(),
recorded_at: new Date().toISOString(),
change_reason: changeReason
})
});
}
export function submitFormInstance(
settings: ApiSettings,
instance: FormInstance,
values: Record<string, unknown>
): Promise<FormInstance> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/submit`, {
method: "POST",
body: JSON.stringify({
expected_revision: instance.revision,
values,
attachment_refs: instance.attachment_refs,
signature_refs: instance.signature_refs,
idempotency_key: crypto.randomUUID(),
recorded_at: new Date().toISOString()
})
});
}
@@ -0,0 +1,384 @@
import { ArrowLeft, Save, Send } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import {
Button,
DismissibleAlert,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
ToggleSwitch,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
getFormDefinition,
getFormInstance,
getFormInstanceEvents,
getFormInstanceHistory,
saveFormDraft,
submitFormInstance,
type FormDefinition,
type FormFieldDefinition,
type FormInstance,
type FormInstanceEvent,
type ValidationResult
} from "../../api/formsRuntime";
export default function FormInstancePage({ settings }: PlatformRouteContext) {
const { instanceId = "" } = useParams();
const navigate = useGuardedNavigate();
const [instance, setInstance] = useState<FormInstance | null>(null);
const [definition, setDefinition] = useState<FormDefinition | null>(null);
const [history, setHistory] = useState<FormInstance[]>([]);
const [events, setEvents] = useState<FormInstanceEvent[]>([]);
const [values, setValues] = useState<Record<string, unknown>>({});
const [changeReason, setChangeReason] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError("");
try {
const nextInstance = await getFormInstance(settings, instanceId, signal);
const [nextDefinition, nextHistory, nextEvents] = await Promise.all([
getFormDefinition(settings, instanceId, signal),
getFormInstanceHistory(settings, instanceId, signal),
getFormInstanceEvents(settings, instanceId, signal)
]);
setInstance(nextInstance);
setDefinition(nextDefinition);
setHistory(nextHistory.revisions);
setEvents(nextEvents.events);
setValues(nextInstance.values);
setChangeReason("");
} finally {
setLoading(false);
}
}, [instanceId, settings]);
useEffect(() => {
const controller = new AbortController();
load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "The Form could not be loaded.");
}
});
return () => controller.abort();
}, [load]);
const editable = instance?.status === "started" || instance?.status === "draft";
const canSave = instance?.status === "draft" && definition?.allow_drafts;
const changed = useMemo(
() => Boolean(instance && JSON.stringify(values) !== JSON.stringify(instance.values)),
[instance, values]
);
const diagnostics = useMemo(() => {
const grouped = new Map<string, ValidationResult[]>();
for (const item of instance?.validation_results ?? []) {
const key = item.field ?? "";
grouped.set(key, [...(grouped.get(key) ?? []), item]);
}
return grouped;
}, [instance]);
async function save() {
if (!instance || !canSave || !changed || !changeReason.trim()) return;
setSaving(true);
setError("");
try {
await saveFormDraft(settings, instance, values, changeReason.trim());
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The draft could not be saved.");
} finally {
setSaving(false);
}
}
async function submit() {
if (!instance || !editable) return;
setSaving(true);
setError("");
try {
await submitFormInstance(settings, instance, values);
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
} finally {
setSaving(false);
}
}
return (
<main className="forms-runtime-page">
<div className="form-instance-shell">
<div className="form-instance-toolbar">
<Button onClick={() => navigate("/forms-runtime")}>
<ArrowLeft size={16} aria-hidden="true" />
Forms
</Button>
{definition && <strong>{definition.title}</strong>}
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={humanize(instance.status)} />}
</div>
<PageScrollViewport className="form-instance-viewport">
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading Form" />}
{!loading && instance && definition &&
<div className="form-instance-content">
<section className="form-instance-main">
<header>
<h1>{definition.title}</h1>
{definition.description && <p>{definition.description}</p>}
</header>
<div className="form-fields">
{definition.fields.map((field) =>
<FormField
key={field.key}
field={field}
value={values[field.key]}
disabled={!editable || saving}
diagnostics={diagnostics.get(field.key) ?? []}
onChange={(value) => setValues((current) => {
const next = { ...current };
if (value === undefined || value === "") delete next[field.key];
else next[field.key] = value;
return next;
})}
/>
)}
</div>
{(instance.attachment_refs.length > 0 || instance.signature_refs.length > 0) &&
<div className="form-evidence-summary">
<span>{instance.attachment_refs.length} attachments</span>
<span>{instance.signature_refs.length} signatures</span>
</div>
}
{editable &&
<div className="form-instance-actions">
{canSave &&
<label className="form-change-reason">
<span>Change reason</span>
<input
value={changeReason}
onChange={(event) => setChangeReason(event.target.value)}
disabled={saving}
/>
</label>
}
{canSave &&
<Button
onClick={() => void save()}
disabled={!changed || !changeReason.trim() || saving}>
<Save size={16} aria-hidden="true" />
Save draft
</Button>
}
<Button variant="primary" onClick={() => void submit()} disabled={saving}>
<Send size={16} aria-hidden="true" />
Submit
</Button>
</div>
}
{!editable && instance.receipt_id &&
<div className="form-receipt">
<span>Submission receipt</span>
<code>{instance.receipt_id}</code>
</div>
}
</section>
<aside className="form-instance-aside">
<section>
<h2>Status history</h2>
<ol>
{events.map((event) =>
<li key={event.event_id}>
<strong>{humanize(event.status)}</strong>
<span>{humanize(event.event_type)}</span>
<time>{formatDateTime(event.occurred_at)}</time>
</li>
)}
</ol>
</section>
<section>
<h2>Revisions</h2>
<ol>
{history.map((item) =>
<li key={item.revision}>
<strong>Revision {item.revision}</strong>
<span>{item.change_reason}</span>
<time>{formatDateTime(item.recorded_at)}</time>
</li>
)}
</ol>
</section>
</aside>
</div>
}
</PageScrollViewport>
</div>
</main>
);
}
function FormField({
field,
value,
disabled,
diagnostics,
onChange
}: {
field: FormFieldDefinition;
value: unknown;
disabled: boolean;
diagnostics: ValidationResult[];
onChange: (value: unknown) => void;
}) {
const describedBy = diagnostics.length > 0 ? `form-field-${field.key}-messages` : undefined;
if (field.value_type === "boolean") {
return (
<div className="form-field form-field-toggle">
<ToggleSwitch
label={`${field.label}${field.required ? " (required)" : ""}`}
checked={Boolean(value)}
onChange={onChange}
disabled={disabled}
help={field.help_text ?? undefined}
/>
<FieldMessages id={describedBy} diagnostics={diagnostics} />
</div>
);
}
return (
<label className="form-field">
<span>{field.label}{field.required && <b aria-hidden="true"> *</b>}</span>
{field.help_text && <small>{field.help_text}</small>}
{renderInput(field, value, disabled, describedBy, onChange)}
<FieldMessages id={describedBy} diagnostics={diagnostics} />
</label>
);
}
function renderInput(
field: FormFieldDefinition,
value: unknown,
disabled: boolean,
describedBy: string | undefined,
onChange: (value: unknown) => void
) {
const common = { disabled, required: field.required, "aria-describedby": describedBy };
if (field.value_type === "multiline_text" || field.value_type === "object" || field.value_type === "list") {
return (
<textarea
{...common}
rows={field.value_type === "multiline_text" ? 5 : 7}
value={structuredValue(value)}
onChange={(event) => onChange(field.value_type === "multiline_text" ? event.target.value : parseStructured(event.target.value))}
/>
);
}
if (field.value_type === "choice") {
return (
<select {...common} value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)}>
<option value="">Select</option>
{field.options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
);
}
if (field.value_type === "multi_choice") {
const selected = Array.isArray(value) ? value.map(String) : [];
return (
<select
{...common}
multiple
value={selected}
onChange={(event) => onChange(Array.from(event.target.selectedOptions, (option) => option.value))}>
{field.options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
);
}
const type = field.value_type === "integer" || field.value_type === "number"
? "number"
: field.value_type === "email"
? "email"
: field.value_type === "date"
? "date"
: field.value_type === "datetime"
? "datetime-local"
: "text";
return (
<input
{...common}
type={type}
step={field.value_type === "integer" ? 1 : field.value_type === "number" ? "any" : undefined}
min={numericConstraint(field.constraints.minimum)}
max={numericConstraint(field.constraints.maximum)}
minLength={numericConstraint(field.constraints.min_length)}
maxLength={numericConstraint(field.constraints.max_length)}
value={inputValue(field, value)}
onChange={(event) => onChange(inputChangeValue(field, event.target.value))}
/>
);
}
function FieldMessages({ id, diagnostics }: { id?: string; diagnostics: ValidationResult[] }) {
if (diagnostics.length === 0) return null;
return (
<span id={id} className="form-field-messages" aria-live="polite">
{diagnostics.map((item) => <small key={item.code} data-severity={item.severity}>{item.message}</small>)}
</span>
);
}
function inputValue(field: FormFieldDefinition, value: unknown): string | number {
if (value === undefined || value === null) return "";
if (field.value_type === "datetime" && typeof value === "string") {
const parsed = new Date(value);
if (!Number.isNaN(parsed.valueOf())) {
const local = new Date(parsed.getTime() - parsed.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
}
return typeof value === "number" || typeof value === "string" ? value : String(value);
}
function inputChangeValue(field: FormFieldDefinition, value: string): unknown {
if (!value) return undefined;
if (field.value_type === "integer") return Number.parseInt(value, 10);
if (field.value_type === "number") return Number(value);
if (field.value_type === "datetime") return new Date(value).toISOString();
return value;
}
function structuredValue(value: unknown): string {
if (value === undefined || value === null) return "";
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
function parseStructured(value: string): unknown {
if (!value.trim()) return undefined;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function numericConstraint(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
@@ -0,0 +1,118 @@
import { RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
Button,
DismissibleAlert,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [items, setItems] = useState<FormInstance[]>([]);
const [total, setTotal] = useState(0);
const [status, setStatus] = useState("open");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
setError("");
return listFormInstances(settings, {
statuses: status === "open" ? OPEN_STATUSES : status ? [status] : undefined,
limit: 200
}, signal).
then((result) => {
setItems(result.instances);
setTotal(result.total);
}).
finally(() => setLoading(false));
}, [settings, status]);
useEffect(() => {
const controller = new AbortController();
load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Forms could not be loaded.");
}
});
return () => controller.abort();
}, [load]);
return (
<main className="forms-runtime-page">
<div className="forms-runtime-shell">
<div className="forms-runtime-toolbar">
<Button onClick={() => void load()} disabled={loading}>
<RefreshCw size={16} aria-hidden="true" />
Refresh
</Button>
<label>
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="open">Open</option>
<option value="">All</option>
<option value="draft">Draft</option>
<option value="submitted">Submitted</option>
<option value="needs_review">Needs review</option>
<option value="accepted">Accepted</option>
<option value="rejected">Rejected</option>
<option value="handed_off">Handed off</option>
<option value="archived">Archived</option>
</select>
</label>
<span className="forms-runtime-count">{total} forms</span>
</div>
<PageScrollViewport className="forms-runtime-list-viewport">
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading forms" />}
{!loading && !error && items.length === 0 &&
<div className="forms-runtime-empty">No matching Forms.</div>
}
{!loading && items.length > 0 &&
<div className="forms-runtime-list" role="list">
{items.map((item) =>
<button
type="button"
role="listitem"
className="forms-runtime-row"
key={item.instance_id}
onClick={() => navigate(`/forms-runtime/${encodeURIComponent(item.instance_id)}`)}>
<span className="forms-runtime-row-main">
<strong>{item.definition_ref.label ?? humanize(item.definition_ref.object_id)}</strong>
<span>Revision {item.definition_ref.version ?? "-"}</span>
</span>
<span>{formatDateTime(item.recorded_at)}</span>
<StatusBadge status={isOpen(item.status) ? "active" : "inactive"} label={humanize(item.status)} />
</button>
)}
</div>
}
</PageScrollViewport>
</div>
</main>
);
}
function isOpen(status: string): boolean {
return OPEN_STATUSES.includes(status);
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+2
View File
@@ -0,0 +1,2 @@
export { default, formsRuntimeModule } from "./module";
export * from "./api/formsRuntime";
+52
View File
@@ -0,0 +1,52 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/forms-runtime.css";
const FormsRuntimePage = lazy(() => import("./features/forms/FormsRuntimePage"));
const FormInstancePage = lazy(() => import("./features/forms/FormInstancePage"));
const routeScopes = [
"forms_runtime:submission:participate",
"forms_runtime:workspace:read"
];
export const formsRuntimeModule: PlatformWebModule = {
id: "forms_runtime",
label: "Forms",
version: "0.1.14",
dependencies: ["access", "forms"],
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "policy", "audit"],
routes: [
{
path: "/forms-runtime",
anyOf: routeScopes,
order: 37,
surfaceId: "forms_runtime.workspace",
render: (context) => createElement(FormsRuntimePage, context)
},
{
path: "/forms-runtime/:instanceId",
anyOf: routeScopes,
order: 38,
surfaceId: "forms_runtime.instance",
render: (context) => createElement(FormInstancePage, context)
}
],
navItems: [
{
to: "/forms-runtime",
label: "Forms",
iconName: "form",
anyOf: routeScopes,
order: 37,
surfaceId: "forms_runtime.navigation"
}
],
viewSurfaces: [
{ id: "forms_runtime.navigation", moduleId: "forms_runtime", kind: "navigation", label: "Forms navigation", order: 10 },
{ id: "forms_runtime.workspace", moduleId: "forms_runtime", kind: "route", label: "Forms workspace", order: 20 },
{ id: "forms_runtime.instance", moduleId: "forms_runtime", kind: "route", label: "Form instance", order: 30 }
]
};
export default formsRuntimeModule;
+299
View File
@@ -0,0 +1,299 @@
.forms-runtime-page,
.forms-runtime-shell,
.form-instance-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.forms-runtime-shell,
.form-instance-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.forms-runtime-toolbar,
.form-instance-toolbar {
display: flex;
align-items: center;
gap: 14px;
min-height: 58px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.forms-runtime-toolbar label {
display: flex;
align-items: center;
gap: 7px;
}
.forms-runtime-toolbar label > span,
.forms-runtime-count {
color: var(--text-soft);
font-size: 0.82rem;
}
.forms-runtime-count,
.form-instance-toolbar .status-badge {
margin-left: auto;
}
.forms-runtime-list-viewport,
.form-instance-viewport {
flex: 1;
min-height: 0;
padding: 16px 18px 24px;
}
.forms-runtime-list {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.forms-runtime-row {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(170px, auto) auto;
align-items: center;
gap: 16px;
width: 100%;
min-height: 64px;
padding: 10px 14px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.forms-runtime-row:last-child {
border-bottom: 0;
}
.forms-runtime-row:hover,
.forms-runtime-row:focus-visible {
background: var(--hover-bg);
}
.forms-runtime-row-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.forms-runtime-row-main strong,
.forms-runtime-row-main span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.forms-runtime-row-main span,
.forms-runtime-row > span:not(.status-badge) {
color: var(--text-soft);
font-size: 0.84rem;
}
.forms-runtime-empty {
padding: 36px 0;
color: var(--text-soft);
text-align: center;
}
.form-instance-content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 28px;
max-width: 1280px;
margin: 0 auto;
}
.form-instance-main,
.form-instance-aside {
min-width: 0;
}
.form-instance-main > header {
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.form-instance-main h1 {
margin: 0;
font-size: 1.45rem;
letter-spacing: 0;
}
.form-instance-main > header p {
max-width: 70ch;
margin: 7px 0 0;
color: var(--text-soft);
}
.form-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 18px;
padding: 20px 0;
}
.form-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.form-field:has(textarea),
.form-field:has(select[multiple]) {
grid-column: 1 / -1;
}
.form-field > span:first-child {
font-size: 0.84rem;
font-weight: 600;
}
.form-field > span b {
color: var(--danger);
}
.form-field > small {
color: var(--text-soft);
font-size: 0.78rem;
}
.form-field textarea {
resize: vertical;
}
.form-field select[multiple] {
min-height: 110px;
}
.form-field-toggle {
justify-content: flex-end;
min-height: 58px;
}
.form-field-messages {
display: flex;
flex-direction: column;
gap: 3px;
}
.form-field-messages [data-severity="error"] {
color: var(--danger);
}
.form-field-messages [data-severity="warning"] {
color: var(--warning);
}
.form-evidence-summary,
.form-receipt {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
padding: 12px 0;
border-top: 1px solid var(--border);
color: var(--text-soft);
font-size: 0.84rem;
}
.form-receipt {
flex-direction: column;
}
.form-receipt code {
overflow-wrap: anywhere;
color: var(--text);
}
.form-instance-actions {
display: flex;
align-items: end;
justify-content: flex-end;
gap: 10px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.form-change-reason {
display: flex;
min-width: min(360px, 100%);
flex: 1;
flex-direction: column;
gap: 5px;
}
.form-change-reason span {
color: var(--text-soft);
font-size: 0.8rem;
}
.form-instance-aside section + section {
margin-top: 24px;
}
.form-instance-aside h2 {
margin: 0 0 8px;
font-size: 0.95rem;
letter-spacing: 0;
}
.form-instance-aside ol {
margin: 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--border);
}
.form-instance-aside li {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 0 10px 12px;
border-bottom: 1px solid var(--border);
border-left: 2px solid var(--accent);
}
.form-instance-aside li span,
.form-instance-aside li time {
color: var(--text-soft);
font-size: 0.78rem;
}
@media (max-width: 860px) {
.forms-runtime-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.forms-runtime-row > span:not(.forms-runtime-row-main, .status-badge) {
display: none;
}
.form-instance-content {
grid-template-columns: 1fr;
}
.form-fields {
grid-template-columns: 1fr;
}
.form-field:has(textarea),
.form-field:has(select[multiple]) {
grid-column: auto;
}
.form-instance-actions {
align-items: stretch;
flex-direction: column;
}
}