Skip to content

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.

AcceptedOwner: Jeff StolzDesign: exploration canvas

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 lg container 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_grid and self_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.

keytypedefaultmeaning
sidebarbooleanfalseregion on/off
sidebar_positionenum left · rightrightwhich side at lg
sidebar_scopeenum portal · pageportalone shared sidebar every page shows, or one per page
sidebar_hero_full_widthbooleantruehero above both columns, or inside main

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.pyPAGE_REGIONS = ("widgets", "sidebar"), widget_lists(page), iter_widgets(document), effective_sidebar(document, page), and rendered_widgets(document). The last two are the pair that matters for anything deciding what a portal DISCLOSES: iter_widgets yields every stored list, including a sidebar left behind by a switched-off region, so a decision about what a customer is served has to ask rendered_widgets instead.
  • JS: portalRenderer/config/portalConfig.jspageRegions(page), effectiveSidebar(config, page), and pageWidgets(page) unchanged (main region only, as every current caller expects).

The completeness gate for Phase 0 is the raw output of

bash
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.sidebar is 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 first hero becomes the hero row, the rest fill the grid. The hero is not moved in storage.
  • Column side is resolveSidebarPosition(layout): right whenever nav_position is left, else the stored value. flex-direction: row-reverse for 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-md rule (min half) applies at md and the single stack applies at sm; 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 a sidebar_label field earns its keep.
  • PortalWidgetFrame.vue gets a region prop; in the sidebar it sets --widget-span and --widget-span-md to SPAN_UNITS and never applies .portal-widget--bleed.
  • PortalLayoutShell.vue is 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

wherechange
contracts/portal_studio_contract.json4 layout_fields; page_regions: ["widgets","sidebar"]; sidebar_width_behaviors: ["flexible","min_half"]
contracts/portal_studio.pyexpose the two new tuples; sidebar_accepts(widget_type)
services/portal_page_regions.pynew: the helpers above
services/portal_config_validator.pypage.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.pymerge_overrides patches both lists; strip_feature_gated_widgets walks both
services/portal_content_sanitization.pysanitize both lists ($.pages[i].sidebar[j] paths)
services/portal_config_flatten.pyflatten_page carries sidebar through untouched
services/project_portal_override_service.py_document_widgets yields both regions
app/api/portal/hours_disclosure.pyfigures_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.pyunchanged; 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:

  1. Sidebar toggle.
  2. Sidebar position — SelectButton Left · Right. Disabled with the hint "Sits on the right while navigation is on the left" when nav_position is left.
  3. 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.
  4. 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 as setNavPosition.
  • Getter sidebarPageId: first page id under portal scope, current page id under page scope. Every sidebar edit targets that page's sidebar.
  • Widget actions take a region ('widgets' | 'sidebar', default 'widgets'): addWidget, addWidgets, _appendWidgets, setWidgets, moveWidget. removeWidget, _pageForWidget, select, selectedWidget look in both lists via pageRegions(page).
  • Caps: _pageWidgetCounts(page) counts main + effective sidebar of that rendered page, matching the validator, so the picker disables welcome_message on a page whose shared sidebar already holds one.
  • lockedWidgetIds, pageTypeCounts, documentOutline walk both regions; the outline gains sidebar: [...] 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 PortalPageRenderer with a second named slot, #sidebar, so the canvas can wrap that list in its own draggable. Both draggables share group="portal-widgets"; the sidebar one adds a :move guard that refuses a widget whose type is not in sidebar_width_behaviors.
  • A cross-list drag emits update:model-value on both lists; each emits reorder-widgets with { 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.
  • StudioEditingKey provides addWidget(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.
  • portal scope: a pinned Sidebar node above the pages carrying a "shared" pill, bound to pages[0].sidebar.
  • page scope: 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 draggable with handle=".studio-widget-grip" emitting reorder-widgets with the region, as the page lists do now.

Publish checks (publish/publishChecks.js)

  • pages_have_content, empty_content_widgets, broken_links, required_widgets walk both regions. A page counts as having content when its main grid or its own sidebar has 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 under MIN_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_WIDTHS in portalConfig.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_index indexes both regions and records region per widget.
  • add_widget(..., region="widgets") and move_widget(..., target_region="") gain the region argument; author_page accepts a sidebar list; the tool refuses a full behavior 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.md explains 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.

#scopedone when
0Contract 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
1Renderer: two-region layout, hero lift, nav-left force, sticky, sub-lg stacking; PortalWidgetFrame regiona hand-authored document with sidebar: true renders correctly in the live portal, template preview and canvas at all three tiers, light and dark
2Studio: Layout panel block + store setterstoggling the four controls updates the canvas live; position disabled under left nav
3Studio: sidebar editing — canvas drop zone, picker region, tree nodes, cross-region drag, caps, publish checksa 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
4Aero: 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; accepts page.sidebar, rejects a full behavior there, rejects a duplicate id across regions, counts max_per_page over main + effective sidebar under both scopes; _sweep_retired prunes a retired prop inside a sidebar widget; merge_overrides hides and patches a sidebar widget by id; strip_feature_gated_widgets strips hours_invested from a sidebar; sanitize_document_content reports a $.pages[i].sidebar[j] path; flatten_page carries sidebar; figures_asked_for sees a sidebar hours_invested.
  • Renderer (portalRenderer/**/__tests__/): PortalPageRenderer renders columns only when active, lifts the hero, honours sidebar_hero_full_width: false, forces right under left nav, renders sidebar after main below lg; PortalWidgetFrame in the sidebar spans full and never bleeds; effectiveSidebar under both scopes.
  • Studio: LayoutPanel shows/hides the block and disables position; store region actions (add/move/remove/setWidgets across regions, sidebarPageId, caps); publishChecks sidebar cases; StudioLeftRail renders the pinned node vs the per-page subgroup; AddWidgetPicker region filter.
  • Agent (onramp-agents/tests/unit/agents/portal_studio/): region-aware add_widget / move_widget, refusal of a full type, outline parity.
  • Playwright: none — there is no Portal Studio e2e suite today, and this does not introduce the first one.

Risks

high A walker misses the sidebar. Silent for most paths; fail-closed for hours disclosure. Mitigation: Phase 0 helper + the pasted completeness grep; the validator test that puts one of every widget in a sidebar.
med Cross-list drag double-applies. vuedraggable emits on both lists; the store must apply both `setWidgets` calls idempotently and never re-derive one list from the other.
med Centered width gets tight. 880px − 320px − gap leaves ~536px for main; a span-6 widget is ~258px. Visible in the canvas's content-width chip; see open questions.
low Hero lifted out of the draggable list. Authors may try to drag it; it snaps back to first. The frame stays selectable and removable, which is what they actually need.

Open questions

Centered + sidebar. Keep 880px and accept a narrow main, or widen `CONTENT_MAX_WIDTH.centered` only while the sidebar is active (one more derived value, one more magic number)? Default: keep 880 and revisit with a real portal.
Sidebar first on small screens. Some sidebars (next step, waiting on you) arguably belong above the grid on a phone. Not in scope; a fifth layout field if asked for.
A label above the stacked sidebar. The canvas drew "From your team". Left out as unauthorable copy; a `sidebar_label` field is the shape if it comes back.

Internal documentation — gated behind Cloudflare Access.