From f9913430578f60719f8f0a3045c97f5a9ba1f4dd Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 01:17:48 +0200 Subject: [PATCH 1/4] FE-1500: add editor presentation profiles --- .changeset/petrinaut-presentation-profiles.md | 7 + .../src/examples/embedded-example-page.tsx | 1 + .../src/examples/full-example-page.tsx | 1 + .../components/sub-view/deferred-sub-view.tsx | 51 ++ .../vertical/vertical-sub-views-container.tsx | 8 +- .../@hashintel/petrinaut/src/ui/petrinaut.tsx | 39 +- .../BottomBar/playback-settings-menu.tsx | 465 +++++++++------- .../BottomBar/simulation-controls.tsx | 112 ++-- .../panels/LeftSideBar/subviews/nets-list.tsx | 21 +- .../subviews/code-field.tsx | 49 ++ .../subviews/main.tsx | 54 +- .../Editor/panels/PropertiesPanel/panel.tsx | 180 +------ .../PropertiesPanel/place-properties/main.tsx | 29 +- .../selected-item-properties.tsx | 174 ++++++ .../transition-properties/main.tsx | 47 +- .../type-properties/subviews/main.tsx | 78 +-- .../src/ui/views/SDCPN/canvas-renderer.ts | 2 + .../SDCPN/components/viewport-controls.tsx | 81 +-- .../react-flow/react-flow-canvas.tsx | 6 +- .../react-flow-canvas/place-state-tooltip.tsx | 19 +- .../use-react-flow-controller.ts | 4 + .../ui/views/shared/presentation-context.tsx | 59 +++ .../simulation-parameter-bounds.test.ts | 20 + .../shared/simulation-parameter-bounds.ts | 15 + .../shared/simulation-scenario-controls.tsx | 499 ++++++++++++++++++ 25 files changed, 1472 insertions(+), 549 deletions(-) create mode 100644 .changeset/petrinaut-presentation-profiles.md create mode 100644 libs/@hashintel/petrinaut/src/ui/components/sub-view/deferred-sub-view.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/code-field.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/selected-item-properties.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/shared/presentation-context.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/shared/simulation-parameter-bounds.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/shared/simulation-parameter-bounds.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/shared/simulation-scenario-controls.tsx diff --git a/.changeset/petrinaut-presentation-profiles.md b/.changeset/petrinaut-presentation-profiles.md new file mode 100644 index 00000000000..3e4bfee1b94 --- /dev/null +++ b/.changeset/petrinaut-presentation-profiles.md @@ -0,0 +1,7 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add a `presentationProfile` prop to `Petrinaut` (`editor` or `review`) that +gates authoring-only controls, and extract the scenario and playback controls +into shared components reusable outside the full editor. diff --git a/apps/petrinaut-website/src/examples/embedded-example-page.tsx b/apps/petrinaut-website/src/examples/embedded-example-page.tsx index 26937e40a20..9eda19e9bd6 100644 --- a/apps/petrinaut-website/src/examples/embedded-example-page.tsx +++ b/apps/petrinaut-website/src/examples/embedded-example-page.tsx @@ -78,6 +78,7 @@ export const EmbeddedExamplePage: FunctionComponent< handle={handle} hideNetManagementControls="all" navigation={navigation} + presentationProfile="review" readonly slots={{ topBarStart: ( diff --git a/apps/petrinaut-website/src/examples/full-example-page.tsx b/apps/petrinaut-website/src/examples/full-example-page.tsx index 3b3deb5465d..810983766a3 100644 --- a/apps/petrinaut-website/src/examples/full-example-page.tsx +++ b/apps/petrinaut-website/src/examples/full-example-page.tsx @@ -72,6 +72,7 @@ export const FullExamplePage = ({ handle={handle} hideNetManagementControls="all" navigation={navigation} + presentationProfile="review" readonly slots={{ topBarStart: ( diff --git a/libs/@hashintel/petrinaut/src/ui/components/sub-view/deferred-sub-view.tsx b/libs/@hashintel/petrinaut/src/ui/components/sub-view/deferred-sub-view.tsx new file mode 100644 index 00000000000..1538fe1af2a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/sub-view/deferred-sub-view.tsx @@ -0,0 +1,51 @@ +import { lazy, Suspense } from "react"; + +import type { SubView } from "./types"; + +type DeferredSubViewOptions = Omit< + SubView, + "component" | "renderHeaderAction" +> & { + load: () => Promise; + hasHeaderAction?: boolean; +}; + +/** + * Keeps an optional subview behind a bundle boundary while preserving the + * synchronous descriptor required by the panel layout. + */ +export const createDeferredSubView = ({ + load, + hasHeaderAction = false, + ...descriptor +}: DeferredSubViewOptions): SubView => { + const DeferredContent = lazy(async () => { + const subView = await load(); + return { default: subView.component }; + }); + const Content = () => ( + + + + ); + + if (!hasHeaderAction) { + return { ...descriptor, component: Content }; + } + + const DeferredHeaderAction = lazy(async () => { + const subView = await load(); + const HeaderAction = () => subView.renderHeaderAction?.() ?? null; + return { default: HeaderAction }; + }); + + return { + ...descriptor, + component: Content, + renderHeaderAction: () => ( + + + + ), + }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx index 594be9013ae..c3dccc20136 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -10,6 +10,7 @@ import { css, cva, cx } from "@hashintel/ds-helpers/css"; import { UserSettingsContext } from "../../../../react/state/user-settings-context"; import { useScrollOverflow } from "../../../hooks/use-scroll-overflow"; +import { usePetrinautPresentation } from "../../../views/shared/presentation-context"; import type { SubView } from "../types"; @@ -422,6 +423,7 @@ interface VerticalSubViewsContainerProps { export const VerticalSubViewsContainer: React.FC< VerticalSubViewsContainerProps > = ({ name, subViews, defaultExpanded = true }) => { + const presentation = usePetrinautPresentation(); const { showAnimations, subViewPanels, updateSubViewSection } = use(UserSettingsContext); @@ -500,7 +502,11 @@ export const VerticalSubViewsContainer: React.FC< renderTitle={subView.renderTitle} isExpanded={isExpanded} onToggle={() => toggleSection(subView)} - renderHeaderAction={subView.renderHeaderAction} + renderHeaderAction={ + presentation.showMutationActions + ? subView.renderHeaderAction + : undefined + } alwaysShowHeaderAction={subView.alwaysShowHeaderAction} /> diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index c628607d697..3234406ed38 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -20,6 +20,10 @@ import { PetrinautProvider } from "../react/petrinaut-provider"; import { Stack } from "./components/stack"; import { MonacoProvider } from "./monaco/provider"; import { EditorView } from "./views/Editor/editor-view"; +import { + PetrinautPresentationProvider, + type PetrinautPresentationProfile, +} from "./views/shared/presentation-context"; // `clip`, not `hidden`: a hidden-overflow box is still programmatically // scrollable, and focusing an element the canvas transform pushed past the @@ -112,6 +116,12 @@ export type PetrinautProps = { lspWorkerFactory?: LspWorkerFactory; /** Optional host-controlled, router-neutral app location. */ navigation?: PetrinautNavigationController; + /** + * Presentation policy for the full editor. `review` keeps the full editor + * surface while suppressing authoring actions for route-scoped read-only + * examples. The default remains `editor`. + */ + presentationProfile?: PetrinautPresentationProfile; }; const noop = () => {}; @@ -140,6 +150,7 @@ export const Petrinaut: FunctionComponent = ({ monteCarloWorkerFactory, lspWorkerFactory, navigation, + presentationProfile = "editor", }) => { const portalContainerRef = useRef(null); const instance = useMemo( @@ -167,19 +178,21 @@ export const Petrinaut: FunctionComponent = ({ lspWorkerFactory={lspWorkerFactory} navigation={navigation} > - - - - - + + + + + + + ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx index ff34e613d4c..f5b062b915c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/playback-settings-menu.tsx @@ -10,6 +10,7 @@ import { type PlaybackSpeed, } from "../../../../../react/playback/context"; import { SimulationContext } from "../../../../../react/simulation/context"; +import { usePetrinautPresentation } from "../../../shared/presentation-context"; import { ToolbarButton } from "./toolbar-button"; const contentWidthStyle = css({ @@ -139,15 +140,27 @@ const maxTimeInputStyle = css({ fontVariantNumeric: "tabular-nums", }); -// Split speeds into two rows of 4 -const speedRows: PlaybackSpeed[][] = [ - PLAYBACK_SPEEDS.slice(0, 4), - PLAYBACK_SPEEDS.slice(4), -]; +export type PlaybackSettingsMenuProps = { + allowedSpeeds?: readonly PlaybackSpeed[]; +}; + +const toSpeedRows = (speeds: readonly PlaybackSpeed[]): PlaybackSpeed[][] => { + const rows: PlaybackSpeed[][] = []; + for (let index = 0; index < speeds.length; index += 4) { + rows.push(speeds.slice(index, index + 4)); + } + return rows; +}; -export const PlaybackSettingsMenu = () => { +export const PlaybackSettingsMenu = ({ + allowedSpeeds = PLAYBACK_SPEEDS, +}: PlaybackSettingsMenuProps) => { + const presentation = usePetrinautPresentation(); const triggerRef = useRef(null); const [open, setOpen] = useState(false); + const playModesVisible = !presentation.compactControls; + const stoppingConditionsVisible = !presentation.compactControls; + const speedRows = toSpeedRows(allowedSpeeds); const { state: simulationState, @@ -200,200 +213,264 @@ export const PlaybackSettingsMenu = () => { onClose={() => setOpen(false)} > - + {[ + , + ...(playModesVisible + ? [ + +
+ When pressing play +
+ + + +
+ , + ] + : []), - {/* When pressing play section */} - -
When pressing play
- - + ))} +
+ ))} + {stoppingConditionsVisible && ( +
)} - - -
- + , - {/* Playback speed section */} - -
Playback speed
- {speedRows.map((row) => ( -
- {row.map((speed) => ( - - ))} -
- ))} -
- - - {/* Stopping conditions section */} - -
Stopping conditions
- - -
- + aria-disabled={hasSimulation} + tooltip={ + hasSimulation + ? "Reset simulation to change stopping conditions" + : undefined + } + > + + + Run indefinitely + + {stoppingCondition === "indefinitely" && ( + + )} + + +
+ , + ] + : []), + ]} )} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx index 6f7abf7556d..7c280ae0805 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx @@ -1,28 +1,38 @@ import { use } from "react"; import { Icon } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; +import { css, cva } from "@hashintel/ds-helpers/css"; import { PlaybackContext } from "../../../../../react/playback/context"; import { SimulationContext } from "../../../../../react/simulation/context"; import { EditorContext } from "../../../../../react/state/editor-context"; +import { usePetrinautPresentation } from "../../../shared/presentation-context"; import { CollapsibleGroup } from "./collapsible-group"; import { PlaybackSettingsMenu } from "./playback-settings-menu"; import { ToolbarButton } from "./toolbar-button"; import { ToolbarDivider } from "./toolbar-divider"; -const frameInfoStyle = css({ - display: "flex", - flexDirection: "column", - alignItems: "center", - fontSize: "[10px]", - color: "neutral.s105", - fontWeight: "medium", - lineHeight: "[1]", - width: "[90px]", - fontVariantNumeric: "tabular-nums", - overflow: "hidden", - whiteSpace: "nowrap", +import type { PlaybackSpeed } from "../../../../../react/playback/context"; + +const frameInfoStyle = cva({ + base: { + display: "flex", + flexDirection: "column", + alignItems: "center", + fontSize: "[10px]", + color: "neutral.s105", + fontWeight: "medium", + lineHeight: "[1]", + width: "[90px]", + fontVariantNumeric: "tabular-nums", + overflow: "hidden", + whiteSpace: "nowrap", + }, + variants: { + compact: { + true: { width: "[64px]" }, + }, + }, }); const elapsedTimeStyle = css({ @@ -38,45 +48,59 @@ const frameIndexStyle = css({ marginTop: "[1px]", }); -const sliderStyle = css({ - width: "[300px]", - height: "[4px]", - appearance: "none", - background: "neutral.s30", - borderRadius: "[2px]", - outline: "none", - cursor: "pointer", - "&:disabled": { - opacity: "[0.5]", - cursor: "not-allowed", - }, - "&::-webkit-slider-thumb": { +const sliderStyle = cva({ + base: { + width: "[300px]", + height: "[4px]", appearance: "none", - width: "[12px]", - height: "[12px]", - borderRadius: "[50%]", - background: "blue.s90", + background: "neutral.s30", + borderRadius: "[2px]", + outline: "none", cursor: "pointer", + "&:disabled": { + opacity: "[0.5]", + cursor: "not-allowed", + }, + "&::-webkit-slider-thumb": { + appearance: "none", + width: "[12px]", + height: "[12px]", + borderRadius: "[50%]", + background: "blue.s90", + cursor: "pointer", + }, + "&::-moz-range-thumb": { + width: "[12px]", + height: "[12px]", + borderRadius: "[50%]", + background: "blue.s90", + cursor: "pointer", + border: "none", + }, }, - "&::-moz-range-thumb": { - width: "[12px]", - height: "[12px]", - borderRadius: "[50%]", - background: "blue.s90", - cursor: "pointer", - border: "none", + variants: { + compact: { + true: { + width: "[clamp(96px, 30vw, 220px)]", + flex: "[1 1 160px]", + minWidth: "[96px]", + }, + }, }, }); -interface SimulationControlsProps { +export interface SimulationControlsProps { disabled?: boolean; inSubnet?: boolean; + allowedPlaybackSpeeds?: readonly PlaybackSpeed[]; } export const SimulationControls: React.FC = ({ disabled = false, inSubnet = false, + allowedPlaybackSpeeds, }) => { + const presentation = usePetrinautPresentation(); const { dt, state: simulationState, reset } = use(SimulationContext); const { @@ -207,8 +231,12 @@ export const SimulationControls: React.FC = ({ {hasSimulation && ( <> -
-
Frame
+
+ {!presentation.compactControls &&
Frame
}
{frameIndex + 1} / {totalFrames}
@@ -224,14 +252,14 @@ export const SimulationControls: React.FC = ({ onChange={(event) => setCurrentViewedFrame(Number(event.target.value)) } - className={sliderStyle} + className={sliderStyle({ compact: presentation.compactControls })} /> )} - + ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx index de212622d96..89907f693c3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/nets-list.tsx @@ -11,6 +11,7 @@ import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { focusLands } from "../../../../../worksheet/focus-flow"; import { useFocusStops } from "../../../../../worksheet/use-focus-stops"; +import { usePetrinautPresentation } from "../../../../shared/presentation-context"; import { RowActionCell } from "./row-action-cell"; import type { SubView } from "../../../../../components/sub-view/types"; @@ -102,12 +103,17 @@ const renameInputStyle = css({ }); const NetsHeaderAction: React.FC = () => { + const presentation = usePetrinautPresentation(); const { petriNetDefinition: { subnets }, } = use(SDCPNContext); const { addSubnet } = usePetrinautMutations(); const isReadOnly = useIsReadOnly(); + if (!presentation.showMutationActions) { + return null; + } + return (
))}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderer.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderer.ts index 40905368c1a..1ef0ef14fdb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderer.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/canvas-renderer.ts @@ -25,6 +25,8 @@ export type CanvasController = { ) => void; zoomIn: () => void; zoomOut: () => void; + /** Frames the whole scene, easing the move when the renderer supports it. */ + fitView: () => void; /** Client (viewport-relative screen) coordinates to scene coordinates. */ screenToScene: (point: CanvasPoint) => CanvasPoint; sceneToScreen: (point: CanvasPoint) => CanvasPoint; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx index d51aac5a2c3..228ca2b09b1 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx @@ -7,6 +7,7 @@ import { usePetrinautNavigation } from "../../../../react/navigation"; import { EditorContext } from "../../../../react/state/editor-context"; import { VIEWPORT_CONTROLS_OFFSET } from "../../../constants/ui"; import { useCanvasInsets } from "../../../hooks/use-canvas-insets"; +import { usePetrinautPresentation } from "../../shared/presentation-context"; import { useCanvasController } from "../canvas-renderer"; import { ViewportSettingsDialog } from "./viewport-settings-dialog"; @@ -36,6 +37,7 @@ const blurredBackground = css({ backdropFilter: "[blur(10px)]" }); export const ViewportControls: React.FC<{ viewportActions?: ViewportAction[]; }> = ({ viewportActions }) => { + const presentation = usePetrinautPresentation(); const navigation = usePetrinautNavigation(); const isSettingsOpen = navigation.state.overlay?.type === "viewport-settings"; const setIsSettingsOpen = (open: boolean) => { @@ -44,7 +46,10 @@ export const ViewportControls: React.FC<{ { cause: "user", action: "overlay" }, ); }; - const { zoomIn, zoomOut } = useCanvasController(); + // Fit view goes through the canvas controller like the zooms: these + // controls sit above the renderer contract and must not reach for a + // renderer's own API. + const { fitView, zoomIn, zoomOut } = useCanvasController(); const { collapseAllPanels, isPanelAnimating } = use(EditorContext); // Shared with the bottom toolbar, so the two keep clear of the same panels @@ -84,39 +89,53 @@ export const ViewportControls: React.FC<{