diff --git a/.changeset/bottom-bar-glass-and-layouts.md b/.changeset/bottom-bar-glass-and-layouts.md
new file mode 100644
index 00000000000..3db691b4070
--- /dev/null
+++ b/.changeset/bottom-bar-glass-and-layouts.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Renders the bottom toolbar's glass as a blur on browsers that cannot refract it, and offers two more toolbar layouts in Settings.
diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md
index d5979178fa5..58501487887 100644
--- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md
+++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md
@@ -18,6 +18,12 @@ 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.
+The toolbar is drawn as frosted glass over the canvas. Chromium-based browsers
+refract the canvas through it, and settle into that refraction over a moment
+after the bar appears; Safari and Firefox show a plain blur instead. How its
+controls are grouped is a choice: see [Bottom toolbar](visual-settings.md#bottom-toolbar-experimental)
+in the settings dialog.
+
## Top bar
diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md
index f5f858be507..5ca5e796a39 100644
--- a/libs/@hashintel/petrinaut/docs/visual-settings.md
+++ b/libs/@hashintel/petrinaut/docs/visual-settings.md
@@ -14,7 +14,7 @@ Changes apply immediately and are saved as your preferences across nets. On host
| Section | Settings |
| -------------- | -------------------------------------------------------------------- |
-| **General** | Animations, panel loading, and the welcome guide. |
+| **General** | Animations, panel loading, the welcome guide, and toolbar layout. |
| **Viewport** | Minimap, compact nodes, arc rendering, grid snapping, and selection. |
| **Simulation** | Experimental compute, parameter sweeps, and optimization options. |
| **Labs** | Experimental modeling views, code layouts, and developer tools. |
@@ -35,6 +35,16 @@ When enabled, hidden panels remain loaded in the background. Switching between p
Show the getting-started guide the next time you open Petrinaut.
+### Bottom toolbar (experimental)
+
+Choose how the toolbar at the bottom of the canvas is laid out. Every layout keeps the same controls and steps aside from the panels in the same way; they differ in how the controls are grouped.
+
+| Layout | Description |
+| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **Split** | Two glass segments: the cursor and editing tools on the left, the panel toggle, diagnostics status and playback controls on the right. (Default) |
+| **Mode switch** | One segment showing one face at a time. The edit face holds the AI assistant and the tools that add nodes; the simulate face holds diagnostics, Play, the playback speed and the scrubber. The Edit / Simulate switch at the right end flips between them, and starting a run flips to the simulate face by itself. The cursor button is tinted blue on the edit face and purple on the simulate face. |
+| **Single** | One segment holding every control in one row: cursor, AI assistant and editing tools, then diagnostics status, a filled blue Play button, the scrubber and the playback settings, with the panel toggle at the far end. |
+
## Viewport
### Minimap
diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts
index 3e5528a2467..cff65f8af8c 100644
--- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts
+++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts
@@ -18,6 +18,25 @@ import type {
export type ArcRendering = "smoothstep" | "bezier" | "custom";
+/**
+ * How the bottom toolbar is laid out. `split` keeps the edit tools and the
+ * playback controls in two glass segments; `modes` is the single segment from
+ * the design file, which shows either face behind an Edit / Simulate switch;
+ * `single` is one segment holding every control.
+ */
+export type BottomBarVariant = "split" | "modes" | "single";
+
+const bottomBarVariants: readonly BottomBarVariant[] = [
+ "split",
+ "modes",
+ "single",
+];
+
+/** Whether a persisted value names a layout this build has. */
+export const isBottomBarVariant = (value: unknown): value is BottomBarVariant =>
+ typeof value === "string" &&
+ (bottomBarVariants as readonly string[]).includes(value);
+
export type SubViewSectionSettings = {
collapsed: boolean;
/** Last known panel height in pixels */
@@ -37,6 +56,7 @@ export type UserSettings = {
enableExperimentalIconPack: boolean;
enableAutomaticArcConnections: boolean;
arcRendering: ArcRendering;
+ bottomBarVariant: BottomBarVariant;
cursorMode: CursorMode;
isLeftSidebarOpen: boolean;
leftSidebarWidth: number;
@@ -117,6 +137,7 @@ export type UserSettingsActions = {
setEnableExperimentalIconPack: (value: boolean) => void;
setEnableAutomaticArcConnections: (value: boolean) => void;
setArcRendering: (value: ArcRendering) => void;
+ setBottomBarVariant: (value: BottomBarVariant) => void;
setIsLeftSidebarOpen: (value: boolean) => void;
setLeftSidebarWidth: (value: number) => void;
setPropertiesPanelWidth: (value: number) => void;
@@ -154,6 +175,7 @@ export const defaultUserSettings: UserSettings = {
enableExperimentalIconPack: false,
enableAutomaticArcConnections: false,
arcRendering: "custom",
+ bottomBarVariant: "split",
cursorMode: "pan",
isLeftSidebarOpen: true,
leftSidebarWidth: DEFAULT_LEFT_SIDEBAR_WIDTH,
@@ -190,6 +212,7 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = {
setEnableExperimentalIconPack: () => {},
setEnableAutomaticArcConnections: () => {},
setArcRendering: () => {},
+ setBottomBarVariant: () => {},
setIsLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
setPropertiesPanelWidth: () => {},
diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx
index 7a9eb8bbc59..2d915fb99eb 100644
--- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx
+++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx
@@ -3,6 +3,7 @@ import { use, useEffect, useState } from "react";
import {
defaultUserSettings,
defaultUserSettingsContextValue,
+ isBottomBarVariant,
UserSettingsContext,
} from "./user-settings-context";
import { rememberCanvasViewport } from "./user-settings-provider/remember-canvas-viewport";
@@ -15,6 +16,7 @@ import type {
} from "./editor-context";
import type {
ArcRendering,
+ BottomBarVariant,
SubViewSectionSettings,
UserSettings,
} from "./user-settings-context";
@@ -63,6 +65,10 @@ const loadSettings = (): UserSettings => {
...parsed,
// Someone who had selected the GPU globally keeps it available.
webGpuEnabled: parsed.webGpuEnabled ?? computeBackend === "webgpu",
+ // A layout this build no longer has falls back to the default one.
+ bottomBarVariant: isBottomBarVariant(parsed.bottomBarVariant)
+ ? parsed.bottomBarVariant
+ : defaultUserSettings.bottomBarVariant,
};
}
} catch {
@@ -104,6 +110,8 @@ const OwnedUserSettingsProvider: React.FC = ({
})),
setArcRendering: (value: ArcRendering) =>
setState((prev) => ({ ...prev, arcRendering: value })),
+ setBottomBarVariant: (value: BottomBarVariant) =>
+ setState((prev) => ({ ...prev, bottomBarVariant: value })),
setCursorMode: (value: CursorMode) =>
setState((prev) => ({ ...prev, cursorMode: value })),
setIsLeftSidebarOpen: (value: boolean) =>
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/ai-assistant-toggle.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/ai-assistant-toggle.tsx
new file mode 100644
index 00000000000..7921788c35f
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/ai-assistant-toggle.tsx
@@ -0,0 +1,23 @@
+import { use } from "react";
+
+import { EditorContext } from "../../../../../react/state/editor-context";
+import { AiAssistantIcon } from "../../../../components/ai-assistant-icon";
+import { ToolbarButton } from "./toolbar-button";
+
+/** Opens and closes the AI assistant panel. */
+export const AiAssistantToggle: React.FC = () => {
+ const { isAiAssistantOpen, toggleAiAssistant } = use(EditorContext);
+ const label = isAiAssistantOpen ? "Hide AI assistant" : "Show AI assistant";
+
+ return (
+
+
+
+ );
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bar-content.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bar-content.ts
new file mode 100644
index 00000000000..d6159950b61
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bar-content.ts
@@ -0,0 +1,14 @@
+import type {
+ CursorMode,
+ EditorState,
+} from "../../../../../react/state/editor-context";
+
+/** What every layout of the bar is given to render its controls from. */
+export interface BarContentProps {
+ mode: EditorState["globalMode"];
+ editionMode: EditorState["editionMode"];
+ onEditionModeChange: (mode: EditorState["editionMode"]) => void;
+ cursorMode: CursorMode;
+ onCursorModeChange: (mode: CursorMode) => void;
+ hasAiAssistant: boolean;
+}
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 c6467f57b28..6b329f9b29a 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,53 +1,24 @@
import { use, useEffect, useRef } from "react";
-import { Icon } from "@hashintel/ds-components";
import { css, cva } from "@hashintel/ds-helpers/css";
-import { refractive } from "@hashintel/refractive";
-import { LanguageClientContext } from "../../../../../react/lsp/context";
-import { ActiveNetContext } from "../../../../../react/state/active-net-context";
+import { EditorContext } from "../../../../../react/state/editor-context";
import {
- type CursorMode,
- EditorContext,
- type EditorState,
-} from "../../../../../react/state/editor-context";
-import { useIsReadOnly } from "../../../../../react/state/use-is-read-only";
-import { AiAssistantIcon } from "../../../../components/ai-assistant-icon";
+ type BottomBarVariant,
+ UserSettingsContext,
+} from "../../../../../react/state/user-settings-context";
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 { ModesBar } from "./modes-bar";
+import { SingleBar } from "./single-bar";
+import { SplitBar } from "./split-bar";
import { useBottomBarLayout } from "./use-bottom-bar-layout";
import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
+import type { BarContentProps } from "./bar-content";
+
/** 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)]",
- // 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)]",
- },
-});
-
-const toolbarContainerStyle = css({
- display: "flex",
- alignItems: "center",
- gap: "1",
-});
-
// Spans the editor so the bar centres on the viewport rather than on the space
// between the panels, and lets clicks through everywhere the bar itself is not.
const bottomBarLaneStyle = css({
@@ -94,48 +65,19 @@ const barAnimatingStyle = cva({
},
});
-type EditorMode = EditorState["globalMode"];
-type EditorEditionMode = EditorState["editionMode"];
-
-interface BottomBarProps {
- mode: EditorMode;
- editionMode: EditorEditionMode;
- onEditionModeChange: (mode: EditorEditionMode) => void;
- cursorMode: CursorMode;
- onCursorModeChange: (mode: CursorMode) => void;
- hasAiAssistant: boolean;
-}
+/** The layouts a user can pick between in Settings. */
+const barByVariant: Record> = {
+ split: SplitBar,
+ modes: ModesBar,
+ single: SingleBar,
+};
-export const BottomBar: React.FC = ({
- mode,
- editionMode,
- hasAiAssistant,
- onEditionModeChange,
- cursorMode,
- onCursorModeChange,
-}) => {
+export const BottomBar: React.FC = (props) => {
+ const { mode, editionMode, onEditionModeChange, onCursorModeChange } = props;
const isActualMode = mode === "actual";
- const {
- isBottomPanelOpen,
- setBottomPanelOpen,
- setActiveBottomPanelTab,
- isAiAssistantOpen,
- isPanelAnimating,
- toggleAiAssistant,
- } = use(EditorContext);
-
- // Only error-severity diagnostics block simulation — warnings and hints
- // (e.g. HIR semantic lints) are informational.
- const { errorDiagnosticsCount } = use(LanguageClientContext);
- const hasDiagnostics = errorDiagnosticsCount > 0;
- const { activeSubnetId } = use(ActiveNetContext);
- const isInSubnet = activeSubnetId !== null;
- const isReadOnly = useIsReadOnly();
-
- const showDiagnostics = () => {
- setBottomPanelOpen(true);
- setActiveBottomPanelTab("diagnostics");
- };
+ const { isPanelAnimating } = use(EditorContext);
+ const { bottomBarVariant } = use(UserSettingsContext);
+ const BarContent = barByVariant[bottomBarVariant];
// Fallback to cursor mode when switching away from edit while in a mutative mode.
useEffect(() => {
@@ -154,10 +96,6 @@ export const BottomBar: React.FC = ({
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/collapsible-group.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx
index 522cf855518..6a7241a7748 100644
--- 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
@@ -24,7 +24,8 @@ const groupStyle = cva({
base: {
display: "grid",
gridTemplateColumns: "[1fr]",
- transition: "[grid-template-columns 160ms ease-in, opacity 160ms ease-in]",
+ transition:
+ "[grid-template-columns 180ms cubic-bezier(0.4, 0, 0.2, 1), opacity 140ms cubic-bezier(0.4, 0, 1, 1)]",
"@media (prefers-reduced-motion: reduce)": {
transition: "[none]",
},
@@ -36,7 +37,9 @@ const groupStyle = cva({
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
+ // folding is not a response to anything and runs on the standard
+ // curve, with the opacity leading the width so nothing is read while
+ // it is being clipped. 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"]) &':
@@ -45,7 +48,7 @@ const groupStyle = cva({
opacity: "[1]",
pointerEvents: "auto",
transition:
- "[grid-template-columns 120ms ease-out, opacity 120ms ease-out]",
+ "[grid-template-columns 140ms cubic-bezier(0, 0, 0.2, 1), opacity 140ms cubic-bezier(0, 0, 0.2, 1) 40ms]",
},
},
},
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx
index 1b2d374cae5..20ff4a92e80 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx
@@ -6,15 +6,30 @@ import {
} from "../../../../../react/state/editor-context";
import { ToolbarMenuTrigger } from "./toolbar-menu-trigger";
+import type { ToolbarTone } from "./toolbar-button";
+
type EditorEditionMode = EditorState["editionMode"];
-/** Picks between the select and pan cursors, and returns to cursor mode. */
+/**
+ * Picks between the select and pan cursors, and returns to cursor mode. The
+ * trigger takes the bar's appearance and tone, so a bar that tints its
+ * controls for the face on show can tint this one too.
+ */
export const CursorModeDropdown: React.FC<{
editionMode: EditorEditionMode;
onEditionModeChange: (mode: EditorEditionMode) => void;
cursorMode: CursorMode;
onCursorModeChange: (mode: CursorMode) => void;
-}> = ({ editionMode, onEditionModeChange, cursorMode, onCursorModeChange }) => {
+ appearance?: "plain" | "filled";
+ tone?: ToolbarTone;
+}> = ({
+ editionMode,
+ onEditionModeChange,
+ cursorMode,
+ onCursorModeChange,
+ appearance = "plain",
+ tone = "brand",
+}) => {
const handleCursorChange = (mode: CursorMode) => {
onCursorModeChange(mode);
onEditionModeChange("cursor");
@@ -47,6 +62,8 @@ export const CursorModeDropdown: React.FC<{
}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/face-switch.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/face-switch.tsx
new file mode 100644
index 00000000000..b77f6ac03c0
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/face-switch.tsx
@@ -0,0 +1,64 @@
+import { Icon } from "@hashintel/ds-components";
+import { css } from "@hashintel/ds-helpers/css";
+
+import { ToolbarButton } from "./toolbar-button";
+
+/** Which set of controls the mode-switch bar shows. */
+export type BarFace = "edit" | "simulate";
+
+const wellStyle = css({
+ display: "flex",
+ alignItems: "center",
+ gap: "[2px]",
+ padding: "[2px]",
+ borderRadius: "lg",
+ borderWidth: "thin",
+ borderColor: "neutral.a50",
+ backgroundColor: "neutral.a10",
+});
+
+const raisedStyle = css({
+ "& > [aria-pressed='true']": {
+ backgroundColor: "[white]",
+ boxShadow: "[0 1px 2px rgba(0, 0, 0, 0.12)]",
+ },
+});
+
+/**
+ * The Edit / Simulate switch at the end of the mode-switch bar. The chosen
+ * face sits raised out of a recessed well, the way the design file draws it.
+ *
+ * While a run holds the net, the edit face has nothing to offer; the switch
+ * stays where it is with that segment locked, so the bar keeps its shape and
+ * the tooltip says what unlocks it.
+ */
+export const FaceSwitch: React.FC<{
+ face: BarFace;
+ canEdit: boolean;
+ onFaceChange: (face: BarFace) => void;
+}> = ({ face, canEdit, onFaceChange }) => (
+
+);
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/glass-finish.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/glass-finish.test.ts
new file mode 100644
index 00000000000..8a6dd508669
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/glass-finish.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+
+import { detectGlassFinish } from "./glass-finish";
+
+const chromeUserAgent =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
+const safariUserAgent =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
+const firefoxUserAgent =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.6; rv:130.0) Gecko/20100101 Firefox/130.0";
+const chromeOnIosUserAgent =
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/128.0.0.0 Mobile/15E148 Safari/604.1";
+
+describe("detectGlassFinish", () => {
+ it("refracts on a Chromium brand whatever the shell", () => {
+ expect(
+ detectGlassFinish({
+ userAgent: chromeUserAgent,
+ userAgentData: {
+ brands: [
+ { brand: "Not/A)Brand" },
+ { brand: "Microsoft Edge" },
+ { brand: "Chromium" },
+ ],
+ },
+ }),
+ ).toBe("refractive");
+ });
+
+ it("blurs when the brands name no Chromium", () => {
+ expect(
+ detectGlassFinish({
+ userAgent: chromeUserAgent,
+ userAgentData: { brands: [{ brand: "Not/A)Brand" }] },
+ }),
+ ).toBe("blur");
+ });
+
+ it("falls back to the user agent string where client hints are absent", () => {
+ expect(detectGlassFinish({ userAgent: chromeUserAgent })).toBe(
+ "refractive",
+ );
+ expect(detectGlassFinish({ userAgent: safariUserAgent })).toBe("blur");
+ expect(detectGlassFinish({ userAgent: firefoxUserAgent })).toBe("blur");
+ });
+
+ it("blurs for Chrome on iOS, which renders with WebKit", () => {
+ expect(detectGlassFinish({ userAgent: chromeOnIosUserAgent })).toBe("blur");
+ });
+});
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/glass-finish.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/glass-finish.ts
new file mode 100644
index 00000000000..5ad54af75f8
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/glass-finish.ts
@@ -0,0 +1,55 @@
+import { useSyncExternalStore } from "react";
+
+/**
+ * How the bar's glass is rendered: the backdrop refracted through an SVG
+ * filter, or plainly blurred.
+ */
+export type GlassFinish = "blur" | "refractive";
+
+/** What the detection reads off `navigator`. */
+export interface NavigatorHints {
+ readonly userAgent: string;
+ readonly userAgentData?: {
+ readonly brands: ReadonlyArray<{ readonly brand: string }>;
+ };
+}
+
+/**
+ * Only Chromium paints an SVG filter given to `backdrop-filter`. Safari and
+ * Firefox accept the declaration and render nothing behind it, so the support
+ * cannot be asked of `CSS.supports` and is read off the browser instead.
+ *
+ * Every Chromium brand qualifies, Edge and Brave included: the capability is
+ * the engine's. Chrome on iOS is WebKit underneath and reports `CriOS`, so it
+ * gets the blur like Safari.
+ */
+export const detectGlassFinish = (hints: NavigatorHints): GlassFinish => {
+ const brands = hints.userAgentData?.brands;
+ if (brands !== undefined) {
+ return brands.some(({ brand }) => /chromium|google chrome/i.test(brand))
+ ? "refractive"
+ : "blur";
+ }
+ return /Chrome\/\d/.test(hints.userAgent) && !/CriOS\//.test(hints.userAgent)
+ ? "refractive"
+ : "blur";
+};
+
+const detectedFinish: GlassFinish =
+ typeof navigator === "undefined"
+ ? "blur"
+ : detectGlassFinish(navigator as NavigatorHints);
+
+const subscribeToNothing = () => () => {};
+
+/**
+ * The finish this browser can render. Read as an external value so a server
+ * render or a hydration pass starts from the blur every browser can paint, and
+ * the refraction arrives with the client.
+ */
+export const useGlassFinish = (): GlassFinish =>
+ useSyncExternalStore(
+ subscribeToNothing,
+ () => detectedFinish,
+ () => "blur",
+ );
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/glass-surface.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/glass-surface.tsx
new file mode 100644
index 00000000000..0ecab2317d6
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/glass-surface.tsx
@@ -0,0 +1,117 @@
+import { useState } from "react";
+
+import { css, cva } from "@hashintel/ds-helpers/css";
+import { refractive } from "@hashintel/refractive";
+
+import { useGlassFinish } from "./glass-finish";
+
+/** The corner radius shared by every glass segment of the bar. */
+export const GLASS_RADIUS = 8;
+
+/**
+ * The refraction the Chromium finish applies. The 3px blur is part of the
+ * refracted look, not the fallback blur, and matches what the bar showed
+ * before the finish was split by browser.
+ */
+const REFRACTION = {
+ radius: GLASS_RADIUS,
+ blur: 3,
+ bezelWidth: 20,
+ glassThickness: 100,
+};
+
+const surfaceStyle = css({
+ position: "relative",
+ borderWidth: "thin",
+ borderColor: "neutral.a50",
+ boxShadow: "[0 3px 11px rgba(0, 0, 0, 0.1)]",
+ transition: "[box-shadow 0.3s ease]",
+ _hover: {
+ boxShadow: "[0 4px 13px rgba(0, 0, 0, 0.15)]",
+ },
+});
+
+/**
+ * The two finishes are layers under the fill rather than a filter on the
+ * segment itself, so one can hand over to the other. A layer that fades
+ * composites its filtered backdrop at its opacity, which is what lets the
+ * blur dissolve into the refraction instead of switching.
+ */
+const layerStyle = {
+ position: "absolute",
+ inset: "[0]",
+ borderRadius: "[inherit]",
+ pointerEvents: "none",
+ animationDuration: "[0.4s]",
+ animationTimingFunction: "[ease]",
+ animationFillMode: "both",
+} as const;
+
+const blurLayerStyle = cva({
+ base: {
+ ...layerStyle,
+ backdropFilter: "[blur(14px) saturate(1.4)]",
+ },
+ variants: {
+ handingOver: {
+ true: { animationName: "fadeOut" },
+ },
+ },
+});
+
+const refractiveLayerStyle = css({
+ ...layerStyle,
+ animationName: "fadeIn",
+});
+
+const fillStyle = css({
+ position: "relative",
+ padding: "1",
+ borderRadius: "[inherit]",
+ backgroundColor: "white.a95",
+ transition: "[background-color 0.3s ease]",
+ // Stays on one line: Panda writes the key into the class name, and a
+ // wrapped one stops matching the rule it generated.
+ "[data-glass]:hover &": {
+ backgroundColor: "white.a110",
+ },
+});
+
+/**
+ * One glass segment of the bar.
+ *
+ * Every browser paints the blurred backdrop first. Where the browser can also
+ * refract it, the refraction fades in over 0.4s while the blur fades out, so
+ * the segment settles into the refractive finish rather than popping into it.
+ */
+export const GlassSurface: React.FC<{ children: React.ReactNode }> = ({
+ children,
+}) => {
+ const finish = useGlassFinish();
+ const isRefractive = finish === "refractive";
+ // Once the blur has faded behind the refraction it is only a filter the
+ // compositor keeps running at zero opacity, so it goes.
+ const [isBlurRetired, setBlurRetired] = useState(false);
+
+ return (
+
+ );
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/modes-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/modes-bar.tsx
new file mode 100644
index 00000000000..663c1cd7a93
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/modes-bar.tsx
@@ -0,0 +1,127 @@
+import { use, useState } from "react";
+
+import { LanguageClientContext } from "../../../../../react/lsp/context";
+import { SimulationContext } from "../../../../../react/simulation/context";
+import { ActiveNetContext } from "../../../../../react/state/active-net-context";
+import { EditorContext } from "../../../../../react/state/editor-context";
+import { useReadOnlyReason } from "../../../../../react/state/use-read-only-reason";
+import { AiAssistantToggle } from "./ai-assistant-toggle";
+import { CollapsibleGroup } from "./collapsible-group";
+import { CursorModeDropdown } from "./cursor-mode-dropdown";
+import { DiagnosticsIndicator } from "./diagnostics-indicator";
+import { EditionTools } from "./edition-tools";
+import { type BarFace, FaceSwitch } from "./face-switch";
+import { GlassSurface } from "./glass-surface";
+import { PanelToggle } from "./panel-toggle";
+import { SimulationControls } from "./simulation-controls";
+import { toolbarContainerStyle } from "./split-bar";
+import { ToolbarDivider } from "./toolbar-divider";
+
+import type { BarContentProps } from "./bar-content";
+
+/**
+ * The single segment from the design file: the cursor control tinted for the
+ * face on show, that face's controls, and an Edit / Simulate switch at the
+ * end. The edit face holds the tools that add to the net; the simulate face
+ * holds the run. Starting a run turns the bar to the simulate face by itself;
+ * everything else is the switch.
+ */
+export const ModesBar: React.FC = ({
+ mode,
+ editionMode,
+ onEditionModeChange,
+ cursorMode,
+ onCursorModeChange,
+ hasAiAssistant,
+}) => {
+ const isActualMode = mode === "actual";
+ const { isBottomPanelOpen, setBottomPanelOpen, setActiveBottomPanelTab } =
+ use(EditorContext);
+ const { errorDiagnosticsCount } = use(LanguageClientContext);
+ const { activeSubnetId } = use(ActiveNetContext);
+ const { state: simulationState } = use(SimulationContext);
+ const readOnlyReason = useReadOnlyReason();
+
+ const hasSimulation = simulationState !== "NotRun";
+ const [chosenFace, setChosenFace] = useState(
+ hasSimulation ? "simulate" : "edit",
+ );
+ // A run starting is the one event that turns the face over unasked: the
+ // controls for it would otherwise be a click away while it plays.
+ const [hadSimulation, setHadSimulation] = useState(hasSimulation);
+ if (hasSimulation !== hadSimulation) {
+ setHadSimulation(hasSimulation);
+ if (hasSimulation) {
+ setChosenFace("simulate");
+ }
+ }
+
+ const showDiagnostics = () => {
+ setBottomPanelOpen(true);
+ setActiveBottomPanelTab("diagnostics");
+ };
+
+ // The edit face holds the tools that add to the net, so it exists only
+ // while the net takes additions. A run holding it is the one refusal the
+ // bar can undo, and the switch stays up with that face locked; a net that
+ // is read-only for good gets no switch at all.
+ const canEdit = !isActualMode && readOnlyReason === null;
+ const editLockedByRun = readOnlyReason?.kind === "simulation-active";
+ const showFaceSwitch = canEdit || editLockedByRun;
+ const face: BarFace = canEdit ? chosenFace : "simulate";
+
+ return (
+
+