A formal specification of the design language embodied by the Bevy Editor mockups and the bevy_feathers widget crate. This document exists to make the implicit rules of the design explicit, so that future contributors can add widgets, themes, and layouts that feel cohesive without needing to reverse-engineer the original designer's intuition.
Where the mockups and the existing code disagree, the mockups take precedence. Where both are silent, this document fills the gap and flags it as a new rule.
Feathers is a toolkit for building editors, inspectors, and other dense productivity tools — not games. The aesthetic is:
- Functional over decorative. Controls announce their function clearly and don't compete with the user's content (3D viewports, code, data).
- Dense but not cramped. Every pixel does work; whitespace exists to group, not to breathe.
- Consistent over customizable. One way to do each thing, applied everywhere.
- Dark by default. The reference theme is dark because editors are used for extended sessions and often alongside bright content.
Any proposed addition to the design language should be justifiable in these terms. If a widget needs four spacing values and three new colors to look right, the widget is probably wrong, not the language.
Tokens are the atoms of the design language. Every visual property of every widget should resolve through a token rather than a literal value. This is how a theme swap, a density change, or an accessibility adjustment can propagate through the whole system.
All spacing — padding, gaps, margins — is a multiple of 6 pixels. This is the base rhythm of the entire UI.
| Token | Value | Typical use |
|---|---|---|
space::XS |
6px | Gap between icon and label, inner padding of tight controls |
space::SM |
12px | Gap between rows, padding inside panels |
space::MD |
18px | Gap between grouped sections |
space::LG |
24px | Margin around panel contents |
space::XL |
30px | Separation between major regions |
Rule: if you reach for a spacing value not on this scale, you are either doing something wrong or discovering a new rule. In the second case, propose an addition to the scale rather than adding a one-off literal.
Note on the mockups: the reference image contains values like 13, 15, 22, 25, 26. With the exception of ROW_HEIGHT (26, see §2.3), these are treated as drift from a strict scale and should be normalized to the nearest 6-multiple unless a specific optical adjustment is documented.
| Token | Value | Use |
|---|---|---|
BORDER_RADIUS |
5px | All rounded corners, universally |
BORDER_THICKNESS |
1px | All borders, universally |
One radius, one thickness. No exceptions without a design review.
| Token | Value | Use |
|---|---|---|
ROW_HEIGHT |
26px | Height of every interactive control: buttons, inputs, tabs, dropdowns, sliders |
ICON_SIZE |
18px | All icons, square |
CHECKBOX_SIZE |
16px | Width and height of a checkbox (existing) |
RADIO_SIZE |
16px | Width and height of a radio button (existing) |
TOGGLE_WIDTH |
— | Width of a toggle switch (existing) |
TOGGLE_HEIGHT |
— | Height of a toggle switch (existing) |
ROW_HEIGHT is the single most important number in the system. It is what makes a row of controls align regardless of which controls they are. Every interactive atom respects it.
With 18px icons inside 26px rows, vertical padding is 4px — this is an intentional off-scale value (the scale is 6-based) that produces correct optical centering. It is the one documented exception to the 6px rule.
| Token | Value | Use |
|---|---|---|
FONT_SIZE_BODY |
12px | Labels, values, most UI text |
FONT_SIZE_HEADING |
12px | Panel headers (currently same as body; may diverge) |
The design uses one visual size and distinguishes hierarchy through weight, color, and placement rather than size. FONT_SIZE_HEADING is reserved as a separate token so it can diverge later without a migration.
Font asset paths live in constants::fonts.
The palette is the lowest color layer — literal OKLCH/hex values with no semantic meaning. Widgets never reference the palette directly; they reference tokens (§2.6), which reference the palette.
Using OKLCH means perceived lightness can be adjusted predictably across any hue. State derivations (hover, pressed, disabled) are defined as lightness-channel shifts so they remain consistent regardless of the base color.
| Palette entry | Role description |
|---|---|
GRAY_0 |
Window background (darkest surface) |
GRAY_1 |
Pane background |
GRAY_2 |
Item background (inputs, buttons) |
GRAY_3 |
Item background, active |
WARM_GRAY_1 |
Borders |
LIGHT_GRAY_1 |
Primary text |
LIGHT_GRAY_2 |
Secondary / dimmed text |
WHITE |
Button label text (on accent) |
BLACK |
Reserved |
ACCENT |
Call-to-action and selection |
X_AXIS |
Red, for X-axis inputs |
Y_AXIS |
Green, for Y-axis inputs |
Z_AXIS |
Blue, for Z-axis inputs |
Surfaces form a four-step hierarchy getting lighter as you nest: window → pane → item → item-active. Nesting a fifth level is a signal that the layout is wrong, not that a fifth color is needed.
Missing from today's palette, proposed additions:
| Palette entry | Purpose |
|---|---|
DANGER |
Error states, destructive action |
WARNING |
Validation warnings |
SUCCESS |
Confirmations, healthy status |
INFO |
Informational highlights |
These are needed the moment widgets handle validation, build output, or async operations. Specifying them now prevents one-off reds and yellows scattered through the codebase later.
Tokens assign palette colors to specific visual roles. This is the layer widgets consume. Existing tokens follow the pattern {WIDGET}_{PROPERTY}[_STATE], e.g. BUTTON_BG_HOVER, CHECKBOX_BORDER_DISABLED.
Existing coverage: globals (WINDOW_BG, TEXT_MAIN, TEXT_DIM, FOCUS_RING), plus full state sets for button, checkbox, radio, slider, toggle switch, and color plane.
Proposed additions (all required by the mockup but currently absent or under-specified):
Surface hierarchy:
PANE_BG— background of a docked panelITEM_BG,ITEM_BG_HOVER,ITEM_BG_ACTIVE— background of individual rows in trees, lists, asset gridsPANEL_HEADER_BG— the darker strip at the top of Node / Scene Tree / Reflectable Component panels
Structure:
BORDER_DEFAULT— the standard 1px borderBORDER_FOCUS— border when a container owns keyboard focusSELECTION_OUTLINE— persistent selection indicator (distinct from momentary focus ring)
Text:
TEXT_HEADING— panel titles, section labels (may aliasTEXT_MAINinitially)TEXT_DISABLED— explicit disabled text token (currently handled per-widget)
Status:
STATUS_ERROR,STATUS_ERROR_BGSTATUS_WARNING,STATUS_WARNING_BGSTATUS_SUCCESS,STATUS_SUCCESS_BGSTATUS_INFO,STATUS_INFO_BG
Tree and list rows:
TREE_ROW_BG,TREE_ROW_BG_HOVER,TREE_ROW_BG_SELECTED
Axis-tinted inputs (Vec2/Vec3/Vec4):
VEC_X_ACCENT,VEC_Y_ACCENT,VEC_Z_ACCENT— resolve to the paletteX_AXIS/Y_AXIS/Z_AXISbut named by role so Vec widgets don't reach into the palette directly
Every interactive widget supports the same set of states. This is non-negotiable: missing a state is a bug, not a choice.
| State | Trigger | Visual treatment |
|---|---|---|
| Default | Idle | Base color from token |
| Hover | Pointer over | Base color lightened by ~5% in OKLCH L channel |
| Focus | Keyboard-focused | FOCUS_RING outline, 1px, outside border |
| Pressed | Mouse/touch held down | Base color darkened slightly, or swapped to pressed token |
| Selected | Persistent selection after click | SELECTION_OUTLINE (accent color), replaces focus ring |
| Disabled | Not interactive | Desaturated tokens; hover and press are no-ops |
| Invalid | Failed validation (inputs only) | Border swapped to STATUS_ERROR; icon or message nearby |
Rules:
- Hover and Focus are independent. A control can be both hovered and focused; both visuals apply.
- Selected outranks Focus for display. If a control is selected, show the selection outline rather than the focus ring. The ring reappears if focus moves to a second selected item (multi-select).
- Disabled is terminal. A disabled control shows no hover, no press, no focus. The 70% opacity rule from the mockup is a visual fallback; the preferred approach is explicit disabled tokens (
*_DISABLED) with colors chosen for legibility. - Invalid applies to inputs only. Buttons and toggles don't have an invalid state; their parent form or row does.
Derivation rule for hover colors: a hover token is the base token's color with L (lightness) in OKLCH increased by 0.05. For existing statically-defined hover tokens, this rule was applied by hand. For new additions, apply it the same way and document the source base. A future refactor may derive hover tokens programmatically from base tokens at theme-construction time; the static definitions remain the source of truth until then.
Every container widget exposes three sizing modes on each axis, matching the Figma auto-layout model:
| Mode | Meaning |
|---|---|
Fill |
Stretch to fill the parent along this axis |
Hug |
Shrink to fit content along this axis |
Auto |
Context-dependent default (usually Fill horizontally, Hug vertically) |
Modes are specified per-axis, so a panel can be Fill horizontally and Hug vertically. The default for most containers is Auto.
Controls (buttons, inputs, etc.) are always Hug vertically at ROW_HEIGHT. Horizontal sizing varies by context: Fill in property rows, Hug in toolbars.
The mockup's recurring structural patterns. New widgets should compose from these primitives rather than invent new ones.
The workhorse of every inspector. A horizontal row with:
- A fixed-width label on the left (
TEXT_DIM,FONT_SIZE_BODY, right-aligned) - A
space::XSgap - A control on the right, sized
Fillhorizontally,Hugvertically atROW_HEIGHT
All rows within a single panel share the same label-column width, so controls align vertically down the panel.
A container with:
- A header strip at
PANEL_HEADER_BG,ROW_HEIGHTtall, containing: chevron (▸/▾), optional icon, title text (TEXT_HEADING), optional trailing controls (overflow⋯, visibility eye, etc.) - A body below the header,
PANE_BG, withspace::SMinternal padding - Smooth expand/collapse; collapsed state shows header only
Sections within a panel can nest, but the header background alternates or indents to preserve the four-step surface hierarchy.
A top-level dockable region. PANE_BG, 1px BORDER_DEFAULT, BORDER_RADIUS corners (only on free edges, square where docked). Contains CollapsiblePanels or direct content.
A horizontal strip of icon buttons and segmented controls, ROW_HEIGHT tall, space::XS gaps between items, space::SM gaps between groups.
Horizontal row of tabs at ROW_HEIGHT. Active tab uses ITEM_BG_ACTIVE; others use ITEM_BG. A trailing + adds a new tab.
Vertical list of expandable rows. Each row:
ROW_HEIGHTtall- Left indent proportional to depth (
space::SMper level) - Chevron → icon → label → optional trailing controls
TREE_ROW_BGdefault,TREE_ROW_BG_HOVERon hover,TREE_ROW_BG_SELECTEDwhen selected
Multi-select is supported; selected rows all share the selected background, and the focused row additionally gets SELECTION_OUTLINE.
Grid of tiles with icon on top, label below. Tile size is consistent within a grid; space::SM gap between tiles. Selection uses the same outline rules as tree rows.
Surfaces nest in this order, each step one palette level lighter:
Window (GRAY_0, WINDOW_BG)
└─ Pane (GRAY_1, PANE_BG)
└─ Panel (header: PANEL_HEADER_BG, body: PANE_BG)
└─ Row (ITEM_BG on hover/active; transparent otherwise)
└─ Control (ITEM_BG for inputs; BUTTON_BG for buttons; etc.)
Going deeper than Control means you're building a new widget, not nesting further. The hierarchy is four levels because the palette supplies four surface colors; adding a fifth is a design-review question.
Tracking what exists and what the mockups imply.
- Button (regular + primary variants, full state set)
- Checkbox
- Radio button
- Slider
- Toggle switch
- Color plane, color slider, color swatch
- Inputs: text input, numeric input (with type hints I32/F32/etc.), Vec2/Vec3/Vec4 input (axis-tinted), enum dropdown, file picker, bool (beyond checkbox — the mockup shows a row-styled bool)
- Containers: Pane, CollapsiblePanel, PropertyRow, Toolbar, TabBar, MenuBar, StatusBar
- Navigation / data: TreeView, AssetGrid, Breadcrumb
- Special: ViewportGizmo (3D nav cube), reflection-driven component inspector (the "Reflectable Component" card)
Each of these should be implemented as a composition of the primitives in §5, using only tokens from §2.6 and states from §3. If an implementation needs a token that doesn't exist, that's a proposal for §2.6, not a reason to hardcode.
When you need something new:
- Check if it already exists in a token, a primitive, or a widget. Most needs are covered.
- If it's a new color role, add a token in §2.6 referencing an existing palette entry. Only add a palette entry (§2.5) if no existing color works.
- If it's a new spacing, check the scale in §2.1 first. New scale steps require a design review.
- If it's a new state, it should apply to all interactive widgets — not just yours. Propose it for §3.
- If it's a new primitive, document it in §5 with anatomy before building downstream widgets on it.
- If it's a new widget, add it to §7 and verify it composes from §5 primitives using §2.6 tokens only.
Pull requests that hardcode colors, spacings, radii, or sizes should be rejected on sight. The whole point of the token system is that these values live in exactly one place.
Things this document has taken a position on that may warrant further discussion:
- Hover derivation: static vs algorithmic. Today's tokens hardcode each hover color. The +5% OKLCH-L rule could be applied programmatically at theme-construction, which would eliminate a whole class of drift but adds a build-time dependency on color-space math.
- Font size divergence. Reserving
FONT_SIZE_HEADINGseparate fromFONT_SIZE_BODYeven though they're equal today — is the intent that they stay equal, or eventually diverge? - Status palette. Adding
DANGER/WARNING/SUCCESS/INFOis proposed here but not in the mockups. The designer should weigh in on hues so they sit correctly alongsideACCENTand the axis colors. - Off-scale ROW_HEIGHT. 26px breaks the 6-based scale. Changing to 24px would restore the scale but requires redrawing every control. Documented as an intentional exception; worth revisiting if the scale ever changes.
- Mockup drift. Values in the reference image at 13, 15, 22, 25 — normalize to 6-multiples, or preserve as intentional optical adjustments? This document assumes normalize; confirm with the designer.