diff --git a/.changeset/preview-navigation-vocabularies.md b/.changeset/preview-navigation-vocabularies.md new file mode 100644 index 00000000000..d3d1d1e9349 --- /dev/null +++ b/.changeset/preview-navigation-vocabularies.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Export the `EditorGlobalMode` and `SimulateViewMode` types, so a host encoding Petrinaut's navigation state into its own router can spell both vocabularies and fail its build when either gains a member. diff --git a/apps/petrinaut-website/src/examples/example-search.ts b/apps/petrinaut-website/src/examples/example-search.ts index fedbd8071a6..730a513f729 100644 --- a/apps/petrinaut-website/src/examples/example-search.ts +++ b/apps/petrinaut-website/src/examples/example-search.ts @@ -13,15 +13,53 @@ import { type SelectionItemType, } from "@hashintel/petrinaut-core/selection"; +/** + * The editor's mode, its Simulate section, and the overlay it has open, spelled + * for a URL. + * + * Declared here rather than imported so this module stays free of the editor: + * the oEmbed server function speaks the same contract and must not bundle it. + * `navigation-search.ts` maps each of these onto the editor's own vocabulary + * with an exhaustive switch, so a rename on either side fails to compile. + */ +export const sharedModes = ["edit", "simulate", "actual", "notebook"] as const; + +export const sharedSimulateViews = [ + "scenarios", + "metrics", + "experiments", + "optimizations", +] as const; + +export const sharedOverlays = [ + "viewport-settings", + "create-scenario", + "create-metric", + "create-experiment", + "create-optimization", +] as const; + +export type SharedMode = (typeof sharedModes)[number]; +export type SharedSimulateView = (typeof sharedSimulateViews)[number]; +export type SharedOverlay = (typeof sharedOverlays)[number]; + /** * Search params understood by every example surface. A URL carries at most one * focused item: multi-selection is in-app state, not a shareable location. + * + * A field the URL leaves out means "whatever this page starts from", which for + * every page but `/brunch` is the editor's own default. That is what lets Back + * undo a mode change or close an overlay: the entry it returns to simply does + * not name the field. */ export type SharedExampleSearch = { scenario?: string; subnet?: string; itemType?: SelectionItemType; itemId?: string; + mode?: SharedMode; + view?: SharedSimulateView; + overlay?: SharedOverlay; }; /** The keys this contract owns. Anything else in a URL is foreign. */ @@ -30,6 +68,9 @@ const sharedSearchKeys = [ "subnet", "itemType", "itemId", + "mode", + "view", + "overlay", ] as const satisfies readonly (keyof SharedExampleSearch)[]; // `.catch(undefined)` is the contract's whole validation story: anything a URL @@ -41,6 +82,13 @@ const optionalSelectionItemType = z .optional() .catch(undefined); +const optionalMode = z.enum(sharedModes).optional().catch(undefined); +const optionalSimulateView = z + .enum(sharedSimulateViews) + .optional() + .catch(undefined); +const optionalOverlay = z.enum(sharedOverlays).optional().catch(undefined); + /** The focused item, when the URL names a complete one. */ export const selectionFromInput = ( input: Record, @@ -68,6 +116,9 @@ export const validateSharedExampleSearch = ( ): 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), ...selectionToSearch(selectionFromInput(input)), }); diff --git a/apps/petrinaut-website/src/examples/navigation-search.ts b/apps/petrinaut-website/src/examples/navigation-search.ts index 94698850184..7e836f0c001 100644 --- a/apps/petrinaut-website/src/examples/navigation-search.ts +++ b/apps/petrinaut-website/src/examples/navigation-search.ts @@ -1,8 +1,16 @@ /** - * Projects the example URL contract onto Petrinaut's navigation state. The - * editor navigates more than the URL carries (mode, Simulate section, - * overlays), so those fields take editor defaults here and live in page state - * instead — see `useSharedSearchNavigation`. + * 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. + * + * 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 + * what makes Back undo a mode change or close an overlay: the entry Back + * returns to simply omits the field. The baseline is the editor's own default + * everywhere except `/brunch`, which starts in Actual mode. */ import { defaultPetrinautNavigationState } from "@hashintel/petrinaut/react"; @@ -10,9 +18,17 @@ import { selectionFromInput, selectionToSearch, type SharedExampleSearch, + type SharedMode, + type SharedOverlay, + type SharedSimulateView, } from "./example-search"; -import type { PetrinautNavigationState } from "@hashintel/petrinaut/react"; +import type { + EditorGlobalMode, + PetrinautNavigationOverlay, + PetrinautNavigationState, + SimulateViewMode, +} from "@hashintel/petrinaut/react"; /** `none` is an explicit no-scenario choice; absence means "first available". */ const scenarioFromSearch = ( @@ -28,19 +44,58 @@ const scenarioToSearch = ( scenarioId: string | null | undefined, ): string | undefined => (scenarioId === null ? "none" : scenarioId); +/** + * The editor's vocabularies, narrowed to the contract's. These are assignments + * rather than casts, so adding a mode, a Simulate section or an overlay to the + * editor fails this file's type check until the contract decides whether the + * URL should carry it. + */ +const modeToSearch = (mode: EditorGlobalMode): SharedMode => mode; + +const simulateViewToSearch = (view: SimulateViewMode): SharedSimulateView => + view; + +const overlayToSearch = ( + overlay: PetrinautNavigationOverlay, +): SharedOverlay | undefined => overlay?.type; + +const overlayFromSearch = ( + overlay: SharedOverlay, +): PetrinautNavigationOverlay => ({ type: overlay }); + export const sharedSearchToNavigationState = ( search: SharedExampleSearch, + baseline: PetrinautNavigationState = defaultPetrinautNavigationState, ): PetrinautNavigationState => ({ - ...defaultPetrinautNavigationState, + ...baseline, scenarioId: scenarioFromSearch(search), subnetId: search.subnet ?? null, selection: selectionFromInput(search as Record), + mode: search.mode ?? baseline.mode, + simulateView: search.view ?? baseline.simulateView, + overlay: + search.overlay === undefined + ? baseline.overlay + : overlayFromSearch(search.overlay), }); export const navigationStateToSharedSearch = ( state: Readonly, -): SharedExampleSearch => ({ - scenario: scenarioToSearch(state.scenarioId), - subnet: state.subnetId ?? undefined, - ...selectionToSearch(state.selection), -}); + baseline: PetrinautNavigationState = defaultPetrinautNavigationState, +): SharedExampleSearch => { + const mode = modeToSearch(state.mode); + const view = simulateViewToSearch(state.simulateView); + const overlay = overlayToSearch(state.overlay); + return { + scenario: scenarioToSearch(state.scenarioId), + subnet: state.subnetId ?? undefined, + // Omitted at the baseline, so an untouched page keeps a clean URL and the + // decode above puts the baseline back. + mode: mode === modeToSearch(baseline.mode) ? undefined : mode, + view: + view === simulateViewToSearch(baseline.simulateView) ? undefined : view, + overlay: + overlay === overlayToSearch(baseline.overlay) ? undefined : overlay, + ...selectionToSearch(state.selection), + }; +}; diff --git a/apps/petrinaut-website/src/examples/oembed-endpoint.test.ts b/apps/petrinaut-website/src/examples/oembed-endpoint.test.ts index dd8285d8a93..565d17c5a1f 100644 --- a/apps/petrinaut-website/src/examples/oembed-endpoint.test.ts +++ b/apps/petrinaut-website/src/examples/oembed-endpoint.test.ts @@ -62,6 +62,8 @@ describe("Petrinaut oEmbed endpoint", () => { it("preserves only valid embed state from the source URL", async () => { const source = new URL("https://demo.petrinaut.org/examples/gases-2-spn"); source.searchParams.set("mode", "simulate"); + // Neither of these is a contract key, so both drop: the Simulate section + // is carried as `view`, and `section` is somebody else's spelling. source.searchParams.set("section", "metrics"); source.searchParams.set("scenario", "scenario-1"); source.searchParams.set("subnet", "subnet-1"); @@ -76,7 +78,7 @@ describe("Petrinaut oEmbed endpoint", () => { expect(response.status).toBe(200); expect(body.html).toBe( - '', + '', ); }); 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 a571b5d7caf..36812ea7ae7 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 @@ -6,13 +6,20 @@ import { describe, expect, it, vi } from "vitest"; import { useSharedSearchNavigation } from "./use-shared-search-navigation"; import type { SharedExampleSearch } from "./example-search"; -import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; +import type { + PetrinautNavigationController, + PetrinautNavigationState, +} from "@hashintel/petrinaut/react"; const Probe = ({ + initialState, onController, onSearchChange, search, }: { + initialState?: Partial< + Omit + >; onController: (controller: PetrinautNavigationController) => void; onSearchChange: ( search: SharedExampleSearch, @@ -20,7 +27,9 @@ const Probe = ({ ) => void; search: SharedExampleSearch; }) => { - onController(useSharedSearchNavigation(search, onSearchChange)); + onController( + useSharedSearchNavigation(search, onSearchChange, { initialState }), + ); return null; }; @@ -38,15 +47,24 @@ describe("useSharedSearchNavigation", () => { />, ); - // A mode change is not part of the URL contract: it applies in memory - // and produces no URL write. + // 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, mode: "simulate" }), { - history: "push", - intent: { cause: "user", action: "mode" }, - }); + controller.onNavigate( + (current) => ({ + ...current, + simulateResource: { type: "experiment", id: "experiment-1" }, + }), + { + history: "push", + intent: { cause: "user", action: "simulation-resource" }, + }, + ); + }); + expect(controller.state.simulateResource).toEqual({ + type: "experiment", + id: "experiment-1", }); - expect(controller.state.mode).toBe("simulate"); expect(onSearchChange).not.toHaveBeenCalled(); // A subnet change is shared: it applies in memory AND writes the URL. @@ -57,7 +75,10 @@ describe("useSharedSearchNavigation", () => { ); }); expect(controller.state.subnetId).toBe("subnet-1"); - expect(controller.state.mode).toBe("simulate"); + expect(controller.state.simulateResource).toEqual({ + type: "experiment", + id: "experiment-1", + }); expect(onSearchChange).toHaveBeenCalledOnce(); expect(onSearchChange).toHaveBeenCalledWith( { scenario: "scenario-1", subnet: "subnet-1" }, @@ -79,14 +100,20 @@ describe("useSharedSearchNavigation", () => { ); act(() => { - controller.onNavigate((current) => ({ ...current, mode: "simulate" }), { - history: "push", - intent: { cause: "user", action: "mode" }, - }); + controller.onNavigate( + (current) => ({ + ...current, + simulateResource: { type: "experiment", id: "experiment-1" }, + }), + { + history: "push", + intent: { cause: "user", action: "simulation-resource" }, + }, + ); }); // Back/Forward delivers a different shared search: URL-owned fields - // update, the in-memory mode survives. + // update, and the one field the URL cannot carry survives. view.rerender( { @@ -97,6 +124,168 @@ describe("useSharedSearchNavigation", () => { />, ); expect(controller.state.scenarioId).toBe("scenario-2"); - expect(controller.state.mode).toBe("simulate"); + expect(controller.state.simulateResource).toEqual({ + type: "experiment", + id: "experiment-1", + }); + }); + + it("returns a URL-owned field to the baseline when Back drops it", () => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + const view = render( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{}} + />, + ); + + // Opening the create-experiment overlay in Simulate is a location, so it + // reaches the URL and therefore the history stack. + act(() => { + controller.onNavigate( + (current) => ({ + ...current, + mode: "simulate", + overlay: { type: "create-experiment" }, + }), + { history: "push", intent: { cause: "user", action: "overlay" } }, + ); + }); + expect(onSearchChange).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "simulate", + overlay: "create-experiment", + }), + "push", + ); + + // The router delivers the hook's own write back first; that echo is + // suppressed, since the in-memory location already holds it. + view.rerender( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{ mode: "simulate", overlay: "create-experiment" }} + />, + ); + expect(controller.state.overlay).toEqual({ type: "create-experiment" }); + + // Back then returns to an entry that names neither, which is what closes + // the overlay and restores the mode rather than leaving them applied. + view.rerender( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{}} + />, + ); + expect(controller.state.overlay).toBeNull(); + expect(controller.state.mode).toBe("edit"); + }); + + it("keeps a multi-item selection when the router echoes its own URL write", () => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + const view = render( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{ itemType: "place", itemId: "place-1" }} + />, + ); + + // A second selected item cannot be spelled in the URL, so the write drops + // the item entirely. The echo of that write must not be mistaken for an + // external change, or it merges the lossy projection back over the + // in-memory selection and clears it. + act(() => { + controller.onNavigate( + (current) => ({ + ...current, + selection: [ + { type: "place", id: "place-1" }, + { type: "place", id: "place-2" }, + ], + }), + { history: "push", intent: { cause: "user", action: "selection" } }, + ); + }); + + expect(onSearchChange).toHaveBeenCalledWith({}, "push"); + expect(controller.state.selection).toHaveLength(2); + + view.rerender( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{}} + />, + ); + + expect(controller.state.selection).toHaveLength(2); + }); + + it("seeds the fields the URL does not carry, alongside the ones it does", () => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + render( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{ subnet: "subnet-from-url" }} + />, + ); + + // A controlled host replaces the provider's initial state, so a page that + // opens in a non-default mode has to seed it here. The URL-owned fields + // still come from the link the visitor opened; the option's type excludes + // them rather than accepting a value it would discard. + expect(controller.state.mode).toBe("actual"); + expect(controller.state.subnetId).toBe("subnet-from-url"); + expect(onSearchChange).not.toHaveBeenCalled(); + }); + + it("keeps a seeded field across an external URL change", () => { + let controller!: PetrinautNavigationController; + const onSearchChange = vi.fn(); + const view = render( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{}} + />, + ); + + view.rerender( + { + controller = value; + }} + onSearchChange={onSearchChange} + search={{ scenario: "scenario-1" }} + />, + ); + + expect(controller.state.scenarioId).toBe("scenario-1"); + expect(controller.state.mode).toBe("actual"); }); }); 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 18d7dce3959..f3e0b7de177 100644 --- a/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts +++ b/apps/petrinaut-website/src/examples/use-shared-search-navigation.ts @@ -1,5 +1,7 @@ import { useLayoutEffect, useRef, useState } from "react"; +import { defaultPetrinautNavigationState } from "@hashintel/petrinaut/react"; + import { sharedSearchesMatch, type SharedExampleSearch, @@ -18,26 +20,58 @@ import type { /** * Overwrites the URL-owned fields of the in-memory location with the current * shared search, keeping the fields the URL cannot represent. + * + * A field the search omits resolves to the page's baseline, so Back onto an + * entry that does not name it returns to where the page started. */ const mergeSharedSearch = ( current: PetrinautNavigationState, search: SharedExampleSearch, + baseline: PetrinautNavigationState, ): PetrinautNavigationState => { - const shared = sharedSearchToNavigationState(search); + const shared = sharedSearchToNavigationState(search, baseline); return { ...current, scenarioId: shared.scenarioId, subnetId: shared.subnetId, selection: shared.selection, + mode: shared.mode, + simulateView: shared.simulateView, + overlay: shared.overlay, }; }; /** - * Navigation controller for pages whose URL carries the shared - * scenario/subnet/selection subset. The editor navigates more than that - * (global mode, overlays), so the full location lives in page state and only - * its shared projection is mirrored to the URL — otherwise every control - * driving a non-shared field would silently snap back. + * The URL-owned fields, cleared, for a host whose document is being replaced. + * + * Writing an empty search is not enough on its own: the shared projection is + * lossy, so a location the URL already renders as empty — a multi-item + * selection, for one — leaves the `search` prop unchanged, the merge below + * never runs, and the in-memory selection survives into the next document. + * Passing this to `onNavigate` clears the location itself and lets the write + * to the URL fall out of the usual path. + */ +export const withClearedSharedLocation = ( + current: PetrinautNavigationState, +): PetrinautNavigationState => ({ + ...current, + scenarioId: undefined, + subnetId: null, + selection: [], +}); + +/** + * 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. + * + * `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 + * replaces `PetrinautNavigationProvider`'s own initial state, including the + * Actual-mode default it applies when a live stream is available, so a page + * that opens in a non-default mode states that mode here. */ export const useSharedSearchNavigation = ( search: SharedExampleSearch, @@ -45,19 +79,45 @@ export const useSharedSearchNavigation = ( search: SharedExampleSearch, history: "push" | "replace", ) => void, - options?: { historyPolicy?: PetrinautNavigationHistoryPolicy }, + options?: { + historyPolicy?: PetrinautNavigationHistoryPolicy; + initialState?: Partial; + }, ): PetrinautNavigationController => { + // Snapshotted once: the caller passes a fresh object literal every render, + // and this is the value every absent URL field resolves to for the life of + // the page. + const [baseline] = useState(() => ({ + ...defaultPetrinautNavigationState, + ...options?.initialState, + })); + const [navigationState, setNavigationState] = useState(() => - sharedSearchToNavigationState(search), + // The URL wins over the baseline for the fields it names, so a shared + // link still resolves to the location it carries. + mergeSharedSearch(baseline, search, baseline), ); // Merge external URL changes (Back/Forward, a normalization redirect) - // into the in-memory location during render. + // into the in-memory location during render. The hook's own write is + // suppressed once, when the router delivers it back: the in-memory location + // already holds it, and the shared projection cannot represent all of it. const [previousSearch, setPreviousSearch] = useState(search); + const [writtenSearch, setWrittenSearch] = + useState(null); if (!sharedSearchesMatch(search, previousSearch)) { + const isOwnWrite = + writtenSearch !== null && sharedSearchesMatch(search, writtenSearch); setPreviousSearch(search); - setNavigationState((current) => mergeSharedSearch(current, search)); + // Cleared either way, so a later Back or Forward onto the same location + // still merges rather than being mistaken for the same echo again. + setWrittenSearch(null); + if (!isOwnWrite) { + setNavigationState((current) => + mergeSharedSearch(current, search, baseline), + ); + } } // Freshest committed location for callbacks that can fire several times @@ -85,9 +145,14 @@ export const useSharedSearchNavigation = ( navigationStateRef.current = next; setNavigationState(next); - const nextSearch = navigationStateToSharedSearch(next); + const nextSearch = navigationStateToSharedSearch(next, baseline); if (!sharedSearchesMatch(nextSearch, latestSearchRef.current)) { latestSearchRef.current = nextSearch; + // The router delivers this write back as a new `search` prop, and the + // merge above must not treat that echo as an external change: the + // projection is lossy, so re-merging it would clear a selection of + // any size but one. + setWrittenSearch(nextSearch); onSearchChange(nextSearch, history); } }, diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx index e18b4a5f393..4dd0ee6542a 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-route.tsx @@ -4,12 +4,15 @@ import { BrunchPetrinaut } from "./brunch-petrinaut"; import { BrunchStatusPage } from "./brunch-status-page"; import type { BrunchRouteSearch } from "./brunch-search"; +import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; import type { ViewportAction } from "@hashintel/petrinaut/ui"; export const BrunchActualModeRoute = ({ + navigation, search, viewportActions, }: { + navigation: PetrinautNavigationController; search: BrunchRouteSearch; viewportActions: ViewportAction[]; }) => { @@ -33,7 +36,10 @@ export const BrunchActualModeRoute = ({ key={`${endpointResult.endpoint}:${endpointResult.runId ?? ""}`} runId={endpointResult.runId} > - + ); }; diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.test.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.test.tsx new file mode 100644 index 00000000000..fe2ae065b41 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom + +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; + +const controllers: PetrinautNavigationController[] = []; + +vi.mock("../sentry-feedback-button", () => ({ + useSentryFeedbackAction: () => ({ + key: "sentry-feedback", + icon: null, + label: "Feedback", + tooltip: "Feedback", + }), +})); + +vi.mock("./brunch-actual-mode-route", () => ({ + BrunchActualModeRoute: ({ + navigation, + }: { + navigation: PetrinautNavigationController; + }) => { + controllers.push(navigation); + return null; + }, +})); + +const { BrunchDemoApp } = await import("./brunch-demo-app"); + +describe("BrunchDemoApp", () => { + it("opens the controlled location in Actual mode", () => { + // A controlled host replaces the navigation provider's initial state, + // including the Actual-mode default it applies when a stream is + // available. Opening in Edit mode would point the execution frame at the + // local simulation instead of the Brunch stream. + render( + {}} + search={{ sse: "https://brunch.example/events" }} + />, + ); + + expect(controllers.at(-1)!.state.mode).toBe("actual"); + }); + + it("still resolves a shared location from the URL", () => { + render( + {}} + search={{ + sse: "https://brunch.example/events", + scenario: "scenario-1", + subnet: "subnet-1", + }} + />, + ); + + const controller = controllers.at(-1)!; + expect(controller.state.mode).toBe("actual"); + expect(controller.state.scenarioId).toBe("scenario-1"); + expect(controller.state.subnetId).toBe("subnet-1"); + }); +}); diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx index e813850a1d8..821d91208d1 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-demo-app.tsx @@ -3,16 +3,35 @@ * @role Brunch Actual Mode demo: streams a live net from a Brunch endpoint */ +import { useSharedSearchNavigation } from "../../../examples/use-shared-search-navigation"; import { useSentryFeedbackAction } from "../sentry-feedback-button"; import { BrunchActualModeRoute } from "./brunch-actual-mode-route"; +import type { SharedExampleSearch } from "../../../examples/example-search"; import type { BrunchRouteSearch } from "./brunch-search"; -export const BrunchDemoApp = ({ search }: { search: BrunchRouteSearch }) => { +export const BrunchDemoApp = ({ + onSearchChange, + search, +}: { + onSearchChange: ( + search: SharedExampleSearch, + history: "push" | "replace", + ) => void; + search: BrunchRouteSearch; +}) => { const sentryFeedbackAction = useSentryFeedbackAction(); + // Petrinaut only mounts below once the Brunch stream is available, and the + // stream is the whole point of this route, so the location starts in Actual + // mode. Without this the controlled state would open in Edit mode and the + // execution frame would read the local simulation instead of the stream. + const navigation = useSharedSearchNavigation(search, onSearchChange, { + initialState: { mode: "actual" }, + }); return ( diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx index 57c45fda007..06e61a3a8c5 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-petrinaut.tsx @@ -4,7 +4,10 @@ import { createJsonDocHandle, PETRINAUT_EXTENSION_NAMES, } from "@hashintel/petrinaut-core"; -import { ActualModeContext } from "@hashintel/petrinaut/react"; +import { + ActualModeContext, + type PetrinautNavigationController, +} from "@hashintel/petrinaut/react"; import { Petrinaut, type ViewportAction } from "@hashintel/petrinaut/ui"; import { BrunchStatusPage } from "./brunch-status-page"; @@ -20,11 +23,13 @@ const getSourceKey = (source: ActualModeSource): string => const BrunchPetrinautWithHandle = ({ definition, + navigation, source, title, viewportActions, }: { definition: SDCPN; + navigation: PetrinautNavigationController; source: ActualModeSource; title: string; viewportActions: ViewportAction[]; @@ -46,6 +51,7 @@ const BrunchPetrinautWithHandle = ({ {}} title={title} @@ -56,8 +62,10 @@ const BrunchPetrinautWithHandle = ({ }; export const BrunchPetrinaut = ({ + navigation, viewportActions, }: { + navigation: PetrinautNavigationController; viewportActions: ViewportAction[]; }) => { const actualMode = use(ActualModeContext); @@ -102,6 +110,7 @@ export const BrunchPetrinaut = ({ { +describe("validateBrunchSearch", () => { it("keeps string parameters", () => { expect( - brunchSearchSchema.parse({ + validateBrunchSearch({ runId: "run-1", sse: "https://brunch.example/events", }), @@ -19,7 +19,7 @@ describe("brunchSearchSchema", () => { // `?runId=1e3` reaches the schema as the number 1000, not the original // text, so coercing it back to a string would keep a corrupted id. expect( - brunchSearchSchema.parse({ + validateBrunchSearch({ runId: 1000, sse: true, }), @@ -31,7 +31,7 @@ describe("brunchSearchSchema", () => { it("falls back for malformed structured parameters", () => { expect( - brunchSearchSchema.parse({ + validateBrunchSearch({ runId: ["run-1"], sse: { href: "https://brunch.example/events" }, }), @@ -40,4 +40,64 @@ describe("brunchSearchSchema", () => { sse: undefined, }); }); + + it("keeps the shared example location next to the stream keys", () => { + expect( + validateBrunchSearch({ + sse: "https://brunch.example/events", + scenario: "scenario_baseline", + subnet: "subnet_dispatch", + }), + ).toEqual({ + sse: "https://brunch.example/events", + scenario: "scenario_baseline", + subnet: "subnet_dispatch", + }); + }); +}); + +describe("withBrunchStreamKeys", () => { + it("carries the stream keys over a navigation", () => { + // Dropping them would leave getBrunchEndpoint without an endpoint, and + // the live editor would be replaced by the status page mid-run. + expect( + withBrunchStreamKeys( + { runId: "run-1", sse: "https://brunch.example/events" }, + { subnet: "subnet-1" }, + ), + ).toEqual({ + runId: "run-1", + sse: "https://brunch.example/events", + subnet: "subnet-1", + }); + }); + + it("replaces the contract part rather than merging into it", () => { + // The hook always produces a complete contract search, so a key it omits + // is a key the new location does not have. + expect( + withBrunchStreamKeys( + { + runId: "run-1", + sse: "https://brunch.example/events", + scenario: "scenario-1", + itemType: "place", + itemId: "place-1", + }, + { subnet: "subnet-1" }, + ), + ).toEqual({ + runId: "run-1", + sse: "https://brunch.example/events", + subnet: "subnet-1", + }); + }); + + it("adds no stream keys when the current search has none", () => { + expect(withBrunchStreamKeys({}, { scenario: "scenario-1" })).toEqual({ + runId: undefined, + sse: undefined, + scenario: "scenario-1", + }); + }); }); diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts index 3bf22999e8e..283395871b5 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-search.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +import { + validateSharedExampleSearch, + type SharedExampleSearch, +} from "../../../examples/example-search"; + /** * The router's search parser JSON-decodes values before validation, so a * numeric-looking parameter arrives pre-mangled (`?runId=1e3` becomes 1000, @@ -8,9 +13,36 @@ import { z } from "zod"; */ const optionalSearchStringSchema = z.string().optional().catch(undefined); -export const brunchSearchSchema = z.object({ +const brunchStreamSearchSchema = z.object({ runId: optionalSearchStringSchema, sse: optionalSearchStringSchema, }); -export type BrunchRouteSearch = z.infer; +export type BrunchRouteSearch = z.infer & + SharedExampleSearch; + +/** + * A Brunch URL names the stream (`sse`, `runId`) and speaks the shared example + * contract for the location inside the net. + */ +export const validateBrunchSearch = ( + input: Record, +): BrunchRouteSearch => ({ + ...brunchStreamSearchSchema.parse(input), + ...validateSharedExampleSearch(input), +}); + +/** + * Replaces the contract part of a Brunch search and carries the stream keys + * over. Every other route writes a contract-only search; here the stream keys + * name the run, and dropping them would swap the live editor for the + * missing-endpoint status page mid-run. + */ +export const withBrunchStreamKeys = ( + current: BrunchRouteSearch, + next: SharedExampleSearch, +): BrunchRouteSearch => ({ + runId: current.runId, + sse: current.sse, + ...next, +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx index 6fe948c18a2..02e1bfe1b7f 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx @@ -1,16 +1,34 @@ /** * @vitest-environment jsdom */ +import { act, cleanup, render } from "@testing-library/react"; import { isValidElement, type ReactNode } from "react"; -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { defaultPetrinautNavigationHistoryPolicy } from "@hashintel/petrinaut/react"; import { VoiceInterviewControl } from "../voice-interview/voice-interview-control"; -import { getBrunchVoiceMode } from "./local-storage-demo-app"; +import { + getBrunchVoiceMode, + LocalStorageDemoApp, +} from "./local-storage-demo-app"; + +import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; const defaultTransportOptions = vi.hoisted(() => ({ current: null as unknown, })); +const editorProps = vi.hoisted(() => ({ + current: null as { + navigation?: unknown; + createNewNet?: (params: { + petriNetDefinition: unknown; + title: string; + }) => void; + } | null, +})); + vi.mock("./brunch-principal", () => ({ getOrCreateBrunchPrincipal: () => "test-principal", })); @@ -21,7 +39,10 @@ vi.mock("@hashintel/petrinaut/ui", () => ({ defaultTransportOptions.current = options; } }, - Petrinaut: () => null, + Petrinaut: (props: Record) => { + editorProps.current = props; + return null; + }, WalkthroughProvider: ({ children }: { children: ReactNode }) => children, definePetrinautAiInteractiveTool: (definition: unknown) => definition, })); @@ -76,3 +97,190 @@ describe("local storage demo Brunch voice integration", () => { ); }); }); + +/** + * Node supplies its own `localStorage` global that shadows the jsdom one and + * carries no `setItem`, so the demo's storage hooks cannot read a seed from + * it. An in-memory store gives them one. + */ +const stubStorage = () => { + const entries = new Map(); + vi.stubGlobal("localStorage", { + get length() { + return entries.size; + }, + clear: () => entries.clear(), + getItem: (key: string) => entries.get(key) ?? null, + key: (index: number) => [...entries.keys()][index] ?? null, + removeItem: (key: string) => entries.delete(key), + setItem: (key: string, value: string) => entries.set(key, value), + } satisfies Storage); +}; + +const seedStoredNet = () => { + stubStorage(); + localStorage.setItem( + "petrinaut-sdcpn", + JSON.stringify({ + "net-1": { + id: "net-1", + title: "Seeded net", + lastUpdated: "2020-01-01T00:00:00.000Z", + sdcpn: { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + }, + }, + }), + ); +}; + +/** + * `navigation` is an optional prop, so dropping it from the editor compiles + * and leaves every other check green while the demo silently stops mirroring + * its location to the URL. These render the real component to pin the wiring. + */ +describe("local storage demo URL navigation", () => { + // Without this, a tree left mounted by an earlier case re-renders after the + // next one and overwrites the captured props with its own controller. + afterEach(() => { + cleanup(); + editorProps.current = null; + }); + + const mountedNavigation = (): PetrinautNavigationController => { + const navigation = editorProps.current?.navigation; + expect(navigation).toBeDefined(); + return navigation as PetrinautNavigationController; + }; + + test("resolves a URL-borne location into the controller it hands the editor", () => { + seedStoredNet(); + + render( + {}} + search={{ subnet: "subnet-1", itemType: "place", itemId: "place-1" }} + />, + ); + + const navigation = mountedNavigation(); + expect(navigation.state.subnetId).toBe("subnet-1"); + expect(navigation.state.selection).toEqual([ + { type: "place", id: "place-1" }, + ]); + }); + + test("writes an editor navigation back to the URL", () => { + seedStoredNet(); + const onSearchChange = vi.fn(); + + render(); + + mountedNavigation().onNavigate( + (current) => ({ ...current, subnetId: "subnet-2" }), + { history: "push", intent: { cause: "user", action: "subnet" } }, + ); + + expect(onSearchChange).toHaveBeenCalledWith({ subnet: "subnet-2" }, "push"); + }); + + test("leaves history to the library default, so a discrete click pushes", () => { + seedStoredNet(); + + render( {}} search={{}} />); + + // Constraining this page's policy once made selections replace, which left + // the page with no history entries at all and sent the first Back press + // off the site. The default keeps drag churn to one entry by replacing + // continuing intents, so it needs no host override. + expect(mountedNavigation().historyPolicy).toBeUndefined(); + expect( + defaultPetrinautNavigationHistoryPolicy({ + cause: "user", + action: "selection", + phase: "discrete", + }), + ).toBe("push"); + expect( + defaultPetrinautNavigationHistoryPolicy({ + cause: "user", + action: "selection", + phase: "continue", + }), + ).toBe("replace"); + }); + + test("clears the shared location when a new net replaces the open one", () => { + seedStoredNet(); + const onSearchChange = vi.fn(); + + render( + , + ); + + // A location names a place inside the net that was open, so carrying it + // into the next net would select something that is not there. Petrinaut's + // own per-document reset does not cover a controlled location. + act(() => { + editorProps.current?.createNewNet?.({ + petriNetDefinition: { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + }, + title: "Another net", + }); + }); + + expect(onSearchChange).toHaveBeenCalledWith({}, "replace"); + }); + + test("clears a multi-item selection the URL never carried", () => { + seedStoredNet(); + const onSearchChange = vi.fn(); + + render(); + + // A selection of more than one item projects to an empty search, so the + // URL is already empty and writing `{}` to it changes no prop. Clearing + // only through the URL therefore left this selection in place and carried + // ids from the old net into the next one. + act(() => { + mountedNavigation().onNavigate( + (current) => ({ + ...current, + selection: [ + { type: "place", id: "place-1" }, + { type: "place", id: "place-2" }, + ], + }), + { history: "push", intent: { cause: "user", action: "selection" } }, + ); + }); + expect(mountedNavigation().state.selection).toHaveLength(2); + + act(() => { + editorProps.current?.createNewNet?.({ + petriNetDefinition: { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + }, + title: "Another net", + }); + }); + + expect(mountedNavigation().state.selection).toEqual([]); + }); +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx index d558d90c757..9b40e97ea1c 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -27,6 +27,10 @@ import { WalkthroughProvider, } from "@hashintel/petrinaut/ui"; +import { + useSharedSearchNavigation, + withClearedSharedLocation, +} from "../../../examples/use-shared-search-navigation"; import { VOICE_REQUEST_ID_HEADER } from "../../../voice-diagnostics"; import { CommandPalette } from "../command-palette"; import { useSentryFeedbackAction } from "../sentry-feedback-button"; @@ -48,6 +52,8 @@ import { } from "./use-local-storage-sdcpns"; import { walkthroughSteps } from "./walkthrough/walkthrough-steps"; +import type { SharedExampleSearch } from "../../../examples/example-search"; + const isEmptySDCPN = (sdcpn: SDCPN) => sdcpn.places.length === 0 && sdcpn.transitions.length === 0 && @@ -183,10 +189,45 @@ const DemoCommands = ({ * Switching files replaces the active handle instead of keeping handles alive * for background nets. */ -export const LocalStorageDemoApp = () => { +export const LocalStorageDemoApp = ({ + onSearchChange, + search, +}: { + onSearchChange: ( + search: SharedExampleSearch, + history: "push" | "replace", + ) => void; + search: SharedExampleSearch; +}) => { const sentryFeedbackAction = useSentryFeedbackAction(); const [openAIVoiceConfig, setOpenAIVoiceConfig] = useState(); + /** + * History is left to the library's default on purpose. That default already + * replaces rather than pushes while an intent continues, so a drag-select + * records one entry instead of one per intermediate selection, and a discrete + * click is the only thing that pushes. Making selections replace as well + * removed every entry this page can produce, which left the first Back press + * leaving the site instead of retracing the net. + */ + const navigation = useSharedSearchNavigation(search, onSearchChange); + + /** + * The location belongs to the net that was open. Petrinaut resets its own + * location per document by keying on the handle id, but that only resets an + * uncontrolled location, so the host clears this one. + * + * Cleared through the controller rather than by writing an empty search: the + * shared projection is lossy, so a location the URL already renders as empty + * leaves the search prop unchanged and the in-memory selection would survive + * into the next net. + */ + const clearSharedLocation = () => { + navigation.onNavigate(withClearedSharedLocation, { + history: "replace", + intent: { cause: "normalization", action: "selection" }, + }); + }; const { aiMessagesByNetId, setAiMessagesByNetId } = useLocalStorageAiMessages(); const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); @@ -301,6 +342,7 @@ export const LocalStorageDemoApp = () => { }); setActiveHandle(createActiveHandle(newNet)); setCurrentNetId(newNet.id); + clearSharedLocation(); }; const loadPetriNet = (petriNetId: string) => { @@ -329,6 +371,9 @@ export const LocalStorageDemoApp = () => { } setActiveHandle(createActiveHandle(netToLoad)); setCurrentNetId(petriNetId); + if (petriNetId !== currentNetId) { + clearSharedLocation(); + } }; const setTitle = (title: string) => { @@ -432,6 +477,7 @@ export const LocalStorageDemoApp = () => { existingNets={existingNets} createNewNet={createNewNet} loadPetriNet={loadPetriNet} + navigation={navigation} readonly={false} setTitle={setTitle} title={currentNet.title} diff --git a/apps/petrinaut-website/src/main/app/optimization-demo/optimization-demo-app.tsx b/apps/petrinaut-website/src/main/app/optimization-demo/optimization-demo-app.tsx index 8cc9053da7f..78ec54ee071 100644 --- a/apps/petrinaut-website/src/main/app/optimization-demo/optimization-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/optimization-demo/optimization-demo-app.tsx @@ -6,8 +6,12 @@ import { LocalStorageDemoApp } from "../local-storage-demo/local-storage-demo-app"; import { PetrinautOptOptimizationProvider } from "./petrinaut-opt-optimization-provider"; -export const OptimizationDemoApp = () => ( +import type { ComponentProps } from "react"; + +export const OptimizationDemoApp = ( + props: ComponentProps, +) => ( - + ); diff --git a/apps/petrinaut-website/src/routes/brunch.tsx b/apps/petrinaut-website/src/routes/brunch.tsx index dce6ebd46ff..a92b2a94bb7 100644 --- a/apps/petrinaut-website/src/routes/brunch.tsx +++ b/apps/petrinaut-website/src/routes/brunch.tsx @@ -1,13 +1,35 @@ -import { createFileRoute, useSearch } from "@tanstack/react-router"; +import { + createFileRoute, + useNavigate, + useSearch, +} from "@tanstack/react-router"; import { BrunchDemoApp } from "../main/app/brunch-demo/brunch-demo-app"; -import { brunchSearchSchema } from "../main/app/brunch-demo/brunch-search"; +import { + validateBrunchSearch, + withBrunchStreamKeys, +} from "../main/app/brunch-demo/brunch-search"; function BrunchRoute() { - return ; + const navigate = useNavigate({ from: "/brunch" }); + const search = useSearch({ from: "/brunch" }); + + return ( + { + void navigate({ + replace: history === "replace", + // Applied to the router's own previous search, so two navigations + // in one event compose instead of the second reverting the first. + search: (previous) => withBrunchStreamKeys(previous, nextSearch), + }); + }} + search={search} + /> + ); } export const Route = createFileRoute("/brunch")({ component: BrunchRoute, - validateSearch: brunchSearchSchema, + validateSearch: validateBrunchSearch, }); diff --git a/apps/petrinaut-website/src/routes/index.tsx b/apps/petrinaut-website/src/routes/index.tsx index 9345bddcc2b..4f969a72595 100644 --- a/apps/petrinaut-website/src/routes/index.tsx +++ b/apps/petrinaut-website/src/routes/index.tsx @@ -1,7 +1,27 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { + createFileRoute, + useNavigate, + useSearch, +} from "@tanstack/react-router"; +import { validateSharedExampleSearch } from "../examples/example-search"; import { LocalStorageDemoApp } from "../main/app/local-storage-demo/local-storage-demo-app"; +function IndexRoute() { + const navigate = useNavigate({ from: "/" }); + const search = useSearch({ from: "/" }); + + return ( + { + void navigate({ replace: history === "replace", search: nextSearch }); + }} + search={search} + /> + ); +} + export const Route = createFileRoute("/")({ - component: LocalStorageDemoApp, + component: IndexRoute, + validateSearch: validateSharedExampleSearch, }); diff --git a/apps/petrinaut-website/src/routes/optimization.tsx b/apps/petrinaut-website/src/routes/optimization.tsx index 3b2de36aecb..0b0103e178d 100644 --- a/apps/petrinaut-website/src/routes/optimization.tsx +++ b/apps/petrinaut-website/src/routes/optimization.tsx @@ -1,12 +1,33 @@ -import { createFileRoute, notFound } from "@tanstack/react-router"; +import { + createFileRoute, + notFound, + useNavigate, + useSearch, +} from "@tanstack/react-router"; +import { validateSharedExampleSearch } from "../examples/example-search"; import { OptimizationDemoApp } from "../main/app/optimization-demo/optimization-demo-app"; +function OptimizationRoute() { + const navigate = useNavigate({ from: "/optimization" }); + const search = useSearch({ from: "/optimization" }); + + return ( + { + void navigate({ replace: history === "replace", search: nextSearch }); + }} + search={search} + /> + ); +} + export const Route = createFileRoute("/optimization")({ beforeLoad: () => { if (import.meta.env.VITE_PETRINAUT_OPT_PROVIDER !== "service") { throw notFound(); } }, - component: OptimizationDemoApp, + component: OptimizationRoute, + validateSearch: validateSharedExampleSearch, }); diff --git a/libs/@hashintel/petrinaut/src/react/index.ts b/libs/@hashintel/petrinaut/src/react/index.ts index 49086a0d5dc..23f48ce35d1 100644 --- a/libs/@hashintel/petrinaut/src/react/index.ts +++ b/libs/@hashintel/petrinaut/src/react/index.ts @@ -49,6 +49,13 @@ export type { PetrinautNavigationUpdater, PetrinautSimulateResource, } from "./navigation"; +// The vocabularies two navigation fields are drawn from. A host encoding the +// location into a router needs to spell them, and to fail its own build when +// either gains a member. +export type { + EditorGlobalMode, + SimulateViewMode, +} from "./state/editor-context"; export { NetManagementContext, type NetManagement, diff --git a/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx b/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx index 4f39623ffe6..ec12800aa2c 100644 --- a/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/handle/host-integration.mdx @@ -383,6 +383,7 @@ full prop list, from `petrinaut/src/ui/petrinaut.tsx`: | `existingNets`, `createNewNet`, `loadPetriNet` | Multi-net management hooks backing the burger menu. Omit them when the host owns navigation. | | `aiAssistant` | Transport, message store, and interactive tools for the AI assistant panel. Omitting it hides the panel. | | `viewportActions` | Extra buttons appended to the canvas zoom/fit controls. | +| `navigation` | Host-controlled, router-neutral app location. Omitted: Petrinaut keeps its location internally. See Router integration. | | `slots` | Host components injected into the top bar (`topBarStart`, `topBarEnd`) plus `titleStyle`. | | `simulationWorkerFactory`, `monteCarloWorkerFactory`, `lspWorkerFactory` | Worker construction for hosts consuming the published dist, typically via the bundler's `?worker` import. Omitted: inlined-blob workers. | diff --git a/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx b/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx index 9dff86c5041..500f9f362e8 100644 --- a/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx +++ b/libs/@local/petrinaut-arch-docs/content/website/router-integration.mdx @@ -39,6 +39,17 @@ mirrors `navigationStateToSharedSearch(state)` to the URL, and merges external URL changes (Back, Forward, a shared link) back into the in-memory location. Fields outside the contract survive that merge untouched. +## A controlled host owns the whole initial location + +`PetrinautNavigationProvider` reads `controller?.state ?? uncontrolledState`, +so a host that passes a controller replaces the provider's initial state +outright. That includes the Actual-mode default the provider applies when a +live stream is available. A page that must open in a non-default mode states +it in the hook's `initialState`, which seeds the fields the URL does not +carry; the URL still wins for the fields it owns. `/brunch` seeds +`mode: "actual"` for exactly this reason, and without it the execution frame +would read the local simulation instead of the stream. + ## Validation normalizes the location `validateSharedExampleSearch` drops everything it cannot represent: an unknown @@ -51,8 +62,22 @@ encoder to build embed URLs. Query params outside the contract are not rewritten on load. TanStack Router only re-stringifies the URL when the parsed search changes, so stripping them eagerly would need a document reload, which an embed must not do. The first -navigation drops them, because both routes write a fresh contract-only search -rather than merging into the existing one. +navigation drops them, because the routes write a fresh contract-only search +rather than merging into the existing one. `/brunch` is the exception: its +`sse` and `runId` params name the stream, so it carries them over and replaces +only the contract part. + +## Which routes speak the contract + +Every page that mounts Petrinaut does: the editable demo at `/` and +`/optimization`, the Brunch stream at `/brunch`, and the example pages at +`/examples/$slug` and `/embed/examples/$slug`. + +They differ in whether the URL also names the model. The editable demo holds +its nets in local storage, so a link there names a location inside whatever +net the visitor has open. `/brunch` names its model with the stream keys it +carries over, so a copied URL reproduces the run. The example pages name the +model in the path. ## What a URL carries, and what it does not @@ -70,5 +95,5 @@ an embed with no scenario has nothing to run. The embed route itself honours ## Hosting Petrinaut without a router The prop is optional. Omit it and Petrinaut keeps its location in internal -state, which is what Storybook and the local-storage demo do. Nothing about the -editor's behaviour depends on a host router being present. +state, which is what Storybook does. Nothing about the editor's behaviour +depends on a host router being present.