← Documentation overviewDeveloper guide / CLIENT

One framework.
A concrete desktop.

Trace the production client from injected ports to launcher actions, bar layout, draggable widgets and independent native menus.

01 / THE EXECUTABLE CHOOSES

Adapters enter through
the composition root.

Desktop::new(DesktopAdapters) receives shared trait objects. The only desktop module that selects production service implementations is platform.rs. Tests can construct the same desktop with fake adapters, deterministic snapshots and bundled assets.

Live source · production dependency injection
pub fn desktop_adapters() -> DesktopAdapters {
    DesktopAdapters {
        apps: Arc::new(XdgApplications::default()),
        compositor: Hyprland::from_env()
            .ok()
            .map(|h| Arc::new(h) as Arc<dyn CompositorPort>),
        settings: Arc::new(JsonSettings::default()),
        theme: Arc::new(LucentTheme),
        palette_generation: Arc::new(lucent_services::wallpaper_catalog::ImagePalette),
        catalog: Arc::new(lucent_services::wallpaper_catalog::OnlineWallpapers),
        clock: Arc::new(LocalClock),
        system: Arc::new(LinuxSystem::default()),
        audio: Arc::new(lucent_services::audio::PulseAudio::default()),
        network: Arc::new(lucent_services::network::NetworkManager::default().with_secret_agent()),
        bluetooth: Arc::new(lucent_services::bluetooth::BlueZ::default()),
        displays: Arc::new(lucent_services::displays::HyprlandDisplays::default()),
        power: Arc::new(lucent_services::power::PowerServices::default()),
        tray: Arc::new(lucent_services::tray::StatusNotifier::default()),
        media: Arc::new(lucent_services::media::Mpris::default()),
        weather: Arc::new(OpenMeteo),
        wallpaper: Arc::new(LucentWallpaper),
        playback: Arc::new(lucent_services::video::NativeVideoWallpapers::default()),
        session: Arc::new(LucentSession),
        assets: Arc::new(NativeAssets),
        uri: Arc::new(lucent_services::applications::XdgUri),
        notifications: Arc::new(
            lucent_services::notifications::FreedesktopNotifications::with_history(Some(Arc::new(
                lucent_services::notifications::JsonNotificationHistory::default(),
            ))),
        ),
    }
}

The compositor is optional: without a valid connection, launching apps still works, but workspace/focus behavior is unavailable. AssetPort stays at the presentation boundary because it returns image data. Domain services return domain values.

The root implements the framework’s Component and Application contracts. Its launcher is a child controller with private state and typed messages. launcher::Input borrows current app, settings and appearance snapshots; it does not copy the desktop or retain adapter access. Notifications and wallpaper selection have their own child state. Widget coordination now follows the same explicit-input pattern; the bar remains root-owned.

02 / STATE HAS DIFFERENT LIFETIMES

What changes,
and where it lives.

LifetimeStateOwner / behavior
Durable preferencesDesktopSettingsWidget positions/visibility, pins, notes and palette mode. Loaded from the settings port; a revision-aware child coalesces edits before ordered save effects.
Observed external stateapps, compositor, clock, system, media, weather, wallpapersSnapshots and discovered data. Changed by worker results, not by view functions. Absence and failures follow each service’s current policy.
Interaction statelauncher::Launcher; widgets::HostLauncher privately owns mode/query/results/selection/scroll. The widget host privately owns drag origins/previews, calendar navigation and its timer. Typed messages change each feature’s state.
Presentation statepanel motions, carousel, images, viewportTime-dependent scalar transitions, decoded assets and current logical surface size. These are not domain entities.
Composed child statelauncher::Launcher, widgets::Host, notifications::Center, wallpaper_selection::SelectionEach owns a cohesive interaction or operation. The parent maps messages and effects and coordinates shared settings and adapters.

At startup, init queues settings and wallpaper discovery. Message::Loaded installs that configuration. Independently, the applications subscription watches the catalog through ApplicationPort and routes typed observations to the coordinator; loading, ready empty and unavailable states stay distinct. Asset loading has one active request and latest demand. A failed settings load leaves saving disabled to protect the original file.

Subscriptions provide compositor and notification streams, native media owner/property/seek observations, clock samples every second, system samples every five seconds and weather every fifteen minutes. These are independent worker streams. Updates compare relevant fields before repainting: a clock tick redraws the minute clock only when its displayed minute/date changes; a visible running timer can update each second.

persistence::Persistence is the sole producer for the settings queue. It owns one active revision and the latest immutable preference snapshot. New edits replace pending intent; an accepted write finishes first. Completion acknowledges its revision and starts a newer pending snapshot. Failure keeps the latest edit in memory and retains its error until a retry or newer save succeeds. Other action results cannot clear this feature-owned error.

Live source · coalesce preferences before worker admission
pub fn request(&mut self, settings: &DesktopSettings, effects: &mut Effects<Message>) {
        let revision = self
            .latest
            .as_ref()
            .map_or(0, |snapshot| snapshot.revision.0);
        let Some(next) = revision.checked_add(1) else {
            self.error = "Preference revision limit reached; restart before editing again".into();
            Self::redraw(effects);
            return;
        };
        self.latest = Some(Arc::new(Snapshot {
            revision: Revision(next),
            settings: settings.clone(),
        }));
        // An in-flight write is never cancelled or overtaken. Pending snapshots
        // replace each other without adding tasks to the runtime queue.
        self.start(effects);
        Self::redraw(effects);
    }

The client supplies save status to the Notes view. Retry returns a child intent; the root delegates it to persistence. The same operation is available as lucent-cli settings retry. The existing domain port and filesystem adapter are unchanged. A successful acknowledgement means the adapter completed its write/rename; it is not a new fsync or power-loss durability guarantee. Pending edits are not flushed automatically when the process is terminated.

The view reads snapshots; it does not fetch them. The renderer consumes the resulting scene; it does not know whether a value came from Hyprland, a fake adapter or a saved setting.

03 / KEYSTROKE TO APPLICATION

Follow one launcher action.

  1. Super+Space reaches the desktop command socket. Message::Launcher(launcher::Message::Toggle) is delegated to the child through Effects::delegate.
  2. The child opens Apps, clears query/selection, ranks cached applications through the pure search use case, retargets motion, and requests a dock redraw.
  3. launcher::View reads private child state, borrowed snapshots and decoded Artwork. Element::map(Message::Launcher) lifts its handlers into the root message type. No view calls an OS port.
  4. Typing emits launcher::Message::Query. Arrows update selection and its visible row window; Tab and Shift+Tab cycle the five modes. The child owns keyboard routing only while its dock surface is open.
  5. Enter returns Intent::Launch(AppId, false). The parent queues the existing launch effect and sends Close to the child. Palette, wallpaper and widget selections likewise return explicit intents for the parent to coordinate.
  6. The worker invokes the launch/focus use case with injected adapters. Message::Completed returns success or an actionable error to the UI thread. A dock click still passes prefer_running = true.
Live source · child messages and parent-facing intents
#[derive(Clone)]
pub enum Message {
    Toggle,
    Close,
    Compacted(u64),
    Submitted {
        height: f32,
        now: f64,
    },
    Mode(Mode),
    Query(String),
    EditQuery(TextEdit),
    Select(usize),
    Navigate(i32),
    Activate,
    OpenActions(Application),
    SelectedActions,
    Back,
    SelectAction(usize),
    ChooseAction {
        menu: u64,
        action: ApplicationActionId,
    },
    ActionFinished {
        revision: u64,
        result: lucent_domain::Result<()>,
    },
    Request(Intent),
}

#[derive(Clone)]
pub enum Intent {
    Error(lucent_domain::DomainError),
    Launch(AppId, bool),
    LaunchAction {
        revision: u64,
        app: Application,
        action: ApplicationActionId,
    },
    Pin(AppId),
    Action(Action),
    Wallpaper(usize, bool),
    Browser(wallpaper_browser::Message),
    ToggleWidget(String),
    ResetLayout,
    EditWidgets(bool),
    Palette(ThemePalette),
    ExtractPalette,
    LauncherSize(LauncherSize),
}
Live source · borrow snapshots, delegate effects, coordinate intent
fn update_launcher(&mut self, message: launcher::Message, effects: &mut Effects<Message>) {
        let was_open = self.launcher.open();
        let palettes = if self.launcher.mode() == Mode::Themes
            || matches!(message, launcher::Message::Mode(Mode::Themes))
        {
            self.palette_choices()
        } else {
            Vec::new()
        };
        let mut geometry = self.launcher_geometry();
        geometry.palettes = palettes.len();
        let input = launcher::Input {
            apps: &self.apps,
            application_status: self.catalog.status(),
            widgets_editing: self.active_widgets().editing(),
            settings: &self.settings,
            widget_layout: self
                .monitors
                .active
                .as_ref()
                .and_then(|id| self.settings.monitor_layouts.get(id))
                .unwrap_or(&self.settings.layout),
            palettes: &palettes,
            wallpapers: &self.wallpapers,
            wallpaper_index: self.wallpaper_index,
            browser: &self.browser,
        };
        let mut intent = None;
        effects.delegate(
            |child| intent = self.launcher.update(message, input, geometry, child),
            Message::Launcher,
        );
        self.update_dock(crate::dock::Message::Forced(self.launcher.open()), effects);
        self.panel_opened(Panel::Launcher, was_open, effects);
        if let Some(intent) = intent {
            use launcher::Intent;
            let message = match intent {
                Intent::Error(error) => Message::Completed(Err(error)),
                Intent::Launch(id, prefer) => Message::Launch(id, prefer),
                Intent::LaunchAction {
                    revision,
                    app,
                    action,
                } => {
                    self.launch_application_action(revision, app, action, effects);
                    return;
                }
                Intent::Pin(id) => Message::Dock(crate::dock::Message::Pin(id)),
                Intent::Action(action) => Message::Action(action),
                Intent::Wallpaper(index, apply) => Message::Wallpaper(index, apply),
                Intent::Browser(message) => Message::Browser(message),
                Intent::ToggleWidget(id) => Message::Widgets(widgets::Message::Toggle(id)),
                Intent::ResetLayout => Message::Widgets(widgets::Message::ResetLayout),
                Intent::EditWidgets(enabled) => {
                    self.update_launcher(launcher::Message::Close, effects);
                    Message::Widgets(widgets::Message::Edit(enabled))
                }
                Intent::Palette(palette) => Message::Palette(palette),
                Intent::ExtractPalette => Message::ExtractPalette,
                Intent::LauncherSize(size) => Message::LauncherSize(size),
            };
            self.update(message, effects);
        }
    }
    fn submitted_event(&self, event: Event) -> Option<Message> {
        match event {
            Event::FrameSubmitted {
                surface: "weather-controls",
                now,
                ..
            } if !self.weather.open() => {
                Some(Message::Weather(crate::weather::Message::Frame(now)))
            }
            Event::FrameSubmitted {
                surface: "background",
                now,
                ..
            } => Some(Message::BackgroundPresented(None, now)),
            Event::FrameSubmitted {
                surface: "live-wallpaper",
                now,
                ..
            } => Some(Message::LiveWallpaper(
                crate::live_wallpaper::Message::Frame(now),
            )),
            Event::FrameSubmitted {
                surface: "notifications",
                now,
                ..
            } if self.notifications.settled(now) => Some(Message::Notifications(
                crate::notifications::Message::Settled,
            )),
            Event::Scoped { instance, event } => {
                let route = self.monitors.route(&instance)?;
                match *event {
                    Event::FrameSubmitted {
                        surface: "background",
                        now,
                        ..
                    } => Some(Message::BackgroundPresented(Some(instance), now)),
                    event => self
                        .submitted_event(event)
                        .map(|message| Message::OnMonitor(route, Box::new(message))),
                }
            }
            event => self
                .launcher
                .event(event, self.browser.source.is_some())
                .map(Message::Launcher),
        }
    }
    pub fn active_widgets(&self) -> &widgets::Host {
        self.monitors
            .current()
            .map_or(&self.widgets, |(_, monitor)| &monitor.widgets)
    }
    pub fn widget_layout(&self, id: Option<&MonitorId>) -> &WidgetLayout {
        id.and_then(|id| self.settings.monitor_layouts.get(id))
            .unwrap_or(&self.settings.layout)
    }
    pub fn active_widget_layout(&self) -> &WidgetLayout {
        self.widget_layout(self.monitors.active.as_ref())
    }
    /// A logical widget can paint inside a differently named overlap group.
    /// Resolve current host membership, including frozen capture sources, rather
    /// than invalidating every surface in the widgets redraw group.
    fn redraw_widget(&self, widget: &str, effects: &mut Effects<Message>) {
        let mut redraw_host = |host: &widgets::Host,
                               layout: &WidgetLayout,
                               viewport: (f32, f32),
                               instance: Option<&SurfaceInstance>| {
            let mut visible = false;
            for source in host.surfaces(layout, viewport) {
                if source.members.contains(&widget) {
                    visible = true;
                    effects.redraw_surface(SurfaceAddress {
                        role: source.id,
                        instance: instance.cloned(),
                    });
                }
            }
            // Captured sources retain input only; the temporary full-host
            // preview paints every visible card and the decorative grid.
            if visible && host.grid_visible() {
                effects.redraw_surface(SurfaceAddress {
                    role: "widgets",
                    instance: instance.cloned(),
                });
            }
        };
        if self.monitors.legacy() {
            redraw_host(&self.widgets, &self.settings.layout, self.viewport, None);
        } else {
            for (id, monitor) in &self.monitors.connected {
                redraw_host(
                    &monitor.widgets,
                    self.widget_layout(Some(id)),
                    monitor.viewport,
                    Some(&monitor.instance),
                );
            }
        }
    }
    fn select_monitor(&mut self, id: &MonitorId, effects: &mut Effects<Message>) {
        let Some(monitor) = self.monitors.connected.get(id) else {
            return;
        };
        let viewport = monitor.viewport;
        if self.monitors.active.as_ref() == Some(id) && self.viewport == viewport {
            return;
        }
        effects.delegate(
            |child| self.media.update(crate::media::Message::CancelSeek, child),
            Message::Media,
        );
        self.monitors.active = Some(id.clone());
        self.viewport = viewport;
        self.update_dock(crate::dock::Message::Cancel, effects);
        self.retarget_panel_transition(effects.now);
        for role in [
            "bar",
            "dock",
            "audio",
            "media-controls",
            "weather-controls",
            "network",
            "bluetooth",
            "power",
            "displays",
            "tray",
            "notifications",
            "osd",
        ] {
            effects.redraw(role);
        }
    }
    fn explicitly_select_monitor(&mut self, id: &MonitorId, effects: &mut Effects<Message>) {
        if self.monitors.connected.contains_key(id) {
            self.monitors.pending_focus = None;
            self.select_monitor(id, effects);
        }
    }
    fn resize_monitor_background(&mut self, effects: &mut Effects<Message>) {
        match self.monitors.background_target() {
            Ok(Some(target)) => effects.delegate(
                |child| self.wallpaper.resize(target, child),
                Message::WallpaperSelection,
            ),
            Ok(None) => {}
            Err(error) => {
                self.error = error.to_string();
                effects.redraw("dock");
            }
        }
    }
    fn widget_view_on(&self, id: Option<&MonitorId>) -> widgets::View<'_> {
        let host = id
            .and_then(|id| self.monitors.connected.get(id))
            .map_or(&self.widgets, |monitor| &monitor.widgets);
        widgets::View {
            host,
            input: widgets::Input {
                layout: self.widget_layout(id),
                notes: &self.settings.notes,
                clock: &self.clock,
                system: &self.system,
                network_status: self.network.status(),
                media: &self.media,
                weather: &self.weather,
                save_status: self.persistence.status(self.settings_writable),
            },
            artwork: self.artwork(),
        }
    }

This is explicit composition, not a second event loop. The runtime calls Desktop; Desktop calls the child on the same UI thread. The child’s update accepts current borrowed inputs and can return an intent, so it is a client controller rather than another top-level Application implementation. The existing framework mapping/delegation API is sufficient; no new framework port is needed for this ownership change.

Live source · client effect around the launch use case
fn launch(&self, id: &AppId, prefer_running: bool, effects: &mut Effects<Message>) {
        let Some(app) = self.apps.iter().find(|a| &a.id == id).cloned() else {
            return;
        };
        let app_id = app
            .id
            .as_str()
            .strip_suffix(".desktop")
            .unwrap_or(app.id.as_str())
            .to_owned();
        effects.activation_token(Some(app_id), move |outcome| {
            Message::LaunchPrepared(app, prefer_running, outcome)
        });
    }
    fn launch_prepared(
        &self,
        app: domain::Application,
        prefer_running: bool,
        outcome: api::ActivationOutcome,
        effects: &mut Effects<Message>,
    ) {
        if !matches!(self.catalog.status(), crate::catalog::Status::Ready)
            || self.apps.iter().find(|entry| entry.id == app.id) != Some(&app)
        {
            return;
        }
        let context = launch_context(outcome);
        let apps = self.adapters.apps.clone();
        let hypr = self.adapters.compositor.clone();
        effects.task_on(crate::work::CONTROLS, move || {
            Message::Completed(if let Some(hypr) = hypr {
                lucent_usecases::activate_application_with_context(
                    apps.as_ref(),
                    hypr.as_ref(),
                    &app,
                    prefer_running,
                    context,
                )
            } else {
                apps.launch_with_context(&app, context)
            })
        });
    }
    fn launch_application_action(
        &mut self,
        revision: u64,
        app: domain::Application,
        action: ApplicationActionId,
        effects: &mut Effects<Message>,
    ) {
        let app_id = app
            .id
            .as_str()
            .strip_suffix(".desktop")
            .unwrap_or(app.id.as_str())
            .to_owned();
        effects.activation_token(Some(app_id), move |outcome| {
            Message::LaunchActionPrepared(revision, app, action, outcome)
        });
    }
    fn launch_application_action_prepared(
        &mut self,
        revision: u64,
        app: domain::Application,
        action: ApplicationActionId,
        outcome: api::ActivationOutcome,
        effects: &mut Effects<Message>,
    ) {
        let mut current = self.apps.iter().filter(|entry| entry.id == app.id);
        if !matches!(self.catalog.status(), crate::catalog::Status::Ready)
            || current.next() != Some(&app)
            || current.next().is_some()
        {
            self.update_launcher(
                launcher::Message::ActionFinished {
                    revision,
                    result: Err(DomainError::Invalid(
                        "Application actions changed; open them again".into(),
                    )),
                },
                effects,
            );
            return;
        }
        let apps = self.adapters.apps.clone();
        let context = launch_context(outcome);
        effects.task_on_with_failure(
            crate::work::CONTROLS,
            move || {
                Message::Launcher(launcher::Message::ActionFinished {
                    revision,
                    result: lucent_usecases::applications::application_action(&app, &action)
                        .and_then(|_| apps.launch_action_with_context(&app, &action, context)),
                })
            },
            move |failure| {
                Message::Launcher(launcher::Message::ActionFinished {
                    revision,
                    result: Err(DomainError::Failed(format!(
                        "Application action outcome is uncertain: {failure}"
                    ))),
                })
            },
        );
    }

The fill and outline of a selected row come from the same controlled state on the same frame. There is no delayed selection-background animation. Panel width/height and wallpaper-carousel motion have separate timelines; changing a row does not need to animate the entire panel.

App icons use exact desktop-ID mappings to Lucent’s monochrome SVG catalog, with XDG icons and a bundled symbol as fallbacks. Their loading belongs to the asset adapter; row rendering receives a cached image. Search, app launching and icon resolution remain separate responsibilities. Catalog updates arrive through ApplicationPort.watch; the client retains one active icon preparation and latest demand, retires removed keys and preserves selection by AppId.

04 / NATIVE SURFACES, CLIENT GEOMETRY

The bar and launcher
share a layout contract.

SurfaceWayland layerKeyboardPurpose
barTopNo keyboard captureA 48-pixel surface and exclusive zone cover the top inset and capsules; Hyprland adds the 16-pixel window gap once. Only controls receive pointer input.
widget groupsBottomOn demandBounded buffers include whole shadows. Overlapping bounds share a buffer in original paint order; margins position each group. Only widget hits capture input.
widgets · drag previewBottomNoneA temporary full-output preview paints the cards and grid with no pointer coverage. Stationary source buffers retain capture and suppress paint until release or cancellation.
dock · closedTopNoneA short bottom-anchored buffer preserves the display-grid position and shadow. At 1920×1080/1× its native extent is 1920×120. Only controls capture input.
dock · openOverlayExclusiveThe same client surface hosts the launcher/pickers and captures outside clicks for dismissal.
notificationsOverlayOn demandVisible when the child has toast/history content; input remains limited to interactive regions.

A native surface’s size is not necessarily the painted panel’s size. The settled dock uses a short bottom surface; it expands to a full-output transparent surface while the launcher morph runs. Auto-hide retains an input-only hotspot, and transient drag presentation stays separate from saved pin order. Its rounded panel, children and input regions are ordinary elements inside that surface. Hyprland manages the surface’s layer and focus; the client manages its internal layout.

shell_layout.rs contains pure desktop geometry shared by animation targets and views. Settled outer panels use the 16-pixel shell grid; control placement follows an 8-pixel rhythm with a 4-pixel inset subdivision. Launcher/input rows are 48 pixels. Height is derived from actual header, body, gaps and padding instead of copied totals for each mode.

Whole rows determine launcher capacity. The last selected result remains reachable, reset stays in a reserved footer and theme cards use equal widths with their actual token gap. Dock slots are centered with equal side insets. Available space is rounded down before placement; leftover display pixels can make a grid-aligned center slightly different from the mathematical center.

BarLayout derives capsule widths from control slots and gaps. Optional media and clock capsules appear only when their calculated bounds fit between the other groups. A compact workspace strip retains the active workspace. Text is constrained inside its slot; a long track name cannot push another capsule off screen.

These are client layout decisions. The framework supplies rows, constraints and clipping; the renderer supplies rounded shapes. Neither needs a special case for Lucent’s launcher or bar. During morphs, dimensions remain continuous between settled grid targets.

PRESENTATION ASSET OWNERSHIP

Pixels have an owner
before they reach the GPU.

The desktop keeps symbols/application icons separate from local wallpaper previews. previews::Previews owns the latter and receives an injected AssetPort adapter. The root supplies the current and target carousel ranges; painting uses the same range helper. The launcher receives a read-only view of prepared pixels and failures, without scheduling or adapter access.

The coordinator submits one thumbnail task at a time. Rapid navigation replaces demand while the accepted decode finishes; obsolete results are dropped. A prepared preview from a committed wallpaper takes precedence over an older speculative decode. Failures stay visible until an explicit retry or a new visit, avoiding an automatic failure loop. NativeAssets performs decoding and mip preparation on the worker.

Demand and cache entries are capped at 16, with previews limited to 1024 pixels per side. Unwanted entries are evicted against a 64 MiB reuse budget; wanted images stay pinned and can exceed that byte budget within the entry/dimension limits. Closing the local browser reduces reuse to 8 MiB after its transition settles. This is a client pixel-storage policy, separate from the renderer's GPU cache and process RSS.

lucent-cli inspect reports prepared icon, local-preview, online-preview and background storage. The total counts shared immutable allocations once. It excludes in-flight messages/work, decoder scratch, scene references, fonts and driver allocations. Online results still retain a provider page; their lifetime and discovery errors remain separate audit work.

06 / POINTER TO PERSISTED POSITION

Dragging is an interaction.
Snapping is a use case.

  1. The framework recognizes a drag and emits deltas relative to its start.
  2. Message::Widgets(widgets::Message::Move) routes to the child, which records the widget’s initial placement once in its private drag state. It starts from the visible, clamped position and adds each delta to that origin.
  3. constrain_placement returns a bounded preview without mutating preferences. The host retains it and invalidates the widgets surface; the view prefers this preview over the saved placement.
  4. While an origin is active, the view adds decorative grid elements using the same spacing token as snapping. They have no input handlers.
  5. On release, snap_placement rounds the final position, then move_widget clamps and commits it to the shared settings. The preview is removed, hiding the grid, and a typed Intent::Save asks the root to persist the result through its settings adapter.

Dragging remains smooth between grid points; snapping happens on release. The grid is generated in the client’s widget view, so the renderer receives the same generic elements as any other decoration.

The widget catalog contains calendar, clock, weather, media, system, notes and focus timer definitions. A single entry supplies selector text, token-derived size/default position and a view function. The same catalog validates widget IPC. The framework registry is a small table built for the current borrowed view context; it owns no snapshots or adapters. This remains compile-time composition.

widgets::Host owns transient drag state, month navigation and the focus timer. widgets::View borrows settings, clock/system/media/weather snapshots and prepared artwork. Its elements map child messages through Element::map(Message::Widgets). Updates borrow the root's settings aggregate for the duration of the call; no second preferences copy is retained. Save and retry intents return to the root, which coordinates persistence. Media controls map to the shared native Media child; its view is borrowed by the widget and its targeted adapter commands run as effects. The widget only reads save status; it does not invoke SettingsPort.

Saving another preference during a drag sees only the committed layout. Hiding a widget, resetting positions or invalid geometry cancels the rest of that gesture, including its eventual release. A later new drag can start normally; unknown IDs cannot create drag state. Invalid geometry returns an error without saving. These rules belong to the client interaction controller; finite positions, snapping and bounds remain pure domain/use-case behavior.

Live source · widget messages, intents and borrowed update input
#[derive(Clone)]
pub enum Message {
    Toggle(String),
    Show(String),
    Move(String, DragEvent),
    Resize(String, DragEvent),
    Size(String, WidgetSize),
    SizeStep(String, i32),
    Edit(bool),
    Press,
    EditNotes(TextEdit),
    ScrollNotes(ScrollEvent),
    RetrySave,
    TimerToggle,
    TimerReset,
    Tick(u64),
    Month(i32),
    ResetLayout,
    /// Cancel capture before its output or loaded configuration changes.
    CancelDrags,
    Media(crate::media::Message),
    Weather(crate::weather::Message),
}
pub enum Intent {
    Save,
    RetrySave,
    Media(crate::media::Message),
    Weather(crate::weather::Message),
    Error(lucent_domain::DomainError),
}
/// Preferences remain in the root aggregate. The child borrows them for this
/// update rather than maintaining a second persisted copy.
pub struct Update<'a> {
    pub layout: &'a mut WidgetLayout,
    pub notes: &'a mut String,
    pub instance: Option<lucent_api::SurfaceInstance>,
    pub viewport: (f32, f32),
    pub unix_seconds: u64,
}
#[derive(Clone, Copy)]
enum Drag {
    Active {
        origin: Placement,
        preview: Placement,
    },
    // Ignore the rest of a gesture after hide/reset/invalid input. Its final
    // release removes this marker; a subsequent press can start a new drag.
    Cancelled,
}
Live source · child update followed by root-owned persistence or action
fn update_widgets(&mut self, message: widgets::Message, effects: &mut Effects<Message>) {
        let selected = self.monitors.active.clone();
        self.update_widgets_on(selected.as_ref(), message, effects);
    }
    fn update_widgets_on(
        &mut self,
        id: Option<&MonitorId>,
        message: widgets::Message,
        effects: &mut Effects<Message>,
    ) {
        if let (Some(id), widgets::Message::Tick(now)) = (id, &message) {
            let layout = self
                .settings
                .monitor_layouts
                .get(id)
                .unwrap_or(&self.settings.layout);
            let Some(monitor) = self.monitors.connected.get_mut(id) else {
                return;
            };
            if monitor
                .widgets
                .tick(*now, layout.visible_widgets.iter().any(|id| id == "timer"))
            {
                for source in monitor.widgets.surfaces(layout, monitor.viewport) {
                    effects.redraw_surface(
                        SurfaceAddress::new(source.id).instance(monitor.instance.clone()),
                    );
                }
            }
            return;
        }
        let mut intent = None;
        let route = id.and_then(|id| {
            self.monitors
                .connected
                .get(id)
                .map(|monitor| monitor.route(id))
        });
        let instance = route
            .as_ref()
            .and_then(|route| self.monitors.connected.get(&route.id))
            .map(|monitor| monitor.instance.clone());
        let shared_notes = matches!(message, widgets::Message::EditNotes(_));
        let (host, layout, viewport) = if let Some(route) = &route {
            if let Err(error) =
                lucent_usecases::ensure_monitor_layout(&mut self.settings, &route.id)
            {
                self.error = error.to_string();
                effects.redraw("dock");
                return;
            }
            let layout = self
                .settings
                .monitor_layouts
                .get_mut(&route.id)
                .expect("initialized monitor layout");
            let monitor = self
                .monitors
                .connected
                .get_mut(&route.id)
                .expect("current route");
            (&mut monitor.widgets, layout, monitor.viewport)
        } else {
            (&mut self.widgets, &mut self.settings.layout, self.viewport)
        };
        effects.delegate(
            |child| {
                intent = host.update(
                    message,
                    widgets::Update {
                        layout,
                        notes: &mut self.settings.notes,
                        instance: instance.clone(),
                        viewport,
                        unix_seconds: self.clock.unix_seconds,
                    },
                    child,
                );
                if let Some(instance) = &instance
                    && !shared_notes
                    && child.redraw.remove("widgets")
                {
                    child.redraw_surface(SurfaceAddress::new("widgets").instance(instance.clone()));
                    for source in host.surfaces(layout, viewport) {
                        child.redraw_surface(
                            SurfaceAddress::new(source.id).instance(instance.clone()),
                        );
                    }
                }
            },
            move |message| match &route {
                Some(route) => {
                    Message::OnMonitor(route.clone(), Box::new(Message::Widgets(message)))
                }
                None => Message::Widgets(message),
            },
        );
        match intent {
            Some(widgets::Intent::Save) => self.save(effects),
            Some(widgets::Intent::RetrySave) => self.update(
                Message::Persistence(crate::persistence::Message::Retry),
                effects,
            ),
            Some(widgets::Intent::Media(message)) => self.update(Message::Media(message), effects),
            Some(widgets::Intent::Weather(message)) => {
                self.update(Message::Weather(message), effects)
            }
            Some(widgets::Intent::Error(error)) => {
                self.error = error.to_string();
                effects.redraw("dock");
            }
            None => {}
        }
    }
07 / REUSE BEYOND THE DESKTOP

Other clients use
the same engine.

Notifications: the parent maps the child’s elements with Element::map(Message::Notifications) and routes updates through Effects::delegate. The child owns toast/history presentation, grouping, paging, DND and versioned actions. The D-Bus adapter and pure inbox policy remain outside it. History and DND persist through NotificationHistoryPort and its bounded native storage worker. Inline styles, links and prepared images use shared SDK controls; the notification guide separates implemented content/storage from current application/compositor acceptance.

Keybindings and menus: lucent-menu is an independent client with pure selection policy. It returns the original selected value after destroying its keyboard surface. Omarchy’s per-user adapter supplies installed binding/menu data and dispatches actions. Back/Escape and nested menus use the native picker; trusted user actions retain precedence. See the menu integration contract.

Lock and login: both use a shared SessionScreen, injected authentication and native rendering. Lock uses the secure session-lock runtime; login uses greetd. A decorative layer-shell window cannot replace that protocol. Secrets stay outside scene and texture data.

Still supplied by the system: Hyprland handles windows, tiling, keybindings and decorations. Headless system services provide idle, clipboard and authorization backends; Lucent owns their shell presentation. The native clients do not yet replace every stock shell feature; see the ownership guide.

08 / A PRACTICAL SOURCE MAP

Change the owning layer.

  • desktop.rs ↗Typed messages, state transitions, effects, subscriptions and surface declarations.
  • views.rs + shell_layout.rs ↗Launcher/bar/dock presentation and shared pure geometry. Add a geometry assertion when changing capacity or bounds.
  • widgets/catalog.rs ↗Add one definition with a stable ID, view and geometry. Keep interaction in widgets/mod.rs and presentation in widgets/view.rs.
  • design/tokens.json ↗Primary scales, semantic roles and component recipes. Regenerate the Rust and website consumers.
  • Domain ports and adapters →Add or replace an external capability here, then select it in platform.rs.
  • Validation workflow →Pure decisions, scene geometry, reviewed Vulkan baselines and real input checks cover different failure modes.

The dock and catalog have private client coordinators. Widget resizing follows the same boundary: the host freezes native source geometry, keeps its preview outside settings and returns a save intent only after release. The sizing guide follows that flow through the domain value, use case, SDK grip and rendering tree. Remaining architectural work includes richer bar coordination, selected polling adapters and consistent loading/error presentation.