GPUI Box GitHub

Token model

Authority

The documents under crates/gpui-kit-tokens/tokens/ are the authority, and they sit inside the crate that embeds them so that crate can be packaged on its own. schema.json is the single current portable schema; documents and nested objects reject unknown or legacy fields rather than carrying compatibility readers. Cargo package gpui-box-kit-tokens embeds and validates every bundled theme; gpui-box-kit-theme is the only GPUI adapter. studio-dark is the default and studio-light is its light counterpart.

Eight further documents ship beside them — catppuccin-mocha, catppuccin-latte, nord, tokyo-night, gruvbox-dark, dracula, solarized-dark and solarized-light — with palettes transcribed from their upstream schemes (PROVENANCE.md P11). They carry the studio pair's exact key set, are validated by the same gates, and ThemeRegistry::new registers them after it. They are deliberately not part of tokens::bundled(): that pair is what the library designs against and captures its visual baselines in, and every scene is rendered once per member of it. tokens::all() is the whole shipped catalog, and tokens::presets() is the eight on their own.

JSON token document
    ↓
typed semantic roles
    ↓
GPUI Theme global
    ↓
component recipes
    ↓
application views

No crate reads a path outside this repository.

Three layers

Raw values

Literal values live only in the token document, in one palette per theme:

"palette": { "neutral": { "200": "#1b1b1b" } }

Everything else references the palette, optionally with a hexadecimal alpha suffix, so a theme is retuned by editing its scales rather than every role:

"raised": "{neutral.200}",
"hover": "{neutral.850}/24"

Semantic roles

Callers select meaning:

tokens.surface(Surface::Raised)
tokens.semantic(SemanticColor::Danger)
tokens.radius(Radius::Dialog)

Component usage

Components combine semantic roles:

popover.background = surface.overlay
popover.separator = interactive.divider
popover.radius = radius.card

Component aliases are implemented in Rust while the catalog is small. Move an alias into JSON only when several components must share and evolve it together.

An alias names a complete entity recipe, not only one primitive value. For example, OverlaySurface::FLOATING pairs radius.card with elevation.overlay, OverlaySurface::MODAL pairs radius.dialog with elevation.modal, and OverlaySurface::EDGE keeps the modal elevation but no radius because a drawer is attached to the window plane. Message surfaces read radius.bubble. This is what prevents two components with the same elevation from accidentally claiming the same shape.

Palette-backed variants follow color.paletteSteps: ordered filled, hover, active, and per-appearance readable fallback lists. The theme adapter resolves the first step the selected palette group declares. This selection policy is part of the theme document rather than a hard-coded Open Color convention in Rust, so a sparse or differently numbered custom palette can retune variants without replacing the resolver.

Theme::from_tokens resolves every palette entry and each of those selected ramp steps once. palette_color and palette-backed variant recipes then read hash tables; rendering does not rebuild a "group.step" path or parse a hex value. A Theme is an Arc handle over ThemeData, so the clone components take while rendering is constant-time even though a complete theme owns strings, shadow vectors, and a categorical sequence. A subtree exception uses theme.clone().modify(|data| ...); this copy-on-write boundary preserves the source theme and rebuilds palette lookups if the callback changes the palette.

Ownership boundary

Configurability depends on where a value is owned, not on whether it has a descriptive local variable name. Production code follows this boundary:

OwnerValues that belong there
JSON tokenspalette and semantic colours; reusable alpha ladders and effects; spacing; entity radii; typography; control scale; cross-component measures; elevation; layer order; opacity roles; shared motion duration, easing, spring, and response
Theme adaptertyped access to one token, and complete product-neutral recipes composed only from tokens, such as a semantic wash, border strength, variant colour set, focus ring, or overlay surface
Componentcaller-owned data; transient interaction state; composition of shared recipes; one component's named layout topology, hit-test geometry, asset geometry, data visualization encoding, or animation keyframe shape
Scene or testfixture values and assertion geometry, which are deliberately not production style authority

A value moves into JSON when changing it should retune more than one component or one shipped theme. A one-off width can stay local because it describes a component's topology; a readable-copy width cannot, because empty, error, media, and dock surfaces all make the same reading decision. Likewise, zero and one may stay as mathematical endpoints of a gradient or interpolation, but the alpha of a semantic wash belongs to a token even if only one call site happened to expose the omission first.

cargo run -p xtask -- tokens check enforces the consumption side with a Rust AST pass over gpui-kit-theme and production gpui-kit source. It rejects anonymous non-zero spacing, radius, typography, and font-weight literals; literal alpha in theme recipes; literal alpha on theme colour roles; and anonymous non-endpoint colour alpha in components. Local visualization policy must therefore be named, while shared appearance policy must be token-backed. Scenes and test-only items are excluded because they are consumers and fixtures, not authorities.

Color

Colors use #RRGGBB, #RRGGBBAA, or a {group.step} palette reference. Alpha is part of a token where it expresses a reusable wash, hairline, or effect. Application views do not invent new palette colors.

Large planes use surface roles. Accent and semantic colors remain compact.

Contrast

TokenDocument::parse, TokenDocument::validate, and therefore ThemeRegistry::register_json reject a theme that drops below its contrast floor. The error names every failing foreground/background pair, its measured ratio, and its required ratio. The floors are 4.5:1 for body text and text.onAccent over semantic.accent, and 3.0:1 for text.faint and status colors, which never carry required instructions on their own. semantic.accentStrong is an emphasis, border and hover color rather than a text-bearing fill, so it is held to the non-text minimum. cargo run -p xtask -- tokens check applies the same contract to the bundled documents.

Lines a pointer acts on, and lines that only divide

In-content control definition is a separate material role: color.surface.control, controlHover, and controlPressed are opaque fills, with color.interactive.controlHairline below 3:1 and a one-pixel top inset controlHighlight. These are not plane-ladder surfaces and do not use backdrop glass. Labels retain their text contrast floors on every control state. Knobs reuse elevation.raised rather than introducing a duplicate effect.controlKnobShadow scale. Required effect.fieldFocus is a typed "ring" | "fill" choice for editable fields only: ring adds the normal focus halo, while fill uses surface.controlHover with no focus shadow. The caret is unchanged. Disabled fields suppress focus treatment; invalid fields retain their danger wash and glow even when disabled or focused. Resting control shadows and interactive.controlHairline remain independent. All bundled themes select ring. Missing or unknown values fail parsing, with no fallback. Other controls retain their focus rings; selection remains tonal fill. Rules and dividers still do not carry the 3:1 floor.

The 3:1 non-text floor asks whether a reader can find a boundary they have to aim at. It is the right question for interactive.track and interactive.hairlineStrong — a slider rail, a switch edge, a scrollbar gutter, a resize seam — and those two are checked against every surface.

It is the wrong question for interactive.hairline and interactive.divider. Nobody aims at a rule between two menu groups; it only has to be seen without being noticed. Holding them to 3:1 against the darkest and the lightest surface in the theme forces alphas in the 35–55% range, and a line that strong drawn around every card, table header, menu, and toolbar is exactly the boxed-in look a borderless library exists to avoid. That was not theoretical either: it is what studio-dark and studio-light shipped, at hairline 35% and divider 40%, and every scene in the catalog wore a grid.

So the two roles carry a different gate. Each is composited over each of the six surfaces, and the result must stand at least 1.5 L* away from the surface it was drawn on:

RoleGateQuestion it answers
track, hairlineStrong3:1 contrast ratiocan a reader aim at it?
hairline, divider1.5 L* separationcan a reader see it at all?

The floor is a floor, not a target. A line that clears 3:1 passes it too, which is why the library's own test asserts the reverse direction as well: the bundled themes must keep their decorative lines below the control-boundary minimum.

TokenError::Line reports a violation, naming the role, the surface it disappeared against, and its measured separation. contrast::line_report returns the full table.

Surface separation

A contrast ratio answers whether a foreground is legible on a background. It does not answer whether two backgrounds are distinguishable, and the two questions need different measures. The WCAG ratio adds 0.05 to both sides so that black text stays measurable, which compresses everything near black into almost no range: #050505 behind #0a0a0a reports 1.03:1, and so does a step a reader can point at. Near white it fails the other way, by reporting a comfortable-looking number for a step nobody can see.

So surfaces are compared in signed CIE L* lightness differences. In Dark, every checked nesting must gain at least 2 L* over the surface behind it. In Light, structural and elevated pairs must be non-descending (minimum 0 L*); only canvas over sunken and panel over sunken retain the 2 L* minimum so recessed wells remain visible:

SurfaceBehind
canvasabove backdrop
panelabove backdrop, and above canvas
sunkenbelow canvas, and below panel
raisedabove panel
overlayabove panel, and above canvas

Light may intentionally use the same white for backdrop, canvas, panel, raised, and overlay. Elevation need not be a tinted upward color ladder; equal backgrounds do not waive foreground, focus, nontext, or decorative-line readability. Reversed elevation is still invalid. Dark retains a visibly ascending ramp at every checked nesting.

backdrop is the substrate behind the page. It is checked against canvas and panel and not against sunken: a well never sits on the substrate, it sits in a panel or on the page, and holding those two apart as well would collapse the dark ramp. overlay is checked against what it opens over rather than against raised, because a popover and a code block never touch.

The 2 L* floor prevents collapsed Dark planes and missing Light recesses. Themes may spread their ramps wider, as the bundled palettes do, without making that palette choice a requirement for every Light document.

ThemeRegistry::register_json rejects a violation with TokenError::Separation, naming each nesting, its measured distance and its minimum.

Tone distinction

The same argument applies to the foreground, and the same measure answers it. muted, faint, placeholder and disabled are four different facts, not four intensities of one: information that is secondary, detail that supports it, a description of a value that is not there, and a value that is there and cannot be used. A theme that gives two of them one colour has not styled them alike, it has stopped saying which one holds, and this library's own rule is that unavailable and absent are distinct states rather than degrees of the same one.

Each rung must therefore stand at least 3 L* closer to the page than the one above it:

ToneStands closer to the page than
mutedprimary
faintmuted
placeholderfaint
disabledplaceholder

Distance from the page, rather than lightness, is what makes one rule hold in both appearances: a dimmer fact is darker in a light theme and lighter in a dark one, and both are the same movement toward the canvas.

This rule also exists because its absence was not theoretical. studio-dark drew faint, placeholder and disabled in one grey. Every contrast pair passed — all three were perfectly legible — and eight reviewers reported across a dozen scenes that unavailable values were unreadable. They were not unreadable. They were indistinguishable, which the contrast table had no way to say.

TokenError::Distinction reports a violation, naming each pair, its measured distance and its minimum.

The series scale

color.sequence.categorical is an ordered list of exactly eight colors, and the length is part of the contract: TokenDocument::validate rejects any other count, and a caller reading past the end wraps rather than running out.

It exists because a chart with no categorical scale draws its series in the only thing it has, which is one hue at four lightnesses — and that tells a reader the four slices of a donut are four degrees of one quantity. The scale separates them by hue instead: indigo, teal, amber, pink, cyan, lime, violet, orange. Dark themes take a lighter step of each ramp and light themes a darker one, and every entry is held to the 3:1 identity floor on each of the three surfaces a chart is drawn on — canvas, panel and raised. Not backdrop: that is the substrate behind the page, no plot is drawn on it, and holding a light theme's scale to it would darken eight colours for a surface nobody uses.

The node canvas

color.node.* is the vocabulary a graph is read in. Every value in it exists because a canvas drawn from the control vocabulary alone says nothing: with one grey for every port and one for every edge, a reader cannot tell what is attached to what without following each line by eye.

RoleWhat it saysGate
portIdlenothing is attached here1.5 L* on canvas
portConnectedan edge lands here3:1 on canvas, and 3 L* further from the page than portIdle
edgea resting connection1.5 L* on canvas
edgeActivetraffic, or the pointer3:1 on canvas
grid, gridStrongthe dot grid and its major interval1.5 L* on canvas
labelWashthe chip under an edge labeltext.muted at 4.5:1 on it, over bare canvas and over an edge
headerWasha node's header band, uncategorised1.5 L* on raised

The two floors are the ones the rest of the document already uses, and they are assigned by the same question. A canvas is mostly edges and grid, so drawing either at a control boundary's loudness turns a graph into a mesh: those carry the line rule, which only asks whether they were drawn at all. A connected port and a live edge are what a reader scans for, so they carry the identity floor. And the port pair carries the tone ladder's rule as well, signed the same way, because attached is the louder fact in both appearances.

labelWash is checked twice on purpose. A chip that is legible on the bare canvas and too thin to cover the line running under it is exactly the label nobody can read, and the first check passes it happily.

Elevation, layers, and density

elevation describes the shadow each surface casts. A step is an ordered set of layers, not a single offset: in the bundled themes, flat is empty, and raised, overlay and modal each carry two downward casts. There is no horizontal offset; a close contact shadow is y plus blur.

The two layers are an ambient contact shadow and a key. The ambient layer is tight, close and about 60% of the key's alpha; the key is the soft, further cast that was already there. A single cast puts a surface at one distance from the page in every direction at once, which is why one-layer elevation reads as a sticker printed on the page rather than as a thing above it — the contact shadow is what says the surface touches something. The ambient layer is deliberately inside the key's reach, so the ordering of the four steps is unchanged: reach is still the farthest y + blur in the set, and that is still the key's.

Steps are ordered by reach — the farthest y + blur in the set — and TokenDocument::validate requires that reach to increase strictly from flat to modal, with one exception: a Light theme may leave both flat and raised empty, genuinely doing no shadow work at either step. Equal nonzero reaches and nonempty zero-reach layers do not qualify. Every other adjacent pair, and every pair in Dark, must still increase strictly. zIndex fixes the paint order of floating surfaces, and density scales spacing, control geometry and type independently. Density is applied when a Theme is built, and gpui_kit::set_density rebuilds the active theme and repaints every window. Colors and radii never change with density.

Themes at runtime

ThemeRegistry holds every registered document. An application registers its own JSON with ThemeRegistry::register_json, replacing a bundled theme when it reuses its id, and switches with gpui_kit::activate_theme.

Typography

The theme provides Geist and Geist Mono. Kit type styles carry bundled Noto Sans Arabic and Noto Sans Hebrew as an explicit ordered fallback chain rather than depending on fonts installed by the host. Size, line-height, and weight travel as one TypeStep; consumers should not mix a size from one step with line-height from another.

typography.readoutScale is the one intentional step beyond that prose ladder. An AnimatedNumber still lets its caller choose the base TypeScale, then multiplies size and line-height by this theme-owned factor so a numeric readout does not turn Title into a globally larger heading style.

Static Medium, SemiBold, and Bold font files are included because not every GPUI text backend applies a variable font's weight axis.

Motion and effects

Motion tokens store duration and cubic-bezier control points. Component motion evaluates CSS-compatible curves through the pure CubicBezier implementation. motion.staggerMaxItems and durationMs.staggerStep jointly bound a row wave; the public Stagger::rows preset takes the theme explicitly so the token is not bypassed by a default constructor. Micro bounce, wobble, and pop timings also live in durationMs; their keyframe shapes remain local component topology.

Overlay surfaces use Regular Liquid by default: effect.glassFrostBlur, glassSaturation and shader-owned achromatic glassWash separate reading content from its backdrop. Rim refraction samples the scattered (blurred) source, so the edge bends colour bands and luminance without recognizable background details. Clear and Lens naturally remain sharp at their default blur = 0; adding blur scatters their rim source too. effect.glassAlpha is Frosted's source-over fill, never an adaptive Regular fill. Adaptive appearance only flips small controls; large reading surfaces retain the host appearance. glassTransmissionGain multiplies transmitted backdrop light and glassOpticalLift adds light in the material.

Clear belongs above media and always carries light color.onMediaForeground content. Only Clear honors dimmed(true), using effect.glassDimming (35%). The host-owned Reduce transparency preference resolves Regular/Lens to Frosted and Clear to dark color.onMediaBackground Frosted with light content; it is not a token or a platform setting read by Kit.

The optical profile scales from glassBevelRatio times each control's short edge and is bounded by glassBevelMin/glassBevelMax. Refraction and dispersion are independent ratios; glassHairline remains one logical pixel rather than growing with the control. A Frosted surface at glassAlpha 1 is opaque and does not paint backdrop work. GPUI Box Kit does not fake optics with a gradient, because the colour behind a translucent window is not a colour anything can paint.

Selection and focus live here too, and they are drawn differently on purpose: selection says which answer is current, focus says where the next keystroke goes, and a reader who cannot tell them apart cannot tell what pressing a key would do. effect.focusRingWidth and effect.focusRingAlpha draw an outset halo in color.interactive.focus around whatever holds the keyboard. The halo uses blur instead of a hard counter outline; a control with a different resting fill resolves the same single halo to a readable pole on that fill. Selection uses the stronger tonal fill at color.interactive.selected, never an outline or an edge rail. Component-owned state washes distinguish changed lines, matched ranges, and drop targets without adding another selection vocabulary.

Gradients are not a token group. This library composes one from a base colour and an alpha ladder, the way Theme::glow composes a bloom from color plus effect.glowAlpha, glowBlur and glowSpread, so what a theme owns is the scalars:

TokenWhat it sets
effect.sheenAlphathe strength of a top-edge highlight on a raised surface
effect.areaWashAlphathe alpha an area fill starts at under a chart line, fading to nothing at the baseline
effect.headerTintAlphahow strongly a node header band takes its category colour
effect.nodeActive*Alpha, nodeTrafficAlpha, nodePreviewAlpha, nodeMinimapAlphathe node-canvas paint ladder for active routes, traffic, connection previews, and minimap identity
effect.semanticWash*Alphareusable weak, normal, and strong semantic-colour backgrounds
effect.semanticBorderAlpha, accentBorder*Alphareport, selected, and active-target boundary strengths
effect.variant*Alphathe shared Light and Subtle state ladders
effect.railWidthhow wide an identity rail is, in pixels
effect.customColorReadable*, customColor*LightnessDeltafallback readability and interaction ladders for caller colours that have no authored palette ramp

Surface sheen, chart area wash, and node header tint are lower in light themes than in dark ones. A wash or a highlight is read as a departure from the surface under it, and a light theme's surfaces sit near the top of the range: the same alpha that lights a dark card's edge has nowhere to go on a near-white one and reads as a smudge, and the same wash under a chart line swamps the line it is meant to support.

effect.railWidth belongs only to an identity rail: it says what a thing is — a node's category or a callout's severity — and is drawn whether or not anybody is looking at it. Selection is deliberately not another rail use; its material wash says which answer is current without adding an ornamental line.

Validation

TokenDocument::validate rejects:

  • a missing schema declaration or unknown fields at any level;
  • invalid RGB/RGBA literals and unresolvable palette references;
  • empty metadata;
  • negative geometry and non-increasing spacing or control heights;
  • invalid type size, line-height, weight, or a readout scale below one;
  • effect and opacity alpha outside 0–1, or a non-positive focus ring width;
  • crossed custom-colour readability or interaction ladders;
  • negative elevation blur, or elevation steps whose reach (y + blur of the farthest layer) is not strictly increasing, except for Light flat and raised when both layer sets are empty;
  • z-index layers that are not strictly increasing;
  • density factors outside 0.5–1.5, or a comfortable axis that is not 1;
  • a non-positive identity rail width;
  • a series scale that does not carry exactly eight colors;
  • required foreground/background pairs below their contrast floor;
  • a decorative line, a canvas grid or a resting edge that composites into a surface it is drawn on;
  • an idle and a connected port a reader cannot tell apart.

Run:

cargo run -p xtask -- tokens generate
cargo run -p xtask -- tokens check