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. + full-editor ## 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 (
= ({ reportGroupWidth: layout.reportGroupWidth, }} > - {/* Edition tools segment */} - -
- - {hasEditionGroup && ( - - - {hasAiAssistant && ( - <> - - - - - - )} - - )} -
-
- - {/* Playback segment */} - -
- setBottomPanelOpen(!isBottomPanelOpen)} - ariaLabel={isBottomPanelOpen ? "Hide panel" : "Show panel"} - ariaExpanded={isBottomPanelOpen} - > - {isBottomPanelOpen ? ( - - ) : ( - - )} - - {!isActualMode && ( - <> - - - - - )} -
-
+
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 }) => ( +
+ onFaceChange("edit") : undefined} + > + + + onFaceChange("simulate")} + > + + +
+); 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 ( +
+ {isBlurRetired ? null : ( +
setBlurRetired(true) : undefined} + /> + )} + {isRefractive ? ( + + ) : null} +
{children}
+
+ ); +}; 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 ( + +
+ + {hasAiAssistant && !isActualMode && ( + <> + + + + )} + {face === "edit" ? ( + + + + ) : null} + {face === "simulate" && !isActualMode ? ( + <> + + + 0} + inSubnet={activeSubnetId !== null} + settingsTrigger="speed" + /> + + ) : null} + + + {showFaceSwitch && ( + <> + + + + )} +
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/panel-toggle.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/panel-toggle.tsx new file mode 100644 index 00000000000..68d922b4250 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/panel-toggle.tsx @@ -0,0 +1,35 @@ +import { use } from "react"; + +import { Icon } from "@hashintel/ds-components"; + +import { EditorContext } from "../../../../../react/state/editor-context"; +import { ToolbarButton } from "./toolbar-button"; + +/** + * Shows or hides the bottom panel. The chevron points where the panel will + * go; the chart glyph is the design file's reading of the same control, where + * the panel is where the timeline lives. + */ +export const PanelToggle: React.FC<{ glyph: "chevron" | "chart" }> = ({ + glyph, +}) => { + const { isBottomPanelOpen, setBottomPanelOpen } = use(EditorContext); + + return ( + setBottomPanelOpen(!isBottomPanelOpen)} + ariaLabel={isBottomPanelOpen ? "Hide panel" : "Show panel"} + ariaExpanded={isBottomPanelOpen} + isSelected={glyph === "chart" && isBottomPanelOpen} + > + {glyph === "chart" ? ( + + ) : isBottomPanelOpen ? ( + + ) : ( + + )} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx index 02eff467128..ab8a5188990 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx @@ -143,9 +143,28 @@ const maxTimeInputStyle = css({ }); export type PlaybackSettingsMenuProps = { + /** + * What opens the menu: a gear, or the current playback speed with a chevron, + * for a bar that puts the speed in view next to Play. + */ + trigger?: "gear" | "speed"; allowedSpeeds?: readonly PlaybackSpeed[]; }; +const speedTriggerStyle = css({ + display: "flex", + alignItems: "center", + gap: "[2px]", + paddingX: "[4px]", + fontSize: "sm", + fontWeight: "medium", + fontVariantNumeric: "tabular-nums", +}); + +const speedChevronStyle = css({ + opacity: "[0.5]", +}); + const toSpeedRows = (speeds: readonly PlaybackSpeed[]): PlaybackSpeed[][] => { const rows: PlaybackSpeed[][] = []; for (let index = 0; index < speeds.length; index += 4) { @@ -156,6 +175,7 @@ const toSpeedRows = (speeds: readonly PlaybackSpeed[]): PlaybackSpeed[][] => { export const PlaybackSettingsMenu = ({ allowedSpeeds = PLAYBACK_SPEEDS, + trigger = "gear", }: PlaybackSettingsMenuProps) => { const presentation = usePetrinautPresentation(); const triggerRef = useRef(null); @@ -207,7 +227,12 @@ export const PlaybackSettingsMenu = ({ ariaExpanded={open} onClick={() => setOpen((wasOpen) => !wasOpen)} > - {enableExperimentalIconPack ? ( + {trigger === "speed" ? ( + + {playbackSpeed}x + + + ) : enableExperimentalIconPack ? ( ) : ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx index 0bffbea8db5..4cddf468211 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx @@ -108,12 +108,21 @@ export interface SimulationControlsProps { disabled?: boolean; inSubnet?: boolean; allowedPlaybackSpeeds?: readonly PlaybackSpeed[]; + /** `filled` paints Play in the brand colour as the segment's one action. */ + playEmphasis?: "plain" | "filled"; + /** + * Where the playback settings open from: a gear after the scrubber, or the + * current speed next to Play, the way the design file places it. + */ + settingsTrigger?: "gear" | "speed"; } export const SimulationControls: React.FC = ({ disabled = false, inSubnet = false, allowedPlaybackSpeeds, + playEmphasis = "plain", + settingsTrigger = "gear", }) => { const presentation = usePetrinautPresentation(); const experimentalIcons = useExperimentalIconPackEnabled(); @@ -240,6 +249,7 @@ export const SimulationControls: React.FC = ({ onClick={handlePlayPause} disabled={isPlayDisabled} ariaLabel={getPlayPauseAriaLabel()} + emphasis={playEmphasis} > {experimentalIcons ? ( @@ -249,42 +259,54 @@ export const SimulationControls: React.FC = ({ )} + {settingsTrigger === "speed" && ( + + )} {/* 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 && ( - <> -
- {times.elapsed} - / {times.total} -
+ {(hasSimulation || settingsTrigger === "gear") && ( + + {hasSimulation && ( + <> +
+ {times.elapsed} + / {times.total} +
- - setCurrentViewedFrame(Number(event.target.value)) - } - className={sliderStyle({ compact: presentation.compactControls })} - /> + + setCurrentViewedFrame(Number(event.target.value)) + } + className={sliderStyle({ + compact: presentation.compactControls, + })} + /> - - - )} + {settingsTrigger === "gear" && } + + )} - -
+ {settingsTrigger === "gear" && ( + + )} +
+ )} ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/single-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/single-bar.tsx new file mode 100644 index 00000000000..546b34296e9 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/single-bar.tsx @@ -0,0 +1,89 @@ +import { use } from "react"; + +import { LanguageClientContext } from "../../../../../react/lsp/context"; +import { ActiveNetContext } from "../../../../../react/state/active-net-context"; +import { EditorContext } from "../../../../../react/state/editor-context"; +import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; +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 { 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"; + +/** + * Every control in one glass segment, in the order the work happens: what + * the cursor does, what it adds, then the run, with Play filled as the one + * action the bar is for and the panel toggle at the far end. + */ +export const SingleBar: 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 isReadOnly = useIsReadOnly(); + + const showDiagnostics = () => { + setBottomPanelOpen(true); + setActiveBottomPanelTab("diagnostics"); + }; + + const hasEditionGroup = !isActualMode && (!isReadOnly || hasAiAssistant); + + return ( + +
+ + {hasEditionGroup && ( + + {hasAiAssistant && ( + <> + + + + )} + + + )} + + {!isActualMode && ( + <> + + 0} + inSubnet={activeSubnetId !== null} + playEmphasis="filled" + /> + + + )} + +
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/split-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/split-bar.tsx new file mode 100644 index 00000000000..29534664196 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/split-bar.tsx @@ -0,0 +1,104 @@ +import { use } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; + +import { LanguageClientContext } from "../../../../../react/lsp/context"; +import { ActiveNetContext } from "../../../../../react/state/active-net-context"; +import { EditorContext } from "../../../../../react/state/editor-context"; +import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; +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 { GlassSurface } from "./glass-surface"; +import { PanelToggle } from "./panel-toggle"; +import { SimulationControls } from "./simulation-controls"; +import { ToolbarDivider } from "./toolbar-divider"; + +import type { BarContentProps } from "./bar-content"; + +export const toolbarContainerStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", +}); + +/** + * The bar as two glass segments: the edit tools on the left, the playback + * controls on the right, with the canvas showing between them. + */ +export const SplitBar: React.FC = ({ + mode, + editionMode, + onEditionModeChange, + cursorMode, + onCursorModeChange, + hasAiAssistant, +}) => { + const isActualMode = mode === "actual"; + const { isBottomPanelOpen, setBottomPanelOpen, setActiveBottomPanelTab } = + use(EditorContext); + // Only error-severity diagnostics block simulation — warnings and hints + // (e.g. HIR semantic lints) are informational. + const { errorDiagnosticsCount } = use(LanguageClientContext); + const { activeSubnetId } = use(ActiveNetContext); + const isReadOnly = useIsReadOnly(); + + const showDiagnostics = () => { + setBottomPanelOpen(true); + setActiveBottomPanelTab("diagnostics"); + }; + + // 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 ( + <> + +
+ + {hasEditionGroup && ( + + + {hasAiAssistant && ( + <> + + + + )} + + )} +
+
+ + +
+ + {!isActualMode && ( + <> + + + 0} + inSubnet={activeSubnetId !== null} + /> + + )} +
+
+ + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx index d6e0396dbe9..773c4d1f042 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-button.tsx @@ -3,6 +3,9 @@ import { cva } from "@hashintel/ds-helpers/css"; import type { CSSProperties, ReactNode, Ref } from "react"; +/** Which of the bar's two accents a control takes when selected or filled. */ +export type ToolbarTone = "brand" | "simulation"; + const buttonStyle = cva({ base: { display: "flex", @@ -27,6 +30,10 @@ const buttonStyle = cva({ }, }, variants: { + tone: { + brand: {}, + simulation: {}, + }, isSelected: { true: { color: "[#3b82f6]", @@ -40,7 +47,43 @@ const buttonStyle = cva({ opacity: "[0.4]", }, }, + emphasis: { + plain: {}, + filled: { + color: "[white]", + backgroundColor: "[#3b82f6]", + _hover: { + color: "[white]", + backgroundColor: "[#2563eb]", + }, + _active: { + color: "[white]", + }, + }, + }, }, + compoundVariants: [ + { + tone: "simulation", + isSelected: true, + css: { + color: "[#8b5cf6]", + _hover: { + color: "[#7c3aed]", + }, + }, + }, + { + tone: "simulation", + emphasis: "filled", + css: { + backgroundColor: "[#8b5cf6]", + _hover: { + backgroundColor: "[#7c3aed]", + }, + }, + }, + ], }); interface ToolbarButtonProps { @@ -55,6 +98,13 @@ interface ToolbarButtonProps { isSelected?: boolean; /** Whether the button appears disabled (lower opacity, but still clickable) */ disabled?: boolean; + /** The accent a selected or filled button takes. Brand blue by default. */ + tone?: ToolbarTone; + /** + * `filled` paints the button in its tone with a white glyph, for the one + * action a segment is about. Everything else stays `plain`. + */ + emphasis?: "plain" | "filled"; /** Accessibility label */ ariaLabel: string; /** Accessibility expanded state */ @@ -84,6 +134,8 @@ export const ToolbarButton: React.FC = ({ style, isSelected = false, disabled = false, + tone = "brand", + emphasis = "plain", ariaLabel, ariaExpanded, draggable = false, @@ -105,7 +157,12 @@ export const ToolbarButton: React.FC = ({ type="button" onClick={onClick} onKeyDown={handleKeyDown} - className={buttonStyle({ isSelected, isDisabled: disabled })} + className={buttonStyle({ + tone, + isSelected, + isDisabled: disabled, + emphasis, + })} style={style} aria-label={ariaLabel} aria-expanded={ariaExpanded} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx index dd526f2e195..9572a2676f9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx @@ -1,6 +1,8 @@ import { Icon, type IconName } from "@hashintel/ds-components"; import { css, cva } from "@hashintel/ds-helpers/css"; +import type { ToolbarTone } from "./toolbar-button"; + const triggerStyle = cva({ base: { display: "flex", @@ -17,7 +19,7 @@ const triggerStyle = cva({ paddingX: "[6px]", fontSize: "xl", "& > *": { - transition: "[transform 0.2s ease]", + transition: "[transform 0.2s ease, background-color 0.2s ease]", }, _hover: { color: "neutral.s120", @@ -40,9 +42,55 @@ const triggerStyle = cva({ }, }, }, + appearance: { + plain: {}, + filled: { + paddingLeft: "[2px]", + }, + }, }, }); +/** + * The filled appearance keeps the glyph in a tinted square and leaves the + * chevron outside it, so the square reads as the mode and the chevron as the + * menu. + */ +const glyphBoxStyle = cva({ + base: { + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + variants: { + appearance: { + plain: {}, + filled: { + width: "7", + height: "7", + borderRadius: "md", + color: "[white]", + }, + }, + tone: { + brand: {}, + simulation: {}, + }, + }, + compoundVariants: [ + { + appearance: "filled", + tone: "brand", + css: { backgroundColor: "[#3b82f6]" }, + }, + { + appearance: "filled", + tone: "simulation", + css: { backgroundColor: "[#8b5cf6]" }, + }, + ], +}); + const chevronStyle = css({ opacity: "[0.5]", }); @@ -59,13 +107,24 @@ const chevronStyle = css({ export const ToolbarMenuTrigger = ({ icon, isActive, + appearance = "plain", + tone = "brand", ...buttonProps }: { icon: IconName; isActive: boolean; + /** `filled` paints the glyph on a square in the tone; `plain` colours it. */ + appearance?: "plain" | "filled"; + tone?: ToolbarTone; } & React.ComponentPropsWithRef<"button">) => ( - ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx index 8c63bc7059b..ecd274f3ec4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx @@ -333,12 +333,17 @@ describe("user settings", () => { name: "Show welcome guide", }); expect(document.activeElement).toBe(welcome); - fireEvent.keyDown(welcome, { key: "ArrowLeft" }); + fireEvent.keyDown(welcome, { key: "ArrowDown" }); + const toolbar = screen.getByRole("combobox", { + name: "Bottom toolbar (Experimental)", + }); + expect(document.activeElement).toBe(toolbar); + fireEvent.keyDown(toolbar, { key: "ArrowLeft" }); expect(document.activeElement).toBe(general); fireEvent.keyDown(general, { key: "ArrowRight" }); + expect(document.activeElement).toBe(toolbar); + fireEvent.keyDown(toolbar, { key: "ArrowUp" }); expect(document.activeElement).toBe(welcome); - fireEvent.keyDown(welcome, { key: "ArrowUp" }); - expect(document.activeElement).toBe(panels); }); it("keeps one content panel and lets dropdowns own their open keyboard interaction", async () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx index 2bcbcea49e1..19b6dc5ff30 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx @@ -491,6 +491,28 @@ export const UserSettingsDialog = ({ onChange={settings.setShowWalkthroughOnInit} /> + + + {(aria) => ( +