# NiceGUI Dark/Light Mode Toggle — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add a sun/moon dark/light mode toggle to all NiceGUI apps with shared CSS variables and localStorage persistence.

**Architecture:** Extract a shared theme module from the existing `vario/theme.py` pattern into `lib/nicegui/theme.py`. Defines CSS variables for light and dark modes (keyed to Quasar's `.body--dark` class). Each app calls `setup_theme()` for CSS injection + `dark_toggle()` in its header. Toggle persists via localStorage.

**Tech Stack:** NiceGUI, Quasar (built-in), CSS custom properties, localStorage

---

## Scope

**9 NiceGUI apps to migrate:**

| # | App       | File                              | Current        | Hardcoded colors |
|---|-----------|-----------------------------------|----------------|------------------|
| 1 | Index     | `index/app.py`                    | `dark_mode(T)` | 1 border         |
| 2 | Vario ng  | `vario/app.py` + `theme.py`    | Has theme+toggle| Already CSS vars |
| 3 | Doctor    | `doctor/app.py`                   | `dark=True`     | 4 (pre bg)       |
| 4 | Intel     | `intel/app.py`                    | `dark_mode(T)`  | 14 (heavy)       |
| 5 | Draft     | `draft/ui/app.py`                 | `dark=None`     | 1 (header)       |
| 6 | Jobs      | `jobs/app_ng.py`                  | `dark=True`     | 5                |
| 7 | Ops       | `ops/app.py`                      | `dark_mode(T)`  | Minimal          |
| 8 | Ideas     | `ideas/ui/app.py`                 | `dark=True`     | 1                |
| 9 | Semnet    | `lib/semnet/presenter/app_ng.py`  | Hardcoded vars  | 3                |

**Not in scope:** Gradio apps, standalone HTML report generators (they produce self-contained HTML with their own styles).

---

### Task 1: Create `lib/nicegui/theme.py`

**Files:**
- Create: `lib/nicegui/theme.py`
- Create: `lib/nicegui/tests/test_theme.py`

**Step 1: Write the test**

```python
# lib/nicegui/tests/test_theme.py
"""Smoke tests for theme module — importability and CSS structure."""
from lib.nicegui.theme import (
    BG, SURFACE, BORDER, TEXT, TEXT_MUTED, ACCENT,
    THEME_CSS, setup_theme, dark_toggle,
)


def test_css_variables_reference_correct_prefix():
    """All exported color constants use --r- prefix."""
    for var in [BG, SURFACE, BORDER, TEXT, TEXT_MUTED, ACCENT]:
        assert var.startswith("var(--r-"), f"{var} should use --r- prefix"


def test_theme_css_has_light_and_dark():
    """CSS defines both :root (light) and .body--dark (dark) variables."""
    assert ":root" in THEME_CSS
    assert ".body--dark" in THEME_CSS
    assert "--r-bg" in THEME_CSS
    assert "--r-surface" in THEME_CSS


def test_setup_theme_callable():
    """setup_theme is a callable that takes no required args."""
    assert callable(setup_theme)


def test_dark_toggle_callable():
    """dark_toggle is a callable."""
    assert callable(dark_toggle)
```

**Step 2: Run test — expect FAIL**

Run: `python -m pytest lib/nicegui/tests/test_theme.py -v`
Expected: FAIL — module doesn't exist yet

**Step 3: Implement `lib/nicegui/theme.py`**

```python
"""Shared NiceGUI theme — CSS variables for light/dark mode + toggle component.

Usage in any NiceGUI app:
    from lib.nicegui.theme import setup_theme, dark_toggle

    @ui.page("/")
    def page():
        setup_theme()
        with ui.header():
            ui.label("App Name")
            ui.space()
            dark_toggle()
"""

from __future__ import annotations

from nicegui import ui

# ---------------------------------------------------------------------------
# CSS variable references — use these in .style() calls
# ---------------------------------------------------------------------------

BG = "var(--r-bg)"
SURFACE = "var(--r-surface)"
SURFACE_RAISED = "var(--r-surface-raised)"
BORDER = "var(--r-border)"
TEXT = "var(--r-text)"
TEXT_MUTED = "var(--r-text-muted)"
ACCENT = "var(--r-accent)"
LINK = "var(--r-link)"
CODE_BG = "var(--r-code-bg)"

# Semantic colors (same in both modes)
SUCCESS = "#2ea043"
WARNING = "#d29922"
ERROR = "#f85149"

# ---------------------------------------------------------------------------
# CSS variable definitions
# ---------------------------------------------------------------------------

THEME_CSS = """
:root {
  --r-bg: #f6f8fa;
  --r-surface: #ffffff;
  --r-surface-raised: #f3f4f6;
  --r-border: #d0d7de;
  --r-text: #1f2328;
  --r-text-muted: #656d76;
  --r-accent: #6366f1;
  --r-link: #0969da;
  --r-code-bg: #f6f8fa;
}
.body--dark {
  --r-bg: #0d1117;
  --r-surface: #161b22;
  --r-surface-raised: #21262d;
  --r-border: #30363d;
  --r-text: #e6edf3;
  --r-text-muted: #8b949e;
  --r-accent: #6366f1;
  --r-link: #58a6ff;
  --r-code-bg: #0d1117;
}
body {
  background-color: var(--r-bg) !important;
  color: var(--r-text) !important;
}
"""

_STORAGE_KEY = "rivus-dark-mode"

_RESTORE_JS = """
(function() {
    const pref = localStorage.getItem('""" + _STORAGE_KEY + """');
    if (pref === 'false') {
        document.body.classList.remove('body--dark');
        document.body.classList.add('body--light');
    }
})();
"""


def setup_theme(default_dark: bool = True) -> None:
    """Inject theme CSS variables into the current page.

    Call once per page function, before building UI.
    Restores user's dark/light preference from localStorage.
    """
    ui.add_css(THEME_CSS)
    if default_dark:
        # Inject early JS to prevent flash if user prefers light
        ui.add_head_html(f"<script>{_RESTORE_JS}</script>")


def dark_toggle() -> ui.button:
    """Sun/moon toggle button for header bars.

    Toggles NiceGUI dark mode and persists preference to localStorage.
    Shows light_mode icon when dark (click to go light),
    dark_mode icon when light (click to go dark).
    """
    state = {"dark": True}

    def _icon() -> str:
        return "light_mode" if state["dark"] else "dark_mode"

    def _tooltip() -> str:
        return "Switch to light mode" if state["dark"] else "Switch to dark mode"

    dark = ui.dark_mode()

    async def _toggle():
        state["dark"] = not state["dark"]
        if state["dark"]:
            dark.enable()
        else:
            dark.disable()
        btn._props["icon"] = _icon()
        btn.update()
        tooltip.text = _tooltip()
        await ui.run_javascript(
            f"localStorage.setItem('{_STORAGE_KEY}', '{str(state[\"dark\"]).lower()}')"
        )

    btn = ui.button(icon=_icon(), on_click=_toggle).props(
        "flat dense round size=sm"
    ).style("color: var(--r-text-muted);")
    tooltip = btn.tooltip(_tooltip())

    # Restore preference from localStorage on page load
    async def _restore():
        pref = await ui.run_javascript(
            f"localStorage.getItem('{_STORAGE_KEY}')"
        )
        if pref == "false":
            state["dark"] = False
            dark.disable()
            btn._props["icon"] = _icon()
            btn.update()
            tooltip.text = _tooltip()
        elif pref == "true":
            state["dark"] = True
            dark.enable()
            btn._props["icon"] = _icon()
            btn.update()
            tooltip.text = _tooltip()

    ui.timer(0.1, _restore, once=True)

    return btn
```

**Step 4: Run test — expect PASS**

Run: `python -m pytest lib/nicegui/tests/test_theme.py -v`
Expected: PASS

**Step 5: Commit**

```bash
git add lib/nicegui/theme.py lib/nicegui/tests/test_theme.py
git commit -m "feat(nicegui): shared theme with dark/light toggle + localStorage persistence"
```

---

### Task 2: Migrate Index app (simplest — validates the pattern)

**Files:**
- Modify: `index/app.py`

**Step 1: Add theme imports and replace dark mode**

Replace:
```python
@ui.page("/")
def main():
    ui.dark_mode(True)
```

With:
```python
from lib.nicegui.theme import setup_theme, dark_toggle, BORDER

@ui.page("/")
def main():
    setup_theme()
```

**Step 2: Add toggle to header**

In the header section, add `dark_toggle()` after the search input:
```python
with ui.header().classes("items-center justify-between q-px-md"):
    ui.label("Rivus").classes("text-h5 font-bold")
    search_input = ui.input(...).classes("w-80")
    dark_toggle()
```

**Step 3: Replace hardcoded border color**

Replace `border: 1px solid #374151;` with `border: 1px solid var(--r-border);` in the service card styles.

**Step 4: Change `ui.run()` dark parameter**

Change `dark=True` to `dark=True` (keep as default — the toggle + localStorage override it dynamically).

**Step 5: Test manually**

Run: `python index/app.py`
Verify: Dark mode loads by default. Click sun icon → switches to light. Reload page → light persists. Click moon → back to dark. Reload → dark persists.

**Step 6: Commit**

```bash
git add index/app.py
git commit -m "feat(index): dark/light mode toggle with shared theme"
```

---

### Task 3: Migrate Vario ng (already has theme — swap to shared)

**Files:**
- Modify: `vario/theme.py`
- Modify: `vario/app.py`
- Modify: `vario/ui_design.py` (uses theme vars)
- Modify: `vario/ui_run.py` (uses theme vars)

**Step 1: Update `vario/theme.py` to delegate to shared**

Replace the module to re-export from shared theme, keeping vario-specific aliases:
```python
"""Vario NiceGUI theme — delegates to shared rivus theme."""
from lib.nicegui.theme import (
    BG, SURFACE as CARD_BG, BORDER, TEXT, TEXT_MUTED,
    ACCENT, CODE_BG, SURFACE_RAISED,
    SUCCESS, WARNING, ERROR,
    THEME_CSS, setup_theme, dark_toggle,
)

# Vario-specific aliases for backward compat with ui_design.py / ui_run.py
DIAGRAM_BG = "var(--r-surface)"
DIAGRAM_BORDER = "var(--r-border)"

def inject_theme() -> None:
    """Inject theme CSS variables — delegates to shared setup_theme()."""
    setup_theme()

def make_card():
    """Standard themed card."""
    from nicegui import ui
    return ui.card().classes("w-full").style(
        f"background: {CARD_BG}; border: 1px solid {BORDER}; padding: 0;"
    )
```

**Step 2: Update `vario/app.py` header to use shared toggle**

Replace the existing manual dark toggle:
```python
dark = ui.dark_mode(True)
ui.button(icon="dark_mode", on_click=dark.toggle).props("flat dense round")
```
With:
```python
from vario.theme import dark_toggle
dark_toggle()
```

And remove the line `dark = ui.dark_mode(True)` since `setup_theme()` (called via `inject_theme()`) handles it.

**Step 3: Verify imports in ui_design.py and ui_run.py still work**

These files import from `vario.theme` — the re-exports should keep them working.

**Step 4: Test manually**

Run: `python vario/app.py`
Verify: Toggle works, design page renders correctly in both modes.

**Step 5: Commit**

```bash
git add vario/theme.py vario/app.py
git commit -m "refactor(vario/ng): delegate theme to shared lib/nicegui/theme"
```

---

### Task 4: Migrate Doctor app

**Files:**
- Modify: `doctor/app.py`

**Step 1: Add imports**

```python
from lib.nicegui.theme import setup_theme, dark_toggle, SURFACE, BORDER, CODE_BG
```

**Step 2: Add `setup_theme()` to both pages**

In `_build_triage_page()` at the top, add `setup_theme()`. Same for `_build_open_page()`.

Or better: since doctor uses `dark=True` in `ui.run()`, add `setup_theme()` inside both page builders and add `dark_toggle()` to both headers (the nav row).

**Step 3: Replace hardcoded colors**

- `background:#1a1a2e` in pre/code blocks → `background: var(--r-code-bg)`
- In `_REVIEW_HTML` template (standalone HTML, not NiceGUI) — leave as-is (it's self-contained)

**Step 4: Add toggle to header rows**

In both `_build_triage_page` and `_build_open_page`, add after the nav links:
```python
ui.space()
dark_toggle()
```

**Step 5: Test manually**

Run: `python doctor/app.py`
Verify: Both pages render, toggle works, pre blocks use correct bg in both modes.

**Step 6: Commit**

```bash
git add doctor/app.py
git commit -m "feat(doctor): dark/light mode toggle with shared theme"
```

---

### Task 5: Migrate Intel app (most hardcoded colors)

**Files:**
- Modify: `intel/app.py`

**Step 1: Add imports**

```python
from lib.nicegui.theme import (
    setup_theme, dark_toggle,
    SURFACE, SURFACE_RAISED, BORDER, TEXT_MUTED, LINK,
)
```

**Step 2: Replace `APP_CSS` hardcoded colors with CSS variables**

```css
.intel-card {
    background: var(--r-surface); border: 1px solid var(--r-border); ...
}
.intel-card a { color: var(--r-link); }
.stat-card { background: var(--r-surface); border: 1px solid var(--r-border); ... }
.dashed-placeholder { background: var(--r-surface); border: 1px dashed var(--r-border); ... }
```

**Step 3: Replace hardcoded colors in inline styles**

All `.style("background: #161b22; border: 1px solid #30363d;")` calls throughout the file → use CSS variables:
- `#161b22` → `var(--r-surface)`
- `#0d1117` → `var(--r-bg)`
- `#30363d` → `var(--r-border)`
- `#21262d` → `var(--r-surface-raised)`
- `#58a6ff` → `var(--r-link)`
- `#8b949e` → `var(--r-text-muted)`

**Step 4: Replace `ui.dark_mode(True)` on every page**

Remove `ui.dark_mode(True)` from every page function. Add `setup_theme()` once per page instead.

**Step 5: Add toggle to `_layout()` header**

In `_layout()`, after the search select, add:
```python
ui.space()
dark_toggle()
```

**Step 6: Update left drawer background**

Change `"background: #0d1117; width: 180px;"` to `f"background: {BG}; width: 180px;"` — but since we're in an f-string context and BG is a `var()` reference, just use the CSS variable inline: `"background: var(--r-bg); width: 180px;"`.

**Step 7: Update `_score_bar_html` and `_tag_chips_html`**

- `#21262d` background in score bar → `var(--r-surface-raised)`
- `#21262d` in tag chips → `var(--r-surface-raised)`
- `#8b949e` in tag chips → `var(--r-text-muted)`

**Step 8: Test manually**

Run: `python intel/app.py` (or `inv intel.server`)
Verify: All pages (startups, VCs, people, person detail, debug pages) render in both dark and light modes. Cards, chips, score bars, avatars all look correct.

**Step 9: Commit**

```bash
git add intel/app.py
git commit -m "feat(intel): dark/light mode toggle with shared theme"
```

---

### Task 6: Migrate Draft app

**Files:**
- Modify: `draft/ui/app.py`

**Step 1: Add imports**

```python
from lib.nicegui.theme import setup_theme, dark_toggle
```

**Step 2: Replace header CSS**

The header gradient `background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)` needs a light-mode variant. Use CSS variables:

Add to `ui.add_css()`:
```css
.draft-header {
    background: linear-gradient(135deg, #e8eaf6 0%, #c5cae9 50%, #9fa8da 100%) !important;
}
.body--dark .draft-header {
    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%) !important;
}
```

**Step 3: Add `setup_theme()` at top of page function**

Before `ui.add_css(...)`, add `setup_theme()`.

**Step 4: Add toggle to header**

After `header_status` label:
```python
dark_toggle()
```

**Step 5: Change `dark=None` to `dark=True` in `ui.run()`**

Since we now have localStorage persistence, default to dark (matches all other apps). Users who prefer light will have it persisted.

**Step 6: Test manually**

Run: `python draft/ui/app.py`
Verify: Header gradient works in both modes. Document input, tabs, analysis all render correctly.

**Step 7: Commit**

```bash
git add draft/ui/app.py
git commit -m "feat(draft): dark/light mode toggle with shared theme"
```

---

### Task 7: Migrate Jobs app

**Files:**
- Modify: `jobs/app_ng.py`

**Step 1: Add imports and setup_theme()**

Add `from lib.nicegui.theme import setup_theme, dark_toggle` at the top with other imports.

Find the page function(s) and add `setup_theme()` at the top.

**Step 2: Add toggle to header**

Jobs uses a header with tabs. Add `dark_toggle()` to the right side of the header.

**Step 3: Replace hardcoded colors**

Search for `#161b22`, `#0d1117`, `#30363d`, `#21262d` in the file and replace with CSS variable references where they appear in NiceGUI `.style()` calls.

**Step 4: Test manually**

Run: `python jobs/app_ng.py`

**Step 5: Commit**

```bash
git add jobs/app_ng.py
git commit -m "feat(jobs): dark/light mode toggle with shared theme"
```

---

### Task 8: Migrate Ops app

**Files:**
- Modify: `ops/app.py`

**Step 1: Add imports and setup_theme()**

Replace `ui.dark_mode(True)` with `setup_theme()`. Add `dark_toggle()` to the header.

**Step 2: Test manually**

Run: `python ops/app.py`

**Step 3: Commit**

```bash
git add ops/app.py
git commit -m "feat(ops): dark/light mode toggle with shared theme"
```

---

### Task 9: Migrate Ideas app

**Files:**
- Modify: `ideas/ui/app.py`

**Step 1: Add imports and setup_theme()**

Same pattern as other apps. Add `dark_toggle()` to the header.

**Step 2: Replace any hardcoded colors**

One occurrence of dark color — replace with CSS variable.

**Step 3: Test manually**

Run: `python ideas/ui/app.py`

**Step 4: Commit**

```bash
git add ideas/ui/app.py
git commit -m "feat(ideas): dark/light mode toggle with shared theme"
```

---

### Task 10: Migrate Semnet app

**Files:**
- Modify: `lib/semnet/presenter/app_ng.py`

**Step 1: Replace local theme constants**

The file defines `DARK_BG`, `CARD_BG`, `BORDER`, `ACCENT`, `MUTED` locally. Replace with imports from shared theme.

**Step 2: Add `setup_theme()` + `dark_toggle()`**

Same pattern. Add toggle to the header area.

**Step 3: Test manually**

Run: `python lib/semnet/presenter/app_ng.py`

**Step 4: Commit**

```bash
git add lib/semnet/presenter/app_ng.py
git commit -m "feat(semnet): dark/light mode toggle with shared theme"
```

---

### Task 11: Update lib/nicegui/doc_input.py

**Files:**
- Modify: `lib/nicegui/doc_input.py`

**Step 1: Update `render_doc_preview()` to use CSS variables**

The preview border uses `var(--q-border-color, #ddd)` — update to `var(--r-border)` for consistency.

The "No document loaded" placeholder uses hardcoded `color:#999` — change to `var(--r-text-muted)`.

**Step 2: Test by loading a doc in Draft**

**Step 3: Commit**

```bash
git add lib/nicegui/doc_input.py
git commit -m "refactor(doc_input): use shared theme CSS variables"
```

---

## Notes

- **AG Grid**: Already works — theme `"balham"` automatically responds to `body--dark`. No changes needed.
- **Quasar components**: `q-` classes automatically adapt to dark mode. No changes needed.
- **Report generators** (doctor/sweep.py, vario/review_report.py, etc.): Produce standalone HTML — not in scope. They have their own embedded CSS.
- **Light mode colors**: The `:root` values use GitHub-light-style palette. The `.body--dark` values preserve the current dark palette exactly.
- **localStorage key**: `rivus-dark-mode` — shared across all apps on the same domain.
