# muxplex Frontend Recovery Plan

**Date:** 2026-05-27
**Scope:** Stabilize the broken Lit migration; restore all core user workflows

---

## 1. Correction of Errors (COE)

### What happened

Five commits were applied in sequence without live-testing the app between phases:

```
b9fe3d7  docs: add AGENTS.md
8687d72  Phase 1 Lit migration (session-tile, session-grid, sidebar-item, toast, pill, status)
e0c540b  Phase 2 (hover-preview, bottom-sheet-switcher, search-filter)
9c86cf0  Phase 3 (view-dropdown, store.js)
b1f4569  P0 UX improvements (visual hierarchy, readable previews, search filter, default session)
```

Each phase introduced Lit web components to replace innerHTML-based rendering, but:

1. **No manual browser testing between phases.** The test suite (1306 tests, all passing)
   validates JavaScript function contracts in a Node.js harness -- it cannot catch DOM
   rendering regressions, CSS layout breaks, or WebSocket lifecycle issues that only
   manifest in a real browser.

2. **Dual data paths were left alive.** Old `buildTileHTML()` and `buildSidebarHTML()`
   remain in app.js for test compatibility, but the browser runs Lit components. Tests
   validate the dead code path. The live path has zero automated coverage.

3. **Incomplete component wiring.** `view-dropdown.js` fires custom events
   (`view-new-input`) but `app.js` still queries for `[data-action="new-view"]` which
   doesn't exist in the Lit-rendered DOM. The sidebar dropdown tries to find
   `#sidebar-view-dropdown-trigger` which was replaced by an anonymous button inside
   the Lit component.

4. **Dead store.** `store.js` (`AppStore extends EventTarget`) was imported but never
   wired to any component or to app.js. It sits in memory doing nothing, creating the
   false impression of a reactive data layer.

5. **Behavioral drift in tile rendering.** Snapshot line count changed from 20 to 12
   with no rationale or CSS compensation. Preview tiles show less terminal context.

### Root cause

**The migration was done by a code-generation agent that could run tests but never
opened the app in a browser.** All breakages are invisible to the test harness because
the test harness doesn't render DOM, doesn't connect WebSockets, and doesn't layout CSS.

### Lessons

- Test suite passing != app working. For UI work, browser verification is mandatory.
- Lit migrations must be atomic per component: migrate, test in browser, commit.
  Not "4 phases batch-committed."
- Dead code paths that tests validate against must be removed or tests updated.
  They create a false sense of safety.

---

## 2. Application State Machine

### Top-Level View States

```
                        ┌──────────────────────────┐
                        │                          │
                        │    DASHBOARD (grid)       │
                        │    _viewMode = 'grid'     │
                        │                          │
                        │  ┌──────────────────┐    │
                        │  │ session-grid      │    │
                        │  │  ├ session-tile   │    │
                        │  │  ├ session-tile   │    │
                        │  │  └ session-tile   │    │
                        │  └──────────────────┘    │
                        └────────────┬─────────────┘
                                     │
                          click tile / restore state
                          openSession(name, opts)
                                     │
                                     ▼
                        ┌──────────────────────────┐
                        │                          │
                        │  TERMINAL (fullscreen)    │
                        │  _viewMode = 'fullscreen' │
                        │                          │
                        │  ┌────────┬──────────┐   │
                        │  │sidebar │ terminal  │   │
                        │  │        │ container │   │
                        │  │  item  │           │   │
                        │  │  item  │  xterm.js │   │
                        │  │  item  │           │   │
                        │  └────────┴──────────┘   │
                        └────────────┬─────────────┘
                                     │
                          back btn / Escape / closeSession()
                                     │
                                     ▼
                             DASHBOARD (grid)
```

### Navigation State Transitions

```
┌─────────────┐   openSession()   ┌──────────────┐  sidebar-select  ┌──────────────┐
│  DASHBOARD   │ ───────────────► │  TERMINAL     │ ──────────────► │  TERMINAL     │
│  (grid)      │                  │  (session A)  │                 │  (session B)  │
└─────────────┘ ◄─────────────── └──────────────┘                  └──────┬───────┘
                  closeSession()        ▲                                  │
                                        │          sidebar-select          │
                                        └──────────────────────────────────┘
```

### WebSocket Connection Lifecycle

```
                 openSession(name)
                       │
                       ▼
            POST /api/sessions/{name}/connect   ← spawns ttyd
                       │
                       ▼
            window._openTerminal(name, remoteId, fontSize)
                       │
                       ▼
            createTerminal(fontSize)
                       │
                       ▼
            connectWebSocket(name, remoteId)
                       │
                       ▼
            new WebSocket(url, ['tty'])
                       │
              ┌────────┴────────┐
              │                 │
           onopen           onerror
              │                 │
              ▼                 ▼
     send auth + dims     console.warn
     hide reconnect       (falls through
     overlay              to onclose)
     focus terminal
              │
              ▼
          onmessage ◄──── continuous data ────┐
              │                                │
              ▼                                │
       _term.write(data)                       │
       reset reconnect counter on first msg    │
              │                                │
              └────────────────────────────────┘
              │
          onclose
              │
              ▼
     ┌─ _currentSession null? ─── yes ──► DONE (intentional close)
     │
     no
     │
     ▼
     show reconnect overlay
     _reconnectAttempts++
     delay = min(1s * 2^(n-1), 15s) + jitter
     │
     ├── attempts < 2 ──► direct reconnect (new WebSocket)
     │
     └── attempts >= 2 ──► POST /connect to respawn ttyd
                            wait 800ms
                            then new WebSocket
```

### Poll Loop Data Flow

```
                   every 2 seconds
                        │
                        ▼
            GET /api/sessions (or /api/federation/sessions)
                        │
                        ▼
              _currentSessions = response
                        │
               ┌────────┼─────────┬──────────────┐
               │        │         │              │
               ▼        ▼         ▼              ▼
          renderGrid  renderSidebar  updatePill  updateTitle
               │        │                        updateFavicon
               │        │
               ▼        ▼
          set props   create/update
          on          <sidebar-item>
          <session-grid>  elements
               │
               ▼
          Lit re-renders
          <session-tile>
          via repeat()
```

---

## 3. User Storyboards

### Storyboard 1: Dashboard Load (cold start)

```
USER                              APP                           SERVER
 │                                 │                              │
 │  navigate to /                  │                              │
 │ ──────────────────────────────► │                              │
 │                                 │  GET /api/settings           │
 │                                 │ ─────────────────────────────►
 │                                 │  ◄───── settings JSON ───────│
 │                                 │                              │
 │                                 │  GET /api/state              │
 │                                 │ ─────────────────────────────►
 │                                 │  ◄── { active_session } ─────│
 │                                 │                              │
 │                                 │  GET /api/sessions           │
 │  ◄── grid renders with tiles ── │  ◄── [session objects] ──────│
 │      (snapshot previews,        │                              │
 │       activity badges,          │  start 2s poll loop          │
 │       timestamps)               │  start 5s heartbeat          │
```

### Storyboard 2: Open Terminal from Dashboard

```
USER                              APP                           SERVER
 │                                 │                              │
 │  click session tile "dev"       │                              │
 │ ──────────────────────────────► │                              │
 │                                 │  tile-click event bubbles    │
 │                                 │  openSession("dev")          │
 │                                 │                              │
 │  ◄── tile zoom animation ───── │  POST /sessions/dev/connect  │
 │      tile expands to viewport   │ ─────────────────────────────►
 │                                 │  ◄── 200 (ttyd spawned) ────│
 │                                 │                              │
 │  ◄── view swap: grid hidden ── │  _openTerminal("dev")        │
 │      terminal view visible      │  new WebSocket → ttyd        │
 │                                 │                              │
 │  ◄── terminal ready,           │  ◄── ws data stream ─────────│
 │      cursor blinking            │                              │
 │                                 │  sidebar renders with        │
 │  ◄── sidebar shows sessions ── │  current session highlighted  │
```

### Storyboard 3: Switch Sessions via Sidebar

```
USER                              APP                           SERVER
 │                                 │                              │
 │  click sidebar item "prod"     │                              │
 │ ──────────────────────────────► │                              │
 │                                 │  sidebar-select event        │
 │                                 │  openSession("prod")         │
 │                                 │                              │
 │                                 │  _closeTerminal() on "dev"   │
 │                                 │  (sets _currentSession=null) │
 │                                 │  (ws.close, no reconnect)    │
 │                                 │                              │
 │                                 │  POST /sessions/prod/connect │
 │                                 │ ─────────────────────────────►
 │  ◄── terminal switches to      │  _openTerminal("prod")       │
 │      "prod" session             │  new WebSocket → ttyd        │
 │                                 │                              │
 │  ◄── sidebar updates,          │  renderSidebar marks "prod"  │
 │      "prod" highlighted         │  as active                   │
```

### Storyboard 4: Return to Dashboard

```
USER                              APP                           SERVER
 │                                 │                              │
 │  click back button / Escape    │                              │
 │ ──────────────────────────────► │                              │
 │                                 │  closeSession()              │
 │                                 │  _closeTerminal()            │
 │                                 │  (ws closed, no reconnect)   │
 │                                 │                              │
 │                                 │  PATCH /api/state            │
 │                                 │  { active_session: null }    │
 │                                 │ ─────────────────────────────►
 │                                 │                              │
 │  ◄── grid view restored ────── │  view-expanded hidden        │
 │      with fresh poll data       │  view-overview visible       │
```

### Storyboard 5: Connection Lost / Reconnect

```
USER                              APP                           SERVER
 │                                 │                              │
 │  (network hiccup or ttyd dies) │                              │
 │                                 │  ws.onclose fires            │
 │                                 │  _currentSession != null     │
 │  ◄── "Reconnecting..." overlay │  show #reconnect-overlay     │
 │                                 │  attempt++ = 1               │
 │                                 │  wait 1s + jitter            │
 │                                 │  new WebSocket (attempt 1)   │
 │                                 │                              │
 │  (if fails again)              │  attempt++ = 2               │
 │                                 │  POST /sessions/x/connect   │
 │                                 │ ────────────────────────────►│
 │                                 │  (respawn ttyd)              │
 │                                 │  wait 800ms                  │
 │                                 │  new WebSocket (attempt 2)   │
 │                                 │                              │
 │  ◄── overlay disappears ────── │  ws.onopen → hide overlay    │
 │      terminal resumes           │  first data → reset counter  │
```

---

## 4. Lit Data Model: What It Should Be

### Current Architecture (broken hybrid)

```
app.js (module-level vars)           components/ (Lit)
┌──────────────────────┐            ┌──────────────────┐
│ _currentSessions     │───set───►  │ session-grid     │
│ _viewingSession      │  props     │   .sessions      │
│ _viewMode            │            │   .visibleSessions│
│ _serverSettings      │            │                  │
│ _activeView          │───set───►  │ sidebar-item (N) │
│ _gridViewMode        │  props     │   .session       │
│ _pollFailCount       │            │   .active        │
│                      │            │                  │
│ startPolling()       │            │ view-dropdown    │
│ openSession()        │            │   .views         │
│ closeSession()       │            │   .activeView    │
│ bindStaticListeners()│            │                  │
└──────────────────────┘            │ store.js ← DEAD  │
                                    └──────────────────┘

Data flows ONE WAY: app.js → component props (push)
Events flow BACK:   component events → app.js handlers (bubble)
```

### What's Wrong

1. **No single source of truth.** State lives in app.js module variables.
   Components receive props from `renderGrid()` / `renderSidebar()` calls.
   There's no store, no subscription. If a poll renders the grid but
   something prevents renderSidebar(), the sidebar is stale.

2. **store.js exists but is unused.** It was meant to be the reactive layer
   but nothing writes to it or reads from it.

3. **renderSidebar() clears and rebuilds every poll.** Line 961:
   `list.innerHTML = ''; list.appendChild(fragment);` -- this destroys all
   <sidebar-item> elements and recreates them every 2 seconds, losing
   scroll position, hover state, and any transient UI state.

4. **Components can't self-update.** Since there's no store subscription,
   components are purely passive. They render when app.js pushes props.
   This means any bug in the push path = stale component.

### Target Architecture (stabilized, minimal change)

**Phase 1: Stabilize (this plan).** Don't introduce the store yet. Fix the
broken wiring so the existing push-props architecture works correctly.

```
app.js (owner of all state)
  │
  ├── pollSessions()
  │     └── _currentSessions = response
  │           ├── renderGrid()     → set props on <session-grid>
  │           ├── renderSidebar()  → DIFF <sidebar-item> elements (don't clear+rebuild)
  │           ├── updatePill()     → set props on <session-pill>
  │           └── updateTitle()
  │
  ├── openSession(name)
  │     ├── _viewMode = 'fullscreen'
  │     ├── _viewingSession = name
  │     ├── view DOM swap
  │     ├── _openTerminal(name)
  │     └── renderSidebar()
  │
  └── closeSession()
        ├── _viewMode = 'grid'
        ├── _viewingSession = null
        ├── _closeTerminal()
        └── view DOM swap
```

**Phase 2 (future): Introduce reactive store.**

```
store.js (EventTarget, single source of truth)
  │
  ├── .sessions       ← written by pollSessions()
  ├── .viewMode       ← written by openSession/closeSession
  ├── .activeSession  ← written by openSession/closeSession
  ├── .activeView     ← written by view switcher
  ├── .settings       ← written by loadServerSettings()
  │
  └── .on('sessions', fn)  ← components subscribe
      .on('viewMode', fn)
      .on('activeSession', fn)

Components subscribe to store keys and self-update.
app.js becomes a thin orchestrator: event handlers + store.set().
```

Phase 2 is NOT this plan. This plan is about making Phase 1 work.

---

## 5. Diagnosed Issues + Fix Plan

### CRITICAL: Issues causing user-visible breakage NOW

#### C1. Sidebar clear+rebuild every poll (causes flicker, scroll loss, layout jank)

**File:** `app.js:961` -- `list.innerHTML = ''; list.appendChild(fragment);`
**Problem:** Every 2-second poll destroys all `<sidebar-item>` elements and
creates new ones. This causes layout reflow, loses scroll position, may cause
the "tiles sized incorrectly" report if a render cycle catches mid-layout.
**Fix:** Diff existing elements by session key. Update props on existing
elements; only add/remove what actually changed. The keyed map is already
built (`existingItems`), the clear+rebuild at the end undoes the work.
**Original pattern:** Original app.js also used innerHTML, but it was a
single string assignment -- no element lifecycle to disrupt.

#### C2. Sidebar <sidebar-item> uses `createRenderRoot() { return this; }` (light DOM)

**File:** `sidebar-item.js:38`
**Problem:** Light DOM means the component renders directly into itself. The
existing CSS `.sidebar-item { height: 120px; }` should apply. But the Lit
element ITSELF (`<sidebar-item>`) is an extra wrapper element that the
original CSS didn't account for. The CSS targets `.sidebar-item` (the article
inside), but the `<sidebar-item>` custom element wrapper has no explicit
`display` property, defaulting to `display: inline`. An inline wrapper
around a 120px-height block article creates layout havoc.
**Fix:** Add `sidebar-item { display: contents; }` or
`sidebar-item { display: block; }` to style.css. Same for `session-tile`.

#### C3. <session-tile> wrapper element also lacks display rule

**File:** `session-tile.js` (light DOM)
**Problem:** Same as C2. The `<session-tile>` custom element wrapping the
`<article class="session-tile">` has no CSS display rule. Grid layout
(`display: grid` on `.session-grid`) expects direct children to be grid
items. With `<session-tile>` as an inline wrapper, grid sizing breaks.
`<session-grid>` itself also needs a display rule.
**Fix:** Add CSS:
```css
session-grid { display: contents; }
session-tile { display: contents; }
sidebar-item { display: contents; }
```
Or alternatively `display: block` if contents causes other issues.
This is THE most likely cause of the broken grid layout and sidebar sizing.

#### C4. view-dropdown "New View" creation dead

**File:** `app.js:1131` queries `[data-action="new-view"]`, `view-dropdown.js`
doesn't render that attribute.
**Fix:** Add `data-action="new-view"` to the button in view-dropdown.js, OR
have app.js listen for the `view-new-input` custom event instead.

#### C5. Sidebar dropdown close references non-existent element

**File:** `app.js:1227` queries `$('sidebar-view-dropdown-trigger')` which
doesn't exist (now rendered anonymously inside `<view-dropdown>`).
**Fix:** Use the `<view-dropdown>` element's `.open` property instead.

### HIGH: Correctness issues

#### H1. Snapshot line count reduced (20 → 12) without rationale

**File:** `session-tile.js:82`, `sidebar-item.js:71`
**Problem:** Tiles show ~40% less terminal context. User reports "preview
thumbnail things missing."
**Fix:** Restore to 20 lines. The original 20 was tuned for 300px tile height
at the original font size.

#### H2. Dead code: buildTileHTML / buildSidebarHTML still in app.js

**File:** `app.js:516, 580`
**Problem:** Tests validate dead code. Live code is untested.
**Fix:** Remove dead functions. Update tests to validate Lit component
rendering instead, or add integration tests.

#### H3. store.js imported but unused

**File:** `index.html:288`, `components/store.js`
**Fix:** Remove the import. The store is a future concern.

### MEDIUM: Behavioral drift

#### M1. Duplicate utility functions (app.js globals + components/utils.js)

`formatTimestamp`, `sessionPriority`, `ansiToHtml`, etc. exist in both.
**Fix:** Components import from utils.js. Remove duplicates from app.js
and have app.js import from utils.js too, OR keep app.js self-contained
(it's a non-module script, can't import ES modules).
Decision: Keep both for now. app.js is `defer` (not `type=module`).
The utils.js copy is canonical for components; app.js copy is canonical
for itself. Document the constraint.

#### M2. Document-level `.tile-options-btn` delegated handler (dead)

**File:** `app.js:3904`
The old handler still exists but `session-tile._onOptionsClick` calls
`stopPropagation()` so it never fires.
**Fix:** Remove the dead handler.

---

## 6. Fix Execution Order

Priority: restore usability first, clean up second.

| # | Fix | Risk | Est. |
|---|-----|------|------|
| 1 | C3: Add `display: contents` CSS for custom element wrappers | LOW | 5 min |
| 2 | C1: Fix sidebar diff (stop clear+rebuild) | MED | 15 min |
| 3 | H1: Restore 20-line snapshot in tile + sidebar | LOW | 2 min |
| 4 | C4: Fix view-dropdown "New View" wiring | LOW | 5 min |
| 5 | C5: Fix sidebar dropdown close | LOW | 5 min |
| 6 | H3: Remove dead store.js import | LOW | 1 min |
| 7 | M2: Remove dead tile-options-btn handler | LOW | 1 min |
| 8 | H2: Remove dead buildTileHTML/buildSidebarHTML | MED | 10 min |
| 9 | Browser-verify all storyboards 1-5 | -- | 10 min |

**Total estimated: ~55 minutes**

Steps 1-3 should resolve the user's three reported symptoms:
- Grid/sidebar layout broken → C3 (display rules)
- Sidebar sizing wrong → C1 + C3
- Missing preview thumbnails → H1 (line count)

The "constantly hitting reconnect" report needs browser investigation.
terminal.js is unchanged. Possible causes:
- Layout break from C3 may cause terminal-container to have 0 dimensions,
  which makes FitAddon report 0 cols/0 rows, which ttyd may reject
- The reconnect overlay may be visible due to CSS stacking issues from
  the extra wrapper elements
- Investigate AFTER C3 fix is applied -- it may self-resolve

---

## 7. Verification Criteria

After all fixes, manually verify each storyboard:

- [ ] Dashboard loads, all session tiles visible with terminal previews
- [ ] Tiles show ~20 lines of terminal content, readable
- [ ] Click tile → zoom animation → terminal opens → cursor blinks
- [ ] Terminal receives keystrokes and displays output
- [ ] No "Reconnecting..." overlay unless network is actually down
- [ ] Sidebar shows all sessions, current session highlighted
- [ ] Click different sidebar session → switches correctly
- [ ] Back button / Escape → returns to dashboard
- [ ] Settings dialog opens and closes
- [ ] View dropdown works (switch views, create new view)
- [ ] Mobile: tiles render in list mode, bottom sheet works
- [ ] Test suite still passes: `pytest muxplex/tests/ -x -q --timeout=30`