diff --git a/.changeset/bottom-bar-panel-clearance.md b/.changeset/bottom-bar-panel-clearance.md
new file mode 100644
index 00000000000..a4004b84ea5
--- /dev/null
+++ b/.changeset/bottom-bar-panel-clearance.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Keep the bottom toolbar clear of the side panels and the viewport controls: it stays centered on the canvas until a panel would cover it, then shifts aside, and collapses to its essential controls — expanding again on hover or focus — when the space between the panels is too narrow for the full set.
diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md
index fedd5733889..0d1aa4315e2 100644
--- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md
+++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md
@@ -11,6 +11,13 @@ The editor is organized around a central canvas where you build your net:
- **Bottom panel** -- tabs for Diagnostics (code errors), Simulation Settings, and Timeline (during simulation).
- **Bottom toolbar** -- editing mode buttons, simulation controls, the AI assistant toggle, and a show/hide button for the bottom panel.
+The bottom toolbar sits centered on the canvas and steps aside rather than
+slide under anything: open a panel wide enough to reach it and it shifts to the
+free side, keeping clear of the left sidebar, the properties panel and the
+viewport controls. Where even that leaves too little room it shrinks to the
+cursor, the panel toggle, the diagnostics status and Play; point at it, or tab
+into it, and the rest comes back for as long as you stay on it.
+
## Top bar
diff --git a/libs/@hashintel/petrinaut/docs/simulation.md b/libs/@hashintel/petrinaut/docs/simulation.md
index 9ee2336477b..864865b6b6a 100644
--- a/libs/@hashintel/petrinaut/docs/simulation.md
+++ b/libs/@hashintel/petrinaut/docs/simulation.md
@@ -98,6 +98,10 @@ The bottom toolbar provides playback controls:
The frame counter shows the current frame number, total frames, and elapsed simulation time.
+Playback widens the toolbar, so in a narrow window it keeps Play and folds the
+scrubber, the frame counter and the playback settings away until you point at
+it.
+
### Speed
diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx
index 6ab6c248657..4693fb8e05d 100644
--- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx
+++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx
@@ -46,6 +46,7 @@ const editorContextValue = (
setLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
setPropertiesPanelWidth: () => {},
+ setAiAssistantWidth: () => {},
setBottomPanelOpen: () => {},
toggleBottomPanel: () => {},
setBottomPanelHeight: () => {},
diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx
index 9672cec8e14..b7fa73a7a46 100644
--- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx
+++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx
@@ -49,6 +49,7 @@ const editorContextValue = (
setLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
setPropertiesPanelWidth: () => {},
+ setAiAssistantWidth: () => {},
setBottomPanelOpen: () => {},
toggleBottomPanel: () => {},
setBottomPanelHeight: () => {},
diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts
index 54122e7d9f3..1936ff1a8c1 100644
--- a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts
+++ b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts
@@ -1,6 +1,7 @@
import { createContext, createRef } from "react";
import {
+ DEFAULT_AI_ASSISTANT_WIDTH,
DEFAULT_BOTTOM_PANEL_HEIGHT,
DEFAULT_LEFT_SIDEBAR_WIDTH,
DEFAULT_PROPERTIES_PANEL_WIDTH,
@@ -81,6 +82,11 @@ export type EditorState = {
propertiesPanelWidth: number;
isBottomPanelOpen: boolean;
bottomPanelHeight: number;
+ /**
+ * Width of the AI assistant panel. Held here rather than inside the panel so
+ * the surfaces that have to keep clear of it can read it.
+ */
+ aiAssistantWidth: number;
activeBottomPanelTab: BottomPanelTab;
componentSubnetId: string | null;
selection: SelectionMap;
@@ -127,6 +133,7 @@ export type EditorActions = {
setLeftSidebarOpen: (isOpen: boolean) => void;
setLeftSidebarWidth: (width: number) => void;
setPropertiesPanelWidth: (width: number) => void;
+ setAiAssistantWidth: (width: number) => void;
setBottomPanelOpen: (isOpen: boolean) => void;
toggleBottomPanel: () => void;
setBottomPanelHeight: (height: number) => void;
@@ -189,6 +196,7 @@ export const initialEditorState: EditorState = {
propertiesPanelWidth: DEFAULT_PROPERTIES_PANEL_WIDTH,
isBottomPanelOpen: false,
bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT,
+ aiAssistantWidth: DEFAULT_AI_ASSISTANT_WIDTH,
activeBottomPanelTab: "diagnostics",
componentSubnetId: null,
selection: new Map(),
@@ -215,6 +223,7 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = {
setLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
setPropertiesPanelWidth: () => {},
+ setAiAssistantWidth: () => {},
setBottomPanelOpen: () => {},
toggleBottomPanel: () => {},
setBottomPanelHeight: () => {},
diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx
index a9b5e170c60..6b43a1f678b 100644
--- a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx
+++ b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx
@@ -410,6 +410,8 @@ export const EditorProvider: React.FC = ({ children }) => {
setState((prev) => ({ ...prev, leftSidebarWidth: width })),
setPropertiesPanelWidth: (width) =>
setState((prev) => ({ ...prev, propertiesPanelWidth: width })),
+ setAiAssistantWidth: (width) =>
+ setState((prev) => ({ ...prev, aiAssistantWidth: width })),
setBottomPanelOpen: (isOpen) => {
scheduleAnimationEnd();
setState((prev) => ({
diff --git a/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts b/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts
index 472d222462d..ad68b8472cf 100644
--- a/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts
+++ b/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts
@@ -11,3 +11,5 @@ export const DEFAULT_LEFT_SIDEBAR_WIDTH = 320;
export const DEFAULT_PROPERTIES_PANEL_WIDTH = 450;
export const DEFAULT_BOTTOM_PANEL_HEIGHT = 180;
+
+export const DEFAULT_AI_ASSISTANT_WIDTH = 500;
diff --git a/libs/@hashintel/petrinaut/src/ui/constants/ui.ts b/libs/@hashintel/petrinaut/src/ui/constants/ui.ts
index fe0a3c89429..ba4ca9d0e8a 100644
--- a/libs/@hashintel/petrinaut/src/ui/constants/ui.ts
+++ b/libs/@hashintel/petrinaut/src/ui/constants/ui.ts
@@ -29,6 +29,15 @@ export const MAX_LEFT_SIDEBAR_WIDTH = 500;
export const MIN_PROPERTIES_PANEL_WIDTH = 250;
export const MAX_PROPERTIES_PANEL_WIDTH = 800;
+// Viewport controls — the button column at the bottom right of the canvas.
+// The width is one `xs` icon button, rounded up from its rendered 21px.
+export const VIEWPORT_CONTROLS_OFFSET = 12;
+export const VIEWPORT_CONTROLS_WIDTH = 24;
+
+/** What a control sharing the column's row has to leave free of it. */
+export const VIEWPORT_CONTROLS_CLEARANCE =
+ VIEWPORT_CONTROLS_OFFSET + VIEWPORT_CONTROLS_WIDTH;
+
// Bottom Panel (DEFAULT_BOTTOM_PANEL_HEIGHT in react/state/panel-defaults)
export const MIN_BOTTOM_PANEL_HEIGHT = 100;
export const MAX_BOTTOM_PANEL_HEIGHT = 600;
diff --git a/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts
new file mode 100644
index 00000000000..73d82896401
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest";
+
+import { getCanvasInsets, type PanelLayoutState } from "./use-canvas-insets";
+
+const closed: PanelLayoutState = {
+ isLeftSidebarOpen: false,
+ isSearchOpen: false,
+ leftSidebarWidth: 320,
+ hasSelection: false,
+ propertiesPanelWidth: 450,
+ isAiAssistantOpen: false,
+ aiAssistantWidth: 500,
+ isBottomPanelOpen: false,
+ bottomPanelHeight: 180,
+};
+
+describe("getCanvasInsets", () => {
+ it("counts nothing while every panel is closed", () => {
+ expect(getCanvasInsets(closed)).toEqual({ left: 0, right: 0, bottom: 0 });
+ });
+
+ it("counts the left sidebar whether the toggle or search opened it", () => {
+ expect(getCanvasInsets({ ...closed, isLeftSidebarOpen: true }).left).toBe(
+ 320,
+ );
+ expect(getCanvasInsets({ ...closed, isSearchOpen: true }).left).toBe(320);
+ });
+
+ it("counts the properties panel only against a selection", () => {
+ expect(getCanvasInsets(closed).right).toBe(0);
+ expect(getCanvasInsets({ ...closed, hasSelection: true }).right).toBe(450);
+ });
+
+ it("stacks the assistant on the properties panel, which it docks beside", () => {
+ expect(getCanvasInsets({ ...closed, isAiAssistantOpen: true }).right).toBe(
+ 500,
+ );
+ expect(
+ getCanvasInsets({
+ ...closed,
+ hasSelection: true,
+ isAiAssistantOpen: true,
+ }).right,
+ ).toBe(950);
+ });
+
+ it("counts the bottom panel's height, not its open state alone", () => {
+ expect(
+ getCanvasInsets({
+ ...closed,
+ isBottomPanelOpen: true,
+ bottomPanelHeight: 240,
+ }).bottom,
+ ).toBe(240);
+ });
+});
diff --git a/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts
new file mode 100644
index 00000000000..503b4305152
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts
@@ -0,0 +1,51 @@
+import { use } from "react";
+
+import { EditorContext } from "../../react/state/editor-context";
+import { PANEL_MARGIN } from "../constants/ui";
+
+/** How much of the canvas each edge's panels cover, in CSS pixels. */
+export interface CanvasInsets {
+ readonly left: number;
+ readonly right: number;
+ readonly bottom: number;
+}
+
+/** The editor state the insets are derived from. */
+export interface PanelLayoutState {
+ readonly isLeftSidebarOpen: boolean;
+ readonly isSearchOpen: boolean;
+ readonly leftSidebarWidth: number;
+ readonly hasSelection: boolean;
+ readonly propertiesPanelWidth: number;
+ readonly isAiAssistantOpen: boolean;
+ readonly aiAssistantWidth: number;
+ readonly isBottomPanelOpen: boolean;
+ readonly bottomPanelHeight: number;
+}
+
+/**
+ * Each edge's rule is the one the panel on it renders by: search opens the
+ * left sidebar without the toggle, a selection opens the properties panel, and
+ * the assistant docks beside the properties panel rather than over it, so an
+ * open pair covers the sum of the two.
+ */
+export const getCanvasInsets = (state: PanelLayoutState): CanvasInsets => ({
+ left:
+ state.isLeftSidebarOpen || state.isSearchOpen
+ ? state.leftSidebarWidth + PANEL_MARGIN
+ : 0,
+ right:
+ (state.hasSelection ? state.propertiesPanelWidth + PANEL_MARGIN : 0) +
+ (state.isAiAssistantOpen ? state.aiAssistantWidth : 0),
+ bottom: state.isBottomPanelOpen ? state.bottomPanelHeight + PANEL_MARGIN : 0,
+});
+
+/**
+ * What the docked panels take out of the canvas, for the controls that float
+ * over it and have to keep clear of them.
+ *
+ * The panels overlay the canvas rather than shrinking it, so a floating
+ * control cannot read this off its own layout.
+ */
+export const useCanvasInsets = (): CanvasInsets =>
+ getCanvasInsets(use(EditorContext));
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts
new file mode 100644
index 00000000000..0e0addd3290
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ type BottomBarBounds,
+ fitsWithinBounds,
+ getBottomBarOffset,
+} from "./bottom-bar-placement";
+
+const bounds = (overrides: Partial = {}): BottomBarBounds => ({
+ containerWidth: 1000,
+ leftInset: 0,
+ rightInset: 0,
+ margin: 12,
+ ...overrides,
+});
+
+describe("getBottomBarOffset", () => {
+ it("leaves a bar that clears both panels centred", () => {
+ expect(
+ getBottomBarOffset(bounds({ leftInset: 200, rightInset: 200 }), 400),
+ ).toBe(0);
+ });
+
+ it("pushes a bar clear of the left panel", () => {
+ // Centred, the 600px bar starts at 200px, 62px inside the panel and its margin.
+ expect(getBottomBarOffset(bounds({ leftInset: 250 }), 600)).toBe(62);
+ });
+
+ it("pushes a bar clear of the right panel", () => {
+ expect(getBottomBarOffset(bounds({ rightInset: 250 }), 600)).toBe(-62);
+ });
+
+ it("pushes clear of the wider side when both panels are open", () => {
+ const space = bounds({
+ containerWidth: 1200,
+ leftInset: 100,
+ rightInset: 400,
+ });
+
+ expect(getBottomBarOffset(space, 500)).toBe(-62);
+ });
+
+ it("keeps the left edge when the bar is wider than the space between the panels", () => {
+ const space = bounds({ leftInset: 400, rightInset: 400 });
+ const offset = getBottomBarOffset(space, 400);
+
+ // 1000 - 400 = 600, and the left edge lands on the panel edge plus margin.
+ expect(offset).toBe(112);
+ expect((1000 - 400) / 2 + offset).toBe(412);
+ });
+
+ it("keeps the left edge when the panels are lopsided too", () => {
+ // The right inset alone leaves less than the bar needs, so neither edge
+ // can be cleared and the left one wins. A symmetric pair would pass this
+ // by arithmetic accident.
+ const space = bounds({ leftInset: 120, rightInset: 700 });
+ const offset = getBottomBarOffset(space, 400);
+
+ expect((1000 - 400) / 2 + offset).toBe(132);
+ });
+
+ it("clears both panels for every width it says fits", () => {
+ const space = bounds({ leftInset: 250, rightInset: 350 });
+ /** Where the bar ends up, once the offset is applied. */
+ const placed = (width: number) => {
+ const left =
+ (space.containerWidth - width) / 2 + getBottomBarOffset(space, width);
+ return { left, right: left + width };
+ };
+
+ for (const width of [100, 300, 376]) {
+ expect(fitsWithinBounds(space, width)).toBe(true);
+ expect(placed(width).left).toBeGreaterThanOrEqual(262);
+ expect(placed(width).right).toBeLessThanOrEqual(638);
+ }
+
+ // A pixel over, and no position clears both: the left edge is what the
+ // offset holds on to.
+ expect(fitsWithinBounds(space, 377)).toBe(false);
+ expect(placed(377).left).toBe(262);
+ });
+
+ it("stays put until the container and the bar have been measured", () => {
+ expect(getBottomBarOffset(bounds({ containerWidth: 0 }), 400)).toBe(0);
+ expect(getBottomBarOffset(bounds(), 0)).toBe(0);
+ });
+});
+
+describe("fitsWithinBounds", () => {
+ it("counts both insets and both margins against the container", () => {
+ // 1000 - 250 - 350 - 2 x 12
+ const space = bounds({ leftInset: 250, rightInset: 350 });
+
+ expect(fitsWithinBounds(space, 376)).toBe(true);
+ expect(fitsWithinBounds(space, 377)).toBe(false);
+ });
+
+ it("imposes no limit before the container has been measured", () => {
+ expect(fitsWithinBounds(bounds({ containerWidth: 0 }), 800)).toBe(true);
+ });
+});
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts
new file mode 100644
index 00000000000..d819c54a7c0
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts
@@ -0,0 +1,62 @@
+/**
+ * Where the bottom bar sits between the panels docked around the canvas, and
+ * whether everything it can show fits there.
+ *
+ * The bar is centered on the canvas rather than on the space left between the
+ * panels: opening a panel must not move a bar that still has room. A bar that
+ * would run under a panel is pushed aside, and only as far as it takes to
+ * clear it.
+ */
+
+/** The space the bar has to sit in, in CSS pixels. */
+export interface BottomBarBounds {
+ /** Width of the canvas area the bar is centered on. */
+ readonly containerWidth: number;
+ /** Width taken by whatever is docked on the left. */
+ readonly leftInset: number;
+ /** Width taken by whatever is docked on the right, viewport controls included. */
+ readonly rightInset: number;
+ /** Gap kept clear inside each inset, so the bar never touches a panel. */
+ readonly margin: number;
+}
+
+/** Width left for the bar once the insets and both margins are taken out. */
+const getAvailableWidth = (bounds: BottomBarBounds): number =>
+ bounds.containerWidth -
+ bounds.leftInset -
+ bounds.rightInset -
+ bounds.margin * 2;
+
+/**
+ * Whether a bar of `width` clears both insets while it stays centered, or can
+ * be pushed aside far enough to. An unmeasured container imposes no limit, so
+ * the bar shows everything until it has been measured.
+ */
+export const fitsWithinBounds = (
+ bounds: BottomBarBounds,
+ width: number,
+): boolean => bounds.containerWidth <= 0 || width <= getAvailableWidth(bounds);
+
+/**
+ * How far to shift a bar of `barWidth` from the centered position for it to
+ * clear both insets: positive to the right, negative to the left, zero while
+ * the centered bar already clears them. A bar wider than the space between the
+ * insets cannot clear both, and keeps its left edge.
+ */
+export const getBottomBarOffset = (
+ bounds: BottomBarBounds,
+ barWidth: number,
+): number => {
+ if (bounds.containerWidth <= 0 || barWidth <= 0) {
+ return 0;
+ }
+
+ const centeredLeft = (bounds.containerWidth - barWidth) / 2;
+ const leftLimit = bounds.leftInset + bounds.margin;
+ const rightLimit =
+ bounds.containerWidth - bounds.rightInset - bounds.margin - barWidth;
+
+ // `Math.max` last so the left limit wins when the two cross, which is what
+ // happens once the bar is wider than the space between the insets.
+ return Math.max(leftLimit, Math.min(centeredLeft, rightLimit)) - centeredLeft;
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx
index aa264351c90..c594ef13d90 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx
@@ -1,4 +1,4 @@
-import { use, useCallback, useEffect } from "react";
+import { use, useEffect, useRef } from "react";
import { Icon } from "@hashintel/ds-components";
import { css, cva } from "@hashintel/ds-helpers/css";
@@ -11,21 +11,31 @@ import {
EditorContext,
type EditorState,
} from "../../../../../react/state/editor-context";
+import { useIsReadOnly } from "../../../../../react/state/use-is-read-only";
import { AiAssistantIcon } from "../../../../components/ai-assistant-icon";
+import { BottomBarCollapseContext } from "./collapse-context";
+import { CollapsibleGroup } from "./collapsible-group";
+import { CursorModeDropdown } from "./cursor-mode-dropdown";
import { DiagnosticsIndicator } from "./diagnostics-indicator";
+import { EditionTools } from "./edition-tools";
import { SimulationControls } from "./simulation-controls";
import { ToolbarButton } from "./toolbar-button";
import { ToolbarDivider } from "./toolbar-divider";
-import { ToolbarModes } from "./toolbar-modes";
+import { useBottomBarLayout } from "./use-bottom-bar-layout";
import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
+/** Gap between the bar and whatever is below it, canvas or bottom panel. */
+const BOTTOM_BAR_GAP = 24;
+
const glassPanelStyle = css({
padding: "1",
backgroundColor: "white.a95",
borderWidth: "thin",
borderColor: "neutral.a50",
boxShadow: "[0 3px 11px rgba(0, 0, 0, 0.1)]",
- transition: "[all 0.3s ease]",
+ // Named rather than `all`, which would animate the width a folding group
+ // changes and take twice as long doing it as the group itself.
+ transition: "[background-color 0.3s ease, box-shadow 0.3s ease]",
_hover: {
backgroundColor: "white.a110",
boxShadow: "[0 4px 13px rgba(0, 0, 0, 0.15)]",
@@ -38,21 +48,47 @@ const toolbarContainerStyle = css({
gap: "1",
});
-const bottomBarPositionStyle = css({
+// Spans the canvas so the bar centres on the canvas rather than on the space
+// between the panels, and lets clicks through everywhere the bar itself is not.
+const bottomBarLaneStyle = css({
position: "absolute",
- left: "[50%]",
- transform: "translateX(-50%)",
+ left: "[0]",
+ right: "[0]",
+ display: "flex",
+ justifyContent: "center",
+ pointerEvents: "none",
zIndex: "[calc(var(--z-index-sticky) + 1)]",
+});
+
+const bottomBarStyle = css({
display: "flex",
gap: "[20px]",
+ pointerEvents: "auto",
+ // A bar wider than the lane overflows rather than squashing its segments:
+ // revealing the hidden controls in a cramped window does exactly that.
+ flexShrink: 0,
});
-const animatingStyle = cva({
+/**
+ * Only a panel opening or closing animates the bar into place. Folding moves
+ * it too, but there the offset follows the width the bar is measured at, frame
+ * by frame, and a transition would race that with a curve of its own; a resize
+ * drag wants none either, so the bar tracks the edge under the pointer.
+ *
+ * Both axes ride one transform, which the compositor animates like the panel's
+ * own slide. A main-thread property could not stay with it: the frames dropped
+ * while a panel's content mounts leave a layout-driven animation behind.
+ *
+ * Reduced motion is deliberately not honoured here. This transition is not
+ * decoration, it is what keeps the bar attached to a panel that animates
+ * regardless of the setting, and stopping only the bar detaches it.
+ */
+const barAnimatingStyle = cva({
base: {},
variants: {
animating: {
true: {
- transition: "[bottom 150ms ease-in-out]",
+ transition: "[transform 150ms ease-in-out]",
},
},
},
@@ -83,7 +119,6 @@ export const BottomBar: React.FC = ({
isBottomPanelOpen,
setBottomPanelOpen,
setActiveBottomPanelTab,
- bottomPanelHeight,
isAiAssistantOpen,
isPanelAnimating,
toggleAiAssistant,
@@ -95,15 +130,12 @@ export const BottomBar: React.FC = ({
const hasDiagnostics = errorDiagnosticsCount > 0;
const { activeSubnetId } = use(ActiveNetContext);
const isInSubnet = activeSubnetId !== null;
+ const isReadOnly = useIsReadOnly();
- const showDiagnostics = useCallback(() => {
+ const showDiagnostics = () => {
setBottomPanelOpen(true);
setActiveBottomPanelTab("diagnostics");
- }, [setBottomPanelOpen, setActiveBottomPanelTab]);
-
- const toggleBottomPanel = useCallback(() => {
- setBottomPanelOpen(!isBottomPanelOpen);
- }, [setBottomPanelOpen, isBottomPanelOpen]);
+ };
// Fallback to cursor mode when switching away from edit while in a mutative mode.
useEffect(() => {
@@ -115,93 +147,127 @@ export const BottomBar: React.FC = ({
// Setup keyboard shortcuts
useKeyboardShortcuts(mode, onEditionModeChange, onCursorModeChange);
- // Calculate bottom offset based on bottom panel visibility
- const bottomOffset = isBottomPanelOpen
- ? bottomPanelHeight + 24 // panel height + margin + spacing
- : 24;
+ const laneRef = useRef(null);
+ const barRef = useRef(null);
+ const layout = useBottomBarLayout(laneRef, barRef, {
+ hasViewportControls: !isActualMode,
+ isAnimating: isPanelAnimating,
+ });
+
+ // Edit tools are absent on a read-only net and outside edit mode, so the
+ // group would otherwise fold an empty box and leave its gap behind.
+ const hasEditionGroup = !isActualMode && (!isReadOnly || hasAiAssistant);
return (
);
};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts
new file mode 100644
index 00000000000..9239cf424af
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts
@@ -0,0 +1,34 @@
+import { createContext } from "react";
+
+/** What one collapsible group of toolbar controls takes up, in CSS pixels. */
+export interface CollapsibleGroupWidth {
+ /** What this group takes when its controls are shown. */
+ readonly natural: number;
+ /** How much of that is clipped away right now — 0 while it is shown. */
+ readonly hidden: number;
+}
+
+export interface BottomBarCollapseValue {
+ /** True while the bar shows only its essential controls. */
+ readonly isCollapsed: boolean;
+ /**
+ * Report what a group takes up, so the bar knows how wide it would be with
+ * everything shown. Pass `null` when the group unmounts.
+ */
+ readonly reportGroupWidth: (
+ id: string,
+ width: CollapsibleGroupWidth | null,
+ ) => void;
+}
+
+/**
+ * Lets controls anywhere under the bottom bar mark themselves collapsible
+ * without the bar having to know what its segments are made of.
+ *
+ * The default shows everything and measures nothing: away from the bar there
+ * is no space to run out of.
+ */
+export const BottomBarCollapseContext = createContext({
+ isCollapsed: false,
+ reportGroupWidth: () => {},
+});
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx
new file mode 100644
index 00000000000..522cf855518
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx
@@ -0,0 +1,123 @@
+import { use, useId, useLayoutEffect, useRef } from "react";
+
+import { css, cva } from "@hashintel/ds-helpers/css";
+
+import { BottomBarCollapseContext } from "./collapse-context";
+
+/**
+ * The group folds by animating its grid column to nothing while the content
+ * inside keeps its natural width. That keeps the content measurable whether it
+ * is shown or hidden, so the bar can tell how wide it would be with everything
+ * shown without laying the controls out twice.
+ *
+ * The reveal is a selector on the bar rather than state passed back down: the
+ * browser maintains `:hover` and `:focus-within` itself, where mirroring them
+ * into React can strand the bar open — a control that unmounts while focused
+ * fires no blur.
+ *
+ * A menu opened from the bar renders in a portal outside it, taking the
+ * pointer and the focus with it, so the third selector holds the bar open
+ * while a control inside it reports one. The same pattern keeps the sidebar's
+ * row actions up; see `filterable-list-sub-view`.
+ */
+const groupStyle = cva({
+ base: {
+ display: "grid",
+ gridTemplateColumns: "[1fr]",
+ transition: "[grid-template-columns 160ms ease-in, opacity 160ms ease-in]",
+ "@media (prefers-reduced-motion: reduce)": {
+ transition: "[none]",
+ },
+ },
+ variants: {
+ collapsed: {
+ true: {
+ gridTemplateColumns: "[0fr]",
+ opacity: "[0]",
+ pointerEvents: "none",
+ // Revealing answers the pointer, so it runs shorter and decelerates;
+ // folding is not a response to anything and eases in. The selector
+ // stays on one line: Panda writes the key into the class name, and a
+ // wrapped one stops matching the rule it generated.
+ '[data-bottom-bar]:hover &, [data-bottom-bar]:focus-within &, [data-bottom-bar]:has([data-state="open"]) &':
+ {
+ gridTemplateColumns: "[1fr]",
+ opacity: "[1]",
+ pointerEvents: "auto",
+ transition:
+ "[grid-template-columns 120ms ease-out, opacity 120ms ease-out]",
+ },
+ },
+ },
+ },
+});
+
+const clipStyle = css({
+ overflow: "hidden",
+ minWidth: "[0]",
+});
+
+const contentStyle = css({
+ display: "flex",
+ alignItems: "center",
+ gap: "1",
+ width: "[max-content]",
+});
+
+/**
+ * Toolbar controls the bottom bar hides when it runs out of room, and shows
+ * again while the pointer or the keyboard is on the bar.
+ *
+ * A folded group keeps its controls focusable on purpose: focus is what
+ * reveals them, so making the subtree inert would leave a keyboard with no way
+ * in.
+ */
+export const CollapsibleGroup: React.FC<{ children: React.ReactNode }> = ({
+ children,
+}) => {
+ const { isCollapsed, reportGroupWidth } = use(BottomBarCollapseContext);
+ const groupId = useId();
+ const clipRef = useRef(null);
+ const contentRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const clip = clipRef.current;
+ const content = contentRef.current;
+ if (!clip || !content) {
+ return;
+ }
+
+ const measure = () => {
+ const natural = content.getBoundingClientRect().width;
+ const rendered = clip.getBoundingClientRect().width;
+ reportGroupWidth(groupId, {
+ natural,
+ hidden: Math.max(natural - rendered, 0),
+ });
+ };
+ measure();
+
+ // Both boxes are watched: the content changes when a control appears, and
+ // the clip changes on every frame of the fold. Reporting the pair from one
+ // observer keeps the two in step, so the width the bar derives from them
+ // is right mid-animation too.
+ const observer = new ResizeObserver(measure);
+ observer.observe(clip);
+ observer.observe(content);
+
+ return () => {
+ observer.disconnect();
+ reportGroupWidth(groupId, null);
+ };
+ }, [groupId, reportGroupWidth]);
+
+ return (
+
- {frameIndex + 1} / {totalFrames}
+ {/* Frame controls - only visible when simulation exists - and the
+ playback settings, which the bar hides first when it runs short of
+ room: the scrubber is the widest thing on it. */}
+
+ {hasSimulation && (
+ <>
+