Complete technology decisions and implementation details.
Package manager: pnpm (workspaces)
unloved/
├── packages/
│ ├── cli/ # CLI entry point + launcher
│ ├── server/ # Express + WebSocket + PTY backend
│ ├── web/ # React + Vite frontend
│ └── shared/ # Shared types, constants, protocol definitions
├── pnpm-workspace.yaml
├── tsconfig.base.json
└── package.json
All packages are TypeScript. Shared types prevent protocol drift between server and clients.
Runtime: Node.js (>=20)
Language: TypeScript, compiled with tsup to a single CJS/ESM bundle.
Dependencies:
| Package | Purpose |
|---|---|
commander |
CLI argument parsing |
chalk |
Terminal coloring |
open |
Opens browser after server boot |
Entry point: bin/unloved
Responsibilities:
- Parse CLI flags (
--port,--config,--preview,--cli) - Resolve project root (walk up to find
unloved.config.jsonor use cwd) - Boot the server (import from
@unloved/server) - Print connection info (URL, QR code for mobile)
- Open browser
Build:
tsup src/index.ts --format esm --dtsPublished as: unloved on npm, with bin field pointing to compiled output.
Runtime: Node.js (>=20)
Language: TypeScript
Dependencies:
| Package | Purpose |
|---|---|
express |
HTTP server (REST endpoints) |
ws |
WebSocket server (device comms) |
node-pty |
Pseudoterminal for LLM CLIs |
chokidar |
Config file watching |
nanoid |
Session/device ID generation |
zod |
Config validation |
Responsibilities:
- Manage PTY sessions (spawn, buffer, broadcast)
- WebSocket message routing
- REST API for build triggers, config, device list
- Config file watching + hot reload
- CLI detection (
whichlookups)
Build: tsup — outputs ESM, consumed by CLI package.
Framework: React 19 + Vite
Language: TypeScript
Dependencies:
| Package | Purpose |
|---|---|
@xterm/xterm |
Terminal rendering |
@xterm/addon-fit |
Auto-resize terminal |
@xterm/addon-webgl |
GPU-accelerated render |
zustand |
State management |
tailwindcss v4 |
Styling |
lucide-react |
Icons |
react-resizable-panels |
Resizable split layout |
Fonts:
Inter— UI text (loaded via@fontsource/inter)JetBrains Mono— terminal (loaded via@fontsource/jetbrains-mono)
Dev server: Vite dev server during development. In production, built static assets served by the unloved server via Express.
Pure TypeScript — no runtime dependencies.
Contains:
- WebSocket message type definitions (discriminated union)
- Config schema (Zod)
- Device type enums
- Layout mode enums
- Session types
- Build event types
Example:
// packages/shared/src/protocol.ts
export type ServerMessage =
| { type: 'TERMINAL_OUTPUT'; data: string }
| { type: 'BUFFER_REPLAY'; lines: string[] }
| { type: 'BUILD_STARTED' }
| { type: 'BUILD_COMPLETE' }
| { type: 'BUILD_FAILED'; error: string }
| { type: 'CONFIG_UPDATED'; config: Config }
| { type: 'DEVICE_CONNECTED'; device: DeviceInfo }
| { type: 'DEVICE_DISCONNECTED'; deviceId: string }
export type ClientMessage =
| { type: 'TERMINAL_INPUT'; data: string }
| { type: 'REGISTER_DEVICE'; deviceType: DeviceType }
| { type: 'TRIGGER_REBUILD' }Tailwind v4 uses CSS-first configuration. No tailwind.config.ts file needed.
Main CSS file (packages/web/src/index.css):
@import "tailwindcss";
@theme {
/* Colors */
--color-bg: #F6F7F9;
--color-bg-dark: #0B0F19;
--color-sidebar: #F3F4F6;
--color-sidebar-hover: #E9EBF0;
--color-sidebar-active: #FFFFFF;
--color-card: #FFFFFF;
--color-card-dark: #111827;
--color-divider: #E6E8EC;
--color-divider-dark: #1F2937;
--color-accent: #5B6BFF;
--color-accent-hover: #4C59E8;
--color-accent-dark: #6D7DFF;
--color-text-primary: #0F172A;
--color-text-secondary: #6B7280;
--color-text-dark: #E5E7EB;
--color-terminal-bg: #0D1117;
--color-terminal-text: #E6EDF3;
--color-preview-toolbar: #F8FAFC;
/* Border Radius */
--radius-card: 20px;
--radius-button: 12px;
--radius-input: 14px;
--radius-modal: 24px;
--radius-sidebar: 10px;
--radius-preview: 8px;
--radius-console-input: 16px;
/* Shadows */
--shadow-card: 0 8px 24px rgba(0, 0, 0, 0.08);
--shadow-hover: 0 12px 32px rgba(0, 0, 0, 0.12);
--shadow-float: 0 10px 40px rgba(0, 0, 0, 0.12);
--shadow-subtle: 0 2px 8px rgba(0, 0, 0, 0.05);
/* Typography */
--font-sans: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Spacing (layout-specific) */
--sidebar-width: 260px;
--header-height: 64px;
--console-width: 420px;
--preview-min-width: 600px;
}// Sidebar
<aside className="w-[--sidebar-width] bg-sidebar border-r border-divider p-4">
// Header
<header className="h-[--header-height] px-6 flex items-center">
// Card
<div className="bg-card rounded-card shadow-card">
// Primary button
<button className="h-9 px-3.5 rounded-button bg-accent text-white
hover:bg-accent-hover transition-colors duration-150">
// Input field
<input className="h-10 rounded-input border border-divider px-3.5 text-base
focus:border-accent focus:ring-3 focus:ring-accent/20" />
// Terminal container
<div className="bg-terminal-bg text-terminal-text rounded-button p-4 font-mono text-[13px]">
// Console input
<div className="h-14 rounded-console-input shadow-card p-4 text-sm">Use Tailwind's dark: variant with class strategy:
@custom-variant dark (&:where(.dark, .dark *));<div className="bg-bg dark:bg-bg-dark">
<div className="bg-card dark:bg-card-dark">
<p className="text-text-primary dark:text-text-dark">
<div className="border-divider dark:border-divider-dark">| Breakpoint | Target | Width |
|---|---|---|
| default | Phone | < 768px |
md |
Tablet | >= 768px |
lg |
Desktop | >= 1024px |
Phone layout hides sidebar and uses toggle:
<aside className="hidden lg:block w-[--sidebar-width]">pnpm dev # runs all packages concurrently
pnpm dev:server # server only
pnpm dev:web # vite dev server onlypnpm build # builds all packagesBuild order (enforced by pnpm workspace dependencies):
@unloved/shared@unloved/server@unloved/web@unloved/cli
| Package | Runner |
|---|---|
| server | vitest |
| web | vitest |
| shared | vitest |
| cli | vitest |
node-pty is a native module. Requires:
- Python 3 + build tools for compilation
- On macOS: Xcode Command Line Tools
Single Node.js process runs:
- Express HTTP server
- WebSocket server (shares HTTP server via
upgrade) - PTY child processes (one per session)
- Config file watcher
No clustering needed — target is local development use.
- Build commands executed via
execFile(notexec) to prevent shell injection - Config file validated with Zod before applying
- WebSocket connections optionally authenticated via pairing code
- Server binds to
0.0.0.0by default for LAN access, configurable to127.0.0.1