Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/classic-transition-dimensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut-core": patch
---

`classicNodeDimensions` gives places a 104 × 104 footprint and transitions a 180 × 60 footprint for canvas rendering and layout.
5 changes: 5 additions & 0 deletions .changeset/default-nodes-compact-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@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 and dimensions.
4 changes: 2 additions & 2 deletions libs/@hashintel/petrinaut-core/src/layout/dimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export const compactNodeDimensions: RenderNodeDimensions = {
};

export const classicNodeDimensions: RenderNodeDimensions = {
place: { width: 130, height: 130 },
transition: { width: 160, height: 80 },
place: { width: 104, height: 104 },
transition: { width: 180, height: 60 },
componentInstance: { width: 180, height: 96 },
};

Expand Down
8 changes: 6 additions & 2 deletions libs/@hashintel/petrinaut/docs/visual-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,12 @@ 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) -- 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 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.

Expand Down
49 changes: 49 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
});
});
17 changes: 17 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/lib/label-wrap-points.ts
Original file line number Diff line number Diff line change
@@ -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");
101 changes: 0 additions & 101 deletions libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.test.ts

This file was deleted.

33 changes: 0 additions & 33 deletions libs/@hashintel/petrinaut/src/ui/lib/split-pascal-case.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -17,62 +23,43 @@ const containerStyle = css({
height: "full",
});

const placeCircleStyle = css({
paddingY: "4",
paddingX: "2",
borderRadius: "[50%]",
width: "full",
height: "full",
const placeBoxStyle = css({
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: "3",
minWidth: "0",
border: "2px solid color-mix(in oklab, black, white 35%)",
fontSize: "[15px]",
boxSizing: "border-box",
position: "relative",
gap: "[2px]",
textAlign: "center",
lineHeight: "[1.3]",
cursor: "default",
// A circle, since the node is square.
borderRadius: "[50%]",
// Wider than the transition's, to keep the name clear of the curve.
padding: "[8px 14px]",
});

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",
});
Expand All @@ -97,11 +84,9 @@ export const ClassicPlaceNode: React.FC<NodeProps<PlaceNodeType>> = ({
!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 (
Expand All @@ -114,21 +99,29 @@ export const ClassicPlaceNode: React.FC<NodeProps<PlaceNodeType>> = ({
style={handleStyling}
/>
<div
className={`${placeCircleStyle} ${nodeFocusStyle({ focus })}`}
style={{
borderColor: placeBorderColor(data.typeColor),
backgroundColor: placeFillColor(data.typeColor),
}}
className={`${nodeSurfaceStyle} ${nodeFocusStyle({ focus })} ${classicNodeBoxStyle} ${placeBoxStyle}`}
style={
{
"--node-outline-color": placeBorderColor(data.typeColor),
backgroundColor: placeFillColor(data.typeColor),
} as React.CSSProperties
Comment thread
kube marked this conversation as resolved.
}
>
{data.dynamicsEnabled && (
<div className={dynamicsIconStyle}>
<Icon name="function" size="sm" />
</div>
)}
<div className={labelContainerStyle}>{label}</div>
{tokenCount !== null && (
<div className={tokenCountBadgeStyle}>{tokenCount}</div>
)}
<div className={`${classicNodeRowStyle} ${placeRowStyle}`}>
{data.dynamicsEnabled ? (
<div className={dynamicsIconStyle}>
<Icon name="function" size="sm" />
</div>
) : null}
</div>
<div className={`${classicNodeLabelStyle} ${placeLabelStyle}`}>
{label}
</div>
<div className={`${classicNodeRowStyle} ${placeRowStyle}`}>
{tokenCount === null ? null : (
<div className={tokenCountBadgeStyle}>{tokenCount}</div>
)}
</div>
</div>
<Handle
type="source"
Expand Down
Loading
Loading