# Phase 6: Advanced Discovery and Fuzzy Search - Research

**Researched:** 2026-03-07
**Domain:** Slint UI search/filter interactions, Rust fuzzy matching, stateful discovery navigation
**Confidence:** HIGH

## Summary

Phase 6 adds three major interactive features to the existing discovery navigation shell: (1) an always-visible search bar with instant fuzzy filtering, (2) option grid views for Recipient and Product modes with filtered card-view transitions, and (3) a bottom-bar horizontal chip strip for multi-select status filtering. All three features compose on top of the existing `DiscoveryState`, `DashboardCardViewModel`, and Slint dashboard layout delivered in Phase 5.

The dataset is small (tens to low hundreds of cards), so the fuzzy search does not require a specialized search index or external crate. A case-insensitive substring match against `recipient_name` and `item_summary` fields is sufficient and avoids adding dependencies. The primary complexity is in UI state management: per-mode search persistence, three-layer Esc dismissal, and the option-grid-to-filtered-card-view transition with breadcrumb navigation.

**Primary recommendation:** Implement search as a pure Rust filter function over `Vec<DashboardCardViewModel>`, drive all new UI through Slint property bindings and callbacks following the existing pattern, and use Flickable with HorizontalLayout for the bottom chip bar.

<user_constraints>

## User Constraints (from CONTEXT.md)

### Locked Decisions
- Always-visible search bar positioned inline with the "Refresh All" button, above the card grid area.
- Search bar auto-focuses when user starts typing any character -- no explicit Ctrl+F or click-to-activate needed.
- Search matches against recipient name and item/product summary fields (not notes or status text).
- Results shown by filtering cards in-place -- non-matching cards removed from grid, grid re-layouts to show only matches.
- Result count badge displayed near the search input (e.g., "12 results") as feedback while typing.
- Search is global across all modes -- always filters against ALL cards regardless of active discovery mode.
- By Recipient mode: Alphabetical tile grid with A-Z section headers. Clickable name tiles arranged in a responsive grid, grouped alphabetically.
- By Product Shipped mode: Tile grid with item image thumbnails alongside product names. Visually richer than recipient grid.
- Fuzzy search also filters option grid tiles.
- Clicking an option tile replaces the option grid with filtered card view showing only matching cards.
- Breadcrumb or back-arrow at top indicates filtered state.
- Esc returns from filtered card view back to the option grid.
- Bottom bar: Horizontal chip bar along the bottom edge of the card grid area.
- Chips are toggleable with accent color highlight.
- Available in ALL four discovery modes.
- All modes include status values (Not Shipped, Label Created, In Transit, Delivered, Return In Transit, Returned) as universal filter dimension.
- Per-mode state persistence: search text and chip selections persist per-mode independently.
- Three-layer Esc dismissal: (1) defocus search, (2) clear search text, (3) mode home.
- Double-Esc within 500ms window still resets to default mode (Phase 5 behavior preserved).
- If in filtered card view (from tile click), Esc returns to option grid before clearing search.

### Claude's Discretion
- Fuzzy matching algorithm choice (substring, trigram, edit distance).
- Search debounce timing.
- Exact chip bar height, spacing, and scroll indicator styling.
- Breadcrumb/back-arrow design in filtered card view.
- How option grid tiles respond to hover/focus states.
- Tile grid column count and responsive breakpoints.
- How the result count badge is positioned and styled.

### Deferred Ideas (OUT OF SCOPE)
None -- discussion stayed within phase scope.

</user_constraints>

<phase_requirements>

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| DISC-01 | Instant fuzzy search begins when user types any letter or space | Search bar with TextInput `edited` callback, case-insensitive substring filter, no debounce needed for small dataset |
| DISC-04 | By Recipient mode starts with recipient option grid then transitions to filtered card view | Slint conditional content areas (`if` guards), `DiscoveryViewState` enum to track option-grid vs filtered-cards |
| DISC-05 | By Product Shipped mode starts with product option grid then transitions to filtered card view | Same pattern as DISC-04 but with image thumbnails in tiles |
| DISC-06 | In filtered card view, bottom bar shows additional options for multi-select filtering | Horizontal Flickable with chip toggles, per-mode `HashSet<String>` for selected status filters |

</phase_requirements>

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| slint | 1.x | UI framework | Already in use, all UI is Slint |
| slint-build | 1.x | Build-time .slint compilation | Already in use |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| (no new dependencies) | - | Fuzzy search is hand-rolled substring match | Dataset is small enough that `str::to_lowercase().contains()` is sufficient |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Hand-rolled substring | `sublime_fuzzy` crate | Adds dependency for minimal benefit on <500 items; sublime-style matching is overkill |
| Hand-rolled substring | `nucleo-matcher` crate | High-performance async fuzzy matcher; designed for thousands of items, not needed here |

**Installation:**
```bash
# No new dependencies needed
```

## Architecture Patterns

### Recommended Project Structure
```
crates/app/src/dashboard/
  discovery.rs      # EXTEND: DiscoveryState with per-mode search text, filter sets, view state
  mod.rs             # EXTEND: DashboardRuntime with search/filter/tile-click/esc methods
  projection.rs      # EXTEND: Add filter_cards_by_search() and filter_cards_by_status()
  view_model.rs      # No changes needed (existing fields are search targets)
  state.rs           # No changes needed

crates/app/ui/
  dashboard.slint    # EXTEND: Add search bar, option grids, bottom chip bar
  search-bar.slint   # NEW: Search bar component
  option-grid.slint  # NEW: Reusable tile grid component
  chip-bar.slint     # NEW: Bottom chip bar component
  card.slint         # No changes needed
  tab-strip.slint    # No changes needed
```

### Pattern 1: Per-Mode State in DiscoveryState
**What:** Extend `DiscoveryState` to hold per-mode search text, chip selections, and view state (option grid vs filtered cards).
**When to use:** All search/filter state needs mode isolation.
**Example:**
```rust
use std::collections::HashSet;

/// Tracks whether a mode shows its option grid or filtered card view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeViewState {
    OptionGrid,          // Showing tile grid (Recipient/Product modes)
    FilteredCards,       // Showing cards filtered by tile selection
    CardGrid,            // Default card grid (StatusUpdated/ShipDate modes)
}

/// Per-mode persistent state for search and filters.
#[derive(Debug, Clone)]
pub struct ModeState {
    pub search_text: String,
    pub selected_status_filters: HashSet<String>,
    pub view_state: ModeViewState,
    pub selected_tile: Option<String>,  // Which tile was clicked
}

impl Default for ModeState {
    fn default() -> Self {
        Self {
            search_text: String::new(),
            selected_status_filters: HashSet::new(),
            view_state: ModeViewState::CardGrid,
            selected_tile: Option::None,
        }
    }
}
```

### Pattern 2: Layered Filter Pipeline
**What:** Apply filters in sequence: mode sort -> search filter -> status filter -> output.
**When to use:** Every card refresh/re-render cycle.
**Example:**
```rust
/// Filter cards by case-insensitive substring match against name and item summary.
pub fn filter_cards_by_search(
    cards: &[DashboardCardViewModel],
    query: &str,
) -> Vec<DashboardCardViewModel> {
    if query.trim().is_empty() {
        return cards.to_vec();
    }
    let q = query.to_lowercase();
    cards
        .iter()
        .filter(|c| {
            c.recipient_name.to_lowercase().contains(&q)
                || c.item_summary.to_lowercase().contains(&q)
        })
        .cloned()
        .collect()
}

/// Filter cards by selected status values (OR/union logic).
pub fn filter_cards_by_status(
    cards: &[DashboardCardViewModel],
    statuses: &HashSet<String>,
) -> Vec<DashboardCardViewModel> {
    if statuses.is_empty() {
        return cards.to_vec();
    }
    cards
        .iter()
        .filter(|c| statuses.contains(&c.status_pill))
        .cloned()
        .collect()
}
```

### Pattern 3: Three-Layer Esc State Machine
**What:** Extend the existing Esc handler to support search-active layers before reaching mode-home and double-Esc reset.
**When to use:** Every Esc keypress when search is active.
**Example:**
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscAction {
    DefocusSearch,          // Layer 1: blur search input, keep filter
    ClearSearch,            // Layer 2: clear search text, restore unfiltered
    ReturnToOptionGrid,     // From filtered card view -> option grid
    ClearFiltersAndScrollTop, // Layer 3: clear chips, scroll top
    ResetToDefault,         // Double-Esc: back to ByStatusUpdated
}
```

### Pattern 4: Slint Conditional Content Areas
**What:** Use `if` guards in Slint to swap between option grid and card grid content.
**When to use:** Recipient and Product modes need to show option grid OR filtered cards, not both.
**Example:**
```slint
// Inside the card grid region
if show-option-grid : OptionGrid {
    // tile grid content
}
if !show-option-grid : Flickable {
    // card grid content (existing)
}
```

### Pattern 5: Slint TextInput with Global Key Capture
**What:** The search TextInput must coexist with the global FocusScope for Up/Down/Esc. When TextInput has focus, `text-input-focused` suppresses global handlers. Character keys auto-focus the search input.
**When to use:** Search bar integration with existing keyboard navigation.
**Example:**
```slint
// In global FocusScope key-pressed handler, for non-special keys:
// Forward the character to search bar and focus it
// This leverages the existing text-input-focused property pattern
```

### Anti-Patterns to Avoid
- **Rebuilding the entire card model on every keystroke:** Filter in Rust, push only the filtered slice to Slint. Do NOT rebuild VecModel from scratch each time -- use Slint's ModelRc efficiently.
- **Storing search state in Slint:** Keep all filter/search state in Rust's `DiscoveryState`. Slint properties are display-only bindings.
- **Mixing chip filter logic with search logic:** Keep them as separate filter stages in the pipeline. Search filters cards, then status chips filter the result. They compose, not merge.
- **Using ScrollView for chip bar:** ScrollView adds vertical scrollbar chrome. Use raw Flickable for clean horizontal-only scroll.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Horizontal scrollable chip bar | Custom scroll logic with TouchArea drag | Slint Flickable with `viewport-width` | Flickable handles touch/mouse drag, momentum, and bounds natively |
| Text input with focus management | Manual character capture in FocusScope | Slint TextInput element | TextInput handles cursor, selection, clipboard, IME |
| Card grid layout math | Manual position calculation for filtered grids | Existing `mod(index, 3)` / `floor(index / 3)` pattern | Already proven in dashboard.slint, just apply to filtered card list |

**Key insight:** The existing dashboard already solves card grid layout. Filtering just changes which cards appear in the model -- the layout math stays identical.

## Common Pitfalls

### Pitfall 1: FocusScope vs TextInput Focus Conflict
**What goes wrong:** Global FocusScope captures all keys, preventing TextInput from receiving character input.
**Why it happens:** Slint's FocusScope is greedy when enabled.
**How to avoid:** The existing `text-input-focused` property pattern already solves this. When TextInput has focus, set `text-input-focused: true` which disables the global FocusScope via `enabled: !root.text-input-focused`. The tricky part is auto-focusing the TextInput on any character press -- this requires the global FocusScope to detect non-special keys and forward focus.
**Warning signs:** Keys typed but nothing appears in search bar; or search works but Up/Down/Esc stop working.

### Pitfall 2: Viewport Height Not Updating After Filter
**What goes wrong:** After filtering cards, the Flickable's `viewport-height` still reflects the unfiltered card count, leaving empty scrollable space.
**Why it happens:** Viewport height is bound to `root.cards.length` which must update when the filtered model changes.
**How to avoid:** Bind viewport-height to the model that is actually displayed (the filtered one), not the source-of-truth model.
**Warning signs:** Large empty space below cards after searching.

### Pitfall 3: Per-Mode State Lost on Mode Switch
**What goes wrong:** Switching to another mode and back clears the search text or chip selections.
**Why it happens:** State stored as single global value instead of per-mode map.
**How to avoid:** Use a `HashMap<DiscoveryMode, ModeState>` or `[ModeState; 4]` array indexed by mode. On mode switch, save current state and restore the target mode's state.
**Warning signs:** User filters in Recipient mode, switches to Status mode and back, filter is gone.

### Pitfall 4: Double-Esc Interference with Three-Layer Esc
**What goes wrong:** The double-Esc-within-500ms behavior fires when the user is just dismissing search layers.
**Why it happens:** The Esc timing window doesn't account for which layer the Esc is operating on.
**How to avoid:** Only start the double-Esc timer when the Esc reaches the "ClearFiltersAndScrollTop" layer (i.e., when search is already clear and we're at mode home). If search is active or in filtered card view, Esc processes layers without touching the double-Esc timer.
**Warning signs:** User presses Esc twice quickly to defocus then clear search, but gets sent back to default mode.

### Pitfall 5: Option Grid Tile Data Extraction
**What goes wrong:** Building the option grid tile list requires extracting unique recipients or products from the card data, but this isn't readily available.
**Why it happens:** `DashboardCardViewModel` stores display strings, not structured data for grouping.
**How to avoid:** Compute option grid tile lists from the card view models: for recipients, deduplicate by `recipient_name` and sort alphabetically; for products, deduplicate by `item_summary` and include the `first_item_image_hint`. This computation happens in Rust and pushes a tile model to Slint.
**Warning signs:** Duplicate tiles, missing tiles for cards with shared recipients.

## Code Examples

### Search Filter Function (Rust)
```rust
// Source: Project-specific, using standard Rust str methods
pub fn filter_cards_by_search(
    cards: &[DashboardCardViewModel],
    query: &str,
) -> Vec<DashboardCardViewModel> {
    let q = query.trim().to_lowercase();
    if q.is_empty() {
        return cards.to_vec();
    }
    cards
        .iter()
        .filter(|c| {
            c.recipient_name.to_lowercase().contains(&q)
                || c.item_summary.to_lowercase().contains(&q)
        })
        .cloned()
        .collect()
}
```

### Search Bar Component (Slint)
```slint
// Source: Slint docs TextInput reference
component SearchBar inherits Rectangle {
    in-out property <string> search-text;
    in property <int> result-count: 0;
    out property <bool> input-has-focus: search-input.has-focus;
    callback text-changed(string);

    height: 32px;
    background: #242838;
    border-radius: 16px;
    border-width: 1px;
    border-color: search-input.has-focus ? #4a7cff : #3a3f52;

    HorizontalLayout {
        padding-left: 12px;
        padding-right: 12px;
        spacing: 8px;
        alignment: center;

        // Search icon placeholder
        Text {
            text: "?";
            font-size: 14px;
            color: #8a92a8;
            vertical-alignment: center;
            width: 16px;
        }

        search-input := TextInput {
            text <=> root.search-text;
            color: #e0e4ef;
            font-size: 13px;
            single-line: true;
            horizontal-alignment: left;
            vertical-alignment: center;

            edited => {
                root.text-changed(self.text);
            }
        }

        // Result count badge
        if root.search-text != "" : Text {
            text: root.result-count + " results";
            font-size: 11px;
            color: #8a92a8;
            vertical-alignment: center;
        }
    }

    // Public function to focus the input programmatically
    public function focus-input() {
        search-input.focus();
    }
}
```

### Horizontal Chip Bar (Slint)
```slint
// Source: Slint Flickable docs
struct ChipData {
    label: string,
    selected: bool,
}

component ChipBar inherits Rectangle {
    in property <[ChipData]> chips;
    callback chip-toggled(int);

    height: 36px;
    background: #1a1e2a;

    Flickable {
        width: parent.width;
        height: parent.height;
        viewport-width: chip-layout.preferred-width;
        interactive: true;

        chip-layout := HorizontalLayout {
            padding-left: 8px;
            padding-right: 8px;
            spacing: 6px;
            alignment: start;

            for chip[idx] in root.chips : Rectangle {
                width: chip-text.preferred-width + 20px;
                height: 26px;
                border-radius: 13px;
                background: chip.selected ? #4a7cff : #2d3348;
                border-width: 1px;
                border-color: chip.selected ? #4a7cff : #3a3f52;

                chip-text := Text {
                    text: chip.label;
                    font-size: 11px;
                    color: chip.selected ? #ffffff : #8a92a8;
                    horizontal-alignment: center;
                    vertical-alignment: center;
                }

                TouchArea {
                    mouse-cursor: pointer;
                    clicked => { root.chip-toggled(idx); }
                }
            }
        }
    }
}
```

### Option Grid Tile (Slint)
```slint
// Recipient tile - simple text tile with hover state
component RecipientTile inherits Rectangle {
    in property <string> name;
    callback clicked();

    width: 120px;
    height: 36px;
    border-radius: 6px;
    background: tile-touch.has-hover ? #2d3348 : #242838;
    border-width: 1px;
    border-color: tile-touch.has-hover ? #4a7cff : transparent;

    Text {
        text: root.name;
        font-size: 12px;
        color: #e0e4ef;
        horizontal-alignment: center;
        vertical-alignment: center;
    }

    tile-touch := TouchArea {
        mouse-cursor: pointer;
        clicked => { root.clicked(); }
    }
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Single global DiscoveryState | Per-mode state array | Phase 6 | Enables search/filter persistence across mode switches |
| Direct card model binding | Filtered projection pipeline | Phase 6 | Search and chips compose as filter layers |
| FocusScope-only keyboard | FocusScope + TextInput coexistence | Phase 6 | Must manage focus handoff for search activation |

**Deprecated/outdated:**
- None for this phase -- all patterns build on Phase 5's foundation.

## Open Questions

1. **Auto-focus on character key: Implementation approach**
   - What we know: The global FocusScope can detect non-special key presses. TextInput has a `focus()` function.
   - What's unclear: Whether Slint allows calling `focus()` on a TextInput from within a FocusScope's key-pressed handler, and whether the pressed character will be forwarded to the TextInput after focus transfer.
   - Recommendation: Test this during Plan 06-01. Fallback: user clicks the search bar or presses a dedicated shortcut to activate search. The key-pressed handler may need to append the character to the search text property manually after focus transfer.

2. **Viewport-width for chip bar Flickable**
   - What we know: Flickable's `viewport-width` should auto-calculate from children in theory, but Slint docs note this may not work with `for` loops (issue #407).
   - What's unclear: Whether Slint 1.x has fixed this issue.
   - Recommendation: Explicitly compute and bind `viewport-width` from chip count and chip widths rather than relying on auto-calculation.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in `#[cfg(test)]` + `#[test]` |
| Config file | None -- uses `cargo test` |
| Quick run command | `cargo test --lib -p app -- dashboard` |
| Full suite command | `cargo test --lib` |

### Phase Requirements to Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| DISC-01 | Fuzzy search filters cards by substring match | unit | `cargo test --lib -p app -- dashboard::discovery::tests::filter_by_search` | No -- Wave 0 |
| DISC-01 | Empty query returns all cards | unit | `cargo test --lib -p app -- dashboard::discovery::tests::filter_empty_query` | No -- Wave 0 |
| DISC-01 | Search matches recipient_name and item_summary | unit | `cargo test --lib -p app -- dashboard::discovery::tests::filter_matches_name_and_item` | No -- Wave 0 |
| DISC-04 | Recipient tile list extracted from card data, deduplicated | unit | `cargo test --lib -p app -- dashboard::discovery::tests::recipient_tiles` | No -- Wave 0 |
| DISC-05 | Product tile list extracted with image hints | unit | `cargo test --lib -p app -- dashboard::discovery::tests::product_tiles` | No -- Wave 0 |
| DISC-06 | Status chip filter with OR/union logic | unit | `cargo test --lib -p app -- dashboard::discovery::tests::filter_by_status` | No -- Wave 0 |
| DISC-06 | Empty status selection returns all cards | unit | `cargo test --lib -p app -- dashboard::discovery::tests::filter_status_empty` | No -- Wave 0 |
| ALL | Per-mode state persists across mode switches | unit | `cargo test --lib -p app -- dashboard::discovery::tests::per_mode_state` | No -- Wave 0 |
| ALL | Three-layer Esc progression | unit | `cargo test --lib -p app -- dashboard::discovery::tests::esc_layers` | No -- Wave 0 |
| ALL | Double-Esc only fires at mode-home layer | unit | `cargo test --lib -p app -- dashboard::discovery::tests::double_esc_at_home` | No -- Wave 0 |

### Sampling Rate
- **Per task commit:** `cargo test --lib -p app -- dashboard`
- **Per wave merge:** `cargo test --lib`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `filter_cards_by_search` tests in `discovery.rs` -- covers DISC-01
- [ ] `filter_cards_by_status` tests in `discovery.rs` -- covers DISC-06
- [ ] `extract_recipient_tiles` / `extract_product_tiles` tests -- covers DISC-04, DISC-05
- [ ] `ModeState` persistence tests -- covers per-mode state
- [ ] Three-layer Esc state machine tests -- covers extended Esc behavior

## Sources

### Primary (HIGH confidence)
- [Slint TextInput docs](https://docs.slint.dev/latest/docs/slint/reference/keyboard-input/textinput/) - All TextInput properties, callbacks, and functions
- [Slint Flickable docs](https://docs.slint.dev/latest/docs/slint/reference/gestures/flickable/) - Horizontal scrolling capabilities
- Existing codebase: `crates/app/src/dashboard/discovery.rs`, `mod.rs`, `view_model.rs`, `dashboard.slint` - Current patterns and integration points

### Secondary (MEDIUM confidence)
- [Slint Flickable viewport-width auto-calculation issue](https://github.com/slint-ui/slint/issues/192) - May not auto-calc with `for` loops
- [Rust fuzzy search crates survey](https://crates.io/keywords/fuzzy) - Confirmed small dataset doesn't need external crate

### Tertiary (LOW confidence)
- Auto-focus transfer from FocusScope to TextInput with character forwarding -- needs runtime validation

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH - No new dependencies, building on proven patterns
- Architecture: HIGH - Direct extension of existing DiscoveryState and dashboard patterns
- Pitfalls: HIGH - Based on direct analysis of existing codebase and Slint docs
- Slint auto-focus behavior: MEDIUM - TextInput.focus() exists but character forwarding untested

**Research date:** 2026-03-07
**Valid until:** 2026-04-07 (stable domain, no fast-moving dependencies)
