@reforgium/regula
v0.0.3
Published
Headless behavior and policy layer for Angular Signal Forms
Maintainers
Readme
@reforgium/regula
regula is a headless behavior layer above Angular Signal Forms.
It is not a new form engine, not a schema-driven renderer, and not a replacement for Angular forms APIs. The package should exist only if it helps move repeated form behavior out of app-level glue code into a small, composable, explainable set of policies.
At this stage that boundary matters even more because Signal Forms is still experimental.
regula should treat Signal Forms as the underlying host, not as an implementation detail it can freely absorb.
Problem
Angular Signal Forms can model field state well, but application teams still repeatedly solve the same behavior problems in ad hoc ways:
- value normalization while typing, on commit, and on submit
- error visibility rules
- disabled, readonly, hidden, and excluded semantics
- cross-field reactions and resets
- payload shaping for APIs
- server error mapping
- diagnostics for "why did this happen?"
Without a dedicated layer, this logic usually ends up split across effects, submit handlers, mappers, directives, and component-local utilities.
Intended Position
Angular should own:
- form structure
- field lifecycle
- validation execution
- public form primitives
- the experimental Signal Forms API surface
regula should own:
- behavior policies
- relational field logic
- model-to-payload transitions
- visibility and interactivity rules
- explainable derived form state
Non-Goals
regula should not become:
- a replacement form engine
- a UI schema renderer
- a layout builder
- a giant config DSL
- a private Angular API wrapper
- a CVA-centric abstraction layer
If the library starts describing the entire form instead of describing how an existing form behaves, scope has drifted.
Host Boundary
regula should treat Signal Forms as the default host, with the binding hidden behind the main Regula API.
That boundary still exists for two reasons:
- Signal Forms is still experimental and may change.
regulamust remain a layer above the host form system, not a replacement for it.
The default scenario should be:
new Regula(form, { rules })- host-form access hidden behind the main
Regulainstance - built-in payload and state reads over that host
One Regula instance should own behavior for one host form.
If multiple forms need to collaborate, coordination belongs outside regula.
The lower-level adapter contract should remain available only as an advanced option for non-Signal-Forms hosts.
Value Test
The library is worth building only if v0 can prove all of the following:
- A real form can adopt it without rewriting form structure.
- The public API stays smaller than the app code it replaces.
- Cross-field behavior becomes more explicit, not more magical.
- Diagnostics can answer at least basic "why" questions.
- The implementation does not depend on unstable Angular internals.
If those conditions are not met, the behavior should stay in app-level code instead of becoming a shared library.
Design Constraints
- Signal-first where possible.
- Deterministic execution and override order.
- Small composable policies over one orchestration object.
- Field behavior separate from rendering.
- Explicit separation between:
- input value, model value, payload value
- invalid state, visible error state
- hidden, disabled, readonly, excluded
- reset, preserve, derive
Proposed Public Surface for v0
v0 should stay narrow and headless.
- normalization pipeline
- validation visibility strategy
- disabled, readonly, hidden, excluded rules
- payload serialization
- minimal diagnostics reasons
- simple cross-field reactions for a few common cases
Everything else should justify its place later.
Implemented Surface
Current v0 already includes:
Regulamain class withnew Regula(form, { rules })- form-wide
defaultsfor baseline field behavior - field-centric rules
- built-in Signal Forms host integration
- auto-connected
resetOnrelationships for the default Signal Forms host resetOn: 'change' | 'empty' | 'initial'- field-level
derivefor pure derived-value updates - field-level
submit.canSubmit(...)payload gating - typed top-level
fields.*access from the host form shape - diagnostics summaries on
form.normalized(),form.payload(), andform.patch() - payload serialization and inbound patching
- regula-oriented field codecs/presets
re-form-fieldstandalone wrapper component
Example
Current usage centers on a main Regula class created through its constructor, with Signal Forms wired in directly:
import { Regula } from '@reforgium/regula';
const regula = new Regula(checkoutForm, {
defaults: {
errorVisibility: 'dirty-or-submit',
},
rules: {
email: {
normalize: {
commit: ['trim', 'lowercase'],
},
codec: 'trimmed-string',
patch: {
from: 'profile.email',
},
submit: {
include: true,
codec: 'trimmed-string',
},
},
country: {
derive: ({ field }) => String(field.value ?? '').toUpperCase() || undefined,
submit: {
include: ({ field }) => !!field.value,
},
},
city: {
disabled: ({ form }) => !form.fields['country'].value,
resetOn: {
country: 'change',
},
submit: {
canSubmit: ({ field }) => !field.disabled && !!field.value,
},
},
hasMiddleName: {
submit: {
include: true,
},
},
middleName: {
hidden: ({ form }) => !form.fields['hasMiddleName'].value,
resetOn: {
hasMiddleName: 'change',
},
submit: {
include: ({ field }) => !field.hidden,
},
},
},
});
const emailCommit = regula.fields.email.normalized();
const cityState = regula.fields.city.state();
const emailErrors = regula.fields.email.errorVisibility();
const emailRef = regula.fields.email;
regula.form.patch({
profile: {
email: ' [email protected] ',
},
});
regula.form.submitAttempt();
const payload = regula.form.payload();For the default Signal Forms host, resetOn relationships are wired automatically.
If that auto-connect step cannot be created, Regula now reports a diagnostic and throws instead of silently degrading.
Manual react(...) is still available as a low-level escape hatch, but normal host usage should not require template event wiring.
Available main APIs:
field(key)/fields.keyfield(key).snapshot()field(key).normalized()field(key).errorVisibility()field(key).state()field(key).react()for low-level/manual relationship triggeringform.snapshot()form.normalized()form.payload()form.patch(source)form.connect()/form.disconnect()form.submitAttempt()
Auto-connect notes:
autoConnectdefaults totrue- if relationship effects cannot be wired,
Regulareportsauto-connect:failedand throws autoConnect: falseis the intended path for manual/runtime-only scenarios outside Angular injection contextreportDiagnostics(entries)can be passed in the constructor options when using the built-in Signal Forms host path
Diagnostics:
form.normalized().diagnosticsform.normalized().diagnosticsSummaryform.payload().diagnosticsform.payload().diagnosticsSummaryform.patch(source).diagnosticsform.patch(source).diagnosticsSummary
Useful field-rule additions:
defaults.errorVisibilityfor form-wide baseline error visibilityresetOn: { source: 'change' | 'empty' | 'initial' }resetOn: { source: (ctx) => undefined }to reset the target to emptyresetOn: { source: (ctx) => nextValue }to reset the target to a derived valuederive: (ctx) => nextValue | undefinedfor post-cascade pure derived updatessubmit.canSubmit(ctx)for payload-level submit gating
Derive Boundary
derive is for pure derived values only.
Good fits:
- derive
unitfrom currentproduct - canonicalize or align a dependent field after a cascade
Bad fits:
- HTTP calls
- async work
- service writes
- store writes
- command-style side effects such as
setProduct(...)
If the need is "react to a field change and start async work", that belongs in app integration code, not in RegulaFieldRules.
There is also a live sandbox example at test-routing/regula, showing:
new Regula(...)as the central integration pointnew Regula(form, { rules })as the default host scenariore-form-fieldas a thin field wrapper centered on[regulaField]- email commit normalization
- form-wide default error visibility
- city disabled state derived from country and auto-reset on country change
- country value derivation after cascades
- middle name branch excluded from payload and auto-cleared when the toggle is turned off
- error visibility after submit attempt
- final serialized payload and omission reasons, including payload-level submit blocking
Form Field
re-form-field is intentionally narrow. It is a field wrapper, not a UI kit or renderer.
Primary usage:
<re-form-field
label="Email"
description="Commit uses trim + lowercase."
hint="Shown until validation becomes visible."
required
[regulaField]="regula.fields.email"
>
<input type="email" [formField]="checkoutForm.email" />
</re-form-field>Supported ergonomics:
labeldescriptionhinterrorrequired[regulaField]as the main Regula-driven input- optional
[field]fallback for non-Regula or bridge scenarios reFormFieldControlfor explicit control-level accessibility wiringreFormFieldPrefixreFormFieldSuffixreFormFieldFooter
Form Field Public Contract
re-form-field should be treated as a narrow field shell with a small public contract.
Public slots:
[reFormFieldControl]on the projected input/select/textarea/custom control that owns focus and validation semantics[reFormFieldPrefix]for leading affordances such as country code, icon, or currency sign[reFormFieldSuffix]for trailing affordances such as actions, counters, or status badges[reFormFieldFooter]for additional helper content below the main hint/error line
Public states:
- default: no prefix, no suffix, no error
- prefix only
- suffix only
- prefix + suffix
- disabled via
[field]or[regulaField] - hidden via
[field]or[regulaField] - hint visible
- error visible
Public accessibility behavior:
- the projected
[reFormFieldControl]receives a stableid - the label uses
for=<control id>and the control receivesaria-labelledby=<label id> - description and meta text are merged into
aria-describedby - visible error state sets
aria-invalid="true"on the projected control - disabled state mirrors to
aria-disabled - visible error text uses
role="alert"andaria-live="polite"
Anything deeper than these slots, states, and CSS variables should be treated as internal DOM structure rather than stable API.
Form Field CSS Variables
re-form-field keeps styling intentionally small, but the main layout and text tokens can be overridden via CSS variables.
--re-form-field-gap- outer vertical gap between field sections (0.375rem)--re-form-field-header-gap- gap between label and description (0.125rem)--re-form-field-disabled-opacity- wrapper opacity in disabled state (0.72)--re-form-field-label-gap- gap between label text and required marker (0.25rem)--re-form-field-label-font-weight- label weight (700)--re-form-field-label-color- label color (inherit)--re-form-field-description-font-size- description font size (0.75rem)--re-form-field-description-color- description color (inherit)--re-form-field-description-opacity- description opacity (0.72)--re-form-field-required-color- required marker color (#b42318)--re-form-field-control-gap- gap between prefix / control / suffix (0.625rem)--re-form-field-control-rounded- control row border radius (0.75rem)--re-form-field-slot-color- prefix/suffix tint (color-mix(in srgb, currentColor 72%, transparent))--re-form-field-prefix-padding-start- prefix inset (0.125rem)--re-form-field-suffix-padding-end- suffix inset (0.125rem)--re-form-field-meta-font-size- hint/error font size (0.75rem)--re-form-field-meta-color- hint color (inherit)--re-form-field-meta-opacity- hint opacity (0.78)--re-form-field-error-color- error text color (#b42318)--re-form-field-error-opacity- error text opacity (1)--re-form-field-error-font-weight- error font weight (600)
The older regula + path binding style is intentionally not part of the current surface.
Codecs
regula supports narrow bidirectional field codecs for the common case where:
- server or storage payload comes in one shape
- component or form logic works with another shape
- submit payload needs to be shaped again on the way out
Field rules can use:
- top-level
codec patch.codecsubmit.codec
Built-in presets are exported as REGULA_CODEC_PRESETS, and the resolver also supports preset names directly.
Typical uses:
- string trimming
- lowercased transport values
- empty-string/null bridging
- number/string conversion
- date-only formatting
The low-level serializer engine is shared through hidden @reforgium/internal, but regula keeps a smaller form-oriented codec surface.
Compound Payload Mapping
Some UI fields represent one logical input but do not match backend payload shape.
Typical example:
- PrimeNG date range keeps one field such as
period: [Date | null, Date | null] - backend expects either:
- one scalar string such as
period: "2026-04-01..2026-04-09" - or a payload fragment such as
{ start: "2026-04-01", end: "2026-04-09" }
- one scalar string such as
For this case, submit.as can choose between the normal field value path and root payload fragment merge:
period: {
patch: ({ source }) => [source.start, source.end],
submit: {
include: true,
as: 'fragment',
map: ({ field }) => {
const [start, end] = field.value as [string | null, string | null];
return {
start,
end,
};
},
},
}Use as: 'value' for the normal payload[field] = value path.
Use as: 'fragment' when one form field should emit multiple payload keys.
For backends that still want one scalar payload value, keep the normal field key path:
period: {
submit: {
include: true,
as: 'value',
map: ({ field }) => {
const [start, end] = field.value as [string | null, string | null];
return `${start ?? ''}-${end ?? ''}`;
},
},
}Typing
For the standard new Regula(form, { rules }) path, regula now binds field APIs and rule keys to the host form shape.
That means:
regula.fields.email.normalized().valueis inferred from the form field value typeruleskeys are checked against known form keysfieldsconfig keys are checked against known form keysctx.form.fields.product.valueis typed inside short-constructor rule callbacks
If submit.canSubmit(...) is present, you do not need to repeat include: true.
include already defaults to true when a submit block exists.
Scalar Fields
Typical scalar fields should not need manual type arguments:
const regula = new Regula(checkoutForm, {
rules: {
email: {
normalize: {
commit: ['trim', 'lowercase'],
},
},
},
});
const email = regula.fields.email.normalized().value;
// stringTyped Rules Map
If you want an explicit rules object before constructing Regula, use RegulaTypedFieldRulesMap:
import type { RegulaTypedFieldRulesMap } from '@reforgium/regula';
type CheckoutForm = typeof checkoutForm;
const rules: RegulaTypedFieldRulesMap<CheckoutForm> = {
email: {
errorVisibility: 'dirty-or-submit',
},
};Typed Options Helper
new Regula(form, options) remains the main and only construction path.
If you want to keep typed options in a separate constant without repeating the full Regula generics at the call site, use defineRegulaOptions(...):
import { defineRegulaOptions, Regula } from '@reforgium/regula';
const options = defineRegulaOptions<CheckoutPatchSource, CheckoutPayload>(checkoutForm, {
rules: {
email: {
patch: {
from: 'profile.email',
map: ({ source }) => source.profile.email.trim(),
},
},
},
});
const regula = new Regula(checkoutForm, options);Date Period Fields
Compound fields such as PrimeNG date ranges are still harder for TypeScript to infer perfectly, especially when the field value is a tuple or custom object.
For those cases, regula now helps with:
- key-safe field rules
- value inference for
regula.fields.period - field-level patch/payload support
But a local cast inside submit.map(...) can still be reasonable when the UI field shape is complex:
period: {
submit: {
as: 'fragment',
map: ({ field }) => {
const [start, end] = field.value as [Date | null, Date | null];
return {
start: start ? formatDate(start) : null,
end: end ? formatDate(end) : null,
};
},
},
}The goal is to keep as local to the compound field boundary instead of scattering casts across the whole form integration.
Not in v0
- broad async orchestration
- debounce policy framework
- state machine for sections or whole forms
- changed-only submission engine
- advanced server derive/reconciliation model
- renderer integration layer
- schema-driven configuration
Package Status
regula is still early and intentionally narrow, but it is no longer only a package scaffold.
What exists now:
- working Nx package
- implemented
Regularuntime - built-in Signal Forms host path
- auto-connected
resetOnrelationship wiring for Signal Forms - sandbox page at
test-routing/regula - initial
re-form-fieldcomponent - initial codec/patch/payload path
What still needs pressure:
- real-form validation against more than one consumer
- tighter relationship/reset cascade model
- continued pressure to keep
Regulaa layer above the host form system
Go / No-Go Questions
Proceed only if the answers remain "yes":
- Can this stay a behavior layer instead of a form platform?
- Can the first version solve concrete cases with a small API?
- Can the cascade model stay deterministic and debuggable?
- Can Angular integration stay behind a narrow host boundary?
- Will more than one package or app in the repo realistically reuse it?
