From 46a2f8e80687b1e43d687f97da3a50b722e736d4 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 08:32:56 +0200 Subject: [PATCH 01/13] Add the shared DrawerFrame with a condensing header, a fixed-card body and a footer --- .../shared/drawer-frame.stories.tsx | 157 ++++++++ .../SimulateView/shared/drawer-frame.test.tsx | 121 +++++++ .../SimulateView/shared/drawer-frame.tsx | 231 ++++++++++++ .../drawer-frame/compute-batches-chip.tsx | 189 ++++++++++ .../shared/drawer-frame/frame-band.tsx | 70 ++++ .../shared/drawer-frame/frame-columns.tsx | 66 ++++ .../shared/drawer-frame/frame-header.tsx | 334 ++++++++++++++++++ .../drawer-frame/frame-layout-signature.ts | 33 ++ .../shared/drawer-frame/use-body-scrolled.ts | 17 + .../use-prefers-reduced-motion.ts | 19 + 10 files changed, 1237 insertions(+) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.stories.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/compute-batches-chip.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-layout-signature.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-body-scrolled.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-prefers-reduced-motion.ts diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.stories.tsx new file mode 100644 index 00000000000..72c981791d5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.stories.tsx @@ -0,0 +1,157 @@ +/** + * The frame on its own, with placeholder cards: at rest, the header shows + * the title line, the stats line and the bar; once the body has scrolled it + * condenses to one line of compact chips and grows back under the pointer. + */ +import { useEffect } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; + +import { ChartCard, ChartCardGrid, chartCardHeight } from "./chart-card"; +import { + ComputeBatchesChip, + DrawerFrame, + FrameBand, + FrameColumns, + FrameStat, + FrameStatusPill, +} from "./drawer-frame"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta = { + title: "Simulate / DrawerFrame", + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const sectionStyle = css({ + display: "flex", + height: "[100vh]", + width: "full", +}); + +const placeholderStyle = css({ + height: "full", + borderRadius: "md", + backgroundColor: "neutral.s10", +}); + +const PLOT_HEIGHT = 220; +const CARD_HEIGHT = chartCardHeight({ bodyHeight: PLOT_HEIGHT }); + +const Stats = () => ( + <> + + Running + + + 900 active, 100 complete + + + 0 + + + 45 / 180 + + + 4m 02s + + + 61 / 100 runs + + + +); + +const Cards = ({ count }: { count: number }) => ( + + {Array.from({ length: count }, (_, index) => ( + +
+ + ))} + +); + +const Frame = ({ cards }: { cards: number }) => ( +
+ } + badge={CPU} + progress={61} + footer={Actions} + > + + +
+ + X · Y · Metric} + footerHeight={24} + > +
+ + + } + secondary={} + /> + +
+); + +export const AtRest: Story = { + name: "At rest", + render: () => , +}; + +/** Scrolls the body once mounted, so the story opens on the condensed header. */ +const ScrolledFrame = () => { + useEffect(() => { + document.querySelector("[data-frame-body]")?.scrollTo(0, 160); + }, []); + return ; +}; + +export const Condensed: Story = { + parameters: { + docs: { + description: { + story: + "The body has scrolled: the stats line folded into the title line as compact chips and the header is 36px tall. Move the pointer over it to see it grow back.", + }, + }, + }, + render: () => , +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx new file mode 100644 index 00000000000..84a028168fd --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx @@ -0,0 +1,121 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + DrawerFrame, + FRAME_HEADER_CONDENSED_HEIGHT, + FRAME_HEADER_HEIGHT, + FrameStat, + FrameStatusPill, +} from "./drawer-frame"; + +afterEach(cleanup); + +const renderFrame = (note: { content: string; tone: "error" } | null = null) => + render( + Step 3 of 30} + stats={ + <> + + Running + + + 100 complete + + + } + badge={CPU} + progress={40} + note={note} + footer={} + > +
+ , + ); + +const header = () => + document.querySelector("[data-frame-header]")!; +const body = () => document.querySelector("[data-frame-body]")!; + +const scrollBodyTo = (top: number) => { + Object.defineProperty(body(), "scrollTop", { + configurable: true, + value: top, + }); + fireEvent.scroll(body()); +}; + +describe("DrawerFrame", () => { + it("renders the title, the stats with their labels, the badge, the bar and the footer at rest", () => { + renderFrame(); + + expect(header().style.height).toBe(`${FRAME_HEADER_HEIGHT}px`); + expect(header().dataset.condensed).toBe("false"); + expect( + screen.getByText( + "SIR transmission sweep · Seasonal Flu · 100 runs · dt 1", + ), + ).toBeTruthy(); + expect(screen.getByText("Step 3 of 30")).toBeTruthy(); + expect(screen.getByText("Runs").nextElementSibling?.textContent).toBe( + "100 complete", + ); + expect(screen.getByText("Runs").nextElementSibling).toHaveProperty( + "style.minWidth", + "12ch", + ); + expect(screen.getByText("CPU")).toBeTruthy(); + expect( + document.querySelector("[data-frame-progress] > div")?.style + .width, + ).toBe("40%"); + expect(screen.getByRole("button", { name: "Close" })).toBeTruthy(); + }); + + it("condenses once the body has scrolled, folding the stats into the title line, and grows back under the pointer", () => { + renderFrame(); + + scrollBodyTo(48); + expect(header().style.height).toBe(`${FRAME_HEADER_CONDENSED_HEIGHT}px`); + expect(header().dataset.condensed).toBe("true"); + // The compact copy sits in the title line; the stats line is folded away. + const compact = document.querySelector("[data-frame-compact-stats]")!; + expect(compact.textContent).toContain("Running"); + expect(compact.textContent).toContain("CPU"); + expect( + compact.querySelector("[data-frame-stat]")?.getAttribute("title"), + ).toBe("Runs"); + expect( + document.querySelector("[data-frame-stats]")?.getAttribute("aria-hidden"), + ).toBe("true"); + + fireEvent.pointerEnter(header()); + expect(header().style.height).toBe(`${FRAME_HEADER_HEIGHT}px`); + expect(document.querySelector("[data-frame-compact-stats]")).toBeNull(); + + fireEvent.pointerLeave(header()); + expect(header().style.height).toBe(`${FRAME_HEADER_CONDENSED_HEIGHT}px`); + + scrollBodyTo(0); + expect(header().style.height).toBe(`${FRAME_HEADER_HEIGHT}px`); + }); + + it("keeps the note row mounted at one height whether or not there is a note", () => { + const empty = renderFrame(); + const emptyRow = document.querySelector("[data-frame-note]")!; + expect(emptyRow.style.height).toBe("20px"); + expect(emptyRow.textContent).toBe(""); + empty.unmount(); + + renderFrame({ content: "metric__profit: Unexpected token", tone: "error" }); + const row = document.querySelector("[data-frame-note]")!; + expect(row.style.height).toBe("20px"); + expect(row.dataset.tone).toBe("error"); + expect(row.textContent).toBe("metric__profit: Unexpected token"); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx new file mode 100644 index 00000000000..42cfc1a7c4d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx @@ -0,0 +1,231 @@ +/** + * @layerRoot ui.views.editor.drawer-frame + * @role The chrome every Simulate drawer and the full study view share: a header that condenses once the body scrolls, a body of fixed-height cards, a footer of actions + * + * The header sits outside the body's scroll container, so condensing it + * changes the body's available height and never its scroll offset. The body + * is a size container; `FrameColumns` arranges its content by the body's + * width. A `note` row is always mounted, empty when there is nothing to say, + * so an error or a resume note appearing moves nothing. + */ +import { use, useState, type ReactNode } from "react"; + +import { Drawer } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; +import { FrameHeader } from "./drawer-frame/frame-header"; +import { useBodyScrolled } from "./drawer-frame/use-body-scrolled"; +import { usePrefersReducedMotion } from "./drawer-frame/use-prefers-reduced-motion"; + +export { + FRAME_HEADER_CONDENSED_HEIGHT, + FRAME_HEADER_HEIGHT, + FrameStat, + FrameStatusPill, + type FrameStatusTone, +} from "./drawer-frame/frame-header"; +export { + FRAME_TWO_COLUMN_MIN_WIDTH, + FrameColumns, +} from "./drawer-frame/frame-columns"; +export { FrameBand } from "./drawer-frame/frame-band"; +export { + type FrameLayoutSignature, + frameLayoutSignature, +} from "./drawer-frame/frame-layout-signature"; +export { + type ComputeBatch, + ComputeBatchesChip, +} from "./drawer-frame/compute-batches-chip"; + +/** The reserved row under the header: a note in the muted or the error tone. */ +export type FrameNote = { + content: ReactNode; + tone: "muted" | "error"; +}; + +/** The height of the note row in pixels; reserved whether or not a note shows. */ +export const FRAME_NOTE_HEIGHT = 20; + +// The ds close button is 28px wide with a 20px right gutter. +const DRAWER_CLOSE_GUTTER = 52; + +const sectionFrameStyle = css({ + display: "flex", + flexDirection: "column", + flex: "1", + minWidth: "[0]", + minHeight: "[0]", + height: "full", + backgroundColor: "neutral.s00", +}); + +const sectionHeaderStyle = css({ + borderBottomWidth: "[1px]", + borderBottomStyle: "solid", + borderBottomColor: "neutral.s40", + flexShrink: "0", +}); + +const sectionFooterStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: "2", + flexShrink: "0", + paddingX: "5", + paddingY: "3", + borderTopWidth: "[1px]", + borderTopStyle: "solid", + borderTopColor: "neutral.s40", +}); + +// The ds header's own padding goes; the frame header brings its own and sets +// its height. The close button the ds header draws floats over the frame +// header's right gutter. +const drawerHeaderStyle = css({ + display: "block", + padding: "[0 !important]", + position: "relative", + "& > div:first-child": { minWidth: "[0]" }, + "& > button": { + position: "absolute", + top: "[4px]", + right: "[20px]", + margin: "[0]", + }, +}); + +const drawerBodyStyle = css({ + display: "flex", + flexDirection: "column", + overflow: "hidden", +}); + +const bodyStyle = css({ + display: "flex", + flexDirection: "column", + flex: "[1]", + minHeight: "[0]", + minWidth: "[0]", + overflowY: "auto", + overflowX: "hidden", + scrollbarWidth: "[thin]", + scrollbarGutter: "stable", + paddingX: "5", + paddingBottom: "4", + containerType: "inline-size", + containerName: "drawer-frame-body", +}); + +const noteRowStyle = css({ + display: "flex", + alignItems: "center", + flexShrink: "0", + minWidth: "[0]", + marginBottom: "1", + fontSize: "xs", + lineHeight: "[16px]", + color: "neutral.s80", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + "&[data-tone=error]": { color: "red.s100" }, +}); + +export type DrawerFrameProps = { + /** One line: `SIR transmission sweep · Seasonal Flu · 100 runs · dt 1`. */ + title: string; + /** Before the title: a Back button in the full view. */ + leading?: ReactNode; + /** The title line's right side while at rest: the study's progress line. */ + headline?: ReactNode; + /** The stats line: `FrameStat`s, the status pill, the computing chip. */ + stats: ReactNode; + /** Pinned to the stats line's right: the compute badge. */ + badge?: ReactNode; + /** The bar along the header's bottom edge, 0 to 100. */ + progress: number; + /** The reserved row at the top of the body; null keeps the row empty. */ + note?: FrameNote | null; + /** The footer's actions. */ + footer: ReactNode; + /** Given, the frame renders inside a ds `Drawer`; otherwise it fills its section. */ + drawer?: { onClose: () => void; swapKey: string }; + children: ReactNode; +}; + +export const DrawerFrame = ({ + title, + leading, + headline, + stats, + badge, + progress, + note = null, + footer, + drawer, + children, +}: DrawerFrameProps) => { + const { showAnimations } = use(UserSettingsContext); + const reducedMotion = usePrefersReducedMotion(); + const { scrolled, onScroll } = useBodyScrolled(); + const [engaged, setEngaged] = useState(false); + const condensed = scrolled && !engaged; + + const header = ( + + ); + + const body = ( +
+
+ {note?.content} +
+ {children} +
+ ); + + if (drawer === undefined) { + return ( +
+
{header}
+ {body} +
{footer}
+
+ ); + } + + return ( + + {header} + + {body} + + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/compute-batches-chip.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/compute-batches-chip.tsx new file mode 100644 index 00000000000..75d7b4c7e91 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/compute-batches-chip.tsx @@ -0,0 +1,189 @@ +/** + * The "N computing" chip on the frame's stats line and the list it opens: one + * row per batch computing right now with its own progress. A sweep runs the + * selection's ladder, surface chunks and cell refinements in parallel, a + * study its steps and the navigated point's refinement, so the list shows + * that parallelism. The list is a popover, so opening it moves nothing; the + * chip keeps its slot while nothing computes, hidden. + */ +import { useRef, useState } from "react"; + +import { Icon, Popover } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +/** One computing batch, for the list. */ +export type ComputeBatch = { + id: string; + label: string; + /** Priority work draws in blue; background work in grey. */ + tone: "priority" | "background"; + runCount: number; + completedRuns: number; +}; + +const slotStyle = css({ + display: "inline-flex", + flexShrink: "0", + "&[data-idle=true]": { visibility: "hidden" }, +}); + +const chipStyle = css({ + display: "inline-flex", + alignItems: "center", + gap: "1", + paddingX: "1.5", + height: "[18px]", + minWidth: "[11ch]", + borderRadius: "sm", + borderWidth: "[0]", + fontSize: "[11px]", + fontWeight: "medium", + fontVariantNumeric: "tabular-nums", + color: "neutral.s100", + backgroundColor: "neutral.s10", + cursor: "pointer", + whiteSpace: "nowrap", + _hover: { backgroundColor: "neutral.s20" }, +}); + +const computingDotStyle = css({ + width: "[6px]", + height: "[6px]", + borderRadius: "full", + backgroundColor: "blue.s100", +}); + +const listStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[3px]", + minWidth: "[320px]", + padding: "2", +}); + +const rowStyle = css({ + display: "grid", + gridTemplateColumns: "[minmax(76px, auto) minmax(0, 1fr) 88px]", + alignItems: "center", + gap: "2", + minHeight: "[16px]", +}); + +const labelStyle = css({ + display: "inline-flex", + alignItems: "center", + gap: "1", + fontSize: "[11px]", + color: "neutral.s100", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + maxWidth: "[220px]", +}); + +const dotStyle = css({ + width: "[6px]", + height: "[6px]", + borderRadius: "full", + flexShrink: "0", + backgroundColor: "neutral.s60", + "&[data-tone=priority]": { backgroundColor: "blue.s100" }, +}); + +const trackStyle = css({ + height: "[4px]", + borderRadius: "full", + backgroundColor: "neutral.s30", + overflow: "hidden", +}); + +const fillStyle = css({ + height: "full", + borderRadius: "full", + backgroundColor: "neutral.s90", + transition: "[width 160ms ease-out]", + "&[data-tone=priority]": { backgroundColor: "blue.s100" }, +}); + +const countStyle = css({ + fontSize: "[11px]", + color: "neutral.s80", + fontVariantNumeric: "tabular-nums", + textAlign: "right", + whiteSpace: "nowrap", +}); + +const BatchRow = ({ batch }: { batch: ComputeBatch }) => { + const percent = + batch.runCount > 0 + ? Math.min(100, (batch.completedRuns / batch.runCount) * 100) + : 0; + + return ( +
+ + + {batch.label} + +
+
+
+ + {batch.completedRuns.toLocaleString("en-US")} /{" "} + {batch.runCount.toLocaleString("en-US")} runs + +
+ ); +}; + +export const ComputeBatchesChip = ({ + batches, +}: { + batches: readonly ComputeBatch[]; +}) => { + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + const idle = batches.length === 0; + const showing = open && !idle; + + return ( + + + {showing ? ( + setOpen(false)} + > + +
+ {batches.map((batch) => ( + + ))} +
+
+
+ ) : null} +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx new file mode 100644 index 00000000000..4e984a59e0c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx @@ -0,0 +1,70 @@ +/** + * A titled band in the frame body: a title row with an optional help tooltip + * and a trailing readout, then the band's controls. Bands hold parameter + * controls; they have no card border and no collapse. + */ +import { HelpTooltip } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import type { ReactNode } from "react"; + +const bandStyle = css({ + display: "flex", + flexDirection: "column", + gap: "2", + minWidth: "[0]", +}); + +const headerRowStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "2", + minHeight: "[24px]", +}); + +const headerLeftStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", + flexShrink: "0", +}); + +const titleStyle = css({ + fontWeight: "semibold", + fontSize: "sm", + lineHeight: "[20px]", + color: "neutral.fg.body", +}); + +const trailingStyle = css({ + display: "flex", + alignItems: "center", + minWidth: "[0]", +}); + +export const FrameBand = ({ + title, + help, + trailing, + children, +}: { + title: string; + help?: string; + /** The title row's right side: a state line, a switch. */ + trailing?: ReactNode; + children: ReactNode; +}) => ( +
+
+
+ {title} + {help === undefined ? null : } +
+ {trailing === undefined ? null : ( +
{trailing}
+ )} +
+ {children} +
+); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx new file mode 100644 index 00000000000..170afeb1382 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx @@ -0,0 +1,66 @@ +/** + * The frame body's arrangement. Wide enough, two columns: the parameters + * and the surface on the left, the metric cards on the right. Narrower, one + * column with the metric cards first, then the parameters, then the surface, + * so the thing being watched is at the top either way. Whatever follows + * (a steps table) spans the width beneath. + */ +import { css } from "@hashintel/ds-helpers/css"; + +import type { ReactNode } from "react"; + +/** The body width, in pixels, from which the two-column arrangement applies. */ +export const FRAME_TWO_COLUMN_MIN_WIDTH = 1100; + +// The body is the `drawer-frame-body` size container (declared in +// drawer-frame.tsx); Panda extracts the query statically, so the threshold is +// written out here and mirrored by the constant above. +const columnsStyle = css({ + display: "grid", + gap: "4", + alignItems: "start", + gridTemplateColumns: "minmax(0, 1fr)", + gridTemplateAreas: '"secondary" "primary" "after"', + "@container drawer-frame-body (min-width: 1100px)": { + gridTemplateColumns: "minmax(0, 3fr) minmax(0, 5fr)", + gridTemplateAreas: '"primary secondary" "after after"', + }, +}); + +const areaStyle = css({ + display: "flex", + flexDirection: "column", + gap: "4", + minWidth: "[0]", +}); + +export const FrameColumns = ({ + primary, + secondary, + after, +}: { + /** The parameters band, then the surface card. */ + primary?: ReactNode; + /** The metric cards grid. */ + secondary?: ReactNode; + /** Full width beneath both columns: a steps table. */ + after?: ReactNode; +}) => ( +
+ {primary === undefined ? null : ( +
+ {primary} +
+ )} + {secondary === undefined ? null : ( +
+ {secondary} +
+ )} + {after === undefined ? null : ( +
+ {after} +
+ )} +
+); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx new file mode 100644 index 00000000000..bae23722f56 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx @@ -0,0 +1,334 @@ +/** + * The frame's header: one title line, one line of stats with the compute + * badge at its right, and the progress bar along the bottom edge. It sits + * outside the body's scroll container. Once the body has scrolled it + * condenses: the stats line folds into the title line as compact chips, and + * the header grows back while the pointer or focus is on it. + */ +import { createContext, use, type ReactNode } from "react"; + +import { css, cx } from "@hashintel/ds-helpers/css"; + +/** The header's height in pixels at rest: the title line, the stats line and the bar. */ +export const FRAME_HEADER_HEIGHT = 56; +/** The header's height in pixels once the body has scrolled: one line and the bar. */ +export const FRAME_HEADER_CONDENSED_HEIGHT = 36; + +/** How the stats render: with their labels on their own line, or as compact chips beside the title. */ +export type FrameStatsDensity = "full" | "compact"; + +const FrameStatsDensityContext = createContext("full"); + +const rootStyle = css({ + position: "relative", + display: "flex", + flexDirection: "column", + flexShrink: "0", + boxSizing: "border-box", + minWidth: "[0]", + overflow: "hidden", + paddingTop: "1.5", + paddingLeft: "5", + paddingRight: "5", + backgroundColor: "neutral.s00", + "&[data-animate=true]": { + transition: "[height 160ms ease-out]", + }, +}); + +const titleRowStyle = css({ + display: "flex", + alignItems: "center", + gap: "3", + height: "[24px]", + minWidth: "[0]", + flexShrink: "0", +}); + +const titleStyle = css({ + fontSize: "sm", + fontWeight: "semibold", + lineHeight: "[20px]", + color: "neutral.s120", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + flexShrink: "1", + minWidth: "[0]", +}); + +// The headline reads to the right of the title while the header is at rest; +// condensed, the compact chips take that room and the headline steps aside. +const headlineStyle = css({ + display: "flex", + alignItems: "center", + marginLeft: "auto", + minWidth: "[0]", + overflow: "hidden", + "[data-condensed=true] &": { display: "none" }, +}); + +const compactRowStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + flex: "[1]", + minWidth: "[0]", + overflow: "hidden", + whiteSpace: "nowrap", + "[data-animate=true] &": { + animationName: "[dialogBackdropIn]", + animationDuration: "[160ms]", + animationTimingFunction: "ease-out", + }, +}); + +const statsRowStyle = css({ + display: "flex", + alignItems: "center", + gap: "3", + height: "[20px]", + minWidth: "[0]", + overflow: "hidden", + whiteSpace: "nowrap", + opacity: "[1]", + "[data-animate=true] &": { + transition: + "[height 160ms ease-out, opacity 120ms ease-out, visibility 0s]", + }, + "[data-condensed=true] &": { + height: "[0]", + opacity: "[0]", + visibility: "hidden", + transitionDelay: "[0s, 0s, 160ms]", + }, +}); + +const badgeSlotStyle = css({ + display: "inline-flex", + alignItems: "center", + marginLeft: "auto", + flexShrink: "0", +}); + +const progressTrackStyle = css({ + position: "absolute", + left: "[0]", + right: "[0]", + bottom: "[0]", + height: "[3px]", + backgroundColor: "neutral.s30", +}); + +const progressFillStyle = css({ + height: "full", + backgroundColor: "neutral.s120", + "[data-animate=true] &": { + transition: "[width 160ms ease-out]", + }, +}); + +const statStyle = css({ + display: "inline-flex", + alignItems: "baseline", + gap: "1.5", + minWidth: "[0]", + flexShrink: "0", + fontSize: "xs", + lineHeight: "[16px]", + color: "neutral.s120", +}); + +const statLabelStyle = css({ + fontSize: "[10px]", + fontWeight: "medium", + letterSpacing: "[0.04em]", + textTransform: "uppercase", + color: "neutral.s70", + "&[data-density=compact]": { display: "none" }, +}); + +const statValueStyle = css({ + display: "inline-flex", + alignItems: "center", + fontWeight: "medium", + fontVariantNumeric: "tabular-nums", + whiteSpace: "nowrap", +}); + +const pillStyle = css({ + display: "inline-flex", + alignItems: "center", + gap: "1.5", + flexShrink: "0", + paddingX: "2", + height: "[18px]", + borderRadius: "full", + fontSize: "xs", + fontWeight: "medium", + color: "neutral.s110", + backgroundColor: "neutral.s10", + fontVariantNumeric: "tabular-nums", + whiteSpace: "nowrap", + "&[data-tone=active]": { color: "blue.s100", backgroundColor: "blue.s10" }, + "&[data-tone=done]": { color: "green.s100", backgroundColor: "green.s10" }, + "&[data-tone=error]": { color: "red.s100", backgroundColor: "red.s10" }, +}); + +const pillDotStyle = css({ + width: "[6px]", + height: "[6px]", + borderRadius: "full", + flexShrink: "0", + backgroundColor: "neutral.s60", + "[data-tone=active] > &": { backgroundColor: "blue.s100" }, + "[data-tone=done] > &": { backgroundColor: "green.s90" }, + "[data-tone=error] > &": { backgroundColor: "red.s100" }, +}); + +export type FrameHeaderProps = { + /** One line, ellipsized when narrow: `SIR transmission sweep · Seasonal Flu · 100 runs · dt 1`. */ + title: string; + /** Before the title: a Back button in the full view. */ + leading?: ReactNode; + /** The title line's right side while at rest: a live readout such as the study's progress line. */ + headline?: ReactNode; + /** The second line: `FrameStat`s and chips. Rendered again as compact chips while condensed. */ + stats: ReactNode; + /** Pinned to the stats line's right: the compute badge. */ + badge?: ReactNode; + /** The bar along the bottom edge, 0 to 100. Always drawn. */ + progress: number; + condensed: boolean; + /** Whether the height and opacity changes animate: off under reduced motion or the animations setting. */ + animate: boolean; + /** Room kept clear on the right for a close button the surrounding chrome draws. */ + closeGutter?: number; + onEngagedChange: (engaged: boolean) => void; +}; + +export const FrameHeader = ({ + title, + leading, + headline, + stats, + badge, + progress, + condensed, + animate, + closeGutter = 0, + onEngagedChange, +}: FrameHeaderProps) => ( +
0 ? closeGutter : undefined, + }} + onPointerEnter={() => onEngagedChange(true)} + onPointerLeave={() => onEngagedChange(false)} + onFocus={() => onEngagedChange(true)} + onBlur={() => onEngagedChange(false)} + > +
+ {leading === undefined ? null : leading} + + {title} + + {condensed ? ( + +
+ {stats} + {badge === undefined ? null : ( + {badge} + )} +
+
+ ) : null} + {headline === undefined ? null : ( +
{headline}
+ )} +
+
+ {stats} + {badge === undefined ? null : ( + {badge} + )} +
+
+
+
+
+); + +/** + * One stat on the stats line: a small uppercase label and its value. The + * value reserves `minChars` so a changing number never moves its neighbours. + * Compact (while the header is condensed) the label becomes a tooltip. + */ +export const FrameStat = ({ + label, + minChars, + children, + className, +}: { + label: string; + minChars?: number; + children: ReactNode; + className?: string; +}) => { + const density = use(FrameStatsDensityContext); + return ( + + + {label} + + + {children} + + + ); +}; + +export type FrameStatusTone = "active" | "done" | "error" | "neutral"; + +/** The status pill: a dot in the status's colour and the status word. */ +export const FrameStatusPill = ({ + tone, + minChars, + children, +}: { + tone: FrameStatusTone; + /** Reserve this many characters so the pill keeps its width across statuses. */ + minChars?: number; + children: ReactNode; +}) => ( + + + {children} + +); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-layout-signature.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-layout-signature.ts new file mode 100644 index 00000000000..d1f39280092 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-layout-signature.ts @@ -0,0 +1,33 @@ +/** + * The boxes the frame promises to hold still, read from the DOM: the header + * and note row heights, every card's title and body height, every grid's row + * height and the steps table's height. Two renders of one drawer in two + * states must give the same signature; tests and probes compare them. + */ +export type FrameLayoutSignature = { + header: string; + note: string; + cards: [title: string, bodyHeight: string][]; + gridRows: string[]; + steps: string | null; +}; + +const styleHeight = (element: Element | null): string => + element instanceof HTMLElement ? element.style.height : ""; + +export const frameLayoutSignature = ( + root: ParentNode, +): FrameLayoutSignature => ({ + header: styleHeight(root.querySelector("[data-frame-header]")), + note: styleHeight(root.querySelector("[data-frame-note]")), + cards: [...root.querySelectorAll("[data-chart-card]")].map((card) => [ + card.querySelector("span")?.textContent ?? "", + styleHeight(card.querySelector("[data-chart-card-body]")), + ]), + gridRows: [...root.querySelectorAll("[data-chart-card-grid]")].map((grid) => + grid instanceof HTMLElement ? grid.style.gridAutoRows : "", + ), + steps: root.querySelector("[data-steps-table]") + ? styleHeight(root.querySelector("[data-steps-table]")) + : null, +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-body-scrolled.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-body-scrolled.ts new file mode 100644 index 00000000000..a2d7ff6eb8c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-body-scrolled.ts @@ -0,0 +1,17 @@ +import { useState, type UIEvent } from "react"; + +/** + * Whether a scroll container has moved off its top. Read from the element's + * scroll events alone: the frame's header condenses on `scrollTop > 0` and + * nothing else, so no observer is needed. + */ +export const useBodyScrolled = (): { + scrolled: boolean; + onScroll: (event: UIEvent) => void; +} => { + const [scrolled, setScrolled] = useState(false); + return { + scrolled, + onScroll: (event) => setScrolled(event.currentTarget.scrollTop > 0), + }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-prefers-reduced-motion.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-prefers-reduced-motion.ts new file mode 100644 index 00000000000..343c40e8461 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-prefers-reduced-motion.ts @@ -0,0 +1,19 @@ +import { useSyncExternalStore } from "react"; + +const QUERY = "(prefers-reduced-motion: reduce)"; + +const subscribe = (onChange: () => void): (() => void) => { + if (typeof window.matchMedia !== "function") { + return () => {}; + } + const media = window.matchMedia(QUERY); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); +}; + +const getSnapshot = (): boolean => + typeof window.matchMedia === "function" && window.matchMedia(QUERY).matches; + +/** Whether the viewer asked the system for reduced motion; follows the setting live. */ +export const usePrefersReducedMotion = (): boolean => + useSyncExternalStore(subscribe, getSnapshot, () => false); From 45423ab54603b1ac774c28ac8cf91a5d21be95bc Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 08:32:56 +0200 Subject: [PATCH 02/13] Put the experiment drawer in the DrawerFrame and drop its Summary section --- .../view-experiment-drawer.stories.tsx | 35 ++- .../view-experiment-drawer.test.tsx | 218 ++++++++++++++++ .../experiments/view-experiment-drawer.tsx | 236 ++++++++---------- .../experiment-stats.tsx | 155 ++++++++++++ .../experiment-summary.tsx | 190 -------------- .../SimulateView/shared/metric-tiles.tsx | 4 +- 6 files changed, 509 insertions(+), 329 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx delete mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-summary.tsx diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx index 3113b49bebd..74c997a12cd 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx @@ -5,9 +5,13 @@ */ import { use } from "react"; -import { ExperimentsContext } from "../../../../../../react/experiments/context"; +import { + type ExperimentRecord, + ExperimentsContext, +} from "../../../../../../react/experiments/context"; import { FakeExperimentsProvider, + makeExperiment, makeParameterSweepExperiment, } from "./experiments-story-fixtures"; import { ViewExperimentDrawer } from "./view-experiment-drawer"; @@ -40,3 +44,32 @@ export const Sweep: Story = { ), }; + +/** A plain experiment: no parameters, no surface, the metric cards alone under the header. */ +const plainExperiment = ( + status: ExperimentRecord["status"], +): ExperimentRecord => + makeExperiment(1, { + name: "SIR Monte Carlo", + status, + metricSpecs: makeParameterSweepExperiment().metricSpecs, + metricFrames: makeParameterSweepExperiment().metricFrames, + }); + +export const Running: Story = { + name: "Plain experiment, running", + render: () => ( + + + + ), +}; + +export const Complete: Story = { + name: "Plain experiment, complete", + render: () => ( + + + + ), +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx new file mode 100644 index 00000000000..37728f8afc3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx @@ -0,0 +1,218 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { frameLayoutSignature } from "../shared/drawer-frame"; +import { + makeExperiment, + makeParameterSweepExperiment, +} from "./experiments-story-fixtures"; +import { ViewExperimentDrawer } from "./view-experiment-drawer"; + +import type { ExperimentRecord } from "../../../../../../react/experiments/context"; +import type { ReactNode } from "react"; + +vi.mock("@hashintel/ds-components", async (importOriginal) => { + const actual = + await importOriginal(); + const Drawer = Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { + Header: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + Body: ({ children }: { children: ReactNode }) =>
{children}
, + Footer: ({ actions }: { actions: ReactNode }) => ( +
{actions}
+ ), + }, + ); + const Tooltip = ({ children }: { children: ReactNode }) => <>{children}; + type FlatItem = { + id?: string; + text?: ReactNode; + onClick?: (id: string) => void; + }; + type MenuEntry = FlatItem & { items?: MenuEntry[] }; + const flatten = (entries: MenuEntry[]): FlatItem[] => + entries.flatMap((entry) => (entry.items ? flatten(entry.items) : [entry])); + // The Ark menu positions itself with a ResizeObserver jsdom lacks; this one + // lists the items as buttons. + const Menu = ({ + items, + trigger, + }: { + items: MenuEntry[]; + trigger: ReactNode; + }) => ( + <> + {trigger} +
+ {flatten(items).map((item, index) => ( + + ))} +
+ + ); + return { ...actual, Drawer, Menu, Tooltip }; +}); + +// The contour surface draws on a canvas jsdom cannot host; the card around +// it is real so its box counts. +vi.mock("./sweep-surface", async () => { + const chartCard = await vi.importActual< + typeof import("../shared/chart-card") + >("../shared/chart-card"); + return { + SweepSurface: () => ( + X · Y
} + footerHeight={24} + > +
+ + ), + }; +}); + +// uPlot cannot mount in jsdom; the menu and the subtitle are real. +vi.mock("./experiment-metric-timeline", async () => { + const [menu, describeView, viewState] = await Promise.all([ + vi.importActual< + typeof import("./experiment-metric-timeline/metric-view-menu") + >("./experiment-metric-timeline/metric-view-menu"), + vi.importActual< + typeof import("./experiment-metric-timeline/describe-metric-view") + >("./experiment-metric-timeline/describe-metric-view"), + vi.importActual( + "./experiment-metric-timeline/view-state", + ), + ]); + return { + MetricViewMenu: menu.MetricViewMenu, + describeMetricView: describeView.describeMetricView, + DEFAULT_METRIC_VIEW_SETTINGS: viewState.DEFAULT_METRIC_VIEW_SETTINGS, + ExperimentMetricTimeline: ({ plotHeight }: { plotHeight: number }) => ( +
+ ), + }; +}); + +// The navigator's Ark sliders measure themselves with a ResizeObserver jsdom +// lacks; nothing here depends on a measurement. +class ObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +globalThis.ResizeObserver = ObserverStub as unknown as typeof ResizeObserver; + +afterEach(cleanup); + +const renderDrawer = (experiment: ExperimentRecord) => + render( + {}} experiment={experiment} />, + ); + +const sweep = makeParameterSweepExperiment(); + +/** The sweep in each state a drawer can show it. */ +const sweepIn = (status: ExperimentRecord["status"]): ExperimentRecord => ({ + ...sweep, + status, + finishedAt: status === "running" ? null : Date.now(), + error: status === "error" ? "worker crashed" : null, + progress: + status === "running" + ? sweep.progress + : { ...sweep.progress!, activeRuns: 0, allFinished: true }, +}); + +describe("ViewExperimentDrawer in the frame", () => { + it("titles the drawer in one line and puts the stats and the badge in the header", () => { + renderDrawer(sweep); + + expect( + screen.getByText( + /^SIR transmission sweep · Seasonal Flu · 100 runs · dt 1$/u, + ), + ).toBeTruthy(); + expect(screen.getByText("Running")).toBeTruthy(); + expect(screen.getByText("Runs").nextElementSibling?.textContent).toMatch( + /active, .* complete$/u, + ); + expect(screen.getByText("Selection")).toBeTruthy(); + expect(screen.getByText("CPU")).toBeTruthy(); + expect(screen.queryByText("Summary")).toBeNull(); + expect(document.querySelector("[data-frame-progress]")).toBeTruthy(); + expect(screen.getByText("Parameters")).toBeTruthy(); + expect(screen.getByTestId("sweep-surface")).toBeTruthy(); + }); + + it("keeps the header, the note row and every card at one height across running, complete, cancelled and error", () => { + const signatures = ( + ["running", "complete", "cancelled", "error"] as const + ).map((status) => { + const view = renderDrawer(sweepIn(status)); + const signature = frameLayoutSignature(view.container); + view.unmount(); + return signature; + }); + + expect(signatures[0]!.header).toBe("56px"); + expect(signatures[0]!.note).toBe("20px"); + expect(signatures[0]!.cards.length).toBeGreaterThan(1); + for (const signature of signatures.slice(1)) { + expect(signature).toEqual(signatures[0]); + } + }); + + it("shows the error in the reserved note row without adding a row", () => { + renderDrawer(sweepIn("error")); + + const note = document.querySelector("[data-frame-note]")!; + expect(note.textContent).toBe("worker crashed"); + expect(note.dataset.tone).toBe("error"); + expect(note.style.height).toBe("20px"); + }); + + it("leaves a metric card's height alone when its aggregation changes", () => { + const view = renderDrawer(sweep); + const before = frameLayoutSignature(view.container); + + fireEvent.click( + screen.getAllByRole("button", { name: "Chart options" })[0]!, + ); + fireEvent.click(screen.getAllByRole("menuitem", { name: "Median" })[0]!); + + expect(screen.getAllByText(/^median over runs/u).length).toBeGreaterThan(0); + expect(frameLayoutSignature(view.container)).toEqual(before); + }); + + it("shows a plain experiment's metric cards alone, with no parameters and no surface", () => { + renderDrawer( + makeExperiment(1, { + metricSpecs: sweep.metricSpecs, + metricFrames: sweep.metricFrames, + }), + ); + + expect(screen.queryByText("Parameters")).toBeNull(); + expect(screen.queryByTestId("sweep-surface")).toBeNull(); + expect(screen.queryByText("Selection")).toBeNull(); + expect(screen.getAllByTestId("metric-timeline").length).toBe( + sweep.metricSpecs.length, + ); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx index 0f45662e661..ab22d98549d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx @@ -1,39 +1,25 @@ +/** + * One experiment in a drawer over the Experiments list, in the shared frame: + * the one-line title and the stats in the header, then the metric cards, the + * parameter band and the surface arranged by the drawer's width, with the + * actions in the footer. + */ import { use } from "react"; -import { Button, Drawer, Icon } from "@hashintel/ds-components"; +import { Button, Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { ExperimentsActionsContext, type ExperimentRecord, } from "../../../../../../react/experiments/context"; -import { Section, SectionList } from "../../../../../components/section"; +import { experimentProgressPercent } from "../../../shared/experiment-progress"; import { ComputeBackendBadge } from "../shared/compute-backend-badge"; +import { DrawerFrame, FrameBand, FrameColumns } from "../shared/drawer-frame"; import { SweepNavigator } from "./sweep-navigator"; import { SweepSurface } from "./sweep-surface"; import { ExperimentMetrics } from "./view-experiment-drawer/experiment-metrics"; -import { ExperimentSummary } from "./view-experiment-drawer/experiment-summary"; - -// The drawer body is a column: the summary, the navigator, and the surface -// hold still at the top, and the metric charts alone scroll below them. -const drawerBodyStyle = css({ - paddingTop: "[0]", - display: "flex", - flexDirection: "column", - // The overlay body scrolls by default; here only the metric list may. - overflow: "hidden", -}); - -const fixedSectionStyle = css({ - flexShrink: "0", -}); - -const metricsScrollStyle = css({ - flex: "[1]", - minHeight: "[160px]", - overflowY: "auto", - scrollbarWidth: "[thin]", -}); +import { ExperimentStats } from "./view-experiment-drawer/experiment-stats"; // Keeps its footprint when a run can no longer be cancelled, so Remove and // Close do not slide when a run finishes. @@ -42,6 +28,18 @@ const cancelSlotStyle = css({ "&[data-hidden=true]": { visibility: "hidden" }, }); +const PARAMETERS_HELP = + "Only the selected combination computes. Move a control and compute follows it; results for visited combinations are kept."; + +/** The frame's one-line title: `SIR transmission sweep · Seasonal Flu · 100 runs · dt 1`. */ +export const describeExperiment = ( + experiment: Pick< + ExperimentRecord, + "name" | "scenarioName" | "runCount" | "dt" + >, +): string => + `${experiment.name} · ${experiment.scenarioName ?? "Default scenario"} · ${experiment.runCount.toLocaleString("en-US")} runs · dt ${experiment.dt}`; + export const ViewExperimentDrawer = ({ open, onClose, @@ -61,128 +59,94 @@ export const ViewExperimentDrawer = ({ const canCancel = experiment.status === "initializing" || experiment.status === "running"; + const { sweep } = experiment; return ( - - - - -
( - - )} + } + badge={} + progress={experimentProgressPercent(experiment)} + note={ + experiment.error === null + ? null + : { content: experiment.error, tone: "error" } + } + footer={ + <> + + - -
- {experiment.sweep ? ( -
- experiment.sweep ? ( - - setSweepSelection(experiment.id, selection) - } - /> - ) : null - } - > - {null} -
- ) : null} - {experiment.sweep && experiment.parameterAxes.length >= 2 ? ( -
- {/* Keyed so the axis and metric pickers never carry one - experiment's identifiers into another when the drawer swaps - records in place. */} - -
- ) : null} - {experiment.metricSpecs.length > 0 ? ( -
-
- {/* Keyed so faded previous pictures and size choices never - leak from one experiment into another when the drawer - swaps records in place. */} - -
-
- ) : null} -
-
- - - - - - + + + + } + > + + + + setSweepSelection(experiment.id, selection) + } + /> + + {experiment.parameterAxes.length >= 2 ? ( + // Keyed so the axis and metric pickers never carry one + // experiment's identifiers into another when the drawer swaps + // records in place. + + ) : null} + + ) : undefined + } + secondary={ + experiment.metricSpecs.length > 0 ? ( + // Keyed so faded previous pictures and view choices never leak + // from one experiment into another when the drawer swaps records + // in place. + + ) : undefined } /> -
+ ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx new file mode 100644 index 00000000000..fcd1a7188e4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx @@ -0,0 +1,155 @@ +/** + * The experiment frame's stats line: the status pill, the runs, the errors, + * the simulated time, the wall-clock time, the selection's sampling for a + * sweep, and the chip listing what computes. Every value reserves its width. + */ +import { useEffect, useState } from "react"; + +import { + type ExperimentRecord, + getExperimentElapsedMs, + isExperimentActive, + type SweepBatchStatus, +} from "../../../../../../../react/experiments/context"; +import { + type ComputeBatch, + ComputeBatchesChip, + FrameStat, + FrameStatusPill, + type FrameStatusTone, +} from "../../shared/drawer-frame"; +import { formatFixed } from "../../shared/format-value"; +import { formatDurationMs } from "../format-duration"; + +const STATUS_DISPLAY: Record< + ExperimentRecord["status"], + { label: string; tone: FrameStatusTone } +> = { + initializing: { label: "Initializing", tone: "active" }, + running: { label: "Running", tone: "active" }, + idle: { label: "Idle", tone: "neutral" }, + complete: { label: "Complete", tone: "done" }, + error: { label: "Error", tone: "error" }, + cancelled: { label: "Cancelled", tone: "neutral" }, +}; + +/** Longest status label plus the dot, so the pill keeps its width as the status changes. */ +const STATUS_CHARS = + Math.max( + ...Object.values(STATUS_DISPLAY).map((entry) => entry.label.length), + ) + 2; + +/** + * A clock that advances while `active`, so an elapsed-time readout keeps + * moving even when a stalled run stops publishing progress. + */ +const useNow = (active: boolean): number => { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!active) { + return; + } + const update = () => setNow(Date.now()); + update(); + const intervalId = window.setInterval(update, 250); + return () => window.clearInterval(intervalId); + }, [active]); + + return now; +}; + +/** + * Simulated time to show when no batch is publishing progress: an idle sweep + * or a complete run has taken every run to the end. + */ +const settledTime = (experiment: ExperimentRecord): number => + experiment.status === "idle" || experiment.status === "complete" + ? experiment.maxTime + : 0; + +/** + * "selection" is the navigator's own ladder, the priority work; "surface" + * is a contour chunk; "refine" is a single cell brought up to depth. + */ +const BATCH_KIND_META: Record< + SweepBatchStatus["kind"], + Pick +> = { + selection: { label: "Selection", tone: "priority" }, + surface: { label: "Surface", tone: "background" }, + refine: { label: "Refine", tone: "background" }, +}; + +/** The sweep's batches as the computing list shows them. */ +export const experimentComputeBatches = ( + sweepBatches: readonly SweepBatchStatus[], +): ComputeBatch[] => + sweepBatches.map((batch) => ({ + id: String(batch.id), + ...BATCH_KIND_META[batch.kind], + runCount: batch.runCount, + completedRuns: batch.completedRuns, + })); + +const formatCount = (value: number): string => value.toLocaleString("en-US"); + +export const ExperimentStats = ({ + experiment, +}: { + experiment: ExperimentRecord; +}) => { + const progress = experiment.progress; + const now = useNow(isExperimentActive(experiment)); + const elapsedMs = getExperimentElapsedMs(experiment, now); + const status = STATUS_DISPLAY[experiment.status]; + const runCount = formatCount(experiment.runCount); + + return ( + <> + + {status.label} + + + {progress + ? `${formatCount(progress.activeRuns)} active, ${formatCount(progress.completedRuns)} complete` + : runCount} + + + {formatCount(progress?.erroredRuns ?? 0)} + + + {formatFixed(progress?.time ?? settledTime(experiment))} /{" "} + {formatFixed(experiment.maxTime)} + + {/* Wall-clock, as distinct from the simulated time; dashed out when + stepping never began. */} + + {elapsedMs === null ? "—" : formatDurationMs(elapsedMs)} + + {experiment.sweep ? ( + + {formatCount(experiment.sweep.runsSampled)} / {runCount} runs + + ) : null} + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-summary.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-summary.tsx deleted file mode 100644 index c69ff9d05ef..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-summary.tsx +++ /dev/null @@ -1,190 +0,0 @@ -/** - * The drawer's summary: a strip of stats with a status dot, the compute - * activity underneath, and the error text when the experiment failed. - */ -import { useEffect, useState } from "react"; - -import { css } from "@hashintel/ds-helpers/css"; - -import { - type ExperimentRecord, - getExperimentElapsedMs, - isExperimentActive, - type SweepBatchStatus, -} from "../../../../../../../react/experiments/context"; -import { experimentProgressPercent } from "../../../../shared/experiment-progress"; -import { - ComputeActivity, - type ComputeActivityBatch, -} from "../../shared/compute-activity"; -import { formatFixed } from "../../shared/format-value"; -import { - SummaryStat, - SummaryStatusDot, - type SummaryStatusTone, - SummaryStrip, -} from "../../shared/summary-strip"; -import { formatDurationMs } from "../format-duration"; - -const summaryStyle = css({ - marginTop: "-1", - marginBottom: "3", -}); - -const activityStyle = css({ - marginTop: "2", -}); - -const errorStyle = css({ - fontSize: "sm", - color: "red.s100", - whiteSpace: "pre-wrap", -}); - -const STATUS_DISPLAY: Record< - ExperimentRecord["status"], - { label: string; tone: SummaryStatusTone } -> = { - initializing: { label: "Initializing", tone: "active" }, - running: { label: "Running", tone: "active" }, - idle: { label: "Idle", tone: "neutral" }, - complete: { label: "Complete", tone: "done" }, - error: { label: "Error", tone: "error" }, - cancelled: { label: "Cancelled", tone: "neutral" }, -}; - -/** - * A clock that advances while `active`, so an elapsed-time readout keeps - * moving even when a stalled run stops publishing progress. - */ -const useNow = (active: boolean): number => { - const [now, setNow] = useState(() => Date.now()); - - useEffect(() => { - if (!active) { - return; - } - const update = () => setNow(Date.now()); - update(); - const intervalId = window.setInterval(update, 250); - return () => window.clearInterval(intervalId); - }, [active]); - - return now; -}; - -/** Longest status label, so the strip never reflows as the status changes. */ -/** - * Simulated time to show when no batch is publishing progress: an idle sweep - * or a complete run has taken every run to the end. - */ -const settledTime = (experiment: ExperimentRecord): number => - experiment.status === "idle" || experiment.status === "complete" - ? experiment.maxTime - : 0; - -const STATUS_CHARS = - Math.max( - ...Object.values(STATUS_DISPLAY).map((entry) => entry.label.length), - ) + 2; - -/** - * "selection" is the navigator's own ladder — the priority work; "surface" - * is a contour chunk; "refine" is a single cell brought up to depth. - */ -const BATCH_KIND_META: Record< - SweepBatchStatus["kind"], - Pick -> = { - selection: { label: "Selection", tone: "priority" }, - surface: { label: "Surface", tone: "background" }, - refine: { label: "Refine", tone: "background" }, -}; - -/** The sweep's batches as the activity list shows them. */ -const activityBatches = ( - sweepBatches: readonly SweepBatchStatus[], -): ComputeActivityBatch[] => - sweepBatches.map((batch) => ({ - id: String(batch.id), - ...BATCH_KIND_META[batch.kind], - runCount: batch.runCount, - completedRuns: batch.completedRuns, - })); - -/** The bar under the stats: the selection's runs for a sweep, simulated time otherwise. */ -const activityBar = (experiment: ExperimentRecord) => ({ - percent: experimentProgressPercent(experiment), - label: experiment.sweep - ? `Selection · ${experiment.sweep.runsSampled.toLocaleString("en-US")} / ${experiment.runCount.toLocaleString("en-US")} runs` - : `Time · ${(experiment.progress?.time ?? 0).toLocaleString("en-US")} / ${experiment.maxTime.toLocaleString("en-US")}`, -}); - -export const ExperimentSummary = ({ - experiment, -}: { - experiment: ExperimentRecord; -}) => { - const progress = experiment.progress; - const now = useNow(isExperimentActive(experiment)); - const elapsedMs = getExperimentElapsedMs(experiment, now); - const status = STATUS_DISPLAY[experiment.status]; - - return ( -
- - - - {status.label} - - - {experiment.scenarioName ?? "Default"} - - - {progress - ? `${progress.activeRuns} active, ${progress.completedRuns} complete` - : experiment.runCount} - - - {progress?.erroredRuns ?? 0} - - - {formatFixed(progress?.time ?? settledTime(experiment))} /{" "} - {formatFixed(experiment.maxTime)} - - {/* Wall-clock, as distinct from the simulated time; dashed out - when stepping never began. */} - - {elapsedMs === null ? "—" : formatDurationMs(elapsedMs)} - - -
- -
- {experiment.error ? ( - {experiment.error} - ) : null} -
- ); -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx index 19b9a573270..3a42a9a0216 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx @@ -30,8 +30,8 @@ export const METRIC_PLOT_HEIGHT = 220; export const METRIC_CARD_HEIGHT = chartCardHeight({ bodyHeight: METRIC_PLOT_HEIGHT, }); -/** Two cards per row in an extra-large drawer; one when the drawer is narrower. */ -const METRIC_CARD_MIN_WIDTH = 360; +/** Two cards per row in an extra-large drawer and in the frame's right column above 1100px; one when narrower. */ +const METRIC_CARD_MIN_WIDTH = 320; export const MetricTiles = ({ tiles, From 855f59e05032a6fb83d55724d1e3f1f3e6cf09ab Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 08:32:56 +0200 Subject: [PATCH 03/13] Put the optimization drawer and the full view in the DrawerFrame through one StudyFrame --- .../optimization-full-view.test.tsx | 2 +- .../optimizations/optimization-full-view.tsx | 119 ++---- .../SimulateView/optimizations/study-view.tsx | 381 ++++++++++-------- .../study-view/navigator-band.tsx | 55 +-- .../study-view/shared/study-progress.ts | 62 +-- .../optimizations/study-view/steps-table.tsx | 20 +- ...mary-strip.test.ts => study-stats.test.ts} | 2 +- .../optimizations/study-view/study-stats.tsx | 136 +++++++ .../study-view/study-summary-strip.tsx | 199 --------- .../view-optimization-drawer.test.tsx | 136 ++++++- .../view-optimization-drawer.tsx | 52 +-- .../SimulateView/shared/compute-activity.tsx | 273 ------------- .../SimulateView/shared/summary-strip.tsx | 121 ------ 13 files changed, 531 insertions(+), 1027 deletions(-) rename libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/{study-summary-strip.test.ts => study-stats.test.ts} (95%) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-stats.tsx delete mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-summary-strip.tsx delete mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-activity.tsx delete mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/summary-strip.tsx diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx index 523bf8dc080..b7c4cddb0f1 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx @@ -148,7 +148,7 @@ describe("OptimizationFullView", () => { setSimulatePresentation, }); - expect(screen.getByText(input.name)).toBeTruthy(); + expect(screen.getByText(new RegExp(`^${input.name} · `, "u"))).toBeTruthy(); expect(screen.getByText(/best step so far/u)).toBeTruthy(); expect(screen.getByText("Objective by step")).toBeTruthy(); expect(screen.getByText("Objective at the step in flight")).toBeTruthy(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.tsx index f7ab4660b40..a26c8951f3e 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.tsx @@ -1,7 +1,7 @@ /** - * The Optimizations section given over to one study: a top bar with Back to - * list, the study's name and its actions, then the study body spread over - * the section's width. The drawer shows the same body stacked. + * The Optimizations section given over to one study: the study frame filling + * the section, with Back to list before the title and the actions in the + * footer. The drawer shows the same frame over the list. */ import { use } from "react"; @@ -9,76 +9,15 @@ import { Button, Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { OptimizationsContext } from "../../../../../../react/optimizations/context"; -import { EditorContext } from "../../../../../../react/state/editor-context"; -import { describeStudy, StudyActions, StudyBody } from "./study-view"; +import { StudyFrame } from "./study-view"; import type { OptimizationRecord } from "../../../../../../react/optimizations/context"; -const frameStyle = css({ +const sectionStyle = css({ display: "flex", - flexDirection: "column", flex: "1", minWidth: "[0]", height: "full", - backgroundColor: "neutral.s00", -}); - -// The same bar the section list has, so the switch between the two reads as -// one place changing what it shows. -const topBarStyle = css({ - display: "flex", - alignItems: "center", - gap: "3", - minHeight: "[52px]", - paddingLeft: "[12px]", - paddingRight: "[20px]", - paddingY: "[8px]", - borderBottomWidth: "[1px]", - borderBottomStyle: "solid", - borderBottomColor: "neutral.s40", - flexShrink: 0, -}); - -const titleBlockStyle = css({ - display: "flex", - flexDirection: "column", - flex: "1", - minWidth: "[0]", -}); - -const titleStyle = css({ - fontSize: "sm", - fontWeight: "semibold", - color: "neutral.s120", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -const descriptionStyle = css({ - fontSize: "xs", - color: "neutral.s80", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -const actionsStyle = css({ - display: "flex", - alignItems: "center", - gap: "2", - flexShrink: 0, -}); - -// Never scrolls itself: the study body keeps its summary band still and -// scrolls the region beneath it. -const bodyStyle = css({ - flex: "1", - display: "flex", - flexDirection: "column", - minHeight: "[0]", - overflow: "hidden", - paddingX: "5", }); export const OptimizationFullView = ({ @@ -86,39 +25,27 @@ export const OptimizationFullView = ({ }: { optimization: OptimizationRecord; }) => { - const { setSimulatePresentation } = use(EditorContext); const { setSelectedOptimizationId } = use(OptimizationsContext); + const backToList = () => setSelectedOptimizationId(null); return ( -
-
- -
- {optimization.input.name} - - {describeStudy(optimization)} - -
-
- setSelectedOptimizationId(null)} - /> -
-
-
- -
+
+ } + onClick={backToList} + > + Back to list + + } + onClose={backToList} + />
); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx index 592f0224361..7d43922df0d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx @@ -1,14 +1,15 @@ /** - * The body of a study, shared by the drawer and the full view: a fixed - * summary band (the header line, the strip, the progress bar) above one - * scrolling region holding the navigator band, the surface, the objective at - * the selected point, the objective by step and the steps table. The two - * surfaces arrange the same pieces; `layout` says how. + * One study in the shared frame, for the drawer and the full view alike: the + * one-line title with the progress line beside it, the stats and the compute + * badge, the steps bar; then, arranged by the frame's width, the Parameters + * band and the surface, the chart cards (the objective at the point, the + * objective by step, Constraints when the study declares any, Parameter + * importance) and the steps table; and the actions in the footer. */ -import { use } from "react"; +import { use, type ReactNode } from "react"; import { Button, HelpTooltip, Icon } from "@hashintel/ds-components"; -import { css, cx } from "@hashintel/ds-helpers/css"; +import { css } from "@hashintel/ds-helpers/css"; import { type ConnectedStudyState, @@ -18,9 +19,21 @@ import { type OptimizationRecord, OptimizationsContext, } from "../../../../../../react/optimizations/context"; +import { EditorContext } from "../../../../../../react/state/editor-context"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; -import { Section, SectionList } from "../../../../../components/section"; -import { ChartCardMenu, type ChartCardTone } from "../shared/chart-card"; +import { + ChartCardGrid, + chartCardHeight, + ChartCardMenu, + type ChartCardTone, +} from "../shared/chart-card"; +import { ComputeBackendBadge } from "../shared/compute-backend-badge"; +import { + DrawerFrame, + FrameBand, + FrameColumns, + type FrameNote, +} from "../shared/drawer-frame"; import { formatScalar } from "../shared/format-value"; import { NavigatedOptimizationSurface, @@ -38,43 +51,33 @@ import { OptimizationMetrics, } from "./study-view/optimization-metrics"; import { ParameterImportancePanel } from "./study-view/parameter-importance-panel"; +import { stepsProgressPercent } from "./study-view/shared/study-progress"; import { StepsTable } from "./study-view/steps-table"; +import { StudyHeader } from "./study-view/study-header"; import { type StudyPhase, studyPhase } from "./study-view/study-phase"; -import { StudySummaryBand } from "./study-view/study-summary-strip"; +import { StudyStats } from "./study-view/study-stats"; import type { PetrinautSimulatePresentation } from "../../../../../../react/state/editor-context"; export { studyPhase, type StudyPhase } from "./study-view/study-phase"; export { describeStudyProgress } from "./study-view/study-header"; -/** How a study body is arranged: stacked for the drawer, or spread over the section's width. */ -export type StudyLayout = "drawer" | "full"; - -// The band holds still; only the region beneath it scrolls, so condensing -// or growing the band never moves the body's scroll offset. -const bodyStyle = css({ - display: "flex", - flexDirection: "column", - flex: "[1]", - minHeight: "[0]", - overflow: "hidden", +/** Every chart card of a study is this tall; the surface card, with its footer, comes to the same. */ +const STUDY_CARD_HEIGHT = chartCardHeight({ + bodyHeight: OBJECTIVE_PLOT_HEIGHT, }); +/** Two cards per row in the frame's right column above 1100px and in the drawer; one when narrower. */ +const STUDY_CARD_MIN_WIDTH = 320; +/** The steps table's fixed height in pixels; the steps scroll inside it. */ +const STEPS_TABLE_HEIGHT = 320; -const scrollRegionStyle = css({ +const stepsStyle = css({ display: "flex", flexDirection: "column", - flex: "[1]", - minHeight: "[0]", - overflowY: "auto", - scrollbarWidth: "[thin]", -}); - -const fixedSectionStyle = css({ - flexShrink: "0", + gap: "1", }); const stepsScrollStyle = css({ - flex: "[1]", overflowY: "auto", scrollbarWidth: "[thin]", borderWidth: "[1px]", @@ -91,54 +94,6 @@ const stepsScrollStyle = css({ }, }); -const remoteStepsHeightStyle = css({ - minHeight: "[160px]", -}); - -// A connected study's steps get whatever height the panes above leave. The -// table's own header names the columns, so no section title precedes it. -const connectedStepsStyle = css({ - display: "flex", - flexDirection: "column", - gap: "2", - flex: "[1]", - minHeight: "[0]", - paddingTop: "3", - paddingBottom: "3", -}); - -const connectedStepsHeightStyle = css({ - minHeight: "[160px]", -}); - -const fullStepsHeightStyle = css({ - minHeight: "[240px]", -}); - -// The chart cards side by side, all the same height; a lone card on the -// last row of the drawer takes the whole row rather than half of it. -const panesStyle = css({ - display: "grid", - alignItems: "stretch", - gap: "5", - paddingTop: "2.5", - paddingBottom: "2", -}); - -const drawerPanesStyle = css({ - gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 400px), 1fr))", - "& > :last-child:nth-child(odd)": { gridColumn: "[1 / -1]" }, -}); - -const fullPanesStyle = css({ - gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 380px), 1fr))", -}); - -const historyBlockStyle = css({ - paddingTop: "2.5", - paddingBottom: "3", -}); - const bestParametersStyle = css({ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(14rem, 1fr))", @@ -182,7 +137,10 @@ const bestParameterValueStyle = css({ const SURFACE_HELP = "The objective over two optimized parameters, drawn from the study's own steps: each step is a dot, the best emphasized, pruned steps hollow, and the field is interpolated between them. The ringed dot is the step being evaluated, filling in as it runs; once the study is over, or Follow steps is off, click or drag the plot to refine a point."; -/** The header's second line: the scenario and the objective. */ +const REMOTE_SURFACE_HELP = + "The objective over two optimized parameters, computed locally on this machine; the study's own trials appear as rings. Move the sliders or click the plot to recompute elsewhere."; + +/** The scenario and the objective, the title's second and third parts. */ export const describeStudy = (optimization: OptimizationRecord): string => { const { input } = optimization; const scenario = input.model.definition.scenarios?.find( @@ -196,6 +154,10 @@ export const describeStudy = (optimization: OptimizationRecord): string => { return `${scenario?.name ?? input.scenario.id} · ${direction} ${metric?.name ?? input.objective.metricId}`; }; +/** The frame's one-line title: `Supply chain · Base scenario · Maximize Profit`. */ +export const studyTitle = (optimization: OptimizationRecord): string => + `${optimization.input.name} · ${describeStudy(optimization)}`; + /** * What the objective's timeline describes: the step in flight while a live * study is followed, otherwise the point the navigation holds. A settled @@ -211,18 +173,34 @@ export const objectiveAtPointTitle = ( ? "Objective at the step in flight" : "Objective at the selected point"; -const BestParametersSection = ({ +/** The frame's note row: the error when the study failed, else the resume note while paused. */ +export const studyNote = ( + optimization: OptimizationRecord, +): FrameNote | null => { + if (optimization.error) { + return { content: optimization.error, tone: "error" }; + } + if (optimization.status === "paused" && optimization.connected) { + return { + content: ( + + Resuming continues the study's history; it does not reproduce the + draws an uninterrupted run would have made. + + ), + tone: "muted", + }; + } + return null; +}; + +const BestParametersBand = ({ optimization, }: { optimization: OptimizationRecord; }) => optimization.best ? ( -
+
{Object.entries(optimization.best.parameters).map( ([identifier, value]) => ( @@ -235,78 +213,85 @@ const BestParametersSection = ({ ), )}
-
+ ) : null; -/** A study run elsewhere: the summary, the results, the objective by step and the experimental surface. */ +const StudySteps = ({ + optimization, + bestTrial, +}: { + optimization: OptimizationRecord; + bestTrial: number | null; +}) => ( +
+ +
+); + +/** A study run elsewhere: the best parameters, the objective by step, the experimental surface and the steps. */ const RemoteStudyBody = ({ optimization, - layout, }: { optimization: OptimizationRecord; - layout: StudyLayout; }) => { const { enableOptimizationSurface } = use(UserSettingsContext); const surfaceEligible = enableOptimizationSurface && optimization.axes.length >= 2; return ( -
- - -
- -
- -
- {surfaceEligible ? ( -
- -
- ) : null} - {optimization.trials.length > 0 ? ( -
- -
- ) : null} -
-
-
+ + + {surfaceEligible ? ( + + + + ) : null} + + ) : undefined + } + secondary={ + + + + } + after={ + optimization.trials.length > 0 ? ( + + ) : undefined + } + /> ); }; /** - * A study evaluated in this browser: the header and the summary, the - * parameter controls with their state line, the chart cards (the surface, the - * objective at the point, the objective by step, Constraints when the study - * declares any, and Sensitivity analysis), and the steps filling what is - * left. The navigation drives the surface and the objective's timeline, + * A study evaluated in this browser: the parameter controls with their state + * line and the surface on one side, the chart cards on the other, the steps + * beneath. The navigation drives the surface and the objective's timeline, * following each step while the study runs. */ const ConnectedStudyBody = ({ optimization, connected, - layout, }: { optimization: OptimizationRecord; connected: ConnectedStudyState; - layout: StudyLayout; }) => { const { setOptimizationNavigation } = use(OptimizationsContext); const onNavigationChange = (patch: Partial) => @@ -317,21 +302,15 @@ const ConnectedStudyBody = ({ optimization.status === "paused" ? "paused" : "default"; return ( -
- -
- -
+ + {optimization.axes.length >= 2 ? ( ) : null} + + } + secondary={ + {/* Keyed so faded previous pictures never leak from one study into - another when the surface swaps records. */} + another when the surface swaps records. */} ) : null} {/* Only a study evaluated here receives importances; a remote study - has no panel rather than an empty one. */} + has no panel rather than an empty one. */} -
-
- -
-
-
+ + } + after={ + + } + /> ); }; -/** - * The whole body of a study in either surface: the fixed summary band, then - * the scrolling region. The surface around it must not scroll on its own. - */ +/** The body of a study in either surface, arranged by the frame's width. */ export const StudyBody = ({ optimization, - layout, }: { optimization: OptimizationRecord; - /** `drawer` stacks for the overlay; `full` spreads the chart cards over the section's width. */ - layout: StudyLayout; }) => optimization.connected ? ( ) : ( - + ); /** The button that moves the navigation to the best step and computes there. */ @@ -611,3 +584,59 @@ export const StudyActions = ({ ); }; + +/** + * The whole study in the shared frame. `drawer` puts it in a ds Drawer over + * the list; without it the frame fills the section, with `leading` (Back to + * list) before the title. `onClose` leaves the record: after Remove, and + * from the drawer's Close button. + */ +export const StudyFrame = ({ + optimization, + presentation, + drawer, + leading, + onClose, +}: { + optimization: OptimizationRecord; + presentation: PetrinautSimulatePresentation; + drawer?: { onClose: () => void; swapKey: string }; + leading?: ReactNode; + onClose: () => void; +}) => { + const { setSimulatePresentation } = use(EditorContext); + const { connected } = optimization; + + return ( + } + stats={} + badge={ + connected ? ( + + ) : undefined + } + progress={stepsProgressPercent(optimization)} + note={studyNote(optimization)} + footer={ + + } + > + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx index 6637e1bee6d..f55c4b9f89f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx @@ -1,13 +1,10 @@ /** - * The Parameters band of a connected study's drawer: a title row with the - * help tooltip and the navigator's status line, then the parameter controls. - * It holds still above the plots; the drawer keeps it out of the scrolling - * region, so there is no section body beneath it. + * The Parameters band of a connected study: the frame band with the help + * tooltip and the navigator's status line in its title row, then the + * parameter controls. */ -import { HelpTooltip } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; - import { optimizationBooleanIdentifiers } from "../../../../../../../react/optimizations/surface-grid"; +import { FrameBand } from "../../shared/drawer-frame"; import { OptimizationNavigator, OptimizationNavigatorStatus, @@ -19,35 +16,6 @@ import type { OptimizationRecord, } from "../../../../../../../react/optimizations/context"; -const bandStyle = css({ - display: "flex", - flexDirection: "column", - gap: "2", - paddingTop: "3", - paddingBottom: "2", - flexShrink: "0", -}); - -const headerRowStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: "2", -}); - -const headerLeftStyle = css({ - display: "flex", - alignItems: "center", - gap: "1", -}); - -const titleStyle = css({ - fontWeight: "semibold", - fontSize: "sm", - lineHeight: "[14px]", - color: "neutral.fg.body", -}); - const PARAMETERS_HELP = "The chart beside the surface shows the objective at this point. While the study runs and Follow steps is on, the point follows each step as it is evaluated and the controls only show it; turn Follow steps off, or wait for the study to finish, to move them and look elsewhere."; @@ -63,19 +31,18 @@ export const NavigatorBand = ({ running: boolean; onNavigationChange: (patch: Partial) => void; }) => ( -
-
-
- Parameters - -
+ -
+ } + > -
+ ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/shared/study-progress.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/shared/study-progress.ts index b02bc655973..689894cfd7b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/shared/study-progress.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/shared/study-progress.ts @@ -1,20 +1,16 @@ /** - * A study's progress as the summary's bars and activity list show it: steps - * finished over steps requested, the followed step's runs over the runs each - * step gets, and the batches computing right now, each named. + * A study's progress as the frame's header shows it: steps finished over + * steps requested for the bar, and the batches computing right now, each + * named, for the computing chip. */ import { type ConnectedStudyState, - followedTrial, type OptimizationBatchStatus, type OptimizationRecord, } from "../../../../../../../../react/optimizations/context"; import { formatParameters } from "../../../shared/format-value"; -import type { - ComputeActivityBar, - ComputeActivityBatch, -} from "../../../shared/compute-activity"; +import type { ComputeBatch } from "../../../shared/drawer-frame"; export const finishedStepCount = ( optimization: Pick< @@ -26,56 +22,30 @@ export const finishedStepCount = ( optimization.prunedTrials + optimization.failedTrials; -/** The main bar: steps finished over steps requested. */ -export const stepsBar = ( +/** The header bar: steps finished over steps requested, 0 to 100. */ +export const stepsProgressPercent = ( optimization: Pick< OptimizationRecord, "completedTrials" | "prunedTrials" | "failedTrials" | "requestedTrials" >, - label?: string, -): ComputeActivityBar => { - const finished = finishedStepCount(optimization); - return { - percent: - optimization.requestedTrials > 0 - ? Math.min(100, (finished / optimization.requestedTrials) * 100) - : 0, - label, - }; -}; - -/** - * The thinner bar beneath: the followed step's runs over the runs each step - * gets, while a step is being followed. - */ -export const followedStepBar = ( - optimization: Pick, -): ComputeActivityBar | null => { - const selection = optimization.connected?.selection ?? null; - if (selection === null || !selection.computing) { - return null; - } - const trial = followedTrial(selection.key); - if (trial === null) { - return null; - } - const runsPerStep = optimization.input.execution.seedsPerTrial ?? 1; - return { - percent: Math.min(100, (selection.runsCompleted / runsPerStep) * 100), - label: `Step ${trial + 1} · ${selection.runsCompleted} / ${runsPerStep} runs`, - }; -}; +): number => + optimization.requestedTrials > 0 + ? Math.min( + 100, + (finishedStepCount(optimization) / optimization.requestedTrials) * 100, + ) + : 0; -/** A batch as the activity list names it: "Step 4", or "Refining population=1850, infected_ratio=0.36". */ +/** A batch as the computing list names it: "Step 4", or "Refining population=1850, infected_ratio=0.36". */ export const describeBatch = (batch: OptimizationBatchStatus): string => batch.kind === "trial" ? `Step ${batch.trial + 1}` : `Refining ${formatParameters(batch.values)}`; -/** The study's batches as the activity list shows them; steps are the priority work. */ +/** The study's batches as the computing list shows them; steps are the priority work. */ export const activityBatches = ( connected: ConnectedStudyState | null, -): ComputeActivityBatch[] => +): ComputeBatch[] => (connected?.activity ?? []).map((batch) => ({ id: String(batch.id), label: describeBatch(batch), diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/steps-table.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/steps-table.tsx index c664eab5b73..5a562735696 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/steps-table.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/steps-table.tsx @@ -26,7 +26,10 @@ type StepState = Step["state"]; const INFEASIBLE_MARK_COLOR = "#cccccc"; const stepHintStyle = css({ + display: "block", + height: "[16px]", fontSize: "xs", + lineHeight: "[16px]", color: "neutral.s80", }); @@ -200,23 +203,26 @@ export const StepsTable = ({ optimization, bestTrial, className, + height, }: { optimization: OptimizationRecord; /** The step to star; null marks none. */ bestTrial: number | null; className: string; + /** The table box's fixed height in pixels; the steps scroll inside it. */ + height: number; }) => { const displayedSteps = optimization.trials.slice(-DISPLAYED_STEPS).reverse(); return ( <> - {optimization.trials.length > DISPLAYED_STEPS ? ( - - Showing the latest {DISPLAYED_STEPS} of {optimization.trials.length}{" "} - received steps. - - ) : null} -
+ {/* The hint's row is reserved, so the 201st step moves nothing. */} + + {optimization.trials.length > DISPLAYED_STEPS + ? `Showing the latest ${DISPLAYED_STEPS} of ${optimization.trials.length} received steps.` + : ""} + +
= { + initializing: "active", + running: "active", + paused: "neutral", + complete: "done", + error: "error", + cancelled: "neutral", +}; + +/** Longest status word plus the dot, so the pill never reflows as it changes. */ +const STATUS_CHARS = "Reconnecting".length + 2; + +/** "4 / 30 · 3 runs each · 2 at once", with the parts that are 1 left out. */ +export const describeStepProgress = ( + optimization: Pick< + OptimizationRecord, + | "completedTrials" + | "prunedTrials" + | "failedTrials" + | "requestedTrials" + | "connected" + | "input" + >, +): string => { + const runsPerStep = optimization.input.execution.seedsPerTrial ?? 1; + const parallelism = optimization.connected?.parallelism ?? 1; + return [ + `${finishedStepCount(optimization)} / ${optimization.requestedTrials}`, + ...(runsPerStep > 1 ? [`${runsPerStep} runs each`] : []), + ...(parallelism > 1 ? [`${parallelism} at once`] : []), + ].join(" · "); +}; + +/** The status word; a run whose event stream is being re-established says so instead. */ +export const describeStudyStatus = ( + optimization: Pick< + OptimizationRecord, + "status" | "connected" | "connectionState" + >, +): string => + optimization.connectionState === "reconnecting" + ? "Reconnecting" + : describeOptimizationStatus(optimization); + +export const StudyStats = ({ + optimization, +}: { + optimization: OptimizationRecord; +}) => { + const { connected } = optimization; + const constrained = (optimization.input.constraints ?? []).length > 0; + const rates = constrained + ? studyConstraintRates( + optimization.trials, + constraintAlpha(optimization.input), + ) + : null; + + return ( + <> + + {describeStudyStatus(optimization)} + + + {describeStepProgress(optimization)} + + {rates === null ? null : ( + + {formatRate(rates.stepsClear, rates.stepsSimulated)} + + )} + + {optimization.best ? ( + + {formatNumber(optimization.best.objective)} + + ) : ( + "—" + )} + + {connected ? ( + + ) : null} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-summary-strip.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-summary-strip.tsx deleted file mode 100644 index f291d82cf99..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-summary-strip.tsx +++ /dev/null @@ -1,199 +0,0 @@ -/** - * A study's summary as one fixed band at the top of its surface, outside the - * scrolling body: the header line (where the study is, the best step so far, - * the verdict chip) with the backend badge at its right for a connected - * study, then status, steps finished over requested and the best value so - * far in a strip, with the steps bar beneath and the error when there is - * one. A connected study adds the followed step's runs under the steps bar, - * the "N computing" chip and the fallback note. A study with constraints - * adds the steps clear across the study to the strip. The band has no title - * of its own: it is the drawer's header continued, not a section. - */ -import { Tooltip } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; - -import { - constraintAlpha, - formatRate, - studyConstraintRates, -} from "../../../../../../../react/optimizations/constraint-rates"; -import { ComputeActivity } from "../../shared/compute-activity"; -import { ComputeBackendBadge } from "../../shared/compute-backend-badge"; -import { formatNumber, formatParameters } from "../../shared/format-value"; -import { - SummaryStat, - SummaryStatusDot, - type SummaryStatusTone, - SummaryStrip, -} from "../../shared/summary-strip"; -import { describeOptimizationStatus } from "../optimization-status"; -import { - activityBatches, - finishedStepCount, - followedStepBar, - stepsBar, -} from "./shared/study-progress"; -import { StudyHeader } from "./study-header"; - -import type { OptimizationRecord } from "../../../../../../../react/optimizations/context"; - -const bandStyle = css({ - display: "flex", - flexDirection: "column", - gap: "2", - flexShrink: "0", - paddingTop: "2", - paddingBottom: "3", - borderBottomWidth: "[1px]", - borderBottomStyle: "solid", - borderBottomColor: "neutral.bd.subtle", -}); - -const headerRowStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: "3", -}); - -const noteStyle = css({ - fontSize: "xs", - color: "neutral.s80", -}); - -const errorStyle = css({ - fontSize: "sm", - color: "red.s100", - whiteSpace: "pre-wrap", -}); - -const STATUS_TONE: Record = { - initializing: "active", - running: "active", - paused: "neutral", - complete: "done", - error: "error", - cancelled: "neutral", -}; - -/** Longest status label plus the dot, so the strip never reflows as it changes. */ -const STATUS_CHARS = "Initializing (reconnecting…)".length; - -/** "4 / 30 · 3 runs each · 2 at once", with the parts that are 1 left out. */ -export const describeStepProgress = ( - optimization: Pick< - OptimizationRecord, - | "completedTrials" - | "prunedTrials" - | "failedTrials" - | "requestedTrials" - | "connected" - | "input" - >, -): string => { - const runsPerStep = optimization.input.execution.seedsPerTrial ?? 1; - const parallelism = optimization.connected?.parallelism ?? 1; - return [ - `${finishedStepCount(optimization)} / ${optimization.requestedTrials}`, - ...(runsPerStep > 1 ? [`${runsPerStep} runs each`] : []), - ...(parallelism > 1 ? [`${parallelism} at once`] : []), - ].join(" · "); -}; - -export const StudySummaryBand = ({ - optimization, -}: { - optimization: OptimizationRecord; -}) => { - const { connected } = optimization; - const status = describeOptimizationStatus(optimization); - const fallbackReason = connected?.computeBackendFallbackReason ?? null; - const constrained = (optimization.input.constraints ?? []).length > 0; - const rates = constrained - ? studyConstraintRates( - optimization.trials, - constraintAlpha(optimization.input), - ) - : null; - - return ( -
-
- - {connected ? ( - - ) : null} -
- - - - {status} - {optimization.connectionState === "reconnecting" - ? " (reconnecting…)" - : ""} - - - {describeStepProgress(optimization)} - - {rates === null ? null : ( - - {formatRate(rates.stepsClear, rates.stepsSimulated)} - - )} - - {optimization.best ? ( - - {formatNumber(optimization.best.objective)} - - ) : ( - "—" - )} - - - - {fallbackReason === null ? null : ( - Ran on the CPU: {fallbackReason} - )} - {optimization.status === "paused" && connected ? ( - - Resuming continues the study's history; it does not reproduce the - draws an uninterrupted run would have made. - - ) : null} - {optimization.error ? ( - {optimization.error} - ) : null} -
- ); -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx index c141e14ad23..af080ac1c92 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx @@ -12,6 +12,7 @@ import { type OptimizationsContextValue, } from "../../../../../../react/optimizations/context"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; +import { frameLayoutSignature } from "../shared/drawer-frame"; import { fakeConstrainedStudyInput, fakeConstrainedStudyTrials, @@ -37,17 +38,8 @@ vi.mock("@hashintel/ds-components", async (importOriginal) => { const Drawer = Object.assign( ({ children }: { children: ReactNode }) =>
{children}
, { - Header: ({ - title, - description, - }: { - title: ReactNode; - description?: ReactNode; - }) => ( -
- {title} -

{description}

-
+ Header: ({ children }: { children: ReactNode }) => ( +
{children}
), Body: ({ children }: { children: ReactNode }) =>
{children}
, Footer: ({ actions }: { actions: ReactNode }) => ( @@ -111,7 +103,18 @@ vi.mock("@hashintel/ds-components", async (importOriginal) => { ); }; - return { ...actual, Drawer, Menu, Slider, Tooltip }; + // The Ark popover positions itself against a trigger jsdom cannot lay out; + // this one renders its panel in place. + const Popover = Object.assign( + ({ children }: { children: ReactNode }) =>
{children}
, + { + Container: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + }, + ); + + return { ...actual, Drawer, Menu, Popover, Slider, Tooltip }; }); vi.mock("./optimization-surface", () => ({ @@ -286,7 +289,7 @@ describe("ViewOptimizationDrawer for a remote study", () => { expect(screen.queryByTitle("Best step")).toBeNull(); }); - it("names the scenario and the objective under the title", () => { + it("names the scenario and the objective in the one-line title", () => { renderDrawer(remote); const metric = input.model.definition.metrics![0]!; @@ -516,13 +519,9 @@ describe("ViewOptimizationDrawer for a connected study", () => { }, }); + // The reason lives in the badge's tooltip, which the mock does not render. expect(screen.getByText("CPU")).toBeTruthy(); expect(screen.queryByText("GPU")).toBeNull(); - expect( - screen.getByText( - "Ran on the CPU: the GPU cannot compute expression metrics", - ), - ).toBeTruthy(); }); it("badges a study that ran on the GPU", () => { @@ -566,7 +565,7 @@ describe("ViewOptimizationDrawer for a connected study", () => { expect(extendOptimization).toHaveBeenCalledWith(stopped.id, 4); }); - it("shows the followed step's runs under the steps bar and lists the batches computing", () => { + it("lists the batches computing from the computing chip", () => { renderDrawer({ ...connected, connected: { @@ -585,7 +584,6 @@ describe("ViewOptimizationDrawer for a connected study", () => { }); expect(screen.getByText("3 / 30")).toBeTruthy(); - expect(screen.getByText("Step 3 · 1 / 1 runs")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /2 computing/ })); expect(screen.getByText("Step 3")).toBeTruthy(); expect(screen.getByText("0 / 1 runs")).toBeTruthy(); @@ -902,3 +900,101 @@ describe("ViewOptimizationDrawer's Sensitivity analysis card", () => { expect(screen.queryByText("Sensitivity analysis")).toBeNull(); }); }); + +describe("ViewOptimizationDrawer holds every box still across states", () => { + const navigation = navigationAtTrial(input, trials[2]!, true); + const base = { + input, + trials: trials.slice(0, 3), + best: trials[2]!.best, + }; + const states: OptimizationRecord[] = [ + makeOptimizationRecord({ + ...base, + status: "running", + connected: makeConnectedStudyState(input, { + navigation, + selection: makeSelectionStream({ + input, + navigation, + followedTrial: 2, + runsCompleted: 1, + computing: true, + frameCount: 4, + }), + }), + }), + makeOptimizationRecord({ + ...base, + status: "paused", + connected: makeConnectedStudyState(input, { + navigation: navigationAtTrial(input, trials[2]!, false), + selection: null, + resumable: true, + }), + }), + makeOptimizationRecord({ + ...base, + status: "cancelled", + connected: makeConnectedStudyState(input, { + navigation: navigationAtTrial(input, trials[2]!, false), + resumable: true, + }), + }), + makeOptimizationRecord({ + ...base, + status: "complete", + connected: makeConnectedStudyState(input, { + navigation: navigationAtTrial(input, trials[2]!, false), + resumable: true, + }), + }), + ]; + + it("gives the header, the note row, every card and the steps table one height in running, paused, stopped and complete", () => { + const signatures = states.map((state) => { + const view = renderDrawer(state); + const signature = frameLayoutSignature(view.container); + view.unmount(); + return signature; + }); + + expect(signatures[0]!.header).toBe("56px"); + expect(signatures[0]!.note).toBe("20px"); + expect(signatures[0]!.steps).toBe("320px"); + expect(signatures[0]!.cards.map(([title]) => title)).toEqual([ + "Objective at the step in flight", + "Objective by step", + "Parameter importance", + ]); + for (const signature of signatures.slice(1)) { + // A settled study titles the objective card for the point it shows; + // the boxes are the same. + expect({ + ...signature, + cards: signature.cards.map(([, height]) => height), + }).toEqual({ + ...signatures[0], + cards: signatures[0]!.cards.map(([, height]) => height), + }); + } + }); + + it("puts the resume note in the reserved row while paused", () => { + renderDrawer(states[1]!); + + const note = document.querySelector("[data-frame-note]")!; + expect(note.style.height).toBe("20px"); + expect(note.querySelector("[data-resume-note]")).toBeTruthy(); + }); + + it("leaves the objective card's height alone when its aggregation changes", () => { + const view = renderDrawer(states[0]!); + const before = frameLayoutSignature(view.container); + + fireEvent.click(screen.getByRole("button", { name: "Chart options" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Median" })); + + expect(frameLayoutSignature(view.container)).toEqual(before); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.tsx index 9c7f922a35d..c5d0c418b16 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.tsx @@ -1,27 +1,12 @@ /** - * One study in a drawer over the Optimizations list: the study body stacked - * for the overlay's width, with the actions in the footer. The full view - * shows the same body spread over the section; "Open full view" switches. + * One study in a drawer over the Optimizations list: the study frame in a ds + * Drawer. The full view shows the same frame over the section; "Open full + * view" in the footer switches. */ -import { use } from "react"; - -import { Drawer } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; - -import { EditorContext } from "../../../../../../react/state/editor-context"; -import { describeStudy, StudyActions, StudyBody } from "./study-view"; +import { StudyFrame } from "./study-view"; import type { OptimizationRecord } from "../../../../../../react/optimizations/context"; -// The body is a column that never scrolls itself: the study body keeps its -// summary band still and scrolls the region beneath it. -const drawerBodyStyle = css({ - paddingTop: "[0]", - display: "flex", - flexDirection: "column", - overflow: "hidden", -}); - export const ViewOptimizationDrawer = ({ open, onClose, @@ -31,35 +16,16 @@ export const ViewOptimizationDrawer = ({ onClose: () => void; optimization: OptimizationRecord | undefined; }) => { - const { setSimulatePresentation } = use(EditorContext); - if (!open || !optimization) { return null; } return ( - - - - - - - } - /> - + /> ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-activity.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-activity.tsx deleted file mode 100644 index 71c8139a967..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-activity.tsx +++ /dev/null @@ -1,273 +0,0 @@ -/** - * A summary's compute readout: the progress bar for the thing the drawer - * shows, an optional thinner bar for the batch feeding it, and a compact, - * toggleable list of every batch computing right now — a sweep runs the - * selection's ladder, surface chunks and cell refinements in parallel, a - * study its steps and the navigated point's refinement — so the list shows - * that parallelism. Collapsed, it is one line ("N computing"); the toggle - * hides when nothing computes. - */ -import { useState } from "react"; - -import { Icon } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; - -/** One computing batch, for the expanded list. */ -export type ComputeActivityBatch = { - id: string; - label: string; - /** Priority work draws in blue; background work in grey. */ - tone: "priority" | "background"; - runCount: number; - completedRuns: number; -}; - -/** A progress bar's fill and the label under it; no label leaves the slot empty. */ -export type ComputeActivityBar = { - percent: number; - label?: string; -}; - -const barTrackStyle = css({ - height: "[6px]", - width: "full", - backgroundColor: "neutral.s30", - borderRadius: "full", - overflow: "hidden", -}); - -const barFillStyle = css({ - height: "full", - borderRadius: "full", - backgroundColor: "neutral.s120", - transition: "[width 160ms ease-out]", -}); - -const secondaryTrackStyle = css({ - height: "[3px]", - width: "full", - marginTop: "[3px]", - backgroundColor: "neutral.s20", - borderRadius: "full", - overflow: "hidden", -}); - -const secondaryFillStyle = css({ - height: "full", - borderRadius: "full", - backgroundColor: "blue.s100", - transition: "[width 160ms ease-out]", -}); - -const metaRowStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: "2", - marginTop: "1", -}); - -const metaLabelsStyle = css({ - display: "flex", - alignItems: "baseline", - gap: "2", - minWidth: "[0]", -}); - -// The toggle stays in the row while nothing computes so the row keeps its -// height, and the sections below hold still when batches start and finish. -const toggleSlotStyle = css({ - display: "inline-flex", - "&[data-idle=true]": { visibility: "hidden" }, -}); - -const metaLabelStyle = css({ - fontSize: "[11px]", - color: "neutral.s80", - fontVariantNumeric: "tabular-nums", -}); - -const secondaryLabelStyle = css({ - fontSize: "[11px]", - color: "blue.s100", - fontVariantNumeric: "tabular-nums", - whiteSpace: "nowrap", -}); - -const toggleStyle = css({ - display: "inline-flex", - alignItems: "center", - gap: "1", - paddingX: "1.5", - paddingY: "[2px]", - borderRadius: "sm", - borderWidth: "[0]", - fontSize: "[11px]", - fontWeight: "medium", - color: "neutral.s100", - backgroundColor: "neutral.s10", - cursor: "pointer", - _hover: { backgroundColor: "neutral.s20" }, -}); - -const computingDotStyle = css({ - width: "[6px]", - height: "[6px]", - borderRadius: "full", - backgroundColor: "blue.s100", -}); - -const batchListStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[3px]", - marginTop: "1", - padding: "1.5", - borderWidth: "[1px]", - borderStyle: "solid", - borderColor: "neutral.bd.subtle", - borderRadius: "sm", - backgroundColor: "neutral.s10", -}); - -const batchRowStyle = css({ - display: "grid", - gridTemplateColumns: "[minmax(76px, auto) minmax(0, 1fr) 88px]", - alignItems: "center", - gap: "2", - minHeight: "[16px]", -}); - -const batchLabelStyle = css({ - display: "inline-flex", - alignItems: "center", - gap: "1", - fontSize: "[11px]", - color: "neutral.s100", - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", - maxWidth: "[220px]", -}); - -const batchDotStyle = css({ - width: "[6px]", - height: "[6px]", - borderRadius: "full", - flexShrink: "0", - backgroundColor: "neutral.s60", - "&[data-tone=priority]": { backgroundColor: "blue.s100" }, -}); - -const batchTrackStyle = css({ - height: "[4px]", - borderRadius: "full", - backgroundColor: "neutral.s30", - overflow: "hidden", -}); - -const batchFillStyle = css({ - height: "full", - borderRadius: "full", - backgroundColor: "neutral.s90", - transition: "[width 160ms ease-out]", - "&[data-tone=priority]": { backgroundColor: "blue.s100" }, -}); - -const batchCountStyle = css({ - fontSize: "[11px]", - color: "neutral.s80", - fontVariantNumeric: "tabular-nums", - textAlign: "right", - whiteSpace: "nowrap", -}); - -const BatchRow = ({ batch }: { batch: ComputeActivityBatch }) => { - const percent = - batch.runCount > 0 - ? Math.min(100, (batch.completedRuns / batch.runCount) * 100) - : 0; - - return ( -
- - - {batch.label} - -
-
-
- - {batch.completedRuns.toLocaleString("en-US")} /{" "} - {batch.runCount.toLocaleString("en-US")} runs - -
- ); -}; - -export const ComputeActivity = ({ - bar, - secondaryBar = null, - batches, -}: { - bar: ComputeActivityBar; - /** A thinner bar beneath the main one; null hides it. */ - secondaryBar?: ComputeActivityBar | null; - batches: readonly ComputeActivityBatch[]; -}) => { - const [expanded, setExpanded] = useState(false); - - return ( -
-
-
-
- {secondaryBar ? ( -
-
-
- ) : null} -
- - {bar.label === undefined ? null : ( - {bar.label} - )} - {secondaryBar ? ( - {secondaryBar.label} - ) : null} - - - - -
- {expanded && batches.length > 0 ? ( -
- {batches.map((batch) => ( - - ))} -
- ) : null} -
- ); -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/summary-strip.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/summary-strip.tsx deleted file mode 100644 index ee8025383c2..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/summary-strip.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/** - * A drawer summary's strip: stats side by side, each a small uppercase label - * over its value, divided by hairlines, wrapping when the drawer is narrow. - */ -import { css } from "@hashintel/ds-helpers/css"; - -import type { ReactNode } from "react"; - -// Every stat carries its own leading hairline, and the strip shifts left by -// exactly one divider-plus-gap so each row's first divider lands outside the -// clipping wrapper — wrapped rows therefore start flush, not with a floating -// rule (a sibling selector cannot see flex line breaks). -const stripClipStyle = css({ - overflow: "hidden", -}); - -const stripStyle = css({ - display: "flex", - flexWrap: "wrap", - alignItems: "center", - rowGap: "2", - marginLeft: "[-17px]", -}); - -const statStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[1px]", - minWidth: "[0]", - paddingLeft: "4", - marginLeft: "[1px]", - borderLeftWidth: "[1px]", - borderLeftStyle: "solid", - borderLeftColor: "neutral.bd.subtle", - paddingRight: "4", -}); - -const statLabelStyle = css({ - fontSize: "[10px]", - fontWeight: "medium", - letterSpacing: "[0.04em]", - textTransform: "uppercase", - color: "neutral.s70", -}); - -const statValueStyle = css({ - fontSize: "sm", - fontWeight: "medium", - color: "neutral.s120", - fontVariantNumeric: "tabular-nums", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -// Inline-block inside the value span, so a long value still ellipsizes (a -// flex value container turns its text into an item ellipsis cannot reach). -const statusDotStyle = css({ - display: "inline-block", - width: "[7px]", - height: "[7px]", - borderRadius: "full", - marginRight: "1.5", - verticalAlign: "[1px]", - backgroundColor: "neutral.s60", - "&[data-tone=active]": { backgroundColor: "blue.s100" }, - "&[data-tone=done]": { backgroundColor: "green.s90" }, - "&[data-tone=error]": { backgroundColor: "red.s100" }, -}); - -const trailingStyle = css({ - display: "inline-flex", - alignItems: "center", - marginLeft: "auto", - paddingLeft: "4", -}); - -export type SummaryStatusTone = "active" | "done" | "error" | "neutral"; - -export const SummaryStrip = ({ - children, - trailing, -}: { - children: ReactNode; - /** Pinned to the strip's right edge, outside the stats' hairlines. */ - trailing?: ReactNode; -}) => ( -
-
- {children} - {trailing === undefined ? null : ( - {trailing} - )} -
-
-); - -export const SummaryStat = ({ - label, - minChars, - children, -}: { - label: string; - /** Reserve this many characters so a changing value never reflows the strip. */ - minChars?: number; - children: ReactNode; -}) => ( -
- {label} - - {children} - -
-); - -export const SummaryStatusDot = ({ tone }: { tone: SummaryStatusTone }) => ( - -); From 867c14981b8814e8ab0ee004ce4ed52ccd7f5e3b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 08:32:57 +0200 Subject: [PATCH 04/13] Document the Simulate drawer header and body arrangement --- .changeset/simulate-drawer-frame.md | 5 ++ libs/@hashintel/petrinaut/docs/experiments.md | 32 +++++---- .../@hashintel/petrinaut/docs/optimization.md | 70 +++++++++++-------- 3 files changed, 63 insertions(+), 44 deletions(-) create mode 100644 .changeset/simulate-drawer-frame.md diff --git a/.changeset/simulate-drawer-frame.md b/.changeset/simulate-drawer-frame.md new file mode 100644 index 00000000000..7eee8b7253c --- /dev/null +++ b/.changeset/simulate-drawer-frame.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +The Simulate drawers and the full optimization view share one frame: a one-line title with the status, runs, time and compute badge in a header that condenses as the body scrolls, the metric cards beside or above the parameter controls and the surface by the drawer's width, and every card at a fixed height so nothing moves while results stream. The Summary section is gone; its content is the header. diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 5f9e1b793c1..768a2b88764 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -74,9 +74,9 @@ Every selection uses the same seed sequence (common random numbers), and a run's #### The surface view -A sweep with two or more swept parameters grows a **Surface** section between the parameter strip and the metric charts: a card holding a contour plot of one metric's final value over two parameters you pick, with every other parameter held at the middle of its selected range. The **X**, **Y** and **Metric** pickers sit in the row under the plot, and the line under the card's title reads the sampling progress (or, mid-drag, the values under the pointer). The plot fills in live and coarse-first — the four corners, then ever finer subdivisions, each level a complete picture (8 runs per point) — and **the surface is itself a control**: click, or press and drag with a live crosshair and value readout, and on release both shown parameters collapse to a point there, which then refines with more runs. An orange ring marks where the navigator currently sits. Your selected point always computes first: the metric charts start streaming before surface sampling begins, and after every slider move the surface waits for the new selection's first frames before continuing. Every metric is measured on the same samples, so switching the shown metric repaints instantly from what was already computed; changing the fixed parameters or the axes restarts the fill for the new slice. +A sweep with two or more swept parameters grows a **Surface** card under the **Parameters** band: a contour plot of one metric's final value over two parameters you pick, with every other parameter held at the middle of its selected range. The **X**, **Y** and **Metric** pickers sit in the row under the plot, and the line under the card's title reads the sampling progress (or, mid-drag, the values under the pointer). The plot fills in live and coarse-first — the four corners, then ever finer subdivisions, each level a complete picture (8 runs per point) — and **the surface is itself a control**: click, or press and drag with a live crosshair and value readout, and on release both shown parameters collapse to a point there, which then refines with more runs. An orange ring marks where the navigator currently sits. Your selected point always computes first: the metric charts start streaming before surface sampling begins, and after every slider move the surface waits for the new selection's first frames before continuing. Every metric is measured on the same samples, so switching the shown metric repaints instantly from what was already computed; changing the fixed parameters or the axes restarts the fill for the new slice. -The summary, the parameter strip, and the surface hold still at the top of the drawer; the metric charts scroll on their own below them, so the graphs stay in view while you browse the charts. +The drawer arranges its parts by its width. In a wide view (the full-size presentation, above about 1100px) the **Parameters** band and the **Surface** sit on the left and the metric cards on the right; in the drawer the metric cards come first, then **Parameters**, then **Surface**, so the charts you watch are at the top either way. Every card keeps a fixed height, and only the body scrolls, under the header. ### Compute backend (experimental) @@ -101,24 +101,26 @@ Run count has no ceiling of its own: runs beyond what your GPU can hold at once Two things to know before comparing results: -- **The same seed gives different numbers on the two backends.** They deliberately use different random number generators, so the trajectories differ while the distributions agree. On the built-in SIR example the two backends' mean token counts agree to within half a percent. The badge in each experiment's summary records which backend ran it, so results stay attributable after the fact. +- **The same seed gives different numbers on the two backends.** They deliberately use different random number generators, so the trajectories differ while the distributions agree. On the built-in SIR example the two backends' mean token counts agree to within half a percent. The badge in each experiment's header records which backend ran it, so results stay attributable after the fact. - Continuous dynamics are integrated with a **more accurate method** (Runge-Kutta 4) than the CPU's, so a model with differential equations may show slightly different — better — values, not just different noise. - The GPU steps every run to the configured max time, while the CPU stops a run as soon as it can no longer fire anything. So a net that finishes early reports a **higher frame count and simulated time** on the GPU for the same results. Nothing is wrong with either; they just stop counting at different points. -### Reading the summary +### Reading the header -Open an experiment's drawer and its **Summary** section reports: +Open an experiment's drawer and its header names the experiment in one line: the name, the scenario (or **Default scenario**), the run count and the time step, for example **SIR transmission sweep · Seasonal Flu · 100 runs · dt 1**. Beneath it, one line of stats: -| Field | Meaning | -| ------------ | ----------------------------------------------------------------------------------------------------------------------- | -| **Status** | One of the five statuses above. | -| **Scenario** | The scenario the experiment runs, or `Default`. | -| **Runs** | How many runs are in flight, and how many have finished. | -| **Errors** | How many individual runs errored — shown only when at least one has. An experiment can complete with some runs errored. | -| **Time** | Simulated time reached, against the configured maximum. This is model time, not clock time. | -| **Elapsed** | Clock time the experiment has been simulating. Once it stops, this becomes **Duration** and holds the total it took. | +| Stat | Meaning | +| ------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Status** | One of the five statuses above, as a pill with a coloured dot. | +| **Runs** | How many runs are in flight, and how many have finished. | +| **Errors** | How many individual runs errored. An experiment can complete with some runs errored. | +| **Time** | Simulated time reached, against the configured maximum. This is model time, not clock time. | +| **Elapsed** | Clock time the experiment has been simulating. Once it stops, this becomes **Duration** and holds the total it took. | +| **Selection** | For a sweep: the selected combination's runs sampled over the run budget. | -A badge beside the **Summary** heading shows whether the run used the **CPU** or the **GPU**, and stays visible when the section is collapsed. Hover it for detail — on a CPU-backed experiment that asked for the GPU, the badge explains which requirement the net did not meet. +A badge at the right of the stats line shows whether the run used the **CPU** or the **GPU**. Hover it for detail; on a CPU-backed experiment that asked for the GPU, the badge explains which requirement the net did not meet. A progress bar runs along the header's bottom edge: the selected combination's runs for a sweep, simulated time otherwise. If the experiment failed, the error reads in the line under the header. + +Once the drawer's body has scrolled, the header condenses to one line, with the stats folded in as compact chips beside the title; move the pointer over it and it grows back. Nothing in the drawer moves when a status changes or a number grows a digit: every value reserves its width and every card its height. **Elapsed** and **Duration** measure simulating only. Compiling the net's user code and starting the workers (or acquiring the GPU device and compiling the shader) happens before the clock starts, so the number is comparable between the two backends. An experiment that fails before it starts simulating shows `—` rather than a duration. @@ -153,7 +155,7 @@ A confirmation prompt blocks browser/tab close while any experiment is initializ ### Notifications -The Summary's progress bar tracks the selected combination (runs sampled over the run budget). While anything computes, a **"N computing"** chip appears beside it — a sweep runs several simulations in parallel (the selection's own batches, surface chunks, cell refinements) — and clicking the chip expands a compact list with each batch's kind and progress. Selection batches are the priority work and sort first. +While anything computes, a **"N computing"** chip sits at the end of the header's stats line — a sweep runs several simulations in parallel (the selection's own batches, surface chunks, cell refinements) — and clicking the chip opens a compact list with each batch's kind and progress. Selection batches are the priority work and sort first. A small toast appears when an experiment **completes** or **errors**, even if its drawer isn't open. The top-bar **Active experiments** popover (see below) lets you jump to any in-flight experiment from anywhere in the app. diff --git a/libs/@hashintel/petrinaut/docs/optimization.md b/libs/@hashintel/petrinaut/docs/optimization.md index 9cd55345598..bef4088ebdf 100644 --- a/libs/@hashintel/petrinaut/docs/optimization.md +++ b/libs/@hashintel/petrinaut/docs/optimization.md @@ -120,30 +120,43 @@ records can be removed from their result drawer; a paused record's **Remove** sits in the **More actions** menu at the left of its footer. The drawer's footer offers **Open full view**, which gives the whole -Optimizations section to the study: the same summary, controls, charts and -steps, spread over the section's width so the three chart cards sit side by -side. Its top bar holds **Back to list**, which returns to the list, and -**Show in drawer**, which shows the same study in the drawer again. Both +Optimizations section to the study: the same header, controls, charts and +steps, spread over the section's width so the controls and the surface sit on +the left and the chart cards on the right. Its header starts with **Back to +list**, which returns to the list, and its footer holds **Show in drawer**, +which shows the same study in the drawer again. Both presentations are places in the app, so the browser's Back button undoes the switch. Once you are in the full presentation, opening another row from the list opens it full too. -Every study opens with a summary band that holds still while the rest -scrolls beneath it. Its first line says where the study is: **Step 17 of 30 · -best step so far: step 12 (650.5)** while it runs, or **Stopped after 17 of -30 steps** (or **Finished**, **Cancelled**, **Failed**) once it is over. +Every study opens under a header that holds still while the body scrolls +beneath it. Its title is one line: the study's name, the scenario and the +objective, **Maximize profit · Rich stock · Maximize Adjusted profit**. At the +right of the title a line says where the study is: **Step 17 of 30 · best +step so far: step 12 (650.5)** while it runs, or **Stopped after 17 of 30 +steps** (or **Finished**, **Cancelled**, **Failed**) once it is over. While it runs, a chip beside the line says whether the study is still finding better steps: **Still improving** when the best moved within the last few completed steps (a tenth of the requested steps, five at least), **Converging** when that many steps passed without a better one, and **Too early to say** before one such window has completed. -Beneath the line, the band shows the status, the steps finished over the -steps requested (with the runs per step when above one), and **Best step so -far**, the best value seen so far (hover it for the best step's parameters), -with a progress bar for the steps beneath. The value is named for what it -is: the best of the steps tried, not a confirmed result at that -configuration. +Beneath the title, the header's stats line shows the status, the steps +finished over the steps requested (with the runs per step when above one), +and **Best step so far**, the best value seen so far (hover it for the best +step's parameters). A progress bar for the steps runs along the header's +bottom edge. The value is named for what it is: the best of the steps tried, +not a confirmed result at that configuration. Once the body has scrolled the +header condenses to one line, the stats folded in as compact chips; move the +pointer over it and it grows back. The line under the header is reserved for +a note (the error when a study failed, the resume note while it is paused), +so nothing moves when one appears. + +The body arranges its parts by its width. In the full view the **Parameters** +band and the **Objective surface** sit on the left and the other chart cards +on the right; in the drawer the chart cards come first, then **Parameters**, +then the surface. The steps table follows at a fixed height and scrolls on +its own. Every card keeps its height whatever it shows. The **Objective by step** card draws every step's objective value as a dot over the step number, with the best so far as a line stepping up (or down, @@ -153,7 +166,7 @@ The line under the title counts the completed steps. The steps table sits at the bottom, newest steps first, each with its parameters, objective value and a state mark (complete, pruned or failed). It scrolls on its own, and a long study shows its newest 200 steps while the -strip keeps the totals and the best. A study with +header keeps the totals and the best. A study with [constraints](#constraints) run in the browser adds a **Runs passed** column (`52 / 60 · 87%`, the constraint with the fewest passing runs when there are several; hover the mark for the rest) and greys the rows of infeasible steps, @@ -166,8 +179,8 @@ in the view claims to still be following a step. ### On the optimization service -A study run on the optimization service adds a **Best parameters** section -between the strip and the steps: the best step's value for every scenario +A study run on the optimization service adds a **Best parameters** band +beside the Objective by step card: the best step's value for every scenario parameter. **Cancel** ends the run on the server, and its status reads **Cancelled**. @@ -180,17 +193,16 @@ kept, and a **Retry** action starts a fresh run with the same settings. A study that runs in the browser (see [Running in the browser](#running-in-the-browser)) shows more, because the machine computing -it is yours, and lays it out so the strip, the controls and the plots stay in -view on a laptop screen while the study streams: - -- The summary band also shows the parallel steps when above one, and, at the - right of its first line, a badge saying where the steps run. It reads - **CPU**, because the GPU backend cannot - compute an expression objective (see step 4 of - [Creating an optimization](#creating-an-optimization)). Under the steps bar, - a thinner bar tracks the runs of the step in flight, and an **N computing** - chip appears while anything computes (the steps in flight and the picked - point's refinement), expanding into one row per batch with its own progress. +it is yours, and lays it out so the header, the controls and the plots stay +in view on a laptop screen while the study streams: + +- The header's stats line also shows the parallel steps when above one, and, + at its right, a badge saying where the steps run. It reads **CPU**, because + the GPU backend cannot compute an expression objective (see step 4 of + [Creating an optimization](#creating-an-optimization)); hover it for the + reason. An **N computing** chip sits at the end of the stats line while + anything computes (the steps in flight and the picked point's refinement), + opening a compact list with one row per batch and its own progress. - A **Parameters** band with one slider per optimized numeric parameter and a switch per optimized boolean parameter, two to a row when they fit. Its heading carries the state line and the **Follow steps** switch. While the @@ -239,7 +251,7 @@ view on a laptop screen while the study streams: the latest step's verdict (clear, limited or infeasible) with the runs that passed its tightest constraint, and one bar per state constraint shows the share of steps it passed, with a dashed mark at the threshold. The same - headline sits in the summary band as **Steps clear**. Infeasible draws are + headline sits in the header's stats line as **Steps clear**. Infeasible draws are grey dots on the Objective by step chart and hollow grey rings on the surface, and the best step so far is never one of them. - The **Sensitivity analysis** card, last in the row, ranks the optimized From 4ca187d902e2d9696a52bb29d95b82e63ce15b92 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 09:36:41 +0200 Subject: [PATCH 05/13] Condense the frame header on scroll even after a focused header control went idle The study drawer's opening focus landed on the computing chip inside the header. The chip was disabled under it without a blur, so the header's engaged state stuck at true and the header stayed at 56px on scroll until the pointer visited it. The drawer body now takes the opening focus, and the frame remembers which element took focus in the header and drops it on scroll once that element is disabled, removed or no longer active. The ds Drawer's focus ref props accept a ref that starts null, as the Popover's do. --- .changeset/drawer-focus-refs-nullable.md | 5 + .../src/components/Drawer/drawer.tsx | 4 +- .../view-experiment-drawer.test.tsx | 19 ++- .../optimization-full-view.test.tsx | 15 +++ .../view-optimization-drawer.test.tsx | 76 +++++++++++- .../SimulateView/shared/drawer-frame.test.tsx | 110 ++++++++++++------ .../SimulateView/shared/drawer-frame.tsx | 25 +++- .../shared/drawer-frame/frame-header.tsx | 12 +- .../shared/drawer-frame/frame-test-helpers.ts | 21 ++++ .../shared/drawer-frame/use-header-engaged.ts | 51 ++++++++ 10 files changed, 287 insertions(+), 51 deletions(-) create mode 100644 .changeset/drawer-focus-refs-nullable.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-test-helpers.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-header-engaged.ts diff --git a/.changeset/drawer-focus-refs-nullable.md b/.changeset/drawer-focus-refs-nullable.md new file mode 100644 index 00000000000..a3239b461d9 --- /dev/null +++ b/.changeset/drawer-focus-refs-nullable.md @@ -0,0 +1,5 @@ +--- +"@hashintel/ds-components": patch +--- + +`Drawer` accepts `initialFocusRef` and `returnFocusRef` refs that start `null`, as `Popover` does. diff --git a/libs/@hashintel/ds-components/src/components/Drawer/drawer.tsx b/libs/@hashintel/ds-components/src/components/Drawer/drawer.tsx index 3ccab0c7867..2fb28d0b135 100644 --- a/libs/@hashintel/ds-components/src/components/Drawer/drawer.tsx +++ b/libs/@hashintel/ds-components/src/components/Drawer/drawer.tsx @@ -285,8 +285,8 @@ const DrawerRoot = ({ shouldCloseOn?: OverlayShouldCloseOn; loading?: boolean; onClose?: () => void; - initialFocusRef?: React.RefObject; - returnFocusRef?: React.RefObject; + initialFocusRef?: React.RefObject; + returnFocusRef?: React.RefObject; } & React.AriaAttributes) => { const portalContainerRef = usePortalContainerRef(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx index 37728f8afc3..e77d0800900 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx @@ -4,7 +4,14 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { frameLayoutSignature } from "../shared/drawer-frame"; +import { + FRAME_HEADER_CONDENSED_HEIGHT, + frameLayoutSignature, +} from "../shared/drawer-frame"; +import { + frameHeader, + scrollFrameBody, +} from "../shared/drawer-frame/frame-test-helpers"; import { makeExperiment, makeParameterSweepExperiment, @@ -160,6 +167,16 @@ describe("ViewExperimentDrawer in the frame", () => { expect(screen.getByTestId("sweep-surface")).toBeTruthy(); }); + it("condenses the header once the body scrolls, with nothing else involved", () => { + renderDrawer(sweep); + + scrollFrameBody(80); + expect(frameHeader().style.height).toBe( + `${FRAME_HEADER_CONDENSED_HEIGHT}px`, + ); + expect(frameHeader().dataset.condensed).toBe("true"); + }); + it("keeps the header, the note row and every card at one height across running, complete, cancelled and error", () => { const signatures = ( ["running", "complete", "cancelled", "error"] as const diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx index b7c4cddb0f1..76d83a6ffb4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/optimization-full-view.test.tsx @@ -7,6 +7,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { OptimizationsContext } from "../../../../../../react/optimizations/context"; import { EditorContext } from "../../../../../../react/state/editor-context"; +import { FRAME_HEADER_CONDENSED_HEIGHT } from "../shared/drawer-frame"; +import { + frameHeader, + scrollFrameBody, +} from "../shared/drawer-frame/frame-test-helpers"; import { OptimizationFullView } from "./optimization-full-view"; import { makeConnectedStudyState, @@ -164,6 +169,16 @@ describe("OptimizationFullView", () => { expect(setSimulatePresentation).toHaveBeenCalledWith("drawer"); }); + it("condenses the header once the body scrolls", () => { + renderFullView(running); + + scrollFrameBody(80); + expect(frameHeader().style.height).toBe( + `${FRAME_HEADER_CONDENSED_HEIGHT}px`, + ); + expect(frameHeader().dataset.condensed).toBe("true"); + }); + it("shows the results once the study settled: no verdict, no live titles", () => { const setSelectedOptimizationId = vi.fn(); const removeOptimization = vi.fn(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx index af080ac1c92..9e866a87833 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx @@ -1,7 +1,13 @@ /** * @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import { cloneElement, use, useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -12,7 +18,14 @@ import { type OptimizationsContextValue, } from "../../../../../../react/optimizations/context"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; -import { frameLayoutSignature } from "../shared/drawer-frame"; +import { + FRAME_HEADER_CONDENSED_HEIGHT, + frameLayoutSignature, +} from "../shared/drawer-frame"; +import { + frameHeader, + scrollFrameBody, +} from "../shared/drawer-frame/frame-test-helpers"; import { fakeConstrainedStudyInput, fakeConstrainedStudyTrials, @@ -565,6 +578,65 @@ describe("ViewOptimizationDrawer for a connected study", () => { expect(extendOptimization).toHaveBeenCalledWith(stopped.id, 4); }); + it("condenses the header once the body scrolls, also after the focused computing chip went idle", () => { + const view = renderDrawer(connected); + + scrollFrameBody(80); + expect(frameHeader().style.height).toBe( + `${FRAME_HEADER_CONDENSED_HEIGHT}px`, + ); + scrollFrameBody(0); + + // Focus lands on the chip while a batch computes, then the batch ends and + // the chip is disabled under the focus without a blur. + const computing = { + ...connected, + connected: { + ...following, + activity: [ + { + id: 3, + kind: "trial" as const, + trial: 2, + runCount: 1, + completedRuns: 0, + }, + ], + }, + }; + view.rerender( + + + {}} + optimization={computing} + /> + + , + ); + act(() => screen.getByRole("button", { name: /1 computing/ }).focus()); + view.rerender( + + + {}} + optimization={connected} + /> + + , + ); + scrollFrameBody(80); + expect(frameHeader().style.height).toBe( + `${FRAME_HEADER_CONDENSED_HEIGHT}px`, + ); + }); + it("lists the batches computing from the computing chip", () => { renderDrawer({ ...connected, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx index 84a028168fd..ac1c2b31ef4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx @@ -1,7 +1,13 @@ /** * @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -11,44 +17,54 @@ import { FrameStat, FrameStatusPill, } from "./drawer-frame"; +import { + frameHeader as header, + scrollFrameBody as scrollBodyTo, +} from "./drawer-frame/frame-test-helpers"; + +import type { ReactNode } from "react"; afterEach(cleanup); +/** The computing chip's shape: a button in the stats line, disabled once nothing computes. */ +const computingChip = (computing: boolean) => ( + +); + +const frame = ({ + note = null, + stats, +}: { + note?: { content: string; tone: "error" } | null; + stats?: ReactNode; +} = {}) => ( + Step 3 of 30} + stats={ + <> + + Running + + + 100 complete + + {stats} + + } + badge={CPU} + progress={40} + note={note} + footer={} + > +
+ +); + const renderFrame = (note: { content: string; tone: "error" } | null = null) => - render( - Step 3 of 30} - stats={ - <> - - Running - - - 100 complete - - - } - badge={CPU} - progress={40} - note={note} - footer={} - > -
- , - ); - -const header = () => - document.querySelector("[data-frame-header]")!; -const body = () => document.querySelector("[data-frame-body]")!; - -const scrollBodyTo = (top: number) => { - Object.defineProperty(body(), "scrollTop", { - configurable: true, - value: top, - }); - fireEvent.scroll(body()); -}; + render(frame({ note })); describe("DrawerFrame", () => { it("renders the title, the stats with their labels, the badge, the bar and the footer at rest", () => { @@ -105,6 +121,30 @@ describe("DrawerFrame", () => { expect(header().style.height).toBe(`${FRAME_HEADER_HEIGHT}px`); }); + it("holds its height while a control inside it has the focus, and condenses once that control lost it without a blur", () => { + const view = render(frame({ stats: computingChip(true) })); + const chip = screen.getByRole("button", { name: "1 computing" }); + act(() => chip.focus()); + + // Keyboard focus on a header control holds the header open. + scrollBodyTo(48); + expect(header().style.height).toBe(`${FRAME_HEADER_HEIGHT}px`); + + // The chip goes idle under the focus: disabled, so it fires no blur. + view.rerender(frame({ stats: computingChip(false) })); + scrollBodyTo(60); + expect(header().style.height).toBe(`${FRAME_HEADER_CONDENSED_HEIGHT}px`); + expect(header().dataset.condensed).toBe("true"); + view.unmount(); + + // The same when the focused control is unmounted. + const removed = render(frame({ stats: computingChip(true) })); + act(() => screen.getByRole("button", { name: "1 computing" }).focus()); + removed.rerender(frame()); + scrollBodyTo(60); + expect(header().style.height).toBe(`${FRAME_HEADER_CONDENSED_HEIGHT}px`); + }); + it("keeps the note row mounted at one height whether or not there is a note", () => { const empty = renderFrame(); const emptyRow = document.querySelector("[data-frame-note]")!; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx index 42cfc1a7c4d..6a7cf6a5b1f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx @@ -6,9 +6,11 @@ * changes the body's available height and never its scroll offset. The body * is a size container; `FrameColumns` arranges its content by the body's * width. A `note` row is always mounted, empty when there is nothing to say, - * so an error or a resume note appearing moves nothing. + * so an error or a resume note appearing moves nothing. In a drawer the body + * takes the opening focus, so wheel and arrow keys scroll it at once and no + * control in the header holds the header open. */ -import { use, useState, type ReactNode } from "react"; +import { type ReactNode, use, useRef } from "react"; import { Drawer } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; @@ -16,6 +18,7 @@ import { css } from "@hashintel/ds-helpers/css"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; import { FrameHeader } from "./drawer-frame/frame-header"; import { useBodyScrolled } from "./drawer-frame/use-body-scrolled"; +import { useHeaderEngaged } from "./drawer-frame/use-header-engaged"; import { usePrefersReducedMotion } from "./drawer-frame/use-prefers-reduced-motion"; export { @@ -111,6 +114,7 @@ const bodyStyle = css({ minWidth: "[0]", overflowY: "auto", overflowX: "hidden", + outline: "none", scrollbarWidth: "[thin]", scrollbarGutter: "stable", paddingX: "5", @@ -171,7 +175,8 @@ export const DrawerFrame = ({ const { showAnimations } = use(UserSettingsContext); const reducedMotion = usePrefersReducedMotion(); const { scrolled, onScroll } = useBodyScrolled(); - const [engaged, setEngaged] = useState(false); + const { engaged, engagement, settleFocus } = useHeaderEngaged(); + const bodyRef = useRef(null); const condensed = scrolled && !engaged; const header = ( @@ -185,12 +190,21 @@ export const DrawerFrame = ({ condensed={condensed} animate={showAnimations && !reducedMotion} closeGutter={drawer === undefined ? 0 : DRAWER_CLOSE_GUTTER} - onEngagedChange={setEngaged} + engagement={engagement} /> ); const body = ( -
+
{ + onScroll(event); + settleFocus(); + }} + >
{header} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx index bae23722f56..7550f2a3f9e 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx @@ -9,6 +9,8 @@ import { createContext, use, type ReactNode } from "react"; import { css, cx } from "@hashintel/ds-helpers/css"; +import type { FrameHeaderEngagement } from "./use-header-engaged"; + /** The header's height in pixels at rest: the title line, the stats line and the bar. */ export const FRAME_HEADER_HEIGHT = 56; /** The header's height in pixels once the body has scrolled: one line and the bar. */ @@ -204,7 +206,8 @@ export type FrameHeaderProps = { animate: boolean; /** Room kept clear on the right for a close button the surrounding chrome draws. */ closeGutter?: number; - onEngagedChange: (engaged: boolean) => void; + /** The pointer and focus handlers that hold the header open while the body is scrolled. */ + engagement: FrameHeaderEngagement; }; export const FrameHeader = ({ @@ -217,7 +220,7 @@ export const FrameHeader = ({ condensed, animate, closeGutter = 0, - onEngagedChange, + engagement, }: FrameHeaderProps) => (
0 ? closeGutter : undefined, }} - onPointerEnter={() => onEngagedChange(true)} - onPointerLeave={() => onEngagedChange(false)} - onFocus={() => onEngagedChange(true)} - onBlur={() => onEngagedChange(false)} + {...engagement} >
{leading === undefined ? null : leading} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-test-helpers.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-test-helpers.ts new file mode 100644 index 00000000000..51d3359473b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-test-helpers.ts @@ -0,0 +1,21 @@ +import { fireEvent } from "@testing-library/react"; + +/** The frame header's element, for tests that read its height and state. */ +export const frameHeader = (): HTMLElement => + document.querySelector("[data-frame-header]")!; + +/** The frame body's scroll container. */ +export const frameBody = (): HTMLElement => + document.querySelector("[data-frame-body]")!; + +/** + * Scrolls the frame body to `top` the way a wheel would: jsdom lays nothing + * out, so the offset is set on the element and its scroll event fired. + */ +export const scrollFrameBody = (top: number): void => { + Object.defineProperty(frameBody(), "scrollTop", { + configurable: true, + value: top, + }); + fireEvent.scroll(frameBody()); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-header-engaged.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-header-engaged.ts new file mode 100644 index 00000000000..b89cc2611d6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/use-header-engaged.ts @@ -0,0 +1,51 @@ +import { type FocusEvent, useState } from "react"; + +/** + * The handlers the header's root element takes so the pointer and the + * keyboard focus can hold it at its full height while the body is scrolled. + */ +export type FrameHeaderEngagement = { + onPointerEnter: () => void; + onPointerLeave: () => void; + onFocus: (event: FocusEvent) => void; + onBlur: () => void; +}; + +/** + * Whether the pointer or the keyboard focus is on the header. The pointer + * is tracked by its enter and leave; focus is remembered as the element + * that took it, because an element disabled or removed while focused fires + * no blur and would otherwise hold the header open for good. `settleFocus`, + * called when the body scrolls, drops a remembered focus that element no + * longer holds. + */ +export const useHeaderEngaged = (): { + engaged: boolean; + engagement: FrameHeaderEngagement; + settleFocus: () => void; +} => { + const [hovered, setHovered] = useState(false); + const [focusedElement, setFocusedElement] = useState(null); + + return { + engaged: hovered || focusedElement !== null, + engagement: { + onPointerEnter: () => setHovered(true), + onPointerLeave: () => setHovered(false), + onFocus: (event) => setFocusedElement(event.target), + onBlur: () => setFocusedElement(null), + }, + settleFocus: () => { + if (focusedElement === null) { + return; + } + const holdsFocus = + focusedElement.isConnected && + document.activeElement === focusedElement && + !focusedElement.matches(":disabled"); + if (!holdsFocus) { + setFocusedElement(null); + } + }, + }; +}; From 7299dd6d1c9f5b280b64681e1da5487d387e445b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 09:41:36 +0200 Subject: [PATCH 06/13] Lay the frame out in two columns at the extra-large drawer's width The two-column arrangement started at 1100px of body width while the extra-large drawer's body measures 1010px, 995px beside a classic scrollbar, so every drawer stacked and a two-axis sweep with four metrics could still put the metrics below the fold. The threshold is 960px, and the secondary column is never narrower than two chart cards and their gap: the primary column yields until the body is wide enough for the 3:5 split. The chart card minimum is one shared constant. A "Sweep, four metrics" story shows the common case. --- libs/@hashintel/petrinaut/docs/experiments.md | 2 +- .../@hashintel/petrinaut/docs/optimization.md | 11 +++--- .../view-experiment-drawer.stories.tsx | 27 ++++++++++++++ .../SimulateView/optimizations/study-view.tsx | 7 ++-- .../panels/SimulateView/shared/chart-card.tsx | 6 ++++ .../SimulateView/shared/drawer-frame.test.tsx | 14 ++++++++ .../SimulateView/shared/drawer-frame.tsx | 1 + .../shared/drawer-frame/frame-columns.tsx | 36 +++++++++++++------ .../SimulateView/shared/metric-tiles.tsx | 11 +++--- 9 files changed, 90 insertions(+), 25 deletions(-) diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 768a2b88764..58b0309a255 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -76,7 +76,7 @@ Every selection uses the same seed sequence (common random numbers), and a run's A sweep with two or more swept parameters grows a **Surface** card under the **Parameters** band: a contour plot of one metric's final value over two parameters you pick, with every other parameter held at the middle of its selected range. The **X**, **Y** and **Metric** pickers sit in the row under the plot, and the line under the card's title reads the sampling progress (or, mid-drag, the values under the pointer). The plot fills in live and coarse-first — the four corners, then ever finer subdivisions, each level a complete picture (8 runs per point) — and **the surface is itself a control**: click, or press and drag with a live crosshair and value readout, and on release both shown parameters collapse to a point there, which then refines with more runs. An orange ring marks where the navigator currently sits. Your selected point always computes first: the metric charts start streaming before surface sampling begins, and after every slider move the surface waits for the new selection's first frames before continuing. Every metric is measured on the same samples, so switching the shown metric repaints instantly from what was already computed; changing the fixed parameters or the axes restarts the fill for the new slice. -The drawer arranges its parts by its width. In a wide view (the full-size presentation, above about 1100px) the **Parameters** band and the **Surface** sit on the left and the metric cards on the right; in the drawer the metric cards come first, then **Parameters**, then **Surface**, so the charts you watch are at the top either way. Every card keeps a fixed height, and only the body scrolls, under the header. +The drawer arranges its parts by its width. At the drawer's full width, and in the full-size presentation, the **Parameters** band and the **Surface** sit on the left and the metric cards on the right, two to a row, so two swept parameters and up to four metrics fit without scrolling; in a narrower drawer the metric cards come first, then **Parameters**, then **Surface**, so the charts you watch are at the top either way. Every card keeps a fixed height, and only the body scrolls, under the header. ### Compute backend (experimental) diff --git a/libs/@hashintel/petrinaut/docs/optimization.md b/libs/@hashintel/petrinaut/docs/optimization.md index bef4088ebdf..73477be67c9 100644 --- a/libs/@hashintel/petrinaut/docs/optimization.md +++ b/libs/@hashintel/petrinaut/docs/optimization.md @@ -152,11 +152,12 @@ pointer over it and it grows back. The line under the header is reserved for a note (the error when a study failed, the resume note while it is paused), so nothing moves when one appears. -The body arranges its parts by its width. In the full view the **Parameters** -band and the **Objective surface** sit on the left and the other chart cards -on the right; in the drawer the chart cards come first, then **Parameters**, -then the surface. The steps table follows at a fixed height and scrolls on -its own. Every card keeps its height whatever it shows. +The body arranges its parts by its width. In the drawer at its full width and +in the full view the **Parameters** band and the **Objective surface** sit on +the left and the other chart cards on the right, two to a row; in a narrower +drawer the chart cards come first, then **Parameters**, then the surface. The +steps table follows at a fixed height and scrolls on its own. Every card keeps +its height whatever it shows. The **Objective by step** card draws every step's objective value as a dot over the step number, with the best so far as a line stepping up (or down, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx index 74c997a12cd..b663d24f752 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.stories.tsx @@ -45,6 +45,33 @@ export const Sweep: Story = { ), }; +/** The two-axis sweep with four metrics: the common case the drawer must show without scrolling. */ +const fourMetricSweep = (): ExperimentRecord => { + const sweep = makeParameterSweepExperiment(); + const infected = sweep.metricSpecs[0]!; + return { + ...sweep, + metricSpecs: [ + infected, + { ...infected, id: "susceptible", label: "Susceptible" }, + { ...infected, id: "recovered", label: "Recovered" }, + { ...infected, id: "hospitalised", label: "Hospitalised" }, + ], + }; +}; + +export const FourMetrics: Story = { + name: "Sweep, four metrics", + render: () => ( + + + + ), +}; + /** A plain experiment: no parameters, no surface, the metric cards alone under the header. */ const plainExperiment = ( status: ExperimentRecord["status"], diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx index 7d43922df0d..037a36cbaa1 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx @@ -22,6 +22,7 @@ import { import { EditorContext } from "../../../../../../react/state/editor-context"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; import { + CHART_CARD_MIN_WIDTH, ChartCardGrid, chartCardHeight, ChartCardMenu, @@ -66,8 +67,6 @@ export { describeStudyProgress } from "./study-view/study-header"; const STUDY_CARD_HEIGHT = chartCardHeight({ bodyHeight: OBJECTIVE_PLOT_HEIGHT, }); -/** Two cards per row in the frame's right column above 1100px and in the drawer; one when narrower. */ -const STUDY_CARD_MIN_WIDTH = 320; /** The steps table's fixed height in pixels; the steps scroll inside it. */ const STEPS_TABLE_HEIGHT = 320; @@ -262,7 +261,7 @@ const RemoteStudyBody = ({ } secondary={ {/* Keyed so faded previous pictures never leak from one study into diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx index 67a626cf70c..0bd6ed0fc06 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/chart-card.tsx @@ -244,6 +244,12 @@ export const ChartCardMenu = ({ label, items }: ChartCardMenuProps) => ( /> ); +/** + * The narrowest a chart card gets in a grid, in pixels: two fit side by side + * in the frame's secondary column from its minimum width up. + */ +export const CHART_CARD_MIN_WIDTH = 320; + const gridStyle = css({ display: "grid", alignItems: "stretch", diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx index ac1c2b31ef4..d3f1eaf5b84 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx @@ -10,10 +10,13 @@ import { } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; +import { CHART_CARD_MIN_WIDTH } from "./chart-card"; import { DrawerFrame, FRAME_HEADER_CONDENSED_HEIGHT, FRAME_HEADER_HEIGHT, + FRAME_SECONDARY_MIN_WIDTH, + FRAME_TWO_COLUMN_MIN_WIDTH, FrameStat, FrameStatusPill, } from "./drawer-frame"; @@ -145,6 +148,17 @@ describe("DrawerFrame", () => { expect(header().style.height).toBe(`${FRAME_HEADER_CONDENSED_HEIGHT}px`); }); + it("gives the secondary column room for two chart cards and their gap from the two-column width up", () => { + // The grid's gap is the `3` spacing token, 12px. + expect(FRAME_SECONDARY_MIN_WIDTH).toBe(2 * CHART_CARD_MIN_WIDTH + 12); + // The extra-large drawer's body content box is 1010px, 995px beside a + // classic scrollbar; both are two-column widths. + expect(FRAME_TWO_COLUMN_MIN_WIDTH).toBeLessThanOrEqual(995); + expect(FRAME_TWO_COLUMN_MIN_WIDTH).toBeGreaterThan( + FRAME_SECONDARY_MIN_WIDTH, + ); + }); + it("keeps the note row mounted at one height whether or not there is a note", () => { const empty = renderFrame(); const emptyRow = document.querySelector("[data-frame-note]")!; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx index 6a7cf6a5b1f..ccfcfa85706 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx @@ -29,6 +29,7 @@ export { type FrameStatusTone, } from "./drawer-frame/frame-header"; export { + FRAME_SECONDARY_MIN_WIDTH, FRAME_TWO_COLUMN_MIN_WIDTH, FrameColumns, } from "./drawer-frame/frame-columns"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx index 170afeb1382..9f1a28bfdd4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx @@ -1,28 +1,42 @@ /** - * The frame body's arrangement. Wide enough, two columns: the parameters - * and the surface on the left, the metric cards on the right. Narrower, one - * column with the metric cards first, then the parameters, then the surface, - * so the thing being watched is at the top either way. Whatever follows - * (a steps table) spans the width beneath. + * The frame body's arrangement. At the extra-large drawer's width and above, + * two columns: the parameters and the surface on the left, the metric cards + * on the right, the right column never narrower than two cards. In a + * narrower drawer, one column with the metric cards first, then the + * parameters, then the surface, so the thing being watched is at the top + * either way. Whatever follows (a steps table) spans the width beneath. */ import { css } from "@hashintel/ds-helpers/css"; import type { ReactNode } from "react"; -/** The body width, in pixels, from which the two-column arrangement applies. */ -export const FRAME_TWO_COLUMN_MIN_WIDTH = 1100; +/** + * The body width, in pixels, from which the two-column arrangement applies. + * The extra-large ds drawer is 1060px wide and its body's content box 1010px, + * 995px beside a classic scrollbar; the threshold sits under that with room + * for a viewport that clamps the drawer a little. + */ +export const FRAME_TWO_COLUMN_MIN_WIDTH = 960; + +/** + * The secondary column's minimum width in pixels: two chart cards at + * `CHART_CARD_MIN_WIDTH` and the grid's 12px gap between them. The primary + * column yields until the body is wide enough for the 3:5 split. + */ +export const FRAME_SECONDARY_MIN_WIDTH = 652; // The body is the `drawer-frame-body` size container (declared in -// drawer-frame.tsx); Panda extracts the query statically, so the threshold is -// written out here and mirrored by the constant above. +// drawer-frame.tsx); Panda extracts the query statically, so the threshold +// and the column minimum are written out here and mirrored by the constants +// above. const columnsStyle = css({ display: "grid", gap: "4", alignItems: "start", gridTemplateColumns: "minmax(0, 1fr)", gridTemplateAreas: '"secondary" "primary" "after"', - "@container drawer-frame-body (min-width: 1100px)": { - gridTemplateColumns: "minmax(0, 3fr) minmax(0, 5fr)", + "@container drawer-frame-body (min-width: 960px)": { + gridTemplateColumns: "minmax(0, 3fr) minmax(652px, 5fr)", gridTemplateAreas: '"primary secondary" "after after"', }, }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx index 3a42a9a0216..22ea35b51cd 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/metric-tiles.tsx @@ -13,7 +13,12 @@ import { MetricViewMenu, type MetricViewSettings, } from "../experiments/experiment-metric-timeline"; -import { ChartCard, ChartCardGrid, chartCardHeight } from "./chart-card"; +import { + CHART_CARD_MIN_WIDTH, + ChartCard, + ChartCardGrid, + chartCardHeight, +} from "./chart-card"; import type { MonteCarloUserDefinedMetricFrame } from "@hashintel/petrinaut-core"; @@ -30,8 +35,6 @@ export const METRIC_PLOT_HEIGHT = 220; export const METRIC_CARD_HEIGHT = chartCardHeight({ bodyHeight: METRIC_PLOT_HEIGHT, }); -/** Two cards per row in an extra-large drawer and in the frame's right column above 1100px; one when narrower. */ -const METRIC_CARD_MIN_WIDTH = 320; export const MetricTiles = ({ tiles, @@ -53,7 +56,7 @@ export const MetricTiles = ({ return ( {tiles.map((tile) => { From 24bc96c7de673857bfcf38c570a20af5929a9ba9 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 09:48:43 +0200 Subject: [PATCH 07/13] Keep the band title row and the navigator status lines on one line In the two-column drawer the primary column is 342px wide, and the Parameters band's status wrapped onto a second line while a study followed its steps, then unwrapped on Pause: a 20.8px shift in the column below. The band's title row is a fixed 24px, its trailing readout clips with an ellipsis and a tooltip instead of wrapping, and the Follow steps toggle never shrinks. The sweep navigator's sampling status line is one fixed line the same way, and the study navigator's control grid never exceeds the band's width. --- .../experiments/sweep-navigator.tsx | 11 ++++++++++- .../study-view/optimization-navigator.tsx | 17 +++++++++++++---- .../shared/drawer-frame/frame-band.tsx | 6 +++++- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx index 24cf6a6231f..911dd91ac45 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx @@ -83,16 +83,25 @@ const sliderStyle = css({ flex: "1", }); +// One line whatever the band's width, so a change of wording never moves +// what follows the band. const statusStyle = css({ display: "flex", alignItems: "center", gap: "[6px]", // Aligns under the sliders: the 140px name column plus the row gap. paddingLeft: "[148px]", + minWidth: "[0]", fontSize: "xs", color: "neutral.s80", fontVariantNumeric: "tabular-nums", - minHeight: "[16px]", + whiteSpace: "nowrap", + height: "[16px]", + "& > span:last-child": { + minWidth: "[0]", + overflow: "hidden", + textOverflow: "ellipsis", + }, }); const spinnerSlotStyle = css({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx index d0e8c9daa50..7ff2709fda5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx @@ -64,10 +64,11 @@ const axisStepAt = (axis: OptimizationSurfaceAxis, position: number): number => ) / 2; // Two columns of controls when the band is wide enough for two readable -// sliders, so several parameters cost one row per pair. +// sliders, so several parameters cost one row per pair; one column, never +// wider than the band, otherwise. const navigatorStyle = css({ display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(400px, 1fr))", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 400px), 1fr))", columnGap: "8", rowGap: "[6px]", }); @@ -104,14 +105,18 @@ const readoutStyle = css({ textAlign: "right", }); +// One line whatever the band's width: the text clips with an ellipsis rather +// than wrapping, so a status change never changes the row's height. const statusStyle = css({ display: "flex", alignItems: "center", gap: "2", + minWidth: "[0]", fontSize: "xs", color: "neutral.s80", fontVariantNumeric: "tabular-nums", - minHeight: "[24px]", + whiteSpace: "nowrap", + height: "[24px]", }); const spinnerSlotStyle = css({ @@ -120,14 +125,17 @@ const spinnerSlotStyle = css({ }); const statusTextStyle = css({ + minWidth: "[0]", + overflow: "hidden", + textOverflow: "ellipsis", "&[data-tone=error]": { color: "red.s100", - whiteSpace: "pre-wrap", }, }); const followStyle = css({ marginLeft: "2", + flexShrink: "0", fontSize: "xs", color: "neutral.s100", }); @@ -247,6 +255,7 @@ export const OptimizationNavigatorStatus = ({ data-tone={ selection !== null && selection.error !== null ? "error" : undefined } + title={describeSelection(selection, running)} > {describeSelection(selection, running)} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx index 4e984a59e0c..8b88c0c807a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx @@ -15,12 +15,14 @@ const bandStyle = css({ minWidth: "[0]", }); +// The row is one line at a fixed height: whatever the trailing readout says, +// the controls below it never move. const headerRowStyle = css({ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "2", - minHeight: "[24px]", + height: "[24px]", }); const headerLeftStyle = css({ @@ -41,6 +43,8 @@ const trailingStyle = css({ display: "flex", alignItems: "center", minWidth: "[0]", + overflow: "hidden", + whiteSpace: "nowrap", }); export const FrameBand = ({ From 51b2657e7a88ef47d14f004d0dd7d44ec1ddf784 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 12:33:41 +0200 Subject: [PATCH 08/13] Lay the frame header's stats out as labelled columns sized by their widest value Every stat is a column with its label over its value and a hairline before it, as the old summary strip drew them. The column is exactly as wide as the widest text its value can show, laid invisibly under the live one, so a number growing a digit, a status changing word or the computing count going to zero moves nothing. The computing chip is always drawn, 0 computing and disabled while nothing runs, with room for a three-digit count; the compute badge is the strip's last column and stays among the compact chips once the header condenses. The header is 68px at rest for the two-line columns. --- .../view-experiment-drawer.test.tsx | 12 +- .../experiment-stats.tsx | 63 ++--- .../optimizations/study-view/study-stats.tsx | 62 +++-- .../view-optimization-drawer.test.tsx | 13 +- .../drawer-frame/compute-batches-chip.tsx | 42 ++- .../shared/drawer-frame/frame-header.tsx | 248 +++++++++++------- 6 files changed, 261 insertions(+), 179 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx index e77d0800900..1746757dc23 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.test.tsx @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { FRAME_HEADER_CONDENSED_HEIGHT, + FRAME_HEADER_HEIGHT, frameLayoutSignature, } from "../shared/drawer-frame"; import { @@ -156,9 +157,12 @@ describe("ViewExperimentDrawer in the frame", () => { ), ).toBeTruthy(); expect(screen.getByText("Running")).toBeTruthy(); - expect(screen.getByText("Runs").nextElementSibling?.textContent).toMatch( - /active, .* complete$/u, - ); + expect( + screen + .getByText("Runs") + .nextElementSibling?.querySelector("[data-frame-stat-value]") + ?.textContent, + ).toMatch(/^\d+ active, \d+ complete$/u); expect(screen.getByText("Selection")).toBeTruthy(); expect(screen.getByText("CPU")).toBeTruthy(); expect(screen.queryByText("Summary")).toBeNull(); @@ -187,7 +191,7 @@ describe("ViewExperimentDrawer in the frame", () => { return signature; }); - expect(signatures[0]!.header).toBe("56px"); + expect(signatures[0]!.header).toBe(`${FRAME_HEADER_HEIGHT}px`); expect(signatures[0]!.note).toBe("20px"); expect(signatures[0]!.cards.length).toBeGreaterThan(1); for (const signature of signatures.slice(1)) { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx index fcd1a7188e4..682341c8a8c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx @@ -1,7 +1,8 @@ /** - * The experiment frame's stats line: the status pill, the runs, the errors, + * The experiment frame's stat columns: the status pill, the runs, the errors, * the simulated time, the wall-clock time, the selection's sampling for a - * sweep, and the chip listing what computes. Every value reserves its width. + * sweep, and the chip listing what computes. Every column is as wide as its + * widest value. */ import { useEffect, useState } from "react"; @@ -33,11 +34,13 @@ const STATUS_DISPLAY: Record< cancelled: { label: "Cancelled", tone: "neutral" }, }; -/** Longest status label plus the dot, so the pill keeps its width as the status changes. */ -const STATUS_CHARS = - Math.max( - ...Object.values(STATUS_DISPLAY).map((entry) => entry.label.length), - ) + 2; +/** The longest status label, so the pill keeps its width as the status changes. */ +const WIDEST_STATUS = Object.values(STATUS_DISPLAY) + .map((entry) => entry.label) + .reduce((widest, label) => (label.length > widest.length ? label : widest)); + +/** The widest wall-clock readout `formatDurationMs` prints. */ +const WIDEST_DURATION = "59m 59s"; /** * A clock that advances while `active`, so an elapsed-time readout keeps @@ -104,52 +107,44 @@ export const ExperimentStats = ({ const elapsedMs = getExperimentElapsedMs(experiment, now); const status = STATUS_DISPLAY[experiment.status]; const runCount = formatCount(experiment.runCount); + const maxTime = formatFixed(experiment.maxTime); return ( <> - - {status.label} - + + + {status.label} + + {progress ? `${formatCount(progress.activeRuns)} active, ${formatCount(progress.completedRuns)} complete` : runCount} - + {formatCount(progress?.erroredRuns ?? 0)} - - {formatFixed(progress?.time ?? settledTime(experiment))} /{" "} - {formatFixed(experiment.maxTime)} + + {formatFixed(progress?.time ?? settledTime(experiment))} / {maxTime} - {/* Wall-clock, as distinct from the simulated time; dashed out when - stepping never began. */} - + {/* Wall-clock, as distinct from the simulated time; it stops once the + experiment finishes and is dashed out when stepping never began. */} + {elapsedMs === null ? "—" : formatDurationMs(elapsedMs)} {experiment.sweep ? ( - + {formatCount(experiment.sweep.runsSampled)} / {runCount} runs ) : null} - + + + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-stats.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-stats.tsx index 19fd952c4bf..550d26df92f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-stats.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/study-stats.tsx @@ -1,9 +1,10 @@ /** - * The study frame's stats line: the status pill, the steps finished over the - * steps requested (with the runs per step and the steps at once when above - * one), the steps clear for a study with constraints, the best value so far - * (hover it for the best step's parameters), and the chip listing what - * computes for a study evaluated here. Every value reserves its width. + * The study frame's stat columns: the status pill, the steps finished over + * the steps requested (with the runs per step and the steps at once when + * above one), the steps clear for a study with constraints, the best value so + * far (hover it for the best step's parameters), and the chip listing what + * computes for a study evaluated here. Every column is as wide as its widest + * value. */ import { Tooltip } from "@hashintel/ds-components"; @@ -33,8 +34,11 @@ const STATUS_TONE: Record = { cancelled: "neutral", }; -/** Longest status word plus the dot, so the pill never reflows as it changes. */ -const STATUS_CHARS = "Reconnecting".length + 2; +/** The longest status word, so the pill never reflows as it changes. */ +const WIDEST_STATUS = "Reconnecting"; + +/** The widest objective `formatNumber` prints: a sign, six significant digits and an exponent. */ +const WIDEST_OBJECTIVE = "-0.00000e+00"; /** "4 / 30 · 3 runs each · 2 at once", with the parts that are 1 left out. */ export const describeStepProgress = ( @@ -84,39 +88,37 @@ export const StudyStats = ({ return ( <> - - {describeStudyStatus(optimization)} - + + + {describeStudyStatus(optimization)} + + {describeStepProgress(optimization)} {rates === null ? null : ( {formatRate(rates.stepsClear, rates.stepsSimulated)} )} - + {optimization.best ? ( {connected ? ( - + + + ) : null} ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx index 9e866a87833..a895874cb0b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx @@ -20,6 +20,7 @@ import { import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; import { FRAME_HEADER_CONDENSED_HEIGHT, + FRAME_HEADER_HEIGHT, frameLayoutSignature, } from "../shared/drawer-frame"; import { @@ -291,7 +292,10 @@ describe("ViewOptimizationDrawer for a remote study", () => { expect(screen.getByText("Complete")).toBeTruthy(); expect(screen.getByText("5 / 30")).toBeTruthy(); expect( - screen.getByText("Best step so far").nextElementSibling?.textContent, + screen + .getByText("Best step so far") + .nextElementSibling?.querySelector("[data-frame-stat-value]") + ?.textContent, ).toBe(formatObjective(best!.objective)); expect(screen.getByText("Best parameters")).toBeTruthy(); expect(screen.getByRole("table")).toBeTruthy(); @@ -416,7 +420,10 @@ describe("ViewOptimizationDrawer for a connected study", () => { expect(screen.getByText("Running")).toBeTruthy(); expect(screen.getByText("3 / 30")).toBeTruthy(); expect( - screen.getByText("Best step so far").nextElementSibling?.textContent, + screen + .getByText("Best step so far") + .nextElementSibling?.querySelector("[data-frame-stat-value]") + ?.textContent, ).toBe(formatObjective(trials[2]!.best!.objective)); // The table lists the newest step first; the header row is row 1. const bestTrial = trials[2]!.best!.trial; @@ -1031,7 +1038,7 @@ describe("ViewOptimizationDrawer holds every box still across states", () => { return signature; }); - expect(signatures[0]!.header).toBe("56px"); + expect(signatures[0]!.header).toBe(`${FRAME_HEADER_HEIGHT}px`); expect(signatures[0]!.note).toBe("20px"); expect(signatures[0]!.steps).toBe("320px"); expect(signatures[0]!.cards.map(([title]) => title)).toEqual([ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/compute-batches-chip.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/compute-batches-chip.tsx index 75d7b4c7e91..ae67c19f9ac 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/compute-batches-chip.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/compute-batches-chip.tsx @@ -3,8 +3,9 @@ * row per batch computing right now with its own progress. A sweep runs the * selection's ladder, surface chunks and cell refinements in parallel, a * study its steps and the navigated point's refinement, so the list shows - * that parallelism. The list is a popover, so opening it moves nothing; the - * chip keeps its slot while nothing computes, hidden. + * that parallelism. The list is a popover, so opening it moves nothing. The + * chip is always drawn, `0 computing` and disabled while nothing runs, and is + * as wide as a three-digit count, so the strip never moves around it. */ import { useRef, useState } from "react"; @@ -21,10 +22,12 @@ export type ComputeBatch = { completedRuns: number; }; +/** The widest count the chip reserves room for. */ +const WIDEST_COUNT = "000"; + const slotStyle = css({ display: "inline-flex", flexShrink: "0", - "&[data-idle=true]": { visibility: "hidden" }, }); const chipStyle = css({ @@ -33,7 +36,6 @@ const chipStyle = css({ gap: "1", paddingX: "1.5", height: "[18px]", - minWidth: "[11ch]", borderRadius: "sm", borderWidth: "[0]", fontSize: "[11px]", @@ -44,6 +46,22 @@ const chipStyle = css({ cursor: "pointer", whiteSpace: "nowrap", _hover: { backgroundColor: "neutral.s20" }, + "&[data-idle=true]": { + color: "neutral.s70", + cursor: "default", + _hover: { backgroundColor: "neutral.s10" }, + }, +}); + +// The live text and the widest text share one grid cell, so the chip is as +// wide as `000 computing` whatever the count. +const chipTextStyle = css({ + display: "grid", + "& > *": { gridArea: "[1 / 1]" }, +}); + +const sizerStyle = css({ + visibility: "hidden", }); const computingDotStyle = css({ @@ -51,6 +69,7 @@ const computingDotStyle = css({ height: "[6px]", borderRadius: "full", backgroundColor: "blue.s100", + "[data-idle=true] > &": { backgroundColor: "neutral.s50" }, }); const listStyle = css({ @@ -151,22 +170,23 @@ export const ComputeBatchesChip = ({ const showing = open && !idle; return ( - + {showing ? ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx index 7550f2a3f9e..890e004af61 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-header.tsx @@ -1,9 +1,15 @@ /** - * The frame's header: one title line, one line of stats with the compute - * badge at its right, and the progress bar along the bottom edge. It sits - * outside the body's scroll container. Once the body has scrolled it - * condenses: the stats line folds into the title line as compact chips, and - * the header grows back while the pointer or focus is on it. + * The frame's header: one title line, one strip of labelled stat columns + * divided by hairlines with the compute badge as its last column, and the + * progress bar along the bottom edge. It sits outside the body's scroll + * container. Once the body has scrolled it condenses: the strip folds into + * the title line as compact chips, and the header grows back while the + * pointer or focus is on it. + * + * Every column is exactly as wide as its widest value: the value cell lays + * an invisible copy of that widest text under the live one, so a number + * growing a digit, a status changing word or a count going to zero moves + * nothing. */ import { createContext, use, type ReactNode } from "react"; @@ -11,12 +17,12 @@ import { css, cx } from "@hashintel/ds-helpers/css"; import type { FrameHeaderEngagement } from "./use-header-engaged"; -/** The header's height in pixels at rest: the title line, the stats line and the bar. */ -export const FRAME_HEADER_HEIGHT = 56; +/** The header's height in pixels at rest: the title line, the stat strip and the bar. */ +export const FRAME_HEADER_HEIGHT = 68; /** The header's height in pixels once the body has scrolled: one line and the bar. */ export const FRAME_HEADER_CONDENSED_HEIGHT = 36; -/** How the stats render: with their labels on their own line, or as compact chips beside the title. */ +/** How the stats render: as labelled columns on their own line, or as compact chips beside the title. */ export type FrameStatsDensity = "full" | "compact"; const FrameStatsDensityContext = createContext("full"); @@ -47,6 +53,8 @@ const titleRowStyle = css({ flexShrink: "0", }); +// The title yields to the compact chips: it may shrink to a few characters, +// the chips never shrink at all. const titleStyle = css({ fontSize: "sm", fontWeight: "semibold", @@ -56,7 +64,7 @@ const titleStyle = css({ textOverflow: "ellipsis", whiteSpace: "nowrap", flexShrink: "1", - minWidth: "[0]", + minWidth: "[48px]", }); // The headline reads to the right of the title while the header is at rest; @@ -74,9 +82,8 @@ const compactRowStyle = css({ display: "flex", alignItems: "center", gap: "2", - flex: "[1]", - minWidth: "[0]", - overflow: "hidden", + marginLeft: "auto", + flexShrink: "0", whiteSpace: "nowrap", "[data-animate=true] &": { animationName: "[dialogBackdropIn]", @@ -85,11 +92,12 @@ const compactRowStyle = css({ }, }); +// The strip's height is the label line, its gap and the value line; it +// folds to nothing while condensed. const statsRowStyle = css({ display: "flex", - alignItems: "center", - gap: "3", - height: "[20px]", + alignItems: "stretch", + height: "[32px]", minWidth: "[0]", overflow: "hidden", whiteSpace: "nowrap", @@ -106,13 +114,6 @@ const statsRowStyle = css({ }, }); -const badgeSlotStyle = css({ - display: "inline-flex", - alignItems: "center", - marginLeft: "auto", - flexShrink: "0", -}); - const progressTrackStyle = css({ position: "absolute", left: "[0]", @@ -130,19 +131,37 @@ const progressFillStyle = css({ }, }); +// A column: the label over the value, a hairline on its left from the second +// column on. Compact, the label goes (its text becomes the tooltip) and the +// column is one chip with no rule. const statStyle = css({ - display: "inline-flex", - alignItems: "baseline", - gap: "1.5", - minWidth: "[0]", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", flexShrink: "0", + minWidth: "[0]", + paddingX: "3", fontSize: "xs", - lineHeight: "[16px]", + lineHeight: "[18px]", color: "neutral.s120", + "&[data-density=full]:first-child": { paddingLeft: "[0]" }, + "&[data-density=full] + &[data-density=full]": { + borderLeftWidth: "[1px]", + borderLeftStyle: "solid", + borderLeftColor: "neutral.bd.subtle", + }, + "&[data-align=end]": { alignItems: "flex-end" }, + "&[data-density=compact]": { + flexDirection: "row", + alignItems: "center", + padding: "[0]", + }, + "&[data-trailing=true]": { marginLeft: "auto" }, }); const statLabelStyle = css({ fontSize: "[10px]", + lineHeight: "[12px]", fontWeight: "medium", letterSpacing: "[0.04em]", textTransform: "uppercase", @@ -150,12 +169,27 @@ const statLabelStyle = css({ "&[data-density=compact]": { display: "none" }, }); +// The live value and the invisible widest value share one grid cell, so the +// cell is as wide as the widest and the live text sits inside it. const statValueStyle = css({ - display: "inline-flex", + display: "grid", alignItems: "center", fontWeight: "medium", fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap", + "& > *": { gridArea: "[1 / 1]" }, + "&[data-align=end]": { justifyItems: "end" }, +}); + +const sizerStyle = css({ + visibility: "hidden", + pointerEvents: "none", +}); + +const valueTextStyle = css({ + display: "inline-flex", + alignItems: "center", + minWidth: "[0]", }); const pillStyle = css({ @@ -177,6 +211,11 @@ const pillStyle = css({ "&[data-tone=error]": { color: "red.s100", backgroundColor: "red.s10" }, }); +const pillTextStyle = css({ + display: "grid", + "& > *": { gridArea: "[1 / 1]" }, +}); + const pillDotStyle = css({ width: "[6px]", height: "[6px]", @@ -188,6 +227,79 @@ const pillDotStyle = css({ "[data-tone=error] > &": { backgroundColor: "red.s100" }, }); +/** + * One column of the strip: a small uppercase label over its value. The + * column is as wide as `widest`, the longest text the value can be, so a + * changing value never moves its neighbours. Numbers align to the end. + * Compact (while the header is condensed) the label becomes a tooltip. + */ +export const FrameStat = ({ + label, + widest, + align = "end", + trailing = false, + children, + className, +}: { + label: string; + /** The widest text the value can show; it sizes the column invisibly. */ + widest: string; + /** Where the value sits in its column: numbers at the end, words at the start. */ + align?: "start" | "end"; + /** Pinned to the strip's right edge. */ + trailing?: boolean; + children: ReactNode; + className?: string; +}) => { + const density = use(FrameStatsDensityContext); + return ( + + + {label} + + + + {widest} + + + {children} + + + + ); +}; + +export type FrameStatusTone = "active" | "done" | "error" | "neutral"; + +/** The status pill: a dot in the status's colour and the status word, as wide as the longest word. */ +export const FrameStatusPill = ({ + tone, + widest, + children, +}: { + tone: FrameStatusTone; + /** The longest status word, so the pill keeps its width across statuses. */ + widest: string; + children: ReactNode; +}) => ( + + + + + {widest} + + {children} + + +); + export type FrameHeaderProps = { /** One line, ellipsized when narrow: `SIR transmission sweep · Seasonal Flu · 100 runs · dt 1`. */ title: string; @@ -195,9 +307,9 @@ export type FrameHeaderProps = { leading?: ReactNode; /** The title line's right side while at rest: a live readout such as the study's progress line. */ headline?: ReactNode; - /** The second line: `FrameStat`s and chips. Rendered again as compact chips while condensed. */ + /** The strip: `FrameStat` columns. Rendered again as compact chips while condensed. */ stats: ReactNode; - /** Pinned to the stats line's right: the compute badge. */ + /** The strip's last column, pinned right: the compute badge. */ badge?: ReactNode; /** The bar along the bottom edge, 0 to 100. Always drawn. */ progress: number; @@ -210,6 +322,12 @@ export type FrameHeaderProps = { engagement: FrameHeaderEngagement; }; +const BadgeColumn = ({ badge }: { badge: ReactNode }) => ( + + {badge} + +); + export const FrameHeader = ({ title, leading, @@ -242,9 +360,7 @@ export const FrameHeader = ({
{stats} - {badge === undefined ? null : ( - {badge} - )} + {badge === undefined ? null : }
) : null} @@ -258,9 +374,7 @@ export const FrameHeader = ({ aria-hidden={condensed ? true : undefined} > {stats} - {badge === undefined ? null : ( - {badge} - )} + {badge === undefined ? null : }
); - -/** - * One stat on the stats line: a small uppercase label and its value. The - * value reserves `minChars` so a changing number never moves its neighbours. - * Compact (while the header is condensed) the label becomes a tooltip. - */ -export const FrameStat = ({ - label, - minChars, - children, - className, -}: { - label: string; - minChars?: number; - children: ReactNode; - className?: string; -}) => { - const density = use(FrameStatsDensityContext); - return ( - - - {label} - - - {children} - - - ); -}; - -export type FrameStatusTone = "active" | "done" | "error" | "neutral"; - -/** The status pill: a dot in the status's colour and the status word. */ -export const FrameStatusPill = ({ - tone, - minChars, - children, -}: { - tone: FrameStatusTone; - /** Reserve this many characters so the pill keeps its width across statuses. */ - minChars?: number; - children: ReactNode; -}) => ( - - - {children} - -); From 1704f50401e90f3f0bf190c081abf2810639ab48 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 12:33:42 +0200 Subject: [PATCH 09/13] Span the Parameters band across the frame body and make it collapsible The parameter controls are global to the drawer, so the band sits under the header across the whole body instead of sharing the left column with the surface. A chevron folds it; the fold animates the band's height alone, the controls stay mounted so slider positions and refinement state survive, and the columns beneath move as one block. FrameColumns now holds the surface and the cards only, and gives the cards the whole width when there is no surface. --- libs/@hashintel/petrinaut/docs/experiments.md | 32 ++-- .../@hashintel/petrinaut/docs/optimization.md | 47 ++--- .../experiments/view-experiment-drawer.tsx | 54 +++--- .../SimulateView/optimizations/study-view.tsx | 176 +++++++++--------- .../study-view/navigator-band.tsx | 7 +- .../shared/drawer-frame.stories.tsx | 90 ++++----- .../SimulateView/shared/drawer-frame.test.tsx | 103 ++++++++-- .../SimulateView/shared/drawer-frame.tsx | 23 ++- .../drawer-frame/frame-animate-context.ts | 8 + .../shared/drawer-frame/frame-band.tsx | 95 ++++++++-- .../shared/drawer-frame/frame-columns.tsx | 27 ++- 11 files changed, 415 insertions(+), 247 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-animate-context.ts diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 58b0309a255..e9d6f5cca4b 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -63,7 +63,7 @@ Parameter sweeps are experimental and off by default. Turn on **Parameter sweeps Flip **Sweep** on any numeric scenario parameter to explore an interval of values instead of one. Set the minimum and the maximum — that is all a sweep declares. Petrinaut quantizes the interval finely (about fifty steps; integer parameters step by whole numbers) so a selection has a stable identity and revisiting one restores its results. With [ad-hoc scenarios](ad-hoc-scenarios.md) enabled, the same toggle sits on every numeric value of the ad-hoc form -- a token count, a cell, a variable, a parameter override -- and each selection sweeps as a generated parameter named after the value, shown in the navigator under the value's path. -A sweep computes **what you have selected**. The results drawer grows a **Parameters** strip — pinned while you scroll — with one slider per swept parameter. Each slider selects a range on its interval, and starts spanning the whole of it: +A sweep computes **what you have selected**. The results drawer grows a **Parameters** band across the top of its body, with one slider per swept parameter; the chevron before its title folds the sliders away and back without losing their positions. Each slider selects a range on its interval, and starts spanning the whole of it: - **Range** (the default): Petrinaut runs **one stochastic simulation over the ranges** — every run draws its own value for each ranged parameter, spread across the selected interval — and the metric charts stream the live distribution **over the region**, sharpening exactly like a plain experiment's. Resize a range from either end to focus; compute restarts on the new selection. Range selections run on the GPU when the net qualifies — each run's parameter draw is uploaded alongside its state — and otherwise on the CPU at full parallelism; an initial state that a scenario derives from a ranged parameter holds at the range's midpoint, while the simulation itself reads each run's own value. - **Point**: switch a parameter's control to Point and its slider collapses to a single value. A point refines in escalating batches (8, 25, 100, … up to your run budget), exactly like a plain experiment at that value — including on the GPU. @@ -74,9 +74,9 @@ Every selection uses the same seed sequence (common random numbers), and a run's #### The surface view -A sweep with two or more swept parameters grows a **Surface** card under the **Parameters** band: a contour plot of one metric's final value over two parameters you pick, with every other parameter held at the middle of its selected range. The **X**, **Y** and **Metric** pickers sit in the row under the plot, and the line under the card's title reads the sampling progress (or, mid-drag, the values under the pointer). The plot fills in live and coarse-first — the four corners, then ever finer subdivisions, each level a complete picture (8 runs per point) — and **the surface is itself a control**: click, or press and drag with a live crosshair and value readout, and on release both shown parameters collapse to a point there, which then refines with more runs. An orange ring marks where the navigator currently sits. Your selected point always computes first: the metric charts start streaming before surface sampling begins, and after every slider move the surface waits for the new selection's first frames before continuing. Every metric is measured on the same samples, so switching the shown metric repaints instantly from what was already computed; changing the fixed parameters or the axes restarts the fill for the new slice. +A sweep with two or more swept parameters grows a **Surface** card under the **Parameters** band: a contour plot of one metric's final value over two parameters you pick, with every other parameter held at the middle of its selected range. The **X** and **Y** pickers sit in the row under the plot and the **Metric** picker in the row beneath them, and the line under the card's title reads the sampling progress (or, mid-drag, the values under the pointer). The plot fills in live and coarse-first — the four corners, then ever finer subdivisions, each level a complete picture (8 runs per point) — and **the surface is itself a control**: click, or press and drag with a live crosshair and value readout, and on release both shown parameters collapse to a point there, which then refines with more runs. An orange ring marks where the navigator currently sits. Your selected point always computes first: the metric charts start streaming before surface sampling begins, and after every slider move the surface waits for the new selection's first frames before continuing. Every metric is measured on the same samples, so switching the shown metric repaints instantly from what was already computed; changing the fixed parameters or the axes restarts the fill for the new slice. -The drawer arranges its parts by its width. At the drawer's full width, and in the full-size presentation, the **Parameters** band and the **Surface** sit on the left and the metric cards on the right, two to a row, so two swept parameters and up to four metrics fit without scrolling; in a narrower drawer the metric cards come first, then **Parameters**, then **Surface**, so the charts you watch are at the top either way. Every card keeps a fixed height, and only the body scrolls, under the header. +The drawer arranges its parts by its width. The **Parameters** band spans the body under the header. Beneath it, at the drawer's full width and in the full-size presentation, the **Surface** sits on the left and the metric cards on the right, two to a row, so two swept parameters and up to four metrics fit without scrolling; in a narrower drawer the metric cards come first, then **Surface**, so the charts you watch are at the top either way. A sweep with one swept parameter has no surface, and its cards take the whole width. Every card keeps a fixed height, and only the body scrolls, under the header. ### Compute backend (experimental) @@ -107,20 +107,22 @@ Two things to know before comparing results: ### Reading the header -Open an experiment's drawer and its header names the experiment in one line: the name, the scenario (or **Default scenario**), the run count and the time step, for example **SIR transmission sweep · Seasonal Flu · 100 runs · dt 1**. Beneath it, one line of stats: +Open an experiment's drawer and its header names the experiment in one line: the name, the scenario (or **Default scenario**), the run count and the time step, for example **SIR transmission sweep · Seasonal Flu · 100 runs · dt 1**. Beneath it, a strip of labelled columns divided by hairlines: -| Stat | Meaning | -| ------------- | -------------------------------------------------------------------------------------------------------------------- | -| **Status** | One of the five statuses above, as a pill with a coloured dot. | -| **Runs** | How many runs are in flight, and how many have finished. | -| **Errors** | How many individual runs errored. An experiment can complete with some runs errored. | -| **Time** | Simulated time reached, against the configured maximum. This is model time, not clock time. | -| **Elapsed** | Clock time the experiment has been simulating. Once it stops, this becomes **Duration** and holds the total it took. | -| **Selection** | For a sweep: the selected combination's runs sampled over the run budget. | +| Column | Meaning | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Status** | One of the five statuses above, as a pill with a coloured dot. | +| **Runs** | How many runs are in flight, and how many have finished. | +| **Errors** | How many individual runs errored. An experiment can complete with some runs errored. | +| **Time** | Simulated time reached, against the configured maximum. This is model time, not clock time. | +| **Elapsed** | Clock time the experiment has been simulating; it stops with the experiment and holds the total it took. | +| **Selection** | For a sweep: the selected combination's runs sampled over the run budget. | +| **Activity** | The **N computing** chip: how many batches run right now, **0 computing** when nothing does. Click it while something runs to list them. | +| **Compute** | Whether the run uses the **CPU** or the **GPU**. Hover it for detail; on a CPU-backed experiment that asked for the GPU, it names the requirement the net did not meet. | -A badge at the right of the stats line shows whether the run used the **CPU** or the **GPU**. Hover it for detail; on a CPU-backed experiment that asked for the GPU, the badge explains which requirement the net did not meet. A progress bar runs along the header's bottom edge: the selected combination's runs for a sweep, simulated time otherwise. If the experiment failed, the error reads in the line under the header. +A progress bar runs along the header's bottom edge: the selected combination's runs for a sweep, simulated time otherwise. If the experiment failed, the error reads in the line under the header. -Once the drawer's body has scrolled, the header condenses to one line, with the stats folded in as compact chips beside the title; move the pointer over it and it grows back. Nothing in the drawer moves when a status changes or a number grows a digit: every value reserves its width and every card its height. +Once the drawer's body has scrolled, the header condenses to one line, with the columns folded in as compact chips beside the title, the compute badge and the computing chip still among them; move the pointer over it and it grows back. Nothing in the header moves when a status changes, a count goes to zero or a number grows a digit: every column is as wide as its widest value, and every card in the body keeps its height. **Elapsed** and **Duration** measure simulating only. Compiling the net's user code and starting the workers (or acquiring the GPU device and compiling the shader) happens before the clock starts, so the number is comparable between the two backends. An experiment that fails before it starts simulating shows `—` rather than a duration. @@ -155,7 +157,7 @@ A confirmation prompt blocks browser/tab close while any experiment is initializ ### Notifications -While anything computes, a **"N computing"** chip sits at the end of the header's stats line — a sweep runs several simulations in parallel (the selection's own batches, surface chunks, cell refinements) — and clicking the chip opens a compact list with each batch's kind and progress. Selection batches are the priority work and sort first. +The **N computing** chip in the header's **Activity** column counts the batches running right now — a sweep runs several simulations in parallel (the selection's own batches, surface chunks, cell refinements) — and clicking it opens a compact list with each batch's kind and progress; it reads **0 computing** while nothing runs. Selection batches are the priority work and sort first. A small toast appears when an experiment **completes** or **errors**, even if its drawer isn't open. The top-bar **Active experiments** popover (see below) lets you jump to any in-flight experiment from anywhere in the app. diff --git a/libs/@hashintel/petrinaut/docs/optimization.md b/libs/@hashintel/petrinaut/docs/optimization.md index 73477be67c9..ebfd75e5490 100644 --- a/libs/@hashintel/petrinaut/docs/optimization.md +++ b/libs/@hashintel/petrinaut/docs/optimization.md @@ -141,23 +141,25 @@ completed steps (a tenth of the requested steps, five at least), **Converging** when that many steps passed without a better one, and **Too early to say** before one such window has completed. -Beneath the title, the header's stats line shows the status, the steps -finished over the steps requested (with the runs per step when above one), -and **Best step so far**, the best value seen so far (hover it for the best -step's parameters). A progress bar for the steps runs along the header's +Beneath the title, the header's strip of labelled columns shows the status, +the steps finished over the steps requested (with the runs per step when above +one), and **Best step so far**, the best value seen so far (hover it for the +best step's parameters). A progress bar for the steps runs along the header's bottom edge. The value is named for what it is: the best of the steps tried, -not a confirmed result at that configuration. Once the body has scrolled the -header condenses to one line, the stats folded in as compact chips; move the -pointer over it and it grows back. The line under the header is reserved for -a note (the error when a study failed, the resume note while it is paused), -so nothing moves when one appears. - -The body arranges its parts by its width. In the drawer at its full width and -in the full view the **Parameters** band and the **Objective surface** sit on -the left and the other chart cards on the right, two to a row; in a narrower -drawer the chart cards come first, then **Parameters**, then the surface. The -steps table follows at a fixed height and scrolls on its own. Every card keeps -its height whatever it shows. +not a confirmed result at that configuration. Every column is as wide as its +widest value, so nothing in the header moves as the numbers change. Once the +body has scrolled the header condenses to one line, the columns folded in as +compact chips; move the pointer over it and it grows back. The line under the +header is reserved for a note (the error when a study failed, the resume note +while it is paused), so nothing moves when one appears. + +The body arranges its parts by its width. The **Parameters** band spans the +body under the header; the chevron before its title folds the controls away +and back without losing their positions. Beneath it, in the drawer at its full +width and in the full view, the **Objective surface** sits on the left and the +other chart cards on the right, two to a row; in a narrower drawer the chart +cards come first, then the surface. The steps table follows at a fixed height +and scrolls on its own. Every card keeps its height whatever it shows. The **Objective by step** card draws every step's objective value as a dot over the step number, with the best so far as a line stepping up (or down, @@ -197,13 +199,14 @@ browser](#running-in-the-browser)) shows more, because the machine computing it is yours, and lays it out so the header, the controls and the plots stay in view on a laptop screen while the study streams: -- The header's stats line also shows the parallel steps when above one, and, - at its right, a badge saying where the steps run. It reads **CPU**, because - the GPU backend cannot compute an expression objective (see step 4 of +- The header's strip also shows the parallel steps when above one, an + **Activity** column with the **N computing** chip, and a **Compute** column + saying where the steps run. The badge reads **CPU**, because the GPU backend + cannot compute an expression objective (see step 4 of [Creating an optimization](#creating-an-optimization)); hover it for the - reason. An **N computing** chip sits at the end of the stats line while - anything computes (the steps in flight and the picked point's refinement), - opening a compact list with one row per batch and its own progress. + reason. The chip counts the batches running right now (the steps in flight + and the picked point's refinement), **0 computing** when nothing does, and + opens a compact list with one row per batch and its own progress. - A **Parameters** band with one slider per optimized numeric parameter and a switch per optimized boolean parameter, two to a row when they fit. Its heading carries the state line and the **Follow steps** switch. While the diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx index ab22d98549d..60a92601b85 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer.tsx @@ -1,8 +1,8 @@ /** * One experiment in a drawer over the Experiments list, in the shared frame: - * the one-line title and the stats in the header, then the metric cards, the - * parameter band and the surface arranged by the drawer's width, with the - * actions in the footer. + * the one-line title and the stats in the header, the Parameters band across + * the body, then the surface and the metric cards arranged by the drawer's + * width, with the actions in the footer. */ import { use } from "react"; @@ -109,33 +109,31 @@ export const ViewExperimentDrawer = ({ } > + {sweep ? ( + + + setSweepSelection(experiment.id, selection) + } + /> + + ) : null} - - - setSweepSelection(experiment.id, selection) - } - /> - - {experiment.parameterAxes.length >= 2 ? ( - // Keyed so the axis and metric pickers never carry one - // experiment's identifiers into another when the drawer swaps - // records in place. - - ) : null} - + sweep && experiment.parameterAxes.length >= 2 ? ( + // Keyed so the axis and metric pickers never carry one + // experiment's identifiers into another when the drawer swaps + // records in place. + ) : undefined } secondary={ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx index 037a36cbaa1..00d73a34169 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx @@ -1,10 +1,10 @@ /** * One study in the shared frame, for the drawer and the full view alike: the * one-line title with the progress line beside it, the stats and the compute - * badge, the steps bar; then, arranged by the frame's width, the Parameters - * band and the surface, the chart cards (the objective at the point, the - * objective by step, Constraints when the study declares any, Parameter - * importance) and the steps table; and the actions in the footer. + * badge, the steps bar; the Parameters band across the body; then, arranged + * by the frame's width, the surface, the chart cards (the objective at the + * point, the objective by step, Constraints when the study declares any, + * Parameter importance) and the steps table; and the actions in the footer. */ import { use, type ReactNode } from "react"; @@ -243,47 +243,45 @@ const RemoteStudyBody = ({ enableOptimizationSurface && optimization.axes.length >= 2; return ( - - - {surfaceEligible ? ( - - - - ) : null} - - ) : undefined - } - secondary={ - - - - } - after={ - optimization.trials.length > 0 ? ( - - ) : undefined - } - /> + <> + + + + + ) : undefined + } + secondary={ + + + + } + after={ + optimization.trials.length > 0 ? ( + + ) : undefined + } + /> + ); }; /** * A study evaluated in this browser: the parameter controls with their state - * line and the surface on one side, the chart cards on the other, the steps - * beneath. The navigation drives the surface and the objective's timeline, - * following each step while the study runs. + * line across the body, then the surface on one side, the chart cards on the + * other, the steps beneath. The navigation drives the surface and the + * objective's timeline, following each step while the study runs. */ const ConnectedStudyBody = ({ optimization, @@ -301,16 +299,16 @@ const ConnectedStudyBody = ({ optimization.status === "paused" ? "paused" : "default"; return ( - - - {optimization.axes.length >= 2 ? ( + <> + + = 2 ? ( } tone={tone} /> - ) : null} - - } - secondary={ - - {/* Keyed so faded previous pictures never leak from one study into - another when the surface swaps records. */} - - - {(optimization.input.constraints ?? []).length > 0 ? ( - + {/* Keyed so faded previous pictures never leak from one study into + another when the surface swaps records. */} + + - ) : null} - {/* Only a study evaluated here receives importances; a remote study - has no panel rather than an empty one. */} - 0 ? ( + + ) : null} + {/* Only a study evaluated here receives importances; a remote study + has no panel rather than an empty one. */} + + + } + after={ + - - } - after={ - - } - /> + } + /> + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx index f55c4b9f89f..eb2c108e783 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/navigator-band.tsx @@ -1,7 +1,7 @@ /** - * The Parameters band of a connected study: the frame band with the help - * tooltip and the navigator's status line in its title row, then the - * parameter controls. + * The Parameters band of a connected study: the collapsible frame band with + * the help tooltip and the navigator's status line in its title row, then the + * parameter controls across the body. */ import { optimizationBooleanIdentifiers } from "../../../../../../../react/optimizations/surface-grid"; import { FrameBand } from "../../shared/drawer-frame"; @@ -34,6 +34,7 @@ export const NavigatorBand = ({ ( <> - - Running - - + + + Running + + + 900 active, 100 complete - + 0 - + 45 / 180 - + 4m 02s - + 61 / 100 runs - + + + ); @@ -108,22 +114,20 @@ const Frame = ({ cards }: { cards: number }) => ( progress={61} footer={Actions} > + +
+ - -
- - X · Y · Metric} - footerHeight={24} - > -
- - + X · Y · Metric} + footerHeight={24} + > +
+ } secondary={} /> @@ -149,7 +153,7 @@ export const Condensed: Story = { docs: { description: { story: - "The body has scrolled: the stats line folded into the title line as compact chips and the header is 36px tall. Move the pointer over it to see it grow back.", + "The body has scrolled: the stat columns folded into the title line as compact chips and the header is 36px tall. Move the pointer over it to see it grow back.", }, }, }, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx index d3f1eaf5b84..194e0b6f0e6 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.test.tsx @@ -12,11 +12,14 @@ import { afterEach, describe, expect, it } from "vitest"; import { CHART_CARD_MIN_WIDTH } from "./chart-card"; import { + ComputeBatchesChip, DrawerFrame, FRAME_HEADER_CONDENSED_HEIGHT, FRAME_HEADER_HEIGHT, FRAME_SECONDARY_MIN_WIDTH, FRAME_TWO_COLUMN_MIN_WIDTH, + FrameBand, + FrameColumns, FrameStat, FrameStatusPill, } from "./drawer-frame"; @@ -48,10 +51,12 @@ const frame = ({ headline={Step 3 of 30} stats={ <> - - Running - - + + + Running + + + 100 complete {stats} @@ -70,7 +75,7 @@ const renderFrame = (note: { content: string; tone: "error" } | null = null) => render(frame({ note })); describe("DrawerFrame", () => { - it("renders the title, the stats with their labels, the badge, the bar and the footer at rest", () => { + it("renders the title, the stat columns sized by their widest value, the badge column, the bar and the footer at rest", () => { renderFrame(); expect(header().style.height).toBe(`${FRAME_HEADER_HEIGHT}px`); @@ -81,13 +86,21 @@ describe("DrawerFrame", () => { ), ).toBeTruthy(); expect(screen.getByText("Step 3 of 30")).toBeTruthy(); - expect(screen.getByText("Runs").nextElementSibling?.textContent).toBe( + const runs = screen.getByText("Runs").nextElementSibling!; + expect(runs.querySelector("[data-frame-stat-value]")?.textContent).toBe( "100 complete", ); - expect(screen.getByText("Runs").nextElementSibling).toHaveProperty( - "style.minWidth", - "12ch", + expect(runs.querySelector("[data-frame-stat-sizer]")?.textContent).toBe( + "1,000 complete", ); + // The stats are labelled columns; the badge is the last of them. + const columns = [ + ...document.querySelectorAll("[data-frame-stats] > [data-frame-stat]"), + ]; + expect( + columns.map((column) => column.querySelector("span")?.textContent), + ).toEqual(["Status", "Runs", "Compute"]); + expect(columns.at(-1)?.getAttribute("data-trailing")).toBe("true"); expect(screen.getByText("CPU")).toBeTruthy(); expect( document.querySelector("[data-frame-progress] > div")?.style @@ -107,8 +120,10 @@ describe("DrawerFrame", () => { expect(compact.textContent).toContain("Running"); expect(compact.textContent).toContain("CPU"); expect( - compact.querySelector("[data-frame-stat]")?.getAttribute("title"), - ).toBe("Runs"); + [...compact.querySelectorAll("[data-frame-stat]")].map((stat) => + stat.getAttribute("title"), + ), + ).toEqual(["Status", "Runs", "Compute"]); expect( document.querySelector("[data-frame-stats]")?.getAttribute("aria-hidden"), ).toBe("true"); @@ -172,4 +187,70 @@ describe("DrawerFrame", () => { expect(row.dataset.tone).toBe("error"); expect(row.textContent).toBe("metric__profit: Unexpected token"); }); + + it("draws the computing chip at zero, disabled, with room for a three-digit count", () => { + render(); + + const chip = screen.getByRole("button", { name: "0 computing" }); + expect(chip).toHaveProperty("disabled", true); + expect( + chip.closest("[data-compute-batches]")?.getAttribute("data-idle"), + ).toBe("true"); + expect(chip.textContent).toContain("000 computing"); + }); + + it("folds a collapsible band's controls away without unmounting them", () => { + render( + + + , + ); + + const toggle = screen.getByRole("button", { name: "Collapse Parameters" }); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + fireEvent.click(toggle); + + const content = document.querySelector( + "[data-frame-band-content]", + )!; + expect( + document + .querySelector("[data-frame-band]") + ?.getAttribute("data-collapsed"), + ).toBe("true"); + expect(content.getAttribute("aria-hidden")).toBe("true"); + expect(content.hasAttribute("inert")).toBe(true); + expect(content.querySelector("input")?.value).toBe("42"); + expect( + screen + .getByRole("button", { name: "Expand Parameters" }) + .getAttribute("aria-expanded"), + ).toBe("false"); + + fireEvent.click(screen.getByRole("button", { name: "Expand Parameters" })); + expect(content.hasAttribute("inert")).toBe(false); + expect(screen.getByRole("textbox", { name: "population" })).toBeTruthy(); + }); + + it("gives the secondary column the whole width when there is no primary", () => { + const view = render(cards
} />); + expect( + view.container + .querySelector("[data-frame-columns]") + ?.getAttribute("data-primary"), + ).toBe("false"); + view.unmount(); + + render( + surface
} + secondary={
cards
} + />, + ); + expect( + document + .querySelector("[data-frame-columns]") + ?.getAttribute("data-primary"), + ).toBe("true"); + }); }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx index ccfcfa85706..db129587be3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame.tsx @@ -4,11 +4,12 @@ * * The header sits outside the body's scroll container, so condensing it * changes the body's available height and never its scroll offset. The body - * is a size container; `FrameColumns` arranges its content by the body's - * width. A `note` row is always mounted, empty when there is nothing to say, - * so an error or a resume note appearing moves nothing. In a drawer the body - * takes the opening focus, so wheel and arrow keys scroll it at once and no - * control in the header holds the header open. + * is a size container: a `note` row always mounted, empty when there is + * nothing to say, so an error or a resume note appearing moves nothing; then + * the adopter's parameter band across the width, then `FrameColumns`, which + * arranges the surface and the cards by the body's width. In a drawer the + * body takes the opening focus, so wheel and arrow keys scroll it at once and + * no control in the header holds the header open. */ import { type ReactNode, use, useRef } from "react"; @@ -16,6 +17,7 @@ import { Drawer } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; +import { FrameAnimateContext } from "./drawer-frame/frame-animate-context"; import { FrameHeader } from "./drawer-frame/frame-header"; import { useBodyScrolled } from "./drawer-frame/use-body-scrolled"; import { useHeaderEngaged } from "./drawer-frame/use-header-engaged"; @@ -110,6 +112,7 @@ const drawerBodyStyle = css({ const bodyStyle = css({ display: "flex", flexDirection: "column", + gap: "3", flex: "[1]", minHeight: "[0]", minWidth: "[0]", @@ -129,7 +132,6 @@ const noteRowStyle = css({ alignItems: "center", flexShrink: "0", minWidth: "[0]", - marginBottom: "1", fontSize: "xs", lineHeight: "[16px]", color: "neutral.s80", @@ -146,9 +148,9 @@ export type DrawerFrameProps = { leading?: ReactNode; /** The title line's right side while at rest: the study's progress line. */ headline?: ReactNode; - /** The stats line: `FrameStat`s, the status pill, the computing chip. */ + /** The stat columns: `FrameStat`s holding the status pill, the counts, the computing chip. */ stats: ReactNode; - /** Pinned to the stats line's right: the compute badge. */ + /** The strip's last column, pinned right: the compute badge. */ badge?: ReactNode; /** The bar along the header's bottom edge, 0 to 100. */ progress: number; @@ -179,6 +181,7 @@ export const DrawerFrame = ({ const { engaged, engagement, settleFocus } = useHeaderEngaged(); const bodyRef = useRef(null); const condensed = scrolled && !engaged; + const animate = showAnimations && !reducedMotion; const header = ( @@ -215,7 +218,7 @@ export const DrawerFrame = ({ > {note?.content}
- {children} + {children}
); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-animate-context.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-animate-context.ts new file mode 100644 index 00000000000..d5a2a08eebb --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-animate-context.ts @@ -0,0 +1,8 @@ +/** + * Whether the frame's transitions run: the frame reads the animations setting + * and the OS's reduced-motion preference once and hands the answer down to + * the header and the bands. Outside a frame the transitions run. + */ +import { createContext } from "react"; + +export const FrameAnimateContext = createContext(true); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx index 8b88c0c807a..da17e602e3d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-band.tsx @@ -1,17 +1,21 @@ /** * A titled band in the frame body: a title row with an optional help tooltip * and a trailing readout, then the band's controls. Bands hold parameter - * controls; they have no card border and no collapse. + * controls and span the body's width; they have no card border. A + * collapsible band folds its controls away behind a chevron, keeping them + * mounted so their state survives; the fold animates the band's height + * alone, and whatever follows the band moves as one block. */ -import { HelpTooltip } from "@hashintel/ds-components"; +import { use, useState, type ReactNode } from "react"; + +import { Button, HelpTooltip } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; -import type { ReactNode } from "react"; +import { FrameAnimateContext } from "./frame-animate-context"; const bandStyle = css({ display: "flex", flexDirection: "column", - gap: "2", minWidth: "[0]", }); @@ -47,28 +51,87 @@ const trailingStyle = css({ whiteSpace: "nowrap", }); +// The fold: a one-row grid whose row goes from `1fr` to `0fr`, so the +// content's own height is what animates and the content stays mounted. +const foldStyle = css({ + display: "grid", + gridTemplateRows: "[1fr]", + "&[data-animate=true]": { + transition: "[grid-template-rows 160ms ease-out, visibility 0s]", + }, + "&[data-collapsed=true]": { + gridTemplateRows: "[0fr]", + visibility: "hidden", + transitionDelay: "[0s, 160ms]", + }, +}); + +const contentStyle = css({ + minHeight: "[0]", + minWidth: "[0]", + overflow: "hidden", +}); + +const contentInnerStyle = css({ + paddingTop: "2", +}); + export const FrameBand = ({ title, help, trailing, + collapsible = false, children, }: { title: string; help?: string; /** The title row's right side: a state line, a switch. */ trailing?: ReactNode; + /** A chevron before the title folds the controls away; they stay mounted. */ + collapsible?: boolean; children: ReactNode; -}) => ( -
-
-
- {title} - {help === undefined ? null : } +}) => { + const animate = use(FrameAnimateContext); + const [collapsed, setCollapsed] = useState(false); + const contentId = `frame-band-${title.replace(/\s+/gu, "-").toLowerCase()}`; + + return ( +
+
+
+ {collapsible ? ( +
+ {trailing === undefined ? null : ( +
{trailing}
+ )} +
+
+
+
{children}
+
- {trailing === undefined ? null : ( -
{trailing}
- )}
- {children} -
-); + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx index 9f1a28bfdd4..22b847e3e98 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/drawer-frame/frame-columns.tsx @@ -1,10 +1,11 @@ /** - * The frame body's arrangement. At the extra-large drawer's width and above, - * two columns: the parameters and the surface on the left, the metric cards - * on the right, the right column never narrower than two cards. In a - * narrower drawer, one column with the metric cards first, then the - * parameters, then the surface, so the thing being watched is at the top - * either way. Whatever follows (a steps table) spans the width beneath. + * The frame body's arrangement under the Parameters band. At the extra-large + * drawer's width and above, two columns: the surface on the left, the metric + * cards on the right, the right column never narrower than two cards. In a + * narrower drawer, one column with the metric cards first, then the surface, + * so the thing being watched is at the top either way. Without a surface the + * cards take the whole width. Whatever follows (a steps table) spans the + * width beneath. */ import { css } from "@hashintel/ds-helpers/css"; @@ -36,8 +37,10 @@ const columnsStyle = css({ gridTemplateColumns: "minmax(0, 1fr)", gridTemplateAreas: '"secondary" "primary" "after"', "@container drawer-frame-body (min-width: 960px)": { - gridTemplateColumns: "minmax(0, 3fr) minmax(652px, 5fr)", - gridTemplateAreas: '"primary secondary" "after after"', + "&[data-primary=true]": { + gridTemplateColumns: "minmax(0, 3fr) minmax(652px, 5fr)", + gridTemplateAreas: '"primary secondary" "after after"', + }, }, }); @@ -53,14 +56,18 @@ export const FrameColumns = ({ secondary, after, }: { - /** The parameters band, then the surface card. */ + /** The surface card; absent, the secondary column takes the width. */ primary?: ReactNode; /** The metric cards grid. */ secondary?: ReactNode; /** Full width beneath both columns: a steps table. */ after?: ReactNode; }) => ( -
+
{primary === undefined ? null : (
{primary} From 170777d8dbf413b6b0eb82c39f60f3ab7b7551da Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 12:33:42 +0200 Subject: [PATCH 10/13] Add a plain slider variant and use it in the parameter navigators The ds Slider gains variant plain: a 12px round thumb of one fixed size, no swell while dragged, and a rail that spans the root whatever aligns the root's children. The optimization navigator centred the slider root's children, which shrank the rail to the thumb; the sweep navigator's range slider takes the same plain thumb. --- .changeset/ds-slider-plain-variant.md | 5 ++ .../src/components/Slider/slider.tsx | 90 ++++++++++++++----- libs/@hashintel/ds-components/src/main.ts | 6 +- .../experiments/sweep-navigator.tsx | 5 +- .../sweep-navigator/range-slider.tsx | 20 ++--- .../study-view/optimization-navigator.tsx | 11 ++- 6 files changed, 101 insertions(+), 36 deletions(-) create mode 100644 .changeset/ds-slider-plain-variant.md diff --git a/.changeset/ds-slider-plain-variant.md b/.changeset/ds-slider-plain-variant.md new file mode 100644 index 00000000000..dc47c0c780b --- /dev/null +++ b/.changeset/ds-slider-plain-variant.md @@ -0,0 +1,5 @@ +--- +"@hashintel/ds-components": patch +--- + +`Slider` gains a `plain` variant: a small round thumb of one fixed size that never swells while dragged, for dense control rows. The rail now spans the slider's root whatever aligns the root's children. diff --git a/libs/@hashintel/ds-components/src/components/Slider/slider.tsx b/libs/@hashintel/ds-components/src/components/Slider/slider.tsx index e5d2e1bf2b6..fe6490315ae 100644 --- a/libs/@hashintel/ds-components/src/components/Slider/slider.tsx +++ b/libs/@hashintel/ds-components/src/components/Slider/slider.tsx @@ -6,6 +6,29 @@ const THUMB_WIDTH = 18; const THUMB_HEIGHT = 16; const THUMB_RADIUS = THUMB_HEIGHT / 2; const THUMB_ACTIVE_SCALE = 2.2; +/** The plain variant's round thumb, in pixels; it never scales. */ +const PLAIN_THUMB_SIZE = 12; + +/** + * How the thumb draws. `default` is the pill thumb that swells while dragged; + * `plain` is a small round thumb of one fixed size, for dense control rows + * where a swelling thumb would cover its neighbours. + */ +export type SliderVariant = "default" | "plain"; + +const plainThumbStyles = css({ + outline: "none", + display: "block", + width: `[${PLAIN_THUMB_SIZE}px]`, + height: `[${PLAIN_THUMB_SIZE}px]`, + borderRadius: "full", + border: "[1px solid rgba(255,255,255,0.6)]", + backgroundColor: "blue.s90", + boxShadow: "[0 1px 3px rgba(37,99,235,0.3)]", + "&[data-focus]": { + boxShadow: "[0 0 0 3px rgba(59,130,246,0.3)]", + }, +}); const thumbInnerStyles = css({ display: "block", @@ -28,6 +51,7 @@ export interface SliderProps { defaultValue?: number; label?: string; showValueText?: boolean; + variant?: SliderVariant; /** Shows the value without letting the pointer or keyboard move it. */ disabled?: boolean; onChange?: (value: number) => void; @@ -45,15 +69,24 @@ export const Slider: React.FC = ({ defaultValue, label, showValueText = false, + variant = "default", disabled, onChange, onChangeEnd, }) => { + const plain = variant === "plain"; return ( = ({ position: "relative", display: "flex", alignItems: "center", + // The rail spans the root whatever aligns the root's children; a + // centred root would otherwise shrink the control to the thumb. + width: "full", + "&[data-variant=plain]": { + height: `[${PLAIN_THUMB_SIZE + 4}px]`, + }, })} + data-variant={variant} > = ({ /> - div": { - background: - "[linear-gradient(180deg, rgba(59,130,246,0.95) 0%, rgba(37,99,235,0.98) 100%)]", - transformOrigin: "center", - transition: - "[transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), background 0.2s ease]", - }, - "&[data-dragging] > div": { - transform: `scale(${THUMB_ACTIVE_SCALE})`, - background: "[rgba(255,255,255,0.2)]", - shadow: - "[0 2px 4px rgba(0,0,0,0.1), inset 0 1px 3px rgba(0,0,0,0.1), inset 0 -1px 3px rgba(255,255,255,0.1)]", - }, - })} - > -
- - + {plain ? ( + + + + ) : ( + div": { + background: + "[linear-gradient(180deg, rgba(59,130,246,0.95) 0%, rgba(37,99,235,0.98) 100%)]", + transformOrigin: "center", + transition: + "[transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), background 0.2s ease]", + }, + "&[data-dragging] > div": { + transform: `scale(${THUMB_ACTIVE_SCALE})`, + background: "[rgba(255,255,255,0.2)]", + shadow: + "[0 2px 4px rgba(0,0,0,0.1), inset 0 1px 3px rgba(0,0,0,0.1), inset 0 -1px 3px rgba(255,255,255,0.1)]", + }, + })} + > +
+ + + )} ); diff --git a/libs/@hashintel/ds-components/src/main.ts b/libs/@hashintel/ds-components/src/main.ts index 33e97f34859..16a94401fa2 100644 --- a/libs/@hashintel/ds-components/src/main.ts +++ b/libs/@hashintel/ds-components/src/main.ts @@ -74,7 +74,11 @@ export { Select, type SelectItem, } from "./components/Select/select"; -export { Slider, type SliderProps } from "./components/Slider/slider"; +export { + Slider, + type SliderProps, + type SliderVariant, +} from "./components/Slider/slider"; export { TextArea } from "./components/TextArea/text-area"; export { TextInput } from "./components/TextInput/text-input"; export { TextMark } from "./components/TextMark/text-mark"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx index 911dd91ac45..8caaad36ae6 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-navigator.tsx @@ -4,8 +4,8 @@ * parameter's quantized interval — the whole interval by default, * collapsible to a single point — and committing a move reports the new * selection so the owner can redirect compute to it. In the experiment - * drawer this strip lives in the section's sticky band and stays visible - * while the charts scroll. + * drawer this strip is the Parameters band across the body, under the + * header. * * Purely presentational: selection and sampling progress come in as props, * and the only output is `onSelectionChange`. Slider moves commit live — @@ -157,6 +157,7 @@ const AxisControl = ({ // thumbs trap the drag on the upper one, which cannot move left. = ({ className, @@ -66,6 +64,7 @@ export const RangeSlider: React.FC = ({ step={step} value={[value[0], value[1]]} minStepsBetweenThumbs={0} + thumbSize={{ width: THUMB_SIZE, height: THUMB_SIZE }} disabled={disabled} aria-label={ ariaLabel ? [`${ariaLabel} minimum`, `${ariaLabel} maximum`] : undefined @@ -93,6 +92,7 @@ export const RangeSlider: React.FC = ({ position: "relative", display: "flex", alignItems: "center", + width: "full", height: `[${THUMB_SIZE + 4}px]`, })} > diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx index 7ff2709fda5..3c4ff71505d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view/optimization-navigator.tsx @@ -90,7 +90,15 @@ const nameStyle = css({ whiteSpace: "nowrap", }); +// The slider's root: it stretches across the row so the rail is drawn at +// full width; centring its children would shrink the rail to the thumb. const controlStyle = css({ + flex: "1", + minWidth: "[0]", + justifyContent: "center", +}); + +const toggleSlotStyle = css({ flex: "1", display: "flex", alignItems: "center", @@ -176,6 +184,7 @@ export const OptimizationNavigator = ({ {identifier} - + Date: Wed, 9 Sep 2026 12:33:42 +0200 Subject: [PATCH 11/13] Lay the surface footer out as a grid with a reserved second row for the metric select The axis selects share their row's width instead of a fixed 170px each, so the footer fits the surface card's narrowest column; the sweep's Metric select takes a second row the footer reserves at all times. --- .../experiments/sweep-surface.tsx | 3 ++ .../SimulateView/shared/surface-frame.tsx | 32 ++++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx index e016d30a3b2..95879c2e0df 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/sweep-surface.tsx @@ -25,6 +25,7 @@ import { ContourSurface } from "../../../../../components/contour-surface"; import { formatAxisValue } from "../shared/format-axis-value"; import { describeSurfaceSampling, + SURFACE_FOOTER_TWO_ROW_HEIGHT, SURFACE_PLOT_HEIGHT, SurfaceAxisControls, surfaceCaption, @@ -215,6 +216,8 @@ export const SweepSurface = ({ }), })} bodyHeight={SURFACE_PLOT_HEIGHT} + // The axis selects on one row, the metric select on the next. + footerHeight={SURFACE_FOOTER_TWO_ROW_HEIGHT} footer={ div > div": { minWidth: "[0]" }, }); @@ -40,6 +47,7 @@ export const SurfaceFrame = ({ actions, bodyHeight, footer, + footerHeight = SURFACE_FOOTER_HEIGHT, tone, children, }: { @@ -52,6 +60,8 @@ export const SurfaceFrame = ({ bodyHeight?: number; /** The axis selects and whatever else the view controls. */ footer: ReactNode; + /** The footer's content height; two rows when the view adds controls to the axis selects. */ + footerHeight?: number; tone?: ChartCardTone; children: ReactNode; }) => ( @@ -61,7 +71,7 @@ export const SurfaceFrame = ({ actions={actions} bodyHeight={bodyHeight} footer={footer} - footerHeight={SURFACE_FOOTER_HEIGHT} + footerHeight={footerHeight} tone={tone} > {children} @@ -72,7 +82,7 @@ export const SurfaceControlLabel = ({ children }: { children: ReactNode }) => ( {children} ); -/** The X and Y axis selects; `children` adds further controls to the row. */ +/** The X and Y axis selects on one row; `children` adds further controls on the row beneath. */ export const SurfaceAxisControls = ({ axes, xAxisId, From 7c5d452e358c71ef286ee25661b8c161c219f772 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 12:42:15 +0200 Subject: [PATCH 12/13] Size the Time column for a fractional readout and the compute badge for the strip The live simulated time prints three decimals under the maximum, so the column's widest text is a fraction just under the maximum over the maximum. The compute badge is 18px tall like the status pill and the computing chip, so it fits the strip's value line instead of clipping under it. --- .../experiments/view-experiment-drawer/experiment-stats.tsx | 5 ++++- .../panels/SimulateView/shared/compute-backend-badge.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx index 682341c8a8c..14412fef407 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/view-experiment-drawer/experiment-stats.tsx @@ -108,6 +108,9 @@ export const ExperimentStats = ({ const status = STATUS_DISPLAY[experiment.status]; const runCount = formatCount(experiment.runCount); const maxTime = formatFixed(experiment.maxTime); + // The widest time readout: a fraction just under the maximum, which prints + // its three decimals, over the maximum. + const widestTime = `${formatFixed(Math.max(0, experiment.maxTime - 0.001))} / ${maxTime}`; return ( <> @@ -127,7 +130,7 @@ export const ExperimentStats = ({ {formatCount(progress?.erroredRuns ?? 0)} - + {formatFixed(progress?.time ?? settledTime(experiment))} / {maxTime} {/* Wall-clock, as distinct from the simulated time; it stops once the diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx index af3e070d3f3..892f222fdd3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/shared/compute-backend-badge.tsx @@ -12,14 +12,17 @@ export type ComputeBackendSummary = Pick< // Local rather than the design system's `Badge`, whose `brand` scheme puts // #5EB1EF on a near-white #FBFDFF — about 2.3:1, below the 4.5:1 WCAG AA // needs for text this size. +// 18px tall, like the status pill and the computing chip it shares the +// header's strip with. const badgeStyle = css({ display: "inline-flex", alignItems: "center", gap: "1", paddingX: "1.5", - paddingY: "[2px]", + height: "[18px]", borderRadius: "sm", fontSize: "xs", + lineHeight: "[18px]", fontWeight: "medium", color: "neutral.s110", backgroundColor: "neutral.s10", From e7007b0d27a3242ae0aed881c324230f6d6cdc7a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 9 Sep 2026 14:36:35 +0200 Subject: [PATCH 13/13] Name the Sensitivity analysis card in the frame's comment and layout test --- .../Editor/panels/SimulateView/optimizations/study-view.tsx | 2 +- .../optimizations/view-optimization-drawer.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx index 00d73a34169..4f9e78c2f05 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/study-view.tsx @@ -4,7 +4,7 @@ * badge, the steps bar; the Parameters band across the body; then, arranged * by the frame's width, the surface, the chart cards (the objective at the * point, the objective by step, Constraints when the study declares any, - * Parameter importance) and the steps table; and the actions in the footer. + * Sensitivity analysis) and the steps table; and the actions in the footer. */ import { use, type ReactNode } from "react"; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx index a895874cb0b..74aa7855bae 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/view-optimization-drawer.test.tsx @@ -1044,7 +1044,7 @@ describe("ViewOptimizationDrawer holds every box still across states", () => { expect(signatures[0]!.cards.map(([title]) => title)).toEqual([ "Objective at the step in flight", "Objective by step", - "Parameter importance", + "Sensitivity analysis", ]); for (const signature of signatures.slice(1)) { // A settled study titles the objective card for the point it shows;