# Phase 5: Discovery Navigation Framework - Research

**Researched:** 2026-03-06
**Domain:** Slint UI state machine, keyboard navigation, tab UI
**Confidence:** HIGH

## Summary

Phase 5 introduces a discovery mode system with four sort modes, a left-side icon tab strip, and global keyboard navigation. The project uses Slint UI with a Rust backend. The existing codebase has well-established enum-based state patterns (`CardRefreshState`, `CardEditState`, `DashboardSelectionState`) that the new `DiscoveryMode` enum should follow. The dashboard layout in `dashboard.slint` currently uses absolute positioning for a 3-column card grid; adding the tab strip requires adjusting x-offsets and widths.

Key technical challenges are: (1) Slint's `FocusScope` keyboard event handling must coexist with the existing popup `FocusScope` Esc handler in `card.slint`, requiring careful event propagation via `accept`/`reject` returns; (2) the double-Esc timed window (500ms) needs a Rust-side `slint::Timer` or timestamp tracking since Slint markup has no built-in timer primitive for this; (3) global shortcuts must be suppressed when a `TextInput` has focus, which requires tracking focus state from Slint back to the keyboard handler logic.

**Primary recommendation:** Use a top-level `FocusScope` in `dashboard.slint` with `key-pressed` for global keyboard handling, a Rust-side `DiscoveryMode` enum with sort functions in `projection.rs`, and `slint::Timer::single_shot` for the double-Esc timing window.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- Slim icon strip (~48px wide), icon-only tabs with tooltip labels on hover showing full mode name.
- Active tab indicated with left accent bar + lighter background highlight (both).
- Always visible -- permanent fixture, never auto-hides.
- Positioned below the title bar at the left edge; title bar remains full-width above. Tab strip starts at the same Y as the card grid content region.
- Instant content swap on mode switch -- no animation or transition. Cards immediately replace with new mode's sort order.
- All four modes show the same card set, re-sorted by the mode's criterion (status update date, ship date, recipient name, product shipped).
- Default mode on app launch is By Status Updated.
- Keyboard mode switch (Up/Down arrows) triggers a brief flash/pulse on the newly active tab for confirmation. Click-based switching does not flash.
- Esc once: clears any active filters and scrolls to top of current mode (mode home).
- Double-Esc within 500ms window: switches to default mode (By Status Updated). Timed window -- not state-based sequential logic.
- Visual feedback on Esc: tab flash + brief toast message (e.g., "Filters cleared" or "Back to Status Updated").
- Home key scrolls to top of card grid; End key scrolls to bottom.
- Global shortcuts (Up/Down/Esc/Home/End) suppressed only when a TextInput has focus (note editing, future search input). All other times, global keys are active.
- Layered Esc dismissal: if summary popup is open, Esc closes popup first. Mode navigation Esc only fires after popup is dismissed.
- Phase 5 keyboard is mode switching only -- no card-level keyboard navigation (Tab/arrow within grid).
- App window grabs focus on launch -- keyboard shortcuts active immediately without requiring a click.

### Claude's Discretion
- Exact icon choices for the four mode tabs.
- Toast message styling, duration, and positioning.
- Tab flash animation duration and easing.
- Exact tab strip background color and hover states for inactive tabs.

### Deferred Ideas (OUT OF SCOPE)
- Home/End key support for scrolling noted here but may be straightforward enough to include in Phase 5 implementation.
- Card-level keyboard navigation (Tab through cards, arrow keys within grid) -- future phase.
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| DISC-02 | UI provides left-side tabs for discovery mode switching | Tab strip component in `dashboard.slint` with 48px icon rail, `TouchArea` per tab, active state via `DiscoveryMode` enum binding |
| DISC-03 | Discovery modes include By Status Updated, By Ship Date, By Recipient, By Product Shipped | `DiscoveryMode` Rust enum with 4 variants; sort functions in `projection.rs` operating on `DashboardCardViewModel` fields |
| DISC-07 | Up/Down arrows cycle discovery mode | Top-level `FocusScope` in `dashboard.slint` handling `Key.UpArrow`/`Key.DownArrow`; mode cycling logic in Rust with wrapping |
| DISC-08 | Esc once returns current discovery mode to its home screen | Esc handler clears filters (Phase 6 hook) and sets `viewport-y: 0` on ScrollView; layered with popup Esc |
| DISC-09 | Esc twice returns to default discovery mode (By Status Updated) | 500ms timed window via `slint::Timer::single_shot` or `Instant` timestamp tracking in Rust |
</phase_requirements>

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Slint | latest (1.x) | UI framework - .slint markup + Rust bindings | Already used by project; FocusScope + key-pressed is the established keyboard pattern |
| slint::Timer | (part of slint) | Single-shot delayed callbacks for double-Esc timing | Only timer mechanism available in Slint's Rust API |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| std::time::Instant | stdlib | Timestamp tracking for double-Esc window alternative | If Timer approach proves awkward for state tracking |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| slint::Timer for double-Esc | Instant-based timestamp check | Timer is cleaner (auto-resets), Instant requires manual clearing but avoids Timer lifetime management |
| Top-level FocusScope for globals | Per-element key handlers | Top-level is simpler to reason about; per-element creates event propagation complexity |

## Architecture Patterns

### Recommended Project Structure
```
crates/app/src/dashboard/
  mod.rs           # DashboardRuntime gains discovery_mode field + mode transition methods
  state.rs         # DiscoveryMode enum + DiscoveryState struct (mode + esc timing)
  projection.rs    # sort_by_mode() function added alongside project_snapshot()
  view_model.rs    # DashboardCardViewModel gains sort-relevant fields if needed

crates/app/ui/
  dashboard.slint  # Tab strip component + FocusScope for global keys + ScrollView wrapper
  card.slint       # Unchanged (popup Esc handler already returns accept/reject correctly)
  tab-strip.slint  # (optional) Extract tab strip to own component for clarity
```

### Pattern 1: DiscoveryMode Enum (follows existing state.rs pattern)
**What:** Enum with four variants matching the four discovery modes, with methods for cycling and default.
**When to use:** All mode state transitions.
**Example:**
```rust
// Follows CardRefreshState / CardEditState pattern in state.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiscoveryMode {
    ByStatusUpdated,  // Default
    ByShipDate,
    ByRecipient,
    ByProductShipped,
}

impl DiscoveryMode {
    pub const DEFAULT: Self = Self::ByStatusUpdated;
    pub const ALL: [Self; 4] = [
        Self::ByStatusUpdated,
        Self::ByShipDate,
        Self::ByRecipient,
        Self::ByProductShipped,
    ];

    pub fn next(self) -> Self {
        match self {
            Self::ByStatusUpdated => Self::ByShipDate,
            Self::ByShipDate => Self::ByRecipient,
            Self::ByRecipient => Self::ByProductShipped,
            Self::ByProductShipped => Self::ByStatusUpdated,
        }
    }

    pub fn prev(self) -> Self {
        match self {
            Self::ByStatusUpdated => Self::ByProductShipped,
            Self::ByShipDate => Self::ByStatusUpdated,
            Self::ByRecipient => Self::ByShipDate,
            Self::ByProductShipped => Self::ByRecipient,
        }
    }

    pub fn index(self) -> usize {
        match self {
            Self::ByStatusUpdated => 0,
            Self::ByShipDate => 1,
            Self::ByRecipient => 2,
            Self::ByProductShipped => 3,
        }
    }

    pub fn label(self) -> &'static str {
        match self {
            Self::ByStatusUpdated => "By Status Updated",
            Self::ByShipDate => "By Ship Date",
            Self::ByRecipient => "By Recipient",
            Self::ByProductShipped => "By Product Shipped",
        }
    }
}
```

### Pattern 2: Sort-by-Mode in Projection Layer
**What:** A function that sorts the card view model vec based on the active discovery mode.
**When to use:** Every time the mode changes or cards are refreshed.
**Example:**
```rust
// In projection.rs - sorts in place after projection
pub fn sort_cards_by_mode(cards: &mut Vec<DashboardCardViewModel>, mode: DiscoveryMode) {
    match mode {
        DiscoveryMode::ByStatusUpdated => {
            // Sort by last_updated_at descending (most recent first)
            cards.sort_by(|a, b| b.last_updated_at.cmp(&a.last_updated_at));
        }
        DiscoveryMode::ByShipDate => {
            // Sort by status_date_inline descending
            cards.sort_by(|a, b| b.status_date_inline.cmp(&a.status_date_inline));
        }
        DiscoveryMode::ByRecipient => {
            // Sort by recipient_name ascending (alphabetical)
            cards.sort_by(|a, b| a.recipient_name.to_lowercase().cmp(&b.recipient_name.to_lowercase()));
        }
        DiscoveryMode::ByProductShipped => {
            // Sort by item_summary ascending (group by product)
            cards.sort_by(|a, b| a.item_summary.cmp(&b.item_summary));
        }
    }
}
```

### Pattern 3: Global FocusScope with TextInput Guard
**What:** A top-level FocusScope in dashboard.slint that handles global keyboard shortcuts but defers when a TextInput has focus.
**When to use:** The main dashboard window keyboard handler.
**Example:**
```slint
// In dashboard.slint - wrapping the entire content area
export component DashboardWindow {
    in property <bool> text-input-focused: false;
    in property <int> active-mode-index: 0;
    in property <bool> tab-flash-active: false;
    in property <string> toast-message: "";
    in property <bool> toast-visible: false;

    callback mode-switch-up();
    callback mode-switch-down();
    callback esc-pressed();
    callback home-pressed();
    callback end-pressed();

    // Global keyboard handler
    global-keys := FocusScope {
        enabled: !root.text-input-focused;
        key-pressed(event) => {
            if (event.text == Key.UpArrow) {
                root.mode-switch-up();
                return accept;
            }
            if (event.text == Key.DownArrow) {
                root.mode-switch-down();
                return accept;
            }
            if (event.text == Key.Escape) {
                root.esc-pressed();
                return accept;
            }
            if (event.text == Key.Home) {
                root.home-pressed();
                return accept;
            }
            if (event.text == Key.End) {
                root.end-pressed();
                return accept;
            }
            return reject;
        }
    }
    // ... rest of layout
}
```

### Pattern 4: Double-Esc Timed Window (Rust side)
**What:** Track Esc press timing using Instant timestamps to detect double-Esc within 500ms.
**When to use:** Handling the esc-pressed callback from Slint.
**Example:**
```rust
use std::time::{Duration, Instant};

struct DiscoveryState {
    mode: DiscoveryMode,
    last_esc_time: Option<Instant>,
}

impl DiscoveryState {
    const DOUBLE_ESC_WINDOW: Duration = Duration::from_millis(500);

    fn handle_esc(&mut self) -> EscAction {
        let now = Instant::now();
        if let Some(last) = self.last_esc_time {
            if now.duration_since(last) <= Self::DOUBLE_ESC_WINDOW {
                self.last_esc_time = None;
                self.mode = DiscoveryMode::DEFAULT;
                return EscAction::ResetToDefault;
            }
        }
        self.last_esc_time = Some(now);
        EscAction::ClearFiltersAndScrollTop
    }
}

enum EscAction {
    ClearFiltersAndScrollTop,
    ResetToDefault,
}
```

### Pattern 5: Tab Flash Animation (Slint side)
**What:** Brief background color pulse on the active tab triggered by keyboard mode switch.
**When to use:** After Up/Down arrow mode change only (not click).
**Example:**
```slint
// Tab rectangle with flash animation
Rectangle {
    property <bool> flash-active: false;
    property <color> base-color: #e8ecf2;
    property <color> flash-color: #c0d4f0;
    background: flash-active ? flash-color : base-color;
    animate background {
        duration: 300ms;
        easing: ease-out;
    }
}
```

### Pattern 6: Toast Message (Slint side)
**What:** A brief overlay text that appears on Esc actions and auto-hides.
**When to use:** After Esc-once ("Filters cleared") and double-Esc ("Back to Status Updated").
**Example:**
```slint
// Toast overlay at bottom of window
if root.toast-visible : Rectangle {
    x: (parent.width - 200px) / 2;
    y: parent.height - 60px;
    width: 200px;
    height: 32px;
    border-radius: 16px;
    background: #1d2430e0;
    opacity: 1.0;
    animate opacity {
        duration: 200ms;
        easing: ease-out;
    }
    Text {
        text: root.toast-message;
        color: #ffffff;
        font-size: 12px;
        horizontal-alignment: center;
        vertical-alignment: center;
    }
}
```
Toast hide timing is managed from Rust via `slint::Timer::single_shot(Duration::from_millis(1500), ...)`.

### Anti-Patterns to Avoid
- **Per-card FocusScope for global keys:** Do NOT add key handlers to each RecipientCard. Use a single top-level FocusScope. Multiple FocusScopes competing for keyboard input creates debugging nightmares.
- **State-based sequential Esc counting:** Do NOT track "esc_count" that increments. Use timestamp-based timing window as specified. Counter-based would require manual reset logic and creates edge cases.
- **Animating card transitions between modes:** User explicitly decided "instant content swap, no animation." Do not add transitions when card sort order changes.
- **Modifying card.slint popup FocusScope:** The existing Esc handler in the popup should remain unchanged. Layered Esc behavior is achieved by the popup consuming the event (returning `accept`) before it reaches the global handler.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Timer/delay callbacks | Custom thread-based timers | `slint::Timer::single_shot` | Must run on Slint event loop thread; custom timers risk thread-safety issues |
| Scroll-to-top/bottom | Manual viewport position math | Set `viewport-y: 0` (top) or `viewport-y: -(viewport-height - height)` (bottom) on Flickable/ScrollView | Slint's viewport model handles this correctly |
| Keyboard event propagation | Custom event bus between components | Slint's built-in `accept`/`reject` EventResult propagation | Already works correctly -- popup Esc returns `accept` preventing bubbling to global handler |

**Key insight:** Slint's event propagation model (accept/reject from FocusScope callbacks) already handles the layered Esc requirement. The popup's FocusScope will consume Esc before it reaches the global handler, provided the popup has focus when open.

## Common Pitfalls

### Pitfall 1: FocusScope Focus Loss
**What goes wrong:** The global FocusScope loses focus when user clicks a TouchArea or TextInput, and keyboard shortcuts stop working.
**Why it happens:** Slint transfers focus to the clicked element. The global FocusScope's `has-focus` becomes false.
**How to avoid:** Use the `enabled` property to gate the global FocusScope on `!text-input-focused`, and re-call `global-keys.focus()` after TextInput editing completes. Alternatively, track a `text-input-focused` property from the note editing state and suppress shortcuts during editing rather than relying on FocusScope focus state alone.
**Warning signs:** Keyboard shortcuts work on launch but stop after first click anywhere.

### Pitfall 2: Popup Esc vs Global Esc Collision
**What goes wrong:** Pressing Esc with popup open both closes the popup AND triggers mode navigation.
**Why it happens:** If both FocusScopes process the same key event.
**How to avoid:** The popup's FocusScope already returns `accept` for Esc (see card.slint line 369-374). As long as the popup FocusScope has focus when the popup is open, it will consume the event. The global handler should also check if any popup is open via a bound property.
**Warning signs:** Esc closes popup and simultaneously changes mode or shows toast.

### Pitfall 3: Card Grid X-Offset Not Updated
**What goes wrong:** Adding the 48px tab strip but forgetting to shift the card grid region rightward, causing cards to render under the tab strip.
**Why it happens:** The card grid in dashboard.slint uses `x: 24px` and `width: parent.width - 48px` for the content region. These must account for the new tab strip width.
**How to avoid:** Update the content region to `x: 24px + 48px` (or `72px`) and reduce width by 48px accordingly.
**Warning signs:** Cards visually overlap with or appear behind the tab strip.

### Pitfall 4: Sort Stability
**What goes wrong:** Cards with identical sort keys (e.g., same status date) shuffle order on every mode switch.
**Why it happens:** Rust's `sort_by` is not stable by default (actually it is stable, but `sort_unstable_by` is not).
**How to avoid:** Use `sort_by` (stable) not `sort_unstable_by`. Add `recipient_id` as a tiebreaker in all sort comparisons for deterministic ordering.
**Warning signs:** Cards with same dates/names flicker between positions on repeated mode switches.

### Pitfall 5: ScrollView Viewport Size with Dynamic Content
**What goes wrong:** Home/End keys don't scroll correctly because viewport height is wrong.
**Why it happens:** The current card grid uses absolute positioning without a ScrollView. When cards overflow, there's no scrollable container.
**How to avoid:** Wrap the card grid in a Flickable or ScrollView with explicitly calculated `viewport-height` based on card count and row height: `viewport-height: (card_count / 3 + 1) * (card_height + gap)`.
**Warning signs:** Cards render beyond the visible area with no way to scroll to them.

## Code Examples

### Card Grid with ScrollView and Tab Strip Layout
```slint
// Source: Pattern derived from existing dashboard.slint + Slint ScrollView docs
export component DashboardWindow {
    // ... properties ...

    Rectangle {
        background: #f5f6f8;

        // Title bar (full width, unchanged)
        Text { text: root.title-text; x: 24px; y: 20px; font-size: 28px; color: #1d2430; }

        // Tab strip (left side, below title bar)
        Rectangle {
            x: 0px;
            y: 72px;
            width: 48px;
            height: parent.height - 72px;
            background: #f0f2f6;
            // Tab items rendered here (see tab component pattern)
        }

        // Card grid content area (shifted right by tab strip width)
        ScrollView {
            x: 48px + 12px;
            y: 72px;
            width: parent.width - 60px - 12px;
            height: parent.height - 96px;
            viewport-height: /* calculated from card count */;

            for card-data[card-index] in root.cards : RecipientCard {
                // ... card rendering with adjusted widths ...
            }
        }
    }
}
```

### Mode Index to Slint Binding
```rust
// Source: Follows existing pattern in main.rs for binding data to Slint
// In the Slint-Rust bridge code:
let mode_index: i32 = discovery_state.mode.index() as i32;
dashboard_window.set_active_mode_index(mode_index);

// Re-sort and update cards
let mut cards = current_cards.clone();
sort_cards_by_mode(&mut cards, discovery_state.mode);
dashboard_window.set_cards(/* convert to Slint model */);
```

### Toast Display from Rust
```rust
// Source: slint::Timer docs (https://docs.rs/slint/latest/slint/struct.Timer.html)
use slint::Timer;
use std::time::Duration;

fn show_toast(window: &DashboardWindow, message: &str) {
    window.set_toast_message(message.into());
    window.set_toast_visible(true);

    let weak = window.as_weak();
    Timer::single_shot(Duration::from_millis(1500), move || {
        if let Some(w) = weak.upgrade() {
            w.set_toast_visible(false);
        }
    });
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Direct FocusScope on window | `capture-key-pressed` for pre-intercept | Slint 1.6+ | Could use for guaranteed global key capture, but `key-pressed` with `enabled` gating is sufficient for this use case |
| Manual scroll position tracking | Flickable/ScrollView `viewport-y` binding | Stable in Slint 1.x | Set `viewport-y: 0` for scroll-to-top, no custom math needed |

**Current in project:**
- No Slint dependency in Cargo.toml yet (Slint is used for `.slint` markup files but not compiled -- the app currently bootstraps without Slint runtime). Phase 5 may be the point where `slint` crate dependency is added, OR it may continue the pattern of `.slint` files as design specifications with Rust-side logic tested independently.
- The `dashboard-ui` feature flag exists but is unused -- likely reserved for gating Slint compilation.

## Open Questions

1. **Slint runtime integration status**
   - What we know: `.slint` files exist, `Cargo.toml` has no `slint` dependency, `dashboard-ui` feature flag is defined but unused, `main.rs` bootstraps with println not Slint window.
   - What's unclear: Whether Phase 5 should add the Slint runtime dependency or continue the design-spec-then-integrate pattern.
   - Recommendation: Continue the established pattern -- implement Rust-side state machine and sort logic with tests, write `.slint` markup, defer Slint runtime integration. The state machine and sort logic are independently testable.

2. **Ship date and product shipped sort data**
   - What we know: `DashboardCardViewModel` has `last_updated_at` (SystemTime) and `status_date_inline` (Option<String>) for date sorts, `recipient_name` for name sort, `item_summary` for product sort.
   - What's unclear: Whether `status_date_inline` is a formatted display string or parseable date. Whether `item_summary` is the right field for "By Product Shipped" sorting.
   - Recommendation: Use `status_date_inline` string comparison for ship date (lexicographic works if dates are ISO-formatted). For product shipped, `item_summary` is the available field. If finer-grained sort data is needed, add optional sort key fields to the view model.

3. **Home/End key scrolling**
   - What we know: Listed in CONTEXT.md locked decisions, but also in deferred ideas as "may be straightforward enough to include."
   - What's unclear: Whether to include or defer.
   - Recommendation: Include in Phase 5 -- it's a simple `viewport-y` assignment with no new state, and the keyboard handler is already being built.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in test framework (cargo test) |
| Config file | Standard Cargo.toml -- no special test config needed |
| Quick run command | `cargo test -p app --lib` |
| Full suite command | `cargo test --workspace` |

### Phase Requirements to Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| DISC-02 | Tab strip UI renders four mode tabs | manual-only | N/A (visual Slint markup) | N/A |
| DISC-03 | DiscoveryMode enum has 4 variants with labels, sort functions work | unit | `cargo test -p app --lib -- discovery` | No -- Wave 0 |
| DISC-07 | Up/Down arrows cycle through modes with wrapping | unit | `cargo test -p app --lib -- discovery` | No -- Wave 0 |
| DISC-08 | Esc clears filters and returns to mode home | unit | `cargo test -p app --lib -- discovery` | No -- Wave 0 |
| DISC-09 | Double-Esc within 500ms resets to default mode | unit | `cargo test -p app --lib -- discovery` | No -- Wave 0 |

### Sampling Rate
- **Per task commit:** `cargo test -p app --lib`
- **Per wave merge:** `cargo test --workspace`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `crates/app/src/dashboard/state.rs` -- add DiscoveryMode enum tests (cycling, default, index, label)
- [ ] `crates/app/src/dashboard/projection.rs` -- add sort_cards_by_mode tests (each mode, tiebreakers, empty vec)
- [ ] `crates/app/src/dashboard/mod.rs` or new `discovery.rs` -- add DiscoveryState tests (single Esc, double-Esc timing, mode transitions)

## Sources

### Primary (HIGH confidence)
- [Slint FocusScope docs](https://docs.slint.dev/latest/docs/slint/reference/keyboard-input/focusscope/) -- FocusScope API, key-pressed/capture-key-pressed callbacks, enabled property, EventResult
- [Slint Key Handling Overview](https://docs.slint.dev/latest/docs/slint/reference/keyboard-input/overview/) -- Key enum constants (UpArrow, DownArrow, Escape, Home, End)
- [Slint Animations docs](https://docs.slint.dev/latest/docs/slint/guide/language/coding/animation/) -- animate block syntax, duration, easing functions
- [slint::Timer API](https://docs.rs/slint/latest/slint/struct.Timer.html) -- Timer::single_shot for delayed callbacks
- [Slint ScrollView docs](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/views/scrollview/) -- viewport-y for programmatic scrolling
- Existing project code: `crates/app/src/dashboard/state.rs`, `dashboard.slint`, `card.slint` -- established patterns

### Secondary (MEDIUM confidence)
- [Slint global key interception discussion](https://github.com/slint-ui/slint/discussions/4231) -- FocusScope limitations with TextInput focus, workaround patterns
- [Slint toast discussion](https://github.com/slint-ui/slint/discussions/4344) -- No built-in toast; Timer + visibility toggle is community pattern

### Tertiary (LOW confidence)
- None

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- Slint is the project's chosen UI framework, patterns verified against official docs
- Architecture: HIGH -- follows established enum-based state patterns already in codebase
- Pitfalls: HIGH -- FocusScope focus loss and event propagation are well-documented issues in Slint community

**Research date:** 2026-03-06
**Valid until:** 2026-04-06 (stable -- Slint 1.x API is mature)
