Skip to content

FE-1590: Keep the bottom toolbar clear of the panels around it - #9533

Merged
kube merged 7 commits into
mainfrom
claude/bottombar-overlap-prevention-3b9d4d
Sep 5, 2026
Merged

FE-1590: Keep the bottom toolbar clear of the panels around it#9533
kube merged 7 commits into
mainfrom
claude/bottombar-overlap-prevention-3b9d4d

Conversation

@kube

@kube kube commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Before this PR, the bottom toolbar was centered on the canvas by a fixed transform that knew nothing about the panels around it. Left sidebar, properties panel and viewport controls all sit in its row, and playback adds a 300px scrubber to it, so a wide panel or a narrow window left the toolbar overlapping a panel or sliding under the zoom column.

Toolbar now measures the space docked around it. It stays centered on the canvas while it clears both sides, shifts to the free side with a 12px margin when it would not, and folds to the cursor, the panel toggle, the diagnostics status and Play when even that leaves too little room. Pointer or keyboard focus on the toolbar brings the hidden controls back for as long as it stays there. One derivation of the panel insets serves the toolbar and the viewport controls, so the assistant panel no longer covers either.

Both recordings run the same path: select a place, drag the properties panel wider, hover the toolbar, deselect, open the bottom panel.

Before

9533-before-1800.mp4

After

9533-after-1800.mp4

Links

Changes

Placement

  • Toolbar sits in a full-width lane and moves by transform

    Lane spans the canvas and passes clicks through, so flex centering gives the resting position.
    Offset follows the width the bar is measured at, and animates only while a panel opens or closes.

  • bottom-bar-placement.ts holds the geometry as pure functions

    getBottomBarOffset clamps the centered position between the insets.
    A bar wider than the space between them keeps its left edge rather than splitting the overlap.

  • Insets come from panel state, not from measuring the panels

    Left sidebar, properties panel and viewport controls are read from EditorContext, so the toolbar moves on the same frame the panels do.

  • Viewport controls publish their offset and width as constants

    VIEWPORT_CONTROLS_OFFSET and VIEWPORT_CONTROLS_WIDTH replace the local BASE_OFFSET, and the toolbar reserves the column.

  • Bar rides both axes on one composited transform

    Lane holds a fixed inset and the bar lifts over the bottom panel by transform, which the compositor animates like the panel's own slide.
    A bottom transition fell behind the panel across the frames dropped while its content mounts.

  • Segment transition names its properties instead of all

    A collapsing group changes the glass panel's width, which all 0.3s animated over twice the group's own 150ms, drifting the bar past its place and back.

  • Collapse decelerates over 120ms rather than easing in and out over 150ms

    A hover moved 0.3% of the way in its first frame and now moves 22%.
    Expansion settles in 133ms, against 315ms before these three changes.

  • Assistant panel width moves into editor state

    It lived in the panel's own useState, where nothing that has to keep clear of it could read it. The panel is 500px wide and painted over both the toolbar and the zoom column.

  • useCanvasInsets derives what the panels cover, once

    Each edge's rule is the one the panel on it renders by, and the viewport controls take their offsets from it too.
    Four other call sites still derive their own; FE-1591 converges them.

Folding

  • Reveal is CSS, not mirrored state

    :hover, :focus-within and :has([data-state="open"]) on the bar reveal the folded groups, so nothing about the reveal lives in React.
    A control that unmounts while focused fires no blur, so a focus flag in state could stay set and strand the bar open.

  • useBottomBarLayout owns the measurement and the fold policy

    The component renders; the hook measures, decides and places. The offset follows the width the bar is heading for while a panel animates, so its transition is not restarted every frame.

  • CollapsibleGroup marks the controls that give way

    Group animates its grid column to zero while the content inside keeps its natural width, so the bar can measure what it hides without a second layout.
    Edit tools, AI toggle, stop button, frame counter, scrubber and playback settings are grouped; cursor, panel toggle, diagnostics and Play never are.

  • Collapse decision is derived, not stored

    Measured bar width plus the width the groups hide gives the expanded width, which is compared against the free space every render.

  • Pointer, focus and an open menu hold the toolbar expanded

    Menus render outside the bar, so a click inside it keeps the controls out until the next click lands elsewhere.

  • ToolbarModes splits into CursorModeDropdown and EditionTools

    Two components let the bar keep the cursor and collapse the rest. showEditTools goes away with them.

Test coverage

  • bottom-bar-placement.test.ts:

    Centering with room to spare, both push directions, the pinned left edge when the bar is too wide, and the unmeasured container.

  • use-canvas-insets.test.ts:

    Each edge's rule, and that an open assistant stacks on the properties panel it docks beside.

  • Browser checks on the website demo:

    Toolbar clear of sidebar, properties panel and zoom column at 780px, 1050px and 1250px, with and without playback. Collapse, hover expansion and re-collapse verified against the rendered geometry.

Review fixes

  • An open menu holds the toolbar unfolded

    A menu opened from the toolbar renders in a portal outside it, so the click-hold was released by the very menus it protected: the bar folded under its own open menu, which stayed anchored to a control that had just been clipped away. Opening one from the keyboard never took the hold at all.
    The reveal now keys on the menu's own open state, as the sidebar's row actions already do, and the JavaScript hold is gone.

  • Menu trigger forwards its injected props

    Menu mounts its trigger through Ark's asChild, which clones the element to attach the click handling, the trigger ref and its ARIA. The extracted component consumed only its own props, so neither dropdown opened.

Known issues

  • Reduced motion is not honoured for the bar's tracking transform, because the panels it tracks animate regardless of the setting and stopping only the bar detaches it. FE-1591 covers honouring it across the panel family.
  • Each fold re-encodes the glass filter on both segments, which the refractive package rebuilds on every size change. Its own FE-43 removes that observer.

How to test

  • Open Petrinaut preview on Vercel
  • Menu > Load example > SIR Model
  • Narrow window until properties panel is closed and toolbar nears the sidebar

    Expect toolbar to stop at a small gap from the sidebar instead of running under it

  • Select a place

    Expect toolbar to shift left, clear of the properties panel and the zoom column

  • Drag properties panel wider

    Expect toolbar to keep pace with the edge

  • Narrow window until toolbar shrinks

    Expect cursor, panel toggle, diagnostics and Play to remain

  • Point at toolbar

    Expect hidden controls back while pointer stays, gone once it leaves

  • Press Play, then narrow window

    Expect scrubber and frame counter to fold away first

@kube kube self-assigned this Sep 4, 2026
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hash Ready Ready Preview Sep 4, 2026 9:47pm UTC
petrinaut Ready Ready Preview Sep 4, 2026 9:47pm UTC
petrinaut-docs Ready Ready Preview Sep 4, 2026 9:47pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
hashdotdesign-tokens Ignored Ignored Preview Sep 4, 2026 9:47pm UTC

Request Review

@github-actions github-actions Bot added area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team labels Sep 4, 2026
@kube
kube force-pushed the claude/bottombar-overlap-prevention-3b9d4d branch from dcef401 to 68924cd Compare September 4, 2026 12:22
kube added 5 commits September 4, 2026 17:58
The bar stays centered on the canvas until a panel would cover it, then
shifts to the free side with a margin. Where the space between the panels
is too narrow for the full set of controls it collapses to the cursor, the
diagnostics status and Play, expanding again while the pointer or the
keyboard is on it.
Each group measures from an effect keyed on the callback, so a fresh one
per render would disconnect the observers and report a width in a loop.
The segment's transition was `all 0.3s`, so a collapsing group animated the
glass panel's width over twice the group's own 150ms, and the offset, which
transitioned on a curve of its own, pulled the bar past its place and back.
The transition now names the two properties the hover effect changes, and the
bar only animates its offset while a panel opens or closes: a collapse keeps
it glued to the width it is measured at, and a resize drag to the panel edge.

Hover expansion settles in 174ms rather than 315ms, a collapse in 86ms rather
than 201ms.
The bar rode above the bottom panel on its `bottom`, which the main thread
animates, while the panel slides on a transform the compositor animates. The
frames dropped while the panel's content mounts leave a layout-driven
animation behind, so the bar arrived after the panel it is meant to sit on.
Lane now holds a fixed inset and both axes ride one transform.

Collapse decelerates over 120ms instead of easing in and out over 150ms:
hovering moved 0.3% of the way in the first frame and now moves 22%, and the
bar settles in 133ms rather than 174ms.
Keeps the toolbar clear of the assistant panel, which paints over it: the
panel's width moves from its own state into the editor's, and one derivation
of the panel insets now serves both the toolbar and the viewport controls,
which were hidden behind the assistant for the same reason.

Reveals the hidden controls from CSS rather than mirrored state. A control
that unmounts while focused fires no blur, so the focus flag could stay set
and leave the toolbar stranded open; `:focus-within` cannot go stale. Only
the click-hold stays in JavaScript, for the menus that render outside the bar.

Takes the measurement and the fold policy out of the component into
`useBottomBarLayout`, drops the forked width hook for the package's own
`useElementSize`, and splits the toolbar's two dropdowns into their own files
around the trigger they share. While a panel animates, the offset now follows
the width the bar is heading for, so its transition is not restarted every
frame.

Folding eases in over 160ms and revealing decelerates over 120ms, since only
one of the two answers a pointer.
@kube
kube force-pushed the claude/bottombar-overlap-prevention-3b9d4d branch from 68924cd to 09661be Compare September 4, 2026 16:01
@kube kube changed the title Keep the bottom toolbar clear of the side panels and viewport controls FE-1590: Keep the bottom toolbar clear of the panels around it Sep 4, 2026
@kube
kube marked this pull request as ready for review September 4, 2026 16:03
@kube
kube requested a balanced review from Copilot and removed request for Copilot September 4, 2026 16:03
@kube
kube requested review from CiaranMn and YannisZa September 4, 2026 16:03
@cursor

cursor Bot commented Sep 4, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
UI layout and editor state wiring only; simulation and net data paths are unchanged, with solid unit test coverage for placement math.

Overview
The bottom toolbar no longer sits on a fixed center transform that ignores side panels, the zoom column, or a wide playback scrubber. It lives in a full-width canvas lane, derives panel insets from editor state via useCanvasInsets, and shifts horizontally (with a margin) when a centered bar would run under the left sidebar, properties panel, viewport controls, or AI assistant. AI assistant width moves into EditorContext so those surfaces can reserve the same space the panel paints over.

When space is still too tight, non-essential controls collapse into CollapsibleGroup segments (edit tools, scrubber, playback settings, etc.) while cursor, panel toggle, diagnostics, and Play stay visible; hover, focus, or an open menu expands the hidden controls in CSS. Placement geometry lives in bottom-bar-placement.ts; useBottomBarLayout measures the bar and drives offset, lift over the bottom panel, and collapse. Viewport controls now use the same inset rules. Docs and unit tests cover placement and insets.

Reviewed by Cursor Bugbot for commit 3159005. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Keyboard-opened portalled menus do not hold the toolbar expanded while active.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Keeps Petrinaut’s bottom toolbar clear of surrounding panels and viewport controls.

Changes:

  • Adds measured toolbar placement and collapsible control groups.
  • Centralizes canvas insets and AI assistant width in editor state.
  • Adds geometry tests, documentation, and a patch changeset.
File summaries
File Description
.changeset/bottom-bar-panel-clearance.md Adds the package changeset.
libs/@hashintel/petrinaut/docs/drawing-a-net.md Documents toolbar placement and folding.
libs/@hashintel/petrinaut/docs/simulation.md Documents collapsed playback controls.
libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx Updates the editor context fixture.
libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx Updates the editor context fixture.
libs/@hashintel/petrinaut/src/react/state/editor-context.ts Adds assistant-width state and action.
libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx Implements assistant-width updates.
libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts Defines the default assistant width.
libs/@hashintel/petrinaut/src/ui/constants/ui.ts Exposes viewport-control geometry.
libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts Tests shared panel insets.
libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts Derives canvas insets from editor state.
libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts Tests toolbar placement geometry.
libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts Calculates fit and horizontal offset.
libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx Integrates placement and folding behavior.
libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts Defines collapsible-group context.
libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx Animates and measures folded controls.
libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx Extracts the persistent cursor selector.
libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx Extracts collapsible editing tools.
libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx Groups collapsible playback controls.
libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx Adds a shared dropdown trigger.
libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx Removes the superseded combined component.
libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts Owns measurement, folding, and placement state.
libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx Updates the editor context fixture.
libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx Moves assistant width into editor state.
libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx Updates the story fixture.
libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx Uses shared insets for positioning.
Review details
  • Files reviewed: 26/26 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx Outdated
`Menu` mounts its trigger through Ark's `asChild`, which clones the element to
attach the click handling, the trigger ref and its ARIA. Wrapping the button in
a component that consumed only its own props dropped all of that, so neither
the cursor-mode nor the add-component dropdown opened.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c117f5e. Configure here.

The click-hold was released by the very menus it protected. A menu opened from
the toolbar renders in a portal outside it, so a click on a row that leaves the
menu open failed the containment check, cleared the hold, and folded the bar
under its own open menu — with the menu still anchored to a control that had
just been clipped away. Opening a menu from the keyboard never took the hold at
all.

The reveal now keys on the menu's own open state, the way the sidebar's row
actions already do, so hover, focus and an open menu are the three CSS
selectors that reveal the controls and the JavaScript hold is gone. Ark marks
its own menu triggers; the playback popover renders its trigger externally, so
that one marks itself, which `ToolbarButton` can now carry.
@kube
kube added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 0c1c119 Sep 5, 2026
79 checks passed
@hash-release hash-release Bot mentioned this pull request Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

3 participants