Skip to content

Portal Progress Tracker Widget

DraftOwner: Jeff StolzSurface: customer portal v3 + Portal Studio

Grow the portal's stage_stepper widget from one fixed rendering into a small family: the stage row it draws today, or a single progress bar, either one able to state the percentage of the onboarding that is complete.

TL;DR

Renamed, not re-typed

The builder label becomes Progress tracker. The contract type stays stage_stepper — it is frozen by a gate test and persisted inside every published portal document, so renaming it is a data migration that buys nothing anyone can see.

Two new props

display (stages | progress_bar) and show_percentage (boolean, applies to both). Both default to today's behaviour, so no published portal changes on deploy.

No backend work

project.percentage is already on every data adapter, and the API's prop validation and the Aero vocabulary are both generated from the contract. The change is the contract JSON plus renderer Vue.

Current state a build must respect

FactWhereWhy it constrains this
The widget is a thin wrapper with one prop (title)app/ui-customer/portalRenderer/widgets/WidgetStageStepper.vueEverything new lands here and in the primitive below it.
The primitive already degrades across three tiers — fullcompactbar — chosen by width-per-stagewidgets/StageStepper.vue, data/stepperDensity.jsIts narrowest tier is already a segmented bar. A "progress bar" mode that is also segmented duplicates a tier the widget falls to on its own.
project.percentage is exposed by every adapter this widget reachesdata/createLiveAdapter.js, data/previewDataAdapter.js, data/samplePortalData.jsNo new endpoint, no preview-service change, no source module.
projectProgress(project) already clamps and rounds that numberdata/projectStatus.jsThe percentage helper exists; reuse it rather than adding a second one.
isProjectComplete() is the canonical completion signal, not the numberdata/projectStatus.jsA project can be marked complete at 96%. Completion styling reads the status code.
The inspector renders enumSelect and booleanToggleSwitch generically from the contractpages/portalStudio/components/inspector/InspectorField.vueNo new inspector component.
Enum props are validated generically against optionsapp/api/portal_studio/services/portal_config_validator.pyNo new validator branch.
The Aero prompt's widget vocabulary is generated from the contractonramp-agents/.../prompts/vocabulary.pyAero learns both props for free; only one hand-written docstring needs a touch.
PortalWidgetFrame binds only stored keys — an absent key falls to the Vue prop defaultPortalWidgetFrame.vue (boundProps)Component defaults must equal contract defaults, or existing portals drift.

Locked UX decisions

  1. Label: "Progress tracker." Description rewritten to cover both forms: "How far the onboarding has come — as stages, or as a single progress bar." Icon moves from pi pi-arrow-right-arrow-left to pi pi-chart-line.
  2. Percentage sits in the title row, right-aligned. One placement that works in both modes, with or without a title, at every width. With no title the number sits alone on that row and the row keeps its 16px gap.
  3. Bar mode is one continuous bar, with a stage line beneath it (Compliance · stage 3 of 4 on the left, the date label on the right) — not the v2 per-module segmented bar. The segmented form stays what it is today: the stepper's narrowest responsive tier.
  4. Bar mode gets its own narrow rendering — bar and percentage on one line, no stage line. resolveStepperTier governs stages only and is not reused for this.
  5. Percentage source is project.percentage, the same number every other progress surface in the portal renders. See the open question about the merge field.
  6. The hero's embedded stepper is out of scope. hero.show_stepper renders the same primitive on-brand; giving the hero a bar mode and a percentage is a second, larger change. Only its prop label is swept for consistency.

Plan

Phase 0 — Contract

Both copies, in one commit — they are pinned byte-identical by test_agent_vocabulary_parity.py::test_vendored_contract_is_byte_identical:

  • app/api/portal_studio/contracts/portal_studio_contract.json
  • onramp-agents/src/agents/portal_studio/contracts/portal_studio_contract.json
json
{
  "type": "stage_stepper",
  "label": "Progress tracker",
  "description": "How far the onboarding has come — as stages, or as a single progress bar.",
  "icon": "pi pi-chart-line",
  "group": "Work",
  "scopes": ["project"],
  "width_behavior": "full",
  "default_span": 12,
  "props": [
    { "key": "title", "label": "Title", "type": "text", "default": "" },
    {
      "key": "display",
      "label": "Display",
      "type": "enum",
      "options": ["stages", "progress_bar"],
      "default": "stages",
      "help": "Stages name each phase of the onboarding. A progress bar shows one overall number."
    },
    {
      "key": "show_percentage",
      "label": "Show percentage complete",
      "type": "boolean",
      "default": false,
      "help": "The share of the whole onboarding that's finished."
    }
  ]
}

enum, not selectselect is not in the contract's prop_types. Option labels are derived by the inspector (progress_bar → "Progress bar"), so the option strings are user-visible copy.

Defaults are deliberately today's behaviour: display: "stages", show_percentage: false. Nothing published changes appearance on deploy, and no retired_props entry is needed because nothing is renamed or removed.

Gate tests need no edits. test_widget_types_frozen and test_account_home_widget_set_is_frozen key on the type, which is unchanged; test_every_widget_has_complete_spec only requires a label and a pi pi- icon. Run them anyway to prove it.

Phase 1 — The percentage, in both modes

widgets/WidgetStageStepper.vue — filename stays aligned to the contract type, the convention every other widget file follows.

  • Add display and showPercentage props with defaults matching the contract.
  • Read projectProgress(adapter.project.value) from data/projectStatus.js. No new helper.
  • Header becomes a title row: title on the left (omitted when blank), percentage on the right, font-variant-numeric: tabular-nums so it does not jitter as it changes.
  • Completion styling reads adapter.isProjectComplete, never === 100.
  • The number is decorative beside an already-labelled control, so it carries no redundant ARIA of its own; the bar owns the accessible name (Phase 2).

Phase 2 — Bar mode

New sibling primitive widgets/ProgressMeter.vue, beside StageStepper.vue:

  • PrimeVue <ProgressBar :show-value="false">, the same component WidgetProjectGrid uses, so a customer meets one bar across the portal.
  • aria-label naming the state in words — "62% complete — Compliance, stage 3 of 4" — following the pattern the existing bar tier set with its role="img".
  • Stage line beneath: currentStage / currentStageIndex from stepperDensity.js on the left, projectDateLabel(project) from projectStatus.js on the right.
  • Complete state swaps the fill to the success token and the line to "Onboarding complete".
  • Its own narrow rendering — bar and percentage on one line, stage line dropped — resolved from a width threshold local to this component. resolveStepperTier is about stages-per-width and is not extended to cover it.

WidgetStageStepper.vue then picks StageStepper or ProgressMeter on display, and the empty state ("Stages appear once this project has modules.") stays shared: both modes are derived from the same modules, so both are empty in the same case.

Phase 3 — Builder copy

  • pages/portalStudio/components/inspector/dataSources.js — the stage_stepper note says "Data pulled from the project's modules." Bar mode also reads the project's overall percentage; reword to "Data pulled from the project's modules and overall progress."
  • Sweep the hero's prop label: hero.show_stepper reads "Show stage stepper" and should read "Show progress stepper". Label only — the prop key is untouched, so no stored document is affected and no retired_props entry is needed.
  • Nothing else. AddWidgetPicker, StudioInspector and StudioCanvas are all contract-driven, and the canvas renders through PortalPageRenderer, so the registry entry it already has is enough.

Phase 4 — Aero

  • onramp-agents/.../tools/edit.py — the add_widget docstring says "stage_stepper only shows which stage they are on." Reword so Aero knows it can also author the bar and the percentage.
  • The prop vocabulary itself regenerates from the contract; no other agent change.

Phase 5 — Tests

TestStatusCovers
widgets/__tests__/WidgetStageStepper.test.jsnew — the widget has no test todaymode switching, percentage on/off in both modes, blank title, empty-stages state, defaults matching the contract
widgets/__tests__/ProgressMeter.test.jsnewfill width, accessible name, stage line, complete state, narrow rendering
widgets/__tests__/StageStepper.test.jsextendunchanged tier behaviour — proof the new mode did not disturb it
test_portal_studio_contract_gate.pyrun unchangedthe frozen sets still pass
test_agent_vocabulary_parity.pyrun unchangedboth contract copies byte-identical
test_portal_config_validator.pyadddisplay: "bogus" is rejected; a document with neither new key still validates

Phase 6 — Browser verification

Not optional, and not delegated to review. On this worktree (https://local.onramp.us:3001, cloned DB):

  1. Builder: add the widget, flip display, flip show_percentage, confirm the canvas and the inspector agree.
  2. Authenticated customer portal, real project — both modes, percentage on and off.
  3. Light and dark, and narrow enough to reach both the stepper's compact/bar tiers and bar mode's own narrow rendering.
  4. A published portal that predates this change still renders the stage row with no percentage.

Backwards compatibility

Additive only. Stored documents carry neither new key; boundProps binds only what is stored, so both fall to the component defaults, which equal the contract defaults, which equal today's behaviour. The type string is untouched, so no published portal, gate assertion, or agent-authored document is invalidated. The one way to break this is a component default that disagrees with the contract — the new widget test asserts they match.

Risks & open questions

Medium

Two rival percentages

project.percentage comes from completed_percentage_customer. The merge field {{project.percentage_complete}} resolves through weekly_percentage_complete instead. An author who puts that tag in hero copy can show one number beside this widget's other number on the same page. This plan reads project.percentage, the portal-wide source; reconciling the merge field is its own change.

Low

Bar mode says less than the stepper

A single bar drops stage names. Mitigated by the stage line, which is why it is part of the mode rather than a further option.

Low

Percentage vs. stage disagreement

A project at 100% whose last module is not closed shows "100%" over a non-final current stage. Completion styling reads the status code, so the two only diverge in copy, not in the complete/incomplete verdict.

Open questions

Does the hero follow?

hero.show_stepper draws the same primitive on-brand. A hero.progress_display plus percentage is a coherent follow-up, and deliberately not in this cut.

Should show_percentage default true for newly added widgets?

It cannot default true in the contract without changing published portals. A scope-independent "new widget only" default does not exist today, so the answer is no unless the mechanism is worth building.

Internal documentation — gated behind Cloudflare Access.