Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Snora documentation

Welcome. This directory holds long-form documentation for snora — tutorials, guides, architectural reference, and contributor notes. The generated API reference (per-function signatures and short doc comments) lives on docs.rs.

Two top-level files complement these docs: CHANGELOG records what changed in each release, and ROADMAP sketches what is expected next.

I am new to snora

Read these in order. Each is short and self-contained.

  1. Install — add snora to your Cargo.toml.
  2. Hello, snora — the smallest working app.
  3. Add a header, sidebar, footer — fill the skeleton.
  4. Toasts — notifications with framework-managed lifetime.
  5. When to use snora — fit and non-fit guidance.

I have used snora before

Pick the topic you need.

I want to look up a specific symbol or layout

I want to contribute

Welcome — see the contributor docs:

1 — Install

Add snora and iced to your Cargo.toml. snora targets iced 0.14 and Rust edition 2024 (rustc ≥ 1.85).

[dependencies]
iced  = { version = "0.14", features = ["tokio"] }
snora = "0.7"

You normally do not depend on snora-core directly. The snora crate re-exports the entire vocabulary (AppLayout, Toast, ToastPosition, Dialog, Sheet, LayoutDirection, …), so a single use snora::… suffices.

Optional features

FeatureWhat it addsWhen to enable
widgetsPrefab app_header / app_side_bar / app_footer / render_menu / icon_element. On by default.You want a working app on screen quickly without writing chrome from scratch
lucide-iconsIcon::Lucide variant + snora::lucide re-exportsYou want the Lucide icon set
svg-iconsIcon::Svg(PathBuf) variantYou want to load custom SVG files at runtime

Enable them on the snora line:

snora = { version = "0.7", features = ["lucide-icons"] }

When a feature is disabled the corresponding Icon variant does not exist in the enum at all — there is no runtime “feature missing” branch.

Engine-only build

Applications that supply 100 % of their UI parts (header, sidebar, footer, menu) and do not want the prefab widgets compiled in can opt out:

snora = { version = "0.7", default-features = false }

In this configuration the snora-widgets crate is not pulled in, snora::widget does not exist, and your build pays only for the engine (render, overlay layers, toast lifecycle).

Verify

cargo build

If the build succeeds you are ready for Hello, snora.

2 — Hello, snora

The smallest possible snora application — a single body element, nothing else.

use iced::{Element, widget::text};
use snora::{AppLayout, render};

#[derive(Debug, Clone)]
enum Message {}

#[derive(Default)]
struct App;

impl App {
    fn update(&mut self, _msg: Message) {}

    fn view(&self) -> Element<'_, Message> {
        let body: Element<'_, Message> = text("Hello, snora!").into();
        render(AppLayout::new(body))
    }
}

fn main() -> iced::Result {
    iced::application(App::default, App::update, App::view)
        .title(|_: &App| String::from("Hello"))
        .run()
}

That is the entire program. There is no trait to implement, no wrapper enum to write, no overlay scaffolding to plumb. AppLayout::new(body) returns a layout with body filled in and every other slot empty; render consumes it and produces an iced::Element.

What snora did for you

Even at this size, snora has already:

  • Wrapped your body in a full-window container.
  • Established the z-stack layers (skeleton at the bottom, toasts at the top) so future overlays slot in without code changes.
  • Picked a default LayoutDirection (LTR) and ToastPosition (TopEnd).

When you want a header, a sidebar, or a footer, you set them as fields on AppLayout — see the next page.

Try it locally

The examples/hello/ directory in the snora workspace is exactly the program above. Run it with:

cargo run -p snora-example-hello

3 — Add a header, sidebar, footer

AppLayout has four skeleton slots: header, side_bar, body, footer. Each accepts any iced::Element — you can hand-roll your own or use the prefab helpers in snora::widget.

Adding slots one at a time

#![allow(unused)]
fn main() {
use snora::{
    AppLayout, LayoutDirection, SideBar, SideBarItem,
    render,
    widget::{app_footer, app_header, app_side_bar},
};

fn view(state: &State) -> iced::Element<'_, Message> {
    let header = app_header(
        "My App",
        Vec::<snora::Menu<(), ()>>::new(),  // no menus yet
        &Message::HeaderAction,
        None,                               // no menu open
        None,                               // no end-of-row controls
        LayoutDirection::Ltr,
    );

    let sidebar = app_side_bar(
        SideBar {
            items: vec![
                SideBarItem {
                    view_id: ViewId::Home,
                    icon: "🏠".into(),
                    tooltip: "Home".into(),
                    on_press: Message::Switch(ViewId::Home),
                },
                // …
            ],
            active: state.active_view,
        },
        LayoutDirection::Ltr,
    );

    let footer = app_footer(iced::widget::text("status").into());

    let body: iced::Element<'_, Message> = state.body();

    let layout = AppLayout::new(body)
        .header(header)
        .side_bar(sidebar)
        .footer(footer);

    render(layout)
}
}

A note on direction

Every prefab widget that has a left/right asymmetry takes a LayoutDirection argument. Passing the same direction everywhere is the typical pattern; in apps that support live LTR/RTL flipping you keep the active direction on your state and re-pass it on each view.

AppLayout::direction(...) separately controls the body row mirroring (sidebar side flips). See Direction and ABDD for the full picture.

Custom slots

You are not required to use the prefab widgets. Anything that yields an Element<'_, Message> slots in:

#![allow(unused)]
fn main() {
let custom_header = iced::widget::container(my_header_row())
    .width(iced::Length::Fill)
    .padding(12)
    .into();

let layout = AppLayout::new(body).header(custom_header);
}

snora draws the skeleton; what fills each slot is your decision.

Next

Toasts have framework-managed lifetime. See Toasts.

4 — Toasts

A toast is a small, auto-stacking notification anchored to one corner of the window. snora owns the rendering, the stacking, and the lifetime sweep; the application only stores Vec<Toast<Message>> and writes two one-liners.

Three pieces

#![allow(unused)]
fn main() {
use std::time::Instant;
use iced::{Subscription, Task};
use snora::{Toast, ToastIntent, ToastLifetime};

struct App {
    toasts: Vec<Toast<Message>>,
    next_id: u64,
}

#[derive(Debug, Clone)]
enum Message {
    ShowSaved,
    Dismiss(u64),
    ToastTick,                        // framework asks us to sweep
}
}

1. Push a toast

#![allow(unused)]
fn main() {
fn update(&mut self, msg: Message) -> Task<Message> {
    if let Message::ShowSaved = msg {
        let id = self.next_id;
        self.next_id += 1;
        self.toasts.push(Toast::new(
            id,
            ToastIntent::Success,
            "Saved",
            "Document written to disk.",
            Message::Dismiss(id),
        ));
    }
    if let Message::Dismiss(id) = msg {
        self.toasts.retain(|t| t.id != id);
    }
    if let Message::ToastTick = msg {
        snora::toast::sweep_expired(&mut self.toasts, Instant::now());
    }
    Task::none()
}
}

2. Subscribe to TTL ticks

#![allow(unused)]
fn main() {
fn subscription(&self) -> Subscription<Message> {
    snora::toast::subscription(&self.toasts, || Message::ToastTick)
}
}

subscription returns Subscription::none() when the queue holds only persistent toasts (or nothing at all), so the runtime does not wake on an idle screen.

3. Pass the queue to the layout

#![allow(unused)]
fn main() {
fn view(&self) -> iced::Element<'_, Message> {
    let body: iced::Element<'_, Message> = /* … */;
    let layout = snora::AppLayout::new(body)
        .toasts(self.toasts.clone());
    snora::render(layout)
}
}

Lifetime policies

API callBehavior
Toast::new(...)Default 4-second auto-dismiss
.with_lifetime(ToastLifetime::seconds(10))Custom auto-dismiss
.persistent()Stays until the user clicks the close button

Persistent is for messages the user must acknowledge — completed exports, fatal errors. Transient is for everything else.

Intent → color

ToastIntent is one of Debug, Info, Success, Warning, Error. The engine resolves intents to theme-aware colors automatically.

Position

Toasts anchor at ToastPosition::TopEnd by default (top-right under LTR, top-left under RTL). Override with AppLayout::toast_position(ToastPosition::BottomCenter) etc. The position can be changed at runtime — re-rendering with a different position re-anchors the entire stack on the next frame.

Why not store TTL outside the toast?

Toast carries created_at and lifetime so that any single toast is self-describing: Toast::is_expired(now) is a pure function on the struct, easy to test without setting up renderer state. See the testing guide.

Next

You now know how to build a working snora app. The next page helps you decide whether snora is the right fit for what you are building.

5 — When to use snora

snora is opinionated. It does a small set of things well and is not trying to be a general-purpose UI framework. Use this page to decide whether snora is the right tool before you adopt it — that is cheaper than discovering misfit later.

A good fit

snora is built for desktop applications that combine an interactive UI with non-trivial background work and want accessibility correct from day one:

  • Local-first tools. Apps that run heavy computation on the user’s machine — local AI inference, search indexers, file processors, dataset converters. snora keeps the UI thread responsive while you drive iced::Task::perform for the heavy lifting.

  • Apps with ABDD as a hard requirement. RTL support, logical edge layout, theme-aware colors are baked into the type signatures. You cannot accidentally hardcode “left” or “right” using snora’s prefab widgets, and the Edge / LayoutDirection vocabulary makes custom widgets equally compliant.

  • Standard desktop chrome. Apps whose UI fits the header / sidebar / body / footer skeleton with optional dialog, bottom sheet, context menu, and toast overlays.

  • Small to medium teams that value reading the framework’s source occasionally — snora is a few thousand lines, on purpose.

A workable fit (with caveats)

You can ship with snora, but the framework gives you less leverage:

  • Form-heavy applications. snora has nothing form-specific (no field widgets, no validation primitives). You wire up iced’s text_input / pick_list / checkbox directly. snora won’t fight you, it just won’t help.

  • Multilingual apps with complex typography. snora handles direction but not text shaping or i18n. Pair with fluent or similar for messages.

  • Highly bespoke chrome. If your design language calls for a shoulder bar, a multi-row header, a non-rectangular sidebar, a vertical tab strip etc., the prefab widgets stop applying. The skeleton (AppLayout) still works because slots take any Element, but you write more of the UI yourself.

A poor fit

  • Games. snora is built for retained-mode UI; games want per-frame re-rendering with their own scene graph. Use iced directly, or a game-oriented framework.

  • Real-time visualization. 3D scenes, live spectrograms, 60 fps charts. iced’s canvas widget can do this without snora’s overhead, and snora’s overlay machinery adds nothing for these workloads.

  • Web targets as the primary deliverable. snora consumes iced 0.14, which has limited web support. If the web is your main target, consider Leptos / Dioxus / Yew.

  • Very small applications. A single-window calculator with one input box and one output gets nothing from snora. Use raw iced.

Quick decision flow

Does the app have a header / body / footer kind of layout? If no → use raw iced.

Does the app run heavy work alongside an interactive UI? If no → snora’s structure helps but a simpler iced setup works fine too.

Will more than one person work on this app over months? If yes → snora’s vocabulary (AppLayout, Toast, Dialog, SheetSize) gives you shared shorthand. Worth it.

Does the app need to flip LTR ↔ RTL or support multiple themes? If yes → snora pays for itself the first time you flip.

When in doubt, write the smallest version of your screen with both raw iced and snora and compare. snora’s value shows up around the second or third screen, not the first.

Overlays

snora has three overlay surfaces. They differ in how modal they are and how the user dismisses them.

SurfaceModal?Default dismissLayer
Dialogyesclick backdrop or close button you provideabove modal dim
Sheetyesclick backdrop or close button you provideabove modal dim
context_menu slotno (light overlay)click anywhere outsidebelow modal dim
header_menu slotno (light overlay)click anywhere outsidebelow modal dim

One close sink, two channels

#![allow(unused)]
fn main() {
let layout = AppLayout::new(body)
    .on_close_modals(Message::CloseModals)   // dialog / sheet
    .on_close_menus(Message::CloseMenus);    // context / header menus
}

These are the only two close sinks. Individual Dialog and Sheet values do not carry their own close messages — there is exactly one place per channel.

If you set an overlay but leave its sink None, the overlay still renders. The framework simply omits the click-outside-to-close backdrop, and you must provide an explicit close button inside the overlay content. snora never silently drops a populated overlay.

Dialog

A centered modal card. Snora paints the dim backdrop and centers your content; everything else is your decision.

#![allow(unused)]
fn main() {
use snora::{AppLayout, Dialog};

let layout = AppLayout::new(body)
    .dialog(Dialog::new(my_card_element()))
    .on_close_modals(Message::CloseModals);
}

Dialog does not own the card chrome — you decide whether the dialog content is a plain container, a styled card with a border, an entire form. snora is a positioner, not a styler.

Sheet

A modal panel anchored to one of the four window edges, occupying a configurable size along the perpendicular axis.

#![allow(unused)]
fn main() {
use snora::{AppLayout, Sheet, SheetEdge, SheetSize};

let sheet = Sheet::new(my_drawer_content())
    .at(SheetEdge::Bottom)
    .with_size(SheetSize::Half);

let layout = AppLayout::new(body)
    .sheet(sheet)
    .on_close_modals(Message::CloseModals);
}

Edges

VariantWhere it slides from
SheetEdge::Bottom (default)bottom of the window
SheetEdge::Toptop of the window
SheetEdge::Startlogical start (LTR=left, RTL=right)
SheetEdge::Endlogical end (LTR=right, RTL=left)

Start and End mirror automatically under LayoutDirection::Rtl, like every other axis-aligned piece of snora vocabulary.

The engine rounds only the inside-facing corners — the corners that sit against the application content, not against the window edge. So a bottom-anchored sheet rounds its top corners; a start-anchored sheet under LTR rounds its right corners; etc.

Size

The size is interpreted along the axis perpendicular to the edge: it is a height for top/bottom edges and a width for start/end edges.

VariantResolved size
SheetSize::OneThird (default)33 % of the relevant axis
SheetSize::Half50 %
SheetSize::TwoThirds67 %
SheetSize::Ratio(f32)clamped to 0.0..=1.0
SheetSize::Pixels(f32)fixed pixels, ignores window size

Pixel sizes ignore window resize and are usually wrong; prefer ratio variants unless you have a hard pixel budget.

Context menu

A floating menu (right-click style). It uses on_close_menus, not on_close_modals, so it can coexist with an open dialog without one dismissing the other.

#![allow(unused)]
fn main() {
let layout = AppLayout::new(body)
    .context_menu(my_floating_menu(point))
    .on_close_menus(Message::CloseMenus);
}

iced 0.14 does not surface the click coordinate alongside a button press, so Point-based positioning of a context menu currently requires either a mouse_area subscription or the iced advanced widget API. The examples/context_menu demo uses fixed positions for clarity; treat it as a starting point rather than a complete recipe.

Header menu

Drop-down menus attached to a header bar (File / Edit / View …). See the dedicated Menus guide.

Z-order recap

From bottom of the stack to top:

0. skeleton           header / body / sidebar / footer
1. menu backdrop      transparent click sink for header/context menus
2. header_menu / context_menu
3. modal dim          40 % black click sink for dialog/sheet
4. dialog
5. sheet
6. toasts             always on top so they survive over modals

Toasts are deliberately on top of modals — a long-running export finishing while a dialog is open should not be invisible.

Menus

snora has two menu shapes:

  • Header menu — drop-down attached to the header bar (File / Edit / View). Triggered by clicking a labeled button. Item list renders inline below the button.
  • Context menu — floating menu, typically right-click. Renders at a caller-chosen point. See overlays.

Both share the Menu / MenuItem / MenuAction vocabulary from snora-core.

Application-defined ids

You define two enums for menu identities:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MyMenuId {
    File,
    View,
    Help,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MyMenuItemId {
    New,
    Open,
    Quit,
    ToggleStatus,
    About,
}
}

snora is generic over both. It does not impose any string-based naming or numeric tagging — your enum is the source of truth.

Building menus

#![allow(unused)]
fn main() {
use snora::{Menu, MenuItem};

let menus = vec![
    Menu {
        id: MyMenuId::File,
        label: "File".into(),
        icon: None,
        items: vec![
            MenuItem { menu_id: MyMenuId::File, id: MyMenuItemId::New,  label: "New".into(),  icon: None },
            MenuItem { menu_id: MyMenuId::File, id: MyMenuItemId::Open, label: "Open…".into(), icon: None },
            MenuItem { menu_id: MyMenuId::File, id: MyMenuItemId::Quit, label: "Quit".into(),  icon: None },
        ],
    },
    // …View, Help…
];
}

MenuItem::menu_id repeats the parent menu id so that the [MenuAction::MenuItemPressed] event carries it without a second lookup.

Wiring into a header

#![allow(unused)]
fn main() {
use snora::{
    AppLayout, LayoutDirection, MenuAction,
    render, widget::app_header,
};

#[derive(Debug, Clone)]
enum Message {
    HeaderAction(MenuAction<MyMenuId, MyMenuItemId>),
    CloseMenus,
}

fn view(state: &State) -> iced::Element<'_, Message> {
    let header = app_header(
        "My App",
        menus,                              // built above
        &Message::HeaderAction,             // map MenuAction → Message
        state.active_menu.as_ref(),         // which menu is open
        None,                               // no end-controls
        LayoutDirection::Ltr,
    );

    let mut layout = AppLayout::new(state.body())
        .header(header)
        .on_close_menus(Message::CloseMenus);

    // Snora installs the click-outside backdrop only when
    // `header_menu` is `Some`. The actual dropdown is drawn inline by
    // `app_header`; this slot just opts the backdrop in.
    if state.active_menu.is_some() {
        layout = layout.header_menu(iced::widget::space().into());
    }

    render(layout)
}
}

Handling the actions

MenuAction has two variants:

#![allow(unused)]
fn main() {
match action {
    MenuAction::MenuPressed(id) => {
        // Toggle: same id closes; different id switches.
        state.active_menu = if state.active_menu == Some(id) { None } else { Some(id) };
    }
    MenuAction::MenuItemPressed { menu_id, menu_item_id } => {
        state.active_menu = None;            // close after pick
        state.dispatch(menu_id, menu_item_id);
    }
}
}

The “click the button to toggle, click an item to close” pattern is the most common; you are free to use a different model (e.g. hover-to-open) since snora only emits the events.

Why header_menu takes an empty Space

iced 0.14’s element tree does not expose absolute positioning at the overlay layer; the dropdown is drawn inside app_header and what populates AppLayout::header_menu is just the opt-in signal that “a menu is open, please install the click-outside backdrop”. Using Space (zero-sized, transparent) is the canonical idiom.

This may become an actual element in a future version if iced exposes absolute overlay positioning. The application-facing shape stays the same.

Direction and ABDD

snora’s first principle is ABDD — Accessible By Default and by Design. Layout is described in logical edges, not physical directions, so an app written for English readers also works for Arabic / Hebrew / Persian readers without a per-screen rewrite.

Two switches

#![allow(unused)]
fn main() {
use snora::{LayoutDirection, ToastPosition};

let layout = AppLayout::new(body)
    .direction(LayoutDirection::Rtl)            // body row mirrors
    .toast_position(ToastPosition::TopEnd);      // toast anchor mirrors
}

direction controls how snora composes the skeleton row (where the sidebar lands), and is also passed to the prefab widgets so they mirror their own internal start / end arrangements.

Logical vs physical

Edge is the central vocabulary type:

#![allow(unused)]
fn main() {
pub enum Edge { Start, End }
}
DirectionEdge::StartEdge::End
Ltrleftright
Rtlrightleft

Use Edge::is_left_under(direction) if you ever need to translate to a physical side.

Direction-aware rows

For your own widgets, snora::direction::row_dir (and row_dir_three) build a row! whose order is decided by direction:

#![allow(unused)]
fn main() {
use snora::direction::row_dir;

let bar = row_dir(
    state.direction,
    iced::widget::text("File: untitled"),       // start
    iced::widget::button("Save")                // end
        .on_press(Message::Save),
);
}

Under Ltr, “File:” is on the left and the Save button on the right; under Rtl, the order is mirrored. You write the row once.

Built-in widgets that take direction

WidgetWhat flips
app_headerTitle group on Start, end-controls on End
app_side_barTooltip side; the sidebar position is determined by AppLayout::direction
Toast layerAnchor side, when ToastPosition is *Start or *End
SheetAnchor side, when SheetEdge is Start or End. Inside-facing rounded corner also flips. Top / Bottom edges are unaffected.
DialogUnaffected — centered, not edge-anchored

Live flip

Live LTR ↔ RTL flipping is a one-line accessibility setting. Keep direction: LayoutDirection on your state, mutate it on a user action, and re-pass it on each view. Snora re-renders the whole skeleton mirrored — no per-widget reset needed.

The examples/rtl demo flips on a button press.

Intentionally non-mirroring elements

Some elements should not mirror:

  • Numbers, currency, ISO dates — readers of any direction parse these left-to-right within their text.
  • Code, logs, file paths — same.

Keep these in plain text; iced + snora will not mirror their internal contents, only the surrounding layout.

What snora does not do

  • Bidirectional text shaping. iced 0.14 handles BiDi at the text-layout layer; snora does not augment that.
  • Locale-driven number formatting. Use the icu or num-format crates.
  • Mirrored icons. Lucide ships icons that already include direction-aware variants where appropriate; pick the right name. snora does not flip raster or SVG content.

ABDD is a layout discipline, not a complete i18n stack. snora gets the skeleton right; you bring the rest of the i18n story.

Icons

Icon is a single enum with feature-gated variants. Choose your icon source per call; nothing is global.

#![allow(unused)]
fn main() {
pub enum Icon {
    Text(String),                      // always available
    #[cfg(feature = "lucide-icons")]
    Lucide(lucide_icons::Icon),
    #[cfg(feature = "svg-icons")]
    Svg(std::path::PathBuf),
}
}

When a feature is disabled the variant does not exist in the enum. No runtime “unknown icon kind” branch is reachable.

Icon::Text — the always-available path

#![allow(unused)]
fn main() {
let i: Icon = "★".into();                // From<&str>
let i: Icon = String::from("★").into();  // From<String>
let i = Icon::Text("✓".into());           // explicit
}

Strings can be a single Unicode glyph (, , , 🛠) or a short label ("OK"). The engine renders text icons at the same default font size as labels in built-in widgets, so they line up visually.

This variant has no asset dependency, no feature flag, and works on every platform that iced supports.

Icon::Lucide — the curated icon set

[dependencies]
snora = { version = "0.5", features = ["lucide-icons"] }
#![allow(unused)]
fn main() {
use snora::Icon;
use snora::lucide;                     // re-exported variants

let i: Icon = lucide::Home.into();
let i = Icon::Lucide(lucide::Settings);
}

lucide-icons ships every Lucide glyph as a variant. Cargo includes only the ones you reference at compile time — Icon::Lucide(lucide::Home) does not pull lucide::Settings into the binary.

Icon::Svg — your own assets

snora = { version = "0.5", features = ["svg-icons"] }
#![allow(unused)]
fn main() {
let i = Icon::Svg(std::path::PathBuf::from("assets/logo.svg"));
}

The engine reads the file at render time using iced’s SVG widget. Pixel size is the same default as the other variants.

Sizing

The default size is 14 px to match the default body text. To override:

#![allow(unused)]
fn main() {
use snora::widget::icon::icon_element_sized;

let big_logo = icon_element_sized(&Icon::Text("✓".into()), 24.0);
}

ABDD: icons should not be the only signal

Icons are a secondary signal. Always pair them with a text label or a tooltip — keyboard users, screen-reader users, and users with low vision rely on the text. The prefab app_side_bar enforces this by requiring tooltip: String on every SideBarItem; do the same in your custom widgets.

In the same spirit, the toast renderer encodes intent via both the background color and the surrounding text, so red is never the sole signal of an error.

Testing UI logic without a renderer

snora does not ship a separate test-helper crate. Instead, snora’s public types expose enough fields directly that you can verify state-driven UI logic with plain assert! against your App state.

What you can test today

  • “Did the right toast get pushed?” — assert against state.toasts.
  • “Is the toast persistent?” — match on toast.lifetime.
  • “Is a dialog open?” — check state.show_dialog or whatever flag drives AppLayout::dialog.
  • “Did the active view switch?” — assert state.active == ViewId::X.

What you cannot test with this approach is the rendered pixel output — that is iced’s responsibility and would need a windowing backend. snora deliberately stops at the data shape.

Pattern: split state from view

Keep your update function pure (mutates state, returns Task) and have view be the only function that touches iced widgets. Tests exercise update; the renderer is never invoked.

#![allow(unused)]
fn main() {
// src/app.rs

#[derive(Default)]
pub struct App {
    pub toasts: Vec<snora::Toast<Message>>,
    pub next_id: u64,
    pub active: ViewId,
}

impl App {
    pub fn update(&mut self, msg: Message) -> iced::Task<Message> {
        match msg {
            Message::ExportCompleted(Ok(_)) => {
                let id = self.issue_id();
                self.toasts.push(
                    snora::Toast::new(
                        id,
                        snora::ToastIntent::Success,
                        "Export complete",
                        "File written to disk.",
                        Message::DismissToast(id),
                    )
                    .persistent(),
                );
            }
            // ...
        }
        iced::Task::none()
    }

    pub fn view(&self) -> iced::Element<'_, Message> { /* … */ }
}
}

Pattern: assert against the queue

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn export_completion_pushes_persistent_success_toast() {
        let mut app = App::default();

        app.update(Message::ExportCompleted(Ok(fake_report())));

        let last = app.toasts.last().expect("a toast was queued");
        assert_eq!(last.intent, snora::ToastIntent::Success);
        assert!(matches!(last.lifetime, snora::ToastLifetime::Persistent));
    }

    #[test]
    fn cancel_clears_active_dialog_flag() {
        let mut app = App {
            show_export_dialog: true,
            ..Default::default()
        };

        app.update(Message::CancelExport);

        assert!(!app.show_export_dialog);
    }

    #[test]
    fn ttl_sweep_drops_only_expired_transient() {
        use std::time::{Duration, Instant};
        use snora::ToastLifetime;

        let now = Instant::now();
        let mut app = App::default();
        app.toasts.push(
            snora::Toast::new(1, snora::ToastIntent::Info, "old", "", Message::DismissToast(1))
                .with_lifetime(ToastLifetime::millis(100))
                .with_created_at(now),
        );
        app.toasts.push(
            snora::Toast::new(2, snora::ToastIntent::Error, "keep", "", Message::DismissToast(2))
                .persistent()
                .with_created_at(now),
        );

        snora::toast::sweep_expired(&mut app.toasts, now + Duration::from_secs(1));

        let ids: Vec<u64> = app.toasts.iter().map(|t| t.id).collect();
        assert_eq!(ids, vec![2]);
    }
}
}

Three things to notice:

  1. Toast’s fields are pub, so the assertion reads naturally.
  2. Toast::with_created_at is a public builder method intended for tests — it lets you control the timestamp without freezing the real clock.
  3. snora::toast::sweep_expired is a public function. Calling it from a test is identical to how production code calls it — the same logic gets exercised.

What is not currently testable this way

  • Click coordinates for context menus. snora does not surface mouse events; you would need to test through iced’s own mouse_area / subscription primitives.
  • Layout measurements. Whether two columns fit, whether a sheet reaches the top of the screen, etc. These are renderer-side concerns.

For the first class of test we recommend a small integration test that boots iced in a hidden window — that is rare in practice and out of scope here.

A note on a future snora-test

We considered shipping a dedicated test-helper crate. The conclusion was that doing so would freeze internal data shapes (the lifetime field, etc.) into the public API and create a second surface to maintain. The current “pub fields + pure update” pattern covers the common cases, and the API stays small.

Migrating from 0.6 to 0.7

Snora 0.7 has two themes:

  1. Removal of the deprecation aliases introduced in 0.6 (BottomSheet, SheetHeight, AppLayout::bottom_sheet()).
  2. Two new navigation widgets: app_tab_bar and app_breadcrumb, with their respective vocabulary types.

If you migrated all the way through the 0.6 deprecation hints, your 0.6 code compiles unchanged on 0.7.

At a glance

ChangeSeverityAction
BottomSheet removedBreakingUse Sheet (introduced in 0.6)
SheetHeight removedBreakingUse SheetSize (introduced in 0.6)
AppLayout::bottom_sheet(...) removedBreakingUse AppLayout::sheet(...) (introduced in 0.6)
Tab, TabBar, TabAction, app_tab_barAdditiveNew widget — use it if you want tabs
Crumb, BreadcrumbAction, app_breadcrumbAdditiveNew widget — use it if you want breadcrumbs

Removing the 0.6 aliases

If your 0.6 build was clean of #[deprecated] warnings, no edit is needed. If you suppressed the warnings or skipped 0.6, do the renames now:

#![allow(unused)]
fn main() {
// Before (0.5 / 0.6 with deprecation warnings)
use snora::{BottomSheet, SheetHeight};
let sheet = BottomSheet::new(content).with_height(SheetHeight::Half);
let layout = AppLayout::new(body).bottom_sheet(sheet);

// After (0.6 / 0.7)
use snora::{Sheet, SheetSize};
let sheet = Sheet::new(content).with_size(SheetSize::Half);
let layout = AppLayout::new(body).sheet(sheet);
}

The full vocabulary of Sheet (including SheetEdge for non-bottom anchors) is documented in guides/overlays.md.

New: tab bar

For peer-level navigation — three to seven sibling views — use TabBar and app_tab_bar.

#![allow(unused)]
fn main() {
use snora::{
    AppLayout, Tab, TabAction, TabBar, render,
    widget::app_tab_bar,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorkspaceTab { Library, Editor, Settings }

#[derive(Debug, Clone)]
enum Message {
    TabAction(TabAction<WorkspaceTab>),
    /* ... */
}

fn view(state: &State) -> iced::Element<'_, Message> {
    let tabs = TabBar {
        tabs: vec![
            Tab { id: WorkspaceTab::Library,  label: "Library".into(),  icon: None },
            Tab { id: WorkspaceTab::Editor,   label: "Editor".into(),   icon: None },
            Tab { id: WorkspaceTab::Settings, label: "Settings".into(), icon: None },
        ],
        active: state.active_tab,
    };

    let body = iced::widget::column![
        app_tab_bar(tabs, &Message::TabAction, state.direction),
        state.body(),
    ];

    render(AppLayout::new(body.into()))
}
}

Key points:

  • TabBar is generic over TabId. Use a small Copy + PartialEq enum.
  • The active tab is highlighted with an underline drawn from the theme’s primary color.
  • Direction-aware: under LayoutDirection::Rtl the entire tab order mirrors.

The full guide on choosing between tabs and a sidebar is in guides/menus.md (note: tabs and menus are different beasts; the guide spans both).

New: breadcrumb

For ancestor-level navigation — “where am I in the hierarchy” — use Crumb and app_breadcrumb.

#![allow(unused)]
fn main() {
use snora::{
    BreadcrumbAction, Crumb, render,
    widget::app_breadcrumb,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CrumbId { Home, Library, Books }

let crumbs = vec![
    Crumb::ancestor(CrumbId::Home,    "Home"),
    Crumb::ancestor(CrumbId::Library, "Library"),
    Crumb::leaf(CrumbId::Books,       "Books"),       // current page
];

let crumb_row = app_breadcrumb(crumbs, &Message::Breadcrumb, state.direction);
}

Key points:

  • The application is responsible for marking exactly one entry as the leaf. Leaves render as plain text and do not emit events.
  • Ancestors are clickable and emit BreadcrumbAction::Pressed(CrumbId).
  • The separator glyph flips with direction ( under LTR, under RTL).

Examples

Two focused example crates ship with the repo:

cargo run -p snora-example-tab          # peer-level navigation
cargo run -p snora-example-breadcrumb   # ancestor-level navigation

Each example is single-purpose so it stays under ~150 lines and reads as documentation; combine them in your own application as appropriate.

A note on per-widget feature gates

Snora 0.7 still has only one widgets feature, not one feature per widget. The criteria for revisiting that decision are documented in contributing/feature-gating-criteria.md.

If you have a use case that does not fit the current coarse gate, that document explains what kind of evidence would justify a finer split.

Crate version pins

Bump:

snora = "0.7"
# (rare) snora-core, snora-widgets if depended on directly

There are no other breaking changes in 0.7.

Migrating from 0.5 to 0.6

Snora 0.6 brings two changes that affect existing applications:

  1. The BottomSheet overlay is now Sheet and supports four anchor edges (Top, Bottom, Start, End).
  2. The prefab widgets moved out of the snora crate into a new snora-widgets crate, re-exported under snora::widget behind a feature gate.

Both changes ship with deprecated aliases so that 0.5.x code keeps compiling. The aliases will be removed in 0.7.0.

At a glance

ChangeSeverityAction
BottomSheetSheetSource-compat alias keptOptional rename in code; required before 0.7
SheetHeightSheetSizeSource-compat alias keptOptional rename; required before 0.7
Sheet::with_height(...)Sheet::with_size(...)No aliasRename calls (one-line edit each)
AppLayout::bottom_sheet(...)AppLayout::sheet(...)Deprecated alias keptOptional rename; required before 0.7
New: Sheet::at(SheetEdge::...)AdditiveUse to anchor sheets at non-bottom edges
3-crate split (snora-widgets carved out)Internal — re-exports preservedNone for typical apps
Engine-only opt-out (default-features = false)AdditiveUse if you want to skip the widget set

BottomSheetSheet

BottomSheet was a fixed-bottom-anchor type. Sheet generalizes that into a panel anchored to any of the four window edges, with the size axis perpendicular to the chosen edge.

Minimal rename

#![allow(unused)]
fn main() {
// 0.5
use snora::{BottomSheet, SheetHeight};
let sheet = BottomSheet::new(content).with_height(SheetHeight::Half);
let layout = AppLayout::new(body).bottom_sheet(sheet);

// 0.6 (recommended)
use snora::{Sheet, SheetSize};
let sheet = Sheet::new(content).with_size(SheetSize::Half);
let layout = AppLayout::new(body).sheet(sheet);
}

If you do nothing, the 0.5 code still compiles in 0.6 because BottomSheet, SheetHeight, and AppLayout::bottom_sheet are deprecated aliases of the new names. You will see #[deprecated] warnings; suppress them with #[allow(deprecated)] on a function or upgrade your code on whatever schedule fits.

Sheet::with_height(...) does not have an alias — it is renamed to with_size(...) because the parameter type changed. This is the only edit some applications will need to make to silence warnings entirely.

New: anchoring at other edges

#![allow(unused)]
fn main() {
use snora::{Sheet, SheetEdge, SheetSize};

// Sheet from the start edge (LTR=left, RTL=right)
let nav = Sheet::new(my_nav())
    .at(SheetEdge::Start)
    .with_size(SheetSize::Pixels(280.0));

let layout = AppLayout::new(body).sheet(nav);
}

SheetEdge is a logical edge type with Start / End variants that mirror under LayoutDirection::Rtl, so an app written for LTR readers automatically does the right thing for RTL readers.

The default edge is Bottom, matching the 0.5 behavior — Sheet::new(c) without .at(...) produces a bottom-anchored sheet.

SheetSize semantics

SheetSize is interpreted along the axis perpendicular to the edge. For Top / Bottom edges it is a height; for Start / End edges it is a width. The variants are:

VariantMeaning
OneThird (default)33 % of the relevant axis
Half50 %
TwoThirds67 %
Ratio(f32)clamped to 0.0..=1.0
Pixels(f32)fixed pixels

3-crate split

In 0.6, the prefab widgets (app_header, app_side_bar, app_footer, render_menu, icon_element) and their support modules (direction, style) moved out of snora and into a new snora-widgets crate. See reference/architecture.md for the full dependency picture.

What changes for typical applications: nothing

snora’s widgets feature is on by default, and snora’s lib re-exports snora-widgets under the same paths used in 0.5. Your imports keep working:

#![allow(unused)]
fn main() {
// Both 0.5 and 0.6 — same import paths
use snora::widget::{app_header, app_side_bar, app_footer};
use snora::widget::icon::icon_element;
use snora::direction::row_dir;
use snora::style::chrome_container_style;
}

What this enables: engine-only builds

Applications that supply 100 % of their UI parts and do not want the widget set compiled in can opt out:

[dependencies]
snora = { version = "0.6", default-features = false }

This skips compilation of snora-widgets entirely. The snora::widget module is not present, and neither is snora::direction / snora::style. The engine surface (render, toast, AppLayout, all vocabulary types) remains unchanged.

If you mix-and-match — keeping snora-widgets but, say, swapping in your own header — that still works without any opt-out, because all AppLayout slots accept any iced::Element.

Direct snora-widgets dependency (rare)

If you want to depend on snora-widgets directly (for example to reuse widgets in a non-snora engine), it is published alongside:

[dependencies]
snora-widgets = "0.6"

This is unusual; the supported pattern is to depend on snora and let it pull in snora-widgets transitively.

Deprecation timeline

API0.6 status0.7 plan
BottomSheet (type alias)Available, deprecatedRemoved
SheetHeight (type alias)Available, deprecatedRemoved
AppLayout::bottom_sheet(...) (method)Available, deprecatedRemoved

To prepare for 0.7, do the renames now and run cargo clippy --workspace -- -D warnings -A deprecated to verify you have no remaining usages of the old names.

Crate version pins

If your Cargo.toml had

snora = "0.5"

bump to

snora = "0.6"

If you have a direct dependency on snora-core, bump it too:

snora-core = "0.6"

Direct dependencies on snora-widgets are new in 0.6 and only needed in the rare cases described above.

Migrating from 0.4 to 0.5

Snora 0.5 is a small release whose only breaking change is the toast anchor default. Most apps need a one-line edit (or no edit at all).

At a glance

ChangeSeverityAction
Toast default position is now TopEnd (was BottomEnd)Breaking, visualAdd .toast_position(ToastPosition::BottomEnd) if you want the old look
AppLayout gains toast_position: ToastPosition fieldCompatibleNone — AppLayout::new(...) still defaults sensibly
lucide-icons upgraded to ^1Compatible at the source levelRebuild
snora-core = "0.5"Same workspace inheritanceNone if you only use snora (re-exports cover everything)

Toast position default

In 0.4, toasts always anchored to the bottom-right (LTR) / bottom-left (RTL). In 0.5 the default is the top-end corner.

Why we changed it

Many local-first applications place primary content in the bottom half of the window — preview panels, editors, lists. Bottom-anchored toasts compete with that content for visual space. Top-anchored toasts sit clear of typical primary content and are easier to notice without obscuring work.

The 0.4 default reflected the OS-level notification convention (macOS / GNOME / Windows). That convention applies to system notifications outside any application; in-app toasts in modern UI frameworks (Material Snackbar, Chakra, Mantine) more often default to the top-end. We followed the in-app convention.

To keep the 0.4 behavior

#![allow(unused)]
fn main() {
use snora::{AppLayout, ToastPosition};

let layout = AppLayout::new(body)
    .toasts(self.toasts.clone())
    .toast_position(ToastPosition::BottomEnd);   // explicit 0.4 default
}

That single setter on every view call restores the previous positioning exactly.

To pick something else

ToastPosition has six variants:

#![allow(unused)]
fn main() {
pub enum ToastPosition {
    TopEnd,        // default (LTR=top-right, RTL=top-left)
    TopStart,
    TopCenter,
    BottomEnd,
    BottomStart,
    BottomCenter,
}
}

Stack growth direction is derived from the position automatically — top anchors grow downward, bottom anchors grow upward, so the newest toast is always closest to the screen edge.

New: opt-in position picker UX

If your application has a settings panel and you want to let users choose, the position is suitable for runtime configuration. Store the choice on your state and re-pass it on every render:

#![allow(unused)]
fn main() {
struct App { /* ... */ toast_position: ToastPosition }

fn view(&self) -> iced::Element<'_, Message> {
    AppLayout::new(body)
        .toasts(self.toasts.clone())
        .toast_position(self.toast_position)
        .into()
}
}

Switching position re-anchors the entire stack on the next frame; no per-toast change is needed.

lucide-icons 1.x

If you depend on snora only, no change is needed. If you also import lucide_icons::* directly in your code, double-check that any constants you reference still exist — the lucide upstream renames glyphs occasionally. The vast majority of names are stable.

Crate versions

If your Cargo.toml had

snora = "0.4"

just bump it to

snora = "0.6"

If you depend on snora-core directly (rare), bump that too:

snora-core = "0.5"

There are no other breaking changes in 0.5. The AppLayout builder, overlay close-sink convention, Toast lifetime API, and direction vocabulary are all source-compatible with 0.4.

Architecture overview

Snora is three crates with a strict dependency direction.

your application
       │
       ▼
   snora                 (engine — depends on iced)
       │
       ├──► snora-widgets  (optional, prefab UI parts — depends on iced)
       │        │
       ▼        ▼
   snora-core            (vocabulary — no iced dependency)

Applications normally depend on a single crate, snora, which re-exports the vocabulary from snora-core and (when its widgets feature is enabled, the default) the prefab widgets from snora-widgets.

snora-core — vocabulary

This crate owns the shape of the conversation between an application and a renderer. It contains:

  • AppLayout<Node, Message> — the data structure describing what should be on screen.
  • Vocabulary enums — LayoutDirection, Edge, ToastIntent, ToastLifetime, ToastPosition, SheetEdge, SheetSize, Icon.
  • Plain-data overlay types — Dialog<Node, Message>, Sheet<Node, Message>, Menu, MenuItem, MenuAction, SideBar, SideBarItem, Toast.

snora-core has zero dependency on iced. It is, in principle, a candidate for being driven by an alternative engine (a test double, a WGPU frontend, an HTML renderer).

snora-widgets — optional prefab widgets

This crate owns the visuals of the prefab parts — the bordered header bar, the icon-rail sidebar, the chrome-styled footer, the drop-down menu rendering, the icon resolver. Each is a function returning an iced::Element, so they slot into any AppLayout position by hand.

snora-widgets depends on snora-core (vocabulary) and iced. It does not depend on snora — the widgets work against any engine that consumes snora-core.

Applications normally do not depend on snora-widgets directly. They are pulled in transparently by snora’s default widgets feature, which re-exports them under snora::widget.

snora — engine

This crate binds the vocabulary to iced 0.14:

  • render(layout) — the single entry point. Consumes AppLayout<iced::Element<'_, M>, M> and returns iced::Element<'_, M>.
  • Toast layer — builds the stacked toast column and resolves ToastPosition to a physical anchor.
  • Overlay renderers — dialog, sheet. They paint the centered card / edge-anchored panel; the dim backdrop is owned by render itself.
  • Lifecycle helpers — snora::toast::subscription, snora::toast::sweep_expired.
  • Re-exports of snora-widgets (when the widgets feature is on) under the path snora::widget.

Why this split

Three reasons matter in practice:

  1. One iced upgrade only touches the iced-dependent crates. When iced 0.15 ships, snora-core’s vocabulary stays the same; only snora and snora-widgets need their dependency line bumped. Applications that depend only on the re-exported names see no churn.

  2. Engine and widgets evolve at different paces. snora (engine) is conservative — z-stack rules and overlay machinery should change rarely. snora-widgets (visuals) is freer to add new prefab parts on a faster cadence. Splitting them lets each move without dragging the other.

  3. The vocabulary is the smallest reviewable surface. Reading snora-core’s few hundred lines is a quick way to understand what can be on screen in a snora application. Implementation details (z-stacks, dim layers, padding constants, widget styles) stay out of the conceptual model.

The split is not for runtime modularity — it is a documentation and upgrade-management tool. Applications that supply 100 % of their UI parts can opt out of snora-widgets via default-features = false on snora to avoid pulling its compilation in.

Layer-by-layer rendering

The render function composes layers in this order, bottom to top:

0. skeleton          header / body+sidebar / footer
1. menu backdrop     transparent click sink (if a menu is open)
2. header_menu
3. context_menu
4. modal dim         40 % black click sink (if a modal is present)
5. dialog
6. sheet
7. toasts            always on top, even over modals

Layers are conditional: each one materializes only when the corresponding AppLayout field is populated. The dim layer’s click-to-close behavior is driven by on_close_modals / on_close_menus; if those are None, the layers still render but without click-outside dismissal.

What is not in any of these crates

  • Form widgets (validation, fields). Use iced’s primitives.
  • Data-table or chart components. Use iced’s canvas or a data-visualization crate.
  • Theming definitions. snora consumes the active iced Theme to resolve intent colors and chrome styling; the theme itself is iced’s concern.
  • Persistence, networking, business logic. snora is a presentation layer.

Vocabulary cheatsheet

Every public enum in snora-core, with one-line semantics. Use this as a quick scan when you forget a variant name.

Direction and edges

#![allow(unused)]
fn main() {
pub enum LayoutDirection { Ltr, Rtl }
pub enum Edge            { Start, End }
}

LayoutDirection is the framework-wide reading direction. Edge expresses logical position on the horizontal axis; resolve to physical left/right via Edge::is_left_under(direction).

Toasts

#![allow(unused)]
fn main() {
pub enum ToastIntent {
    Debug,
    Info,
    Success,
    Warning,
    Error,
}

pub enum ToastLifetime {
    Transient(std::time::Duration),
    Persistent,
}

pub enum ToastPosition {
    TopEnd,        // default
    TopStart,
    TopCenter,
    BottomEnd,
    BottomStart,
    BottomCenter,
}
}

ToastIntent maps to theme colors (intent → palette pair). Helpers:

  • ToastLifetime::DEFAULT — 4-second transient.
  • ToastLifetime::seconds(n) / ToastLifetime::millis(ms).
  • ToastPosition::is_top() / is_bottom() — partition helpers.

Sheets

#![allow(unused)]
fn main() {
pub enum SheetEdge {
    Bottom,             // default
    Top,
    Start,              // logical (LTR=left, RTL=right)
    End,                // logical (LTR=right, RTL=left)
}

pub enum SheetSize {
    OneThird,           // default
    Half,
    TwoThirds,
    Ratio(f32),         // clamped 0.0..=1.0
    Pixels(f32),
}
}

SheetSize is interpreted along the axis perpendicular to the edge — height for top/bottom, width for start/end.

Helpers:

  • SheetSize::DEFAULT, as_ratio(), as_pixels().
  • SheetEdge::is_vertical() / is_horizontal() — partition helpers.

Icons

#![allow(unused)]
fn main() {
pub enum Icon {
    Text(String),
    #[cfg(feature = "lucide-icons")] Lucide(lucide_icons::Icon),
    #[cfg(feature = "svg-icons")]    Svg(std::path::PathBuf),
}
}
#![allow(unused)]
fn main() {
pub enum MenuAction<MenuId, MenuItemId> {
    MenuPressed(MenuId),
    MenuItemPressed { menu_id: MenuId, menu_item_id: MenuItemId },
}
}

MenuId and MenuItemId are application-defined. snora is generic over both.

Tabs

#![allow(unused)]
fn main() {
pub struct Tab<TabId: Clone + PartialEq> {
    pub id: TabId,
    pub label: String,
    pub icon: Option<Icon>,
}

pub struct TabBar<TabId: Clone + PartialEq> {
    pub tabs: Vec<Tab<TabId>>,
    pub active: TabId,
}

pub enum TabAction<TabId> {
    Pressed(TabId),
}
}

TabId is application-defined (typically a small enum). The widget renders the entry whose id == active with an underline.

#![allow(unused)]
fn main() {
pub struct Crumb<CrumbId: Clone> {
    pub id: CrumbId,
    pub label: String,
    pub is_leaf: bool,
}

pub enum BreadcrumbAction<CrumbId> {
    Pressed(CrumbId),
}
}

Helpers:

  • Crumb::ancestor(id, label) — clickable ancestor.
  • Crumb::leaf(id, label) — current (last) entry, plain text.

Defaults at a glance

LayoutDirection::default()  → Ltr
ToastPosition::default()    → TopEnd
ToastLifetime::DEFAULT      → Transient(4 s)
SheetEdge::default()        → Bottom
SheetSize::DEFAULT          → OneThird

Built-in widgets

snora ships a small set of prefab iced::Element builders for the common chrome — header, sidebar, footer, menu, icon. They are all plain functions, available under snora::widget (re-exported from the snora-widgets crate when the widgets feature is enabled, which is the default), and they are entirely optional: any iced::Element works in an AppLayout slot.

When to use the prefabs

Use them to get a working app on screen quickly, or when your chrome is indistinguishable from generic desktop UI. Skip them the moment you want to customize beyond what the helper exposes — write your own iced row and put it in the slot. Snora’s value is the skeleton + overlay machinery, not the styling of these specific widgets.

If you want to skip the widget compilation entirely:

snora = { version = "0.6", default-features = false }

In that configuration snora::widget, snora::direction, and snora::style do not exist; you supply your own elements for every AppLayout slot.

Inventory

app_header

#![allow(unused)]
fn main() {
pub fn app_header<'a, Message, MenuId, MenuItemId, F>(
    title: &'a str,
    menus: Vec<Menu<MenuId, MenuItemId>>,
    on_menu_action: &'a F,
    active_menu_id: Option<&MenuId>,
    end_controls: Option<Element<'a, Message>>,
    direction: LayoutDirection,
) -> Element<'a, Message>
}

Bold title on the start edge, drop-down menus next to it, optional controls anchored to the end edge. Direction-aware.

app_side_bar

#![allow(unused)]
fn main() {
pub fn app_side_bar<'a, Message, ViewId>(
    side_bar: SideBar<Message, ViewId>,
    direction: LayoutDirection,
) -> Element<'a, Message>
}

Vertical icon rail with tooltips. The active item gets a subtle background highlight. Tooltip side flips with direction.

#![allow(unused)]
fn main() {
pub fn app_footer<'a, Message>(
    content: Element<'a, Message>,
) -> Element<'a, Message>
}

Thin chrome-styled container. Direction is the caller’s responsibility — pass a row_dir-built row if you need start/end layout inside.

render_menu

#![allow(unused)]
fn main() {
pub fn render_menu<'a, Message, MenuId, MenuItemId, F>(
    menu: Menu<MenuId, MenuItemId>,
    on_action: &'a F,
    is_active: bool,
) -> Element<'a, Message>
}

Used internally by app_header. You normally do not call this directly — app_header consumes a Vec<Menu> and renders all of them. Direct calls are for non-header menus.

icon_element / icon_element_sized

#![allow(unused)]
fn main() {
pub fn icon_element<'a, Message>(icon: &Icon) -> Element<'a, Message>;
pub fn icon_element_sized<'a, Message>(icon: &Icon, size: f32) -> Element<'a, Message>;
}

Resolve an Icon to an iced element at the default (14 px) or a specified size. Honors all enabled icon backends.

app_tab_bar

#![allow(unused)]
fn main() {
pub fn app_tab_bar<'a, Message, TabId, F>(
    bar: TabBar<TabId>,
    on_action: &'a F,
    direction: LayoutDirection,
) -> Element<'a, Message>
where
    F: Fn(TabAction<TabId>) -> Message + 'a;
}

Horizontal tab strip for peer-level navigation. The active tab gets a colored underline; inactive tabs are flat text. Direction-aware: the entire tab order mirrors under Rtl.

app_breadcrumb

#![allow(unused)]
fn main() {
pub fn app_breadcrumb<'a, Message, CrumbId, F>(
    crumbs: Vec<Crumb<CrumbId>>,
    on_action: &'a F,
    direction: LayoutDirection,
) -> Element<'a, Message>
where
    F: Fn(BreadcrumbAction<CrumbId>) -> Message + 'a;
}

Hierarchical position indicator. Ancestors render as clickable text; the leaf (current page, marked with Crumb::leaf(...)) is plain text. The separator glyph flips with direction ( / ).

Direction helpers

In snora::direction:

#![allow(unused)]
fn main() {
pub fn row_dir<'a, M>(direction, start, end) -> iced::widget::Row<'a, M>;
pub fn row_dir_three<'a, M>(direction, start, center, end) -> iced::widget::Row<'a, M>;
}

The smallest tool for writing your own direction-aware widgets — see Direction and ABDD.

Style hooks

In snora::style:

  • chrome_container_style(theme) — the bordered chrome look used by app_header and app_footer.
  • menu_button_style(theme, status) — text-only button styling for menu entries.
  • sidebar_active_color(theme) — the highlight color used for the active sidebar item.

These are exposed so that custom widgets can match the prefab look when desired.

Internal architecture

This page is for people changing snora’s source. For consumers, see reference/architecture.md, which is shorter and stops at the public surface.

Source layout

crates/
├── snora-core/                  # vocabulary (no iced dep)
│   src/
│     lib.rs                     # re-exports only
│     direction.rs               # LayoutDirection, Edge
│     crumb.rs                   # Crumb / BreadcrumbAction
│     icon.rs                    # Icon enum + From conversions
│     layout.rs                  # AppLayout struct + builder
│     menu.rs                    # Menu / MenuItem / MenuAction
│     overlay.rs                 # Dialog / Sheet / SheetEdge / SheetSize
│     sidebar.rs                 # SideBar / SideBarItem
│     tab.rs                     # Tab / TabBar / TabAction
│     toast.rs                   # Toast / ToastIntent / ToastLifetime
│                                # / ToastPosition
├── snora-widgets/               # optional prefab widgets
│   src/
│     lib.rs                     # re-exports
│     direction.rs               # row_dir / row_dir_three
│     style.rs                   # shared style functions
│     crumb.rs                   # app_breadcrumb
│     footer.rs                  # app_footer
│     header.rs                  # app_header
│     icon.rs                    # icon_element / icon_element_sized
│     menu.rs                    # render_menu
│     sidebar.rs                 # app_side_bar
│     tab.rs                     # app_tab_bar
└── snora/                       # iced engine
    src/
      lib.rs                     # vocabulary re-exports + widget bridge
      render.rs                  # the only entry point: render(layout)
      toast.rs                   # toast layer + lifecycle helpers
      overlay.rs                 # module declaration
      overlay/
        sheet.rs                 # render_sheet (all 4 edges)
        dialog.rs                # render_dialog

No mod.rs files; we use the my_module.rs + my_module/ layout introduced in Rust 2018.

Crate boundaries — what goes where

When in doubt, ask: can this be done without iced?

  • If yes, it belongs in snora-core. Examples: enum definitions, struct field shapes, sweep logic on Vec<Toast>, the partition helpers is_top() / is_left_under().
  • If no, it belongs in snora. Examples: anything that returns or consumes iced::Element, anything that touches iced::Theme, any Subscription.

There are no exceptions. snora-core’s Cargo.toml does not list iced as a dependency, and cargo check -p snora-core confirms this before each merge.

The render flow

render is the only place where layers get assembled into a stack. Nothing else in either crate composes z-layers; downstream code only ever produces individual elements. This keeps the layer order in one place — see the comment block at the top of render.rs.

Internal vs public visibility

  • pub — appears in snora-core re-exports (and via snora’s re-exports). Consumer-visible.
  • pub(crate) — engine internals. Used by render to call into the individual overlay renderers without exposing them.
  • private — module-internal helpers.

When adding a new overlay or vocabulary item, default to private and escalate when needed; do not start at pub.

Adding a new vocabulary type

  1. Add the enum / struct to snora-core/src/<topic>.rs.
  2. Add unit tests next to it (variants partition cleanly, defaults are what they say, etc.).
  3. Add it to snora-core/src/lib.rs’s top-level re-exports.
  4. Add it to snora/src/lib.rs’s pub use snora_core::{ ... }.
  5. Document it in docs/reference/vocabulary.md.

Adding a new prefab widget

  1. Add the function in snora/src/widget/<name>.rs.
  2. Declare the module in snora/src/widget.rs.
  3. Re-export from snora/src/widget.rs for ergonomic access.
  4. Document it in docs/reference/widgets.md.

Why no Cargo.lock in version control

Snora ships as libraries; Cargo’s convention for libraries is to leave Cargo.lock out of git so consumers’ lockfiles win. Examples are internal binaries but they share the workspace lockfile, which still follows the library convention.

Design decisions

A snora API decision is rarely a free choice — most of them have a shape that closes off other shapes. This page records the reasoning so that future contributors don’t relitigate decisions whose trade-offs are still valid.

Why no PageContract trait

Early drafts (≤ 0.3) defined a trait that page-like objects implemented:

#![allow(unused)]
fn main() {
trait PageContract {
    type Node;
    type Message;
    fn view(&self) -> Self::Node;
    fn dialog(&self) -> Option<Dialog<Self::Node, Self::Message>>;
    fn toasts(&self) -> Vec<Toast<Self::Message>>;
    fn context_menu(&self) -> Option<Self::Node>;
    fn on_close_menus(&self) -> Option<Self::Message>;
    fn on_close_modals(&self) -> Option<Self::Message>;
}
}

The intent was that render_app would call each method and compose the result. In practice the engine never consumed any method other than view, so applications had to plumb the rest manually anyway — and the trait’s associated types forced all four layout slots to share a single page type, which produced a “Section enum” boilerplate.

In 0.4 the trait was removed and overlay state was moved to plain fields on AppLayout. Reasoning:

  • The trait did not earn its keep — it described a contract no engine implemented in full.
  • Plain fields make the closure of “what can be on screen” obvious by inspection of one struct.
  • Independent slot types are recoverable any time without API breakage by changing Node to Box<dyn Trait> if needed.

Why one close sink per channel, not per overlay

Dialog and Sheet could each carry an on_outside_click: Option<Message>. We considered that and rejected it.

  • Two overlays can be present together (a sheet under a dialog). With per-overlay sinks, two outside-clicks are needed to close both, which is unintuitive — usually the user wants the dim area to dismiss everything modal at once.
  • The 99% case is “one CloseModals message that resets all modal state”. Moving that into AppLayout::on_close_modals puts the user in the pit of success.
  • Per-overlay sinks would also have to interact with z-order rules, which is engine business.

The design loses flexibility (you cannot close the dialog and keep the sheet open via outside-click) but gains a one-place wiring rule that is hard to misuse. Net: positive.

Why one Sheet type, not BottomSheet / TopSheet / SideSheet

In 0.6 we generalized the bottom-anchored drawer of 0.5 into a single Sheet with a SheetEdge { Bottom, Top, Start, End }. The alternative — keep BottomSheet and add separate TopSheet / SideSheet types — was rejected.

  • AppLayout would need three optional fields where one suffices. The 99 % case is “show one sheet at a time”, and the engine’s z-order rule does not need to distinguish between edges.
  • Three nearly-identical builder methods would force callers to remember which type matches which edge. The general Sheet lets the edge ride on the value (Sheet::new(...).at(...)), keeping one builder symbol.
  • Snora’s “vocabulary over flags” principle says the enum is the vocabulary. Adding a SheetEdge enum is the canonical way to express the choice; adding three types is the anti-pattern.
  • The size axis is naturally edge-relative (height for vertical edges, width for horizontal). A single SheetSize reads cleanly in both senses without a per-type rename.

The 0.5 → 0.6 type rename (BottomSheetSheet, SheetHeightSheetSize) is breaking on paper but cushioned with #[deprecated] aliases that ship in 0.6 and are removed in 0.7.

Why default ToastPosition is TopEnd

In 0.4 the default was BottomEnd, mirroring OS notifications. In 0.5 we moved to TopEnd. Reasoning:

  • snora’s primary user — a local-first app with heavy background work — usually puts primary content (preview, editor, list) in the lower half of the window. Bottom-anchored toasts compete with primary content for screen space.
  • In-app notification frameworks across languages (Material Snackbar, Chakra, Mantine, sonner.js) more commonly default to a top corner.
  • The change is a one-line override for users who want the old behavior. We documented it in the migration guide.

Why the toast queue is Vec<Toast<Message>> owned by the application

Earlier drafts had snora own the queue internally. Externalizing it:

  • Lets the application persist toasts (e.g. across hot-reload or serialize them in tests) without an opaque framework handle.
  • Keeps update pure — snora’s framework state does not interleave with the application’s state machine.
  • Matches the iced “owned state, immutable view” idiom.

The cost is that the application clones the vec every view call to pass it into AppLayout::toasts. We measured: with toasts under a few dozen and Message types under a few hundred bytes, the clone cost is below the noise floor in iced’s render loop. We will revisit if a large-message use case shows up.

Why no Cargo.toml for snora-test

We considered shipping a separate crate of test helpers (Toast inspector, mock AppLayout). Decided against:

  • It would freeze internal types into the public test API. Adding a Toast::is_persistent() predicate, for instance, makes lifetime: ToastLifetime a stability commitment.
  • The Toast / Dialog / etc. structs already have pub fields, so plain assert! against application state covers the common cases — see guides/testing.md.
  • A dedicated test crate adds release coordination overhead (every release needs snora, snora-core, and snora-test bumped).

If the pattern becomes painful in practice, we will revisit.

Why three crates instead of two

In 0.4 and 0.5, snora was a two-crate workspace (snora-core + snora). In 0.6 we carved out the prefab widgets into a third crate, snora-widgets. The reasoning:

  • Widget evolution should not gate engine evolution. Adding a new widget (a tab bar, a breadcrumb, a status bar) is a faster cadence of change than adding a new overlay layer. Putting them in the same crate as render made every widget addition a release of the engine.
  • Engine-only applications shouldn’t pay for widgets. Applications that supply 100 % of their UI parts can opt out with default-features = false on snora and the snora-widgets compilation is skipped entirely.
  • The widget set is properly downstream of snora-core, not of snora. Widgets consume the vocabulary types (Icon, LayoutDirection, MenuAction<...>) but do not need the engine. The dependency edge snora-widgets → snora-core is direct; the previous structure forced widgets to be in snora even though they had no logical relationship to render.

The cost is one more Cargo.toml to maintain and one extra crate in publish order. In exchange we get clean dependency edges and a clear ownership boundary.

The 3-crate split is invisible to applications that depend only on snorasnora’s lib re-exports snora-widgets under the familiar snora::widget path when the widgets feature is on (the default).

Why Tab and Crumb are separate vocabulary, not one navigation type

In 0.7 we added TabBar and Crumb as independent types rather than collapsing them into a single Navigation enum.

  • They communicate different shapes of UI affordance. Tabs imply peer-level switching — three to seven views the user expects to flip among. Breadcrumbs imply ancestor-level navigation — a path showing depth, only the parents are interactable. Conflating them in one type forces every consumer to handle both shapes; keeping them separate lets each screen pick exactly the affordance it wants.
  • The id types have different semantics. A TabId is a small closed set (3–7 values, typically all variants of an enum) and active is one of them. A CrumbId is a path-element id — potentially open-ended in the wider application even if any single trail is short. The semantic difference would have required generics either way; collapsing types only saves a module and gains nothing for the caller.
  • The is_leaf flag on Crumb would be meaningless on a tab. Tabs do not have a leaf concept; one of them is “active”, but pressing any of them is symmetric.

The cost of two types is two short modules. Each is around 60 lines of vocabulary and 80 lines of widget code. We are not at risk of vocabulary explosion in this corner of the API.

Why widget feature gating is coarse, not per-widget

Snora 0.7 ships one widgets feature on the snora crate. There is no widget-tab-bar / widget-breadcrumb / widget-header distinction. We deliberately stop at the coarse boundary.

  • The current widget set is small (seven prefab elements at 0.7). Compile time savings from gating any one widget out are negligible compared to the iced compile, which dominates cold-cache time.
  • A wider feature matrix multiplies documentation surface — every combination is something a user might trip over and a maintainer must keep coherent.
  • Fine-grained gates are additive. We can add them later without breaking anything; the inverse (removing them after shipping) breaks downstream code. Default to the simpler shape.

The criteria that would justify revisiting the decision are documented separately in contributing/feature-gating-criteria.md. That document records the indicators (compile time threshold, binary size threshold, heavy optional deps, platform-specific deps, field requests) so future maintainers do not have to reconstruct the reasoning.

Why AppLayout has both fields and a builder

Both are public and both supported. Reasoning:

  • The builder (AppLayout::new(body).header(h).footer(f)) is the recommended path because each setter has a clear name and you read the building site top-to-bottom.
  • Direct struct-literal construction (AppLayout { body, header, side_bar, ... }) is available as an escape hatch when generating layouts programmatically (e.g. in tests where you want explicit field-by-field overrides).

We are not going to add a Default impl that requires body: Option<Node>body is mandatory by construction; AppLayout::new exists precisely to enforce that.

Why no mod.rs

Style preference. my_module.rs + my_module/ is the Rust-2018+ idiom, keeps the file tree shallow, and matches how documentation generators present the module hierarchy (the file name appears alongside the directory name).

Why English-only comments

All comments are in English so that snora is reviewable by contributors regardless of language. Documentation prose in docs/ follows the same rule. Translations of docs/ into other languages are welcome as a separate effort.

Adding a new overlay kind

Use this page when you want to add an overlay surface that does not fit Dialog, Sheet, or context_menu. Examples that have come up in discussion (none implemented yet): a command palette centered like a dialog but with Escape to close, an anchored popover attached to a specific widget, an inline banner that drops in from the top of the body region rather than over the whole window.

Note that side panels and edge-anchored drawers are already covered by Sheet — choose SheetEdge::Start / End rather than introducing a separate “drawer” type.

Decision tree first

Before writing code, ask:

  1. Is this really a new overlay, or could it be a Dialog with different inner content? A command palette is often best built as a Dialog whose content is your search input + result list. You get the dim layer and the on_close_modals plumbing for free, and there is no new vocabulary.
  2. Is this a Sheet at a non-default edge? If so, just use Sheet::new(...).at(SheetEdge::Top) (or Start/End). No new overlay needed.
  3. Is it modal or transient? Modal → a sibling of Dialog / Sheet. Transient → a sibling of header_menu / context_menu.
  4. Does it have configuration that does not fit existing vocabulary? A command palette has search history, a popover has an anchor element. New configuration is the strongest reason to introduce a new type.

If you can answer “use a Dialog” or “use a Sheet at a different edge” to (1) or (2), stop here.

Steps if you do need a new overlay

1. Add the data type to snora-core

Place it in src/overlay.rs next to Dialog and Sheet. Keep the same shape:

#![allow(unused)]
fn main() {
pub struct CommandPalette<Node, Message> {
    pub content: Node,
    pub recent_count: usize,
    _marker: PhantomData<Message>,
}

impl<Node, Message> CommandPalette<Node, Message> {
    pub fn new(content: Node) -> Self { /* sane default */ }

    #[must_use]
    pub fn with_recent_count(mut self, n: usize) -> Self {
        self.recent_count = n;
        self
    }
}
}

Notes:

  • No on_close field — outside-click dismissal is wired via AppLayout::on_close_modals (modal) or on_close_menus (transient). Keep the rule consistent across overlay kinds.
  • The _marker: PhantomData<Message> keeps the Message type parameter available for future expansion (animations, lifecycle hooks) without being a breaking change.

2. Add any new vocabulary enums

If your overlay has configuration that does not fit a primitive (palette mode, popover anchor type), add a small enum next to the struct. Use logical terms (Start / End) for axis-aligned variants — never Left / Right directly.

3. Add an AppLayout field + builder method

In snora-core/src/layout.rs:

#![allow(unused)]
fn main() {
pub struct AppLayout<Node, Message> {
    // existing fields...
    pub command_palette: Option<CommandPalette<Node, Message>>,
}

impl<Node, Message: Clone> AppLayout<Node, Message> {
    #[must_use]
    pub fn command_palette(
        mut self,
        palette: CommandPalette<Node, Message>,
    ) -> Self {
        self.command_palette = Some(palette);
        self
    }
}
}

Update AppLayout::new to initialize it to None.

4. Add the renderer in snora

Create snora/src/overlay/command_palette.rs:

#![allow(unused)]
fn main() {
pub(crate) fn render_command_palette<'a, Message>(
    palette: CommandPalette<Element<'a, Message>, Message>,
    direction: LayoutDirection,
) -> Element<'a, Message>
where Message: Clone + 'a,
{
    // physical anchoring resolved here, not in snora-core
    // ...
}
}

Wire it into render in snora/src/render.rs. Decide which existing layer it joins (above the dim, with the modals; or below, with the menus) and put the if let Some(palette) block in the right place. Update the doc comment listing the layer order.

5. Add tests in snora-core

In the new overlay’s tests module:

  • Default constructor produces sensible defaults.
  • Builder methods compose (.at(...).at(...) keeps the second).
  • Any new vocabulary enums partition cleanly (cf. the ToastPosition::is_top / is_bottom pair).

6. Document

  • docs/reference/vocabulary.md — add the new enums.
  • docs/guides/overlays.md — add a section to the table at top and a paragraph below explaining when to reach for it.
  • If it changes behavior of close sinks, update docs/guides/overlays.md’s “one close sink, two channels” section.

7. Add an example

Under examples/<n>/ create a tiny crate that demonstrates the overlay in isolation. Existing examples are 100–200 lines each; follow the same length budget.

Add the new example to:

  • The workspace Cargo.toml members list.
  • The “Examples” section of the root README.md.
  • docs/README.md if it is something a beginner should see early.

Things to not do

  • Do not put physical anchoring (Left / Right) in snora-core. That breaks ABDD. Always use logical edges and resolve to physical sides in the engine renderer.
  • Do not let the new overlay carry its own close hook. Outside- click is wired once at AppLayout level. Per-overlay close buttons inside the content are fine; outside-click sinks are not.
  • Do not change the dim layer’s behavior. If your overlay needs no dim, classify it as a menu (uses transparent backdrop, fired by on_close_menus), not a modal. The dim layer is shared by all modals.

When to introduce per-widget feature gates

Snora’s current widget feature gating is coarse: a single widgets feature on the snora crate switches the entire snora-widgets set on or off. There is no widget-tab-bar / widget-breadcrumb / widget-header distinction.

This page records the criteria that would justify revisiting that decision and introducing per-widget feature gates. Do not split the widgets feature into multiple features unless at least one of the indicators below applies.

Background

The wider the feature matrix, the more combinations have to compile, test, and stay coherent in documentation. Five widgets with five toggleable features yields 32 combinations; ten widgets, 1024. Each combination is a potential bug surface (one widget references another’s helper, breaks when the helper is gated out) and a piece of documentation surface (which combinations are supported, which are not).

We accepted the cost of one on/off (widgets) because engine-only builds are a real, named use case. We deferred everything finer.

Indicators that justify revisiting

If two or more of these become true, open a discussion to introduce per-widget feature gates.

1. Compile time grows past acceptable

Threshold: cargo build -p snora-widgets from cold cache exceeds 30 seconds on a developer’s machine of average specs (8-core laptop, SSD, 16 GB RAM, no other heavy work).

Reasoning: snora’s selling point includes a fast iteration cycle. If cargo check of the widget set on its own approaches the cost of recompiling iced, the per-widget gate becomes worth its documentation cost.

How to measure: run cargo clean -p snora-widgets && time cargo build -p snora-widgets --release. Use --release so we are measuring optimization workload, not debug-info layout. Repeat the measurement at each release; track the trend.

2. Binary size measurably increases for engine-only consumers

Threshold: the difference between

cargo build --release -p snora-example-hello
cargo build --release -p snora-example-hello --no-default-features

exceeds 150 KB stripped on Linux x86_64.

Reasoning: at a small absolute size the noise from iced itself swamps any saving. The threshold reflects “noticeable in a discriminating distribution” rather than “the largest possible absolute saving”.

How to measure: build both binaries, strip them (strip --strip-all), wc -c each. Re-measure on each release.

3. A widget gains a heavy optional dependency

Threshold: any single widget pulls in a crate larger than 500 KB compiled that is not already required by the rest of snora-widgets.

Examples that would qualify (none have shipped):

  • A markdown_view widget pulling in a markdown parser.
  • A data_table widget pulling in a sortable-table or virtualized-list crate.
  • A chart widget pulling in plotters.

When this happens, the widget should ship behind its own feature flag immediately — that is the only way users who do not need it can avoid paying for it. Do not wait for two indicators.

This is the only indicator that, taken alone, justifies a new feature gate. The others require corroboration.

4. A widget needs a new platform-specific dependency

Threshold: any single widget links a system library that the rest of snora does not (e.g. libnotify for desktop notification fallback, a system clipboard binding beyond what iced provides).

Reasoning: optional system bindings are exactly what feature flags are for. Engine-only builds and CI cross-compile builds need to opt out cleanly.

5. A widget category is requested for distinct opt-in

Threshold: at least three independent applications in the field tell us they want a specific subset of widgets without the rest. “I only use the chrome widgets, not navigation” or “I only want icons and menus, no tab bar”.

Reasoning: this is the user-experience signal that the coarse gate no longer matches actual usage patterns. It is a soft indicator — two reports could be a coincidence; three suggests a structural mismatch.

What “revisiting” looks like

If the criteria justify per-widget gates, the work is:

  1. Add features named after their widget (widget-header, widget-sidebar, widget-tab-bar, …) to snora-widgets/Cargo.toml.
  2. Gate each module declaration in snora-widgets/src/lib.rs with #[cfg(feature = "widget-X")].
  3. Make the existing widgets feature on snora enable all of them, so the default user experience is unchanged.
  4. Document the new features in docs/getting-started/01-install.md and docs/guides/feature-gating.md.
  5. Bump the minor version (these are additive features).

The widgets umbrella feature should remain. We never want users who do not care about the partition to face a long feature list.

What this document is not

This is not a checklist that forces a split when an indicator is met. It is a list of inputs to a judgment call. If compile time grows but the cause is a transitive iced bump that affects all crates equally, splitting widget features will not help; the right fix is elsewhere. Indicators trigger a discussion, not a refactor.

Current status (snora 0.7.0)

IndicatorStatus
1. Compile timeWithin budget — snora-widgets builds in seconds when iced is cached.
2. Binary sizeUntested as of 0.7.0; no baseline. Establish in 0.8 if convenient.
3. Heavy optional depNone — all widgets share iced and snora-core only.
4. Platform-specific depNone.
5. Field requestsNone.

Re-evaluate at each release. Update this table as part of the release process if anything changed.

Release process

The workspace uses inheritance for the version number, so a release is fundamentally one edit. The supporting steps make sure the release is consistent across crates, examples, and the published artifact.

Versioning policy

Snora is pre-1.0 and follows the conventions of pre-1.0 SemVer:

  • Patch (0.x.y0.x.(y+1)) — bug fixes only. No API change, no behavior change visible to a typical app.
  • Minor (0.x0.(x+1)) — feature additions, API additions, and small breaking changes when justified. The 0.4 → 0.5 toast-default change is an example.
  • Major (0.x0.(x+1)) does not exist; a true major bump will be 1.0 with a stability pledge.

Inside a workspace cycle, all member crates share the same version. This is enforced by [workspace.package].version inheritance.

One-edit release

# Cargo.toml at workspace root
[workspace.package]
version = "0.5.1"        # bump

This change propagates to every member crate via version.workspace = true. No per-crate edit is needed.

If snora-core’s on-disk version changes minor digits, also bump snora’s declared dep:

# crates/snora/Cargo.toml
[dependencies]
snora-core = { path = "../snora-core", version = "0.5" }

The trailing "0.5" is a caret range (^0.5), so all 0.5.* patch releases are accepted. Bump it only on a minor.

Release checklist

[ ] Bump [workspace.package].version
[ ] If minor: bump snora-core / snora-widgets dep versions across crates
[ ] Move the [Unreleased] section in CHANGELOG.md to the new version,
    and reset [Unreleased] to "Nothing yet."
[ ] Update docs/guides/migration-X.Y-to-X.Z.md (minor only)
[ ] Update ROADMAP.md (move shipped items off; rewrite "Near-term"
    if priorities changed)
[ ] Re-run cargo metadata; confirm every crate reports new version
[ ] cargo check --workspace --all-features
[ ] cargo clippy --workspace --all-targets --all-features -- -D warnings
[ ] cargo test --workspace --all-features
[ ] mdbook build docs               # validates the book renders
[ ] cargo package -p snora-core    --no-verify    # check .crate contents
[ ] cargo package -p snora-widgets --no-verify    # check .crate contents
[ ] cargo package -p snora         --no-verify    # check .crate contents
[ ] git commit, git tag vX.Y.Z, git push --tags
[ ] cargo publish -p snora-core
[ ] cargo publish -p snora-widgets
[ ] cargo publish -p snora

Why --no-verify

cargo package --no-verify skips the dependency-resolution check that would otherwise demand the sibling crate be on crates.io already. We use it to inspect the .crate archive locally before the actual cargo publish (which has its own verification step that is order-aware).

Publish order

Strictly bottom-up along the dependency graph:

  1. snora-core (no internal deps).
  2. snora-widgets (depends on snora-core).
  3. snora (depends on snora-core, optionally snora-widgets).

Each crate’s Cargo.toml uses both path = "..." and version = "..." for inter-crate references, so cargo’s local build does not require crates.io, and crates.io’s verification finds the just-published sibling at the matching version.

Tarball releases (if used)

For local release artifacts shipped outside crates.io, name them with a version suffix:

snora-X.Y.Z.tar.gz

This was the convention adopted from 0.4.2 onward.

Examples are not published

The examples/* crates set publish = false in their Cargo.toml. They are part of the workspace for cargo check and cargo run -p convenience but never go to crates.io.