Skip to content

Finish Screens

When a workflow terminates, the server returns a WfFinished envelope. <AsWfForm> reads it, fires @finished, and — by default — renders an AsWfFinish screen that drives the next user action: a redirect, a countdown, a button choice, or just a final message.

This page documents the envelope shape, the workflow-author helpers that produce it, the slot contract for customizing the rendering, and the event contract for hooking the actions into your router.

Why a unified terminal shape

Different workflows want different post-finish UX — sign-in wants a silent redirect, a long-running task wants a countdown the user can skip, a multi-outcome flow wants a choice screen. Without a shared shape the client either renders nothing (forcing every consumer to own the screen) or hardcodes one outcome (forcing the server to write JavaScript). The envelope keeps the server in charge of intent and lets the client render the corresponding UX without a custom component per workflow.

The envelope

ts
interface WfFinished<TData = unknown> {
  finished: true;
  data?: TData;
  message?: WfMessage;
  end?: WfFinishedEnd;
  aborted?: boolean;
  reason?: string;
}

interface WfMessage {
  level: "info" | "success" | "warn" | "error";
  text: string;
}

type WfFinishedEnd =
  | { mode: "immediate"; action: WfAction }
  | {
      mode: "auto";
      timeoutMs: number;
      action: WfAction;
      skipButton?: { label: string; behavior?: "now" | "cancel" };
    }
  | {
      mode: "manual";
      primary?: WfButton;
      options?: WfButton[];
    };

interface WfButton {
  label: string;
  action: WfAction;
}

type WfAction =
  | { type: "redirect"; target: string; reason?: string }
  | { type: "reload" }
  | { type: "dismiss" };
  • finished: true — required marker. The HTTP adapter wraps bare step results to add it; the Phase-4 helpers below already include it, so wrapping is a no-op for them.
  • data — your domain payload. Whatever the consumer reads on @finished. Type-parameterize via WfFinished<MyShape>.
  • message — optional banner rendered at the top of the finish screen. Use for "you're signed in" / "submission saved" toasts that the screen itself shows for a beat before navigating.
  • end — what to do next. Drives the rendering mode.
  • aborted / reason — soft-failure signal. Used together when the flow ends in a recoverable terminal state ("reason: rate-limited").

When end is omitted, AsWfFinish just renders the message (if any) and waits for the consumer to act. With end set, the rendering and action wiring follow the rules below.

Helpers

All five helpers live in @atscript/moost-wf and call the underlying useWfFinished from @moostjs/event-wf. Use them in place of raw useWfFinished().set({ value: {...} }) whenever possible — the helpers build the envelope correctly and keep the wire shape stable across upgrades.

finishWfWithData(data, message?)

Terminal data, no transition UI. The client reads data on @finished and is responsible for what happens next.

ts
import { finishWfWithData } from "@atscript/moost-wf";

@Step("invoice-submit-save")
async save(@WorkflowParam("input") input: InvoiceInput) {
  const id = await invoices.insertOne(input);
  finishWfWithData({ ok: true, id });
}

finishWfWithMessage(level, text)

Pure message — no data, no transition. Use when the only outcome is "tell the user something."

ts
finishWfWithMessage("success", "We've sent a verification email.");

finishWfWithRedirect(target, opts?)

Redirect to another URL. With autoMs you get a countdown + optional skip button; without autoMs the redirect fires on mount (mode: 'immediate').

ts
// Immediate — `AsWfFinish` triggers the `navigate` prop on mount, no UI flashes by.
finishWfWithRedirect("/dashboard", { reason: "post-login" });

// Auto — countdown ticks down, skip button fires the action immediately.
finishWfWithRedirect("/dashboard", {
  autoMs: 4000,
  skipLabel: "Go now",
  message: { level: "success", text: "All set!" },
});
OptionTypeEffect
reasonstringFree-form hint propagated via @action for analytics or branching.
messageWfMessageBanner shown alongside the countdown (or briefly on immediate).
autoMsnumberSwitches to mode: 'auto' with this delay.
skipLabelstringAdds a "skip" button to the auto screen.

finishWfWithChoice({ message?, primary?, options? })

mode: 'manual'. The user picks one of the buttons. Provide either a primary (Enter-key target) or one or more options; passing neither throws at runtime.

ts
finishWfWithChoice({
  message: { level: "info", text: "Submission queued. What's next?" },
  primary: {
    label: "View submission",
    action: { type: "redirect", target: "/submissions/123" },
  },
  options: [
    {
      label: "Submit another",
      action: { type: "redirect", target: "/submit" },
    },
    { label: "Done", action: { type: "dismiss" } },
  ],
});

When primary is omitted, all options render with equal visual weight and the first option becomes the Enter-key target.

finishWfAborted(reason, opts?)

Terminal soft-failure. aborted: true plus the reason; optional message and end if you want to redirect the user away from the form.

ts
finishWfAborted("rate-limited", {
  message: { level: "warn", text: "Too many attempts. Try again in 5 minutes." },
});

Escape hatch — finishWf(envelope)

If you need a combination the helpers don't cover (e.g. mode: 'manual' with terminal data AND a primary button), pass the full envelope yourself:

ts
import { finishWf } from "@atscript/moost-wf";

finishWf({
  finished: true,
  data: { receiptId: 42 },
  end: {
    mode: "manual",
    primary: { label: "Print", action: { type: "reload" } },
    options: [{ label: "Done", action: { type: "dismiss" } }],
  },
});

Rendering on the client

<AsWfForm> renders the finish screen automatically. The default behaviour ships in AsWfFinish (Tier-2 swappable), wired to <AsWfForm> so consumers get correct rendering without any template work.

The three modes:

  • immediate — no DOM is rendered. The action fires on mount. Redirects call the navigate prop you pass to <AsWfForm>; if no prop is provided, the component falls back to window.location.assign. The user sees a screen-reader-only "Redirecting…" announcement and that's it.
  • auto — a filled primary CTA (the skip button) whose background fills left-to-right with a bg-black/20 darken overlay over timeoutMs, with a smaller muted countdown line ("Continuing in N…") centered underneath. The button is the progress indicator — there's no separate progress bar. Clicking it fires the action immediately (behavior: 'now', the default) or only clears the timer (behavior: 'cancel'). The fill is CSS-driven via @keyframes progress-fill plus a --progress-duration custom property, so the bar animates smoothly regardless of the JS countdown's tick rate. If skipButton is omitted from the envelope, only the countdown text renders.
  • manualmessage banner + buttons. The primary button (if provided) gets initial focus and is the Enter-key target; options render alongside. If no primary, the first option is the Enter-key target.

Customizing via slots

<AsWfForm> forwards five named scoped slots into AsWfFinish. Each has a working default — override only the pieces you want to restyle. Every slot scope includes a callback so your custom UI keeps the action wiring without re-implementing the logic.

SlotRenders whenScope
wf.finishedAny finished envelope{ response, payload } — full override; ignores the rest of the table
wf.finish.messagepayload.message is set{ message: WfMessage }
wf.finish.countdownend.mode === 'auto'{ secondsRemaining, totalSeconds, skip, cancel }secondsRemaining ticks 1/sec (250ms internally; integer transitions only)
wf.finish.skipend.mode === 'auto' + skipButton{ button: { label, behavior }, trigger }
wf.finish.primaryend.mode === 'manual' with primary{ button: WfButton, trigger }
wf.finish.optionend.mode === 'manual' (each option){ button: WfButton, index: number, trigger }

Example — override the primary button with a design-system one, keeping the trigger contract:

vue
<AsWfForm path="/api/wf" name="checkout" :types="types" :navigate="navigate">
  <template #wf.finish.primary="{ button, trigger }">
    <MyBrandButton :variant="'filled'" @click="trigger">
      {{ button.label }}
    </MyBrandButton>
  </template>
</AsWfForm>

The trigger callback runs the action (redirect / reload / dismiss) exactly as the default button would. Custom UI never needs to re-implement the redirect-or-emit decision.

Routing

redirect actions go through a navigate prop on <AsWfForm>. The prop is a function that receives the target URL — your handler decides cross-origin vs in-app routing.

ts
import { useRouter } from "vue-router";
const router = useRouter();
const navigate = (url: string) => router.push(url);
vue
<AsWfForm path="/api/wf" name="checkout" :types="types" :navigate="navigate" />

If no navigate prop is provided, AsWfFinish falls back to window.location.assign(url). In an SSR or non-browser context with no prop and no browser location, it logs console.error and does nothing — better than crashing the render path.

The same navigate handler can be passed to @atscript/db-client's Client({ navigate }) option, so one navigation function covers both workflow redirects and processor: 'navigate' DB actions.

Events

<AsWfForm> (and the default AsWfFinish) emit two events when an action runs:

ts
defineEmits<{
  (e: "dismiss"): void;
  (e: "action", action: WfAction): void;
}>();
  • @dismiss — fired for action.type === 'dismiss'. The flow stays on screen — the consumer decides what to do (close a modal, reset state, etc.).

  • @action — fires before every action, including reloads and redirects. The WfAction payload includes the optional reason for analytics or telemetry, without intercepting navigation.

Aborted flows

A workflow that calls finishWfAborted(reason) still fires @finished on the client — aborted and reason are exposed on the envelope alongside data. Treat them like a soft-error: render a banner via the message field (if you set one) or branch on payload.aborted inside the wf.finished slot for full control.

Reusing the progress-button primitive

The auto-mode skip button is built on a public c8-progress shortcut family in @atscript/ui-styles. The same primitive composes any "fills then fires" UI — hold-to-confirm CTAs, timed confirmations, any button you want to double as its own progress indicator. The three classes:

html
<button
  class="c8-filled scope-primary c8-progress h-fingertip-m px-$m"
  :style="{ '--progress-duration': '4000ms' }"
>
  <span class="c8-progress-fill" />
  <span class="c8-progress-label">Confirm</span>
</button>
  • c8-progress — adds relative overflow-hidden. Layer it on top of any c8-* base (c8-filled, c8-flat, c8-light).
  • c8-progress-fill — absolutely-positioned bg-black/20 overlay that animates width 0% → 100% over --progress-duration via the globally-registered @keyframes progress-fill (no JS).
  • c8-progress-label — keeps the label in flow so the button doesn't collapse to its padding box. Required.

The keyframes are registered as a UnoCSS preflight by asPresetVunor, so consumers don't have to register anything beyond installing the preset. See Styling — the as-* shortcut system for the broader shortcut tree.

Reference

  • Types and helpers: packages/moost-wf/src/wf-finished.ts
  • Default component: packages/vue-wf/src/components/defaults/as-wf-finish.vue
  • Client wiring: packages/vue-wf/src/components/as-wf-form.vue

Where to go next

Released under the MIT License.