Portal Studio Sidebar Region
Give Portal Studio a second layout region — a sidebar column beside the page grid — so long-lived free-text widgets have a home that grows without stretching the rest of the portal.
Problem
A page is one 12-track CSS grid with align-items: stretch. A welcome_message (default span 8) that a team appends to every week drags its row-mate up to its own height and pushes everything below it down. The hero already clamps its body behind "Show more"; nothing else does, and clamping alone gives evergreen content no place to live.
Decision
Option A from the exploration canvas, as drawn:
- A sidebar column beside the page grid. The main column keeps its own 12-track grid, so a widget's span still means "this fraction of the content area". The sidebar is a fixed-width vertical stack; widgets in it ignore span and fill the column. Row stretch cannot cross the column boundary.
- Hero above both columns by default. A page's hero stays a full-bleed banner spanning the whole content width; a toggle places it inside the main column instead.
- One sidebar for the whole portal by default. A toggle switches to one sidebar per page.
- Position left or right. Right by default. When navigation is on the left the sidebar is forced right — two left columns crowd the page.
- Below the
lgcontainer tier the column cannot exist. Sidebar widgets flow after the main content, hero first either way. - Full-width widgets are not sidebar widgets.
hero,stage_stepper,timeline,project_gridandself_serve_playbooks(width_behavior: full) are excluded from the sidebar picker and rejected by the validator.
Alternates C (details drawer) and D (clamp in place) are out of scope.
Document shape
Two additions, both derived from what the document already has.
Layout fields
Four flat scalar fields in layout, declared in layout_fields of app/api/portal_studio/contracts/portal_studio_contract.json. Flat scalars — not a nested sidebar object — because _validate_layout and the overrides document (partial layout) already handle enum/boolean fields with no new code, and every layout field becomes per-customer overridable for free.
| key | type | default | meaning |
|---|---|---|---|
sidebar | boolean | false | region on/off |
sidebar_position | enum left · right | right | which side at lg |
sidebar_scope | enum portal · page | portal | one shared sidebar every page shows, or one per page |
sidebar_hero_full_width | boolean | true | hero above both columns, or inside main |
Sidebar widgets
Each page gains an optional sidebar: [] list holding widgets in exactly the shape page.widgets uses ({id, type, span, props}). Widget ids stay unique across the whole document, so the overrides map (widgets[id].props, widgets[id].hidden) and every id-keyed service keep working untouched.
An absent list and an empty one differ under page scope: absent means the page has no sidebar, empty means it has one that is waiting for a widget. Under portal scope the distinction does not arise — the sidebar belongs to the portal, so every page renders it.
Storage is always per page; the shared sidebar is derived. With sidebar_scope: portal the rendered sidebar on every page is the first page's sidebar. Flipping the scope toggle moves nothing and loses nothing: per-page sidebars persist while shared mode hides them, and the shared one is simply the home page's when you come back. The rejected alternative — a document-root list in shared mode and per-page lists otherwise — needs every widget walker to learn two shapes and needs a migration on every toggle flip.
Consequence worth stating: "home" is already the first page (nav mark, usePortalPageRoute fallback), so reordering pages moves the shared sidebar to the new first page. That is visible immediately in the editor.
span is kept on a sidebar widget so a move back to the grid restores its width; the renderer ignores it inside the column.
No schema_version bump: both additions are optional and every stored v6 document is a valid v6 document with an empty sidebar. No database migration.
One accessor per side
The codebase walks pages[].widgets in ~25 places (counted below). A region adds a second list per page, so every walker either goes through one helper or is a bug waiting for a sidebar widget.
- Python:
app/api/portal_studio/services/portal_page_regions.py—PAGE_REGIONS = ("widgets", "sidebar"),widget_lists(page),iter_widgets(document),effective_sidebar(document, page), andrendered_widgets(document). The last two are the pair that matters for anything deciding what a portal DISCLOSES:iter_widgetsyields every stored list, including a sidebar left behind by a switched-off region, so a decision about what a customer is served has to askrendered_widgetsinstead. - JS:
portalRenderer/config/portalConfig.js—pageRegions(page),effectiveSidebar(config, page), andpageWidgets(page)unchanged (main region only, as every current caller expects).
The completeness gate for Phase 0 is the raw output of
grep -rnE 'page\.widgets|\.get\("widgets"\)|\["widgets"\]|get\("sidebar"\)|page\.sidebar' \
app/api/portal_studio app/api/portal app/ui-customer/portalRenderer \
app/ui-organization/pages/portalStudio app/ui-organization/stores \
onramp-agents/src/agents/portal_studio --include='*.py' --include='*.js' --include='*.vue' \
| grep -vE '/tests/|__tests__|default_templates'pasted into the PR, with each hit either routed through the helper or annotated with why it is main-region-only by design (the flatten of legacy sections, for one).
Rendering
PortalPageRenderer.vue grows from one grid to a partitioned page. Everything is derived from the page and layout at render time; nothing is stored twice.
.portal-page-renderer
├─ .portal-page-renderer__hero ← only when sidebar active, hero_full_width, page has a hero
├─ .portal-page-renderer__columns ← flex row at lg; block below
│ ├─ .portal-page-renderer__grid ← main: the existing 12-track grid
│ └─ .portal-page-renderer__sidebar ← aside: flex 0 0 var(--portal-sidebar-width, 320px); stack
└─ (below lg) .portal-page-renderer__sidebar renders AFTER the grid, under a hairline rule- Active means
layout.sidebaris true and the effective sidebar for this page is non-empty. An enabled-but-empty sidebar renders nothing in the live portal; the editor shows the drop zone instead. - Hero lift partitions
pageWidgets(page): the firstherobecomes the hero row, the rest fill the grid. The hero is not moved in storage. - Column side is
resolveSidebarPosition(layout):rightwhenevernav_positionisleft, else the stored value.flex-direction: row-reversefor left. - Scrolls with the page at lg — not sticky, no internal scroll. The column no longer distorts the main column, which was the whole point; pinning it would make the page feel like two documents.
- Below lg the sidebar list is its own 12-track grid so the existing
--widget-span-mdrule (min half) applies atmdand the single stack applies atsm; the separator is a hairline only. The canvas showed a "From your team" eyebrow; that is customer-facing copy with no place to author it, so it is left out until asidebar_labelfield earns its keep. PortalWidgetFrame.vuegets aregionprop; in the sidebar it sets--widget-spanand--widget-span-mdtoSPAN_UNITSand never applies.portal-widget--bleed.PortalLayoutShell.vueis unchanged: the nav column and the content column stay as they are, and the sidebar is inside the content column.
Hosts need no change — Home.vue and PortalChassisHost.vue already render one PortalPageRenderer per page, and the Studio canvas and template preview reuse it, so all three surfaces pick the layout up together.
Backend
| where | change |
|---|---|
contracts/portal_studio_contract.json | 4 layout_fields; page_regions: ["widgets","sidebar"]; sidebar_width_behaviors: ["flexible","min_half"] |
contracts/portal_studio.py | expose the two new tuples; sidebar_accepts(widget_type) |
services/portal_page_regions.py | new: the helpers above |
services/portal_config_validator.py | page.sidebar optional list (invalid_sidebar); each widget through _validate_widget with the doc-wide seen_ids; invalid_sidebar_widget for a full behavior; max_per_page counted over main + effective sidebar of the rendered page; _sweep_retired normalizes both lists |
services/portal_config_resolver.py | merge_overrides patches both lists; strip_feature_gated_widgets walks both |
services/portal_content_sanitization.py | sanitize both lists ($.pages[i].sidebar[j] paths) |
services/portal_config_flatten.py | flatten_page carries sidebar through untouched |
services/project_portal_override_service.py | _document_widgets yields both regions |
app/api/portal/hours_disclosure.py | figures_asked_for reads both regions — this one fails closed, so missing it means an hours_invested widget in the sidebar refuses to load |
constants/default_templates.py | unchanged; sidebar is off in every template |
Overrides need nothing new: a customer scope may already carry a partial layout, so each of the four fields is overridable per customer, and widget patches are keyed by id.
Editor
Layout panel (components/LayoutPanel.vue)
One grouped block between Content width and Footer, as drawn:
- Sidebar toggle.
- Sidebar position — SelectButton Left · Right. Disabled with the hint "Sits on the right while navigation is on the left" when
nav_positionisleft. - Shared sidebar toggle (
sidebar_scope) — one sidebar every page shows, rather than a sidebar per page. The distinction the name has to carry is one universal sidebar, not a sidebar on each page, which the per-page mode also gives you. - Hero spans full width toggle.
2–4 render only while the sidebar is on. Emits mirror the existing four (update-sidebar, update-sidebar-position, update-sidebar-scope, update-sidebar-hero-full-width), relayed by StudioLeftRail.vue and wired in ComposeStep.vue to store setters, exactly as update-nav-position is today.
Store (stores/portalStudio.store.js)
- Setters:
setSidebar,setSidebarPosition,setSidebarScope,setSidebarHeroFullWidth— same shape assetNavPosition. - Getter
sidebarPageId: first page id underportalscope, current page id underpagescope. Every sidebar edit targets that page'ssidebar. - Widget actions take a
region('widgets' | 'sidebar', default'widgets'):addWidget,addWidgets,_appendWidgets,setWidgets,moveWidget.removeWidget,_pageForWidget,select,selectedWidgetlook in both lists viapageRegions(page). - Caps:
_pageWidgetCounts(page)counts main + effective sidebar of that rendered page, matching the validator, so the picker disableswelcome_messageon a page whose shared sidebar already holds one. lockedWidgetIds,pageTypeCounts,documentOutlinewalk both regions; the outline gainssidebar: [...]per page for the agent.setSidebar(false)leaves the widgets in place. Turning it back on restores them; the rendered portal simply stops showing the column.
Canvas (components/StudioCanvas.vue)
- Renders
PortalPageRendererwith a second named slot,#sidebar, so the canvas can wrap that list in its owndraggable. Both draggables sharegroup="portal-widgets"; the sidebar one adds a:moveguard that refuses a widget whose type is not insidebar_width_behaviors. - A cross-list drag emits
update:model-valueon both lists; each emitsreorder-widgetswith{ pageId, region, widgets }and the store applies both. Reordering inside a list is the same event. - When the sidebar is on, the aside is always rendered in the editor, empty or not, as a dashed drop zone with a chip naming its scope ("Sidebar · every page" / "Sidebar · this page") and an "Add to sidebar" button. The canvas carries no scope chrome — the region is dashed only while it is empty, so the editor reads like the published page; the tree is where scope shows.
- The lifted hero keeps its
PortalWidgetFrame(select, remove, lock) but sits outside the draggable list — it is always first, so reordering it is not a thing. StudioEditingKeyprovidesaddWidget(pageId, event, region)so the in-frame "Add widget" affordances and the empty-page CTA keep working.
Picker (components/AddWidgetPicker.vue)
A region prop. For sidebar it filters to sidebar_width_behaviors and receives the rendered-page type counts described above. alwaysPresentTypes stays as it is: the required, capped-at-one types are all full behaviors and never appear in the sidebar list anyway.
Left rail tree (components/StudioLeftRail.vue)
- The Layout panel owns whether the portal has a sidebar at all. While that is off the tree says nothing about one; every control below appears only once it is on.
portalscope: a pinned Sidebar node above the pages carrying a "shared" pill, bound topages[0].sidebar.pagescope: a page that has a sidebar gets a Sidebar subgroup beneath its widgets, carrying its own remove; a page that has none is offered one. Which is to say the empty list is what records that a page has a sidebar at all, so one page can carry none while its siblings do — and a page without one keeps its full width in the editor as well as the portal. A shared sidebar belongs to the portal rather than to any page, so it has nothing per page to add or take away.- Each list is its own
draggablewithhandle=".studio-widget-grip"emittingreorder-widgetswith the region, as the page lists do now.
Publish checks (publish/publishChecks.js)
pages_have_content,empty_content_widgets,broken_links,required_widgetswalk both regions. A page counts as having content when its main grid or its ownsidebarhas widgets; a shared sidebar does not rescue an empty page.- New warning-level check
sidebar_has_content: sidebar on, but the effective sidebar of every page is empty — "The sidebar is on but has no widgets". - New warning-level check
sidebar_leaves_room: a populated sidebar that leaves the main column underMIN_MAIN_COLUMN. A widget keeps its authored span however narrow its column is — the container query measures the portal, not the column — so a left nav plus a sidebar renders half-width widgets at ~215px whether the portal is centered or full width. The width is derived from one home for the column geometry (COLUMN_WIDTHSinportalConfig.js, read back by the shell and the page renderer as CSS vars) rather than a check that names the offending combination of flags, and the detail names a lever that actually clears the check — evaluated against each candidate rather than ordered by guesswork, since going full width on a left-nav portal returns the same warning.centered+ top nav sits just above the floor and is left to the open question below.
Aero
The agent reads the draft through document_outline and edits it through the tools in onramp-agents/src/agents/portal_studio/tools/edit.py.
_widget_indexindexes both regions and recordsregionper widget.add_widget(..., region="widgets")andmove_widget(..., target_region="")gain the region argument;author_pageaccepts asidebarlist; the tool refuses afullbehavior in the sidebar with the same reason the validator gives.- A layout tool sets the four sidebar fields, or the existing layout path does if one exists.
prompts/conversation.mdexplains when to reach for the sidebar (evergreen notes, team, links) and the parity test that names every contract field is extended to the four layout fields.- The agent's copy of the contract JSON is a mirror and must move with the source; the mirror list in the widget-retirement notes is the checklist.
Until this phase ships, Aero cannot author or move sidebar widgets — the same gap next_step had at first.
Phases
One PR, since this is one feature and none of the phases has value on its own. The phases are the build order and the commit order: one commit per phase, in sequence, each leaving the tree green, so the reviewer can read the diff phase by phase instead of as one blob. Fixes from review stack as further commits — never a force-push once review has started — and the PR squashes at merge.
| # | scope | done when |
|---|---|---|
| 0 | Contract fields · region helpers on both sides · every walker through the helpers (behaviour-neutral) | completeness-gate grep pasted in the PR description; existing suites green; a v6 document with page.sidebar validates, sanitizes, merges and flattens |
| 1 | Renderer: two-region layout, hero lift, nav-left force, sticky, sub-lg stacking; PortalWidgetFrame region | a hand-authored document with sidebar: true renders correctly in the live portal, template preview and canvas at all three tiers, light and dark |
| 2 | Studio: Layout panel block + store setters | toggling the four controls updates the canvas live; position disabled under left nav |
| 3 | Studio: sidebar editing — canvas drop zone, picker region, tree nodes, cross-region drag, caps, publish checks | a widget can be added to, moved into/out of, reordered within and removed from the sidebar in both scopes; welcome_message is capped per rendered page; publish checks name sidebar offenders |
| 4 | Aero: outline, tools, prompt, contract mirror | "put the welcome message in the sidebar" works end to end |
Tests
Behaviour only — nothing asserts on a mock's arguments.
- Backend (
app/api/portal_studio/tests/): validator accepts the four layout fields and rejects unknown values; acceptspage.sidebar, rejects afullbehavior there, rejects a duplicate id across regions, countsmax_per_pageover main + effective sidebar under both scopes;_sweep_retiredprunes a retired prop inside a sidebar widget;merge_overrideshides and patches a sidebar widget by id;strip_feature_gated_widgetsstripshours_investedfrom a sidebar;sanitize_document_contentreports a$.pages[i].sidebar[j]path;flatten_pagecarriessidebar;figures_asked_forsees a sidebarhours_invested. - Renderer (
portalRenderer/**/__tests__/):PortalPageRendererrenders columns only when active, lifts the hero, honourssidebar_hero_full_width: false, forces right under left nav, renders sidebar after main below lg;PortalWidgetFramein the sidebar spans full and never bleeds;effectiveSidebarunder both scopes. - Studio:
LayoutPanelshows/hides the block and disables position; store region actions (add/move/remove/setWidgets across regions,sidebarPageId, caps);publishCheckssidebar cases;StudioLeftRailrenders the pinned node vs the per-page subgroup;AddWidgetPickerregion filter. - Agent (
onramp-agents/tests/unit/agents/portal_studio/): region-awareadd_widget/move_widget, refusal of afulltype, outline parity. - Playwright: none — there is no Portal Studio e2e suite today, and this does not introduce the first one.