From e72d69174fcb7a044d09d2884951b88ef4fbb77f Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 15 Sep 2026 02:37:44 +0200 Subject: [PATCH 1/4] Keep ad-hoc value errors visible while editing --- .changeset/ad-hoc-value-errors.md | 5 ++++ .../petrinaut/docs/ad-hoc-scenarios.md | 2 +- .../ad-hoc-scenario-form.test.tsx | 30 +++++++++++++++++-- .../ad-hoc-scenario-form/value-editor.tsx | 6 ++-- 4 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 .changeset/ad-hoc-value-errors.md diff --git a/.changeset/ad-hoc-value-errors.md b/.changeset/ad-hoc-value-errors.md new file mode 100644 index 00000000000..9b6a5dbb7b6 --- /dev/null +++ b/.changeset/ad-hoc-value-errors.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Keep validation errors visible while editing ad-hoc scenario values. diff --git a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md index 0d4b5cfbb84..02963389828 100644 --- a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md +++ b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md @@ -94,4 +94,4 @@ Selecting a saved ad-hoc scenario in Simulation Settings shows it through the sa ## Errors -Ad-hoc definitions are validated as you type, on the value they belong to, and again when you run. In quick simulation, compile problems also appear in the Simulation Settings error banner; in the experiment panel, in the footer. +Ad-hoc definitions are validated as you type, on the value they belong to, and again when you run. Hover over an underlined value to read its error, or open the value editor to see the error below the input. In quick simulation, compile problems also appear in the Simulation Settings error banner; in the experiment panel, in the footer. diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx index 6f539ecc131..17dbdd4a990 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx @@ -360,7 +360,7 @@ describe("AdHocScenarioForm", () => { expect(place.count.expression).toBe("parameters.rate * 4"); }); - it("renders a synthesis error on the closed slot's trigger", () => { + it("keeps bound errors visible when the editor opens", async () => { const initial: AdHocScenarioState = { variables: [], netParameters: [], @@ -389,7 +389,33 @@ describe("AdHocScenarioForm", () => { const trigger = screen.getByRole("button", { name: "Pumps › item 0 › pressure", }); - expect(trigger.getAttribute("title")).toContain("nope"); + const error = trigger.getAttribute("title"); + expect(error).toBeTruthy(); + fireEvent.click(trigger); + expect((await screen.findByRole("alert")).textContent).toBe(error); + }); + + it("shows an invalid ratio value below valid optimization bounds", async () => { + render( + , + ); + const trigger = screen.getByTitle(/between 0 and 1/); + fireEvent.click(trigger); + expect((await screen.findByRole("alert")).textContent).toContain( + "between 0 and 1", + ); }); it("removes the row when Delete is pressed on its gutter", async () => { diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor.tsx index e5f926073e6..5d657b317db 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor.tsx @@ -994,8 +994,10 @@ export const ValueEditor: React.FC = ({ /> ) : null} - {boundsError ? ( -
{boundsError}
+ {error ? ( +
+ {error} +
) : null} From a86200351f725c4a432c4bb2837b5c8eafe3f5a8 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 15 Sep 2026 23:03:18 +0200 Subject: [PATCH 2/4] Polish scenario forms, keyboard navigation and computed values --- .changeset/ad-hoc-value-errors.md | 2 +- .../petrinaut/docs/ad-hoc-scenarios.md | 8 +- libs/@hashintel/petrinaut/docs/experiments.md | 4 +- libs/@hashintel/petrinaut/docs/simulation.md | 8 +- .../ad-hoc-scenario-form.test.tsx | 47 +- .../ad-hoc-scenario-form.tsx | 153 ++-- .../ad-hoc-scenario-form/form-context.ts | 2 + .../form-section-header.tsx | 51 ++ .../ad-hoc-scenario-form/parameter-rows.tsx | 2 +- .../ad-hoc-scenario-form/place-block.tsx | 65 +- .../spreadsheet/form-table.ts | 4 +- .../ad-hoc-scenario-form/value-editor.tsx | 166 ++-- .../value-editor/computed-expression.tsx | 110 +++ .../src/ui/components/overlay-scroll-area.tsx | 71 ++ .../components/pointer-help-tooltip.test.tsx | 31 + .../ui/components/pointer-help-tooltip.tsx | 31 + .../src/ui/components/scroll-fade.tsx | 69 ++ .../petrinaut/src/ui/components/section.tsx | 124 ++- .../src/ui/components/spreadsheet.tsx | 2 +- .../ui/components/stacked-sections.test.tsx | 134 +++ .../src/ui/components/stacked-sections.tsx | 350 ++++++++ .../user-settings-dialog/settings-panel.tsx | 81 +- .../subviews/simulation-settings.tsx | 764 ++++++++---------- .../create-experiment-drawer.test.tsx | 7 + .../experiments/create-experiment-drawer.tsx | 543 +++++++------ .../experiment-scenario-run.test.tsx | 41 +- .../experiments/experiment-scenario-run.tsx | 71 +- .../scenarios/ad-hoc-scenario-authoring.tsx | 127 +-- .../scenarios/create-scenario-drawer.test.tsx | 4 +- .../SimulateView/shared/simulation-panel.tsx | 9 +- .../views/shared/scenario-expressions.test.ts | 57 ++ .../ui/views/shared/scenario-expressions.ts | 72 ++ .../shared/simulation-scenario-controls.tsx | 42 +- .../src/ui/worksheet/focus-controls.test.tsx | 99 +++ .../src/ui/worksheet/focus-controls.tsx | 120 +++ .../src/ui/worksheet/use-focus-clearance.ts | 3 + 36 files changed, 2381 insertions(+), 1093 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-section-header.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor/computed-expression.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/components/overlay-scroll-area.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/components/pointer-help-tooltip.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/components/pointer-help-tooltip.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/components/scroll-fade.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/components/stacked-sections.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/components/stacked-sections.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/shared/scenario-expressions.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/shared/scenario-expressions.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/worksheet/focus-controls.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/worksheet/focus-controls.tsx diff --git a/.changeset/ad-hoc-value-errors.md b/.changeset/ad-hoc-value-errors.md index 9b6a5dbb7b6..f348b145382 100644 --- a/.changeset/ad-hoc-value-errors.md +++ b/.changeset/ad-hoc-value-errors.md @@ -2,4 +2,4 @@ "@hashintel/petrinaut": patch --- -Keep validation errors visible while editing ad-hoc scenario values. +Improve scenario and experiment forms with keyboard navigation, stacked section headers, overlay scrollbars, default starting-place filtering, and source expressions over selected computed values. Keep validation errors visible while editing ad-hoc values. diff --git a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md index 02963389828..a90a5562027 100644 --- a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md +++ b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md @@ -18,9 +18,9 @@ The form has up to three sections. Variables come first -- parameter overrides m - **Variables** -- named values (real, integer, boolean, or ratio -- a real between 0 and 1) written as `scenario.` in every expression below, exactly as scenario parameters are written in scenario code. Use them to drive many values from one number. Add one from the dimmed **Add a variable** line at the bottom of the list: like any cell, a first click selects it and a second click (or Enter, or its gutter's `+`) adds the variable -- or reach it with the down arrow from the last row; the fresh name opens ready to type. Each row starts with a small variable-glyph gutter whose menu offers **Delete variable**, and the add line's gutter shows a `+`. A variable's name edits like any other cell: select it, then press Enter (or click again) to edit, and Enter or Escape to leave. Its type select is a cell too: arrow keys move past it, Enter opens it. In the quick-simulation embedding, Variables sit above Parameters in the left column. - **Parameters** -- one row per [net-level parameter](petri-net-extensions.md#global-parameters), showing its type and its value. An untouched parameter shows its default quietly, marked with a small `default` tag; enter an expression to override the value for this run -- it may read the Variables above. In the quick-simulation embedding this section sits under Variables in the left column, beside Initial state. -- **Initial state** -- one block per place in the net. Each place's title carries its token colour dot (grey for untyped places). +- **Initial state** -- one block per default starting place. Turn on **Show all places** on the right of the header to include the other places. Turn it off to restore the filter. The switch appears only when there are other places to reveal. Each place's title carries its token colour dot (grey for untyped places). Filtering changes only what is visible; all place definitions are kept. -In the experiment panel each section collapses: click the chevron in its header, or focus the header and press Left to collapse and Right to expand. Place headers inside Initial state collapse the same way everywhere, and a collapsed place shows a one-line summary of its rows and token total. In the quick-simulation embedding, places start collapsed. +In the experiment panel each section collapses: click the chevron in its header, or focus the header and press Left to collapse and Right to expand. Place headers inside Initial state collapse the same way everywhere, and a collapsed place shows a one-line summary of its rows and token total. In the quick-simulation embedding, places start collapsed. Left on an expanded place collapses it and keeps focus on its header. Press Left again to move to the neighbouring focus group. Every value in the form is an expression. A first click selects a value; a second click, a double-click, or Enter opens the editor in place: a code input with completion and type checking at exactly the cell's position, the value's path (for example `Space › item 0 › x`) above it, and -- in the experiment panel with sweeps enabled -- the interval toggle below it. Expressions may use your Variables (`scenario.`), net parameters (`parameters.`), and arithmetic -- the same [expression language](scenarios.md#expression-language) scenarios use. Press Enter, Escape, or click elsewhere to close the editor. Escape closes only the innermost thing that is open -- a completion list, a bound edit, the editor itself -- and never the panel or dialog around the form; close those from their own buttons. Closing tidies a valid expression's formatting (spacing, redundant parentheses) without changing its meaning. A value may also be left **empty**: an empty cell reads as its type's neutral value -- 0 for numbers, `false` for booleans, `""` for text, the nil UUID -- shown grayed in the cell, and it is never an error. An empty dynamic-row count means 1 token; an empty place count means 0. @@ -28,6 +28,8 @@ Opening a value with Enter or a second click selects its whole content, so typin ### Keyboard editing and undo +Section headers stack at the top as you scroll. Earlier headers fade slightly; click one to return to that section. Upcoming section headers stay at the bottom; click one to jump ahead. A soft fade marks the edge where content scrolls beneath the headers and clears when you return to the section's start. Spreadsheet text does not select when dragged; text selection remains available inside an open editor. + Every table in the form is a keyboard grid: arrow keys move between cells, phantom rows and type selects included, and moving up from a dynamic row's cells lands on its count strip, so counts and bounds are editable without the mouse. While an editor is open on a value that is just a number (or empty), the up and down arrows step it by 1 (by 10 with Shift held); a ratio steps by 0.1 (by 0.01 with Shift held) and stays between 0 and 1; on a boolean value, Up sets `true` and Down sets `false`; text and UUID values leave the arrows to the editor. Where the form lays its sections out as side-by-side columns (the quick-simulation embedding), vertical arrows stay within a column, and a horizontal arrow at a table's edge crosses into the neighbouring column, returning you to the cell you last used there. Tab keeps its usual browser behaviour throughout the form (inside an open row menu it dismisses the menu, as menus do). A token table's column headers are the grid's top line. In a token table, the left arrow from a row's first cell reaches the **row gutter**: focusing it highlights and selects the whole row, Enter opens the row's menu, and Delete removes the row. The menu is a keyboard menu too: it opens with the current kind focused, arrow keys move through the items, Enter chooses, and Escape returns to the gutter. Every row action lives in that menu -- the row kinds and **Delete row**. The walk does not stop at a table's edge: moving down from a table's last row continues to the next part of the form -- a section header, a place header, the next table -- and moving up continues backwards the same way. Collapsed sections are skipped. @@ -90,7 +92,7 @@ A saved scenario shown through the form in the experiment panel offers the same Saving keeps your form entries as the scenario's definition, so editing the scenario reopens exactly the form you left. -Selecting a saved ad-hoc scenario in Simulation Settings shows it through the same form, read-only: only the scenario parameters (the exposed Variables) take value edits, for that run alone; auxiliary Variables stay hidden, and the parameter overrides and initial state can be browsed with the usual keyboard navigation but not changed. Editing any scenario opens this form: a scenario saved per place by an earlier version or by the AI assistant opens converted, and saving stores it in the form's format; a scenario that defines its initial state as code keeps that code, shown read-only -- edit its name, description, Variables and Parameters here, or recreate it from the form with a Dynamic row (see [Scenarios](scenarios.md#scenarios-stored-as-code)). Such a scenario stores no form entries, so every Variable must be marked **Scenario Parameter** to be kept -- the form refuses to save one that is not. +Selecting a saved ad-hoc scenario in Simulation Settings shows it through the same form, read-only: only the scenario parameters (the exposed Variables) take value edits, for that run alone. The Parameters and Initial state sections show computed values, updating when you change a scenario parameter. Select a value to see its source expression in a floating cell over the selected value. The expression disappears when focus moves away. Auxiliary Variables stay hidden. Editing any scenario opens this form: a scenario saved per place by an earlier version or by the AI assistant opens converted, and saving stores it in the form's format; a scenario that defines its initial state as code keeps that code, shown read-only -- edit its name, description, Variables and Parameters here, or recreate it from the form with a Dynamic row (see [Scenarios](scenarios.md#scenarios-stored-as-code)). Such a scenario stores no form entries, so every Variable must be marked **Scenario Parameter** to be kept -- the form refuses to save one that is not. ## Errors diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index 4b094472861..a7cea1f0045 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -39,7 +39,7 @@ experiments are not restored after a reload. With "No scenario" selected, the Scenario section shows the [ad-hoc scenario form](ad-hoc-scenarios.md): define the initial state and parameter values inline for this experiment, without saving a scenario. Left untouched, the experiment runs from the manually-set markings and defaults. The experiments table shows "Ad-hoc scenario" in its Scenario column for such runs. With [Parameter sweeps](#parameter-sweeps) enabled, every numeric value of the form carries the same interval toggle -- see [Interval selections](ad-hoc-scenarios.md#interval-selections-experiments). -With a scenario selected, the Scenario section shows it through the same form: the scenario parameters take value edits in worksheet style -- a ratio parameter's edit applies only between 0 and 1; outside, the form marks it and the run keeps the previous value -- each numeric one with the interval toggle when Parameter sweeps is on, and a collapsed **Computed state** sub-section underneath previews the exact parameter values and initial tokens each run will start with -- computed only when you open it, and recomputed as you change the values above. A swept parameter previews at the start of its range, the first combination the sweep runs, and the preview says so. The preview sits in its own tinted panel and scrolls as one, so a net with many places leaves the rest of the panel in reach. +With a scenario selected, the Scenario section shows it through the same form: the scenario parameters take value edits in worksheet style -- a ratio parameter's edit applies only between 0 and 1; outside, the form marks it and the run keeps the previous value -- each numeric one with the interval toggle when Parameter sweeps is on, and a collapsed **Computed state** sub-section underneath previews the exact parameter values and initial tokens each run will start with -- computed only when you open it, and recomputed as you change the values above. A swept parameter previews at the start of its range, the first combination the sweep runs, and the preview says so. The preview scrolls within a bordered panel, with Parameters and Initial state headers that stack as you scroll. Click a faded earlier header to return to its section, or an upcoming header at the bottom to jump ahead. Scrollbars overlay the content when you hover over a scrollable area, without shifting the columns. It initially shows default starting places; turn on **Show all places** beside Initial state to inspect the rest. The switch appears only when there are other places. Select a computed value to see its source expression in a floating cell over the selected value. The expression disappears when focus moves away. | **Runs** | `1000` | Positive integer; how many independent simulations to run. For a sweep the field reads **Max runs per selection**: each selection refines progressively (8, 25, 100, … 1000, 5000, …) up to this ceiling, so large budgets — 100,000 on the GPU — sharpen the distribution the longer you stay. | | **Time step (dt)** | `0.1` | Same meaning as in single-run simulations (see [Simulation](simulation.md#time-step-dt)). | | **Max time (seconds)** | `180` | Each run advances until simulation time reaches this value, then completes. | @@ -87,6 +87,8 @@ Two consequences worth knowing: - Progress reports the slowest worker's position, so the progress bar never runs ahead of the results behind it. - Several experiments running at once each use the same number of workers, so they compete for cores and all of them slow down. Run them one at a time if you want any single one to finish as fast as possible. +Use arrow keys to move between the form's sections, fields, scenario tables, metrics, and footer actions. Left collapses an expanded section or place; Right expands it. Text fields keep Left and Right for moving the caret until it reaches an edge. Tab follows the usual browser order. + ### Parameter sweeps Parameter sweeps are experimental and off by default. Turn on **Parameter sweeps** under Simulation in the [settings dialog](visual-settings.md#parameter-sweeps-experimental) to get the interval toggle on every numeric value of the experiment form: it reads **Sweep** on its own, and **Optimize** once [In-browser optimization](visual-settings.md#in-browser-optimization-experimental) is on with an optimizer that runs in your browser -- a host whose optimizer runs elsewhere keeps **Sweep**, since a sweep can only be optimized in the browser. Either word means the same thing on the value: an interval instead of one number. diff --git a/libs/@hashintel/petrinaut/docs/simulation.md b/libs/@hashintel/petrinaut/docs/simulation.md index e607ac78cee..f1302e7217b 100644 --- a/libs/@hashintel/petrinaut/docs/simulation.md +++ b/libs/@hashintel/petrinaut/docs/simulation.md @@ -34,7 +34,7 @@ Quick-action buttons next to the picker let you edit the selected scenario, crea Override values for this run: - With **No scenario** selected: the form's **Parameters** table -- an expression per [net-level parameter](petri-net-extensions.md#global-parameters), the default shown with a `default` tag until you override it; expressions may read the Variables above as `scenario.`. -- With a scenario selected: the **scenario parameters** are shown instead, pre-filled with that scenario's defaults. Net-level parameter values are fixed by the scenario's [parameter overrides](scenarios.md#parameters) and are not editable here. Every selected scenario shows through the [ad-hoc form](ad-hoc-scenarios.md): its scenario parameters take value edits in the left column, and its parameter overrides and initial state sit read-only in the right one -- browsable with the same keyboard navigation, but only a scenario edit (the pencil next to the picker) changes them. A scenario saved from the ad-hoc form shows its definition; any other scenario shows a computed preview of the exact tokens the run will start with, recomputed as you change parameter values (very large places preview their first 100 rows). +- With a scenario selected: the **scenario parameters** are shown instead, pre-filled with that scenario's defaults. Net-level parameter values are fixed by the scenario's [parameter overrides](scenarios.md#parameters) and are not editable here. Every selected scenario shows through the [ad-hoc form](ad-hoc-scenarios.md): its scenario parameters take value edits in the left column, and its parameter overrides and initial state sit read-only in the right one -- browsable with the same keyboard navigation, but only a scenario edit (the pencil next to the picker) changes them. Every scenario shows computed parameter values and the exact tokens the run will start with, recomputed as you change scenario parameters. Select a read-only value to see its source expression in a floating cell over the selected value. The expression disappears when focus moves away. Very large places preview their first 100 rows. Changes here do not modify the parameter definition or the scenario -- they only apply to the simulation. Parameter values are locked while a simulation is running. Reset the simulation to change them. @@ -61,6 +61,12 @@ If there are unresolved error-severity [diagnostics](petri-net-extensions.md#dia simulation-settings +### Navigating Simulation Settings + +Use arrow keys to move between the scenario picker, its action buttons, the time-step field, and the tables below. Enter opens a picker or edits a selected table value. Text fields keep Left and Right for moving the caret until it reaches an edge. Tab follows the usual browser order. + +The uppercase section headers stack at the top as you scroll each column. Earlier headers fade slightly; click one to return to that section. Upcoming section headers stay at the bottom; click one to jump ahead. A soft fade marks the edge where content scrolls beneath the headers and clears when you return to the section's start. Informational tooltips are skipped by Tab and arrow-key navigation. Scrollbars overlay the content when you hover over a scrollable area, without shifting the columns. Initial state starts with the places marked **Default starting place** in their properties. Turn on **Show all places** on the header to inspect the rest. The switch appears only when the model contains other places. Left collapses an expanded place; Left again moves to the neighbouring focus group. + ## How a frame is computed Each simulation step proceeds in two phases: diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx index 17dbdd4a990..490bb5cba01 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.test.tsx @@ -81,6 +81,7 @@ const context: AdHocSynthesisContext = { name: "Pumps", colorId: "colour-pump", dynamicsEnabled: false, + showAsInitialState: true, differentialEquationId: null, x: 0, y: 0, @@ -90,6 +91,7 @@ const context: AdHocSynthesisContext = { name: "Queue", colorId: null, dynamicsEnabled: false, + showAsInitialState: true, differentialEquationId: null, x: 0, y: 0, @@ -147,6 +149,35 @@ const colouredPlace = (state: AdHocScenarioState | undefined) => { }; describe("AdHocScenarioForm", () => { + it("filters starting places without changing their authored state", async () => { + const onChange = vi.fn(); + render( + ({ + ...place, + showAsInitialState: place.id === "place-queue", + })), + }} + selection="none" + />, + ); + expect(screen.queryByRole("button", { name: "Pumps place" })).toBeNull(); + expect(screen.getByRole("button", { name: "Queue › count" })).toBeTruthy(); + fireEvent.click(screen.getByRole("checkbox", { name: "Show all places" })); + expect( + await screen.findByRole("button", { name: "Pumps place" }), + ).toBeTruthy(); + fireEvent.click(screen.getByRole("checkbox", { name: "Show all places" })); + await waitFor(() => + expect(screen.queryByRole("button", { name: "Pumps place" })).toBeNull(), + ); + expect(onChange).not.toHaveBeenCalled(); + }); + it("selects a row's kind from the gutter menu", async () => { let latest: AdHocScenarioState | undefined; render( @@ -653,11 +684,13 @@ describe("AdHocScenarioForm", () => { it("walks between the form's members and toggles sections from their headers", async () => { render(); - // Variables lead the form; down from the parameters grid (one row) - // lands on the Initial state section header. + // A model with only starting places needs no visibility toggle. const rateValue = screen.getByRole("button", { name: "Rate" }); rateValue.focus(); fireEvent.keyDown(rateValue, { key: "ArrowDown" }); + expect( + screen.queryByRole("checkbox", { name: "Show all places" }), + ).toBeNull(); expect(document.activeElement).toBe( screen.getByRole("button", { name: "Toggle Initial state section" }), ); @@ -1272,6 +1305,16 @@ describe("AdHocScenarioForm", () => { fireEvent.keyDown(addVariable, { key: "ArrowLeft" }); expect(document.activeElement).toBe(optimizeToggle); + pumpsHeader.focus(); + fireEvent.keyDown(pumpsHeader, { key: "ArrowLeft" }); + expect(pumpsHeader.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(pumpsHeader); + fireEvent.keyDown(pumpsHeader, { key: "ArrowLeft" }); + expect(document.activeElement).toBe(optimizeToggle); + fireEvent.keyDown(optimizeToggle, { key: "ArrowRight" }); + expect(document.activeElement).toBe(pumpsHeader); + fireEvent.keyDown(pumpsHeader, { key: "ArrowRight" }); + // Within the places column the walk still chains: up from the token // table's column header lands on the place's own add-variable line. const pressureHeader = screen.getByRole("button", { diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.tsx index 4625625f358..d9cf8282887 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/ad-hoc-scenario-form.tsx @@ -35,6 +35,7 @@ import { use, useEffect, useRef, useState } from "react"; +import { Toggle } from "@hashintel/ds-components"; import { css, cx } from "@hashintel/ds-helpers/css"; import { adHocPlaceStateFor, @@ -46,7 +47,8 @@ import { } from "@hashintel/petrinaut-core"; import { LanguageClientContext } from "../../../react/lsp/context"; -import { FocusRoot, FocusStack } from "../../worksheet/focus-stack"; +import { FocusControls } from "../../worksheet/focus-controls"; +import { FocusStack } from "../../worksheet/focus-stack"; import { useFocusClearance } from "../../worksheet/use-focus-clearance"; import { useFocusHeader } from "../../worksheet/use-focus-member"; import { Section, SectionList } from "../section"; @@ -59,6 +61,8 @@ import { useAdHocLspSession } from "./use-ad-hoc-lsp-session"; import { useAdHocFormHistory } from "./use-form-history"; import { VariableRows } from "./variable-rows"; +export { FormSectionHeader } from "./form-section-header"; + import type { AdHocFocusTarget } from "./dependency-highlight"; import type { AdHocFormMode, @@ -69,6 +73,7 @@ import type { AdHocScenarioState, AdHocSlot, AdHocSynthesisContext, + AdHocValueTarget, } from "@hashintel/petrinaut-core"; // The CSS twin of useFocusClearance (which carries the shared 25px @@ -84,7 +89,7 @@ const focusClearanceStyle = css({ const placesListStyle = css({ display: "flex", flexDirection: "column", - gap: "1.5", + gap: "1", }); export interface AdHocScenarioFormProps { @@ -100,6 +105,7 @@ export interface AdHocScenarioFormProps { * everything else is read-only yet keyboard-navigable and selectable. */ mode?: AdHocFormMode; + expressionFor?: (target: AdHocValueTarget) => string | undefined; /** * Custom arrangement: the host receives each group — already wired to the * form's contexts — and lays them out itself (e.g. Simulation Settings @@ -115,6 +121,7 @@ export interface AdHocScenarioFormProps { variables: React.ReactNode; parameters: React.ReactNode; places: React.ReactNode; + placesVisibilityControl: React.ReactNode; }) => React.ReactNode; /** Classname for the form's root element (the keyboard-handling div). */ className?: string; @@ -144,14 +151,16 @@ const NavigableSection: React.FC<{ title: string; tooltip: string; children: React.ReactNode; -}> = ({ title, tooltip, children }) => { + action?: React.ReactNode; +}> = ({ title, tooltip, children, action }) => { const [open, setOpen] = useState(true); const header = useFocusHeader({ - collapse: () => setOpen(false), - expand: () => setOpen(true), + collapse: open ? () => setOpen(false) : undefined, + expand: open ? undefined : () => setOpen(true), }); return (
action : undefined} > {children}
@@ -172,11 +182,13 @@ export const AdHocScenarioForm: React.FC = ({ context, selection, mode = "author", + expressionFor = () => undefined, renderLayout, className, sessionId: externalSessionId, }) => { const sessionId = useAdHocLspSession(state, externalSessionId); + const [showAllPlaces, setShowAllPlaces] = useState(false); const { diagnosticsByUri, requestFormatExpression } = use( LanguageClientContext, ); @@ -300,6 +312,7 @@ export const AdHocScenarioForm: React.FC = ({ setFocusedValue, formatExpression: requestFormatExpression, mode, + expressionFor, dense: renderLayout !== undefined, overlayKeyDown: { capture: handleKeyDown, bubble: stopDeleteKeys }, }; @@ -322,9 +335,43 @@ export const AdHocScenarioForm: React.FC = ({ /> ); + const visiblePlaces = context.places.filter( + (place) => showAllPlaces || place.showAsInitialState, + ); + const hasOtherPlaces = context.places.some( + (place) => !place.showAsInitialState, + ); + const placesVisibilityControl = hasOtherPlaces ? ( + + + + ) : null; const placesList = ( -
- {context.places.map((place) => { +
+ {visiblePlaces.length === 0 ? ( +

+ {hasOtherPlaces + ? "No default starting places. Turn on “Show all places” to inspect the initial state." + : "No places defined."} +

+ ) : null} + {visiblePlaces.map((place) => { const placeState = adHocPlaceStateFor(state, context, place.id); const colour = place.colorId ? context.types.find((type) => type.id === place.colorId) @@ -356,57 +403,57 @@ export const AdHocScenarioForm: React.FC = ({ return ( - - {/* Undo/redo listens in the capture phase, so it sees keys before any + {/* Undo/redo listens in the capture phase, so it sees keys before any cell handler; open text fields and Monaco pass through untouched. */} -
- {renderLayout ? ( - - {renderLayout({ - variables: variableRows, - parameters: parameterRows, - places: placesList, - })} - - ) : ( - - - - {variableRows} - - - {parameterRows ? ( - - {parameterRows} - - ) : null} +
+ {renderLayout ? ( + + {renderLayout({ + variables: variableRows, + parameters: parameterRows, + places: placesList, + placesVisibilityControl, + })} + + ) : ( + + + + {variableRows} + + {parameterRows ? ( - {placesList} + {parameterRows} - - - )} -
- + ) : null} + + + {placesList} + +
+
+ )} +
); }; diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts index 63f0c046e85..fe296429675 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-context.ts @@ -79,6 +79,7 @@ export type AdHocFormMode = "author" | "run"; export interface AdHocFormServices { /** What the form lets the user change; see {@link AdHocFormMode}. */ mode: AdHocFormMode; + expressionFor: (target: AdHocValueTarget) => string | undefined; /** The whole form state, as currently edited. */ formState: AdHocScenarioState; /** @@ -139,6 +140,7 @@ export interface AdHocFormServices { export const AdHocFormContext = createContext({ mode: "author", + expressionFor: () => undefined, formState: { variables: [], netParameters: [], places: {} }, dispatch: () => {}, synthesisContext: { netParameters: [], places: [], types: [] }, diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-section-header.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-section-header.tsx new file mode 100644 index 00000000000..0b439dbb166 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/form-section-header.tsx @@ -0,0 +1,51 @@ +import { css } from "@hashintel/ds-helpers/css"; + +import { FocusStack } from "../../worksheet/focus-stack"; +import { PointerHelpTooltip } from "../pointer-help-tooltip"; +import { StackedSectionHeader } from "../stacked-sections"; + +const headerStyle = css({ + position: "sticky", + top: "[0]", + zIndex: "[2]", + display: "flex", + alignItems: "center", + gap: "1", + minHeight: "[28px]", + paddingY: "1", + backgroundColor: "neutral.s00", +}); +const titleStyle = css({ + fontSize: "[var(--form-heading-size, 12px)]", + fontWeight: "semibold", + color: "neutral.a100", + textTransform: "[var(--form-heading-case, none)]", + letterSpacing: "[var(--form-heading-spacing, normal)]", +}); +const actionsStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", + marginLeft: "auto", +}); + +export const FormSectionHeader: React.FC<{ + title: string; + tooltip?: string; + spaceBefore?: boolean; + children?: React.ReactNode; +}> = ({ title, tooltip, spaceBefore, children }) => ( + + {(renderTitle) => ( + <> + {renderTitle(title, titleStyle)} + {tooltip ? : null} + {children ? ( +
+ {children} +
+ ) : null} + + )} +
+); diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx index 388245a8923..502ab74046e 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/parameter-rows.tsx @@ -101,7 +101,7 @@ export const ParameterRows: React.FC = ({ entries }) => { target={target} value={entry} display={ - entry.optimize + entry.optimize || mode === "run" ? undefined : entry.expression || ( diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/place-block.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/place-block.tsx index cec79ba52c1..23619778095 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/place-block.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/place-block.tsx @@ -6,9 +6,6 @@ * grid-track transition and makes the content inert; collapsed, the place * is one line: its name and a summary of its rows and token total. An * uncoloured place is a header plus one full-width count cell. - * - * The collapse chevron hangs in the left margin, so every place name — - * coloured or not — starts at the same x as the blocks beneath it. */ import { use, useState } from "react"; @@ -31,11 +28,10 @@ import type { } from "@hashintel/petrinaut-core"; const blockStyle = css({ - contentVisibility: "auto", - containIntrinsicSize: "[auto 200px]", display: "flex", flexDirection: "column", gap: "1.5", + "&[data-collapsed]": { gap: "0" }, }); const denseBlockStyle = css({ @@ -52,9 +48,6 @@ const headerStyle = css({ minHeight: "[26px]", }); -// The place-name trigger pulls itself left by its padding plus the chevron -// slot, so the dot + name align with the un-chevroned headers and the -// tables below. const placeNameButtonStyle = css({ display: "flex", alignItems: "center", @@ -62,10 +55,11 @@ const placeNameButtonStyle = css({ border: "none", background: "[transparent]", padding: "[2px 4px]", - marginLeft: "[-20px]", + marginLeft: "[-24px]", + minWidth: "[0]", borderRadius: "xs", - fontSize: "sm", - fontWeight: "semibold", + fontSize: "xs", + fontWeight: "medium", color: "neutral.s120", cursor: "pointer", _hover: { backgroundColor: "neutral.s10" }, @@ -76,11 +70,9 @@ const placeNameButtonStyle = css({ }, }); -// Collapsed, the title button takes the shared fixed width (the 20px -// chevron hang included), so the summary aligns with the uncoloured -// places' count cells. const collapsedTitleButtonStyle = css({ - width: "[190px]", + width: "[214px]", + flexShrink: "0", }); const collapsedTitleNameStyle = css({ @@ -97,8 +89,9 @@ const chevronStyle = css({ display: "flex", alignItems: "center", justifyContent: "center", - color: "neutral.s70", + color: "neutral.s100", width: "[12px]", + flexShrink: "0", transition: "[transform 0.12s ease]", }); @@ -153,16 +146,9 @@ const placeNameStyle = css({ alignItems: "center", gap: "1", padding: "[2px 0]", - fontSize: "sm", - fontWeight: "semibold", - color: "neutral.s120", -}); - -// The embedded (dense) rendering shrinks the titles a step and tightens -// their padding, so a long place list stays scannable in a panel. -const densePlaceNameStyle = css({ fontSize: "xs", fontWeight: "medium", + color: "neutral.s120", }); const headerSpacerStyle = css({ @@ -187,7 +173,8 @@ const uncolouredCountTriggerStyle = css({ }); const uncolouredTitleStyle = css({ - width: "[170px]", + marginLeft: "[-4px]", + width: "[194px]", flexShrink: "0", overflow: "hidden", whiteSpace: "nowrap", @@ -250,14 +237,17 @@ export const ColouredPlaceBlock: React.FC = ({ setEverExpanded(true); } const { attach: attachHeader, onHeaderKeyDown } = useFocusHeader({ - collapse: () => setCollapsed(true), - expand: () => setCollapsed(false), + collapse: collapsed ? undefined : () => setCollapsed(true), + expand: collapsed ? () => setCollapsed(false) : undefined, }); const total = placeTotal(place.id); const totalText = total.resolved ? `${total.total}` : total.text; return ( -
+
@@ -328,7 +313,7 @@ export const UncolouredPlaceBlock: React.FC = ({ place, state, }) => { - const { mode, dense } = use(AdHocFormContext); + const { mode } = use(AdHocFormContext); const target = { kind: "count" as const, placeId: place.id, row: null }; // The count cell is a single-element member: vertical arrows leave to the // neighbouring member, horizontal ones cross into a sibling column. @@ -336,19 +321,13 @@ export const UncolouredPlaceBlock: React.FC = ({ return (
- +
= ({ formatExpression, dispatch, overlayKeyDown, + expressionFor, } = use(AdHocFormContext); const triggerPlaceholder = placeholder ?? (kind === "count" ? "0" : adHocNeutralExpression(kind)); @@ -796,85 +798,95 @@ export const ValueEditor: React.FC = ({ }; const boundValue = (key: BoundKey): string => value.optimize?.[key] ?? ""; + const [focused, setFocused] = useState(false); + const sourceExpression = readOnly ? expressionFor(target) : undefined; + const showSource = focused && sourceExpression && sourceExpression !== text; + const sourceId = `${editorId}-source`; + const trigger = ( + + ); return ( <> - + {trigger} + {showSource ? ( + + ) : null} {open && rect ? (
; +}> = ({ id, expression, anchorRef }) => { + const portalContainerRef = usePortalContainerRef(); + const [bounds, setBounds] = useState<{ + top: number; + left: number; + width: number; + minHeight: number; + maxHeight: number; + } | null>(null); + const [visible, setVisible] = useState(true); + useLayoutEffect(() => { + const anchor = anchorRef.current; + if (!anchor) { + return; + } + const measure = () => { + const rect = anchor.getBoundingClientRect(); + const longestLine = Math.max( + ...expression.split("\n").map((line) => line.length), + ); + const width = Math.min( + window.innerWidth - 16, + Math.max(rect.width, Math.min(560, longestLine * 7.3 + 18)), + ); + setBounds({ + top: rect.top, + left: Math.max( + 8, + rect.left + width <= window.innerWidth - 8 + ? rect.left + : rect.right - width, + ), + width, + minHeight: rect.height, + maxHeight: Math.max( + rect.height, + Math.min(240, window.innerHeight - rect.top - 8), + ), + }); + }; + const resize = new ResizeObserver(measure); + resize.observe(anchor); + const intersection = new IntersectionObserver(([entry]) => + setVisible(entry?.isIntersecting ?? false), + ); + intersection.observe(anchor); + window.addEventListener("resize", measure); + window.addEventListener("scroll", measure, true); + measure(); + return () => { + resize.disconnect(); + intersection.disconnect(); + window.removeEventListener("resize", measure); + window.removeEventListener("scroll", measure, true); + }; + }, [anchorRef, expression]); + + return bounds && visible ? ( + +
event.preventDefault()} + style={bounds} + > + +
+            {expression}
+          
+
+
+
+ ) : null; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/components/overlay-scroll-area.tsx b/libs/@hashintel/petrinaut/src/ui/components/overlay-scroll-area.tsx new file mode 100644 index 00000000000..82f78f61ec4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/overlay-scroll-area.tsx @@ -0,0 +1,71 @@ +import { ScrollArea } from "@ark-ui/react/scroll-area"; + +import { css, cx } from "@hashintel/ds-helpers/css"; + +const rootStyle = css({ + display: "flex", + flexDirection: "column", + flex: "[1]", + minHeight: "[0]", + minWidth: "[0]", + overflow: "hidden", +}); +const viewportStyle = css({ + flex: "[1]", + minHeight: "[0]", + minWidth: "[0]", + scrollbarWidth: "[none]", + scrollbarGutter: "auto", + "&::-webkit-scrollbar": { display: "none" }, +}); +const scrollbarStyle = css({ + zIndex: "[4]", + padding: "[2px]", + opacity: "[0]", + pointerEvents: "none", + transition: "[opacity 120ms ease]", + "&[data-hover], &[data-dragging]": { opacity: "[1]", pointerEvents: "auto" }, + "&[data-orientation=vertical]": { width: "[8px]" }, + "&[data-orientation=horizontal]": { height: "[8px]" }, + "&[data-orientation=vertical]:not([data-overflow-y]), &[data-orientation=horizontal]:not([data-overflow-x])": + { display: "none" }, + "@media (prefers-reduced-motion: reduce)": { transition: "[none]" }, +}); +const thumbStyle = css({ + borderRadius: "full", + backgroundColor: "neutral.a60", + "&[data-orientation=horizontal]": { height: "[100%]" }, + "&[data-orientation=vertical]": { width: "[100%]" }, +}); + +export const overlayScrollDrawerBodyStyle = css({ + display: "flex", + overflow: "hidden", +}); +export const overlayScrollDrawerViewportStyle = css({ + paddingX: "[var(--panel-horizontal-padding)]", + paddingBottom: "5", +}); + +export const OverlayScrollArea: React.FC<{ + children: React.ReactNode; + className?: string; + viewportClassName?: string; +}> = ({ children, className, viewportClassName }) => ( + + + + {children} + + + + + + + + + +); diff --git a/libs/@hashintel/petrinaut/src/ui/components/pointer-help-tooltip.test.tsx b/libs/@hashintel/petrinaut/src/ui/components/pointer-help-tooltip.test.tsx new file mode 100644 index 00000000000..87f6b249f51 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/pointer-help-tooltip.test.tsx @@ -0,0 +1,31 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; + +import { FocusControls } from "../worksheet/focus-controls"; +import { PointerHelpTooltip } from "./pointer-help-tooltip"; + +afterEach(cleanup); + +it("keeps informational tooltips out of Tab and arrow navigation after rendering", () => { + const toolbar = (content: string) => ( + + + + + + ); + const { container, rerender } = render(toolbar("Help")); + const trigger = container.querySelector( + '[data-scope="tooltip"][data-part="trigger"]', + ); + expect(trigger?.tabIndex).toBe(-1); + rerender(toolbar("Updated help")); + expect(trigger?.tabIndex).toBe(-1); + const scenario = screen.getByRole("button", { name: "Scenario" }); + scenario.focus(); + fireEvent.keyDown(scenario, { key: "ArrowRight" }); + expect(document.activeElement).toBe( + screen.getByRole("spinbutton", { name: "Time step" }), + ); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/components/pointer-help-tooltip.tsx b/libs/@hashintel/petrinaut/src/ui/components/pointer-help-tooltip.tsx new file mode 100644 index 00000000000..0726a975a45 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/pointer-help-tooltip.tsx @@ -0,0 +1,31 @@ +import { useEffect, useRef } from "react"; + +import { HelpTooltip } from "@hashintel/ds-components"; +import { css, cx } from "@hashintel/ds-helpers/css"; + +const wrapperStyle = css({ + display: "inline-flex", + alignItems: "center", + flexShrink: "0", +}); +const iconStyle = css({ top: "[0]", marginLeft: "[0]", display: "block" }); + +export const PointerHelpTooltip: React.FC< + React.ComponentProps +> = ({ className, ...props }) => { + const rootRef = useRef(null); + useEffect(() => { + // DS adds a tab stop to informational icons; these forms reserve stops for controls. + const trigger = rootRef.current?.querySelector( + '[data-scope="tooltip"][data-part="trigger"]', + ); + if (trigger) { + trigger.tabIndex = -1; + } + }); + return ( + + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/components/scroll-fade.tsx b/libs/@hashintel/petrinaut/src/ui/components/scroll-fade.tsx new file mode 100644 index 00000000000..b49166e4546 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/scroll-fade.tsx @@ -0,0 +1,69 @@ +import { css } from "@hashintel/ds-helpers/css"; + +import type { CSSProperties } from "react"; + +const fadeStyle = css({ + position: "absolute", + left: "[0]", + right: "[0]", + pointerEvents: "none", + zIndex: "[3]", + "&[data-animated]": { + transition: "[opacity 150ms ease]", + "@media (prefers-reduced-motion: reduce)": { transition: "[none]" }, + }, +}); + +const layerStyle = css({ + position: "absolute", + inset: "[0]", +}); + +export const ScrollFade: React.FC<{ + edge: "top" | "bottom"; + visible: boolean; + size?: number; + blur?: number; + offset?: CSSProperties["top"]; + color?: string; + animated?: boolean; +}> = ({ + edge, + visible, + size = 16, + blur = 0, + offset = 0, + color = "var(--colors-neutral-s00)", + animated = true, +}) => ( + +); diff --git a/libs/@hashintel/petrinaut/src/ui/components/section.tsx b/libs/@hashintel/petrinaut/src/ui/components/section.tsx index 3ea16ba033b..167e3f5633a 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/section.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/section.tsx @@ -1,10 +1,13 @@ import { Collapsible } from "@ark-ui/react/collapsible"; -import { type ReactNode, use } from "react"; +import { type ReactNode, use, useState } from "react"; import { Button, HelpTooltip } from "@hashintel/ds-components"; import { css, cx } from "@hashintel/ds-helpers/css"; import { UserSettingsContext } from "../../react/state/user-settings-context"; +import { useFocusHeader } from "../worksheet/use-focus-member"; +import { PointerHelpTooltip } from "./pointer-help-tooltip"; +import { StackedSectionHeader, StackedSections } from "./stacked-sections"; // -- SectionList (wrapper) -------------------------------------------------- @@ -22,15 +25,32 @@ const sectionListStyle = css({ interface SectionListProps { children: ReactNode; + stacked?: boolean; } -export const SectionList = ({ children }: SectionListProps) => ( -
{children}
-); +export const SectionList = ({ children, stacked }: SectionListProps) => + stacked ? ( + {children} + ) : ( +
{children}
+ ); // -- Section ----------------------------------------------------------------- +const stackedSectionStyle = css({ + display: "contents", + "&[data-state=open] + [data-section] > [data-stack-anchor]": { + marginTop: "2", + }, +}); +const stackedContentStyle = css({ + paddingBottom: "2", + borderBottom: "[1px solid {colors.neutral.a20}]", + "&[data-part=content]": { overflow: "visible" }, +}); + const sectionStyle = css({ + "&:has([data-stacked-sections]) > [data-part=content]": { overflow: "clip" }, display: "flex", flexDirection: "column", position: "relative", @@ -60,6 +80,8 @@ const fillHeightSectionStyle = css({ }); const headerStyle = css({ + "&[data-stack-header]": { paddingY: "1" }, + "&[data-stack-header]::after": { display: "none" }, position: "sticky", top: "[0]", zIndex: "[2]", @@ -163,6 +185,7 @@ const fillHeightContentStyle = css({ interface SectionProps { title: string; + stacked?: boolean; tooltip?: string; collapsible?: boolean; defaultOpen?: boolean; @@ -190,6 +213,7 @@ interface SectionProps { export const Section = ({ title, + stacked = false, tooltip, collapsible = false, defaultOpen = true, @@ -205,52 +229,87 @@ export const Section = ({ children, className, }: SectionProps) => { + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const expanded = open ?? internalOpen; + const setExpanded = (next: boolean) => { + setInternalOpen(next); + onOpenChange?.(next); + }; + const header = useFocusHeader({ + collapse: expanded ? () => setExpanded(false) : undefined, + expand: expanded ? undefined : () => setExpanded(true), + }); const headerLeft = ( + renderTitle?: (text: string, className: string) => ReactNode, + ) => (
{renderHeaderLeading?.()} - {title} - {tooltip && } + {renderTitle ? ( + renderTitle(title, titleStyle) + ) : ( + {title} + )} + {tooltip && + (stacked ? ( + + ) : ( + + ))}
); const { showAnimations } = use(UserSettingsContext); + const renderHeaderContent = ( + renderTitle?: (text: string, className: string) => ReactNode, + ) => ( + <> +
+ {headerLeft(renderTitle)} + {renderHeaderAction &&
{renderHeaderAction()}
} + + + +
+ {renderStickyBand && ( +
{renderStickyBand()}
+ )} + + ); + if (collapsible) { return ( onOpenChange(details.open) : undefined - } + open={expanded} + onOpenChange={(details) => setExpanded(details.open)} lazyMount={unmountOnCollapse} unmountOnExit={unmountOnCollapse} - className={cx(sectionStyle, className)} + data-section + className={cx(stacked ? stackedSectionStyle : sectionStyle, className)} > -
-
- {headerLeft} - {renderHeaderAction &&
{renderHeaderAction()}
} - - - + {stacked ? ( + + {renderHeaderContent} + + ) : ( +
+ {renderHeaderContent()}
- {renderStickyBand && ( -
{renderStickyBand()}
- )} -
+ )}
{children}
@@ -261,6 +320,7 @@ export const Section = ({ return (
-
+
- {headerLeft} + {headerLeft()} {renderHeaderAction &&
{renderHeaderAction()}
}
{renderStickyBand && ( diff --git a/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx b/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx index d3fb8cc181a..1641b76f300 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx @@ -49,7 +49,7 @@ const tableContainerStyle = css({ borderWidth: "[1px]", borderStyle: "solid", borderColor: "neutral.bd.subtle", - borderRadius: "sm", + borderRadius: "md", overflow: "auto", width: "[100%]", backgroundColor: "neutral.s10", diff --git a/libs/@hashintel/petrinaut/src/ui/components/stacked-sections.test.tsx b/libs/@hashintel/petrinaut/src/ui/components/stacked-sections.test.tsx new file mode 100644 index 00000000000..cfb1ec48798 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/stacked-sections.test.tsx @@ -0,0 +1,134 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; + +import { StackedSectionHeader, StackedSections } from "./stacked-sections"; + +const sectionNames = ["Variables", "Parameters", "Initial state"]; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it("stacks previous headings and returns to the selected section in its own scroll area", async () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + vi.stubGlobal("matchMedia", () => ({ matches: true })); + const scrollTo = vi.fn(); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function measureSection(this: HTMLElement) { + const scroller = this.closest('[data-testid="scroll-area"]'); + const headers = Array.from( + scroller?.querySelectorAll("[data-stack-header]") ?? [], + ); + const header = this.hasAttribute("data-stack-anchor") + ? this.nextElementSibling + : this; + const index = headers.indexOf(header as Element); + const top = + index < 0 ? 100 : 112 + index * 200 - (scroller?.scrollTop ?? 0); + return { + top, + bottom: top + 32, + height: 32, + left: 0, + right: 400, + width: 400, + x: 0, + y: top, + toJSON: () => ({}), + }; + }, + ); + render( +
+ + {sectionNames.map((name) => ( +
+ + {(title) => title(name)} + +
{name} content
+
+ ))} +
+
, + ); + const variables = screen.getByRole("button", { name: "Back to Variables" }); + const parameters = screen.getByRole("button", { + name: /(?:Back|Go) to Parameters/, + }); + const initialState = screen.getByRole("button", { + name: /(?:Back|Go) to Initial state/, + }); + expect(variables.tabIndex).toBe(-1); + const scroller = screen.getByTestId("scroll-area"); + scroller.scrollTo = scrollTo; + Object.defineProperty(scroller, "clientHeight", { value: 220 }); + Object.defineProperty(scroller, "scrollHeight", { + get: () => + 450 + + Number.parseFloat( + scroller.querySelector("[data-stack-tail]")?.style + .height ?? "0", + ), + }); + fireEvent.scroll(scroller); + await waitFor(() => + expect(parameters.getAttribute("aria-label")).toBe("Go to Parameters"), + ); + expect(initialState.tabIndex).toBe(0); + await waitFor(() => + expect( + scroller.querySelector("[data-stack-tail]")?.style.height, + ).toBe("118px"), + ); + expect( + parameters.closest("[data-stack-header]")?.style.bottom, + ).toBe("16px"); + expect( + initialState.closest("[data-stack-header]")?.style.bottom, + ).toBe("-16px"); + fireEvent.click(initialState); + expect(scrollTo).toHaveBeenLastCalledWith({ top: 348, behavior: "instant" }); + scroller.scrollTop = 400; + fireEvent.scroll(scroller); + await waitFor(() => expect(variables.tabIndex).toBe(0)); + expect(parameters.tabIndex).toBe(0); + expect(initialState.tabIndex).toBe(-1); + expect( + initialState.closest("[data-stack-header]")?.style.top, + ).toBe("52px"); + fireEvent.click(parameters); + expect(scrollTo).toHaveBeenCalledWith({ top: 180, behavior: "instant" }); + const parameterFade = parameters + .closest("[data-stack-header]") + ?.querySelector('[data-scroll-fade="top"]'); + scroller.scrollTop = 183; + fireEvent.scroll(scroller); + await waitFor(() => expect(parameterFade?.style.opacity).toBe("1")); + scroller.scrollTop = 180.5; + fireEvent.scroll(scroller); + await waitFor(() => expect(parameterFade?.style.opacity).toBe("0")); + fireEvent.click(variables); + expect(scrollTo).toHaveBeenLastCalledWith({ top: 12, behavior: "instant" }); + scroller.scrollTop = 0; + fireEvent.scroll(scroller); + await waitFor(() => expect(variables.tabIndex).toBe(-1)); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/components/stacked-sections.tsx b/libs/@hashintel/petrinaut/src/ui/components/stacked-sections.tsx new file mode 100644 index 00000000000..e18fdb983d5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/stacked-sections.tsx @@ -0,0 +1,350 @@ +import { + createContext, + use, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; + +import { css, cx } from "@hashintel/ds-helpers/css"; + +import { UserSettingsContext } from "../../react/state/user-settings-context"; +import { FocusControls } from "../worksheet/focus-controls"; +import { ScrollFade } from "./scroll-fade"; + +interface HeaderPosition { + id: string; + top: number; + inactive: boolean; + upcoming: boolean; + bottom: number; + topFade: boolean; + bottomFade: boolean; +} + +const StackContext = createContext<{ + positions: HeaderPosition[]; + jump: (id: string) => void; +} | null>(null); + +const stackStyle = css({ + display: "flex", + flexDirection: "column", + minWidth: "[0]", +}); +const headerStyle = css({ + position: "sticky", + zIndex: "[3]", + backgroundColor: "neutral.s00", + flexShrink: "0", + "&[data-inactive] > :not([data-scroll-fade])": { opacity: "[0.6]" }, +}); +const animatedStyle = css({ + "& > *": { transition: "[opacity 160ms ease]" }, + "@media (prefers-reduced-motion: reduce)": { + "& > *": { transition: "[none]" }, + }, +}); +const anchorStyle = css({ + height: "[0]", + flexShrink: "0", + pointerEvents: "none", +}); +const sectionSpacingStyle = css({ marginTop: "3" }); +const titleButtonStyle = css({ + color: "[inherit]", + font: "inherit", + textAlign: "left", + border: "none", + padding: "[0]", + background: "[transparent]", + cursor: "pointer", + borderRadius: "xs", + "&[tabindex='-1']": { cursor: "default" }, + _focusVisible: { + outline: "[2px solid {colors.blue.s70}]", + outlineOffset: "[2px]", + }, +}); + +const scrollContentTop = (element: HTMLElement) => { + const paddingTop = Number.parseFloat(getComputedStyle(element).paddingTop); + return ( + element.getBoundingClientRect().top + + element.clientTop + + (Number.isFinite(paddingTop) ? paddingTop : 0) + ); +}; + +export const StackedSections: React.FC<{ + children: React.ReactNode; + className?: string; +}> = ({ children, className }) => { + const rootRef = useRef(null); + const scrollRef = useRef(null); + const [positions, setPositions] = useState([]); + const [trailingSpace, setTrailingSpace] = useState(0); + const { showAnimations } = use(UserSettingsContext); + + useLayoutEffect(() => { + const root = rootRef.current; + if (!root) { + return; + } + let scroller: HTMLElement | null = root; + while ( + scroller && + !/(auto|scroll)/.test(getComputedStyle(scroller).overflowY) + ) { + scroller = scroller.parentElement; + } + if (!scroller) { + return; + } + scrollRef.current = scroller; + let frame = 0; + const update = () => { + const headers = Array.from( + root.querySelectorAll("[data-stack-header]"), + ).filter((header) => header.closest("[data-stacked-sections]") === root); + let top = -( + Number.parseFloat(getComputedStyle(scroller).paddingTop) || 0 + ); + // A drawer's containing section may already pin its own header above this stack. + let parent = root.parentElement; + while (parent && parent !== scroller) { + if (parent.hasAttribute("data-section")) { + const header = parent.querySelector( + ":scope > [data-section-header]", + ); + if (header) { + top += header.getBoundingClientRect().height; + } + } + parent = parent.parentElement; + } + const scrollTop = scrollContentTop(scroller); + let active = 0; + const paddingBottom = + Number.parseFloat(getComputedStyle(scroller).paddingBottom) || 0; + const scrollBottom = + scroller.getBoundingClientRect().top + + scroller.clientTop + + scroller.clientHeight; + const next = headers + .map((header, index) => { + const id = header.dataset.stackHeader ?? ""; + const anchor = header.previousElementSibling; + const position = { + id, + top, + inactive: false, + upcoming: false, + bottom: 0, + topFade: false, + bottomFade: false, + }; + if ( + anchor && + anchor.getBoundingClientRect().top <= scrollTop + top + 1 + ) { + active = index; + } + top += header.getBoundingClientRect().height; + return position; + }) + .map((position, index) => ({ ...position, inactive: index < active })); + let bottom = 0; + let firstUpcoming = -1; + for (let index = next.length - 1; index >= 0; index--) { + const position = next[index]; + const header = headers[index]; + if (!position || !header) { + continue; + } + position.bottom = bottom - paddingBottom; + const height = header.getBoundingClientRect().height; + const anchorTop = + header.previousElementSibling?.getBoundingClientRect().top ?? 0; + position.upcoming = + index > active && anchorTop + height > scrollBottom - bottom + 1; + if (position.upcoming) { + firstUpcoming = index; + } + bottom += height; + } + const activePosition = next[active]; + if (activePosition) { + const anchorTop = + headers[active]?.previousElementSibling?.getBoundingClientRect() + .top ?? 0; + activePosition.topFade = anchorTop < scrollTop + activePosition.top - 2; + } + const upcomingPosition = next[firstUpcoming]; + if (upcomingPosition) { + upcomingPosition.bottomFade = true; + } + const lastPosition = next.at(-1); + const lastAnchor = headers.at(-1)?.previousElementSibling; + const tail = root.querySelector( + ":scope > [data-stack-tail]", + ); + if (lastPosition && lastAnchor && tail && scroller.clientHeight > 0) { + const currentSpace = Number.parseFloat(tail.style.height) || 0; + const targetScroll = + scroller.scrollTop + + lastAnchor.getBoundingClientRect().top - + scrollTop - + lastPosition.top; + const availableScroll = + scroller.scrollHeight - scroller.clientHeight - currentSpace; + setTrailingSpace( + Math.max(0, Math.ceil(targetScroll - availableScroll)), + ); + } + setPositions((previous) => + previous.length === next.length && + previous.every((position, index) => { + const candidate = next[index]; + return ( + candidate?.id === position.id && + candidate.top === position.top && + candidate.inactive === position.inactive && + candidate.upcoming === position.upcoming && + candidate.bottom === position.bottom && + candidate.topFade === position.topFade && + candidate.bottomFade === position.bottomFade + ); + }) + ? previous + : next, + ); + }; + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(update); + }; + const resize = new ResizeObserver(schedule); + resize.observe(root); + resize.observe(scroller); + const mutations = new MutationObserver(schedule); + mutations.observe(root, { childList: true, subtree: true }); + scroller.addEventListener("scroll", schedule, { passive: true }); + update(); + return () => { + cancelAnimationFrame(frame); + resize.disconnect(); + mutations.disconnect(); + scroller.removeEventListener("scroll", schedule); + scrollRef.current = null; + }; + }, []); + + const jump = (id: string) => { + const root = rootRef.current; + const scroller = scrollRef.current; + const anchor = Array.from( + root?.querySelectorAll("[data-stack-anchor]") ?? [], + ).find((element) => element.dataset.stackAnchor === id); + if (!scroller || !anchor) { + return; + } + const top = positions.find((position) => position.id === id)?.top ?? 0; + scroller.scrollTo({ + top: + scroller.scrollTop + + anchor.getBoundingClientRect().top - + scrollContentTop(scroller) - + top, + behavior: + showAnimations && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "smooth" + : "instant", + }); + }; + + return ( + +
+ {children} + + + ); +}; + +export const StackedSectionHeader: React.FC<{ + className?: string; + spaceBefore?: boolean; + children: ( + title: (text: string, className?: string) => React.ReactNode, + ) => React.ReactNode; +}> = ({ className, spaceBefore, children }) => { + const id = useId(); + const stack = use(StackContext); + const position = stack?.positions.find((entry) => entry.id === id); + const { showAnimations } = use(UserSettingsContext); + const title = (text: string, titleClassName?: string) => + stack ? ( + + + + ) : ( + {text} + ); + return ( + <> + ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx index ff6ee76c335..73d5c43c734 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx @@ -3,12 +3,11 @@ import { use, useState } from "react"; import { Banner, Button, - HelpTooltip, Icon, NumberInput, Select, } from "@hashintel/ds-components"; -import { css, cva, cx } from "@hashintel/ds-helpers/css"; +import { css, cx } from "@hashintel/ds-helpers/css"; import { classicRunParameterValues, classicRunVariables, @@ -26,8 +25,14 @@ import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { AdHocScenarioForm, FormLayoutColumn, + FormSectionHeader, } from "../../../../../components/ad-hoc-scenario-form/ad-hoc-scenario-form"; -import { useScrollOverflow } from "../../../../../hooks/use-scroll-overflow"; +import { OverlayScrollArea } from "../../../../../components/overlay-scroll-area"; +import { PointerHelpTooltip } from "../../../../../components/pointer-help-tooltip"; +import { StackedSections } from "../../../../../components/stacked-sections"; +import { FocusControls } from "../../../../../worksheet/focus-controls"; +import { FocusRoot, FocusStack } from "../../../../../worksheet/focus-stack"; +import { scenarioExpressions } from "../../../../shared/scenario-expressions"; import { ViewScenarioDrawer } from "../../SimulateView/scenarios/view-scenario-drawer"; import { scenarioRunParameterValues, @@ -43,6 +48,9 @@ import type { AdHocScenarioState } from "@hashintel/petrinaut-core"; // and owns its insets instead: a tighter top, no bottom padding at all so the // form's columns can scroll through the panel's full height. const rootStyle = css({ + "--form-heading-case": "uppercase", + "--form-heading-size": "10px", + "--form-heading-spacing": "0.5px", display: "flex", flexDirection: "column", height: "full", @@ -59,7 +67,7 @@ const scenarioRowStyle = css({ // Small right inset so the row's controls don't hug the panel edge, // matching the form's columns below. paddingRight: "2", - marginBottom: "3", + marginBottom: "1.5", }); // The picker reads as one control, not a full-panel bar; the Time Step @@ -109,8 +117,9 @@ const scenarioSelectWrapperStyle = css({ // align. const containerStyle = css({ display: "grid", - gridTemplateColumns: "[1fr 1.4fr]", - gap: "8", + gridTemplateColumns: "[minmax(0, 1fr) minmax(0, 1.4fr)]", + gap: "3", + minWidth: "[0]", flex: "[1]", minHeight: "[0]", }); @@ -118,9 +127,8 @@ const containerStyle = css({ // The left column stacks two titled blocks in one scroll area; the gap // separates them. const leftColumnSectionsStyle = css({ - display: "flex", - flexDirection: "column", - gap: "6", + display: "contents", + "& > div": { display: "contents" }, }); // The form wraps the whole grid, so its keyboard handling covers both @@ -132,43 +140,12 @@ const adHocFormRootStyle = css({ minHeight: "[0]", }); -const initialStateTitleRowStyle = css({ - display: "flex", - alignItems: "center", - gap: "1", -}); - -const initialStateSpacerStyle = css({ - flex: "[1]", -}); - // The inline form while a simulation is live: visible but inert and dimmed, // matching the panel's disabled inputs. const lockedFormStyle = css({ opacity: "[0.5]", }); -const sectionStyle = css({ - display: "flex", - flexDirection: "column", - gap: "1", - minHeight: "[0]", -}); - -// Lets the parameters section absorb the column's remaining height so its -// list scrolls to the panel bottom. -const fillSectionStyle = css({ - flex: "[1]", -}); - -const sectionTitleStyle = css({ - fontSize: "[10px]", - fontWeight: "semibold", - textTransform: "uppercase", - color: "neutral.a100", - letterSpacing: "[0.5px]", -}); - const labelStyle = css({ fontSize: "xs", fontWeight: "medium", @@ -180,63 +157,12 @@ const smallLabelStyle = css({ fontWeight: "normal", }); -const parametersScrollWrapperStyle = css({ - position: "relative", - flex: "[1]", - minHeight: "[0]", - display: "flex", - flexDirection: "column", -}); - -/** - * White fades over the edges the list can still be scrolled towards — at the - * top once scrolled, at the bottom while more content is below. Overflow state - * is tracked by the shared `useScrollOverflow` hook. - */ -const parametersFadeStyle = cva({ - base: { - position: "absolute", - left: "[0]", - right: "[0]", - height: "[16px]", - pointerEvents: "none", - zIndex: "[1]", - opacity: "[0]", - transition: "[opacity 150ms ease]", - }, - variants: { - position: { - top: { - top: "[0]", - background: - "[linear-gradient(to bottom, var(--colors-neutral-s00), transparent)]", - }, - bottom: { - bottom: "[0]", - background: - "[linear-gradient(to top, var(--colors-neutral-s00), transparent)]", - }, - }, - visible: { true: { opacity: "[1]" } }, - }, -}); - const parametersListStyle = css({ - display: "flex", - flexDirection: "column", overflowY: "auto", flex: "[1]", minHeight: "[0]", - // End padding: scrolls with the content, giving the last row breathing - // room without reserving fixed space below the list. - paddingBottom: "3", - // Small right inset so row values don't hug the scrollbar/column edge. - paddingRight: "2", - // Bleed: place headers pull their chevron 18px left of the tables; the - // padding/negative-margin pair keeps positions identical while extending - // the clip box so the chevron isn't cropped. - paddingLeft: "[18px]", - marginLeft: "[-18px]", + minWidth: "[0]", + paddingBottom: "4", }); // The Clear affordance stays quiet until pointed at: it wipes the whole @@ -301,36 +227,13 @@ const scenarioMessagesStyle = css({ // -- Component ---------------------------------------------------------------- -/** - * Wraps a form column in a container with white scroll fades: at the top - * once the column is scrolled, at the bottom while more content is below. - */ const ParametersScrollArea: React.FC<{ children: React.ReactNode }> = ({ children, -}) => { - const { scrollRef, canScrollUp, canScrollDown, onScroll } = - useScrollOverflow(); - - return ( -
-
-
- {children} -
-
-
- ); -}; +}) => ( + + {children} + +); const NO_SCENARIO = "__none__"; @@ -387,7 +290,13 @@ const SimulationSettingsContent: React.FC = () => { // materialized into literal read-only rows. const selectedClassicScenario = selectedScenario && !selectedAdHocScenario ? selectedScenario : undefined; - const classicHir = useScenarioHir(selectedClassicScenario); + const scenarioHir = useScenarioHir(selectedScenario, { + adHocContext: { + netParameters: globalParameters, + places, + types: extensions.colors ? types : [], + }, + }); // `seededFrom` is the persisted scenario definition the run state came // from, by content: saving an edit to the selected scenario changes it, so // the form reseeds to the new definition instead of showing the old one @@ -475,12 +384,13 @@ const SimulationSettingsContent: React.FC = () => { types: extensions.colors ? types : [], }; const onScenarioRunChange = (next: AdHocScenarioState) => { - if (!selectedAdHocScenario) { + if (!selectedAdHocScenario || !scenarioRun) { return; } - setScenarioRun((current) => current && { ...current, state: next }); + const updated = { ...scenarioRun.state, variables: next.variables }; + setScenarioRun((current) => current && { ...current, state: updated }); for (const { identifier, value } of scenarioRunParameterValues( - next, + updated, adHocFormContext, )) { setScenarioParameterValue(identifier, value); @@ -505,55 +415,39 @@ const SimulationSettingsContent: React.FC = () => { } }; - // What the run-mode branch renders, for either scenario kind. A classic - // scenario's places come from compiling its initial state with the - // current parameter values; until that preview is ready (or when it - // fails) the notice explains and the Initial state section stays empty. const scenarioRunView: { state: AdHocScenarioState; onChange: (next: AdHocScenarioState) => void; notice: string | null; previewReady: boolean; } | null = (() => { - if ( - selectedAdHocScenario && - scenarioRun?.scenarioId === selectedAdHocScenario.id - ) { - return { - state: scenarioRun.state, - onChange: onScenarioRunChange, - notice: null, - previewReady: true, - }; - } - if ( - !selectedClassicScenario || - scenarioRun?.scenarioId !== selectedClassicScenario.id - ) { + if (!selectedScenario || scenarioRun?.scenarioId !== selectedScenario.id) { return null; } const withoutPreview = (notice: string) => ({ state: { variables: scenarioRun.state.variables, - netParameters: Object.entries( - selectedClassicScenario.parameterOverrides, - ).map(([parameterId, expression]) => ({ - parameterId, - expression, - optimize: null, - })), + netParameters: Object.entries(selectedScenario.parameterOverrides).map( + ([parameterId, expression]) => ({ + parameterId, + expression, + optimize: null, + }), + ), places: {}, }, - onChange: onClassicRunChange, + onChange: selectedAdHocScenario + ? onScenarioRunChange + : onClassicRunChange, notice, previewReady: false, }); - if (classicHir.error !== null) { + if (scenarioHir.error !== null) { return withoutPreview( - `The initial state preview could not be compiled: ${classicHir.error}`, + `The initial state preview could not be compiled: ${scenarioHir.error}`, ); } - if (classicHir.hir === null) { + if (scenarioHir.hir === null) { return withoutPreview("Compiling the initial state preview…"); } const numericValues = createUserKeyedRecord(); @@ -564,8 +458,8 @@ const SimulationSettingsContent: React.FC = () => { } } const outcome = compileScenario( - selectedClassicScenario, - classicHir.hir, + selectedScenario, + scenarioHir.hir, globalParameters, places, adHocFormContext.types, @@ -579,7 +473,7 @@ const SimulationSettingsContent: React.FC = () => { ); } const materialized = classicScenarioRunState( - selectedClassicScenario, + selectedScenario, outcome.result.initialState, { places, types: adHocFormContext.types }, scenarioParameterValues, @@ -588,8 +482,23 @@ const SimulationSettingsContent: React.FC = () => { state: { ...materialized.state, variables: scenarioRun.state.variables, + netParameters: globalParameters.flatMap((parameter) => { + const resolved = + outcome.result.parameterValues[parameter.variableName]; + return resolved === undefined + ? [] + : [ + { + parameterId: parameter.id, + expression: resolved, + optimize: null, + }, + ]; + }), }, - onChange: onClassicRunChange, + onChange: selectedAdHocScenario + ? onScenarioRunChange + : onClassicRunChange, notice: materialized.truncated.length > 0 ? `Preview truncated: ${materialized.truncated @@ -612,7 +521,7 @@ const SimulationSettingsContent: React.FC = () => {
{ ); return ( -
- setIsViewScenarioOpen(false)} - scenario={selectedScenario} - /> - - {/* The scenario picker and Time Step share the top row, so the form's + + +
+ setIsViewScenarioOpen(false)} + scenario={selectedScenario} + /> + + {/* The scenario picker and Time Step share the top row, so the form's two columns below keep the full panel width and their headers start at the same height. */} -
-
- Scenario -
- + setContextScenarioId( + scenarioId === NO_SCENARIO ? null : scenarioId, + ) + } + items={scenarioOptions} + size="xs" + disabled={isSimulationActive} + renderItem={(value) => { + const option = scenarioOptions.find( + (opt) => opt.value === value, + ); + return ( + + {value === NO_SCENARIO && ( + + )} + + {option?.text} + + + ); }} - > - {value === NO_SCENARIO && ( - - )} - - {option?.text} - - - ); - }} - /> -
-
- {selectedScenario && ( -
-
- {timeStepControl} -
+ /> +
+
+ {selectedScenario && ( +
+
+ {timeStepControl} +
+ - {adHocActive ? ( - /* No scenario: Variables and Parameters share the left panel + {adHocActive ? ( + /* No scenario: Variables and Parameters share the left panel column, Initial state fills the right one, laid out through renderLayout — the form's keyboard handling wraps the whole grid, and each visual column is one FormLayoutColumn in the keyboard flow. This embedding offers no Optimize/expose toggles. */ - ( -
- -
- -
-
-
-
Variables
- + ( +
+ + +
+
+ + {variables}
- {variableRows} -
-
-
-
Parameters
- +
+ + {parameterRows ?? ( +
+ No parameters defined +
+ )}
- {parameterRows ?? ( -
- No parameters defined -
- )}
-
-
-
- - - -
-
-
Initial state
- - - -
- - {/* Like every input in this panel, the definition locks - while a simulation is live — an edit would dispose the - run. The scroll container stays interactive so the - content can still be reviewed mid-run. */} -
- {placesList} -
-
+ + + + + + {placesVisibilityControl} + + + + +
+ {placesList} +
+
+
-
-
- )} - /> - ) : scenarioRunView ? ( - /* A selected scenario, shown through the form in run mode: the + )} + /> + ) : scenarioRunView ? ( + /* A selected scenario, shown through the form in run mode: the scenario's parameters (its exposed Variables) take value edits in the left column; Parameters and Initial state sit read-only in the right one, still walkable and selectable. The run state reseeds in the same render pass as the selection, so this arm renders whenever a scenario is selected. */ - ( -
- -
- -
-
-
- Scenario parameters -
- -
- {scenarioParameterRows ?? ( -
- This scenario exposes no parameters -
- )} -
-
-
-
- -
- -
-
-
-
Parameters
- -
- {parameterRows ?? ( + ( +
+ + + +
+ {variables ?? (
- No parameters defined + This scenario exposes no parameters
)}
-
-
-
Initial state
- + + + + +
+
+ + {scenarioRunView.previewReady + ? (parameterRows ?? ( +
+ No parameters defined +
+ )) + : null} +
+
+ + {placesVisibilityControl} + + {scenarioRunView.notice === null ? null : ( +
+ {scenarioRunView.notice} +
+ )} + {scenarioRunView.previewReady ? placesList : null}
- {scenarioRunView.notice === null ? null : ( -
- {scenarioRunView.notice} -
- )} - {scenarioRunView.previewReady ? placesList : null}
-
- + +
-
-
+ )} + /> + ) : null} + + {scenarioCompilationErrors && ( + + + Scenario failed to compile — its parameter overrides and initial + state are not applied. + + + {scenarioCompilationErrors.map((compilationError) => ( + + {compilationError.message} + + ))} + + )} - /> - ) : null} - - {scenarioCompilationErrors && ( - - - Scenario failed to compile — its parameter overrides and initial - state are not applied. - - - {scenarioCompilationErrors.map((compilationError) => ( - - {compilationError.message} - - ))} - - - )} -
+
+ + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx index b5b5fec7fbf..05ca6b0e30d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.test.tsx @@ -414,6 +414,13 @@ const unparameterizedContextValue: SDCPNContextValue = { }; beforeEach(() => { + class ObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal("ResizeObserver", ObserverStub); + vi.stubGlobal("IntersectionObserver", ObserverStub); // `isWebGpuAvailable()` only reads `navigator.gpu`, so a bare object is enough — // and spreading the real Navigator would drop its prototype. vi.stubGlobal("navigator", { gpu: {} }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx index cea8cdad797..5dc7db94717 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx @@ -31,9 +31,16 @@ import { useOptimizationSource } from "../../../../../../react/optimizations/use import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { UserSettingsContext } from "../../../../../../react/state/user-settings-context"; import { AdHocScenarioForm } from "../../../../../components/ad-hoc-scenario-form/ad-hoc-scenario-form"; +import { + OverlayScrollArea, + overlayScrollDrawerBodyStyle, + overlayScrollDrawerViewportStyle, +} from "../../../../../components/overlay-scroll-area"; import { Section, SectionList } from "../../../../../components/section"; import { CodeEditor } from "../../../../../monaco/code-editor"; import { getMetricDocumentUri } from "../../../../../monaco/editor-paths"; +import { FocusControls } from "../../../../../worksheet/focus-controls"; +import { FocusRoot, FocusStack } from "../../../../../worksheet/focus-stack"; import { useMetricLspSession } from "../metrics/metric-form"; import { summarizeMetricLspErrors } from "../metrics/metric-lsp"; import { @@ -1173,261 +1180,313 @@ export const CreateExperimentDrawer = ({ } return ( - - - - -
-
- Name - -
-
-
- {/* A sweep refines each selection progressively (8, 25, 100, + + + + + + + + + +
+ +
+ + +
+
+ +
+
+ {/* A sweep refines each selection progressively (8, 25, 100, ... 1000, 5000, ...) up to this budget, so for sweeps this is a ceiling, not a batch size — 100,000 is a reasonable value on the GPU. */} - - {sweepSummary ? "Max runs per selection" : "Runs"} - - - setRunCount( - nextRunCount === null ? "" : String(nextRunCount), - ) - } - /> -
-
- Time step - - setDt(nextDt === null ? "" : String(nextDt)) - } - /> -
-
- Max time (s) - - setMaxTime(nextMaxTime === null ? "" : String(nextMaxTime)) - } - /> -
- {/* A labelled cell in the same grid as Runs / Time step / Max time: + + + setRunCount( + nextRunCount === null ? "" : String(nextRunCount), + ) + } + /> +
+
+ + + setDt(nextDt === null ? "" : String(nextDt)) + } + /> +
+
+ + + setMaxTime( + nextMaxTime === null ? "" : String(nextMaxTime), + ) + } + /> +
+ {/* A labelled cell in the same grid as Runs / Time step / Max time: the backend is a property of the experiment like the rest, and a bare control below the grid read as an orphan. */} - {webGpuEnabled && webGpuAvailable && ( -
- Backend - -
- )} -
- {/* Only shown once WebGPU is switched on in settings — otherwise the + {webGpuEnabled && webGpuAvailable && ( +
+ Backend + +
+ )} +
+ + {/* Only shown once WebGPU is switched on in settings — otherwise the choice does not exist and the row would be noise. */} -
- -
-
- { + const option = scenarioOptions.find( + (opt) => opt.value === value, + ); + return ( + + {value === NO_SCENARIO_VALUE && ( + + )} + {option?.text} + + ); + }} + /> +
+ + + {selectedScenario ? ( + // The selected scenario shows through the form in run mode: + // scenario parameters editable in worksheet style, and a + // collapsed "Computed state" preview of the exact values and + // tokens each run starts with. + <> + + setParamInputs((prev) => { + const next = { ...prev }; + for (const update of updates) { + next[update.identifier] = update.input; + } + return next; + }) + } + /> + {sweepSummaryLine} + + ) : ( + // With no scenario, the experiment's Initial State + Parameters + // are defined inline and compile through a scenario generated + // at experiment start, never persisted. Left untouched, the + // experiment runs from the model's own initial marking. + <> + + {sweepSummaryLine} + + )} +
+ + {objectiveEnabled ? ( + + + + ) : null} + + {constraintsEnabled ? ( + + + + ) : null} + +
+ +
+
+ + {metricDrafts.length === 0 + ? "No experiment metrics" + : `${metricDrafts.length} experiment metric${ + metricDrafts.length === 1 ? "" : "s" + }`} + + +
+ + {metricDrafts.map((metric) => ( + + handleMetricLspDiagnosticsChange( + metric.id, + diagnostics, + ) + } + onRemove={() => handleMetricRemove(metric.id)} /> - )} - {option?.text} - - ); - }} - /> -
- - {selectedScenario ? ( - // The selected scenario shows through the form in run mode: - // scenario parameters editable in worksheet style, and a - // collapsed "Computed state" preview of the exact values and - // tokens each run starts with. - <> - - setParamInputs((prev) => { - const next = { ...prev }; - for (const update of updates) { - next[update.identifier] = update.input; - } - return next; - }) - } - /> - {sweepSummaryLine} - - ) : ( - // With no scenario, the experiment's Initial State + Parameters - // are defined inline and compile through a scenario generated - // at experiment start, never persisted. Left untouched, the - // experiment runs from the model's own initial marking. - <> - - {sweepSummaryLine} - - )} -
- - {objectiveEnabled ? ( - - ) : null} - - {constraintsEnabled ? ( - - ) : null} - -
-
-
- - {metricDrafts.length === 0 - ? "No experiment metrics" - : `${metricDrafts.length} experiment metric${ - metricDrafts.length === 1 ? "" : "s" - }`} - + ))} +
+ +
+
+ +
+ {footerError} + ) : undefined + } + actions={ + -
- - {metricDrafts.map((metric) => ( - - handleMetricLspDiagnosticsChange(metric.id, diagnostics) +
- - - - {footerError} - ) : undefined - } - actions={ - <> - - - - } - /> - + prefix={ + isSubmitting ? ( + + ) : objectiveEnabled ? ( + + ) : sweepSummary ? undefined : ( + + ) + } + onClick={() => { + void handleSubmit(); + }} + > + {submitLabel} + + + } + /> + + + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.test.tsx index 2ad03958f03..cedb47dc012 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.test.tsx @@ -49,6 +49,7 @@ const place = (index: number) => ({ name: `Place ${index}`, colorId: null, dynamicsEnabled: false, + showAsInitialState: true, differentialEquationId: null, x: 0, y: 0, @@ -93,6 +94,40 @@ const languageClient = { }; describe("ExperimentScenarioRun", () => { + it("shows the computed value and reveals its source on keyboard focus", async () => { + render( + + {}} + /> + , + ); + fireEvent.click( + screen.getByRole("button", { name: "Toggle Computed state section" }), + ); + const value = await screen.findByRole("button", { + name: "Place 1 › count", + }); + expect(value.textContent).toBe("7"); + fireEvent.mouseEnter(value); + expect(screen.queryByText("scenario.rate")).toBeNull(); + fireEvent.keyDown(document.body, { key: "Tab" }); + value.focus(); + const expression = await screen.findByText("scenario.rate"); + expect(value.contains(expression)).toBe(false); + expect(expression.closest("[data-computed-expression]")).not.toBeNull(); + expect(value.getAttribute("aria-describedby")).toBe(expression.id); + expect(value.textContent).toBe("7"); + expect(screen.queryByRole("tooltip")).toBeNull(); + fireEvent.blur(value); + await waitFor(() => expect(screen.queryByText("scenario.rate")).toBeNull()); + expect(screen.queryByLabelText("Expression")).toBeNull(); + }); + it("renders the computed initial state in a bounded scroll region", async () => { render( @@ -117,7 +152,9 @@ describe("ExperimentScenarioRun", () => { // A net with many places — or one coloured place with many token rows — // would otherwise push Metrics and the drawer's footer out of view. expect(region).not.toBeNull(); - expect(region!.className).toContain("ov-y_auto"); + expect( + region!.querySelector('[data-part="viewport"]')?.getAttribute("style"), + ).toContain("overflow: auto"); expect(region!.className).toMatch(/max-h_\[\d+px\]/); // Parameters and initial state scroll together inside it, and it reads as // a panel rather than more form. @@ -125,7 +162,7 @@ describe("ExperimentScenarioRun", () => { expect(region!.querySelector("[aria-label='Place 1 › count']")).not.toBe( null, ); - expect(region!.className).toContain("bg-c_neutral.s20"); + expect(region!.className).toContain("bg-c_neutral.s00"); expect(region!.className).toContain("bd-c_neutral.bd.subtle"); }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.tsx index 4b9fef4b039..ae9bf373e1f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiment-scenario-run.tsx @@ -22,8 +22,12 @@ import { useScenarioHir } from "../../../../../../react/simulation/use-scenario- import { AdHocScenarioForm, FormLayoutColumn, + FormSectionHeader, } from "../../../../../components/ad-hoc-scenario-form/ad-hoc-scenario-form"; +import { OverlayScrollArea } from "../../../../../components/overlay-scroll-area"; import { Section } from "../../../../../components/section"; +import { StackedSections } from "../../../../../components/stacked-sections"; +import { scenarioExpressions } from "../../../../shared/scenario-expressions"; import { scenarioRunInputs } from "./experiment-scenario-inputs"; import type { ExperimentParameterInput } from "../../../../../../react/experiments/parameter-grid"; @@ -40,41 +44,17 @@ const emptyMessageStyle = css({ color: "neutral.s80", }); -const groupTitleStyle = css({ - fontSize: "xs", - fontWeight: "semibold", - textTransform: "uppercase", - letterSpacing: "wide", - color: "neutral.s80", -}); - -const groupStyle = css({ - display: "flex", - flexDirection: "column", - gap: "1.5", -}); +const groupStyle = css({ display: "contents" }); -// The computed state is a preview, not part of the form: a net with many -// places (or a coloured place with many token rows) would otherwise push -// Metrics and the drawer's own footer out of view. Parameters and initial -// state share one bounded region and scroll together, tinted so the preview -// reads as a panel the form writes into rather than more form. The left -// padding covers the 20px the place headers hang their chevron into, plus a -// gutter, so the hang is not clipped by the region's own overflow. const computedStatePreviewStyle = css({ display: "flex", flexDirection: "column", - gap: "3", maxHeight: "[320px]", - overflowY: "auto", - backgroundColor: "neutral.s20", + backgroundColor: "neutral.s00", borderWidth: "[1px]", borderStyle: "solid", borderColor: "neutral.bd.subtle", borderRadius: "md", - paddingY: "2", - paddingRight: "2", - paddingLeft: "[28px]", }); const noticeStyle = css({ @@ -286,7 +266,13 @@ export const ExperimentScenarioRun: React.FC = ({ context={context} selection={selection} mode="run" - renderLayout={({ variables, parameters, places }) => ( + expressionFor={scenarioExpressions(scenario, context)} + renderLayout={({ + variables, + parameters, + places, + placesVisibilityControl, + }) => ( {exposesParameters ? ( variables @@ -307,18 +293,25 @@ export const ExperimentScenarioRun: React.FC = ({
{computed.notice}
)} {computed?.ready ? ( -
-
-
Parameters
- {parameters ?? ( -
No parameters
- )} -
-
-
Initial state
- {places} -
-
+ + +
+ + {parameters ?? ( +
No parameters
+ )} +
+
+ + {placesVisibilityControl} + + {places} +
+
+
) : null}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx index 293b17574d6..a13c76280c7 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx @@ -29,6 +29,11 @@ import { AdHocScenarioForm, FormLayoutColumn, } from "../../../../../components/ad-hoc-scenario-form/ad-hoc-scenario-form"; +import { + OverlayScrollArea, + overlayScrollDrawerBodyStyle, + overlayScrollDrawerViewportStyle, +} from "../../../../../components/overlay-scroll-area"; import { Section, SectionList } from "../../../../../components/section"; import { CodeEditor } from "../../../../../monaco/code-editor"; import { SimulationPanel } from "../shared/simulation-panel"; @@ -224,64 +229,72 @@ export const AdHocScenarioAuthoringBody: React.FC<{ }> = ({ authoring, children }) => { const { code } = authoring; return ( - -
- - + +
+ - - -