From 0504ef46a21a490b8b30bc9e1bbb5a691fb4dee3 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 14 Sep 2026 00:23:57 +0200 Subject: [PATCH 1/3] Give the default canvas nodes the compact nodes' surface --- .changeset/classic-transition-dimensions.md | 5 + .changeset/default-nodes-compact-surface.md | 5 + .../petrinaut-core/src/layout/dimensions.ts | 5 +- .../petrinaut/docs/visual-settings.md | 6 +- .../src/ui/lib/label-wrap-points.test.ts | 49 ++++++++ .../petrinaut/src/ui/lib/label-wrap-points.ts | 17 +++ .../src/ui/lib/split-pascal-case.test.ts | 101 ---------------- .../petrinaut/src/ui/lib/split-pascal-case.ts | 33 ------ .../react-flow-canvas/classic-place-node.tsx | 108 ++++++++---------- .../classic-transition-node.tsx | 92 +++++++-------- .../react-flow/react-flow-canvas/mini-map.tsx | 15 +-- .../use-transition-firing-animation.ts | 3 +- .../views/SDCPN/styles/classic-node-layout.ts | 60 ++++++++++ .../src/ui/views/SDCPN/styles/focus.ts | 2 +- .../src/ui/views/SDCPN/styles/node-surface.ts | 25 ++++ 15 files changed, 267 insertions(+), 259 deletions(-) create mode 100644 .changeset/classic-transition-dimensions.md create mode 100644 .changeset/default-nodes-compact-surface.md create mode 100644 libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.ts delete mode 100644 libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.test.ts delete mode 100644 libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/classic-node-layout.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts diff --git a/.changeset/classic-transition-dimensions.md b/.changeset/classic-transition-dimensions.md new file mode 100644 index 00000000000..0a8f3d62ef7 --- /dev/null +++ b/.changeset/classic-transition-dimensions.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +`classicNodeDimensions` draws a place as a 128px square, so it renders as a circle, and a transition as a 107px square. diff --git a/.changeset/default-nodes-compact-surface.md b/.changeset/default-nodes-compact-surface.md new file mode 100644 index 00000000000..692be466d35 --- /dev/null +++ b/.changeset/default-nodes-compact-surface.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +The default (classic) canvas nodes take the compact nodes' colours and shadow. A place is a circle and a transition a square, each showing its name over several lines. Minimap transitions sit on the same footprint as places, where they used to be drawn offset from it. Their outline is a 1.5px ring, a shade heavier than the compact card's border. A node name that is not written in PascalCase keeps all of its words. diff --git a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts index cfa208583ba..1895e7c5fc9 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts @@ -24,8 +24,9 @@ export const compactNodeDimensions: RenderNodeDimensions = { }; export const classicNodeDimensions: RenderNodeDimensions = { - place: { width: 130, height: 130 }, - transition: { width: 160, height: 80 }, + /** Both square: the place draws as a circle, the transition as a box. */ + place: { width: 128, height: 128 }, + transition: { width: 107, height: 107 }, componentInstance: { width: 180, height: 96 }, }; diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index e3a2627d5e4..e92353ca7f8 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -104,8 +104,10 @@ When enabled, node positions snap to a grid when placing new nodes or dragging e Switch between two node rendering styles: -- **Compact** (enabled, the default) -- small card-style nodes that show just the place / transition name. Fits more of the net on screen at once; best for large or dense models where you mostly select-and-inspect via the Properties panel. -- **Classic** (disabled) -- larger nodes with inline summary information (e.g. token counts, firing-rate type indicators). Easier to read at a glance for small models or when teaching / demoing. +- **Classic** (disabled, the default) -- larger nodes with inline summary information (e.g. token counts, firing-rate type indicators). Easier to read at a glance for small models or when teaching / demoing. +- **Compact** (enabled) -- small card-style nodes that show just the place / transition name. Fits more of the net on screen at once; best for large or dense models where you mostly select-and-inspect via the Properties panel. + +Both styles draw a node the same way -- a thin outline in the place's token colour, a white transition, and a soft shadow -- and differ only in a node's size, shape, and how much it shows. Toggle freely -- this setting only affects rendering, not the underlying net. diff --git a/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.test.ts b/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.test.ts new file mode 100644 index 00000000000..1d25511ba29 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { withLabelWrapPoints } from "./label-wrap-points"; + +const ZERO_WIDTH_SPACE = "\u200B"; + +const segments = (label: string): string[] => + withLabelWrapPoints(label).split(ZERO_WIDTH_SPACE); + +describe("withLabelWrapPoints", () => { + it("breaks a PascalCase name between its words", () => { + expect(segments("HelloWorld")).toEqual(["Hello", "World"]); + }); + + it("keeps an acronym together", () => { + expect(segments("QAQueue")).toEqual(["QA", "Queue"]); + expect(segments("XMLHttpRequest")).toEqual(["XML", "Http", "Request"]); + expect(segments("IOError")).toEqual(["IO", "Error"]); + }); + + it("breaks before a run of digits", () => { + expect(segments("Space42")).toEqual(["Space", "42"]); + }); + + it("leaves a single word alone", () => { + expect(segments("Hello")).toEqual(["Hello"]); + }); + + it("leaves a label written as a sentence alone", () => { + for (const label of [ + "A motorway load is offered", + "Unload the tanker (SteadyNitrogen)", + "idle tankers", + ]) { + expect(withLabelWrapPoints(label).replace(/\u200B/gu, "")).toBe(label); + } + }); + + it("still breaks the PascalCase parts of a sentence", () => { + expect(segments("Unload the tanker (SteadyNitrogen)")).toEqual([ + "Unload the tanker (Steady", + "Nitrogen)", + ]); + }); + + it("returns an empty string unchanged", () => { + expect(withLabelWrapPoints("")).toBe(""); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.ts b/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.ts new file mode 100644 index 00000000000..7a5e6047919 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.ts @@ -0,0 +1,17 @@ +/** + * Inserts zero width spaces at a label's word boundaries, so a long name + * wraps inside a node instead of overflowing it. + * + * Boundaries are the PascalCase ones a browser will not break on its own: the + * start of a capitalised word, the start of a word after an acronym, and the + * start of a run of digits. Every other character is left as it is, so a label + * written as a sentence comes back untouched. + * + * "CriticalOxygenTank" wraps as Critical / Oxygen / Tank, "XMLHttpRequest" as + * XML / Http / Request, "Space42" as Space / 42. + */ +const WORD_BOUNDARY = + /(?<=[\p{Ll}\d])(?=\p{Lu})|(?<=\p{Lu})(?=\p{Lu}\p{Ll})|(?<=\p{L})(?=\d)/gu; + +export const withLabelWrapPoints = (label: string): string => + label.replace(WORD_BOUNDARY, "\u200B"); diff --git a/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.test.ts b/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.test.ts deleted file mode 100644 index b2eb69b30c3..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { splitPascalCase } from "./split-pascal-case"; - -describe("splitPascalCase", () => { - it("should split a simple PascalCase string into segments", () => { - const result = splitPascalCase("HelloWorld"); - - expect(result).toEqual(["Hello", "World"]); - }); - - it("should handle acronyms followed by words correctly", () => { - const result = splitPascalCase("QAQueue"); - - expect(result).toEqual(["QA", "Queue"]); - }); - - it("should handle longer acronyms", () => { - const result = splitPascalCase("XMLHttpRequest"); - - expect(result).toEqual(["XML", "Http", "Request"]); - }); - - it("should handle acronyms at the end", () => { - const result = splitPascalCase("IOError"); - - expect(result).toEqual(["IO", "Error"]); - }); - - it("should handle a single word", () => { - const result = splitPascalCase("Hello"); - - expect(result).toEqual(["Hello"]); - }); - - it("should handle multiple consecutive words", () => { - const result = splitPascalCase("MyVeryLongClassName"); - - expect(result).toEqual(["My", "Very", "Long", "Class", "Name"]); - }); - - it("should handle mixed acronyms and words", () => { - const result = splitPascalCase("HTTPSConnection"); - - expect(result).toEqual(["HTTPS", "Connection"]); - }); - - it("should handle a string ending with an acronym", () => { - const result = splitPascalCase("ParseXML"); - - expect(result).toEqual(["Parse", "XML"]); - }); - - it("should handle single uppercase letter words", () => { - const result = splitPascalCase("AQuickTest"); - - expect(result).toEqual(["A", "Quick", "Test"]); - }); - - it("should handle only uppercase letters (acronym)", () => { - const result = splitPascalCase("HTML"); - - expect(result).toEqual(["HTML"]); - }); - - it("should handle only uppercase letters (acronym) with numbers", () => { - const result = splitPascalCase("HTML42"); - - expect(result).toEqual(["HTML", "42"]); - }); - - it("should handle empty string", () => { - const result = splitPascalCase(""); - - expect(result).toEqual([""]); - }); - - it("should handle complex real-world examples", () => { - const result = splitPascalCase("HTTPResponseCodeXML"); - - expect(result).toEqual(["HTTP", "Response", "Code", "XML"]); - }); - - it("should handle UIElement pattern", () => { - const result = splitPascalCase("UIElement"); - - expect(result).toEqual(["UI", "Element"]); - }); - - it("should handle APIKey pattern", () => { - const result = splitPascalCase("APIKey"); - - expect(result).toEqual(["API", "Key"]); - }); - - it("should handle PascalCase with numbers", () => { - const result = splitPascalCase("Space42"); - - expect(result).toEqual(["Space", "42"]); - }); -}); diff --git a/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.ts b/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.ts deleted file mode 100644 index 16ab720556d..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Splits a PascalCase string into segments. - * - * Algorithm: - * - Splits on capital letters that are followed by lowercase letters (start of new word) - * - Handles consecutive capitals (acronyms) by keeping them together until a lowercase letter appears - * - Treats sequences of digits as separate segments - * - * Examples: - * - "HelloWorld" -> ["Hello", "World"] - * - "QAQueue" -> ["QA", "Queue"] - * - "XMLHttpRequest" -> ["XML", "Http", "Request"] - * - "IOError" -> ["IO", "Error"] - * - "Space42" -> ["Space", "42"] - * - * @param pascalCaseString - The PascalCase string to split - * @returns An array of string segments - */ -export const splitPascalCase = (pascalCaseString: string): string[] => { - // Match segments: either a capital letter followed by lowercase letters, - // or a sequence of capitals followed by a lowercase letter (acronym), - // or a sequence of capitals at the end or before digits (final acronym), - // or a sequence of digits - const segments = pascalCaseString.match( - /([A-Z]+(?=[A-Z][a-z]|\b|\d)|[A-Z][a-z]+|\d+)/g, - ); - - if (!segments) { - return [pascalCaseString]; - } - - return segments; -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx index 587eda9b080..0f3e47ce6c1 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx @@ -3,9 +3,15 @@ import { Handle, type NodeProps, Position } from "@xyflow/react"; import { Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; -import { splitPascalCase } from "../../../../../lib/split-pascal-case"; +import { withLabelWrapPoints } from "../../../../../lib/label-wrap-points"; import { usePlaceTokenCount } from "../../../canvas-frame-store"; +import { + classicNodeBoxStyle, + classicNodeLabelStyle, + classicNodeRowStyle, +} from "../../../styles/classic-node-layout"; import { nodeFocusStyle } from "../../../styles/focus"; +import { nodeSurfaceStyle } from "../../../styles/node-surface"; import { handleStyling } from "../../../styles/styling"; import { placeBorderColor, placeFillColor } from "../../../styles/type-colors"; import { PlaceStateTooltip } from "./place-state-tooltip"; @@ -17,62 +23,38 @@ const containerStyle = css({ height: "full", }); -const placeCircleStyle = css({ - paddingY: "4", - paddingX: "2", +const placeBoxStyle = css({ + // A circle, since the node is square. borderRadius: "[50%]", - width: "full", - height: "full", - display: "flex", - flexDirection: "column", - justifyContent: "center", - alignItems: "center", - gap: "3", - minWidth: "0", - border: "2px solid color-mix(in oklab, black, white 35%)", + // Wider than the transition's, to keep the name clear of the curve. + padding: "[8px 20px]", fontSize: "[15px]", - boxSizing: "border-box", - position: "relative", - textAlign: "center", - lineHeight: "[1.3]", - cursor: "default", }); -const dynamicsIconStyle = css({ - position: "absolute", - top: "[25px]", - left: "[0px]", - width: "[100%]", - display: "flex", - alignItems: "center", - gap: "4", - justifyContent: "center", - color: "blue.s110", - fontSize: "lg", +const placeRowStyle = css({ + height: "[18px]", }); -const labelContainerStyle = css({ - textAlign: "center", - padding: "[12px 0]", - lineHeight: "[1.1]", - maxWidth: "[100%]", - overflowWrap: "break-word", +const placeLabelStyle = css({ lineClamp: "3", }); +const dynamicsIconStyle = css({ + color: "blue.s110", + fontSize: "lg", +}); + const tokenCountBadgeStyle = css({ - position: "absolute", - top: "[70%]", - fontSize: "base", display: "flex", alignItems: "center", justifyContent: "center", + height: "[18px]", + minWidth: "[22px]", + borderRadius: "[9px]", + padding: "[0 6px]", + fontSize: "sm", color: "neutral.s00", backgroundColor: "[black]", - minWidth: "[26px]", - height: "[26px]", - borderRadius: "[13px]", - padding: "[0 6px]", fontWeight: "semibold", fontVariantNumeric: "tabular-nums", }); @@ -97,11 +79,9 @@ export const ClassicPlaceNode: React.FC> = ({ !data.dragging && (data.hovered || data.visualizerPinned); - // Add zero width space to labels between pascal case points as text-wrapping breakpoints - const label = splitPascalCase(data.label).join("\u200B"); + // Wrap points let a long name break inside the box instead of clipping. + const label = withLabelWrapPoints(data.label); - // React Flow marks a node selected as a drag-selection is drawn, before the - // change reaches the editor's own selection. const focus = selected ? "focused" : data.focus; return ( @@ -114,21 +94,29 @@ export const ClassicPlaceNode: React.FC> = ({ style={handleStyling} />
- {data.dynamicsEnabled && ( -
- -
- )} -
{label}
- {tokenCount !== null && ( -
{tokenCount}
- )} +
+ {data.dynamicsEnabled ? ( +
+ +
+ ) : null} +
+
+ {label} +
+
+ {tokenCount === null ? null : ( +
{tokenCount}
+ )} +
> = ({ positionAbsoluteY, selected, }: NodeProps) => { - const { label } = data; + // Wrap points let a long name break inside the square instead of clipping. + const label = withLabelWrapPoints(data.label); // Refs for animated elements const boxRef = useRef(null); @@ -98,8 +82,6 @@ export const ClassicTransitionNode: React.FC> = ({ y: positionAbsoluteY, }); - // React Flow marks a node selected as a drag-selection is drawn, before the - // change reaches the editor's own selection. const focus = selected ? "focused" : data.focus; return ( @@ -112,16 +94,22 @@ export const ClassicTransitionNode: React.FC> = ({ />
- {data.lambdaType === "stochastic" && ( -
- +
+ {data.lambdaType === "stochastic" ? ( +
+ +
+ ) : null} +
+
+ {label} +
+
+
+
- )} -
{label}
-
-
= ({ id, x, y }) => { = ({ id, x, y }) => { return ( = ({ id, x, y }) => { /** * A wrapper around ReactFlow's MiniMap with custom styling. - * Renders place nodes as circles and transition nodes as rectangles. + * Renders place nodes as circles and transition nodes as squared rectangles. * Positions at top-right, offset by properties panel width when visible. */ export const MiniMap: React.FC> = (props) => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-transition-firing-animation.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-transition-firing-animation.ts index 690f16026cf..d582e55503d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-transition-firing-animation.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-transition-firing-animation.ts @@ -49,7 +49,8 @@ export const useTransitionFiringAnimation = ( [ { background: "rgba(255, 224, 132, 0.7)", - boxShadow: "0 0 6px 1px rgba(255, 132, 0, 0.59)", + boxShadow: + "var(--node-outline-ring, 0 0 #0000), 0 0 6px 1px rgba(255, 132, 0, 0.59)", offset: 0, }, ], diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/classic-node-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/classic-node-layout.ts new file mode 100644 index 00000000000..d38cef7a805 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/classic-node-layout.ts @@ -0,0 +1,60 @@ +/** + * The layout the classic place and transition share: a centred name with a + * fixed row above and below it. + * + * The rows are always in the layout, so a name sits at the same height on + * every node and whatever a row holds -- the dynamics or lambda mark above, + * a token count or the firing bolt below -- appears over or under the name + * rather than pushing it aside. Each node sets its own row height and font + * size, since a transition has less height to give than a circle, and two + * classes setting one property would leave the winner to Panda's ordering. + */ + +import { css } from "@hashintel/ds-helpers/css"; + +/** Half a pixel heavier than the compact card's hairline. */ +const OUTLINE_WIDTH = "1.5px"; + +/** + * The classic node is outlined by a ring rather than a border: a browser + * floors a 1.5px border to a whole pixel, while a shadow's spread is painted + * as given. `--node-outline-color` carries the colour, which a place takes + * from its token type, and `--node-outline-ring` the shadow that draws it, + * which the firing flash reuses so a transition keeps its outline while it + * glows. Only a classic node sets the ring, so a compact card's flash draws + * none. + */ +export const classicNodeBoxStyle = css({ + "--node-outline-ring": `0 0 0 ${OUTLINE_WIDTH} var(--node-outline-color, currentColor)`, + "--node-elevation": "0px 2px 9px rgba(0, 0, 0, 0.04)", + _hover: { + "--node-elevation": "0px 4px 11px rgba(0, 0, 0, 0.1)", + }, + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + gap: "[6px]", + textAlign: "center", +}); + +export const classicNodeRowStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: "0", + lineHeight: "[1]", +}); + +/** + * How many lines a name may take is left to each node: a circle has the + * height for three, a flat transition for one. Both classes setting the + * clamp would leave the winner to Panda's own ordering. + */ +export const classicNodeLabelStyle = css({ + maxWidth: "[100%]", + overflowWrap: "break-word", + textOverflow: "ellipsis", + overflow: "hidden", + lineHeight: "[1.2]", +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/focus.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/focus.ts index c02b86fafe2..3bc4b0793b1 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/focus.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/focus.ts @@ -39,7 +39,7 @@ export const nodeFocusStyle = cva({ outline: "[4px solid transparent]", outlineOffset: "[3px]", boxShadow: - "[var(--focus-glow, 0 0 #0000), var(--node-elevation, 0 0 #0000)]", + "[var(--node-outline-ring, 0 0 #0000), var(--focus-glow, 0 0 #0000), var(--node-elevation, 0 0 #0000)]", /** * Only the ring and the glow fade. Muting recolours the border and label * of most of the net at once, so those two switch rather than animate. diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts new file mode 100644 index 00000000000..9bc1ce7cfc8 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts @@ -0,0 +1,25 @@ +import { css } from "@hashintel/ds-helpers/css"; + +export const nodeSurfaceStyle = css({ + width: "full", + height: "full", + boxSizing: "border-box", + position: "relative", + cursor: "default", +}); + +/** + * A transition's colours. Places take theirs from their token type; a + * transition carries none, so its surface names them here. + */ +export const transitionSurfaceStyle = css({ + background: "neutral.s00", + // The compact card outlines itself with a border, the classic node with a + // ring; each mode reads the one that applies to it. + borderColor: "neutral.s70", + "--node-outline-color": "var(--colors-neutral-s70)", + _hover: { + borderColor: "neutral.s100", + "--node-outline-color": "var(--colors-neutral-s100)", + }, +}); From 8b16265851e6416e9cca5af04fb55ee75c9906da Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 15 Sep 2026 16:39:23 +0200 Subject: [PATCH 2/3] Refine default node proportions and soften outlines --- .changeset/classic-transition-dimensions.md | 2 +- .changeset/default-nodes-compact-surface.md | 2 +- .../petrinaut-core/src/layout/dimensions.ts | 5 +- .../petrinaut/docs/visual-settings.md | 8 ++- .../react-flow-canvas/classic-place-node.tsx | 9 ++- .../classic-transition-node.tsx | 71 +++++++++++++------ .../views/SDCPN/styles/classic-node-layout.ts | 27 +------ 7 files changed, 68 insertions(+), 56 deletions(-) diff --git a/.changeset/classic-transition-dimensions.md b/.changeset/classic-transition-dimensions.md index 0a8f3d62ef7..12e8d97420f 100644 --- a/.changeset/classic-transition-dimensions.md +++ b/.changeset/classic-transition-dimensions.md @@ -2,4 +2,4 @@ "@hashintel/petrinaut-core": patch --- -`classicNodeDimensions` draws a place as a 128px square, so it renders as a circle, and a transition as a 107px square. +`classicNodeDimensions` gives places a 104 × 104 footprint and transitions a 180 × 60 footprint for canvas rendering and layout. diff --git a/.changeset/default-nodes-compact-surface.md b/.changeset/default-nodes-compact-surface.md index 692be466d35..bdddc7e30f7 100644 --- a/.changeset/default-nodes-compact-surface.md +++ b/.changeset/default-nodes-compact-surface.md @@ -2,4 +2,4 @@ "@hashintel/petrinaut": patch --- -The default (classic) canvas nodes take the compact nodes' colours and shadow. A place is a circle and a transition a square, each showing its name over several lines. Minimap transitions sit on the same footprint as places, where they used to be drawn offset from it. Their outline is a 1.5px ring, a shade heavier than the compact card's border. A node name that is not written in PascalCase keeps all of its words. +Give default canvas nodes lighter colours, a soft shadow, a muted 1.5px outline, and matching name styling. Places are circular; transitions are rectangular with an icon on the left, a wrapped name, and the transition type. Preserve words in node names and align minimap nodes with their canvas positions. diff --git a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts index 1895e7c5fc9..98d12456971 100644 --- a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts @@ -24,9 +24,8 @@ export const compactNodeDimensions: RenderNodeDimensions = { }; export const classicNodeDimensions: RenderNodeDimensions = { - /** Both square: the place draws as a circle, the transition as a box. */ - place: { width: 128, height: 128 }, - transition: { width: 107, height: 107 }, + place: { width: 104, height: 104 }, + transition: { width: 180, height: 60 }, componentInstance: { width: 180, height: 96 }, }; diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index e92353ca7f8..c9f12aaf20f 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -104,10 +104,12 @@ When enabled, node positions snap to a grid when placing new nodes or dragging e Switch between two node rendering styles: -- **Classic** (disabled, the default) -- larger nodes with inline summary information (e.g. token counts, firing-rate type indicators). Easier to read at a glance for small models or when teaching / demoing. -- **Compact** (enabled) -- small card-style nodes that show just the place / transition name. Fits more of the net on screen at once; best for large or dense models where you mostly select-and-inspect via the Properties panel. +- **Classic** (disabled, the default) -- circular places show their names and token counts. Rectangular transitions show an icon on the left, a name wrapping over up to two lines, and the transition type below it. Easier to read at a glance for small models or when teaching / demoing. +- **Compact** (enabled) -- shorter card-style nodes show an icon, a single-line name, and the place or transition type. Fits more of the net on screen at once; best for large or dense models where you mostly select-and-inspect via the Properties panel. -Both styles draw a node the same way -- a thin outline in the place's token colour, a white transition, and a soft shadow -- and differ only in a node's size, shape, and how much it shows. +Both styles use thin outlines and soft shadows. Places follow their token type's colour; classic nodes use softer, more muted outlines. Transitions have a white surface. + +Classic places and transitions use the same text size and weight for their names. Toggle freely -- this setting only affects rendering, not the underlying net. diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx index 0f3e47ce6c1..cf310ba7cfc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-place-node.tsx @@ -24,11 +24,16 @@ const containerStyle = css({ }); const placeBoxStyle = css({ + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + gap: "[2px]", + textAlign: "center", // A circle, since the node is square. borderRadius: "[50%]", // Wider than the transition's, to keep the name clear of the curve. - padding: "[8px 20px]", - fontSize: "[15px]", + padding: "[8px 14px]", }); const placeRowStyle = css({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-transition-node.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-transition-node.tsx index 5a9b69cb993..e492218aff9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-transition-node.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/classic-transition-node.tsx @@ -10,7 +10,6 @@ import { useFiringDelta } from "../../../hooks/use-firing-delta"; import { classicNodeBoxStyle, classicNodeLabelStyle, - classicNodeRowStyle, } from "../../../styles/classic-node-layout"; import { nodeFocusStyle } from "../../../styles/focus"; import { @@ -18,6 +17,7 @@ import { transitionSurfaceStyle, } from "../../../styles/node-surface"; import { handleStyling } from "../../../styles/styling"; +import { iconBadgeStyle } from "./node-card"; import { useTransitionFiringAnimation } from "./use-transition-firing-animation"; import type { TransitionNodeType } from "./react-flow-types"; @@ -29,29 +29,49 @@ const containerStyle = css({ }); const transitionBoxStyle = css({ - // Tighter than the circle's, so four lines of a name and the two rows - // around them fit the square with room to spare. - padding: "[2px 10px]", - // The flat box leaves less room for a name than a circle does, so its - // three lines are set smaller. - fontSize: "[13px]", + display: "flex", + alignItems: "center", + gap: "[10px]", + padding: "[6px 12px]", + textAlign: "left", }); -const transitionRowStyle = css({ - height: "[12px]", +const transitionIconStyle = css({ + position: "relative", + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "[28px]", + height: "[28px]", + flexShrink: "0", + color: "neutral.s80", +}); + +const transitionTextStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[2px]", + minWidth: "0", }); const transitionLabelStyle = css({ - lineClamp: "4", + lineClamp: "2", +}); + +const transitionTypeStyle = css({ + fontSize: "[11px]", + lineHeight: "[1.2]", + color: "neutral.a90", }); const stochasticIconStyle = css({ color: "blue.s60", - fontSize: "lg", }); const firingIndicatorStyle = css({ - fontSize: "xl", + position: "absolute", + top: "[-8px]", + right: "[-8px]", color: "yellow.s60", opacity: "[0]", transform: "scale(0.5)", @@ -65,8 +85,13 @@ export const ClassicTransitionNode: React.FC> = ({ positionAbsoluteY, selected, }: NodeProps) => { - // Wrap points let a long name break inside the square instead of clipping. const label = withLabelWrapPoints(data.label); + const subtitle = + data.lambdaType === "none" + ? "Transition" + : data.lambdaType === "stochastic" + ? "Stochastic" + : "Predicate"; // Refs for animated elements const boxRef = useRef(null); @@ -96,20 +121,22 @@ export const ClassicTransitionNode: React.FC> = ({ ref={boxRef} className={`${nodeSurfaceStyle} ${nodeFocusStyle({ focus })} ${transitionSurfaceStyle} ${classicNodeBoxStyle} ${transitionBoxStyle}`} > -
+
+ {data.lambdaType === "stochastic" ? ( -
- +
+
) : null}
-
- {label} -
-
-
- +
+
+ {label}
+
{subtitle}
+
+
+
Date: Tue, 15 Sep 2026 16:53:53 +0200 Subject: [PATCH 3/3] Match minimap geometry to canvas node dimensions --- .changeset/default-nodes-compact-surface.md | 2 +- .../react-flow/react-flow-canvas/mini-map.tsx | 40 +++++++------------ .../src/ui/views/SDCPN/styles/node-surface.ts | 4 -- 3 files changed, 16 insertions(+), 30 deletions(-) diff --git a/.changeset/default-nodes-compact-surface.md b/.changeset/default-nodes-compact-surface.md index bdddc7e30f7..80dee7e481e 100644 --- a/.changeset/default-nodes-compact-surface.md +++ b/.changeset/default-nodes-compact-surface.md @@ -2,4 +2,4 @@ "@hashintel/petrinaut": patch --- -Give default canvas nodes lighter colours, a soft shadow, a muted 1.5px outline, and matching name styling. Places are circular; transitions are rectangular with an icon on the left, a wrapped name, and the transition type. Preserve words in node names and align minimap nodes with their canvas positions. +Give default canvas nodes lighter colours, a soft shadow, a muted 1.5px outline, and matching name styling. Places are circular; transitions are rectangular with an icon on the left, a wrapped name, and the transition type. Preserve words in node names and align minimap nodes with their canvas positions and dimensions. diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/mini-map.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/mini-map.tsx index e6040e3672c..edbf671acb5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/mini-map.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/mini-map.tsx @@ -21,9 +21,6 @@ const miniMapClassName = css({ }, }); -const SHAPE_SIZE = 90; -/** Component instances are the one wide shape on the canvas. */ -const COMPONENT_INSTANCE_WIDTH_RATIO = 1.5; const DEFAULT_TRANSITION_FILL = "#6b7280"; const DEFAULT_COMPONENT_FILL = "#0f766e"; /** Thick and solid: at map scale a ring has to carry the whole signal. */ @@ -31,14 +28,20 @@ const FOCUS_STROKE_WIDTH = 22; /** * Custom node renderer for the MiniMap. - * Renders place nodes as circles and transition nodes as squared rectangles, carrying + * Renders place nodes as circles and transition nodes as rectangles, carrying * the canvas's focus roles: a shape at the focused item is boxed in the * role's colour and the rest of the net drops far back, so a glance at the * map answers "where is this neighbourhood" on a net larger than the screen. * The map is too small for the canvas's white band, so the box and the fade * carry it alone. */ -const MiniMapNode: React.FC = ({ id, x, y }) => { +const MiniMapNode: React.FC = ({ + id, + x, + y, + width, + height, +}) => { // MiniMapNodeProps doesn't include node data, so we look it up from the store const node = useStore( (state) => state.nodeLookup.get(id) as NodeType | undefined, @@ -77,23 +80,9 @@ const MiniMapNode: React.FC = ({ id, x, y }) => { if (node.data.kind === "place") { return ( - ); - } - - if (node.data.kind === "componentInstance") { - return ( - @@ -104,8 +93,9 @@ const MiniMapNode: React.FC = ({ id, x, y }) => { @@ -114,7 +104,7 @@ const MiniMapNode: React.FC = ({ id, x, y }) => { /** * A wrapper around ReactFlow's MiniMap with custom styling. - * Renders place nodes as circles and transition nodes as squared rectangles. + * Renders place nodes as circles and transition nodes as rectangles. * Positions at top-right, offset by properties panel width when visible. */ export const MiniMap: React.FC> = (props) => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts index 9bc1ce7cfc8..e018e968d5e 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/styles/node-surface.ts @@ -14,12 +14,8 @@ export const nodeSurfaceStyle = css({ */ export const transitionSurfaceStyle = css({ background: "neutral.s00", - // The compact card outlines itself with a border, the classic node with a - // ring; each mode reads the one that applies to it. - borderColor: "neutral.s70", "--node-outline-color": "var(--colors-neutral-s70)", _hover: { - borderColor: "neutral.s100", "--node-outline-color": "var(--colors-neutral-s100)", }, });