# CLAUDE.md

This file provides guidance to Claude Code when working with code in this repository.

## Project Overview

**legitimate-salvage** is a JupyterLab 4 theme extension. Currently ships:

- **Rocinante** — cool cyan/teal palette (dark)
- **Amber** — warm amber/orange palette (dark)
- **Olive** — warm cream/olive green palette (light)
- **Cinnamon** — warm brown/cinnamon palette (dark)
- **Mauve** — dusty rose/mauve palette (light)
- **Rosewater** — pale blush/deep rose palette (light)
- **Caramel** — warm golden butterscotch palette (light)
- **Parchment** — warm ivory/terracotta palette (light)
- **Muslin** — natural linen/steel blue palette (light)
- **Rosé Pine** — muted purple/rose palette (dark) — [rosepinetheme.com](https://rosepinetheme.com)
- **Rosé Pine Dawn** — warm cream/rose palette (light) — [rosepinetheme.com](https://rosepinetheme.com)
- **Broadsheet** — warm off-white, ink-blue accent, editorial restraint (light)
- **Graphite** — cool near-black, ink-blue accent, maximal data-ink ratio (dark)
- **Spinzero** — Solarized-inspired, CMU Serif typography (light) — adapted from Neil Panchal's theme

More themes will be added over time. The architecture is designed to make adding new themes straightforward — define a palette in `palettes.json`, register it in `src/index.ts`, and add its `data-jp-theme-name` to the CSS selectors in `style/index.css`.

Users can customize any theme via **Settings > Settings Editor > Legitimate Salvage Theme Settings**. Changes hot-reload instantly and persist across server restarts.

## Commands

```bash
# Install dependencies
jlpm install

# Development build
jlpm build

# Production build
jlpm build:prod

# Install extension locally (editable)
pip install -e .

# Verify extension is registered
jupyter labextension list

# Publish to PyPI (uses ~/.pypirc credentials, self-contained via uv)
./publish.sh            # build + upload
./publish.sh --dry      # build only, no upload

# Run JupyterLab for testing
jupyter lab --no-browser
```

These commands require a Python environment with JupyterLab 4 installed. The `publish.sh` script is self-contained — it creates an ephemeral `.build-venv/` via `uv` with all needed dependencies and cleans it up after.

## Architecture

### How Multiple Themes Work From One Extension

JupyterLab's `themePath` in `package.json` only supports one CSS file. We work around this:

1. **`palettes.json`** (project root) is the **single source of truth** for all palette data. Both TypeScript and Python read from it.
2. **`src/palettes.ts`** imports `palettes.json` and exports typed palette objects + `generateVariablesCSS()` which maps a palette to all `--jp-*` CSS variables.
3. **`src/settings.ts`** handles user customization: reads settings from `ISettingRegistry`, merges user overrides with palette defaults, generates additional CSS for granular settings (heading sizes, DataFrame colors, etc.).
4. **`src/index.ts`** registers each theme via `manager.register()`. Each theme's `load()` merges the base palette with user settings, injects the result as a `<style>` element, then loads the shared structural CSS. Listens for setting changes to hot-reload.
5. **`style/index.css`** is the shared structural CSS. All colors reference `--jp-*` and `--theme-*` variables — no hardcoded hex values.

### Key Files

| File | Purpose |
|------|---------|
| `palettes.json` | **Single source of truth** — all palette data for both TypeScript and Python |
| `src/index.ts` | Plugin entry — registers themes, wires up `ISettingRegistry`, handles hot reload |
| `src/palettes.ts` | `IThemePalette` interface, CSS variable generation, DOM injection |
| `src/settings.ts` | Settings interfaces, merge logic (palette + global + per-theme overrides), granular CSS generation |
| `schema/plugin.json` | JSON Schema for Settings Editor — defines all customizable properties with types/descriptions |
| `style/index.css` | Shared structural CSS (cells, tabs, sidebar, tables, markdown, TOC, output areas, etc.) |
| `legitimate_salvage/__init__.py` | Python package entry + labextension path registration |
| `legitimate_salvage/palettes.py` | Python palette loader — reads from `palettes.json`, converts camelCase → snake_case |
| `pyproject.toml` | Python/PyPI package config (hatchling build) |
| `package.json` | npm config, JupyterLab extension metadata, linter configs |
| `publish.sh` | Self-contained PyPI publish script (uses `uv`) |
| `TRADEOFFS.md` | Deferred decisions log (custom color picker, cursor blink, shadow intensity, etc.) |

### Naming Conventions

- **PyPI / npm package name**: `legitimate-salvage` (hyphens)
- **Python package directory**: `legitimate_salvage/` (underscores)
- **Theme names** (in JupyterLab UI): lowercase, set via `name` field in `manager.register()` (e.g., `'rocinante'`, `'amber'`)
- **CSS selectors**: `body[data-jp-theme-name='<theme-name>']`
- **Plugin ID**: `legitimate-salvage:plugin`

### Palette → JupyterLab Variable Mapping

Each `IThemePalette` has semantic color roles that map to JupyterLab and CodeMirror variables:

| Palette Role | JupyterLab Usage |
|---|---|
| `bg`, `bgAlt`, `bgHl`, `bgRegion` | `--jp-layout-color0` through `--jp-layout-color3` |
| `fg`, `fgDim`, `fgBright` | `--jp-ui-font-color*`, `--jp-content-font-color*` |
| `accent`, `accentBright`, `accentDark` | `--jp-brand-color*`, syntax keyword/builtin/operator |
| `green` | Syntax strings |
| `orange` | Syntax numbers/constants/atoms |
| `yellow` | Syntax types |
| `red` | Syntax tags, errors |
| `blue` | Syntax attributes, info |
| `border` | `--jp-border-color*` |

### Build Output

`jlpm build` compiles TypeScript to `lib/` and produces the labextension in `legitimate_salvage/labextension/`. The labextension directory is build output — don't edit files in it directly.

Note: `rootDir` in `tsconfig.json` is set to `"."` (project root) so that TypeScript can import `palettes.json`. This means compiled output goes to `lib/src/` (not `lib/` directly), and `package.json`'s `main` field points to `lib/src/index.js`.

## Settings / Customization System

Users customize themes via **Settings > Settings Editor > Legitimate Salvage Theme Settings**. The system uses JupyterLab's `ISettingRegistry`.

### How It Works

1. `schema/plugin.json` defines the settings schema — types, descriptions, defaults
2. Settings persist at `~/.jupyter/lab/user-settings/legitimate-salvage/plugin.jupyterlab-settings`
3. On theme load, `src/settings.ts` merges: base palette (from `palettes.json`) + global user settings + per-theme user overrides
4. On settings change, the active theme's CSS is re-injected instantly (hot reload)
5. Reset = delete user override → schema default restores automatically

### Font Cascade

```
per-theme user override → global user override → theme built-in default → extension default
```

### Settings Schema Structure

- **Typography (Global)**: code/content/UI font families, sizes, line heights
- **Scrollbar**: auto-hide (default: true), width, thumb/track colors
- **Layout (Global)**: border radius, cell padding, notebook padding, prompt width, cursor width
- **Terminal**: "Use Theme Colors" toggle (default: true) — maps palette to ANSI colors
- **Theme Customization**: per-theme overrides (one collapsed section per theme), containing:
  - Typography overrides
  - Colors (backgrounds, text, accent, semantic, border)
  - Syntax Colors (24 CodeMirror token types)
  - Markdown (heading color/weight/alignment per-level, blockquote, inline code, code blocks, links)
  - DataFrames (header background/color/weight, row alternation, hover, border)
  - Table of Contents (font size, active color, indent)

### Granular CSS Variables

Beyond the standard `--jp-*` palette variables, the extension injects `--theme-*` variables for granular control:

| Variable | Controls |
|---|---|
| `--theme-heading-color`, `--theme-heading-font-weight`, `--theme-heading-border-bottom`, `--theme-heading-align` | All headings (h1–h6) |
| `--theme-h1-size` through `--theme-h6-size`, `--theme-h1-align` through `--theme-h6-align` | Per-level heading size and alignment |
| `--theme-blockquote-border-color`, `--theme-blockquote-border-width`, `--theme-blockquote-text-color` | Blockquotes |
| `--theme-inline-code-background`, `--theme-inline-code-color` | Inline code |
| `--theme-code-block-background` | Fenced code blocks |
| `--theme-df-header-bg`, `--theme-df-header-color`, `--theme-df-header-weight`, `--theme-df-row-alt-bg`, `--theme-df-row-hover-bg`, `--theme-df-border-color` | Tables / DataFrames |
| `--theme-toc-font-size`, `--theme-toc-active-color`, `--theme-toc-indent` | Table of Contents |

All use CSS `var()` with fallbacks, so the theme works unchanged when no overrides are set.

### Scrollbar Handling

Scrollbars are managed programmatically in `src/index.ts` (not in `style/index.css`):
- **Auto-hide on** (default): no custom CSS is injected — OS-native scrollbar behavior (macOS auto-hides)
- **Auto-hide off**: custom themed scrollbar CSS is injected with configurable width, thumb, and track colors

### Terminal Colors

When "Use Theme Colors" is enabled (default), `src/index.ts` injects `--jp-terminal-color-*` CSS variables mapping the palette to the ANSI 16-color terminal palette. When disabled, JupyterLab defaults are used.

## Adding a New Theme

A new theme requires changes to `palettes.json` + 3 source files + updating CLAUDE.md:

1. `palettes.json` — add the palette (single source of truth for both TS and Python)
2. `src/index.ts` — add the theme to `THEME_ENTRIES` array
3. `style/index.css` — add the theme name to all CSS selectors (+ any theme-specific overrides)
4. `schema/plugin.json` — add the theme to the `themeCustomization` properties
5. `legitimate_salvage/palettes.py` — update the `ThemeName` Literal (palette data is auto-loaded from JSON)
6. `CLAUDE.md` — add the theme to the list

Then: build, install, run tests, launch JupyterLab, apply the theme, open `test_theme.ipynb`, run all cells, and take screenshots.

### Step 1: Design the Palette

The `IThemePalette` interface has 17 fields. Every theme must provide all of them.

#### Color Roles

| Field | Role | Dark theme example | Light theme example |
|-------|------|-------------------|---------------------|
| `bg` | Primary background | `#0d1117` | `#faf6f0` |
| `bgAlt` | Sidebar, toolbar, secondary bg | `#151b24` | `#f3ede4` |
| `bgHl` | Hover, highlight | `#1a2535` | `#e8e0d4` |
| `bgRegion` | Selection regions | `#2a3a50` | `#d9cfc0` |
| `fg` | Body text | `#c0d0e0` | `#4a4340` |
| `fgDim` | Comments, secondary text | `#6c7086` | `#8a7e78` |
| `fgBright` | Headings, emphasis | `#e0e8f0` | `#2d2826` |
| `accent` | Keywords, links, cursor, brand-color1 | `#40c8d8` | `#6b8f71` |
| `accentBright` | brand-color0 (strongest emphasis) | `#60e0f0` | `#4a7050` |
| `accentDark` | brand-color2 (soft wash) | `#2a5060` | `#a8c5ae` |
| `green` | Strings | `#50b878` | `#5e8c61` |
| `red` | Errors, tags | `#d85050` | `#b05c5c` |
| `blue` | Info, attributes | `#5890d0` | `#5c7a99` |
| `magenta` | Collaborator colors | `#b868b8` | `#a07080` |
| `orange` | Numbers, constants, atoms | `#d89050` | `#c0885a` |
| `yellow` | Warnings, types | `#d8c860` | `#b09840` |
| `cyan` | JSON icon, collaborators | `#40c8d8` | `#5a8a85` |
| `border` | Border color | `#2a3a50` | `#d9cfc0` |

#### Light vs Dark: The `accentBright`/`accentDark` Inversion

This is the most important design decision for light themes. In `generateVariablesCSS()`:
- `accentBright` maps to `--jp-brand-color0` (strongest emphasis — used for headings)
- `accent` maps to `--jp-brand-color1` (primary accent — links, active states)
- `accentDark` maps to `--jp-brand-color2` (softest — subtle backgrounds, selected borders)

For a **dark theme**, "strongest" means literally brightest (lighter color on dark bg).
For a **light theme**, "strongest" means darkest/most saturated (darker color on light bg).

So for light themes, `accentBright` should be a **darker** shade than `accent`, and `accentDark` should be a **lighter** wash. The naming is counterintuitive but the mapping logic in `generateVariablesCSS()` doesn't change.

#### Contrast Requirements

Check contrast ratios against `bg` for readability:
- `fg` (body text): aim for ~7:1 (WCAG AAA)
- `fgBright` (headings): aim for ~10:1+
- `fgDim` (secondary): aim for ~4:1+ (AA)
- `accent` (links, keywords): aim for ~4.5:1 (AA)

#### No Changes Needed to `generateVariablesCSS()`

The function works for both light and dark themes as-is. The palette values alone encode the polarity. The `darken()` and `lighten()` helpers produce sensible results in either direction.

### Step 2: Add the Palette (`palettes.json`)

Add a new entry to `palettes.json` (the single source of truth). Use camelCase keys:

```json
"<name>": {
  "bg": "#...", "bgAlt": "#...", "bgHl": "#...", "bgRegion": "#...",
  "fg": "#...", "fgDim": "#...", "fgBright": "#...",
  "accent": "#...", "accentBright": "#...", "accentDark": "#...",
  "green": "#...", "red": "#...", "blue": "#...",
  "magenta": "#...", "orange": "#...", "yellow": "#...",
  "cyan": "#...", "border": "#...",
  "isLight": true
}
```

Both TypeScript and Python read from this file. No need to duplicate palette data.

### Step 3: Update Python `ThemeName` (`legitimate_salvage/palettes.py`)

The palette data is auto-loaded from JSON, but the `ThemeName` Literal needs updating for type safety:

```python
ThemeName = Literal[
    "amber",
    "caramel",
    "cinnamon",
    "<name>",          # add in alphabetical order
    "mauve",
    # ...
]
```

No changes needed to `_PALETTES` — it's loaded from `palettes.json` automatically.

Key mapping is handled by `_camel_to_snake()` in `palettes.py`: `bgAlt` → `bg_alt`, `fgDim` → `fg_dim`, etc. The JSON also includes `isLight` (boolean), which Python converts to `is_light`.

### Step 4: Register the Theme (`src/index.ts`)

Add the theme to the `THEME_ENTRIES` array in `src/index.ts`:
```typescript
const THEME_ENTRIES: IThemeEntry[] = [
  { name: 'rocinante', displayName: 'Rocinante' },
  // ...
  { name: '<name>', displayName: '<Name>' },  // add this
].map(entry => ({
  ...entry,
  isLight: (palettesData as Record<string, any>)[entry.name]?.isLight ?? false,
  palette: allPalettes[entry.name]
});
```

The `isLight` flag is read from `palettes.json` automatically. It tells JupyterLab to set `data-jp-theme-light="true"` on the body, which affects system icon colors and image backgrounds.

### Step 5: Add to Settings Schema (`schema/plugin.json`)

Add the new theme to the `themeCustomization` properties:
```json
"<name>": { "title": "<Name>", "description": "<brief description>", "$ref": "#/definitions/themeCustomization" }
```

### Step 6: Update CSS (`style/index.css`)

#### Add to All Selector Groups

Every selector group in the CSS lists all theme names. Add the new theme to each one. There are ~30 groups. The pattern:

```css
body[data-jp-theme-name='rocinante'],
body[data-jp-theme-name='amber'],
body[data-jp-theme-name='olive'],
body[data-jp-theme-name='<new-name>'] {
```

Use `replace_all` with the Edit tool to add the new theme name after the last existing theme in each selector group. Be careful with compound selectors (input fields, heading h1-h6) that list each theme with multiple sub-selectors.

#### Light Theme Shadow Overrides

If the theme is light, the dark-theme shadows (hardcoded at `rgba(0,0,0,0.3-0.5)`) will be too heavy on light backgrounds. Add a theme-specific override section at the end of the CSS:

```css
body[data-jp-theme-name='<name>'] .jp-Toolbar {
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}

body[data-jp-theme-name='<name>'] .lm-Menu {
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}

body[data-jp-theme-name='<name>'] .jp-DocumentSearch-overlay {
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
```

### Step 6: Update CLAUDE.md

Add the new theme to the bullet list in the Project Overview section.

### Step 7: Build and Install

```bash
eval "$(conda shell.zsh hook)" && conda activate ml && jlpm build
eval "$(conda shell.zsh hook)" && conda activate ml && pip install -e .
```

Both commands must succeed before testing.

### Step 8: Run Tests

Run the smoke tests to verify the new palette works with matplotlib, pandas, and plotnine across all themes (including the new one):

```bash
eval "$(conda shell.zsh hook)" && conda activate ml && pytest tests/test_plotting.py -v
```

The test suite is parametrized across all themes listed in `ThemeName`. If the new palette was added to `_PALETTES` and `ThemeName` correctly, the new theme is automatically covered by every test. All tests must pass.

If tests fail, common causes:
- Missing or misnamed key in the `_PALETTES` entry
- Theme name not added to `ThemeName` Literal
- Hex values that aren't valid 7-character strings (e.g. missing `#` prefix, wrong length)

### Step 9: Take Screenshots

#### Launch JupyterLab

```bash
eval "$(conda shell.zsh hook)" && conda activate ml && jupyter lab --no-browser --port=8899 --ServerApp.token='' --ServerApp.password='' &
```

Wait ~5 seconds for startup.

#### Screenshot Workflow (Playwright)

1. **Set viewport** to full width: `1728 x 1117` (16" MacBook Pro logical resolution)
2. **Navigate** to `http://localhost:8899/lab/tree/test_theme.ipynb`
3. **Wait** 5 seconds for the notebook to load
4. **Apply the theme**: Settings menu > Theme > select the new theme
5. **Wait** 3 seconds for the theme to apply
6. **Run all cells**: Click "Restart kernel and run all cells" button, confirm the dialog
7. **Wait** for kernel to become idle (~8 seconds)
8. **If the error cell stopped execution** of remaining cells (expected), click on the first unexecuted pandas cell and press Shift+Enter for each remaining cell

#### Screenshot Targets

Create `screenshots/<theme-name>/` directory and save these screenshots:

**Full width (1728px):**
- Top of notebook (headings, prose, blockquote, lists)
- Code in markdown, LaTeX equations, markdown table
- Python function code cell with output
- Python class code cell with output
- Error traceback
- Pandas DataFrame + describe() output

**Half width (864px):**
- Resize viewport to `864 x 1117`
- Reload page (theme persists across reloads)
- Top of notebook
- Code cell
- Error + DataFrame

#### Navigating the Windowed Panel

JupyterLab uses a virtualized/windowed panel for cells. Scrolling via JavaScript (`scrollTop = 0`) does NOT work. Instead:
- Press **Escape** to enter command mode
- Use **ArrowDown** / **ArrowUp** keys to navigate between cells — this forces the viewport to scroll
- **Reload the page** to start from the top (the theme setting persists)

#### Cleanup

Stop the JupyterLab server when done:
```bash
pkill -f "jupyter-lab.*8899"
```

### Checklist

- [ ] Palette defined in `palettes.json` (all 18 fields + `isLight`)
- [ ] Theme added to `THEME_ENTRIES` in `src/index.ts`
- [ ] Theme added to `themeCustomization` in `schema/plugin.json`
- [ ] `data-jp-theme-name` added to all selector groups in `style/index.css`
- [ ] Light theme shadow overrides added (if `isLight: true`)
- [ ] `ThemeName` Literal updated in `legitimate_salvage/palettes.py`
- [ ] CLAUDE.md updated
- [ ] `jlpm build` succeeds
- [ ] `pip install -e .` succeeds
- [ ] `pytest tests/test_plotting.py -v` — all tests pass
- [ ] Theme appears in Settings > Theme menu
- [ ] Screenshots taken at full width and half width
- [ ] Screenshots saved to `screenshots/<theme-name>/`

## Plotting Integration (`legitimate_salvage.plotting`)

The Python plotting module provides theme-matched colors for matplotlib, pandas, and plotnine.

### API Summary

| Function | Purpose |
|---|---|
| `set_theme('name')` | Apply theme to matplotlib rcParams (+ plotnine global theme if installed) |
| `color_palette('name', kind, n)` | Get raw hex colors — `'qualitative'` (distinct hues) or `'sequential'` (lightness gradient) |
| `theme_plotnine('name')` | Returns a plotnine `theme` object (inherits from `theme_minimal`) |
| `scale_color(kind='qualitative')` | plotnine color scale using theme colors |
| `scale_fill(kind='sequential', n=12)` | plotnine fill scale — sequential for ordered categories |
| `mpl_style('name')` | Get the raw rcParams dict |

### How It Works

- `set_theme()` updates `matplotlib.rcParams` (figure/axes backgrounds, text colors, grid, spines, color cycle). This automatically affects pandas `.plot()` and any matplotlib-based library.
- `theme_plotnine()` returns `theme_minimal() + theme(...)` — inheriting structure from the base theme and overriding colors. This is required because plotnine 0.15+ expects complete theme elements with margins.
- The qualitative color cycle alternates warm/cool hues for visual separation and deduplicates (e.g. rocinante has `accent == cyan`).
- Sequential palettes use HSL interpolation from the accent hue, varying lightness. Light themes ramp light→dark; dark themes ramp dark→light.

### `ThemeName` Literal Type

All public functions use `ThemeName` from `legitimate_salvage.palettes` for type safety. This is a `Literal` union of all valid theme names. Editors autocomplete valid names and flag typos. At runtime, invalid names raise `ValueError` listing all available themes.

When adding a new theme, the `ThemeName` Literal **must** be updated — otherwise the new theme won't get type checking or IDE autocomplete, even though it would work at runtime.

## Test Notebook (`test_theme.ipynb`)

The test notebook is reused across all themes. It contains:
- **Markdown cells**: H1-H4 headings, prose, blockquote, bullet/numbered lists, inline code, fenced code block, LaTeX (block + inline), markdown table
- **Collapsible heading**: H2 with content underneath
- **Python functions**: imports, decorators, type annotations, docstrings, f-strings
- **Python class**: dataclass with properties, setter, methods, `__str__`
- **Error cell**: nested function calls producing a KeyError traceback
- **Pandas cells**: DataFrame creation + display, `.describe()` output
- **Matplotlib plots**: `set_theme()` call, line plot (color cycle), scatter + bar subplots, pandas `.plot()` integration
- **plotnine plots**: scatter with `scale_color()`, bar chart with `scale_fill()` and `coord_flip()`

The matplotlib/plotnine cells use `set_theme(THEME)` where `THEME` is a variable set at the top of the plot section. Change it to the theme being tested. The `set_theme()` call applies the palette to both matplotlib rcParams and plotnine's global theme.

Do NOT modify this notebook when adding new themes. If it needs updating, update it for all themes.

## Design Principles

- **No runtime customization** — well-crafted static themes, not a runtime color picker.
- **Maximize content-to-chrome ratio** — no backdrop-filter blur, no transform hover animations, no text-shadow glow effects. Let the content breathe.
- **Hierarchy through typography, not decoration** — headings use size/weight, not glow or colored boxes.
- **All colors via CSS custom properties** — the structural CSS never hardcodes hex values. Theme differentiation is purely through variable injection.

## Things to Watch Out For

- **`data-jp-theme-name` must match the `name` field** in `manager.register()`, not the package name.
- **Sidebar icon visibility** depends on `--jp-icon-contrast-color*` and `--jp-inverse-layout-color*` variables being set to light-enough values. If icons disappear, check these in `palettes.ts`.
- **Pandas DataFrame tables** use inline styles — CSS overrides need `!important` on `.dataframe thead th` and `.dataframe tbody td` to take effect.
- **Matplotlib/plotnine figure internals** cannot be themed via CSS — users need `plt.style.use('dark_background')` in their notebook code. The CSS only controls the container/border.
- **`publish.sh` is self-contained** — it uses `uv` to create an ephemeral `.build-venv/` with all dependencies. No conda or system Python env is assumed.
