Skip to content

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 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