Verified against Semitexa Ultimate 2026.09.19.1020
UI Primitives
Six primitives ship in v1. All are semantic HTML with CSS styling via ui="<id>" + modifiers. CSS is the stable contract; Twig macros in resources/twig/primitives/ are optional DX.
ui="button"
| Attribute | Values | Default |
|---|---|---|
ui-variant |
solid · soft · ghost |
solid |
ui-tone |
neutral · brand · success · warning · danger |
brand (solid) |
ui-size |
sm · md · lg |
md |
States: :hover, :active, :disabled handled automatically.
<button ui="button" ui-tone="danger">Delete</button>
<button ui="button" ui-variant="ghost">Cancel</button>
<a ui="button" href="/export" ui-variant="soft">Export</a>
ui="input"
| Attribute | Values | Default |
|---|---|---|
ui-size |
sm · md · lg |
md |
ui-state |
default · invalid |
default |
Uses color-mix(in oklab, ...) for focus ring tinting against --ui-accent-brand.
ui="label"
Form label with role-appropriate typography. ui-size: sm/md/lg.
ui="field-shell"
Wraps label + input + optional ui="error-text". When parent has ui-state="invalid":
- Descendant
[ui="label"]turns--ui-state-danger - Descendant
[ui="input"]border turns danger [ui="error-text"]becomes visible (hidden by default)
<div ui="field-shell" ui-state="invalid">
<label ui="label" for="email">Email</label>
<input ui="input" id="email" type="email" ui-state="invalid">
<span ui="error-text">Enter a valid email.</span>
</div>
ui="surface"
Opinionated panel container. Defaults to panel background + subtle border + 1rem padding. Compose with sx-padding, sx-radius, sx-surface for variants.
ui="badge"
| Attribute | Values | Default |
|---|---|---|
ui-variant |
solid · soft |
solid |
ui-tone |
neutral · brand · success · warning · danger |
brand |
Deferred to v1.1+
textarea, select, checkbox, radio, switch, hint, tag, divider, icon, toolbar.
Introspection
bin/semitexa platform-ui:css:explain button— variants, tones, sizes, tokens referencedSemitexa\PlatformUi\Primitive\PrimitiveRegistry::all()— programmatic enumeration
Runtime (attribute-driven)
#[AsUiPrimitive] declares a class as a Semitexa UI primitive. The lifecycle listener BootPlatformUiRegistryListener boots UiPrimitiveRegistry with ClassDiscovery at worker start; from that point primitives are discoverable by canonical name (e.g. platform.button) or short UI alias (e.g. button).
use Semitexa\PlatformUi\Attribute\AsUiPrimitive;
#[AsUiPrimitive(
name: 'platform.button',
ui: 'button',
template: '@platform-ui/primitives/runtime/button.html.twig',
style: 'platform-ui:css:full',
)]
final class ButtonPrimitive {}
name— canonical registry/debug identity. Used by handler resolution, signed contexts, manifests. Unique across the registry.ui— short CSS/markup alias for theui="..."attribute. Unique across the registry. Derived from the last dot-segment ofnamewhen omitted.template— Twig template that renders the primitive. Receivespropsplus_primitive: {name, ui}in context.style/script— optional asset keys. Required throughAssetCollectorStoreat render time and deduplicated by the collector.
primitive() Twig helper
PlatformUiTwigExtension registers primitive(name, props) via #[AsTwigExtension]:
{{ primitive('button', { text: 'Save', tone: 'brand', variant: 'solid' }) }}
{{ primitive('input', { name: 'email', placeholder: 'Email' }) }}
{{ primitive('badge', { text: 'Active', tone: 'success' }) }}
Both the canonical name and the ui alias resolve to the same primitive: primitive('platform.button', ...) ≡ primitive('button', ...).
The rendered output carries stable root markers for future frontend-runtime scanning:
<button ui="button" data-ui-primitive="platform.button" type="button" ui-tone="brand">Save</button>
Primitive prop vocabulary
The current attribute-driven primitives accept this small vocabulary:
| primitive | accepted props |
|---|---|
button |
text, tone (brand/neutral/success/warning/danger), variant (solid/soft/ghost), size (sm/md/lg), disabled, href, type |
input |
name, id, type, value, placeholder, size, state (invalid), required, disabled, help, error |
badge |
text, tone, variant (solid/soft) |
Only text (and href on button) ever changes the rendered tag; everything else maps to a ui-* attribute that the active skin's tokens.css resolves. error on an input automatically sets ui-state="invalid", aria-invalid="true", and an inline danger-toned message; help renders muted help text with aria-describedby. Both wrap the input in a stack — bare inputs (no help/error) still emit a single <input> element so existing usage is preserved.
Active skin/theme assumption
Platform-ui CSS reads every visual decision (color, radius, spacing, motion) from CSS custom properties prefixed --ui-*. Those properties are defined by the active skin at runtime (/assets/skins/<slug>/tokens.css). The active skin is determined by semitexa/theme from the (tenant, domain, locale) tuple. The project's app layout includes both platform-ui:css:full (auto-required via requireGlobals()) and the skin tokens link via theme_skin_css(), with tokens loaded last so they win the cascade.
Local playground
src/modules/UiPlayground is the local development surface. Routes:
| Route | Demo |
|---|---|
/ui-playground |
Menu / dashboard |
/ui-playground/primitives/buttons |
Buttons — tones, variants, sizes, disabled, anchor |
/ui-playground/primitives/inputs |
Inputs — placeholder, sizes, help, error, disabled |
/ui-playground/primitives/badges |
Badges — tones, soft variant, in-context |
/ui-playground/foundation/colors |
Skin tokens — surfaces, accent/text, state, typography |
The playground only consumes public APIs — primitive declarations and the primitive() helper live in semitexa/platform-ui.
Current limitations
- SSE server-push channel is wired (this slice). Patches can arrive via the dispatch response or pushed as canonical
ui.patchframes over the single KISS stream (/__semitexa_kiss); both transports reuse the sameUiResponsePatchshape and the same safe applier. SSE messages target DOM nodes that already exist inside the component instance — no node creation, noinnerHTML, no arbitrary selectors. - Patch op allow-list covers
setText,setValue,setAttributeonly. NosetHtml, no class-list mutations, no node insertion/removal — those are future-slice concerns and intentionally absent. setAttributeis restricted to four allow-listed attribute names (aria-invalid,aria-describedby,data-state,ui-state). Anything else is rejected server-side and again by the frontend applier.- A patch whose target element is missing in the rendered DOM (e.g. the caller did not pass
showServerAckTarget: true) is a graceful no-op — the bridge emitssemitexa:ui-patch:failedwithreason: "target_not_found"and the rest of the batch continues. - Dispatch responses are still ack-style: a single JSON body with optional
patches[]. Streaming/__ui/dispatchresponses (chunked patches over the dispatch transport) is not on the roadmap — clients that want streaming use the SSE channel. - Replay guard is wired through DI and runtime-checked:
UiReplayStoreInterfaceresolves toCacheBackedUiReplayStoreby default viaSatisfiesServiceContract, and the dispatcher's runtime guard refuses to invoke handlers in production-like environments when the bound store reportsisShared() === false. The 503ui_replay_store_not_sharedresponse tells operators exactly what to fix. WithCACHE_DRIVER=redis(this project's shipped config), replay protection is global across Swoole workers — verified live with 10/10 same-(ctx,dispatchId)requests returning 409. - Authorization hook is wired through DI:
UiInteractionAuthorizerInterfaceresolves toAllowAllUiInteractionAuthorizerby default viaSatisfiesServiceContract. Apps override by registering their own implementation in a module that "extends"semitexa-platform-ui; the contract registry's module-order winner picks the descendant. No per-handler wiring required. - No full anti-abuse system. Replay protection is exclusively
(ctx, dispatchId)deduplication: a malicious client holding a validctxcan mint as many freshdispatchIds as it wants within the ctx TTL. Bot/abuse mitigation (rate limiting, captcha, behavioural heuristics) is a separate concern that should sit in pipeline middleware, not in the dispatcher. - No full policy / RBAC matrix. The authorizer hook is a single yes/no decision per (component, instance, part, event). Richer policy expressions / role-aware scopes land in a later slice; the seam is intentionally narrow for now.
- No replay nonce inside SignedContext. Replay protection is exclusively
(ctx, dispatchId)— the signedctxis reusable within its TTL. Embedding a nonce in the signed context would force the server to mint a new ctx per dispatch, breaking opt-in transport bridging and complicating SSR. - No per-handler validation pipeline. Handlers receive the raw (guard-scrubbed) payload; richer per-event payload schemas land later.
- No DI-managed components yet. Components must have a no-required-arg constructor. If they don't, the dispatcher returns 422
cannot_instantiate_component— by design, not a regression. - The transport bridge is opt-in per page. The runtime never auto-attaches. This keeps non-event pages no-network and lets each surface decide its own dispatch contract.
SignedContext::signadds a TTL (default 300s). Captured events for an expired ctx will return 403invalid_signed_ctx. Re-rendering the component reissues the ctx; no in-place re-sign API exists yet.- Bind is server-rendered projection only — no client-side two-way binding, no live updates.
- Bind currently projects
valueonly.checked/selectedare not wired yet. ProvidesUiPartmethods are pure projections; no IO, no service calls, no external data providers.valueextraction on the wire body is intentionally minimal —partEl.valueif present, then thevalueattribute. Composite values (e.g.<select multiple>,<input type="file">) are out of scope for this slice.- Manifest entries currently target
value-bound events only. - The legacy
Semitexa\PlatformUi\Primitive\PrimitiveRegistrycoexists with the new attribute-driven registry; consumers should prefer the attribute-driven path.
Future runtime steps (post-this-slice)
- Multi-provider custom rule contribution: replace the full-registry-replacement model (this slice) with a contributor pattern so several modules can each add their own rules without coordinating on a single registry implementation. Rules continue to be sync + pure.
- Container-managed component instances: replace
UiInteractionDispatcher::instantiate()'s reflection-basednewInstance()with a container-aware path so components can declare#[InjectAsReadonly]dependencies directly. Today components opt into theUsesUiFieldRuleRegistrybridge — once container-managed instantiation lands, the bridge can drop and rule-registry-aware components use property injection like every other Platform UI service. - Multi-field / cross-field validation —
FieldComponent's rule today sees only its own value. A future slice introduces a server-side validation context that can read related fields (e.g. password + confirm-password) without storing state. - Richer patch ops:
setClass/addClass/removeClass,setHidden, conditionalsetTextwith templating, and aredirect(...)variant onUiInteractionResult— all under the same allow-list + instance-scoping invariants. - DI-managed component instantiation: container-aware resolution so handlers can declare typed dependencies in their component constructor.
- Atomic cache primitive in
CacheManagerInterface(SETNX /add) soCacheBackedUiReplayStore::claimcan drop its get-then-put race window. - SSE delivery semantics upgrade: at-least-once via Redis pub/sub fanout (today the bridge uses LPOP — at-most-once per claim), plus reconnect with
Last-Event-IDso a transient drop does not lose patches. - SSE channel revocation: an out-of-band revocation set (Redis key with token id) so an issued channel token can be invalidated before its TTL expires.
- Persistent component state — a server-side projection a handler can read/mutate, with SSE deltas published over the canonical KISS stream. The validation result type from this slice is the smallest shape that fits — the persistent state layer will wrap it, not replace it. (Patch shape stays the same.)
- Framework-layer unification: a
UiInteractionDispatcherInterfacecontract so SSR's/__ui/eventcan delegate to platform-ui's dispatcher; the two endpoints collapse into one. (The streaming half of this unification is done — all UI streaming now rides the single/__semitexa_kissstream onAsyncResourceSseServer.)