Shell, Style, and Design Token System
The portal-style crate is the single source of truth for every visual decision in the Portal desktop environment. It defines colors, typography, spacing, shadows, border radii, animation timings, icon specifications, and a runtime theme manager with hot-reload support — all as typed tokens with semantic names. The portal-shell crate provides the scaffold for shell-level UI components (status bar, notifications). Together, these two crates enforce visual consistency across every surface that renders pixels for the user, from the launcher to the keyboard to the AR glasses display.
The critical design principle is that portal-style defines types and resolution logic, but never renders anything. Consumers (shell, launcher, keyboard daemon, compositor plugins) read these tokens and translate them into Cairo, Pango, GLES, or wgpu draw calls. This separation eliminates circular dependencies and keeps the style crate as a leaf node in the dependency graph — everything depends on it, it depends on nothing except portal-common.
Sources: portal/style/src/lib.rs#L1-L87, openspec/specs/style/style-crate-v3.0-implementation-spec.md#L113-L163
Architecture Overview
Section titled “Architecture Overview”The system follows a layered architecture where raw primitives at the bottom compose upward into semantic roles, then into complete themes, and finally into per-component overrides. This ensures that a change to a single brand color automatically propagates through every dependent token.
graph TB
subgraph "Primitive Layer"
Color["Color (RGBA f32)"]
CS["ColorSpace<br/>SRgb / DisplayP3"]
OKLCh["OKLCh Conversion<br/>Perceptual Operations"]
end
subgraph "Token Layer"
Palette["Palette<br/>28 ColorRoles"]
Typography["Typography<br/>FontFamily, TextStyle"]
Spacing["SpacingScale<br/>7 tokens (2–48px)"]
Radius["Radius<br/>6 levels (0–16px)"]
Shadow["Elevation / Shadow<br/>5 levels"]
Transition["Transition<br/>Duration + Easing"]
Icon["Icon System<br/>Names, Sizes, Gradients"]
end
subgraph "Theme Layer"
Theme["Theme Aggregate<br/>Palette + Spacing + Radii<br/>+ Transitions + Overrides"]
Overrides["ComponentThemeOverrides<br/>Launcher, Shell, Keyboard<br/>Notifications"]
end
subgraph "Runtime Layer"
Manager["DefaultThemeManager<br/>Arc swap, version counter<br/>Hot-reload watcher"]
Channel["ThemeEvent Channel<br/>bounded(16)"]
end
Color --> Palette
CS --> Palette
OKLCh --> Color
Palette --> Theme
Typography --> Theme
Spacing --> Theme
Radius --> Theme
Shadow --> Palette
Transition --> Theme
Icon --> Overrides
Overrides --> Theme
Theme --> Manager
Manager --> Channel
Sources: portal/style/src/theme/theme.rs#L22-L42, portal/style/src/lib.rs#L28-L38
Color Primitives: The Foundation
Section titled “Color Primitives: The Foundation”Every color in the Portal UI is a Color struct — four f32 components (r, g, b, a) each clamped to [0.0, 1.0] at construction. The struct uses #[repr(C)] for ABI compatibility with native rendering backends, and implements a manual Eq trait because the clamping invariant guarantees no NaN values can exist.
The color module provides multiple construction paths: direct float constructors, u8 constructors (rgb_u8, rgba_u8), hex string parsing (#RGB, #RRGGBB, #RRGGBBAA), CSS rgba() string parsing, and OKLCh string parsing. All perceptual adjustments (lighten, darken, desaturate, adjust hue) operate in OKLCh color space — a perceptually uniform model that ensures human-expected results when shifting brightness or saturation. Porter-Duff “source over” compositing is available for layering translucent surfaces.
Sources: portal/style/src/color/mod.rs#L46-L94, portal/style/src/color/constructors.rs#L10-L211, portal/style/src/color/ops.rs#L11-L123
WCAG 2.1 Contrast Validation
Section titled “WCAG 2.1 Contrast Validation”The style crate enforces accessibility through WCAG 2.1 contrast ratio calculations. Three compliance levels are tracked: AA (4.5:1 for normal text), AALarge (3.0:1 for large text), and AAA (7.0:1). The contrast ratio computation uses relative luminance — a nonlinear transform from sRGB to linear space followed by the standard WCAG formula (lighter + 0.05) / (darker + 0.05).
| Method | Threshold | Use Case |
|---|---|---|
meets_aa_normal() |
≥ 4.5:1 | Body text, labels, buttons |
meets_aa_large() |
≥ 3.0:1 | Headings, large UI elements |
meets_aaa_normal() |
≥ 7.0:1 | Enhanced accessibility mode |
Sources: portal/style/src/color/ops.rs#L57-L78, portal/style/src/palette/contrast.rs#L1-L76
Brand Palette Constants
Section titled “Brand Palette Constants”The Demain brand identity is encoded as compile-time constants on the Color type. Pure black backgrounds, pure white text, and a signature cyan accent (rgb(100, 200, 255)) form the core identity. Semantic colors — green for success, orange for warning, red for destructive — each include three tiers: a solid foreground variant, a low-alpha background variant (4% opacity), and a border variant (18% opacity).
| Constant | RGB Value | Purpose |
|---|---|---|
DEMAIN_BG |
#000000 |
Background |
DEMAIN_FG |
#FFFFFF |
Primary text |
DEMAIN_ACCENT |
#64C8FF |
Brand accent (cyan) |
DEMAIN_GREEN |
#2ED573 |
Success |
DEMAIN_ORANGE |
#FF9F43 |
Warning |
DEMAIN_RED |
#FF4757 |
Destructive |
DEMAIN_SHIMMER_A |
rgba(180, 180, 180, 0.80) |
Shimmer animation |
Sources: portal/style/src/color/constants.rs#L7-L40
Display-Target-Aware Adjustment
Section titled “Display-Target-Aware Adjustment”Portal renders to multiple display targets — the compositor panel, AR glasses, and overlays. AR glasses receive a subtle 8% lightness boost via adjust_color_for_target() to compensate for the see-through OLED display physics. A more sophisticated display_target_aware_color_adjust() function iteratively lightens or darkens a color until it reaches a target contrast ratio, with AR glasses requiring a 10% higher target ratio than other displays.
Sources: portal/style/src/color/color_space.rs#L18-L23, portal/style/src/palette/adjust.rs#L8-L47
Semantic Color Roles and the Palette
Section titled “Semantic Color Roles and the Palette”The ColorRole enum defines 28 semantic color tokens with explicit #[repr(u8)] discriminants, ensuring stable array indexing and forward-compatible serialization. Roles are grouped into five categories:
| Category | Roles | Count |
|---|---|---|
| Backgrounds | Background, Surface, SurfaceHover, SurfaceRaised, SurfaceOverlay | 5 |
| Text | TextPrimary, TextBody, TextSecondary, TextMuted, TextDim | 5 |
| Accent | Accent, AccentHover, AccentMuted, AccentBorder | 4 |
| Semantic | Success/Warning/Destructive × {solid, muted, border} + Info | 10 |
| Borders & Overlay | Border, BorderCard, BorderFocus, Scrim | 4 |
The Palette struct holds a fixed array of [Color; 28] plus a BTreeMap<ColorRole, f32> for runtime alpha overrides. The BTreeMap (chosen over HashMap for deterministic serialization) allows individual color roles to have their opacity adjusted without mutating the underlying color. Resolution is a two-step process: array index lookup (O(1)) followed by an optional BTreeMap lookup for alpha override.
Sources: portal/style/src/palette/color_role.rs#L1-L80, portal/style/src/palette/palette.rs#L12-L160
Built-in Themes
Section titled “Built-in Themes”Two built-in palettes ship with the crate. Demain Dark is the default and primary theme — pure black background, white text at varying opacities (85%, 45%, 20%), and the signature cyan accent. Demain Light inverts this: white background with black text at corresponding opacity tiers. Both themes share the same accent and semantic colors; only background/text roles differ. Deprecated portal_dark()/portal_light() aliases maintain backward compatibility.
Sources: portal/style/src/palette/themes.rs#L11-L93
Palette Contrast Validation
Section titled “Palette Contrast Validation”The validate_contrast() method checks all text roles against background and surface roles at load time. Normal text roles require AA (4.5:1) against backgrounds; muted text requires AALarge (3.0:1). Surface checks are skipped for surfaces below 10% opacity (translucent layers that don’t serve as readable backgrounds). Violations are classified as Critical (below 2:1) or Warning (below threshold but above 2:1).
Sources: portal/style/src/palette/palette.rs#L55-L116, portal/style/src/palette/contrast.rs#L38-L70
Design Token Scales
Section titled “Design Token Scales”Spacing
Section titled “Spacing”The spacing system uses a 7-step scale with values optimized for spatial UI layouts. All values are in device-independent pixels:
| Token | Value | Typical Use |
|---|---|---|
Xs |
2.0px | Tight gaps, micro-padding |
Sm |
4.0px | Small padding, form gaps |
Md |
8.0px | Default gap, list spacing |
Lg |
16.0px | Card padding, section spacing |
Xl |
24.0px | Large section gaps |
Xxl |
32.0px | Page-level spacing |
Xxxl |
48.0px | Hero-level spacing |
The Padding struct provides directional padding (top, right, bottom, left) with convenience constructors (all(), horizontal(), vertical()). SpacingScale includes preset padding patterns like compact_padding() (2px/4px) and card_padding() (16px uniform).
Sources: portal/style/src/spacing.rs#L1-L179
Border Radius
Section titled “Border Radius”Portal follows a sharp-corners aesthetic — no pill shapes, no fully rounded containers. The radius scale spans six discrete levels from 0px to 16px, with CustomRadius clamped to a 16px maximum to enforce the design constraint. The ComponentRadii struct assigns default radii to each component type (buttons, cards, inputs, modals, notifications, tooltips), all defaulting to Radius::Md (8.0px).
Sources: portal/style/src/radius.rs#L1-L75
Shadow and Elevation
Section titled “Shadow and Elevation”Shadows are elevation-based and theme-aware — the shadow alpha changes depending on whether the palette is dark or light. Five elevation levels map to specific shadow parameters:
| Elevation | Offset Y | Blur | Spread | Alpha (Dark/Light) |
|---|---|---|---|---|
None |
0 | 0 | 0 | 0 / 0 |
Low |
1 | 3 | 0 | 0.08 / 0.10 |
Medium |
4 | 8 | -1 | 0.12 / 0.16 |
High |
8 | 24 | -4 | 0.20 / 0.26 |
Overlay |
12 | 32 | -6 | 0.25 / 0.30 |
The border_for_elevation() function returns theme-aware border colors — white-tinted on dark themes, black-tinted on light themes — at increasing opacity for higher elevations. The Scrim struct defines modal (50%), dialog (40%), and notification (30%) backdrop overlays.
Sources: portal/style/src/shadow.rs#L1-L154
Transition and Animation
Section titled “Transition and Animation”Animation tokens consist of AnimDuration (5 preset levels: INSTANT, FAST 100ms, NORMAL 200ms, SLOW 300ms, DELIBERATE 500ms) and Easing (6 modes including cubic-bezier curves and spring physics). Seven named Transition presets cover the most common UI animation patterns:
| Preset | Duration | Easing | Use Case |
|---|---|---|---|
FADE |
200ms | Ease | Element show/hide |
PRESS |
100ms | EaseOut | Button press feedback |
SLIDE_IN |
200ms | EaseOut | Panel entrance |
SLIDE_OUT |
100ms | EaseIn | Panel exit |
OVERLAY_ENTER |
300ms | Ease | Modal/overlay enter |
OVERLAY_EXIT |
200ms | EaseInOut | Modal/overlay exit |
SPRING |
200ms | Spring | Physics-based motion |
Spring easing maps to SpringParams { stiffness: 300.0, damping: 20.0, mass: 1.0 } and returns None for cubic-bezier conversion (springs require physics simulation, not Bézier curves).
Sources: portal/style/src/transition.rs#L1-L119
Typography
Section titled “Typography”The typography system centers on Science Gothic as the sole UI font, with JetBrains Mono/Fira Code for monospace contexts. FontWeight clamps to the CSS range [100, 1000], rejecting invalid values like 0 at construction. Nine font sizes range from XS (11px) to HERO (56px), with three line-height presets (TIGHT 1.15, NORMAL 1.55, RELAXED 1.65, LOOSE 1.8) and six letter-spacing levels.
The TextStyle struct supports two builder patterns: consuming (.family(), .weight(), .size()) for one-time construction, and immutable (.with_color_role(), .with_text_transform(), .scaled()) for deriving new styles from presets without consuming the original. Twelve preset styles (body(), caption(), label(), overline(), monospace(), hero(), heading_1() through heading_4()) provide standard starting points.
| Preset | Size | Weight | Line Height | Role |
|---|---|---|---|---|
body() |
14px | 400 | 1.55 | TextBody |
body_secondary() |
13.5px | 400 | 1.55 | TextSecondary |
caption() |
11px | 400 | 1.15 | TextMuted |
label() |
13.5px | 500 | 1.55 | TextPrimary |
overline() |
11px | 600 | — | TextSecondary |
hero() |
56px | 700 | 1.15 | TextBody |
heading_1() |
32px | 700 | 1.15 | TextBody |
heading_2() |
24px | 600 | 1.15 | TextBody |
Sources: portal/style/src/typography.rs#L12-L371
Icon System
Section titled “Icon System”Icons are specified by name (well-known constants like LAUNCHER, VOICE, SETTINGS), size (5 standard levels from 12px to 32px, custom clamped to [1, 128]), and geometric shape (Circle, Triangle, Square, Diamond, Hexagon — no emoji). The IconStyle struct resolves its color through a ColorRole at render time by querying the palette. Gradients support linear (with angle) and radial (with center position) variants, including the signature demain_shimmer() gradient.
Sources: portal/style/src/icon.rs#L1-L200
Theme Composition and Management
Section titled “Theme Composition and Management”The Theme Aggregate
Section titled “The Theme Aggregate”A Theme bundles all token systems into a single serializable struct: palette, spacing scale, component radii, animation transitions, per-component overrides, and an optional typography scale override for accessibility. Two built-in constructors — Theme::demain_dark() and Theme::demain_light() — produce the default themes. Every field except name and display_name falls back to system defaults, so a TOML theme file can override as little or as much as needed.
Sources: portal/style/src/theme/theme.rs#L22-L87
Per-Component Overrides
Section titled “Per-Component Overrides”Four override structs allow themes to customize individual components without affecting the global palette:
| Override | Fields | Key Customizations |
|---|---|---|
LauncherThemeOverride |
11 | Search/result radius, overlay colors, glasses-specific font scale and result count |
ShellThemeOverride |
4 | Top bar background, height, tray icon color, clock style |
KeyboardThemeOverride |
19 | Key colors/borders/gaps, 8 glow colors (typing, pointing, gesturing, system, highlight, idle, error, committed), glow animation params |
NotificationThemeOverride |
15 | Surface/border/radius, slide-in/fade-out transitions, 4 priority color roles + 4 priority icon shapes, title/body text styles |
All fields are Option<T>, so an override only changes what it specifies. The ComponentThemeOverrides container aggregates all four as optional fields.
Sources: portal/style/src/theme/overrides.rs#L1-L143
Theme Manager: Atomic Swapping at Runtime
Section titled “Theme Manager: Atomic Swapping at Runtime”The ThemeManager trait defines the contract for runtime theme state. The default implementation, DefaultThemeManager, uses a lock-free atomic swap pattern: the active theme lives behind an Arc<RwLock<Arc<Theme>>>. Reading the active theme is an RwLock read lock followed by an Arc clone (~5ns on ARM64). Theme swaps write a new Arc<Theme> under a write lock, atomically increment a version counter, and broadcast a ThemeEvent through a bounded crossbeam channel.
sequenceDiagram
participant Caller
participant Manager as DefaultThemeManager
participant Inner as DefaultThemeManagerInner
participant Channel as crossbeam (bounded 16)
participant Watcher as File Watcher (optional)
Note over Caller,Watcher: Explicit theme switch
Caller->>Manager: set_active_theme("my-custom")
Manager->>Inner: load_theme("my-custom")
Inner->>Inner: resolve_theme_path → parse TOML
Inner-->>Manager: Arc<Theme>
Manager->>Manager: apply_validation_policy()
Manager->>Inner: RwLock write → swap theme
Manager->>Inner: version.fetch_add(1)
Manager->>Channel: try_send(ThemeEvent)
Channel-->>Caller: Receiver receives event
Note over Caller,Watcher: Hot-reload path (optional feature)
Watcher->>Inner: debounced_reload(path)
Inner->>Inner: check debounce timer (100ms)
Inner->>Inner: load_theme_from_file(path)
Inner->>Inner: RwLock write → swap theme
Inner->>Inner: version.fetch_add(1)
Inner->>Channel: try_send(ThemeEvent, HotReload)
The ThemeEvent carries the new theme as an Arc<Theme>, a monotonically increasing version number, and a ThemeChangeSource (Explicit, HotReload, or Initial). Consumers poll the channel receiver; if the bounded channel (capacity 16) is full, try_send silently drops the event — consumers can always call active_theme() for the latest state, making events idempotent.
Sources: portal/style/src/theme/manager.rs#L1-L85, portal/style/src/theme/manager_impl.rs#L1-L276, portal/style/src/theme/default_manager.rs#L1-L127
Hot-Reload via File System Watching
Section titled “Hot-Reload via File System Watching”When compiled with the hot-reload feature (which pulls in the notify v6 crate), the manager starts a recursive file watcher on the configured theme directory. On detecting a .toml file modification, a debounced reload (100ms minimum interval) re-parses the file, swaps the theme atomically, and broadcasts a ThemeEvent with HotReload source. The watcher holds a Weak<DefaultThemeManagerInner> reference, so if the manager is dropped, the watcher callback becomes a no-op rather than keeping the manager alive.
Sources: portal/style/src/theme/manager_impl.rs#L131-L169, portal/style/src/theme/default_manager.rs#L41-L92
TOML Theme File Format (V1)
Section titled “TOML Theme File Format (V1)”Custom themes are loaded from TOML files discovered in the theme directory. Two layout patterns are supported: {dir}/{name}.toml (flat) and {dir}/{name}/theme.toml (nested). The deserialization uses ThemeFileV1 with #[serde(deny_unknown_fields)] for version-scoped schema enforcement, and every section is optional — unspecified sections fall back to the built-in Demain Dark defaults.
Sources: portal/style/src/theme/loader/mod.rs#L21-L150
Configuration and Environment
Section titled “Configuration and Environment”The StyleConfig struct controls runtime behavior with sensible defaults and environment-variable overrides:
| Field | Default | Env Override | Purpose |
|---|---|---|---|
theme_directory |
~/.config/portal/themes |
PORTAL_THEME_DIR |
Theme file discovery |
hot_reload_enabled |
true |
PORTAL_HOT_RELOAD |
Enable file watcher |
active_theme |
"demain-dark" |
PORTAL_THEME |
Initial theme name |
watch_debounce_ms |
100 |
— | Reload debounce interval |
font_scale |
1.0 |
PORTAL_FONT_SCALE |
Global font multiplier |
enforce_contrast |
true |
— | WCAG validation at load |
force_color_space |
None |
— | Override sRGB/P3 |
on_validation_failure |
Reject |
— | Reject or WarnAndApply |
The ValidationPolicy enum controls what happens when a loaded theme fails WCAG contrast checks: Reject (default) returns an error and falls back to the previous theme, while WarnAndApply logs warnings through tracing but applies the theme anyway.
Sources: portal/style/src/config.rs#L11-L121
Shell Layer: Status Bar and Notifications
Section titled “Shell Layer: Status Bar and Notifications”The portal-shell crate (v0.2.0) is currently a scaffold with foundational data structures. The NotificationManager maintains an ordered vector of Notification records with monotonic IDs and four priority levels (Low, Normal, High, Urgent). The StatusBar manages a list of StatusItem entries — each with an ID, label, and optional icon — supporting insertion-order-preserving add/remove operations.
The shell crate depends only on thiserror at the production level, keeping it lightweight. Its error type ShellError covers notification errors, status errors, and I/O errors. The shell crate is designed to consume theme tokens from portal-style — specifically the ShellThemeOverride (top bar background, height, clock style) and NotificationThemeOverride (15 fields covering notification visual behavior).
Sources: portal/shell/src/notifications.rs#L1-L90, portal/shell/src/status.rs#L1-L56, portal/shell/src/error.rs#L1-L20
Error Handling
Section titled “Error Handling”The style crate’s StyleError enum covers six failure modes:
| Variant | Trigger | Recovery |
|---|---|---|
ThemeNotFound |
Theme name not in built-ins or directory | Fall back to demain-dark |
InvalidColor |
Malformed hex/rgba/oklch string | Reject the theme |
ParseError |
TOML deserialization failure | Reject the theme |
ValidationError |
WCAG contrast check failure | Reject or WarnAndApply |
IoError |
File read failure | Reject the theme |
ConfigError |
Configuration parsing failure | Fall back to defaults |
All mutex poisoning is handled gracefully — poisoned locks are recovered via into_inner() with a tracing::warn! log, ensuring the system continues operating even after a panic in a dependent thread.
Sources: portal/style/src/error.rs#L1-L30, portal/style/src/theme/manager_impl.rs#L70-L77
Performance Profile
Section titled “Performance Profile”The style crate is benchmarked via Criterion on hot paths. The design choices — array-indexed palette resolution, Arc-cloning instead of memcpy, atomic version counters — target sub-microsecond performance on ARM64 (RK3588):
| Operation | Budget | Mechanism |
|---|---|---|
Palette::resolve() |
< 1ns | Array index + optional BTreeMap |
active_theme() |
< 5ns | RwLock read + Arc clone |
theme_version() |
< 2ns | AtomicU64 Relaxed load |
Color::contrast_ratio() |
~100ns | sRGB→linear + WCAG formula |
validate_contrast() |
< 200ns | 14 role-pair luminance checks |
Color::from_hex() |
~50ns | String parse + clamp |
| Theme load from TOML | ~10µs | File I/O + TOML parse + validate |
Sources: portal/style/benches/style_bench.rs#L1-L125, openspec/specs/style/style-crate-v3.0-implementation-spec.md#L78-L86
Feature Flags
Section titled “Feature Flags”| Feature | Default | Adds | Purpose |
|---|---|---|---|
hot-reload |
off | notify v6 |
File-system watcher for runtime theme reload |
telemetry |
off | StyleMetrics, StyleMetricsSnapshot |
Optional runtime metrics collection |
The hot-reload feature is opt-in because the notify crate adds ~50KB of transitive dependencies — acceptable for a production deployment, but unnecessary for test builds or consumers that don’t need live theme switching.
Sources: portal/style/Cargo.toml#L9-L13
Next Steps
Section titled “Next Steps”- Learn how the Universal Launcher applies these design tokens for its search overlay and action results: Universal Launcher: Action Registry, Fuzzy Search, and Plugin System
- Understand how the Keyboard Daemon uses
KeyboardThemeOverridefor key rendering and glow effects: Keyboard Daemon: DRM/KMS Direct Scanout, Cairo Rendering, and evdev Parsing - Explore how the Context Engine consumes theme events for template synthesis: Context Engine: Event Ingestion, Decision Making, and Template Synthesis
- See how testing validates the token system: Testing Strategy: Unit Tests, Property Tests, Fuzzing, and Benchmarks