NodeTool Design System

Navigation: Root AGENTS.md CLAUDE.md UI Primitives Strategy

Single reference for every design token and visual rule in NodeTool. All frontend work must follow these rules. When touching any UI file, scan for violations in the sections below and fix them in the same PR.

Authoritative source files:


1. Typography

Fonts

Variable Value Use
var(--fontFamily1) 'Inter', Arial, sans-serif All UI text
var(--fontFamily2) 'JetBrains Mono', 'Inter', Arial, sans-serif Code, values, node output

Size Scale

Four pixel sizes, exposed as CSS custom properties on :root. Change them in ThemeNodetool.tsx and they propagate everywhere.

CSS variable Pixels Token key
var(--fontSizeBig) 18px FONT_SIZE_SANS.title
var(--fontSizeNormal) 15px FONT_SIZE_SANS.body
var(--fontSizeSmall) 13px FONT_SIZE_SANS.label / FONT_SIZE_MONO.*
var(--fontSizeSmaller) 11px FONT_SIZE_SANS.caption / FONT_SIZE_MONO.caption

Legacy names (--fontSizeGiant, --fontSizeBigger, --fontSizeTiny, --fontSizeTinyer) all collapse onto one of the four sizes above.

The Eight Sanctioned Type Styles

These are the only allowed size+weight+family combinations. Spread them from TYPOGRAPHY or use the corresponding primitive.

import { TYPOGRAPHY, Text, Label, Caption } from "../ui_primitives";

Sans (Inter)

Key Size Weight Line height Primitive Use
TYPOGRAPHY.sans.title 18px / 600 semibold 1.3 <Text size="big"> Headings h1–h3, dialog titles, section titles
TYPOGRAPHY.sans.body 15px / 400 normal 1.45 <Text> Default body text, paragraphs
TYPOGRAPHY.sans.label 13px / 500 medium 1.35 <Label> Form labels, buttons, controls, h4–h6
TYPOGRAPHY.sans.caption 11px / 400 normal 1.4 <Caption> Hints, metadata, badges

Mono (JetBrains Mono)

Key Size Weight Line height Use
TYPOGRAPHY.mono.code 13px / 400 normal 1.5 Code, values, editor text
TYPOGRAPHY.mono.strong 13px / 600 semibold 1.5 Emphasized inline code
TYPOGRAPHY.mono.label 13px / 500 medium 1.35 Mono keys and labels
TYPOGRAPHY.mono.caption 11px / 400 normal 1.4 Small mono, mono tooltips

Allowed Weights

Token Value
FONT_WEIGHT.normal 400
FONT_WEIGHT.medium 500
FONT_WEIGHT.semibold 600

Usage

// Prefer primitives — they pick the right combo automatically
<Text>Body copy</Text>               // sans.body — 15px / 400
<Text size="big">Section Title</Text>// sans.title — 18px / 600
<Label>Channel name</Label>          // sans.label — 13px / 500
<Caption>Updated 2h ago</Caption>    // sans.caption — 11px / 400

// In sx / css blocks, spread the style object
<Box sx={{ ...TYPOGRAPHY.sans.label }}>Filters</Box>
<Box sx={{ ...TYPOGRAPHY.mono.code }}>{value}</Box>

Heading Collapse Rule

HTML headings map to two type styles only:

  • h1–h3TYPOGRAPHY.sans.title (18px / 600)
  • h4–h6TYPOGRAPHY.sans.label (13px / 500)

Heading hierarchy is expressed through margin, letter-spacing, color, and text transform — never by introducing extra font sizes.

Forbidden

  • Any raw fontSize px/rem literal: "14px", "0.85rem", "20px", even "13px" — reference var(--fontSize*) instead
  • fontWeight: 700, fontWeight: "bold", fontWeight: 300 — only 400 / 500 / 600
  • Mixing a size with a non-sanctioned weight for that role (e.g. 15px / 600)
  • A fifth font size anywhere in the app

Exception: Icon glyph sizing via relative em units (e.g. <DeleteIcon sx= />) is icon scaling, not text typography — em is only allowed for icons.


2. Spacing — 4px Grid

All padding, margin, and gap — on both axes — must use one of the nine canonical steps. There are no other allowed values.

Base: theme.spacing(1) === 4px

import { SPACING, GAP, PADDING, MARGIN, snapSpacing, getSpacingPx } from "../ui_primitives";

SPACING — raw scale

Token Theme units Pixels Role
SPACING.none 0 0px Flush
SPACING.micro 0.5 2px Icon/label gaps in dense controls
SPACING.xs 1 4px Tight stacking
SPACING.sm 1.5 6px Compact control padding
SPACING.md 2 8px Default gap / padding
SPACING.lg 3 12px Grouped sections
SPACING.xl 4 16px Panel padding
SPACING.xxl 6 24px Large section separation
SPACING.xxxl 8 32px Page-level rhythm

GAP — for flex / grid gap

Token Pixels
GAP.none 0px
GAP.micro 2px
GAP.tight 4px
GAP.compact 6px
GAP.normal 8px
GAP.comfortable 12px
GAP.spacious 16px

PADDING — for container padding

Token Pixels
PADDING.none 0px
PADDING.micro 2px
PADDING.compact 6px
PADDING.normal 8px
PADDING.comfortable 12px
PADDING.spacious 16px
PADDING.section 24px

MARGIN — for margin

Token Pixels
MARGIN.none 0px
MARGIN.micro 2px
MARGIN.tight 4px
MARGIN.compact 6px
MARGIN.normal 8px
MARGIN.comfortable 12px
MARGIN.spacious 16px
MARGIN.section 24px

Utility Functions

// Snap any arbitrary theme-unit value to the nearest canonical step
const snapped = snapSpacing(2.3); // → 2 (8px)

// Get a px string from theme units
const px = getSpacingPx(3); // → "12px"

// Multi-value padding / margin strings
const p = createPadding(theme, 2, 3); // "8px 12px"

Raw CSS Pixel Grid

When writing plain CSS or sx pixel values directly (rare), only these pixels are allowed:

0 · 2 · 4 · 6 · 8 · 12 · 16 · 24 · 32

Forbidden

5px, 7px, 10px, 13px, 20px and any theme unit not in [0, 0.5, 1, 1.5, 2, 3, 4, 6, 8].

Not spacing: ReactFlow fitView({ padding }), viewport ratios, opacity, scale, flex shrink/grow, zIndex — these are not layout spacing and are excluded from the rule.


3. Color

Colors never appear as hardcoded hex or rgb values in component code. Every color reference goes through theme.vars.palette.*.

Semantic Palette (MUI standard)

Token Dark Light Use
theme.vars.palette.primary.main #6690d4 #2A8077 Primary actions, links
theme.vars.palette.primary.light #7aa0e2 #4FA59C Hover on primary
theme.vars.palette.primary.dark #3d68a8 #1E5F58 Active / pressed primary
theme.vars.palette.secondary.main #E879F9 #C97C5D Secondary actions
theme.vars.palette.error.main #FF5555 #D8615B Errors, destructive
theme.vars.palette.warning.main #FFB86C #D99A3B Warnings
theme.vars.palette.info.main #22D3EE #3F7D8C Information
theme.vars.palette.success.main #50FA7B #6BAA75 Success states
theme.vars.palette.text.primary #F7F8F8 #1A1715 Main text
theme.vars.palette.text.secondary #8A8F98 #5A5550 Secondary / muted text
theme.vars.palette.text.disabled rgba(247,248,248,0.38) #9A938A Disabled text
theme.vars.palette.background.default #08090A #FAF6EF Page background
theme.vars.palette.background.paper #101113 #FFFFFF Surface / card background
theme.vars.palette.divider rgba(255,255,255,0.08) #DCD3C5 Borders, dividers

Custom Semantic Colors (c_*)

NodeTool-specific colors for editor and UI chrome. Reference via theme.vars.palette.*.

Token Use Dark Light
c_app_header App header background #0A0B0D #FFFFFF
c_tabs_header Tab bar background #101113 #F2EDE4
c_node_menu Node context menu bg #17181B #F7F5F0
c_node_bg Workflow node background #1B1D21 #FFFFFF
c_node_header_bg Node header background #141518 #FAF8F5
c_node_bg_group Group node background #22252A #FAF8F5
c_editor_bg_color Canvas background #08090A #FAF6EF
c_editor_grid_color Canvas grid lines #1F2126 #EDE6DA
c_editor_axis_color Canvas axis lines #17181B #E6E2DE
c_selection Node selection ring #8EACA777 #5E9A8F33
c_selection_rect Marquee selection box #cdcdcd33 rgba(94,154,143,0.12)
c_input Input handle color #2e4a4e #F9F7F5
c_output Output handle color #3e3448 #F2F5F2
c_attention Attention / highlight #E35BFF #C96E51
c_delete Destructive action #FF2222 #D8615B
c_progress Progress indicator #556611 #6BAA75
c_link Hyperlink #93C5FD #3F7D75
c_link_visited Visited link #A5B4FC #6A8C88
c_scroll_bg Scrollbar track transparent transparent
c_scroll_thumb Scrollbar thumb #27292E #D1CCC6
c_scroll_hover Scrollbar thumb hover #3A3D44 #E0DCD6

Greyscale

Indexed from 0 (brightest) to 1000 (darkest) in dark mode; reversed in light mode.

Index Dark Light
grey[0] #fff (white) #000
grey[100] #D4D6DB #2C2A27
grey[300] #9CA0A8 #6A6660
grey[500] #5C606A #A59F97
grey[700] #27292E #DED8D0
grey[900] #0A0B0D #F6F2EC
grey[1000] #000 (black) #FAF7F2

Semantic grey aliases (c_gray0c_gray6) map to the same scale.

Custom Surface / Glass

Token Use Dark Light
Paper.default Default paper #101113 #FFFFFF
Paper.paper Nested paper #101113 #F4F0E9
Paper.overlay Popover / overlay bg #17181B #F0EDE6
glass.blur Backdrop filter blur(16px) saturate(180%) blur(50px)
glass.backgroundDialog Dialog glass bg rgba(0,0,0,0.2) rgba(255,248,240,0.27)

Provider Badge Colors

Token Use Dark Light
c_provider_api API provider badge #93C5FD #2C415A
c_provider_local Local provider badge #86EFAC #2E5B4E
c_provider_hf HuggingFace badge #C4B5FD #6D4B6F

Rules

  • Never hardcode hex or rgb colors in component code.
  • Always reference theme.vars.palette.* — the vars switch automatically between light/dark schemes.
  • New colors must be added to both paletteDark.ts and paletteLight.ts as a c_* token.
  • Brand identity (primary/secondary) changes go only in the theme palette, not in individual components.

4. Border Radius

All borderRadius values must use BORDER_RADIUS.* constants. Never use raw numbers or raw var(--rounded-*) strings in component code.

import { BORDER_RADIUS } from "../ui_primitives";

Token Table

Token CSS variable Pixels Use
BORDER_RADIUS.xs var(--rounded-xs) 2px Tight insets, dense list items
BORDER_RADIUS.sm var(--rounded-sm) 4px Input fields, small cards
BORDER_RADIUS.md var(--rounded-md) 6px Buttons, controls (controlRadius)
BORDER_RADIUS.lg var(--rounded-lg) 8px Panels, menus (menuRadius)
BORDER_RADIUS.xl var(--rounded-xl) 12px Cards, modals
BORDER_RADIUS.xxl var(--rounded-xxl) 16px Large surfaces
BORDER_RADIUS.circle var(--rounded-circle) 50% Avatars, circular icons
BORDER_RADIUS.pill var(--rounded-pill) 9999px Tags, chips, compact buttons

Semantic Aliases

These have no BORDER_RADIUS constant. In TSX, access via theme.rounded.*; in plain CSS files, use the --rounded-* var.

Theme key CSS variable Pixels Use
theme.rounded.dialog --rounded-dialog 20px Dialog / modal outer radius
theme.rounded.node --rounded-node 8px Workflow node cards
theme.rounded.buttonSmall --rounded-buttonSmall 4px Small button radius
theme.rounded.buttonLarge --rounded-buttonLarge 6px Default button radius

Usage

borderRadius: BORDER_RADIUS.sm     // input fields
borderRadius: BORDER_RADIUS.lg     // panels, menus
borderRadius: BORDER_RADIUS.pill   // tags, chips
borderRadius: BORDER_RADIUS.circle // avatar, FAB icon

// Compose for asymmetric corners
borderRadius: `${BORDER_RADIUS.sm} ${BORDER_RADIUS.sm} 0 0`

Forbidden

Magic numbers: 1, 3, 4, 7, 10, 18, 20. Raw "var(--rounded-*)" string literals in TSX where a BORDER_RADIUS constant or theme.rounded.* key exists (plain .css files use the vars — that’s what they’re for). For circles, use BORDER_RADIUS.circle not "50%".


5. Motion

All transition and animation timing values must use MOTION.* constants. Never write raw timing strings.

import { MOTION, reducedMotion } from "../ui_primitives";

Duration Tiers

Token Value Use
MOTION.fast 120ms ease Hover micro-interactions, icon state changes
MOTION.normal 200ms ease Standard UI transitions (color, border, opacity)
MOTION.slow 350ms ease Panel open/close, drawer animations
MOTION.none "none" Disable a transition (use in reduced-motion overrides)

Property Shorthands

Token Value Use
MOTION.all all 200ms ease Multi-property transition shorthand
MOTION.border border-color 200ms ease Border color changes
MOTION.background background-color 150ms ease Background color changes
MOTION.transform transform 120ms ease Scale, translate, rotate
MOTION.opacity opacity 150ms ease Fade in/out
MOTION.shadow box-shadow 200ms ease Elevation changes

Usage

// Single property
sx={{ transition: MOTION.all }}
sx={{ transition: MOTION.border }}

// Multiple properties (template literal composition)
sx={{ transition: `${MOTION.border}, ${MOTION.shadow}` }}
sx={{ transition: `${MOTION.background}, ${MOTION.opacity}` }}

Accessibility — prefers-reduced-motion (WCAG 2.3.3)

Rule: Every component that uses a transition on layout, opacity, or transform, or that runs a CSS keyframe animation, must include a prefers-reduced-motion: reduce override.

Use the reducedMotion() helper to add the override inline — it returns an Emotion-compatible @media block:

import { MOTION, reducedMotion } from "../ui_primitives";

// Transition — suppress it entirely
css({
  transition: MOTION.all,
  ...reducedMotion({ transition: MOTION.none }),
})

// Keyframe animation — remove motion, keep visual state
css({
  animation: `${spin} 1s linear infinite`,
  ...reducedMotion({ animation: "none", opacity: 0.6 }),
})

// Multiple transitions
css({
  transition: `${MOTION.border}, ${MOTION.shadow}`,
  ...reducedMotion({ transition: MOTION.none }),
})

What counts as “nonessential” (must be suppressed):

  • Entrance/exit animations (fades, slides, scales)
  • Hover transitions on color, border, background, shadow
  • Infinite/looping animations (spinners, pulsing indicators)
  • Transform transitions (expand/collapse, panel slide-in)

What does NOT need suppression:

  • State changes that convey information without motion (color alone, icon swap)
  • Loading states that go static when reduce is set (opacity-only)

Existing components that write @media (prefers-reduced-motion: reduce) raw should be migrated to reducedMotion() opportunistically.

Forbidden

Raw timing strings of any kind: "200ms", "0.2s ease-in-out", "all 150ms linear". Compose from MOTION.* tokens. Raw @media (prefers-reduced-motion: reduce) { … } blocks in new code — use reducedMotion() instead.


6. Z-Index

Two separate scales serve two different concerns:

  • Z_INDEX — in-content stacking layers (dropdowns, overlays, modals, tooltips, toasts within the NodeTool UI)
  • theme.zIndex — MUI framework layers + Nodetool-specific command layers

Z_INDEX — content layer

Use for components you build. Import from ui_primitives.

import { Z_INDEX } from "../ui_primitives";
Token Value Use
Z_INDEX.base 0 Normal document flow
Z_INDEX.raised 1 Slightly elevated (node selection rings)
Z_INDEX.dropdown 10 Menus, select popovers
Z_INDEX.sticky 20 Sticky headers, fixed toolbars
Z_INDEX.overlay 100 Backdrops, blocking overlays
Z_INDEX.modal 200 Dialogs, drawers
Z_INDEX.tooltip 300 Tooltips
Z_INDEX.toast 400 Toasts and notifications

theme.zIndex — MUI + Nodetool layers

Use via theme.zIndex.* when you need to co-ordinate with MUI framework components.

Key Value Use
theme.zIndex.appBar 1100 App bar
theme.zIndex.drawer 1200 MUI Drawer
theme.zIndex.modal 1300 MUI Modal
theme.zIndex.tooltip 1500 MUI Tooltip
theme.zIndex.behind -1 Below everything
theme.zIndex.commandMenu 9999 Command palette
theme.zIndex.popover 10001 Primary popovers
theme.zIndex.autocomplete 10002 Autocomplete menus
theme.zIndex.floating 10003 Floating panels
theme.zIndex.popover2 99990 Secondary popovers (above popover)
theme.zIndex.highest 100000 Emergency top layer

Forbidden

Arbitrary integers (9999 in new component code, 1000, 2, 5) outside of Z_INDEX.* or theme.zIndex.*.


7. Editor Tokens

Editor-specific sizing tokens live in theme.editor.*. These apply only behind the editor marker class — do not use them in non-editor components.

const { editor } = useTheme();
Token Value Use
editor.heightNode 28px Node control height (compact density)
editor.heightInspector 32px Inspector control height (comfortable density)
editor.padXNode 8px Node horizontal control padding
editor.padYNode 4px Node vertical control padding
editor.padXInspector 10px Inspector horizontal control padding
editor.padYInspector 6px Inspector vertical control padding
editor.controlRadius 6px Border radius for controls inside the editor (BORDER_RADIUS.md)
editor.menuRadius 8px Border radius for menus inside the editor (BORDER_RADIUS.lg)
editor.menuShadow 0 10px 30px rgba(0,0,0,0.5) Node context menu drop-shadow

The EditorUiProvider with scope="node" applies heightNode density; scope="inspector" applies heightInspector density. Components that are density-aware read these via the provider context.


8. Virtual Scroll

Pre-computed overscan counts for TanStack Virtual. Access via theme.virtualScroll.overscan.*.

Token Value Use
overscan.small 10 Short lists (≤50 items)
overscan.normal 25 Default — most lists
overscan.large 50 Large grids, asset galleries
overscan.gridRow 4 Extra grid rows to render

9. Scrollbars

Two helpers in tokens.ts. Use them instead of writing raw ::-webkit-scrollbar CSS.

import { scrollbarStyles, thinScrollbarStyles } from "../ui_primitives/tokens";

// Standard app scrollbar (10px wide, theme palette colors)
css({
  overflowY: "auto",
  ...scrollbarStyles(theme),
})

// Thin variant (6px, lighter appearance)
css({
  overflowY: "auto",
  ...thinScrollbarStyles(theme),
})

Both functions pull colors from theme.vars.palette.c_scroll_* so they automatically switch between light and dark themes. Only override scrollbar styles when a component intentionally needs a different appearance (e.g. the chat thread’s tinted scrollbar).


10. Migration Checklist

When editing any UI file, scan for these violations and fix them in the same PR.

Found Replace with
borderRadius: 4 / "4px" / "3px" BORDER_RADIUS.sm
borderRadius: 6 / "6px" BORDER_RADIUS.md
borderRadius: 8 / "8px" / "10px" BORDER_RADIUS.lg
borderRadius: 12 / "12px" BORDER_RADIUS.xl
borderRadius: 18 / "18px" / "20px" BORDER_RADIUS.xxl or theme.rounded.dialog
borderRadius: "50%" BORDER_RADIUS.circle
borderRadius: 999 / "999px" BORDER_RADIUS.pill
"var(--rounded-sm)" raw string BORDER_RADIUS.sm
transition: "all 200ms ease" MOTION.all
transition: "background-color 150ms" MOTION.background
transition: "transform 120ms ease" MOTION.transform
transition: "opacity 150ms ease" MOTION.opacity
Any other raw timing string Compose from MOTION.*
fontSize: "13px" / "0.85rem" var(--fontSizeSmall) or <Label>
fontSize: "11px" var(--fontSizeSmaller) or <Caption>
fontSize: "15px" var(--fontSizeNormal) or <Text>
fontSize: "18px" var(--fontSizeBig) or <Text size="big">
Any other raw px/rem font size Snap to nearest CSS var above
fontWeight: 700 / "bold" 600 (or remove if already paired correctly)
fontWeight: 300 / 200 400 (normal)
padding: "5px" / gap: 5 SPACING.xs (4px) or SPACING.sm (6px)
padding: "10px" / gap: 10 SPACING.md (8px) or SPACING.lg (12px)
padding: "13px" / gap: 13 SPACING.lg (12px)
padding: "20px" / gap: 20 SPACING.xxl (24px)
margin: "5px" etc. Same snapping rules as padding/gap
zIndex: 9999 in a component Z_INDEX.toast or theme.zIndex.commandMenu
zIndex: 1000 Z_INDEX.overlay or theme.zIndex.mobileStepper
Raw #hex / rgb() color in sx theme.vars.palette.* token
<Typography> <Text>, <Label>, or <Caption>
display: "flex" in sx <FlexRow> or <FlexColumn>
overflow: "auto" container <ScrollArea>
textOverflow: "ellipsis" <TruncatedText>

11. Adding New Tokens

  1. Spacing: Only add a new SPACING.* step if the existing nine steps genuinely cannot express the design intent. Justify in the PR. Update spacing.ts.
  2. Typography: Do not add a ninth type style. Any new text hierarchy must collapse onto one of the eight existing combinations.
  3. Color: Add as a c_* key in both paletteDark.ts and paletteLight.ts. Document its semantic role in a comment.
  4. Border radius: Add a new BORDER_RADIUS.* entry in tokens.ts and a corresponding --rounded-* CSS var in ThemeNodetool.tsx MuiCssBaseline.
  5. Motion: If a new timing is needed, add to MOTION in tokens.ts as a named constant — never use the value inline. New animated components must also include a reducedMotion() override.
  6. Z-index: Add to Z_INDEX in tokens.ts or to theme.zIndex in ThemeNodetool.tsx. Never use a raw integer.

12. Design Decisions and Tradeoffs

Documented rationale for choices that differ from common defaults.

Body text at 15px (not 16px)

The WCAG accessibility guidance and many style guides recommend 16px as the minimum body text size. NodeTool uses 15px (--fontSizeNormal) because the application is a dense developer tool (workflow editor, inspector, code runners) where screen real estate is precious and users are primarily on desktop. VS Code uses 13px, GitHub uses 14px, Linear uses 14px. At 15px on a 1x display, readability is acceptable; the font-size root setting in the user’s browser can still scale it up, and WCAG 1.4.4 (Resize Text) is met as long as text reflows at 200% zoom without loss of content. If the product ever targets general-audience screens or mobile as a primary surface, revisit this.

4px base grid (not 8px)

Material Design and Apple HIG both recommend an 8px grid. NodeTool uses a 4px grid because it needs intermediate densities (2px micro-gaps inside node controls, 6px compact padding) that an 8px grid cannot express without off-grid values. The 8px steps still exist (SPACING.md = 8px, SPACING.xl = 16px) as the default spacing rhythm; the finer steps are used only in the editor’s dense control surfaces.

Flat token tier (no primitive → semantic → component layering)

The W3C Design Tokens Community Group specification (DTCG, stable as of 2025.10) recommends a three-tier model: primitive tokens → semantic/alias tokens → component tokens, expressed in a .tokens.json file. NodeTool uses a flat semantic tier only — SPACING.md, BORDER_RADIUS.sm, c_node_bg — because:

  • The codebase is a single product, not a multi-brand system. The main benefit of the primitive tier (reusing raw values across brands) does not apply.
  • TypeScript constant objects provide type safety and tree-shaking that a JSON token file does not.
  • The DTCG format is valuable if you want Figma/Storybook/other tools to consume the tokens directly. When that need arises, generate the .tokens.json from the TypeScript source — do not author two separate files.

Motion: duration and easing bundled, not separated

Some systems separate duration.fast = 120ms from easing.standard = ease to allow independent variation. NodeTool bundles them (MOTION.fast = "120ms ease") because: (a) all transitions use the same easing curve (ease), so separation adds API surface with no benefit; (b) property shorthands (MOTION.border = "border-color 200ms ease") already encode all three values. If you ever need a different easing (e.g. a spring curve for a specific animation), use MOTION.slow as the duration tier and add a named constant for the specific animation.