Verified against Semitexa Ultimate 2026.09.19.1020
UI Forms
Form composition (platform.form)
A minimal composition container for grouping fields and surfacing a client-local aggregate of their server-validated state. Not a form engine — no real submit, no persistence, no CSRF, no server-side form-state store, no cross-field rules, no async validation. Field rules continue to run server-side through FieldComponent's existing pipeline; the form layer never re-evaluates rules.
{% set _fields %}
{{ component('platform.field', {
label: 'Username',
name: 'username',
showValidationTarget: true,
rules: ['required', ['minLength', 3], ['maxLength', 20]],
}) }}
{{ component('platform.field', {
label: 'Display name',
name: 'display_name',
showValidationTarget: true,
rules: [['maxLength', 12]],
}) }}
{% endset %}
{{ component('platform.form', {
title: 'Sign-up details',
description: 'Validation runs server-side; the aggregate is client-local.',
showStatus: true,
showSubmit: true,
submitText: 'Create account',
}, {
content: _fields,
}) }}
Component props (all optional):
| Prop | Type | Default | Notes |
|---|---|---|---|
title |
string | null |
Rendered as <h2 ui-text="title"> when set. |
description |
string | null |
Muted paragraph beneath the title. |
showStatus |
bool | true |
Renders the data-ui-patch-target="form-status" target. |
statusInitialMessage |
string | 'No fields validated yet.' |
Text shown before any field has validated. |
showSubmit |
bool | false |
Renders a platform.button shell — visual only, no submit pipeline. |
submitText |
string | 'Submit' |
Button label. |
ariaLabel |
string | null |
Accessible name when the visual title is absent. |
Slot: content — caller-provided markup, typically one or more FieldComponents. Passed as the third argument of component('platform.form', props, { content: … }).
Rendered shape:
<div data-ui-component="platform.form"
data-ui-component-instance-id="uci_<16hex>"
data-ui-form-aggregate="1"
ui-component="form"
sx-layout="stack" sx-gap="4"
role="group">
<h2 ui-text="title">Sign-up details</h2>
<p ui-text="muted">Validation runs server-side…</p>
<div data-ui-form-fields sx-layout="stack" sx-gap="3">
<!-- caller's content slot — usually one or more FieldComponents -->
</div>
<div data-ui-patch-target="form-status" aria-live="polite" role="status" ui-text="muted">
No fields validated yet.
</div>
<!-- optional visual submit -->
</div>
Field-name marker. FieldComponent now stamps data-ui-field-name="<name>" on its root only when the name prop matches the safe identifier shape [A-Za-z_][A-Za-z0-9_-]* — the same pattern the patch validator accepts for target names. Anything else is dropped silently. Anonymous fields fall back to the field's data-ui-component-instance-id as the aggregate key so they still aggregate distinctly.
Aggregation runtime (in event-runtime.js):
After every successful dispatch response, the transport bridge calls updateFormAggregate(response, captured):
- Reads
response.debug.validation.{state,message}— the shapeFieldComponent::onInputChanged()already returns. No new wire shape. - Resolves the enclosing form root by walking up the DOM from the field root, looking for the nearest ancestor matching
[data-ui-form-aggregate="1"][data-ui-component-instance-id]. If no form root is found, aggregation is a graceful no-op. - Updates a module-local map
{ formInstance → { fields: { fieldKey → {state, message} }, lastAt } }. The key isdata-ui-field-namewhen set, the field's instance id otherwise. Repeated updates for the same field overwrite — never duplicate — the entry. - Computes a summary:
{knownCount, invalidCount, validCount, aggregateState, message}.aggregateStateis'invalid'if any known field is invalid,'valid'if all known fields are valid,'pending'before the first field validates. - Synthesises two patches targeting the form root:
{op: 'setText', target: {instance: <form>, name: 'form-status'}, value: <message>}{op: 'setAttribute', target: {instance: <form>}, attribute: 'ui-state', value: <aggregateState>}
- Feeds them through the existing
applyOnePatch— the same safe applier the dispatch transport and the SSE bridge use. No new mutation engine, no new patch op, no expansion of the attribute allow-list. - Dispatches a
semitexa:ui-form:aggregateCustomEvent ondocumentso consumers can mirror the snapshot without re-deriving it.
Public API (kept narrow):
window.SemitexaUi.forms.snapshot(formInstance?)— returns{formInstance, fields, summary}for one form, or a map keyed by form instance when called without an argument.nullfor an unknown id.window.SemitexaUi.forms.reset(formInstance?)— drops one form's aggregate, or all of them.
Aggregation status messages (verbatim contract, pinned by tests):
| Aggregate state | Message |
|---|---|
| no fields known | No fields validated yet. |
| 1 invalid field | 1 field needs attention. |
| N invalid fields | <N> fields need attention. |
| 1 valid field, 0 invalid | 1 field validated — looks good. |
| N valid fields, 0 invalid | All <N> validated fields look good. |
What this slice does NOT introduce:
- No real form submit, no persistence, no session-backed form state, no server-side form state store.
- No CSRF / submit pipeline / file uploads / multi-step navigation.
- No cross-field rules, no field dependency rules, no schema validation.
- No client-side rule mirror.
UiFieldValidatorstays server-only. - No async validation, no WebSockets, no new SSE semantics.
- No HTML patches, no
innerHTML, noeval, no arbitrary selectors. Same allow-list as before. - No new
UiResponsePatchop. Aggregation reusessetText+setAttribute. - No
disabledattribute mutation —disabledremains off the patch allow-list on purpose. The submit button is visual only in this slice. - No persistent server-side form snapshot. State is per-page-load, per-tab. A reload resets the aggregate; broadcasting (e.g. via SSE) is future work.
Form submit pipeline (authoritative final validation)
FormComponent now declares #[UiOn(part: 'form', event: 'submit')] so the dispatcher routes a verified submit ctx to FormComponent::onSubmit. Submit is the authoritative counterpart to the input-change cross-field path: it revalidates every signed field rule against the submitted snapshot before returning a result.
Caller API:
{{ component('platform.form', {
title: 'Submit validation demo',
showStatus: true,
showSubmit: true,
submitText: 'Validate form',
fields: [
{
name: 'access_code',
label: 'Access code',
required: true,
rules: ['required', ['minLength', 4]],
},
{
name: 'confirm_access_code',
label: 'Confirm access code',
required: true,
rules: [
'required',
['sameAsField', 'access_code', 'Codes must match.'],
],
},
],
}, { content: _fields }) }}
The fields prop is the server-owned definition of which fields participate in submit, with what labels and which rules. It is normalised through UiFormSubmitConfigParser at render time (delegating to the active UiFieldRuleParser) and signed into cfg.f of the submit ctx.
autoFields — derive cfg.f from slotted FieldComponents
FormComponent also accepts autoFields: true, which derives cfg.f directly from the FieldComponents rendered inside the form's content slot — no manual fields prop required. Mechanism:
- The form template opens a render-scope collector frame (
UiFormSubmitDefinitionCollector::open()) before rendering the content slot. - The slot output is captured into a Twig variable, so every slotted FieldComponent runs while the frame is open.
- Each FieldComponent's template calls
ui_form_field_register({ n, i, r, l, q })after computing its own instance id and normalised rule wire. Safe-named fields (matching[A-Za-z_][A-Za-z0-9_-]*) register into the active frame; unsafe / anonymous names render normally but do not register. - After the slot has rendered,
ui_form_resolve_submit_fields(frameToken, autoFields, manualFields)closes the frame, returns the collected wire shape, and the form template signs it intocfg.fas usual.
The collector is a process-local stack of frames keyed by an opaque token. Nested forms push their own frame; outer fields stay isolated from inner ones. The token returned from open() must match the one passed to close() — a mismatch fails loud so a template bug surfaces rather than silently leaking definitions across renders. reset() is the explicit recovery hook for tests and worker boundaries.
Caller-side DX:
{% set _fields %}
{{ component('platform.field', {
name: 'access_code',
label: 'Access code',
required: true,
showValidationTarget: true,
rules: ['required', ['minLength', 4]],
}) }}
{{ component('platform.field', {
name: 'confirm_access_code',
label: 'Confirm access code',
required: true,
showValidationTarget: true,
rules: ['required', ['sameAsField', 'access_code', 'Codes must match.']],
}) }}
{% endset %}
{{ component('platform.form', {
showStatus: true,
showSubmit: true,
submitText: 'Validate form',
autoFields: true,
}, { content: _fields }) }}
Field instance ids in cfg.f.i are the same uci_… ids emitted on each FieldComponent's data-ui-component-instance-id — no caller-side bookkeeping. Per-field submit projection works automatically.
autoFields behaviour matrix:
autoFields |
fields prop |
Result |
|---|---|---|
true |
not provided or empty | cfg.f derived from collected FieldComponents |
true |
non-empty | throws UiComponentRegistryException at render time (ambiguous; pick one) |
false (default) |
non-empty | cfg.f parsed from manual fields (legacy path) |
false (default) |
not provided or empty | no cfg.f signed; submit handler returns no_signed_fields |
The collector frame opens unconditionally (so a no-op happens whether or not autoFields is on) and always closes — manual-mode renders simply collect an empty list and discard it.
submitAction — typed submit-action seam
FormComponent accepts an optional submitAction prop naming a server-registered action. When the form's authoritative validation passes, the form template-time signed action name (cfg.a) is resolved through UiFormSubmitActionRegistryInterface, the action is invoked with a typed UiFormSubmitActionContext, and its UiFormSubmitActionResult becomes the final form-status + ui-state.
{{ component('platform.form', {
autoFields: true,
showSubmit: true,
submitText: 'Validate form',
submitAction: 'platform.demo.accept',
}, { content: _fields }) }}
Action contract (Semitexa\PlatformUi\Application\Service\Submit\UiFormSubmitActionInterface):
interface UiFormSubmitActionInterface
{
public function name(): string;
public function handle(UiFormSubmitActionContext $context): UiFormSubmitActionResult;
}
Action context — read-only value object carrying:
formInstanceId(the form's render-time uci_ id);actionName(the resolved registry name);dispatchId(correlation only — dispatcher already consumed it for replay protection);values(sanitisedpayload.form.valuessnapshot — still client-controlled, treat as untrusted);fields(signedUiFormSubmitFieldDefinitionlist);submitResult(authoritative validation summary — alwaysvalidby the timehandle()is called).
The context never includes the raw SignedContext token, the Request object, container services, or secrets.
Action result — {accepted: bool, message: string, debug?: array, extraPatches?: list<UiResponsePatch>}. Two factories:
UiFormSubmitActionResult::accepted($message)→accepted=true;UiFormSubmitActionResult::rejected($message)→accepted=false.
The message becomes setText form-status; accepted maps to setAttribute ui-state = valid|invalid. Extra patches are validated through the same UiPatchValidator the rest of the pipeline uses — actions cannot target unsigned instances or use unallow-listed ops.
Registry contract (UiFormSubmitActionRegistryInterface):
interface UiFormSubmitActionRegistryInterface
{
public function resolve(string $actionName): UiFormSubmitActionInterface;
public function knownActionNames(): array;
}
Apps register their own implementation with #[SatisfiesServiceContract(of: UiFormSubmitActionRegistryInterface::class)] in a module that "extends" semitexa-platform-ui. Compose with DefaultUiFormSubmitActionRegistry to inherit platform.demo.accept:
#[SatisfiesServiceContract(of: UiFormSubmitActionRegistryInterface::class)]
final class AppFormSubmitActionRegistry implements UiFormSubmitActionRegistryInterface
{
public function __construct(private DefaultUiFormSubmitActionRegistry $builtins = new DefaultUiFormSubmitActionRegistry()) {}
public function resolve(string $actionName): UiFormSubmitActionInterface
{
return match ($actionName) {
'app.signup.preview' => new SignupPreviewAction(),
default => $this->builtins->resolve($actionName),
};
}
public function knownActionNames(): array
{
return [...$this->builtins->knownActionNames(), 'app.signup.preview'];
}
}
The registry MUST resolve through a fixed match — NEVER new $name(...) or class_exists($name). Custom registries MUST honour the same perimeter.
Behaviour matrix:
| Validation | submitAction |
Result |
|---|---|---|
| valid | absent | existing accepted summary ("Form is valid. Submit accepted."); no debug.action key |
| valid | registered name | action invoked; form-status ← action message; ui-state ← valid if accepted, invalid if rejected; debug.action carries {name, accepted, message} |
| invalid | absent | existing invalid summary |
| invalid | registered name | action not invoked; existing invalid summary; debug.action ← {name, invoked: false, reason: 'validation_invalid'} |
| any | unknown name | render-time UiFormSubmitActionException (the form fails to render in dev) |
| any | unsafe shape | render-time UiFormSubmitActionException |
| any | tampered cfg.a |
dispatcher returns 403 invalid_signed_ctx (HMAC fails) |
| any | client payload smuggling | 400 forbidden_payload_field (UiPayloadFieldGuard) |
payload.action, payload.submitAction, payload.form.submitAction and case-variant siblings (submit_action, submit-action) are all forbidden in the request body.
Built-in actions:
platform.demo.accept(PlatformDemoAcceptAction) — inert. ReturnsUiFormSubmitActionResult::accepted('Demo action accepted. No data was persisted.')with safe debug counts only. Does not persist, send email, redirect, or echo raw values.platform.demo.storeContact(PlatformDemoStoreContactAction) — first persistent demo. Allow-listscontact_name/contact_message/contact_topicfrom the sanitised snapshot, trims them, drops empties, and saves aUiFormDemoSubmissionRecordthroughUiFormDemoSubmissionRepositoryInterface(cache-backed, 24h TTL). Returns'Demo submission saved. No external side effects were performed.'withdebug.action.detail = {stored, submissionId, storedFieldCount}(never raw values).platform.demo.storeContactDb(PlatformDemoStoreContactDbAction) — first database-backed demo. Identical sanitisation contract as the cache-backed sibling, but persists throughUiFormDatabaseDemoSubmissionRepositoryInterface— production-bound toUiFormDemoSubmissionDbRepository(ORM-backed,#[SatisfiesRepositoryContract], tableplatform_ui_demo_submissions). Returns'Demo submission saved to the database. No external side effects were performed.'withdebug.action.detail = {stored, submissionId, storage: "database", storedFieldCount}. Durable until you delete the row — still demo storage, not a real CRM.
First project-side business action (lives outside this package, in the UiPlayground module):
-
ui-playground.lead.store(Semitexa\Modules\UiPlayground\Application\Service\Submit\Action\UiPlaygroundStoreLeadAction) — first project-owned business action. Proves the seven-gate pipeline is extensible from the project side without touching the package. Allow-listslead_name/lead_company/lead_message, trims, drops empties + non-scalars, generates auilead_<16hex>id, and saves aUiPlaygroundLeadSubmissionRecordthrough the project-sideUiPlaygroundLeadSubmissionRepositoryInterface(ORM-backed, tableui_playground_leads). Returns'Lead request saved. No email or external side effects were performed.'withdebug.action.detail = {stored, leadId, storage: "database", storedFieldCount}(never raw values). No email, no redirect, no external API, no export, no edit/delete, no async — those are explicit future slices.Read-only admin listing at
GET /ui-playground/admin/leads. Mirrors the package-side demo-submissions diagnostic listing shape: project-sideLeadAdminPayload/LeadAdminHandler/LeadAdminResponse+lead-admin.html.twig. Reads throughUiPlaygroundLeadSubmissionRepositoryInterface(DEFAULT_RECENT_LIMIT = 25,MAX_RECENT_LIMIT = 100, newest-first viaORDER BY submitted_at DESC, id DESC). View-model is{id, actionName, formInstanceId, submittedAt, leadName, leadCompany, leadMessagePreview (≤160+ellipsis), storedFieldCount}—values_jsonnever reaches the template; tokens / ctx / dispatchId / debug never reach the page. No search, no edit, no delete, no export — explicit non-goals.Cursor pagination + search/filter. Same keyset-pagination + filter-fingerprint pattern as the package-side demo listing, project-namespaced to avoid coupling to demo-submission naming:
Query param Default Behaviour limit25(DEFAULT_RECENT_LIMIT)Clamped server-side to [1, MAX_RECENT_LIMIT](100). Non-numeric / empty / negative / whitespace falls back to the default.0clamps up to1.cursor(absent) Opaque base64url token returned by the previous page's nextCursor. Empty / missing → first page. Malformed → HTTP 400invalid_cursorwith the safe template state and no repository read. Cursor binds to the filter combination it was issued under.q(absent) Bounded diagnostic-grade search term (max 100UTF-8 characters; longer → HTTP 400invalid_search_query). Trimmed; empty / whitespace → treated as absent. Case-insensitive substring match against the allow-listed lead fields (lead_name,lead_company,lead_message). Not a full-text engine.action(absent) Optional allow-listed action filter. The only accepted value today is ui-playground.lead.store— the listing surfaces project-side rows only. Any other value (includingplatform.demo.*) → HTTP 400invalid_action_filterand the bad value is never echoed back.Cursor shape:
UiPlaygroundLeadSubmissionCursoris a project-side counterpart of the demo-sideUiFormDemoSubmissionCursor. Wire format:base64url(JSON {"s": <int>, "i": "<id>" [, "f": "<16hex>"]})where theidregex enforcesuilead_[a-f0-9]{16}— distinct from the demo cursor'suifs_…shape, so the two listings cannot trade cursors (pinned by a unit test). The optionalfkey is the filter fingerprint, present only when the cursor was minted under a filtered listing. Tight key whitelist (only{s,i}or{s,i,f}), strictjson_decode(..., JSON_THROW_ON_ERROR), type-checked, noserialize/unserialize/eval. Any deviation throwsUiPlaygroundLeadSubmissionCursorException(reasoninvalid_cursor) with a fixed safe message that never echoes the bad input back.Handler gate ordering (security-significant):
- Authorization runs FIRST. A denied caller never sees a 400 for malformed q/action/cursor (no decode oracle on the deny path), and the repository is never read.
- Search criteria (
q+action+limit) are parsed and validated next. Oversizeq→ 400invalid_search_query. Unknownaction→ 400invalid_action_filter. No raw bad input is echoed back; the form re-renders empty. - Cursor decode runs only after criteria validation passes. Malformed cursor → 400
invalid_cursor. Still no repository read. - Cursor / filter binding: the cursor's optional
filterFingerprintMUST equal the criteria'sfingerprint(). Mismatch → 400invalid_cursor. A cursor issued under a filtered listing is not reusable as an unfiltered cursor and vice-versa. - Repository read runs only after every gate passes —
paginate()when the criteria is unfiltered,searchPage()otherwise.
Search semantics & SQL safety. The ORM impl binds the search term via a parameterised LIKE against the serialised
values_jsoncolumn:WHERE values_json LIKE ? ESCAPE '\\'. User-supplied%/_/\are escaped inescapeLike()before binding so a literal%in the search term cannot turn into a wildcard. The escape character\is escaped FIRST so a trailing\cannot escape the closing%of the bound pattern. Theaction_namefilter binds via the typedwhere(Operator::Equals)helper, not raw SQL. The search term is NEVER concatenated into SQL — it travels as a?placeholder value.Filter fingerprint binding (cursor v2): the
fkey is the first 16 hex characters ofsha256(query|action)over the canonical case-folded form. Acceptance:- v1 cursor (no
f) decodes asfilterFingerprint = null; accepted only for unfiltered requests. - v2 cursor with
f = Xaccepted only when the active criteria'sfingerprint()equalsX. - Any other combination → HTTP 400
invalid_cursor. Makes splicing a cursor from one filter onto another impossible without inverting sha256.
Repository contract:
paginate(?cursor, $limit)andsearchPage(criteria, ?cursor)both fetchlimit + 1rows for cheaphasMoredetection, trim, and buildnextCursorfrom the LAST returned record when more rows remain. The next-cursor inherits the active criteria's fingerprint (ornullwhen unfiltered).recent($limit)ispaginate(null, $limit)->records— same semantics, simpler surface for ergonomic callers.Template Next-page link: rendered only when
paginationHasMore && paginationNextCursor !== null. Preserves the activeq/action/limitalongside the encoded cursor (URL-encoded by Twig'surl_encode). The 400 states render "← Back to the first page" links. No JavaScript, no AJAX, no infinite scroll, no "Previous" link in this slice.Access control for the lead listing is its own project-side seam:
UiPlaygroundLeadAdminAuthorizerInterface, defaultAllowAllUiPlaygroundLeadAdminAuthorizer(#[SatisfiesServiceContract]), opt-inConfigurableUiPlaygroundLeadAdminAuthorizergated by the env flagUIPLAYGROUND_LEAD_ADMIN_ENABLED(truthy =1/true/yes/on/enabled, case-insensitive after trim; anything else denies withreason: lead_admin_disabled). Worker-scoped static holderUiPlaygroundLeadAdminAuthorizer::{getActive, setActive, reset}, seeded by the project-sideBootUiPlaygroundRegistryListenerfrom the container-bound winner. Denial → HTTP 403 + safe template state (reason+ message; no row data). The denial message NEVER echoes the bad env value, the flag name, or any class FQCN.Registry wiring: a project-side
UiPlaygroundFormSubmitActionRegistryimplementsUiFormSubmitActionRegistryInterfaceand is discovered as the active winner via#[SatisfiesServiceContract(of: UiFormSubmitActionRegistryInterface::class)]. It resolvesui-playground.lead.storeto its own action and delegates every other name toDefaultUiFormSubmitActionRegistry— so every existingplatform.demo.*action keeps working unchanged. Constructor caveat: the container instantiates#[SatisfiesServiceContract]winners vianewInstanceWithoutConstructor(), so a composite registry's__constructis NOT invoked at container build. Any composed default-registry instance MUST be lazy-initialised (e.g.private ?DefaultUiFormSubmitActionRegistry $builtins = null;plus a$this->builtins ??= new ...accessor) — initialising the property from__constructleaves it uninitialised at runtime and the firstresolve()call throws a typed-property fatal.Boot listener: a project-side
BootUiPlaygroundRegistryListener(AsServerLifecycleListenerphaseWorkerStartAfterContainer, priority0so it fires AFTER the package's-5) stashes the container-boundUiPlaygroundLeadSubmissionRepositoryInterfacewinner in the project's worker-scoped static holder. The composite registry pulls the active repo from that holder lazily at action-resolve time, so the action class itself stays free of container access.Superseded (One Way Phase 6). Everything in this
platform.gridsection describes the v1 grid apparatus —GridComponent,grid.html.twig,grid-runtime.js,UiGridDataResponse,GridRuntimeStaticAssertTest— which was DELETED in the One Way Phase 6 sweep. The replacement is the contract-drivenplatform.grid-v2shell (resources/twig/components/runtime/grid-v2.html.twig) +grid-runtime-v2.js: grids boot from the route's OPTIONS contract and render the canonical{data, meta}collection envelope (pull or SSE). The description below is retained as historical design context only.platform.grid— reusable interactive grid shell. A minimal package-level component. Two consumers now drive it through identical client-side code: the lead admin listing (/ui-playground/admin/leads) and the demo-submissions diagnostic listing (/ui-playground/admin/demo-submissions). Each consumer owns its own data endpoint, criteria, cursor, authorizer, and (for leads) SSE topic + publisher — the grid component owns only the shell + the runtime contract.-
Component:
Semitexa\PlatformUi\Application\Component\Builtin\GridComponent(#[AsComponent(name: 'platform.grid', template: '@platform-ui/components/runtime/grid.html.twig', cacheable: true)]). Slots:warning,filters,filterState,footer— all caller-owned. The component owns ONLY the grid shell (root + data-* attrs + hidden refresh marker + table headers fromcolumns+ initial-rows fallback tbody + Next-link with fallback href + inline JSON bundle). It deliberately does NOT know query semantics, authorization, repository, cursor internals, or SSE topic internals. -
Caller props:
gridId(required),instanceId(optional, falls back toui_component_instance()),dataUrl(required),sseUrl(optional),refreshMarker(defaults togrid-refresh-marker; callers set this to whatever name their server-side publisher targets — the lead listing useslead-grid-refresh-marker),columns(list of{key, label, style?, sortAsc?, sortDesc?}— the runtime renders rows in this exact order, ignoring extra keys;sortAsc+sortDescopt a column into the sortable-header UI),initialRows+initialPagination(server-rendered fallback),initialQuery+initialAction+initialSort+sortParam+pageFallbackUrl(no-JS Next-link + sort-toggle href composition;sortParamdefaults tosortand matches the caller's data-endpoint contract),emptyMessage(empty-state copy). -
Sortable column headers (lead grid + demo-submissions grid, both at parity). When a column map carries BOTH
sortAsc+sortDescallow-listed tokens, the template renders the header label as<a data-ui-grid-sort data-ui-grid-sort-asc="..." data-ui-grid-sort-desc="...">with anaria-sort="ascending|descending|none"on the surrounding<th>and a small toggle indicator (▲/▼/↕). The togglehrefis composed server-side frompageFallbackUrl+ active filter state + the other direction's token (so the no-JS path works without JS). The runtime intercepts clicks, flips betweensortAscandsortDescbased on the currentstate.sort, clears the cursor, mirrors the new sort into the caller-owned hidden<input name="sort">inside the filter form, updates the aria-sort + toggle-href + indicator glyph immediately, and reloads. The tokens are SERVER-OWNED and ALLOW-LISTED — the runtime never invents one. Per-grid allow-lists (both grids identical in this slice):submittedAt_desc(default) +submittedAt_asc. Sortable column for both grids: ONLYsubmittedAt. Demo-submissions Sort VO is the package-sideSemitexa\PlatformUi\Domain\Model\Event\UiFormDemoSubmissionSort— same allow-list shape as the project-side lead Sort VO, withinvalid_sortrejection routed through the package-sideUiFormDemoSubmissionSearchException. Out-of-scope this slice: contact-field / lead_message /id_*sorting, multi-sort, client-side sort. The sharedUiGridFilterStateenvelope is UNCHANGED (Option B): sort travels as a hidden form input, not as part of the on-wire filter envelope. -
Runtime:
src/Application/Static/js/grid-runtime.js, declared in the packageassets.json(scope: global,position: body,priority: 70,defer: true). Namespace:window.SemitexaUi.grid(bootAll()is the explicit re-discovery hook; auto-runs onDOMContentLoaded). Reads the inline<script type="application/json" data-ui-grid-bundle>block for column order + refresh-marker name + page-fallback URL. -
Runtime safety: DOM mutations use
createElement+textContentexclusively. Cell attributes go throughsetAttributewith a literal allow-list (style,ui-text,data-ui-grid-row-id); the per-columnstylestring is sourced from the server-rendered bundle, NOT from the JSON envelope. Row keys filtered through the bundle'scolumnsallow-list — extra keys ignored. JSON envelope shape-checked before any DOM update; deviation surfaces an error banner. NoinnerHTML, noeval, noFunctionconstructor, nodocument.write, no script-tag rendering, no arbitrary selectors from server payload, no client-side dataset cache. Pinned byGridRuntimeStaticAssertTest. -
Generic SSE refresh (per-grid configurable). The runtime listens for
semitexa:ui-sse:patch-appliedCustomEvents and reloads whenpatch.target.name === <refreshMarker>ANDpatch.target.instance === <grid-root-instance-id>. The marker name is read from the grid root'sdata-ui-grid-refresh-markerattribute, so each grid can use its own marker namespace without runtime changes. -
Lead listing migration (
src/modules/UiPlayground/src/Application/View/templates/pages/lead-admin.html.twig): now invokescomponent('platform.grid', {gridId, instanceId, dataUrl, sseUrl, refreshMarker: 'lead-grid-refresh-marker', pageFallbackUrl, emptyMessage, columns: […], initialRows, initialPagination, initialQuery, initialAction}, {filters: <form>, filterState: <p>, footer: <p>}). The lead admin handler still owns the SSE channel mint + topic subscribe + bundle push; the grid component template just renders the shell. -
Project-side runtime removed. The previous
src/modules/UiPlayground/src/Application/Static/js/lead-grid-runtime.js+ itsassets.jsonare gone — the package runtime covers it via the configurable refresh-marker name. Project module no longer needs an asset manifest. -
Demo-submissions migration (
src/modules/UiPlayground/src/Application/View/templates/pages/demo-submissions-admin.html.twig): the OK branch invokescomponent('platform.grid', {gridId: 'platform-ui.demo-submissions', dataUrl: '/ui-playground/admin/demo-submissions/grid-data', sseUrl: null, pageFallbackUrl, emptyMessage, columns: [submittedAt (sortable), id, actionName, contactName, contactTopic, contactMessagePreview], initialRows: submissions, initialPagination, initialQuery, initialAction, initialSort, sortParam}, {filters: <form (incl. hidden sort input)>, filterState: <p>}). JSON data endpoint atGET /ui-playground/admin/demo-submissions/grid-data(DemoSubmissionsAdminGridDataPayload+DemoSubmissionsAdminGridDataHandler) reuses the package-sideUiFormDemoSubmissionListCriteria,UiFormDemoSubmissionCursor,UiFormDemoSubmissionSort,UiFormDatabaseDemoSubmissionRepositoryInterface, andUiDemoSubmissionAdminAuthorizerInterfaceverbatim — same 5-gate ordering, same envelope shape as the lead grid-data endpoint. Accepts?sort=with the allow-listed tokens (submittedAt_descdefault,submittedAt_asc); unknown tokens →400withreason: invalid_sortand NO repository read. The cursor's filter fingerprint binds the active sort token so cross-sort cursor reuse →400 invalid_cursor. No SSE for demo-submissions in this slice:sseUrl: nullon the grid root → the package runtime renders the grid dynamically (filter + sort + Next) but skips SSE attach; the demo listing is refresh-on-action, not live-refresh. -
Grid id namespacing:
platform-ui.demo-submissions(the underlying records are package-owned demo data) vs.ui-playground.leads(project-owned business data). The grid id is opaque to the runtime; the convention helps operators correlate the two grids in mixed deployments. -
Shared envelope contract (
Semitexa\PlatformUi\Domain\Model\Grid\UiGridDataResponse): both grid-data handlers shape their JSON envelopes through one tiny static factory:UiGridDataResponse::success( gridId: 'platform-ui.demo-submissions', rows: $rows, // already projected by the handler pagination: new UiGridPaginationData($page->limit, $page->hasMore, $page->nextCursor?->encode()), filters: new UiGridFilterState($criteria->query, $criteria->actionName), ); UiGridDataResponse::error('invalid_cursor', 'Pagination cursor is invalid.');The helper owns ONLY the on-wire envelope shape (key list + key order pinned by
UiGridDataResponseTest). It does NOT authorize, query, parse criteria, decode cursors, project rows, or pick HTTP statuses — every one of those concerns stays in the handler. TheUiGridPaginationData+UiGridFilterStateDTOs are pure data carriers (no validation, no normalisation); handlers feed already-canonical values. A future grid-data endpoint that wants to participate in theplatform.gridcontract MUST go throughUiGridDataResponseso the on-wire envelope cannot drift across consumers. This is an envelope contract, not a generic grid-data-provider framework — there is no shared repository, no shared criteria, no shared SSE policy; those decisions stay with each consumer.
Superseded. The original project-side SSE refresh plumbing below was built against the now-retired platform-ui patch-stream subsystem (channel-token + per-channel patch publisher). With all UI streaming unified on the canonical KISS stream, that plumbing is superseded; the description is retained as historical design context only.
The original SSE refresh plumbing (topic registry, publisher wrapper, store-action call) was:
-
JSON data endpoint at
GET /ui-playground/admin/leads/grid-data(LeadAdminGridDataPayload+LeadAdminGridDataHandler). Returns the safe envelope{ok, gridId: "ui-playground.leads", rows[...], pagination: {limit, hasMore, nextCursor}, filters: {q, action}}— the envelope key list + key order is byte-identical to the demo-submissions grid (Option B: sort travels as a query parameter / form input, not in the response). Accepts?sort=with the allow-listed tokens (submittedAt_descdefault,submittedAt_asc); unknown tokens →400withreason: invalid_sortand NO repository read. The cursor's filter fingerprint binds the active sort token, so a cursor minted under one sort cannot be re-used under another (400 invalid_cursor). Same 5-gate ordering as the page handler (authorize → criteria → cursor → fingerprint → repo). Error envelope on any failure:{ok: false, reason, message}with 400/403 status. Never carriesvalues_json, tokens, ctx, dispatchId, debug, or class FQCNs (pinned by a canary integration test). -
Topic registry for SSE refresh — the retired framework patch publisher was strictly point-to-point (per-channel Redis LIST), so the project introduced a small subscription layer:
UiPlaygroundLeadGridRefreshTopicInterface(cache-backed default + in-memory test fallback) holds a map{channelId → (instanceId, expiresAt)}under namespaceui-playground-lead-grid-refresh. The lead admin page handler subscribed its freshly minted SSE channel + grid root instance id on each render (TTL 600s). Stale entries are pruned on every read; the single-map storage shape is bounded by the cache key's namespace TTL. -
Refresh publisher (
UiPlaygroundLeadGridRefreshPublisherInterface, defaultDefaultUiPlaygroundLeadGridRefreshPublisher—#[SatisfiesServiceContract], container-managed, the framework patch publisher property-injected). AfterUiPlaygroundStoreLeadAction::handle()saves a row, the action callspublishRefresh()which iterates the topic's subscribers and publishes asetTextpatch to each:targetName: 'lead-grid-refresh-marker',value: (string) time(). Patch fan-out is best-effort — both the publisher and the action wrap the publish call intry {…} catch (\Throwable)so a stale channel id, dead Redis connection, or contract-violating custom publisher cannot un-do the save. -
Refresh-marker patch shape. Fixed:
op=setText,targetInstance=<grid-root-instance-id>,targetName='lead-grid-refresh-marker',value='<unix-ts>'. Never carries lead values, leadId, or operator-internal jargon (pinned byrefresh_signal_carries_no_lead_values— the publisher's contract method is parameterless, so lead-value data has nowhere to flow through it). -
Pagination footer UX (added after the original RC; ships in both grids because the component is shared):
- Previous button (
<button data-ui-grid-prev>). Hidden on first paint; the runtime reveals it whenever the client-side cursor-history stack grows past page 1. No server-side fallback href — cursors are forward-only and the server doesn't keep history; on the no-JS path users still see only the Next link, identical to the original RC behavior. - Visited-page numbered buttons (
<span data-ui-grid-pages>populated with<button data-ui-grid-page="N">one per visible visited page). The runtime renders buttons only for pages whose cursor it has actually observed (page 1 is the implicitnullcursor) AND only within the configured sliding window (see Sliding window below). Clicking pageNre-uses the stored cursor for that page; there is no jump-to-arbitrary-page because the cursor model has no total-count orpage=Nsupport. - Sliding window (configurable). Caller prop:
paginationWindowSize(default7, clamped server-side AND client-side to[1, 25]; non-numeric / missing values fall back to7). Emitted onto the grid root asdata-ui-grid-pagination-window-size="<N>". The runtime's pure helpercomputePageWindow(currentPage, knownPages, windowSize)(exposed onwindow.SemitexaUi.gridfor console debugging / future Node-side tests) returns the inclusive[start, end]page range to render. The window centers the active page when possible, slides toward the start near page 1, and toward the end nearknownPages. It NEVER extends paststate.cursors.length— fabricating buttons for unknown future pages would offer cursors we don't have. Examples (withwindowSize=5,knownPages=20):page 1 → 1..5,page 4 → 2..6,page 8 → 6..10,page 20 → 16..20. WhenknownPages < windowSize, the window simply shrinks to fit (page 1 of 3 known →1..3). WhenknownPages = 0(the empty-state branch the table-wrap already hides), the window is{0, 0}and the pagination footer collapses. - Ellipsis markers (
<span aria-hidden="true">…</span>) appear on either side of the visible window when there are visited pages outside it. They are deliberately NOT buttons and do NOT carrydata-ui-grid-page— the click delegator looks forclosest('[data-ui-grid-page]'), so an ellipsis can never resolve to a navigation. Their only role is the visual hint "more known history exists" — they're decorative. - Current-page indicator (
<span data-ui-grid-page-indicator>). Updated viatextContenton every reload —Page <N>, suffixed with· more availablewhenpagination.hasMoreis true. Never displayed asPage N of MbecauseMis unknown. - State preservation: Previous / numbered-page navigation preserves
q,action,sort, andlimitunchanged. The Next link continues to do the same. - Reset triggers: any filter-form submit, any opt-in
data-ui-grid-reload-on-changechange (typically the page-size select), and any sort-header click invokeresetPaginationHistory()— clearsstate.cursor, resetsstate.cursorsto[null], and setsstate.page = 1. A cross-criteria cursor would either be rejected by the server-side fingerprint guard (forq/action/sortchanges) or land mid-stream (forlimitchanges); the early reset means the UI never offers a misleading Previous / numbered button that would behave inconsistently. - Cursor security unchanged: the existing
UiFormDemoSubmissionCursor/UiPlaygroundLeadSubmissionCursorshape regex + filter-fingerprint binding still gate every server-side cursor request. Stored client-side cursors are opaque to the runtime — they're treated as strings and pushed onto / popped off the history stack without inspection. Server-side rejection (400 invalid_cursor) is preserved end-to-end. - Safe DOM: every button + indicator update goes through
createElement+textContent+setAttributeonly. NoinnerHTML, noeval, noFunctionconstructor, nodocument.write— pinned byGridRuntimeStaticAssertTest.
- Previous button (
Explicit non-goals for the grid (each is a separate future slice): row selection, inline edit, bulk actions, export, virtual scroll, client-side dataset cache, client-side query engine, arbitrary jump-to-page (page=N), total-count display (would require an extra repo call per request and a contract change), multi-column sort, client-side sort,
id_*/lead_message/contactName_*/contactTopic_*/contactMessage_*sort tokens, demo-submissions SSE refresh. Column sort UI for both grids (lead + demo-submissions,submittedAt_*only on each) has shipped in two parity-matched slices — see the Sortable column headers bullet above for the contract. Previous-button + visited-page-button + current-page-indicator pagination has shipped (see Pagination footer UX above); arbitrary jump-to-page remains deferred.
Demo submission repository (UiFormDemoSubmissionRepositoryInterface):
interface UiFormDemoSubmissionRepositoryInterface
{
public function save(UiFormDemoSubmissionRecord $record): string; // returns id verbatim
public function find(string $id): ?UiFormDemoSubmissionRecord; // test + safe diagnostic
public function isShared(): bool;
public function diagnosticName(): string;
}
Default impl: CacheBackedUiFormDemoSubmissionRepository (#[SatisfiesServiceContract]), namespace ui-form-demo-submissions, 24h TTL (CacheBackedUiFormDemoSubmissionRepository::TTL_SECONDS = 86400). Lazy-default fallback: InMemoryUiFormDemoSubmissionRepository (worker-local; used in tests and single-worker dev). Worker-scoped static holder: UiFormDemoSubmissionRepository::{getActive, setActive, reset}, populated by BootPlatformUiRegistryListener mirroring the rule / action / authorizer / policy / CSRF-store pattern.
Database-backed sibling (UiFormDatabaseDemoSubmissionRepositoryInterface):
interface UiFormDatabaseDemoSubmissionRepositoryInterface
{
public function save(UiFormDemoSubmissionRecord $record): string;
public function find(string $id): ?UiFormDemoSubmissionRecord;
public function isShared(): bool;
public function diagnosticName(): string;
}
Same shape as the cache variant — separate interface so the cache-backed action and the database-backed action stay strictly orthogonal (neither one silently re-targets the other). Production default: UiFormDemoSubmissionDbRepository (#[SatisfiesRepositoryContract], ORM-managed via OrmManager). Lazy-default fallback: InMemoryUiFormDatabaseDemoSubmissionRepository (worker-local, used in tests). Worker-scoped static holder: UiFormDatabaseDemoSubmissionRepository::{getActive, setActive, reset}.
Table: platform_ui_demo_submissions. Columns:
| Column | Type | Notes |
|---|---|---|
id |
VARCHAR(32) PK manual |
uifs_<16hex> — caller-supplied |
form_instance_id |
VARCHAR(80) |
uci_<…> of the rendered form |
action_name |
VARCHAR(128) |
platform.demo.storeContactDb |
submitted_at |
DATETIME |
server-stamped at insert |
values_json |
LONGTEXT |
json_encode of the action's allow-listed values map (deterministic) |
created_at |
DATETIME |
from HasTimestamps trait |
updated_at |
DATETIME |
from HasTimestamps trait |
Schema is kept in sync by bin/semitexa orm:sync (run as part of bin/semitexa update). The table is registered through the standard #[FromTable] + #[Column] attributes on UiFormDemoSubmissionResource; no manual SQL migrations.
The same UiFormDemoSubmissionRecord readonly value object is exchanged at the public interface boundary — both repositories implement save(Record) + find(id): ?Record so dispatch tests can assert exact stored shapes regardless of which sink received the row.
Read-only diagnostic listing (/ui-playground/admin/demo-submissions)
The UiFormDatabaseDemoSubmissionRepositoryInterface exposes two bounded read-only listing methods:
public function recent(int $limit = self::DEFAULT_RECENT_LIMIT): array;
public function paginate(
?UiFormDemoSubmissionCursor $cursor = null,
int $limit = self::DEFAULT_RECENT_LIMIT,
): UiFormDemoSubmissionPage;
// DEFAULT_RECENT_LIMIT = 25, MAX_RECENT_LIMIT = 100
Implementations clamp $limit to [1, MAX_RECENT_LIMIT] and return rows newest-first (ORDER BY submitted_at DESC, id DESC — the id tie-breaker keeps the ordering deterministic when multiple rows share the same submitted_at second). recent($n) is the simple "first page only" surface, equivalent to paginate(null, $n)->records. No findAll(), no filter-by-action, no date-range scan, no search — those are explicit future work.
Keyset pagination. paginate() advances via an opaque cursor — never an offset. Implementations fetch $limit + 1 rows to detect hasMore, then trim. The returned UiFormDemoSubmissionPage carries {records, nextCursor, limit, hasMore}:
final readonly class UiFormDemoSubmissionPage
{
/** @var list<UiFormDemoSubmissionRecord> */
public array $records;
public ?UiFormDemoSubmissionCursor $nextCursor;
public int $limit;
public bool $hasMore;
}
nextCursor is set when hasMore && records !== [], pointing at the LAST returned record so the next page can resume strictly after it under the compound predicate (submitted_at, id) < (cursor.submittedAt, cursor.id).
Cursor shape. UiFormDemoSubmissionCursor is opaque to callers — its only public surface is encode(): string (the wire form) and decode(string): self / tryFromString(?string): ?self. The encoded cursor is base64url(JSON {s: submittedAt, i: id}) with no padding, no signing, no operator-internal fields. Decode is strict: base64url alphabet only, base64_decode(..., true), json_decode(depth: 4, JSON_THROW_ON_ERROR), tight key whitelist (['i', 's'] only), int/string type checks, and the id regex (/\Auifs_[a-f0-9]{16}\z/) — any deviation throws UiFormDemoSubmissionCursorException(reasonCode: 'invalid_cursor'). The codec never calls unserialize or eval. Exception messages are a fixed string — they never echo the bad cursor back.
The cursor carries no secrets — only an id and a timestamp that are already visible on the listing page. No signing is required because (a) the cursor reveals nothing the listing doesn't already reveal, and (b) the strict shape validation rejects every malformed input at parse time before any repository read happens.
This drives a tiny dev-facing diagnostic page registered in the UiPlayground module at GET /ui-playground/admin/demo-submissions. The page shows the most recent rows from platform_ui_demo_submissions with the same safety guarantees the rest of the submit pipeline maintains:
- Twig autoescapes every value (
<script>alert(1)</script>renders as literal text). - Message previews are server-truncated to 160 characters (single
…ellipsis appended). - The view-model is a flat array (
{id, actionName, formInstanceId, submittedAt, contactName, contactTopic, contactMessagePreview, storedFieldCount}). Rawvalues_jsonnever reaches the template. - The page deliberately does NOT surface CSRF token ids, raw tokens, signed-ctx blobs, dispatchIds, raw payload bytes, or debug internals — they are not in the table to begin with, and the projection never invents them.
Access control: UiDemoSubmissionAdminAuthorizerInterface (throw-on-deny, mirrors the action authorizer / security policy seams). Default impl is ConfigurableUiDemoSubmissionAdminAuthorizer (#[SatisfiesServiceContract]) — deny-by-default unless PLATFORM_UI_DEMO_ADMIN_ENABLED is explicitly truthy. Dev playground deployments that intentionally want open diagnostics can bind AllowAllUiDemoSubmissionAdminAuthorizer themselves or install it through the worker-scoped static holder. Worker-scoped static holder + Boot listener wiring match the surrounding patterns. Denial returns HTTP 403 with a safe template state — never the bad caller's identity, never class FQCNs.
Protected mode (default built-in): the package ships the env-gated authorizer as the default:
#[SatisfiesServiceContract(of: UiDemoSubmissionAdminAuthorizerInterface::class)]
final class ConfigurableUiDemoSubmissionAdminAuthorizer
implements UiDemoSubmissionAdminAuthorizerInterface
{
public const ENV_FLAG = 'PLATFORM_UI_DEMO_ADMIN_ENABLED';
// ...
}
Apps that need a different decision can replace the default either via their own boot listener call —
UiDemoSubmissionAdminAuthorizer::setActive(
new AllowAllUiDemoSubmissionAdminAuthorizer(), // dev-only open diagnostics
);
— or by binding their own implementation through #[SatisfiesServiceContract(of: UiDemoSubmissionAdminAuthorizerInterface::class)] (e.g. a permission-based authorizer composed with the configurable one).
Flag matrix (case-insensitive after trim): 1 / true / yes / on / enabled → allow. Anything else (unset, empty, 0, false, off, disabled, random text, whitespace) → deny with UiDemoSubmissionAdminAuthorizationException(reasonCode: 'demo_admin_disabled'). The message is a fixed string — it never echoes the env value, the flag name, or any class FQCN back. Pinned by ConfigurableUiDemoSubmissionAdminAuthorizerTest.
Denial paths converge on the same handler behaviour: HTTP 403, repository NOT read, no rows rendered, safe denial copy + reason code in the template. The reason code distinguishes the cause:
| Reason code | Source |
|---|---|
demo_admin_forbidden |
default UiDemoSubmissionAdminAuthorizationException() |
demo_admin_disabled |
ConfigurableUiDemoSubmissionAdminAuthorizer — env flag absent or falsey |
role_required / any |
custom application authorizer |
Diagnostic listing query parameters:
| Param | Default | Behaviour |
|---|---|---|
limit |
25 (DEFAULT_RECENT_LIMIT) |
Clamped server-side to [1, MAX_RECENT_LIMIT] (MAX_RECENT_LIMIT = 100). Non-numeric / empty / negative / whitespace falls back to the default. 0 clamps up to 1. Both the handler and the repository clamp — the handler value is surfaced to the template so the rendered "page size" matches what the user receives. |
cursor |
(absent) | Opaque base64url token returned by the previous page's nextCursor. Empty / missing → first page. Malformed → HTTP 400 with the safe template state (no submissions rendered, repository never read). Cursor binds to the filter combination it was issued under (see Cursor / filter binding below). |
q |
(absent) | Bounded diagnostic-grade search term (max 100 UTF-8 characters; longer → HTTP 400 invalid_search_query). Trimmed; empty / whitespace → treated as absent. Case-insensitive substring match against the allow-listed contact fields (contact_name, contact_topic, contact_message). Not a full-text engine. |
action |
(absent) | Optional allow-listed action filter. The only accepted value today is platform.demo.storeContactDb — the listing surfaces DB-backed rows only. Any other value → HTTP 400 invalid_action_filter and the bad value is never echoed back. |
Gate ordering inside the handler (security-significant):
- Authorization runs FIRST. A denied caller never sees a 400 for malformed q / action / cursor input (no decode oracle on the deny path). The repository is never read.
- Search criteria (
q+action+limit) are parsed and validated next. Oversizeq→ 400invalid_search_query. Unknownaction→ 400invalid_action_filter. No raw bad input is echoed back; the form re-renders empty. - Cursor decode runs only after criteria validation passes. Malformed cursor → 400
invalid_cursor. Still no repository read. - Cursor / filter binding: the cursor's optional
filterFingerprintMUST equal the criteria'sfingerprint(). Mismatch → 400invalid_cursor. A cursor issued under a filtered listing is not reusable as an unfiltered cursor and vice-versa. - Repository read runs only after every gate passes —
paginate()when the criteria is unfiltered,searchPage()otherwise.
Search semantics & SQL safety.
The ORM impl binds the search term via a parameterised LIKE against the serialised values_json column:
WHERE values_json LIKE ? ESCAPE '\\'
User-supplied % and _ characters are escaped before binding so a literal % in the search term cannot turn into a wildcard. The escape character \ is itself escaped first so a trailing \ in the user input cannot escape the closing % of the bound pattern. The search term is NEVER concatenated into SQL — it travels as a ? placeholder value. The action_name filter binds via the typed where(Operator::Equals) helper, not raw SQL.
This is diagnostic-grade search:
- It substring-matches against the entire JSON-serialised values blob — there's no per-field index.
- It scans up to
limit + 1rows, never the whole table. - It does NOT support phrase queries, stemming, ranking, or fuzzy matching. Use a real search engine for any of those.
Cursor / filter binding (filter fingerprint).
The cursor's wire format gained an optional f key:
// unfiltered listing // filtered listing (q="alpha")
{"s": 1778900000, "i": "uifs_…"} {"s": 1778900000, "i": "uifs_…", "f": "<16 hex>"}
f is the first 16 hex characters of sha256(query|action) over the canonical case-folded form (mb_strtolower(trim(q ?? '')) + | + action ?? ''). It is a tamper-resistance prefix, not a secret — knowing f doesn't help an attacker because the listing is already public-by-route under the active authorizer.
Acceptance rules:
- A v1 (2-key) cursor decodes as
filterFingerprint=null. It is accepted only when the active criteria is unfiltered (f === null). - A v2 (3-key) cursor with
f=Xis accepted only when the active criteria'sfingerprint()equalsX. - Any other combination → HTTP 400
invalid_cursor. The repository is never read.
This makes "splice a cursor from listing A onto listing B" impossible — an operator who wanted to walk a filtered keyset under a different filter would have to forge a matching fingerprint, which means knowing the canonical form of the new filter's (q, action) tuple.
The Next page → link in the template preserves the active q / action / limit alongside the encoded cursor so following pagination keeps the same filter context end-to-end.
Privacy guarantees (unchanged across this slice):
- The repository's
save()path still only stores the four documented columns (id/form_instance_id/action_name/submitted_at/values_json) — no tokens, no signed-ctx blob, no dispatchId, no debug, no payload bytes. - The handler's projection still emits only
{id, actionName, formInstanceId, submittedAt, contactName, contactTopic, contactMessagePreview, storedFieldCount}. Rawvalues_jsonis never surfaced. - The bad-search / bad-cursor states render a safe banner with a stable reason code; the bad user input is never echoed back.
- Twig autoescape still wraps every value at render time — even an
<script>alert(1)</script>incontact_name,contact_message, or theqparameter renders as literal text, never markup.
What this slice does NOT implement (explicit non-goals): per-field search (field), date-range query, per-user view, edit, delete, export, soft-delete, undo, RBAC matrix, admin UI for the cache-backed repository, list endpoint for the find() method beyond this one route, "Previous page" link / backward keyset, jump-to-page / total-count display, persistent cursor history, full-text engine, ranking / scoring, search-as-you-type.
Stored record shape (UiFormDemoSubmissionRecord):
final readonly class UiFormDemoSubmissionRecord {
public string $id; // 'uifs_<16hex>' generated by the action
public string $formInstanceId; // 'uci_<…>' of the rendered form
public string $actionName; // 'platform.demo.storeContact'
public int $submittedAt; // Unix timestamp
public array $values; // allow-listed sanitised values only
}
The record carries no tokens, no signed-ctx blob, no dispatchId, no request payload, no debug internals. The repository is a dumb sink — sanitisation lives in the action.
Safety gate ordering before storage (canonical pipeline):
- signed-ctx HMAC verification;
- dispatchId replay claim;
- dispatcher-level
UiInteractionAuthorizerInterface; - authoritative server-side field validation;
UiFormSubmitActionRegistryInterfaceresolves the action by signed name;UiFormSubmitActionAuthorizerInterfaceallows the attempt;UiFormSubmitSecurityPolicyInterfaceverifies + consumes the one-time CSRF token.
Only after all seven pass does the action's handle() run. Invalid submits / replay / authz denial / CSRF failures never reach storage — pinned by the dispatch test matrix (one record per valid submit; zero records after any gate fails).
Demo-grade limitations:
- The default
platform.demo.storeContactaction uses the cache-backed demo repository with a 24-hour TTL — records evaporate; abandoned demo deployments do not accumulate data. - The alternate
platform.demo.storeContactDbaction uses the DB-backed demo repository/table (platform_ui_demo_submissions) and persists rows until the consuming app's database retention policy removes them. Operators can override that behaviour by bindingUiFormDatabaseDemoSubmissionRepositoryInterfaceor replacing the DB action wiring. - The action does NOT send email, redirect, call external APIs, create accounts, or run any other business action.
- Real persistence (audit, retention, queryability) is a separate slice with its own storage contract.
Submit action authorizer + security policy seams
The action seam is gated by two dedicated seams that run AFTER authoritative field validation and BEFORE the action's handle():
UiFormSubmitActionAuthorizerInterface::authorize(UiFormSubmitActionAuthorizationContext)— application-level identity / role / rate-limit decision. DefaultAllowAllUiFormSubmitActionAuthorizeris a no-op so the demo flow works unchanged.UiFormSubmitSecurityPolicyInterface::verify(UiFormSubmitSecurityContext)— submit-shaped CSRF / session / token check. Default is nowCacheBackedUiFormSubmitSecurityPolicy— a one-time nonce bound to the rendered form via the signed ctx (cfg.s = {k, t}) and an HMAC-stored cache entry. See "Submit CSRF policy" below for the full token flow.SignedContextOnlyUiFormSubmitSecurityPolicystays available as an explicit opt-in fallback for environments without a shared cache or for tests that want to bypass CSRF.
Both seams use a throw-on-deny convention (the dispatcher-level UiInteractionAuthorizerInterface returns bool because it has no need for a per-decision reason channel — these seams do, so they raise typed exceptions instead):
interface UiFormSubmitActionAuthorizerInterface
{
/** @throws UiFormSubmitActionAuthorizationException on deny. */
public function authorize(UiFormSubmitActionAuthorizationContext $context): void;
}
interface UiFormSubmitSecurityPolicyInterface
{
/** @throws UiFormSubmitSecurityPolicyException on policy failure. */
public function verify(UiFormSubmitSecurityContext $context): void;
}
Both exceptions carry a reasonCode (role_required, rate_limited, csrf_verification_failed, session_required, submit_security_failed, …) plus a user-facing message. FormComponent catches them and emits the same two form-level patches as a normal action (setText form-status + setAttribute ui-state=invalid) — no class names, no raw values, no patches outside the existing allow-list. Debug surface:
"action": {
"name": "platform.demo.accept",
"invoked": false,
"reason": "action_forbidden", // or "submit_security_failed"
"detail": "role_required", // the exception's reasonCode
"message": "You do not have permission to run this action."
}
Override seam: apps bind their own implementations via #[SatisfiesServiceContract(of: ...)] in a module that "extends" semitexa-platform-ui. The contract registry picks the descendant-module winner; BootPlatformUiRegistryListener stashes them in UiFormSubmitActionAuthorizer / UiFormSubmitSecurityPolicy (worker-scoped static holders, mirroring the rule-registry pattern).
Submit CSRF policy (nonce-backed, one-time consume)
The default security policy is CacheBackedUiFormSubmitSecurityPolicy. It binds a one-time token to each rendered form-with-action:
-
Render time. Form template calls
ui_form_issue_submit_csrf($actionName)(only whensubmitActionis set). The helper asks the activeUiFormSubmitCsrfTokenStoreInterfaceto mint a fresh{id, raw}pair. The store keeps ONLYhash_hmac('sha256', raw, id)againstidin a namespaced cache (ui-form-submit-csrf), with a TTL bounded by the form ctx lifetime (default 600 s / 10 min). The pair is signed intocfg.s = {k: <id>, t: <raw>}of the submit ctx. -
Dispatch time. FormComponent reads
event->config['s']and passes it as the newUiFormSubmitSecurityContext::$securityConfigfield to the policy. The policy:- asserts
cfg.s.kmatchesuicsrf_[a-f0-9]{16}andcfg.s.tmatches[a-f0-9]{32}; - calls
UiFormSubmitCsrfTokenStoreInterface::consume($k, $t)which atomically verifies HMAC + removes the entry; - returns void on success, throws
UiFormSubmitSecurityPolicyException(reasonCode: 'csrf_verification_failed', message: 'Submit security check failed. Please reload the form and try again.')on any failure (missing / expired / wrong / already consumed — all collapse to the same surface, no side-channel).
- asserts
-
One-time consume semantics. The policy runs AFTER field validation + the action authorizer. So:
- invalid submits never reach
consume()→ the token survives → the user can fix the form and resubmit; - authorizer-denied submits never reach
consume()→ token survives; - valid + authorized submits consume the token regardless of whether the action itself rejects (acceptable — the user already saw a server response, which is enough to invalidate the bearer secret).
- A second submit attempt with the same
cfg.safter a successful first one fails CSRF — the user reloads the form to mint a fresh token. The playground demo exercises this end-to-end.
- invalid submits never reach
Token store: UiFormSubmitCsrfTokenStoreInterface (issue($ttl) → UiFormSubmitCsrfTokenHandle{id, raw} + consume($id, $rawToken): bool + isShared(): bool + diagnosticName(): string). Default impl is CacheBackedUiFormSubmitCsrfTokenStore (#[SatisfiesServiceContract], namespaced through CacheManagerInterface, observable across all workers sharing the cache backend). Lazy-default fallback is InMemoryUiFormSubmitCsrfTokenStore for tests / single-worker dev (NOT safe across Swoole workers).
Trust perimeter:
- Cache stores ONLY the HMAC hash. A leaked cache snapshot cannot replay a token because the raw value is never persisted and HMAC keys each hash with the token id.
- Failure messages never echo the bad token id or value.
cfg.scarries only{k, t}: no session id, no cache key format details, no class FQCNs, no service ids.payload.csrf/payload.csrfToken/payload.csrf_token(top-level + form-nested) remain forbidden byUiPayloadFieldGuardfrom the previous slice — the client cannot smuggle the token through anywhere except the signed ctx, where the HMAC binds it.
Known limitations (call-outs in primitives.md limitations list):
- This is a nonce-backed, not yet session-bound policy. The token is bound to the rendered form via the signed ctx; it is not bound to a session cookie. A leaked full-page HTML (with the signed ctx + token) can be submitted from any UA until consumed. True session binding lands when Semitexa exposes a stable request-scoped seam reachable from reflection-instantiated components.
- No CSRF token rotation across multiple forms on the same page — each
FormComponentinstance mints its own independent token. - TTL is a fixed default (600 s) at the helper level; future work threads a configurable TTL through.
Override seam: apps that want a stricter policy bind their own implementation with #[SatisfiesServiceContract(of: UiFormSubmitSecurityPolicyInterface::class)] (and optionally their own token store). The BootPlatformUiRegistryListener stashes the container-bound winners in the matching static holders.
Submit ordering (single canonical pipeline):
- parse signed
cfg.f; - parse signed
cfg.a(optional); - validate every signed field →
UiFormSubmitResult; - if invalid: emit per-field + summary patches; authorizer / policy / action NEVER run;
debug.action.reason = 'validation_invalid'; - if valid && cfg.a is set:
a. resolve action via registry;
b. run authorizer → may throw
UiFormSubmitActionAuthorizationException→ safe denial patches +debug.action.reason = 'action_forbidden'; c. run security policy → may throwUiFormSubmitSecurityPolicyException→ safe denial patches +debug.action.reason = 'submit_security_failed'; d. invoke action'shandle()→ form-status uses the action's message + state. - if valid && no cfg.a: emit per-field + summary patches with the standard "Form is valid. Submit accepted." message.
Patch order is fully stable: per-field first, form-level last, action extras (if any) appended after. Denials NEVER produce action extras.
Payload guard rejects the corresponding smuggling attempts: payload.action, payload.submitAction, payload.csrf, payload.csrfToken, payload.security, payload.policy, payload.authorization, payload.authz (and case / separator variants) all return 400 forbidden_payload_field. Single-letter a is intentionally NOT in the forbidden list because legitimate form field names could collide; the signed cfg.a is the only canonical channel.
Trust perimeter (auto vs. manual is identical):
- Field definitions are server-rendered. The collector stores PHP value objects (
UiFormSubmitFieldDefinition); no client payload, no DOM scanning at submit time. - Rules are normalised through the active
UiFieldRuleRegistryat render time. Unknown rule names / malformed params fail in the template, not at dispatch. - The collector defensively re-runs the wire validation through
UiFormSubmitConfigParser::parseSignedWire()before signing, so duplicate field names / duplicate instance ids / unsafe instance ids cannot reachcfg.f. - No raw values, no class names, no service / method names enter the metadata.
Rendered shape:
<div data-ui-component="platform.form"
data-ui-component-instance-id="uci_..."
data-ui-form-aggregate="1"
ui-component="form"
role="group">
<h2>title</h2>
<p>description</p>
<form data-ui-part="form" action="#" novalidate>
<div data-ui-form-fields>...content slot...</div>
<div data-ui-patch-target="form-status">...</div>
<div data-ui-form-submit-row>
<button data-ui-primitive="platform.button" type="submit" ui-tone="brand">Validate form</button>
</div>
</form>
<script type="application/json" data-ui-event-manifest="...">{"v":1,"c":"platform.form","i":"uci_...","events":[{"p":"form","e":"submit","ctx":"sc1.…"}]}</script>
</div>
Signed submit ctx — claim shape:
{
"c": "platform.form",
"i": "uci_<form-instance>",
"p": "form",
"e": "submit",
"cfg": {
"f": [
{"n":"access_code","r":[{"n":"required"},{"n":"minLength","p":[4]}],"l":"Access code","q":true},
{"n":"confirm_access_code","r":[{"n":"required"},{"n":"sameAsField","p":["access_code","Codes must match."]}],"l":"Confirm access code","q":true}
]
},
"iat": ..., "exp": ...
}
Each field entry uses the compact single-letter wire shape: n (name), r (rules wire), l (optional label), q (optional required flag).
Wire payload (submit dispatch):
{
"ctx": "sc1.…", // signed form-submit ctx
"dispatchId": "ui_evt_<hex>",
"payload": {
"value": null, // form-level events have no single value
"form": { // sanitised by UiFormPayloadSnapshot
"values": {
"access_code": "abcd",
"confirm_access_code": "abcd"
}
}
}
}
Smuggling attempts (payload.rules, payload.cfg, payload.form.rules, payload.form.cfg, any routing-flavored key) are rejected with 400 forbidden_payload_field by the existing UiPayloadFieldGuard.
Authoritative final validation flow:
UiInteractionDispatcherverifies the submit ctx, resolves the handler throughUiComponentRegistry::get('platform.form')->event('form','submit'), and instantiatesFormComponent.UiFormPayloadSnapshot::extract($payload)produces the sanitisedformValuesmap, which the dispatcher attaches to the event.FormComponent::onSubmitreads$event->config['f'](the signed field list) throughUiFormSubmitConfigParser::parseSignedWire. The list is the authoritative input to validation — client values feed it, the client cannot change it.- For every signed field: the handler instantiates the rule chain via
UiFieldRuleParser::resolveFromWire, builds aUiFieldValidationContextcarrying the fullformValues(so cross-field rules likesameAsFieldsee siblings), and runsUiFieldValidator. - The handler aggregates
{name, state, message}per field intoUiFormSubmitResult::fromFieldResults(...). - The result projects to two patches only —
setTextonform-status,setAttributeui-stateon the form root.
Result + status messages (verbatim contract, pinned by tests):
| Case | Message |
|---|---|
totalCount === 0 (no signed fields) |
Form has no fields. |
| All fields valid | Form is valid. Submit accepted. |
| 1 field invalid | 1 field needs attention. |
| N fields invalid | <N> fields need attention. |
Debug surface — safe-to-log shape, never echoes submitted values:
"debug": {
"instance": "uci_...",
"submit": {
"valid": false,
"totalCount": 2,
"validCount": 1,
"invalidCount": 1,
"fields": [
{"name":"access_code", "state":"valid", "message":"Looks good."},
{"name":"confirm_access_code", "state":"invalid", "message":"Codes must match."}
],
"message": "1 field needs attention."
},
"form": {
"snapshotFields": ["access_code", "confirm_access_code"],
"snapshotSize": 2
}
}
Frontend submit capture:
event-runtime.js extends its existing event delegation:
- The native
submitevent on an element matchingdata-ui-part="form"is captured (capture phase), andev.preventDefault()is called exactly there — no other native event has its default suppressed. The single guardedpreventDefaultcallsite is pinned byEventRuntimeAssetTest. collectFormValuesSnapshotnow handles both cases: captured instance IS the form root (submit dispatch), or captured instance is a field inside a form root (input-change dispatch). Walks the same[data-ui-form-aggregate="1"][data-ui-component-instance-id]ancestor query in both directions.- The wire body for submit is the same
{ctx, dispatchId, payload}envelope every other dispatch uses;payload.valueisnull(the form element has no.value),payload.form.valuescarries the snapshot.
Security / trust boundary:
- Submit is authoritative within the demo: the response distinguishes accepted from rejected based on signed rules + sanitised values.
- Submit does NOT trust client-side aggregate state, validation messages, or any boolean the client claims. Counts and per-field outcomes come from running the server-owned rule chain.
- Submit does NOT persist anything. Persistence in a future slice must add authorization, CSRF/session policy if relevant, and storage-specific validation on top of this seam.
- Submit response never echoes submitted values — only counts + per-field state/message + the snapshot field-key set.
- The signed
cfg.fshape, the parser, the result projection, and the patch allow-list are all the same trust perimeter the rest of the validation stack uses.
Limitations of this slice:
- No persistence. No business action. No redirect. No real account creation / email send.
- Automatic slot introspection now resolves field definitions for FieldComponents inside the content slot (
autoFields: true). Fields rendered outside the slot, or anonymous / unsafe-named FieldComponents, are not discovered — caller must use the manualfieldsprop instead. No multi-pass component tree reflection. - Submit action seam, action authorizer seam, CSRF/security policy seam, and the first persistent demo action are all in place. Built-in defaults are
platform.demo.accept(no-op),platform.demo.storeContact(cache-backed demo storage),AllowAllUiFormSubmitActionAuthorizer,CacheBackedUiFormSubmitSecurityPolicy, andCacheBackedUiFormDemoSubmissionRepository. Real persistent business actions (DB-backed, audit-aware, with bespoke authorization) are an explicit future slice. - CSRF policy is in place via
CacheBackedUiFormSubmitSecurityPolicy(one-time nonce, HMAC-stored in a namespaced cache, bound to the rendered form viacfg.s). It is nonce-backed, not session-bound — a token issued for a rendered form survives across user agents until consumed. Full session binding lands when Semitexa exposes a request-scoped seam reachable from reflection-instantiated components. - No redirect / file upload / async / database action variants in
UiFormSubmitActionResult. - No request metadata (session id, auth identity, IP) in the authorization / security contexts yet — a separate slice once Semitexa lands a stable convention for passing it into UI handlers.
- No async / database / remote validation.
- No multi-step forms, no durable form state, no cross-tab state sync.
- No new patch op, no new attribute allow-list entry.
disabledis still off the allow-list — submit button cannot be disabled through a patch in this slice.
Per-field submit projection (signed cfg.f.i)
Submit now emits per-field validation patches in addition to the form-level summary. Each signed field definition can carry an instanceId matching UiInstanceIdGenerator::SAFE_ID_PATTERN (uci_[A-Za-z0-9_-]{1,64}). When cfg.f[*].i is present, the handler projects that field's UiFieldValidationResult::toPatches($i) output — the SAME shape FieldComponent::onInputChanged already emits (aria-invalid + ui-state + validation-message). Patches are ordered per-field first, form-level last, so the visible form-status reflects the aggregate after every field DOM has been updated.
Caller API (extends the existing submit demo):
{% set _field_defs = [
{
name: 'access_code',
instanceId: 'uci_submit_access_code', # signed into cfg.f.i
label: 'Access code',
required: true,
rules: ['required', ['minLength', 4]],
},
{
name: 'confirm_access_code',
instanceId: 'uci_submit_confirm_access_code',
label: 'Confirm access code',
required: true,
rules: ['required', ['sameAsField', 'access_code', 'Codes must match.']],
},
] %}
{% set _fields %}
{{ component('platform.field', {
name: 'access_code',
instanceId: 'uci_submit_access_code', # SAME id on field render
showValidationTarget: true,
rules: ['required', ['minLength', 4]],
}) }}
{{ component('platform.field', {
name: 'confirm_access_code',
instanceId: 'uci_submit_confirm_access_code',
showValidationTarget: true,
rules: ['required', ['sameAsField', 'access_code', 'Codes must match.']],
}) }}
{% endset %}
{{ component('platform.form', {
fields: _field_defs,
showSubmit: true,
submitText: 'Validate form',
}, { content: _fields }) }}
FieldComponent instanceId prop:
The instanceId prop is optional. When absent, the field generates a fresh uci_<16hex> id (unchanged behaviour). When present and matching UiInstanceIdGenerator::SAFE_ID_PATTERN, the field uses it consistently:
- on
data-ui-component-instance-id(root attribute), - in the signed event manifest's
iclaim (so the input-change ctx pins to the same id), - in
data-ui-event-manifest.
A render-time ui_component_instance_for(override) Twig helper handles the override + safe-id validation. An unsafe override raises UiComponentRegistryException at render time — developer mistakes surface immediately as a Twig error rather than at dispatch.
Wire shape change (additive — i is optional):
"cfg": {
"f": [
{
"n": "access_code",
"i": "uci_submit_access_code", // NEW (optional)
"r": [{"n":"required"},{"n":"minLength","p":[4]}],
"l": "Access code",
"q": true
},
{
"n": "confirm_access_code",
"i": "uci_submit_confirm_access_code", // NEW (optional)
"r": [{"n":"required"},{"n":"sameAsField","p":["access_code","Codes must match."]}],
"l": "Confirm access code",
"q": true
}
]
}
Old submit ctxs (rendered before this slice) keep working: when cfg.f[*].i is absent the handler skips per-field projection and emits only the form-level summary.
Patch allow-list — instance-id authority:
UiPatchValidator now accepts an optional additionalAllowedInstances: list<string> argument. UiInteractionDispatcher walks the verified cfg claim, collects every value matching UiInstanceIdGenerator::SAFE_ID_PATTERN, and passes the resulting set to the validator alongside the primary signed instance. The rule is: a handler may patch ANY instance that itself survived HMAC verification.
- The walk is generic (no FormComponent-specific knowledge in the dispatcher) and produces no false positives because the safe-id regex (
uci_+ alphanumerics/underscores/hyphens, bounded) is a tight subset of arbitrary strings. - A patch targeting any unsigned instance still returns
422 patch_instance_mismatch. - The client cannot retarget patches through the payload — the payload has no instance-id surface, and
payload.form.valuesis sanitised to safe identifier keys → scalar values only. - Tampering
cfg.f[*].iinvalidates the signed ctx →403 invalid_signed_ctx.
Result + patches (per-field invalid example):
{
"patches": [
// access_code (valid)
{"op":"setAttribute","target":{"instance":"uci_submit_access_code","part":"input"},"attribute":"aria-invalid","value":null},
{"op":"setAttribute","target":{"instance":"uci_submit_access_code","part":"input"},"attribute":"ui-state","value":"valid"},
{"op":"setText","target":{"instance":"uci_submit_access_code","name":"validation-message"},"value":"Looks good."},
// confirm_access_code (invalid — sameAsField mismatch)
{"op":"setAttribute","target":{"instance":"uci_submit_confirm_access_code","part":"input"},"attribute":"aria-invalid","value":"true"},
{"op":"setAttribute","target":{"instance":"uci_submit_confirm_access_code","part":"input"},"attribute":"ui-state","value":"invalid"},
{"op":"setText","target":{"instance":"uci_submit_confirm_access_code","name":"validation-message"},"value":"Codes must match."},
// form-level summary LAST
{"op":"setText","target":{"instance":"uci_form_...","name":"form-status"},"value":"1 field needs attention."},
{"op":"setAttribute","target":{"instance":"uci_form_..."},"attribute":"ui-state","value":"invalid"}
],
"debug": {
"instance": "uci_form_...",
"submit": {
"valid": false, "totalCount": 2, "validCount": 1, "invalidCount": 1,
"fields": [
{"name":"access_code","state":"valid","message":"Looks good."},
{"name":"confirm_access_code","state":"invalid","message":"Codes must match."}
],
"message": "1 field needs attention.",
"projectedFieldInstances": ["uci_submit_access_code","uci_submit_confirm_access_code"]
},
"form": {"snapshotFields": ["access_code","confirm_access_code"], "snapshotSize": 2}
}
}
Limitations of this slice:
- Per-field projection is opt-in — when a field def lacks
instanceId, only the form-level summary is emitted for that field. Backward-compatible by design. - With
autoFields: true, slotted FieldComponents auto-register their instance id into cfg.f.i — no caller-sideinstanceIdplumbing required. The manualfieldspath still supports an explicitinstanceIdfor bespoke wiring; both share the same trust perimeter. - No new patch op, no new attribute allow-list entry.
disabledremains off the allow-list — submit button cannot be disabled mid-flight. - No persistence, no business action, no redirect, no CSRF / session policy.
- No multi-step forms, no async validation, no durable form state.
- Frontend runtime is unchanged. The patches go through the same safe applier the field input-change path already uses.
Playground
/ui-playground/components/field now walks through thirteen scenarios: Event metadata (declaration-only) table, Signed event manifest (inert JSON), Frontend capture (no backend dispatch) with a live capture log, Backend dispatch — response patches with a dispatch + patch lifecycle log and a visible server-ack target updated by a setText patch, Server push — SSE patches with Connect / Push / Disconnect buttons that exercise the full publish→subscribe→apply path, Server validation — response patches with showValidationTarget: true and live aria-invalid / ui-state / validation-message updates as the user types, bind, basic field, help, error, disabled, slots (prefix/suffix with primitives), inputProps caller overrides, and a multi-field fake form layout. The backend-dispatch, SSE, and validation demos are the only places on the playground that opt the transport bridges in and update visible patch targets; regular pages do not POST, subscribe, or validate automatically.
/ui-playground/components/form shows the minimal FormComponent aggregation slice end-to-end: three FieldComponents inside a single platform.form, each with its own signed rule list. As the user types, every change triggers a dispatch; the runtime updates the field's own validation patches and then refreshes the form-level form-status text + ui-state attribute via two synthesised patches through the existing safe applier. The page exposes the live forms.snapshot() data and a dispatch log so the aggregate transition (pending → invalid → valid) is visible without DevTools. A second section, Authoritative submit validation — per-field projection, demonstrates the submit pipeline with two access_code fields wired with stable instanceIds (uci_submit_access_code / uci_submit_confirm_access_code) threaded into both the fields prop and each platform.field render. Pressing Validate form dispatches the signed submit ctx; the server runs every rule against the submitted snapshot and returns per-field patches (aria-invalid + ui-state + validation-message) AND the form-level summary — same patch shape, no persistence.