diff --git a/.changeset/dialog-content-position.md b/.changeset/dialog-content-position.md new file mode 100644 index 00000000000..063f66f226f --- /dev/null +++ b/.changeset/dialog-content-position.md @@ -0,0 +1,5 @@ +--- +"@hashintel/ds-components": patch +--- + +Support content refs and inline styles on Dialog for movable, resizable layouts. diff --git a/.changeset/petrinaut-user-settings.md b/.changeset/petrinaut-user-settings.md new file mode 100644 index 00000000000..c0a00437fd4 --- /dev/null +++ b/.changeset/petrinaut-user-settings.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Provide movable, resizable User Settings with keyboard-accessible sections, inline experimental badges, and scroll-edge blur. diff --git a/.changeset/quiet-boats-dock.md b/.changeset/quiet-boats-dock.md new file mode 100644 index 00000000000..92cbc08da4f --- /dev/null +++ b/.changeset/quiet-boats-dock.md @@ -0,0 +1,6 @@ +--- +"@hashintel/petrinaut": patch +--- + +Dock the AI assistant on the right, detach it into a movable floating panel, and toggle it with Cmd/Ctrl+Shift+K across editor views while preserving the conversation and keeping the toolbar centered. +Space out the header tabs, animate its icons, align the send button inside the composer, resize the floating panel from any edge or corner with fading border highlights, and hide Clear AI while the conversation is empty. diff --git a/apps/petrinaut-website/src/examples/example-search.test.ts b/apps/petrinaut-website/src/examples/example-search.test.ts index d231a63afbf..6d53a5045ca 100644 --- a/apps/petrinaut-website/src/examples/example-search.test.ts +++ b/apps/petrinaut-website/src/examples/example-search.test.ts @@ -8,6 +8,30 @@ import { } from "./example-search"; describe("example search contract", () => { + it("validates settings sections only for the user settings dialog", () => { + expect( + validateSharedExampleSearch({ + overlay: "user-settings", + settings: "viewport", + }), + ).toMatchObject({ overlay: "user-settings", settings: "viewport" }); + expect( + validateSharedExampleSearch({ + overlay: "user-settings", + settings: "unknown", + }).settings, + ).toBeUndefined(); + expect( + validateSharedExampleSearch({ + overlay: "create-experiment", + settings: "viewport", + }).settings, + ).toBeUndefined(); + expect( + validateSharedExampleSearch({ overlay: "viewport-settings" }).overlay, + ).toBe("viewport-settings"); + }); + it("strips unsupported query values", () => { expect( validateSharedExampleSearch({ diff --git a/apps/petrinaut-website/src/examples/example-search.ts b/apps/petrinaut-website/src/examples/example-search.ts index e8685086546..b245df20796 100644 --- a/apps/petrinaut-website/src/examples/example-search.ts +++ b/apps/petrinaut-website/src/examples/example-search.ts @@ -31,12 +31,20 @@ export const sharedSimulateViews = [ ] as const; export const sharedOverlays = [ + "user-settings", "viewport-settings", "create-scenario", "create-metric", "create-experiment", ] as const; +export const sharedSettingsSections = [ + "general", + "viewport", + "simulation", + "labs", +] as const; + export type SharedMode = (typeof sharedModes)[number]; export type SharedSimulateView = (typeof sharedSimulateViews)[number]; export type SharedOverlay = (typeof sharedOverlays)[number]; @@ -58,6 +66,7 @@ export type SharedExampleSearch = { mode?: SharedMode; view?: SharedSimulateView; overlay?: SharedOverlay; + settings?: (typeof sharedSettingsSections)[number]; }; /** The keys this contract owns. Anything else in a URL is foreign. */ @@ -69,6 +78,7 @@ const sharedSearchKeys = [ "mode", "view", "overlay", + "settings", ] as const satisfies readonly (keyof SharedExampleSearch)[]; // `.catch(undefined)` is the contract's whole validation story: anything a URL @@ -117,6 +127,14 @@ export const validateSharedExampleSearch = ( 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)), }); diff --git a/apps/petrinaut-website/src/examples/navigation-search.test.ts b/apps/petrinaut-website/src/examples/navigation-search.test.ts index 87f7546875c..b691d6e1b5b 100644 --- a/apps/petrinaut-website/src/examples/navigation-search.test.ts +++ b/apps/petrinaut-website/src/examples/navigation-search.test.ts @@ -8,6 +8,31 @@ import { } from "./navigation-search"; describe("navigation state projection", () => { + it.each(["general", "viewport", "simulation", "labs"] as const)( + "round-trips the %s settings section in Simulate", + (settings) => { + const search = { + mode: "simulate", + view: "metrics", + overlay: "user-settings", + settings, + } as const; + const state = sharedSearchToNavigationState(search); + expect(state.overlay).toEqual({ + type: "user-settings", + section: settings, + }); + expect(navigationStateToSharedSearch(state)).toMatchObject(search); + expect( + applyPreviewNavigationUpdate(search, (current) => current), + ).toMatchObject(search); + expect( + sharedSearchToNavigationState({ mode: "simulate", view: "metrics" }) + .overlay, + ).toBeNull(); + }, + ); + it("round-trips scenario, subnet, and selection", () => { const state = sharedSearchToNavigationState({ scenario: "none", diff --git a/apps/petrinaut-website/src/examples/navigation-search.ts b/apps/petrinaut-website/src/examples/navigation-search.ts index b3ed0b90e43..7a009ee4148 100644 --- a/apps/petrinaut-website/src/examples/navigation-search.ts +++ b/apps/petrinaut-website/src/examples/navigation-search.ts @@ -71,7 +71,9 @@ const overlayToSearch = ( const overlayFromSearch = ( overlay: SharedOverlay, -): PetrinautNavigationOverlay => ({ type: overlay }); + section: SharedExampleSearch["settings"], +): PetrinautNavigationOverlay => + overlay === "user-settings" ? { type: overlay, section } : { type: overlay }; export const sharedSearchToNavigationState = ( search: SharedExampleSearch, @@ -86,7 +88,7 @@ export const sharedSearchToNavigationState = ( overlay: search.overlay === undefined ? baseline.overlay - : overlayFromSearch(search.overlay), + : overlayFromSearch(search.overlay, search.settings), }); export const navigationStateToSharedSearch = ( @@ -106,6 +108,10 @@ export const navigationStateToSharedSearch = ( view === simulateViewToSearch(baseline.simulateView) ? undefined : view, overlay: overlay === overlayToSearch(baseline.overlay) ? undefined : overlay, + settings: + state.overlay?.type === "user-settings" + ? state.overlay.section + : undefined, ...selectionToSearch(state.selection), }; }; @@ -139,6 +145,7 @@ export const applyPreviewNavigationUpdate = ( mode: search.mode, view: search.view, overlay: search.overlay, + settings: search.settings, ...navigationStateToPreviewSearch( update(previewSearchToNavigationState(search)), ), diff --git a/apps/petrinaut-website/src/main/app/command-palette.tsx b/apps/petrinaut-website/src/main/app/command-palette.tsx index 7a0cae0e095..903b2fc51f8 100644 --- a/apps/petrinaut-website/src/main/app/command-palette.tsx +++ b/apps/petrinaut-website/src/main/app/command-palette.tsx @@ -110,7 +110,12 @@ export const CommandPalette = () => { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + if ( + !event.shiftKey && + !event.altKey && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "k" + ) { event.preventDefault(); setOpen((open) => !open); setQuery(""); @@ -158,6 +163,14 @@ export const CommandPalette = () => { aria-label="Command palette" style={paletteStyle} onPointerDown={(event) => event.stopPropagation()} + onBlur={(event) => { + if ( + event.relatedTarget && + !event.currentTarget.contains(event.relatedTarget) + ) { + setOpen(false); + } + }} > element?.focus()} 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 e2ec3ee7939..a9394db61f5 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 @@ -1241,6 +1241,22 @@ describe("local storage demo prepared fixture", () => { brunchPreviewConfig.isBrunchConfigured = true; }); + test.each(["metaKey", "ctrlKey"])( + "reserves %s + Shift + K for the assistant and keeps plain K for the palette", + (modifier) => { + seedStoredNet(); + render( {}} search={{}} />); + fireEvent.keyDown(window, { key: "K", [modifier]: true, shiftKey: true }); + expect( + screen.queryByRole("dialog", { name: "Command palette" }), + ).toBeNull(); + fireEvent.keyDown(window, { key: "k", [modifier]: true }); + expect( + screen.getByRole("dialog", { name: "Command palette" }), + ).not.toBeNull(); + }, + ); + test("shows the fixture selector only while Brunch demo mode is on", () => { seedStoredNet(); render( {}} search={{}} />); diff --git a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx index ab1fd78bf89..095f6eeadce 100644 --- a/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx +++ b/libs/@hashintel/ds-components/src/components/Dialog/dialog.tsx @@ -23,6 +23,8 @@ export type DialogSize = "xs" | "sm" | "md" | "lg" | "xl" | "fullScreen"; const backdropClassName = overlayPartsStyles({ component: "dialog" }).backdrop; const DialogRoot = ({ + ref, + style, className, size = "md", variant = "partitionedFooter", @@ -35,6 +37,8 @@ const DialogRoot = ({ onKeyDown, ...ariaAttributes }: { + ref?: React.Ref; + style?: React.CSSProperties; className?: string; size?: DialogSize; onKeyDown?: React.KeyboardEventHandler; @@ -112,6 +116,8 @@ const DialogRoot = ({