refactor: restructure project into muxplex/ subdir with brand integration
- Move coordinator/, frontend/, Caddyfile, pyproject.toml, requirements.txt, docs/ into muxplex/ subdir in prep for packaging/sharing - Add brand assets to frontend/: favicon.ico, pwa-192/512.png, apple-touch-icon.png, wordmark-on-dark.svg - Update app: title → muxplex, header → wordmark SVG, brand color tokens in style.css, manifest.json updated with muxplex name and brand icons - Add design system: assets/branding/tokens.css (101 CSS custom properties), tokens.json (127 tokens), DESIGN-SYSTEM.md (856-line spec) - Add assets/branding/: SVG sources, rendered PNGs (icons, favicons, PWA, OG) - Add scripts/render-brand-assets.py for reproducible PNG generation - Add muxplex/README.md
@@ -0,0 +1,11 @@
|
|||||||
|
:8088 {
|
||||||
|
# WebSocket to ttyd: strip /terminal prefix so /terminal/ws reaches ttyd's /ws endpoint.
|
||||||
|
# ttyd only registers the 'tty' WebSocket protocol handler at /ws — not at /terminal/ws.
|
||||||
|
# handle + uri strip_prefix rewrites /terminal/ws → /ws before the reverse_proxy sees it.
|
||||||
|
handle /terminal/* {
|
||||||
|
uri strip_prefix /terminal
|
||||||
|
reverse_proxy localhost:7682
|
||||||
|
}
|
||||||
|
# All other requests (static files, API) → coordinator (port 8099)
|
||||||
|
reverse_proxy * localhost:8099
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# muxplex
|
||||||
|
|
||||||
|
A web-based dashboard for tmux sessions. Access and manage all your tmux sessions from any device — browser, phone, tablet.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Live tile grid showing all running tmux sessions
|
||||||
|
- Click any session to open a full interactive terminal
|
||||||
|
- Bell/activity notifications across sessions
|
||||||
|
- Multi-device support with state sync
|
||||||
|
- Mobile-friendly, responsive, PWA-capable
|
||||||
|
- Works over Tailscale private network
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- **Backend:** Python FastAPI coordinator (`coordinator/`)
|
||||||
|
- **Frontend:** Vanilla JS + xterm.js (`frontend/`)
|
||||||
|
- **Terminal:** ttyd (WebSocket bridge to tmux)
|
||||||
|
- **Proxy:** Caddy
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Start coordinator (from this directory)
|
||||||
|
python -m uvicorn coordinator.main:app --host 0.0.0.0 --port 8099
|
||||||
|
|
||||||
|
# Start Caddy proxy
|
||||||
|
caddy run --config Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brand assets
|
||||||
|
|
||||||
|
Design language, tokens, and brand assets in `assets/branding/`.
|
||||||
|
To regenerate PNG/favicon assets from SVG sources:
|
||||||
|
```bash
|
||||||
|
python3 scripts/render-brand-assets.py
|
||||||
|
```
|
||||||
@@ -0,0 +1,857 @@
|
|||||||
|
# muxplex Design System
|
||||||
|
|
||||||
|
**Version:** 1.0.0
|
||||||
|
**Date:** 2026-03-27
|
||||||
|
**Applies to:** muxplex web-based tmux session dashboard
|
||||||
|
|
||||||
|
This document defines every visual property used in muxplex. It is the single
|
||||||
|
source of truth. All values reference `tokens.css` (CSS custom properties) and
|
||||||
|
`tokens.json` (structured data for tooling).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Use This File
|
||||||
|
|
||||||
|
1. Add `<link rel="stylesheet" href="tokens.css">` before your app styles.
|
||||||
|
2. Reference any token as `var(--color-bg-base)`, `var(--space-4)`, etc.
|
||||||
|
3. This document explains *when* and *why* to use each token.
|
||||||
|
4. The JSON file mirrors every value for scripts, linters, or framework config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Color System](#1-color-system)
|
||||||
|
2. [Typography](#2-typography)
|
||||||
|
3. [Spacing](#3-spacing)
|
||||||
|
4. [Border Radius](#4-border-radius)
|
||||||
|
5. [Shadows & Elevation](#5-shadows--elevation)
|
||||||
|
6. [Motion & Transitions](#6-motion--transitions)
|
||||||
|
7. [Z-Index Layers](#7-z-index-layers)
|
||||||
|
8. [Component Patterns](#8-component-patterns)
|
||||||
|
9. [Dark / Light Mode](#9-dark--light-mode)
|
||||||
|
10. [Accessibility](#10-accessibility)
|
||||||
|
11. [Do / Don't Reference](#11-do--dont-reference)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Color System
|
||||||
|
|
||||||
|
### 1.1 Backgrounds
|
||||||
|
|
||||||
|
Dark mode uses **luminance stepping** — lighter surfaces appear more elevated.
|
||||||
|
This is the primary depth cue; shadows are supplementary.
|
||||||
|
|
||||||
|
| Token | Dark | Light | Purpose |
|
||||||
|
|--------------------------|-------------|-------------|-----------------------------------|
|
||||||
|
| `--color-bg-base` | `#0D1117` | `#FFFFFF` | Page background, deepest layer |
|
||||||
|
| `--color-bg-elevated` | `#10131C` | `#F0F0F0` | Cards, panels, tile bodies |
|
||||||
|
| `--color-bg-surface` | `#1A1F2B` | `#E8E9EE` | Raised surfaces, hover states |
|
||||||
|
| `--color-bg-muted` | `#222433` | `#DADCE0` | Subtle fills, secondary panels |
|
||||||
|
|
||||||
|
**Usage rules:**
|
||||||
|
- `bg-base` is always the page `<body>` background.
|
||||||
|
- `bg-elevated` is the default card/panel surface (session tiles, picker).
|
||||||
|
- `bg-surface` appears on hover or as a secondary tier within a card (tile header).
|
||||||
|
- `bg-muted` is for de-emphasized areas (idle session backgrounds, code blocks).
|
||||||
|
|
||||||
|
### 1.2 Text
|
||||||
|
|
||||||
|
| Token | Dark | Light | Contrast on bg-base | Purpose |
|
||||||
|
|----------------------------|-------------|-------------|---------------------|--------------------------|
|
||||||
|
| `--color-text-primary` | `#F0F6FF` | `#0D1117` | 17.4:1 / 18.9:1 AAA | Headings, body text |
|
||||||
|
| `--color-text-secondary` | `#8E95A3` | `#4A5060` | 6.3:1 / 8.1:1 AA | Labels, timestamps |
|
||||||
|
| `--color-text-disabled` | `#4A5060` | `#8E95A3` | 2.4:1 / 2.6:1 | Disabled controls only |
|
||||||
|
|
||||||
|
**Usage rules:**
|
||||||
|
- `text-primary` for anything the user needs to read — session names, body copy,
|
||||||
|
headings, wordmark text.
|
||||||
|
- `text-secondary` for supporting information — "2s ago" timestamps, hint text,
|
||||||
|
metadata labels, picker keyboard shortcuts.
|
||||||
|
- `text-disabled` **only** for controls the user cannot interact with. Never for
|
||||||
|
content a sighted user needs to read. The low contrast is intentional — it
|
||||||
|
signals "you can't use this."
|
||||||
|
|
||||||
|
### 1.3 Accents
|
||||||
|
|
||||||
|
| Token | Dark | Light | Role |
|
||||||
|
|--------------------------|-------------|-------------|-----------------------------------|
|
||||||
|
| `--color-accent-cyan` | `#00D9F5` | `#007D8C` | Primary accent, links, "plex" |
|
||||||
|
| `--color-accent-amber` | `#F1A640` | `#946000` | Notifications, bell indicator |
|
||||||
|
|
||||||
|
**Critical note — light mode:** The brand cyan (`#00D9F5`) fails contrast on
|
||||||
|
white (1.7:1). In light mode, the token automatically maps to `#007D8C` (4.9:1
|
||||||
|
AA) for text. If you need the bright cyan for a decorative non-text element in
|
||||||
|
light mode, use the literal hex `#00D9F5` directly — but never for text.
|
||||||
|
|
||||||
|
The same applies to amber: `#946000` replaces `#F1A640` in light mode for text
|
||||||
|
use (5.3:1 AA).
|
||||||
|
|
||||||
|
### 1.4 Borders
|
||||||
|
|
||||||
|
| Token | Dark | Light | Purpose |
|
||||||
|
|---------------------------|-------------|-------------|------------------------------|
|
||||||
|
| `--color-border-subtle` | `#1E2430` | `#E0E2E8` | Dividers within a surface |
|
||||||
|
| `--color-border-default` | `#2A3040` | `#D0D2D8` | Card outlines, input borders |
|
||||||
|
| `--color-border-strong` | `#3A4050` | `#B0B4BC` | Active outlines, emphasis |
|
||||||
|
|
||||||
|
**Usage rules:**
|
||||||
|
- Borders in dark mode are **decorative**, not functional separators. Don't rely
|
||||||
|
on border visibility for meaning — use background contrast instead.
|
||||||
|
- `border-subtle` for hairline dividers inside cards (e.g., between tile header
|
||||||
|
and terminal content).
|
||||||
|
- `border-default` for card outlines and input field borders.
|
||||||
|
- `border-strong` for active/focused input borders or highlighted tiles.
|
||||||
|
|
||||||
|
### 1.5 Semantic State Colors
|
||||||
|
|
||||||
|
| Token | Dark | Light | Contrast (dark bg-base) | Purpose |
|
||||||
|
|--------------------|-------------|-------------|-------------------------|--------------------|
|
||||||
|
| `--color-success` | `#3FB950` | `#1A7F37` | 7.5:1 AAA | Connected, healthy |
|
||||||
|
| `--color-error` | `#F85149` | `#CF222E` | 5.7:1 AA | Disconnected, fail |
|
||||||
|
| `--color-warning` | `#F1A640` | `#946000` | 9.3:1 AAA | Attention needed |
|
||||||
|
| `--color-info` | `#58A6FF` | `#0969DA` | 7.5:1 AAA | Informational |
|
||||||
|
|
||||||
|
Each state has a background tint variant (`--color-success-bg`, etc.) at ~10-12%
|
||||||
|
opacity for banner or badge backgrounds.
|
||||||
|
|
||||||
|
### 1.6 Overlays
|
||||||
|
|
||||||
|
| Token | Dark | Light | Purpose |
|
||||||
|
|----------------------|------------------------------|--------------------------------|----------------------|
|
||||||
|
| `--color-backdrop` | `rgba(0, 0, 0, 0.60)` | `rgba(0, 0, 0, 0.30)` | Behind picker/modal |
|
||||||
|
| `--color-scrim` | `rgba(13, 17, 23, 0.80)` | `rgba(255, 255, 255, 0.80)` | Frosted glass areas |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Typography
|
||||||
|
|
||||||
|
### 2.1 Font Stacks
|
||||||
|
|
||||||
|
```css
|
||||||
|
--font-display: 'Urbanist', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-ui: 'DM Sans', 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Menlo', monospace;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Load from Google Fonts:**
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Urbanist:wght@700&display=swap" rel="stylesheet">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Font Roles
|
||||||
|
|
||||||
|
| Font | Weights | Use Cases |
|
||||||
|
|-----------------|-------------|------------------------------------------------------|
|
||||||
|
| Urbanist | 700 | Wordmark only, H1-level page titles |
|
||||||
|
| DM Sans | 400 / 500 / 600 | All UI text — buttons, labels, body, headings H2+ |
|
||||||
|
| JetBrains Mono | 400 / 500 | Terminal tile content, expanded terminal, code blocks |
|
||||||
|
|
||||||
|
**Urbanist is restricted.** It appears in exactly two places:
|
||||||
|
1. The "muxplex" wordmark in the app bar.
|
||||||
|
2. An H1 page title if the app ever has one (currently: "Terminal Dashboard" in
|
||||||
|
the app bar could use it, or stay DM Sans 600 for a quieter look).
|
||||||
|
|
||||||
|
Do not use Urbanist for buttons, labels, or body text. Its high x-height and
|
||||||
|
geometric structure read as a display face, not a UI face.
|
||||||
|
|
||||||
|
### 2.3 Type Scale
|
||||||
|
|
||||||
|
All sizes use `--font-ui` (DM Sans) unless noted.
|
||||||
|
|
||||||
|
| Token | Size | Weight | Line-height | Use |
|
||||||
|
|----------------|--------|------------------|----------------------|--------------------------------------|
|
||||||
|
| `--text-xs` | 12px | 400 regular | `--leading-base` 1.5 | Fine print, keyboard shortcuts |
|
||||||
|
| `--text-sm` | 13px | 400 / 500 | `--leading-base` 1.5 | Timestamps, secondary labels |
|
||||||
|
| `--text-base` | 14px | 400 / 500 | `--leading-base` 1.5 | **Default UI text** — buttons, body |
|
||||||
|
| `--text-md` | 16px | 500 medium | `--leading-snug` 1.375 | Session names in tiles |
|
||||||
|
| `--text-lg` | 20px | 600 semibold | `--leading-tight` 1.2 | Section headings |
|
||||||
|
| `--text-xl` | 24px | 600 semibold | `--leading-tight` 1.2 | Panel titles |
|
||||||
|
| `--text-2xl` | 30px | 700 Urbanist | `--leading-tight` 1.2 | H1 page titles |
|
||||||
|
| `--text-3xl` | 36px | 700 Urbanist | `--leading-none` 1.0 | Hero wordmark (if needed) |
|
||||||
|
|
||||||
|
### 2.4 Letter Spacing
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|----------------------|------------|---------------------------------------------|
|
||||||
|
| `--tracking-tight` | -0.01em | Headings 20px+ (tighten for optical balance)|
|
||||||
|
| `--tracking-normal` | 0 | Body text, default |
|
||||||
|
| `--tracking-wide` | 0.02em | Small text <13px (open up for legibility) |
|
||||||
|
| `--tracking-wider` | 0.04em | All-caps labels ("NEEDS ATTENTION") |
|
||||||
|
|
||||||
|
### 2.5 Terminal Typography
|
||||||
|
|
||||||
|
| Context | Font | Size | Weight |
|
||||||
|
|--------------------|---------------|---------------------------|--------|
|
||||||
|
| Tile preview | JetBrains Mono | `--tile-font-size` 10.5px | 400 |
|
||||||
|
| Expanded terminal | JetBrains Mono | `--terminal-font-size` 14px | 400 |
|
||||||
|
| Code in UI | JetBrains Mono | `--text-sm` 13px | 400 |
|
||||||
|
|
||||||
|
- Terminal text uses the standard ANSI 16-color palette provided by xterm.js.
|
||||||
|
Don't override ANSI colors with brand colors.
|
||||||
|
- In overview tiles, 10.5px monospace yields ~50 characters per 360px tile width —
|
||||||
|
enough to scan build output, log lines, and command prompts.
|
||||||
|
- The expanded terminal at 14px matches standard terminal emulator defaults.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Spacing
|
||||||
|
|
||||||
|
### 3.1 Scale
|
||||||
|
|
||||||
|
4px base unit. Every spacing value is a multiple of 4.
|
||||||
|
|
||||||
|
| Token | Value | Pixels | Common Use |
|
||||||
|
|-------------|-------|--------|---------------------------------------------|
|
||||||
|
| `--space-1` | 0.25rem | 4px | Inner padding of small elements, icon gaps |
|
||||||
|
| `--space-2` | 0.5rem | 8px | Grid gap, compact padding, inline spacing |
|
||||||
|
| `--space-3` | 0.75rem | 12px | Card inner padding, button padding |
|
||||||
|
| `--space-4` | 1rem | 16px | Page margin, section gaps |
|
||||||
|
| `--space-6` | 1.5rem | 24px | Between groups of content |
|
||||||
|
| `--space-8` | 2rem | 32px | Major section separation |
|
||||||
|
| `--space-12`| 3rem | 48px | App bar height equivalent, hero spacing |
|
||||||
|
| `--space-16`| 4rem | 64px | Maximum breathing room |
|
||||||
|
|
||||||
|
### 3.2 Layout Constants
|
||||||
|
|
||||||
|
These are fixed dimensions drawn from the layout architecture spec.
|
||||||
|
|
||||||
|
| Token | Value | Purpose |
|
||||||
|
|-----------------------------|--------|--------------------------------------|
|
||||||
|
| `--tile-min-width` | 360px | CSS Grid auto-fill minmax floor |
|
||||||
|
| `--tile-height` | 300px | Fixed tile height for scanning |
|
||||||
|
| `--grid-gap` | 8px | Space between tiles |
|
||||||
|
| `--page-margin` | 16px | Inset from viewport edges |
|
||||||
|
| `--app-bar-height` | 48px | Overview mode header |
|
||||||
|
| `--status-bar-height` | 36px | Expanded mode header |
|
||||||
|
| `--tile-header-height` | 28px | Session name row in each tile |
|
||||||
|
| `--picker-width` | 400px | Command palette panel width |
|
||||||
|
| `--picker-row-height` | 36px | Desktop session row |
|
||||||
|
| `--picker-row-height-touch` | 48px | Touch-device session row (44px min) |
|
||||||
|
| `--breakpoint-list` | 640px | Below this: list view, not grid |
|
||||||
|
|
||||||
|
### 3.3 Grid Setup
|
||||||
|
|
||||||
|
```css
|
||||||
|
.session-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(var(--tile-min-width), 1fr));
|
||||||
|
gap: var(--grid-gap);
|
||||||
|
padding: var(--page-margin);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This one rule handles responsive column count from 320px to 2560px+ viewports.
|
||||||
|
No breakpoints needed for column count — `auto-fill` adapts naturally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Border Radius
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|-----------------|----------|-----------------------------------------------|
|
||||||
|
| `--radius-none` | 0 | Terminal content areas (hard edges = terminal) |
|
||||||
|
| `--radius-sm` | 4px | Small buttons, input fields, badges |
|
||||||
|
| `--radius-md` | 6px | Session tiles, cards, panels |
|
||||||
|
| `--radius-lg` | 8px | Modal dialogs, command palette |
|
||||||
|
| `--radius-xl` | 12px | Large cards, image containers |
|
||||||
|
| `--radius-full` | 9999px | Pills, dots, avatar circles |
|
||||||
|
|
||||||
|
**Guiding principle:** Terminal-adjacent elements use sharper radii (0–4px).
|
||||||
|
UI chrome uses softer radii (6–12px). The contrast between sharp terminal
|
||||||
|
content and softened UI chrome reinforces the "tool wrapping a terminal" feel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Shadows & Elevation
|
||||||
|
|
||||||
|
### 5.1 Dark Mode
|
||||||
|
|
||||||
|
Primary depth cue is **luminance stepping** (bg-base < bg-elevated < bg-surface <
|
||||||
|
bg-muted). Shadows are secondary — used mainly on floating overlays.
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|----------------|-----------------------------------------------------------|------------------------------|
|
||||||
|
| `--shadow-none`| `none` | Default (no shadow needed) |
|
||||||
|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.30)` | Subtle lift on hover |
|
||||||
|
| `--shadow-md` | `0 2px 8px rgba(0,0,0,0.35), 0 1px 2px rgba(0,0,0,0.20)`| Cards, dropdown menus |
|
||||||
|
| `--shadow-lg` | `0 4px 16px rgba(0,0,0,0.40), 0 2px 4px rgba(0,0,0,0.25)`| Picker panel, modals |
|
||||||
|
| `--shadow-xl` | `0 8px 32px rgba(0,0,0,0.50), 0 4px 8px rgba(0,0,0,0.30)`| Expanded tile during zoom |
|
||||||
|
|
||||||
|
### 5.2 Glow Effects
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|-----------------|---------------------------------------|----------------------------|
|
||||||
|
| `--glow-cyan` | `0 0 8px rgba(0,217,245,0.35)` | Focus ring halo, active UI |
|
||||||
|
| `--glow-amber` | `0 0 8px rgba(241,166,64,0.40)` | Bell indicator pulse |
|
||||||
|
|
||||||
|
### 5.3 Light Mode
|
||||||
|
|
||||||
|
Shadows become the primary depth cue in light mode. Opacity values are lower
|
||||||
|
(0.04–0.14) since dark shadows on light surfaces are naturally more visible.
|
||||||
|
Token names stay the same — values switch automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Motion & Transitions
|
||||||
|
|
||||||
|
### 6.1 Duration Tokens
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|------------------------|--------|-----------------------------------|
|
||||||
|
| `--duration-instant` | 75ms | Hover color shifts |
|
||||||
|
| `--duration-fast` | 150ms | Button press, toggle, focus ring |
|
||||||
|
| `--duration-moderate` | 250ms | Tile zoom, panel slide, picker |
|
||||||
|
| `--duration-slow` | 400ms | Page-level transitions |
|
||||||
|
|
||||||
|
### 6.2 Easing Functions
|
||||||
|
|
||||||
|
| Token | Curve | Feel |
|
||||||
|
|-----------------|------------------------------------|--------------------------------|
|
||||||
|
| `--ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | Decelerating — tile zoom in |
|
||||||
|
| `--ease-in-out` | `cubic-bezier(0.45, 0, 0.55, 1)` | Symmetric — tile zoom out |
|
||||||
|
| `--ease-spring` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | Overshoot — picker pop-in |
|
||||||
|
| `--ease-linear` | `linear` | Progress bars, continuous |
|
||||||
|
|
||||||
|
### 6.3 Convenience Shorthands
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Color transitions — use on interactive elements */
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
|
||||||
|
/* Opacity transitions — use on fade in/out */
|
||||||
|
transition: var(--transition-opacity);
|
||||||
|
|
||||||
|
/* Transform transitions — use on position/scale changes */
|
||||||
|
transition: var(--transition-transform);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 GPU Acceleration Rule
|
||||||
|
|
||||||
|
Only animate `transform` and `opacity`. These are composited on the GPU and
|
||||||
|
maintain 60fps. Never animate `width`, `height`, `top`, `left`, `margin`, or
|
||||||
|
`padding` — these trigger layout recalculation.
|
||||||
|
|
||||||
|
**Example — tile zoom:**
|
||||||
|
```css
|
||||||
|
.tile--expanding {
|
||||||
|
/* Good: GPU-composited */
|
||||||
|
transform: scale(1.5) translate(100px, 50px);
|
||||||
|
transition: transform var(--duration-moderate) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad: triggers layout */
|
||||||
|
.tile--expanding-bad {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
transition: width 250ms, height 250ms;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Reduced Motion
|
||||||
|
|
||||||
|
All duration tokens collapse to `0ms` when `prefers-reduced-motion: reduce` is
|
||||||
|
active. This means transitions become instantaneous — no motion, but the state
|
||||||
|
change still happens. No additional code is needed if you use the tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Z-Index Layers
|
||||||
|
|
||||||
|
| Token | Value | Layer |
|
||||||
|
|------------------|-------|------------------------------------|
|
||||||
|
| `--z-base` | 0 | Default stacking context |
|
||||||
|
| `--z-tile` | 1 | Session tiles in grid |
|
||||||
|
| `--z-app-bar` | 10 | App bar / status bar |
|
||||||
|
| `--z-expanding` | 20 | Tile during zoom animation |
|
||||||
|
| `--z-backdrop` | 30 | Semi-transparent overlay backdrop |
|
||||||
|
| `--z-picker` | 40 | Command palette panel |
|
||||||
|
| `--z-toast` | 50 | Toast notifications |
|
||||||
|
| `--z-tooltip` | 60 | Tooltips (always on top) |
|
||||||
|
|
||||||
|
**Rule:** Never use raw z-index numbers. Always reference the token. This
|
||||||
|
prevents z-index wars where developers guess higher and higher values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Component Patterns
|
||||||
|
|
||||||
|
### 8.1 Session Tile Card
|
||||||
|
|
||||||
|
The fundamental UI element. A fixed-height card showing a tmux session preview.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ ● session-name 2s ago │ ← Header: 28px
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ $ npm run build │
|
||||||
|
│ > project@1.0.0 build │
|
||||||
|
│ > tsc && vite build │ ← Terminal content
|
||||||
|
│ ... │ JetBrains Mono 10.5px
|
||||||
|
│ ✓ built in 3.42s │ on bg-elevated
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.session-tile {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: var(--radius-md); /* 6px */
|
||||||
|
height: var(--tile-height); /* 300px */
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-tile:hover {
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border-color: var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-tile:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-header {
|
||||||
|
height: var(--tile-header-height); /* 28px */
|
||||||
|
padding: 0 var(--space-2); /* 0 8px */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-name {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-md); /* 16px */
|
||||||
|
font-weight: var(--weight-medium); /* 500 */
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-timestamp {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs); /* 12px */
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-terminal {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-1) var(--space-2); /* 4px 8px */
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--tile-font-size); /* 10.5px */
|
||||||
|
line-height: var(--leading-snug); /* 1.375 */
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: pre;
|
||||||
|
/* Fade overflow at top — show most recent lines */
|
||||||
|
mask-image: linear-gradient(to bottom, transparent 0%, black 24px);
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 24px);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Header / App Bar
|
||||||
|
|
||||||
|
```css
|
||||||
|
.app-bar {
|
||||||
|
height: var(--app-bar-height); /* 48px */
|
||||||
|
padding: 0 var(--page-margin); /* 0 16px */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3); /* 12px */
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
z-index: var(--z-app-bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded mode — thinner */
|
||||||
|
.status-bar {
|
||||||
|
height: var(--status-bar-height); /* 36px */
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
z-index: var(--z-app-bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-family: var(--font-display); /* Urbanist */
|
||||||
|
font-weight: var(--weight-bold); /* 700 */
|
||||||
|
font-size: var(--text-lg); /* 20px */
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wordmark coloring: mux = primary text, plex = cyan accent */
|
||||||
|
.wordmark-mux { color: var(--color-text-primary); }
|
||||||
|
.wordmark-plex { color: var(--color-accent-cyan); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Terminal Container (Expanded Mode)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.terminal-container {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--color-bg-base); /* Deepest black */
|
||||||
|
/* xterm.js mounts into this element */
|
||||||
|
/* Sized via ResizeObserver — no fixed dimensions */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* xterm.js configuration tokens */
|
||||||
|
.xterm-config {
|
||||||
|
--xterm-font-family: var(--font-mono);
|
||||||
|
--xterm-font-size: var(--terminal-font-size); /* 14px */
|
||||||
|
--xterm-bg: var(--color-bg-base);
|
||||||
|
--xterm-fg: var(--color-text-primary);
|
||||||
|
--xterm-cursor: var(--color-accent-cyan);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 Bell Badge / Activity Indicator
|
||||||
|
|
||||||
|
The bell is the only element that should interrupt the user's scanning pattern.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Dot indicator in tile header */
|
||||||
|
.bell-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-accent-amber);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Idle state — no dot, or dim neutral */
|
||||||
|
.bell-dot--idle {
|
||||||
|
background: var(--color-text-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active bell — amber with glow pulse */
|
||||||
|
.bell-dot--active {
|
||||||
|
background: var(--color-accent-amber);
|
||||||
|
box-shadow: var(--glow-amber);
|
||||||
|
animation: bell-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bell-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bell-dot--active {
|
||||||
|
animation: none;
|
||||||
|
/* Static amber dot — still visible, just not moving */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Border glow on the whole tile when bell is active */
|
||||||
|
.session-tile--bell {
|
||||||
|
border-color: var(--color-accent-amber);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-accent-amber),
|
||||||
|
var(--glow-amber);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.5 Command Palette / Session Picker
|
||||||
|
|
||||||
|
```css
|
||||||
|
.picker-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-backdrop);
|
||||||
|
z-index: var(--z-backdrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: var(--picker-width); /* 400px */
|
||||||
|
max-height: 70vh;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
border-radius: var(--radius-lg); /* 8px */
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: var(--z-picker);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-row {
|
||||||
|
height: var(--picker-row-height); /* 36px */
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-row:hover,
|
||||||
|
.picker-row--active {
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-hint {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: bottom sheet */
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.picker-panel {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
transform: none;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-row {
|
||||||
|
height: var(--picker-row-height-touch); /* 48px */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.6 Status Indicator (Connection)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot--connected {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot--disconnected {
|
||||||
|
background: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot--connecting {
|
||||||
|
background: var(--color-warning);
|
||||||
|
animation: blink 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.status-dot--connecting {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Dark / Light Mode
|
||||||
|
|
||||||
|
### 9.1 How It Works
|
||||||
|
|
||||||
|
`tokens.css` defines dark mode as the default (`:root`). Light mode activates in
|
||||||
|
two ways:
|
||||||
|
|
||||||
|
1. **Automatic:** `@media (prefers-color-scheme: light)` overrides all tokens.
|
||||||
|
2. **Manual:** Add `data-theme="light"` to `<html>` for user toggle.
|
||||||
|
|
||||||
|
The `data-theme` attribute always wins. If a user sets `data-theme="dark"`, they
|
||||||
|
stay dark even if their OS is in light mode.
|
||||||
|
|
||||||
|
### 9.2 Implementation
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Read user preference or fall back to OS
|
||||||
|
function getTheme() {
|
||||||
|
const stored = localStorage.getItem('muxplex-theme');
|
||||||
|
if (stored) return stored; // 'light' | 'dark'
|
||||||
|
return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('muxplex-theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle
|
||||||
|
function toggleTheme() {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme') || getTheme();
|
||||||
|
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 What Changes, What Doesn't
|
||||||
|
|
||||||
|
| Property | Changes between modes? | Notes |
|
||||||
|
|----------------|------------------------|-----------------------------------------|
|
||||||
|
| Background | Yes | Full palette swap |
|
||||||
|
| Text | Yes | Full palette swap |
|
||||||
|
| Accents | Yes | Darkened for light mode contrast |
|
||||||
|
| Borders | Yes | Full palette swap |
|
||||||
|
| State colors | Yes | Darkened for light mode contrast |
|
||||||
|
| Shadows | Yes | Lower opacity in light mode |
|
||||||
|
| Typography | No | Same fonts, sizes, weights |
|
||||||
|
| Spacing | No | Same layout constants |
|
||||||
|
| Border radius | No | Same values |
|
||||||
|
| Motion | No | Same durations and easing |
|
||||||
|
| Z-index | No | Same stacking |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Accessibility
|
||||||
|
|
||||||
|
### 10.1 Contrast Compliance
|
||||||
|
|
||||||
|
All text-on-background combinations meet WCAG 2.1 AA (4.5:1 for normal text,
|
||||||
|
3:1 for large text):
|
||||||
|
|
||||||
|
| Pairing | Dark | Light | Level |
|
||||||
|
|-----------------------------------|--------|--------|-------|
|
||||||
|
| text-primary on bg-base | 17.4:1 | 18.9:1 | AAA |
|
||||||
|
| text-primary on bg-elevated | 17.1:1 | 16.6:1 | AAA |
|
||||||
|
| text-primary on bg-surface | 15.2:1 | 15.6:1 | AAA |
|
||||||
|
| text-secondary on bg-base | 6.3:1 | 8.1:1 | AA |
|
||||||
|
| text-secondary on bg-surface | 5.5:1 | 6.6:1 | AA |
|
||||||
|
| accent-cyan on bg-base | 11.0:1 | 4.9:1 | AA+ |
|
||||||
|
| accent-amber on bg-base | 9.3:1 | 5.3:1 | AA |
|
||||||
|
|
||||||
|
**Exception:** `text-disabled` intentionally fails contrast (2.4:1). It is used
|
||||||
|
only on non-interactive disabled elements where the low contrast communicates
|
||||||
|
"unavailable." This follows WCAG 2.1 §1.4.3 exception for disabled components.
|
||||||
|
|
||||||
|
### 10.2 Focus Indicators
|
||||||
|
|
||||||
|
Every interactive element gets a visible focus ring via `:focus-visible`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The cyan focus ring achieves 11.0:1 contrast on dark backgrounds and 4.9:1 on
|
||||||
|
light — well above the 3:1 WCAG requirement for UI components.
|
||||||
|
|
||||||
|
### 10.3 Touch Targets
|
||||||
|
|
||||||
|
- Picker rows on mobile: 48px tall (≥44px WCAG minimum).
|
||||||
|
- Session tiles: full card is the click target — well above 44px.
|
||||||
|
- Settings gear and close buttons: minimum 44×44px touch area (can be visually
|
||||||
|
smaller with padding extending the target).
|
||||||
|
|
||||||
|
### 10.4 Reduced Motion
|
||||||
|
|
||||||
|
All `--duration-*` tokens become `0ms` under `prefers-reduced-motion: reduce`.
|
||||||
|
Animations declared with `@keyframes` should include an explicit
|
||||||
|
`@media (prefers-reduced-motion: reduce)` block that sets `animation: none`.
|
||||||
|
|
||||||
|
### 10.5 Screen Reader Support
|
||||||
|
|
||||||
|
Reference the semantic HTML structure in the layout spec. Key points:
|
||||||
|
- Grid uses `role="grid"` with `role="gridcell"` per tile.
|
||||||
|
- Each tile has `aria-label` combining session name, bell state, and timestamp.
|
||||||
|
- Expanded terminal uses `role="application"` (keyboard goes to xterm.js).
|
||||||
|
- Skip link: "Skip to sessions" at page top.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Do / Don't Reference
|
||||||
|
|
||||||
|
### Color
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Use `var(--color-text-primary)` for all readable text | Use `text-disabled` for text users need to read |
|
||||||
|
| Use semantic tokens (`--color-error`) for status meaning | Use accent-amber for errors (amber = attention, not failure) |
|
||||||
|
| Use `--color-bg-surface` for hover states | Create new hex colors outside the token system |
|
||||||
|
| Let accents auto-adjust between dark/light mode | Hardcode `#00D9F5` in light mode (fails contrast) |
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Use Urbanist 700 only for wordmark and H1 titles | Use Urbanist for buttons, labels, or body text |
|
||||||
|
| Use DM Sans for all UI text (buttons, labels, body) | Mix Inter and DM Sans — pick one (DM Sans primary) |
|
||||||
|
| Use JetBrains Mono only for terminal/code content | Use JetBrains Mono for UI labels or headings |
|
||||||
|
| Use `--tracking-wider` for all-caps section labels | Set letter-spacing to 0 on all-caps text (looks cramped) |
|
||||||
|
|
||||||
|
### Spacing
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Use `--space-*` tokens for all spacing | Use arbitrary values (15px, 7px, 23px) |
|
||||||
|
| Use `--grid-gap` (8px) between tiles | Use larger gaps — this is a dense monitoring tool |
|
||||||
|
| Use `--page-margin` (16px) for viewport inset | Let tiles touch viewport edges |
|
||||||
|
| Use `--space-2` (8px) for compact inline gaps | Use 0 gap between inline elements |
|
||||||
|
|
||||||
|
### Borders & Radius
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Use `--radius-md` (6px) for cards and tiles | Use large radii (16px+) — this is a developer tool |
|
||||||
|
| Use `--radius-none` (0) on terminal content areas | Round terminal corners (terminals have hard edges) |
|
||||||
|
| Use `--border-subtle` inside cards, `--border-default` on card edges | Use borders as primary separators — background contrast is primary |
|
||||||
|
|
||||||
|
### Motion
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Animate only `transform` and `opacity` | Animate `width`, `height`, `top`, `left`, `margin` |
|
||||||
|
| Use `--ease-out` for zoom in, `--ease-in-out` for zoom out | Use `ease` (default) or `linear` for spatial transitions |
|
||||||
|
| Keep tile zoom at 200-300ms total | Use decorative flourishes or slow-motion effects |
|
||||||
|
| Always include `prefers-reduced-motion` fallback | Forget reduced motion (tokens handle it, but check @keyframes) |
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Show bottom of terminal content (most recent) with top fade | Show top of content with scrollbars |
|
||||||
|
| Use amber dot for bell — visible, non-semantic | Use red for bell (implies error) or green (implies success) |
|
||||||
|
| Use command palette (keyboard-triggered overlay) for session switching | Use persistent sidebar (steals terminal space) |
|
||||||
|
| Keep tile height fixed for spatial scanning | Use variable-height tiles (breaks scan and position memory) |
|
||||||
|
|
||||||
|
### Dark / Light Mode
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Use CSS custom properties — values swap automatically | Hardcode hex colors in component styles |
|
||||||
|
| Test both modes when adding new UI elements | Assume dark-mode-only (light mode tokens exist for a reason) |
|
||||||
|
| Use `data-theme` attribute for user override | Use class names for theme switching (attributes are cleaner) |
|
||||||
|
| Use luminance stepping for depth in dark mode | Rely on shadows for depth in dark mode (they're invisible) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Inventory
|
||||||
|
|
||||||
|
| File | Format | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `tokens.css` | CSS custom properties | Link into HTML — single source of truth for styles |
|
||||||
|
| `tokens.json` | Structured JSON | Machine-readable for tooling, scripts, or framework config |
|
||||||
|
| `DESIGN-SYSTEM.md` | This document | Human-readable specification and usage guide |
|
||||||
|
|
||||||
|
All three files live in `muxplex/assets/branding/` alongside the existing brand
|
||||||
|
assets (icons, wordmark, lockup, etc.).
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# muxplex Brand Assets
|
||||||
|
|
||||||
|
## Source files (SVGs)
|
||||||
|
All brand assets are derived from these source SVGs in `svg/`:
|
||||||
|
|
||||||
|
| File | Use |
|
||||||
|
|------|-----|
|
||||||
|
| `svg/icon/muxplex-icon-dark.svg` | App icon — dark background version |
|
||||||
|
| `svg/icon/muxplex-icon-light.svg` | App icon — light background version |
|
||||||
|
| `svg/wordmark/wordmark-on-dark.svg` | Wordmark text — for use on dark surfaces |
|
||||||
|
| `svg/wordmark/wordmark-on-light.svg` | Wordmark text — for use on light surfaces |
|
||||||
|
| `svg/lockup/lockup-on-dark.svg` | Icon + wordmark combined — dark |
|
||||||
|
| `svg/lockup/lockup-on-light.svg` | Icon + wordmark combined — light |
|
||||||
|
|
||||||
|
## Generated assets
|
||||||
|
Run `../scripts/render-brand-assets.py` to regenerate everything from the SVG sources.
|
||||||
|
|
||||||
|
| Directory | Contents |
|
||||||
|
|-----------|----------|
|
||||||
|
| `icons/` | App icons at 16–1024px |
|
||||||
|
| `favicons/` | favicon.ico, favicon-*.png, apple-touch-icon.png |
|
||||||
|
| `pwa/` | PWA manifest icons (192, 512px) |
|
||||||
|
| `og/` | Open Graph images 1200×630 (dark + light) |
|
||||||
|
| `wordmark/` | Wordmark PNGs at 32 and 64px height |
|
||||||
|
| `lockup/` | Lockup PNGs at 32 and 64px height |
|
||||||
|
|
||||||
|
## Wordmark note
|
||||||
|
Font: Urbanist 700 (Google Fonts). SVGs reference Google Fonts CDN.
|
||||||
|
For offline rendering, install locally: `pip install cairosvg` and ensure network access
|
||||||
|
or embed the font in the SVG beforehand.
|
||||||
|
|
||||||
|
## Colour palette
|
||||||
|
| Token | Hex | Use |
|
||||||
|
|-------|-----|-----|
|
||||||
|
| Dark background | `#0D1117` | Primary dark bg |
|
||||||
|
| Icon inner | `#10131C` | Icon card bg (dark) |
|
||||||
|
| White / light text | `#F0F6FF` | "mux" wordmark, icon frame |
|
||||||
|
| Cyan | `#00D9F5` | "plex" wordmark, cyan traffic light |
|
||||||
|
| Amber | `#F1A640` | Amber traffic light, accent |
|
||||||
|
| Light panes | `#E8E9EE` | Icon card bg (light) |
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 397 B |
|
After Width: | Height: | Size: 669 B |
|
After Width: | Height: | Size: 919 B |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 397 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 495 B |
|
After Width: | Height: | Size: 535 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 669 B |
|
After Width: | Height: | Size: 919 B |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_5_84)">
|
||||||
|
<path d="M61.2547 2.7453H2.7453V61.2547H61.2547V2.7453Z" fill="#10131C"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.941 0C63.0781 0 64 0.921837 64 2.05898V61.941C64 63.0781 63.0781 64 61.941 64H2.05898C0.921839 64 0 63.0781 0 61.941V2.05898C0 0.921837 0.921837 0 2.05898 0H61.941ZM2.74531 18.1877V61.2547H61.2547V18.1877H2.74531ZM2.74531 15.7855H61.2547V2.74531H2.74531V15.7855Z" fill="white"/>
|
||||||
|
<path d="M9.2654 12.1823C10.8764 12.1823 12.1823 10.8764 12.1823 9.2654C12.1823 7.65445 10.8764 6.34851 9.2654 6.34851C7.65445 6.34851 6.34851 7.65445 6.34851 9.2654C6.34851 10.8764 7.65445 12.1823 9.2654 12.1823Z" fill="#F1A640"/>
|
||||||
|
<path d="M18.5308 12.1823C20.1418 12.1823 21.4477 10.8764 21.4477 9.2654C21.4477 7.65445 20.1418 6.34851 18.5308 6.34851C16.9199 6.34851 15.614 7.65445 15.614 9.2654C15.614 10.8764 16.9199 12.1823 18.5308 12.1823Z" fill="white"/>
|
||||||
|
<path d="M27.7962 12.1823C29.4072 12.1823 30.7131 10.8764 30.7131 9.2654C30.7131 7.65445 29.4072 6.34851 27.7962 6.34851C26.1853 6.34851 24.8793 7.65445 24.8793 9.2654C24.8793 10.8764 26.1853 12.1823 27.7962 12.1823Z" fill="#00D9F5"/>
|
||||||
|
<path d="M28.8257 24.5362H12.3539C11.7853 24.5362 11.3244 24.9971 11.3244 25.5657V36.8901C11.3244 37.4587 11.7853 37.9196 12.3539 37.9196H28.8257C29.3943 37.9196 29.8552 37.4587 29.8552 36.8901V25.5657C29.8552 24.9971 29.3943 24.5362 28.8257 24.5362Z" fill="#222433"/>
|
||||||
|
<path d="M51.6461 24.5362H35.1743C34.6057 24.5362 34.1448 24.9971 34.1448 25.5657V36.8901C34.1448 37.4587 34.6057 37.9196 35.1743 37.9196H51.6461C52.2147 37.9196 52.6756 37.4587 52.6756 36.8901V25.5657C52.6756 24.9971 52.2147 24.5362 51.6461 24.5362Z" fill="#222433"/>
|
||||||
|
<path d="M28.8257 42.2091H12.3539C11.7853 42.2091 11.3244 42.67 11.3244 43.2386V54.563C11.3244 55.1316 11.7853 55.5925 12.3539 55.5925H28.8257C29.3943 55.5925 29.8552 55.1316 29.8552 54.563V43.2386C29.8552 42.67 29.3943 42.2091 28.8257 42.2091Z" fill="#222433"/>
|
||||||
|
<path d="M51.6461 42.2091H35.1743C34.6057 42.2091 34.1448 42.67 34.1448 43.2386V54.563C34.1448 55.1316 34.6057 55.5925 35.1743 55.5925H51.6461C52.2147 55.5925 52.6756 55.1316 52.6756 54.563V43.2386C52.6756 42.67 52.2147 42.2091 51.6461 42.2091Z" fill="#222433"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_5_84">
|
||||||
|
<rect width="64" height="64" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_5_94)">
|
||||||
|
<path d="M61.2547 2.7453H2.74536V61.2547H61.2547V2.7453Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.941 0C63.0781 0 64 0.921837 64 2.05898V61.941C64 63.0781 63.0781 64 61.941 64H2.05898C0.921839 64 0 63.0781 0 61.941V2.05898C0 0.921837 0.921837 0 2.05898 0H61.941ZM2.74531 18.1877V61.2547H61.2547V18.1877H2.74531ZM2.74531 15.7855H61.2547V2.74531H2.74531V15.7855Z" fill="#0D1117"/>
|
||||||
|
<path d="M9.2654 12.1823C10.8764 12.1823 12.1823 10.8764 12.1823 9.2654C12.1823 7.65445 10.8764 6.34851 9.2654 6.34851C7.65445 6.34851 6.34851 7.65445 6.34851 9.2654C6.34851 10.8764 7.65445 12.1823 9.2654 12.1823Z" fill="#F1A640"/>
|
||||||
|
<path d="M18.5308 12.1823C20.1417 12.1823 21.4477 10.8764 21.4477 9.2654C21.4477 7.65445 20.1417 6.34851 18.5308 6.34851C16.9198 6.34851 15.6139 7.65445 15.6139 9.2654C15.6139 10.8764 16.9198 12.1823 18.5308 12.1823Z" fill="#888899"/>
|
||||||
|
<path d="M27.7963 12.1823C29.4072 12.1823 30.7132 10.8764 30.7132 9.2654C30.7132 7.65445 29.4072 6.34851 27.7963 6.34851C26.1853 6.34851 24.8794 7.65445 24.8794 9.2654C24.8794 10.8764 26.1853 12.1823 27.7963 12.1823Z" fill="#00D9F5"/>
|
||||||
|
<path d="M28.8257 24.5362H12.3538C11.7853 24.5362 11.3243 24.9971 11.3243 25.5657V36.8901C11.3243 37.4587 11.7853 37.9196 12.3538 37.9196H28.8257C29.3943 37.9196 29.8552 37.4587 29.8552 36.8901V25.5657C29.8552 24.9971 29.3943 24.5362 28.8257 24.5362Z" fill="#888899"/>
|
||||||
|
<path d="M51.6461 24.5362H35.1743C34.6057 24.5362 34.1448 24.9971 34.1448 25.5657V36.8901C34.1448 37.4587 34.6057 37.9196 35.1743 37.9196H51.6461C52.2147 37.9196 52.6756 37.4587 52.6756 36.8901V25.5657C52.6756 24.9971 52.2147 24.5362 51.6461 24.5362Z" fill="#888899"/>
|
||||||
|
<path d="M28.8257 42.2091H12.3538C11.7853 42.2091 11.3243 42.67 11.3243 43.2386V54.563C11.3243 55.1316 11.7853 55.5925 12.3538 55.5925H28.8257C29.3943 55.5925 29.8552 55.1316 29.8552 54.563V43.2386C29.8552 42.67 29.3943 42.2091 28.8257 42.2091Z" fill="#888899"/>
|
||||||
|
<path d="M51.6461 42.2091H35.1743C34.6057 42.2091 34.1448 42.67 34.1448 43.2386V54.563C34.1448 55.1316 34.6057 55.5925 35.1743 55.5925H51.6461C52.2147 55.5925 52.6756 55.1316 52.6756 54.563V43.2386C52.6756 42.67 52.2147 42.2091 51.6461 42.2091Z" fill="#888899"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_5_94">
|
||||||
|
<rect width="64" height="64" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg width="250" height="58" viewBox="0 0 250 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_5_59)">
|
||||||
|
<path d="M48.7102 7.97281H7.81012V50.0181H48.7102V7.97281Z" fill="#10131C"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.19 6C49.9848 6 50.6293 6.66244 50.6293 7.4796V50.5113C50.6293 51.3284 49.9848 51.9909 49.19 51.9909H7.33035C6.53545 51.9909 5.89105 51.3284 5.89105 50.5113V7.4796C5.89105 6.66244 6.53545 6 7.33035 6H49.19ZM7.81012 19.0698V50.0181H48.7102V19.0698H7.81012ZM7.81012 17.3436H48.7102V7.9728H7.81012V17.3436Z" fill="white"/>
|
||||||
|
<path d="M12.3679 14.7543C13.494 14.7543 14.4069 13.8158 14.4069 12.6582C14.4069 11.5006 13.494 10.5621 12.3679 10.5621C11.2418 10.5621 10.3289 11.5006 10.3289 12.6582C10.3289 13.8158 11.2418 14.7543 12.3679 14.7543Z" fill="#F1A640"/>
|
||||||
|
<path d="M18.8447 14.7543C19.9708 14.7543 20.8837 13.8158 20.8837 12.6582C20.8837 11.5006 19.9708 10.5621 18.8447 10.5621C17.7186 10.5621 16.8057 11.5006 16.8057 12.6582C16.8057 13.8158 17.7186 14.7543 18.8447 14.7543Z" fill="white"/>
|
||||||
|
<path d="M25.3216 14.7543C26.4477 14.7543 27.3606 13.8158 27.3606 12.6582C27.3606 11.5006 26.4477 10.5621 25.3216 10.5621C24.1955 10.5621 23.2826 11.5006 23.2826 12.6582C23.2826 13.8158 24.1955 14.7543 25.3216 14.7543Z" fill="#00D9F5"/>
|
||||||
|
<path d="M26.0412 23.6319H14.5268C14.1294 23.6319 13.8072 23.9631 13.8072 24.3717V32.5095C13.8072 32.9181 14.1294 33.2493 14.5268 33.2493H26.0412C26.4387 33.2493 26.7609 32.9181 26.7609 32.5095V24.3717C26.7609 23.9631 26.4387 23.6319 26.0412 23.6319Z" fill="#222433"/>
|
||||||
|
<path d="M41.9935 23.6319H30.4791C30.0817 23.6319 29.7595 23.9631 29.7595 24.3717V32.5095C29.7595 32.9181 30.0817 33.2493 30.4791 33.2493H41.9935C42.391 33.2493 42.7132 32.9181 42.7132 32.5095V24.3717C42.7132 23.9631 42.391 23.6319 41.9935 23.6319Z" fill="#222433"/>
|
||||||
|
<path d="M26.0412 36.3318H14.5268C14.1294 36.3318 13.8072 36.663 13.8072 37.0716V45.2094C13.8072 45.618 14.1294 45.9492 14.5268 45.9492H26.0412C26.4387 45.9492 26.7609 45.618 26.7609 45.2094V37.0716C26.7609 36.663 26.4387 36.3318 26.0412 36.3318Z" fill="#222433"/>
|
||||||
|
<path d="M41.9935 36.3318H30.4791C30.0817 36.3318 29.7595 36.663 29.7595 37.0716V45.2094C29.7595 45.618 30.0817 45.9492 30.4791 45.9492H41.9935C42.391 45.9492 42.7132 45.618 42.7132 45.2094V37.0716C42.7132 36.663 42.391 36.3318 41.9935 36.3318Z" fill="#222433"/>
|
||||||
|
<path d="M60.88 41V17H66.736V19.136C67.552 18.272 68.528 17.592 69.664 17.096C70.8 16.584 72.024 16.328 73.336 16.328C75 16.328 76.528 16.72 77.92 17.504C79.328 18.288 80.448 19.336 81.28 20.648C82.128 19.336 83.248 18.288 84.64 17.504C86.032 16.72 87.56 16.328 89.224 16.328C90.984 16.328 92.576 16.76 94 17.624C95.44 18.472 96.584 19.616 97.432 21.056C98.296 22.48 98.728 24.072 98.728 25.832V41H92.872V27.392C92.872 26.464 92.64 25.616 92.176 24.848C91.728 24.064 91.12 23.44 90.352 22.976C89.6 22.496 88.752 22.256 87.808 22.256C86.864 22.256 86.008 22.488 85.24 22.952C84.488 23.4 83.88 24.008 83.416 24.776C82.952 25.544 82.72 26.416 82.72 27.392V41H76.864V27.392C76.864 26.416 76.64 25.544 76.192 24.776C75.744 24.008 75.136 23.4 74.368 22.952C73.6 22.488 72.744 22.256 71.8 22.256C70.872 22.256 70.024 22.496 69.256 22.976C68.488 23.44 67.872 24.064 67.408 24.848C66.96 25.616 66.736 26.464 66.736 27.392V41H60.88ZM102.576 32.12V17H108.432V30.584C108.432 31.528 108.664 32.392 109.128 33.176C109.592 33.944 110.208 34.56 110.976 35.024C111.76 35.472 112.616 35.696 113.544 35.696C114.504 35.696 115.368 35.472 116.136 35.024C116.904 34.56 117.52 33.944 117.984 33.176C118.448 32.392 118.68 31.528 118.68 30.584V17H124.536L124.56 41H118.704L118.68 38.816C117.848 39.68 116.864 40.368 115.728 40.88C114.592 41.376 113.376 41.624 112.08 41.624C110.336 41.624 108.744 41.2 107.304 40.352C105.864 39.488 104.712 38.344 103.848 36.92C103 35.48 102.576 33.88 102.576 32.12ZM128.404 41L137.188 28.952L128.476 16.976H135.7L140.788 23.96L145.9 16.976H153.124L144.412 28.952L153.196 41H145.972L140.788 33.896L135.628 41H128.404Z" fill="#F0F6FF"/>
|
||||||
|
<path d="M162.783 53H156.927V17H162.783V20.048C163.567 18.96 164.527 18.08 165.663 17.408C166.815 16.72 168.159 16.376 169.695 16.376C171.455 16.376 173.095 16.704 174.615 17.36C176.135 18.016 177.471 18.928 178.623 20.096C179.791 21.248 180.695 22.584 181.335 24.104C181.991 25.624 182.319 27.256 182.319 29C182.319 30.744 181.991 32.384 181.335 33.92C180.695 35.456 179.791 36.808 178.623 37.976C177.471 39.128 176.135 40.032 174.615 40.688C173.095 41.344 171.455 41.672 169.695 41.672C168.159 41.672 166.815 41.336 165.663 40.664C164.527 39.976 163.567 39.088 162.783 38V53ZM169.623 22.016C168.407 22.016 167.319 22.336 166.359 22.976C165.399 23.6 164.647 24.44 164.103 25.496C163.559 26.552 163.287 27.72 163.287 29C163.287 30.28 163.559 31.456 164.103 32.528C164.647 33.584 165.399 34.432 166.359 35.072C167.319 35.696 168.407 36.008 169.623 36.008C170.855 36.008 171.983 35.696 173.007 35.072C174.031 34.448 174.847 33.608 175.455 32.552C176.063 31.48 176.367 30.296 176.367 29C176.367 27.72 176.063 26.552 175.455 25.496C174.847 24.44 174.031 23.6 173.007 22.976C171.999 22.336 170.871 22.016 169.623 22.016ZM185.708 41V5H191.564V41H185.708ZM207.518 41.624C205.31 41.624 203.294 41.056 201.47 39.92C199.662 38.784 198.214 37.256 197.126 35.336C196.054 33.416 195.518 31.296 195.518 28.976C195.518 27.232 195.83 25.6 196.454 24.08C197.078 22.544 197.934 21.2 199.022 20.048C200.126 18.88 201.406 17.968 202.862 17.312C204.318 16.656 205.87 16.328 207.518 16.328C209.39 16.328 211.102 16.728 212.654 17.528C214.222 18.312 215.55 19.392 216.638 20.768C217.726 22.144 218.518 23.712 219.014 25.472C219.51 27.232 219.622 29.072 219.35 30.992H201.806C202.03 31.888 202.398 32.696 202.91 33.416C203.422 34.12 204.07 34.688 204.854 35.12C205.638 35.536 206.526 35.752 207.518 35.768C208.542 35.784 209.47 35.544 210.302 35.048C211.15 34.536 211.854 33.848 212.414 32.984L218.39 34.376C217.414 36.504 215.958 38.248 214.022 39.608C212.086 40.952 209.918 41.624 207.518 41.624ZM201.614 26.6H213.422C213.246 25.64 212.87 24.776 212.294 24.008C211.734 23.224 211.038 22.6 210.206 22.136C209.374 21.672 208.478 21.44 207.518 21.44C206.558 21.44 205.67 21.672 204.854 22.136C204.038 22.584 203.342 23.2 202.766 23.984C202.206 24.752 201.822 25.624 201.614 26.6ZM220.326 41L229.11 28.952L220.398 16.976H227.622L232.71 23.96L237.822 16.976H245.046L236.334 28.952L245.118 41H237.894L232.71 33.896L227.55 41H220.326Z" fill="#00D9F5"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_5_59">
|
||||||
|
<rect width="250" height="58" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg width="250" height="58" viewBox="0 0 250 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_5_71)">
|
||||||
|
<rect width="250" height="58" fill="white"/>
|
||||||
|
<path d="M48.7102 7.97281H7.81006V50.0181H48.7102V7.97281Z" fill="#F0F0F0"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.1899 6C49.9848 6 50.6292 6.66244 50.6292 7.4796V50.5113C50.6292 51.3284 49.9848 51.9909 49.1899 51.9909H7.33029C6.53539 51.9909 5.89099 51.3284 5.89099 50.5113V7.4796C5.89099 6.66244 6.53539 6 7.33029 6H49.1899ZM7.81006 19.0698V50.0181H48.7102V19.0698H7.81006ZM7.81006 17.3436H48.7102V7.9728H7.81006V17.3436Z" fill="#0D1117"/>
|
||||||
|
<path d="M12.3679 14.7543C13.494 14.7543 14.4069 13.8158 14.4069 12.6582C14.4069 11.5006 13.494 10.5621 12.3679 10.5621C11.2418 10.5621 10.3289 11.5006 10.3289 12.6582C10.3289 13.8158 11.2418 14.7543 12.3679 14.7543Z" fill="#F1A640"/>
|
||||||
|
<path d="M18.8447 14.7543C19.9708 14.7543 20.8837 13.8158 20.8837 12.6582C20.8837 11.5006 19.9708 10.5621 18.8447 10.5621C17.7186 10.5621 16.8057 11.5006 16.8057 12.6582C16.8057 13.8158 17.7186 14.7543 18.8447 14.7543Z" fill="#888899"/>
|
||||||
|
<path d="M25.3215 14.7543C26.4476 14.7543 27.3605 13.8158 27.3605 12.6582C27.3605 11.5006 26.4476 10.5621 25.3215 10.5621C24.1954 10.5621 23.2825 11.5006 23.2825 12.6582C23.2825 13.8158 24.1954 14.7543 25.3215 14.7543Z" fill="#00D9F5"/>
|
||||||
|
<path d="M26.0412 23.6319H14.5268C14.1293 23.6319 13.8071 23.9631 13.8071 24.3717V32.5095C13.8071 32.9181 14.1293 33.2493 14.5268 33.2493H26.0412C26.4386 33.2493 26.7608 32.9181 26.7608 32.5095V24.3717C26.7608 23.9631 26.4386 23.6319 26.0412 23.6319Z" fill="#888899"/>
|
||||||
|
<path d="M41.9934 23.6319H30.479C30.0816 23.6319 29.7594 23.9631 29.7594 24.3717V32.5095C29.7594 32.9181 30.0816 33.2493 30.479 33.2493H41.9934C42.3909 33.2493 42.7131 32.9181 42.7131 32.5095V24.3717C42.7131 23.9631 42.3909 23.6319 41.9934 23.6319Z" fill="#888899"/>
|
||||||
|
<path d="M26.0412 36.3318H14.5268C14.1293 36.3318 13.8071 36.663 13.8071 37.0716V45.2094C13.8071 45.618 14.1293 45.9492 14.5268 45.9492H26.0412C26.4386 45.9492 26.7608 45.618 26.7608 45.2094V37.0716C26.7608 36.663 26.4386 36.3318 26.0412 36.3318Z" fill="#888899"/>
|
||||||
|
<path d="M41.9934 36.3318H30.479C30.0816 36.3318 29.7594 36.663 29.7594 37.0716V45.2094C29.7594 45.618 30.0816 45.9492 30.479 45.9492H41.9934C42.3909 45.9492 42.7131 45.618 42.7131 45.2094V37.0716C42.7131 36.663 42.3909 36.3318 41.9934 36.3318Z" fill="#888899"/>
|
||||||
|
<path d="M60.88 41V17H66.736V19.136C67.552 18.272 68.528 17.592 69.664 17.096C70.8 16.584 72.024 16.328 73.336 16.328C75 16.328 76.528 16.72 77.92 17.504C79.328 18.288 80.448 19.336 81.28 20.648C82.128 19.336 83.248 18.288 84.64 17.504C86.032 16.72 87.56 16.328 89.224 16.328C90.984 16.328 92.576 16.76 94 17.624C95.44 18.472 96.584 19.616 97.432 21.056C98.296 22.48 98.728 24.072 98.728 25.832V41H92.872V27.392C92.872 26.464 92.64 25.616 92.176 24.848C91.728 24.064 91.12 23.44 90.352 22.976C89.6 22.496 88.752 22.256 87.808 22.256C86.864 22.256 86.008 22.488 85.24 22.952C84.488 23.4 83.88 24.008 83.416 24.776C82.952 25.544 82.72 26.416 82.72 27.392V41H76.864V27.392C76.864 26.416 76.64 25.544 76.192 24.776C75.744 24.008 75.136 23.4 74.368 22.952C73.6 22.488 72.744 22.256 71.8 22.256C70.872 22.256 70.024 22.496 69.256 22.976C68.488 23.44 67.872 24.064 67.408 24.848C66.96 25.616 66.736 26.464 66.736 27.392V41H60.88ZM102.576 32.12V17H108.432V30.584C108.432 31.528 108.664 32.392 109.128 33.176C109.592 33.944 110.208 34.56 110.976 35.024C111.76 35.472 112.616 35.696 113.544 35.696C114.504 35.696 115.368 35.472 116.136 35.024C116.904 34.56 117.52 33.944 117.984 33.176C118.448 32.392 118.68 31.528 118.68 30.584V17H124.536L124.56 41H118.704L118.68 38.816C117.848 39.68 116.864 40.368 115.728 40.88C114.592 41.376 113.376 41.624 112.08 41.624C110.336 41.624 108.744 41.2 107.304 40.352C105.864 39.488 104.712 38.344 103.848 36.92C103 35.48 102.576 33.88 102.576 32.12ZM128.404 41L137.188 28.952L128.476 16.976H135.7L140.788 23.96L145.9 16.976H153.124L144.412 28.952L153.196 41H145.972L140.788 33.896L135.628 41H128.404Z" fill="#0D1117"/>
|
||||||
|
<path d="M162.783 53H156.927V17H162.783V20.048C163.567 18.96 164.527 18.08 165.663 17.408C166.815 16.72 168.159 16.376 169.695 16.376C171.455 16.376 173.095 16.704 174.615 17.36C176.135 18.016 177.471 18.928 178.623 20.096C179.791 21.248 180.695 22.584 181.335 24.104C181.991 25.624 182.319 27.256 182.319 29C182.319 30.744 181.991 32.384 181.335 33.92C180.695 35.456 179.791 36.808 178.623 37.976C177.471 39.128 176.135 40.032 174.615 40.688C173.095 41.344 171.455 41.672 169.695 41.672C168.159 41.672 166.815 41.336 165.663 40.664C164.527 39.976 163.567 39.088 162.783 38V53ZM169.623 22.016C168.407 22.016 167.319 22.336 166.359 22.976C165.399 23.6 164.647 24.44 164.103 25.496C163.559 26.552 163.287 27.72 163.287 29C163.287 30.28 163.559 31.456 164.103 32.528C164.647 33.584 165.399 34.432 166.359 35.072C167.319 35.696 168.407 36.008 169.623 36.008C170.855 36.008 171.983 35.696 173.007 35.072C174.031 34.448 174.847 33.608 175.455 32.552C176.063 31.48 176.367 30.296 176.367 29C176.367 27.72 176.063 26.552 175.455 25.496C174.847 24.44 174.031 23.6 173.007 22.976C171.999 22.336 170.871 22.016 169.623 22.016ZM185.708 41V5H191.564V41H185.708ZM207.518 41.624C205.31 41.624 203.294 41.056 201.47 39.92C199.662 38.784 198.214 37.256 197.126 35.336C196.054 33.416 195.518 31.296 195.518 28.976C195.518 27.232 195.83 25.6 196.454 24.08C197.078 22.544 197.934 21.2 199.022 20.048C200.126 18.88 201.406 17.968 202.862 17.312C204.318 16.656 205.87 16.328 207.518 16.328C209.39 16.328 211.102 16.728 212.654 17.528C214.222 18.312 215.55 19.392 216.638 20.768C217.726 22.144 218.518 23.712 219.014 25.472C219.51 27.232 219.622 29.072 219.35 30.992H201.806C202.03 31.888 202.398 32.696 202.91 33.416C203.422 34.12 204.07 34.688 204.854 35.12C205.638 35.536 206.526 35.752 207.518 35.768C208.542 35.784 209.47 35.544 210.302 35.048C211.15 34.536 211.854 33.848 212.414 32.984L218.39 34.376C217.414 36.504 215.958 38.248 214.022 39.608C212.086 40.952 209.918 41.624 207.518 41.624ZM201.614 26.6H213.422C213.246 25.64 212.87 24.776 212.294 24.008C211.734 23.224 211.038 22.6 210.206 22.136C209.374 21.672 208.478 21.44 207.518 21.44C206.558 21.44 205.67 21.672 204.854 22.136C204.038 22.584 203.342 23.2 202.766 23.984C202.206 24.752 201.822 25.624 201.614 26.6ZM220.326 41L229.11 28.952L220.398 16.976H227.622L232.71 23.96L237.822 16.976H245.046L236.334 28.952L245.118 41H237.894L232.71 33.896L227.55 41H220.326Z" fill="#0090B0"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_5_71">
|
||||||
|
<rect width="250" height="58" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="210" height="58" viewBox="0 0 210 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_5_54)">
|
||||||
|
<path d="M5.12 41.8772V15.8772H11.464V18.1912C12.348 17.2552 13.4053 16.5185 14.636 15.9812C15.8667 15.4265 17.1927 15.1492 18.614 15.1492C20.4167 15.1492 22.072 15.5739 23.58 16.4232C25.1053 17.2725 26.3187 18.4079 27.22 19.8292C28.1387 18.4079 29.352 17.2725 30.86 16.4232C32.368 15.5739 34.0233 15.1492 35.826 15.1492C37.7327 15.1492 39.4573 15.6172 41 16.5532C42.56 17.4719 43.7993 18.7112 44.718 20.2712C45.654 21.8139 46.122 23.5385 46.122 25.4452V41.8772H39.778V27.1352C39.778 26.1299 39.5267 25.2112 39.024 24.3792C38.5387 23.5299 37.88 22.8539 37.048 22.3512C36.2333 21.8312 35.3147 21.5712 34.292 21.5712C33.2693 21.5712 32.342 21.8225 31.51 22.3252C30.6953 22.8105 30.0367 23.4692 29.534 24.3012C29.0313 25.1332 28.78 26.0779 28.78 27.1352V41.8772H22.436V27.1352C22.436 26.0779 22.1933 25.1332 21.708 24.3012C21.2227 23.4692 20.564 22.8105 19.732 22.3252C18.9 21.8225 17.9727 21.5712 16.95 21.5712C15.9447 21.5712 15.026 21.8312 14.194 22.3512C13.362 22.8539 12.6947 23.5299 12.192 24.3792C11.7067 25.2112 11.464 26.1299 11.464 27.1352V41.8772H5.12ZM50.2909 32.2572V15.8772H56.6349V30.5932C56.6349 31.6159 56.8863 32.5519 57.3889 33.4012C57.8916 34.2332 58.5589 34.9005 59.3909 35.4032C60.2403 35.8885 61.1676 36.1312 62.1729 36.1312C63.2129 36.1312 64.1489 35.8885 64.9809 35.4032C65.8129 34.9005 66.4803 34.2332 66.9829 33.4012C67.4856 32.5519 67.7369 31.6159 67.7369 30.5932V15.8772H74.0809L74.1069 41.8772H67.7629L67.7369 39.5112C66.8356 40.4472 65.7696 41.1925 64.5389 41.7472C63.3083 42.2845 61.9909 42.5532 60.5869 42.5532C58.6976 42.5532 56.9729 42.0939 55.4129 41.1752C53.8529 40.2392 52.6049 38.9999 51.6689 37.4572C50.7503 35.8972 50.2909 34.1639 50.2909 32.2572ZM78.2714 41.8772L87.7874 28.8252L78.3494 15.8512H86.1754L91.6874 23.4172L97.2254 15.8512H105.051L95.6134 28.8252L105.129 41.8772H97.3034L91.6874 34.1812L86.0974 41.8772H78.2714Z" fill="#F0F6FF"/>
|
||||||
|
<path d="M115.515 54.8772H109.171V15.8772H115.515V19.1792C116.364 18.0005 117.404 17.0472 118.635 16.3192C119.883 15.5739 121.339 15.2012 123.003 15.2012C124.909 15.2012 126.686 15.5565 128.333 16.2672C129.979 16.9779 131.427 17.9659 132.675 19.2312C133.94 20.4792 134.919 21.9265 135.613 23.5732C136.323 25.2199 136.679 26.9879 136.679 28.8772C136.679 30.7665 136.323 32.5432 135.613 34.2072C134.919 35.8712 133.94 37.3359 132.675 38.6012C131.427 39.8492 129.979 40.8285 128.333 41.5392C126.686 42.2499 124.909 42.6052 123.003 42.6052C121.339 42.6052 119.883 42.2412 118.635 41.5132C117.404 40.7679 116.364 39.8059 115.515 38.6272V54.8772ZM122.925 21.3112C121.607 21.3112 120.429 21.6579 119.389 22.3512C118.349 23.0272 117.534 23.9372 116.945 25.0812C116.355 26.2252 116.061 27.4905 116.061 28.8772C116.061 30.2639 116.355 31.5379 116.945 32.6992C117.534 33.8432 118.349 34.7619 119.389 35.4552C120.429 36.1312 121.607 36.4692 122.925 36.4692C124.259 36.4692 125.481 36.1312 126.591 35.4552C127.7 34.7792 128.584 33.8692 129.243 32.7252C129.901 31.5639 130.231 30.2812 130.231 28.8772C130.231 27.4905 129.901 26.2252 129.243 25.0812C128.584 23.9372 127.7 23.0272 126.591 22.3512C125.499 21.6579 124.277 21.3112 122.925 21.3112ZM140.35 41.8772V2.87719H146.694V41.8772H140.35ZM163.978 42.5532C161.586 42.5532 159.402 41.9379 157.426 40.7072C155.467 39.4765 153.899 37.8212 152.72 35.7412C151.559 33.6612 150.978 31.3645 150.978 28.8512C150.978 26.9619 151.316 25.1939 151.992 23.5472C152.668 21.8832 153.595 20.4272 154.774 19.1792C155.97 17.9139 157.357 16.9259 158.934 16.2152C160.511 15.5045 162.193 15.1492 163.978 15.1492C166.006 15.1492 167.861 15.5825 169.542 16.4492C171.241 17.2985 172.679 18.4685 173.858 19.9592C175.037 21.4499 175.895 23.1485 176.432 25.0552C176.969 26.9619 177.091 28.9552 176.796 31.0352H157.79C158.033 32.0059 158.431 32.8812 158.986 33.6612C159.541 34.4239 160.243 35.0392 161.092 35.5072C161.941 35.9579 162.903 36.1919 163.978 36.2092C165.087 36.2265 166.093 35.9665 166.994 35.4292C167.913 34.8745 168.675 34.1292 169.282 33.1932L175.756 34.7012C174.699 37.0065 173.121 38.8959 171.024 40.3692C168.927 41.8252 166.578 42.5532 163.978 42.5532ZM157.582 26.2772H170.374C170.183 25.2372 169.776 24.3012 169.152 23.4692C168.545 22.6199 167.791 21.9439 166.89 21.4412C165.989 20.9385 165.018 20.6872 163.978 20.6872C162.938 20.6872 161.976 20.9385 161.092 21.4412C160.208 21.9265 159.454 22.5939 158.83 23.4432C158.223 24.2752 157.807 25.2199 157.582 26.2772ZM177.853 41.8772L187.369 28.8252L177.931 15.8512H185.757L191.269 23.4172L196.807 15.8512H204.633L195.195 28.8252L204.711 41.8772H196.885L191.269 34.1812L185.679 41.8772H177.853Z" fill="#0090B0"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_5_54">
|
||||||
|
<rect width="210" height="58" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="210" height="58" viewBox="0 0 210 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_5_50)">
|
||||||
|
<path d="M5.12 41.8772V15.8772H11.464V18.1912C12.348 17.2552 13.4053 16.5185 14.636 15.9812C15.8667 15.4265 17.1927 15.1492 18.614 15.1492C20.4167 15.1492 22.072 15.5739 23.58 16.4232C25.1053 17.2725 26.3187 18.4079 27.22 19.8292C28.1387 18.4079 29.352 17.2725 30.86 16.4232C32.368 15.5739 34.0233 15.1492 35.826 15.1492C37.7327 15.1492 39.4573 15.6172 41 16.5532C42.56 17.4719 43.7993 18.7112 44.718 20.2712C45.654 21.8139 46.122 23.5385 46.122 25.4452V41.8772H39.778V27.1352C39.778 26.1299 39.5267 25.2112 39.024 24.3792C38.5387 23.5299 37.88 22.8539 37.048 22.3512C36.2333 21.8312 35.3147 21.5712 34.292 21.5712C33.2693 21.5712 32.342 21.8225 31.51 22.3252C30.6953 22.8105 30.0367 23.4692 29.534 24.3012C29.0313 25.1332 28.78 26.0779 28.78 27.1352V41.8772H22.436V27.1352C22.436 26.0779 22.1933 25.1332 21.708 24.3012C21.2227 23.4692 20.564 22.8105 19.732 22.3252C18.9 21.8225 17.9727 21.5712 16.95 21.5712C15.9447 21.5712 15.026 21.8312 14.194 22.3512C13.362 22.8539 12.6947 23.5299 12.192 24.3792C11.7067 25.2112 11.464 26.1299 11.464 27.1352V41.8772H5.12ZM50.2909 32.2572V15.8772H56.6349V30.5932C56.6349 31.6159 56.8863 32.5519 57.3889 33.4012C57.8916 34.2332 58.5589 34.9005 59.3909 35.4032C60.2403 35.8885 61.1676 36.1312 62.1729 36.1312C63.2129 36.1312 64.1489 35.8885 64.9809 35.4032C65.8129 34.9005 66.4803 34.2332 66.9829 33.4012C67.4856 32.5519 67.7369 31.6159 67.7369 30.5932V15.8772H74.0809L74.1069 41.8772H67.7629L67.7369 39.5112C66.8356 40.4472 65.7696 41.1925 64.5389 41.7472C63.3083 42.2845 61.9909 42.5532 60.5869 42.5532C58.6976 42.5532 56.9729 42.0939 55.4129 41.1752C53.8529 40.2392 52.6049 38.9999 51.6689 37.4572C50.7503 35.8972 50.2909 34.1639 50.2909 32.2572ZM78.2714 41.8772L87.7874 28.8252L78.3494 15.8512H86.1754L91.6874 23.4172L97.2254 15.8512H105.051L95.6134 28.8252L105.129 41.8772H97.3034L91.6874 34.1812L86.0974 41.8772H78.2714Z" fill="#0D1117"/>
|
||||||
|
<path d="M115.515 54.8772H109.171V15.8772H115.515V19.1792C116.364 18.0005 117.404 17.0472 118.635 16.3192C119.883 15.5739 121.339 15.2012 123.003 15.2012C124.909 15.2012 126.686 15.5565 128.333 16.2672C129.979 16.9779 131.427 17.9659 132.675 19.2312C133.94 20.4792 134.919 21.9265 135.613 23.5732C136.323 25.2199 136.679 26.9879 136.679 28.8772C136.679 30.7665 136.323 32.5432 135.613 34.2072C134.919 35.8712 133.94 37.3359 132.675 38.6012C131.427 39.8492 129.979 40.8285 128.333 41.5392C126.686 42.2499 124.909 42.6052 123.003 42.6052C121.339 42.6052 119.883 42.2412 118.635 41.5132C117.404 40.7679 116.364 39.8059 115.515 38.6272V54.8772ZM122.925 21.3112C121.607 21.3112 120.429 21.6579 119.389 22.3512C118.349 23.0272 117.534 23.9372 116.945 25.0812C116.355 26.2252 116.061 27.4905 116.061 28.8772C116.061 30.2639 116.355 31.5379 116.945 32.6992C117.534 33.8432 118.349 34.7619 119.389 35.4552C120.429 36.1312 121.607 36.4692 122.925 36.4692C124.259 36.4692 125.481 36.1312 126.591 35.4552C127.7 34.7792 128.584 33.8692 129.243 32.7252C129.901 31.5639 130.231 30.2812 130.231 28.8772C130.231 27.4905 129.901 26.2252 129.243 25.0812C128.584 23.9372 127.7 23.0272 126.591 22.3512C125.499 21.6579 124.277 21.3112 122.925 21.3112ZM140.35 41.8772V2.87719H146.694V41.8772H140.35ZM163.978 42.5532C161.586 42.5532 159.402 41.9379 157.426 40.7072C155.467 39.4765 153.899 37.8212 152.72 35.7412C151.559 33.6612 150.978 31.3645 150.978 28.8512C150.978 26.9619 151.316 25.1939 151.992 23.5472C152.668 21.8832 153.595 20.4272 154.774 19.1792C155.97 17.9139 157.357 16.9259 158.934 16.2152C160.511 15.5045 162.193 15.1492 163.978 15.1492C166.006 15.1492 167.861 15.5825 169.542 16.4492C171.241 17.2985 172.679 18.4685 173.858 19.9592C175.037 21.4499 175.895 23.1485 176.432 25.0552C176.969 26.9619 177.091 28.9552 176.796 31.0352H157.79C158.033 32.0059 158.431 32.8812 158.986 33.6612C159.541 34.4239 160.243 35.0392 161.092 35.5072C161.941 35.9579 162.903 36.1919 163.978 36.2092C165.087 36.2265 166.093 35.9665 166.994 35.4292C167.913 34.8745 168.675 34.1292 169.282 33.1932L175.756 34.7012C174.699 37.0065 173.121 38.8959 171.024 40.3692C168.927 41.8252 166.578 42.5532 163.978 42.5532ZM157.582 26.2772H170.374C170.183 25.2372 169.776 24.3012 169.152 23.4692C168.545 22.6199 167.791 21.9439 166.89 21.4412C165.989 20.9385 165.018 20.6872 163.978 20.6872C162.938 20.6872 161.976 20.9385 161.092 21.4412C160.208 21.9265 159.454 22.5939 158.83 23.4432C158.223 24.2752 157.807 25.2199 157.582 26.2772ZM177.853 41.8772L187.369 28.8252L177.931 15.8512H185.757L191.269 23.4172L196.807 15.8512H204.633L195.195 28.8252L204.711 41.8772H196.885L191.269 34.1812L185.679 41.8772H177.853Z" fill="#0090B0"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_5_50">
|
||||||
|
<rect width="210" height="58" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,355 @@
|
|||||||
|
/* ================================================================
|
||||||
|
muxplex — Design Tokens
|
||||||
|
================================================================
|
||||||
|
Single source of truth for all visual properties.
|
||||||
|
Dark mode is the default. Light mode via prefers-color-scheme.
|
||||||
|
Manual override: set `data-theme="light"` on <html>.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
<link rel="stylesheet" href="tokens.css">
|
||||||
|
<!-- then reference vars anywhere: color: var(--color-text-primary); -->
|
||||||
|
|
||||||
|
Last updated: 2026-03-27
|
||||||
|
================================================================ */
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
1. COLOR — Dark mode (default)
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Backgrounds — luminance stepping (lighter = more elevated) */
|
||||||
|
--color-bg-base: #0D1117; /* Page background, deepest layer */
|
||||||
|
--color-bg-elevated: #10131C; /* Cards, panels, icon bg */
|
||||||
|
--color-bg-surface: #1A1F2B; /* Raised surfaces, hover states */
|
||||||
|
--color-bg-muted: #222433; /* Subtle fills, secondary panels */
|
||||||
|
|
||||||
|
/* Text — three-tier hierarchy */
|
||||||
|
--color-text-primary: #F0F6FF; /* Primary text, headings, wordmark 17.4:1 on bg-base */
|
||||||
|
--color-text-secondary: #8E95A3; /* Secondary labels, timestamps 6.3:1 on bg-base */
|
||||||
|
--color-text-disabled: #4A5060; /* Disabled controls, placeholder 2.4:1 — decorative */
|
||||||
|
|
||||||
|
/* Accents */
|
||||||
|
--color-accent-cyan: #00D9F5; /* Primary accent, links, focus rings 11.0:1 on bg-base */
|
||||||
|
--color-accent-amber: #F1A640; /* Notifications, activity indicators 9.3:1 on bg-base */
|
||||||
|
|
||||||
|
/* Borders — decorative, not relied upon for meaning */
|
||||||
|
--color-border-subtle: #1E2430; /* Dividers within a surface */
|
||||||
|
--color-border-default: #2A3040; /* Card outlines, input borders */
|
||||||
|
--color-border-strong: #3A4050; /* Emphasized borders, active outlines */
|
||||||
|
|
||||||
|
/* State / semantic — GitHub Primer–adjacent, tested on bg-base */
|
||||||
|
--color-success: #3FB950; /* 7.5:1 on bg-base */
|
||||||
|
--color-error: #F85149; /* 5.7:1 on bg-base */
|
||||||
|
--color-warning: #F1A640; /* 9.3:1 (= amber) */
|
||||||
|
--color-info: #58A6FF; /* 7.5:1 on bg-base */
|
||||||
|
|
||||||
|
/* State backgrounds — low-opacity tints for banners, badges */
|
||||||
|
--color-success-bg: rgba(63, 185, 80, 0.12);
|
||||||
|
--color-error-bg: rgba(248, 81, 73, 0.12);
|
||||||
|
--color-warning-bg: rgba(241, 166, 64, 0.12);
|
||||||
|
--color-info-bg: rgba(88, 166, 255, 0.12);
|
||||||
|
|
||||||
|
/* Overlay */
|
||||||
|
--color-backdrop: rgba(0, 0, 0, 0.60);
|
||||||
|
--color-scrim: rgba(13, 17, 23, 0.80);
|
||||||
|
|
||||||
|
/* Focus ring */
|
||||||
|
--color-focus-ring: #00D9F5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
2. COLOR — Light mode
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Activated by OS preference or data-theme="light" on <html>.
|
||||||
|
Accent-accessible variants darken cyan/amber to pass 4.5:1 on
|
||||||
|
white. Use these for TEXT set in accent colors; the bright values
|
||||||
|
remain available for decorative/non-text use.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root:not([data-theme="dark"]) {
|
||||||
|
--color-bg-base: #FFFFFF;
|
||||||
|
--color-bg-elevated: #F0F0F0;
|
||||||
|
--color-bg-surface: #E8E9EE;
|
||||||
|
--color-bg-muted: #DADCE0;
|
||||||
|
|
||||||
|
--color-text-primary: #0D1117; /* 18.9:1 on bg-base */
|
||||||
|
--color-text-secondary: #4A5060; /* 8.1:1 on bg-base */
|
||||||
|
--color-text-disabled: #8E95A3; /* 2.6:1 — decorative */
|
||||||
|
|
||||||
|
/* Bright values kept for icons, decorative indicators */
|
||||||
|
--color-accent-cyan: #007D8C; /* 4.9:1 on #FFF — AA text */
|
||||||
|
--color-accent-amber: #946000; /* 5.3:1 on #FFF — AA text */
|
||||||
|
|
||||||
|
--color-border-subtle: #E0E2E8;
|
||||||
|
--color-border-default: #D0D2D8;
|
||||||
|
--color-border-strong: #B0B4BC;
|
||||||
|
|
||||||
|
--color-success: #1A7F37;
|
||||||
|
--color-error: #CF222E;
|
||||||
|
--color-warning: #946000;
|
||||||
|
--color-info: #0969DA;
|
||||||
|
|
||||||
|
--color-success-bg: rgba(26, 127, 55, 0.10);
|
||||||
|
--color-error-bg: rgba(207, 34, 46, 0.10);
|
||||||
|
--color-warning-bg: rgba(148, 96, 0, 0.10);
|
||||||
|
--color-info-bg: rgba(9, 105, 218, 0.10);
|
||||||
|
|
||||||
|
--color-backdrop: rgba(0, 0, 0, 0.30);
|
||||||
|
--color-scrim: rgba(255, 255, 255, 0.80);
|
||||||
|
|
||||||
|
--color-focus-ring: #007D8C;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual override */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--color-bg-base: #FFFFFF;
|
||||||
|
--color-bg-elevated: #F0F0F0;
|
||||||
|
--color-bg-surface: #E8E9EE;
|
||||||
|
--color-bg-muted: #DADCE0;
|
||||||
|
|
||||||
|
--color-text-primary: #0D1117;
|
||||||
|
--color-text-secondary: #4A5060;
|
||||||
|
--color-text-disabled: #8E95A3;
|
||||||
|
|
||||||
|
--color-accent-cyan: #007D8C;
|
||||||
|
--color-accent-amber: #946000;
|
||||||
|
|
||||||
|
--color-border-subtle: #E0E2E8;
|
||||||
|
--color-border-default: #D0D2D8;
|
||||||
|
--color-border-strong: #B0B4BC;
|
||||||
|
|
||||||
|
--color-success: #1A7F37;
|
||||||
|
--color-error: #CF222E;
|
||||||
|
--color-warning: #946000;
|
||||||
|
--color-info: #0969DA;
|
||||||
|
|
||||||
|
--color-success-bg: rgba(26, 127, 55, 0.10);
|
||||||
|
--color-error-bg: rgba(207, 34, 46, 0.10);
|
||||||
|
--color-warning-bg: rgba(148, 96, 0, 0.10);
|
||||||
|
--color-info-bg: rgba(9, 105, 218, 0.10);
|
||||||
|
|
||||||
|
--color-backdrop: rgba(0, 0, 0, 0.30);
|
||||||
|
--color-scrim: rgba(255, 255, 255, 0.80);
|
||||||
|
|
||||||
|
--color-focus-ring: #007D8C;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
3. TYPOGRAPHY
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Font stacks */
|
||||||
|
--font-display: 'Urbanist', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-ui: 'DM Sans', 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Menlo', monospace;
|
||||||
|
|
||||||
|
/* Font weights */
|
||||||
|
--weight-regular: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
--weight-semibold: 600;
|
||||||
|
--weight-bold: 700;
|
||||||
|
|
||||||
|
/* Type scale — DM Sans base (UI text) */
|
||||||
|
--text-xs: 0.75rem; /* 12px */
|
||||||
|
--text-sm: 0.8125rem; /* 13px */
|
||||||
|
--text-base: 0.875rem; /* 14px — default UI text */
|
||||||
|
--text-md: 1rem; /* 16px */
|
||||||
|
--text-lg: 1.25rem; /* 20px */
|
||||||
|
--text-xl: 1.5rem; /* 24px */
|
||||||
|
--text-2xl: 1.875rem; /* 30px — Urbanist headings start here */
|
||||||
|
--text-3xl: 2.25rem; /* 36px */
|
||||||
|
|
||||||
|
/* Line heights */
|
||||||
|
--leading-none: 1;
|
||||||
|
--leading-tight: 1.2;
|
||||||
|
--leading-snug: 1.375;
|
||||||
|
--leading-base: 1.5;
|
||||||
|
--leading-relaxed: 1.625;
|
||||||
|
|
||||||
|
/* Letter spacing */
|
||||||
|
--tracking-tight: -0.01em;
|
||||||
|
--tracking-normal: 0;
|
||||||
|
--tracking-wide: 0.02em;
|
||||||
|
--tracking-wider: 0.04em; /* All-caps labels */
|
||||||
|
|
||||||
|
/* Terminal-specific */
|
||||||
|
--tile-font-size: 10.5px; /* Monospace in overview tiles */
|
||||||
|
--terminal-font-size: 14px; /* Monospace in expanded xterm.js */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
4. SPACING
|
||||||
|
----------------------------------------------------------------
|
||||||
|
4px base unit. Use semantic names for layout constants,
|
||||||
|
numeric names for general-purpose spacing.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Scale */
|
||||||
|
--space-1: 0.25rem; /* 4px */
|
||||||
|
--space-2: 0.5rem; /* 8px */
|
||||||
|
--space-3: 0.75rem; /* 12px */
|
||||||
|
--space-4: 1rem; /* 16px */
|
||||||
|
--space-6: 1.5rem; /* 24px */
|
||||||
|
--space-8: 2rem; /* 32px */
|
||||||
|
--space-12: 3rem; /* 48px */
|
||||||
|
--space-16: 4rem; /* 64px */
|
||||||
|
|
||||||
|
/* Layout constants (from layout spec) */
|
||||||
|
--tile-min-width: 360px;
|
||||||
|
--tile-height: 300px;
|
||||||
|
--grid-gap: 8px;
|
||||||
|
--page-margin: 16px;
|
||||||
|
--app-bar-height: 48px;
|
||||||
|
--status-bar-height: 36px;
|
||||||
|
--tile-header-height: 28px;
|
||||||
|
--picker-width: 400px;
|
||||||
|
--picker-row-height: 36px;
|
||||||
|
--picker-row-height-touch: 48px;
|
||||||
|
--breakpoint-list: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
5. BORDER RADIUS
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius-none: 0;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-xl: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
6. SHADOWS / ELEVATION
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Dark mode: luminance stepping is the primary depth cue.
|
||||||
|
Shadows serve as supplementary reinforcement on overlays.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--shadow-none: none;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.35),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.20);
|
||||||
|
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.40),
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.25);
|
||||||
|
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.50),
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.30);
|
||||||
|
|
||||||
|
/* Glow — for bell indicator, focus ring */
|
||||||
|
--glow-cyan: 0 0 8px rgba(0, 217, 245, 0.35);
|
||||||
|
--glow-amber: 0 0 8px rgba(241, 166, 64, 0.40);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root:not([data-theme="dark"]) {
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.10),
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.14),
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
--glow-cyan: 0 0 8px rgba(0, 125, 140, 0.25);
|
||||||
|
--glow-amber: 0 0 8px rgba(148, 96, 0, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.10),
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.14),
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
--glow-cyan: 0 0 8px rgba(0, 125, 140, 0.25);
|
||||||
|
--glow-amber: 0 0 8px rgba(148, 96, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
7. MOTION / TRANSITIONS
|
||||||
|
----------------------------------------------------------------
|
||||||
|
GPU-accelerated properties only (transform, opacity).
|
||||||
|
Respect prefers-reduced-motion.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Durations */
|
||||||
|
--duration-instant: 75ms; /* Hover color shifts */
|
||||||
|
--duration-fast: 150ms; /* Button press, toggle, focus */
|
||||||
|
--duration-moderate: 250ms; /* Tile zoom, panel slide */
|
||||||
|
--duration-slow: 400ms; /* Page transitions, complex */
|
||||||
|
|
||||||
|
/* Easing */
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* Decelerate — zoom in */
|
||||||
|
--ease-in-out: cubic-bezier(0.45, 0, 0.55, 1); /* Symmetric — zoom out */
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* Overshoot — picker appear */
|
||||||
|
--ease-linear: linear;
|
||||||
|
|
||||||
|
/* Convenience shorthands */
|
||||||
|
--transition-colors: color var(--duration-instant) var(--ease-out),
|
||||||
|
background-color var(--duration-instant) var(--ease-out),
|
||||||
|
border-color var(--duration-instant) var(--ease-out);
|
||||||
|
--transition-opacity: opacity var(--duration-fast) var(--ease-out);
|
||||||
|
--transition-transform: transform var(--duration-moderate) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion — collapse all durations to near-zero */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--duration-instant: 0ms;
|
||||||
|
--duration-fast: 0ms;
|
||||||
|
--duration-moderate: 0ms;
|
||||||
|
--duration-slow: 0ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
8. Z-INDEX LAYERS
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--z-base: 0;
|
||||||
|
--z-tile: 1;
|
||||||
|
--z-app-bar: 10;
|
||||||
|
--z-expanding: 20; /* Tile during zoom transition */
|
||||||
|
--z-backdrop: 30;
|
||||||
|
--z-picker: 40; /* Command palette overlay */
|
||||||
|
--z-toast: 50;
|
||||||
|
--z-tooltip: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
9. FOCUS STYLES
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Global focus-visible ring. 2px offset ensures it doesn't
|
||||||
|
overlap content. Uses accent cyan for maximum visibility.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default outlines — :focus-visible handles it */
|
||||||
|
:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://tr.designtokens.org/format/",
|
||||||
|
"$metadata": {
|
||||||
|
"name": "muxplex",
|
||||||
|
"description": "Design tokens for muxplex — web-based tmux session dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"generated": "2026-03-27"
|
||||||
|
},
|
||||||
|
|
||||||
|
"color": {
|
||||||
|
"dark": {
|
||||||
|
"bg": {
|
||||||
|
"base": { "$value": "#0D1117", "$description": "Page background, deepest layer" },
|
||||||
|
"elevated": { "$value": "#10131C", "$description": "Cards, panels, icon bg" },
|
||||||
|
"surface": { "$value": "#1A1F2B", "$description": "Raised surfaces, hover states" },
|
||||||
|
"muted": { "$value": "#222433", "$description": "Subtle fills, secondary panels" }
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": { "$value": "#F0F6FF", "$description": "Primary text, headings — 17.4:1 on bg-base" },
|
||||||
|
"secondary": { "$value": "#8E95A3", "$description": "Secondary labels, timestamps — 6.3:1 on bg-base" },
|
||||||
|
"disabled": { "$value": "#4A5060", "$description": "Disabled controls — decorative only" }
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"cyan": { "$value": "#00D9F5", "$description": "Primary accent, links, focus — 11.0:1 on bg-base" },
|
||||||
|
"amber": { "$value": "#F1A640", "$description": "Notifications, activity — 9.3:1 on bg-base" }
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"subtle": { "$value": "#1E2430", "$description": "Dividers within a surface" },
|
||||||
|
"default": { "$value": "#2A3040", "$description": "Card outlines, input borders" },
|
||||||
|
"strong": { "$value": "#3A4050", "$description": "Emphasized borders, active outlines" }
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"success": { "$value": "#3FB950", "$description": "7.5:1 on bg-base" },
|
||||||
|
"error": { "$value": "#F85149", "$description": "5.7:1 on bg-base" },
|
||||||
|
"warning": { "$value": "#F1A640", "$description": "9.3:1 on bg-base" },
|
||||||
|
"info": { "$value": "#58A6FF", "$description": "7.5:1 on bg-base" },
|
||||||
|
"success-bg": { "$value": "rgba(63, 185, 80, 0.12)" },
|
||||||
|
"error-bg": { "$value": "rgba(248, 81, 73, 0.12)" },
|
||||||
|
"warning-bg": { "$value": "rgba(241, 166, 64, 0.12)" },
|
||||||
|
"info-bg": { "$value": "rgba(88, 166, 255, 0.12)" }
|
||||||
|
},
|
||||||
|
"overlay": {
|
||||||
|
"backdrop": { "$value": "rgba(0, 0, 0, 0.60)" },
|
||||||
|
"scrim": { "$value": "rgba(13, 17, 23, 0.80)" }
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": { "$value": "#00D9F5" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"light": {
|
||||||
|
"bg": {
|
||||||
|
"base": { "$value": "#FFFFFF" },
|
||||||
|
"elevated": { "$value": "#F0F0F0" },
|
||||||
|
"surface": { "$value": "#E8E9EE" },
|
||||||
|
"muted": { "$value": "#DADCE0" }
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": { "$value": "#0D1117", "$description": "18.9:1 on bg-base" },
|
||||||
|
"secondary": { "$value": "#4A5060", "$description": "8.1:1 on bg-base" },
|
||||||
|
"disabled": { "$value": "#8E95A3", "$description": "Decorative only" }
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"cyan": { "$value": "#007D8C", "$description": "Darkened for 4.9:1 AA on white" },
|
||||||
|
"amber": { "$value": "#946000", "$description": "Darkened for 5.3:1 AA on white" }
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"subtle": { "$value": "#E0E2E8" },
|
||||||
|
"default": { "$value": "#D0D2D8" },
|
||||||
|
"strong": { "$value": "#B0B4BC" }
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"success": { "$value": "#1A7F37" },
|
||||||
|
"error": { "$value": "#CF222E" },
|
||||||
|
"warning": { "$value": "#946000" },
|
||||||
|
"info": { "$value": "#0969DA" },
|
||||||
|
"success-bg": { "$value": "rgba(26, 127, 55, 0.10)" },
|
||||||
|
"error-bg": { "$value": "rgba(207, 34, 46, 0.10)" },
|
||||||
|
"warning-bg": { "$value": "rgba(148, 96, 0, 0.10)" },
|
||||||
|
"info-bg": { "$value": "rgba(9, 105, 218, 0.10)" }
|
||||||
|
},
|
||||||
|
"overlay": {
|
||||||
|
"backdrop": { "$value": "rgba(0, 0, 0, 0.30)" },
|
||||||
|
"scrim": { "$value": "rgba(255, 255, 255, 0.80)" }
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": { "$value": "#007D8C" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"typography": {
|
||||||
|
"fontFamily": {
|
||||||
|
"display": { "$value": "'Urbanist', system-ui, -apple-system, sans-serif", "$description": "Wordmark, H1 headings" },
|
||||||
|
"ui": { "$value": "'DM Sans', 'Inter', system-ui, -apple-system, sans-serif", "$description": "Body, labels, buttons" },
|
||||||
|
"mono": { "$value": "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Menlo', monospace", "$description": "Terminal content, code" }
|
||||||
|
},
|
||||||
|
"fontWeight": {
|
||||||
|
"regular": { "$value": 400 },
|
||||||
|
"medium": { "$value": 500 },
|
||||||
|
"semibold": { "$value": 600 },
|
||||||
|
"bold": { "$value": 700 }
|
||||||
|
},
|
||||||
|
"fontSize": {
|
||||||
|
"xs": { "$value": "0.75rem", "$description": "12px" },
|
||||||
|
"sm": { "$value": "0.8125rem", "$description": "13px" },
|
||||||
|
"base": { "$value": "0.875rem", "$description": "14px — default UI" },
|
||||||
|
"md": { "$value": "1rem", "$description": "16px" },
|
||||||
|
"lg": { "$value": "1.25rem", "$description": "20px" },
|
||||||
|
"xl": { "$value": "1.5rem", "$description": "24px" },
|
||||||
|
"2xl": { "$value": "1.875rem", "$description": "30px" },
|
||||||
|
"3xl": { "$value": "2.25rem", "$description": "36px" }
|
||||||
|
},
|
||||||
|
"lineHeight": {
|
||||||
|
"none": { "$value": 1 },
|
||||||
|
"tight": { "$value": 1.2 },
|
||||||
|
"snug": { "$value": 1.375 },
|
||||||
|
"base": { "$value": 1.5 },
|
||||||
|
"relaxed": { "$value": 1.625 }
|
||||||
|
},
|
||||||
|
"letterSpacing": {
|
||||||
|
"tight": { "$value": "-0.01em" },
|
||||||
|
"normal": { "$value": "0" },
|
||||||
|
"wide": { "$value": "0.02em" },
|
||||||
|
"wider": { "$value": "0.04em", "$description": "All-caps labels" }
|
||||||
|
},
|
||||||
|
"terminal": {
|
||||||
|
"tileFontSize": { "$value": "10.5px", "$description": "Monospace in overview tiles" },
|
||||||
|
"terminalFontSize": { "$value": "14px", "$description": "Monospace in expanded xterm.js" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"spacing": {
|
||||||
|
"scale": {
|
||||||
|
"1": { "$value": "0.25rem", "$description": "4px" },
|
||||||
|
"2": { "$value": "0.5rem", "$description": "8px" },
|
||||||
|
"3": { "$value": "0.75rem", "$description": "12px" },
|
||||||
|
"4": { "$value": "1rem", "$description": "16px" },
|
||||||
|
"6": { "$value": "1.5rem", "$description": "24px" },
|
||||||
|
"8": { "$value": "2rem", "$description": "32px" },
|
||||||
|
"12": { "$value": "3rem", "$description": "48px" },
|
||||||
|
"16": { "$value": "4rem", "$description": "64px" }
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"tileMinWidth": { "$value": "360px" },
|
||||||
|
"tileHeight": { "$value": "300px" },
|
||||||
|
"gridGap": { "$value": "8px" },
|
||||||
|
"pageMargin": { "$value": "16px" },
|
||||||
|
"appBarHeight": { "$value": "48px" },
|
||||||
|
"statusBarHeight": { "$value": "36px" },
|
||||||
|
"tileHeaderHeight": { "$value": "28px" },
|
||||||
|
"pickerWidth": { "$value": "400px" },
|
||||||
|
"pickerRowHeight": { "$value": "36px" },
|
||||||
|
"pickerRowHeightTouch": { "$value": "48px" },
|
||||||
|
"breakpointList": { "$value": "640px" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"borderRadius": {
|
||||||
|
"none": { "$value": "0" },
|
||||||
|
"sm": { "$value": "4px" },
|
||||||
|
"md": { "$value": "6px" },
|
||||||
|
"lg": { "$value": "8px" },
|
||||||
|
"xl": { "$value": "12px" },
|
||||||
|
"full": { "$value": "9999px" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"shadow": {
|
||||||
|
"dark": {
|
||||||
|
"none": { "$value": "none" },
|
||||||
|
"sm": { "$value": "0 1px 2px rgba(0, 0, 0, 0.30)" },
|
||||||
|
"md": { "$value": "0 2px 8px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.20)" },
|
||||||
|
"lg": { "$value": "0 4px 16px rgba(0, 0, 0, 0.40), 0 2px 4px rgba(0, 0, 0, 0.25)" },
|
||||||
|
"xl": { "$value": "0 8px 32px rgba(0, 0, 0, 0.50), 0 4px 8px rgba(0, 0, 0, 0.30)" },
|
||||||
|
"glowCyan": { "$value": "0 0 8px rgba(0, 217, 245, 0.35)" },
|
||||||
|
"glowAmber": { "$value": "0 0 8px rgba(241, 166, 64, 0.40)" }
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"none": { "$value": "none" },
|
||||||
|
"sm": { "$value": "0 1px 2px rgba(0, 0, 0, 0.06)" },
|
||||||
|
"md": { "$value": "0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)" },
|
||||||
|
"lg": { "$value": "0 4px 16px rgba(0, 0, 0, 0.10), 0 2px 4px rgba(0, 0, 0, 0.06)" },
|
||||||
|
"xl": { "$value": "0 8px 32px rgba(0, 0, 0, 0.14), 0 4px 8px rgba(0, 0, 0, 0.08)" },
|
||||||
|
"glowCyan": { "$value": "0 0 8px rgba(0, 125, 140, 0.25)" },
|
||||||
|
"glowAmber": { "$value": "0 0 8px rgba(148, 96, 0, 0.25)" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"motion": {
|
||||||
|
"duration": {
|
||||||
|
"instant": { "$value": "75ms", "$description": "Hover color shifts" },
|
||||||
|
"fast": { "$value": "150ms", "$description": "Button press, toggle, focus" },
|
||||||
|
"moderate": { "$value": "250ms", "$description": "Tile zoom, panel slide" },
|
||||||
|
"slow": { "$value": "400ms", "$description": "Page transitions" }
|
||||||
|
},
|
||||||
|
"easing": {
|
||||||
|
"out": { "$value": "cubic-bezier(0.16, 1, 0.3, 1)", "$description": "Decelerate — zoom in" },
|
||||||
|
"inOut": { "$value": "cubic-bezier(0.45, 0, 0.55, 1)", "$description": "Symmetric — zoom out" },
|
||||||
|
"spring": { "$value": "cubic-bezier(0.34, 1.56, 0.64, 1)", "$description": "Overshoot — picker appear" },
|
||||||
|
"linear": { "$value": "linear" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"zIndex": {
|
||||||
|
"base": { "$value": 0 },
|
||||||
|
"tile": { "$value": 1 },
|
||||||
|
"appBar": { "$value": 10 },
|
||||||
|
"expanding": { "$value": 20, "$description": "Tile during zoom transition" },
|
||||||
|
"backdrop": { "$value": 30 },
|
||||||
|
"picker": { "$value": 40, "$description": "Command palette overlay" },
|
||||||
|
"toast": { "$value": 50 },
|
||||||
|
"tooltip": { "$value": 60 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,177 @@
|
|||||||
|
"""
|
||||||
|
Bell flag polling and unseen_count tracking for the tmux-web coordinator.
|
||||||
|
|
||||||
|
Based on spike findings: reading the tmux window_bell_flag does NOT clear it.
|
||||||
|
The flag persists until the window is made active inside tmux.
|
||||||
|
|
||||||
|
In-memory state:
|
||||||
|
_bell_seen — tracks whether the bell flag was '1' on the last poll,
|
||||||
|
keyed by session_name. Used to detect 0→1 transitions.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
poll_bell_flag(session_name) → bool
|
||||||
|
process_bell_flags(session_names, state) → bool
|
||||||
|
should_clear_bell(session_name, state) → bool
|
||||||
|
apply_bell_clear_rule(state) → list[str]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from coordinator.sessions import run_tmux
|
||||||
|
from coordinator.state import empty_bell
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-memory tracking: session_name → bool (was flag set on last poll?)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_bell_seen: dict[str, bool] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# poll_bell_flag
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_bell_flag(session_name: str) -> bool:
|
||||||
|
"""Poll the tmux window_bell_flag for session_name.
|
||||||
|
|
||||||
|
Calls: tmux display-message -t <name> -p #{window_bell_flag}
|
||||||
|
|
||||||
|
Returns True if the output is '1', False otherwise (including on errors).
|
||||||
|
Note: reading does NOT clear the tmux bell flag.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
output = await run_tmux(
|
||||||
|
"display-message", "-t", session_name, "-p", "#{window_bell_flag}"
|
||||||
|
)
|
||||||
|
return output.strip() == "1"
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# process_bell_flags
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def process_bell_flags(session_names: list[str], state: dict) -> bool:
|
||||||
|
"""Poll bell flags for all sessions and update state accordingly.
|
||||||
|
|
||||||
|
NOTE: The tmux alert-bell hook (POST /api/sessions/{name}/bell) is the
|
||||||
|
primary bell detection mechanism. window_bell_flag is only set when NO
|
||||||
|
tmux client is watching the window — with an SSH/WezTerm session attached,
|
||||||
|
the flag is never set even though the bell fires. This function serves as
|
||||||
|
a fallback for sessions that fired before the coordinator registered the hook.
|
||||||
|
|
||||||
|
Detects 0→1 transitions using _bell_seen and increments unseen_count.
|
||||||
|
Persistent '1' flags (1→1) are not double-counted.
|
||||||
|
When flag clears (1→0), _bell_seen is reset so the next '1' counts as
|
||||||
|
a new, separate bell event.
|
||||||
|
|
||||||
|
Ensures the bell sub-dict exists for each session in state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_names: List of session names to poll.
|
||||||
|
state: Mutable state dict (modified in-place).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if any bell state changed (new bell detected), False otherwise.
|
||||||
|
"""
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
for name in session_names:
|
||||||
|
# Ensure session entry and bell sub-dict exist
|
||||||
|
if name not in state["sessions"]:
|
||||||
|
state["sessions"][name] = {}
|
||||||
|
if "bell" not in state["sessions"][name]:
|
||||||
|
state["sessions"][name]["bell"] = empty_bell()
|
||||||
|
|
||||||
|
bell = state["sessions"][name]["bell"]
|
||||||
|
flag_set = await poll_bell_flag(name)
|
||||||
|
previously_seen = _bell_seen.get(name, False)
|
||||||
|
|
||||||
|
if flag_set and not previously_seen:
|
||||||
|
# 0→1 transition: new bell event
|
||||||
|
bell["unseen_count"] += 1
|
||||||
|
bell["last_fired_at"] = time.time()
|
||||||
|
_bell_seen[name] = True
|
||||||
|
changed = True
|
||||||
|
elif not flag_set and previously_seen:
|
||||||
|
# 1→0: flag cleared — reset tracking so next '1' is a new bell
|
||||||
|
# Do NOT decrement unseen_count
|
||||||
|
_bell_seen[name] = False
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bell clear rule constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_INTERACTION_WINDOW_SECONDS: float = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# should_clear_bell
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def should_clear_bell(session_name: str, state: dict) -> bool:
|
||||||
|
"""Return True if any connected device qualifies to globally acknowledge bells.
|
||||||
|
|
||||||
|
A session's bells should be cleared when ANY device satisfies ALL of:
|
||||||
|
- viewing_session == session_name
|
||||||
|
- view_mode == 'fullscreen'
|
||||||
|
- last_interaction_at > now - _INTERACTION_WINDOW_SECONDS
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_name: Name of the tmux session to check.
|
||||||
|
state: Current application state dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at least one device meets all conditions, False otherwise.
|
||||||
|
"""
|
||||||
|
cutoff = time.time() - _INTERACTION_WINDOW_SECONDS
|
||||||
|
for device in state["devices"].values():
|
||||||
|
if (
|
||||||
|
device["viewing_session"] == session_name
|
||||||
|
and device["view_mode"] == "fullscreen"
|
||||||
|
and device["last_interaction_at"] > cutoff
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# apply_bell_clear_rule
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def apply_bell_clear_rule(state: dict) -> list[str]:
|
||||||
|
"""Check every session with unseen_count > 0 against the active-device gate.
|
||||||
|
|
||||||
|
For each qualifying session (unseen_count > 0 AND should_clear_bell):
|
||||||
|
- Resets unseen_count to 0
|
||||||
|
- Sets seen_at to now
|
||||||
|
- Resets _bell_seen[name] = False
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Mutable application state dict (modified in-place).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of session names whose bells were cleared.
|
||||||
|
"""
|
||||||
|
cleared: list[str] = []
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
for name, session in state["sessions"].items():
|
||||||
|
bell = session.get("bell")
|
||||||
|
if bell is None or bell.get("unseen_count", 0) == 0:
|
||||||
|
continue
|
||||||
|
if should_clear_bell(name, state):
|
||||||
|
bell["unseen_count"] = 0
|
||||||
|
bell["seen_at"] = now
|
||||||
|
_bell_seen[name] = False
|
||||||
|
cleared.append(name)
|
||||||
|
|
||||||
|
return cleared
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""
|
||||||
|
FastAPI coordinator application for tmux-web.
|
||||||
|
|
||||||
|
Entry point for the coordinator service. Exposes:
|
||||||
|
GET /health → {"status": "ok"}
|
||||||
|
|
||||||
|
Background poll loop reconciles tmux session state every POLL_INTERVAL seconds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import time
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from coordinator.bells import apply_bell_clear_rule, process_bell_flags
|
||||||
|
from coordinator.sessions import (
|
||||||
|
enumerate_sessions,
|
||||||
|
get_session_list,
|
||||||
|
get_snapshots,
|
||||||
|
run_tmux,
|
||||||
|
snapshot_all,
|
||||||
|
update_session_cache,
|
||||||
|
)
|
||||||
|
from coordinator.state import (
|
||||||
|
empty_bell,
|
||||||
|
load_state,
|
||||||
|
prune_devices,
|
||||||
|
read_state,
|
||||||
|
register_device,
|
||||||
|
save_state,
|
||||||
|
state_lock,
|
||||||
|
)
|
||||||
|
from coordinator.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
POLL_INTERVAL: float = float(os.environ.get("POLL_INTERVAL", "2.0"))
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level task reference
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_poll_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Poll cycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_poll_cycle() -> None:
|
||||||
|
"""Perform one full poll cycle, all operations executed under state_lock."""
|
||||||
|
async with state_lock:
|
||||||
|
# 1. Enumerate live tmux sessions
|
||||||
|
names = await enumerate_sessions()
|
||||||
|
name_set = set(names)
|
||||||
|
|
||||||
|
# 2. Capture pane snapshots and update in-memory snapshot cache
|
||||||
|
new_snapshots = await snapshot_all(names)
|
||||||
|
update_session_cache(names, new_snapshots)
|
||||||
|
|
||||||
|
# 3. Load current persisted state
|
||||||
|
state = load_state()
|
||||||
|
|
||||||
|
# 4. Reconcile session_order: preserve user ordering, add new, remove deleted
|
||||||
|
state["session_order"] = [s for s in state["session_order"] if s in name_set]
|
||||||
|
existing_order_set = set(state["session_order"])
|
||||||
|
for name in names:
|
||||||
|
if name not in existing_order_set:
|
||||||
|
state["session_order"].append(name)
|
||||||
|
|
||||||
|
# 5. Ensure bell entries exist for every current session
|
||||||
|
for name in names:
|
||||||
|
if name not in state["sessions"]:
|
||||||
|
state["sessions"][name] = {}
|
||||||
|
if "bell" not in state["sessions"][name]:
|
||||||
|
state["sessions"][name]["bell"] = empty_bell()
|
||||||
|
|
||||||
|
# 6. Remove state entries for sessions that no longer exist
|
||||||
|
deleted = [s for s in list(state["sessions"]) if s not in name_set]
|
||||||
|
for name in deleted:
|
||||||
|
del state["sessions"][name]
|
||||||
|
|
||||||
|
# 7. Clear active_session if the session is gone
|
||||||
|
if state["active_session"] not in name_set:
|
||||||
|
state["active_session"] = None
|
||||||
|
|
||||||
|
# 8. Process bell flags (detect 0→1 transitions, update unseen_count)
|
||||||
|
await process_bell_flags(names, state)
|
||||||
|
|
||||||
|
# 9. Apply bell clear rule (acknowledge bells when device is watching fullscreen)
|
||||||
|
apply_bell_clear_rule(state)
|
||||||
|
|
||||||
|
# 10. Prune devices that haven't sent a heartbeat recently
|
||||||
|
prune_devices(state)
|
||||||
|
|
||||||
|
# 11. Atomically persist the updated state
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Poll loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_loop() -> None:
|
||||||
|
"""Run _run_poll_cycle() every POLL_INTERVAL seconds, catching all exceptions."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await _run_poll_cycle()
|
||||||
|
except Exception:
|
||||||
|
_log.exception("poll cycle error")
|
||||||
|
await asyncio.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifespan
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
global _poll_task
|
||||||
|
|
||||||
|
# Startup: kill any orphaned ttyd from a previous coordinator run, then
|
||||||
|
# start the background poll loop.
|
||||||
|
await kill_orphan_ttyd()
|
||||||
|
_poll_task = asyncio.create_task(_poll_loop())
|
||||||
|
|
||||||
|
# Register tmux alert-bell hook so bells are detected even when clients are attached.
|
||||||
|
# window_bell_flag is only set when no client watches the window; the hook fires always.
|
||||||
|
try:
|
||||||
|
await run_tmux(
|
||||||
|
"set-hook",
|
||||||
|
"-g",
|
||||||
|
"alert-bell",
|
||||||
|
"run-shell 'curl -sfo /dev/null -X POST http://localhost:8099/api/sessions/#{session_name}/bell || true'",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # tmux not running at startup is OK; hook will be set on first poll
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown: cancel the poll loop task and wait for it to finish.
|
||||||
|
if _poll_task is not None:
|
||||||
|
_poll_task.cancel()
|
||||||
|
try:
|
||||||
|
await _poll_task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app = FastAPI(title="tmux-web coordinator", version="0.1.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request / response models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class StatePatch(BaseModel):
|
||||||
|
session_order: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatPayload(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
label: str
|
||||||
|
viewing_session: str | None
|
||||||
|
view_mode: Literal["grid", "fullscreen"]
|
||||||
|
last_interaction_at: float
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
"""Simple liveness check."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/state")
|
||||||
|
async def get_state() -> dict:
|
||||||
|
"""Return the full persistent state."""
|
||||||
|
return await read_state()
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/state")
|
||||||
|
async def patch_state(patch: StatePatch) -> dict:
|
||||||
|
"""Update session_order in the persistent state and return the updated state."""
|
||||||
|
async with state_lock:
|
||||||
|
state = load_state()
|
||||||
|
state["session_order"] = patch.session_order
|
||||||
|
save_state(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sessions")
|
||||||
|
async def get_sessions() -> list[dict]:
|
||||||
|
"""Return list of sessions with name, snapshot, and bell data."""
|
||||||
|
names = get_session_list()
|
||||||
|
snapshots = get_snapshots()
|
||||||
|
state = await read_state()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for name in names:
|
||||||
|
session_state = state.get("sessions", {}).get(name, {})
|
||||||
|
bell = session_state.get("bell", empty_bell())
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"snapshot": snapshots.get(name, ""),
|
||||||
|
"bell": bell,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/sessions/{name}/connect")
|
||||||
|
async def connect_session(name: str) -> dict:
|
||||||
|
"""Connect to a tmux session via ttyd.
|
||||||
|
|
||||||
|
Kills any existing ttyd process, spawns a new one attached to *name*,
|
||||||
|
and updates the active_session in persistent state.
|
||||||
|
|
||||||
|
Returns {active_session: name, ttyd_port: 7682}.
|
||||||
|
Raises HTTP 404 if *name* is not in the known session list (when non-empty).
|
||||||
|
"""
|
||||||
|
known = get_session_list()
|
||||||
|
if known and name not in known:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
|
||||||
|
|
||||||
|
await kill_ttyd()
|
||||||
|
await spawn_ttyd(name)
|
||||||
|
|
||||||
|
async with state_lock:
|
||||||
|
state = load_state()
|
||||||
|
state["active_session"] = name
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
return {"active_session": name, "ttyd_port": TTYD_PORT}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/sessions/current")
|
||||||
|
async def delete_current_session() -> dict:
|
||||||
|
"""Disconnect the current ttyd session.
|
||||||
|
|
||||||
|
Kills the running ttyd process and clears active_session in persistent state.
|
||||||
|
|
||||||
|
Returns {active_session: None}.
|
||||||
|
"""
|
||||||
|
await kill_ttyd()
|
||||||
|
|
||||||
|
async with state_lock:
|
||||||
|
state = load_state()
|
||||||
|
state["active_session"] = None
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
return {"active_session": None}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/heartbeat")
|
||||||
|
async def heartbeat(payload: HeartbeatPayload) -> dict:
|
||||||
|
"""Register or update a device heartbeat.
|
||||||
|
|
||||||
|
Acquires state_lock, loads state, calls register_device() with payload
|
||||||
|
fields, saves state.
|
||||||
|
|
||||||
|
Returns {device_id: str, status: 'ok'}.
|
||||||
|
Missing device_id or invalid view_mode returns 422 (handled by Pydantic).
|
||||||
|
"""
|
||||||
|
async with state_lock:
|
||||||
|
state = load_state()
|
||||||
|
register_device(
|
||||||
|
state,
|
||||||
|
device_id=payload.device_id,
|
||||||
|
label=payload.label,
|
||||||
|
viewing_session=payload.viewing_session,
|
||||||
|
view_mode=payload.view_mode,
|
||||||
|
last_interaction_at=payload.last_interaction_at,
|
||||||
|
)
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
return {"device_id": payload.device_id, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/sessions/{name}/bell")
|
||||||
|
async def receive_bell(name: str) -> dict:
|
||||||
|
"""Called by tmux alert-bell hook when a bell fires in session *name*.
|
||||||
|
|
||||||
|
This is more reliable than polling window_bell_flag because tmux only
|
||||||
|
sets that flag when no client is attached -- with an SSH/WezTerm session
|
||||||
|
attached, the flag never gets set even though the bell fires.
|
||||||
|
"""
|
||||||
|
async with state_lock:
|
||||||
|
state = load_state()
|
||||||
|
if name not in state["sessions"]:
|
||||||
|
state["sessions"][name] = {}
|
||||||
|
if "bell" not in state["sessions"][name]:
|
||||||
|
state["sessions"][name]["bell"] = empty_bell()
|
||||||
|
bell = state["sessions"][name]["bell"]
|
||||||
|
bell["unseen_count"] = bell.get("unseen_count", 0) + 1
|
||||||
|
bell["last_fired_at"] = time.time()
|
||||||
|
save_state(state)
|
||||||
|
return {"ok": True, "session": name}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/internal/setup-hooks")
|
||||||
|
async def setup_hooks() -> dict:
|
||||||
|
"""Re-register tmux hooks. Call after tmux server restarts."""
|
||||||
|
try:
|
||||||
|
await run_tmux(
|
||||||
|
"set-hook",
|
||||||
|
"-g",
|
||||||
|
"alert-bell",
|
||||||
|
"run-shell 'curl -sfo /dev/null -X POST http://localhost:8099/api/sessions/#{session_name}/bell || true'",
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Static file serving — MUST come after all API routes (first-match-wins)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FRONTEND_DIR = pathlib.Path(__file__).parent.parent / "frontend"
|
||||||
|
app.mount("/", StaticFiles(directory=str(_FRONTEND_DIR), html=True), name="frontend")
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
tmux session enumeration and snapshot helpers for the tmux-web coordinator.
|
||||||
|
|
||||||
|
In-memory cache:
|
||||||
|
_session_list — most-recently-enumerated list of session names.
|
||||||
|
_snapshots — most-recently-captured pane text, keyed by session name.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
get_session_list() → list[str]
|
||||||
|
get_snapshots() → dict[str, str]
|
||||||
|
update_session_cache(names, snapshots) → None
|
||||||
|
run_tmux(*args) → str (raises RuntimeError on nonzero exit)
|
||||||
|
enumerate_sessions() → list[str]
|
||||||
|
capture_pane(name, lines) → str
|
||||||
|
snapshot_all(names) → dict[str, str]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-memory cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_session_list: list[str] = []
|
||||||
|
_snapshots: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_list() -> list[str]:
|
||||||
|
"""Return a copy of the cached session name list."""
|
||||||
|
return list(_session_list)
|
||||||
|
|
||||||
|
|
||||||
|
def get_snapshots() -> dict[str, str]:
|
||||||
|
"""Return a copy of the cached pane-snapshot dict."""
|
||||||
|
return dict(_snapshots)
|
||||||
|
|
||||||
|
|
||||||
|
def update_session_cache(names: list[str], snapshots: dict[str, str]) -> None:
|
||||||
|
"""Replace the in-memory caches with fresh data.
|
||||||
|
|
||||||
|
Sets _session_list to *names* and _snapshots to the provided *snapshots* dict.
|
||||||
|
Callers must pass the return value of snapshot_all() as *snapshots*.
|
||||||
|
"""
|
||||||
|
global _session_list, _snapshots
|
||||||
|
_session_list = list(names)
|
||||||
|
_snapshots = snapshots
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Subprocess helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def run_tmux(*args: str) -> str:
|
||||||
|
"""Run `tmux <args>` in a subprocess and return stdout as a string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If the process exits with a nonzero return code.
|
||||||
|
The error message contains the decoded stderr output.
|
||||||
|
"""
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"tmux",
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout_bytes, stderr_bytes = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(stderr_bytes.decode("utf-8", errors="replace"))
|
||||||
|
return stdout_bytes.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session enumeration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def enumerate_sessions() -> list[str]:
|
||||||
|
"""Return the list of currently running tmux session names.
|
||||||
|
|
||||||
|
Calls ``tmux list-sessions -F #{session_name}``, splits on newlines,
|
||||||
|
and strips whitespace from each entry.
|
||||||
|
|
||||||
|
Returns [] if tmux is not running (RuntimeError from run_tmux).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
output = await run_tmux("list-sessions", "-F", "#{session_name}")
|
||||||
|
except RuntimeError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
names = [line.strip() for line in output.splitlines() if line.strip()]
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pane capture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def capture_pane(session_name: str, lines: int = 30) -> str:
|
||||||
|
"""Capture the last *lines* lines of output from *session_name*.
|
||||||
|
|
||||||
|
Returns the captured text, or '' on any error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await run_tmux(
|
||||||
|
"capture-pane",
|
||||||
|
"-p",
|
||||||
|
"-t",
|
||||||
|
session_name,
|
||||||
|
"-S",
|
||||||
|
f"-{lines}",
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def snapshot_all(names: list[str]) -> dict[str, str]:
|
||||||
|
"""Capture all sessions concurrently and return a name→text mapping.
|
||||||
|
|
||||||
|
Uses asyncio.gather with return_exceptions=True so that individual
|
||||||
|
failures do not abort the whole batch. Failed sessions map to ''.
|
||||||
|
|
||||||
|
Note: this function does not mutate module state — it does not update the module cache.
|
||||||
|
Callers are responsible for passing the result to update_session_cache.
|
||||||
|
"""
|
||||||
|
if not names:
|
||||||
|
return {}
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[capture_pane(name) for name in names],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
snapshots: dict[str, str] = {}
|
||||||
|
for name, result in zip(names, results):
|
||||||
|
if isinstance(result, BaseException):
|
||||||
|
snapshots[name] = ""
|
||||||
|
else:
|
||||||
|
snapshots[name] = result
|
||||||
|
return snapshots
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Bell Flag Spike — run ONCE manually to determine tmux bell flag read behavior.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 coordinator/spike_bell_flag.py
|
||||||
|
|
||||||
|
What this tests:
|
||||||
|
Does `tmux display-message -p "#{window_bell_flag}"` clear the flag when
|
||||||
|
read, or does the flag persist until the window is visited in tmux?
|
||||||
|
|
||||||
|
Expected result (almost certain): the flag persists. Reading it does NOT clear
|
||||||
|
it. The flag is cleared only when the window is marked active inside tmux.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
SESSION = "bell-spike-test"
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str]) -> str:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# 1. Create a test session
|
||||||
|
print("Creating test session...")
|
||||||
|
subprocess.run(
|
||||||
|
["tmux", "new-session", "-d", "-s", SESSION, "-x", "80", "-y", "24"], check=True
|
||||||
|
)
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# 2. Send a bell to the session
|
||||||
|
print("Sending bell to session...")
|
||||||
|
subprocess.run(
|
||||||
|
["tmux", "send-keys", "-t", SESSION, "printf '\\a'", "Enter"], check=True
|
||||||
|
)
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
# 3. Read the bell flag (first read)
|
||||||
|
flag_read1 = run(
|
||||||
|
["tmux", "display-message", "-t", SESSION, "-p", "#{window_bell_flag}"]
|
||||||
|
)
|
||||||
|
print(f"Bell flag (1st read): '{flag_read1}'")
|
||||||
|
|
||||||
|
# 4. Read the bell flag immediately again (second read)
|
||||||
|
flag_read2 = run(
|
||||||
|
["tmux", "display-message", "-t", SESSION, "-p", "#{window_bell_flag}"]
|
||||||
|
)
|
||||||
|
print(f"Bell flag (2nd read): '{flag_read2}'")
|
||||||
|
|
||||||
|
# 5. Cleanup
|
||||||
|
subprocess.run(["tmux", "kill-session", "-t", SESSION])
|
||||||
|
|
||||||
|
# 6. Report
|
||||||
|
print()
|
||||||
|
if flag_read1 == "1" and flag_read2 == "1":
|
||||||
|
print("FINDING: Reading does NOT clear the flag. Both reads show '1'.")
|
||||||
|
print(
|
||||||
|
"Implementation: use in-memory _bell_seen dict to detect 0→1 transitions."
|
||||||
|
)
|
||||||
|
elif flag_read1 == "1" and flag_read2 == "0":
|
||||||
|
print(
|
||||||
|
"FINDING: Reading CLEARS the flag. First read shows '1', second shows '0'."
|
||||||
|
)
|
||||||
|
print("Implementation: each '1' is a new bell — no transition tracking needed.")
|
||||||
|
elif flag_read1 == "0":
|
||||||
|
print("WARNING: Bell flag not set after printf '\\a'. Try running manually:")
|
||||||
|
print(f" tmux send-keys -t {SESSION} \"printf '\\\\a'\" Enter")
|
||||||
|
print(
|
||||||
|
" Then check: tmux display-message -t bell-spike-test -p '#{window_bell_flag}'"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"UNEXPECTED: read1={flag_read1!r}, read2={flag_read2!r}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
State schema and factory functions for the tmux-web coordinator.
|
||||||
|
|
||||||
|
State schema (all values are plain JSON-serialisable dicts):
|
||||||
|
|
||||||
|
{
|
||||||
|
"active_session": str | None,
|
||||||
|
"session_order": list[str],
|
||||||
|
"sessions": {
|
||||||
|
"<name>": {
|
||||||
|
"bell": {
|
||||||
|
"last_fired_at": float | None,
|
||||||
|
"seen_at": float | None,
|
||||||
|
"unseen_count": int,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"<device_id>": {
|
||||||
|
"label": str,
|
||||||
|
"viewing_session": str | None,
|
||||||
|
"view_mode": "fullscreen" | "grid",
|
||||||
|
"last_interaction_at": float,
|
||||||
|
"last_heartbeat_at": float,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_default_state_dir = Path.home() / ".local" / "share" / "tmux-web"
|
||||||
|
STATE_DIR: Path = Path(os.environ.get("TMUX_WEB_STATE_DIR", _default_state_dir))
|
||||||
|
STATE_PATH: Path = STATE_DIR / "state.json"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Global asyncio lock — must be acquired before reading or writing state.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
state_lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Factory functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def empty_state() -> dict:
|
||||||
|
"""Return a fresh, empty top-level state dict.
|
||||||
|
|
||||||
|
Every call returns a fully independent object — no shared mutables.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"active_session": None,
|
||||||
|
"session_order": [],
|
||||||
|
"sessions": {},
|
||||||
|
"devices": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def empty_bell() -> dict:
|
||||||
|
"""Return a fresh bell sub-dict with all fields reset."""
|
||||||
|
return {
|
||||||
|
"last_fired_at": None,
|
||||||
|
"seen_at": None,
|
||||||
|
"unseen_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def empty_device(device_id: str, label: str) -> dict: # noqa: ARG001
|
||||||
|
"""Return a fresh device sub-dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: Identifier for the device (unused in the dict itself,
|
||||||
|
kept as a parameter for call-site clarity).
|
||||||
|
label: Human-readable name for the device.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
return {
|
||||||
|
"label": label,
|
||||||
|
"viewing_session": None,
|
||||||
|
"view_mode": "grid",
|
||||||
|
"last_interaction_at": now,
|
||||||
|
"last_heartbeat_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Device helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def register_device(
|
||||||
|
state: dict,
|
||||||
|
device_id: str,
|
||||||
|
label: str,
|
||||||
|
viewing_session: str | None,
|
||||||
|
view_mode: str,
|
||||||
|
last_interaction_at: float,
|
||||||
|
) -> None:
|
||||||
|
"""Create or update a device entry in state['devices'].
|
||||||
|
|
||||||
|
For new devices, seeds the entry via empty_device().
|
||||||
|
Always refreshes last_heartbeat_at to time.time().
|
||||||
|
Updates label, viewing_session, view_mode, last_interaction_at.
|
||||||
|
"""
|
||||||
|
if device_id not in state["devices"]:
|
||||||
|
state["devices"][device_id] = empty_device(device_id, label)
|
||||||
|
|
||||||
|
device = state["devices"][device_id]
|
||||||
|
device["label"] = label
|
||||||
|
device["viewing_session"] = viewing_session
|
||||||
|
device["view_mode"] = view_mode
|
||||||
|
device["last_interaction_at"] = last_interaction_at
|
||||||
|
device["last_heartbeat_at"] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def prune_devices(state: dict, ttl_seconds: float = 300.0) -> list[str]:
|
||||||
|
"""Remove devices whose last_heartbeat_at is older than ttl_seconds.
|
||||||
|
|
||||||
|
Returns the list of removed device IDs.
|
||||||
|
"""
|
||||||
|
cutoff = time.time() - ttl_seconds
|
||||||
|
stale = [
|
||||||
|
device_id
|
||||||
|
for device_id, device in state["devices"].items()
|
||||||
|
if device["last_heartbeat_at"] < cutoff
|
||||||
|
]
|
||||||
|
for device_id in stale:
|
||||||
|
del state["devices"][device_id]
|
||||||
|
return stale
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sync I/O helpers (no lock — callers must hold state_lock when appropriate)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def load_state() -> dict:
|
||||||
|
"""Read and return state from STATE_PATH.
|
||||||
|
|
||||||
|
Returns empty_state() if the file does not exist or contains invalid JSON.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(STATE_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return empty_state()
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: dict) -> None:
|
||||||
|
"""Atomically write *state* to STATE_PATH.
|
||||||
|
|
||||||
|
Uses the write-to-tmp-then-os.replace pattern so readers never see a
|
||||||
|
partial file. Creates STATE_DIR (and parents) if it does not exist.
|
||||||
|
"""
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = Path(str(STATE_PATH) + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(state, indent=2))
|
||||||
|
os.replace(tmp, STATE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Async wrappers — acquire state_lock before touching the file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def read_state() -> dict:
|
||||||
|
"""Async read: acquires state_lock, then delegates to load_state()."""
|
||||||
|
async with state_lock:
|
||||||
|
return load_state()
|
||||||
|
|
||||||
|
|
||||||
|
async def write_state(state: dict) -> None:
|
||||||
|
"""Async write: acquires state_lock, then delegates to save_state()."""
|
||||||
|
async with state_lock:
|
||||||
|
save_state(state)
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
"""
|
||||||
|
Tests for coordinator/main.py — FastAPI skeleton, lifespan, /health endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from coordinator.main import app
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# autouse fixture — redirect state/PID files, mock startup side-effects
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def patch_startup_and_state(tmp_path, monkeypatch):
|
||||||
|
"""Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op."""
|
||||||
|
# Redirect state files
|
||||||
|
tmp_state_dir = tmp_path / "state"
|
||||||
|
tmp_state_path = tmp_state_dir / "state.json"
|
||||||
|
monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir)
|
||||||
|
monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path)
|
||||||
|
|
||||||
|
# Redirect PID files
|
||||||
|
tmp_pid_dir = tmp_path / "ttyd"
|
||||||
|
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
||||||
|
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
||||||
|
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
||||||
|
|
||||||
|
# Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async)
|
||||||
|
async def _mock_kill_orphan():
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main.kill_orphan_ttyd", _mock_kill_orphan)
|
||||||
|
|
||||||
|
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles
|
||||||
|
async def noop_poll_loop() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main._poll_loop", noop_poll_loop)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Client fixture — TestClient with lifespan enabled
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
"""Return a TestClient that triggers the app lifespan on entry/exit."""
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_returns_200(client):
|
||||||
|
"""GET /health must return HTTP 200."""
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_returns_ok_status(client):
|
||||||
|
"""GET /health must return JSON body {status: 'ok'}."""
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_state_returns_full_state(client):
|
||||||
|
"""GET /api/state must return a dict with all 4 top-level keys."""
|
||||||
|
response = client.get("/api/state")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "active_session" in data
|
||||||
|
assert "session_order" in data
|
||||||
|
assert "sessions" in data
|
||||||
|
assert "devices" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_state_active_session_is_none_initially(client):
|
||||||
|
"""GET /api/state active_session must be None on a fresh state."""
|
||||||
|
response = client.get("/api/state")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["active_session"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /api/state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_state_updates_session_order(client):
|
||||||
|
"""PATCH /api/state updates session_order and persists the change."""
|
||||||
|
from coordinator.state import load_state, save_state
|
||||||
|
|
||||||
|
# Write initial state with a known session order
|
||||||
|
initial_state = {
|
||||||
|
"active_session": None,
|
||||||
|
"session_order": ["alpha", "beta"],
|
||||||
|
"sessions": {},
|
||||||
|
"devices": {},
|
||||||
|
}
|
||||||
|
save_state(initial_state)
|
||||||
|
|
||||||
|
# Patch with reversed order
|
||||||
|
response = client.patch("/api/state", json={"session_order": ["beta", "alpha"]})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["session_order"] == ["beta", "alpha"]
|
||||||
|
|
||||||
|
# Verify the update was persisted to disk
|
||||||
|
persisted = load_state()
|
||||||
|
assert persisted["session_order"] == ["beta", "alpha"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_state_rejects_non_list_session_order(client):
|
||||||
|
"""PATCH /api/state rejects non-list session_order with HTTP 422."""
|
||||||
|
response = client.patch("/api/state", json={"session_order": "not-a-list"})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_state_ignores_unknown_fields(client):
|
||||||
|
"""PATCH /api/state ignores unknown fields in the request body."""
|
||||||
|
response = client.patch(
|
||||||
|
"/api/state",
|
||||||
|
json={"session_order": ["a", "b"], "unknown_field": "should_be_ignored"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "unknown_field" not in data
|
||||||
|
assert data["session_order"] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/sessions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sessions_returns_list(client, monkeypatch):
|
||||||
|
"""GET /api/sessions must return a JSON list."""
|
||||||
|
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"coordinator.main.get_snapshots", lambda: {"alpha": "some text"}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/sessions")
|
||||||
|
assert response.status_code == 200
|
||||||
|
items = response.json()
|
||||||
|
assert isinstance(items, list)
|
||||||
|
assert items[0]["name"] == "alpha"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sessions_each_item_has_required_fields(client, monkeypatch):
|
||||||
|
"""Each item in GET /api/sessions must have name, snapshot, and bell fields."""
|
||||||
|
from coordinator.state import save_state
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["beta"])
|
||||||
|
monkeypatch.setattr("coordinator.main.get_snapshots", lambda: {"beta": "output"})
|
||||||
|
save_state(
|
||||||
|
{
|
||||||
|
"active_session": None,
|
||||||
|
"session_order": ["beta"],
|
||||||
|
"sessions": {
|
||||||
|
"beta": {
|
||||||
|
"bell": {"last_fired_at": None, "seen_at": None, "unseen_count": 0}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devices": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/sessions")
|
||||||
|
assert response.status_code == 200
|
||||||
|
items = response.json()
|
||||||
|
assert len(items) == 1
|
||||||
|
item = items[0]
|
||||||
|
assert "name" in item
|
||||||
|
assert "snapshot" in item
|
||||||
|
assert "bell" in item
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sessions_includes_snapshot_text(client, monkeypatch):
|
||||||
|
"""GET /api/sessions snapshot field must contain the cached capture-pane text."""
|
||||||
|
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["gamma"])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"coordinator.main.get_snapshots",
|
||||||
|
lambda: {"gamma": "hello from tmux pane"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/sessions")
|
||||||
|
assert response.status_code == 200
|
||||||
|
items = response.json()
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["name"] == "gamma"
|
||||||
|
assert items[0]["snapshot"] == "hello from tmux pane"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sessions_includes_bell_state(client, monkeypatch):
|
||||||
|
"""GET /api/sessions bell field must include unseen_count from persistent state."""
|
||||||
|
from coordinator.state import save_state
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["delta"])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"coordinator.main.get_snapshots", lambda: {"delta": "pane text"}
|
||||||
|
)
|
||||||
|
save_state(
|
||||||
|
{
|
||||||
|
"active_session": None,
|
||||||
|
"session_order": ["delta"],
|
||||||
|
"sessions": {
|
||||||
|
"delta": {
|
||||||
|
"bell": {
|
||||||
|
"last_fired_at": 1234567890.0,
|
||||||
|
"seen_at": None,
|
||||||
|
"unseen_count": 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devices": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/sessions")
|
||||||
|
assert response.status_code == 200
|
||||||
|
items = response.json()
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["bell"]["unseen_count"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sessions_returns_empty_list_when_no_sessions(client, monkeypatch):
|
||||||
|
"""GET /api/sessions must return an empty list when there are no sessions."""
|
||||||
|
monkeypatch.setattr("coordinator.main.get_session_list", lambda: [])
|
||||||
|
monkeypatch.setattr("coordinator.main.get_snapshots", lambda: {})
|
||||||
|
|
||||||
|
response = client.get("/api/sessions")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/sessions/{name}/connect
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_session_returns_200(client, monkeypatch):
|
||||||
|
"""POST /api/sessions/{name}/connect returns 200 and correct body when session exists."""
|
||||||
|
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"])
|
||||||
|
|
||||||
|
async def mock_kill():
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill)
|
||||||
|
|
||||||
|
async def mock_spawn(name):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn)
|
||||||
|
|
||||||
|
response = client.post("/api/sessions/alpha/connect")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["active_session"] == "alpha"
|
||||||
|
assert data["ttyd_port"] == 7682
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_session_sets_active_session(client, monkeypatch):
|
||||||
|
"""POST /api/sessions/{name}/connect persists active_session to state."""
|
||||||
|
from coordinator.state import load_state
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"])
|
||||||
|
|
||||||
|
async def mock_kill():
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill)
|
||||||
|
|
||||||
|
async def mock_spawn(name):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn)
|
||||||
|
|
||||||
|
client.post("/api/sessions/alpha/connect")
|
||||||
|
|
||||||
|
state = load_state()
|
||||||
|
assert state["active_session"] == "alpha"
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_session_kills_existing_ttyd(client, monkeypatch):
|
||||||
|
"""POST /api/sessions/{name}/connect calls kill_ttyd then spawn_ttyd."""
|
||||||
|
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"])
|
||||||
|
|
||||||
|
call_order = []
|
||||||
|
|
||||||
|
async def mock_kill():
|
||||||
|
call_order.append("kill")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def mock_spawn(name):
|
||||||
|
call_order.append(("spawn", name))
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill)
|
||||||
|
monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn)
|
||||||
|
|
||||||
|
response = client.post("/api/sessions/alpha/connect")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert call_order == ["kill", ("spawn", "alpha")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_nonexistent_session_returns_404(client, monkeypatch):
|
||||||
|
"""POST /api/sessions/{name}/connect returns 404 when session is not in list."""
|
||||||
|
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha", "beta"])
|
||||||
|
|
||||||
|
response = client.post("/api/sessions/gamma/connect")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /api/sessions/current
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_current_kills_ttyd_and_clears_active(client, monkeypatch):
|
||||||
|
"""DELETE /api/sessions/current kills ttyd and clears active_session."""
|
||||||
|
from coordinator.state import load_state, save_state
|
||||||
|
|
||||||
|
# Set up initial state with active session
|
||||||
|
save_state(
|
||||||
|
{
|
||||||
|
"active_session": "alpha",
|
||||||
|
"session_order": ["alpha"],
|
||||||
|
"sessions": {},
|
||||||
|
"devices": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
kill_called = []
|
||||||
|
|
||||||
|
async def mock_kill():
|
||||||
|
kill_called.append(True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill)
|
||||||
|
|
||||||
|
response = client.delete("/api/sessions/current")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["active_session"] is None
|
||||||
|
assert len(kill_called) == 1
|
||||||
|
|
||||||
|
# Verify state was persisted
|
||||||
|
state = load_state()
|
||||||
|
assert state["active_session"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/heartbeat
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_returns_200(client):
|
||||||
|
"""POST /api/heartbeat must return HTTP 200 with device_id and status 'ok'."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/heartbeat",
|
||||||
|
json={
|
||||||
|
"device_id": "dev-abc",
|
||||||
|
"label": "My Laptop",
|
||||||
|
"viewing_session": None,
|
||||||
|
"view_mode": "grid",
|
||||||
|
"last_interaction_at": 1234567890.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["device_id"] == "dev-abc"
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_registers_new_device(client):
|
||||||
|
"""POST /api/heartbeat registers a new device visible in GET /api/state."""
|
||||||
|
client.post(
|
||||||
|
"/api/heartbeat",
|
||||||
|
json={
|
||||||
|
"device_id": "dev-new",
|
||||||
|
"label": "Test Device",
|
||||||
|
"viewing_session": "mysession",
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": 1111111111.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
state_response = client.get("/api/state")
|
||||||
|
assert state_response.status_code == 200
|
||||||
|
state = state_response.json()
|
||||||
|
assert "dev-new" in state["devices"]
|
||||||
|
device = state["devices"]["dev-new"]
|
||||||
|
assert device["label"] == "Test Device"
|
||||||
|
assert device["viewing_session"] == "mysession"
|
||||||
|
assert device["view_mode"] == "fullscreen"
|
||||||
|
assert device["last_interaction_at"] == 1111111111.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_updates_existing_device(client):
|
||||||
|
"""Two POST /api/heartbeat calls: second values are persisted."""
|
||||||
|
# First heartbeat
|
||||||
|
client.post(
|
||||||
|
"/api/heartbeat",
|
||||||
|
json={
|
||||||
|
"device_id": "dev-update",
|
||||||
|
"label": "Old Label",
|
||||||
|
"viewing_session": None,
|
||||||
|
"view_mode": "grid",
|
||||||
|
"last_interaction_at": 1000000000.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Second heartbeat with updated values
|
||||||
|
client.post(
|
||||||
|
"/api/heartbeat",
|
||||||
|
json={
|
||||||
|
"device_id": "dev-update",
|
||||||
|
"label": "New Label",
|
||||||
|
"viewing_session": "session-x",
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": 2000000000.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
state_response = client.get("/api/state")
|
||||||
|
state = state_response.json()
|
||||||
|
device = state["devices"]["dev-update"]
|
||||||
|
assert device["label"] == "New Label"
|
||||||
|
assert device["viewing_session"] == "session-x"
|
||||||
|
assert device["view_mode"] == "fullscreen"
|
||||||
|
assert device["last_interaction_at"] == 2000000000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_missing_device_id_returns_422(client):
|
||||||
|
"""POST /api/heartbeat without device_id must return HTTP 422."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/heartbeat",
|
||||||
|
json={
|
||||||
|
"label": "My Laptop",
|
||||||
|
"viewing_session": None,
|
||||||
|
"view_mode": "grid",
|
||||||
|
"last_interaction_at": 1234567890.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_invalid_view_mode_returns_422(client):
|
||||||
|
"""POST /api/heartbeat with invalid view_mode must return HTTP 422."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/heartbeat",
|
||||||
|
json={
|
||||||
|
"device_id": "dev-abc",
|
||||||
|
"label": "My Laptop",
|
||||||
|
"viewing_session": None,
|
||||||
|
"view_mode": "invalid_mode",
|
||||||
|
"last_interaction_at": 1234567890.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/sessions/{name}/bell
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_receive_bell_returns_ok_and_session_name(client):
|
||||||
|
"""POST /api/sessions/{name}/bell returns {"ok": True, "session": name}."""
|
||||||
|
response = client.post("/api/sessions/web-tmux/bell")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["session"] == "web-tmux"
|
||||||
|
|
||||||
|
|
||||||
|
def test_receive_bell_increments_unseen_count(client):
|
||||||
|
"""POST /api/sessions/{name}/bell increments unseen_count in state."""
|
||||||
|
from coordinator.state import load_state
|
||||||
|
|
||||||
|
client.post("/api/sessions/my-session/bell")
|
||||||
|
|
||||||
|
state = load_state()
|
||||||
|
bell = state["sessions"]["my-session"]["bell"]
|
||||||
|
assert bell["unseen_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_receive_bell_creates_session_entry_if_absent(client):
|
||||||
|
"""POST /api/sessions/{name}/bell creates session/bell entries if missing."""
|
||||||
|
from coordinator.state import load_state
|
||||||
|
|
||||||
|
# Ensure session does not exist in state yet
|
||||||
|
client.post("/api/sessions/brand-new/bell")
|
||||||
|
|
||||||
|
state = load_state()
|
||||||
|
assert "brand-new" in state["sessions"]
|
||||||
|
assert "bell" in state["sessions"]["brand-new"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_receive_bell_multiple_calls_accumulate(client):
|
||||||
|
"""Three POST calls to the bell endpoint accumulate unseen_count to 3."""
|
||||||
|
from coordinator.state import load_state
|
||||||
|
|
||||||
|
for _ in range(3):
|
||||||
|
client.post("/api/sessions/multi-session/bell")
|
||||||
|
|
||||||
|
state = load_state()
|
||||||
|
bell = state["sessions"]["multi-session"]["bell"]
|
||||||
|
assert bell["unseen_count"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_receive_bell_sets_last_fired_at(client):
|
||||||
|
"""POST /api/sessions/{name}/bell sets last_fired_at to a recent timestamp."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from coordinator.state import load_state
|
||||||
|
|
||||||
|
before = time.time()
|
||||||
|
client.post("/api/sessions/timed-session/bell")
|
||||||
|
after = time.time()
|
||||||
|
|
||||||
|
state = load_state()
|
||||||
|
bell = state["sessions"]["timed-session"]["bell"]
|
||||||
|
assert bell["last_fired_at"] is not None
|
||||||
|
assert before <= bell["last_fired_at"] <= after
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/internal/setup-hooks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_hooks_returns_ok(client, monkeypatch):
|
||||||
|
"""POST /api/internal/setup-hooks returns {"ok": True} when tmux hook registers."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
monkeypatch.setattr("coordinator.main.run_tmux", AsyncMock(return_value=""))
|
||||||
|
|
||||||
|
response = client.post("/api/internal/setup-hooks")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_hooks_returns_ok_false_on_error(client, monkeypatch):
|
||||||
|
"""POST /api/internal/setup-hooks returns {"ok": False} when tmux raises."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"coordinator.main.run_tmux",
|
||||||
|
AsyncMock(side_effect=RuntimeError("tmux not found")),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post("/api/internal/setup-hooks")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is False
|
||||||
|
assert "error" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_hooks_curl_discards_response_body(client, monkeypatch):
|
||||||
|
"""POST /api/internal/setup-hooks passes curl with -o /dev/null to discard response."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
mock_run_tmux = AsyncMock(return_value="")
|
||||||
|
monkeypatch.setattr("coordinator.main.run_tmux", mock_run_tmux)
|
||||||
|
|
||||||
|
response = client.post("/api/internal/setup-hooks")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify run_tmux was called with the correct hook command
|
||||||
|
assert mock_run_tmux.called
|
||||||
|
call_args = mock_run_tmux.call_args
|
||||||
|
# Positional args are: "set-hook", "-g", "alert-bell", <hook_command>
|
||||||
|
hook_command = call_args[0][3] if len(call_args[0]) > 3 else None
|
||||||
|
assert hook_command is not None
|
||||||
|
# Should have -sfo /dev/null, not just -sf
|
||||||
|
assert "-sfo /dev/null" in hook_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifespan_alert_bell_hook_discards_response(monkeypatch):
|
||||||
|
"""Lifespan startup registers alert-bell hook with curl -o /dev/null to discard response."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from coordinator.main import app
|
||||||
|
|
||||||
|
# Mock run_tmux to capture the hook command
|
||||||
|
mock_run_tmux = AsyncMock(return_value="")
|
||||||
|
monkeypatch.setattr("coordinator.main.run_tmux", mock_run_tmux)
|
||||||
|
|
||||||
|
# Trigger lifespan by creating a TestClient
|
||||||
|
with TestClient(app) as _:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Verify run_tmux was called during lifespan startup
|
||||||
|
assert mock_run_tmux.called
|
||||||
|
# Find the call that sets the alert-bell hook
|
||||||
|
hook_calls = [
|
||||||
|
call
|
||||||
|
for call in mock_run_tmux.call_args_list
|
||||||
|
if len(call[0]) > 3 and call[0][2] == "alert-bell"
|
||||||
|
]
|
||||||
|
assert len(hook_calls) > 0, "alert-bell hook was not set during lifespan"
|
||||||
|
|
||||||
|
# Check the first hook call
|
||||||
|
hook_command = hook_calls[0][0][3]
|
||||||
|
assert "-sfo /dev/null" in hook_command
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Static file serving tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_serves_html(client):
|
||||||
|
"""GET / must return 200 with text/html content-type."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_css_served(client):
|
||||||
|
"""GET /style.css must return 200 with text/css content-type."""
|
||||||
|
response = client.get("/style.css")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "text/css" in response.headers["content-type"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_routes_not_shadowed(client):
|
||||||
|
"""GET /api/sessions must still return 200 with JSON list (not shadowed by StaticFiles)."""
|
||||||
|
response = client.get("/api/sessions")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert isinstance(response.json(), list)
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""
|
||||||
|
Tests for coordinator/bells.py — bell flag polling and unseen_count tracking.
|
||||||
|
All 17 acceptance-criteria tests are defined here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from coordinator.bells import (
|
||||||
|
_bell_seen,
|
||||||
|
apply_bell_clear_rule,
|
||||||
|
poll_bell_flag,
|
||||||
|
process_bell_flags,
|
||||||
|
should_clear_bell,
|
||||||
|
)
|
||||||
|
from coordinator.state import empty_bell, empty_state
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# autouse fixture — clear _bell_seen before/after each test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_bell_seen():
|
||||||
|
"""Clear _bell_seen before and after each test for isolation."""
|
||||||
|
_bell_seen.clear()
|
||||||
|
yield
|
||||||
|
_bell_seen.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# poll_bell_flag tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_poll_bell_flag_returns_true_when_flag_is_1():
|
||||||
|
"""poll_bell_flag returns True when tmux reports window_bell_flag=1."""
|
||||||
|
with patch("coordinator.bells.run_tmux", new=AsyncMock(return_value="1\n")):
|
||||||
|
result = await poll_bell_flag("my-session")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_poll_bell_flag_returns_false_when_flag_is_0():
|
||||||
|
"""poll_bell_flag returns False when tmux reports window_bell_flag=0."""
|
||||||
|
with patch("coordinator.bells.run_tmux", new=AsyncMock(return_value="0\n")):
|
||||||
|
result = await poll_bell_flag("my-session")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_poll_bell_flag_returns_false_on_error():
|
||||||
|
"""poll_bell_flag returns False when run_tmux raises RuntimeError."""
|
||||||
|
with patch(
|
||||||
|
"coordinator.bells.run_tmux",
|
||||||
|
new=AsyncMock(side_effect=RuntimeError("session not found")),
|
||||||
|
):
|
||||||
|
result = await poll_bell_flag("my-session")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# process_bell_flags tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_bell_flags_increments_unseen_count_on_new_bell():
|
||||||
|
"""process_bell_flags increments unseen_count on a 0→1 transition."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {"bell": empty_bell()}
|
||||||
|
|
||||||
|
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=True)):
|
||||||
|
changed = await process_bell_flags(["session-a"], state)
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 1
|
||||||
|
assert state["sessions"]["session-a"]["bell"]["last_fired_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_bell_flags_does_not_double_count_persistent_flag():
|
||||||
|
"""process_bell_flags does not increment unseen_count if flag stays at 1."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {"bell": empty_bell()}
|
||||||
|
|
||||||
|
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=True)):
|
||||||
|
# First poll — 0→1 transition
|
||||||
|
await process_bell_flags(["session-a"], state)
|
||||||
|
# Second poll — 1→1 (persistent), should NOT increment again
|
||||||
|
changed = await process_bell_flags(["session-a"], state)
|
||||||
|
|
||||||
|
assert changed is False
|
||||||
|
assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_bell_flags_resets_tracking_when_flag_clears():
|
||||||
|
"""1→0→1 sequence counts as two separate bells."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {"bell": empty_bell()}
|
||||||
|
|
||||||
|
# side_effect drives three sequential calls: 0→1, 1→0, 0→1
|
||||||
|
with patch(
|
||||||
|
"coordinator.bells.poll_bell_flag",
|
||||||
|
new=AsyncMock(side_effect=[True, False, True]),
|
||||||
|
):
|
||||||
|
for _ in range(3):
|
||||||
|
await process_bell_flags(["session-a"], state)
|
||||||
|
|
||||||
|
assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_bell_flags_no_change_returns_false():
|
||||||
|
"""process_bell_flags returns False when no bell state changed."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {"bell": empty_bell()}
|
||||||
|
|
||||||
|
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=False)):
|
||||||
|
changed = await process_bell_flags(["session-a"], state)
|
||||||
|
|
||||||
|
assert changed is False
|
||||||
|
assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_bell_flags_creates_bell_entry_if_missing():
|
||||||
|
"""process_bell_flags creates the bell sub-dict if session has no bell key."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {} # no 'bell' key
|
||||||
|
|
||||||
|
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=False)):
|
||||||
|
await process_bell_flags(["session-a"], state)
|
||||||
|
|
||||||
|
assert "bell" in state["sessions"]["session-a"]
|
||||||
|
assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# should_clear_bell tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_clear_bell_returns_true_for_fullscreen_recent_interaction():
|
||||||
|
"""should_clear_bell returns True when a device is fullscreen and interacted recently."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {"bell": empty_bell()}
|
||||||
|
state["devices"]["device-1"] = {
|
||||||
|
"label": "Device 1",
|
||||||
|
"viewing_session": "session-a",
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": time.time() - 10.0, # 10 seconds ago
|
||||||
|
"last_heartbeat_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert should_clear_bell("session-a", state) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_clear_bell_returns_false_for_grid_mode():
|
||||||
|
"""should_clear_bell returns False when device is in grid mode."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {"bell": empty_bell()}
|
||||||
|
state["devices"]["device-1"] = {
|
||||||
|
"label": "Device 1",
|
||||||
|
"viewing_session": "session-a",
|
||||||
|
"view_mode": "grid",
|
||||||
|
"last_interaction_at": time.time() - 10.0, # recent interaction
|
||||||
|
"last_heartbeat_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert should_clear_bell("session-a", state) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_clear_bell_returns_false_when_interaction_too_old():
|
||||||
|
"""should_clear_bell returns False when last interaction was more than 60s ago."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {"bell": empty_bell()}
|
||||||
|
state["devices"]["device-1"] = {
|
||||||
|
"label": "Device 1",
|
||||||
|
"viewing_session": "session-a",
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": time.time() - 90.0, # 90 seconds ago (> 60s window)
|
||||||
|
"last_heartbeat_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert should_clear_bell("session-a", state) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_clear_bell_returns_false_when_device_viewing_different_session():
|
||||||
|
"""should_clear_bell returns False when device is viewing a different session."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {"bell": empty_bell()}
|
||||||
|
state["devices"]["device-1"] = {
|
||||||
|
"label": "Device 1",
|
||||||
|
"viewing_session": "session-b", # different session
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": time.time() - 10.0,
|
||||||
|
"last_heartbeat_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert should_clear_bell("session-a", state) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_clear_bell_returns_false_when_no_devices():
|
||||||
|
"""should_clear_bell returns False when there are no connected devices."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {"bell": empty_bell()}
|
||||||
|
# No devices in state["devices"]
|
||||||
|
|
||||||
|
assert should_clear_bell("session-a", state) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# apply_bell_clear_rule tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_bell_clear_rule_clears_matching_sessions():
|
||||||
|
"""apply_bell_clear_rule resets unseen_count to 0 and sets seen_at for qualifying sessions."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {
|
||||||
|
"bell": {
|
||||||
|
"unseen_count": 3,
|
||||||
|
"last_fired_at": time.time() - 30.0,
|
||||||
|
"seen_at": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state["devices"]["device-1"] = {
|
||||||
|
"label": "Device 1",
|
||||||
|
"viewing_session": "session-a",
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": time.time() - 10.0,
|
||||||
|
"last_heartbeat_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
before = time.time()
|
||||||
|
apply_bell_clear_rule(state)
|
||||||
|
after = time.time()
|
||||||
|
|
||||||
|
bell = state["sessions"]["session-a"]["bell"]
|
||||||
|
assert bell["unseen_count"] == 0
|
||||||
|
assert bell["seen_at"] is not None
|
||||||
|
assert before <= bell["seen_at"] <= after
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_bell_clear_rule_skips_sessions_with_zero_unseen():
|
||||||
|
"""apply_bell_clear_rule does not modify sessions that already have unseen_count == 0."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {
|
||||||
|
"bell": {
|
||||||
|
"unseen_count": 0,
|
||||||
|
"last_fired_at": None,
|
||||||
|
"seen_at": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state["devices"]["device-1"] = {
|
||||||
|
"label": "Device 1",
|
||||||
|
"viewing_session": "session-a",
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": time.time() - 10.0,
|
||||||
|
"last_heartbeat_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = apply_bell_clear_rule(state)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
assert state["sessions"]["session-a"]["bell"]["seen_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_bell_clear_rule_returns_list_of_cleared_session_names():
|
||||||
|
"""apply_bell_clear_rule returns the names of sessions that were cleared."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {
|
||||||
|
"bell": {"unseen_count": 2, "last_fired_at": time.time() - 5.0, "seen_at": None}
|
||||||
|
}
|
||||||
|
state["sessions"]["session-b"] = {
|
||||||
|
"bell": {"unseen_count": 1, "last_fired_at": time.time() - 5.0, "seen_at": None}
|
||||||
|
}
|
||||||
|
state["sessions"]["session-c"] = {
|
||||||
|
"bell": {"unseen_count": 0, "last_fired_at": None, "seen_at": None}
|
||||||
|
}
|
||||||
|
state["devices"]["device-1"] = {
|
||||||
|
"label": "Device 1",
|
||||||
|
"viewing_session": "session-a",
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": time.time() - 10.0,
|
||||||
|
"last_heartbeat_at": time.time(),
|
||||||
|
}
|
||||||
|
state["devices"]["device-2"] = {
|
||||||
|
"label": "Device 2",
|
||||||
|
"viewing_session": "session-b",
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": time.time() - 10.0,
|
||||||
|
"last_heartbeat_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = apply_bell_clear_rule(state)
|
||||||
|
|
||||||
|
assert sorted(result) == ["session-a", "session-b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_bell_clear_rule_resets_bell_seen_tracking():
|
||||||
|
"""apply_bell_clear_rule resets _bell_seen[name] = False for cleared sessions."""
|
||||||
|
state = empty_state()
|
||||||
|
state["sessions"]["session-a"] = {
|
||||||
|
"bell": {"unseen_count": 1, "last_fired_at": time.time() - 5.0, "seen_at": None}
|
||||||
|
}
|
||||||
|
state["devices"]["device-1"] = {
|
||||||
|
"label": "Device 1",
|
||||||
|
"viewing_session": "session-a",
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": time.time() - 10.0,
|
||||||
|
"last_heartbeat_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pre-seed _bell_seen as if the bell was previously seen
|
||||||
|
_bell_seen["session-a"] = True
|
||||||
|
|
||||||
|
apply_bell_clear_rule(state)
|
||||||
|
|
||||||
|
assert _bell_seen.get("session-a") is False
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Tests for frontend/style.css — design tokens and dark theme."""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
CSS_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "style.css"
|
||||||
|
|
||||||
|
|
||||||
|
def read_css() -> str:
|
||||||
|
return CSS_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_design_tokens():
|
||||||
|
css = read_css()
|
||||||
|
assert "--bg:" in css
|
||||||
|
assert "--bell:" in css
|
||||||
|
assert "--font-mono:" in css
|
||||||
|
assert "--tile-height:" in css
|
||||||
|
assert "--t-zoom:" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_session_grid(css=None):
|
||||||
|
css = read_css()
|
||||||
|
assert "auto-fill" in css
|
||||||
|
assert "minmax" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_tile_height(css=None):
|
||||||
|
css = read_css()
|
||||||
|
assert ".session-tile" in css
|
||||||
|
assert "var(--tile-height)" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_bell_indicator(css=None):
|
||||||
|
css = read_css()
|
||||||
|
assert "bell-pulse" in css
|
||||||
|
assert ".session-tile--bell" in css
|
||||||
|
assert ".tile-bell" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_breakpoints():
|
||||||
|
css = read_css()
|
||||||
|
assert "599px" in css
|
||||||
|
assert "899px" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_zoom_transition():
|
||||||
|
css = read_css()
|
||||||
|
assert ".session-tile--expanding" in css
|
||||||
|
assert "session-tile--expanded" in css
|
||||||
|
assert ".session-grid--dimming" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_bell_count_and_toast():
|
||||||
|
css = read_css()
|
||||||
|
assert ".tile-bell-count" in css
|
||||||
|
assert ".connection-status--ok" in css
|
||||||
|
assert ".connection-status--warn" in css
|
||||||
|
assert ".connection-status--err" in css
|
||||||
|
assert ".toast" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_mobile_tiers():
|
||||||
|
css = read_css()
|
||||||
|
assert "session-tile--tier-bell" in css
|
||||||
|
assert "session-tile--tier-active" in css
|
||||||
|
assert "session-tile--tier-idle" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_command_palette():
|
||||||
|
css = read_css()
|
||||||
|
assert ".command-palette__dialog" in css
|
||||||
|
assert ".command-palette__input" in css
|
||||||
|
assert ".palette-item" in css
|
||||||
|
assert ".palette-item--selected" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_bottom_sheet():
|
||||||
|
css = read_css()
|
||||||
|
assert ".bottom-sheet__panel" in css
|
||||||
|
assert ".bottom-sheet__handle" in css
|
||||||
|
assert ".sheet-item" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_session_pill():
|
||||||
|
css = read_css()
|
||||||
|
assert ".session-pill" in css
|
||||||
|
assert ".session-pill__label" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_reduced_motion():
|
||||||
|
css = read_css()
|
||||||
|
assert "prefers-reduced-motion" in css
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Tests for frontend/index.html — verifies presence of all required DOM elements."""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
HTML_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "index.html"
|
||||||
|
|
||||||
|
# Parse once per module — tests are read-only so sharing is safe.
|
||||||
|
_SOUP: BeautifulSoup = BeautifulSoup(HTML_PATH.read_text(), "html.parser")
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_pwa_meta() -> None:
|
||||||
|
"""apple-mobile-web-app-capable, rel=manifest, theme-color, apple-mobile-web-app-status-bar-style."""
|
||||||
|
soup = _SOUP
|
||||||
|
# rel=manifest
|
||||||
|
assert soup.find("link", rel="manifest"), "Missing <link rel='manifest'>"
|
||||||
|
# theme-color
|
||||||
|
assert soup.find("meta", attrs={"name": "theme-color"}), (
|
||||||
|
"Missing <meta name='theme-color'>"
|
||||||
|
)
|
||||||
|
# apple-mobile-web-app-capable
|
||||||
|
assert soup.find("meta", attrs={"name": "apple-mobile-web-app-capable"}), (
|
||||||
|
"Missing <meta name='apple-mobile-web-app-capable'>"
|
||||||
|
)
|
||||||
|
# apple-mobile-web-app-status-bar-style
|
||||||
|
assert soup.find("meta", attrs={"name": "apple-mobile-web-app-status-bar-style"}), (
|
||||||
|
"Missing <meta name='apple-mobile-web-app-status-bar-style'>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_viewport_suppresses_pinch_zoom() -> None:
|
||||||
|
"""viewport must include maximum-scale=1.0 and user-scalable=no."""
|
||||||
|
soup = _SOUP
|
||||||
|
viewport = soup.find("meta", attrs={"name": "viewport"})
|
||||||
|
assert viewport, "Missing <meta name='viewport'>"
|
||||||
|
content = str(viewport.get("content", "")) # type: ignore[union-attr]
|
||||||
|
assert "maximum-scale=1.0" in content, (
|
||||||
|
f"viewport missing maximum-scale=1.0: {content!r}"
|
||||||
|
)
|
||||||
|
assert "user-scalable=no" in content, (
|
||||||
|
f"viewport missing user-scalable=no: {content!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_required_views() -> None:
|
||||||
|
"""id=view-overview, view-expanded, session-grid, terminal-container, empty-state."""
|
||||||
|
soup = _SOUP
|
||||||
|
for id_ in (
|
||||||
|
"view-overview",
|
||||||
|
"view-expanded",
|
||||||
|
"session-grid",
|
||||||
|
"terminal-container",
|
||||||
|
"empty-state",
|
||||||
|
):
|
||||||
|
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_expanded_view_elements() -> None:
|
||||||
|
"""id=back-btn, expanded-session-name, palette-trigger, reconnect-overlay."""
|
||||||
|
soup = _SOUP
|
||||||
|
for id_ in (
|
||||||
|
"back-btn",
|
||||||
|
"expanded-session-name",
|
||||||
|
"palette-trigger",
|
||||||
|
"reconnect-overlay",
|
||||||
|
):
|
||||||
|
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_command_palette() -> None:
|
||||||
|
"""id=command-palette, palette-input, palette-list, palette-backdrop."""
|
||||||
|
soup = _SOUP
|
||||||
|
for id_ in ("command-palette", "palette-input", "palette-list", "palette-backdrop"):
|
||||||
|
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_bottom_sheet() -> None:
|
||||||
|
"""id=bottom-sheet, sheet-list, sheet-backdrop, session-pill, session-pill-label, session-pill-bell."""
|
||||||
|
soup = _SOUP
|
||||||
|
for id_ in (
|
||||||
|
"bottom-sheet",
|
||||||
|
"sheet-list",
|
||||||
|
"sheet-backdrop",
|
||||||
|
"session-pill",
|
||||||
|
"session-pill-label",
|
||||||
|
"session-pill-bell",
|
||||||
|
):
|
||||||
|
assert soup.find(id=id_), f"Missing element with id='{id_}'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_toast() -> None:
|
||||||
|
"""id=toast, aria-live=polite."""
|
||||||
|
soup = _SOUP
|
||||||
|
toast = soup.find(id="toast")
|
||||||
|
assert toast, "Missing element with id='toast'"
|
||||||
|
assert toast.get("aria-live") == "polite", ( # type: ignore[union-attr]
|
||||||
|
f"toast missing aria-live=polite, got: {toast.get('aria-live')!r}" # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_scripts() -> None:
|
||||||
|
"""src=/app.js, src=/terminal.js, xterm."""
|
||||||
|
soup = _SOUP
|
||||||
|
scripts = soup.find_all("script")
|
||||||
|
srcs = [str(s.get("src", "")) for s in scripts]
|
||||||
|
assert any("/app.js" in s for s in srcs), (
|
||||||
|
f"Missing script src=/app.js; found: {srcs}"
|
||||||
|
)
|
||||||
|
assert any("/terminal.js" in s for s in srcs), (
|
||||||
|
f"Missing script src=/terminal.js; found: {srcs}"
|
||||||
|
)
|
||||||
|
assert any("xterm" in s for s in srcs), f"Missing xterm script; found: {srcs}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_xterm_css() -> None:
|
||||||
|
"""xterm.css CDN link present."""
|
||||||
|
soup = _SOUP
|
||||||
|
links = soup.find_all("link", rel="stylesheet")
|
||||||
|
hrefs = [str(lnk.get("href", "")) for lnk in links]
|
||||||
|
assert any("xterm.css" in h for h in hrefs), (
|
||||||
|
f"Missing xterm.css link; found: {hrefs}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_style_css() -> None:
|
||||||
|
"""href=/style.css present."""
|
||||||
|
soup = _SOUP
|
||||||
|
links = soup.find_all("link", rel="stylesheet")
|
||||||
|
hrefs = [str(lnk.get("href", "")) for lnk in links]
|
||||||
|
assert any("/style.css" in h for h in hrefs), (
|
||||||
|
f"Missing /style.css link; found: {hrefs}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_element_classes() -> None:
|
||||||
|
"""Critical and important elements must carry their CSS styling classes."""
|
||||||
|
soup = _SOUP
|
||||||
|
cases = [
|
||||||
|
# (element_id, required_class, reason)
|
||||||
|
("terminal-container", "terminal-container", "xterm.js needs flex:1 to render"),
|
||||||
|
(
|
||||||
|
"reconnect-overlay",
|
||||||
|
"reconnect-overlay",
|
||||||
|
"needs position:absolute to overlay terminal",
|
||||||
|
),
|
||||||
|
("session-pill", "session-pill", "needs position:fixed to float"),
|
||||||
|
("toast", "toast", "needs position:fixed and animation"),
|
||||||
|
("back-btn", "back-btn", "needs border and hover styles"),
|
||||||
|
("palette-trigger", "palette-trigger", "needs border and hover styles"),
|
||||||
|
(
|
||||||
|
"expanded-session-name",
|
||||||
|
"expanded-session-name",
|
||||||
|
"needs text-overflow:ellipsis",
|
||||||
|
),
|
||||||
|
("session-pill-label", "session-pill__label", "needs max-width truncation"),
|
||||||
|
("session-pill-bell", "session-pill__bell", "needs amber var(--bell) color"),
|
||||||
|
]
|
||||||
|
for el_id, expected_class, reason in cases:
|
||||||
|
el = soup.find(id=el_id)
|
||||||
|
assert el is not None, f"#{el_id} not found in HTML"
|
||||||
|
classes = el.get("class") or []
|
||||||
|
assert expected_class in classes, (
|
||||||
|
f"#{el_id} is missing class '{expected_class}' — {reason}. Has: {classes}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for the tmux-web coordinator.
|
||||||
|
|
||||||
|
These tests require a real tmux installation and spin up an isolated tmux
|
||||||
|
server on socket 'test-server' for the duration of the module.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
pytest -m integration -v
|
||||||
|
|
||||||
|
Default test run (unit tests only):
|
||||||
|
pytest -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import coordinator.state as state_mod
|
||||||
|
from coordinator.bells import poll_bell_flag
|
||||||
|
from coordinator.main import _run_poll_cycle
|
||||||
|
from coordinator.sessions import enumerate_sessions, get_snapshots
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper function
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def tmux(socket: str, *args: str) -> str:
|
||||||
|
"""Run a tmux command against the specified socket and return stdout."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["tmux", "-L", socket, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def tmux_server():
|
||||||
|
"""Start an isolated tmux server on socket 'test-server', create session 'test' (220x50).
|
||||||
|
|
||||||
|
Sets monitor-bell on so that bell characters sent to the session are detected.
|
||||||
|
Tears down the server after all module tests complete.
|
||||||
|
"""
|
||||||
|
socket = "test-server"
|
||||||
|
# Start a new tmux server with an isolated socket and create the test session
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"tmux",
|
||||||
|
"-L",
|
||||||
|
socket,
|
||||||
|
"new-session",
|
||||||
|
"-d",
|
||||||
|
"-s",
|
||||||
|
"test",
|
||||||
|
"-x",
|
||||||
|
"220",
|
||||||
|
"-y",
|
||||||
|
"50",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
# Enable bell monitoring so window_bell_flag is set when a bell is received
|
||||||
|
subprocess.run(
|
||||||
|
["tmux", "-L", socket, "set-window-option", "-t", "test", "monitor-bell", "on"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
yield socket
|
||||||
|
# Teardown: kill the isolated server (suppress errors if already dead)
|
||||||
|
subprocess.run(
|
||||||
|
["tmux", "-L", socket, "kill-server"],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def use_tmp_state(tmp_path, monkeypatch):
|
||||||
|
"""Redirect state and PID files to tmp_path for test isolation."""
|
||||||
|
tmp_state_dir = tmp_path / "state"
|
||||||
|
tmp_state_path = tmp_state_dir / "state.json"
|
||||||
|
monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir)
|
||||||
|
monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path)
|
||||||
|
|
||||||
|
tmp_pid_dir = tmp_path / "ttyd"
|
||||||
|
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
||||||
|
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
||||||
|
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helper: patched run_tmux that uses the isolated test socket
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_run_tmux_for_socket(socket: str):
|
||||||
|
"""Return an async run_tmux substitute that routes all tmux calls through *socket*.
|
||||||
|
|
||||||
|
Prepends ``-L <socket>`` to every tmux invocation so the test server
|
||||||
|
is used instead of the default server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def patched_run_tmux(*args: str) -> str:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"tmux",
|
||||||
|
"-L",
|
||||||
|
socket,
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout_bytes, stderr_bytes = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(stderr_bytes.decode("utf-8", errors="replace"))
|
||||||
|
return stdout_bytes.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
return patched_run_tmux
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_enumerate_sessions_finds_test_session(tmux_server):
|
||||||
|
"""enumerate_sessions discovers the 'test' session on the isolated tmux server."""
|
||||||
|
patched_run_tmux = make_run_tmux_for_socket(tmux_server)
|
||||||
|
with patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux):
|
||||||
|
sessions = await enumerate_sessions()
|
||||||
|
assert "test" in sessions
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_capture_pane_returns_content(tmux_server):
|
||||||
|
"""tmux capture-pane returns output that includes what was echoed to the session."""
|
||||||
|
tmux(tmux_server, "send-keys", "-t", "test", "echo hello-world", "Enter")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# Use the tmux helper directly: capture-pane -p captures the pane content to stdout
|
||||||
|
content = tmux(tmux_server, "capture-pane", "-p", "-t", "test")
|
||||||
|
assert "hello-world" in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_bell_flag_detected_after_printf_bell(tmux_server):
|
||||||
|
"""poll_bell_flag returns True after a bell character is sent to the test session."""
|
||||||
|
tmux(tmux_server, "send-keys", "-t", "test", r"printf '\a'", "Enter")
|
||||||
|
# Allow tmux time to propagate the bell and set window_bell_flag
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
patched_run_tmux = make_run_tmux_for_socket(tmux_server)
|
||||||
|
with patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux):
|
||||||
|
result = await poll_bell_flag("test")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_full_poll_cycle_via_api(tmux_server):
|
||||||
|
"""_run_poll_cycle with patched run_tmux adds 'test' to session_order in state
|
||||||
|
and populates the in-memory snapshot cache with non-empty content."""
|
||||||
|
patched_run_tmux = make_run_tmux_for_socket(tmux_server)
|
||||||
|
with (
|
||||||
|
patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux),
|
||||||
|
patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux),
|
||||||
|
):
|
||||||
|
await _run_poll_cycle()
|
||||||
|
|
||||||
|
state = state_mod.load_state()
|
||||||
|
assert "test" in state["session_order"]
|
||||||
|
|
||||||
|
# Verify snapshots were captured and stored — Critical Issue #1 regression guard.
|
||||||
|
# If snapshot_all() return value is discarded, get_snapshots() returns {} and
|
||||||
|
# snapshots["test"] falls back to "", causing this assertion to fail.
|
||||||
|
snapshots = get_snapshots()
|
||||||
|
assert "test" in snapshots, (
|
||||||
|
"snapshot cache must contain an entry for the 'test' session"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_state_file_written_atomically_by_poll_cycle(tmux_server):
|
||||||
|
"""After _run_poll_cycle, state.json exists, no .tmp file remains, content is valid JSON."""
|
||||||
|
patched_run_tmux = make_run_tmux_for_socket(tmux_server)
|
||||||
|
with (
|
||||||
|
patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux),
|
||||||
|
patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux),
|
||||||
|
):
|
||||||
|
await _run_poll_cycle()
|
||||||
|
|
||||||
|
state_path = state_mod.STATE_PATH
|
||||||
|
tmp_path = state_mod.STATE_PATH.parent / (state_mod.STATE_PATH.name + ".tmp")
|
||||||
|
|
||||||
|
# state.json must exist after a successful poll cycle
|
||||||
|
assert state_path.exists(), "state.json was not written by _run_poll_cycle"
|
||||||
|
|
||||||
|
# The temporary file must be gone (atomic write completed)
|
||||||
|
assert not tmp_path.exists(), (
|
||||||
|
".tmp file was left behind (atomic write may have failed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# File content must be valid JSON
|
||||||
|
content = state_path.read_text()
|
||||||
|
data = json.loads(content)
|
||||||
|
assert isinstance(data, dict), "state.json does not contain a JSON object"
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""
|
||||||
|
Tests for coordinator/sessions.py — tmux session enumeration and helpers.
|
||||||
|
All 6 acceptance-criteria tests are defined here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import coordinator.sessions as sessions_mod
|
||||||
|
from coordinator.sessions import (
|
||||||
|
capture_pane,
|
||||||
|
enumerate_sessions,
|
||||||
|
get_snapshots,
|
||||||
|
get_session_list,
|
||||||
|
run_tmux,
|
||||||
|
snapshot_all,
|
||||||
|
update_session_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers for mocking asyncio.create_subprocess_exec
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_process(stdout: str, stderr: str = "", returncode: int = 0):
|
||||||
|
"""Return a mock process whose communicate() returns encoded strings."""
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = returncode
|
||||||
|
proc.communicate = AsyncMock(return_value=(stdout.encode(), stderr.encode()))
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_subprocess():
|
||||||
|
"""Fixture factory: returns a context-manager patch for asyncio.create_subprocess_exec.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
with mock_subprocess(stdout="...") as mock_create:
|
||||||
|
await some_function()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _factory(stdout: str = "", stderr: str = "", returncode: int = 0):
|
||||||
|
proc = _make_mock_process(stdout, stderr, returncode)
|
||||||
|
return patch("asyncio.create_subprocess_exec", new=AsyncMock(return_value=proc))
|
||||||
|
|
||||||
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# run_tmux tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_tmux_calls_correct_command(mock_subprocess):
|
||||||
|
"""run_tmux('list-sessions', '-F', '#{session_name}') must call tmux
|
||||||
|
with exactly those positional arguments via asyncio.create_subprocess_exec."""
|
||||||
|
with mock_subprocess("session1\nsession2\n") as mock_create:
|
||||||
|
await run_tmux("list-sessions", "-F", "#{session_name}")
|
||||||
|
|
||||||
|
# First positional arg must be 'tmux'; rest must be the args we passed.
|
||||||
|
call_args = mock_create.call_args[0]
|
||||||
|
assert call_args[0] == "tmux"
|
||||||
|
assert call_args[1] == "list-sessions"
|
||||||
|
assert call_args[2] == "-F"
|
||||||
|
assert call_args[3] == "#{session_name}"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_tmux_raises_on_nonzero_exit(mock_subprocess):
|
||||||
|
"""run_tmux() must raise RuntimeError when the subprocess exits non-zero."""
|
||||||
|
with mock_subprocess(
|
||||||
|
stdout="", stderr="no server running on /tmp/tmux-1000/default", returncode=1
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="no server running"):
|
||||||
|
await run_tmux("list-sessions", "-F", "#{session_name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# enumerate_sessions tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enumerate_sessions_parses_newline_output(mock_subprocess):
|
||||||
|
"""enumerate_sessions() splits newline-separated output into a list of names."""
|
||||||
|
with mock_subprocess("alpha\nbeta\ngamma\n"):
|
||||||
|
result = await enumerate_sessions()
|
||||||
|
|
||||||
|
assert result == ["alpha", "beta", "gamma"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enumerate_sessions_returns_empty_list_when_no_sessions(mock_subprocess):
|
||||||
|
"""enumerate_sessions() returns [] when tmux output is empty."""
|
||||||
|
with mock_subprocess(""):
|
||||||
|
result = await enumerate_sessions()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enumerate_sessions_strips_whitespace(mock_subprocess):
|
||||||
|
"""enumerate_sessions() strips leading/trailing whitespace from each name."""
|
||||||
|
with mock_subprocess(" session1 \n session2 \n"):
|
||||||
|
result = await enumerate_sessions()
|
||||||
|
|
||||||
|
assert result == ["session1", "session2"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enumerate_sessions_handles_tmux_error(mock_subprocess):
|
||||||
|
"""enumerate_sessions() returns [] when run_tmux raises RuntimeError
|
||||||
|
(e.g. tmux server not running)."""
|
||||||
|
with mock_subprocess(stdout="", stderr="no server running", returncode=1):
|
||||||
|
result = await enumerate_sessions()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# capture_pane tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_capture_pane_returns_output(mock_subprocess):
|
||||||
|
"""capture_pane() returns the text output from tmux capture-pane."""
|
||||||
|
with mock_subprocess("line1\nline2\nline3\n"):
|
||||||
|
result = await capture_pane("my-session")
|
||||||
|
|
||||||
|
assert result == "line1\nline2\nline3\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_capture_pane_returns_empty_string_on_error(mock_subprocess):
|
||||||
|
"""capture_pane() returns '' when tmux exits with an error."""
|
||||||
|
with mock_subprocess(
|
||||||
|
stdout="", stderr="can't find session my-session", returncode=1
|
||||||
|
):
|
||||||
|
result = await capture_pane("my-session")
|
||||||
|
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_capture_pane_calls_correct_tmux_args(mock_subprocess):
|
||||||
|
"""capture_pane() calls tmux with: capture-pane -p -t <name> -S -<lines>.
|
||||||
|
|
||||||
|
Uses -S -N (start N lines from bottom) to limit output.
|
||||||
|
Does NOT pass -e (escape sequences) or -l (invalid in tmux 3.4).
|
||||||
|
"""
|
||||||
|
with mock_subprocess("output text\n") as mock_create:
|
||||||
|
await capture_pane("target-session", lines=50)
|
||||||
|
|
||||||
|
call_args = mock_create.call_args[0]
|
||||||
|
assert call_args[0] == "tmux"
|
||||||
|
assert call_args[1] == "capture-pane"
|
||||||
|
assert call_args[2] == "-p"
|
||||||
|
assert call_args[3] == "-t"
|
||||||
|
assert call_args[4] == "target-session"
|
||||||
|
assert call_args[5] == "-S"
|
||||||
|
assert call_args[6] == "-50"
|
||||||
|
assert len(call_args) == 7, "No extra args — -e and -l must not be present"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# snapshot_all tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_all_returns_dict_keyed_by_name():
|
||||||
|
"""snapshot_all() returns a dict mapping each session name to its pane output."""
|
||||||
|
|
||||||
|
async def mock_capture(name, lines=30):
|
||||||
|
return f"output-for-{name}"
|
||||||
|
|
||||||
|
with patch("coordinator.sessions.capture_pane", side_effect=mock_capture):
|
||||||
|
result = await snapshot_all(["alpha", "beta", "gamma"])
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"alpha": "output-for-alpha",
|
||||||
|
"beta": "output-for-beta",
|
||||||
|
"gamma": "output-for-gamma",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_all_returns_empty_dict_for_empty_input():
|
||||||
|
"""snapshot_all([]) returns an empty dict without calling capture_pane."""
|
||||||
|
with patch("coordinator.sessions.capture_pane", new=AsyncMock()) as mock_capture:
|
||||||
|
result = await snapshot_all([])
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
|
mock_capture.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_all_returns_empty_string_on_individual_failure():
|
||||||
|
"""snapshot_all() maps '' for a failing session while others still succeed."""
|
||||||
|
|
||||||
|
async def mock_capture(name, lines=30):
|
||||||
|
if name == "bad-session":
|
||||||
|
raise RuntimeError("pane not found")
|
||||||
|
return f"output-for-{name}"
|
||||||
|
|
||||||
|
with patch("coordinator.sessions.capture_pane", side_effect=mock_capture):
|
||||||
|
result = await snapshot_all(["session-a", "bad-session", "session-b"])
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"session-a": "output-for-session-a",
|
||||||
|
"bad-session": "",
|
||||||
|
"session-b": "output-for-session-b",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# update_session_cache tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_session_cache_populates_snapshots():
|
||||||
|
"""update_session_cache(names, snapshots) must replace _snapshots with provided dict.
|
||||||
|
|
||||||
|
This is the RED test for Critical Issue #1: previously, update_session_cache
|
||||||
|
only accepted names and never received the snapshots dict, so _snapshots
|
||||||
|
stayed empty forever.
|
||||||
|
"""
|
||||||
|
# Reset module state to simulate a fresh start
|
||||||
|
sessions_mod._snapshots = {}
|
||||||
|
sessions_mod._session_list = []
|
||||||
|
|
||||||
|
update_session_cache(
|
||||||
|
["sess1", "sess2"], {"sess1": "line1\nline2", "sess2": "hello"}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = get_snapshots()
|
||||||
|
assert result == {"sess1": "line1\nline2", "sess2": "hello"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_session_cache_updates_session_list():
|
||||||
|
"""update_session_cache() must also replace _session_list with the given names."""
|
||||||
|
sessions_mod._snapshots = {}
|
||||||
|
sessions_mod._session_list = ["old-session"]
|
||||||
|
|
||||||
|
update_session_cache(["alpha", "beta"], {"alpha": "a", "beta": "b"})
|
||||||
|
|
||||||
|
assert get_session_list() == ["alpha", "beta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_session_cache_empty_names_clears_caches():
|
||||||
|
"""update_session_cache([], {}) clears both caches."""
|
||||||
|
sessions_mod._snapshots = {"stale": "text"}
|
||||||
|
sessions_mod._session_list = ["stale"]
|
||||||
|
|
||||||
|
update_session_cache([], {})
|
||||||
|
|
||||||
|
assert get_session_list() == []
|
||||||
|
assert get_snapshots() == {}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""
|
||||||
|
Tests for coordinator/state.py — state schema factories and I/O.
|
||||||
|
All acceptance-criteria tests are defined here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from coordinator.state import (
|
||||||
|
empty_bell,
|
||||||
|
empty_device,
|
||||||
|
empty_state,
|
||||||
|
prune_devices,
|
||||||
|
read_state,
|
||||||
|
register_device,
|
||||||
|
state_lock,
|
||||||
|
write_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# autouse fixture — redirect STATE_DIR and STATE_PATH to a tmp directory
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def use_tmp_state_dir(tmp_path, monkeypatch):
|
||||||
|
"""Redirect state I/O to a fresh temp directory for every test."""
|
||||||
|
tmp_state_dir = tmp_path / "state"
|
||||||
|
tmp_state_path = tmp_state_dir / "state.json"
|
||||||
|
monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir)
|
||||||
|
monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# empty_state()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_state_has_required_top_level_keys():
|
||||||
|
state = empty_state()
|
||||||
|
assert "active_session" in state
|
||||||
|
assert "session_order" in state
|
||||||
|
assert "sessions" in state
|
||||||
|
assert "devices" in state
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_state_active_session_is_none():
|
||||||
|
state = empty_state()
|
||||||
|
assert state["active_session"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_state_session_order_is_empty_list():
|
||||||
|
state = empty_state()
|
||||||
|
assert state["session_order"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_state_sessions_is_empty_dict():
|
||||||
|
state = empty_state()
|
||||||
|
assert state["sessions"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_state_devices_is_empty_dict():
|
||||||
|
state = empty_state()
|
||||||
|
assert state["devices"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_state_returns_independent_dicts():
|
||||||
|
"""Mutating one state must not affect another."""
|
||||||
|
s1 = empty_state()
|
||||||
|
s2 = empty_state()
|
||||||
|
|
||||||
|
s1["session_order"].append("my-session")
|
||||||
|
s1["sessions"]["foo"] = {}
|
||||||
|
s1["devices"]["bar"] = {}
|
||||||
|
|
||||||
|
assert s2["session_order"] == []
|
||||||
|
assert s2["sessions"] == {}
|
||||||
|
assert s2["devices"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# empty_bell()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_bell_has_required_keys():
|
||||||
|
bell = empty_bell()
|
||||||
|
assert "last_fired_at" in bell
|
||||||
|
assert "seen_at" in bell
|
||||||
|
assert "unseen_count" in bell
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_bell_last_fired_at_is_none():
|
||||||
|
bell = empty_bell()
|
||||||
|
assert bell["last_fired_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_bell_seen_at_is_none():
|
||||||
|
bell = empty_bell()
|
||||||
|
assert bell["seen_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_bell_unseen_count_is_zero():
|
||||||
|
bell = empty_bell()
|
||||||
|
assert bell["unseen_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# empty_device(device_id, label)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_device_has_required_keys():
|
||||||
|
device = empty_device("dev-1", "My Laptop")
|
||||||
|
assert "label" in device
|
||||||
|
assert "viewing_session" in device
|
||||||
|
assert "view_mode" in device
|
||||||
|
assert "last_interaction_at" in device
|
||||||
|
assert "last_heartbeat_at" in device
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_device_label_set_correctly():
|
||||||
|
device = empty_device("dev-1", "My Laptop")
|
||||||
|
assert device["label"] == "My Laptop"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_device_viewing_session_is_none():
|
||||||
|
device = empty_device("dev-1", "My Laptop")
|
||||||
|
assert device["viewing_session"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_device_view_mode_is_grid():
|
||||||
|
device = empty_device("dev-1", "My Laptop")
|
||||||
|
assert device["view_mode"] == "grid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_device_timestamps_are_recent():
|
||||||
|
"""last_interaction_at and last_heartbeat_at should be close to now."""
|
||||||
|
before = time.time()
|
||||||
|
device = empty_device("dev-1", "My Laptop")
|
||||||
|
after = time.time()
|
||||||
|
|
||||||
|
assert before <= device["last_interaction_at"] <= after
|
||||||
|
assert before <= device["last_heartbeat_at"] <= after
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# state_lock
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_lock_is_asyncio_lock():
|
||||||
|
assert isinstance(state_lock, asyncio.Lock)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# load_state / save_state / read_state / write_state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_state_returns_empty_when_no_file():
|
||||||
|
"""read_state() returns empty_state() when STATE_PATH does not exist."""
|
||||||
|
state = await read_state()
|
||||||
|
assert state == empty_state()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_write_then_read_roundtrip():
|
||||||
|
"""write_state() persists state; subsequent read_state() returns it intact."""
|
||||||
|
original = empty_state()
|
||||||
|
original["active_session"] = "my-session"
|
||||||
|
original["session_order"] = ["my-session"]
|
||||||
|
await write_state(original)
|
||||||
|
loaded = await read_state()
|
||||||
|
assert loaded == original
|
||||||
|
|
||||||
|
|
||||||
|
async def test_write_creates_state_dir_if_missing():
|
||||||
|
"""save_state() must create STATE_DIR (and parents) if they do not exist."""
|
||||||
|
import coordinator.state as state_mod
|
||||||
|
|
||||||
|
# The tmp "state" subdirectory should not exist yet.
|
||||||
|
assert not state_mod.STATE_DIR.exists()
|
||||||
|
await write_state(empty_state())
|
||||||
|
assert state_mod.STATE_DIR.exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_write_is_atomic_no_tmp_file_left():
|
||||||
|
"""After write_state(), no .tmp file should remain on disk."""
|
||||||
|
import coordinator.state as state_mod
|
||||||
|
|
||||||
|
await write_state(empty_state())
|
||||||
|
tmp_file = Path(str(state_mod.STATE_PATH) + ".tmp")
|
||||||
|
assert not tmp_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_writes_do_not_corrupt():
|
||||||
|
"""Two concurrent write_state() calls must leave valid JSON matching one write."""
|
||||||
|
state_a = empty_state()
|
||||||
|
state_a["active_session"] = "session-a"
|
||||||
|
|
||||||
|
state_b = empty_state()
|
||||||
|
state_b["active_session"] = "session-b"
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
write_state(state_a),
|
||||||
|
write_state(state_b),
|
||||||
|
)
|
||||||
|
|
||||||
|
final = await read_state()
|
||||||
|
# Final state must be exactly one of the two known states — no corruption.
|
||||||
|
assert final == state_a or final == state_b
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# register_device()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_device_adds_new_device():
|
||||||
|
"""register_device() creates a new device entry if device_id is not present."""
|
||||||
|
state = empty_state()
|
||||||
|
register_device(state, "dev-1", "My Laptop", None, "grid", time.time())
|
||||||
|
assert "dev-1" in state["devices"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_device_updates_existing_device():
|
||||||
|
"""register_device() updates label, viewing_session, view_mode, last_interaction_at."""
|
||||||
|
state = empty_state()
|
||||||
|
now = time.time()
|
||||||
|
register_device(state, "dev-1", "Old Label", "session-a", "grid", now)
|
||||||
|
|
||||||
|
later = now + 10
|
||||||
|
register_device(state, "dev-1", "New Label", "session-b", "fullscreen", later)
|
||||||
|
|
||||||
|
device = state["devices"]["dev-1"]
|
||||||
|
assert device["label"] == "New Label"
|
||||||
|
assert device["viewing_session"] == "session-b"
|
||||||
|
assert device["view_mode"] == "fullscreen"
|
||||||
|
assert device["last_interaction_at"] == later
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_device_sets_heartbeat_timestamp():
|
||||||
|
"""register_device() always refreshes last_heartbeat_at to current time.time()."""
|
||||||
|
state = empty_state()
|
||||||
|
before = time.time()
|
||||||
|
register_device(state, "dev-1", "My Laptop", None, "grid", before)
|
||||||
|
after = time.time()
|
||||||
|
|
||||||
|
device = state["devices"]["dev-1"]
|
||||||
|
assert before <= device["last_heartbeat_at"] <= after
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# prune_devices()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_devices_removes_stale():
|
||||||
|
"""prune_devices() removes devices whose last_heartbeat_at is older than ttl."""
|
||||||
|
state = empty_state()
|
||||||
|
old_time = time.time() - 400 # 400s ago, beyond default 300s TTL
|
||||||
|
state["devices"]["stale-dev"] = {
|
||||||
|
"label": "Stale",
|
||||||
|
"viewing_session": None,
|
||||||
|
"view_mode": "grid",
|
||||||
|
"last_interaction_at": old_time,
|
||||||
|
"last_heartbeat_at": old_time,
|
||||||
|
}
|
||||||
|
prune_devices(state)
|
||||||
|
assert "stale-dev" not in state["devices"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_devices_keeps_fresh():
|
||||||
|
"""prune_devices() keeps devices whose last_heartbeat_at is within ttl."""
|
||||||
|
state = empty_state()
|
||||||
|
recent_time = time.time() - 100 # 100s ago, within default 300s TTL
|
||||||
|
state["devices"]["fresh-dev"] = {
|
||||||
|
"label": "Fresh",
|
||||||
|
"viewing_session": None,
|
||||||
|
"view_mode": "grid",
|
||||||
|
"last_interaction_at": recent_time,
|
||||||
|
"last_heartbeat_at": recent_time,
|
||||||
|
}
|
||||||
|
prune_devices(state)
|
||||||
|
assert "fresh-dev" in state["devices"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_devices_returns_list_of_removed_ids():
|
||||||
|
"""prune_devices() returns the list of device IDs that were removed."""
|
||||||
|
state = empty_state()
|
||||||
|
old_time = time.time() - 400
|
||||||
|
state["devices"]["stale-1"] = {
|
||||||
|
"label": "Stale 1",
|
||||||
|
"viewing_session": None,
|
||||||
|
"view_mode": "grid",
|
||||||
|
"last_interaction_at": old_time,
|
||||||
|
"last_heartbeat_at": old_time,
|
||||||
|
}
|
||||||
|
state["devices"]["stale-2"] = {
|
||||||
|
"label": "Stale 2",
|
||||||
|
"viewing_session": None,
|
||||||
|
"view_mode": "grid",
|
||||||
|
"last_interaction_at": old_time,
|
||||||
|
"last_heartbeat_at": old_time,
|
||||||
|
}
|
||||||
|
removed = prune_devices(state)
|
||||||
|
assert sorted(removed) == ["stale-1", "stale-2"]
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""
|
||||||
|
Tests for coordinator/ttyd.py — ttyd process lifecycle management.
|
||||||
|
All 11 acceptance-criteria tests are defined here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import signal
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import coordinator.ttyd as ttyd_mod
|
||||||
|
from coordinator.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# autouse fixture — redirect PID paths to tmp_path for every test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def use_tmp_pid_dir(tmp_path, monkeypatch):
|
||||||
|
"""Redirect PID file I/O to a fresh temp directory for every test."""
|
||||||
|
tmp_pid_dir = tmp_path / "ttyd"
|
||||||
|
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
||||||
|
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
||||||
|
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper for mocking asyncio.create_subprocess_exec
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_ttyd_process(pid: int = 12345):
|
||||||
|
"""Return a mock ttyd process with the given PID."""
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.pid = pid
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# spawn_ttyd tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawn_ttyd_writes_pid_file():
|
||||||
|
"""spawn_ttyd() must write the process PID to TTYD_PID_PATH."""
|
||||||
|
mock_proc = _make_mock_ttyd_process(pid=99999)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
new=AsyncMock(return_value=mock_proc),
|
||||||
|
):
|
||||||
|
await spawn_ttyd("my-session")
|
||||||
|
|
||||||
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||||
|
assert pid_path.exists(), "PID file was not created"
|
||||||
|
assert pid_path.read_text().strip() == "99999"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawn_ttyd_uses_correct_command():
|
||||||
|
"""spawn_ttyd() must call ttyd with args: -W -m 3 -p 7682 tmux attach -t <name>."""
|
||||||
|
mock_proc = _make_mock_ttyd_process(pid=54321)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
new=AsyncMock(return_value=mock_proc),
|
||||||
|
) as mock_create:
|
||||||
|
await spawn_ttyd("test-session")
|
||||||
|
|
||||||
|
call_args = mock_create.call_args[0]
|
||||||
|
assert list(call_args) == [
|
||||||
|
"ttyd",
|
||||||
|
"-W",
|
||||||
|
"-m",
|
||||||
|
"3",
|
||||||
|
"-p",
|
||||||
|
"7682",
|
||||||
|
"tmux",
|
||||||
|
"attach",
|
||||||
|
"-t",
|
||||||
|
"test-session",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawn_ttyd_returns_process_object():
|
||||||
|
"""spawn_ttyd() must return the process object from create_subprocess_exec."""
|
||||||
|
mock_proc = _make_mock_ttyd_process(pid=11111)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
new=AsyncMock(return_value=mock_proc),
|
||||||
|
):
|
||||||
|
result = await spawn_ttyd("another-session")
|
||||||
|
|
||||||
|
assert result is mock_proc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# kill_ttyd tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kill_ttyd_returns_false_when_no_pid_file():
|
||||||
|
"""kill_ttyd() returns False when no PID file exists."""
|
||||||
|
# autouse fixture ensures no PID file is present
|
||||||
|
result = await kill_ttyd()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kill_ttyd_reads_pid_file_and_sends_sigterm():
|
||||||
|
"""kill_ttyd() reads the PID file and sends SIGTERM to the running process."""
|
||||||
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||||
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pid_path.write_text("12345")
|
||||||
|
|
||||||
|
kill_calls = []
|
||||||
|
|
||||||
|
def mock_os_kill(pid, sig):
|
||||||
|
kill_calls.append((pid, sig))
|
||||||
|
# First existence-check (sig=0) succeeds; subsequent sig=0 calls raise
|
||||||
|
if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1:
|
||||||
|
raise ProcessLookupError
|
||||||
|
|
||||||
|
with patch("os.kill", side_effect=mock_os_kill):
|
||||||
|
result = await kill_ttyd()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
||||||
|
assert len(sigterm_calls) == 1
|
||||||
|
assert sigterm_calls[0][0] == 12345
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kill_ttyd_removes_pid_file():
|
||||||
|
"""kill_ttyd() removes the PID file regardless of whether process was alive."""
|
||||||
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||||
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pid_path.write_text("12345")
|
||||||
|
|
||||||
|
def mock_os_kill(pid, sig):
|
||||||
|
# All os.kill calls raise ProcessLookupError — simulates already-dead process
|
||||||
|
raise ProcessLookupError
|
||||||
|
|
||||||
|
with patch("os.kill", side_effect=mock_os_kill):
|
||||||
|
result = await kill_ttyd()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert not pid_path.exists(), "PID file should be removed after kill_ttyd()"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kill_ttyd_handles_process_already_dead():
|
||||||
|
"""kill_ttyd() returns True and clears state when process is already gone."""
|
||||||
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||||
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pid_path.write_text("99999")
|
||||||
|
|
||||||
|
# Simulate process already dead: os.kill(pid, 0) raises ProcessLookupError
|
||||||
|
with patch("os.kill", side_effect=ProcessLookupError):
|
||||||
|
result = await kill_ttyd()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert not pid_path.exists(), (
|
||||||
|
"PID file should be removed when process was already dead"
|
||||||
|
)
|
||||||
|
assert ttyd_mod._active_process is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# kill_orphan_ttyd tests
|
||||||
|
#
|
||||||
|
# kill_orphan_ttyd() is a thin delegation to kill_ttyd(). These tests verify
|
||||||
|
# both the delegation wiring and that behaviour is consistent with kill_ttyd().
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kill_orphan_ttyd_returns_false_when_no_pid_file():
|
||||||
|
"""kill_orphan_ttyd() returns False when no PID file exists (no orphan)."""
|
||||||
|
# autouse fixture ensures no PID file is present
|
||||||
|
result = await kill_orphan_ttyd()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kill_orphan_ttyd_kills_running_process():
|
||||||
|
"""kill_orphan_ttyd() kills a running orphan process and returns True."""
|
||||||
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||||
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pid_path.write_text("55555")
|
||||||
|
|
||||||
|
kill_calls = []
|
||||||
|
|
||||||
|
def mock_os_kill(pid, sig):
|
||||||
|
kill_calls.append((pid, sig))
|
||||||
|
# First existence-check (sig=0) succeeds; subsequent sig=0 calls raise
|
||||||
|
if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1:
|
||||||
|
raise ProcessLookupError
|
||||||
|
|
||||||
|
with patch("os.kill", side_effect=mock_os_kill):
|
||||||
|
result = await kill_orphan_ttyd()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert not pid_path.exists(), "PID file should be removed after kill_orphan_ttyd()"
|
||||||
|
sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
||||||
|
assert len(sigterm_calls) == 1
|
||||||
|
assert sigterm_calls[0][0] == 55555
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kill_orphan_ttyd_handles_pid_file_with_dead_process():
|
||||||
|
"""kill_orphan_ttyd() handles a stale PID file whose process is already gone."""
|
||||||
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||||
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pid_path.write_text("77777")
|
||||||
|
|
||||||
|
with patch("os.kill", side_effect=ProcessLookupError):
|
||||||
|
result = await kill_orphan_ttyd()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert not pid_path.exists(), "PID file should be removed after orphan cleanup"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_kill_orphan_ttyd_handles_invalid_pid_file_content():
|
||||||
|
"""kill_orphan_ttyd() gracefully handles a PID file with non-integer content."""
|
||||||
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||||
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pid_path.write_text("not-a-pid")
|
||||||
|
|
||||||
|
# Should not raise and should clean up the file.
|
||||||
|
# kill_ttyd() calls pid_path.unlink(missing_ok=True) before returning False
|
||||||
|
# on invalid content, so the file is removed even though no kill occurred.
|
||||||
|
result = await kill_orphan_ttyd()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert not pid_path.exists(), "Invalid PID file should be removed"
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
ttyd process lifecycle management for the tmux-web coordinator.
|
||||||
|
|
||||||
|
Constants:
|
||||||
|
TTYD_PID_DIR — directory for the PID file (default: ~/.local/share/tmux-web/)
|
||||||
|
TTYD_PID_PATH — full path to the PID file (TTYD_PID_DIR / 'ttyd.pid')
|
||||||
|
TTYD_PORT — port ttyd listens on (7682)
|
||||||
|
|
||||||
|
Module state:
|
||||||
|
_active_process — the currently running ttyd subprocess (or None)
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
spawn_ttyd(session_name) — spawn ttyd attached to a tmux session, write PID file
|
||||||
|
kill_ttyd() — kill the running ttyd process, clean up PID file
|
||||||
|
kill_orphan_ttyd() — kill any orphaned ttyd from a previous coordinator run
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths and constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_default_ttyd_pid_dir = Path.home() / ".local" / "share" / "tmux-web"
|
||||||
|
TTYD_PID_DIR: Path = Path(os.environ.get("TMUX_WEB_STATE_DIR", _default_ttyd_pid_dir))
|
||||||
|
TTYD_PID_PATH: Path = TTYD_PID_DIR / "ttyd.pid"
|
||||||
|
|
||||||
|
TTYD_PORT: int = 7682
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_active_process: asyncio.subprocess.Process | None = None
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def kill_ttyd() -> bool:
|
||||||
|
"""Kill the running ttyd process and clean up the PID file.
|
||||||
|
|
||||||
|
Reads the PID from TTYD_PID_PATH. If no PID file exists, returns False.
|
||||||
|
If the file content is not a valid integer, removes the file and returns False.
|
||||||
|
|
||||||
|
Checks whether the process is alive via ``os.kill(pid, 0)``. If the
|
||||||
|
process is already gone (ProcessLookupError), cleans up and returns True.
|
||||||
|
Otherwise sends SIGTERM and polls every 0.1 s for up to 2 s waiting for
|
||||||
|
the process to exit. The PID file and ``_active_process`` are cleared in
|
||||||
|
all cases before returning.
|
||||||
|
|
||||||
|
Uses ``asyncio.sleep`` for polling to avoid blocking the event loop.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True — process was killed or was already dead.
|
||||||
|
False — no PID file found, or PID file contained invalid content.
|
||||||
|
"""
|
||||||
|
global _active_process
|
||||||
|
|
||||||
|
if not TTYD_PID_PATH.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = int(TTYD_PID_PATH.read_text().strip())
|
||||||
|
except ValueError:
|
||||||
|
TTYD_PID_PATH.unlink(missing_ok=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check whether the process is still alive.
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
# Already dead — clean up and report success.
|
||||||
|
TTYD_PID_PATH.unlink(missing_ok=True)
|
||||||
|
_active_process = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Process is alive — ask it to terminate.
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
|
||||||
|
# Poll up to 2 s for the process to exit, yielding to the event loop each iteration.
|
||||||
|
deadline = time.time() + 2.0
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Always clean up regardless of whether the process exited in time.
|
||||||
|
TTYD_PID_PATH.unlink(missing_ok=True)
|
||||||
|
_active_process = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def kill_orphan_ttyd() -> bool:
|
||||||
|
"""Kill any orphaned ttyd process left over from a previous coordinator run.
|
||||||
|
|
||||||
|
On coordinator startup, checks for a stale PID file from a previous run.
|
||||||
|
If found, kills the process (if still running) and removes the PID file.
|
||||||
|
Prevents two ttyd instances running simultaneously after a coordinator
|
||||||
|
restart or crash.
|
||||||
|
|
||||||
|
Delegates to kill_ttyd() for all process management and PID file cleanup.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True — an orphan was found (process was dead or alive).
|
||||||
|
False — no PID file found, or PID file contained invalid content.
|
||||||
|
"""
|
||||||
|
return await kill_ttyd()
|
||||||
|
|
||||||
|
|
||||||
|
async def spawn_ttyd(session_name: str) -> asyncio.subprocess.Process:
|
||||||
|
"""Spawn a ttyd process attached to *session_name* via ``tmux attach``.
|
||||||
|
|
||||||
|
Runs::
|
||||||
|
|
||||||
|
ttyd -W -m 3 -p 7682 tmux attach -t <session_name>
|
||||||
|
|
||||||
|
stdout and stderr are discarded (DEVNULL). The PID is written to
|
||||||
|
TTYD_PID_PATH. The process handle is stored in ``_active_process``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_name: The tmux session name to attach to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The asyncio.subprocess.Process object for the spawned ttyd.
|
||||||
|
"""
|
||||||
|
global _active_process
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"ttyd",
|
||||||
|
"-W",
|
||||||
|
"-m",
|
||||||
|
"3",
|
||||||
|
"-p",
|
||||||
|
str(TTYD_PORT),
|
||||||
|
"tmux",
|
||||||
|
"attach",
|
||||||
|
"-t",
|
||||||
|
session_name,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write PID file (create parent dirs if needed)
|
||||||
|
TTYD_PID_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
TTYD_PID_PATH.write_text(str(proc.pid))
|
||||||
|
|
||||||
|
_active_process = proc
|
||||||
|
return proc
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# Web-Tmux Dashboard Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build a browser-based dashboard for monitoring and interacting with ~12 running tmux sessions on a home/dev device, accessible from multiple devices (laptop, mobile, tablet) over Tailscale.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Managing a dozen tmux sessions across multiple devices requires constant SSH-ing and manual `tmux attach` commands. There's no unified view of what's running, no bell notifications when something needs attention, and no way to glance at session output from a phone. This project solves that by providing a live overview grid with capture-pane snapshots and on-demand interactive terminal access through the browser.
|
||||||
|
|
||||||
|
## Tool Decision: ttyd
|
||||||
|
|
||||||
|
Six tools were evaluated for browser-based terminal access:
|
||||||
|
|
||||||
|
| Tool | Verdict | Reason |
|
||||||
|
|------|---------|--------|
|
||||||
|
| **ttyd** | **Chosen** | Single C binary, wraps `tmux attach` natively with no SSH layer. ~5-10 MB memory. Actively maintained (11.3k stars, March 2024 release). Built-in basic auth, mTLS support, header-based auth for proxy delegation, `-m N` connection limits, `-W` writable mode. |
|
||||||
|
| WeTTY | Rejected | Requires SSH daemon + Node.js runtime. Higher complexity for the same outcome. |
|
||||||
|
| GoTTY | Rejected | Original abandoned 2017. Active fork has smaller community than ttyd. |
|
||||||
|
| Sshwifty | Rejected | Browser SSH client, not a terminal server. Solves a different problem. |
|
||||||
|
| Guacamole | Rejected | Java/Tomcat + 3 containers. Enterprise-scale overkill. |
|
||||||
|
| shellinabox | Rejected | No WebSocket support. Effectively a dead project. |
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
**Capture-pane overview + single live ttyd session.**
|
||||||
|
|
||||||
|
The overview grid renders text snapshots via `tmux capture-pane -p -t <session>` (polled every 2s) into styled monospace `<pre>` blocks. A single `ttyd` instance handles whichever session is currently expanded. This avoids resize conflicts and simultaneous WebSocket pile-ups. ttyd stays running persistently (not tied to browser sessions).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three components running directly on the host (no Docker — need access to host tmux sessions), plus a reverse proxy:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Caddy (reverse proxy) │
|
||||||
|
│ - HTTPS termination │
|
||||||
|
│ - Basic auth (basicauth directive) │
|
||||||
|
│ - / and /api/* → Coordinator (port 8080) │
|
||||||
|
│ - /terminal/* → active ttyd (port 7682) │
|
||||||
|
└─────────────┬──────────────────────────────┬────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────────┐ ┌──────────────────────────────┐
|
||||||
|
│ Session Coordinator │ │ ttyd (C binary) │
|
||||||
|
│ Python server (~150 LOC)│ │ Spawned on demand │
|
||||||
|
│ Port 8080 │ │ Port 7682 │
|
||||||
|
│ - Dashboard HTML/JS/CSS │ │ - One instance at a time │
|
||||||
|
│ - JSON API │ │ - tmux attach -t {name} │
|
||||||
|
│ - State management │ │ - -m 3 (multi-device) │
|
||||||
|
│ - Bell detection │ │ - -W (writable) │
|
||||||
|
│ - ttyd lifecycle │ │ │
|
||||||
|
└──────────────────────────┘ └──────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ Dashboard Frontend │
|
||||||
|
│ Vanilla HTML/CSS/JS │
|
||||||
|
│ - CSS auto-fill grid │
|
||||||
|
│ - xterm.js terminal │
|
||||||
|
│ - Bell notifications │
|
||||||
|
│ - PWA support │
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Supervisor:** The Coordinator runs as a systemd service for automatic restart after crashes.
|
||||||
|
|
||||||
|
**Network access:** Tailscale handles network-level access control. Caddy's basic auth serves as a local backstop.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Session Coordinator (Python Server)
|
||||||
|
|
||||||
|
A lightweight Python server (~150 lines) serving the dashboard and exposing a JSON API:
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/api/state` | `GET` | Returns full persistent state (active session, session order, bell state) |
|
||||||
|
| `/api/sessions` | `GET` | Returns all running tmux sessions with capture-pane snapshots and bell flags |
|
||||||
|
| `/api/sessions/{name}/connect` | `POST` | Kills current ttyd, spawns fresh one on port 7682 for named session, updates state |
|
||||||
|
| `/api/sessions/current` | `DELETE` | Terminates active ttyd, clears active session from state |
|
||||||
|
| `/api/state` | `PATCH` | Updates session ordering |
|
||||||
|
|
||||||
|
The coordinator owns all state. Browsers are views; the coordinator is the source of truth.
|
||||||
|
|
||||||
|
### ttyd Instance
|
||||||
|
|
||||||
|
One instance at a time, always on port 7682:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ttyd -W -m 3 -p 7682 tmux attach -t {name}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `-W` — writable mode
|
||||||
|
- `-m 3` — allows laptop + tablet + phone simultaneously
|
||||||
|
- Killed and respawned when switching sessions
|
||||||
|
- Stays running when browser disconnects (persists until explicitly closed)
|
||||||
|
|
||||||
|
### Dashboard Frontend
|
||||||
|
|
||||||
|
Vanilla HTML/CSS/JS (no framework).
|
||||||
|
|
||||||
|
**Desktop layout:**
|
||||||
|
- CSS `auto-fill` grid with `minmax(360px, 1fr)` — column count emerges from viewport (4 at 2560px, 2 at 900px, 1 below 600px)
|
||||||
|
- Fixed 300px tile height
|
||||||
|
- `capture-pane` text rendered in `<pre>` blocks, showing bottom of output (most recent lines), faded top edge, no scrollbars
|
||||||
|
|
||||||
|
**Mobile layout:**
|
||||||
|
- Three-tier priority list:
|
||||||
|
1. Sessions with bells — expanded, 4-6 output lines
|
||||||
|
2. Recently active (< 5 min) — 1 line preview
|
||||||
|
3. Idle — name + timestamp only
|
||||||
|
- Tap → full-screen terminal
|
||||||
|
|
||||||
|
**Mode transition:** Zoom-in-place — selected tile expands from grid position to fill viewport (~250ms), siblings fade. Reverse on return to grid.
|
||||||
|
|
||||||
|
### Caddy (Reverse Proxy)
|
||||||
|
|
||||||
|
- HTTPS termination
|
||||||
|
- `basicauth` directive for authentication
|
||||||
|
- Routes `/` and `/api/*` → Coordinator (port 8080)
|
||||||
|
- Routes `/terminal/*` → active ttyd (port 7682)
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Persistent State
|
||||||
|
|
||||||
|
State file location: `~/.local/share/tmux-web/state.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active_session": "work",
|
||||||
|
"session_order": ["main", "work", "logs", "music"],
|
||||||
|
"sessions": {
|
||||||
|
"logs-prod": {
|
||||||
|
"bell": {
|
||||||
|
"last_fired_at": 1711425900.0,
|
||||||
|
"seen_at": null,
|
||||||
|
"unseen_count": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"d-a1b2c3": {
|
||||||
|
"label": "Laptop Chrome",
|
||||||
|
"viewing_session": "dev-server",
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": 1711425950.0,
|
||||||
|
"last_heartbeat_at": 1711425958.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Atomic writes:** All `state.json` writes use `os.replace()` (write to temp, rename) — atomic on POSIX.
|
||||||
|
|
||||||
|
**Concurrent write safety:** Coordinator uses `asyncio` with a single write lock around `state.json` updates. All async paths serialize through this lock before calling `os.replace()`.
|
||||||
|
|
||||||
|
### Browser Connection Flow
|
||||||
|
|
||||||
|
1. New browser calls `GET /api/state`
|
||||||
|
2. If `active_session` is set, frontend immediately connects to already-running ttyd (no "reopen" needed)
|
||||||
|
3. Browser is a view; coordinator owns the truth
|
||||||
|
|
||||||
|
### ttyd Lifecycle
|
||||||
|
|
||||||
|
- Spawned when user explicitly opens a session via `POST /api/sessions/{name}/connect`
|
||||||
|
- Stays running even when browser disconnects
|
||||||
|
- New connections on any device attach to same running PTY
|
||||||
|
- Killed only when user explicitly closes the session or switches to another
|
||||||
|
|
||||||
|
### Multi-Device Connections
|
||||||
|
|
||||||
|
`-m 3` allows phone + tablet + desktop simultaneously. When a new device connects with a different terminal size, the coordinator issues:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux resize-window -t {session} -x {cols} -y {rows}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Heartbeat
|
||||||
|
|
||||||
|
Every 5 seconds (via WebSocket or poll):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "d-a1b2c3",
|
||||||
|
"viewing_session": "dev-server",
|
||||||
|
"view_mode": "fullscreen",
|
||||||
|
"last_interaction_at": 1711425950.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Devices are pruned from state after 5 minutes of silence.
|
||||||
|
|
||||||
|
## Bell Notification & Acknowledgement
|
||||||
|
|
||||||
|
### Core Model
|
||||||
|
|
||||||
|
One user, one awareness. Bell state is global — not per-device.
|
||||||
|
|
||||||
|
### Detection
|
||||||
|
|
||||||
|
Coordinator polls `tmux display-message -t {name} -p "#{window_bell_flag}"` alongside `capture-pane` every 2s. Transitions from false → true increment `unseen_count` and set `last_fired_at`.
|
||||||
|
|
||||||
|
### Clear Rule
|
||||||
|
|
||||||
|
A bell clears globally (on every device) when:
|
||||||
|
|
||||||
|
1. That session is open **full-screen** on a device (`view_mode == "fullscreen"`), AND
|
||||||
|
2. That device has had a **user interaction** within the last **60 seconds** (`last_interaction_at > now - 60s`)
|
||||||
|
|
||||||
|
If laptop is sitting idle on session #1, bells persist everywhere. When user taps session #1 on phone and interacts, it clears on all devices.
|
||||||
|
|
||||||
|
### `unseen_count`
|
||||||
|
|
||||||
|
Tracks rapid successive bells — shows "3 bells while you were away."
|
||||||
|
|
||||||
|
### Visual Indicators
|
||||||
|
|
||||||
|
- **Overview tiles:** Amber (#E8A040) pulsing dot/border on tiles with `unseen_count > 0`
|
||||||
|
- **Mobile list:** Amber badge next to session name; bell sessions sort to top automatically
|
||||||
|
|
||||||
|
### Browser Notifications API
|
||||||
|
|
||||||
|
On first load, request permission. When poll cycle detects bell transition (false → true), fire `new Notification("Activity in: " + name)`. Shows in OS notification center even when tab is backgrounded. Works on mobile when added to home screen (iOS Safari 16.4+, Android Chrome).
|
||||||
|
|
||||||
|
## UI/UX Design
|
||||||
|
|
||||||
|
### Session Tile Information Hierarchy
|
||||||
|
|
||||||
|
1. **Terminal content** (~90% of tile area) — the `<pre>` IS the tile
|
||||||
|
2. **Bell indicator** — amber (#E8A040) pulsing dot, top-right of header. Only chrome that interrupts scanning.
|
||||||
|
3. **Session name** — top-left, 12-13px, medium weight
|
||||||
|
4. **Last activity time** — top-right, 11-12px, dim. "2s ago" / "5m ago"
|
||||||
|
|
||||||
|
### Responsive Breakpoints
|
||||||
|
|
||||||
|
| Viewport | Columns | Notes |
|
||||||
|
|----------|---------|-------|
|
||||||
|
| < 600px | 1 (list) | Mobile priority list |
|
||||||
|
| 600-899px | 1 | Single-column tiles |
|
||||||
|
| 900-1199px | 2 | NOT 768px — at 768px with 2 columns, monospace tiles are only ~26 chars wide (unreadable) |
|
||||||
|
| 1200px+ | 4 | Full grid |
|
||||||
|
|
||||||
|
### Session Switcher
|
||||||
|
|
||||||
|
**Desktop:** Command palette triggered by `Ctrl+K`. Arrow keys + number keys 1-9 for navigation, type to fuzzy-filter by name, `G` to return to grid. No sidebar — sidebars steal terminal columns.
|
||||||
|
|
||||||
|
**Mobile:** Bottom sheet triggered by floating pill button (48x48px, bottom-right, semi-transparent when idle). 56px row height. Dismiss via swipe-down or tap outside.
|
||||||
|
|
||||||
|
### Connection Status
|
||||||
|
|
||||||
|
Minimal inline indicators — no persistent status bar. Polling freshness shown via tile timestamp staleness. WebSocket status shown only when degraded (brief "reconnecting..." overlay on the active terminal).
|
||||||
|
|
||||||
|
### xterm.js on Mobile
|
||||||
|
|
||||||
|
- Use `visualViewport` API (not `window.innerHeight`) to resize terminal rows when keyboard opens
|
||||||
|
- Never let terminal scroll behind keyboard — pin to fill visual viewport exactly
|
||||||
|
- `scrollback`: 500 rows on mobile (vs 5000 on desktop)
|
||||||
|
- Hint toward landscape in expanded mode (~80+ columns)
|
||||||
|
|
||||||
|
### PWA Configuration
|
||||||
|
|
||||||
|
- `display: standalone` — removes browser chrome
|
||||||
|
- `theme-color` matched to dark theme — prevents white flash on launch
|
||||||
|
- `orientation: any` — don't lock orientation
|
||||||
|
- `apple-mobile-web-app-capable` + `apple-mobile-web-app-status-bar-style: black-translucent` for iOS
|
||||||
|
- Suppress pinch-zoom on terminal view only
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- `prefers-reduced-motion: reduce` — respect for activity pulse animations
|
||||||
|
- Arrow keys to navigate grid tiles, Enter to expand, Escape to return
|
||||||
|
- Focus moves to xterm.js when session expands; returns to tile when closed
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| **Session disappears between polls** | Dropped from grid on next poll. If user was connected, ttyd exits, WebSocket closes, frontend returns to overview grid with "session ended" message. |
|
||||||
|
| **ttyd fails to start** | `POST /connect` returns error JSON. Frontend shows toast: "Couldn't connect to session 'work'". No crashed state. |
|
||||||
|
| **Orphaned ttyd on coordinator restart** | Startup routine reads PID file, checks if process is still running, kills any orphaned ttyd before registering new state. |
|
||||||
|
| **WebSocket drops mid-session** | ttyd reconnects on brief drops (ping/pong). Longer outages show "reconnecting..." overlay. On reconnect, frontend re-calls `/connect` for fresh ttyd process. |
|
||||||
|
| **No tmux sessions running** | Empty state: "No active tmux sessions — will update automatically." |
|
||||||
|
| **Stale bell state on coordinator restart** | Bell state in memory only; restart treats all bells as fresh. Worst case: see a bell already addressed. No missed bells. |
|
||||||
|
| **Concurrent state writes** | `asyncio` single write lock serializes all async paths before `os.replace()`. |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### 1. Coordinator Unit Tests (Python)
|
||||||
|
|
||||||
|
Session enumeration logic, ttyd process lifecycle (spawn, kill, orphan detection), state file atomic writes, bell detection and acknowledgement logic, active-device gate rule. Mock `subprocess` calls to tmux. No tmux or browser required. Fast, always runnable.
|
||||||
|
|
||||||
|
### 2. Integration Tests (Coordinator + tmux)
|
||||||
|
|
||||||
|
Spin up real tmux server in test environment (`tmux -L test-server`), create named sessions, verify full polling cycle — capture-pane output, bell flag detection and clearing, ttyd spawns on correct session, state file updated atomically. Requires tmux installed, no browser.
|
||||||
|
|
||||||
|
### 3. Browser/E2E Tests
|
||||||
|
|
||||||
|
Verify dashboard renders, tiles appear and update, bell indicators show/clear correctly, session expansion works, session switcher opens, mobile layout at 375px and 768px viewport widths.
|
||||||
|
|
||||||
|
### Manual Smoke Test Checklist
|
||||||
|
|
||||||
|
Run after each deploy:
|
||||||
|
|
||||||
|
- [ ] Open two browser tabs; verify state is consistent across both
|
||||||
|
- [ ] Close browser, reopen on different device; verify state restored correctly
|
||||||
|
- [ ] Trigger tmux bell with `printf '\a'`; verify appears on non-focused device within one poll cycle
|
||||||
|
- [ ] Let laptop sit idle on session #1 for 90s; trigger bell in session #1; verify mobile shows bell indicator
|
||||||
|
- [ ] Actively use session #1 on phone; verify bell clears on laptop within one poll cycle
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Escape key strategy** — double-Escape, `Ctrl+Shift+G`, or hold-duration? Affects daily usability in terminal-heavy workflows (Vim, less, fzf all use Escape).
|
||||||
|
2. **Tile density toggle** — single density or compact/comfortable toggle?
|
||||||
|
3. **Session reordering** — drag-to-reorder tiles, or always match tmux session creation order?
|
||||||
|
4. **Bell flag read behavior** — verify whether `tmux display-message -p "#{window_bell_flag}"` clears the flag or only reads it. Must be tested empirically before implementing bell logic.
|
||||||
|
5. **Polling interval** — 2s chosen, but 4-5s may be imperceptible for overview scanning and halves subprocess load. Worth benchmarking.
|
||||||
@@ -0,0 +1,935 @@
|
|||||||
|
// Phase 2b implementation — app.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Unix timestamp (seconds) into a relative time string.
|
||||||
|
* @param {number|null|undefined} ts - Unix timestamp in seconds
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatTimestamp(ts) {
|
||||||
|
if (ts == null) return '\u2014';
|
||||||
|
const diff = Math.floor(Date.now() / 1000 - ts);
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the priority label for a session object.
|
||||||
|
* @param {object} session
|
||||||
|
* @returns {'bell'|'idle'}
|
||||||
|
*/
|
||||||
|
function sessionPriority(session) {
|
||||||
|
const bell = session && session.bell;
|
||||||
|
if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) {
|
||||||
|
return 'bell';
|
||||||
|
}
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Priority rank map used by sortByPriority. Lower rank = higher priority. */
|
||||||
|
const PRIORITY_RANK = { bell: 0, active: 1, idle: 2 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort an array of sessions by priority (ascending rank).
|
||||||
|
* Returns a new array; does not mutate the original.
|
||||||
|
* @param {object[]} sessions
|
||||||
|
* @returns {object[]}
|
||||||
|
*/
|
||||||
|
function sortByPriority(sessions) {
|
||||||
|
return sessions.slice().sort((a, b) => {
|
||||||
|
const rankA = PRIORITY_RANK[sessionPriority(a)] ?? 2;
|
||||||
|
const rankB = PRIORITY_RANK[sessionPriority(b)] ?? 2;
|
||||||
|
return rankA - rankB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter an array of sessions by a search query string.
|
||||||
|
* Matches against session.name (case-insensitive substring match).
|
||||||
|
* Returns all sessions when query is empty or null.
|
||||||
|
* @param {object[]} sessions
|
||||||
|
* @param {string|null} query
|
||||||
|
* @returns {object[]}
|
||||||
|
*/
|
||||||
|
function filterByQuery(sessions, query) {
|
||||||
|
if (!query) return sessions;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return sessions.filter((s) => (s.name || '').toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect which sessions have transitioned to a new or increased bell/alert state.
|
||||||
|
* Builds a Map of previous session names to their unseen_count, then returns
|
||||||
|
* the names of next sessions whose bell.unseen_count > 0 AND > the previous count.
|
||||||
|
* @param {object[]} prev - previous sessions array
|
||||||
|
* @param {object[]} next - updated sessions array
|
||||||
|
* @returns {string[]} names of sessions that newly have or increased bell count
|
||||||
|
*/
|
||||||
|
function detectBellTransitions(prev, next) {
|
||||||
|
const prevMap = new Map(
|
||||||
|
(prev || []).map((s) => [s.name, (s.bell && s.bell.unseen_count) || 0]),
|
||||||
|
);
|
||||||
|
return (next || [])
|
||||||
|
.filter((s) => {
|
||||||
|
const unseen = s.bell && s.bell.unseen_count;
|
||||||
|
if (!unseen || unseen <= 0) return false;
|
||||||
|
const prevCount = prevMap.has(s.name) ? prevMap.get(s.name) : 0;
|
||||||
|
return unseen > prevCount;
|
||||||
|
})
|
||||||
|
.map((s) => s.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a pseudo-random device ID string.
|
||||||
|
* Format: 'd-' followed by 8 alphanumeric characters.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function generateDeviceId() {
|
||||||
|
return 'd-' + Math.random().toString(36).padEnd(10, '0').slice(2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a heartbeat payload object for the current device/view state.
|
||||||
|
* @param {string} device_id - The generated device identifier
|
||||||
|
* @param {string|null} viewing_session - The session currently being viewed, or null
|
||||||
|
* @param {string} view_mode - Current view mode (e.g. 'split', 'full')
|
||||||
|
* @param {number} last_interaction_at - Unix timestamp of last user interaction
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
function buildHeartbeatPayload(device_id, viewing_session, view_mode, last_interaction_at) {
|
||||||
|
const label =
|
||||||
|
typeof navigator !== 'undefined' && navigator.userAgent
|
||||||
|
? navigator.userAgent.slice(0, 50)
|
||||||
|
: 'unknown';
|
||||||
|
return {
|
||||||
|
device_id,
|
||||||
|
label,
|
||||||
|
viewing_session,
|
||||||
|
view_mode,
|
||||||
|
last_interaction_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Runtime constants ────────────────────────────────────────────────────────
|
||||||
|
const POLL_MS = 2000;
|
||||||
|
const HEARTBEAT_MS = 5000;
|
||||||
|
const MOBILE_THRESHOLD = 600;
|
||||||
|
|
||||||
|
// ─── App state ────────────────────────────────────────────────────────────────
|
||||||
|
let _deviceId = '';
|
||||||
|
let _currentSessions = [];
|
||||||
|
let _viewingSession = null;
|
||||||
|
let _viewMode = 'grid';
|
||||||
|
let _lastInteractionAt = Date.now() / 1000;
|
||||||
|
let _pollingTimer;
|
||||||
|
let _heartbeatTimer;
|
||||||
|
let _notificationPermission = 'default';
|
||||||
|
let _pollFailCount = 0;
|
||||||
|
|
||||||
|
// ─── DOM helpers ──────────────────────────────────────────────────────────────
|
||||||
|
function $(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function on(el, ev, fn) {
|
||||||
|
if (el) el.addEventListener(ev, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMobile() {
|
||||||
|
return window.innerWidth < MOBILE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fetch wrapper ────────────────────────────────────────────────────────────
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const opts = { method, headers: {} };
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Device ID ────────────────────────────────────────────────────────────────
|
||||||
|
function initDeviceId() {
|
||||||
|
const STORAGE_KEY = 'tmux-web-device-id';
|
||||||
|
try {
|
||||||
|
let id = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!id) {
|
||||||
|
id = generateDeviceId();
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, id); } catch (_) { /* blocked — ok */ }
|
||||||
|
}
|
||||||
|
_deviceId = id;
|
||||||
|
} catch (_) {
|
||||||
|
// localStorage blocked (Tracking Prevention, private browsing, etc.)
|
||||||
|
// Fall back to a session-only device ID — not persisted but functional
|
||||||
|
if (!_deviceId) _deviceId = generateDeviceId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Interaction tracking ─────────────────────────────────────────────────────
|
||||||
|
function trackInteraction() {
|
||||||
|
_lastInteractionAt = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State restoration ───────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Restore application state from the server on page load.
|
||||||
|
* Calls GET /api/state and, if an active session exists, re-opens it
|
||||||
|
* without POSTing to /connect (ttyd is already running).
|
||||||
|
* Always resolves — errors are logged as warnings so the app can start normally.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function restoreState() {
|
||||||
|
try {
|
||||||
|
const res = await api('GET', '/api/state');
|
||||||
|
const state = await res.json();
|
||||||
|
if (state.active_session) {
|
||||||
|
await openSession(state.active_session, { skipConnect: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[restoreState] could not restore previous session:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Connection status ──────────────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Update the #connection-status indicator element.
|
||||||
|
* @param {'ok'|'warn'|'err'} level
|
||||||
|
*/
|
||||||
|
function setConnectionStatus(level) {
|
||||||
|
const el = $('connection-status');
|
||||||
|
if (!el) return;
|
||||||
|
const map = {
|
||||||
|
ok: { text: '●', cls: 'connection-status--ok' },
|
||||||
|
warn: { text: '◌ slow', cls: 'connection-status--warn' },
|
||||||
|
err: { text: '✕ offline', cls: 'connection-status--err' },
|
||||||
|
};
|
||||||
|
const s = map[level];
|
||||||
|
if (!s) return;
|
||||||
|
el.textContent = s.text;
|
||||||
|
el.className = s.cls;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Session polling ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Fetch /api/sessions and update the UI. Called by startPolling.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function pollSessions() {
|
||||||
|
try {
|
||||||
|
const res = await api('GET', '/api/sessions');
|
||||||
|
const sessions = await res.json();
|
||||||
|
const prev = _currentSessions;
|
||||||
|
_currentSessions = sessions;
|
||||||
|
_pollFailCount = 0;
|
||||||
|
setConnectionStatus('ok');
|
||||||
|
renderGrid(sessions);
|
||||||
|
handleBellTransitions(prev, sessions);
|
||||||
|
updateSessionPill(sessions);
|
||||||
|
} catch (err) {
|
||||||
|
_pollFailCount++;
|
||||||
|
setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the session polling interval. Guards against double-start.
|
||||||
|
*/
|
||||||
|
function startPolling() {
|
||||||
|
if (_pollingTimer) return;
|
||||||
|
pollSessions();
|
||||||
|
_pollingTimer = setInterval(pollSessions, POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Grid rendering ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML special characters to safe entities.
|
||||||
|
* @param {string} str
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the HTML string for a single session tile.
|
||||||
|
* @param {object} session
|
||||||
|
* @param {number} index
|
||||||
|
* @param {boolean} mobile
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildTileHTML(session, index, mobile) {
|
||||||
|
const priority = sessionPriority(session);
|
||||||
|
const isBell = priority === 'bell';
|
||||||
|
const unseen = session.bell && session.bell.unseen_count;
|
||||||
|
|
||||||
|
let classes = 'session-tile';
|
||||||
|
if (isBell) classes += ' session-tile--bell';
|
||||||
|
if (mobile) classes += ` session-tile--tier-${priority}`;
|
||||||
|
|
||||||
|
const name = session.name || '';
|
||||||
|
const escapedName = escapeHtml(name);
|
||||||
|
const timeStr = formatTimestamp(session.last_activity_at || null);
|
||||||
|
|
||||||
|
// Bell indicator
|
||||||
|
let bellHtml = '';
|
||||||
|
if (unseen && unseen > 0) {
|
||||||
|
const countStr = unseen > 9 ? '9+' : String(unseen);
|
||||||
|
bellHtml = `<span class="tile-bell">${countStr}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last 20 lines of snapshot
|
||||||
|
const snapshot = session.snapshot || '';
|
||||||
|
const lastLines = snapshot.split('\n').slice(-20).join('\n');
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<article class="${classes}" data-session="${escapedName}" tabindex="0" role="listitem" aria-label="${escapedName}">` +
|
||||||
|
`<div class="tile-header">` +
|
||||||
|
`<span class="tile-name">${escapeHtml(name)}</span>` +
|
||||||
|
`<span class="tile-meta">${bellHtml}<span class="tile-time">${escapeHtml(timeStr)}</span></span>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="tile-body"><pre>${escapeHtml(lastLines)}</pre></div>` +
|
||||||
|
`</article>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the session grid. Shows empty state when no sessions exist.
|
||||||
|
* On mobile, sorts sessions by priority before rendering.
|
||||||
|
* Binds click and keydown handlers on each tile.
|
||||||
|
* @param {object[]} sessions
|
||||||
|
*/
|
||||||
|
function renderGrid(sessions) {
|
||||||
|
const grid = $('session-grid');
|
||||||
|
const emptyState = $('empty-state');
|
||||||
|
|
||||||
|
if (!sessions || sessions.length === 0) {
|
||||||
|
if (grid) grid.innerHTML = '';
|
||||||
|
if (emptyState) emptyState.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyState) emptyState.classList.add('hidden');
|
||||||
|
|
||||||
|
const mobile = isMobile();
|
||||||
|
const ordered = mobile ? sortByPriority(sessions) : sessions;
|
||||||
|
const html = ordered.map((session, index) => buildTileHTML(session, index, mobile)).join('');
|
||||||
|
if (grid) grid.innerHTML = html;
|
||||||
|
|
||||||
|
// Bind interaction handlers on each tile
|
||||||
|
document.querySelectorAll('.session-tile').forEach((tile) => {
|
||||||
|
on(tile, 'click', () => openSession(tile.dataset.session));
|
||||||
|
on(tile, 'keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
openSession(tile.dataset.session);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_viewMode === 'fullscreen') {
|
||||||
|
updatePillBell();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Notification permission ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request browser notification permission on first load.
|
||||||
|
* - If the Notification API is not available, returns immediately.
|
||||||
|
* - If already granted, records the state synchronously.
|
||||||
|
* - If default (not yet asked), calls requestPermission() and stores the result.
|
||||||
|
* - Otherwise (e.g. denied), stores the current permission value.
|
||||||
|
*/
|
||||||
|
function requestNotificationPermission() {
|
||||||
|
if (typeof Notification === 'undefined') return;
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
_notificationPermission = 'granted';
|
||||||
|
} else if (Notification.permission === 'default') {
|
||||||
|
Notification.requestPermission().then((permission) => {
|
||||||
|
_notificationPermission = permission;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_notificationPermission = Notification.permission;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bell transition notifications ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire OS notifications for sessions that have newly received a bell event.
|
||||||
|
* Only fires when the Notification permission is granted AND the browser tab
|
||||||
|
* is currently hidden (document.hidden === true).
|
||||||
|
* Uses a per-session tag so the OS deduplicates multiple bells into one
|
||||||
|
* notification per session.
|
||||||
|
* @param {object[]} prevSessions - sessions array from the previous poll
|
||||||
|
* @param {object[]} nextSessions - sessions array from the current poll
|
||||||
|
*/
|
||||||
|
function handleBellTransitions(prevSessions, nextSessions) {
|
||||||
|
const transitions = detectBellTransitions(prevSessions, nextSessions);
|
||||||
|
for (const name of transitions) {
|
||||||
|
if (_notificationPermission === 'granted' && document.hidden) {
|
||||||
|
// eslint-disable-next-line no-new
|
||||||
|
new Notification('Activity in: ' + name, {
|
||||||
|
body: 'tmux session needs attention',
|
||||||
|
tag: 'tmux-bell-' + name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Heartbeat ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a single heartbeat POST to /api/heartbeat.
|
||||||
|
* Catches errors and logs them as warnings — never throws.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function sendHeartbeat() {
|
||||||
|
try {
|
||||||
|
const payload = buildHeartbeatPayload(_deviceId, _viewingSession, _viewMode, _lastInteractionAt);
|
||||||
|
await api('POST', '/api/heartbeat', payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[sendHeartbeat] heartbeat failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the heartbeat interval. Guards against double-start.
|
||||||
|
* Calls sendHeartbeat() immediately, then every HEARTBEAT_MS milliseconds.
|
||||||
|
*/
|
||||||
|
function startHeartbeat() {
|
||||||
|
if (_heartbeatTimer) return;
|
||||||
|
sendHeartbeat();
|
||||||
|
_heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only helper: reset heartbeat timer state so tests can exercise startHeartbeat cleanly. */
|
||||||
|
function _resetHeartbeatTimer() {
|
||||||
|
if (_heartbeatTimer) clearInterval(_heartbeatTimer);
|
||||||
|
_heartbeatTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Toast notification ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a brief toast message.
|
||||||
|
* Removes the 'hidden' class immediately, then restores it after 3000ms.
|
||||||
|
* @param {string} msg
|
||||||
|
*/
|
||||||
|
function showToast(msg) {
|
||||||
|
const el = $('toast');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
setTimeout(() => el.classList.add('hidden'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Session pill bell ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the floating session-pill bell indicator.
|
||||||
|
* Shows #session-pill-bell if any session other than _viewingSession has unseen bells.
|
||||||
|
*/
|
||||||
|
function updatePillBell() {
|
||||||
|
const el = $('session-pill-bell');
|
||||||
|
if (!el) return;
|
||||||
|
const hasBell = _currentSessions.some(
|
||||||
|
(s) => s.name !== _viewingSession && s.bell && s.bell.unseen_count > 0,
|
||||||
|
);
|
||||||
|
if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Session open / close ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a session in fullscreen view with a zoom transition.
|
||||||
|
* @param {string} name - session name
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.skipConnect] - if true, skip the /connect POST (ttyd already running)
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function openSession(name, opts = {}) {
|
||||||
|
_viewingSession = name;
|
||||||
|
_viewMode = 'fullscreen';
|
||||||
|
|
||||||
|
// Update expanded header
|
||||||
|
const nameEl = $('expanded-session-name');
|
||||||
|
if (nameEl) nameEl.textContent = name;
|
||||||
|
|
||||||
|
// Zoom animation: pin tile at current position, then animate to full viewport
|
||||||
|
const tile = document.querySelector(`[data-session="${name}"]`);
|
||||||
|
if (tile) {
|
||||||
|
const rect = tile.getBoundingClientRect();
|
||||||
|
tile.style.position = 'fixed';
|
||||||
|
tile.style.top = rect.top + 'px';
|
||||||
|
tile.style.left = rect.left + 'px';
|
||||||
|
tile.style.width = rect.width + 'px';
|
||||||
|
tile.style.height = rect.height + 'px';
|
||||||
|
tile.style.transition = 'none';
|
||||||
|
// Force reflow
|
||||||
|
void tile.offsetWidth;
|
||||||
|
tile.style.transition = 'all 250ms ease';
|
||||||
|
tile.style.top = '0';
|
||||||
|
tile.style.left = '0';
|
||||||
|
tile.style.width = '100vw';
|
||||||
|
tile.style.height = '100vh';
|
||||||
|
}
|
||||||
|
|
||||||
|
// After animation completes: switch views, then mount terminal
|
||||||
|
setTimeout(() => {
|
||||||
|
const overview = $('view-overview');
|
||||||
|
const expanded = $('view-expanded');
|
||||||
|
if (overview) overview.style.display = 'none';
|
||||||
|
if (expanded) {
|
||||||
|
expanded.classList.remove('hidden'); // must remove class — !important wins over style.display
|
||||||
|
expanded.classList.add('view--active'); // makes it display:flex
|
||||||
|
}
|
||||||
|
// Mount terminal AFTER view is visible so FitAddon measures real dimensions
|
||||||
|
if (window._openTerminal) window._openTerminal(name);
|
||||||
|
}, 260);
|
||||||
|
|
||||||
|
// Mobile pill
|
||||||
|
if (isMobile()) {
|
||||||
|
const pill = $('session-pill');
|
||||||
|
if (pill) {
|
||||||
|
pill.classList.remove('hidden'); // pill starts with hidden class
|
||||||
|
const pillLabel = $('session-pill-label');
|
||||||
|
if (pillLabel) pillLabel.textContent = name;
|
||||||
|
}
|
||||||
|
updatePillBell();
|
||||||
|
updateSessionPill(_currentSessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to session (before animation completes so ttyd is ready)
|
||||||
|
try {
|
||||||
|
if (!opts.skipConnect) {
|
||||||
|
await api('POST', `/api/sessions/${name}/connect`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || 'Connection failed');
|
||||||
|
return closeSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the current session and return to the grid view.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function closeSession() {
|
||||||
|
_viewMode = 'grid';
|
||||||
|
_viewingSession = null;
|
||||||
|
|
||||||
|
if (window._closeTerminal) window._closeTerminal();
|
||||||
|
|
||||||
|
// Fire-and-forget DELETE
|
||||||
|
api('DELETE', '/api/sessions/current').catch(() => {});
|
||||||
|
|
||||||
|
const expanded = $('view-expanded');
|
||||||
|
const overview = $('view-overview');
|
||||||
|
if (expanded) {
|
||||||
|
expanded.classList.add('hidden');
|
||||||
|
expanded.classList.remove('view--active');
|
||||||
|
}
|
||||||
|
if (overview) overview.style.display = ''; // overview uses view--active (no !important), style.display clears fine
|
||||||
|
|
||||||
|
const pill = $('session-pill');
|
||||||
|
if (pill) pill.classList.add('hidden');
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only helper: set _viewingSession directly. */
|
||||||
|
function _setViewingSession(name) {
|
||||||
|
_viewingSession = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Command palette state ────────────────────────────────────────────────────
|
||||||
|
const PALETTE_MAX_ITEMS = 9;
|
||||||
|
let _paletteSelectedIndex = 0;
|
||||||
|
let _paletteFilteredSessions = [];
|
||||||
|
let _paletteOpen = false;
|
||||||
|
let _paletteInputListener = null;
|
||||||
|
|
||||||
|
// ─── Command palette functions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the filtered session list inside #palette-list.
|
||||||
|
* Shows up to 9 items. Each item is a <li> with index number,
|
||||||
|
* session name, optional bell emoji, and timestamp.
|
||||||
|
*/
|
||||||
|
function renderPaletteList() {
|
||||||
|
const list = $('palette-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
const items = _paletteFilteredSessions.slice(0, PALETTE_MAX_ITEMS);
|
||||||
|
list.innerHTML = items
|
||||||
|
.map((session, i) => {
|
||||||
|
const isBell = sessionPriority(session) === 'bell';
|
||||||
|
const bell = isBell ? ' 🔔' : '';
|
||||||
|
const time = formatTimestamp(session.last_activity_at || null);
|
||||||
|
const name = escapeHtml(session.name || '');
|
||||||
|
return `<li class="palette-item" data-index="${i}">${i + 1} ${name}${bell} ${escapeHtml(time)}</li>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Bind click handlers on each item
|
||||||
|
list.querySelectorAll('.palette-item').forEach((item) => {
|
||||||
|
on(item, 'click', () => {
|
||||||
|
const idx = parseInt(item.dataset.index, 10);
|
||||||
|
const session = _paletteFilteredSessions[idx];
|
||||||
|
if (session) {
|
||||||
|
closePalette();
|
||||||
|
openSession(session.name).catch((err) => console.error('[renderPaletteList]', err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
highlightPaletteItem(_paletteSelectedIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the palette-item--selected class on the item at `index`.
|
||||||
|
* @param {number} index
|
||||||
|
*/
|
||||||
|
function highlightPaletteItem(index) {
|
||||||
|
const list = $('palette-list');
|
||||||
|
if (!list) return;
|
||||||
|
list.querySelectorAll('.palette-item').forEach((item, i) => {
|
||||||
|
if (i === index) {
|
||||||
|
item.classList.add('palette-item--selected');
|
||||||
|
} else {
|
||||||
|
item.classList.remove('palette-item--selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the command palette.
|
||||||
|
* Shows #command-palette, copies _currentSessions to _paletteFilteredSessions,
|
||||||
|
* renders the list, resets selection index, focuses #palette-input, and binds
|
||||||
|
* the input event listener.
|
||||||
|
*/
|
||||||
|
function openPalette() {
|
||||||
|
_paletteOpen = true;
|
||||||
|
_paletteFilteredSessions = _currentSessions.slice();
|
||||||
|
_paletteSelectedIndex = 0;
|
||||||
|
|
||||||
|
const palette = $('command-palette');
|
||||||
|
if (palette) palette.classList.remove('hidden'); // palette starts with hidden class
|
||||||
|
|
||||||
|
renderPaletteList();
|
||||||
|
|
||||||
|
const input = $('palette-input');
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
input.focus();
|
||||||
|
if (_paletteInputListener) {
|
||||||
|
input.removeEventListener('input', _paletteInputListener);
|
||||||
|
}
|
||||||
|
_paletteInputListener = onPaletteInput;
|
||||||
|
input.addEventListener('input', _paletteInputListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the command palette.
|
||||||
|
* Hides #command-palette and removes the input event listener.
|
||||||
|
*/
|
||||||
|
function closePalette() {
|
||||||
|
_paletteOpen = false;
|
||||||
|
|
||||||
|
const palette = $('command-palette');
|
||||||
|
if (palette) palette.classList.add('hidden');
|
||||||
|
|
||||||
|
const input = $('palette-input');
|
||||||
|
if (input && _paletteInputListener) {
|
||||||
|
input.removeEventListener('input', _paletteInputListener);
|
||||||
|
_paletteInputListener = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle input events on #palette-input.
|
||||||
|
* Filters sessions by the current query, re-renders the list, resets selection.
|
||||||
|
* @param {Event} e
|
||||||
|
*/
|
||||||
|
function onPaletteInput(e) {
|
||||||
|
const query = e && e.target ? e.target.value : '';
|
||||||
|
_paletteFilteredSessions = filterByQuery(_currentSessions, query);
|
||||||
|
_paletteSelectedIndex = 0;
|
||||||
|
renderPaletteList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keydown events inside the command palette.
|
||||||
|
* ArrowDown/Up moves selection, Enter opens selected session,
|
||||||
|
* Escape closes palette, G closes palette + returns to grid,
|
||||||
|
* number keys 1-9 jump directly to that item.
|
||||||
|
* @param {KeyboardEvent} e
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function handlePaletteKeydown(e) {
|
||||||
|
const visibleCount = Math.min(_paletteFilteredSessions.length, PALETTE_MAX_ITEMS);
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closePalette();
|
||||||
|
} else if (e.key === 'g' || e.key === 'G') {
|
||||||
|
e.preventDefault();
|
||||||
|
closePalette();
|
||||||
|
await closeSession();
|
||||||
|
} else if (visibleCount > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
_paletteSelectedIndex = (_paletteSelectedIndex + 1) % visibleCount;
|
||||||
|
highlightPaletteItem(_paletteSelectedIndex);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
_paletteSelectedIndex = (_paletteSelectedIndex - 1 + visibleCount) % visibleCount;
|
||||||
|
highlightPaletteItem(_paletteSelectedIndex);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const session = _paletteFilteredSessions[_paletteSelectedIndex];
|
||||||
|
if (session) {
|
||||||
|
closePalette();
|
||||||
|
await openSession(session.name);
|
||||||
|
}
|
||||||
|
} else if (e.key >= '1' && e.key <= '9') {
|
||||||
|
const idx = parseInt(e.key, 10) - 1;
|
||||||
|
if (idx < visibleCount) {
|
||||||
|
e.preventDefault();
|
||||||
|
const session = _paletteFilteredSessions[idx];
|
||||||
|
closePalette();
|
||||||
|
await openSession(session.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global keydown handler.
|
||||||
|
* When palette is open: delegates to handlePaletteKeydown.
|
||||||
|
* When in fullscreen with palette closed: backtick or Ctrl+K opens palette,
|
||||||
|
* Escape returns to grid.
|
||||||
|
* @param {KeyboardEvent} e
|
||||||
|
*/
|
||||||
|
function handleGlobalKeydown(e) {
|
||||||
|
if (_paletteOpen) {
|
||||||
|
handlePaletteKeydown(e).catch((err) => console.error('[handleGlobalKeydown]', err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_viewMode === 'fullscreen') {
|
||||||
|
if (e.key === '`' || (e.ctrlKey && e.key === 'k')) {
|
||||||
|
e.preventDefault();
|
||||||
|
openPalette();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the bottom sheet (mobile session switcher).
|
||||||
|
* Renders the current session list and removes the 'hidden' class.
|
||||||
|
*/
|
||||||
|
function openBottomSheet() {
|
||||||
|
var sheet = $('bottom-sheet');
|
||||||
|
if (!sheet) return;
|
||||||
|
renderSheetList();
|
||||||
|
sheet.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the bottom sheet.
|
||||||
|
* Adds the 'hidden' class and removes the dynamic backdrop listener.
|
||||||
|
*/
|
||||||
|
function closeBottomSheet() {
|
||||||
|
var sheet = $('bottom-sheet');
|
||||||
|
if (sheet) sheet.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the session list inside #sheet-list for the mobile bottom sheet.
|
||||||
|
* Sorts sessions by priority, builds <li> elements with bell indicator and timestamp,
|
||||||
|
* and binds click handlers to switch sessions.
|
||||||
|
*/
|
||||||
|
function renderSheetList() {
|
||||||
|
var list = $('sheet-list');
|
||||||
|
if (!list) return;
|
||||||
|
var sorted = sortByPriority(_currentSessions);
|
||||||
|
list.innerHTML = sorted.map(function(s) {
|
||||||
|
var hasBell = s.bell && s.bell.unseen_count > 0 &&
|
||||||
|
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
||||||
|
var isActive = s.name === _viewingSession;
|
||||||
|
var escapedName = escapeHtml(s.name || '');
|
||||||
|
return '<li class="sheet-item' + (isActive ? ' sheet-item--active' : '') + '"' +
|
||||||
|
' data-session="' + escapedName + '" role="option">' +
|
||||||
|
'<span class="sheet-item__name">' + escapedName + '</span>' +
|
||||||
|
(hasBell ? '<span class="sheet-item__bell">\uD83D\uDD14</span>' : '') +
|
||||||
|
'<span class="sheet-item__time">' + formatTimestamp(s.bell && s.bell.last_fired_at) + '</span>' +
|
||||||
|
'</li>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
list.querySelectorAll('.sheet-item').forEach(function(item) {
|
||||||
|
item.addEventListener('click', function() {
|
||||||
|
closeBottomSheet();
|
||||||
|
var name = item.dataset.session;
|
||||||
|
if (name !== _viewingSession) openSession(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the session pill bell badge when in fullscreen view.
|
||||||
|
* Shows #session-pill-bell if any other session (not currently viewed) has unseen bells.
|
||||||
|
* @param {object[]} sessions - full sessions array
|
||||||
|
*/
|
||||||
|
function updateSessionPill(sessions) {
|
||||||
|
if (_viewMode !== 'fullscreen') return;
|
||||||
|
var pillBell = $('session-pill-bell');
|
||||||
|
if (!pillBell) return;
|
||||||
|
var othersWithBell = sessions.filter(function(s) {
|
||||||
|
return s.name !== _viewingSession &&
|
||||||
|
s.bell && s.bell.unseen_count > 0 &&
|
||||||
|
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
||||||
|
});
|
||||||
|
if (othersWithBell.length > 0) {
|
||||||
|
pillBell.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
pillBell.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind all static (once-only) event listeners for the app UI.
|
||||||
|
* Called once after restoreState() resolves.
|
||||||
|
*/
|
||||||
|
function bindStaticEventListeners() {
|
||||||
|
on($('back-btn'), 'click', closeSession);
|
||||||
|
on($('palette-trigger'), 'click', openPalette);
|
||||||
|
on($('palette-backdrop'), 'click', closePalette);
|
||||||
|
document.addEventListener('keydown', handleGlobalKeydown);
|
||||||
|
on($('session-pill'), 'click', openBottomSheet);
|
||||||
|
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test-only helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Test-only: set _currentSessions directly. */
|
||||||
|
function _setCurrentSessions(sessions) {
|
||||||
|
_currentSessions = sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: set _paletteFilteredSessions directly. */
|
||||||
|
function _setPaletteFilteredSessions(sessions) {
|
||||||
|
_paletteFilteredSessions = sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: get _paletteFilteredSessions. */
|
||||||
|
function _getPaletteFilteredSessions() {
|
||||||
|
return _paletteFilteredSessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: set _paletteSelectedIndex directly. */
|
||||||
|
function _setPaletteSelectedIndex(index) {
|
||||||
|
_paletteSelectedIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: get _paletteSelectedIndex. */
|
||||||
|
function _getPaletteSelectedIndex() {
|
||||||
|
return _paletteSelectedIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: set _paletteOpen directly. */
|
||||||
|
function _setPaletteOpen(val) {
|
||||||
|
_paletteOpen = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: get _paletteOpen. */
|
||||||
|
function _isPaletteOpen() {
|
||||||
|
return _paletteOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: set _viewMode directly. */
|
||||||
|
function _setViewMode(mode) {
|
||||||
|
_viewMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initDeviceId();
|
||||||
|
document.addEventListener('keydown', trackInteraction);
|
||||||
|
document.addEventListener('click', trackInteraction);
|
||||||
|
document.addEventListener('touchstart', trackInteraction);
|
||||||
|
restoreState()
|
||||||
|
.then(() => {
|
||||||
|
startPolling();
|
||||||
|
startHeartbeat();
|
||||||
|
requestNotificationPermission();
|
||||||
|
bindStaticEventListeners();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[init] restoreState failed, retrying in 5s:', err);
|
||||||
|
setTimeout(() => startPolling(), POLL_MS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Conditional CommonJS export — must remain at the very bottom of this file.
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = {
|
||||||
|
formatTimestamp,
|
||||||
|
sessionPriority,
|
||||||
|
sortByPriority,
|
||||||
|
filterByQuery,
|
||||||
|
detectBellTransitions,
|
||||||
|
generateDeviceId,
|
||||||
|
buildHeartbeatPayload,
|
||||||
|
setConnectionStatus,
|
||||||
|
pollSessions,
|
||||||
|
startPolling,
|
||||||
|
escapeHtml,
|
||||||
|
buildTileHTML,
|
||||||
|
renderGrid,
|
||||||
|
requestNotificationPermission,
|
||||||
|
handleBellTransitions,
|
||||||
|
sendHeartbeat,
|
||||||
|
startHeartbeat,
|
||||||
|
_resetHeartbeatTimer,
|
||||||
|
showToast,
|
||||||
|
updatePillBell,
|
||||||
|
openSession,
|
||||||
|
closeSession,
|
||||||
|
_setViewingSession,
|
||||||
|
// Command palette
|
||||||
|
renderPaletteList,
|
||||||
|
highlightPaletteItem,
|
||||||
|
openPalette,
|
||||||
|
closePalette,
|
||||||
|
onPaletteInput,
|
||||||
|
handlePaletteKeydown,
|
||||||
|
handleGlobalKeydown,
|
||||||
|
bindStaticEventListeners,
|
||||||
|
openBottomSheet,
|
||||||
|
closeBottomSheet,
|
||||||
|
renderSheetList,
|
||||||
|
updateSessionPill,
|
||||||
|
// Test-only helpers
|
||||||
|
_setCurrentSessions,
|
||||||
|
_setPaletteFilteredSessions,
|
||||||
|
_getPaletteFilteredSessions,
|
||||||
|
_setPaletteSelectedIndex,
|
||||||
|
_getPaletteSelectedIndex,
|
||||||
|
_setPaletteOpen,
|
||||||
|
_isPaletteOpen,
|
||||||
|
_setViewMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 669 B |
|
After Width: | Height: | Size: 401 B |
@@ -0,0 +1,74 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#0D1117" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||||||
|
<link rel="stylesheet" href="/style.css" />
|
||||||
|
<title>muxplex</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── Overview view ─────────────────────────────────────────────── -->
|
||||||
|
<div id="view-overview" class="view view--active">
|
||||||
|
<header class="app-header">
|
||||||
|
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
|
||||||
|
<span id="connection-status"></span>
|
||||||
|
</header>
|
||||||
|
<div id="session-grid" class="session-grid" role="list"></div>
|
||||||
|
<div id="empty-state" class="empty-state hidden">No active tmux sessions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Expanded (terminal) view ────────────────────────────────────── -->
|
||||||
|
<div id="view-expanded" class="view hidden">
|
||||||
|
<header class="expanded-header">
|
||||||
|
<button id="back-btn" class="back-btn" aria-label="Back">←</button>
|
||||||
|
<span id="expanded-session-name" class="expanded-session-name"></span>
|
||||||
|
<button id="palette-trigger" class="palette-trigger" aria-label="Open command palette">⌘K</button>
|
||||||
|
</header>
|
||||||
|
<div id="terminal-container" class="terminal-container"></div>
|
||||||
|
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Command palette ─────────────────────────────────────────────── -->
|
||||||
|
<div id="command-palette" class="command-palette hidden" role="dialog" aria-modal="true" aria-label="Switch session">
|
||||||
|
<div class="command-palette__backdrop" id="palette-backdrop"></div>
|
||||||
|
<div class="command-palette__dialog">
|
||||||
|
<input id="palette-input" class="command-palette__input" type="text"
|
||||||
|
placeholder="Jump to session…" autocomplete="off" spellcheck="false">
|
||||||
|
<ul id="palette-list" class="command-palette__list" role="listbox" aria-label="Sessions"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bottom sheet (session switcher) ─────────────────────────────── -->
|
||||||
|
<div id="bottom-sheet" class="bottom-sheet hidden" role="dialog" aria-modal="true" aria-label="Switch session">
|
||||||
|
<div class="bottom-sheet__backdrop" id="sheet-backdrop"></div>
|
||||||
|
<div class="bottom-sheet__panel">
|
||||||
|
<div class="bottom-sheet__handle" aria-hidden="true"></div>
|
||||||
|
<ul id="sheet-list" class="bottom-sheet__list" role="listbox" aria-label="Sessions"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Session pill (persistent overlay button) ───────────────────── -->
|
||||||
|
<button id="session-pill" class="session-pill hidden" aria-label="Switch session">
|
||||||
|
<span id="session-pill-label" class="session-pill__label"></span>
|
||||||
|
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">🔔</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ── Toast notification ─────────────────────────────────────────── -->
|
||||||
|
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
|
||||||
|
<!-- ── Scripts ────────────────────────────────────────────────────── -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
<script src="/terminal.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "muxplex",
|
||||||
|
"short_name": "muxplex",
|
||||||
|
"description": "Browser-based tmux session dashboard",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#0D1117",
|
||||||
|
"theme_color": "#0D1117",
|
||||||
|
"icons": [
|
||||||
|
{"src": "/pwa-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||||
|
{"src": "/pwa-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,747 @@
|
|||||||
|
/* Design tokens, dark base theme, typography */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Background palette */
|
||||||
|
--bg: #0D1117; /* --color-bg-base */
|
||||||
|
--bg-secondary: #10131C; /* --color-bg-elevated */
|
||||||
|
--bg-tile: #10131C; /* --color-bg-elevated */
|
||||||
|
--bg-header: #0D1117; /* --color-bg-base (header matches page) */
|
||||||
|
--bg-overlay: rgba(13, 17, 23, 0.85); /* brand base with opacity */
|
||||||
|
--bg-tile-hover: #1A1F2B; /* --color-bg-surface */
|
||||||
|
|
||||||
|
/* Text palette */
|
||||||
|
--text: #F0F6FF; /* --color-text-primary */
|
||||||
|
--text-dim: #4A5060; /* --color-text-disabled */
|
||||||
|
--text-muted: #8E95A3; /* --color-text-secondary */
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border: #2A3040; /* --color-border-default */
|
||||||
|
--border-subtle: #1E2430; /* --color-border-subtle */
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--accent: #00D9F5; /* brand cyan */
|
||||||
|
--accent-hover: #00b8d1; /* slightly darker cyan for hover */
|
||||||
|
--accent-dim: rgba(0, 217, 245, 0.15);
|
||||||
|
|
||||||
|
/* Bell / notification */
|
||||||
|
--bell: #F1A640;
|
||||||
|
--bell-color: #F1A640; /* brand amber */
|
||||||
|
--activity-color: #F1A640;
|
||||||
|
--bell-glow: rgba(241, 166, 64, 0.25);
|
||||||
|
--bell-border: rgba(241, 166, 64, 0.6);
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--ok: #3fb950;
|
||||||
|
--warn: #d29922;
|
||||||
|
--err: #f85149;
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--tile-height: 300px;
|
||||||
|
--tile-min-width: 360px;
|
||||||
|
--grid-gap: 8px;
|
||||||
|
--grid-padding: 16px;
|
||||||
|
--header-height: 44px;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--t-zoom: 250ms ease-in-out;
|
||||||
|
--t-fast: 150ms ease;
|
||||||
|
--t-fade: 200ms ease;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Box-sizing reset */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility */
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
App layout
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.view {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view--active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
App header
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 var(--grid-padding);
|
||||||
|
background: var(--bg-header);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Session grid
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.session-grid {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--grid-padding);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(var(--tile-min-width), 1fr));
|
||||||
|
gap: var(--grid-gap);
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Session tile
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.session-tile {
|
||||||
|
height: var(--tile-height);
|
||||||
|
background: var(--bg-tile);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: border-color var(--t-fast), box-shadow var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-tile:hover,
|
||||||
|
.session-tile:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bell / notification tile */
|
||||||
|
.session-tile--bell {
|
||||||
|
border-color: var(--bell-border);
|
||||||
|
box-shadow: 0 0 0 1px var(--bell-border), inset 0 0 12px var(--bell-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Tile header
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.tile-header {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: var(--bg-header);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bell dot */
|
||||||
|
.tile-bell {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bell);
|
||||||
|
animation: bell-pulse 1.4s ease-in-out infinite;
|
||||||
|
margin-right: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bell-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Tile body
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.tile-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-pre {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: pre;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade gradient at bottom of tile body */
|
||||||
|
.tile-body::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 40px;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--bg-tile));
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Empty state
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Responsive breakpoints
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@media (max-width:1199px) and (min-width:900px) {
|
||||||
|
:root {
|
||||||
|
--tile-min-width:420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:899px) and (min-width:600px) {
|
||||||
|
.session-grid {
|
||||||
|
grid-template-columns:1fr;
|
||||||
|
padding:8px;
|
||||||
|
}
|
||||||
|
.session-tile {
|
||||||
|
height:200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:599px) {
|
||||||
|
.session-grid {
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
gap:1px;
|
||||||
|
padding:0;
|
||||||
|
}
|
||||||
|
.session-tile {
|
||||||
|
height:auto;
|
||||||
|
border-radius:0;
|
||||||
|
border-left:none;
|
||||||
|
border-right:none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:599px) and (orientation:landscape) {
|
||||||
|
.session-grid {
|
||||||
|
padding:0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Expanded view
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
#view-expanded {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-header {
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg-header);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-session-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-trigger {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
padding: 0 4px; /* keep text off the side edges */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* xterm.js injects overflow-y: scroll on .xterm-viewport — override it */
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
overflow-y: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: var(--header-height) 0 0;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Zoom-in-place expand/collapse transition
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.session-tile--expanding {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition:
|
||||||
|
top var(--t-zoom),
|
||||||
|
left var(--t-zoom),
|
||||||
|
width var(--t-zoom),
|
||||||
|
height var(--t-zoom),
|
||||||
|
border-radius var(--t-zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-tile--expanded {
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-grid--dimming .session-tile:not(.session-tile--expanding) {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--t-fade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Bell count badge
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.tile-bell-count {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bell);
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Connection status indicator states
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.connection-status--ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status--warn {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status--err {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Toast notification
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bg-header);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: toast-in var(--t-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Mobile three-tier priority list (bell / active / idle)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@media (max-width:599px) {
|
||||||
|
/* Tier 1 — bell: expanded preview (~126px total) */
|
||||||
|
.session-tile--tier-bell .tile-body {
|
||||||
|
height: 90px;
|
||||||
|
}
|
||||||
|
.session-tile--tier-bell {
|
||||||
|
min-height: 126px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tier 2 — active: single-line preview (~60px total) */
|
||||||
|
.session-tile--tier-active .tile-body {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
.session-tile--tier-active {
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
.session-tile--tier-active .tile-pre {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tier 3 — idle: name-only (~44px total) */
|
||||||
|
.session-tile--tier-idle .tile-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.session-tile--tier-idle {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.session-tile--tier-idle .tile-header {
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.session-tile--tier-idle .tile-name {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Command palette overlay (desktop session switching)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.command-palette {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 15vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette__dialog {
|
||||||
|
position: relative;
|
||||||
|
width: min(440px, 90vw);
|
||||||
|
background: var(--bg-header);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette__input::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette__list {
|
||||||
|
list-style: none;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-item:hover,
|
||||||
|
.palette-item--selected {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-item__index {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-item__name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-item__bell {
|
||||||
|
color: var(--bell);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-item__time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Mobile Bottom Sheet ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.bottom-sheet {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet__panel {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-header);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: sheet-up var(--t-zoom) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sheet-up {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet__handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 10px auto 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet__list {
|
||||||
|
list-style: none;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 56px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-item:hover {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-item__name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-item__bell {
|
||||||
|
color: var(--bell);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-item__time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Floating Session Pill ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.session-pill {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--bg-header);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-pill:hover {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-pill__label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-pill__bell {
|
||||||
|
color: var(--bell);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Reduced Motion ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tile-bell {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-tile--expanding {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-grid--dimming .session-tile:not(.session-tile--expanding) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet__panel {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
App wordmark
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.app-wordmark {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.app-wordmark img {
|
||||||
|
height: 24px;
|
||||||
|
width: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// Phase 2b implementation — terminal.js
|
||||||
|
// xterm.js Terminal + FitAddon initialization (task-12)
|
||||||
|
|
||||||
|
// ─── Module-level state ───────────────────────────────────────────────────────
|
||||||
|
let _term = null;
|
||||||
|
let _fitAddon = null;
|
||||||
|
let _ws = null;
|
||||||
|
let _reconnectTimer = null;
|
||||||
|
let _currentSession = null;
|
||||||
|
let _vpHandler = null;
|
||||||
|
|
||||||
|
// ─── Forward declarations ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function connectWebSocket(name) {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const url = `${proto}//${location.host}/terminal/ws`;
|
||||||
|
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
||||||
|
var encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
||||||
|
|
||||||
|
function encodePayload(typeChar, str) {
|
||||||
|
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
|
||||||
|
var strBytes = encoder ? encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); }));
|
||||||
|
var payload = new Uint8Array(1 + strBytes.length);
|
||||||
|
payload[0] = typeChar;
|
||||||
|
payload.set(strBytes, 1);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register terminal event handlers (once — _ws captured by closure reference)
|
||||||
|
if (_term) {
|
||||||
|
_term.onData(function(data) {
|
||||||
|
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
||||||
|
// ttyd protocol: input is type 0x30 ('0') + UTF-8 keystroke bytes
|
||||||
|
_ws.send(encodePayload(0x30, data));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_term.onResize(function(size) {
|
||||||
|
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
||||||
|
// ttyd protocol: resize is type 0x31 ('1') + UTF-8 JSON
|
||||||
|
_ws.send(encodePayload(0x31, JSON.stringify({ columns: size.cols, rows: size.rows })));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
// 'tty' subprotocol is REQUIRED — without it ttyd never starts the PTY.
|
||||||
|
// Confirmed via raw Python WebSocket tests: ttyd accepts the TCP upgrade but
|
||||||
|
// sits completely silent (no child process spawned) when subprotocol is omitted.
|
||||||
|
_ws = new WebSocket(url, ['tty']);
|
||||||
|
_ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
_ws.addEventListener('open', function() {
|
||||||
|
if (reconnectOverlay) reconnectOverlay.classList.add('hidden');
|
||||||
|
// Step 1: TEXT frame auth handshake — ttyd checks AuthToken before starting PTY
|
||||||
|
_ws.send(JSON.stringify({ AuthToken: '' }));
|
||||||
|
// Step 2: BINARY frame with initial terminal dimensions — [0x31] + JSON({columns, rows})
|
||||||
|
if (_term) {
|
||||||
|
_ws.send(encodePayload(0x31, JSON.stringify({ columns: _term.cols, rows: _term.rows })));
|
||||||
|
}
|
||||||
|
// Auto-focus the terminal so user can type immediately without clicking
|
||||||
|
if (_term) _term.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
_ws.addEventListener('message', function(e) {
|
||||||
|
if (!_term) return;
|
||||||
|
if (e.data instanceof ArrayBuffer) {
|
||||||
|
var msg = new Uint8Array(e.data);
|
||||||
|
if (msg.length < 1) return;
|
||||||
|
var msgType = msg[0];
|
||||||
|
var payload = msg.slice(1);
|
||||||
|
if (msgType === 0x30) { // '0' = terminal output — write to xterm.js
|
||||||
|
_term.write(payload);
|
||||||
|
}
|
||||||
|
// 0x31 ('1') = window title, 0x32 ('2') = preferences — ignore for now
|
||||||
|
} else if (typeof e.data === 'string') {
|
||||||
|
_term.write(e.data); // fallback for text frames
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_ws.addEventListener('close', function() {
|
||||||
|
if (!_currentSession) return; // intentional close — don't reconnect
|
||||||
|
if (reconnectOverlay) reconnectOverlay.classList.remove('hidden');
|
||||||
|
_reconnectTimer = setTimeout(connect, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
_ws.addEventListener('error', function() {
|
||||||
|
console.warn('tmux-web: WebSocket error on', url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
function initVisualViewport() {
|
||||||
|
if (!window.visualViewport) return;
|
||||||
|
if (_vpHandler) window.visualViewport.removeEventListener('resize', _vpHandler);
|
||||||
|
|
||||||
|
_vpHandler = function() {
|
||||||
|
if (!_term || !_fitAddon) return;
|
||||||
|
var container = document.getElementById('terminal-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Resize container to fill visual viewport above keyboard
|
||||||
|
var headerHeight = 44; // matches --header-height CSS custom property
|
||||||
|
var vvh = window.visualViewport.height;
|
||||||
|
var termHeight = Math.max(100, vvh - headerHeight);
|
||||||
|
container.style.height = termHeight + 'px';
|
||||||
|
|
||||||
|
// Refit xterm.js to new container size
|
||||||
|
try { _fitAddon.fit(); } catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.visualViewport.addEventListener('resize', _vpHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Terminal creation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create (or recreate) the xterm.js Terminal and FitAddon instances.
|
||||||
|
* Disposes any existing terminal first.
|
||||||
|
* Stores the results in module-level _term and _fitAddon.
|
||||||
|
*/
|
||||||
|
function createTerminal() {
|
||||||
|
// Dispose any existing instance
|
||||||
|
if (_term) {
|
||||||
|
_term.dispose();
|
||||||
|
_term = null;
|
||||||
|
_fitAddon = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobile = window.innerWidth < 600;
|
||||||
|
|
||||||
|
_term = new window.Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: mobile ? 12 : 14,
|
||||||
|
fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace",
|
||||||
|
theme: {
|
||||||
|
background: '#000000',
|
||||||
|
foreground: '#c9d1d9',
|
||||||
|
cursor: '#58a6ff',
|
||||||
|
},
|
||||||
|
scrollback: mobile ? 500 : 5000,
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
_fitAddon = new window.FitAddon.FitAddon();
|
||||||
|
_term.loadAddon(_fitAddon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Open / close ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a terminal session inside #terminal-container.
|
||||||
|
* @param {string} sessionName
|
||||||
|
*/
|
||||||
|
function openTerminal(sessionName) {
|
||||||
|
_currentSession = sessionName;
|
||||||
|
|
||||||
|
const container = document.getElementById('terminal-container');
|
||||||
|
if (!container) {
|
||||||
|
console.warn('[openTerminal] #terminal-container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createTerminal();
|
||||||
|
|
||||||
|
_term.open(container);
|
||||||
|
|
||||||
|
if (_fitAddon) {
|
||||||
|
// requestAnimationFrame guarantees one full browser layout pass after the flex
|
||||||
|
// container becomes visible before fit() measures dimensions.
|
||||||
|
// iOS Safari defers flex layout — calling fit() synchronously here gives 0px width
|
||||||
|
// → 2-column terminal. The RAF and 500ms fallback fix this race condition.
|
||||||
|
// Falls back to immediate execution in Node.js test environments where RAF is absent.
|
||||||
|
var fitAddonRef = _fitAddon;
|
||||||
|
var raf = (typeof requestAnimationFrame !== 'undefined') ? requestAnimationFrame : function(fn) { fn(); };
|
||||||
|
raf(function() {
|
||||||
|
try { fitAddonRef.fit(); } catch (_) {}
|
||||||
|
// 500ms fallback for slow mobile layout engines (e.g. first paint on low-end devices)
|
||||||
|
setTimeout(function() {
|
||||||
|
try { if (_fitAddon) _fitAddon.fit(); } catch (_) {}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWebSocket(sessionName);
|
||||||
|
initVisualViewport(); /* defined in Task 14 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the current terminal session and clean up all resources.
|
||||||
|
*/
|
||||||
|
function closeTerminal() {
|
||||||
|
if (_vpHandler) {
|
||||||
|
if (window.visualViewport) window.visualViewport.removeEventListener('resize', _vpHandler);
|
||||||
|
_vpHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_reconnectTimer) {
|
||||||
|
clearTimeout(_reconnectTimer);
|
||||||
|
_reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_ws) {
|
||||||
|
_ws.close();
|
||||||
|
_ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_term) {
|
||||||
|
_term.dispose();
|
||||||
|
_term = null;
|
||||||
|
_fitAddon = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentSession = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Expose to app.js ─────────────────────────────────────────────────────────
|
||||||
|
window._openTerminal = openTerminal;
|
||||||
|
window._closeTerminal = closeTerminal;
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
// Tests for terminal.js — WebSocket + xterm.js integration
|
||||||
|
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a fresh copy of terminal.js with isolated module-level state.
|
||||||
|
* Returns { window } after the script has executed.
|
||||||
|
*/
|
||||||
|
function loadTerminal() {
|
||||||
|
// Delete from require cache so each test gets fresh module-level state
|
||||||
|
const modulePath = join(__dirname, '..', 'terminal.js');
|
||||||
|
delete require.cache[require.resolve(modulePath)];
|
||||||
|
|
||||||
|
// terminal.js reads: location.protocol, location.host, document.getElementById,
|
||||||
|
// window.Terminal, window.FitAddon, window.innerWidth
|
||||||
|
let capturedCloseHandler = null;
|
||||||
|
let capturedReconnectFn = null;
|
||||||
|
let capturedWsProtocols = null;
|
||||||
|
let capturedOnDataFn = null;
|
||||||
|
let capturedOnResizeFn = null;
|
||||||
|
let termWriteMessages = [];
|
||||||
|
let lastWsInstance = null;
|
||||||
|
|
||||||
|
let capturedWsUrl = null;
|
||||||
|
let onDataCallCount = 0;
|
||||||
|
let onResizeCallCount = 0;
|
||||||
|
let focusCallCount = 0;
|
||||||
|
|
||||||
|
const mockTerm = {
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
open: () => {},
|
||||||
|
onData: (fn) => { onDataCallCount++; capturedOnDataFn = fn; },
|
||||||
|
onResize: (fn) => { onResizeCallCount++; capturedOnResizeFn = fn; },
|
||||||
|
loadAddon: () => {},
|
||||||
|
dispose: () => {},
|
||||||
|
write: (data) => { termWriteMessages.push(data); },
|
||||||
|
focus: () => { focusCallCount++; },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture all messages sent via WebSocket.send()
|
||||||
|
const sentMessages = [];
|
||||||
|
|
||||||
|
// WebSocket mock — captures 'close' and 'open' handlers so we can fire them manually
|
||||||
|
class MockWebSocket {
|
||||||
|
constructor(_url, _protocols) {
|
||||||
|
this.readyState = 1; // OPEN
|
||||||
|
this.binaryType = '';
|
||||||
|
this._handlers = {};
|
||||||
|
lastWsInstance = this;
|
||||||
|
}
|
||||||
|
addEventListener(event, handler) {
|
||||||
|
this._handlers[event] = handler;
|
||||||
|
if (event === 'close') capturedCloseHandler = handler;
|
||||||
|
}
|
||||||
|
close() {}
|
||||||
|
send(data) { sentMessages.push(data); }
|
||||||
|
}
|
||||||
|
MockWebSocket.OPEN = 1;
|
||||||
|
|
||||||
|
// setTimeout mock: capture reconnect callback so we can fire it synchronously
|
||||||
|
const origSetTimeout = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (fn, _ms) => {
|
||||||
|
capturedReconnectFn = fn;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.WebSocket = MockWebSocket;
|
||||||
|
globalThis.location = { protocol: 'http:', host: 'localhost' };
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: (id) => {
|
||||||
|
if (id === 'terminal-container') return { appendChild: () => {} };
|
||||||
|
if (id === 'reconnect-overlay') return { classList: { add: () => {}, remove: () => {} } };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
addEventListener: () => {},
|
||||||
|
createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }),
|
||||||
|
};
|
||||||
|
globalThis.window = {
|
||||||
|
addEventListener: () => {},
|
||||||
|
location: { href: '' },
|
||||||
|
innerWidth: 1024,
|
||||||
|
Terminal: function Terminal() { return mockTerm; },
|
||||||
|
FitAddon: {
|
||||||
|
FitAddon: function FitAddon() { return { fit: () => {} }; },
|
||||||
|
},
|
||||||
|
_openTerminal: undefined,
|
||||||
|
_closeTerminal: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
require(modulePath);
|
||||||
|
|
||||||
|
// Restore setTimeout
|
||||||
|
globalThis.setTimeout = origSetTimeout;
|
||||||
|
|
||||||
|
// Find the most recently created MockWebSocket instance's open handler
|
||||||
|
// by pulling it from the instance created during openTerminal() call.
|
||||||
|
// We expose a fireOpen() helper so tests can simulate WebSocket connection.
|
||||||
|
let lastOpenHandler = null;
|
||||||
|
const OrigMockWS = globalThis.WebSocket;
|
||||||
|
globalThis.WebSocket = function MockWSTracker(url, protocols) {
|
||||||
|
capturedWsUrl = url;
|
||||||
|
capturedWsProtocols = protocols;
|
||||||
|
const inst = new OrigMockWS(url);
|
||||||
|
const origAddListener = inst.addEventListener.bind(inst);
|
||||||
|
inst.addEventListener = function(event, handler) {
|
||||||
|
if (event === 'open') lastOpenHandler = handler;
|
||||||
|
origAddListener(event, handler);
|
||||||
|
};
|
||||||
|
lastWsInstance = inst;
|
||||||
|
return inst;
|
||||||
|
};
|
||||||
|
globalThis.WebSocket.OPEN = 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
openTerminal: globalThis.window._openTerminal,
|
||||||
|
closeTerminal: globalThis.window._closeTerminal,
|
||||||
|
get onDataCallCount() { return onDataCallCount; },
|
||||||
|
get onResizeCallCount() { return onResizeCallCount; },
|
||||||
|
get sentMessages() { return sentMessages; },
|
||||||
|
get capturedWsUrl() { return capturedWsUrl; },
|
||||||
|
get capturedWsProtocols() { return capturedWsProtocols; },
|
||||||
|
get capturedOnDataFn() { return capturedOnDataFn; },
|
||||||
|
get capturedOnResizeFn() { return capturedOnResizeFn; },
|
||||||
|
get termWriteMessages() { return termWriteMessages; },
|
||||||
|
get focusCallCount() { return focusCallCount; },
|
||||||
|
fireClose() { if (capturedCloseHandler) capturedCloseHandler(); },
|
||||||
|
fireOpen() { if (lastOpenHandler) lastOpenHandler(); },
|
||||||
|
fireMessage(data) {
|
||||||
|
if (lastWsInstance && lastWsInstance._handlers['message']) {
|
||||||
|
lastWsInstance._handlers['message']({ data });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fireReconnect() { if (capturedReconnectFn) { capturedReconnectFn(); capturedReconnectFn = null; } },
|
||||||
|
// Expose so we can re-patch setTimeout for the actual calls
|
||||||
|
patchTimeout(fn) {
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (cb, _ms) => { capturedReconnectFn = cb; return 0; };
|
||||||
|
fn();
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('onData is registered exactly once after initial connect (no reconnect)', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
// Patch setTimeout so reconnect callbacks are captured but not auto-run
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('my-session');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.strictEqual(t.onDataCallCount, 1, 'onData should be registered exactly once');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onResize is registered exactly once after initial connect (no reconnect)', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('my-session');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.strictEqual(t.onResizeCallCount, 1, 'onResize should be registered exactly once');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onData is NOT re-registered after reconnect — count stays at 1', () => {
|
||||||
|
let reconnectFn = null;
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
// Patch setTimeout to capture reconnect callback
|
||||||
|
globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; };
|
||||||
|
|
||||||
|
t.openTerminal('my-session');
|
||||||
|
|
||||||
|
// Simulate WebSocket dropping — triggers close handler which schedules reconnect
|
||||||
|
t.fireClose();
|
||||||
|
|
||||||
|
// Fire the reconnect (calls connect() again)
|
||||||
|
if (reconnectFn) reconnectFn();
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
t.onDataCallCount,
|
||||||
|
1,
|
||||||
|
'onData should still be registered exactly once after a reconnect',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onResize is NOT re-registered after reconnect — count stays at 1', () => {
|
||||||
|
let reconnectFn = null;
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; };
|
||||||
|
|
||||||
|
t.openTerminal('my-session');
|
||||||
|
|
||||||
|
t.fireClose();
|
||||||
|
if (reconnectFn) reconnectFn();
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
t.onResizeCallCount,
|
||||||
|
1,
|
||||||
|
'onResize should still be registered exactly once after a reconnect',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onData count stays at 1 after multiple reconnects', () => {
|
||||||
|
let reconnectFn = null;
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; };
|
||||||
|
|
||||||
|
t.openTerminal('my-session');
|
||||||
|
|
||||||
|
// Reconnect 3 times
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
t.fireClose();
|
||||||
|
if (reconnectFn) { reconnectFn(); reconnectFn = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
t.onDataCallCount,
|
||||||
|
1,
|
||||||
|
'onData should be registered exactly once even after 3 reconnects',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_fitAddon is nulled out when closeTerminal is called', () => {
|
||||||
|
// This is a whitebox test: verify no crash on dispose + null
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('my-session');
|
||||||
|
// Should not throw
|
||||||
|
assert.doesNotThrow(() => t.closeTerminal(), 'closeTerminal should not throw');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initVisualViewport returns early without error when window.visualViewport is undefined', () => {
|
||||||
|
// Guard test: non-mobile environments have no visualViewport — must not throw
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
// globalThis.window has no visualViewport (see loadTerminal setup)
|
||||||
|
assert.strictEqual(globalThis.window.visualViewport, undefined,
|
||||||
|
'test pre-condition: window.visualViewport must be undefined');
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (fn, _ms) => 0;
|
||||||
|
|
||||||
|
// openTerminal internally calls initVisualViewport — must not throw
|
||||||
|
assert.doesNotThrow(() => t.openTerminal('test-session'),
|
||||||
|
'openTerminal (and initVisualViewport) should not throw when window.visualViewport is undefined');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ttyd protocol tests ──────────────────────────────────────────────────────
|
||||||
|
// ttyd 1.7.7 requires:
|
||||||
|
// 1. WebSocket subprotocol 'tty' — without it ttyd never starts the PTY
|
||||||
|
// 2. First message on open: TEXT frame '{"AuthToken":""}'
|
||||||
|
// 3. Second message on open: BINARY frame [0x31] + UTF-8({"columns":N,"rows":M})
|
||||||
|
// 4. Input keystrokes: BINARY [0x30] + UTF-8(keystroke)
|
||||||
|
// 5. Resize: BINARY [0x31] + UTF-8({"columns":N,"rows":M})
|
||||||
|
// 6. Received frames: 1-byte type prefix — 0x30=output (write to xterm), 0x31/0x32=ignore
|
||||||
|
|
||||||
|
test('connectWebSocket uses tty subprotocol', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('test-session');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
t.capturedWsProtocols,
|
||||||
|
['tty'],
|
||||||
|
"WebSocket must be constructed with ['tty'] subprotocol — without it ttyd never starts the PTY",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connectWebSocket sends text auth init as first message on open', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('test-session');
|
||||||
|
t.fireOpen();
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.ok(t.sentMessages.length >= 1, 'should have sent at least one message on open');
|
||||||
|
|
||||||
|
const firstMsg = t.sentMessages[0];
|
||||||
|
assert.strictEqual(typeof firstMsg, 'string',
|
||||||
|
`first message must be a text string (auth frame), got ${Object.prototype.toString.call(firstMsg)}`);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(firstMsg);
|
||||||
|
assert.strictEqual(parsed.AuthToken, '', 'AuthToken must be empty string');
|
||||||
|
assert.ok(!('columns' in parsed), 'auth-only TEXT frame should NOT contain columns');
|
||||||
|
assert.ok(!('rows' in parsed), 'auth-only TEXT frame should NOT contain rows');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connectWebSocket sends binary resize with 0x31 prefix as second message on open', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('test-session');
|
||||||
|
t.fireOpen();
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.ok(t.sentMessages.length >= 2, 'should have sent at least two messages on open (auth + resize)');
|
||||||
|
|
||||||
|
const resizeMsg = t.sentMessages[1];
|
||||||
|
assert.ok(resizeMsg instanceof Uint8Array,
|
||||||
|
`resize message must be binary Uint8Array, got ${Object.prototype.toString.call(resizeMsg)}`);
|
||||||
|
assert.strictEqual(resizeMsg[0], 0x31, 'first byte of resize message must be 0x31 (resize type)');
|
||||||
|
|
||||||
|
const payload = JSON.parse(Buffer.from(resizeMsg.slice(1)).toString('utf-8'));
|
||||||
|
assert.ok('columns' in payload, 'resize payload must contain columns');
|
||||||
|
assert.ok('rows' in payload, 'resize payload must contain rows');
|
||||||
|
assert.ok(typeof payload.columns === 'number' && payload.columns > 0,
|
||||||
|
`columns must be a positive number, got ${payload.columns}`);
|
||||||
|
assert.ok(typeof payload.rows === 'number' && payload.rows > 0,
|
||||||
|
`rows must be a positive number, got ${payload.rows}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onData sends input with 0x30 type prefix as binary frame', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('test-session');
|
||||||
|
t.fireOpen();
|
||||||
|
|
||||||
|
const initCount = t.sentMessages.length;
|
||||||
|
|
||||||
|
assert.ok(t.capturedOnDataFn, 'onData callback must have been registered');
|
||||||
|
t.capturedOnDataFn('a');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.strictEqual(t.sentMessages.length, initCount + 1, 'onData should send exactly one message');
|
||||||
|
|
||||||
|
const msg = t.sentMessages[initCount];
|
||||||
|
assert.ok(msg instanceof Uint8Array, 'keystroke message must be binary Uint8Array');
|
||||||
|
assert.strictEqual(msg[0], 0x30, 'first byte of input message must be 0x30 (input type)');
|
||||||
|
|
||||||
|
const text = Buffer.from(msg.slice(1)).toString('utf-8');
|
||||||
|
assert.strictEqual(text, 'a', 'payload after type byte must be the keystroke string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onResize sends resize with 0x31 type prefix as binary frame', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('test-session');
|
||||||
|
t.fireOpen();
|
||||||
|
|
||||||
|
const initCount = t.sentMessages.length;
|
||||||
|
|
||||||
|
assert.ok(t.capturedOnResizeFn, 'onResize callback must have been registered');
|
||||||
|
t.capturedOnResizeFn({ cols: 100, rows: 30 });
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.strictEqual(t.sentMessages.length, initCount + 1, 'onResize should send exactly one message');
|
||||||
|
|
||||||
|
const msg = t.sentMessages[initCount];
|
||||||
|
assert.ok(msg instanceof Uint8Array, 'resize message must be binary Uint8Array');
|
||||||
|
assert.strictEqual(msg[0], 0x31, 'first byte of resize message must be 0x31 (resize type)');
|
||||||
|
|
||||||
|
const payload = JSON.parse(Buffer.from(msg.slice(1)).toString('utf-8'));
|
||||||
|
assert.strictEqual(payload.columns, 100, 'columns must match the resize event cols');
|
||||||
|
assert.strictEqual(payload.rows, 30, 'rows must match the resize event rows');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('message handler strips type byte and writes output for type 0x30', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('test-session');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
// Simulate receiving a terminal output frame: [0x30] + UTF-8('hello')
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const hello = encoder.encode('hello');
|
||||||
|
const msg = new Uint8Array(1 + hello.length);
|
||||||
|
msg[0] = 0x30;
|
||||||
|
msg.set(hello, 1);
|
||||||
|
|
||||||
|
t.fireMessage(msg.buffer); // Pass as ArrayBuffer
|
||||||
|
|
||||||
|
assert.strictEqual(t.termWriteMessages.length, 1, 'term.write should be called exactly once');
|
||||||
|
|
||||||
|
const written = t.termWriteMessages[0];
|
||||||
|
assert.ok(written instanceof Uint8Array, 'data written to xterm must be a Uint8Array');
|
||||||
|
assert.strictEqual(written[0], 'h'.charCodeAt(0),
|
||||||
|
'first byte written must be "h" (0x68), not the type byte 0x30');
|
||||||
|
assert.strictEqual(written.length, hello.length,
|
||||||
|
'written data length must equal payload length (type byte stripped)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('message handler ignores title type (0x31) — does not call term.write', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('test-session');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const title = encoder.encode('my session title');
|
||||||
|
const msg = new Uint8Array(1 + title.length);
|
||||||
|
msg[0] = 0x31;
|
||||||
|
msg.set(title, 1);
|
||||||
|
|
||||||
|
t.fireMessage(msg.buffer);
|
||||||
|
|
||||||
|
assert.strictEqual(t.termWriteMessages.length, 0,
|
||||||
|
'term.write must NOT be called for type 0x31 (window title)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('message handler ignores prefs type (0x32) — does not call term.write', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('test-session');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const prefs = encoder.encode('{}');
|
||||||
|
const msg = new Uint8Array(1 + prefs.length);
|
||||||
|
msg[0] = 0x32;
|
||||||
|
msg.set(prefs, 1);
|
||||||
|
|
||||||
|
t.fireMessage(msg.buffer);
|
||||||
|
|
||||||
|
assert.strictEqual(t.termWriteMessages.length, 0,
|
||||||
|
'term.write must NOT be called for type 0x32 (preferences)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connectWebSocket URL uses /terminal/ws path', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('my-session');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured');
|
||||||
|
assert.ok(
|
||||||
|
t.capturedWsUrl.endsWith('/terminal/ws'),
|
||||||
|
`WebSocket URL should end with /terminal/ws, got: ${t.capturedWsUrl}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initVisualViewport registers resize handler on window.visualViewport when present', () => {
|
||||||
|
// RED test: stub does nothing; real impl must call addEventListener('resize', fn)
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
let addedEvent = null;
|
||||||
|
globalThis.window.visualViewport = {
|
||||||
|
addEventListener: (event, _fn) => { addedEvent = event; },
|
||||||
|
removeEventListener: (_event, _fn) => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('test-session');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
delete globalThis.window.visualViewport;
|
||||||
|
|
||||||
|
assert.strictEqual(addedEvent, 'resize',
|
||||||
|
'_vpHandler should be registered as a resize listener on window.visualViewport');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminal is auto-focused when WebSocket opens', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('test-session');
|
||||||
|
t.fireOpen();
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
assert.strictEqual(t.focusCallCount, 1,
|
||||||
|
'_term.focus() should be called exactly once when the WebSocket open event fires');
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="210" height="58" viewBox="0 0 210 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_5_54)">
|
||||||
|
<path d="M5.12 41.8772V15.8772H11.464V18.1912C12.348 17.2552 13.4053 16.5185 14.636 15.9812C15.8667 15.4265 17.1927 15.1492 18.614 15.1492C20.4167 15.1492 22.072 15.5739 23.58 16.4232C25.1053 17.2725 26.3187 18.4079 27.22 19.8292C28.1387 18.4079 29.352 17.2725 30.86 16.4232C32.368 15.5739 34.0233 15.1492 35.826 15.1492C37.7327 15.1492 39.4573 15.6172 41 16.5532C42.56 17.4719 43.7993 18.7112 44.718 20.2712C45.654 21.8139 46.122 23.5385 46.122 25.4452V41.8772H39.778V27.1352C39.778 26.1299 39.5267 25.2112 39.024 24.3792C38.5387 23.5299 37.88 22.8539 37.048 22.3512C36.2333 21.8312 35.3147 21.5712 34.292 21.5712C33.2693 21.5712 32.342 21.8225 31.51 22.3252C30.6953 22.8105 30.0367 23.4692 29.534 24.3012C29.0313 25.1332 28.78 26.0779 28.78 27.1352V41.8772H22.436V27.1352C22.436 26.0779 22.1933 25.1332 21.708 24.3012C21.2227 23.4692 20.564 22.8105 19.732 22.3252C18.9 21.8225 17.9727 21.5712 16.95 21.5712C15.9447 21.5712 15.026 21.8312 14.194 22.3512C13.362 22.8539 12.6947 23.5299 12.192 24.3792C11.7067 25.2112 11.464 26.1299 11.464 27.1352V41.8772H5.12ZM50.2909 32.2572V15.8772H56.6349V30.5932C56.6349 31.6159 56.8863 32.5519 57.3889 33.4012C57.8916 34.2332 58.5589 34.9005 59.3909 35.4032C60.2403 35.8885 61.1676 36.1312 62.1729 36.1312C63.2129 36.1312 64.1489 35.8885 64.9809 35.4032C65.8129 34.9005 66.4803 34.2332 66.9829 33.4012C67.4856 32.5519 67.7369 31.6159 67.7369 30.5932V15.8772H74.0809L74.1069 41.8772H67.7629L67.7369 39.5112C66.8356 40.4472 65.7696 41.1925 64.5389 41.7472C63.3083 42.2845 61.9909 42.5532 60.5869 42.5532C58.6976 42.5532 56.9729 42.0939 55.4129 41.1752C53.8529 40.2392 52.6049 38.9999 51.6689 37.4572C50.7503 35.8972 50.2909 34.1639 50.2909 32.2572ZM78.2714 41.8772L87.7874 28.8252L78.3494 15.8512H86.1754L91.6874 23.4172L97.2254 15.8512H105.051L95.6134 28.8252L105.129 41.8772H97.3034L91.6874 34.1812L86.0974 41.8772H78.2714Z" fill="#F0F6FF"/>
|
||||||
|
<path d="M115.515 54.8772H109.171V15.8772H115.515V19.1792C116.364 18.0005 117.404 17.0472 118.635 16.3192C119.883 15.5739 121.339 15.2012 123.003 15.2012C124.909 15.2012 126.686 15.5565 128.333 16.2672C129.979 16.9779 131.427 17.9659 132.675 19.2312C133.94 20.4792 134.919 21.9265 135.613 23.5732C136.323 25.2199 136.679 26.9879 136.679 28.8772C136.679 30.7665 136.323 32.5432 135.613 34.2072C134.919 35.8712 133.94 37.3359 132.675 38.6012C131.427 39.8492 129.979 40.8285 128.333 41.5392C126.686 42.2499 124.909 42.6052 123.003 42.6052C121.339 42.6052 119.883 42.2412 118.635 41.5132C117.404 40.7679 116.364 39.8059 115.515 38.6272V54.8772ZM122.925 21.3112C121.607 21.3112 120.429 21.6579 119.389 22.3512C118.349 23.0272 117.534 23.9372 116.945 25.0812C116.355 26.2252 116.061 27.4905 116.061 28.8772C116.061 30.2639 116.355 31.5379 116.945 32.6992C117.534 33.8432 118.349 34.7619 119.389 35.4552C120.429 36.1312 121.607 36.4692 122.925 36.4692C124.259 36.4692 125.481 36.1312 126.591 35.4552C127.7 34.7792 128.584 33.8692 129.243 32.7252C129.901 31.5639 130.231 30.2812 130.231 28.8772C130.231 27.4905 129.901 26.2252 129.243 25.0812C128.584 23.9372 127.7 23.0272 126.591 22.3512C125.499 21.6579 124.277 21.3112 122.925 21.3112ZM140.35 41.8772V2.87719H146.694V41.8772H140.35ZM163.978 42.5532C161.586 42.5532 159.402 41.9379 157.426 40.7072C155.467 39.4765 153.899 37.8212 152.72 35.7412C151.559 33.6612 150.978 31.3645 150.978 28.8512C150.978 26.9619 151.316 25.1939 151.992 23.5472C152.668 21.8832 153.595 20.4272 154.774 19.1792C155.97 17.9139 157.357 16.9259 158.934 16.2152C160.511 15.5045 162.193 15.1492 163.978 15.1492C166.006 15.1492 167.861 15.5825 169.542 16.4492C171.241 17.2985 172.679 18.4685 173.858 19.9592C175.037 21.4499 175.895 23.1485 176.432 25.0552C176.969 26.9619 177.091 28.9552 176.796 31.0352H157.79C158.033 32.0059 158.431 32.8812 158.986 33.6612C159.541 34.4239 160.243 35.0392 161.092 35.5072C161.941 35.9579 162.903 36.1919 163.978 36.2092C165.087 36.2265 166.093 35.9665 166.994 35.4292C167.913 34.8745 168.675 34.1292 169.282 33.1932L175.756 34.7012C174.699 37.0065 173.121 38.8959 171.024 40.3692C168.927 41.8252 166.578 42.5532 163.978 42.5532ZM157.582 26.2772H170.374C170.183 25.2372 169.776 24.3012 169.152 23.4692C168.545 22.6199 167.791 21.9439 166.89 21.4412C165.989 20.9385 165.018 20.6872 163.978 20.6872C162.938 20.6872 161.976 20.9385 161.092 21.4412C160.208 21.9265 159.454 22.5939 158.83 23.4432C158.223 24.2752 157.807 25.2199 157.582 26.2772ZM177.853 41.8772L187.369 28.8252L177.931 15.8512H185.757L191.269 23.4172L196.807 15.8512H204.633L195.195 28.8252L204.711 41.8772H196.885L191.269 34.1812L185.679 41.8772H177.853Z" fill="#0090B0"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_5_54">
|
||||||
|
<rect width="210" height="58" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tmux-web-coordinator"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["coordinator/tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
addopts = "--import-mode=importlib -m 'not integration'"
|
||||||
|
markers = [
|
||||||
|
"integration: marks tests as integration tests requiring real tmux (deselect with '-m not integration')",
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.23.0
|
||||||
|
aiofiles>=23.0
|
||||||
|
beautifulsoup4>=4.12
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
muxplex brand asset renderer
|
||||||
|
Usage: python3 render-brand-assets.py [--force] [--workdir PATH]
|
||||||
|
|
||||||
|
Renders all SVG sources to PNG/ICO for web, PWA, favicons, and OG images.
|
||||||
|
Run from the ./muxplex/ directory or pass --workdir path.
|
||||||
|
|
||||||
|
Renderer priority: cairosvg > rsvg-convert > inkscape > imagemagick (convert/magick)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Argument parsing
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
parser = ArgumentParser(description="Render muxplex brand assets")
|
||||||
|
parser.add_argument(
|
||||||
|
"--force", action="store_true", help="Re-render even if output exists"
|
||||||
|
)
|
||||||
|
parser.add_argument("--workdir", default=None, help="Base directory (default: cwd)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
WORKDIR = Path(args.workdir).resolve() if args.workdir else Path.cwd()
|
||||||
|
ASSETS = WORKDIR / "assets" / "branding"
|
||||||
|
SVG_DIR = ASSETS / "svg"
|
||||||
|
FORCE = args.force
|
||||||
|
|
||||||
|
# Verify we're in the right place
|
||||||
|
if not SVG_DIR.exists():
|
||||||
|
sys.exit(
|
||||||
|
f"[ERROR] Cannot find {SVG_DIR}\n"
|
||||||
|
"Run from the muxplex/ directory or pass --workdir <path-to-muxplex-dir>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Renderer detection
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
RENDERER: str | None = None
|
||||||
|
|
||||||
|
if importlib.util.find_spec("cairosvg") is not None:
|
||||||
|
RENDERER = "cairosvg"
|
||||||
|
print("[renderer] cairosvg detected ✓")
|
||||||
|
|
||||||
|
if RENDERER is None and shutil.which("rsvg-convert"):
|
||||||
|
RENDERER = "rsvg-convert"
|
||||||
|
print("[renderer] rsvg-convert detected ✓")
|
||||||
|
|
||||||
|
if RENDERER is None and shutil.which("inkscape"):
|
||||||
|
RENDERER = "inkscape"
|
||||||
|
print("[renderer] inkscape detected ✓")
|
||||||
|
|
||||||
|
if RENDERER is None:
|
||||||
|
for _magick_bin in ("magick", "convert"):
|
||||||
|
if shutil.which(_magick_bin):
|
||||||
|
RENDERER = f"imagemagick:{_magick_bin}"
|
||||||
|
print(f"[renderer] ImageMagick ({_magick_bin}) detected ✓")
|
||||||
|
break
|
||||||
|
|
||||||
|
if RENDERER is None:
|
||||||
|
sys.exit(
|
||||||
|
"[ERROR] No SVG renderer found.\n"
|
||||||
|
"Install one of: cairosvg (pip install cairosvg), librsvg2-bin (rsvg-convert),\n"
|
||||||
|
" inkscape, or imagemagick"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Pillow detection
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
HAS_PILLOW = importlib.util.find_spec("PIL") is not None
|
||||||
|
if HAS_PILLOW:
|
||||||
|
print("[pillow] Pillow detected ✓")
|
||||||
|
else:
|
||||||
|
print("[pillow] Pillow not available — ICO and OG composition will be skipped")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# SVG introspection helpers
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_svg_dimensions(svg_path: Path) -> tuple[float | None, float | None]:
|
||||||
|
"""Return (width, height) floats from the SVG root element."""
|
||||||
|
tree = ET.parse(svg_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
w = root.get("width")
|
||||||
|
h = root.get("height")
|
||||||
|
# Fall back to viewBox
|
||||||
|
if not w or not h:
|
||||||
|
vb = root.get("viewBox", "")
|
||||||
|
parts = vb.replace(",", " ").split()
|
||||||
|
if len(parts) == 4:
|
||||||
|
w, h = parts[2], parts[3]
|
||||||
|
if w and h:
|
||||||
|
# Strip units (px, pt, etc.)
|
||||||
|
w_f = float("".join(c for c in str(w) if c in "0123456789."))
|
||||||
|
h_f = float("".join(c for c in str(h) if c in "0123456789."))
|
||||||
|
return w_f, h_f
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Core render function
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def render_svg(
|
||||||
|
src: Path,
|
||||||
|
dst: Path,
|
||||||
|
width: int | None = None,
|
||||||
|
height: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Render src SVG to dst PNG at the given width and/or height.
|
||||||
|
If only one dimension is given the other is computed from aspect ratio.
|
||||||
|
Returns True on success.
|
||||||
|
"""
|
||||||
|
if dst.exists() and not FORCE:
|
||||||
|
print(f" [skip] {dst.relative_to(WORKDIR)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Compute missing dimension from aspect ratio
|
||||||
|
svg_w, svg_h = get_svg_dimensions(src)
|
||||||
|
if svg_w and svg_h:
|
||||||
|
aspect = svg_w / svg_h
|
||||||
|
if width and not height:
|
||||||
|
height = round(width / aspect)
|
||||||
|
elif height and not width:
|
||||||
|
width = round(height * aspect)
|
||||||
|
# Final fallback dimensions
|
||||||
|
final_w: int = width if width is not None else int(svg_w or 64)
|
||||||
|
final_h: int = height if height is not None else int(svg_h or 64)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if RENDERER == "cairosvg":
|
||||||
|
import cairosvg # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
cairosvg.svg2png(
|
||||||
|
url=str(src),
|
||||||
|
write_to=str(dst),
|
||||||
|
output_width=final_w,
|
||||||
|
output_height=final_h,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif RENDERER == "rsvg-convert":
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"rsvg-convert",
|
||||||
|
"-w",
|
||||||
|
str(final_w),
|
||||||
|
"-h",
|
||||||
|
str(final_h),
|
||||||
|
"-o",
|
||||||
|
str(dst),
|
||||||
|
str(src),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif RENDERER == "inkscape":
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"inkscape",
|
||||||
|
"--export-type=png",
|
||||||
|
f"--export-width={final_w}",
|
||||||
|
f"--export-height={final_h}",
|
||||||
|
f"--export-filename={dst}",
|
||||||
|
str(src),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# ImageMagick — RENDERER is "imagemagick:convert" or "imagemagick:magick"
|
||||||
|
#
|
||||||
|
# ImageMagick's built-in SVG renderer (MSVG) handles clip-paths
|
||||||
|
# reliably only at the SVG's native pixel dimensions. Rendering at
|
||||||
|
# any other size typically produces a 1-colour (transparent) result.
|
||||||
|
#
|
||||||
|
# Strategy:
|
||||||
|
# 1. Render to a temp PNG at native SVG dimensions via ImageMagick.
|
||||||
|
# 2. Scale the temp PNG to the requested target size with Pillow
|
||||||
|
# (LANCZOS — high quality, preserves edges).
|
||||||
|
# 3. Fall back to a direct ImageMagick resize if Pillow is absent
|
||||||
|
# (quality may be poor for upscaling).
|
||||||
|
assert RENDERER is not None # guaranteed by startup check
|
||||||
|
bin_name = RENDERER.split(":")[1]
|
||||||
|
native_w, native_h = get_svg_dimensions(src)
|
||||||
|
|
||||||
|
if native_w and native_h and HAS_PILLOW:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
tmp_native = dst.parent / f"_tmp_native_{src.stem}.png"
|
||||||
|
# Render at native SVG size (no resize flag → ImageMagick uses
|
||||||
|
# the dimensions declared in the SVG header).
|
||||||
|
subprocess.run(
|
||||||
|
[bin_name, "-background", "none", str(src), str(tmp_native)],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
# Pillow: open → resize with LANCZOS → save
|
||||||
|
img = Image.open(tmp_native).convert("RGBA")
|
||||||
|
img = img.resize((final_w, final_h), Image.Resampling.LANCZOS)
|
||||||
|
img.save(str(dst))
|
||||||
|
tmp_native.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
# No Pillow — attempt a direct ImageMagick resize.
|
||||||
|
# Works acceptably for downscaling; upscaling may be imprecise.
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
bin_name,
|
||||||
|
"-background",
|
||||||
|
"none",
|
||||||
|
"-resize",
|
||||||
|
f"{final_w}x{final_h}!",
|
||||||
|
str(src),
|
||||||
|
str(dst),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" [ok] {dst.relative_to(WORKDIR)} ({final_w}×{final_h})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" [FAIL] {dst.relative_to(WORKDIR)} — {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Generated-file registry (for summary table)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
generated: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def track(path: Path, status: str = "ok") -> None:
|
||||||
|
size_kb = f"{path.stat().st_size / 1024:.1f}K" if path.exists() else "—"
|
||||||
|
generated.append(
|
||||||
|
{"path": str(path.relative_to(WORKDIR)), "size": size_kb, "status": status}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 — App icons
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print("\n── App icons ─────────────────────────────────────────────")
|
||||||
|
ICON_SRC = SVG_DIR / "icon" / "muxplex-icon-dark.svg"
|
||||||
|
ICONS_DIR = ASSETS / "icons"
|
||||||
|
ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 192, 256, 512, 1024]
|
||||||
|
|
||||||
|
for sz in ICON_SIZES:
|
||||||
|
dst = ICONS_DIR / f"muxplex-icon-{sz}.png"
|
||||||
|
ok = render_svg(ICON_SRC, dst, width=sz, height=sz)
|
||||||
|
if dst.exists():
|
||||||
|
track(dst, "ok" if ok else "fail")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 — Favicons
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print("\n── Favicons ──────────────────────────────────────────────")
|
||||||
|
FAV_DIR = ASSETS / "favicons"
|
||||||
|
|
||||||
|
for sz in [16, 32, 48]:
|
||||||
|
dst = FAV_DIR / f"favicon-{sz}.png"
|
||||||
|
ok = render_svg(ICON_SRC, dst, width=sz, height=sz)
|
||||||
|
if dst.exists():
|
||||||
|
track(dst)
|
||||||
|
|
||||||
|
# apple-touch-icon 180×180
|
||||||
|
ati = FAV_DIR / "apple-touch-icon.png"
|
||||||
|
ok = render_svg(ICON_SRC, ati, width=180, height=180)
|
||||||
|
if ati.exists():
|
||||||
|
track(ati)
|
||||||
|
|
||||||
|
# favicon.ico (multi-res 16+32+48) — requires Pillow
|
||||||
|
ico_path = FAV_DIR / "favicon.ico"
|
||||||
|
if not ico_path.exists() or FORCE:
|
||||||
|
if HAS_PILLOW:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
fav_pngs = [FAV_DIR / f"favicon-{s}.png" for s in [16, 32, 48]]
|
||||||
|
if all(p.exists() for p in fav_pngs):
|
||||||
|
imgs = [Image.open(p).convert("RGBA") for p in fav_pngs]
|
||||||
|
imgs[0].save(
|
||||||
|
str(ico_path),
|
||||||
|
format="ICO",
|
||||||
|
sizes=[(s, s) for s in [16, 32, 48]],
|
||||||
|
append_images=imgs[1:],
|
||||||
|
)
|
||||||
|
print(f" [ok] {ico_path.relative_to(WORKDIR)} (multi-res ICO)")
|
||||||
|
else:
|
||||||
|
print(" [WARN] favicon PNGs missing — ICO skipped")
|
||||||
|
else:
|
||||||
|
print(" [skip] favicon.ico — Pillow not available")
|
||||||
|
if ico_path.exists():
|
||||||
|
track(ico_path)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 — PWA icons
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print("\n── PWA icons ─────────────────────────────────────────────")
|
||||||
|
PWA_DIR = ASSETS / "pwa"
|
||||||
|
|
||||||
|
for sz in [192, 512]:
|
||||||
|
dst = PWA_DIR / f"pwa-{sz}.png"
|
||||||
|
ok = render_svg(ICON_SRC, dst, width=sz, height=sz)
|
||||||
|
if dst.exists():
|
||||||
|
track(dst)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 — Wordmark PNGs
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print("\n── Wordmarks ─────────────────────────────────────────────")
|
||||||
|
WM_DIR = ASSETS / "wordmark"
|
||||||
|
|
||||||
|
for variant in ["dark", "light"]:
|
||||||
|
src = SVG_DIR / "wordmark" / f"wordmark-on-{variant}.svg"
|
||||||
|
for h in [32, 64]:
|
||||||
|
dst = WM_DIR / f"wordmark-on-{variant}-{h}.png"
|
||||||
|
ok = render_svg(src, dst, height=h)
|
||||||
|
if dst.exists():
|
||||||
|
track(dst)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Step 5 — Lockup PNGs
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print("\n── Lockups ───────────────────────────────────────────────")
|
||||||
|
LOCKUP_DIR = ASSETS / "lockup"
|
||||||
|
|
||||||
|
for variant in ["dark", "light"]:
|
||||||
|
src = SVG_DIR / "lockup" / f"lockup-on-{variant}.svg"
|
||||||
|
for h in [32, 64]:
|
||||||
|
dst = LOCKUP_DIR / f"lockup-on-{variant}-{h}.png"
|
||||||
|
ok = render_svg(src, dst, height=h)
|
||||||
|
if dst.exists():
|
||||||
|
track(dst)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Step 6 — OG images (1200×630)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print("\n── OG images ─────────────────────────────────────────────")
|
||||||
|
OG_DIR = ASSETS / "og"
|
||||||
|
OG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
OG_CONFIGS: list[dict] = [
|
||||||
|
{
|
||||||
|
"variant": "dark",
|
||||||
|
"bg_hex": "#0D1117",
|
||||||
|
"bg_rgb": (13, 17, 23),
|
||||||
|
"lockup_src": SVG_DIR / "lockup" / "lockup-on-dark.svg",
|
||||||
|
"out": OG_DIR / "og-dark.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"variant": "light",
|
||||||
|
"bg_hex": "#FFFFFF",
|
||||||
|
"bg_rgb": (255, 255, 255),
|
||||||
|
"lockup_src": SVG_DIR / "lockup" / "lockup-on-light.svg",
|
||||||
|
"out": OG_DIR / "og-light.png",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for cfg in OG_CONFIGS:
|
||||||
|
out_path: Path = cfg["out"]
|
||||||
|
if out_path.exists() and not FORCE:
|
||||||
|
print(f" [skip] {out_path.relative_to(WORKDIR)}")
|
||||||
|
track(out_path, "skip")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if HAS_PILLOW:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Render lockup at height=200 into a temp file
|
||||||
|
tmp_lockup = OG_DIR / f"_tmp_lockup_{cfg['variant']}.png"
|
||||||
|
ok = render_svg(cfg["lockup_src"], tmp_lockup, height=200)
|
||||||
|
if not ok or not tmp_lockup.exists():
|
||||||
|
print(f" [FAIL] OG {cfg['variant']} — lockup render failed")
|
||||||
|
continue
|
||||||
|
|
||||||
|
bg = Image.new("RGB", (1200, 630), color=cfg["bg_rgb"])
|
||||||
|
lockup_img = Image.open(tmp_lockup).convert("RGBA")
|
||||||
|
|
||||||
|
# Centre the lockup on the canvas
|
||||||
|
x = (1200 - lockup_img.width) // 2
|
||||||
|
y = (630 - lockup_img.height) // 2
|
||||||
|
bg.paste(lockup_img, (x, y), lockup_img)
|
||||||
|
bg.save(str(out_path))
|
||||||
|
tmp_lockup.unlink(missing_ok=True)
|
||||||
|
print(
|
||||||
|
f" [ok] {out_path.relative_to(WORKDIR)} (1200×630, bg={cfg['bg_hex']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Fallback: render lockup at height=200, no canvas composition
|
||||||
|
ok = render_svg(cfg["lockup_src"], out_path, height=200)
|
||||||
|
if ok:
|
||||||
|
print(
|
||||||
|
f" [NOTE] {out_path.relative_to(WORKDIR)} — "
|
||||||
|
"Pillow unavailable; rendered lockup only (no 1200×630 canvas).\n"
|
||||||
|
" Install Pillow and re-run to get proper OG images."
|
||||||
|
)
|
||||||
|
|
||||||
|
if out_path.exists():
|
||||||
|
track(out_path)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Summary table
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print("\n" + "═" * 70)
|
||||||
|
print(f" {'FILE':<52} {'SIZE':>6} STATUS")
|
||||||
|
print("─" * 70)
|
||||||
|
for item in generated:
|
||||||
|
status_icon = (
|
||||||
|
"✓" if item["status"] == "ok" else ("·" if item["status"] == "skip" else "✗")
|
||||||
|
)
|
||||||
|
print(f" {item['path']:<52} {item['size']:>6} {status_icon}")
|
||||||
|
print("═" * 70)
|
||||||
|
print(f" Total: {len(generated)} files\n")
|
||||||