- {#if renderedNameHtml}
-
{@html renderedNameHtml}
+
+
+
+ {#if hasVisibleInputLabels}
+ {#if isVertical}
+
+ {#each data.inputs as port, i}
+
+ {truncatePortLabel(port.name)}
+
+ {/each}
+
{:else}
-
{data.name}
+
+ {#each data.inputs as port, i}
+
+ {truncatePortLabel(port.name)}
+
+ {/each}
+
{/if}
- {#if typeDef}
-
{typeDef.name}
+ {/if}
+
+
+
+
+
+ {#if renderedNameHtml}
+ {@html renderedNameHtml}
+ {:else}
+ {data.name}
+ {/if}
+ {#if typeDef}
+ {typeDef.name}
+ {/if}
+
+
+
+ {#if validPinnedParams().length > 0 && typeDef}
+
+
e.stopPropagation()} ondblclick={(e) => e.stopPropagation()}>
+ {#each validPinnedParams() as paramName}
+ {@const paramDef = typeDef.params.find(p => p.name === paramName)}
+ {#if paramDef}
+
+
+ handlePinnedParamChange(paramName, e.currentTarget.value)}
+ onmousedown={(e) => e.stopPropagation()}
+ onfocus={(e) => e.stopPropagation()}
+ use:paramInput
+ />
+
+ {/if}
+ {/each}
+
{/if}
-
- {#if validPinnedParams().length > 0 && typeDef}
-
-
e.stopPropagation()} ondblclick={(e) => e.stopPropagation()}>
- {#each validPinnedParams() as paramName}
- {@const paramDef = typeDef.params.find(p => p.name === paramName)}
- {#if paramDef}
-
-
- handlePinnedParamChange(paramName, e.currentTarget.value)}
- onmousedown={(e) => e.stopPropagation()}
- onfocus={(e) => e.stopPropagation()}
- use:paramInput
- />
-
- {/if}
- {/each}
-
+
+ {#if hasVisibleOutputLabels}
+ {#if isVertical}
+
+ {#each data.outputs as port, i}
+
+ {truncatePortLabel(port.name)}
+
+ {/each}
+
+ {:else}
+
+ {#each data.outputs as port, i}
+
+ {truncatePortLabel(port.name)}
+
+ {/each}
+
+ {/if}
{/if}
@@ -492,29 +570,32 @@
position: relative;
/* Dimensions set via inline style using grid constants */
/* Note: center-origin handled by SvelteFlow's nodeOrigin={[0.5, 0.5]} */
- display: flex;
- flex-direction: column;
background: var(--surface-raised);
border: 1px solid var(--edge);
font-size: 10px;
- overflow: visible;
+ overflow: visible; /* Allow handles to extend outside */
+ --node-radius: 8px;
}
- /* Shape variants */
+ /* Shape variants - set both border-radius and custom property for inner clipping */
.shape-pill {
- border-radius: 20px;
+ --node-radius: 20px;
+ border-radius: var(--node-radius);
}
.shape-rect {
- border-radius: 4px;
+ --node-radius: 4px;
+ border-radius: var(--node-radius);
}
.shape-circle {
- border-radius: 16px;
+ --node-radius: 16px;
+ border-radius: var(--node-radius);
}
.shape-diamond {
- border-radius: 4px;
+ --node-radius: 4px;
+ border-radius: var(--node-radius);
transform: rotate(45deg);
}
@@ -523,11 +604,13 @@
}
.shape-mixed {
+ --node-radius: 12px;
border-radius: 12px 4px 12px 4px;
}
.shape-default {
- border-radius: 8px;
+ --node-radius: 8px;
+ border-radius: var(--node-radius);
}
/* Subsystem/Interface dashed border */
@@ -550,13 +633,22 @@
z-index: 1000 !important;
}
- /* Inner wrapper for content - fills node, clips to rounded corners */
+ /* Clip wrapper - fills node, clips content to rounded corners */
+ .node-clip {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ border-radius: max(0px, calc(var(--node-radius, 8px) - 1px));
+ display: flex;
+ flex-direction: column;
+ }
+
+ /* Inner wrapper for content */
.node-inner {
flex: 1;
display: flex;
flex-direction: column;
- border-radius: inherit;
- overflow: hidden;
+ min-width: 0;
min-height: 0;
}
@@ -613,21 +705,24 @@
margin-top: 2px;
}
- /* Pinned parameters */
+ /* Pinned parameters - rectangular, clipped by node-clip's overflow:hidden */
.pinned-params {
display: flex;
flex-direction: column;
gap: 4px;
- padding: 4px 10px 6px;
+ padding: 4px 8px 6px;
border-top: 1px solid var(--border);
background: var(--surface);
+ border-radius: 0;
+ overflow: hidden;
}
.pinned-param {
display: flex;
align-items: center;
- gap: 6px;
+ gap: 4px;
min-width: 0;
+ max-width: 100%;
}
.pinned-param label {
@@ -644,7 +739,7 @@
flex: 1;
min-width: 0;
height: 20px;
- padding: 2px 8px;
+ padding: 2px 6px;
font-size: 8px;
font-family: var(--font-mono);
background: var(--surface-raised);
@@ -870,4 +965,158 @@
opacity: 1;
}
}
+
+ /* Port labels - grid layout when labels are shown */
+ /* Grid template columns/rows are set via inline style from JS */
+ .node.show-labels .node-clip {
+ display: grid;
+ }
+
+ /* Label containers */
+ .port-labels {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+ overflow: visible;
+ }
+
+ /* Horizontal layout: grid column placement - rotation 0 (inputs left, outputs right) */
+ /* Both labels: input=1, content=2, output=3 */
+ .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-column: 1; }
+ .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .node-inner { grid-column: 2; }
+ .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-column: 3; }
+ /* Input labels only: input=1, content=2 */
+ .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-column: 1; }
+ .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-column: 2; }
+ /* Output labels only: content=1, output=2 */
+ .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-column: 1; }
+ .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-column: 2; }
+
+ /* Horizontal layout: grid column placement - rotation 2 (inputs right, outputs left) */
+ /* Both labels: output=1, content=2, input=3 */
+ .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-column: 1; }
+ .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .node-inner { grid-column: 2; }
+ .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-column: 3; }
+ /* Output labels only (left side): output=1, content=2 */
+ .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-column: 1; }
+ .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-column: 2; }
+ /* Input labels only (right side): content=1, input=2 */
+ .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-column: 1; }
+ .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-column: 2; }
+
+ /* Horizontal borders */
+ .node.show-labels:not(.vertical) .node-clip > .port-labels-input { grid-row: 1; border-right: 1px solid var(--border); }
+ .node.show-labels:not(.vertical) .node-clip > .node-inner { grid-row: 1; }
+ .node.show-labels:not(.vertical) .node-clip > .port-labels-output { grid-row: 1; border-left: 1px solid var(--border); }
+ /* Rotation 2: swap borders */
+ .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-input { border-right: none; border-left: 1px solid var(--border); }
+ .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-output { border-left: none; border-right: 1px solid var(--border); }
+
+ /* Vertical layout: grid row placement - rotation 1 (inputs top, outputs bottom) */
+ /* Both labels: input=1, content=2, output=3 */
+ .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-row: 1; }
+ .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .node-inner { grid-row: 2; }
+ .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-row: 3; }
+ /* Input labels only: input=1, content=2 */
+ .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-row: 1; }
+ .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-row: 2; }
+ /* Output labels only: content=1, output=2 */
+ .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-row: 1; }
+ .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-row: 2; }
+
+ /* Vertical layout: grid row placement - rotation 3 (inputs bottom, outputs top) */
+ /* Both labels: output=1, content=2, input=3 */
+ .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-row: 1; }
+ .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .node-inner { grid-row: 2; }
+ .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-row: 3; }
+ /* Output labels only (top): output=1, content=2 */
+ .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-row: 1; }
+ .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-row: 2; }
+ /* Input labels only (bottom): content=1, input=2 */
+ .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-row: 1; }
+ .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-row: 2; }
+
+ /* Vertical borders */
+ .node.show-labels.vertical .node-clip > .port-labels-input { grid-column: 1; border-bottom: 1px solid var(--border); }
+ .node.show-labels.vertical .node-clip > .node-inner { grid-column: 1; }
+ .node.show-labels.vertical .node-clip > .port-labels-output { grid-column: 1; border-top: 1px solid var(--border); }
+ /* Rotation 3: swap borders */
+ .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-input { border-bottom: none; border-top: 1px solid var(--border); }
+ .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-output { border-top: none; border-bottom: 1px solid var(--border); }
+
+ /* Individual port labels (absolute positioning for horizontal) */
+ .port-label {
+ position: absolute;
+ font-size: 8px;
+ color: var(--text-muted);
+ white-space: nowrap;
+ transform: translateY(-50%);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 36px;
+ line-height: 1;
+ }
+
+ /* Input labels: align right (near separator), away from handle edge */
+ .port-labels-input .port-label {
+ right: 6px;
+ text-align: right;
+ }
+
+ /* Output labels: align left (near separator), away from handle edge */
+ .port-labels-output .port-label {
+ left: 6px;
+ text-align: left;
+ }
+
+ /* Rotation 2: swap alignment */
+ .node[data-rotation="2"] .port-labels-input .port-label {
+ right: auto;
+ left: 6px;
+ text-align: left;
+ }
+ .node[data-rotation="2"] .port-labels-output .port-label {
+ left: auto;
+ right: 6px;
+ text-align: right;
+ }
+
+ /* Vertical rotation - row of labels with 90deg rotation */
+ .port-labels-row {
+ position: relative;
+ }
+
+ /* Reset horizontal-specific styles for vertical labels */
+ .port-labels-row .port-label {
+ position: absolute;
+ width: auto;
+ max-width: none;
+ right: auto;
+ /* Use center origin for simpler positioning */
+ transform-origin: center center;
+ /* text-align: left = text starts at original left edge = visual bottom after -90deg rotation */
+ text-align: left;
+ }
+
+ /* Input labels at top row: center vertically, shift toward bottom separator */
+ .node.show-labels.vertical .port-labels-input .port-label {
+ top: 50%;
+ bottom: auto;
+ transform: translateX(-50%) translateY(calc(-50% + 6px)) rotate(-90deg);
+ }
+
+ /* Output labels at bottom row: center vertically, shift toward top separator */
+ .node.show-labels.vertical .port-labels-output .port-label {
+ top: 50%;
+ bottom: auto;
+ transform: translateX(-50%) translateY(calc(-50% - 6px)) rotate(-90deg);
+ }
+
+ /* Rotation 3: swap the vertical shifts */
+ .node.show-labels.vertical[data-rotation="3"] .port-labels-input .port-label {
+ transform: translateX(-50%) translateY(calc(-50% - 6px)) rotate(-90deg);
+ }
+ .node.show-labels.vertical[data-rotation="3"] .port-labels-output .port-label {
+ transform: translateX(-50%) translateY(calc(-50% + 6px)) rotate(-90deg);
+ }
diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts
index f95c5b24..83500862 100644
--- a/src/lib/constants/dimensions.ts
+++ b/src/lib/constants/dimensions.ts
@@ -42,6 +42,14 @@ export const EVENT = {
/** Export padding: 4 grid units = 40px */
export const EXPORT_PADDING = G.x4;
+/** Port label dimensions (when labels are shown) */
+export const PORT_LABEL = {
+ /** Width of label column for horizontal ports: 4 grid units = 40px */
+ columnWidth: G.x4,
+ /** Height of label row for vertical ports: 4 grid units = 40px (same as column width) */
+ rowHeight: G.x4
+} as const;
+
/**
* Round up to next 2G (20px) boundary.
* This ensures nodes expand by 1G in each direction (symmetric from center).
@@ -50,6 +58,23 @@ export function snapTo2G(value: number): number {
return Math.ceil(value / G.x2) * G.x2;
}
+/**
+ * Calculate port offset from center in pixels.
+ * Used for SVG rendering and numeric calculations.
+ *
+ * @param index - Port index (0-based)
+ * @param total - Total number of ports on this edge
+ * @returns Offset in pixels from center (negative = before center, positive = after)
+ */
+export function getPortOffset(index: number, total: number): number {
+ if (total <= 0 || total === 1) {
+ return 0; // Single port at center
+ }
+ // For N ports with spacing S: span = (N-1)*S, offset from center = -span/2 + i*S
+ const span = (total - 1) * NODE.portSpacing;
+ return -span / 2 + index * NODE.portSpacing;
+}
+
/**
* Calculate port position as CSS calc() expression.
* Uses offset from center to ensure grid alignment regardless of node size,
@@ -60,21 +85,23 @@ export function snapTo2G(value: number): number {
* @returns CSS position value (e.g., "50%" or "calc(50% + 10px)")
*/
export function getPortPositionCalc(index: number, total: number): string {
- if (total <= 0 || total === 1) {
- return '50%'; // Single port at center
- }
- // For N ports with spacing S: span = (N-1)*S, offset from center = -span/2 + i*S
- const span = (total - 1) * NODE.portSpacing;
- const offsetFromCenter = -span / 2 + index * NODE.portSpacing;
- if (offsetFromCenter === 0) {
+ const offset = getPortOffset(index, total);
+ if (offset === 0) {
return '50%';
}
- return `calc(50% + ${offsetFromCenter}px)`;
+ return `calc(50% + ${offset}px)`;
}
+/** Baseline text height for comparing math rendering (approximate) */
+const BASELINE_TEXT_HEIGHT = 14;
+
/**
* Calculate node dimensions from node data.
* Used by both SvelteFlow (for bounds) and BaseNode (for CSS).
+ *
+ * @param hasVisibleInputLabels - True if input labels are visible (setting ON and inputs exist)
+ * @param hasVisibleOutputLabels - True if output labels are visible (setting ON and outputs exist)
+ * @param measuredName - Optional measured dimensions for math-rendered names
*/
export function calculateNodeDimensions(
name: string,
@@ -82,22 +109,30 @@ export function calculateNodeDimensions(
outputCount: number,
pinnedParamCount: number,
rotation: number,
- typeName?: string
+ typeName?: string,
+ hasVisibleInputLabels?: boolean,
+ hasVisibleOutputLabels?: boolean,
+ measuredName?: { width: number; height: number } | null
): { width: number; height: number } {
const isVertical = rotation === 1 || rotation === 3;
const maxPortsOnSide = Math.max(inputCount, outputCount);
const minPortDimension = Math.max(1, maxPortsOnSide) * NODE.portSpacing;
- // Pinned params height: border(1) + padding(10) + rows(20 each) + gaps(4 between)
+ // Pinned params dimensions
const pinnedParamsHeight = pinnedParamCount > 0 ? 7 + 24 * pinnedParamCount : 0;
+ const pinnedParamsWidth = pinnedParamCount > 0 ? 160 : 0;
- // Width: base, name estimate, type name estimate, pinned params minimum, port dimension (if vertical)
- // Name uses 10px font (~6px per char), type uses 8px font (~5px per char), plus padding for node margins
- // Use slightly larger estimates to ensure text fits (ceil behavior)
- const nameWidth = name.length * 6 + 20;
+ // Type label width estimate (8px font, ~5px per char)
const typeWidth = typeName ? typeName.length * 5 + 20 : 0;
- const pinnedParamsWidth = pinnedParamCount > 0 ? 160 : 0;
- const width = snapTo2G(Math.max(
+
+ // Name width: use measured if available, otherwise estimate (10px font, ~6px per char)
+ // Add 24px for horizontal padding in .node-content (12px each side)
+ const nameWidth = measuredName
+ ? snapTo2G(measuredName.width + 24)
+ : name.length * 6 + 20;
+
+ // Content width (without port labels)
+ let contentWidth = snapTo2G(Math.max(
NODE.baseWidth,
nameWidth,
typeWidth,
@@ -105,11 +140,30 @@ export function calculateNodeDimensions(
isVertical ? minPortDimension : 0
));
- // Height: content height vs port dimension (they share vertical space)
- const contentHeight = NODE.baseHeight + pinnedParamsHeight;
- const height = isVertical
+ // Content height: check if math is significantly taller than baseline text
+ let contentHeight: number;
+ if (measuredName && measuredName.height > BASELINE_TEXT_HEIGHT * 1.2) {
+ // Math is tall (e.g., \displaystyle fractions) - use measured height + type label + padding
+ contentHeight = measuredName.height + 24 + pinnedParamsHeight;
+ } else {
+ // Normal text height
+ contentHeight = NODE.baseHeight + pinnedParamsHeight;
+ }
+
+ // Final dimensions accounting for port space
+ let width = contentWidth;
+ let height = isVertical
? snapTo2G(contentHeight)
: snapTo2G(Math.max(contentHeight, minPortDimension));
+ // Add space for port labels if visible
+ if (isVertical) {
+ if (hasVisibleInputLabels) height += PORT_LABEL.rowHeight;
+ if (hasVisibleOutputLabels) height += PORT_LABEL.rowHeight;
+ } else {
+ if (hasVisibleInputLabels) width += PORT_LABEL.columnWidth;
+ if (hasVisibleOutputLabels) width += PORT_LABEL.columnWidth;
+ }
+
return { width, height };
}
diff --git a/src/lib/constants/theme.ts b/src/lib/constants/theme.ts
index fd58fa02..4165cda0 100644
--- a/src/lib/constants/theme.ts
+++ b/src/lib/constants/theme.ts
@@ -17,6 +17,8 @@ export interface ThemeColors {
text: string;
/** Muted text color */
textMuted: string;
+ /** Disabled text color (lighter than muted) */
+ textDisabled: string;
/** Accent color (default node color) */
accent: string;
}
@@ -27,18 +29,20 @@ export const THEMES: Record<'light' | 'dark', ThemeColors> = {
surface: '#08080c',
surfaceRaised: '#1c1c26',
border: 'rgba(255, 255, 255, 0.08)',
- edge: '#7F7F7F',
+ edge: '#808090',
text: '#f0f0f5',
textMuted: '#808090',
+ textDisabled: '#505060',
accent: '#0070C0'
},
light: {
surface: '#f0f0f4',
surfaceRaised: '#ffffff',
border: 'rgba(0, 0, 0, 0.10)',
- edge: '#7F7F7F',
+ edge: '#808090', // inherits from :root
text: '#1a1a1f',
- textMuted: '#606068',
+ textMuted: '#808090', // inherits from :root
+ textDisabled: '#909098',
accent: '#0070C0'
}
} as const;
diff --git a/src/lib/export/svg/types.ts b/src/lib/export/svg/types.ts
index 9b54a7cb..904cd231 100644
--- a/src/lib/export/svg/types.ts
+++ b/src/lib/export/svg/types.ts
@@ -19,6 +19,8 @@ export interface ExportOptions {
showTypeLabels?: boolean;
/** Whether to render handle shapes (default: true) */
showHandles?: boolean;
+ /** Whether to render port labels: true, false, or 'auto' to match canvas state (default: 'auto') */
+ showPortLabels?: boolean | 'auto';
}
/** Render context passed to all renderers */
@@ -44,5 +46,6 @@ export const DEFAULT_OPTIONS: Required
= {
padding: EXPORT_PADDING,
showLabels: true,
showTypeLabels: true,
- showHandles: true
+ showHandles: true,
+ showPortLabels: 'auto'
};
diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts
index 54eb9348..93368c75 100644
--- a/src/lib/stores/index.ts
+++ b/src/lib/stores/index.ts
@@ -19,6 +19,7 @@ export { contextMenuStore } from './contextMenu';
export { nodeUpdatesStore } from './nodeUpdates';
export { pinnedPreviewsStore } from './pinnedPreviews';
export { hoveredHandle, selectedNodeHighlight } from './hoveredHandle';
+export { portLabelsStore } from './portLabels';
// View actions (re-exports triggers and utils)
export * from './viewActions';
diff --git a/src/lib/stores/portLabels.ts b/src/lib/stores/portLabels.ts
new file mode 100644
index 00000000..4e8722cd
--- /dev/null
+++ b/src/lib/stores/portLabels.ts
@@ -0,0 +1,38 @@
+/**
+ * Port Labels Store
+ *
+ * Controls global visibility of port labels inside nodes.
+ * Toggle with 'L' key. Persists to localStorage.
+ */
+
+import { writable, get } from 'svelte/store';
+import { browser } from '$app/environment';
+
+const STORAGE_KEY = 'pathview-portLabels';
+
+function getInitialValue(): boolean {
+ if (!browser) return false;
+ return localStorage.getItem(STORAGE_KEY) === 'true';
+}
+
+const store = writable(getInitialValue());
+
+// Persist to localStorage on change
+store.subscribe((value) => {
+ if (browser) {
+ localStorage.setItem(STORAGE_KEY, String(value));
+ }
+});
+
+export const portLabelsStore = {
+ subscribe: store.subscribe,
+ toggle(): void {
+ store.update((current) => !current);
+ },
+ set(value: boolean): void {
+ store.set(value);
+ },
+ get(): boolean {
+ return get(store);
+ }
+};
diff --git a/src/lib/utils/portLabels.ts b/src/lib/utils/portLabels.ts
new file mode 100644
index 00000000..67acca7c
--- /dev/null
+++ b/src/lib/utils/portLabels.ts
@@ -0,0 +1,42 @@
+/**
+ * Port Labels Utility
+ *
+ * Shared logic for determining port label visibility.
+ * Used by both BaseNode.svelte (canvas) and SVG renderer (export).
+ */
+
+import type { NodeInstance } from '$lib/types/nodes';
+
+/**
+ * Get effective port label visibility for a node.
+ * Per-node settings override global setting.
+ *
+ * @param node - The node instance
+ * @param globalShowLabels - Global port labels setting (from portLabelsStore)
+ * @returns Object with hasVisibleInputLabels and hasVisibleOutputLabels
+ */
+export function getEffectivePortLabelVisibility(
+ node: NodeInstance,
+ globalShowLabels: boolean
+): { inputs: boolean; outputs: boolean } {
+ // Per-node overrides (undefined = follow global)
+ const inputSetting = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalShowLabels;
+ const outputSetting = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalShowLabels;
+
+ // Actual visibility: setting is ON and ports exist
+ return {
+ inputs: inputSetting && node.inputs.length > 0,
+ outputs: outputSetting && node.outputs.length > 0
+ };
+}
+
+/**
+ * Truncate port label for display.
+ *
+ * @param name - Port name
+ * @param maxChars - Maximum characters (default: 5)
+ * @returns Truncated name
+ */
+export function truncatePortLabel(name: string, maxChars: number = 5): string {
+ return name.length > maxChars ? name.slice(0, maxChars) : name;
+}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 7f755dcc..e9de62d2 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -48,6 +48,7 @@
import { triggerFitView, triggerZoomIn, triggerZoomOut, triggerPan, getViewportCenter, screenToFlow, triggerClearSelection, triggerNudge, hasAnySelection, setFitViewPadding, triggerFlyInAnimation } from '$lib/stores/viewActions';
import { nodeUpdatesStore } from '$lib/stores/nodeUpdates';
import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews';
+ import { portLabelsStore } from '$lib/stores/portLabels';
import { clipboardStore } from '$lib/stores/clipboard';
import Tooltip, { tooltip } from '$lib/components/Tooltip.svelte';
import { isInputFocused } from '$lib/utils/focus';
@@ -653,6 +654,10 @@
event.preventDefault();
pinnedPreviewsStore.toggle();
return;
+ case 'l':
+ event.preventDefault();
+ portLabelsStore.toggle();
+ return;
case 'b':
event.preventDefault();
toggleNodeLibrary();