pseudo_ui 0.8.5 copy "pseudo_ui: ^0.8.5" to clipboard
pseudo_ui: ^0.8.5 copied to clipboard

Server-Driven UI rendering engine for Flutter. Define UI with JSON Schema + View JSON, render natively with Material 3 widgets. 45+ components, expression engine, conditional visibility, LOV cascading [...]

0.8.5 #

  • x-validation: maxField / minField built-in rules (US 473344). A numeric bound that lives in instance data — the bank-limit ceiling on the authority screen — cannot be a JSON Schema maximum (it takes only a literal number). The engine now evaluates maxField / minField itself: the value must not exceed / fall below the reference named by parameters.field, which is a dotted form-data path or an $instance.… expression. Numeric comparison; a value or reference that is not a number passes, so a bound that is still loading never shows an error. Localized errorMessages, re-judged when a referenced form field changes, same as equalsField.
  • Cross-field dependents are found under nested properties too. crossFieldDependents used to scan only the top-level properties, so a rule on limitDraft.accounts.timeDeposit.daily referencing its …transaction sibling was never re-judged when the sibling changed; it now walks the schema and returns dotted keys, the same form a nested bind has.

0.8.4 #

  • Locale-aware numeric input (US 471659). A TextField or NumberField bound to a numeric schema property (type is or lists number / integer) groups thousands and takes the decimal separator of the view language while typing — 400.000,5 in tr, 400,000.5 in en — with a numeric keyboard and the fraction digits the schema allows (integer → none, multipleOf → its step, else 2). The stored value is a num; the display carries the locale, the data does not. A pre-filled string is shown in the view language too ("18000,99" → 18,000.99 for en). Engine: deriveNumberFormat, PseudoNumberText; Flutter: PseudoNumericInput, PseudoNumberInputFormatter.
  • validateField: pattern / minLength / maxLength judge strings only. JSON Schema's string keywords no longer run against value.toString() of a number, so a stored 400000.5 is not rejected by a Turkish-text pattern on a ["string", "number"] property. Strings are matched as before.

0.8.3 #

  • Dropdown opens its menu BELOW the field, capped in height. The DropdownButtonFormField it was built on lays the menu over the field itself — the selected row on top of the field — and lets a long list grow to almost the full screen; on the restricted-account form the bank list covered the page title and the buttons. PseudoDropdown is now a Material 3 DropdownMenu: the menu is anchored under the field (flipping above only when there is no room below), takes the field's width, and scrolls inside kPseudoDropdownMenuMaxHeight (320). It is drawn like the menu it replaces — canvasColor, elevation 8, 2px corners, 16px row inset, titleMedium — not in the M3 tinted surfaceContainer grey. A select, not a combo box: no typing, search or filtering. Long options still wrap in the menu (#466608); the closed field stays one line. (#471456)

    • The field text is synced to the bound value on every build. DropdownMenu.initialSelection alone would keep a stale label when the value is cleared from outside (set, reset) and never show one for a value whose LOV options arrive after the first build.
    • The control is announced as a button carrying its label and value; the inner read-only text field is kept out of the semantics tree. With semantics on (the backoffice web host enables it), the browser focused that <input> on click and the engine sent SemanticsAction.focus to a node that cannot take focus — an assertion in debug builds.
    • PseudoInputDecorator.buildTheme / resolveLabel / resolveHint express the node's floatingLabel / focusOutline policy for controls that take an InputDecorationTheme instead of an InputDecoration.
    • Host widget tests that find the field by type must look for DropdownMenu<String> instead of DropdownButtonFormField<String>.

0.8.2 #

  • Text.tone — a semantic colour role for text. primary | success | error | warning | info | muted, resolved by the host theme (ColorScheme / PseudoUiThemeExt, legibility-guarded like Icon.variant), never a colour value; orthogonal to variant, which stays typography. A backend error message ($instance.kpsSummary.errorMessage) can now read in the error colour instead of body text.

  • Icons read in the page foreground; brand emphasis is per node. buildPseudoThemeData now sets iconTheme to onSurface — Material 3 painted a bare Icon in onSurfaceVariant, the muted grey that made every JSON icon: "info" look disabled. Deliberately no iconButtonTheme: that theme reaches every IconButton on screen (the AppBar back arrow, a text field's suffix, host chrome). An icon that IS the call to action says so with variant: "primary" — new on Icon.variant and on IconButton (variant, same roles as Icon) — and is painted in colorScheme.primary.

  • ForEach.pagination — long lists page instead of drawing everything. Two layers that compose: pageSize windows the rows already loaded and a 'load more' control reveals the next window; when source is $lookup.<name> and that x-lookup declares pagination (param, nextField, optional sizeParam / pageSize), the same control fetches the next page from the backend once every loaded row is shown and APPENDS it to $lookup.<name>. The cursor is SDK state (never form data); a reload of the lookup (watcher / lookup action) restarts the chain; the control hides when nothing more can be shown or fetched. Label from pagination.label, else the host's forEach.loadMore UI string. Engine: paginateForEach / fetchLookupPage; FormContext.lookupPaging + loadMoreLookup.

0.8.1 #

  • Dropdown: a long option WRAPS in the menu instead of being cut short. The field and the menu draw the same DropdownMenuItem.child, so the ellipsis that keeps the closed field to one line was also cutting every row of the open menu. On the account-permission screen that made two options read identically — "Tüm hesapları izleyebilir…" and "Tüm hesapları izleyebilir ve işlem yapab…" — which is the one moment a person is trying to tell them apart.

    The two needs are now met separately: selectedItemBuilder gives the field its own one-line ellipsized copy, while the menu item wraps. itemHeight is released so a wrapped row may be taller than the 48px default, and each row carries that 48px back as a minimum plus its own vertical padding — without it a one-line option falls under the minimum tap target and two wrapped ones touch, turning the menu into a block of text. Flutter only; the web adapters draw a native <select> and the browser already wraps. (#466608)

0.8.0 #

  • BREAKING — x-controlLabels is removed, and the SDK no longer carries any of the words a FilePicker says. It shipped in 0.7.1 and is gone four releases later, which needs saying plainly: the design in #117 put the widget's captions on the bound schema property, beside the field's own label and messages, and rejected a host dictionary at the time. That was wrong, and the reason is a rule that only became visible once the thing was in a real flow. Three kinds of text meet on a screen and each has a different owner: the FIELD's words (its caption, why it refused THIS document) belong to the schema property; the SCREEN's words belong to the view; and the WIDGET's words — "Dosya seç", "Kaldır", "Yükleniyor…" — are identical on every file field in every flow and change only with the application's language. Writing them on a property meant every schema that ever grows a FilePicker carries its own copy of "Kaldır", and a typo in one of them is a schema deploy.

    What replaces it:

    • PseudoView.uiStrings — a host-fed key → text map, where a text is either a plain string or the same {tr, en} shape x-labels uses. It is threaded onto FormContext and reaches nested Component subtrees. See samples/ui-strings.json.
    • engine/ui_strings.dart — resolveUiString(dict, key, lang), which returns '' for an entry the host has not written. It does not decide the fallback; the caller does.
    • filePicker.* — the node's whole vocabulary under one prefix: tooLarge, wrongType, emptyFile, tooMany, tooLargeTotal, failed, unavailable, none, limit, limitMulti, count, choose, add, replace, remove, clear, busy.

    THE KEY IS THE FALLBACK. An entry the host has not written renders as filePicker.remove on screen. That is deliberate: an English fallback looks correct in review and ships a Turkish screen with English buttons on it, where a key is visible to whoever opens the screen and greppable in a repo.

    A field may still word its OWN refusals — x-errorMessages now takes tooLarge, wrongType, emptyFile, tooMany, tooLargeTotal, failed, unavailable — because "Onay formu 3 MB'ı aşamaz" is about that document on that field. It may NOT rename a caption. composeFilePickerText is where that order (field → host → key) is written down, once.

    Migration. Delete x-controlLabels from every schema; it is ignored and getFieldControlLabel no longer exists. Rename the refusal keys under x-errorMessages: oversize → tooLarge, empty → emptyFile, count → tooMany, total → tooLargeTotal, readFailed → failed, pickerUnavailable → unavailable. Pass uiStrings: to PseudoView — a host that passes nothing gets keys on screen, which is the intended way to find out.

  • BottomSheet overlay: own chrome, capped height, scrolls inside. The sheet's children were laid out in a bare Column(min), so a long list either overflowed (a ScrollView child cannot bound itself in a Column) or, with Expanded, stretched the sheet to the whole screen. The sheet now draws a drag handle (dragHandle, default true), an optional pinned header from title with a close control, and puts the children in a scroll area capped at 85% of the screen — short sheets stay short, long ones scroll inside. Authors need neither Expanded nor ScrollView in a sheet (Bank limits sheet, user-permission flow).

0.7.4 #

  • Overlay Dialog (visible set): a tall body scrolls inside the AlertDialog. The hosted-mode fix (#465478) left overlay mode as it was; a visible-driven Dialog with more content than the screen (Yetki Tanımı → Rol Açıklamaları, "BOTTOM OVERFLOWED BY 158 PIXELS") still overflowed. The body now sits in a Flexible scroll area — AlertDialog already bounds its content, so no layout probe is needed — while icon, title and actions stay put. Short bodies keep their natural height. (#466085)

  • Component reloads when its ref changes in place. A page whose root is Component ref: A updated to Component ref: B by the next surface (a form composite replaced by the completion composite after cancel → retry → approve in change-password) kept A's loaded view and fed it B's params: B's title over A's password fields, an empty button where the param names differ. didUpdateWidget now resets the composite (view, schema, LOVs, lookups, errors) and loads the new ref. Web adapters already reloaded on ref. (#465719)

0.7.3 #

  • Flutter icon map extension announced in 0.7.2 ships in this release — arrow_forward, smartphone and ~60 common Material names; the 0.7.2 package did not carry the change.

  • Dialog.icon accepts a host-resolved source (asset URN) for the hero, like Icon.name / IconButton.icon.

  • Card.selectionIndicator (radio | checkbox) — on a selectable card (leading select action) an explicit glyph at the trailing edge mirrors the selection; the border highlight alone did not read as "pick one". Selectable cards also honour padding.

  • Hosted Dialog: a tall body scrolls inside the popup frame. The feedback panel gave its children unbounded height, so a ScrollView inside it never scrolled and the panel overflowed the host's dialog (Yetki Tanımı → Banka Limitleri: 88 cards, "BOTTOM OVERFLOWED BY 6530 PIXELS"). When the frame bounds the height the body now sits in a Flexible scroll area while icon, title and actions stay put; under an unbounded parent (a page ScrollView) the body flows as before. Overlay mode (AlertDialog) is unchanged. (#465478)

  • Bare-string texts are host localization keys first. resolveTextContent offers a plain string (Text.content, Button.label, AppBar.title, any textContent) to the new PseudoViewDelegate.localizeText(key) and renders it verbatim when the host returns null. A view can therefore carry either { "tr": …, "en": … } (resolved by the renderer, unchanged) or the localization keys the legacy neo_* widgets already read, so backends need one text format for both hosts. $… expressions stay data. Hosts that do not override the hook see no change. Parity: React / Vue / Angular.

0.7.2 #

  • RadioGroup rows are tappable on the label, not only on the circle.

  • Skeleton component — shimmering placeholder blocks (rows, variant: text | card, height, gap) for content still loading.

  • ForEach.search — client-side filter of an already-loaded list: {"query": "$form.q", "fields": ["iban", "accountName"]} keeps rows where any field contains the query (case-insensitive, Turkish ı/İ folded). For $instance / $subFlow lists a SearchField needs no backend round trip; server-side search stays an x-lookup re-run by a watcher.

  • ForEach.loading — node rendered while source has not resolved yet (read-through in flight), typically a Skeleton; an empty list still renders nothing. ForEach.gap — spacing between rows.

  • Card.bordered (opt-in hairline, theme outline, 12 px radius) and Card.padding (spacing token) — a flat card on a same-coloured surface can be made visible and given air without changing variant.

  • Overlays (BottomSheet / Dialog / SideSheet) are live. Content is re-rendered on data changes while open (a RadioGroup in a sheet shows the option it just wrote) and writing the visible flag false from inside (a Cancel button's set $ui.x = false) closes the route.

  • Icon.name accepts a host-resolved source (asset URN) like IconButton.icon — a ListTile.leading can be a brand SVG.

  • Flutter icon map extended. arrow_forward (every wizard's Continue button) and ~60 more Material names resolve instead of the "?" fallback.

0.7.1 #

  • set accepts a bare schema field as data ("data": "restrictionType" → $form.restrictionType), as the vocabulary always documented. It was dropped with a "must be prefixed" warning.

  • Watchers fire on async sources. A watcher whose on reads $subFlow.* / $subProcess.* or $lookup.* now runs when that read-through lands, not only on the next form / uiState change. This is how a view seeds a field from a sub-flow's current value: {"on": "$subFlow.<state>.data.x", "action": {"action": "set", "data": "$form.x", "value": "$subFlow.<state>.data.x", "mode": "fill-empty"}} — the parent's record does not carry the child's data by design, so the instance auto-seed cannot. (Vue adapter aligned; React already re-ran on its data tick.)

  • Dropdown no longer overflows on long labels. isExpanded + ellipsis on the selected item; a four-line x-enum label used to paint a "RIGHT OVERFLOWED BY N PIXELS" stripe over the field.

  • List-valued x-lookup results (search lookups). fetchLookupData and PseudoView now store a List at resultField as-is (deep-copied), so a view can iterate it with ForEach source: "$lookup.<name>" — the search-box pattern (SearchField → watcher → lookup). Previously a non-Map result was dropped silently, which is why the same view listed results on web (whose loader accepts any object) and stayed empty on Flutter. FormContext.lookupData, PseudoView.onDebugData and PseudoViewDelegate.onNestedLookupData are typed Map<String, dynamic> accordingly (a Map<String, Map<String, dynamic>> still satisfies them).

  • TextField.trailing — a text action inside the field. { label, action?, command? } renders a compact TextButton in the input's suffix, left of the password reveal toggle, and dispatches through the ordinary action pipeline without validating the form: a login form's "Forgot?" must not be blocked by an empty required password. action defaults to submit; the host interprets command (e.g. urn:vnext:flow:start:… opens another flow). Optional tone (success/warning/error/info) colours the label from the palette's status roles via PseudoUiThemeExt — no literal colours in JSON. Vocabulary: $defs/inputTrailingAction. Parity: React / Vue / Angular.

  • FilePicker refusals now read from the schema's x-errorMessages, like every other field's message. The wording was a fixed English string — the one thing in this vocabulary a definition could not translate — so a Turkish form showed an English refusal beside Turkish labels. The five verdicts and the two capability failures (oversize, wrongType, empty, count, total, readFailed, pickerUnavailable) now resolve through the same getFieldErrorMessage as required and minItems, with the same {tr, en} shape. {size}, {limit}, {type}, {accept}, {max}, {total} and {totalLimit} are filled in; a message written without placeholders is shown verbatim, and an undeclared key keeps the built-in English so nothing that renders today changes. The engine takes a lookup FUNCTION rather than a schema, which is what keeps file_picker.dart free of imports.

  • x-controlLabels — the captions a widget draws itself, declared on the schema property. Every other input shows exactly two kinds of text and both already came from the bound property: the label (x-labels, which floatingLabel: 'never' also renders as the placeholder) and a message (x-errorMessages). FilePicker is the first control with CHROME of its own — "Choose file", "Replace file", "Remove", and the line stating the ceiling — and those had nowhere to come from, so they stayed English when the person switched the app to Turkish.

    They now read from x-controlLabels through getFieldControlLabel, which is getFieldErrorMessage with a different block name and the same {tr, en} shape. Keys: choose, add, replace, remove, clear, busy, hint, hintMulti, limit, usage. The sentences about limits take {perFile}, {maxFiles}, {total}, {used} and {count}; the button captions take none. An undeclared key falls back to kFilePickerControlDefaults, the exact text the adapters rendered before this existed, so a definition that declares nothing renders unchanged. No FilePicker string is hardcoded in any adapter any more.

0.7.0 #

  • FilePicker — ask the person for a document. THE ONE INPUT A DEFINITION CANNOT COMPOSE: no arrangement of Button, ForEach and select makes an operating system offer its own filesystem. Everything around the dialog stays where it already lives — label from the schema's x-labels, requiredness from its required, visibility from x-conditional — and the picked file lands in bind like any other field's value, so a required-but-empty picker stops the submit at exactly the gate a required-but-empty text field does. There is no upload: the file becomes base64 in the form data and rides the ORDINARY TRANSITION BODY. The stored value is {name, mimeType, size, content}, content base64 with no data: prefix.

    maxSizeBytes defaults to 1 MiB and cannot be switched off, because the base64 sits in the instance data and is carried on EVERY read of that record while inflating by 4/3 — a 1 MB document is ~1.37 MB fetched again on each state change, poll and attribute-reading list. multiple: true asks for a SET: the bind ALWAYS holds an array, picking ADDS instead of replacing, order is arrival order, and maxFiles (5) and maxTotalBytes (4 MiB) join the per-file ceiling. An untouched picker writes NOTHING — absent, never [].

    KNOWN LIMITATION: the node's refusal wording is a fixed English string and, unlike a schema-declared message, cannot be overridden through x-errorMessages. A host-fed string dictionary is tracked separately.

  • FilePicker.fields renames the four stored keys to a backend schema that already exists — IDM/DYS spell the same facts {fileName, contentType, fileContext}. Absent means the canonical shape, which is what a new schema should use. It renames ONLY; an omitted key keeps its canonical name.

  • PseudoViewDelegate.readFile — the platform half of the picker. On Flutter it BOTH OPENS THE DIALOG AND READS THE BYTES, because this package takes no file-picking dependency: the plugin a host already ships (file_picker, a camera, a document provider) is the one that must be used. The consequence worth stating is that the bytes are read BEFORE the gate rather than after it; the gate itself is unchanged and still the engine's, so the same files are refused for the same reasons with the same numbers. Optional and defaulted, so no existing delegate breaks.

  • Fixed: a required list field with nothing in it passed the submit gate. The gate asked for null / empty-string and [] is neither, so an empty array read as an answer; minItems / maxItems were declared and never enforced. Both now run. Pre-existing and independent of FilePicker — the multi-mode picker is what made it visible.

  • Fixed: the form-data debug log published the whole record, base64 included. For a form holding a FilePicker that is a customer's identity document written to the host's log sink on every keystroke. withoutFileContents now replaces anything file-shaped with its LENGTH and leaves every other value alone.

0.6.0 #

  • Size tokens now resolve through the theme: new PseudoMetrics ThemeExtension. The pixels behind gap / padding / Icon.size / Avatar.size were const maps inside the widgets, so lg measured 24 logical pixels for every app that embedded the renderer. They are now one overridable extension — install PseudoMetrics(iconSize: {...}, spacing: {...}, avatarRadius: {...}, hostIconSize: ...) in ThemeData.extensions and a design system on its own grid renders the same JSON at its own metrics. Defaults are exactly the previous numbers, and a PARTIAL ladder is honoured (an unmentioned token falls back through the same ladder's md / sm, never to zero). Resolvers: pseudoSpacingOf / pseudoIconSizeOf / pseudoAvatarRadiusOf; the context-free pseudoGap stays for registered component builders and now reads the same default ladder, so the two cannot drift.

  • IconButton.size — the same sm / md / lg / xl token Icon takes, driving both a Material glyph's size and the box a host-drawn icon is given. Nothing in the widget names a pixel any more.

  • Icon.size accepts xl. The Flutter adapter has rendered it since the ladder was written; the vocabulary only declared sm / md / lg, so a valid-looking view failed strict validation. Both now share one iconSizeToken def.

  • IconButton can carry a host's own icon, and has an accessible name. icon is resolved against the built-in Material map first; a name that is not in it — a host's asset URN, say — is now handed to the same PseudoImageBuilderDelegate seam that Image nodes use, so a host that owns an icon set draws it without the SDK bundling or naming any of it. Only when neither resolves does the placeholder glyph appear. This is what a backend-declared AppBar needs: its actions are the host's brand icons, and without the seam the bar filled with question marks.

    New vocabulary field IconButton.label is the button's ACCESSIBLE name, not visible text: it becomes the tooltip (which Flutter also exposes to the accessibility tree) and aria-label / title on the web adapters. Drawing it would defeat the reason an app bar action is an icon in the first place — an icon has a fixed width and can never squeeze the title off the bar the way a long or unresolved text label does.

  • Scaffold: a view can now declare its own PAGE FRAME. New root-only node type carrying appBar, body, and optionally bottomNavigationBar / floatingActionButton. Rendered as a real Material Scaffold, so the top app bar — its title and its actions — comes from the view JSON instead of client code. PseudoView cooperates: a Scaffold root gets neither the outer padding nor the wrapInScroll wrap, since the frame owns the page and body declares its own scrolling.

    PseudoView.padding is ROUTED rather than dropped: the host's page metric belongs on the content, so it is applied to the frame's body (new PseudoScaffold.bodyPadding) and the chrome stays flush with the screen edges. Without this a Scaffold-rooted page rendered edge-to-edge, since the vocabulary has no padding primitive of its own.

    Host contract: viewDeclaresOwnFrame(view) (new, exported from the engine) is the one question a host asks before framing a surface. When it answers true the host must render the view bare — no app bar of its own, no page padding, no safe-area inset — or the page grows two app bars. Only full-page presenters should ask; a popup or bottom-sheet host must ignore it.

  • AppBar is now a real AppBar. PseudoAppBar implements PreferredSizeWidget and returns a Material AppBar instead of the previous Container + Row — that interface is what lets a JSON-declared bar occupy the Scaffold.appBar slot rather than being faux content inside the body. Two new parameters carry the difference between the two call contexts, and the CALLER sets them, not the JSON: as frame chrome (primary: true, automaticallyImplyLeading: true) the bar clears the status bar and, when the view declares no leading, Flutter draws the standard route-popping back button for free; as ordinary body content both are false, so a bar in the middle of a scrolling page gets neither a status-bar inset nor a spurious back arrow. variant: "center" centres the title; medium / large still render as small (they need a SliverAppBar, which a frame slot cannot provide).

    Visual change: an AppBar used as body content now looks like a real app bar (full-bleed, square) instead of a rounded surfaceContainerHighest chip. The only known content doing that is the component-showcase sample's catalog swatch, where the new rendering is the more accurate preview.

  • Component.ref may be a resource URN. The view vocabulary accepted only a plain slug; ref is now oneOf two named defs, plainRef (^[a-z0-9-]+$) and resourceUrn (urn:<ns>:res:<res-key>:<domain>:<key>[:<version>]), so a view can embed a composite that another domain owns. Resolution stays with the host's loadComponent; the renderer never validated ref at runtime, so behaviour is unchanged.

0.5.1 #

  • Form auto-seeded from instanceData (SDK rule). A bound field opens on the record's value under the same key: for every top-level schema property present in instanceData, PseudoView deep-copies it into the form when the form is (re)built (seedFormFromInstance, exported from the engine). Nested maps come along, so dotted binds (limitDraft.accounts.timeDeposit.daily) resolve. Precedence: schema default < instance value < host formData. This is the one rule shared by all adapters and by the workflow manager — hosts no longer pre-fill the form by hand, and the same view opens identically on web and mobile.

  • PseudoView gained formData — host-supplied INITIAL form values (an explicit draft), keyed by bind path (nested maps for dotted binds). Parity with the TypeScript adapters' formData prop. Applied last, over defaults and the instance seed. Deep-copied on apply (deepCopyJson), so form writes never mutate the host's map; a changed formData instance rebuilds the form like a changed instanceData does.

  • ImagePicker.initialValue is deprecated. Still honoured (one warning is logged per process); expose the current value under the bind key in the instance data instead — the auto-seed pre-selects it.

  • Composites ship inside the package. composites/<ref>/{view,schema}.json at the repository root — today flow-completion-page (success / error end screen) and update-password-page (old / new / confirm password form with a live rule checklist) — is embedded as generated source, so a host answers {"type": "Component", "ref": "update-password-page"} without a component endpoint, a per-domain publish or a gateway route: the backend view only names the ref, the client renders the SDK's JSON. BundledComposites.load(ref) is shaped as PseudoViewDelegate.loadComponent for a one-line hand-over; get returns null for an unknown ref so a host can fall through to its own source; has / refs list what this build knows. The delegate seam is unchanged — a host can still serve refs from anywhere. Same folder, same API on the TypeScript side (loadBundledComposite). scripts/sync-composites.mjs regenerates both embeddings (--check for CI).

0.5.0 #

  • set action verb. A declarative field write: {"action": "set", "data": "<bind>", "value": <literal-or-$expr>} writes value into the bound field without reaching the host and clears any stale error on that field. It is the supported way for a Switch, a watcher case, or an action list to seed or reset form data.

  • lookup action verb + trigger: "manual". {"action": "lookup", "name": "<x-lookup>"} loads a named x-lookup on demand and is awaited, so a following set in the same list reads the fresh $lookup.<name>.*. An x-lookup declared with trigger: "manual" is skipped by the initial load and fetched only when a lookup action asks for it.

  • select is deprecated in favour of set. select still works unchanged (a bare bind defaults to $form), but its first use logs a one-time deprecation warning naming set as the replacement. Existing views keep rendering; migrate at your own pace.

  • $default expression namespace. $default.<field> resolves to a schema property's default (walking nested properties), so a view can seed or reset a field to its schema default without hard-coding the value.

  • Finance-domain icon names now resolve everywhere. The renderer kept a private icon map missing the banking glyphs (credit_card, security, transfer, payment, deposit, withdrawal) that the shared pseudoIconMap already carried, so those names drew the help_outline fallback on built-in nodes. The two maps are now one; a node asking for credit_card gets the real icon.

  • Type-safe action / operator / namespace models (public API). The engine exposes enums that were previously magic strings: PseudoAction (reserved verbs and their ActionSpec), ConditionOperator (x-conditional and watcher cases), ExpressionRoot ($form, $instance, $default, …) and PseudoComponentType (node type). Comparing against these instead of raw strings turns a typo into a compile error.

  • Internal: DynamicRenderer split into per-component widgets. The ~3000-line renderer that switched on node.type inline is now a thin dispatch over one Pseudo* widget per component, sharing a FieldBinding for form access and action dispatch. Purely structural — no behaviour change, verified against the full test suite.

0.3.4 #

  • Image nodes can be drawn by the host. The SDK only ever showed http(s) sources through Image.network; anything else — a brand illustration, a bundled SVG, a URN the backend and its clients agree on — landed on the grey placeholder with no way for the app to step in. Two seams on the delegate the host already injects close that, consulted in this order before the built-in behaviour: a delegate that also implements PseudoImageBuilderDelegate.buildImage(context, request) → Widget? draws ANY Image (return null to decline), and PseudoViewDelegate.resolveImageSource(source) → String? (also on the TypeScript delegate) maps a non-URL source to a URL. No global registration: nested components and overlays see the seam through the delegate they inherit. Image.source is now expression-resolved too ($instance.illustration), as the vocabulary always said. Nothing implemented ⇒ unchanged behaviour. fit: "fill" maps to BoxFit.fill instead of cover.

  • Validation errors inside a nested Component persist (Flutter). The child FormContext was rebuilt on every build with a fresh errors: {}, so an error raised by a submit INSIDE a composite (required, minLength, pattern) was wiped by the very repaint it triggered: the button did nothing and no message ever appeared under the field. Field values were unaffected — they round-trip through the parent via bind — which is why input-less composites never showed it. The error map now lives on the nested widget's State, like its LOV and lookup data, matching React's NestedComponentWrapper that keeps its context in a useRef. Errors still clear the moment the field becomes valid. Prerequisite for form composites such as the shared password-input page.

  • validateField accepts a list-typed type. JSON Schema allows "type": ["string", "object"] — a composite's text input that takes a plain string or a {tr, en} map. The as String? cast on type threw for such a property and, because the submit path validates every property of the schema, one list-typed property crashed the whole action ("Action pipeline crashed"): nothing dispatched, no error text. TS compares with === and never had the problem; Dart now does the same.

  • Numeric keyboard survives lookarounds in pattern. The digits-only scanner behind keyboardType / inputFormatters bailed out on any group, so a business-rule pattern such as ^(?!(\d)\1{5}$)(?!123456)[0-9]{6}$ (6-digit PIN, not all-same, not a run) opened an alphanumeric keyboard while its sibling ^[0-9]{6}$ fields opened a numeric one. Lookarounds are zero-width — they decide WHICH digit strings match, never what characters make them up — so the scanner now steps over (?=…) (?!…) (?<=…) (?<!…) and lets the consuming tokens decide; other groups remain unmodelled (no restriction). Mirrored in TS (inputmode=numeric).

  • command may be a $ expression. "command": "$param.submitCommand" (or $ui.…, $instance.…) is resolved on the node's context before the action reaches onAction; literals pass through untouched and the SDK still does not interpret the value. Lets a shared composite carry its own submit button while the host view supplies the flow's transition URN through bind — instead of leaving the host to guess the transition. An expression that resolves to nothing dispatches without a command and logs a warning. Engine helper resolveCommand; mirrored in TS.

  • Icon gained variant (success / error / warning / info): a theme colour ROLE, resolved exactly like the Snackbar variants — PseudoUiThemeExt success/warning/info colours (documented fallbacks) and ColorScheme.error. JSON still never carries a colour; omitted → theme icon colour. Mirrored on web as d-icon--<variant> / pseudo-icon--<variant>.

  • Node-level x-conditional is now part of view-vocabulary.json: every component definition accepts it ($defs.nodeConditional), matching what the Flutter renderer already evaluated and what the TS adapters now evaluate.

  • x-conditional gained the matches operator — a regex test on the field's string form (empty never matches, an invalid pattern is false, not a throw). With node-level x-conditional this renders a live rule checklist (✓ / •) purely from backend JSON. Mirrored in TS; vocabulary updated.

  • x-validation object form is evaluated by the engine. { "rule": "equalsField" | "notEqualsField", "parameters": { "field": "<sibling>" }, "errorMessages": {…} } is a built-in cross-field rule — password / confirm is the canonical case — checked on change and on submit, and re-judged when the SIBLING changes so a fixed newPassword clears the stale error under its confirm field. x-validation: true (and any unknown rule) now reaches the host's onValidationRequest on Flutter too — it was declared on the delegate but never called — and the host's verdict survives the synchronous submit pass. Mirrored across React, Vue and Angular.

  • DatePicker opens a calendar (Flutter). The field was a plain text box that expected an ISO string; it now opens showDatePicker, shows the value as dd/MM/yyyy, keeps storing yyyy-MM-dd, handles the empty value, and a dotted bind writes into the nested object it names. (#459236)

  • View-level watchers[] (Flutter). A view may declare watchers with an on expression and actions; the engine's WatcherRunner fires a watcher only when the watched value CHANGES (never on first render), writes to formData / uiState directly, cascades until quiescent (bounded), and hands non-internal verbs to the host like onAction. Switch gained an action/command dispatch on toggle, and text inputs keep their controller in sync when a watcher (or a select list) rewrites the bound value. (#459237)

0.3.3 #

  • A node's action LIST is dispatched again (Flutter). "action": [{select}, {select}, {submit}] — the "write these values, then advance" gesture a row uses to pick a record — reached the host as the stringified list and was dropped as an unknown verb. _handleAction now fans the list out in declaration order and resolves a descriptor's own action / command (descriptor.command ?? command), matching the engine's dispatchAction. Since select writes are synchronous, a trailing submit reads a formData that already holds them.

  • Card taps go through the shared action path. The card had a hand-rolled handler that read at most the first TWO entries, required both to be select, and never dispatched — so a tappable row wrote its binds and then silently went nowhere. It also read only the legacy onTap, ignoring the action field the vocabulary documents as preferred and as the winner when both are set; a Card written to spec had no tap handler at all. The radio-style selected outline is unchanged.

  • select resolves an expression value. "value": "$item.tckn" inside a ForEach template was written to the bind verbatim, so the row selected the literal string rather than its own record. Only the Card's old private handler resolved this; the shared path now does, for every caller. Both the dispatch path and the Card's selected-outline check read it through one helper, so they cannot drift to different values for the same descriptor.

  • A list is dispatched SYNCHRONOUSLY. An earlier revision awaited each entry so multiple host dispatches would reach the backend in declaration order. Nothing needs that: no view declares more than one host-dispatching entry, and the shape that exists ([select, select, submit]) already works synchronously — select writes return before any await, so the trailing submit reads a formData that holds them. Awaiting only opened a window between entries in which the host could dispose or replace the surface, so a select landed in an orphaned form map and a trailing submit dispatched on a surface that was gone. Staying synchronous closes that window by construction rather than guarding against it.

    A rejected host dispatch is still reported (Main dispatch rejected: <verb>) instead of surfacing as an unhandled async error, and each entry is isolated so a throw from one — a malformed x-conditional failing the validation loop's cast, say — drops that entry instead of abandoning the list.

  • A resolved field stops blocking submit. The validation loop wrote to ctx.errors but never removed a key that had become valid, so the FIRST failed submit locked the screen: every later submit saw a stale error map and returned before dispatching. Matches React's if (err) … else delete ctx.errors[key].

    A hidden field's error is cleared rather than skipped, along with anything keyed UNDER it. Its widget renders as SizedBox.shrink(), so the onChanged that would otherwise remove the error can never fire again — a field that failed while visible and was then hidden by an x-conditional used to leave the button dead forever, with nothing on screen to explain why. Input widgets key errors by bind path, so hiding an object has to clear contact.iban and not only contact. Safe because the whole subtree is hidden: a field the user cannot see must not block submit.

    Still open: an x-conditional on a NESTED property. The loop walks top-level properties only, so it never enumerates that field to notice it is hidden.

    PseudoView also clears _errors when the surface changes. Only keys in the CURRENT schema's property list are recoverable, so an error left by the previous surface blocked every submit on the next one.

    Scope: this covers the keys the loop itself manages — top-level schema properties. Input widgets key their errors by bind PATH (ctx.errors['selectedUser.tckn']), which the loop never enumerates, so an error a widget set on a nested bind still needs that widget to clear it. Clearing nested keys from here would be wrong, not merely incomplete: the loop validates the object-level schema, which may not enforce a nested pattern, so removing them would let an invalid value through.

  • An undispatchable action disables every control, not just Card. IconButton, FAB and Menu gated on action != null, so {}, [] or [null] left an enabled control that reacted and did nothing but log.

  • A failing entry no longer abandons the rest of the list. Only the host dispatch was wrapped, so a throw from anywhere else — a malformed x-conditional in the schema failing the validation loop's cast, for instance — escaped to the outer net, logged one generic line and dropped every remaining entry. With [{select}, {select}, {submit}] that left the selects applied and the submit silently gone. Each entry is now isolated.

  • A Card whose entries all get dropped stays inert. The guard covered only null and [], so {}, [null] and a list whose every entry fails the verb guard still produced a card that rippled and did nothing.

  • A tappable Card clips its ink splash. Card defaults to Clip.none, so the splash painted as a square past the rounded corners.

  • A select on a namespaced bind paints the Card as selected. Routing Card writes through the shared path made them strip the namespace prefix ($ui. → ctx.uiState, $form. → form data), but the selected-outline check still read the RAW bind, so it walked form data looking for a literal $ui key and always found null — a radio-style card with a $ui. bind could never look selected. The read now mirrors the write.

  • A descriptor with no verb is dropped instead of stringified. command, bind and validate were type-checked but the verb was not, so a descriptor missing its required action (or an empty {}) sent the literal string "null" to the host — ungated by validation and with nothing logged.

  • Clearing errors requests a rebuild. onFormDataChanged fired only when validation FAILED, so a validate: true non-submit that passed after an earlier failure left the stale error text on screen (that verb usually does not replace the surface). Fired only when the pass actually cleared errors — doing it unconditionally would add a full LOV reload to every clean submit, since onFormDataChanged(null) schedules _loadLovs.

  • Declared action hooks warn instead of vanishing. preHooks / postHooks are allowed by actionDescriptor and run by the engine's dispatchMainWithHooks, but the Flutter adapter drops them — full parity needs a 4th onAction argument (ActionDispatchContext) that would break every host. They now log at warn so a missing analytics event is diagnosable.

0.3.2 #

  • Nested (dotted) bind paths now work end to end. A bind carrying a . — enteredEmails.address, limitDraft.daily — used to fail on both halves: getSchemaProperty did a flat properties[bind] lookup and returned null, so the field rendered with no label, no validation message and none of the derived input constraints; and the widget wrote the user's input to a FLAT key (formData["enteredEmails.address"]), so the backend saw the sub-property as missing on submit. The resolver now walks properties segment by segment, and every form widget reads and writes through path-aware helpers that create the intermediate maps on first write. Single-segment binds keep the old single-map fast path. This closes the parity gap with the TypeScript engine, which has always treated a dotted bind as a path.

  • ScrollView renders all of its children. It previously rendered children[0] and silently dropped every sibling, so the common ScrollView → [Column(form), Row(actions)] shape showed the form and lost the buttons — users could see the state but not submit it. A single child still renders as-is; multiple children stack in a stretching Column so inputs keep the full viewport width. Same class of bug that was fixed in Card earlier and left unfixed here.

  • ImagePicker gained initialValue. Seeds formData[bind] once, only while the field has no value yet, so a server-side current choice renders pre-selected and submits unchanged when the user does not touch the grid — the anti-phishing image-change flow passes "$instance.currentImage.imageDefinitionId". '$'-prefixed values are expressions; anything else is a literal. A user tap overwrites it like any selection, and an unresolvable expression leaves the field unset.

0.3.1 #

  • Typing-time input constraints, derived from the bound schema. A digits-only pattern (^[0-9]*$, ^\d{6}$) or type: integer opens a numeric keyboard and rejects non-digit characters as they are typed; maxLength (or a fixed-width pattern) caps the field. Until now those keywords only produced an error message after the fact, so fields that are numeric by contract — password, customer no, OTP — happily accepted letters. Derivation is conservative: a pattern whose syntax the scanner does not recognise imposes no restriction, and no vocabulary change is needed, backends opt in per property.

  • Text inputs are keyed by bound field + derived contract. Flutter does not push a changed keyboardType onto a live input connection, so a reused element kept the previous state's numeric keyboard — and its controller text — after a surface swap.

  • Dialog gained a HOSTED mode and a variant. With visible omitted the node now renders INLINE as a feedback panel (hero icon, title, body, stacked full-width actions) instead of rendering nothing; that is the shape a content.display: "popup" surface needs, because the host already supplies the frame. variant (info / success / warning / error) picks the icon and the accent colour from the ColorScheme in both modes, and an explicit icon still wins. Runtimes no longer have to hand-roll error popups out of raw Column/Text/Button trees.

  • The soft keyboard closes when an action is dispatched. It used to stay up over the next surface and over the host's loading overlay until the user dismissed it by hand. select actions are exempt, so tab switches and radio-style pickers do not interrupt typing.

  • Vocabulary fix: Button.action was oneOf [buttonAction, actionDescriptor], but a plain "submit" satisfies both branches, which made strict validators reject every string-action Button. Now anyOf.

0.3.0 #

  • Brand theming is now complete in the engine. buildPseudoThemeData gained everything a brand previously needed a bespoke theme builder for, so a consumer supplies a PseudoColorPalette (Dart class or brand JSON) and nothing else:

    • Contrast-guarded foregrounds. onPrimary, onSecondary, onInverseSurface and the button labels are checked against the background they are painted on (WCAG 1.4.11, ratio ≥ 3.0) and fall back to a legible tone when a brand's token pairing collides — the green-on-green / blue-on-blue class of bug where labels vanish. Pairings that already clear the threshold are untouched, so brand intent is kept. The ColorScheme role and the button that paints on the same background are resolved once and shared, so they can no longer disagree.
    • Inverted chrome roles. New PseudoColorPalette.inverseSurface / onInverseSurface (M3's snackbar / tooltip / inverted-chip surfaces), defaulting to onSurface / surface. Brands with a softer chrome tone override them; JsonPalette reads palette.surface.inverseSurface and palette.surface.onInverseSurface. Additive — existing palettes and brand JSONs are unaffected.
    • cornerRadius parameter (default 8) drives input and button shapes, so a brand's roundness no longer requires its own builder.
    • Input labels now use onSurfaceVariant instead of the Material default.
  • Read-through child data via $subFlow / $subProcess. A parent workflow's view can now display its sub-flow / sub-process data without that data ever being copied into the parent. Dart port of the companion TypeScript engine's engine/subInstances.ts — same grammar, same keying, same suppression, so one backend view definition renders on both.

    $subFlow.<parentState>       single record; the same flow may run at
                                 several points in the parent, so the
                                 STATE is what disambiguates
    $subProcess.<flowName>       always a list (1-to-n fan-out), keyed by
                                 flow name; plus `.length` and `.$done`
    

    Which root a child lands in comes from the runtime's subFlowType (S → $subFlow, P → $subProcess), never from where it was authored. Entries are ordered newest-first, so [0] keeps meaning "the current run" once completed correlations arrive. A group is a list of plain maps, so ForEach + $item.data.<field> consume it with no renderer change.

    Cost is reference-driven: the SDK scans the view for the roots it actually names and asks only for those, so a parent with seven open children serving a view that mentions one fan-out issues three reads. A view with no $sub* binding calls nothing at all. An absent child suppresses silently — $subFlow resolves to null, $subProcess to an empty list so .length is 0 and a ForEach renders nothing — which lets a binding be written eagerly and light up when the child appears.

    Host wiring is one delegate method, PseudoViewDelegate.loadSubInstances, which defaults to returning an empty list — existing delegates keep working untouched. The record's identity comes from the new PseudoView(instanceRef: ...); omit it on start surfaces, where no instance exists yet.

    Known platform limitation, unchanged by this: the runtime today reports only activeCorrelations, and a sequential child is only open while it blocks its parent — so $subFlow resolves to nothing until the runtime also reports completed correlations. $subProcess is unaffected. A host implementation should prefer correlations when present and fall back to activeCorrelations, so this lights up on a runtime upgrade with no client or view change.

  • Cross-adapter input default flipped. When a TextField / TextArea / NumberField / Dropdown / SearchField / AutoComplete / DatePicker / TimePicker node omits floatingLabel and focusOutline, the effective defaults are now floatingLabel: "never" + focusOutline: "hidden" (the legacy Neobank input pattern — label stays inside the field as a static placeholder, focus doesn't shift the outline). Backends that need Material's floating-label pattern must opt in explicitly with "auto" / "always"; the themed focused border is restored with focusOutline: "default". This is a breaking change for consumers that relied on the previous "no override → Material default" behaviour.

  • New Timer component. Countdown widget for OTP / SMS verification flows. Ships in all four adapters (Flutter, Vue, React, Angular). Props: duration (seconds, required), format (mm:ss / hh:mm:ss / ss, default mm:ss), variant (plain / chip, default plain). Display-only — expiry does NOT dispatch any workflow action; the hosting workflow polling loop is the source of truth for state changes.

  • TextField password support. Bound schema properties with "format": "password" (JSON Schema draft-07 standard) are now rendered masked, with a suffix eye icon that toggles visibility. Same treatment ships in the Vue, React, and Angular adapters — Vue/React use their respective PrimeVue/PrimeReact Password components (with the strength meter disabled), Angular Material binds [type]="password | text" and adds a mat-icon-button suffix. The view vocabulary is unchanged — masking is a schema-owned metadata concern, not a layout one.

0.2.3 #

  • New: JSON-driven theme palette as public API. Consumers can now load a brand theme from a JSON document (Material Design Tokens / W3C Design Tokens style) without writing any Dart code:

    final raw = await rootBundle.loadString('assets/themes/acme.json');
    final palette = JsonPalette.fromJson(json.decode(raw));
    
    MaterialApp(
      theme: buildPseudoThemeData(palette: palette),
      home: PseudoView(...),
    );
    

    New public types:

    • PseudoColorPalette — abstract palette contract (20 Material 3 ColorScheme roles + success / warning / info status slots).
    • JsonPalette — PseudoColorPalette factory that reads a JSON map and exposes the brand colors as Dart Colors. Missing fields fall back to the M3 baseline.
    • DefaultMaterialPalette — built-in M3 baseline, used as the fallback for JsonPalette and as a sample to extend from.
    • buildPseudoThemeData(palette: ...) — Material 3 ThemeData builder that seeds a ColorScheme from the palette and attaches PseudoUiThemeExt for status colors.
  • Spacer dispatch case added to DynamicRenderer. Supports both rigid mode (set width and/or height to render a SizedBox) and flex mode (set flex to expand inside a Row/Column). Previously rendered as an "unsupported" placeholder.

  • Internal docs / inline references cleaned up so the package surface carries no internal project names. Publisher identifiers (@burgan-tech/pseudo-ui npm scope, repository URL, MIT copyright) are preserved as legitimate "who shipped this" signals.

  • No breaking changes for existing consumers.

0.2.2 #

  • Widen intl constraint from ^0.19.0 to >=0.19.0 <0.21.0 so consumers running on Flutter 3.27+ (which pins intl: 0.20.x via flutter_localizations) can adopt the package without dependency_overrides gymnastics.

0.2.1 #

  • PseudoView now supports delegate: null for flat views with no actions, LOVs, or nested components. The engine falls back to a built-in NoOpPseudoViewDelegate.instance so consumers no longer need a stub delegate just to render JSON.
  • NoOpPseudoViewDelegate exported from the public barrel for direct use. loadComponent still throws UnsupportedError when nested refs are encountered, surfacing the gap rather than rendering an incomplete tree.

0.2.0 #

  • Add PseudoComponentRegistry — pluggable extension point for third-party component sets. The renderer's default branch now delegates to the registry, so any design system can register builders without touching DynamicRenderer.
  • Add PseudoUiThemeExt — ThemeExtension<PseudoUiThemeExt> for semantic success / warning / info colors. Falls back to Material 3 defaults when not provided by the host.
  • Theme awareness across the renderer: TextField/Dropdown/Card etc. respect host-provided InputDecorationTheme and ColorScheme.
  • DynamicRenderer now exported from the public barrel so external builders can render nested children.
  • Internal: design-system adapters extracted into a sibling package (not published to pub.flutter-io.cn — consumers register their own components via PseudoComponentRegistry).

0.1.3 #

  • CI: use official dart-lang OIDC workflow for automated pub.flutter-io.cn publishing
  • Package size optimized (13MB → 31KB) via .pubignore

0.1.2 #

  • README: Initial data, Lookups, Data Model, Vocabularies sections added
  • Code cleanup: silent catch fixed, empty action handlers wired, lint warnings resolved
  • 101 unit and widget tests (up from 15)
  • LOV reload optimization: skip unchanged params, skip static LOVs on field changes
  • GitHub Actions CI + pub.flutter-io.cn automated publishing

0.1.1 #

  • 45+ Material Design 3 component implementations
  • Expression engine: $form, $instance, $param, $ui, $lov, $lookup, $schema, $item, $context
  • Conditional engine: showIf/hideIf/enableIf/disableIf with 13 operators and compound rules
  • Validation: JSON Schema validation (pattern, format, minLength, min/max) plus async custom validation
  • LOV & Lookup: cascade filtering, smart reload (skip unchanged params)
  • Overlay surfaces: Dialog, BottomSheet, SideSheet, NavigationDrawer driven by $ui state
  • Nested components with isolated contexts and two-way data flow
  • 101 unit and widget tests

0.1.0 #

  • Initial release with core engine and basic Flutter adapter
0
likes
140
points
1.46k
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Server-Driven UI rendering engine for Flutter. Define UI with JSON Schema + View JSON, render natively with Material 3 widgets. 45+ components, expression engine, conditional visibility, LOV cascading, nested components.

Homepage
Repository (GitHub)
View/report issues

Topics

#sdui #server-driven-ui #json-schema #material-design #dynamic-forms

License

MIT (license)

Dependencies

flutter, intl, meta

More

Packages that depend on pseudo_ui