Portal Progress Tracker Widget
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
| Fact | Where | Why it constrains this |
|---|---|---|
The widget is a thin wrapper with one prop (title) | app/ui-customer/portalRenderer/widgets/WidgetStageStepper.vue | Everything new lands here and in the primitive below it. |
The primitive already degrades across three tiers — full → compact → bar — chosen by width-per-stage | widgets/StageStepper.vue, data/stepperDensity.js | Its 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 reaches | data/createLiveAdapter.js, data/previewDataAdapter.js, data/samplePortalData.js | No new endpoint, no preview-service change, no source module. |
projectProgress(project) already clamps and rounds that number | data/projectStatus.js | The percentage helper exists; reuse it rather than adding a second one. |
isProjectComplete() is the canonical completion signal, not the number | data/projectStatus.js | A project can be marked complete at 96%. Completion styling reads the status code. |
The inspector renders enum → Select and boolean → ToggleSwitch generically from the contract | pages/portalStudio/components/inspector/InspectorField.vue | No new inspector component. |
Enum props are validated generically against options | app/api/portal_studio/services/portal_config_validator.py | No new validator branch. |
| The Aero prompt's widget vocabulary is generated from the contract | onramp-agents/.../prompts/vocabulary.py | Aero 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 default | PortalWidgetFrame.vue (boundProps) | Component defaults must equal contract defaults, or existing portals drift. |
Locked UX decisions
- 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-lefttopi pi-chart-line. - 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.
- Bar mode is one continuous bar, with a stage line beneath it (
Compliance · stage 3 of 4on 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. - Bar mode gets its own narrow rendering — bar and percentage on one line, no stage line.
resolveStepperTiergoverns stages only and is not reused for this. - Percentage source is
project.percentage, the same number every other progress surface in the portal renders. See the open question about the merge field. - The hero's embedded stepper is out of scope.
hero.show_stepperrenders 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.jsononramp-agents/src/agents/portal_studio/contracts/portal_studio_contract.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 select — select 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
displayandshowPercentageprops with defaults matching the contract. - Read
projectProgress(adapter.project.value)fromdata/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-numsso 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 componentWidgetProjectGriduses, so a customer meets one bar across the portal. aria-labelnaming the state in words —"62% complete — Compliance, stage 3 of 4"— following the pattern the existingbartier set with itsrole="img".- Stage line beneath:
currentStage/currentStageIndexfromstepperDensity.json the left,projectDateLabel(project)fromprojectStatus.json 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.
resolveStepperTieris 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— thestage_steppernote 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_stepperreads "Show stage stepper" and should read "Show progress stepper". Label only — the prop key is untouched, so no stored document is affected and noretired_propsentry is needed. - Nothing else.
AddWidgetPicker,StudioInspectorandStudioCanvasare all contract-driven, and the canvas renders throughPortalPageRenderer, so the registry entry it already has is enough.
Phase 4 — Aero
onramp-agents/.../tools/edit.py— theadd_widgetdocstring says "stage_stepperonly 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
| Test | Status | Covers |
|---|---|---|
widgets/__tests__/WidgetStageStepper.test.js | new — the widget has no test today | mode switching, percentage on/off in both modes, blank title, empty-stages state, defaults matching the contract |
widgets/__tests__/ProgressMeter.test.js | new | fill width, accessible name, stage line, complete state, narrow rendering |
widgets/__tests__/StageStepper.test.js | extend | unchanged tier behaviour — proof the new mode did not disturb it |
test_portal_studio_contract_gate.py | run unchanged | the frozen sets still pass |
test_agent_vocabulary_parity.py | run unchanged | both contract copies byte-identical |
test_portal_config_validator.py | add | display: "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):
- Builder: add the widget, flip
display, flipshow_percentage, confirm the canvas and the inspector agree. - Authenticated customer portal, real project — both modes, percentage on and off.
- Light and dark, and narrow enough to reach both the stepper's
compact/bartiers and bar mode's own narrow rendering. - 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
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.
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.
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.