diff --git a/.changeset/ad-hoc-value-errors.md b/.changeset/ad-hoc-value-errors.md new file mode 100644 index 00000000000..f348b145382 --- /dev/null +++ b/.changeset/ad-hoc-value-errors.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +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/.changeset/experiment-importance-order.md b/.changeset/experiment-importance-order.md new file mode 100644 index 00000000000..b17805bbce1 --- /dev/null +++ b/.changeset/experiment-importance-order.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Simplify experiment result headers and chart summaries, move configuration and compute information into Details, and show the most influential parameters first in Sensitivity analysis. diff --git a/.changeset/experiment-interval-feedback.md b/.changeset/experiment-interval-feedback.md new file mode 100644 index 00000000000..b8967da11ef --- /dev/null +++ b/.changeset/experiment-interval-feedback.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Simplify experiment creation with automatic metric names, objectives selected on metric rows, compact constraint editors, and keyboard navigation throughout the form. Explain invalid sweep intervals beside the creation button. diff --git a/.changeset/routed-simulation-panels.md b/.changeset/routed-simulation-panels.md new file mode 100644 index 00000000000..d2b1f1b2488 --- /dev/null +++ b/.changeset/routed-simulation-panels.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add routed panels with fullscreen controls for experiments and scenarios, keeping edits and chart choices while resizing beside the AI assistant. diff --git a/.changeset/simulation-panel-guidance.md b/.changeset/simulation-panel-guidance.md new file mode 100644 index 00000000000..a98c8f4cb9b --- /dev/null +++ b/.changeset/simulation-panel-guidance.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +Add simulation panel guidance to the AI assistant's documentation catalog. diff --git a/apps/petrinaut-website/src/examples/example-search.test.ts b/apps/petrinaut-website/src/examples/example-search.test.ts index 6d53a5045ca..90c4630afed 100644 --- a/apps/petrinaut-website/src/examples/example-search.test.ts +++ b/apps/petrinaut-website/src/examples/example-search.test.ts @@ -8,6 +8,46 @@ import { } from "./example-search"; describe("example search contract", () => { + it("validates complete resource links and fullscreen creation links", () => { + const resource = { + resourceType: "scenario", + resourceId: "scenario / one", + presentation: "fullscreen", + }; + const validated = validateSharedExampleSearch(resource); + expect(validated).toMatchObject(resource); + expect(canonicalSearchString(validated)).toBe( + "presentation=fullscreen&resourceId=scenario+%2F+one&resourceType=scenario", + ); + expect( + validateSharedExampleSearch({ + overlay: "create-experiment", + presentation: "fullscreen", + }).presentation, + ).toBe("fullscreen"); + for (const invalid of [ + { resourceType: "unknown", resourceId: "one" }, + { resourceType: "scenario" }, + { resourceType: "experiment", resourceId: "" }, + { resourceId: "one" }, + ]) { + const search = validateSharedExampleSearch({ + ...invalid, + presentation: "fullscreen", + }); + expect(search.resourceType).toBeUndefined(); + expect(search.resourceId).toBeUndefined(); + expect(search.presentation).toBeUndefined(); + } + expect( + validateSharedExampleSearch({ + resourceType: "metric", + resourceId: "one", + presentation: "fullscreen", + }).presentation, + ).toBeUndefined(); + }); + it("validates settings sections only for the user settings dialog", () => { expect( validateSharedExampleSearch({ diff --git a/apps/petrinaut-website/src/examples/example-search.ts b/apps/petrinaut-website/src/examples/example-search.ts index b245df20796..1ab052e5b79 100644 --- a/apps/petrinaut-website/src/examples/example-search.ts +++ b/apps/petrinaut-website/src/examples/example-search.ts @@ -45,6 +45,12 @@ export const sharedSettingsSections = [ "labs", ] as const; +export const sharedResourceTypes = [ + "scenario", + "metric", + "experiment", +] as const; + export type SharedMode = (typeof sharedModes)[number]; export type SharedSimulateView = (typeof sharedSimulateViews)[number]; export type SharedOverlay = (typeof sharedOverlays)[number]; @@ -67,6 +73,9 @@ export type SharedExampleSearch = { view?: SharedSimulateView; overlay?: SharedOverlay; settings?: (typeof sharedSettingsSections)[number]; + resourceType?: (typeof sharedResourceTypes)[number]; + resourceId?: string; + presentation?: "fullscreen"; }; /** The keys this contract owns. Anything else in a URL is foreign. */ @@ -79,6 +88,9 @@ const sharedSearchKeys = [ "view", "overlay", "settings", + "resourceType", + "resourceId", + "presentation", ] as const satisfies readonly (keyof SharedExampleSearch)[]; // `.catch(undefined)` is the contract's whole validation story: anything a URL @@ -121,22 +133,42 @@ export const selectionToSearch = ( */ export const validateSharedExampleSearch = ( input: Record, -): SharedExampleSearch => ({ - scenario: optionalNonEmptyString.parse(input.scenario), - subnet: optionalNonEmptyString.parse(input.subnet), - mode: optionalMode.parse(input.mode), - view: optionalSimulateView.parse(input.view), - overlay: optionalOverlay.parse(input.overlay), - settings: - input.overlay === "user-settings" - ? z - .enum(sharedSettingsSections) - .optional() - .catch(undefined) - .parse(input.settings) - : undefined, - ...selectionToSearch(selectionFromInput(input)), -}); +): SharedExampleSearch => { + const resourceType = z + .enum(sharedResourceTypes) + .optional() + .catch(undefined) + .parse(input.resourceType); + const resourceId = optionalNonEmptyString.parse(input.resourceId); + const hasResource = resourceType !== undefined && resourceId !== undefined; + const canExpand = + (hasResource && resourceType !== "metric") || + input.overlay === "create-scenario" || + input.overlay === "create-experiment"; + + return { + scenario: optionalNonEmptyString.parse(input.scenario), + subnet: optionalNonEmptyString.parse(input.subnet), + mode: optionalMode.parse(input.mode), + view: optionalSimulateView.parse(input.view), + overlay: optionalOverlay.parse(input.overlay), + settings: + input.overlay === "user-settings" + ? z + .enum(sharedSettingsSections) + .optional() + .catch(undefined) + .parse(input.settings) + : undefined, + ...selectionToSearch(selectionFromInput(input)), + resourceType: hasResource ? resourceType : undefined, + resourceId: hasResource ? resourceId : undefined, + presentation: + canExpand && input.presentation === "fullscreen" + ? "fullscreen" + : undefined, + }; +}; /** Canonical query string for a validated search: sorted, contract keys only. */ export const canonicalSearchString = (search: SharedExampleSearch): string => { diff --git a/apps/petrinaut-website/src/examples/navigation-search.test.ts b/apps/petrinaut-website/src/examples/navigation-search.test.ts index b691d6e1b5b..215f080a7b2 100644 --- a/apps/petrinaut-website/src/examples/navigation-search.test.ts +++ b/apps/petrinaut-website/src/examples/navigation-search.test.ts @@ -8,6 +8,34 @@ import { } from "./navigation-search"; describe("navigation state projection", () => { + it.each(["scenario", "experiment"] as const)( + "opens a direct %s link and preserves its presentation through Preview", + (resourceType) => { + const search = { + resourceType, + resourceId: "record / one", + presentation: "fullscreen" as const, + }; + const state = sharedSearchToNavigationState(search); + expect(state.mode).toBe("simulate"); + expect(state.simulateView).toBe( + resourceType === "scenario" ? "scenarios" : "experiments", + ); + expect(state.simulateResource).toEqual({ + type: resourceType, + id: "record / one", + }); + expect(state.simulatePresentation).toBe("fullscreen"); + expect(navigationStateToSharedSearch(state)).toMatchObject(search); + expect( + applyPreviewNavigationUpdate(search, (current) => ({ + ...current, + subnetId: "subnet", + })), + ).toMatchObject(search); + }, + ); + it.each(["general", "viewport", "simulation", "labs"] as const)( "round-trips the %s settings section in Simulate", (settings) => { diff --git a/apps/petrinaut-website/src/examples/navigation-search.ts b/apps/petrinaut-website/src/examples/navigation-search.ts index 7a009ee4148..200987277d9 100644 --- a/apps/petrinaut-website/src/examples/navigation-search.ts +++ b/apps/petrinaut-website/src/examples/navigation-search.ts @@ -1,10 +1,8 @@ /** * Projects the example URL contract onto Petrinaut's navigation state. * - * The URL carries the location a reader can act on: the scenario, the subnet, - * the focused item, the editor's mode, its Simulate section and the overlay it - * has open. It deliberately leaves out `simulateResource`, which names a run - * or a record inside the open document rather than a place in the app. + * The URL carries the selected scenario, subnet, focused item, editor mode, + * Simulate section, open record, overlay and panel presentation. * * Every field is decoded against a BASELINE — the location its page starts * from. A URL that does not name a field means "the baseline's value", which is @@ -83,8 +81,22 @@ export const sharedSearchToNavigationState = ( scenarioId: scenarioFromSearch(search), subnetId: search.subnet ?? null, selection: selectionFromInput(search as Record), - mode: search.mode ?? baseline.mode, - simulateView: search.view ?? baseline.simulateView, + mode: + search.mode ?? + (search.resourceType && search.resourceId ? "simulate" : baseline.mode), + simulateView: + search.resourceType && search.resourceId + ? search.resourceType === "scenario" + ? "scenarios" + : search.resourceType === "experiment" + ? "experiments" + : "metrics" + : (search.view ?? baseline.simulateView), + simulateResource: + search.resourceType && search.resourceId + ? { type: search.resourceType, id: search.resourceId } + : baseline.simulateResource, + simulatePresentation: search.presentation ?? baseline.simulatePresentation, overlay: search.overlay === undefined ? baseline.overlay @@ -98,7 +110,18 @@ export const navigationStateToSharedSearch = ( const mode = modeToSearch(state.mode); const view = simulateViewToSearch(state.simulateView); const overlay = overlayToSearch(state.overlay); + const canExpand = + state.simulateResource?.type === "scenario" || + state.simulateResource?.type === "experiment" || + overlay === "create-scenario" || + overlay === "create-experiment"; return { + resourceType: state.simulateResource?.type, + resourceId: state.simulateResource?.id, + presentation: + canExpand && state.simulatePresentation === "fullscreen" + ? "fullscreen" + : undefined, scenario: scenarioToSearch(state.scenarioId), subnet: state.subnetId ?? undefined, // Omitted at the baseline, so an untouched page keeps a clean URL and the @@ -146,6 +169,9 @@ export const applyPreviewNavigationUpdate = ( view: search.view, overlay: search.overlay, settings: search.settings, + resourceType: search.resourceType, + resourceId: search.resourceId, + presentation: search.presentation, ...navigationStateToPreviewSearch( update(previewSearchToNavigationState(search)), ), diff --git a/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx b/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx index 36812ea7ae7..afe57fbc409 100644 --- a/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.test.tsx @@ -34,101 +34,84 @@ const Probe = ({ }; describe("useSharedSearchNavigation", () => { - it("keeps URL-unrepresentable state in memory and mirrors the shared subset", () => { - let controller!: PetrinautNavigationController; - const onSearchChange = vi.fn(); - render( - { - controller = value; - }} - onSearchChange={onSearchChange} - search={{ scenario: "scenario-1" }} - />, - ); - - // The resource open inside Simulate is the one location field the URL does - // not carry: it applies in memory and produces no URL write. - act(() => { - controller.onNavigate( - (current) => ({ - ...current, - simulateResource: { type: "experiment", id: "experiment-1" }, - }), - { - history: "push", - intent: { cause: "user", action: "simulation-resource" }, - }, + it.each(["experiment", "scenario"] as const)( + "records the open %s and restores drawer/fullscreen with Back and Forward", + (resourceType) => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + const probe = (search: SharedExampleSearch) => ( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={search} + /> ); - }); - expect(controller.state.simulateResource).toEqual({ - type: "experiment", - id: "experiment-1", - }); - expect(onSearchChange).not.toHaveBeenCalled(); - - // A subnet change is shared: it applies in memory AND writes the URL. - act(() => { - controller.onNavigate( - (current) => ({ ...current, subnetId: "subnet-1" }), - { history: "push", intent: { cause: "user", action: "subnet" } }, + const view = render(probe({})); + act(() => + controller.onNavigate( + (current) => ({ + ...current, + mode: "simulate", + simulateView: + resourceType === "scenario" ? "scenarios" : "experiments", + simulateResource: { type: resourceType, id: "record-1" }, + }), + { + history: "push", + intent: { cause: "user", action: "simulation-resource" }, + }, + ), ); - }); - expect(controller.state.subnetId).toBe("subnet-1"); - expect(controller.state.simulateResource).toEqual({ - type: "experiment", - id: "experiment-1", - }); - expect(onSearchChange).toHaveBeenCalledOnce(); - expect(onSearchChange).toHaveBeenCalledWith( - { scenario: "scenario-1", subnet: "subnet-1" }, - "push", - ); - }); - - it("merges an external URL change without resetting in-memory fields", () => { - let controller!: PetrinautNavigationController; - const onSearchChange = vi.fn(); - const view = render( - { - controller = value; - }} - onSearchChange={onSearchChange} - search={{ scenario: "scenario-1" }} - />, - ); - - act(() => { - controller.onNavigate( - (current) => ({ - ...current, - simulateResource: { type: "experiment", id: "experiment-1" }, - }), - { - history: "push", - intent: { cause: "user", action: "simulation-resource" }, - }, + const drawerSearch: SharedExampleSearch = { + mode: "simulate", + view: resourceType === "scenario" ? "scenarios" : undefined, + resourceType, + resourceId: "record-1", + }; + expect(onSearchChange).toHaveBeenLastCalledWith( + expect.objectContaining(drawerSearch), + "push", ); - }); + view.rerender(probe(drawerSearch)); - // Back/Forward delivers a different shared search: URL-owned fields - // update, and the one field the URL cannot carry survives. - view.rerender( - { - controller = value; - }} - onSearchChange={onSearchChange} - search={{ scenario: "scenario-2" }} - />, - ); - expect(controller.state.scenarioId).toBe("scenario-2"); - expect(controller.state.simulateResource).toEqual({ - type: "experiment", - id: "experiment-1", - }); - }); + act(() => + controller.onNavigate( + (current) => ({ ...current, simulatePresentation: "fullscreen" }), + { + history: "push", + intent: { cause: "user", action: "simulation-presentation" }, + }, + ), + ); + const fullscreenSearch = { + ...drawerSearch, + presentation: "fullscreen" as const, + }; + expect(onSearchChange).toHaveBeenLastCalledWith( + expect.objectContaining(fullscreenSearch), + "push", + ); + view.rerender(probe(fullscreenSearch)); + view.rerender(probe(drawerSearch)); + expect(controller.state.simulatePresentation ?? "panel").toBe("panel"); + expect(controller.state.simulateResource).toEqual({ + type: resourceType, + id: "record-1", + }); + view.rerender(probe({})); + expect(controller.state.simulateResource).toBeNull(); + view.rerender(probe(drawerSearch)); + view.rerender(probe(fullscreenSearch)); + expect(controller.state.simulatePresentation).toBe("fullscreen"); + expect(controller.state.simulateResource).toEqual({ + type: resourceType, + id: "record-1", + }); + expect(onSearchChange).toHaveBeenCalledTimes(2); + }, + ); it("returns a URL-owned field to the baseline when Back drops it", () => { let controller!: PetrinautNavigationController; diff --git a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts index f3e0b7de177..db3c6282488 100644 --- a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts @@ -37,6 +37,8 @@ const mergeSharedSearch = ( selection: shared.selection, mode: shared.mode, simulateView: shared.simulateView, + simulateResource: shared.simulateResource, + simulatePresentation: shared.simulatePresentation, overlay: shared.overlay, }; }; @@ -58,14 +60,14 @@ export const withClearedSharedLocation = ( scenarioId: undefined, subnetId: null, selection: [], + simulateResource: null, + simulatePresentation: undefined, }); /** * Navigation controller for pages whose URL carries the shared location: the - * scenario, the subnet, the focused item, the mode, the Simulate section and - * the open overlay. The editor navigates one field more than that — the - * resource open inside Simulate — so the full location still lives in page - * state and only its shared projection reaches the URL. + * selected scenario, subnet, focused item, mode, Simulate section, open record, + * overlay and panel presentation. Page state also retains multi-selection. * * `initialState` is the location this page starts from, for every field the URL * does not name; the URL overrides whatever it does name. A controlled host diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 0f18105957d..cedc8c0cd15 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -104,6 +104,7 @@ export const petrinautDocNames = [ "scenarios", "ad-hoc-scenarios", "experiments", + "simulation-panels", "actual-mode", "preview", "ai-assistant", @@ -129,7 +130,9 @@ export const petrinautDocSummaries: Record = { "ad-hoc-scenarios": "Inline initial state + parameters without saving a scenario: the shared form (scenario. variables, fixed/dynamic/swept-count rows chosen from the row gutter's menu, shared columns, phantom row, place totals, live type checking), its three surfaces (quick simulation, experiments, scenario creation and editing with Scenario Parameter toggles), interval selections — Sweep or Optimize by setting — with generated adhoc_* parameter names, saved scenarios shown in run mode.", experiments: - "Monte Carlo batches: configuration (runs, seed, dt, max time, scenario), parameter sweeps, constraints (parameter and state, pass threshold), Optimize toggles and an Objective section (metric, direction, steps) at creation, the drawer opening already optimizing, Stop on the Parameters card, one study per experiment, lifecycle/statuses, cancel/remove, header columns (Steps, Steps clear), metric charts, the Constraints and Sensitivity analysis cards, the steps table, Objective by step, compute backend, active-experiments popover.", + "Monte Carlo batches: configuration (runs, seed, dt, max time, scenario), parameter sweeps, constraints (parameter and state, pass threshold), Optimize toggles and an Objective section (metric, direction, steps) at creation, the panel opening already optimizing, Stop on the Parameters card, one study per experiment, lifecycle/statuses, cancel/remove, header columns (Steps, Steps clear), metric charts, the Constraints and Sensitivity analysis cards, the steps table, Objective by step, compute backend, active-experiments popover.", + "simulation-panels": + "Experiment and scenario panels, fullscreen controls, state preservation, docked and floating AI layout, links and browser history, session limits for experiments.", "actual-mode": "Actual mode: host-provided live execution view, Brunch stream URL route, read-only extension-free net, current limits.", preview: diff --git a/libs/@hashintel/petrinaut/docs/README.md b/libs/@hashintel/petrinaut/docs/README.md index 62f80cdcf23..05dcd5e8f30 100644 --- a/libs/@hashintel/petrinaut/docs/README.md +++ b/libs/@hashintel/petrinaut/docs/README.md @@ -36,6 +36,7 @@ Petrinaut has three global modes in the top bar, though **Actual** is only enabl - [Scenarios](scenarios.md) -- Save and switch between named simulation configurations. - [Ad-hoc Scenarios](ad-hoc-scenarios.md) -- The scenario form: define initial state and parameters inline for one run, or save them as a scenario. - [Experiments](experiments.md) -- Run Monte Carlo batches and inspect token-count distributions over time. +- [Simulation Panels](simulation-panels.md) -- Open scenarios and experiments beside the main view, expand to fullscreen, and use links and browser history. - [Actual Mode](actual-mode.md) -- View a host-provided live Petri net execution, currently via Brunch. - [Embedded Preview](preview.md) -- Explore a compact, read-only Petri net embedded in a host application. - [AI Assistant](ai-assistant.md) -- Build, review, and revise nets with text or inline Voice mode. diff --git a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md index 6b0367e2b4e..a90a5562027 100644 --- a/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md +++ b/libs/@hashintel/petrinaut/docs/ad-hoc-scenarios.md @@ -9,7 +9,7 @@ Use an ad-hoc scenario for one-off runs and quick exploration. When you want to The same form appears in three places: 1. **Quick simulation** -- in the [Simulation Settings](simulation.md#simulation-settings) tab, with "No scenario" selected, the panel's two columns are the form's own tables: **Variables** above **Parameters** on the left, **Initial state** -- token counts and values -- on the right, no separate dialog. A quiet **Clear** button next to the Initial state title resets your entries. The next simulation run uses what you defined. Any [compile error](#errors) appears in the settings panel's error banner. -2. **Experiments** -- in the [create-experiment drawer](experiments.md#creating-an-experiment), choosing "No scenario" shows the form inside the Scenario section. The experiment's runs start from the state you defined, and the experiments table shows "Ad-hoc scenario" in its Scenario column. With [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value carries an interval toggle (see below). +2. **Experiments** -- in the [create-experiment panel](experiments.md#creating-an-experiment), choosing "No scenario" shows the form inside the Scenario section. The experiment's runs start from the state you defined, and the experiments table shows "Ad-hoc scenario" in its Scenario column. With [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value carries an interval toggle (see below). 3. **Scenario creation** -- [creating or editing a scenario](scenarios.md#creating-a-scenario) uses the same form with a **Scenario Parameter** toggle on each top-level Variable; see [Saving a scenario from the form](#saving-a-scenario-from-the-form). ## The form @@ -18,16 +18,18 @@ 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 drawer 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 drawer 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 drawer 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. +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. Opening a value with Enter or a second click selects its whole content, so typing replaces it. Opening by typing keeps the caret right after what you typed. ### 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. @@ -70,7 +72,7 @@ Every expression is type-checked as you work. The open editor marks problems inl ## Interval selections (experiments) -In the create-experiment drawer, with [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value slot -- cells, counts, variables, shared columns, and net parameters -- carries a labeled interval toggle, purple while on: under the open cell editor, and on the row for Variables and Parameters. It reads **Sweep**, or **Optimize** when the [in-browser optimizer](experiments.md#optimizing-a-sweep) is on; the word is the same on every toggle of the form, and both mean the same thing. Turning it on replaces the expression with **Min** and **Max** cells; an interval declares nothing else, so there is no Scale or Step. Each bound is an expression cell with the same selection model as the rest of the form -- select it, press Enter (or click again) to edit, Enter or Escape to leave; Escape from a selected cell closes the editor. Turning the toggle off restores the expression you had, and the bounds are remembered too. A selected value shows its bounds (`0 … 12`) on a purple slot. Boolean and text values offer no toggle, and changing a selected Variable to boolean turns its toggle off; a cell muted by a shared column does not count. A row's gutter menu offers **Swept count** or **Optimized count** for a dynamic row's count, to match. +In the create-experiment panel, with [Parameter sweeps](experiments.md#parameter-sweeps) enabled, every numeric value slot -- cells, counts, variables, shared columns, and net parameters -- carries a labeled interval toggle, purple while on: under the open cell editor, and on the row for Variables and Parameters. It reads **Sweep**, or **Optimize** when the [in-browser optimizer](experiments.md#optimizing-a-sweep) is on; the word is the same on every toggle of the form, and both mean the same thing. Turning it on replaces the expression with **Min** and **Max** cells; an interval declares nothing else, so there is no Scale or Step. Each bound is an expression cell with the same selection model as the rest of the form -- select it, press Enter (or click again) to edit, Enter or Escape to leave; Escape from a selected cell closes the editor. Turning the toggle off restores the expression you had, and the bounds are remembered too. A selected value shows its bounds (`0 … 12`) on a purple slot. Boolean and text values offer no toggle, and changing a selected Variable to boolean turns its toggle off; a cell muted by a shared column does not count. A row's gutter menu offers **Swept count** or **Optimized count** for a dynamic row's count, to match. Each selection becomes a swept parameter of the experiment with a deterministic name, shown in the sweep navigator under the value's path (`Space › item 0 › x`): @@ -80,9 +82,9 @@ Each selection becomes a swept parameter of the experiment with a deterministic - `adhoc_var_net_` -- a top-level Variable; place-scoped variables use the place's name as the scope. - `adhoc_param_` -- a net parameter override. -Bounds must resolve to constants, integer values need integer bounds, and the maximum must exceed the minimum; a value that does not run shows its problem on the bound, and the drawer's footer names it. The experiment then behaves like any [parameter sweep](experiments.md#parameter-sweeps): the initial state compiles at the navigator's selection, parameter overrides follow each run's draw. Under **Optimize**, the study searches the generated parameters like any others; only [Constraints](experiments.md#constraints) need a saved scenario. +Bounds must resolve to constants, integer values need integer bounds, and the maximum must exceed the minimum; a value that does not run shows its problem on the bound, and the panel's footer names it. The experiment then behaves like any [parameter sweep](experiments.md#parameter-sweeps): the initial state compiles at the navigator's selection, parameter overrides follow each run's draw. Under **Optimize**, the study searches the generated parameters like any others; only [Constraints](experiments.md#constraints) need a saved scenario. -A saved scenario shown through the form in the experiment drawer offers the same toggle on each numeric scenario parameter row. +A saved scenario shown through the form in the experiment panel offers the same toggle on each numeric scenario parameter row. ## Saving a scenario from the form @@ -90,8 +92,8 @@ A saved scenario shown through the form in the experiment drawer 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 -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 drawer, 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/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index 40e6d4684d9..87b6505dd42 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -17,7 +17,9 @@ The assistant stays available across **Edit**, **Simulate**, **Actual**, and **N The assistant opens in a sidebar at the far right of the editor. It sits flush against the viewport, beside the canvas and its properties panel. Its left divider and resize highlight span the full panel height. The sidebar slides in at its full width while the canvas makes room. Closing it returns that space to the canvas. -The bottom toolbar stays centered on the editor when the docked assistant opens, moving only as far as needed to avoid overlapping the panels. +Experiment and Scenario panels make room for the docked assistant too. Their fullscreen presentation fills the main view beside it. Resizing or closing the assistant adjusts the available space; floating the assistant lets it sit above the open panel. + +The bottom toolbar stays centered on the remaining main view, moving when needed to avoid overlapping its panels. Choose **Float AI assistant** in the header to detach it into a rounded panel over the canvas. The canvas expands smoothly to reclaim the sidebar's space, and the floating panel reserves no space at the right edge. Drag anywhere in the header outside the tabs and action buttons to move it, or focus **Move AI assistant** and use the arrow keys. Hold **Shift** with an arrow key to move farther. The floating panel stays within the editor when the window changes size. @@ -234,7 +236,7 @@ ranges to minimize or maximize a metric. The experiment appears in a compact card with its status, run count, and results. Simulation cards use blue; optimization cards use purple and glow while running. Select **View -experiment** to inspect metric distributions in the Experiments drawer. The +experiment** to inspect metric distributions in the Experiments panel. The heatmap shows how values spread across runs; click a time step to see its histogram. Select **Cancel** to stop its work. The assistant receives the diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index d0aac65fa4f..abbbb4de249 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -7,7 +7,7 @@ Experiments live under the **Simulate** [global mode](drawing-a-net.md#global-mo ## Creating an experiment 1. Switch to **Simulate** mode and open the **Experiments** tab. -2. Click **Create**. The Create Experiment drawer opens. +2. Click **Create**. The Create Experiment panel opens. 3. Fill in the configuration (see below). 4. Click **Run** -- **Create sweep** when a value is swept, **Optimize** when the in-browser optimizer will search it. The button reads **Starting** (or **Creating**) while the experiment starts. @@ -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 drawer 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. | @@ -49,14 +49,20 @@ The model used is a snapshot of the current net at the time you press **Run**. E > Currently, an experiment can only run against one scenario at a time. To compare scenarios, create one experiment per scenario. +### Metrics + +Click **Add metric** and choose **Place tokens**, **Transition firing**, or a custom metric. Built-in metric names follow the selected place or transition automatically, such as **Queue tokens** or **Dispatch firing (cumulative)**. Transition names include the **Per frame** or **Cumulative** count mode. Only custom metrics have an editable name. + ### Constraints -With [Parameter sweeps](#parameter-sweeps) and [In-browser optimization](visual-settings.md#in-browser-optimization-experimental) both on, flipping the first **Optimize** toggle on a saved scenario's parameter adds a **Constraints** section to the drawer, between [Objective](#optimizing-a-sweep) and Metrics. Its rows record boolean conditions the optimizer must respect when it [drives the sweep](#optimizing-a-sweep). The sweep itself ignores them: no run is excluded from the charts and the objective is never changed by them. An experiment created with **No scenario** has no Constraints section: the names of its generated parameters are not yours to write. Two kinds, added from the **Parameter constraint** and **State constraint** buttons under the list and mixed in one list, each row marked with a **Parameters** or **State** chip: +With [Parameter sweeps](#parameter-sweeps) and [In-browser optimization](visual-settings.md#in-browser-optimization-experimental) both on, flipping the first **Optimize** toggle on a saved scenario's parameter adds a **Constraints** section to the panel, after [Metrics & objective](#optimizing-a-sweep). Its rows record boolean conditions the optimizer must respect when it [drives the sweep](#optimizing-a-sweep). The sweep itself ignores them: no run is excluded from the charts and the objective is never changed by them. An experiment created with **No scenario** has no Constraints section: the names of its generated parameters are not yours to write. Two kinds, added from the **Parameter constraint** and **State constraint** buttons under the list and mixed in one list, each row labelled **Parameters** or **State**: - **Parameter constraints** -- one-line expressions over the sweep's parameters (`scenario.*` for scenario parameters, `parameters.*` for net parameters) that must produce a boolean, for example `scenario.min_load < scenario.max_load`. Before a step runs, the optimizer checks them at the step's values, snapped to the sweep's grid. A step whose values break one is **infeasible**: it costs one step and no simulation, the sliders do not move to it, it is reported as pruned with the constraint named, and its row is greyed in the steps table. - **State constraints** -- small code bodies that read the simulation `state` exactly like a metric and `return` a boolean, for example `return state.places.Queue.count <= 10;`. Every run of a step reports whether the condition held on every sampled frame: a run **passed** when it did and **failed** otherwise, and a run that errors reports neither, so its step's fraction is over the runs that reported. A state constraint runs beside the sweep's metrics on every batch, from the sweep's creation on, and it runs on the CPU: the WebGPU switch greys out while a state row is drafted, and a sweep with state constraints computes on the CPU whether or not a study drives it. -A step's verdict comes from its runs. The **Pass threshold**, one setting for the whole sweep shown in the section's header once a State row exists, is the share of a step's runs that must pass (95 percent by default, an alpha of 0.05). A step is **clear** when every state constraint held on at least that share of its runs and **limited** when one fell short. Every rate in the results is printed as its raw fraction beside the percentage, `52 / 60 · 87%`, so the run count behind a percentage is always in view. +A step's verdict comes from its runs. The **Pass threshold**, one setting below the constraint rows once a State row exists, is the share of a step's runs that must pass (95 percent by default, an alpha of 0.05). A step is **clear** when every state constraint held on at least that share of its runs and **limited** when one fell short. Every rate in the results is printed as its raw fraction beside the percentage, `52 / 60 · 87%`, so the run count behind a percentage is always in view. + +State editors start on one line and grow with the code up to eight lines, then scroll. Use **Enter** for a new line when a condition needs intermediate calculations before its `return`. Empty fields show examples using the current scenario and model. Each row checks as you type: type errors, unknown names and a result that is not a boolean are underlined, and the message reads in the line under the row. Typing `scenario.`, `parameters.` or `state.places.` offers completions, and hovering a name shows its type. **Optimize** stays disabled, with the first failing row named in the footer, until every row compiles; the rows are compiled once more when you press it. Empty rows are ignored, and removing a row is its trash button. Changing the scenario clears the rows. The constraints are recorded with the experiment: once created, the sweep's **Parameters** card lists them behind **Show N constraints** in its footer, one line per constraint with its kind, its label (**Parameter constraint 1**, **State constraint 1**, in the order you added them) and its code, and the pass threshold under them when a state constraint exists. @@ -64,15 +70,15 @@ Each row checks as you type: type errors, unknown names and a result that is not Experiments progress through these status labels: -| Status | Meaning | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Initializing** | The experiment has been created and its workers are starting up. | -| **Running** | Runs are in progress. | -| **Idle** | A sweep computing nothing: fresh, or its selected region fully sampled. Moving a parameter control resumes running. Grey in the list. | -| **Optimizing** | A sweep whose sliders a study drives (see [Optimizing a sweep](#optimizing-a-sweep)). An experiment created with **Optimize** opens in this state. The drawer's header reads it; the list keeps the sweep's own status, Running or Idle. | -| **Complete** | All runs finished without error. | -| **Error** | The experiment failed to start or hit an unrecoverable error. The drawer shows the error message. For a sweep the error belongs to the selection that failed: move a control and the next selection computes normally. | -| **Cancelled** | You clicked **Cancel**, or the experiment was cancelled. | +| Status | Meaning | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Initializing** | The experiment has been created and its workers are starting up. | +| **Running** | Runs are in progress. | +| **Idle** | A sweep computing nothing: fresh, or its selected region fully sampled. Moving a parameter control resumes running. Grey in the list. | +| **Optimizing** | A sweep whose sliders a study drives (see [Optimizing a sweep](#optimizing-a-sweep)). An experiment created with **Optimize** opens in this state. The panel's header reads it; the list keeps the sweep's own status, Running or Idle. | +| **Complete** | All runs finished without error. | +| **Error** | The experiment failed to start or hit an unrecoverable error. The panel shows the error message. For a sweep the error belongs to the selection that failed: move a control and the next selection computes normally. | +| **Cancelled** | You clicked **Cancel**, or the experiment was cancelled. | Experiments run in background Web Workers, so simulation playback and editor interactions stay responsive. Multiple experiments can run concurrently. @@ -87,13 +93,23 @@ 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. -### Parameter sweeps +### Keyboard navigation + +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. + +The creation panel opens with **Name** focused. Use **Tab** and **Shift+Tab** to move through fields and actions. Use the arrow keys in dropdowns and radio groups, and **Enter** or **Space** to activate buttons and section toggles. + +In metric and constraint code fields, **Tab** moves to the next control. **Enter** adds a line in state conditions and custom metrics. **Escape** dismisses code suggestions first, then returns focus to the metric or constraint row. From an ordinary field or section header, **Escape** closes the panel. The scenario worksheet uses its own [keyboard navigation](ad-hoc-scenarios.md). Removing a row moves focus to the next row, the previous row, or the add button when no rows remain. + +## 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. Flip the toggle on any numeric value to explore an interval of values instead of one. Set the minimum and the maximum — that is all a sweep declares. Petrinaut quantizes the interval finely (about fifty steps; integer parameters step by whole numbers) so a selection has a stable identity and revisiting one restores its results. With **No scenario** selected, the same toggle sits on every numeric value of the [form](ad-hoc-scenarios.md) -- a token count, a cell, a variable, a parameter override -- and each selection sweeps as a generated parameter named after the value, shown in the navigator under the value's path. -A sweep computes **what you have selected**, and nothing until something selects. A sweep created with **Create sweep** sits idle with every slider spanning its whole interval, its charts empty, and the line under the sliders says what to do -- collapse a control to a point or click the surface to compute a point, widen a range to sample across it -- until you move a control or click the surface. A sweep created with **Optimize** is selected by its study from its first step (see [Optimizing a sweep](#optimizing-a-sweep)). The results drawer grows a **Parameters** card across the top of its body, with one slider per swept parameter and the swept count under its title. Each slider selects a range on its interval, and starts spanning the whole of it: +An invalid interval keeps **Create sweep** or **Optimize** disabled. The first configuration error appears in the footer and the disabled button's tooltip. + +A sweep computes **what you have selected**, and nothing until something selects. A sweep created with **Create sweep** sits idle with every slider spanning its whole interval, its charts empty, and the line under the sliders reads **Choose parameter values or ranges to see results** until you move a control or click the surface. A sweep created with **Optimize** is selected by its study from its first step (see [Optimizing a sweep](#optimizing-a-sweep)). The results panel grows a **Parameters** card across the top of its body, with one slider per swept parameter. Each slider selects a range on its interval, and starts spanning the whole of it: - **Range** (the default): Petrinaut runs **one stochastic simulation over the ranges** — every run draws its own value for each ranged parameter, spread across the selected interval — and the metric charts stream the live distribution **over the region**, sharpening exactly like a plain experiment's. Resize a range from either end to focus; compute restarts on the new selection. Range selections run on the GPU when the net qualifies — each run's parameter draw is uploaded alongside its state — and otherwise on the CPU at full parallelism; an initial state that a scenario derives from a ranged parameter holds at the range's midpoint, while the simulation itself reads each run's own value. - **Point**: switch a parameter's control to Point and its slider collapses to a single value. A point refines in escalating batches (8, 25, 100, … up to your run budget), exactly like a plain experiment at that value — including on the GPU. @@ -102,31 +118,31 @@ Move a slider and compute immediately restarts on the new selection, like a rayt Every selection uses the same seed sequence (common random numbers), and a run's parameter draw depends only on the experiment's seed and the run's position in the sequence, so differences you see between selections come from the parameters, not from sampling luck — while experiments with different seeds explore their own value sequences. -#### Optimizing a sweep +### Optimizing a sweep The in-browser optimizer is experimental and off by default. Turn on **In-browser optimization** under Simulation in the [settings dialog](visual-settings.md#in-browser-optimization-experimental); the setting is offered only when the host application provides an optimizer that runs in your browser. Turning it off while a study runs cancels the study. -With it on, the interval toggles of the Create Experiment drawer read **Optimize**, and the first one you flip adds an **Objective** section to the drawer, between Scenario and [Constraints](#constraints): the metric to optimize (one of the experiment's metrics, the first by default), **Maximize** or **Minimize**, and the number of steps to take (30 by default, 1,000 at most). Each step computes eight runs at one point of the sweep before the optimizer reads the metric's value there, the mean over those runs on the last sampled frame; the line under the fields says so -- **30 steps · 8 runs each — the best point then refines to your run budget** -- or names the step budget the optimizer refuses (a run of more than 100,000 simulation steps, or steps × 8 runs × simulation steps over 5,000,000), and the footer stays disabled until it is met. A **No scenario** experiment optimizes too, over the generated parameters of its form values; only Constraints need a saved scenario. +With it on, the interval toggles of the Create Experiment panel read **Optimize**, and the first one you flip changes the Metrics section to **Metrics & objective**, before [Constraints](#constraints). Add a metric, then choose **Maximize** or **Minimize** on its row. The first metric is the objective by default; select **Use as objective** on another metric to optimize it instead. All the experiment's metrics are still measured. **Optimization steps** appears below the list once a metric exists (30 by default, 1,000 at most). Each step computes eight runs at one point of the sweep before the optimizer reads the metric's value there, the mean over those runs on the last sampled frame; the line under the fields says so -- **30 steps · 8 runs each — the best point then refines to your run budget** -- or names the step budget the optimizer refuses (a run of more than 100,000 simulation steps, or steps × 8 runs × simulation steps over 5,000,000), and the footer stays disabled until it is met. A **No scenario** experiment optimizes too, over the generated parameters of its form values; only Constraints need a saved scenario. -The footer reads **Optimize**, then **Starting** while the scenario compiles and the study registers. The experiment then opens already optimizing: the **Parameters** card is purple, the header's status reads **Optimizing** and its progress bar counts the steps, the controls are locked and move by themselves to each point the optimizer tries, the line under the sliders reads **Following step N of M** with the point's runs as they stream (**— 5 of 8 runs**), and every point lands on the Surface as it computes; the **N computing** chip lists the step's batch as **Step N**. The optimizer draws its first steps at random, about a third of the requested steps and at least 2 and at most 10, then proposes each further step from the results so far. Every step's runs use the sweep's common random numbers, so the differences between steps come from the parameters, not from sampling luck. Steps run one after another, and one study at a time: a study started while another runs waits for it, reading **Optimizing** at step 1 with no runs until its turn. Parameters you did not sweep hold at the values the experiment was created with. If the optimizer cannot start -- the optimizer disconnected, or a budget the form did not catch -- nothing is created: the drawer stays open with the reason in its footer and every field as you left it. +The footer reads **Optimize**, then **Starting** while the scenario compiles and the study registers. The experiment then opens already optimizing: the **Parameters** card is purple, the header's status reads **Optimizing** and its progress bar counts the steps, the controls are locked and move by themselves to each point the optimizer tries, the line under the sliders reads **Testing step N** with the point's sampling progress (**· 5 / 8 runs**), and every point lands on the Surface as it computes; **Details → Active batches** lists the step's batch as **Step N**. The optimizer draws its first steps at random, about a third of the requested steps and at least 2 and at most 10, then proposes each further step from the results so far. Every step's runs use the sweep's common random numbers, so the differences between steps come from the parameters, not from sampling luck. Steps run one after another, and one study at a time: a study started while another runs waits for it, reading **Optimizing** at step 1 with no runs until its turn. Parameters you did not sweep hold at the values the experiment was created with. If the optimizer cannot start -- the optimizer disconnected, or a budget the form did not catch -- nothing is created: the panel stays open with the reason in its footer and every field as you left it. -While the study drives the sweep the card's header carries one purple **Stop** button: it ends the search where it stands, and the point it was trying refines to your run budget; when the search finishes on its own the sliders settle on the best point found and that point refines the same way. Once the search settles, the sliders unlock and the line under the sliders keeps its outcome -- **Finished 30 steps · best step so far: step 12 (650.500)**, or **Stopped after 17 of 30 steps · …** -- with the parked point's sampling after it, until the experiment's removal. The value is named for what it is: the best of the steps tried, not a confirmed result at that configuration. From there the sweep is yours to explore by hand -- sliders, **Point** and **Range**, the Surface -- with the study's picture kept; the card offers nothing more, and a new search is a new experiment. **Cancel** in the drawer's footer stops the study as well as the sweep; **Remove** discards both. A study that fails reports its message in the line under the header, where the experiment's own error would read. The study appears nowhere else: the sweep's drawer is its home, and removing the experiment removes it. +While the study drives the sweep the card's header carries one purple **Stop** button: it ends the search where it stands, and the point it was trying refines to your run budget; when the search finishes on its own the sliders settle on the best point found and that point refines the same way. Once the search settles, the sliders unlock and the line under the sliders shows sampling progress while the selected point refines. Once sampling stops, it reads **Optimization complete**, **Optimization stopped**, or **Optimization failed**. The best value stays beside **Objective by step**, and **Details** keeps the full search summary. The value is named for what it is: the best of the steps tried, not a confirmed result at that configuration. From there the sweep is yours to explore by hand -- sliders, **Point** and **Range**, the Surface -- with the study's picture kept; the card offers nothing more, and a new search is a new experiment. **Cancel** in the panel's footer stops the study as well as the sweep; **Remove** discards both. A study that fails reports its message in the line under the header, where the experiment's own error would read. The study appears nowhere else: the sweep's panel is its home, and removing the experiment removes it. The first study in a browser downloads the Python runtime and the optimizer packages before its first step starts; the header reads **Optimizing** with no steps completed while that happens. Later studies reuse the browser's cache. Closing or reloading the page ends the study, and while one runs the browser asks you to confirm first; the study is gone on the next load, the sweep with it. The optimizer proposes with the same sampler, seed and start-up draws the [Petrinaut CLI](../../../@local/petrinaut-arch-docs/content/cli/usage-manual.mdx) uses, so a study's proposals match the CLI's step for step while the objective values it is told match. -An **Objective by step** strip sits under the sliders from the moment the drawer opens: every step's objective value as a purple dot over the step number, with the best so far as a line stepping through them, drawn as the steps land; the axis reaches to the steps asked for while the search runs and ends at the last step run once it settles. Infeasible draws carry no value and are left off the strip, and the best step so far is never one of them. Its title line names the metric and counts the steps, with the best value found; click the line to fold the chart away or bring it back. The strip stays once the search settles. A sweep created with **Create sweep** has no strip. +An **Objective by step** strip sits under the sliders from the moment the panel opens: every step's objective value as a purple dot over the step number, with the best so far as a line stepping through them, drawn as the steps land; the axis reaches to the steps asked for while the search runs and ends at the last step run once it settles. Infeasible draws carry no value and are left off the strip, and the best step so far is never one of them. Its title line names the metric and counts the steps, with the best value found; click the line to fold the chart away or bring it back. The strip stays once the search settles. A sweep created with **Create sweep** has no strip. -The drawer's shape is fixed when the experiment is created, and it holds through running, stopped and failed studies: an experiment created with **Optimize** has, from its first frame, a headline over the header's columns, **Steps** and **Steps clear** columns after **Compute** (see [Reading the header](#reading-the-header)), the **Constraints** and **Sensitivity analysis** cards after the metric charts and the steps table under them (see [Metric charts](#metric-charts)), empty until the steps fill them; a sweep created with **Create sweep** has none of them. Nothing appears later, and nothing moves. +An experiment created with **Optimize** shows its **Optimization steps** in the header, an objective chart under the parameter controls, the **Constraints** and **Sensitivity analysis** cards after the metric charts, and a steps table below them. These remain available after the search stops or fails. The detailed search summary and constraint totals are available in **Details**. -#### The surface view +### The surface view -A sweep with two or more swept parameters grows a **Surface** card under the **Parameters** card: a contour plot of one metric's final value over two parameters you pick, drawn from the points the sweep has computed. It starts empty. Every point you visit — by moving the sliders to a point, by clicking the plot, or through the optimizer — lands as a dot with its value, the field is interpolated between the dots once there are three, and the point being computed is a ring; its value joins the field once its batch completes. Points computed at other values of the parameters not shown are drawn too, projected onto the two you picked. The **X** and **Y** pickers sit in the row under the plot and the **Metric** picker in the row beneath them; every metric is measured at every point, so switching the shown metric repaints from what was already computed. The line under the card's title counts the points and what computes -- **computing the selected point** or **sampling across the selected ranges**, with the runs so far -- or, mid-drag, the values under the pointer. **The surface is itself a control**: click, or press and drag with a live crosshair and value readout, and on release every swept parameter collapses to a point -- the two shown at the place you released, the others at the middle of their current range -- which then computes. A dark ring marks where the navigator sits. While the optimizer drives the sweep the card is read-only: the plot only displays under a not-allowed cursor, the **X**, **Y** and **Metric** pickers lock, a purple **Read-only** mark sits beside them, and between two steps the line says the optimizer is choosing the next point. A cancelled sweep locks the same way, with the mark in grey. +A sweep with two or more swept parameters grows a **Surface** card under the **Parameters** card: a contour plot of one metric's final value over two parameters you pick, drawn from the points the sweep has computed. It starts empty. Every point you visit — by moving the sliders to a point, by clicking the plot, or through the optimizer — lands as a dot with its value, the field is interpolated between the dots once there are three, and the point being computed is a ring; its value joins the field once its batch completes. Points computed at other values of the parameters not shown are drawn too, projected onto the two you picked. The **X** and **Y** pickers sit in the row under the plot and the **Metric** picker in the row beneath them; every metric is measured at every point, so switching the shown metric repaints from what was already computed. The line under the card's title counts sampled points and indicates when sampling is active. During a drag it shows the values under the pointer. The help icon explains the shading and how to select a point. **The surface is itself a control**: click, or press and drag with a live crosshair and value readout, and on release every swept parameter collapses to a point -- the two shown at the place you released, the others at the middle of their current range -- which then computes. A dark ring marks where the navigator sits. While the optimizer drives the sweep the card is read-only: the plot only displays under a not-allowed cursor, the **X**, **Y** and **Metric** pickers lock, a purple **Read-only** mark sits beside them, and between two steps the line says the optimizer is choosing the next point. A cancelled sweep locks the same way, with the mark in grey. -The drawer arranges its parts by its width. The **Parameters** card spans the body under the header. Beneath it, at the drawer's full width, the **Surface** sits on the left and the metric cards on the right, two to a row, so two swept parameters and up to four metrics fit without scrolling; in a narrower drawer the metric cards come first, then **Surface**, so the charts you watch are at the top either way. A sweep with one swept parameter has no surface, and its cards take the whole width. Every card keeps a fixed height, and only the body scrolls, under the header. +The panel arranges its parts by its width. The **Parameters** card spans the body under the header. Beneath it, at the panel's full width, the **Surface** sits on the left and the metric cards on the right, two to a row, so two swept parameters and up to four metrics fit without scrolling; in a narrower panel the metric cards come first, then **Surface**, so the charts you watch are at the top either way. A sweep with one swept parameter has no surface, and its cards take the whole width. Every card keeps a fixed height, and only the body scrolls, under the header. ### Compute backend (experimental) -Experiments run on the CPU unless you ask for the GPU. Switch on **WebGPU** under **Settings → Simulation**, and the Create Experiment drawer gains a **Run on GPU** switch. Running on your graphics hardware is dramatically faster — a 4000-run experiment that takes six seconds on the CPU finishes in a few milliseconds. +Experiments run on the CPU unless you ask for the GPU. Switch on **WebGPU** under **Settings → Simulation**, and the Create Experiment panel gains a **Run on GPU** switch. Running on your graphics hardware is dramatically faster — a 4000-run experiment that takes six seconds on the CPU finishes in a few milliseconds. The choice is per experiment, not global, so a GPU experiment and a CPU experiment can run side by side — useful for comparing the two on the same model. Each gets its own GPU device, so nothing is shared between them. @@ -147,34 +163,23 @@ Run count has no ceiling of its own: runs beyond what your GPU can hold at once Two things to know before comparing results: -- **The same seed gives different numbers on the two backends.** They deliberately use different random number generators, so the trajectories differ while the distributions agree. On the built-in SIR example the two backends' mean token counts agree to within half a percent. The badge in each experiment's header records which backend ran it, so results stay attributable after the fact. +- **The same seed gives different numbers on the two backends.** They deliberately use different random number generators, so the trajectories differ while the distributions agree. On the built-in SIR example the two backends' mean token counts agree to within half a percent. The badge in each experiment's **Details** records which backend ran it, so results stay attributable after the fact. - Continuous dynamics are integrated with a **more accurate method** (Runge-Kutta 4) than the CPU's, so a model with differential equations may show slightly different — better — values, not just different noise. - The GPU steps every run to the configured max time, while the CPU stops a run as soon as it can no longer fire anything. So a net that finishes early reports a **higher frame count and simulated time** on the GPU for the same results. Nothing is wrong with either; they just stop counting at different points. ### Reading the header -Open an experiment's drawer and its header names the experiment in one line: the name, the scenario (or **Default scenario**) and the run count, for example **SIR transmission sweep · Seasonal Flu · 100 runs**. Beneath it, a strip of labelled columns divided by hairlines, always on one line: in a narrow drawer the labels become tooltips and the columns read as chips, **Runs** and **Selection** shorten to their counts, and whatever still does not fit scrolls sideways under a fade at the edge. +The results header shows the experiment's name and status. Plain experiments show **Completed runs**, such as **640 / 1,000 runs**. Optimized sweeps show **Optimization steps**, such as **4 / 30 steps**. Failed runs appear beside the progress count when any occur. An idle sweep reads **Ready**, and sampling progress appears beside its parameter controls. -| Column | Meaning | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Status** | One of the statuses above, as a pill with a coloured dot. | -| **Runs** | Plain experiments: how many runs are in flight, and how many have finished. A sweep shows **Selection** in its place. | -| **Selection** | Sweeps, in place of **Runs**: the selected combination's runs sampled over the run budget. | -| **Errors** | How many individual runs errored. An experiment can complete with some runs errored. | -| **Time** | Simulated time reached, against the configured maximum. This is model time, not clock time. A sweep that has computed nothing reads `0`. | -| **Elapsed** | Plain experiments only: clock time the experiment has been simulating; it stops with the experiment and holds the total it took. A sweep never finishes, so it has no clock. | -| **Activity** | The **N computing** chip: how many batches run right now, **0 computing** when nothing does. Click it while something runs to list them. | -| **Compute** | Whether the run uses the **CPU** or the **GPU**. Hover it for detail; on a CPU-backed experiment that asked for the GPU, it names the requirement the net did not meet. | -| **Steps** | Sweeps created with **Optimize**: the steps finished over the steps requested, with the runs per step, **4 / 30 · 8 runs each**; the count alone in a narrow drawer. | -| **Steps clear** | Sweeps with a study over a sweep with [constraints](#constraints): the steps clear over the steps that simulated, **3 / 4 · 75%**. | +Click **Details** for the scenario, requested runs or sampling limit, runs sampled, errors, simulation time, elapsed time for plain experiments, and compute backend. A sweep's **Sampling limit** is the maximum number of runs for the selected parameter values or ranges. For example, **1,000 runs** means the selection can refine up to that many samples. Moving a control starts sampling the new selection. -A progress bar runs along the header's bottom edge: the selected combination's runs for a sweep (the study's steps while one drives it), simulated time otherwise. If the experiment failed, the error reads in the line under the header; so does the error of a study that failed while driving a sweep. +For optimized sweeps, **Details** also shows the steps finished, runs per step, constraint pass counts, best step, and how the search ended. While optimization runs, a chip describes recent progress: **Still improving**, **Converging**, or **Too early to say**. The assessment uses a window of one tenth of the requested steps, with a minimum of five completed steps. -For a sweep created with **Optimize**, the title line also carries the study's headline at its right: **Starting · no best step yet** before the first step, **Step 5 of 30 · best step so far: step 2 (650.500)** while it runs, then **Finished 30 steps · …**, **Stopped after 17 of 30 steps · …** or **Failed after …**. While it runs, a chip beside the line says whether the study is still finding better steps: **Still improving** when the best moved within the last few completed steps (a tenth of the requested steps, five at least), **Converging** when that many steps passed without a better one, and **Too early to say** before one such window has completed. The chip's place is reserved, so nothing moves when it appears or goes. +The **Compute** badge in Details shows **CPU** or **GPU**. Hover it for more information, including why a requested GPU backend fell back to the CPU. While batches run, **Active batches** opens their individual progress. -Once the drawer's body has scrolled, the header condenses to one line, with the columns folded in as compact chips beside the title, the compute badge and the computing chip still among them; move the pointer over it, or Tab onto one of its controls, and it grows back. Nothing in the header moves when a status changes, a count goes to zero or a number grows a digit: every column is as wide as its widest value, and every card in the body keeps its height. +The bar along the header's bottom edge follows sampling progress for a sweep, optimization steps while a study drives it, and simulation time for a plain experiment. Errors appear directly below the header. Scrolling the results condenses the header to one line; hovering over it or focusing a control expands it again. -**Elapsed** and **Duration** measure simulating only. Compiling the net's user code and starting the workers (or acquiring the GPU device and compiling the shader) happens before the clock starts, so the number is comparable between the two backends. An experiment that fails before it starts simulating shows `—` rather than a duration. +**Simulation time** is time within the model. **Elapsed time** and **Duration** measure wall-clock time spent simulating. Compilation and worker startup happen before that clock starts. An experiment that fails before simulation begins shows `—` for its elapsed time. ### Metric charts @@ -189,40 +194,40 @@ Click (or drag across) a timeline chart to inspect single time steps — a popov #### The study's cards -For a sweep created with **Optimize**, two more cards follow the metric charts in the same grid, at the same height, from the moment the drawer opens. +For a sweep created with **Optimize**, two more cards follow the metric charts in the same grid, at the same height, from the moment the panel opens. + +- The **Constraints** card, only for a sweep with [constraints](#constraints). Its headline is the steps **clear** across the study over the steps that simulated, `14 / 20 · 70%`, with the pass threshold and the infeasible draws counted in the line under the title, **pass threshold 95% (alpha 0.05) · 2 infeasible draws**. Beneath it, one line gives the latest step's verdict -- **Clear**, **Limited · 6 / 8 runs passed · 75% · State constraint 1**, or **Infeasible: Parameter constraint 1** -- and one bar per state constraint shows the share of steps it passed, with a dashed mark at the threshold. The same count is available in **Details** as **Steps clear**. A step stopped mid-flight, or pruned because the sliders moved on, carries no verdict and counts in neither number. +- The **Sensitivity analysis** card lists the swept parameters by descending **Share** with a bar for how much each one matters for reaching the best steps and a **Share** percentage per parameter; parameters without an estimate follow the estimated ones. Tied shares keep the scenario's order. The estimate is Optuna's PED-ANOVA: it takes the best tenth of the completed steps and measures how concentrated each parameter's values are there relative to its whole range, a relative importance that sums to 100% rather than a share of the objective's variance. It is computed by the optimizer running in your browser once the study is over, and again every few steps while a long study runs (every tenth step, or every twentieth of the requested steps when that is more) once it is past the floor. The line under the title says **Based on N completed steps**, or **No estimate yet** while waiting for the first estimate. The help icon explains the method and recommended step count. Below the floor, 50 completed steps for a study of under 100 steps and 100 otherwise, the card is muted, the bars fade and an available estimate reads **Preliminary · N completed steps**: a confident estimate over a handful of steps would mislead, and at the default 30 steps the card stays muted. A **Correlation** column beside the bars gives each parameter's signed correlation with the objective over the completed steps (`+0.34`, `−0.12`), computed from the steps themselves, so it is there from the third completed step whatever the floor. Before the first estimate the rows show a dash. A study that optimizes a single parameter has nothing to rank it against: its line says **Correlation only · one parameter**, the card is never muted, and only the correlation column carries information. -- The **Constraints** card, only for a sweep with [constraints](#constraints). Its headline is the steps **clear** across the study over the steps that simulated, `14 / 20 · 70%`, with the pass threshold and the infeasible draws counted in the line under the title, **pass threshold 95% (alpha 0.05) · 2 infeasible draws**. Beneath it, one line gives the latest step's verdict -- **Clear**, **Limited · 6 / 8 runs passed · 75% · State constraint 1**, or **Infeasible: Parameter constraint 1** -- and one bar per state constraint shows the share of steps it passed, with a dashed mark at the threshold. The same headline sits in the header's strip as **Steps clear**. A step stopped mid-flight, or pruned because the sliders moved on, carries no verdict and counts in neither number. -- The **Sensitivity analysis** card lists the swept parameters in the scenario's order with a bar for how much each one matters for reaching the best steps and a **Share** percentage per parameter; the rows keep their places as estimates land. The estimate is Optuna's PED-ANOVA: it takes the best tenth of the completed steps and measures how concentrated each parameter's values are there relative to its whole range, a relative importance that sums to 100% rather than a share of the objective's variance. It is computed by the optimizer running in your browser once the study is over, and again every few steps while a long study runs (every tenth step, or every twentieth of the requested steps when that is more) once it is past the floor. The line under the title names the statistic and says how many completed steps it is fitted on. Below the floor, 50 completed steps for a study of under 100 steps and 100 otherwise, the card is muted, the bars fade and the line says **below the N-step floor, treat as a hint**, N being the floor just named: a confident estimate over a handful of steps would mislead, and at the default 30 steps the card stays muted. A **Correlation** column beside the bars gives each parameter's signed correlation with the objective over the completed steps (`+0.34`, `−0.12`), computed from the steps themselves, so it is there from the third completed step whatever the floor. Before the first estimate the rows show a dash. A study that optimizes a single parameter has nothing to rank it against: its line says **PED-ANOVA ranks two or more parameters**, the card is never muted, and only the correlation column carries information. +Under both columns, at the panel's full width, the **steps table** lists the study's steps newest first, each with its parameters, objective value and a state mark (complete, pruned or failed), the best step starred and tinted. It keeps a fixed height and scrolls on its own, and a long study shows its newest 200 steps while the header keeps the total and **Details** keeps the best step. A sweep with constraints adds a **Runs passed** column (`52 / 60 · 87%`, the constraint with the fewest passing runs when there are several) and greys the rows of infeasible steps, their mark reading **Infeasible:** and the constraint's name. -Under both columns, at the drawer's full width, the **steps table** lists the study's steps newest first, each with its parameters, objective value and a state mark (complete, pruned or failed), the best step starred and tinted. It keeps a fixed height and scrolls on its own, and a long study shows its newest 200 steps while the header keeps the totals and the best. A sweep with constraints adds a **Runs passed** column (`52 / 60 · 87%`, the constraint with the fewest passing runs when there are several) and greys the rows of infeasible steps, their mark reading **Infeasible:** and the constraint's name. +### Panel and fullscreen views + +Experiment creation and results open in a [panel beside the main view](simulation-panels.md). Expand it to fullscreen for more space; form edits and chart choices stay in place. + +Experiments belong to the current session. Reloading a link after that session has ended shows an unavailable message; it does not rerun the experiment. ### Actions -In the experiment's view drawer (open it from the list, where the first click selects a row and a click on the selected row or Enter opens it, or via any experiment in the top-bar **Active experiments** popover): +In the experiment's view panel (open it from the list, where the first click selects a row and a click on the selected row or Enter opens it, or via any experiment in the top-bar **Active experiments** popover): - **Cancel** -- stops the experiment. Offered while it is initializing or running, and while a study drives a sweep, which it stops too. Once a sweep is cancelled its sliders and its surface lock; a selection that failed locks nothing, and the next selection computes normally. - **Remove** -- deletes the record and disposes the experiment's workers (and, for a sweep, its study). It sits at the left edge of the footer. -- **Close** -- closes the drawer without affecting the experiment. +- **Close** -- closes the panel without affecting the experiment. There is no built-in restart action -- to re-run with the same configuration, **Create** a new experiment with the same settings. -Opening and closing an existing experiment participates in Browser Back / -Forward history on hosts with app navigation enabled. Experiment records and -results remain session data: browser navigation can reopen a record while the -current Petrinaut session is mounted, but reloading a copied experiment URL -does not recreate the run. - A confirmation prompt blocks browser/tab close while any experiment is initializing or running. ### Notifications -The **N computing** chip in the header's **Activity** column counts the batches running right now — a sweep pipelines the selection's batches two deep — and clicking it opens a compact list with each batch's label (**Selection**, or **Step N** while a study drives the sweep) and progress; it reads **0 computing** while nothing runs, and the list closes with its last batch. +In **Details**, **Active batches** counts the batches running and opens a list with each batch's label (**Selection**, or **Step N** during optimization) and progress. It appears while batches are active. -A small toast appears when an experiment **completes** or **errors**, even if its drawer isn't open. The top-bar **Active experiments** popover (see below) lets you jump to any in-flight experiment from anywhere in the app. +A small toast appears when an experiment **completes** or **errors**, even if its panel isn't open. The top-bar **Active experiments** popover (see below) lets you jump to any in-flight experiment from anywhere in the app. ## Active experiments popover -When any experiment is **initializing** or **running**, the top bar shows an **Active experiments** flask icon with a count (e.g. "2 active"). Click it for a popover listing each in-flight experiment with its scenario, progress, status, and a time progress bar. Clicking a row jumps directly to Simulate mode, the Experiments tab, and that experiment's drawer. +When any experiment is **initializing** or **running**, the top bar shows an **Active experiments** flask icon with a count (e.g. "2 active"). Click it for a popover listing each in-flight experiment with its scenario, progress, status, and a time progress bar. Clicking a row jumps directly to Simulate mode, the Experiments tab, and that experiment's panel. The popover hides itself again once nothing is in flight. diff --git a/libs/@hashintel/petrinaut/docs/scenarios.md b/libs/@hashintel/petrinaut/docs/scenarios.md index b45f9146f66..f992294234f 100644 --- a/libs/@hashintel/petrinaut/docs/scenarios.md +++ b/libs/@hashintel/petrinaut/docs/scenarios.md @@ -28,14 +28,18 @@ You will need scenarios when you want to: ## Creating a scenario 1. Switch to **Simulate** mode and open the **Scenarios** tab. -2. Click **Create**. The Create Scenario drawer opens. +2. Click **Create**. The Create Scenario panel opens. 3. Fill in **Scenario name** (required, unique among scenarios) and an optional description. 4. Add **Variables** -- one per value you want to drive from a single number, written `scenario.` in every expression below. Turn **Scenario Parameter** on to expose a Variable as a tunable parameter of the saved scenario: it needs a snake_case name, a constant expression as its default, and a value between 0 and 1 for a ratio. 5. Fill in **Parameters** -- an expression per net-level parameter whose default you want to override; the `default` tag marks the untouched ones. 6. Configure **Initial state** -- a count expression per untyped place, rows of cells per typed place (a Dynamic row builds many tokens from one count). See [Ad-hoc Scenarios](ad-hoc-scenarios.md#the-form) for the form itself. 7. Click **Create**. It is disabled while the name or any value has an error -- hover it to read the first. -The view drawer opens from the Scenarios list, which works like the other Simulate-mode lists: the first click selects a row, and a click on the selected row (or Enter) opens it. The list is a single Tab stop whose rows the arrow keys walk. The drawer shows the same form populated with the existing values, with **Close** and **Save** buttons. +The view panel opens from the Scenarios list, which works like the other Simulate-mode lists: the first click selects a row, and a click on the selected row (or Enter) opens it. The list is a single Tab stop whose rows the arrow keys walk. The panel shows the same form populated with the existing values, with **Close** and **Save** buttons. + +## Panel and fullscreen views + +Scenario creation and editing open in a [panel beside the main view](simulation-panels.md). Expand it to fullscreen for more space; your unsaved edits stay in place. ## Expression language @@ -53,7 +57,7 @@ The subset is strict about booleans and equality: conditions and `&&`/`||` take ## Scenarios stored as code -Net files, the AI assistant and earlier versions of Petrinaut may store a scenario's initial state per place (one expression or one token spreadsheet per place) or as a single code block. Both run unchanged, and both preview as computed rows in Simulation Settings and the experiment drawer. Editing opens each in the form: a per-place scenario opens converted -- its parameters as exposed Variables, its expressions and rows as the form's blocks -- and saving stores it in the form's format; a code scenario opens with its name, description, Variables and Parameters editable and its code shown read-only in the Initial state slot -- edit its values here, change the code from the AI assistant or the net file, or recreate the scenario from the form (a Dynamic row builds many tokens from one count). A code scenario stores no form entries, only its scenario parameters: every Variable must be marked **Scenario Parameter** (the form refuses to save one that is not), and each is kept as its computed default. The code is a function body that returns an object keyed by **place name** -- a number for an untyped place (rounded, clamped to `>= 0`), an array of token objects for a typed one -- with `parameters`, `scenario` and `range` in scope; a key that is not a place name is a compile error, so a typo'd name fails the scenario instead of being silently ignored: +Net files, the AI assistant and earlier versions of Petrinaut may store a scenario's initial state per place (one expression or one token spreadsheet per place) or as a single code block. Both run unchanged, and both preview as computed rows in Simulation Settings and the experiment panel. Editing opens each in the form: a per-place scenario opens converted -- its parameters as exposed Variables, its expressions and rows as the form's blocks -- and saving stores it in the form's format; a code scenario opens with its name, description, Variables and Parameters editable and its code shown read-only in the Initial state slot -- edit its values here, change the code from the AI assistant or the net file, or recreate the scenario from the form (a Dynamic row builds many tokens from one count). A code scenario stores no form entries, only its scenario parameters: every Variable must be marked **Scenario Parameter** (the form refuses to save one that is not), and each is kept as its computed default. The code is a function body that returns an object keyed by **place name** -- a number for an untyped place (rounded, clamped to `>= 0`), an array of token objects for a typed one -- with `parameters`, `scenario` and `range` in scope; a key that is not a place name is a compile error, so a typo'd name fails the scenario instead of being silently ignored: ```ts return { diff --git a/libs/@hashintel/petrinaut/docs/simulation-panels.md b/libs/@hashintel/petrinaut/docs/simulation-panels.md new file mode 100644 index 00000000000..ed84226df2b --- /dev/null +++ b/libs/@hashintel/petrinaut/docs/simulation-panels.md @@ -0,0 +1,21 @@ +# Simulation panels + +Experiment creation, experiment results, and scenario creation and editing open in a panel beside the main view. The list or canvas stays visible and usable alongside it. + +## Expand and return + +Click **Expand to fullscreen** in the panel header to fill the main view. Click **Show as panel** to return to the side-by-side layout. The same form or results stay open: unsaved edits, expanded charts, and scroll position carry across the resize. + +The resize animates when **Animations** is enabled in Settings. It also respects your system's reduced-motion preference. + +Both size controls and **Close panel** sit together on the header's right edge. Escape closes the panel while focus is inside it, unless an editor or menu handles Escape first. Closing an experiment's results leaves the experiment running. Save a scenario's edits before closing it to keep them. + +## Alongside the AI assistant + +A docked AI assistant takes space beside the main view and the simulation panel. Opening, closing, or resizing the assistant adjusts the space available to both. Fullscreen fills the main view beside the assistant. A floating assistant sits above the workspace. + +## Links and browser history + +On the Petrinaut website, the address includes the open experiment or scenario and whether it is fullscreen. Browser **Back** and **Forward** restore that view and its size. + +A scenario link requires the same model with that scenario saved. A link to a creation panel opens an empty form. Experiments belong to the current session; opening an experiment link after that session has ended shows an unavailable message and does not rerun it. 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/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index f5f858be507..61b9265a32e 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -175,7 +175,7 @@ Off by default. Adds an interval toggle to every numeric value of the experiment ### In-browser optimization (experimental) -Shown only when the host application provides an optimizer that runs in your browser. Off by default. On, the experiment form's interval toggles read **Optimize**: creating the experiment starts a study over the selected intervals, with an **Objective** and **Constraints** chosen in the form. Off, the toggles read **Sweep** and the sweep waits for your selection; any running in-browser optimization is cancelled. See [Optimizing a sweep](experiments.md#optimizing-a-sweep). +Shown only when the host application provides an optimizer that runs in your browser. Off by default. On, the experiment form's interval toggles read **Optimize**: creating the experiment starts a study over the selected intervals, with the objective chosen on a metric in **Metrics & objective** and conditions added under **Constraints**. Off, the toggles read **Sweep** and the sweep waits for your selection; any running in-browser optimization is cancelled. See [Optimizing a sweep](experiments.md#optimizing-a-sweep). ## Labs diff --git a/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx b/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx index 5f6129a882c..1d397cafb27 100644 --- a/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/navigation/index.test.tsx @@ -562,3 +562,47 @@ describe("Petrinaut navigation", () => { }); }); }); + +test.each([null, { type: "metric", id: "metric-a" }] as const)( + "clears fullscreen when leaving a panel for %j", + (resource) => { + const Probe = () => { + const { state, navigate } = usePetrinautNavigation(); + return ( + <> + + {state.simulatePresentation ?? "panel"} + + + + ); + }; + const view = render( + + + , + ); + expect(screen.getByLabelText("Presentation").textContent).toBe( + "fullscreen", + ); + fireEvent.click(screen.getByRole("button", { name: "Leave panel" })); + expect(screen.getByLabelText("Presentation").textContent).toBe("panel"); + view.unmount(); + }, +); diff --git a/libs/@hashintel/petrinaut/src/react/navigation/index.tsx b/libs/@hashintel/petrinaut/src/react/navigation/index.tsx index 9757d6bb29e..e30ed8de6fb 100644 --- a/libs/@hashintel/petrinaut/src/react/navigation/index.tsx +++ b/libs/@hashintel/petrinaut/src/react/navigation/index.tsx @@ -54,6 +54,8 @@ export type PetrinautNavigationState = { mode: EditorGlobalMode; simulateView: SimulateViewMode; simulateResource: PetrinautSimulateResource | null; + /** Omission uses the panel presentation, including in existing host controllers. */ + simulatePresentation?: "panel" | "fullscreen"; scenarioId: string | null | undefined; subnetId: string | null; selection: readonly SelectionItem[]; @@ -76,6 +78,7 @@ export type PetrinautNavigationAction = | "mode" | "simulation-view" | "simulation-resource" + | "simulation-presentation" | "scenario" | "subnet" | "selection" @@ -174,6 +177,8 @@ export const petrinautNavigationStatesMatch = ( left.simulateView === right.simulateView && left.simulateResource?.type === right.simulateResource?.type && left.simulateResource?.id === right.simulateResource?.id && + (left.simulatePresentation ?? "panel") === + (right.simulatePresentation ?? "panel") && left.scenarioId === right.scenarioId && left.subnetId === right.subnetId && selectionsMatch(left.selection, right.selection) && @@ -192,6 +197,13 @@ const resolveNavigationUpdate = ( return { ...updated, + simulatePresentation: + updated.simulateResource?.type === "scenario" || + updated.simulateResource?.type === "experiment" || + updated.overlay?.type === "create-experiment" || + updated.overlay?.type === "create-scenario" + ? updated.simulatePresentation + : undefined, selection: canonicalizeSelection(updated.selection), }; }; 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..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( @@ -360,7 +391,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 +420,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 () => { @@ -627,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" }), ); @@ -1246,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 ? (
= ({ />
) : null} - {boundsError ? ( -
{boundsError}
+ {error ? ( +
+ {error} +
) : null}
diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor/computed-expression.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor/computed-expression.tsx new file mode 100644 index 00000000000..fb9b75f383a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/value-editor/computed-expression.tsx @@ -0,0 +1,110 @@ +import { Portal } from "@ark-ui/react/portal"; +import { useLayoutEffect, useState } from "react"; + +import { usePortalContainerRef } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { OverlayScrollArea } from "../../overlay-scroll-area"; + +const expressionStyle = css({ + position: "fixed", + zIndex: "popover", + display: "flex", + flexDirection: "column", + backgroundColor: "neutral.s00", + color: "neutral.fg.body", + border: "[1px solid {colors.blue.s70}]", + borderRadius: "xs", + boxShadow: "[0 3px 12px -4px rgba(0,0,0,0.18)]", + pointerEvents: "auto", +}); +const codeStyle = css({ + margin: "[0]", + paddingX: "2", + paddingY: "1", + fontFamily: "mono", + fontSize: "[12px]", + lineHeight: "[18px]", + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + userSelect: "text", +}); + +export const ComputedExpression: React.FC<{ + id: string; + expression: string; + anchorRef: React.RefObject; +}> = ({ 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.test.tsx b/libs/@hashintel/petrinaut/src/ui/components/section.test.tsx index b996e2c862f..d8822775e5a 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/section.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/section.test.tsx @@ -1,11 +1,13 @@ /** * @vitest-environment jsdom - * - * Stacking is the one thing about a Section that jsdom can still hold to - * account: it computes no layout, but the classes Panda emits carry the - * tiers, and the tiers are what the nesting bug was about. */ -import { cleanup, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { Section, SectionList } from "./section"; @@ -13,6 +15,22 @@ import { Section, SectionList } from "./section"; afterEach(cleanup); describe("Section", () => { + it("exposes the expanded state on its keyboard-accessible toggle", async () => { + render( +
+ Condition +
, + ); + const trigger = screen.getByRole("button", { + name: "Toggle Constraints section", + }); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + fireEvent.click(trigger); + await waitFor(() => + expect(trigger.getAttribute("aria-expanded")).toBe("false"), + ); + }); + it("keeps a focused section below every sticky header", () => { render( diff --git a/libs/@hashintel/petrinaut/src/ui/components/section.tsx b/libs/@hashintel/petrinaut/src/ui/components/section.tsx index 3ea16ba033b..eaf4a419c83 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]", @@ -121,14 +143,6 @@ const triggerButtonStyle = css({ }, }); -// Collapsible.Trigger injects aria-expanded, which the ds Button renders as -// pressed — strip it so the trigger keeps the resting ghost look. data-state -// still carries open/closed for the chevron rotation. -const TriggerButton = ({ - "aria-expanded": _ariaExpanded, - ...props -}: React.ComponentProps) =>
+ {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 +312,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 ( + <> +