Skip to content

Release sdk 3.0.0, ui 1.0.0, temporal 0.2.0 - #197

Merged
piotrblaszczyk merged 50 commits into
releasefrom
main
Oct 2, 2026
Merged

piotrblaszczyk merged 50 commits into
releasefrom
main

Conversation

@piotrblaszczyk

Copy link
Copy Markdown
Contributor

No description provided.

librowski and others added 30 commits September 22, 2026 10:16
* [DS 2.0] Fix dangling --wb-* custom properties + token-usage guard (#83)

* fix(sdk): repoint dangling --wb-* custom properties at existing tokens

23 CSS variable names were used via var() but defined nowhere — --ax- prefix
typos, misspelled token names, and two edge-label variables with no
definition. Form message (neutral/warning) and the variables-settings modal
were visibly rendering without their intended colors and spacing. Redundant
fallbacks that masked the typos are dropped in the same lines; where a used
name has no token equivalent, the value-equivalent token is substituted
(tab-header 16px gap -> spacing-16).

* fix(ui): resolve undefined custom properties in icon-switch and text-area

icon-switch: the base thumb background referenced a variable that never
existed and is always overridden by the primary/secondary variant rule —
dropped the dead declaration. text-area: the disabled background variable
was consumed but never defined; define it as transparent (today's effective
render) so the --ax-public-* override contract actually works.

* fix(ai-studio): correct --ax-txt-secondary token name in log panel

The real token is --ax-txt-secondary-default. The shorter name never
existed, so the hardcoded gray fallback was what actually rendered.
Remaining dead fallbacks in this file are dropped alongside.

* style: drop dead fallbacks on system design tokens

Every one of these var(--ax-…, fallback) fallbacks is unreachable — the
token is defined in the shipped tokens.css, so the fallback never wins.
They also mask exactly the typo class the new token-usage lint catches,
which now rejects unannotated fallbacks on system tokens.

* chore(tokens): add token-usage lint guard

lint-token-usage.mjs validates every var(--…) in packages/*/src and
apps/*/src against the names that actually exist (tokens dist, source
definitions incl. inline styles and setProperty, the provisional registry,
and a runtime allowlist). Unknown names and unannotated fallbacks on system
tokens are errors. Full-scan and per-file modes, self-builds dist when
missing, and reports the var(--ax-*) usage count as the DS 2.0 migration
meter.

* ci: run the token-usage lint in pr-check and lint-staged

CI runs the full scan in the ui job right after pnpm build:ui, so the dist
it validates against is the one just built. lint-staged runs the per-file
mode on staged css/ts files; the script path is anchored to the repo root
because workspace lint-staged configs re-export the root config and run
commands from their own directory.

* docs(sdk): document the --wb-* prefix ownership convention

* refactor(tokens): replace the custom token-usage lint with stylelint

The hand-rolled regex scanner (~160 lines, growing with every review
finding: comments, multi-line var(), @property, template literals) becomes
configuration: csstools/value-no-unknown-custom-properties validates every
var(--…) in workspace CSS against definitions collected by a small importFrom
module (token dist + all source CSS + a short runtime-defined list), and a
~40-line local stylelint rule keeps the fallback ban on system tokens, with
exceptions via the standard stylelint-disable-next-line comment and a
mandatory reason. A real CSS parser fixes two scanner bugs for free
(var() inside comments no longer flags, multi-line var() no longer escapes)
and apps/docs is now excluded consistently in CI, lint-staged, and manual
runs via ignoreFiles. The DS 2.0 migration meter stays as a one-liner in the
CI step. Lockfile note: adding the stylelint devDependencies also re-resolves
stale transitive typescript peer entries (5.9.3 -> 5.6.3) that no declared
range permits — a one-time correction; declared ranges are all ~5.6.3.

* fix(sdk): use the primary text token for the neutral message variant

--ax-colors-gray-800 is a mode-less primitive; on the dark theme it lands
dark-on-dark (1.61:1) against the theme-aware secondary background.
--ax-txt-primary-default resolves to the same gray-800 in light and to
gray-100 in dark. The sdk changeset is reworded around the changes a
consumer can actually see (the message variants are not reachable from the
public API yet), and the ui changes from this PR get their missed changeset
(a new public TextArea variable is an API addition).

* refactor(sdk): drop unconsumed edge color variables

--wb-edge-color, -temporary, -hover, and -select have zero consumers — the
live edge colors are the --ax-public-edge-color-* pair in packages/ui. The
label-edge variables that are consumed stay; the stale missing-token
comments on them pointed at tokens that exist.

* fix(repo): resolve stylelint importFrom from the config file, name the meter

The csstools rule resolves importFrom against process.cwd(), and lint-staged
runs commands from workspace directories — caught by the pre-commit hook
itself. The config moves to .stylelintrc.mjs and anchors the path via
import.meta.url. The DS 2.0 migration meter moves out of an inline CI shell
string into tools/migration-meter.mjs behind pnpm migration:meter.

* docs: drop a brittle cross-rule remark from the stylelint plugin

* ci: drop the migration meter from pr-check

Informational, not a gate — pnpm migration:meter stays as an on-demand
command for migration PRs.

* docs: explain the stylelint definition source in plain terms

* refactor(repo): replace the runtime-variable allowlist with in-place disables

A central RUNTIME_DEFINED list in the definition collector rots and hides
the reason away from the code. Each of the five usage sites of a
JS-set variable now carries a stylelint-disable comment with the reason —
self-cleaning: remove the usage and the exception goes with it.

* chore: drop the migration meter from the repo

Informational-only; lives on as a private local script for the DS 2.0
migration work instead of shipped tooling.

* chore: shield the DS 2.0 migration changelog from prettier

The designer changelog lands verbatim in packages/tokens/migration/ on the
integration branch; prettier reformatting it corrupted token paths inside
emphasis markers. Prettier reads .prettierignore only from the cwd and
lint-staged runs from workspace directories, so the lint-staged command
passes the root ignore file explicitly.

* fix: restore the full .prettierignore and anchor it for lint-staged

The previous commit accidentally replaced the whole ignore file with the
single migration entry — the original entries (dist, coverage, .astro,
generated icons) are back, migration/ added on top. lint-staged runs
prettier from workspace directories where the root .prettierignore is
invisible, so the command now passes it explicitly.

* chore: drop tracker ids from changeset filenames

* ci: run PR checks for the DS 2.0 stack branches

Stacked PRs target the branch below them, and the pull_request branches
filter matches the base — without these entries every layer above the
bottom runs with no CI until the cascade retargets it. Temporary; removed
when the stack lands.

* ci: run PR checks for every base branch

The base allowlist needed a new entry for each integration or stack branch
and left dead entries behind; the default pull_request trigger covers them
all. pr-check-docs keeps its paths filter.

* fix(ai-studio): drop the raw fallback on the skipped marker token

* [DS 2.0] Typography layer (wb-text-* roles) (#84)

* feat(ui): add DS 2.0 type role classes

39 wb-type-{family}-{size}[-emphasized] utility classes straight from the
Typography guidelines table (Display, Headline, Title, Body, Label, Node,
UI/Code). Font sizes reference the wb/font-size primitives; until the DS 2.0
variables export ships them in tokens.css, the same block defines them as
provisional values — the unlayered export will override the layered block,
and the block gets removed with the export task. Existing ax-public-*
classes are untouched; adoption starts with the Chips redesign.

* feat(sdk): bundle Inter for the UI/Code type role

Same rationale as Poppins — CSP-safe, GDPR-safe, air-gap-safe. Regular only,
matching the single UI/Code style.

* docs: add the typography page to the UI Library section

* chore(ui): drop internal ticket reference from the provisional-block comment

* fix(ui): typography review fixes

- wb-type-code sets the family on descendants too — the SDK's universal
  font-family rule would otherwise repaint nested elements in Poppins,
  defeating the one role that ships its own typeface.
- Font weights are order-independent: 400 and 600 live on disjoint selector
  lists instead of a base rule overridden later by source order (the repo
  already treats that hazard as a shipped bug class).
- The NNN = px x 12.5 formula is approximate for 137 and 162 (truncation) —
  say so; drop the false monospace claim from the UI/Code comment.
- sdk README documents the wb-type-* carve-out from --wb-font-family.

* feat(ui): ship Poppins and Inter with the ui package

The wb-type-* classes declare families the package did not deliver — a
standalone consumer silently fell back to system-ui. The fontsource imports
move from the SDK stylesheet to a ui fonts.css entry on the root barrel
(layered via import layer(), matching the built-css guard), with the
CSP/GDPR/air-gap bundling rationale carried along. The SDK inherits the
same 12 font faces through the JS module graph — no duplication, and its
own fontsource dependencies are gone.

* refactor(ui): rename the type-role classes to wb-text-*

In code, "type" reads as a data type before it reads as typography;
text-* is the established CSS convention for typographic utilities. The
classes have no consumers yet, so the rename is free.

* docs(ui): prune paraphrase comments from the typography layer

The per-family group headers restated the class names; the used-for prose
lives in the docs page. What stays carries information the code cannot:
the provisional block's lifecycle, the descendant-selector workaround, the
naming scheme with the Emphasized=600 mapping, and the deliberate absence
of an emphasized UI/Code variant.

* refactor(ui): move the provisional font-size primitives to their own file

A to-be-deleted block hiding inside typography.css relies on someone
reading the comment; a dedicated provisional.css is wired into both
distribution channels (the barrel import and the combine-css-bundle
globals list), so deleting the file without cleaning the wire-ups fails
the build in two places instead of silently shipping stale values.

* refactor(ui): underscore-prefix the provisional stylesheet

_provisional.css sorts first and reads as exceptional at a glance.

* docs(sdk): move the wb-font-family lifecycle note to its definition

The theming section stays about what the variable does today; the 3.0.0
removal-or-narrowing decision lives as a comment at the definition, where
whoever touches it will actually look.

* docs(ui): drop the wb-text section header

The Emphasized=600 mapping is visible in the weight rule itself and the
provisional file announces itself by name.

* docs(ui): generalize the descendant-selector rationale

The hazard is any universal font-family reset, not specifically the SDK's —
the comment should survive the variable's possible 3.0.0 removal.

* docs(ui): drop the Regular-only comment

The docs page's scale table already records it.

* chore: drop tracker ids from changeset filenames

* fix(ui): declare the fontsource dependencies the fonts entry imports

The original move committed fonts.css but lost the package.json/lockfile
hunks in a branch-hop stash; local builds kept passing on node_modules
installed outside git, and a clean install failed to resolve the imports —
vite then emits no CSS assets at all and the combine step aborts.

* docs: deprecate the ax-public typography classes explicitly

"Remain available" invited new adoption; they only exist for the migration
window and are removed in 3.0.0.

* refactor(ui): drop the descendant font selector from the code role

It patched one role against the universal font-family reset while every
role's descendants stay exposed — symptom-level and inconsistent. The
reset's fate is decided at the 3.0.0 close-out; until then the layers are
allowed to be individually incomplete as long as the whole stack lands
coherent.

* docs(ui): tighten the fonts.css header to the two facts that matter

* docs: drop the version label from evergreen prose

Naming the system version in component docs and headers goes stale
the moment the next version starts; the export references carry the
meaning on their own.

* feat(tokens): fail the build on colliding token names with different values (#85)

Style-dictionary's name/kebab flattening can map two distinct token
paths to one CSS custom property, and the later definition silently
wins. The validator derives names by invoking SD's own name/kebab
transform, so the check cannot drift from the build, and aborts on
any collision whose values differ.

* [DS 2.0] Chip component (#87)

* feat(ui): add Chip component

Compact tag in the DS 2.0 solid and outline treatments, four sizes,
optional prefix icon and close affordance. Sized and colored per the
Chips and Tag component set in the DS 2.0 Figma file; the chip tokens
and wb/radius/50 live in _provisional.css until the token export ships
them. The outline background is two stacked fills by design: the brand
surface under a translucent theme overlay.

* refactor(sdk): render the conditions counter tag with Chip

The local .tag treatment is replaced by the UI Chip component; the
class keeps only its layout margins and the tag background variable
goes away with it.

* docs: add the Chip component page

Example, usage snippet, and generated props table. The component
defines no ax-public-* variables (deprecated contract), so the CSS
variables section renders the standard empty state.

* fix(ui): apply Chip review findings

The dark outline overlay aliased gray-900-75, whose exported value is
the documented pre-2.0 bug (50% alpha instead of 75) - mixed from
gray-900 directly, like the light theme already does. The close button
gets the standard focus-visible ring. Variant and Size are renamed to
ChipVariant/ChipSize and exported - the bare names shadowed two
existing public types and rendered misleadingly in the generated props
table. Chip now forwards its ref and native span attributes, and the
close affordance takes an overridable closeLabel. The outline stroke
is an inset ring instead of a border so outline and solid chips render
the same width. Spacing metrics bind the space/size primitives from
the provisional set.

* docs(ui): sharpen Chip prop contracts

The label doc records why it is a string (it feeds the close
affordance's accessible name) and size gets a description - its TSDoc
was only a default tag, which the generated props table does not
surface, leaving an empty cell.

* fix(ui): chip outline ring sits outside the box

The Figma component strokes with strokeAlign OUTSIDE; the inset ring
came from an unverified review claim of an inside stroke and ate one
pixel of the chip's padding.

* docs(ui): cut chip comments down to the exceptions

Version labels in prose age badly; prop docs carry only defaults and
the close-affordance contract, and the two CSS notes state their
invariant without narrating the source.

* docs(ui): drop the outline chip comments

The token names carry the layering (inset under overlay) and the
outer box-shadow ring is a standard idiom.

* refactor(ui): rename the chip component paths to the singular form

* [DS 2.0] Token pipeline 2.0: adopt the wb variable export (#88)

* feat(tokens): switch the build to the 2.0 variable export

The tokens.json export now comes from the WB Token Export plugin:
Numerals is gone, Canvas (root-scoped) and per-theme Effects sets are
in, and font-size primitives are emitted in rem while the other
dimensions stay in px, as the design docs specify. The provisional
stylesheet and both of its wire-ups go away - every value it carried
ships in tokens.css now.

* refactor: adopt the wb token names across the workspaces

Mechanical rewrite of every var(--ax-...) usage to its wb equivalent:
renames and prefix rules from the migration map plus value-matched
dimension tokens (the old numeral scales map 1:1 onto wb/space,
wb/size and wb/radius; two 100px pill radii become radius-full).
Hand-mapped leftovers keep their old rendered values per theme: the
accent chips in the sdk message and branch-card controls and the
list-item selected/destructive backgrounds, which the retired
dropdown tokens used to carry. No compatibility bridge: consumers
theme through the unchanged ax-public contract, and the stylelint
guard fails the build on any name this rewrite might have missed.

* fix(tokens): emit dimensions in rem and bind the right token families

The retired numeral primitives were authored in rem, so component boxes
scaled with the reader's font size; the refreshed export is px-authored,
which left text scaling while its containers stopped. Both transforms now
convert px to rem and refuse any other unit instead of emitting NaN.

Consumers bind the family that owns the value: radius from the radius
scale, shadow geometry and edge strokes from the effects and canvas sets.
List rows take the hover fill, which stays lighter than the surface they
sit on in the dark theme.

The docs API generator reads wb-prefixed definitions again, so component
variables are classified as colors instead of falling through to sizes.
Public theming docs point at names that exist, and the token README
documents the unit split and the new sets.

* fix(tokens): address pipeline switch review

* fix(ai-studio): read execution status leftovers from the wb tokens

* [DS 2.0] Rebuild Button with explicit variant, size and shape props (#89)

* feat(ui)!: rebuild Button on the design system axes

The component takes variant, size and shape as explicit props and
composes its content from prefixIcon, children and suffixIcon, so the
three structural subtypes and the children-shape inference behind them
are gone. Ten variants cover the solid and ghost treatments, sizes are
letter-scaled, and square and round render the icon-only box.

Metrics and colours come from the design component set: heights
48/42/36/32/28 with matching padding, gap, radius, icon box and label
role per size, and every fill, border and label colour bound to its
component token.

Call sites move with it: the outlined treatment is now ghost-secondary
while secondary is the solid grey fill, error becomes critical, and
word sizes become letters.

* fix(ui)!: make the disabled and loading states legible, tighten Button props

Disabled solid buttons paired the disabled surface with the ghost text
colour, which resolves to the same value in the dark theme - the label
and icons disappeared. They take the disabled text token the design
specifies, and each ghost variant takes its own accent counterpart.

The loading dots inherit the current colour instead of the on-accent
one, so they stay visible on light ghost buttons.

Props are a union: a label button with optional icons, or a square or
round button that requires the icon it renders. Combinations that
rendered an empty box no longer compile, and the element-child
fallback is gone, so nothing inspects children's shape any more. Two
SDK wrappers and one call site that erased the discrimination now name
the label form explicitly.

Loading also reaches assistive technology and blocks activation, not
just pointers. The placeholder draws one dashed outline again, the
retired variant variables are deleted, and the docs table lists every
prop the component takes.

* refactor(sdk)!: adopt the redesigned secondary treatment at call sites

The design system's secondary is the solid grey fill. Call sites that
carried the old outlined secondary move to it instead of keeping their
former look through ghost-secondary - cancel and auxiliary actions now
render solid, as the redesign intends. The outlined treatment stays
available as ghost-secondary for deliberate use.

* fix(ui): restore dashed placeholders and tighten button contracts

* docs(ui): document the public button variable migration in the changeset

* [DS 2.0] Rebuild the form fields against the design system (#90)

* feat(ui)!: rebuild the form fields and add NumberField

Fields take an explicit state - default, critical, success or
read-only - in place of the error boolean, and sizes are letter-scaled.
Hover, focus and filled stay visual states rather than props. Read-only
keeps the field focusable and copyable through the native attribute,
while disabled stays out of the tab order.

Icons arrive as prefix and suffix slots with an optional clear
affordance. Heights, padding, gap, radius and the label roles come from
the design component set, and every surface, border and text colour
binds to its field token.

NumberField is new: an input with an always-visible stepper that
honours min, max and step from both the buttons and the arrow keys.

* fix(ui)!: compose fields with label and helper rows, harden NumberField

Fields render the measured composition again: a label row, the control
row and a helper row. The label is associated with the control, the
helper joins its accessible description, and the critical state marks
the control invalid, so validation text is announced instead of being
merely visible.

Focus is visible in every state, not only the default one.

Interactive adornments no longer go into the decorative icon slot,
which does not take pointer events - the datetime field renders its
variable-picker button beside the input, the way its sibling branches
already do.

NumberField now has a value contract: clearing is representable and
reported, a clamped value is emitted once, a controlled parent that
refuses an update wins, non-finite input never reaches a callback,
step=any keeps working, off-grid boundaries stay submittable, and
malformed paste is rejected rather than reinterpreted. Fifteen tests
pin those paths.

* docs(sdk): declare the field adoption as a minor bump

* fix(ui)!: simplify NumberField and harden field composition

* refactor(ui)!: drop NumberField from the field layer

* fix(ui,sdk): expose clearLabel on the field controls and align the critical variable names

* fix(ui): stabilize field composition

* fix(docs): replace inline form example styles

* [DS 2.0] Repoint the remaining controls, overlays and effects (#91)

* style(ui): bind focus rings, elevation and control metrics to the token set

Focusable controls draw the ring the design system defines: the
focus-element geometry with the focus colour, replacing the hardcoded
offsets each component carried. Elevation comes from the shadow ramp
with its own colour token, so dark mode no longer needs a second rule.

Selector, snackbar, tooltip and date-picker surfaces bind their
component tokens, and the sizes that were literal rem values now take
the size scale.

* style(sdk,ai-studio): repoint the remaining design-token usage

The SDK global stylesheet, the diagram surface and the execution
markers bind canvas and ui tokens; app-scoped variables stay app-scoped.

* fix(ui): restore the contracts and metrics the repoint changed

The public snackbar background variables carry the complete layered
value again - a status tint over the surface, consumed as one
background - because two SDK surfaces read them directly and lost
their tint when the tint moved into a private rule.

Selector and switch dimensions go back to the values they had. The
size scale has no step for most of them, so snapping to the nearest
one silently resized every checkbox, radio and switch; a token is
bound only where it resolves to the same value.

The date-picker popup takes the base surface again, so hovering a day
is visible, and the generic radio dot rule no longer overrides the
size classes that follow it.

* fix(ui,sdk): restore modal hierarchy and align repointed details with the design

* fix(ui): preserve list item focus in forced colors

* [DS 2.0] Rebuild NavButton, add MenuTriggerButton (#92)

* feat(ui)!: rebuild NavButton, add MenuTriggerButton

NavButton takes size, styleVariant and explicit icon slots, so the
label/icon/icon+label subtypes and the shared children-shape guards are
gone - nothing imports them any more, so they are deleted too.

The selected state and the mouse-down state are now separate: selection
survives hover and pointer-down overrides it, each on its own token,
which is what the design defines and what the token export ships.

Seven sizes carry the design's boxes, padding, gap, radius and icon
boxes; nav labels keep the regular weight. MenuTriggerButton composes
NavButton and maps its open state onto selection.

SegmentPicker keeps its public API and translates to the new slots
internally.

* fix(ui): restore SegmentPicker compatibility and tighten the nav slots

SegmentPicker accepted an icon, a label, or a mix of both as children;
the slot rewrite only handled the single-element form, so mixed content
lost its icon treatment. Children are normalised into slots again, the
selected segment reports its state to assistive technology, and
clicking it no longer emits a change it used to swallow.

NavButton's props became a union: a label button with optional icons,
or an icon button that requires its icon. Neither form can now be
written so that it renders nothing.

The visual treatment prop is called variant, matching the other
components. The language selector lets the button own its label
typography instead of composing a retired global class, and the two
style modules the rebuild orphaned are gone.

* docs(sdk): declare the nav adoption as a minor bump

* fix(ui,sdk)!: give NavButton real state axes and tighten the trigger contracts

* fix(ui,sdk): complete NavButton adoption fixes

* fix(ui,sdk): localize disclosure labels

* [DS 2.0] Ship the fonts as assets instead of base64 (#93)

* perf(ui)!: ship the fonts as assets, inline only the two used weights

The library inlined twelve font faces as base64 because Vite's library
mode inlines every asset unconditionally, so the built stylesheet
carried 382 KB of fonts: index.css was 509 KB and the SDK stylesheet,
which bundles it, 591 KB.

The faces are generated after Vite finishes, from the fontsource
metadata, and copied into dist/assets. Poppins 400 and 600 latin stay
inlined - the only weights the typography classes declare - so the
common text needs no extra request. Everything else is fetched on
demand, and each face now carries the unicode-range the per-subset
fontsource files omit, so a document without extended latin skips
those files entirely. The legacy woff source is gone.

index.css is 150 KB, the SDK stylesheet 230 KB, and dist gains ten
woff2 files. A new gate fails the build when a stylesheet references
an asset that is not in dist - the failure mode this arrangement
invites, and the one a previous font change hit only on clean CI.

Consumers keep their imports; a Content-Security-Policy naming
font-src needs 'self' rather than 'data:', and the dist layout has to
survive copying.

* docs: scope the label recommendation, follow the font move in knip

The role table recommended the emphasized large label for chips, while
the chip component set specifies the small regular label at every size
- the row now names buttons alone and chips get their measured role.

Knip's ignore entry moves to the ui workspace, where the fontsource
packages now live and are consumed outside its JS/TS walk, and the
CLAUDE.md table stops claiming knip runs as part of pnpm check.

* fix(ui,sdk)!: carry the font faces in the entry chunk and harden the font pipeline

* fix(ui,sdk): harden font asset handling

* fix(ui): ship font licenses with font assets

* docs: register the tenant and font decision logs in the index

* [DS 2.0] Rename public CSS variables to the wb namespace (#97)

* refactor(ui,sdk)!: rename public CSS variables

* fix(sdk,docs): layer public variables and true up rename docs

* [DS 2.0] Bind editor text to explicit typography roles (#98)

* refactor(ui,sdk)!: bind text to explicit type roles

* fix(ui,sdk)!: bind typography to the public family lever and settle the type-role review

* fix(sdk,ui): close explicit typography review gaps

* fix(sdk): align code editor with code type role

* fix(ui): read the date picker font size from the token

* [DS 2.0] Migrate the ui primitives to the type roles (#101)

* refactor(ui)!: migrate primitives to type roles and drop legacy classes

* fix(ui): complete typography migration notes

* [DS 2.0] Split the CSS variable namespaces (#102)

* refactor(ui,sdk)!: split the CSS variable namespaces

* fix(ui,sdk): correct namespace migration contract

* [DS 2.0] Shrink the built-CSS checker and document the pitfalls (#104)

* build(ui): streamline built CSS validation

* fix(ui): harden built CSS checks

* [DS 2.0] Use the measured source-node height for self-loop edges (#121)

* fix(sdk): use the measured source-node height for self-loop geometry and label

LabelEdge subscribes to the source node's height in the React Flow store
(measured.height, then height, then 0) instead of reading a getNode()
snapshot, so the loop path and its label follow every remeasurement -
xyflow 12 writes ResizeObserver results to measured.height only. The same
value is passed to SelfConnectingEdge, whose nodeHeight prop previously
defaulted to 0.

* fix(sdk): derive the self-loop height inside SelfConnectingEdge

Review follow-up. The measured-height selector moves to a shared
useSelfLoopNodeHeight hook (exported), so SelfConnectingEdge reads the
source node's measured height when nodeHeight is omitted instead of
drawing the loop for a zero-height node; LabelEdge keeps passing its own
value so loop and label stay in lockstep. Specs cover the regular-edge
early exit, the realistic measured-but-empty fixture and both paths of
the new default.

* [DS 2.0] Render the default canvas port at the designed 8px (#122)

* fix(ui): zero inherited xyflow handle minimum for 8px default ports

xyflow's 5px minimum applies to the content box and expands the bordered port to 9px.
Reset both minimums so 4px content plus 2px borders remains 8px.

* fix(ui): grow the target port while a connection hovers it

React Flow captures the pointer on the dragged handle, so the node under
the cursor never receives :hover and its ports stayed at the 8px default
during a connection. The connectingto state now shares the connectingfrom
rule, so the target shows the same 16px active port as the source.

* fix(ui): grow only valid connection targets and drop the handle geometry check

Review follow-up. The connecting-state rule now targets .connectingto.valid,
so a port rejected by isValidConnection keeps its default size while the
connection line reports invalid; the comment explains the real reason the
target needs its own rule (xyflow assigns connectingto by proximity within
connectionRadius, so :hover cannot express it). The built-CSS geometry check
is removed: it matched every rule ending in .react-flow__handle and would
have failed any future state rule on the bare handle, and its exact-string
comparison against minified output was fragile. The pitfalls entry records
the manual check instead.

* [DS 2.0] Move the node shell to the DS 2.0 geometry and derive ConnectableItem width (#123)

* fix(ui): move the node shell to DS 2.0 geometry (241px, head padding and gap tokens)

Width follows the design master as a canvas dimension in px (it must not
scale with the root font size). Padding and vertical gap bind to the node
head roles canvas-node-head-h-pad and canvas-node-head-gap from the token
export (8px each) instead of the generic space-100 step.

* fix(sdk): derive the ConnectableItem width from the real container insets

Replaces the undocumented 6 * padding factor with named insets (shell
padding + border, plus the wrapping container's own inset declared via
--wb-sdk-connectable-item-inset). The old content-box correction terms
are dropped because the SDK applies a global border-box reset.

* docs(sdk): describe the ConnectableItem cap change and its follow-up in shipped terms

Review follow-up. The changeset states the actual effect (the cap narrows
to the space the section offers, so labels truncate instead of
overflowing). The decision log drops pointers to a register outside the
repo and names the pending design work with a grep-able slug.

* [DS 2.0] Strip measured node sizes from saved and exported diagrams (#124)

* fix(sdk): strip measured node sizes from saved and exported diagrams

Runtime dimensions were persisted alongside the diagram and observed to stay
at the stale stored value after remeasurement, so exports and localStorage
carried sizes that no longer matched the rendered nodes. Sizes are a runtime
fact; nodes are measured again on load.

* chore(fixtures): drop stored measured node sizes from demo and AI Studio flows

Runtime dimensions are no longer persisted (see the sdk change in this
branch), so the 82 stored measured values were dead data that still
recorded the pre-DS 2.0 width.

* [DS 2.0] Keep node title and subtitle on one line with ellipsis and tooltip (#126)

* fix(ui): keep node title and subtitle on one line with ellipsis and tooltip

Design rule for DS 2.0 nodes: fixed width, height grows, title and subtitle
truncate to one line with the full text in a tooltip. The description block
also stops contributing intrinsic width (contain: inline-size), so a long
subtitle no longer widens bodies sized with min-width: max-content.

* fix(sdk): show the full connectable item label in a tooltip

Decision branch rows and AI tool rows already clip long labels with an
ellipsis but exposed no way to read the full text. The label now carries
a title attribute, matching the node title and subtitle behaviour.

* fix(sdk): apply the design body spacing to node sections and rows (#136)

Sections (Decision branches, AI Agent tools) use 8px padding and gap and the
content radius role; rows use 8px padding and a 4px radius. Values bind to
the space/100 and radius/50 primitives with missing-token markers until the
node.body roles land in the token export. The derived row width becomes 205px.

* feat(ui): drop the warning and ghost-warning button variants (#137)

Design confirmed the warning button has no practical use in the product:
the only occurrences were the import dialog's ignore-and-import action and
the warning snackbar action, both of which now render as secondary. The
variants, their public custom properties and the docs example entries are
removed; the semantic warning role (snackbar surface, node focus ring,
execution statuses) is untouched. Migration steps live in the changeset.

* chore(tokens): refresh the Figma export and bind button sizes to the size scale (#138)

Export from 14.09.2026 adds ui/bg/selected, ui/bg/selected-hover and the
size steps 225, 250, 350, 450 and 525 (18, 20, 28, 36, 42px). No token was
removed or changed. Button heights L/M/XS and icon sizes L/M/S now resolve
from the size steps, which replaces six provisional rem literals with the
values design published for them.

* feat(ui): mark the current choice in Menu with selected (#139)

A menu whose items define selected renders them as a Base UI radio group,
so the current entry carries menuitemradio and aria-checked. Selected list
entries (menu radio items and Select options) move from the solid accent
fill to the ui/bg/selected role with default text and a selected-hover
state. The language menu marks the current language.

* feat(sdk): render palette entries with the canvas Node states (#140)

Design answer to the palette questions: the palette has no component of
its own, an entry is the Node without ports and badge, and its states are
the Node states. Hover already came from the shell; the drag preview now
renders the Active state (outline and ring), and read-only entries render
the Disabled variant instead of a faded copy. NodePanel.Root,
NodeDescription and NodeIcon gain a disabled prop with their own public
defaults; the four node templates pass it through. The palette's unused
variables file and outline styling are removed.

* chore(tokens): adopt the 15.09 and 16.09 exports (design system 1.1.9 to 1.1.12) (#149)

Node body and row roles, focus ring rename (shadow and the four colour
roles), ghost label roles, rebuilt orange scale. The Status invalid badge
and the indicator dot move from the orange-400 primitive to the warning
roles so they stay visible on the rebuilt scale.

* fix: address review follow-ups on the canvas stack (#150)

* fix: address review follow-ups on the canvas stack

- persist: strip the runtime dragging flag alongside measured and selected,
  drop dragging from the bundled templates, cover getStoreDataForIntegration
- node description: document the contain: inline-size constraint
- connectable item: rewrite the width decision log with the current numbers
  and a status section; reword the tooltip changeset
- menu: lay out the RadioGroup wrapper as a column so the list-box gap applies
- ui specs: unmount the React root after each test
- palette: forward disabled in the custom template guide and the demo
  multi-port template, document it on NodePanel.Root, reword the changeset

* fix: apply the 16.09 design answers to rows, loops and the demo template

- rows: in the horizontal layout Decision branches and AI tools fill the
  section width like the design's Node / Row; the derived cap and the
  Decision max-content rule now apply to the DOWN layout only; branch and
  tool rows are spaced with the node body gap
- self-loop: the apex sits 48px above the node's top edge regardless of
  node height and port position; getSelfLoopHeight is exported
- node text: follow-up markers for the DS Tooltip decided for 3.1
- demo: the multi-port template forwards disabled to icon and description

* test: set IS_REACT_ACT_ENVIRONMENT once per package in a vitest setup file (#152)

* test(ui): set IS_REACT_ACT_ENVIRONMENT once in a vitest setup file

The flag was set ad hoc at the top of six specs, with two competing
typings (a globalThis cast and a declare global block). vitest.setup.ts
sets it for every test, types.d.ts declares it, and the setup file is
type-checked but excluded from the emitted declarations.

* test(ai-studio): set IS_REACT_ACT_ENVIRONMENT in a vitest setup file

Same layout as packages/ui: vitest.setup.ts sets the flag, types.d.ts
declares it, the spec drops its declare global block.

* fix: follow-ups from the final Design System 2.0 review (#161)

* fix(ui): follow the design library on node rows, fields and controls

Final review before 3.0 read every value back from the Figma masters:

- node rows drop the border the master does not have, take
  canvas/node/bg-content-default and Body/S Emphasized; sections and the
  AI tools wrapper outline with canvas/node/stroke-default
- the node subtitle renders at Label/S, the size the master binds
- disabled and read-only fields, plus hovered list items, use ui/bg/inset
  like the checkbox, radio and switch already did
- field labels, helpers and the required marker each take their own text
  role, and a success helper finally has one
- Select and DatePicker join the field redesign: they render inside Field,
  share the one size scale, and paint a disabled background
- edge labels subtract their border from the padding, so the outer size
  matches and selection no longer resizes the label
- NavButton label padding, menu item inline padding, snackbar width and
  buttons, segment picker padding and avatar sizes match the library

The self-loop apex is derived from the node's top edge instead of half its
height, which only agreed on a 64px node.

* fix(sdk): ship the font licences and document every NavButton prop

The SDK copies the UI font assets into its own dist so consumers resolve
`./assets/*.woff2` without installing the UI package, but the copy filter
kept only `.woff2` files, leaving the SIL Open Font License texts behind.
The copy step moves into `font-assets.mts` and now takes the licences too,
with a spec that no longer needs a build to run.

NavButton reported 4 props in the docs because its base type sat behind
`Omit<BaseButtonProps, 'children'>`, which the UI API generator could only
warn about. The generator unwraps `Omit`, `Pick` and `Partial` over
first-party types, and warns when a referenced prop type is missing from
the TypeDoc output at all. NavButton documents 8 props.

* fix(sdk): repoint the editor at the renamed input size variables

The letter-based field size scale replaced the word-based one and removed
`--wb-public-input-padding-medium` and `--wb-public-input-border-radius-medium`,
but the syntax highlighter, the dynamic typed input and the variable text
still referenced the old names. An undefined custom property makes the
declaration invalid at computed-value time, so those surfaces lost their
padding and radius with nothing failing.

The changesets are rewritten against 2.3.0, the last published version. A
reader of the CHANGELOG never sees the states this branch passed through,
so superseded entries are merged into the change that replaced them: the
self-loop height step into the apex change, the row width cap into the fill
rule, the body roles into the body spacing, and the three token exports plus
the token prefix rename into one design-token entry. Every major changeset
now carries a `Breaking changes:` list, and RELEASE.md states both rules.

* docs: add the 3.0 upgrade guide

Covers the move from the bundled @synergycodes/overflow-ui to
@workflowbuilder/ui, the three CSS custom property families that replace the
single --ax- namespace, the component API migrations, and the behaviour
changes a 2.3.0 editor will notice.

The design-token section states the real shape of the change: of the 668
tokens published in 2.3.0, 150 keep their name under the new prefix and 518
have no direct counterpart, because the per-component spacing and radius
layer became a generic scale plus semantic role sets. The 88 primitives that
keep their name but change value are listed with both values, since they
repaint a theme that migrates by prefix alone.

* docs: scope the button shape migration to Button

`SegmentPicker` still takes the `Shape` type it always had, `default` or
`circle`, and maps `circle` onto the NavButton `round` variant internally.
Only `Button` moved to `ButtonShape` with `default`, `square` and `round`.
The changeset and the upgrade guide told every consumer to rename
`shape="circle"`, which is wrong advice for `SegmentPicker`.

* fix(ui): paint a disabled list entry the way the design library does

The `Menu item` master binds `ui/bg/inset` for Hover, Focus, Active and
Disabled alike, and `ui/text/disabled` for a disabled label, across all
three sizes. The disabled rule went transparent instead and took its text
colour from `--wb-public-input-color-disabled`, which resolves to
`ui/text/ghost-default` - the role the master gives the suffix text, not
the label.

Both values are exposed as `--wb-public-list-item-background-color-disabled`
and `--wb-public-list-item-color-disabled`, so the entry stops reaching
across to the input's variable for a colour that was wrong anyway.

* feat(ui): mark the chosen list row with a check, not colour alone

The `Menu item` master carries a `Selected indicator` in its Selected and
Selected + Hover variants: Phosphor Check, Outline/Bold, 16px, bound to
`ui/icon/action-default` and drawn at the trailing edge. Its usage
guidelines state that a selection is never communicated by colour alone.
Neither `Menu` nor `Select` rendered it, and the two states they had to
distinguish sit 1.011:1 apart in the light theme.

Row icons also take their own roles now: `ui/icon/subtle-default` by
default and `ui/icon/disabled` when the row is disabled, where before they
inherited the label colour. A destructive row keeps its own colour, since
the master has no destructive variant to follow.

* fix(sdk): render the palette Templates action as ghost-secondary

The `Left_Sidebar` master binds Ghost-Secondary, Size S for it. The button
kept `variant="secondary"` through the redesign, which only renamed its
size; `secondary` meanwhile went from the outlined treatment to the solid
grey one, so the action turned into a filled grey slab.

* fix(ui,sdk): align the snackbar row and stop the folder label wrapping

The snackbar row had no vertical alignment. Its message column stretched
to the row height, which the 32px close button sets, so the text rendered
6px above the icon and the button. The `Snackbar` master aligns the row to
centre and, in the two-line variant, keeps the icon level with the first
line - both are now expressed in CSS.

In the app bar, `.title` carried `white-space: nowrap` and `.folder-name`
did not, so entering title edit mode widened the field and broke the label
over two lines.

* feat(ui): follow the design answers on placeholder, avatar, disabled and critical rows

Token export of 18.09 (library 1.1.13): new `gray-550` primitive, new values
for `ui/text/muted-default` in both themes, `ui/icon/subtle-default` in dark
and `ui/text/critical-default` in light. Nothing renamed or removed.

- the placeholder takes `ui/text/subtle-default`; `ui/text/ghost-default` is
  the disabled-state role and left it at 1.8:1
- `Avatar` medium and small bind `size/300` and `size/225` instead of literals
- a disabled list row has no background; the fill it carried was inherited
  from the neighbouring hover variant, not a decision
- a destructive row becomes `tone="critical"`: the label and icon carry the
  critical colour, the row keeps the ordinary background, and the red tints
  are gone

* fix(ui): keep a selected edge label the size it has in every other state

The padding subtracts the regular border width in every state, but the
selected and temporary rules swapped `border-width` to the bold size without
touching the padding, so the label still grew 2px per axis on selection -
the same delta as before the padding compensation landed. Only the overall
box had got smaller.

The `Edge_labels` master draws every state of a size at one box (XS 85x28,
S 98x32, M 102x36) and thickens the stroke inward, so the extra weight is
now an inset shadow and the border box stays put.

* chore(ui): start the published version line at 1.0.0

`@workflowbuilder/ui` has never been on npm, so its first release starts its
own version line rather than continuing the internal 2.0.0 that no consumer
can install. The package version drops to 0.1.0 so the pending major
changesets resolve it to 1.0.0; the SDK still resolves to 3.0.0.

The upgrade guide named 3.0.0 as fact and `move-ui-library-in-repo.md` cited
`@workflowbuilder/ui@2.0.0`, a number that would have reached the CHANGELOG
without ever reaching npm.

* docs(changeset): state what the segment picker change actually did

The entry claimed the padding now follows the size of the picker. There is
no such mechanism and none was intended: all 70 variants of the master sit
at zero container padding, so the padding and its public property were
removed outright. That removal is breaking for anyone overriding
`--ax-public-segment-picker-padding`, which shipped in the UI library
bundled by 2.3.0, so the entry moves to a major with a migration step.

* docs: count distinct custom properties, not token paths

The 2.3.0 export carries `acc7- 100` through `acc7- 950` alongside the
same names without the space, and each pair collapses to one CSS custom
property. Counting paths made the totals ten high: 658 published and 508
without a counterpart, not 668 and 518. The new-role count moves to 419
with the `gray-550` primitive the 18.09 export added; 150, 88 and 62
reproduce unchanged.

* docs(changeset): put the self-loop breaking list on a major

`SelfConnectingEdge` was exported from 2.3.0 with a `nodeHeight` prop that
no longer exists, so the entry is breaking and its `Breaking changes:` list
belongs on a major, as RELEASE.md now states. The resolved version is
unchanged; the CHANGELOG grouping is not.

* fix: close four gaps the external review found

- the props-table generator recurses into the source of `Omit` / `Pick` /
  `Partial` instead of resolving it by id, so a nested utility type no longer
  drops every inherited prop, and it warns when the keys are not string
  literals rather than silently keeping what it should drop
- the SDK font copy throws when no `OFL-*` file was found; the prefix is a
  second copy of a convention `packages/ui` owns, and a rename there used to
  republish the fonts unlicensed with both specs still green
- `useSelfLoopApexY` resolves its own fallback and returns a number, so the
  loop and its label read one expression instead of two that agreed by luck
- `Select` and `DatePicker` set `aria-required` on their button trigger

Docs: the upgrade guide lists the three public properties that go away with
no counterpart, which two major changesets promise, and RELEASE.md points at
a changeset that still exists.

* docs(release): drop the org-level trusted publisher path

RELEASE.md sent the maintainer of a not-yet-published package to the npm
organization settings for an "Add trusted publisher" button. No npm
documentation describes that path, and the `npm trust` reference states
the opposite prerequisite: the package must already exist on the registry.
The step now names the documented page only and says that a first version
is published by hand, with the trusted publisher registered afterwards.
This applies to `@workflowbuilder/ui@1.0.0`.

* fix(docs): lay out UI examples inside the preview shadow root

Examples render in a shadow root that receives only the library CSS, so the
per-example CSS modules for Button, NavButton, Input and TextArea never
reached their wrappers and every child stacked as a block. ComponentPreview
now owns the layout as `Stack` and `Row`, and the three module files go away.

The stage also clipped tall examples: `overflow: hidden` makes it a scroll
container, which has no content-based automatic minimum, so `aspect-ratio`
acted as a hard clamp. `overflow: clip` hides the same overflow without that
side effect and the stage grows with its content.

* refactor(ui): make the menu trigger a Menu.TriggerButton compound part

`MenuTriggerButton` only ever renders as the `Menu` child, so it moves next
to `Menu` and is reached as `Menu.TriggerButton`, like `SegmentPicker.Item`
and `Tooltip.Trigger`. `Menu` now tracks its open state for the uncontrolled
case as well and hands it to the trigger through context, so the `isOpen`
prop and the duplicated state in every consumer go away.

Docs: the standalone page folds into a section on the Menu page with its own
props table; the registry entry stays so the coverage guard keeps the props
table in check. The package has not been published yet, so nothing here is a
migration.

* chore(tokens): adopt the 21.09 export (design system 1.1.14)

Three values change and one token is added; nothing is renamed or removed.
`ui/bg/canvas` and `ui/bg/app` become gray-300 in the light theme, so the
canvas parts from the white panels, and the grid dots step down to gray-450.
In the dark theme `ui/bg/app` moves from gray-900 to gray-800. The new
`ui/bg/inset-subtle` (gray-200 / gray-650) has no consumer yet.

The export's code-syntax fixes on the Figma side (`--wb-bg-app`,
`--wb-surface-sunken-subtle`) do not reach this repository: the generated
property names come from the token path, not from Figma's code syntax.

* fix(sdk): give VerticalLayout the class its stylesheet declares

The renderer asked the CSS module for `horizontal-layout`, which
`vertical-layout.module.css` never defines, so the wrapper rendered as a
plain block and stacked controls touched. Present since the 2.0.0 code drop;
visible wherever a `VerticalLayout` holds more than one control, such as the
two selects under the AI Agent node's Operational Settings.

* docs(changeset): drop a reverted claim and tighten two release notes

`menu-item-selected.md` still described a disabled list entry keeping the
inset surface and exposing `--wb-public-list-item-background-color-disabled`.
That commit was reverted when the design answers came back: on this branch a
disabled row is transparent and no stylesheet declares the variable, which the
critical-tone changeset already states. Only the `--wb-public-list-item-color-
disabled` migration note stays.

`vertical-layout-class.md` names the nested layouts as the affected surface;
the root layout takes its 16px gap from `.json-form-container > div` and never
depended on the broken class. `canvas-background-role.md` no longer orders
`ui/bg/inset-subtle` between `bg/base` and `bg/inset`, which holds only in the
light theme.

* docs: align the UI changelog with the 1.0.0 first release and defer the nodeId removal

`packages/ui/CHANGELOG.md` still introduced the package as `2.0.0` and kept a
note between the `# Changelog` heading and the first version, which
`release:version` would have folded into the generated release notes. The
first section is now `## [1.0.0]`, ready to receive the generated bullets,
with the overflow-ui provenance inside it.

`getHandleId`'s deprecated `nodeId` stays a no-op through 3.x; its JSDoc now
names 4.0 as the removal, since the 3.0 upgrade already carries the token
migration.
* fix: address the Design System 2.0 clickthrough findings

- List items draw the focus ring on :focus-visible only; Base UI sets
  data-highlighted on hover too, which painted the ring on mouse hover.
- Tooltip z-index moves to the Base UI Positioner, the positioned element;
  on the static Popup it had no effect and host panels covered tooltips.
- A selected edge label draws its heavier stroke as one outline instead of
  border plus inset shadow, which left an anti-aliased seam at canvas zoom.
- The add-condition button is full width again with the outlined treatment.
- The palette drag preview renders the default node state; Chromium and
  WebKit clip the drag image to the element box, which squared the ring.

* fix(ui): centre the icon with the label in the Select value

* fix(sdk): use the outlined ghost-secondary variant for modal cancel actions

* fix(sdk): rest the row port on the content edge like Node / Row, growing around a fixed centre

* fix(demo): name the undo-redo hooks decorator so the SDK stops hashing it

* chore(ui): drop the changesets for the unreleased package
The popup was exactly as wide as the trigger. An option wider than that,
such as the selected one with its check indicator next to a label that
fills a content-sized trigger, overflowed the list and was clipped along
with its rounded corners. The trigger width is now the popup's minimum.
* docs(ui): link UI prop types to a generated Types page

The Props tables of the UI Library pages showed type names such as
ButtonVariant as plain text, so their shape and description could not be
opened. The UI API generator now records which first-party types each prop
mentions and emits those types (transitively) into ui-api.json. A new
UI Library > Types page renders each one with an anchor, and every type name
in a Props table links to it.

Tuple-backed unions like (typeof SIZES)[number] and enum-backed template
literals render as their literal values instead of the formula.

* fix(docs): key UI type links by declaration id

Addresses the review of the UI Types page:

- Resolve type references by TypeDoc target id instead of by name, so an
  aliased import (`Size as ItemSize`) links to its declaration instead of
  crashing the generator, and duplicate names get distinct anchors.
- Keep only references that appear in the rendered type, so every Types
  entry is reachable from a link.
- Render unions by their shape instead of flattening their object members.
- Apply the enum shortcut only to single-span template literals.
- Use h2 for type entries and case-sensitive anchors; escape names in the
  link pattern.

* feat(ui): export and document the prop types the docs reference

Every type a component prop refers to is now exported from
@workflowbuilder/ui and carries a JSDoc summary and an @category tag, so
the docs can generate an API Reference page for it.

- Export SelectorSize, SelectValueType, OffsetAxes, ListItem and
  SegmentPickerItemProps; export the IconSwitch variant as IconSwitchVariant.
- Spell Placement and TooltipPlacement out as literal unions instead of the
  private Side / Align helpers.

No changeset: @workflowbuilder/ui has not had its first release yet.

* docs(ui): replace the Types page with a UI API Reference

A second starlight-typedoc instance generates one page per
@workflowbuilder/ui type, grouped by @category under UI Library > API
Reference, with the same strict options as the SDK API Reference. The
generator writes its entry point (every type a Props table mentions, and
the types those mention), and each type name in a Props table links to its
page. The hand-rolled Types page is gone.

* docs: keep ticket IDs out of commit messages and pull requests

The repo is public and ticket IDs point to a private tracker, so they read
as dead links in commit history and PR descriptions, just as they do in code.

* refactor(docs): mark type links where the generator renders them

typeToString now writes each first-party type name as
`{@link <page path> <name>}` at the point it renders the reference, and the
Props table splits on that marker. This replaces the per-prop reference
lists, the regex that re-found the names in the rendered string, and the
type-code / property-list components; props-table.astro is back to its
original shape plus the link rendering. The rendered tables are unchanged.

* docs: label the two API references by package

The sidebar showed "API Reference" twice, once under UI Library and once at
the top level, and the UI one listed TypeDoc's index page as
"@workflow-builder/docs". They are now "UI API Reference" and "SDK API
Reference". The UI one lists its categories one by one, like the SDK one,
and the sidebar / @category parity check covers both packages.

* chore(docs): drop the category count from the sidebar parity log

* refactor(docs): derive the linked-type closure from the type renderer

Rendering a type already marks the first-party types it mentions, so the
closure the UI API Reference needs is a loop that renders each linked type
while the Set grows, instead of a separate walk over the TypeDoc tree.
Template literals render their spans again so an enum behind one
(SnackbarVariant -> SnackbarType) is reached. The duplicate-name check goes
too: the generated entry would already fail to compile on a duplicate.

* refactor(docs): name the TypeDoc kinds the type links resolve to

* fix(docs): address the review of the UI API Reference

- Watch TypeDoc only under `astro dev`, for both references: in watch mode a
  TypeScript error made `astro build` wait forever instead of failing, and
  the UI output was regenerated mid-build. The two instances now share one
  set of strict TypeDoc options.
- Write the entry import with `/` so it parses on Windows.
- Check after the build that every UI API Reference link on the UI Library
  pages reaches a page and no `{@link}` marker is left.
- Report sidebar / @category drift for both packages in one run.
- Correct the FieldSize, ItemSize and MenuItemProps.size doc comments.
- Name the remaining TypeDoc kinds, explain the tsconfig overrides and the
  Props table split, and fix the leftover "API Reference" label.

* refactor(docs): render Props table type links from matched segments

* refactor(docs): derive the UI API Reference sidebar from the generator

- The generator writes the categories of the linked types to
  src/generated/ui-api-categories.json, and astro.config.mjs builds the UI
  API Reference sidebar group from it, so the list is no longer maintained
  by hand. The sidebar / @category parity check goes back to covering the
  SDK only; the UI groups match the rendered pages by construction.
- Detect `astro dev` through NODE_ENV, which Astro sets before loading the
  config, instead of scanning process.argv.
- Point the shared TypeDoc options at the decision log instead of
  repeating the rationale inline.
- Replace the two reflection-walking IIFEs with a flattening helper, and
  name the parity script's filter parameter.

* refactor(docs): name the UI API Reference generated files instead of commenting them

* refactor(docs): name the parts of a type link match in the Props table

* refactor(docs): spell out single-letter callback parameters

* refactor(ui): share PopupSide and PopupAlign between Menu and Tooltip placements

Placement and TooltipPlacement return to the `Side | \`${Side}-${Align}\``
form instead of spelled-out literal unions, built from one exported pair of
types, so the side and alignment get their own UI API Reference pages.

* refactor(docs): name the dev-server check instead of commenting it

* refactor(docs): move the TypeDoc output cleanup out of package.json

* chore(docs): drop the comments from the UI API Reference tsconfig

* refactor(docs): name the pieces of the UI API Reference link check

* refactor(docs): name the UI API Reference link format and directory once

src/ui-api-reference.mjs now owns the `{@link <page path> <name>}` format
(write, split, strip, detect) and the `ui-api` directory name, and the
generator, Props table, link check, cleanup script and Astro config use it
instead of repeating inline regexes and strings. The remaining literals
(`@category`, TypeDoc's `Other` category, the trailing-slash pattern) get
names too.

* fix(ui): correct the prop type docs and drop the ones that repeat names

- WithIcon, FieldState and Size no longer promise behaviour the components
  do not have (IconSwitch uses `icon` for its off state, only Input and
  TextArea block edits in `read-only`, most components have their own size
  scale).
- Type summaries that only restated the type name keep just their
  `@category`; property comments that restated the field name are gone.
- SelectValueType moves next to SelectItem, whose `value` now uses it, and
  PlacementContextValue.align is `PopupAlign | 'center'`.

* fix(docs): harden the UI API Reference checks and tidy the generator

- The link check fails when it matches no page, instead of passing on an
  empty scan.
- The generator rejects an `@category` with a space: TypeDoc stores it
  with an underscore, so the sidebar group would stay empty.
- The TypeDoc entry imports the UI barrel through the `@ui/*` alias,
  which removes the relative-path and Windows separator handling.
- The TypeDoc output cleanup script is gone: TypeDoc empties its output
  directories itself, under both `astro build` and `astro dev`.
- The decision log records the second instance and why `watch` runs only
  under `astro dev`; stale "API Reference" labels and the duplicated
  decision-log pointer are fixed.
- Generator naming: `categoryOf`, `componentsDataFile`,
  `isAliasOrInterface`, full-word callback parameters, and the variant
  note built from a named value; the unreachable default category is gone.

* refactor(ui): name the SegmentPicker, Modal and Menu types after their component

Before the first release of @workflowbuilder/ui, the generic exported
names `Shape`, `FooterVariant` and `Placement` become
`SegmentPickerShape`, `ModalFooterVariant` and `MenuPlacement`, matching
`IconSwitchVariant` and `TooltipPlacement`. `SegmentPickerShape` moves from
the button types to the segment picker. The SDK modal store and the 3.0
upgrade guide follow the rename.
…human verdict (#120)

* feat(types): add non-terminal waiting execution status

WB-497 step 1: a parked run reports 'waiting' while a gate holds it.
Deliberately absent from TERMINAL_EXECUTION_STATUSES.

* refactor(execution-core): type event and status params across the emit chain

EventEmitterPort, Activities, ExecutionStore and the worker database now take
ExecutionEventType / ExecutionStatus instead of bare strings, so a typo like
'canceled' fails to compile instead of silently never closing a run.
EventEmitterPort is restated in the temporal package's sandbox-safe
core-contract (package-name imports survive into the bundled d.ts), with the
drift pin extended in core-contract.test.ts.

WB-497 step 1b.

* feat(execution-core): waiting node results, fail-fast without an awaitResolution port

NodeExecutionResult is now a union: CompletedNodeExecution or
WaitingNodeExecution, discriminated by the waiting marker, so reading .output
without checking no longer compiles while existing executors stay source-
compatible. ActivityRunnerPort gains optional awaitResolution(nodeId)
resolving to the completion the verdict carries. A waiting result on an
adapter without the port is a runner-level abort routed around errorPolicy:
under 'continue' it would close the run as completed with the gate silently
skipped.

WB-497 step 2.

* feat(execution-core): park waiting nodes on the awaitResolution port

A waiting result now parks its wave slot: node_waiting is emitted, the run
status goes 'waiting' on the first park and back to 'running' after the last
resolution (parked is a counter, so concurrent gates share one transition),
and the verdict's completion rejoins the normal path — node_completed, output,
nextPort. The missing-port abort carries code 'waiting_unsupported'.

WB-497 step 3.

* feat(temporal): resolveNode update delivers the verdict to a parked run

defineUpdate('resolveNode') carries { nodeId, resolution } where resolution is
the CompletedNodeExecution the parked node finishes with, passed through
untouched. The handler and the resolutions map live inside the workflow
function (per-instance), first-write-wins rejects a duplicate verdict
synchronously with 'verdict_already_delivered', and awaitResolution is a
deadline-free condition() so the gateless history is untouched. Decisions
recorded in durable-pause.decision-log.md.

WB-497 step 4.

* test(temporal): restart, independent verdicts and cancel-while-waiting for durable pause

Three harness scenarios: a parked run survives a worker restart and the
resolveNode update resumes it with downstream running exactly once; two gates
in one wave park concurrently, take verdicts independently and reject a
duplicate synchronously; cancelling a parked run closes it as cancelled with
execution_cancelled after node_waiting and no node_failed for the gate.

WB-497 step 5.

* docs(temporal): document durable pause and its wave-barrier limits

Adds the 'Pausing a run for a human' section: the waiting result, node_waiting
and the waiting/running status pair, delivering a verdict through the
resolveNode update, first-write-wins, cancel behaviour, and the three
deliberate wave-barrier limitations. The duplicate-verdict test now pins the
verdict_already_delivered failure type the README promises, and an unused
WaitingNodeExecution re-export flagged by knip is dropped.

WB-497 step 6.

* fix(temporal): validate resolveNode updates before acceptance

A non-TemporalFailure thrown from an update handler fails the workflow task,
which retries and redelivers forever: one malformed verdict wedged a parked
run for good. The validator runs before acceptance instead, so a rejection
writes nothing to history and cannot fail the task. It guards engine
integrity only: envelope shape (verdict_malformed, including the reserved
errorRoute port), the node exists (verdict_for_unknown_node), first-write-wins
(verdict_already_delivered, moved out of the handler) and the node is actually
waiting (node_not_waiting, retryable when a verdict races the parking
activation). Gate lifecycle now lives in one per-run wait-state map:
idle (absent) -> waiting -> resolved.

WB-497 step 7, after external review.

* fix(execution-core): advisory status writes must not cost a park or a verdict

The waiting/running writes are derived state: their failure is now swallowed
(setAdvisoryStatus), so an exhausted DB activity no longer turns a parking
gate into node_failed or evicts a delivered verdict from the finally block.
The parked counter increments inside the try, closing a leak when the waiting
write threw before it. updateStatus now chains behind the sequenced emitter's
event tail, so a 'waiting' from one gate can never race past a 'running' from
another. Terminal statuses stay loud on purpose: nothing after them would heal
a silently missed write.

WB-497 step 8, review round 2.

* fix(execution-worker): started_at survives resumes, only the first running stamps it

Durable pause made the gate un-park the first writer of 'running', which
armed the previously dead started_at CASE: a verdict was stamping (and every
later resume re-stamping) the start timestamp, visible in the API. The IS NULL
guard stamps only the first transition to 'running'. Writing 'running' at
actual run start stays a separate fix (follow-up: running-status-at-start).

WB-497, review round 3.

* test(temporal): pin a parked-gate history in the replay guard

The committed v0-parked-gate.json carries the accepted resolveNode update,
node_waiting, the waiting/running status activities and the resume, so a
command moved on the parked path fails CI even while the gateless baseline
stays green (verified: an extra emit turns only this pin red, with a
DeterminismViolationError). A full park-and-resume history stands in for every
prefix, so it also guards runs still waiting when a deploy lands. Shared test
helpers move to fixtures/helpers.ts.

WB-497, review round 4.

* fix(temporal): accept a verdict whose output the payload converter dropped

The default payload converter is JSON, so resolution.output: undefined
reaches the workflow with no output key at all. The validator required
the key and rejected such a verdict as verdict_malformed, leaving the
run parked. A missing output now reads as undefined; arrays are still
rejected, and the key, nextPort and wait-state checks are unchanged.

Verified through the real converter in the unit test and against a
local Temporal in the durable-pause suite, on a port-routed fixture so
nextPort has an edge to reach.

* ci: run PR checks for PRs targeting feat/human-in-the-loop

The branch collects the human-in-the-loop feature PRs before they reach
main, so they need the same gate. Nothing else in the workflow was tied
to main: the changeset guard already diffs against github.base_ref.

* test(temporal): pin update rejection and cancellation through Event History

WorkflowUpdateFailedError with the expected type would also come back
from an accepted handler that threw later, so the rejection test now
gives the malformed updates explicit ids and checks that history holds
exactly one accepted update, neither of them. The cancel test checks
the failure cause is a CancelledFailure and that the run's last event
is WorkflowExecutionCanceled, not just that result() rejected.

* refactor(execution-core): track gate watchers in a Set in the runner test helper

Deleting the current element while iterating a Set skips nothing, so the
reverse index loop with splice is no longer needed. Notification order
flips to registration order; it was never a contract, since notify only
resolves a promise and the continuations run after the loop.
* fix(execution-core): register the wait before announcing it

* test(temporal): deliver verdicts the instant node_waiting is announced, no retry

* docs(temporal): the wait is registered before node_waiting is announced

* test(temporal): accept the verdict while node_waiting is still in flight

The held-executor test polled the store after release(), so by the time the second
verdict arrived the announcing activity and the status write had already completed.
It was checking a fully parked run, not the moment its name describes. The announcing
activity is now held after the event is recorded and the verdict is delivered inside
that window. The old order (announce, then register) rejects it with node_not_waiting.

Both holds are released in a finally inside the runUntil callback. The worker's shutdown
waits for in-flight activities and the held executor ignores cancellation, so a failed
assertion before release() used to surface as a 120s test timeout instead of the error.
… the run continues (#127)

* feat(types): add DecisionContract and lift it onto BaseNode

Hand-written contract types for a gate node: declarable effects tuple,
DecisionAction discriminated on effect, DecisionDeadline, DecisionContract.
BaseNode gains an optional decision field; presence marks a gate.

WB-500

* feat(backend): zod schema for the gate decision contract

Loose objects at every level so unknown keys survive. Actions are a
discriminated union on effect with parse-time defaults for port,
reasonRequired and maxIterations. Cardinality, unique names, distinct
resume/reject ports, required-subset-of-properties and the deadline
duration format are checked in the schema; graph rules come next.

WB-500

* feat(backend): validate gate contracts and proposal sources in the snapshot

properties becomes a loose object with the reserved decision key, so a
gate contract is parsed in place and everything else passes through. A
superRefine on the snapshot checks the graph rules: an explicit
proposalSourceNodeId must be a direct predecessor, and a gate declaring
rerun-source needs a resolvable source that is not itself a gate.

resolveProposalSource is a pure function over the execution-model shape,
shared with the pending-decision resource and the rerun loop later.

WB-500

* refactor(backend): one dictionary for decision-contract issue messages

Every domain message the contract and graph validation can produce lives
in DECISION_ISSUE_MESSAGES; the schemas and the snapshot refine read it
instead of carrying inline strings. Tests assert the message at each path
through the same dictionary, which surfaced one row that had been passing
on path alone with the wrong rule in mind.

WB-500

* feat(backend): lift the decision contract out of config onto BaseNode

The mapper destructures decision beside errorPolicy and label, so a gate's
contract reaches the engine as node.decision and never as ordinary config.
The value is already validated and defaulted by the snapshot parse.

WB-500

* feat(backend): validate the draft snapshot on publish

Publish parses the draft through workflowSnapshotSchema before copying it
and answers with the same invalid_snapshot 400 as execute; both go through
one parseSnapshot helper in routes/snapshot-validation.ts so they cannot
drift. A null draft keeps its old behaviour and is not validated. Draft
save stays unvalidated, pinned by a test.

WB-500

* refactor: drop the "gate" vocabulary for decision-carrying nodes

A node that carries a decision contract is still a node; the product has
no gate concept, and coining one invented a node kind the SDK never had.
Identifiers, issue keys, messages, JSDoc, comments, test names and
fixtures now say node, contract, or deciding node. The public JSDoc in
packages/types changes with it, so the temporal dist was rebuilt and its
tests re-run.

WB-500

* feat(backend): validateSubmittedDecision builds a Decision from a submission

A pure function the decision endpoint will call: it matches the submitted
action against the contract, requires a reason on reject when the contract
says so and a comment on rerun-source, checks every edited field for being
declared, editable and, if required, not emptied, and derives
resume-with-edits when a resume carries edits. The accepted result is a
Decision carrying the matched action, the effect and what was submitted;
refusals carry a code from the shared issue dictionary and a path into
the request. Value types are a later validator.

WB-500

* refactor: name the node's decision contract a DecisionRequest

The field on a node holds what the node asks a human to decide, not a
decision, so it is now data.properties.decisionRequest and
BaseNode.decisionRequest, typed DecisionRequest. That frees the word
decision for the lifecycle it now names end to end: DecisionRequest is
what the node asks, SubmittedDecision is what the decider sends, Decision
is what validation accepts. Issue keys follow (node_without_decision_request,
source_has_decision_request). The example rerun action is ask-again, so
request means one thing in an authored snapshot.

WB-500

* docs(backend): decision log and README section for the decision request

The log keeps only what the code cannot say: why the request is data on
a node rather than a node kind, why name and effect are split, why edit
is not an action, when validation runs, the lifecycle names and the
alternatives that were rejected. The README points at where the request
lives, when it is validated and how a broken one is reported.

WB-500

* fix(backend): reject an own __proto__ key anywhere in a snapshot before parsing

JSON.parse turns "__proto__" into an ordinary own key and zod's loose
objects copy unknown keys with a plain assignment, which for that key
swaps the output's prototype. A draft could smuggle an unvalidated
decision request through properties.__proto__: the snapshot schema saw
nothing, the mapper copied the inherited value into a real field on the
way to the engine, and a malformed variant made safeParse throw a
TypeError (500 on publish and execute). workflowSnapshotSchema is now
wrapped in a preprocess that walks the raw value and refuses the key at
its path with the usual invalid_snapshot 400. Draft save is unchanged.

WB-500

* fix(backend): parse the submitted decision's shape before checking its rules

validateSubmittedDecision took a typed SubmittedDecision but nothing
enforced the shape at runtime: edits as an array or a number were
accepted into Decision.edits, and a non-string reason threw from trim().
The shape now has one source, submittedDecisionSchema, from which the
type is derived; the decision endpoint parses a body with it before
calling, and the function checks only the rules.

WB-500

* test(backend): close matrix gaps found in review

Pins four behaviours that were live but untested: an own __proto__ key in
a submission's edits is dropped by the record parser without touching
the prototype; edits on a reject or rerun-source submission keep the
declared effect and ride along (the open point in the decision log); a
request with no schema at all is refused at schema; addressing an action
by its label instead of its name is unknown_action.

WB-500

* fix(backend): review minors for the decision request

Blank names, labels and ports are refused, not just empty ones. A
self-loop no longer counts as a predecessor when resolving the proposal
source. The deadline message names the upper bound. Comments trimmed to
the repo rule; the decision log records that decisionRequest is present
or absent and never null, points at the worked example fixture, and lists
node-id uniqueness as a pre-existing gap. README notes that structural
issues surface before graph rules.

WB-500

* fix(backend): second review pass on the decision request

The exported request schema states that it parses a request already inside
a guarded snapshot; raw JSON goes through workflowSnapshotSchema. The
errorRoute comment sits on portSchema again, the decision log names the
guarded parser precisely, a test name says only what it exercises, and
three matrix edges are pinned: the inclusive deadline ceiling, a string in
edits, a non-string action.

WB-500

* refactor: record the decision's action by name, return the matched action beside it

* fix(backend): scan a snapshot for __proto__ without recursion

Nesting depth is whatever the client sent, and the scan that runs before parsing
was recursive. A draft a few thousand levels deep, well inside the 1 MB body
limit, made it throw RangeError. zod does not catch that, so publish and execute
answered 500 where the contract promises invalid_snapshot 400, and the stored row
stayed unpublishable for good. Measured on Node 22, the version CI uses: the old
scan died above depth 3125, around 6 KB of body.

The walk now uses an explicit stack and copies the path once, on a hit, which also
drops the quadratic path copying. The README gains a note that the check does not
weigh position, so it also refuses a key buried in an opaque node property, where
zod never copies keys one by one and the key is inert.

* fix(backend): accept a form property that declares no type

The form schema is documented as validated for shape only and opaque to the
engine, but every property was required to carry a string `type`. JSON Schema
makes `type` optional and allows a list of names, and JsonForms 3.5.1 generates
a control for a property carrying only `enum`, `$ref`, `anyOf` or `oneOf`. All
of those were refused with invalid_snapshot on publish and execute.

Nothing reads a form property's `type`; `readOnly` and `x-pii` are the two keys
the backend does read, and their checks are unchanged. Decision 4 now spells out
what "shape only" covers, since the loose wording is what let this creep in.

* docs: the runner does not read decisionRequest

The backend README and the field's public JSDoc both said its presence, never
`type`, is what makes the run park. No engine reads the field: run a graph whose
node carries a valid request and the executor runs, downstream runs, the run
completes, and nobody is asked anything. The JSDoc ships in the Temporal
plugin's declarations, so a consumer would have built against it.

Not a gap to close but a sentence that conflated two markers. The backend and
the decision endpoint find a request by the field; a run stops where a node's
executor returns a waiting result, deliberately, so the runner learns no
product's vocabulary. Both texts now say that, the node type whose executor only
parks is listed as work outside this change, and a runner test holds the docs to
it by failing the day the runner starts reading the field.

* fix(backend): check edits at every level the form declares

The submission validator only walked the outermost object, so a readOnly child
was rewritten by replacing the object that held it, a child named by a nested
required list was cleared, and a readOnly field inside an array item was swapped.
All three came back as an accepted resume-with-edits.

The walk is now one recursion over the edited value paired with its schema. An
array is described as an object whose keys are indices and whose elements are all
declared by `items`, so the three rules stay a single flat loop and the error
carries the full path. A level the form does not describe inline declares nothing
editable, so an edit into it is an unknown field rather than being waved through.

* fix(backend): guard the decision request wherever it is parsed

The guard wrapped only workflowSnapshotSchema, so the exported
decisionRequestSchema swapped the parsed output's prototype on its own. Every
level of a request is a loose object, and an own __proto__ at the request, an
action, the form schema or a form property came back as an inherited field no
schema had seen. Today's routes reach a request through the guarded snapshot, so
nothing was exposed; the trap was waiting for the decision endpoint and the
pending-decision resource.

The export now guards itself. own-proto-key moves to domain/schema/, so the
decision schema can use it without domain/decision importing from domain/mapper,
which would have turned a one-way dependency into a two-way one.

mapNode reads own keys only. It is the one read that turns an inherited field
into a real one on the node sent to Temporal, and it is exported, so it can be
reached down a path the guard never saw.

* fix(backend): a published decision must name a node to judge

Resolving the proposal source was skipped unless the request declared an explicit
source or a rerun-source action. A node with no predecessor published, and so did
one behind a join with no explicit source, while the same resolver answered
source_missing and source_ambiguous for those graphs. The first is an orphan the
runner fails anyway; the second hands the decider a form and nothing to judge,
discovered while a person is already waiting.

The resolver now runs for every request. Only the rule that the source must not
carry its own request stays tied to rerun-source, the one effect that re-runs it.
Two accepting cases became refusals, and two fixtures gained a predecessor so they
go on testing what their names say.

The field's public JSDoc and decision 6 said the permissive thing; both now state
the rule. Decision 6 also records why the pending-decision resource keeps its
no-proposal path: a resolved source can still be skipped at run time.

* fix(backend): execute and publish agree on when a snapshot is absent

Publish validated any draft that was not null; execute short-circuited on any
falsy value. For an empty string, zero or false the two routes disagreed, one
answering invalid_snapshot and the other published_version_missing, while the
shared helper's comment promised they could never drift. Execute now tests for
null, and the route test that compares their answers became a table over exactly
those values.

The section comments this change had added to the route tests restated the test
names; each is now the header plus the one fact the code does not show.

* docs: regenerate the decision-log index

The collector had been failing since June: it looks for a Date line and the
TenantContextPort log carried Proposed/Landed instead, and the durable-pause log
had no metadata block at all. Both headers now follow the convention of the
other logs, with every date and the landing commit kept. The regenerated index
gains eight entries that had been missing, five of them since spring.

* feat(backend): name the domain issue in snapshot validation details

Every domain message had an identifier, but only the English text reached the
client: decisionIssue used the code to pick the wording and then returned zod's
generic code 'custom', and the serializer forwarded path, message and that code.
A client could only branch or translate by matching the sentence.

The identifier and the interpolated value now ride on the zod issue as params and
come out beside the existing fields as domainCode and params. zod's own code and
the message stay as they were. Sites built on .refine carry the same identifier
through decisionRefinement; .min(1) on actions became a refine, since zod drops
params from its built-in checks; and unknown_effect moved out of the union's error
map, which cannot carry params, into a pre-check that aborts the action the way the
union's failure did. A differential run over sixty-one inputs shows the only change
in existing output is zod's code on five issues, which nothing reads.
* feat(temporal): resolveNode on the engine port delivers a verdict to a parked run

WB-501 step 1. WorkflowEnginePort gains a required resolveNode(executionId,
nodeId, resolution) that answers every expected refusal as a result, never a
throw: the validator's four codes plus run_not_found and delivery_timeout.
The codes live once, as `as const` arrays in the execution-core port module;
the validator types its throws with them and the Temporal adapter derives its
runtime check from them.

The adapter addresses the update by name (RESOLVE_NODE_UPDATE_NAME, a
deliberate root export pinned like RUN_WORKFLOW_NAME) and bounds the RPC with
client.withDeadline (resolveTimeoutMs, default 10 s). Unknown failure types
are rethrown.

The harness showed that an update abandoned at the client deadline is not
dropped: the server still hands it to the next worker, so a retry may hear
verdict_already_delivered. The test pins "exactly one lands".

The validator's messages moved into one dictionary keyed by rejection, in the
backend's style.

* feat(backend): domain pieces the decision endpoint builds on

WB-501 step 2. toNodeResolution turns an accepted Decision and its matched
action into the completion the engine delivers: output is the decision itself,
nextPort the action's port, rerun-source excluded at the type level and the
reserved errorRoute port refused. findDecisionRequest reads a node's request
out of the parsed snapshot. A non-resume submission carrying edits is now
refused with edits_not_allowed, checked before the field rules so the refusal
names the edits.

* feat(backend): count a node's waits, the attempt a decision addresses

WB-501 step 3. countNodeWaits(executionId, nodeId) counts the node_waiting
events of one node in one run. The number is the wait instance a decision
must name, and zero says the node never parked. Shared with the coming
pending-decision resource, so it lives beside the event query, not in a route.

* feat(backend): POST /api/executions/:id/decision delivers a human's decision to a parked run

WB-501 step 4. One door for every future channel. The route loads the row,
authorizes executions:decide with the row's attributes (a deny wins over 404),
refuses terminal and cancelling runs, parses the body, reads the node's request
out of the parsed snapshot, judges the submission, checks the wait instance
(attempt = the node's node_waiting count), refuses rerun-source with 501 until
the engine can re-run a source, and delivers the completion through
engine.resolveNode. Every engine refusal is answered on the first try; no retry.

Codes, statuses and messages live once in decision-refusals.ts: the status map
spells each code, situations are typed against it, and total maps over the
engine's and the lookup's codes make a new code a compile error here.

* test(temporal): pin that the validator throws every code the port declares

A code added to the port's validator group without a throw site compiled fine
and stayed dead. The dictionary is exported and a type-level pin equates its
codes with VerdictRejection in both directions.

* feat(backend): a decision the engine could not confirm in time answers 503 with Retry-After

WB-501 step 5. The adapter's delivery_timeout becomes decision_delivery_timeout.
The update is not durable until a worker accepts it, yet the server may still
hand it to the next worker, so the message says the decision may or may not
have landed and that a retry answering decision_already_made means it did.
No retry anywhere on the server side.

* docs: the decision endpoint, its codes and the reasons behind them

WB-501 step 6. The backend README is the one place for the endpoint and its
answers; the decision log keeps only the reasons and closes its open points;
the Temporal README and decision log say what resolveNode answers with.

* test(backend): pin two more links of the check order and a thrown engine error

The row is checked before the body, the attempt before the effect. An error the engine
throws instead of returning surfaces as 500.

* refactor(backend): one fill for both decision dictionaries

The refusal table reuses the domain's {value} filler instead of carrying its own copy.
The rerun-source marker in the route states the limitation in words.

* refactor(temporal): name the validator's dictionary apart from the port's code array

VERDICT_REJECTIONS meant a private array of codes in execution-core and an exported map of
messages in the validator. The validator's is now VERDICT_REJECTION_MESSAGES.

* test(temporal): pin the default resolveNode deadline the README quotes

A fake client with a frozen clock checks that withDeadline receives now + 10 s by default and
now + resolveTimeoutMs when set. The entry-points table names RUN_WORKFLOW_NAME and
RESOLVE_NODE_UPDATE_NAME.

* docs(backend): when a deny hides ids, and the snapshot re-parse gap

Deny-before-404 hides which ids exist only if the port denies on absent attributes too.
Re-parsing a stored snapshot with today's schema can leave a parked run undecidable after a deploy.

* fix(backend): deliver a decision to the id the row carries, not the url spelling

Postgres accepts a non-canonical uuid and answers with the canonical row, so the
spelling in the url and the row's id can differ. The route passed the url string
on to the engine, which builds a case-sensitive workflow name from it: a request
that found the right row could address a workflow that does not exist and come
back as a 409 for a run that is still parked.

Submit and cancel already take the row's id; the decision route was the one call
site that did not. Every id downstream of the row read is now the row's.

* fix(backend): a timed-out decision resend names the wait, not the sender

The 503 told a caller that a decision_already_made answer to a resend proves
their own decision landed. Nothing in the endpoint tells two senders apart, so
with two deciders racing one wait the answer can be about the other one's
verdict while another parked node keeps the run open. The wording now claims
only what first-write-wins can prove.

The comment above the RPC deadline said a timed-out update is not durable, which
conflates the deadline with acceptance: the update may already have been
accepted when the deadline hits.

* docs: mark the seam where a decision's attempt has to reach the engine

The attempt check reads the node's node_waiting count from Postgres and then
calls the engine, which is handed only a node id: the wait map is keyed by node
id alone, so the check is not atomic with delivery. It holds today only because
a node parks at most once per run, an invariant the rerun loop breaks.

Nothing in code said so. Two comments now name the hazard at both ends and the
decision log carries the slug, so the rerun work starts from a grep rather than
from rediscovering the gap.

* refactor(backend): refuse takes named options, so no call site reads as a riddle

Three of the nine call sites passed a bare `undefined` to skip the message's
interpolation value and reach the extra response fields behind it, which told a
reader nothing about what either argument was for.

Both are now one options object: `{ value }` fills the message's slot, `{ extra }`
adds fields beside `code` and `message`. The refusals with neither pass nothing
at all, as before.

* refactor(backend): one name for the env every route mounts on

`Hono<{ Variables: AuthVariables & TenantVariables }>` was spelled out nine
times: twice in each of the four route factories, once in the server, and once
in two route test harnesses. The alias names the Hono env rather than the whole
app type, because the constructor needs the generic either way.
… a person decides (#154)

* feat(execution-worker): human-decision executor parks the run for a person's decision

* feat(ai-studio): human-decision node on the canvas, one handle per action the request offers

* feat(ai-studio): show a parked run as waiting on the canvas, in the log and on the controls

* feat(ai-studio): refund review template with a person deciding in the middle

* docs: the human-decision node in the AI Studio and worker READMEs

* fix(ai-studio): give the refund review template room to breathe

* fix(ai-studio): replay a snapshot through the same run-status rule as live events

* docs(backend): retire the human-decision-node follow-up marker

* test(ai-studio): a terminal event closes the run for good, live and on replay

* feat(ai-studio): the human-decision output schema names the reason and the comment

* fix(ai-studio): a terminal event takes the hourglass and the spinner off the nodes still in flight
…d reject (#162)

* fix(backend): decision request requires an explicit port on resume and reject

WB-641. A port is the id of a handle on one canvas, so the parser supplies no default
and rerun-source refuses one: a handle exists where a route exists.

* docs(backend): explicit ports in the decision request contract

WB-641. Decision log records why a port has no default and why rerun-source refuses one;
the README says the backend supplies none.

* test(backend): tighten the port assertions and put a comment back over its test

WB-641 review follow-up. params emptiness asserted on its own, the declared-effect test binds the
parsed action to the effect in its name, the abort comment sits over the test it describes.

* docs(backend): a handle per action that carries a port, rerun-source takes none

WB-641 review follow-up.

* fix(backend): refuse the rerun-source port on the field so it survives a sibling failure

WB-641 review follow-up. A superRefine on the object was skipped once a sibling key failed
structurally; the field-level refinement reports the stray port in the same round.

* test(backend): routing fixtures carry ports no default ever produced

WB-641 review follow-up. The nextPort assertions now prove the port came from the request.

* test(ai-studio): the shipped decision requests parse with the backend schema

WB-641 review follow-up. The preset and the Refund Review request go through the real
decisionRequestSchema; where this test should live is left to the authoring task.

* docs(backend): record the first schema tightening in the decision log

WB-641 review follow-up.

* fix(backend): the port-equality rule skips blank ports

WB-641 review follow-up. Two blank ports used to add a third, misleading issue about equal ports
beside the two empty-port issues.

* test(backend): the mapper test proves the lifted request is the parsed one

WB-641 review follow-up.

* docs(backend): name the abort mechanism and the full scope of the tightening

WB-641 review follow-up. Item 20 says why a structural failure suspends the request-level rules and
item 21 lists every shape the tightening refuses; the DecisionAction JSDoc names the two actions
whose port is authored.
* feat(execution-core): a completion may declare the run's outcome

A node's completion carries an optional `outcome: { value, resolvedBy }`.
When its port lights no edge, the runner records no dead end: the run
closes `completed`, `execution_completed` carries the outcome and the node
that declared it, and `updateStatus` receives it as a fourth argument. The
first outcome in scheduling order is the run's; `incomplete` and `failed`
still win over it. No emit is added or moved, so recorded histories replay
unchanged.

WB-499

* feat(temporal): carry a completion's outcome through the validator, the activities and the store

The update validator accepts an `outcome` key on the resolution envelope
and checks its shape: an object with exactly `value` and `resolvedBy`, both
non-empty strings; anything else is `verdict_malformed`. The fourth
`updateStatus` argument travels through the restated port, the activity
interface, `createActivities`, the sequenced emitter and `ExecutionStore`.

`CompletedNodeExecution`, `WaitingNodeExecution` and `NodeExecutionResult`
are restated in the seam instead of re-exported: `outcome` names a type
from `@workflow-builder/types`, and a re-export leaked that package name
into the emitted d.ts. Two comments claiming tsconfig `paths` inlines such
types were wrong and are corrected; a `keyof` pin and a `Parameters` pin
catch the drifts the object pins cannot see.

No command is added or moved, so both recorded histories replay unchanged.

WB-499

* feat(backend): a rejection declares the run's outcome and the route stamps who decided

`Decision` carries a required `resolvedBy`. The decision endpoint stamps it
with `human` after validation; the validator judges the body against the
request and returns the decision without an initiator, and a body claiming
one is stripped by the schema. `toNodeResolution` adds
`outcome: { value: 'rejected', resolvedBy }` to every reject completion,
edge or no edge; a resume is unchanged.

The `executions` row gains nullable `outcome` and `resolved_by` (migration
0002), and `GET /api/executions/:id` returns both. Publish gains no rule: a
test pins that a reject port with no edge still parses and maps.

The human-decision node's output schema in AI Studio names `resolvedBy`, since
the node's output is the decision.

WB-499

* feat(execution-worker): write the run's outcome and its initiator with the terminal status

`updateExecutionStatus` takes the outcome as its fourth argument and writes
`outcome` and `resolved_by` in the same UPDATE, under the same terminal
guard, null when there is none. The payload-size wrapper forwards the
argument, which was the last hop that dropped it.

WB-499

* feat(ai-studio): show a run's outcome and who settled it on the completed log row

The execution store keeps the outcome `execution_completed` carries, live
and from a replayed snapshot, and clears it on a new run. The log panel's
completed row names the value, the initiator and the node that declared
it; a run without an outcome reads as before, and the status pill stays
`completed`.

WB-499

* docs: a rejection as the run's outcome, in the READMEs and decision logs

The execution-core README gains an "Outcomes" section and a table row for
a completion that leaves its port unrouted on purpose. The temporal README
describes the `outcome` key on a verdict and the store's fourth argument.
The backend README and decision log record the outcome on a reject, the
route stamping the initiator, and publish requiring no reject edge; two
known gaps close. The temporal decision log records why the outcome rides
on the completion and why the completion shapes are restated in the seam.

WB-499

* fix: review follow-ups for the run outcome

The runner accepts an outcome only as an object with non-empty `value` and
`resolvedBy`; a truthy shapeless one used to suppress a dead end and write
two NULLs. Two runner tests pin the precedence across waves and against
verdicts delivered out of edge order.

The temporal seam comment states the real rule, reachability from an entry
point, and the inert `paths` mapping is removed after a build without it
stayed free of package-name imports. `keyof` and `Parameters` pins cover
every restated type and method. The store warning is JSDoc so it reaches
the d.ts, and `ExecutionOutcome` is exported from the workflow entry.

AI Studio pins the human-decision output schema against `Decision` in
source, where typecheck sees it, and drops the unread `outcome` store field.
The worker README records the deploy order: migrations, workers, then the
backend, since an old worker refuses the new envelope key.

WB-499

* fix: second review follow-ups for the run outcome

Blank strings count as no outcome in the validator and the runner, so a
whitespace result can neither hide a dead end nor render an empty line. The
worker writes the two columns with COALESCE, so a status write without an
outcome keeps what stands, as the port comments already claimed.

The worker and deploy READMEs record that the reference compose cannot
express workers-before-backend: the backend migrates and the worker waits
for it, leaving a window of seconds in which a reject answers 500. Replay
audit rule 10 and the durable-pause log note that argument-only changes
replay green but not semantically, so workers are one-way from the first
run that carried an outcome.

Pins cover both ports' key sets, `declaredOutcome` returns the checked
values, `ExecutionOutcomeRecord` reaches the main entry, the README states
the validator is stricter than the runner instead of "earlier", and the
decision-log index is regenerated.

WB-499
- Stop marks the request, so Reset shows at once
- Row status wins over replay for cancelling and terminal
- Terminal row settles leftover node markers
- No reopen or disconnect for a run that already ended
- Failed localStorage writes no longer break the store
- Malformed remembered run is cleared on mount
- Terminal frame closes the stream before it is applied
- Controls stay visible while a run exists
- Accessible names on Stop, Play and Reset
- Accurate adapter and migrate comments
- Realistic snapshot fixtures and missing Stop tests
- Shared test setup and helpers
- README: Stop and Reset section with known limits

WB-640
…debar (#173)

* refactor(ai-studio): the human-decision template moves next to the other components

The node folder keeps the definition only: index, schema, uischema and default
properties. Anything that renders lives under components/.

* refactor(ai-studio): one plain-object predicate and one execution-event fixture

Format detection kept a private copy of the predicate, and four test files kept
a copy of the same event builder.

* feat(ai-studio): the properties sidebar shows the decision form while a node waits

Selecting the parked node renders its request as a form: a row per field of the
decision schema, filled from the proposal source's output, read-only fields
disabled, fields absent from the schema not shown at all.

The decider's values are local state and never reach the diagram: the control
does not call handleChange, so nothing enters undo or the saved workflow. A new
node, run or wait remounts the form through its React key, which is the reset.

The form holds what the box shows, not the declared type, because a model fills
the proposal and a number can arrive as a string; the type is honoured when the
edits are built. Field kinds are named, so a kind added later fails to compile
until the control and both conversion tables decide what to do with it.

The action buttons, the reason field and the call to the decision endpoint land
in the next step; the endpoint adapter and the edits diff are already here.

* feat(sdk): export JsonForms for a standalone form

A custom control that renders data other than the node's properties, such as a decision
form over its own schema, needs a second JsonForms instance. Inside a control,
useJsonForms() already hands it the editor's renderers, cells and CSP-safe validator, so
the form looks and validates like the properties panel.

* feat(ai-studio): the decider approves or rejects from the properties sidebar

The hand-written field rows of the previous step give way to the editor's own form over
the request's schema, filled from the proposal: required marks, error highlights and
read-only fields come from the SDK controls. Approve sends the fields the person changed,
measured against the proposal; Reject sends the reason. A refusal from the backend shows
under the buttons and leaves the form usable.

After the decision the panel shows the settled values read-only, with the reason, while
the run goes on and after it ends. The highlighted path on the canvas names the action,
so the record does not repeat it.

What the person types is a draft kept in the execution store per wait, so it survives a
visit to another node; a new run or a reset starts without drafts. The fields are saved
as the form unmounts, from JsonForms' synchronous state, because its change report is
debounced and a click on another node unmounts the form first.

An integer field is not shown yet: the SDK text box hands it over as a string.

* feat(ai-studio): the canvas is read-only while it shows a run

The run executes the graph it was started with. From the start of a run until Reset, ended or
not, the canvas is read-only, so the edges the decision form reads its proposal source from and
the request it renders are the ones the run holds. The editor's read-only mode keeps selection,
and the decision form ignores it, so a decider still decides.

Ctrl+Z did not check read-only and would have changed the graph mid-run; it does now. The
proposal source rule also skips a self-loop, as the backend does.

* fix(ai-studio): only fields the decider can edit are sent or hold the decision back

A field counts when the editor shows it and it is not read-only. Edits are measured on those
fields alone, so a hidden array that a reconnected stream replays as a new object is no edit, and
a field of a type the editor leaves out can never become one. Approve is held back only by a
fault in such a field: a wrong value the model put in a read-only field, or a missing required
field the form does not show, no longer blocks a decision the backend would accept.

The editor form reports which top-level fields the schema faults, and the JSON Pointer encoding
and decoding sit together with that mapping. The control test runs in StrictMode, as the app does.

* fix(ai-studio): a request schema the validator cannot compile no longer takes the page down

The SDK validator throws while JsonForms builds a form from some schemas publish accepts, such as
a pattern that is valid JavaScript but not under the u flag. With no error boundary, React
unmounted the whole app. A boundary around the editor form now shows a notice in place of the
fields; the decision form holds Approve back and Reject still works.

* refactor(ai-studio): pure decision and form helpers move to utils

AI Studio splits by kind: components render, pure functions live in utils, as the visualize
helpers already do. The request, actions, outcome and values readers move to utils/human-decision
and the form schema and layout helpers to utils/editor-form, so the node template can share the
request reader without reaching into the form's folder. Only import paths change.

* docs(sdk): the JsonForms export says what a separate form needs

Without the renderers and core.ajv from useJsonForms a separate form has none of the editor's
controls and builds its own Ajv, which needs unsafe-eval; the editor's read-only mode is not
inherited. A test checks that the package index exports JsonForms.

* fix(ai-studio): the remaining review findings on the decision form

- A sent decision lives in the execution store per wait, so leaving the node and coming back
  finds it still on its way, accepted or refused; a line says so when the run is slow to record it.
- Success only when the answer names its effect; a refusal without a message says the HTTP status.
- Fields JsonForms cannot address are left out; nullable string and boolean fields are shown.
- The editor form holds its schema, layout and validation mode from mount, spaces its fields and
  gives the decider the panel's font.
- Refusal messages end each sentence; comments, names and test helpers match the code.
- Every test checks that the node data never changes; the rules that survived mutations have tests.

* docs(ai-studio): the decision form in the properties sidebar

* fix(ai-studio): the second review's findings on the decision form

- Reset is offered whenever a shown run is not running, disconnected and cancelling included,
  and the controls stay visible while a run is shown, so the locked canvas can always be freed.
- A key the validator reports percent-encoded (a space, `ł`, `%`) is decoded, so its error
  holds Approve back again; the SDK validator fix is tracked separately.
- The error boundary's notice no longer blames compilation.
- The control tests run in development and production mode, and the node-data guard compares
  the whole data.
- The read-only switch in the SDK app bar lifting the lock is deliberate and said so; comments
  and the README state the backend's rules and when the lock starts.

WB-642

* fix(ai-studio): the lock holds per run and the form leaves out keys it cannot address

- The canvas lock follows the shown run, so the SDK app bar's read-only switch lifts it for
  one run and every new run locks the canvas again.
- An empty key and a key with brackets are left out of the form: JsonForms reads the first as
  the whole form and lodash the second as a path, which sent every editable field as null or
  dropped the edit.
- The controls tests pin both sides of the Reset and visibility rules.
- The README and comments point at the lock's exceptions; the second test run is named for
  what it is, without StrictMode.

WB-642

* feat(ai-studio): a rejection asks for its reason in a dialog

- The sidebar no longer holds the reason: "Reject…" opens a dialog with one "Rejection reason"
  field and Cancel and Confirm Rejection in its footer. The dialog is the UI library's modal,
  since the SDK's openModal cannot be closed from outside.
- The reason stays in the wait's draft, so Cancel keeps what was typed; a required reason
  disables Confirm Rejection while it is blank.
- The preset's reject action requires a reason, and the Refund Review template inherits it.

WB-642

* style(ai-studio): the decision form's text areas are monospace like the rest of the panel

WB-642

* fix(ai-studio): one start at a time and one decision per wait

- Pressing Run shows Stop at once, disabled until the backend names the run, and hides Reset
  meanwhile, so a second start cannot leave two runs streaming into one view. Run and Stop carry
  their labels.
- A decision is sent once per wait: submit does nothing while one is on its way or accepted, so
  a second press no longer brings a refusal that hides the acceptance. Confirm Rejection shows
  disabled while the send is on its way.
- The reject dialog's placeholder no longer names a refund, since it serves any request.

WB-642
* feat(execution-worker): AI agent honours an optional output schema

The node's output becomes the object the model returned when the config
declares a JSON Schema; without one it keeps returning { response }.
The provider is created with structured outputs on, otherwise it drops
the schema and sends plain JSON mode. Includes the README section and
a backend test that the key survives into the worker config.

* feat(ai-studio): Refund Review drafts the refund as structured fields

The AI node declares refundAmount, orderDate, replyDraft and
internalReasoning with titles; the decision form lists the first three,
orderDate read-only, and hides the reasoning. Both prompts describe the
task instead of a layout. The AI agent node schema declares the key
without rendering it.

* feat(ai-studio): pick the AI agent's response shape from a dropdown

A "Response" select on the AI Agent node offers plain text, the default,
and the structured refund review preset. The choice is read off the
node's `outputSchema`: choosing the preset writes the shared schema,
choosing plain text removes the key. The schema moves out of the template
into the node's folder so the dropdown and the template share one copy.

* fix(ai-studio): the Response dropdown shows the preset only for its own schema and never rewrites it

* test(execution-worker): pin the request the AI agent sends with and without an output schema

* fix(execution-worker): a structured answer that stops early names its finish reason

* test(execution-worker): cover the AI agent's structured failure paths and tool loop

* test: tighten AI agent structured-output assertions and names

* docs(execution-worker): say the AI agent's answer is not validated against its schema

* docs(execution-worker): give AI agent structured output its own README section

* docs(ai-studio): describe structured AI agent output and how internalReasoning is kept back

* refactor(ai-studio): label the AI agent dropdown "Response format" and tidy its presets

* refactor(ai-studio): the response presets move to utils

* fix(ai-studio): the Response format names a schema no preset matches

* test(ai-studio): the Response format through the real properties panel

* fix(ai-studio): Refund Review sends the reply the person approved

* test(ai-studio): the refund form matches the draft's field types too

* test(execution-worker): web search on a plain-text call and the exact json_schema request

* fix(execution-worker): a structured answer that stops early fails as transient

* fix(execution-worker): a non-object output schema fails before any model call

* fix(execution-worker): the search loop's last step answers instead of searching

* docs(execution-worker): the jsonb key order and the review's wording fixes

* docs(ai-studio): a structured node has no response, and README order follows the base

* fix(ai-studio): the Response format list keeps one length, so Base UI never resets it

* test(ai-studio): the reply stays a field the person can correct

* fix(ai-studio): the refund draft says how much, where and when

* test(execution-worker): the plain-text search loop and a boolean output schema

* fix(ai-studio): the human decision node greys out in the palette while the canvas is read-only

---------

Co-authored-by: Dawid Aksamski <dawid.aksamski@synergycodes.com>
jimmeryn and others added 20 commits September 30, 2026 11:24
…ction

The fields control renders the UI Accordion itself instead of a plain label,
so the whole section, header included, steps aside while the node waits.
contain: inline-size keeps long field names from widening the accordion's
grid past the panel.

The hint takes the mockup's copy and grey block, and shows only when no edge
comes into the node.
…change under it

WB-648

With the app bar's read-only switch lifted, undo can change the picks while
the node waits. The open form now keeps the schema and proposal it opened
with and measures the edits against them; reopened under changed picks, a
field turned read-only shows the proposal instead of the draft. The settled
record follows the current picks.

Editable and required drops null from a nullable type, so a null the model
left holds Approve back. The README bullet is split and says where a hidden
value is still visible.
WB-648

FieldModeRow takes the mode labels, the Select items and its row styles
with it, matching how decision-form keeps one component per file. The
control keeps the store wiring, the accordion and the hint.
…es no output fields

WB-649

Tested on the Refund Review template: the seeded picks, every pick against
the backend contract, and what the decider's form shows after Run.
* chore(temporal): changeset and store contract for waiting nodes

Minor changeset for the HITL API since 0.1.0: waiting results, resolveNode,
the NodeExecutionResult union, advisory statuses and the run outcome.
ExecutionStore and the README warn that waiting/running can land after a
cancel. The execution-core engine guide names resolveNode and awaitResolution.

* fix(execution-worker): an advisory status never replaces cancelling

A waiting/running write scheduled before a cancel could land after the backend
wrote cancelling and put the row back to running, so the decision route and a
reloaded AI Studio treated the run as live again. statusesNotReplacedBy keeps
cancelling (and every terminal status) out of reach of any non-terminal write.

* fix(backend): refuse errorPolicy 'continue' on a node that carries a decision request

Under 'continue' the runner absorbs a failure with no port and lights every
non-error edge, so a missing executor or a failed node_completed write after an
accepted verdict would run approve and reject together. Publish and execute now
refuse it with error_policy_continue; fail and errorRoute stay accepted.

* fix(backend): decision edits are a patch that keeps each described level's shape

Edits were only checked on the keys they named, so replacing an object or a
list (null, a string, the other container) dropped read-only and required
children unchecked. Edits are now defined as a patch of the proposal, carried
unapplied, and a level with properties or items must keep its shape
(field_shape_changed); null passes where the level's type allows it.

* refactor(execution-core): waiting-node wording instead of gate

The waiting_unsupported message now says the adapter does not support waiting
nodes; comments in the runner, the activity-runner port, the sequenced emitter
and the durable-pause log follow. Tests, fixtures and replay histories keep
their names.

* refactor(temporal): resolveNode takes one named input

WorkflowEnginePort.resolveNode and TemporalWorkflowEngine.resolveNode take
ResolveNodeInput { executionId, nodeId, resolution } instead of three
positional arguments. The two ids are both strings, so swapping them
compiled. The method is new in the coming release, so its shape is free to
change only before it ships. The Workflow Update payload stays
{ nodeId, resolution }, so Event History and the replay histories are
untouched. A drift pin keeps the restated input in step with the core.

* docs(temporal): changeset lists the breaking changes and the store's migration

0.1.0 is on npm and exported NodeExecutionResult and WorkflowEnginePort, so
the changeset now carries a Breaking changes list, as packages/RELEASE.md
asks. The most important step is the store's: a 0.1.0 store with an
unconditional status UPDATE was correct, since 0.1.0 wrote only terminal
statuses, and after the upgrade a late advisory write could flip a finished
run back to running. The changeset also names resolveNodeUpdate,
RESOLVE_NODE_UPDATE_NAME and ResolveNodeInput. ExecutionStore's JSDoc now
also says that 'cancelled' can follow another terminal status and the first
one stands.

* docs(temporal): record that a failed Stop leaves the run in cancelling

The DELETE route writes cancelling before engine.cancel and does not undo it
when the call throws. Since the worker lets only a terminal status replace
cancelling, the row stays there while the run goes on, and a node that
parks meanwhile cannot be decided until Stop is retried or the run ends.
The fix is WB-669 (follow-up: cancel-engine-failure). The stale description
of the worker's guard is corrected too.

* test(execution-worker): pin the status guard through the query it sends

The test checked statusesNotReplacedBy alone, so putting the old terminal
list back into the UPDATE would have stayed green. It now drives
database.updateExecutionStatus through a mocked postgres tag and reads the
list the NOT IN guard receives: restoring the old list turns four cases red.
The helper is no longer exported, since only the old test used it.

* docs(backend): name the unapplied edits and the port-routing gap

Nothing applies a decision's edits on the execution path: the node's output
is the decision alone, so a step after it that reads the source's output
sees the proposal without the corrections. The decision log and the README
now say so, with the marker of the task that makes the node output the
settled values (follow-up: decision-settled-values). withEdits is described
as what it is, a display helper. The errorPolicy limitation for nodes that
route by port without a request gets its marker too
(follow-up: port-routing-continue-broadcast). Decision 26 states why null
is the one value exempt from the shape rule.

* docs(temporal): warn against continue on a waiting node and record a crash during Stop

The published README now tells a consumer with their own backend that a
waiting node routing its verdict by port must not use errorPolicy
'continue': a failure has no port, so approve and reject run together, and
the runner does not refuse it. The cancellation decision log adds the case
where engine.cancel never runs because the backend stops between the
cancelling write and the call.
…ck (#184)

* fix(ai-studio): a required field the decider empties holds Approve back

The backend refuses a decision whose edits empty a required field, whitespace
included, even where the field's type accepts the value. The form let such an
approve through, so the decider saw a refusal only after clicking. Approve now
stays disabled for it, measured on the same edits the click sends.

EditorForm reports data and faults in one onChange, so the block is computed
from a single report. requiredFields replaces two copies of the same reader.

* fix(ai-studio): a null the type allows is no edit once typed and cleared

A text area shows a null as empty and hands back '' once touched. editsOf
read that as an edit, so a required nullable field the model left null
held Approve back after the decider typed and cleared it, with nothing on
screen to say why. '' over a proposed null is now no edit where the
field's type includes null. The picker's plain string type still blocks,
since there a null fails the schema.

Tests cover the click that lands before the debounced report, the shape
the panel stores for Editable and required, and both sides of the new
rule; the parity table with the backend gains rows for them and a name
that says what it checks. The comment on isEmptied states the constraint,
the EditorForm onChange JSDoc names the report for the starting data, and
SchemaError is exported. The README sentence on the silent block is
simpler, and the comment at the Approve button is back to its original
wording.
…totype (#178)

* feat(ai-studio): run and stop use the design-system buttons

Run is the primary button and Stop the ghost-critical one, both size S as in
the prototype. They share one minimum width, so the button stays under the
cursor when Run turns into Stop. Reset moves to size S to keep the row even.

* feat(ai-studio): the decision node shows the wait and leads to the decision

While the run waits on it, the node ends in a footer: a clock, "Waiting for
decision" and Decide, which selects the node through React Flow so the
properties panel opens its decision form. The footer replaces the hourglass
marker under the node. The Decision section frame and the borders around
Approve and Reject go, as in the prototype, and the rows use design tokens.

* feat(sdk): apps show their own snackbars in the editor's stack

showSnackbar and closeSnackbar are public: a title and subtitle as given, an
optional key, and no auto-hide when autoHideDuration is null. The SDK's own
snackbars go through a translating helper, showTranslatedSnackbar. notistack
now receives the title as the message, so two different snackbars of one
variant no longer collapse into one. The provider drops its variant
components, unused since every snackbar renders its own content.

* feat(ai-studio): a waiting run is announced in a snackbar

When the run parks on a decision, a snackbar in the SDK's stack names the node
and offers Decide; with several waits it gives their count instead. It stays
until the wait ends or the person closes it, steps aside while a waiting node
is selected, and comes back when another node parks or the same one parks
again. Each show takes its own key, since notistack still counts a closing
snackbar as on screen.

* feat(sdk): content of the properties panel can put actions in its footer

PropertiesPanelFooter, placed anywhere in the panel's content such as a
JsonForms control, renders its children in the panel's footer through a portal,
so they keep the state and context of the tree that renders them. Content
registers with the panel, which shows the footer only while something is in
it. Delete becomes optional: it shows only with onDeleteClick, so a decorator on
'PropertiesBar' can remove it, and an empty footer goes with its separator.

* feat(ai-studio): the decision actions sit in the properties panel footer

Approve, Reject and the messages about the verdict render through
PropertiesPanelFooter, below the decision fields, while the reject dialog stays
in the form's tree. A decorator on 'PropertiesBar' drops the panel's Delete
button, since the footer now holds the decision; deleting stays on the Delete
and Backspace keys.

* fix(sdk): every snackbar without a key shows, even right after one closes

showSnackbar gives each call without a key a key of its own. notistack counts a
closing snackbar as on screen, so a shared key dropped a show that followed a
close, as an effect does under StrictMode. A key now only asks for one snackbar
at a time. The SDK's own messages keep one per content through
showTranslatedSnackbar. Before an editor mounts, showSnackbar and closeSnackbar
do nothing instead of throwing.

* fix(sdk): the panel's footer and Delete button read onDeleteClick the same way

The footer checked onDeleteClick against undefined while the button checked it
for truth, so a decorator setting it to null left an empty footer. The changeset
notes that a decorator calling onDeleteClick now needs onDeleteClick?.().

* fix(ai-studio): the waiting snackbar keeps closed waits closed and stays for a group selection

Closing remembers each wait it showed, so a closed snackbar for two waits no
longer returns when one of them ends. It steps aside only while a waiting node
alone is selected, since the panel shows the decision form for a single
selection only. The per-show key counter goes: the SDK gives each show its own
key now.

* fix(ai-studio): Decide drops a group's selection box, as a click does

useSelectNode resets nodesSelectionActive before addSelectedNodes, as React
Flow's node click does, so selecting the node from Decide after a group
selection leaves no selection box around it.

* docs(ai-studio): describe the waiting footer, the snackbar and the hidden Delete

The README lists the waiting node's footer with Decide and the waiting snackbar
in place of the removed marker, and says the properties panel has no Delete
button for any selection, deliberately for now. plugin.ts says the same, and a
store comment no longer mentions the hourglass.

* feat(sdk): useSetSelection selects nodes and edges from outside the canvas

An app could reach selection only through React Flow's internal store,
which is what AI Studio's Decide did. The new hook returns
setSelection({ nodeIds, edgeIds }): it replaces the selection as a click
does for one element, drops a group's selection box, ignores a held
multi-selection key, clears on an empty call, and returns false and
changes nothing when the canvas lacks any id.

React Flow unselects everything itself before the given ids are
selected, so nothing reads `selected` and a second call in the same tick
cannot work from stale state. Its add-selected actions are not used:
they extend the selection while the multi-selection key is held, and
selecting edges that way unselects every node. It is a hook of its own,
so useWorkflowBuilderActions keeps working outside Root.

* fix(ai-studio): Decide leads to the decision form, and the waiting UI follows the review

- Decide selects the node through the SDK's useSetSelection, replacing
  the selection even with the multi-selection key held, and asks the
  decision form for the focus; the form takes it as it mounts or while
  it shows. The request lasts only while its node waits. The app's own
  useSelectNode, which wrote React Flow's internal store, is gone.
- The waiting snackbar and the node's footer step aside while the run
  is cancelling, since the backend refuses a decision then. The
  snackbar offers Decide only for a node the canvas has, keys its waits
  as JSON so an id with a space can be closed, and reads a label only
  for a single wait, so renaming a node does not flash it.
- Decide on the node is named after it and does not drag it.
- Run and Stop: the class says it sits on the buttons, and the comment
  claims only what the shared width gives.
- The panel test mounts the SDK's PropertiesBar with the app's plugin,
  so it covers the footer and the hidden Delete; the verdict's gaps use
  the spacing token; two test names no longer promise a position.
The custom-properties rule import()s importFrom paths raw, which Windows rejects;
the config now passes the imported object. eslint-plugin-astro found the TS
parser only through the NODE_PATH of pnpm's POSIX bin shim; parser and
processor are now set explicitly.
* feat(types): execution snapshot response type

* feat(backend): a run's executed graph gets its own route

A malformed run or workflow id now answers 404 instead of a server error.

* feat(ai-studio): work out what a link to a run or a workflow should open

* feat(ai-studio): a link opens a run or a workflow

* feat(ai-studio): Run saves into the linked workflow and puts the run in the address

* docs(ai-studio): opening a workflow or a run from the URL

* feat(backend): workflows and runs are listed only when ENABLE_WB_LISTING=true

* feat(ai-studio): the disclaimer says the workspace is shared and what a run link exposes

* refactor(ai-studio): drop the app error boundary and give the Save draft button its own tests

* docs: workflow drafts are stored unvalidated on the public deployment

* fix(ai-studio): a crash while drawing offers the local draft and forgets the run that caused it

* fix(ai-studio): a server error keeps the remembered run in the address, like no answer

* fix(ai-studio): Save draft stays off in a run view, even with the run lock lifted

* test(ai-studio): URL mode neither shows nor writes the local draft

* refactor(ai-studio): the open-from-URL notices show in the editor's snackbars

* feat(backend): the listing switch is read only under the allow-all port

* revert(backend): the malformed-id 404s leave for their own change

* refactor(ai-studio): the address is the only memory of a run

* feat(ai-studio): the editor saves the link's workflow; a run hides Save

* refactor(ai-studio): a run view never offers Run

* refactor(ai-studio): one start function, one screen for every failure

* docs(ai-studio): the URL section follows the address-as-state rules

* fix(ai-studio): an autosave with nothing new writes nothing

* fix(backend): a run's stream listens under its stored id

* fix(ai-studio): a repeated error notice shows once until it is closed

* fix(ai-studio): a reopened run keeps the log as the tab left it

* fix(ai-studio): a run view reloads whenever it forgets its run

* fix(ai-studio): a workflow tab only looked at writes nothing on close

* fix(ai-studio): nothing is saved into the workflow after the error screen

* fix(ai-studio): Run saves the editor's clean shape into the draft

* docs(ai-studio): follow-up markers on the save adapter's SDK workarounds

* feat(ai-studio): the error screen offers to discard a broken local draft

---------

Co-authored-by: Dawid Aksamski <dawid.aksamski@synergycodes.com>
)

* docs(release): prepare main for SDK 3.0, UI 1.0 and Temporal 0.2.0

Drop the pre-release wording about @workflowbuilder/temporal now that 0.1.0 is on npm,
fix factual errors in the SDK changesets, remove changesets that describe states never
published, add the missing breaking changes to the 3.0 upgrade guide, and align the
hand-written UI 1.0.0 notes and the README with the shipped packages.

* docs(release): apply review fixes to the 3.0 release notes and guides

Drop the canvas background claim that compared against an unreleased state, list every
public CSS custom property that renames beyond the prefix or is removed in the upgrade
guide, correct the UI 1.0.0 notes on style imports and dependencies, and make the FAQ
consistent with npm distribution. Print the pre-release replay note only while that
recording exists.

* docs(release): group the removed public properties in the 3.0 upgrade guide
…194)

* chore(deploy): cap the bundled Temporal server at 2 CPUs and 1.5 GB

The deploy workflow now ships this compose to the AI Studio VM, replacing the
hand-maintained file that carried these limits.

* chore(deploy): generate the VM .env from GitHub secrets/vars, add resource limits

---------

Co-authored-by: Jakub Kubacki <jakub.kubacki@synergycodes.com>
@piotrblaszczyk
piotrblaszczyk merged commit 4848114 into release Oct 2, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants