Files
amethyst/docs/plans/2026-03-30-feat-app-drawer-v1a-plan.md
T
nrobi144 250bb5a1ad feat(multi-account): display names, middle-truncated npub, npub-only account fix
Account switcher dropdown improvements:
- Two-row display: Display Name on top, npub (middle-truncated) below
  e.g. 'Alice' / 'npub1abc...wxyz · Bunker'
- Middle-truncation for npub: shows first 10 + last 6 chars
- Resolves display names from DesktopLocalCache user metadata
- Confirmation dialog also shows display name
- npub-only (view-only) accounts now persist to encrypted storage
  (ensureCurrentAccountInStorage called in onLoginSuccess)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 09:35:33 +03:00

30 KiB

title, type, status, date, deepened, origin
title type status date deepened origin
feat: App Drawer with Categories and Search (v1a) feat active 2026-03-30 2026-03-30 docs/brainstorms/2026-03-30-desktop-navigation-overhaul-brainstorm.md

App Drawer with Categories and Search (v1a)

Enhancement Summary

Deepened on: 2026-03-30 Agents used: compose-expert, desktop-expert, kotlin-expert, architecture-strategist, performance-oracle, code-simplicity-reviewer, pattern-recognition-specialist, race-conditions-reviewer, best-practices-researcher

Key Improvements from Deepening

  1. Eliminated DrawerScreen wrapper — use DeckColumnType directly with category() extension. Single source of truth (3 reviewers agreed)
  2. Reduced from 4 files to 2AppDrawer.kt + DeckColumnTypeExtensions.kt in existing ui/deck/ package
  3. Fixed critical race conditionif() block instead of AnimatedVisibility prevents stale state on re-open
  4. Proper Desktop patternsonPreviewKeyEvent (not onKeyEvent), FocusRequester with awaitFrame(), indication = null on backdrop
  5. Added SinglePaneState — mirrors DeckState pattern, solves state hoisting problem, prepares for v1b/v1c
  6. derivedStateOf for filtered/grouped lists — prevents redundant computation on every recomposition
  7. Double-click guard — prevents duplicate column creation

Architectural Corrections

  • No new ui/drawer/ package — files stay in ui/deck/ (consistent with codebase)
  • No DrawerScreen data class — DeckColumnType already has title() and icon()
  • No AppDrawerState separate file — state is simple enough to live inside AppDrawer.kt
  • No ScreenCategory as separate file — category() is an extension function on DeckColumnType

Overview

Replace the current AddColumnDialog with a full-screen App Drawer overlay — a categorized, searchable launcher for all screens in Amethyst Desktop. Phase 1 of 3 in the Desktop Navigation Overhaul (see brainstorm: docs/brainstorms/2026-03-30-desktop-navigation-overhaul-brainstorm.md).

Problem Statement

The current AddColumnDialog is a basic AlertDialog with 11 hardcoded items, no search, no categories, and no keyboard navigation. Additionally, 3 separate registries duplicate screen metadata (navItems, COLUMN_OPTIONS, title()+icon() extensions) — a maintenance hazard.

Proposed Solution

A full-screen overlay triggered by Cmd+K that shows all available screens organized by theme categories, with instant search and keyboard navigation. Works in both Single Pane and Deck modes. Consolidates screen metadata into a single source of truth.

Design Decisions

Resolved from SpecFlow Analysis

Question Decision Rationale
Parameterized types in drawer Show Hashtag (inline text input after selection) and Editor (as "New Draft", null slug). Exclude Profile/Thread/Article Deep-link targets, not launcher screens
Cmd+T in Single Pane Treat as Cmd+K (navigate to screen) Consistent behavior, no dead shortcuts
Duplicate columns (Deck) Object types: focus existing. Parameterized: allow duplicates Two Home columns is odd; two Hashtag columns with different tags is fine
Arrow key navigation Up/Down arrows move selection, Enter confirms Keyboard-first requirement
Empty categories during search Hide entirely Cleaner search experience
UI button to open drawer Replace "+" in DeckSidebar, add button in SinglePaneLayout Discoverability
Open column indicators (Deck) Subtle dot/badge on already-open screens Prevents accidental duplicates
Animation NO AnimatedVisibility — use if() block for correctness AnimatedVisibility causes stale state on re-open (critical race condition)
Recently-used ordering Static category order in v1a Defer to v1b/v1c

Screen Categories (14 items across 5 categories)

Category Icon Screens (DeckColumnType)
Social Icons.Default.Groups HomeFeed, Notifications, Messages, GlobalFeed
Long-Form Icons.AutoMirrored.Filled.Article Reads, Drafts, Editor*, MyHighlights
Discovery Icons.Default.Explore Search, Hashtag*, Bookmarks
Identity Icons.Default.Person MyProfile, Settings
Play Icons.Default.SportsEsports Chess

* = parameterized type with secondary input

Technical Approach

Architecture

Main.kt
├── showAppDrawer: Boolean (replaces showAddColumnDialog)
├── singlePaneState: SinglePaneState (NEW — mirrors DeckState)
├── MenuBar → Cmd+K → showAppDrawer = true (always, both modes)
├── MenuBar → Cmd+T → showAppDrawer = true (backward compat)
└── App()
    ├── if (showAppDrawer) AppDrawer(...)  ← NEW (if block, not AnimatedVisibility)
    │   ├── onNavigate: (DeckColumnType) → Unit
    │   │   ├── Single Pane: singlePaneState.navigate(type)
    │   │   └── Deck: deckState.addColumn() or focusExisting()
    │   └── onDismiss: () → Unit
    └── MainContent()
        ├── SINGLE_PANE → SinglePaneLayout(singlePaneState)
        └── DECK → DeckLayout(deckState)

New Files (2 files only)

File Purpose
desktopApp/.../ui/deck/AppDrawer.kt Overlay composable + private state + screen item composable
desktopApp/.../ui/deck/SinglePaneState.kt State holder for single-pane navigation (mirrors DeckState)

Modified Files

File Changes
DeckColumnType.kt Add category() extension. Add LAUNCHABLE_SCREENS list
ColumnHeader.kt No changes — reuse existing icon() extension
Main.kt Replace showAddColumnDialog with showAppDrawer. Add Cmd+K shortcut. Create SinglePaneState. Wire AppDrawer
DeckSidebar.kt Replace "+" button callback → onShowAppDrawer
SinglePaneLayout.kt Accept SinglePaneState instead of local currentColumnType. Accept onOpenAppDrawer callback
AddColumnDialog.kt Delete after drawer works

Implementation Phases

Phase 1: DeckColumnType Extensions + SinglePaneState

File: DeckColumnType.kt — add extensions:

enum class ScreenCategory(val title: String, val icon: ImageVector) {
    SOCIAL("Social", Icons.Default.Groups),
    LONG_FORM("Long-Form", Icons.AutoMirrored.Filled.Article),
    DISCOVERY("Discovery", Icons.Default.Explore),
    IDENTITY("Identity", Icons.Default.Person),
    PLAY("Play", Icons.Default.SportsEsports),
}

fun DeckColumnType.category(): ScreenCategory = when (this) {
    is DeckColumnType.HomeFeed, is DeckColumnType.Notifications,
    is DeckColumnType.Messages, is DeckColumnType.GlobalFeed -> ScreenCategory.SOCIAL
    is DeckColumnType.Reads, is DeckColumnType.Drafts,
    is DeckColumnType.Editor, is DeckColumnType.MyHighlights -> ScreenCategory.LONG_FORM
    is DeckColumnType.Search, is DeckColumnType.Hashtag,
    is DeckColumnType.Bookmarks -> ScreenCategory.DISCOVERY
    is DeckColumnType.MyProfile, is DeckColumnType.Settings -> ScreenCategory.IDENTITY
    is DeckColumnType.Chess -> ScreenCategory.PLAY
    else -> ScreenCategory.SOCIAL // fallback for deep-link types
}

// Single source of truth — replaces COLUMN_OPTIONS, navItems, DRAWER_SCREENS
val LAUNCHABLE_SCREENS: List<DeckColumnType> = listOf(
    DeckColumnType.HomeFeed,
    DeckColumnType.Notifications,
    DeckColumnType.Messages,
    DeckColumnType.GlobalFeed,
    DeckColumnType.Reads,
    DeckColumnType.Drafts,
    DeckColumnType.Editor(),        // "New Draft" (null slug)
    DeckColumnType.MyHighlights,
    DeckColumnType.Search,
    DeckColumnType.Hashtag(""),     // requires input
    DeckColumnType.Bookmarks,
    DeckColumnType.MyProfile,
    DeckColumnType.Settings,
    DeckColumnType.Chess,
)

// Used by drawer to detect which items need parameter input
fun DeckColumnType.requiresInput(): Boolean = when (this) {
    is DeckColumnType.Hashtag -> true
    else -> false
}

File: SinglePaneState.kt:

// Mirrors DeckState pattern — holds current screen for single-pane mode
// Prepares for v1b (customizable nav bar) and v1c (workspaces)
class SinglePaneState {
    private val _currentScreen = MutableStateFlow<DeckColumnType>(DeckColumnType.HomeFeed)
    val currentScreen: StateFlow<DeckColumnType> = _currentScreen.asStateFlow()

    fun navigate(type: DeckColumnType) {
        _currentScreen.value = type
    }
}

Research insight (architecture-strategist): Creating SinglePaneState now avoids the state-hoisting problem (drawer at App() level needs to set currentColumnType in SinglePaneLayout) and prepares for v1b where pinnedScreens: StateFlow<List<DeckColumnType>> replaces the hardcoded navItems.

Phase 2: AppDrawer Composable

File: AppDrawer.kt — single file with everything:

// Private state holder — not a separate file
// @Stable tells Compose all mutations go through snapshot system
@Stable
private class AppDrawerState {
    var searchQuery by mutableStateOf("")
    var selectedIndex by mutableStateOf(0)
    var hashtagInput by mutableStateOf("")
    var awaitingHashtag by mutableStateOf(false)
    private var consumed by mutableStateOf(false) // double-click guard

    // derivedStateOf caches until searchQuery changes — avoids recompute per recomposition
    val filteredScreens: List<DeckColumnType> by derivedStateOf {
        if (searchQuery.isBlank()) LAUNCHABLE_SCREENS
        else LAUNCHABLE_SCREENS.filter {
            it.title().contains(searchQuery, ignoreCase = true) ||
                it.category().title.contains(searchQuery, ignoreCase = true)
        }
    }

    // Grouped by category, empty categories hidden
    val groupedScreens: Map<ScreenCategory, List<DeckColumnType>> by derivedStateOf {
        filteredScreens.groupBy { it.category() }.filterValues { it.isNotEmpty() }
    }

    fun moveSelection(delta: Int) {
        val size = filteredScreens.size
        if (size > 0) selectedIndex = (selectedIndex + delta).coerceIn(0, size - 1)
    }

    fun select(
        screen: DeckColumnType,
        onSelectScreen: (DeckColumnType) -> Unit,
        onDismiss: () -> Unit,
    ) {
        if (consumed) return // double-click guard
        if (screen.requiresInput()) {
            awaitingHashtag = true
            hashtagInput = ""
        } else {
            consumed = true
            onSelectScreen(screen)
            onDismiss()
        }
    }

    fun confirmHashtag(
        onSelectScreen: (DeckColumnType) -> Unit,
        onDismiss: () -> Unit,
    ) {
        if (consumed || hashtagInput.isBlank()) return
        consumed = true
        onSelectScreen(DeckColumnType.Hashtag(hashtagInput.trim()))
        onDismiss()
    }
}

@Composable
fun AppDrawer(
    openColumnTypes: Set<String>,   // typeKey() of open columns (Deck mode)
    onSelectScreen: (DeckColumnType) -> Unit,
    onDismiss: () -> Unit,
) {
    // Fresh state every open — if() block destroys on close, no stale state
    val state = remember { AppDrawerState() }
    val searchFocusRequester = remember { FocusRequester() }

    // Auto-focus search field (awaitFrame for Desktop timing)
    LaunchedEffect(Unit) {
        awaitFrame()
        searchFocusRequester.requestFocus()
    }

    // Fullscreen scrim — follows GlobalFullscreenOverlay.kt pattern
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black.copy(alpha = 0.5f))
            // onPreviewKeyEvent intercepts BEFORE TextField consumes keys
            .onPreviewKeyEvent { event ->
                if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
                when (event.key) {
                    Key.Escape -> {
                        if (state.awaitingHashtag) { state.awaitingHashtag = false; true }
                        else { onDismiss(); true }
                    }
                    Key.DirectionDown -> { state.moveSelection(1); true }
                    Key.DirectionUp -> { state.moveSelection(-1); true }
                    Key.Enter -> {
                        if (state.awaitingHashtag) {
                            state.confirmHashtag(onSelectScreen, onDismiss); true
                        } else {
                            state.filteredScreens.getOrNull(state.selectedIndex)?.let {
                                state.select(it, onSelectScreen, onDismiss)
                            }
                            true
                        }
                    }
                    else -> false // let TextField handle typing
                }
            }
            // Click scrim to dismiss — no ripple indication
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
            ) { onDismiss() },
        contentAlignment = Alignment.Center,
    ) {
        // Content card — consume pointer events to prevent click-through to scrim
        Surface(
            modifier = Modifier
                .fillMaxWidth(0.6f)
                .fillMaxHeight(0.7f)
                .pointerInput(Unit) {
                    awaitPointerEventScope { while (true) { awaitPointerEvent() } }
                },
            shape = RoundedCornerShape(16.dp),
            tonalElevation = 8.dp,
        ) {
            Column {
                // Search TextField
                TextField(
                    value = state.searchQuery,
                    onValueChange = { state.searchQuery = it; state.selectedIndex = 0 },
                    modifier = Modifier
                        .fillMaxWidth()
                        .focusRequester(searchFocusRequester),
                    placeholder = { Text("Search screens...") },
                    singleLine = true,
                    leadingIcon = { Icon(Icons.Default.Search, "Search") },
                )

                if (state.awaitingHashtag) {
                    // Hashtag parameter input
                    HashtagInputSection(state, onSelectScreen, onDismiss)
                } else {
                    // Categorized grid — isolated composable for recomposition scoping
                    DrawerGrid(state, openColumnTypes, onSelectScreen, onDismiss)
                }
            }
        }
    }
}

// Separate composable = separate recomposition scope
// Only recomposes when filteredScreens actually changes (derivedStateOf)
@Composable
private fun DrawerGrid(
    state: AppDrawerState,
    openColumnTypes: Set<String>,
    onSelectScreen: (DeckColumnType) -> Unit,
    onDismiss: () -> Unit,
) {
    val listState = rememberLazyListState()

    // Auto-scroll to keep selected item visible
    LaunchedEffect(state.selectedIndex) {
        listState.animateScrollToItem(
            (state.selectedIndex / 4).coerceAtLeast(0) // approximate row
        )
    }

    LazyColumn(state = listState) {
        var globalIndex = 0
        state.groupedScreens.forEach { (category, screens) ->
            stickyHeader(key = "header-${category.name}") {
                CategoryHeader(category)
            }
            val startIndex = globalIndex
            item(key = "grid-${category.name}") {
                FlowRow(
                    horizontalArrangement = Arrangement.spacedBy(8.dp),
                    verticalArrangement = Arrangement.spacedBy(8.dp),
                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
                ) {
                    screens.forEachIndexed { localIdx, screen ->
                        DrawerScreenCard(
                            type = screen,
                            isSelected = (startIndex + localIdx) == state.selectedIndex,
                            isOpen = openColumnTypes.contains(screen.typeKey()),
                            onClick = { state.select(screen, onSelectScreen, onDismiss) },
                        )
                    }
                }
            }
            globalIndex += screens.size
        }
    }
}

@Composable
private fun DrawerScreenCard(
    type: DeckColumnType,
    isSelected: Boolean,
    isOpen: Boolean,
    onClick: () -> Unit,
) {
    Surface(
        modifier = Modifier
            .size(80.dp)
            .clickable(onClick = onClick),
        shape = RoundedCornerShape(12.dp),
        tonalElevation = if (isSelected) 8.dp else 2.dp,
        color = if (isSelected)
            MaterialTheme.colorScheme.primaryContainer
        else MaterialTheme.colorScheme.surface,
    ) {
        Box(contentAlignment = Alignment.Center) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Icon(
                    type.icon(),
                    contentDescription = type.title(),
                    modifier = Modifier.size(28.dp),
                )
                Spacer(Modifier.height(4.dp))
                Text(
                    type.title(),
                    style = MaterialTheme.typography.labelSmall,
                    maxLines = 1,
                )
            }
            // Open indicator dot
            if (isOpen) {
                Box(
                    modifier = Modifier
                        .align(Alignment.TopEnd)
                        .padding(4.dp)
                        .size(6.dp)
                        .background(
                            MaterialTheme.colorScheme.primary,
                            CircleShape,
                        ),
                )
            }
        }
    }
}

@Composable
private fun CategoryHeader(category: ScreenCategory) {
    Row(
        modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        Icon(category.icon, category.title, Modifier.size(16.dp))
        Text(
            category.title,
            style = MaterialTheme.typography.titleSmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }
}

@Composable
private fun HashtagInputSection(
    state: AppDrawerState,
    onSelectScreen: (DeckColumnType) -> Unit,
    onDismiss: () -> Unit,
) {
    Column(Modifier.padding(16.dp)) {
        Text("Enter hashtag:", style = MaterialTheme.typography.titleSmall)
        Spacer(Modifier.height(8.dp))
        TextField(
            value = state.hashtagInput,
            onValueChange = { state.hashtagInput = it },
            modifier = Modifier.fillMaxWidth(),
            placeholder = { Text("#bitcoin, #nostr...") },
            singleLine = true,
        )
        Spacer(Modifier.height(8.dp))
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            TextButton(onClick = { state.awaitingHashtag = false }) { Text("Back") }
            Button(
                onClick = { state.confirmHashtag(onSelectScreen, onDismiss) },
                enabled = state.hashtagInput.isNotBlank(),
            ) { Text("Open") }
        }
    }
}

Research insights applied:

  • Compose expert: derivedStateOf for filteredScreens/groupedScreens prevents triple recomputation per keystroke
  • Desktop expert: onPreviewKeyEvent (not onKeyEvent) intercepts before TextField consumes arrow/Enter keys. Pattern from SearchScreen.kt:293, ChatPane.kt:729
  • Desktop expert: Box(fillMaxSize) overlay pattern matches GlobalFullscreenOverlay.kt and LightboxOverlay.kt — NOT Popup or Dialog
  • Race conditions (CRITICAL): if (showAppDrawer) block destroys/recreates composable on each open — fresh remember state, no stale data. Do NOT use AnimatedVisibility
  • Race conditions: consumed flag prevents double-click adding duplicate columns
  • Compose expert: pointerInput consume on content Surface is more robust than clickable(enabled = false) for preventing click-through
  • Kotlin expert: @Stable on AppDrawerState tells Compose mutations go through snapshot system
  • Pattern recognition: Using DeckColumnType directly with title()/icon()/category() instead of wrapper eliminates data duplication

Phase 3: Wire into Main.kt

// Create SinglePaneState (mirrors DeckState)
val singlePaneState = remember { SinglePaneState() }

// Replace showAddColumnDialog with showAppDrawer
var showAppDrawer by remember { mutableStateOf(false) }

// MenuBar — Cmd+K always available (View menu)
Menu("View") {
    Item(
        "App Drawer",
        shortcut = if (isMacOS) KeyShortcut(Key.K, meta = true) else KeyShortcut(Key.K, ctrl = true),
        onClick = { showAppDrawer = !showAppDrawer },
    )
    // ... existing layout toggle, deck-only items
}
// Cmd+T also opens drawer (backward compat, both modes)
Item("Add Column", shortcut = KeyShortcut(Key.T, ...)) {
    showAppDrawer = true
}

// Collect open columns as state (not .value) for reactivity
val openColumns by deckState.columns.collectAsState()

// Render overlay — if() block, NOT AnimatedVisibility
if (showAppDrawer) {
    AppDrawer(
        openColumnTypes = if (layoutMode == LayoutMode.DECK) {
            openColumns.map { it.type.typeKey() }.toSet()
        } else emptySet(),
        onSelectScreen = { type ->
            when (layoutMode) {
                LayoutMode.SINGLE_PANE -> singlePaneState.navigate(type)
                LayoutMode.DECK -> {
                    if (deckState.hasColumnOfType(type)) {
                        deckState.focusExistingColumn(type)
                    } else {
                        deckState.addColumn(type)
                    }
                }
            }
        },
        onDismiss = { showAppDrawer = false },
    )
}

Research insight (race conditions): Use collectAsState() for openColumnTypes rendering, not deckState.columns.value. Direct .value reads are stale for UI — Compose won't recompose when the flow emits.

Phase 4: Update DeckSidebar + SinglePaneLayout

DeckSidebar.kt:

  • Replace onShowAddColumnDialog callback → onShowAppDrawer

SinglePaneLayout.kt:

  • Accept SinglePaneState instead of local currentColumnType
  • Accept onOpenAppDrawer: () -> Unit
  • Add IconButton(Icons.Default.Apps) at bottom of NavigationRail
  • Replace hardcoded navItems with LAUNCHABLE_SCREENS.filter { it.category() != ScreenCategory.PLAY } or a curated default list
  • Read val currentScreen by singlePaneState.currentScreen.collectAsState()

Phase 5: Delete AddColumnDialog.kt + Cleanup

  • Remove AddColumnDialog.kt
  • Remove showAddColumnDialog from Main.kt
  • Remove COLUMN_OPTIONS list
  • Remove onShowAddColumnDialog callbacks from DeckSidebar, etc.
  • Verify navItems in SinglePaneLayout is replaced or reads from LAUNCHABLE_SCREENS

Keyboard Event Handling

Drawer open (onPreviewKeyEvent on outer Box):
  Escape           → awaitingHashtag ? cancel : onDismiss()
  Up Arrow         → state.moveSelection(-1)  [consumed before TextField]
  Down Arrow       → state.moveSelection(+1)  [consumed before TextField]
  Enter            → awaitingHashtag ? confirmHashtag : select highlighted
  Any printable    → falls through to TextField (not consumed)
  Backspace        → falls through to TextField (not consumed)

Desktop expert: onPreviewKeyEvent fires before children process the event. Returning true for arrows/Enter prevents TextField from consuming them. Returning false for printable keys lets TextField handle typing. This exact pattern is used in SearchScreen.kt:293 and ChatPane.kt:729.

System-Wide Impact

Interaction Graph

Cmd+K pressed
  → Main.kt: showAppDrawer = true
    → AppDrawer composed (fresh state via remember)
      → searchFocusRequester.requestFocus() (after awaitFrame)
      → User types → derivedStateOf recalculates filteredScreens
        → DrawerGrid recomposes (isolated scope)
      → User selects screen (consumed flag prevents double-fire)
        → onSelectScreen(DeckColumnType) fires
          → DECK: deckState.addColumn() or focusExisting()
            → DeckState.save() (debounced 500ms)
          → SINGLE_PANE: singlePaneState.navigate(type)
            → currentScreen flow emits → RootContent recomposes
        → onDismiss → showAppDrawer = false
          → AppDrawer leaves composition → state garbage collected

State Lifecycle

  • No stale state risk: if() block destroys composable on close. remember creates fresh AppDrawerState on each open. No LaunchedEffect(Unit) { reset() } needed.
  • Hashtag input cleanup: Automatic — state destroyed on dismiss.
  • SinglePaneLayout state: SinglePaneState created at App() level, passed down. No hoisting problem.
  • Open column indicators: collectAsState() ensures reactivity.

API Surface Parity

Interface Change
DeckColumnType Add category() extension, requiresInput() extension, LAUNCHABLE_SCREENS list
DeckSidebar.onAddColumn Rename to onShowAppDrawer
SinglePaneLayout Accept SinglePaneState + onOpenAppDrawer params
Main.kt MenuBar Add Cmd+K item in "View" menu. Update Cmd+T to open drawer
AddColumnDialog Delete

Registry Consolidation

Before (3+ registries) After (1 source of truth)
COLUMN_OPTIONS in AddColumnDialog.kt Deleted — replaced by LAUNCHABLE_SCREENS
navItems in SinglePaneLayout.kt Replaced — reads from LAUNCHABLE_SCREENS
title() in DeckColumnType.kt Kept — single source for labels
icon() in ColumnHeader.kt Kept — single source for icons
DRAWER_SCREENS (was planned) Never createdLAUNCHABLE_SCREENS + extensions

Acceptance Criteria

Functional Requirements

  • Cmd+K opens App Drawer in both Single Pane and Deck modes
  • Cmd+T opens App Drawer (backward compat, both modes)
  • Search field auto-focused on open
  • Typing filters screens across all categories in real-time
  • Empty categories hidden during search
  • 14 screens across 5 categories displayed correctly
  • Each screen shows correct icon (from icon()) and label (from title())
  • Clicking a screen in Single Pane navigates via SinglePaneState
  • Clicking a screen in Deck adds column (or focuses existing for object types)
  • Hashtag selection shows secondary text input, Enter confirms
  • Editor selection opens new draft (null slug)
  • Escape closes drawer (or cancels hashtag input)
  • Clicking backdrop closes drawer
  • Arrow keys move selection highlight
  • Enter selects highlighted screen
  • Already-open columns show indicator dot in Deck mode
  • Double-click does NOT create duplicate columns
  • "+" button in DeckSidebar opens App Drawer
  • Apps button in SinglePaneLayout nav rail opens App Drawer
  • AddColumnDialog.kt deleted, no remaining references
  • COLUMN_OPTIONS and navItems removed/replaced

Non-Functional Requirements

  • Drawer opens in <100ms (no network calls, pure UI)
  • Search filtering is instant (derivedStateOf caching)
  • No animation (correctness > aesthetics — avoids stale state race condition)
  • Follows Material3 color scheme (Surface, onSurface, primaryContainer)

Dependencies & Risks

Risk Mitigation Source
currentColumnType in SinglePaneLayout is local state Create SinglePaneState class architecture-strategist
AnimatedVisibility causes stale state on re-open Use if() block instead race-conditions-reviewer (CRITICAL)
Double-click fires onSelectScreen twice consumed flag in AppDrawerState race-conditions-reviewer
clickable(enabled=false) doesn't consume events Use pointerInput consume pattern compose-expert
onKeyEvent doesn't intercept before TextField Use onPreviewKeyEvent desktop-expert
Focus may not grab immediately on Desktop awaitFrame() before requestFocus() compose-expert
deckState.columns.value is stale for UI Use collectAsState() race-conditions-reviewer
FlowRow inside LazyColumn measures eagerly Fine at 14 items; max 4 per category performance-oracle

Sources & References

Origin

Internal References (Codebase Patterns)

  • GlobalFullscreenOverlay.kt — fullscreen Box overlay pattern (z-order in MainContent)
  • LightboxOverlay.kt:202-253 — focusRequester + onKeyEvent + clickable scrim pattern
  • SearchScreen.kt:293onPreviewKeyEvent intercepting Enter/Escape before TextField
  • ChatPane.kt:729 — same onPreviewKeyEvent pattern
  • DeckColumnType.kt:25-111 — sealed class, title(), typeKey()
  • ColumnHeader.kt:123-142icon() extension
  • DeckState.kt — StateFlow pattern, addColumn(), save/load persistence
  • SinglePaneLayout.kt:80-93,115 — navItems + currentColumnType (being replaced)
  • Main.kt:187,297-307,570-578 — showAddColumnDialog + shortcuts + dialog render
  • AddColumnDialog.kt:49-62 — COLUMN_OPTIONS (being deleted)

External References

Open Questions

  • Should SinglePaneState persist last-viewed screen to DesktopPreferences? (nice-to-have)
  • Should ScreenCategory live in DeckColumnType.kt or ColumnHeader.kt? (colocation question)
  • Future: Cmd+K also search within content (notes, profiles)? (v2+ concern)
  • Mouse hover on grid items should update selectedIndex? (nice keyboard+mouse hybrid UX)