This document defines the visual language and design principles for the NodeBox GUI. All UI development should follow these guidelines to ensure a consistent, professional, and modern interface.
Reference Implementation: crates/nodebox-desktop/src/theme.rs
NodeBox follows a Linear-inspired design philosophy with these core principles:
- Straight lines and 90° angles for a clean, precise aesthetic
- Most UI elements have zero corner radius — sharp rectangles
- Subtle rounding (
4px) only for specific highlighted elements:- Selected items in lists/dialogs (e.g., node selection dialog)
- Focused input fields
- Creates a professional, tool-like appearance
- Eliminate borders wherever possible — they add visual noise
- Delineate sections through background color differentiation
- Panels are distinguished by subtle background shade changes
- Only use borders for:
- Window/dialog outer edges (1px, very subtle)
- Focus states on inputs
- Critical separation needs
- Near-black backgrounds with subtle cool undertones
- Multiple gray levels create depth through layering
- Darker = further back, lighter = elevated/interactive
- Comfortable for extended use, reduces eye strain
- Purple/violet as the primary accent color — distinctive and modern
- Used for: selections, links, active states, primary actions
- Adds personality without overwhelming
- Status colors: green (success), yellow (warning), red (error)
- Breathable, airy layouts — don't crowd elements
- Consistent 4px grid for all dimensions
- Larger padding creates a premium feel
- White space is a feature, not wasted space
Deep blacks with subtle cool undertones, creating a sophisticated dark interface.
| Token | RGB | Hex | Usage |
|---|---|---|---|
GRAY_0 |
(0, 0, 0) |
#000000 | Pure black |
GRAY_50 |
(9, 9, 11) |
#09090B | Deepest background |
GRAY_100 |
(17, 17, 19) |
#111113 | Main panel background |
GRAY_150 |
(23, 23, 26) |
#17171A | Secondary panels, sidebar |
GRAY_200 |
(31, 31, 35) |
#1F1F23 | Elevated surfaces, inputs |
GRAY_250 |
(39, 39, 43) |
#27272B | Subtle borders, hover bg |
GRAY_300 |
(49, 49, 54) |
#313136 | Widget backgrounds |
GRAY_350 |
(55, 55, 61) |
#37373D | Active/pressed states |
GRAY_400 |
(75, 75, 82) |
#4B4B52 | Disabled elements |
GRAY_500 |
(107, 107, 115) |
#6B6B73 | Muted text, icons |
GRAY_600 |
(139, 139, 148) |
#8B8B94 | Secondary text |
GRAY_700 |
(171, 171, 181) |
#ABABB5 | Body text |
GRAY_800 |
(219, 219, 224) |
#DBDBE0 | Primary text |
GRAY_900 |
(235, 235, 240) |
#EBEBF0 | Bright/emphasized text |
GRAY_1000 |
(255, 255, 255) |
#FFFFFF | Pure white |
Purple/violet creates a distinctive, modern identity.
| Token | RGB | Hex | Usage |
|---|---|---|---|
VIOLET_900 |
(45, 38, 64) |
#2D2640 | Selection background (subtle) |
VIOLET_600 |
(124, 58, 237) |
#7C3AED | Pressed/darker accent |
VIOLET_500 |
(139, 92, 246) |
#8B5CF6 | Primary accent |
VIOLET_400 |
(167, 139, 250) |
#A78BFA | Hover/lighter accent |
Use sparingly for semantic feedback only.
| Token | RGB | Hex | Usage |
|---|---|---|---|
SUCCESS_GREEN |
(34, 197, 94) |
#22C55E | Success, valid |
WARNING_YELLOW |
(234, 179, 8) |
#EAB308 | Warning, caution |
ERROR_RED |
(239, 68, 68) |
#EF4444 | Error, danger |
Always use semantic tokens, never raw values:
// Backgrounds (darkest to lightest)
PANEL_BG // Main panels (GRAY_100)
TAB_BAR_BG // Sidebars, secondary (GRAY_150)
SURFACE_ELEVATED // Cards, dialogs, inputs (GRAY_200)
HOVER_BG // Hover states (GRAY_250)
SELECTION_BG // Selected items (VIOLET_900)
// Text (brightest to dimmest)
TEXT_STRONG // Emphasized, active (GRAY_900)
TEXT_DEFAULT // Body text (GRAY_700)
TEXT_SUBDUED // Secondary info (GRAY_500)
TEXT_DISABLED // Disabled (GRAY_400)
// Widgets
WIDGET_INACTIVE_BG // Button default (GRAY_250)
WIDGET_HOVERED_BG // Button hover (GRAY_300)
WIDGET_ACTIVE_BG // Button pressed (GRAY_350)| Token | Size | Usage |
|---|---|---|
FONT_SIZE_SMALL |
11px | Labels, captions |
FONT_SIZE_BASE |
13px | Body text, buttons |
FONT_SIZE_HEADING |
16px | Section headings |
Use text color (not weight) to establish hierarchy:
- Strong (
TEXT_STRONG/ GRAY_900) — Active, focused, primary - Default (
TEXT_DEFAULT/ GRAY_700) — Standard body text - Subdued (
TEXT_SUBDUED/ GRAY_500) — Secondary, hints - Disabled (
TEXT_DISABLED/ GRAY_400) — Non-interactive
// Example: List item
ui.label(RichText::new("Primary text").color(TEXT_DEFAULT));
ui.label(RichText::new("Secondary").color(TEXT_SUBDUED));All spacing follows a 4px grid for visual consistency.
| Token | Value | Usage |
|---|---|---|
PADDING_SMALL |
4px | Tight spaces, icon gaps |
PADDING |
8px | Standard padding |
PADDING_LARGE |
12px | Button padding, sections |
PADDING_XL |
16px | Large section gaps |
VIEW_PADDING |
12px | Panel content margins |
ITEM_SPACING |
8px | Between list items |
INDENT |
16px | Tree/hierarchy indent |
| Token | Value | Usage |
|---|---|---|
TOP_BAR_HEIGHT |
28px | Address bar, toolbar |
TITLE_BAR_HEIGHT |
24px | Pane headers |
LIST_ITEM_HEIGHT |
28px | List items (taller for touch) |
ROW_HEIGHT |
24px | Parameter rows |
TABLE_HEADER_HEIGHT |
32px | Table headers |
Sharp corners by default, with subtle rounding for interactive highlights.
| Token | Value | Usage |
|---|---|---|
CORNER_RADIUS |
0px | Panels, windows, dialogs — sharp edges |
CORNER_RADIUS_SMALL |
4px | Selected items, focused inputs only |
When to use CORNER_RADIUS_SMALL (4px):
- Selection highlight in node selection dialog
- Hovered/selected list items
- Focused text input fields
Everything else: sharp corners (0px)
Panels are distinguished by background color, not borders:
// Main content area
ui.painter().rect_filled(rect, 0.0, PANEL_BG);
// Sidebar or secondary panel
ui.painter().rect_filled(rect, 0.0, TAB_BAR_BG);
// Elevated card or section
ui.painter().rect_filled(rect, CORNER_RADIUS, SURFACE_ELEVATED);Key principle: Adjacent panels have different background shades. No borders needed.
Blend seamlessly with their panels:
// Header background matches panel, slightly lighter text
ui.painter().rect_filled(header_rect, 0.0, PANE_HEADER_BACKGROUND_COLOR);
ui.label(
RichText::new("PARAMETERS")
.size(FONT_SIZE_SMALL)
.color(PANE_HEADER_FOREGROUND_COLOR)
);- Background:
GRAY_150(matches sidebar tone) - Text:
GRAY_600(subdued, not competing with content) - No bottom border — spacing separates header from content
Sharp corners, no borders, background-based states:
let bg = if response.is_pointer_button_down_on() {
WIDGET_ACTIVE_BG
} else if response.hovered() {
WIDGET_HOVERED_BG
} else {
WIDGET_INACTIVE_BG
};
ui.painter().rect_filled(rect, 0.0, bg); // Sharp corners- Default:
GRAY_250 - Hover:
GRAY_300 - Active:
GRAY_350 - No border strokes, sharp 90° corners
Elevated background, sharp corners:
let response = ui.add(
egui::TextEdit::singleline(&mut value)
.desired_width(100.0)
);- Background:
SURFACE_ELEVATED(GRAY_200) - Text:
TEXT_DEFAULT - No border by default, sharp corners
- Focus: Subtle
VIOLET_500border or 4px rounded highlight
Selected items get subtle rounding to make them "pop":
let bg = if is_selected {
SELECTION_BG // Violet-tinted background
} else if is_hovered {
HOVER_BG
} else {
Color32::TRANSPARENT
};
// Use CORNER_RADIUS_SMALL (4px) for selections only
ui.painter().rect_filled(item_rect, CORNER_RADIUS_SMALL, bg);- Selected:
VIOLET_900with 4px rounding - Hovered:
HOVER_BGwith 4px rounding - Default: transparent, no rounding
- Text becomes
TEXT_STRONGwhen selected
egui::Window::new("Add Node")
.fixed_size([500.0, 400.0])
.frame(egui::Frame::window(&ctx.style())
.fill(SURFACE_ELEVATED)
.stroke(Stroke::new(1.0, GRAY_250))
.rounding(0.0)) // Sharp corners- Background:
SURFACE_ELEVATED(elevated from underlying content) - Border: 1px
GRAY_250(only place we use a visible border) - Corner radius:
0px(sharp) - No drop shadow
| State | Background | Text |
|---|---|---|
| Default | GRAY_250 |
TEXT_DEFAULT |
| Hovered | GRAY_300 |
TEXT_STRONG |
| Active/Pressed | GRAY_350 |
TEXT_STRONG |
| Selected | VIOLET_900 |
TEXT_STRONG |
| Disabled | GRAY_150 |
TEXT_DISABLED |
- Background color lightens one step
- Text brightens to
TEXT_STRONG - No expansion, no borders, no shadows
- Cursor changes to pointer
┌─────────────────────────────────────┐
│ PANE HEADER (24px) │ ← GRAY_150
│ │
│ Content Area │ ← GRAY_100
│ (VIEW_PADDING on all sides) │
│ │
└─────────────────────────────────────┘
No borders between header and content — spacing creates separation.
┌──────────┬──────────────────────────┐
│ │ │
│ Sidebar │ Main Content │
│ GRAY_150 │ GRAY_100 │
│ │ │
└──────────┴──────────────────────────┘
Different background colors distinguish areas. No vertical border needed.
GRID_CELL_SIZE = 48px
NODE_MARGIN = 8px
NODE_WIDTH = 128px (48×3 - 8×2)
NODE_HEIGHT = 32px (48×1 - 8×2)
NODE_PADDING = 4px
NODE_ICON_SIZE = 24px
- ✓ Use sharp 90° corners for most UI elements
- ✓ Use background color to distinguish panels and sections
- ✓ Apply subtle 4px rounding only for selected/hovered items
- ✓ Use the violet accent for selections and links
- ✓ Keep interfaces spacious with generous padding
- ✓ Use semantic color tokens from
theme.rs
- ✗ Round corners on panels, dialogs, or buttons — keep them sharp
- ✗ Add borders to separate panels — use background colors instead
- ✗ Use borders on buttons or list items
- ✗ Add drop shadows (they add visual noise)
- ✗ Hardcode color values — use tokens
- ✗ Use expansion effects on hover — just change color
When creating new UI components:
- Import tokens from
crate::theme - Use semantic colors, not raw values
- Use sharp corners (0px) by default
- Only use 4px rounding for selections/highlights
- NO BORDERS — use background differentiation
- Implement hover/active states via background color
- Use violet (
SELECTION_BG) for selections - Align to 4px grid
use crate::theme::{
// Backgrounds
PANEL_BG, TAB_BAR_BG, SURFACE_ELEVATED, HOVER_BG, SELECTION_BG,
// Text
TEXT_STRONG, TEXT_DEFAULT, TEXT_SUBDUED, TEXT_DISABLED,
// Widgets
WIDGET_INACTIVE_BG, WIDGET_HOVERED_BG, WIDGET_ACTIVE_BG,
// Accents
VIOLET_400, VIOLET_500, VIOLET_900,
SUCCESS_GREEN, WARNING_YELLOW, ERROR_RED,
// Layout
CORNER_RADIUS, CORNER_RADIUS_SMALL,
PADDING, PADDING_SMALL, PADDING_LARGE, PADDING_XL,
ROW_HEIGHT, LIST_ITEM_HEIGHT,
// Typography
FONT_SIZE_BASE, FONT_SIZE_SMALL, FONT_SIZE_HEADING,
};NodeBox uses egui for its GUI. Understanding how to properly configure egui's styling is essential for maintaining visual consistency.
All global styles are configured in theme.rs in the configure_style() function. This function is called once at startup.
pub fn configure_style(ctx: &egui::Context) {
let mut style = Style::default();
let mut visuals = Visuals::dark();
// ... configure style and visuals ...
style.visuals = visuals;
ctx.set_style(style);
}Critical: egui widgets have multiple background properties. If you only set bg_fill, egui will use its default brownish-gray for other properties. Always set ALL of these:
// For each widget state (noninteractive, inactive, hovered, active, open):
visuals.widgets.inactive.bg_fill = SLATE_700; // Primary background
visuals.widgets.inactive.weak_bg_fill = SLATE_700; // IMPORTANT: Button frames use this!
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, SLATE_200); // Text/icon color
visuals.widgets.inactive.bg_stroke = Stroke::NONE; // Border (usually none)
visuals.widgets.inactive.corner_radius = CornerRadius::ZERO;The weak_bg_fill property is particularly important - it's used for:
- Button backgrounds
- ComboBox button backgrounds
- Frame backgrounds for "weak" (unfocused) widgets
If you see brownish-gray colors appearing, you likely forgot to set weak_bg_fill.
egui has five widget states, each needs full configuration:
| State | When Used |
|---|---|
noninteractive |
Labels, static text, disabled widgets |
inactive |
Buttons/widgets not being interacted with |
hovered |
Mouse hovering over widget |
active |
Widget being clicked/pressed |
open |
ComboBox/menu when popup is open |
For menus and popups (like ComboBox dropdowns):
// Sharp corners on menu popups
visuals.menu_corner_radius = CornerRadius::ZERO;
// No shadow on popups
visuals.popup_shadow = egui::Shadow::NONE;
// Tight menu margins
style.spacing.menu_margin = egui::Margin::same(2);For selected items in lists and menus:
visuals.selection.bg_fill = SELECTION_BG; // Background color (violet)
visuals.selection.stroke = Stroke::new(1.0, TEXT_STRONG); // Text visibilityNote: selection.stroke helps ensure text remains visible on the selection background.
For widget-specific styling, modify ui.style_mut() before rendering:
// Example: Smaller font for a specific widget
let style = ui.style_mut();
style.override_font_id = Some(egui::FontId::proportional(FONT_SIZE_SMALL));
// Then render your widget
egui::ComboBox::from_id_salt(id)
.selected_text(label)
.show_ui(ui, |ui| { ... });| Property | Location | Purpose |
|---|---|---|
style.spacing.button_padding |
Spacing | Internal button padding |
style.spacing.menu_margin |
Spacing | Menu interior margin |
style.spacing.item_spacing |
Spacing | Gap between items |
visuals.window_corner_radius |
Visuals | Window/dialog corners |
visuals.menu_corner_radius |
Visuals | Menu popup corners |
visuals.popup_shadow |
Visuals | Shadow on popups |
visuals.window_shadow |
Visuals | Shadow on windows |
visuals.selection.bg_fill |
Visuals | Selection highlight color |
visuals.widgets.*.bg_fill |
Visuals | Widget background |
visuals.widgets.*.weak_bg_fill |
Visuals | Widget frame/button background |
visuals.widgets.*.fg_stroke |
Visuals | Widget text/icon color |
If a widget has wrong colors:
- Brownish-gray background? → Set
weak_bg_fillfor all widget states - Wrong text color? → Check
fg_strokefor the relevant state - Rounded corners appearing? → Set
corner_radius = CornerRadius::ZERO - Selection not visible? → Check
selection.bg_fillandselection.stroke - Menu has shadows? → Set
popup_shadow = Shadow::NONE
// Inactive state (not hovered, not clicked)
visuals.widgets.inactive.bg_fill = SLATE_700;
visuals.widgets.inactive.weak_bg_fill = SLATE_700;
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, SLATE_200);
visuals.widgets.inactive.corner_radius = CornerRadius::ZERO;
visuals.widgets.inactive.bg_stroke = Stroke::NONE;
// Hovered state
visuals.widgets.hovered.bg_fill = SLATE_600;
visuals.widgets.hovered.weak_bg_fill = SLATE_600;
visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, SLATE_100);
visuals.widgets.hovered.corner_radius = CornerRadius::ZERO;
visuals.widgets.hovered.expansion = 0.0; // No size change on hover
visuals.widgets.hovered.bg_stroke = Stroke::NONE;This design system is living documentation. When adding new patterns:
- Check if existing tokens cover your use case
- Add new tokens to
theme.rswith semantic names - Update this document with the new pattern
- Remember: sharp corners, no borders, background differentiation