diff --git a/.gitignore b/.gitignore
index f81008f..f299544 100644
--- a/.gitignore
+++ b/.gitignore
@@ -133,6 +133,7 @@ dist/
.pnp.*
# Generated files
+chart-data.json
out/chart-data.json
out/chart-config.json
out/e2e-*-output.json
@@ -230,3 +231,10 @@ testdata/*-output.json
# Contribot
contribot.*.json
transcripts/
+
+.playwright-cli
+# Editor and tool artifacts
+*.backup
+
+# Claude Code agent artifacts
+.claude/scheduled_tasks.lock
diff --git a/.interface-design/system.md b/.interface-design/system.md
new file mode 100644
index 0000000..7e7ce4d
--- /dev/null
+++ b/.interface-design/system.md
@@ -0,0 +1,129 @@
+# Design System
+
+## File Structure
+
+```
+out/css/tokens.css — CSS custom properties (single source of truth for all tokens)
+out/css/styles.css — component styles (@import ./tokens.css; zero hardcoded values)
+out/index.html —
+```
+
+Dependency graph: `index.html → styles.css → tokens.css`
+
+## Tokens (`out/css/tokens.css`)
+
+### Surfaces
+| Token | Value | Usage |
+|---|---|---|
+| `--surface-base` | `#0f172a` | Deepest bg: body gradient start, table `
` |
+| `--surface-card` | `#1a2035` | Section bg: `.trades-section`, `.info` |
+| `--surface-raised` | `#1e293b` | Card bg: `#container`, `.chart-container` |
+| `--surface-raised-alpha` | `rgba(30,41,59,0.85)` | Fullscreen toggle button bg |
+| `--surface-hover` | `rgba(255,255,255,0.06)` | Row hover — visible on any dark surface |
+
+### Borders
+| Token | Value | Usage |
+|---|---|---|
+| `--border-subtle` | `#2d3748` | Inner dividers, row borders, section internals |
+| `--border-default` | `#334155` | Outer borders: container, chart, buttons |
+
+### Text
+| Token | Value | Usage |
+|---|---|---|
+| `--text-primary` | `#cbd5e1` | Body text, card text |
+| `--text-secondary` | `#94a3b8` | ` | `, summary, button default label |
+| `--text-muted` | `#6b7280` | Timestamp, `.no-trades` placeholder |
+
+### Accent
+| Token | Value | Usage |
+|---|---|---|
+| `--color-accent` | `#5eead4` | Section headings, hover state, focus ring |
+| `--color-accent-alt` | `#2dd4bf` | h1 gradient end |
+| `--color-accent-subtle` | `rgba(94,234,212,0.25)` | Pane resize handle hover fill |
+
+### Actions
+| Token | Value | Usage |
+|---|---|---|
+| `--color-action` | `#2563eb` | `.refresh-btn` bg |
+| `--color-action-hover` | `#1d4ed8` | `.refresh-btn:hover` bg |
+
+### Semantic
+| Token | Value | Usage |
+|---|---|---|
+| `--color-long` | `#10b981` | Long direction, positive P/L |
+| `--color-short` | `#ef4444` | Short direction, negative P/L |
+| `--color-open` | `#3b82f6` | Open / in-progress trades |
+
+### Trade Table
+| Token | Value | Usage |
+|---|---|---|
+| `--stripe-tint` | `rgba(255,255,255,0.03)` | Alternating trade-pair row tint |
+
+### Typography
+| Token | Value | Usage |
+|---|---|---|
+| `--font-ui` | `'Segoe UI', system-ui, sans-serif` | All text |
+
+### Border Radius
+| Token | Value | Usage |
+|---|---|---|
+| `--radius-md` | `6px` | Most elements |
+| `--radius-lg` | `8px` | Outer `#container` only |
+
+### Shadows
+| Token | Value | Usage |
+|---|---|---|
+| `--shadow-container` | `0 4px 6px -1px rgba(0,0,0,0.2)` | `#container` only |
+| `--shadow-text` | `0 2px 4px rgba(0,0,0,0.3)` | `h1` text-shadow |
+
+### Transitions
+| Token | Value | Usage |
+|---|---|---|
+| `--transition-duration` | `0.2s` | All interactive elements |
+
+## Spacing Scale
+Rem-based: `0.5rem`, `0.75rem`, `1rem`, `1.25rem`, `1.5rem`, `2rem`, `2.5rem`
+Pixel exceptions: `8px` (absolute inset positioning only)
+
+## Font Scale
+| Size | Usage |
+|---|---|
+| `2.5rem` | `h1` page title |
+| `1.5rem` | `h2` section title |
+| `1rem` | Body, action button |
+| `0.875rem` | Table, timestamp, secondary labels, compact buttons |
+
+Font weight `600`: profit values, table headers.
+
+## Component Patterns
+
+### Filled button (`.refresh-btn`)
+- `background-color: var(--color-action)` → hover: `var(--color-action-hover)`
+- `padding: 0.75rem 1.5rem`, `border-radius: var(--radius-md)`, `font-size: 1rem`
+- `:focus-visible`: `outline: 2px solid var(--color-accent); outline-offset: 2px`
+- `:active`: `transform: scale(0.97)`
+
+### Ghost/outline button (`.sort-toggle-btn`, `.fullscreen-toggle-btn`)
+- `background: transparent`, `border: 1px solid var(--border-default)`, `color: var(--text-secondary)`
+- Hover: `border-color: var(--color-accent); color: var(--color-accent)`
+- `:focus-visible`: same accent outline ring as filled button
+- `:active`: `transform: scale(0.97)`
+
+### Section card (`.trades-section`, `.info`)
+- `background-color: var(--surface-card)`, `border: 1px solid var(--border-subtle)`, `border-radius: var(--radius-md)`, `padding: 1rem`
+
+### Section header (`.trades-header`)
+- `display: flex`, `justify-content: space-between`, `align-items: center`
+- `margin-bottom: 1rem`, `padding-bottom: 0.5rem`, `border-bottom: 1px solid var(--border-subtle)`
+- Title: `color: var(--color-accent)`
+
+## Chart-specific (JS, not CSS)
+Chart colors are LightweightCharts API options passed in `index.html` inline `
-```
-
-**Works on:** Chrome, Firefox, Safari, Edge (99%+ browser support)
-**Device support:** Desktop, mobile, tablets
-**Installation required:** **NONE** - runs directly in browser
-
-### **Node.js (One Command)**
-```javascript
-const fs = require('fs');
-const wasmBuffer = fs.readFileSync('strategy.wasm');
-WebAssembly.instantiate(wasmBuffer).then(wasm => {
- const result = wasm.instance.exports.runStrategy(marketData);
-});
-```
-
-**Installation:** `npm install` (Node.js has built-in WASM support)
-
-### **Native Apps (Embedded Runtime)**
-- **Electron/Tauri apps:** Direct WASM execution
-- **Mobile (React Native):** Via WASM runtime
-- **Desktop apps:** Embed WASM runtime (wasmer, wasmtime)
-
----
-
-## **Performance Comparison**
-
-| Approach | Speed | Consumer Device | Installation |
-|----------|-------|-----------------|--------------|
-| **Current Python** | 2500ms | ❌ Requires Python 3.12 | Complex |
-| **Current Node.js** | 250ms | ⚠️ Requires Node.js | Medium |
-| **Go WASM** | ~50ms | ✅ Browser built-in | **NONE** |
-| **Rust WASM** | ~30ms | ✅ Browser built-in | **NONE** |
-
----
-
-## **Distribution Model**
-
-### **Option 1: Pure Browser Strategy**
-1. User loads webpage
-2. Downloads `strategy.wasm` (50-500KB typical size)
-3. Executes instantly in browser
-4. **Zero installation, zero dependencies**
-
-### **Option 2: npm Package**
-```bash
-npm install your-strategy-runner
-```
-```javascript
-import { runStrategy } from 'your-strategy-runner';
-const result = await runStrategy('strategy.wasm', marketData);
-```
-
-### **Option 3: Standalone Binary** (Go)
-```bash
-# Compile for all platforms
-GOOS=windows GOARCH=amd64 go build -o strategy.exe
-GOOS=darwin GOARCH=amd64 go build -o strategy-mac
-GOOS=linux GOARCH=amd64 go build -o strategy-linux
-```
-**Download and run** - no runtime required.
-
----
-
-## **WASM Size Optimization**
-
-**Go WASM** (unoptimized): ~2-5MB
-**Go WASM** (with wasm-opt): ~500KB-1MB
-**Rust WASM** (optimized): ~100-300KB
-
-**Optimization command:**
-```bash
-wasm-opt -Oz strategy.wasm -o strategy-optimized.wasm
-```
-
----
-
-## **Security Model**
-
-WASM runs in **sandboxed environment**:
-- ✅ No file system access (unless explicitly imported)
-- ✅ No network access (unless explicitly imported)
-- ✅ Memory isolated from host
-- ✅ Perfect for distributing trading strategies
-
-Users can run **precompiled strategies** without exposing source code.
-
----
-
-## **Recommended Path for Your Project**
-
-### **Phase 1: Go WASM (Immediate - 1-2 weeks)**
-
-Your golang-port already has parser + runtime. Add WASM target:
-
-```bash
-cd golang-port
-GOOS=js GOARCH=wasm go build -o ../out/strategy.wasm cmd/pinescript-go/main.go
-```
-
-Create simple HTML runner:
-```html
-
-
-```
-
-**Result:** Browser-executable strategy with **zero installation**.
-
-### **Phase 2: Rust WASM (Future - 8-12 weeks)**
-
-For optimal performance (~30ms) and smallest binary size (~200KB).
-
----
-
-## **Bottom Line**
-
-### ✅ **WASM Publishing: TRIVIALLY EASY**
-- Go WASM: Built-in, single command
-- Rust WASM: Built-in, single command
-
-### ✅ **Consumer Device Execution: ZERO FRICTION**
-- Modern browsers: Built-in WASM support (no installation)
-- Node.js: Built-in WASM support
-- Mobile/Desktop: Via browser or embedded runtime
-
-### ✅ **Your Project Status**
-You have **Go binaries already compiled** in bin. Adding WASM target is **literally changing two environment variables**.
-
-**Verdict:** WASM publishing is **production-ready** and **consumer-friendly**. Your Go port makes this **immediate** (1-2 weeks to add web runner).
-
-# Give me hyperlink to an Online demo of wasm I could try right away on my mobile device
-
-## 🎮 **Interactive WASM Demos**
-
-### **1. Figma (Real Production App)**
-**https://www.figma.com**
-- Entire design tool runs on WASM
-- Works perfectly on mobile browsers
-- Real-world production WASM app with millions of users
-
-### **2. Google Earth**
-**https://earth.google.com/web/**
-- 3D globe rendering via WASM
-- Works on mobile Safari/Chrome
-- Impressive performance demonstration
-
-### **3. Photopea (Photoshop Clone)**
-**https://www.photopea.com**
-- Full image editor in browser (WASM)
-- Mobile-responsive
-- No installation needed
-
-### **4. Squoosh (Image Compression)**
-**https://squoosh.app**
-- Google's image compression tool
-- WASM-powered codecs
-- Works perfectly on mobile
-
-### **5. WASM Game Demos**
-**https://wasmboy.app**
-- Game Boy emulator in WASM
-- Works on mobile browsers
-- Upload ROM and play instantly
-
-### **6. FFmpeg in Browser**
-**https://ffmpegwasm.netlify.app**
-- Video processing via WASM
-- Mobile compatible (though resource-intensive)
-- Zero installation video editing
-
-### **7. Python in Browser (Pyodide)**
-**https://pyodide.org/en/stable/console.html**
-- Full Python interpreter (WASM)
-- Run Python code on mobile
-- Scientific computing in browser
-
-### **8. WebAssembly Studio**
-**https://webassembly.studio**
-- Online WASM development environment
-- Compile C/Rust to WASM
-- Run immediately in browser
-
----
-
-## 🚀 **Try This One First (Most Impressive)**
-
-**Squoosh** - https://squoosh.app
-- Open on mobile
-- Upload/take photo
-- Watch real-time WASM image processing
-- See compression happen instantly
-
-**Why it's impressive:**
-- Complex image codecs (WebP, AVIF, MozJPEG) running at native speed
-- No server processing - all WASM in your browser
-- Works offline after first load
-
----
-
-All these work with **zero installation** on any modern mobile browser (Safari, Chrome, Firefox).
-
-# Can WASM run let's say React frontends or PixiJs viewports?
-
-**Yes, but with important distinctions:**
-
-## **React + WASM: Indirect Relationship**
-
-### ❌ **React itself does NOT run in WASM**
-React runs as **normal JavaScript** in the browser's JS engine (V8/SpiderMonkey/JavaScriptCore).
-
-### ✅ **React CAN call WASM modules**
-```jsx
-import { runStrategy } from './strategy.wasm';
-
-function TradingDashboard() {
- const [result, setResult] = useState(null);
-
- useEffect(() => {
- // React (JS) calls WASM function
- const strategyOutput = runStrategy(marketData);
- setResult(strategyOutput);
- }, [marketData]);
-
- return ; // React renders
-}
-```
-
-**Architecture:**
-- React UI/rendering → **JavaScript**
-- Heavy computation (strategy execution) → **WASM**
-- React displays results from WASM
-
----
-
-## **PixiJS + WASM: Similar Pattern**
-
-### ❌ **PixiJS does NOT run in WASM**
-PixiJS is a **JavaScript library** using WebGL for rendering.
-
-### ✅ **PixiJS CAN visualize WASM-computed data**
-```javascript
-import * as PIXI from 'pixi.js';
-import { calculateIndicators } from './strategy.wasm';
-
-const app = new PIXI.Application(); // PixiJS (JS)
-
-// WASM computes indicator values
-const indicators = calculateIndicators(marketData);
-
-// PixiJS renders the computed data
-indicators.forEach((point, i) => {
- const circle = new PIXI.Graphics();
- circle.beginFill(0x00FF00);
- circle.drawCircle(i * 10, point.value, 5);
- app.stage.addChild(circle);
-});
-```
-
----
-
-## **Real-World WASM + Frontend Examples**
-
-### **1. Figma (Production App)**
-- **UI Framework:** Custom JS framework
-- **WASM Role:**
- - Vector rendering engine (C++)
- - Layout calculations
- - File parsing
-- **Result:** Near-native performance in browser
-
-### **2. Photopea (Photoshop Clone)**
-- **UI:** JavaScript/HTML
-- **WASM Role:**
- - Image processing algorithms (C++)
- - Filter calculations
- - Format encoding/decoding
-
-### **3. Google Earth**
-- **UI:** JavaScript + WebGL
-- **WASM Role:**
- - 3D terrain rendering
- - Geometry processing
- - Data decompression
-
----
-
-## **For Your PineScript Project**
-
-### **Recommended Architecture:**
-
-```
-┌─────────────────────────────────────────┐
-│ React Frontend (JavaScript) │
-│ ├─ Chart rendering (PixiJS/Canvas) │
-│ ├─ UI controls │
-│ └─ State management │
-└──────────────┬──────────────────────────┘
- │ (function calls)
- v
-┌──────────────┴──────────────────────────┐
-│ PineScript Strategy (WASM) │ ← Go/Rust compiled
-│ ├─ Market data processing │
-│ ├─ Indicator calculations │
-│ ├─ Strategy logic │
-│ └─ Trade signals │
-└──────────────┬──────────────────────────┘
- │ (returns data)
- v
-┌──────────────┴──────────────────────────┐
-│ React updates UI with results │
-│ PixiJS renders charts │
-└─────────────────────────────────────────┘
-```
-
-### **Code Example:**
-
-```javascript
-// strategy.wasm exports: runStrategy(bars: Array) -> signals
-import init, { runStrategy } from './pinescript_strategy.wasm';
-
-function TradingChart({ symbol, timeframe }) {
- const [signals, setSignals] = useState([]);
-
- useEffect(() => {
- async function loadStrategy() {
- // Initialize WASM module
- await init();
-
- // Fetch market data (JS)
- const bars = await fetch(`/api/bars/${symbol}/${timeframe}`);
-
- // Execute strategy in WASM (fast computation)
- const result = runStrategy(bars);
-
- setSignals(result.signals);
- }
-
- loadStrategy();
- }, [symbol, timeframe]);
-
- return (
-
- );
-}
-```
-
----
-
-## **Performance Split**
-
-| Task | Best Runtime | Reason |
-|------|--------------|---------|
-| **UI Rendering** | JavaScript | Native DOM/WebGL APIs |
-| **Chart Drawing** | JavaScript (PixiJS/Canvas) | WebGL is already native |
-| **Strategy Logic** | **WASM** | CPU-intensive computation |
-| **Indicator Calculations** | **WASM** | Mathematical operations |
-| **Event Handling** | JavaScript | Browser event system |
-| **State Management** | JavaScript | React ecosystem |
-
----
-
-## **Bottom Line**
-
-- **React/PixiJS stay in JavaScript** (they're designed for it)
-- **WASM handles computation** (strategy execution, indicators)
-- **They communicate via function calls** (JS ↔ WASM is seamless)
-
-**Your Go port compiled to WASM** would be the **computation engine**, while React/PixiJS would be the **presentation layer**.
-
-This is the **optimal architecture** - each technology doing what it does best.
-
-# How about Preact?
-
-**Preact is even better for WASM integration** - same patterns as React, but with significant advantages:
-
-## **Preact + WASM: Optimal Combination**
-
-### ✅ **Why Preact is Superior for WASM Projects**
-
-| Feature | React | Preact | Impact on WASM |
-|---------|-------|--------|----------------|
-| **Bundle Size** | ~130KB (minified) | **3KB** (minified) | Faster initial load |
-| **WASM Module Size** | No impact | No impact | Same |
-| **Total Bundle** | 130KB + WASM | **3KB + WASM** | 97% smaller JS |
-| **Memory Usage** | Higher | **Lower** | More for WASM |
-| **API** | Full React API | React-compatible | Same code |
-| **Performance** | Fast | **Faster** | Better with WASM |
-
----
-
-## **Preact + WASM Code (Identical to React)**
-
-```javascript
-import { h, render } from 'preact';
-import { useState, useEffect } from 'preact/hooks';
-import init, { runStrategy } from './strategy.wasm';
-
-function TradingChart({ symbol }) {
- const [signals, setSignals] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- async function executeStrategy() {
- // Initialize WASM module
- await init();
-
- // Fetch market data
- const response = await fetch(`/api/bars/${symbol}`);
- const bars = await response.json();
-
- // Run strategy in WASM (fast)
- const result = runStrategy(bars);
-
- setSignals(result);
- setLoading(false);
- }
-
- executeStrategy();
- }, [symbol]);
-
- if (loading) return Loading strategy... ;
-
- return (
-
- Strategy Signals for {symbol}
-
- {signals.map((signal, i) => (
-
- {signal.type}: {signal.price} @ {signal.time}
-
- ))}
-
- );
-}
-
-render(, document.body);
-```
-
-**Code is 99% identical to React** - you can even use `preact/compat` for 100% React compatibility.
-
----
-
-## **Real-World Preact + WASM Examples**
-
-### **1. Uber's m.uber.com**
-- Preact for UI
-- WASM for map rendering optimizations
-- 3x faster than their React version
-
-### **2. Preact + WebAssembly Games**
-- UI: Preact (3KB)
-- Game engine: WASM (Rust/C++)
-- Total bundle: ~500KB vs React's ~1MB+
-
----
-
-## **Your PineScript Project with Preact**
-
-### **Bundle Size Comparison:**
-
-```
-React + WASM Strategy:
-├─ React: 130KB
-├─ React-DOM: 40KB
-├─ Chart Library: 50KB
-├─ Strategy WASM: 500KB
-└─ Total: ~720KB
-
-Preact + WASM Strategy:
-├─ Preact: 3KB ✅
-├─ Preact hooks: 1KB ✅
-├─ Chart Library: 50KB
-├─ Strategy WASM: 500KB
-└─ Total: ~554KB (23% smaller)
-```
-
----
-
-## **Preact + PixiJS + WASM Architecture**
-
-```javascript
-import { h, render } from 'preact';
-import { useEffect, useRef } from 'preact/hooks';
-import * as PIXI from 'pixi.js';
-import init, { runStrategy } from './strategy.wasm';
-
-function TradingDashboard() {
- const canvasRef = useRef(null);
-
- useEffect(() => {
- let app;
-
- async function setupChart() {
- // Initialize WASM
- await init();
-
- // Setup PixiJS
- app = new PIXI.Application({
- view: canvasRef.current,
- width: 1200,
- height: 600,
- backgroundColor: 0x1a1a1a,
- });
-
- // Fetch data
- const bars = await fetchMarketData('BTCUSDT', '1h');
-
- // Run strategy in WASM (fast computation)
- const { indicators, signals } = runStrategy(bars);
-
- // Render with PixiJS (fast rendering)
- renderCandlesticks(app, bars);
- renderIndicators(app, indicators);
- renderSignals(app, signals);
- }
-
- setupChart();
-
- return () => app?.destroy();
- }, []);
-
- return (
-
-
-
- {/* Preact UI controls */}
-
-
- );
-}
-
-render(, document.getElementById('app'));
-```
-
----
-
-## **Performance Benchmark (Mobile Device)**
-
-### **iPhone 13 Safari:**
-
-| Stack | Initial Load | Strategy Execution | UI Render | Total |
-|-------|--------------|-------------------|-----------|-------|
-| **React + JS Strategy** | 800ms | 2500ms | 150ms | **3450ms** |
-| **React + WASM Strategy** | 800ms | 50ms | 150ms | **1000ms** |
-| **Preact + WASM Strategy** | **300ms** | 50ms | 100ms | **450ms** |
-
-**Preact + WASM is 7.7x faster than React + JS**
-
----
-
-## **Migration: React → Preact**
-
-### **Option 1: Drop-in Replacement (Zero Code Changes)**
-
-```javascript
-// vite.config.js or webpack.config.js
-export default {
- resolve: {
- alias: {
- 'react': 'preact/compat',
- 'react-dom': 'preact/compat'
- }
- }
-}
-```
-
-**All your React code works unchanged.**
-
-### **Option 2: Native Preact (Optimal)**
-
-```diff
-- import React, { useState } from 'react';
-+ import { h } from 'preact';
-+ import { useState } from 'preact/hooks';
-
-- import ReactDOM from 'react-dom';
-+ import { render } from 'preact';
-
-- ReactDOM.render(, document.getElementById('root'));
-+ render(, document.getElementById('root'));
-```
-
----
-
-## **Recommended Stack for Your Project**
-
-```
-┌─────────────────────────────────────────┐
-│ Preact (3KB) - UI Framework │
-│ ├─ Trading dashboard │
-│ ├─ Chart controls │
-│ └─ Signal display │
-└──────────────┬──────────────────────────┘
- │
- v
-┌──────────────┴──────────────────────────┐
-│ PixiJS (50KB) - Chart Rendering │
-│ ├─ Candlesticks │
-│ ├─ Indicators overlay │
-│ └─ WebGL acceleration │
-└──────────────┬──────────────────────────┘
- │
- v
-┌──────────────┴──────────────────────────┐
-│ Go WASM (500KB) - Strategy Engine │ ← Your golang-port
-│ ├─ PineScript execution │
-│ ├─ Indicator calculations │
-│ └─ Signal generation │
-└─────────────────────────────────────────┘
-
-Total: ~553KB (vs React: ~720KB)
-Load time: 300ms (vs React: 800ms)
-```
-
----
-
-## **Bottom Line**
-
-**Preact is the better choice when using WASM because:**
-
-1. **Smaller bundle** → WASM loads faster (less JS to parse)
-2. **Lower memory** → More RAM for WASM computations
-3. **Faster rendering** → Better responsiveness with WASM results
-4. **React-compatible** → Can use React tutorials/libraries
-5. **Mobile-optimized** → Perfect for your "consumer device" requirement
-
-**Your Go WASM strategy would work identically with both React and Preact**, but Preact gives you better performance for free.
\ No newline at end of file
diff --git a/e2e/fixtures/strategies/test-security-daily-sma-comparison.pine b/e2e/fixtures/strategies/test-security-daily-sma-comparison.pine
new file mode 100644
index 0000000..549c346
--- /dev/null
+++ b/e2e/fixtures/strategies/test-security-daily-sma-comparison.pine
@@ -0,0 +1,12 @@
+//@version=4
+strategy("Security Daily SMA Comparison", overlay=true)
+
+sma_1d_20 = security(syminfo.tickerid, 'D', sma(close, 20))
+sma_1d_50 = security(syminfo.tickerid, 'D', sma(close, 50))
+sma_1d_200 = security(syminfo.tickerid, 'D', sma(close, 200))
+
+plot(sma_1d_20, "SMA20_1D")
+plot(sma_1d_50, "SMA50_1D")
+plot(sma_1d_200, "SMA200_1D")
+plot(sma_1d_20 > sma_1d_50 ? 1.0 : 0.0, "Bull20_50_1D")
+plot(sma_1d_50 > sma_1d_200 ? 1.0 : 0.0, "Bull50_200_1D")
diff --git a/out/css/styles.css b/out/css/styles.css
new file mode 100644
index 0000000..a626c27
--- /dev/null
+++ b/out/css/styles.css
@@ -0,0 +1,270 @@
+@import './tokens.css';
+
+/* ── Reset ──────────────────────────────────────────────────────────────── */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+/* ── Base ────────────────────────────────────────────────────────────────── */
+
+body {
+ font-family: var(--font-ui);
+ background: linear-gradient(135deg, var(--surface-base) 0%, var(--surface-raised) 100%);
+ color: var(--text-primary);
+ padding: 1.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+}
+
+/* ── Layout ──────────────────────────────────────────────────────────────── */
+
+#container {
+ width: 100%;
+ max-width: 90vw;
+ margin: 0 auto;
+ padding: 1.5rem;
+ background-color: var(--surface-raised);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-container);
+}
+
+/* ── Typography ──────────────────────────────────────────────────────────── */
+
+h1 {
+ font-size: 2.5rem;
+ margin-bottom: 2rem;
+ text-align: center;
+ text-shadow: var(--shadow-text);
+ background: linear-gradient(45deg, var(--color-accent), var(--color-accent-alt));
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+/* ── Info card ───────────────────────────────────────────────────────────── */
+
+.info {
+ background-color: var(--surface-card);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+ margin-bottom: 1rem;
+ color: var(--text-primary);
+}
+
+/* ── Buttons ─────────────────────────────────────────────────────────────── */
+
+.refresh-btn {
+ background-color: var(--color-action);
+ color: white;
+ border: none;
+ padding: 0.75rem 1.5rem;
+ border-radius: var(--radius-md);
+ font-size: 1rem;
+ cursor: pointer;
+ margin-bottom: 1rem;
+ transition: background-color var(--transition-duration);
+}
+
+.refresh-btn:hover { background-color: var(--color-action-hover); }
+.refresh-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
+.refresh-btn:active { transform: scale(0.97); }
+
+.sort-toggle-btn {
+ background: transparent;
+ border: 1px solid var(--border-default);
+ color: var(--text-secondary);
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--radius-md);
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: border-color var(--transition-duration), color var(--transition-duration);
+}
+
+.sort-toggle-btn:hover { border-color: var(--color-accent); color: var(--color-accent); }
+.sort-toggle-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
+.sort-toggle-btn:active { transform: scale(0.97); }
+
+.fullscreen-toggle-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ z-index: 10;
+ background: var(--surface-raised-alpha);
+ border: 1px solid var(--border-default);
+ color: var(--text-secondary);
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-md);
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: border-color var(--transition-duration), color var(--transition-duration);
+}
+
+.fullscreen-toggle-btn:hover { border-color: var(--color-accent); color: var(--color-accent); }
+.fullscreen-toggle-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
+.fullscreen-toggle-btn:active { transform: scale(0.97); }
+
+/* ── Chart container ─────────────────────────────────────────────────────── */
+
+.chart-container {
+ position: relative;
+ background-color: var(--surface-raised);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ margin-bottom: 1rem;
+}
+
+.chart-container > div { margin-bottom: 8px; }
+.chart-container > div:last-child { margin-bottom: 0; }
+
+/* ── Fullscreen states ───────────────────────────────────────────────────── */
+
+.chart-container:fullscreen,
+.chart-container:-webkit-full-screen,
+.chart-container.chart-fullscreen {
+ background: var(--surface-raised);
+ overflow-y: auto;
+ padding: 0;
+ border-radius: 0;
+ border: none;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+.chart-container:fullscreen > #main-chart,
+.chart-container:-webkit-full-screen > #main-chart,
+.chart-container.chart-fullscreen > #main-chart {
+ flex: 1 1 auto;
+ min-height: 200px;
+}
+
+.chart-container:fullscreen > div:not(#main-chart),
+.chart-container:-webkit-full-screen > div:not(#main-chart),
+.chart-container.chart-fullscreen > div:not(#main-chart) {
+ flex: 0 0 auto;
+}
+
+/* ── Pane resize handle ──────────────────────────────────────────────────── */
+
+.pane-resize-handle {
+ height: 4px;
+ cursor: row-resize;
+ background: transparent;
+ user-select: none;
+ flex-shrink: 0;
+ transition: background var(--transition-duration);
+}
+
+.pane-resize-handle:hover { background: var(--color-accent-subtle); }
+.chart-container > .pane-resize-handle { margin: 0; }
+
+/* ── Timestamp ───────────────────────────────────────────────────────────── */
+
+.timestamp {
+ text-align: center;
+ font-size: 0.875rem;
+ color: var(--text-muted);
+ margin-top: 1rem;
+}
+
+/* ── Trades section ──────────────────────────────────────────────────────── */
+
+.trades-section {
+ margin-top: 2rem;
+ background-color: var(--surface-card);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+}
+
+.trades-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.trades-header h2 {
+ color: var(--color-accent);
+ font-size: 1.5rem;
+ margin: 0;
+}
+
+.trades-summary {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+/* ── Trades table ────────────────────────────────────────────────────────── */
+
+.trades-table-container {
+ overflow-x: auto;
+}
+
+.trades-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+}
+
+.trades-table th {
+ background-color: var(--surface-base);
+ color: var(--text-secondary);
+ padding: 0.75rem;
+ text-align: left;
+ font-weight: 600;
+ border-bottom: 2px solid var(--border-default);
+}
+
+.trades-table td {
+ padding: 0.75rem;
+ border-bottom: 1px solid var(--border-subtle);
+ vertical-align: middle;
+}
+
+.trades-table tbody tr:hover {
+ background-color: var(--surface-hover);
+}
+
+.trades-table tbody tr:nth-child(4n+1):not(:only-child),
+.trades-table tbody tr:nth-child(4n+2):not(:only-child) {
+ background-color: var(--stripe-tint);
+}
+
+/* ── Trade row states ────────────────────────────────────────────────────── */
+
+.trade-long { color: var(--color-long); }
+.trade-short { color: var(--color-short); }
+
+.trade-profit-positive { color: var(--color-long); font-weight: 600; }
+.trade-profit-negative { color: var(--color-short); font-weight: 600; }
+
+.trade-open {
+ color: var(--color-open);
+ font-weight: 600;
+ font-style: italic;
+}
+
+.no-trades {
+ text-align: center;
+ padding: 2rem;
+ color: var(--text-muted);
+ font-style: italic;
+}
diff --git a/out/css/tokens.css b/out/css/tokens.css
new file mode 100644
index 0000000..2820608
--- /dev/null
+++ b/out/css/tokens.css
@@ -0,0 +1,48 @@
+:root {
+ /* ── Surfaces ──────────────────────────────────────────────────────────── */
+ --surface-base: #0f172a;
+ --surface-card: #1a2035;
+ --surface-raised: #1e293b;
+ --surface-raised-alpha: rgba(30, 41, 59, 0.85);
+ --surface-hover: rgba(255, 255, 255, 0.06);
+
+ /* ── Borders ───────────────────────────────────────────────────────────── */
+ --border-subtle: #2d3748;
+ --border-default: #334155;
+
+ /* ── Text ──────────────────────────────────────────────────────────────── */
+ --text-primary: #cbd5e1;
+ --text-secondary: #94a3b8;
+ --text-muted: #6b7280;
+
+ /* ── Accent ────────────────────────────────────────────────────────────── */
+ --color-accent: #5eead4;
+ --color-accent-alt: #2dd4bf;
+ --color-accent-subtle: rgba(94, 234, 212, 0.25);
+
+ /* ── Actions ───────────────────────────────────────────────────────────── */
+ --color-action: #2563eb;
+ --color-action-hover: #1d4ed8;
+
+ /* ── Semantic ──────────────────────────────────────────────────────────── */
+ --color-long: #10b981;
+ --color-short: #ef4444;
+ --color-open: #3b82f6;
+
+ /* ── Trade table ───────────────────────────────────────────────────────── */
+ --stripe-tint: rgba(255, 255, 255, 0.03);
+
+ /* ── Typography ────────────────────────────────────────────────────────── */
+ --font-ui: 'Segoe UI', system-ui, sans-serif;
+
+ /* ── Border radius ─────────────────────────────────────────────────────── */
+ --radius-md: 6px;
+ --radius-lg: 8px;
+
+ /* ── Shadows ───────────────────────────────────────────────────────────── */
+ --shadow-container: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
+ --shadow-text: 0 2px 4px rgba(0, 0, 0, 0.3);
+
+ /* ── Transitions ───────────────────────────────────────────────────────── */
+ --transition-duration: 0.2s;
+}
diff --git a/out/index-bak.html b/out/index-bak.html
deleted file mode 100644
index 642e0fd..0000000
--- a/out/index-bak.html
+++ /dev/null
@@ -1,840 +0,0 @@
-
-
-
- Financial Chart Visualization
-
-
-
-
-
-
-
- Financial Chart
-
-
- Symbol: Loading...
- Timeframe: Loading...
- Strategy: Loading...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | # |
- Date |
- Direction |
- Entry |
- Exit |
- Size |
- Profit/Loss |
-
-
-
-
- | No trades to display |
-
-
-
-
-
-
-
-
-
-
diff --git a/out/index.html b/out/index.html
index d7673c4..58a75f4 100644
--- a/out/index.html
+++ b/out/index.html
@@ -4,184 +4,9 @@
quant5-lab/runner
+
-
diff --git a/out/js/ChartApplication.js b/out/js/ChartApplication.js
index e5b4011..3a80c89 100644
--- a/out/js/ChartApplication.js
+++ b/out/js/ChartApplication.js
@@ -1,275 +1,267 @@
-import { ConfigLoader } from './ConfigLoader.js';
-import { PaneAssigner } from './PaneAssigner.js';
-import { PaneManager } from './PaneManager.js';
-import { SeriesRouter } from './SeriesRouter.js';
-import { ChartManager } from './ChartManager.js';
-import { TradeDataFormatter } from './TradeTable.js';
+import { ConfigLoader } from './ConfigLoader.js';
+import { PaneAssigner } from './PaneAssigner.js';
+import { PaneManager } from './PaneManager.js';
+import { SeriesRouter } from './SeriesRouter.js';
+import { ChartManager } from './ChartManager.js';
+import { TradeDataFormatter,
+ formatCurrency } from './TradeTable.js';
import { TradeRowspanTransformer } from './TradeRowspanTransformer.js';
-import { TradeRowspanRenderer } from './TradeRowspanRenderer.js';
-import { TimeIndexBuilder } from './TimeIndexBuilder.js';
-import { PlotOffsetTransformer } from './PlotOffsetTransformer.js';
-import { SeriesDataMapper } from './SeriesDataMapper.js';
-import { LineStyleConverter } from './LineStyleConverter.js';
+import { TradeRowspanRenderer } from './TradeRowspanRenderer.js';
+import { TradeMarkerBuilder } from './TradeMarkerBuilder.js';
+import { TimeIndexBuilder } from './TimeIndexBuilder.js';
+import { PlotOffsetTransformer } from './PlotOffsetTransformer.js';
+import { SeriesDataMapper } from './SeriesDataMapper.js';
+import { LineStyleConverter } from './LineStyleConverter.js';
+import { TradeSortToggle } from './TradeSortToggle.js';
+import { FullscreenToggle } from './FullscreenToggle.js';
+import { SortDirection } from './SortDirection.js';
+import { PaneResizeController } from './PaneResizeController.js';
+import { WindowResizeHandler } from './WindowResizeHandler.js';
export class ChartApplication {
constructor(chartOptions) {
- this.chartOptions = chartOptions;
- this.paneManager = null;
- this.seriesMap = {};
- this.timeIndexBuilder = new TimeIndexBuilder();
+ this.chartOptions = chartOptions;
+ this.paneManager = null;
+ this.seriesMap = {};
+ this.timeIndexBuilder = new TimeIndexBuilder();
this.plotOffsetTransformer = new PlotOffsetTransformer(this.timeIndexBuilder);
- this.seriesDataMapper = new SeriesDataMapper();
- }
+ this.seriesDataMapper = new SeriesDataMapper();
+ this.tradeMarkerBuilder = new TradeMarkerBuilder();
- async initialize() {
- const data = await ConfigLoader.loadChartData();
- const configOverride = await ConfigLoader.loadStrategyConfig(
- data.metadata?.strategy || 'strategy'
+ this.sortToggle = new TradeSortToggle(
+ (direction) => this._rerenderTrades(direction),
+ SortDirection.DESC,
);
- const paneAssigner = new PaneAssigner(data.candlestick);
- const indicatorsWithPanes = paneAssigner.assignAllPanes(
- data.indicators,
- configOverride
+ this.fullscreenToggle = new FullscreenToggle(
+ document.querySelector('.chart-container'),
+ () => this._onResize(),
);
- // Merge config style/color overrides into indicators
- if (configOverride) {
- Object.entries(indicatorsWithPanes).forEach(([key, indicator]) => {
- const override = configOverride[key];
- if (override && typeof override === 'object') {
- if (override.style) indicator.style = { ...indicator.style, ...override };
- }
- });
- }
+ this.resizeController = new PaneResizeController();
+ this.windowResizeHandler = new WindowResizeHandler(() => this._onResize());
+ }
+
+ async initialize() {
+ const data = await ConfigLoader.loadChartData();
+
+ const indicatorsWithPanes = new PaneAssigner(data.candlestick).assignAllPanes(data.indicators);
this.updateMetadataDisplay(data.metadata);
- const paneConfig = this.buildPaneConfig(indicatorsWithPanes, data.ui?.panes);
-
+ const paneConfig = this._buildPaneConfig(indicatorsWithPanes, data.ui?.panes);
this.paneManager = new PaneManager(this.chartOptions);
- this.createCharts(paneConfig);
+ this._createCharts(paneConfig);
+ this.resizeController.mount(this.paneManager.getAllPanes());
- const seriesRouter = new SeriesRouter(this.paneManager, this.seriesMap);
- this.routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter, configOverride);
+ const seriesRouter = new SeriesRouter(this.paneManager, this.seriesMap);
+ const candlestickData = this._routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter);
- this.loadTrades(data.strategy, data.candlestick);
+ this._loadTradeMarkers(data.strategy, candlestickData);
+ this._loadTrades(data.strategy, data.candlestick);
this.updateTimestamp(data.metadata);
-
- this.setupEventListeners();
this.paneManager.synchronizeTimeScales();
setTimeout(() => {
ChartManager.fitContent(this.paneManager.getAllCharts());
+ ChartManager.handleResize(
+ this.paneManager.getAllCharts(),
+ this.paneManager.getAllContainers(),
+ );
}, 50);
}
- buildPaneConfig(indicatorsWithPanes, uiPanes) {
- const config = {
- main: { height: 400, fixed: true },
- };
+ async refresh() {
+ this.resizeController.destroy();
+ this.paneManager.getAllCharts().forEach(chart => chart.remove());
+ this.paneManager.dynamicPanes.forEach(({ container }) => container.remove());
+ this.paneManager.mainPane.container.innerHTML = '';
- const uniquePanes = new Set();
- Object.values(indicatorsWithPanes).forEach((indicator) => {
- const pane = indicator.pane;
- if (pane && pane !== 'main') {
- uniquePanes.add(pane);
- }
- });
+ this.seriesMap = {};
+ this.paneManager = null;
- uniquePanes.forEach((paneName) => {
+ await this.initialize();
+ }
+
+ updateMetadataDisplay(metadata) {
+ if (!metadata) return;
+ document.getElementById('chart-title').textContent = metadata.title || 'Financial Chart';
+ document.getElementById('symbol-display').textContent = metadata.symbol || 'Unknown';
+ document.getElementById('timeframe-display').textContent = metadata.timeframe || 'Unknown';
+ document.getElementById('strategy-display').textContent = metadata.strategy || 'Unknown';
+ }
+
+ updateTimestamp(metadata) {
+ if (!metadata?.timestamp) return;
+ document.getElementById('timestamp').textContent =
+ 'Last updated: ' + new Date(metadata.timestamp).toLocaleString();
+ }
+
+ _buildPaneConfig(indicatorsWithPanes, uiPanes) {
+ const config = { main: { height: 400, fixed: true } };
+
+ const dynamicPanes = new Set(
+ Object.values(indicatorsWithPanes)
+ .map(ind => ind.pane)
+ .filter(pane => pane && pane !== 'main')
+ );
+
+ dynamicPanes.forEach(paneName => {
config[paneName] = uiPanes?.[paneName] || { height: 200, fixed: false };
});
- /* Backward compatibility: ensure 'indicator' pane exists if no dynamic panes */
- if (Object.keys(config).length === 1) {
- config.indicator = { height: 200, fixed: false };
- }
-
return config;
}
- createCharts(paneConfig) {
+ _createCharts(paneConfig) {
const mainContainer = document.getElementById('main-chart');
this.paneManager.createMainPane(mainContainer, paneConfig.main);
- Object.entries(paneConfig).forEach(([paneName, config]) => {
+ this.fullscreenToggle.mount();
+
+ for (const [paneName, config] of Object.entries(paneConfig)) {
if (paneName !== 'main') {
this.paneManager.createDynamicPane(paneName, config);
}
- });
+ }
}
- routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter, configOverride) {
+ _routeAndLoadSeries(indicatorsWithPanes, data, seriesRouter) {
const mainChart = this.paneManager.mainPane.chart;
this.seriesMap.candlestick = ChartManager.addCandlestickSeries(mainChart, {
- upColor: '#26a69a',
- downColor: '#ef5350',
+ upColor: '#26a69a',
+ downColor: '#ef5350',
borderVisible: false,
- wickUpColor: '#26a69a',
+ wickUpColor: '#26a69a',
wickDownColor: '#ef5350',
});
const candlestickData = data.candlestick
.sort((a, b) => a.time - b.time)
- .map((c) => ({
- time: c.time,
- open: c.open,
- high: c.high,
- low: c.low,
- close: c.close,
- }));
+ .map(({ time, open, high, low, close }) => ({ time, open, high, low, close }));
this.seriesMap.candlestick.setData(candlestickData);
- Object.entries(indicatorsWithPanes).forEach(([key, indicator]) => {
- const styleType = configOverride?.[key]?.style || 'line';
- const color = indicator.style?.color || configOverride?.[key]?.color || '#2196F3';
- const lineStyleValue = configOverride?.[key]?.lineStyle;
-
+ for (const [key, indicator] of Object.entries(indicatorsWithPanes)) {
+ const color = indicator.style?.color || '#2196F3';
const seriesConfig = {
- color: color,
- lineWidth: indicator.style?.lineWidth || 2,
- title: indicator.title || key,
- chart: indicator.pane || 'main',
- style: styleType,
- priceLineVisible: false,
- lastValueVisible: true,
+ color,
+ lineWidth: indicator.style?.lineWidth || 2,
+ title: indicator.title || key,
+ chart: indicator.pane || 'main',
+ style: indicator.style?.plotStyle || 'line',
+ priceLineVisible: false,
+ lastValueVisible: true,
crosshairMarkerVisible: true,
- lineStyle: LineStyleConverter.toNumeric(lineStyleValue),
+ lineStyle: LineStyleConverter.toNumeric(indicator.style?.lineStyle),
};
const series = seriesRouter.routeSeries(key, seriesConfig, ChartManager);
-
if (!series) {
console.error(`Failed to create series for '${key}'`);
- return;
+ continue;
}
- const offset = indicator.offset || 0;
- const offsetAdjustedData = this.plotOffsetTransformer.transform(
- indicator.data,
- offset,
- candlestickData
- );
+ const offset = indicator.offset || 0;
+ const shifted = this.plotOffsetTransformer.transform(indicator.data, offset, candlestickData);
+ const colored = this.seriesDataMapper.applyColorToData(shifted, color);
+ const processed = window.adaptLineSeriesData(colored);
- const dataWithColor = this.seriesDataMapper.applyColorToData(offsetAdjustedData, color);
- const processedData = window.adaptLineSeriesData(dataWithColor);
-
- if (processedData.length > 0) {
- series.setData(processedData);
-
- // Auto-zoom to Buy/Sell Potential signals if they exist
- if ((key === 'Buy Potential' || key === 'Sell Potential') && processedData.length > 0) {
- const validPoints = processedData.filter(p => !isNaN(p.value) && p.value !== null);
- if (validPoints.length > 0) {
- const firstTime = validPoints[0].time;
- const lastTime = validPoints[validPoints.length - 1].time;
- const mainChart = this.paneManager.mainPane.chart;
-
- // Zoom to show signals with context (±50 bars = 50 hours for 1h timeframe)
- const contextBars = 50;
- const barInterval = 3600; // 1 hour
- mainChart.timeScale().setVisibleRange({
- from: firstTime - (contextBars * barInterval),
- to: lastTime + (contextBars * barInterval)
- });
- }
- }
+ if (processed.length > 0) {
+ series.setData(processed);
+ this._applySignalZoom(key, processed);
}
+ }
+
+ return candlestickData;
+ }
+
+ _applySignalZoom(key, processedData) {
+ if (key !== 'Buy Potential' && key !== 'Sell Potential') return;
+ const valid = processedData.filter(p => p.value != null && !isNaN(p.value));
+ if (!valid.length) return;
+ const contextBars = 50;
+ const barInterval = 3600;
+ this.paneManager.mainPane.chart.timeScale().setVisibleRange({
+ from: valid[0].time - contextBars * barInterval,
+ to: valid[valid.length - 1].time + contextBars * barInterval,
});
}
- loadTrades(strategy, candlestickData) {
+ _loadTradeMarkers(strategy, candlestickData) {
+ const markers = this.tradeMarkerBuilder.build(strategy, candlestickData);
+ if (markers.length > 0) {
+ this.seriesMap.candlestick.setMarkers(markers);
+ }
+ }
+
+ _loadTrades(strategy, candlestickData) {
if (!strategy) return;
- const allTrades = [
+ const openTrades = strategy.openTrades || [];
+ const allTrades = [
...(strategy.trades || []),
- ...(strategy.openTrades || []).map((t) => ({ ...t, status: 'open' })),
+ ...openTrades.map(t => ({ ...t, status: 'open' })),
];
- // Sort trades: latest first (by entryTime descending)
allTrades.sort((a, b) => (b.entryTime || 0) - (a.entryTime || 0));
- const tbody = document.getElementById('trades-tbody');
+ this._tradeState = {
+ allTrades,
+ openTrades,
+ netProfit: strategy.netProfit || 0,
+ candlestickData,
+ };
+
+ const headerEl = document.querySelector('.trades-header');
+ this.sortToggle.mount(headerEl, allTrades.length > 0);
+
+ this._rerenderTrades(this.sortToggle.direction);
+ }
+
+ _rerenderTrades(sortDirection) {
+ if (!this._tradeState) return;
+ const { allTrades, openTrades, netProfit, candlestickData } = this._tradeState;
+
+ const tbody = document.getElementById('trades-tbody');
const summary = document.getElementById('trades-summary');
if (allTrades.length === 0) {
- tbody.innerHTML =
- ' | | No trades to display |
';
+ tbody.innerHTML = '| No trades to display |
';
summary.textContent = 'No trades';
return;
}
- const currentPrice = candlestickData?.length > 0
- ? candlestickData[candlestickData.length - 1].close
+ const sorted = sortDirection === SortDirection.ASC
+ ? [...allTrades].reverse()
+ : allTrades;
+
+ const currentPrice = candlestickData?.length > 0
+ ? candlestickData[candlestickData.length - 1].close
: null;
const formatter = new TradeDataFormatter(candlestickData);
- const transformer = new TradeRowspanTransformer(formatter);
- const renderer = new TradeRowspanRenderer();
-
- const tradeRows = transformer.transformTrades(allTrades, currentPrice);
- tbody.innerHTML = renderer.renderRows(tradeRows);
+ const rows = new TradeRowspanTransformer(formatter).transformTrades(sorted, currentPrice, sortDirection);
+ tbody.innerHTML = new TradeRowspanRenderer().renderRows(rows);
- const realizedProfit = strategy.netProfit || 0;
const unrealizedProfit = currentPrice
- ? (strategy.openTrades || []).reduce((sum, trade) => {
- const multiplier = trade.direction === 'long' ? 1 : -1;
- return sum + (currentPrice - trade.entryPrice) * trade.size * multiplier;
+ ? openTrades.reduce((sum, t) => {
+ const multiplier = t.direction === 'long' ? 1 : -1;
+ return sum + (currentPrice - t.entryPrice) * t.size * multiplier;
}, 0)
: 0;
- const totalProfit = realizedProfit + unrealizedProfit;
-
- const profitClass =
- totalProfit >= 0 ? 'trade-profit-positive' : 'trade-profit-negative';
- summary.innerHTML = `${allTrades.length} trades | Net P/L: $${totalProfit.toFixed(
- 2
- )}`;
+ const totalProfit = netProfit + unrealizedProfit;
+ const profitClass = totalProfit >= 0 ? 'trade-profit-positive' : 'trade-profit-negative';
+ summary.innerHTML =
+ `${allTrades.length} trades | Net P/L: ${formatCurrency(totalProfit)}`;
}
- updateMetadataDisplay(metadata) {
- if (!metadata) return;
-
- document.getElementById('chart-title').textContent =
- metadata.title || 'Financial Chart';
- document.getElementById('symbol-display').textContent =
- metadata.symbol || 'Unknown';
- document.getElementById('timeframe-display').textContent =
- metadata.timeframe || 'Unknown';
- document.getElementById('strategy-display').textContent =
- metadata.strategy || 'Unknown';
- }
-
- updateTimestamp(metadata) {
- if (!metadata?.timestamp) return;
-
- document.getElementById('timestamp').textContent =
- 'Last updated: ' + new Date(metadata.timestamp).toLocaleString();
- }
-
- setupEventListeners() {
- window.addEventListener('resize', () => {
- const containers = this.paneManager.getAllContainers();
- const charts = this.paneManager.getAllCharts();
- ChartManager.handleResize(charts, containers);
- });
- }
-
- async refresh() {
- // Clear all charts and containers
- const charts = this.paneManager.getAllCharts();
- charts.forEach(chart => chart.remove());
-
- const containers = this.paneManager.getAllContainers();
- containers.forEach((container) => {
- container.innerHTML = '';
- });
-
- this.seriesMap = {};
- this.paneManager = null;
-
- await this.initialize();
+ _onResize() {
+ if (!this.paneManager) return;
+ ChartManager.handleResize(
+ this.paneManager.getAllCharts(),
+ this.paneManager.getAllContainers(),
+ );
}
}
diff --git a/out/js/ChartManager.js b/out/js/ChartManager.js
index 89c5b79..982513e 100644
--- a/out/js/ChartManager.js
+++ b/out/js/ChartManager.js
@@ -1,4 +1,3 @@
-/* Chart creation and series management (SRP) */
export class ChartManager {
static createChart(container, config, chartOptions) {
return LightweightCharts.createChart(container, {
@@ -25,7 +24,8 @@ export class ChartManager {
}
static handleResize(charts, containers) {
- const width = containers[0].clientWidth;
- charts.forEach((chart) => chart.applyOptions({ width }));
+ charts.forEach((chart, i) => {
+ chart.resize(containers[i].clientWidth, containers[i].clientHeight);
+ });
}
}
diff --git a/out/js/ConfigLoader.js b/out/js/ConfigLoader.js
index 93bc593..2eb5b59 100644
--- a/out/js/ConfigLoader.js
+++ b/out/js/ConfigLoader.js
@@ -1,23 +1,6 @@
-/* Config file loader for optional explicit pane overrides (SRP) */
export class ConfigLoader {
- static async loadStrategyConfig(strategyName) {
- try {
- const configUrl = `${strategyName}.config`;
- const response = await fetch(configUrl + '?' + Date.now());
-
- if (!response.ok) {
- return null;
- }
-
- const config = await response.json();
- return config.indicators || null;
- } catch (error) {
- return null;
- }
- }
-
static async loadChartData(url = 'chart-data.json') {
const response = await fetch(url + '?' + Date.now());
- return await response.json();
+ return response.json();
}
}
diff --git a/out/js/FullscreenToggle.js b/out/js/FullscreenToggle.js
new file mode 100644
index 0000000..d2a323f
--- /dev/null
+++ b/out/js/FullscreenToggle.js
@@ -0,0 +1,94 @@
+/**
+ * Prefers the native Fullscreen API; falls back to a `chart-fullscreen` CSS class
+ * for browsers that do not support the API on non-video elements (e.g. iPhone Safari).
+ *
+ * `onResize` is called on every viewport change so the chart library can reflow.
+ *
+ * `mount()` is idempotent — safe to call on every render cycle; attaches exactly
+ * one button and one pair of `fullscreenchange` listeners for the lifetime of the instance.
+ */
+export class FullscreenToggle {
+ #container;
+ #button;
+ #onResize;
+ #usingApiFullscreen = false;
+ #mounted = false;
+ #boundHandler = () => this.#handleFullscreenChange();
+
+ constructor(container, onResize) {
+ this.#container = container;
+ this.#onResize = onResize;
+ this.#button = this.#createButton();
+ }
+
+ mount() {
+ if (this.#mounted) return;
+ this.#mounted = true;
+ this.#container.appendChild(this.#button);
+ document.addEventListener('fullscreenchange', this.#boundHandler);
+ document.addEventListener('webkitfullscreenchange', this.#boundHandler);
+ }
+
+ #createButton() {
+ const btn = document.createElement('button');
+ btn.className = 'fullscreen-toggle-btn';
+ btn.textContent = '⤢';
+ btn.title = 'Toggle fullscreen';
+ btn.setAttribute('aria-pressed', 'false');
+ btn.addEventListener('click', () => this.#toggle());
+ return btn;
+ }
+
+ #supportsFullscreenApi() {
+ return typeof this.#container.requestFullscreen === 'function'
+ || typeof this.#container.webkitRequestFullscreen === 'function';
+ }
+
+ #isFullscreen() {
+ return !!(document.fullscreenElement || document.webkitFullscreenElement)
+ || this.#container.classList.contains('chart-fullscreen');
+ }
+
+ #toggle() {
+ if (this.#isFullscreen()) {
+ this.#exit();
+ } else {
+ this.#enter();
+ }
+ }
+
+ #enter() {
+ if (this.#supportsFullscreenApi()) {
+ this.#usingApiFullscreen = true;
+ (this.#container.requestFullscreen || this.#container.webkitRequestFullscreen)
+ .call(this.#container);
+ } else {
+ this.#container.classList.add('chart-fullscreen');
+ this.#syncButton();
+ this.#onResize();
+ }
+ }
+
+ #exit() {
+ if (this.#usingApiFullscreen) {
+ this.#usingApiFullscreen = false;
+ (document.exitFullscreen || document.webkitExitFullscreen).call(document);
+ } else {
+ this.#container.classList.remove('chart-fullscreen');
+ this.#syncButton();
+ this.#onResize();
+ }
+ }
+
+ #handleFullscreenChange() {
+ this.#syncButton();
+ this.#onResize();
+ }
+
+ #syncButton() {
+ const active = this.#isFullscreen();
+ this.#button.textContent = active ? '✕' : '⤢';
+ this.#button.title = active ? 'Exit fullscreen' : 'Toggle fullscreen';
+ this.#button.setAttribute('aria-pressed', String(active));
+ }
+}
diff --git a/out/js/PaneAssigner.js b/out/js/PaneAssigner.js
index 54ddaec..85ebc65 100644
--- a/out/js/PaneAssigner.js
+++ b/out/js/PaneAssigner.js
@@ -1,94 +1,50 @@
-/* Pane assignment logic based on value range analysis (SRP) */
export class PaneAssigner {
constructor(candlestickData) {
- this.candlestickRange = this.calculateCandlestickRange(candlestickData);
+ this.candlestickRange = this._candlestickRange(candlestickData);
}
- calculateCandlestickRange(candlestickData) {
- if (!candlestickData || candlestickData.length === 0) {
- return { min: 0, max: 0 };
+ _candlestickRange(candlestickData) {
+ if (!candlestickData?.length) return { min: 0, max: 0 };
+ let min = Infinity, max = -Infinity;
+ for (const { low, high } of candlestickData) {
+ if (low < min) min = low;
+ if (high > max) max = high;
}
-
- let min = Infinity;
- let max = -Infinity;
-
- candlestickData.forEach((candle) => {
- if (candle.low < min) min = candle.low;
- if (candle.high > max) max = candle.high;
- });
-
return { min, max };
}
- calculateIndicatorRange(indicatorData) {
- if (!indicatorData || indicatorData.length === 0) {
- return { min: 0, max: 0 };
- }
-
- let min = Infinity;
- let max = -Infinity;
- let validCount = 0;
-
- indicatorData.forEach((point) => {
- if (point.value !== null && point.value !== undefined && !isNaN(point.value) && point.value !== 0) {
- if (point.value < min) min = point.value;
- if (point.value > max) max = point.value;
- validCount++;
+ _indicatorRange(indicatorData) {
+ if (!indicatorData?.length) return { min: 0, max: 0 };
+ let min = Infinity, max = -Infinity, count = 0;
+ for (const { value } of indicatorData) {
+ if (value != null && !isNaN(value) && value !== 0) {
+ if (value < min) min = value;
+ if (value > max) max = value;
+ count++;
}
- });
-
- if (validCount === 0) {
- return { min: 0, max: 0 };
}
-
- return { min, max };
+ return count === 0 ? { min: 0, max: 0 } : { min, max };
}
- rangesOverlap(range1, range2, overlapThreshold = 0.3) {
- const range1Span = range1.max - range1.min;
- const range2Span = range2.max - range2.min;
-
- if (range1Span === 0 || range2Span === 0) return false;
-
- const overlapMin = Math.max(range1.min, range2.min);
- const overlapMax = Math.min(range1.max, range2.max);
- const overlapSpan = Math.max(0, overlapMax - overlapMin);
-
- const overlapRatio = overlapSpan / Math.min(range1Span, range2Span);
-
- return overlapRatio >= overlapThreshold;
+ _rangesOverlap(r1, r2, threshold = 0.3) {
+ const span1 = r1.max - r1.min;
+ const span2 = r2.max - r2.min;
+ if (span1 === 0 || span2 === 0) return false;
+ const overlapSpan = Math.max(0, Math.min(r1.max, r2.max) - Math.max(r1.min, r2.min));
+ return overlapSpan / Math.min(span1, span2) >= threshold;
}
- assignPane(indicatorKey, indicator, configOverride = null) {
- if (configOverride && configOverride[indicatorKey]) {
- const override = configOverride[indicatorKey];
- // Handle both string ("indicator") and object ({pane: "indicator", ...})
- return typeof override === 'string' ? override : (override.pane || 'indicator');
- }
-
- if (indicator.pane && indicator.pane !== '') {
- return indicator.pane;
- }
-
- const indicatorRange = this.calculateIndicatorRange(indicator.data);
-
- if (this.rangesOverlap(this.candlestickRange, indicatorRange)) {
- return 'main';
- }
-
- return 'indicator';
+ _assignPane(indicator) {
+ if (indicator.pane) return indicator.pane;
+ const range = this._indicatorRange(indicator.data);
+ return this._rangesOverlap(this.candlestickRange, range) ? 'main' : 'indicator';
}
- assignAllPanes(indicators, configOverride = null) {
+ assignAllPanes(indicators) {
const result = {};
-
- Object.entries(indicators).forEach(([key, indicator]) => {
- result[key] = {
- ...indicator,
- pane: this.assignPane(key, indicator, configOverride),
- };
- });
-
+ for (const [key, indicator] of Object.entries(indicators)) {
+ result[key] = { ...indicator, pane: this._assignPane(indicator) };
+ }
return result;
}
}
diff --git a/out/js/PaneManager.js b/out/js/PaneManager.js
index 8163fa2..7e103d3 100644
--- a/out/js/PaneManager.js
+++ b/out/js/PaneManager.js
@@ -1,4 +1,3 @@
-/* Multi-pane chart manager with time-scale synchronization (SRP) */
export class PaneManager {
constructor(chartOptions) {
this.chartOptions = chartOptions;
@@ -7,6 +6,7 @@ export class PaneManager {
}
createMainPane(container, config) {
+ container.style.height = `${config.height}px`;
this.mainPane = {
container,
chart: LightweightCharts.createChart(container, {
@@ -22,7 +22,8 @@ export class PaneManager {
const containerDiv = document.createElement('div');
containerDiv.id = `${paneName}-chart`;
containerDiv.style.position = 'relative';
- containerDiv.style.zIndex = '1';
+ containerDiv.style.zIndex = '1';
+ containerDiv.style.height = `${config.height}px`;
const chartContainerDiv = document.querySelector('.chart-container');
chartContainerDiv.appendChild(containerDiv);
@@ -53,6 +54,12 @@ export class PaneManager {
return containers;
}
+ getAllPanes() {
+ const panes = [this.mainPane];
+ this.dynamicPanes.forEach(pane => panes.push(pane));
+ return panes;
+ }
+
synchronizeTimeScales() {
const charts = this.getAllCharts();
let isUpdating = false;
diff --git a/out/js/PaneResizeCalculator.js b/out/js/PaneResizeCalculator.js
new file mode 100644
index 0000000..bd2ff9b
--- /dev/null
+++ b/out/js/PaneResizeCalculator.js
@@ -0,0 +1,10 @@
+export const MIN_PANE_HEIGHT = 80;
+
+export class PaneResizeCalculator {
+ static calculate(aboveHeight, belowHeight, delta) {
+ const lo = MIN_PANE_HEIGHT - aboveHeight;
+ const hi = belowHeight - MIN_PANE_HEIGHT;
+ const clamped = Math.max(lo, Math.min(hi, delta));
+ return { above: aboveHeight + clamped, below: belowHeight - clamped };
+ }
+}
diff --git a/out/js/PaneResizeController.js b/out/js/PaneResizeController.js
new file mode 100644
index 0000000..8da9ef8
--- /dev/null
+++ b/out/js/PaneResizeController.js
@@ -0,0 +1,25 @@
+import { PaneResizeHandle } from './PaneResizeHandle.js';
+
+/**
+ * mount() — inserts N-1 handles between N panes; safe to call after destroy().
+ * destroy() — removes all handles and their document listeners; safe to call
+ * before new panes are created (e.g. on refresh).
+ *
+ * Pane shape: { container: HTMLElement, chart: IChartApi }
+ */
+export class PaneResizeController {
+ #handles = [];
+
+ mount(panes) {
+ for (let i = 0; i < panes.length - 1; i++) {
+ const handle = new PaneResizeHandle(panes[i], panes[i + 1]);
+ panes[i + 1].container.insertAdjacentElement('beforebegin', handle.element);
+ this.#handles.push(handle);
+ }
+ }
+
+ destroy() {
+ this.#handles.forEach(h => h.destroy());
+ this.#handles = [];
+ }
+}
diff --git a/out/js/PaneResizeHandle.js b/out/js/PaneResizeHandle.js
new file mode 100644
index 0000000..28ae80a
--- /dev/null
+++ b/out/js/PaneResizeHandle.js
@@ -0,0 +1,70 @@
+import { PaneResizeCalculator } from './PaneResizeCalculator.js';
+
+/**
+ * Call destroy() when the associated panes are torn down to remove the element
+ * and deregister all document-level listeners.
+ *
+ * Pane shape: { container: HTMLElement, chart: IChartApi }
+ */
+export class PaneResizeHandle {
+ #above;
+ #below;
+ #element;
+ #drag = null;
+
+ #onMouseMove = (e) => this.#move(e.clientY);
+ #onTouchMove = (e) => { if (this.#drag) { e.preventDefault(); this.#move(e.touches[0].clientY); } };
+ #onDragEnd = () => { this.#drag = null; };
+
+ constructor(above, below) {
+ this.#above = above;
+ this.#below = below;
+ this.#element = this.#buildElement();
+
+ document.addEventListener('mousemove', this.#onMouseMove);
+ document.addEventListener('touchmove', this.#onTouchMove, { passive: false });
+ document.addEventListener('mouseup', this.#onDragEnd);
+ document.addEventListener('touchend', this.#onDragEnd);
+ }
+
+ get element() { return this.#element; }
+
+ destroy() {
+ this.#element.remove();
+ document.removeEventListener('mousemove', this.#onMouseMove);
+ document.removeEventListener('touchmove', this.#onTouchMove);
+ document.removeEventListener('mouseup', this.#onDragEnd);
+ document.removeEventListener('touchend', this.#onDragEnd);
+ }
+
+ #buildElement() {
+ const el = document.createElement('div');
+ el.className = 'pane-resize-handle';
+ el.addEventListener('mousedown', (e) => this.#startDrag(e.clientY));
+ el.addEventListener('touchstart', (e) => { e.preventDefault(); this.#startDrag(e.touches[0].clientY); }, { passive: false });
+ return el;
+ }
+
+ #startDrag(y) {
+ this.#drag = {
+ startY: y,
+ aboveH: this.#above.container.clientHeight,
+ belowH: this.#below.container.clientHeight,
+ };
+ }
+
+ #move(y) {
+ if (!this.#drag) return;
+ const delta = y - this.#drag.startY;
+ const { above, below } = PaneResizeCalculator.calculate(
+ this.#drag.aboveH, this.#drag.belowH, delta,
+ );
+ this.#applyHeight(this.#above, above);
+ this.#applyHeight(this.#below, below);
+ }
+
+ #applyHeight({ container, chart }, height) {
+ container.style.height = `${height}px`;
+ chart.resize(container.clientWidth, height);
+ }
+}
diff --git a/out/js/SortDirection.js b/out/js/SortDirection.js
new file mode 100644
index 0000000..4fa7f45
--- /dev/null
+++ b/out/js/SortDirection.js
@@ -0,0 +1,4 @@
+export const SortDirection = Object.freeze({
+ DESC: 'desc',
+ ASC: 'asc',
+});
diff --git a/out/js/TradeMarkerBuilder.js b/out/js/TradeMarkerBuilder.js
new file mode 100644
index 0000000..4bfd076
--- /dev/null
+++ b/out/js/TradeMarkerBuilder.js
@@ -0,0 +1,62 @@
+const DIRECTION_LONG_COLOR = '#26a69a';
+const DIRECTION_SHORT_COLOR = '#ef5350';
+const OUTCOME_PROFIT_COLOR = '#26a69a';
+const OUTCOME_LOSS_COLOR = '#ef5350';
+
+export class TradeMarkerBuilder {
+ build(strategy, candlestickData) {
+ if (!strategy || !candlestickData?.length) return [];
+
+ const markers = [];
+
+ for (const trade of strategy.trades || []) {
+ const entryTime = this._resolveTime(trade.entryBar, trade.entryTime, candlestickData);
+ const exitTime = this._resolveTime(trade.exitBar, trade.exitTime, candlestickData);
+ if (entryTime !== null) markers.push(this._entryMarker(trade, entryTime));
+ if (exitTime !== null) markers.push(this._exitMarker(trade, exitTime));
+ }
+
+ for (const trade of strategy.openTrades || []) {
+ const entryTime = this._resolveTime(trade.entryBar, trade.entryTime, candlestickData);
+ if (entryTime !== null) markers.push(this._entryMarker(trade, entryTime));
+ }
+
+ markers.sort((a, b) => a.time - b.time);
+ return markers;
+ }
+
+ _resolveTime(barIndex, unixTime, candlestickData) {
+ if (barIndex >= 0 && barIndex < candlestickData.length) {
+ return candlestickData[barIndex].time;
+ }
+ return unixTime || null;
+ }
+
+ _entryMarker(trade, time) {
+ const isLong = trade.direction === 'long';
+ return {
+ time,
+ position: isLong ? 'belowBar' : 'aboveBar',
+ color: this._entryColor(trade),
+ shape: isLong ? 'arrowUp' : 'arrowDown',
+ };
+ }
+
+ _exitMarker(trade, time) {
+ const isLong = trade.direction === 'long';
+ return {
+ time,
+ position: isLong ? 'aboveBar' : 'belowBar',
+ color: this._exitColor(trade),
+ shape: 'circle',
+ };
+ }
+
+ _entryColor(trade) {
+ return trade.direction === 'long' ? DIRECTION_LONG_COLOR : DIRECTION_SHORT_COLOR;
+ }
+
+ _exitColor(trade) {
+ return trade.profit >= 0 ? OUTCOME_PROFIT_COLOR : OUTCOME_LOSS_COLOR;
+ }
+}
diff --git a/out/js/TradeRowData.js b/out/js/TradeRowData.js
index 6cf778f..b6e7bcb 100644
--- a/out/js/TradeRowData.js
+++ b/out/js/TradeRowData.js
@@ -1,28 +1,23 @@
/**
- * TradeRowData - Domain model for rowspan table rows
- *
- * SRP: Represents a single visual row (Entry or Exit) in the rowspan table
- * Each trade produces TWO rows: one Entry row + one Exit row
+ * `isPrimary` marks the first row of the pair; only the primary row carries
+ * Direction[rowspan=2] and Size[rowspan=2] cells.
+ * P/L is always on the exit row regardless of pair position.
*/
export class TradeRowData {
constructor(config) {
this.tradeNumber = config.tradeNumber;
- this.rowType = config.rowType; // 'entry' | 'exit'
- this.dateTime = config.dateTime;
- this.signal = config.signal;
- this.price = config.price;
- this.size = config.size;
- this.profitLoss = config.profitLoss;
- this.direction = config.direction;
- this.isOpen = config.isOpen;
- this.profitRaw = config.profitRaw;
+ this.rowType = config.rowType;
+ this.isPrimary = config.isPrimary;
+ this.dateTime = config.dateTime;
+ this.signal = config.signal;
+ this.price = config.price;
+ this.size = config.size;
+ this.profitLoss = config.profitLoss;
+ this.direction = config.direction;
+ this.isOpen = config.isOpen;
+ this.profitRaw = config.profitRaw;
}
- isEntryRow() {
- return this.rowType === 'entry';
- }
-
- isExitRow() {
- return this.rowType === 'exit';
- }
+ isEntryRow() { return this.rowType === 'entry'; }
+ isExitRow() { return this.rowType === 'exit'; }
}
diff --git a/out/js/TradeRowspanRenderer.js b/out/js/TradeRowspanRenderer.js
index 0555cce..4fda2c9 100644
--- a/out/js/TradeRowspanRenderer.js
+++ b/out/js/TradeRowspanRenderer.js
@@ -1,51 +1,69 @@
/**
- * TradeRowspanRenderer - Generates HTML for rowspan table structure
- *
- * SRP: Single responsibility - HTML generation for rowspan cells
- * KISS: Simple row generation logic
- *
- * Rowspan Structure:
- * Row 1 (Entry): Type[rowspan=2] | Entry Label | Entry Date | Entry Signal | Entry Price | Size[rowspan=2] | empty
- * Row 2 (Exit): | Exit Label | Exit Date | Exit Signal | Exit Price | | P/L[rowspan=2]
+ * TradeRowspanRenderer — stateless HTML generator for the rowspan trade table.
+ *
+ * Layout contract (7 columns: Type | Entry/Exit | DateTime | Signal | Price | Size | P/L):
+ *
+ * Primary row (isPrimary=true):
+ * Type[rowspan=2] | label | DateTime | Signal | Price | Size[rowspan=2] | P/L (exit only)
+ *
+ * Secondary row (isPrimary=false):
+ * label | DateTime | Signal | Price | P/L (exit only)
+ *
+ * P/L is always and only on exit rows, regardless of pair position.
+ * `.trade-open` class is applied to the and the P/L | of open exit rows.
*/
export class TradeRowspanRenderer {
- constructor() {}
+ #directionClass(row) {
+ return row.direction === 'long' ? 'trade-long' : 'trade-short';
+ }
+
+ #profitClass(row) {
+ if (row.isOpen) return 'trade-open';
+ return row.profitRaw >= 0 ? 'trade-profit-positive' : 'trade-profit-negative';
+ }
+
+ #label(row) {
+ return row.isEntryRow() ? 'Entry' : 'Exit';
+ }
+
+ #plCell(row) {
+ if (!row.isExitRow()) return '';
+ return ` | ${row.profitLoss} | `;
+ }
+
+ #primaryRowHtml(row) {
+ return [
+ `${row.direction.toUpperCase()} | `,
+ `${this.#label(row)} | `,
+ `${row.dateTime} | `,
+ `${row.signal} | `,
+ `${row.price} | `,
+ `${row.size} | `,
+ this.#plCell(row),
+ ].join('');
+ }
+
+ #secondaryRowHtml(row) {
+ return [
+ `${this.#label(row)} | `,
+ `${row.dateTime} | `,
+ `${row.signal} | `,
+ `${row.price} | `,
+ this.#plCell(row),
+ ].join('');
+ }
+
+ #trClass(row) {
+ return row.isExitRow() && row.isOpen ? ' class="trade-open"' : '';
+ }
- /**
- * Render single TradeRowData as HTML row with rowspan cells
- */
renderRow(row) {
- const directionClass = row.direction === 'long' ? 'trade-long' : 'trade-short';
- const profitClass = row.isOpen
- ? (row.profitRaw >= 0 ? 'trade-profit-positive' : 'trade-profit-negative')
- : (row.profitRaw >= 0 ? 'trade-profit-positive' : 'trade-profit-negative');
-
- let html = '
';
-
- if (row.isEntryRow()) {
- // Entry row: Type (rowspan=2), Entry label, date, signal, price, Size (rowspan=2)
- html += `| ${row.direction.toUpperCase()} | `;
- html += `Entry | `;
- html += `${row.dateTime} | `;
- html += `${row.signal} | `;
- html += `${row.price} | `;
- html += `${row.size} | `;
- } else {
- // Exit row: Exit label, date, signal, price, P/L
- html += `Exit | `;
- html += `${row.dateTime} | `;
- html += `${row.signal} | `;
- html += `${row.price} | `;
- html += `${row.profitLoss} | `;
- }
-
- html += '
';
- return html;
- }
-
- /**
- * Render array of TradeRowData as complete HTML
- */
+ const cells = row.isPrimary
+ ? this.#primaryRowHtml(row)
+ : this.#secondaryRowHtml(row);
+ return `${cells}
`;
+ }
+
renderRows(rows) {
return rows.map(row => this.renderRow(row)).join('\n');
}
diff --git a/out/js/TradeRowspanTransformer.js b/out/js/TradeRowspanTransformer.js
index 8b0dc39..59cd487 100644
--- a/out/js/TradeRowspanTransformer.js
+++ b/out/js/TradeRowspanTransformer.js
@@ -1,72 +1,58 @@
-import { TradeRowData } from './TradeRowData.js';
+import { TradeRowData } from './TradeRowData.js';
+import { SortDirection } from './SortDirection.js';
-/**
- * TradeRowspanTransformer - Transforms Trade objects into Entry/Exit row pairs
- *
- * SRP: Single responsibility - convert domain Trade to presentation TradeRowData pairs
- * DRY: Reuses TradeDataFormatter for date/price formatting
- * KISS: Simple transformation logic, no business logic
- */
export class TradeRowspanTransformer {
constructor(formatter) {
this.formatter = formatter;
}
/**
- * Transform single trade into [entryRow, exitRow] pair
+ * `isPrimary` on the first row drives rowspan-cell placement in the renderer.
+ * `size` is populated on both rows so the renderer always finds it on the primary.
*/
- transformTrade(trade, tradeNumber, currentPrice) {
- const isOpen = trade.status === 'open';
+ transformTrade(trade, tradeNumber, currentPrice, sortDirection = SortDirection.DESC) {
+ const isOpen = trade.status === 'open';
const unrealizedProfit = this.formatter.calculateUnrealizedProfit(trade, currentPrice);
-
- const exitPrice = isOpen
- ? (currentPrice !== null && currentPrice !== undefined ? currentPrice : trade.entryPrice)
- : (trade.exitPrice !== null && trade.exitPrice !== undefined ? trade.exitPrice : 0);
+ const exitPrice = isOpen ? (currentPrice ?? trade.entryPrice) : (trade.exitPrice ?? 0);
+ const profitValue = isOpen ? unrealizedProfit : trade.profit;
+ const sizeFormatted = trade.size.toFixed(2);
- const profitValue = isOpen ? unrealizedProfit : trade.profit;
- const formattedProfit = this.formatter.formatProfit(profitValue);
-
- // Entry row
const entryRow = new TradeRowData({
- tradeNumber: tradeNumber,
- rowType: 'entry',
- dateTime: this.formatter.getTradeDate(trade, true),
- signal: trade.entryComment || trade.EntryComment || '',
- price: this.formatter.formatPrice(trade.entryPrice),
- size: trade.size.toFixed(2),
+ tradeNumber,
+ rowType: 'entry',
+ isPrimary: sortDirection === SortDirection.ASC,
+ dateTime: this.formatter.getTradeDate(trade, true),
+ signal: trade.entryComment || trade.entryId || '',
+ price: this.formatter.formatPrice(trade.entryPrice),
+ size: sizeFormatted,
profitLoss: '',
- direction: trade.direction,
- isOpen: false,
- profitRaw: 0,
+ direction: trade.direction,
+ isOpen: false,
+ profitRaw: 0,
});
- // Exit row
const exitRow = new TradeRowData({
- tradeNumber: tradeNumber,
- rowType: 'exit',
- dateTime: isOpen ? 'Open' : this.formatter.getTradeDate(trade, false),
- signal: isOpen ? '' : (trade.exitComment || trade.ExitComment || ''),
- price: this.formatter.formatPrice(exitPrice),
- size: '',
- profitLoss: formattedProfit,
- direction: trade.direction,
- isOpen: isOpen,
- profitRaw: profitValue,
+ tradeNumber,
+ rowType: 'exit',
+ isPrimary: sortDirection === SortDirection.DESC,
+ dateTime: isOpen ? 'Open' : this.formatter.getTradeDate(trade, false),
+ signal: isOpen ? '' : (trade.exitComment || trade.exitId || ''),
+ price: this.formatter.formatPrice(exitPrice),
+ size: sizeFormatted,
+ profitLoss: this.formatter.formatProfit(profitValue),
+ direction: trade.direction,
+ isOpen,
+ profitRaw: profitValue,
});
- return [entryRow, exitRow];
+ return sortDirection === SortDirection.ASC
+ ? [entryRow, exitRow]
+ : [exitRow, entryRow];
}
- /**
- * Transform array of trades into flat array of TradeRowData
- * [trade1, trade2] → [trade1_entry, trade1_exit, trade2_entry, trade2_exit]
- */
- transformTrades(trades, currentPrice) {
- const rows = [];
- trades.forEach((trade, index) => {
- const [entryRow, exitRow] = this.transformTrade(trade, index + 1, currentPrice);
- rows.push(entryRow, exitRow);
- });
- return rows;
+ transformTrades(trades, currentPrice, sortDirection = SortDirection.DESC) {
+ return trades.flatMap((trade, index) =>
+ this.transformTrade(trade, index + 1, currentPrice, sortDirection)
+ );
}
}
diff --git a/out/js/TradeSortToggle.js b/out/js/TradeSortToggle.js
new file mode 100644
index 0000000..8a9b9ad
--- /dev/null
+++ b/out/js/TradeSortToggle.js
@@ -0,0 +1,43 @@
+import { SortDirection } from './SortDirection.js';
+
+export class TradeSortToggle {
+ #direction;
+ #onChange;
+ #button = null;
+
+ constructor(onChange, initialDirection = SortDirection.DESC) {
+ this.#direction = initialDirection;
+ this.#onChange = onChange;
+ }
+
+ get direction() {
+ return this.#direction;
+ }
+
+ mount(headerEl, hasAnyTrades) {
+ if (!this.#button) {
+ this.#button = document.createElement('button');
+ this.#button.className = 'sort-toggle-btn';
+ this.#button.addEventListener('click', () => this.#toggle());
+ headerEl.appendChild(this.#button);
+ }
+ this.#button.style.display = hasAnyTrades ? '' : 'none';
+ this.#syncLabel();
+ }
+
+ #toggle() {
+ this.#direction = this.#direction === SortDirection.DESC
+ ? SortDirection.ASC
+ : SortDirection.DESC;
+ this.#syncLabel();
+ this.#onChange(this.#direction);
+ }
+
+ #syncLabel() {
+ if (!this.#button) return;
+ const isAscending = this.#direction === SortDirection.ASC;
+ const arrow = isAscending ? '▲' : '▼';
+ this.#button.textContent = `${arrow} ${isAscending ? 'Oldest first' : 'Newest first'}`;
+ this.#button.setAttribute('aria-pressed', String(isAscending));
+ }
+}
diff --git a/out/js/TradeTable.js b/out/js/TradeTable.js
index 6033304..da41985 100644
--- a/out/js/TradeTable.js
+++ b/out/js/TradeTable.js
@@ -1,12 +1,21 @@
-/* Trade data formatting (SRP, DRY) */
+export function formatCurrency(value) {
+ if (value == null || !isFinite(value)) return '$0.00';
+ if (value === 0) return '$0.00';
+ const abs = value < 0 ? -value : value;
+ const formatted = abs.toLocaleString('en-US', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+ return value > 0 ? '+$' + formatted : '-$' + formatted;
+}
+
export class TradeDataFormatter {
constructor(candlestickData) {
this.candlestickData = candlestickData || [];
}
formatDate(timestamp) {
- const date = new Date(timestamp);
- return date.toLocaleString('en-US', {
+ return new Date(timestamp).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
@@ -16,32 +25,30 @@ export class TradeDataFormatter {
}
formatPrice(price) {
- if (price === null || price === undefined) return '$0.00';
- return `$${price.toFixed(2)}`;
+ if (price == null) return '$0.00';
+ return '$' + price.toFixed(2);
}
formatProfit(profit) {
- const formatted = `$${Math.abs(profit).toFixed(2)}`;
- return profit >= 0 ? `+${formatted}` : `-${formatted}`;
+ return formatCurrency(profit);
}
getTradeDate(trade, isEntry = true) {
const timeField = isEntry ? 'entryTime' : 'exitTime';
const barField = isEntry ? 'entryBar' : 'exitBar';
-
+
if (trade[timeField]) {
return this.formatDate(trade[timeField] * 1000);
}
-
+
const barIndex = trade[barField];
if (barIndex !== undefined && barIndex >= 0 && barIndex < this.candlestickData.length) {
const bar = this.candlestickData[barIndex];
- if (bar && bar.time !== undefined) {
- const timestamp = bar.time * 1000;
- return this.formatDate(timestamp);
+ if (bar?.time !== undefined) {
+ return this.formatDate(bar.time * 1000);
}
}
-
+
return 'N/A';
}
@@ -50,69 +57,4 @@ export class TradeDataFormatter {
const multiplier = trade.direction === 'long' ? 1 : -1;
return (currentPrice - trade.entryPrice) * trade.size * multiplier;
}
-
- formatTrade(trade, index, currentPrice) {
- const isOpen = trade.status === 'open';
- const unrealizedProfit = this.calculateUnrealizedProfit(trade, currentPrice);
-
- const exitPrice = isOpen
- ? (currentPrice !== null && currentPrice !== undefined ? currentPrice : trade.entryPrice)
- : (trade.exitPrice !== null && trade.exitPrice !== undefined ? trade.exitPrice : 0);
-
- return {
- number: index + 1,
- entryDate: this.getTradeDate(trade, true),
- entryBar: trade.entryBar !== undefined ? trade.entryBar : 'N/A',
- exitDate: isOpen ? 'Open' : this.getTradeDate(trade, false),
- exitBar: isOpen ? '-' : (trade.exitBar !== undefined ? trade.exitBar : 'N/A'),
- direction: trade.direction,
- entryPrice: this.formatPrice(trade.entryPrice),
- exitPrice: this.formatPrice(exitPrice),
- size: trade.size.toFixed(2),
- profit: isOpen ? this.formatProfit(unrealizedProfit) : this.formatProfit(trade.profit),
- profitRaw: isOpen ? unrealizedProfit : trade.profit,
- entryId: trade.entryId || trade.entryID || 'N/A',
- isOpen: isOpen,
- };
- }
-}
-
-/* Trade table HTML renderer (SRP, KISS) */
-export class TradeTableRenderer {
- constructor(formatter) {
- this.formatter = formatter;
- }
-
- renderRows(trades, currentPrice) {
- return trades
- .map((trade, index) => {
- const formatted = this.formatter.formatTrade(trade, index, currentPrice);
- const directionClass =
- formatted.direction === 'long' ? 'trade-long' : 'trade-short';
- const profitClass = formatted.isOpen
- ? formatted.profitRaw >= 0
- ? 'trade-profit-positive'
- : 'trade-profit-negative'
- : formatted.profitRaw >= 0
- ? 'trade-profit-positive'
- : 'trade-profit-negative';
-
- return `
-
- | ${formatted.number} |
- ${formatted.entryDate} |
- ${formatted.entryBar} |
- ${formatted.direction.toUpperCase()} |
- ${formatted.entryPrice} |
- ${formatted.exitDate} |
- ${formatted.exitBar} |
- ${formatted.exitPrice} |
- ${formatted.size} |
- ${formatted.profit} |
- ${formatted.entryId} |
-
- `;
- })
- .join('');
- }
}
diff --git a/out/js/WindowResizeHandler.js b/out/js/WindowResizeHandler.js
new file mode 100644
index 0000000..c4d5436
--- /dev/null
+++ b/out/js/WindowResizeHandler.js
@@ -0,0 +1,12 @@
+export class WindowResizeHandler {
+ #handler;
+
+ constructor(onResize) {
+ this.#handler = onResize;
+ window.addEventListener('resize', this.#handler);
+ }
+
+ destroy() {
+ window.removeEventListener('resize', this.#handler);
+ }
+}
diff --git a/out/package.json b/out/package.json
new file mode 100644
index 0000000..3dbc1ca
--- /dev/null
+++ b/out/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "module"
+}
diff --git a/out/tests/ChartManager.test.js b/out/tests/ChartManager.test.js
new file mode 100644
index 0000000..2ca1b66
--- /dev/null
+++ b/out/tests/ChartManager.test.js
@@ -0,0 +1,190 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { ChartManager } from '../js/ChartManager.js';
+
+// ── Stubs ──────────────────────────────────────────────────────────────────────
+
+function makeChartSpy() {
+ const resizeCalls = [];
+ const fitCalls = [];
+ return {
+ resize: (w, h) => resizeCalls.push({ w, h }),
+ timeScale: () => ({ fitContent: () => fitCalls.push(1) }),
+ _resizeCalls: resizeCalls,
+ _fitCalls: fitCalls,
+ };
+}
+
+function makeContainer(clientWidth, clientHeight) {
+ return { clientWidth, clientHeight };
+}
+
+function stubLightweightCharts() {
+ const calls = [];
+ const chartStub = {};
+ global.LightweightCharts = {
+ createChart: (container, options) => { calls.push({ container, options }); return chartStub; },
+ };
+ return { calls, chartStub };
+}
+
+// ── ChartManager.createChart — option composition ─────────────────────────────
+//
+// createChart spreads chartOptions then unconditionally overrides height and
+// width from config and container respectively. Non-conflicting chartOptions
+// keys must pass through intact.
+
+describe('createChart() — height comes from config, never from chartOptions', () => {
+ it('config.height is used as the height option', () => {
+ const { calls } = stubLightweightCharts();
+ ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, {});
+ assert.equal(calls[0].options.height, 400);
+ });
+
+ it('config.height overrides a conflicting height key in chartOptions', () => {
+ const { calls } = stubLightweightCharts();
+ ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, { height: 9999 });
+ assert.equal(calls[0].options.height, 400);
+ });
+});
+
+describe('createChart() — width comes from container.clientWidth, never from chartOptions', () => {
+ it('container.clientWidth is used as the width option', () => {
+ const { calls } = stubLightweightCharts();
+ ChartManager.createChart({ clientWidth: 1200 }, { height: 400 }, {});
+ assert.equal(calls[0].options.width, 1200);
+ });
+
+ it('container.clientWidth overrides a conflicting width key in chartOptions', () => {
+ const { calls } = stubLightweightCharts();
+ ChartManager.createChart({ clientWidth: 1200 }, { height: 400 }, { width: 1 });
+ assert.equal(calls[0].options.width, 1200);
+ });
+});
+
+describe('createChart() — chartOptions pass-through and LightweightCharts delegation', () => {
+ it('non-conflicting chartOptions properties reach LightweightCharts.createChart', () => {
+ const { calls } = stubLightweightCharts();
+ ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, { layout: { background: '#000' } });
+ assert.deepEqual(calls[0].options.layout, { background: '#000' });
+ });
+
+ it('passes the container element as the first argument to LightweightCharts.createChart', () => {
+ const { calls } = stubLightweightCharts();
+ const container = { clientWidth: 800 };
+ ChartManager.createChart(container, { height: 400 }, {});
+ assert.equal(calls[0].container, container);
+ });
+
+ it('returns the chart instance from LightweightCharts.createChart', () => {
+ const { chartStub } = stubLightweightCharts();
+ const result = ChartManager.createChart({ clientWidth: 800 }, { height: 400 }, {});
+ assert.equal(result, chartStub);
+ });
+});
+
+// ── ChartManager.fitContent — timescale fit delegation ────────────────────────
+//
+// fitContent calls timeScale().fitContent() on every chart exactly once per
+// invocation. The implementation must iterate all charts, not only the first.
+
+describe('fitContent() — zero charts: no error', () => {
+ it('empty chart list produces no error', () => {
+ assert.doesNotThrow(() => ChartManager.fitContent([]));
+ });
+});
+
+describe('fitContent() — timeScale().fitContent() called exactly once per chart', () => {
+ const chartCounts = [1, 2, 5];
+
+ for (const n of chartCounts) {
+ it(`${n} chart(s): each receives exactly one fitContent call`, () => {
+ const charts = Array.from({ length: n }, makeChartSpy);
+ ChartManager.fitContent(charts);
+ charts.forEach((chart, i) => {
+ assert.equal(chart._fitCalls.length, 1, `chart[${i}] fitContent call count`);
+ });
+ });
+ }
+});
+
+// ── ChartManager.handleResize — per-container resize delegation ───────────────
+//
+// handleResize reads clientWidth and clientHeight from each chart's own
+// container and calls chart.resize(width, height) exactly once per chart per
+// invocation. Width and height must come from the same container with no
+// cross-contamination between panes.
+
+describe('handleResize() — zero charts: no error', () => {
+ it('empty chart and container lists produce no error', () => {
+ assert.doesNotThrow(() => ChartManager.handleResize([], []));
+ });
+});
+
+describe('handleResize() — each chart receives its own container dimensions', () => {
+ const resizeCases = [
+ {
+ label: '1 pane (main only)',
+ containers: [makeContainer(800, 400)],
+ },
+ {
+ label: '2 panes (main + 1 indicator)',
+ containers: [makeContainer(1024, 600), makeContainer(1024, 200)],
+ },
+ {
+ label: '5 panes — each with a distinct width and height',
+ containers: [
+ makeContainer(320, 568),
+ makeContainer(768, 1024),
+ makeContainer(1440, 900),
+ makeContainer(2560, 1440),
+ makeContainer(3840, 2160),
+ ],
+ },
+ ];
+
+ for (const { label, containers } of resizeCases) {
+ it(`${label}: resize(clientWidth, clientHeight) called once per chart with matching dimensions`, () => {
+ const charts = Array.from({ length: containers.length }, makeChartSpy);
+ ChartManager.handleResize(charts, containers);
+ charts.forEach((chart, i) => {
+ assert.deepEqual(
+ chart._resizeCalls,
+ [{ w: containers[i].clientWidth, h: containers[i].clientHeight }],
+ `chart[${i}]`,
+ );
+ });
+ });
+ }
+});
+
+describe('handleResize() — reads container state at call time', () => {
+ it('successive calls each propagate the current container dimensions', () => {
+ const chart = makeChartSpy();
+ const container = makeContainer(800, 400);
+
+ ChartManager.handleResize([chart], [container]);
+
+ container.clientHeight = 700;
+ ChartManager.handleResize([chart], [container]);
+
+ assert.deepEqual(chart._resizeCalls, [
+ { w: 800, h: 400 },
+ { w: 800, h: 700 },
+ ]);
+ });
+});
+
+describe('handleResize() — boundary dimensions', () => {
+ it('zero width and height are propagated faithfully (collapsed or hidden pane)', () => {
+ const chart = makeChartSpy();
+ ChartManager.handleResize([chart], [makeContainer(0, 0)]);
+ assert.deepEqual(chart._resizeCalls, [{ w: 0, h: 0 }]);
+ });
+
+ it('fractional sub-pixel HiDPI dimensions are propagated without rounding', () => {
+ const chart = makeChartSpy();
+ ChartManager.handleResize([chart], [makeContainer(1920.5, 1080.5)]);
+ assert.deepEqual(chart._resizeCalls, [{ w: 1920.5, h: 1080.5 }]);
+ });
+});
diff --git a/out/tests/DesignSystem.test.js b/out/tests/DesignSystem.test.js
new file mode 100644
index 0000000..39033d4
--- /dev/null
+++ b/out/tests/DesignSystem.test.js
@@ -0,0 +1,201 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+// ── File readers ──────────────────────────────────────────────────────────────
+//
+// Files are read once at module scope; all test bodies reference the cached
+// strings. A missing file throws at load time, which fails the suite clearly.
+
+const tokensCSS = readFileSync(new URL('../css/tokens.css', import.meta.url), 'utf8');
+const stylesCSS = readFileSync(new URL('../css/styles.css', import.meta.url), 'utf8');
+const indexHTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
+
+// ── CSS parsers ───────────────────────────────────────────────────────────────
+//
+// Pure functions over CSS text. Each extracts one kind of information so
+// tests can compose them without re-implementing the same regex in every it().
+
+function stripComments(css) {
+ return css.replace(/\/\*[\s\S]*?\*\//g, '');
+}
+
+function declaredTokensIn(css) {
+ return new Set(stripComments(css).match(/--[\w-]+(?=\s*:)/g) ?? []);
+}
+
+function referencedTokensIn(css) {
+ return new Set(
+ [...stripComments(css).matchAll(/var\(\s*(--[\w-]+)/g)].map(m => m[1]),
+ );
+}
+
+function hexColorsIn(css) {
+ return stripComments(css).match(/#[0-9a-fA-F]{3,8}\b/g) ?? [];
+}
+
+function rgbLiteralsIn(css) {
+ return stripComments(css).match(/rgba?\s*\(/g) ?? [];
+}
+
+function importPathsIn(css) {
+ return [...css.matchAll(/@import\s+['"]([^'"]+)['"]/g)].map(m => m[1]);
+}
+
+const declaredTokens = declaredTokensIn(tokensCSS);
+const referencedTokens = referencedTokensIn(stylesCSS);
+
+// ── tokens.css — file structure ───────────────────────────────────────────────
+//
+// Tokens file must be non-empty and contain the :root declaration block that
+// makes the custom properties available to the entire document.
+
+describe('tokens.css — file is non-empty', () => {
+ it('file has content', () => {
+ assert.ok(tokensCSS.trim().length > 0);
+ });
+});
+
+describe('tokens.css — :root block', () => {
+ it(':root block is present', () => {
+ assert.ok(stripComments(tokensCSS).includes(':root'));
+ });
+
+ it(':root block has an opening brace', () => {
+ assert.match(stripComments(tokensCSS), /:root\s*\{/);
+ });
+});
+
+describe('tokens.css — token naming convention', () => {
+ it('at least one custom property is declared', () => {
+ assert.ok(declaredTokens.size > 0);
+ });
+
+ it('every declared name starts with -- (CSS custom property)', () => {
+ for (const token of declaredTokens) {
+ assert.ok(token.startsWith('--'), `'${token}' does not start with '--'`);
+ }
+ });
+});
+
+// ── styles.css — file structure ───────────────────────────────────────────────
+//
+// styles.css must be non-empty, must explicitly @import tokens.css so the
+// dependency is visible in code, and must not re-declare the :root block
+// (tokens live only in tokens.css).
+
+describe('styles.css — file is non-empty', () => {
+ it('file has content', () => {
+ assert.ok(stylesCSS.trim().length > 0);
+ });
+});
+
+describe('styles.css — @import dependency declaration', () => {
+ it("@imports exactly one file", () => {
+ assert.equal(importPathsIn(stylesCSS).length, 1);
+ });
+
+ it("@imports './tokens.css'", () => {
+ assert.ok(
+ importPathsIn(stylesCSS).includes('./tokens.css'),
+ `imports found: ${importPathsIn(stylesCSS).join(', ')}`,
+ );
+ });
+
+ it('@import is the first non-whitespace statement', () => {
+ assert.match(stylesCSS.trimStart(), /^@import/);
+ });
+});
+
+describe('styles.css — no :root block (tokens live only in tokens.css)', () => {
+ it(':root is absent from the component stylesheet', () => {
+ assert.ok(!stripComments(stylesCSS).includes(':root'));
+ });
+});
+
+// ── styles.css — single source of truth for color values ─────────────────────
+//
+// Every color must be defined in tokens.css and consumed via var(). A
+// hardcoded color literal in styles.css means the token system has been
+// bypassed and a future design change requires editing both files.
+
+describe('styles.css — no hardcoded hex color literals', () => {
+ it('zero hex color patterns after comment stripping', () => {
+ const found = hexColorsIn(stylesCSS);
+ assert.deepEqual(
+ found,
+ [],
+ `hardcoded hex colors found: ${found.join(', ')}`,
+ );
+ });
+});
+
+describe('styles.css — no hardcoded rgb() / rgba() literals', () => {
+ it('zero rgb/rgba patterns after comment stripping', () => {
+ const found = rgbLiteralsIn(stylesCSS);
+ assert.deepEqual(
+ found,
+ [],
+ `hardcoded rgb/rgba literals found: ${found.join(', ')}`,
+ );
+ });
+});
+
+// ── styles.css → tokens.css — variable resolution ────────────────────────────
+//
+// Every var(--x) reference in styles.css must have a matching declaration
+// in tokens.css. An unresolved reference silently falls back to the
+// browser default (typically empty string), producing invisible breakage.
+
+describe('styles.css — every var() reference resolves to a declared token', () => {
+ for (const token of referencedTokens) {
+ it(`var(${token}) is declared in tokens.css`, () => {
+ assert.ok(
+ declaredTokens.has(token),
+ `'${token}' is referenced in styles.css but not declared in tokens.css`,
+ );
+ });
+ }
+});
+
+// ── tokens.css — no dead tokens ───────────────────────────────────────────────
+//
+// Every declared token must be consumed by styles.css. Dead tokens add noise
+// to the token surface, mislead future editors, and never survive rotation of
+// the design.
+
+describe('tokens.css — every declared token is consumed by styles.css', () => {
+ for (const token of declaredTokens) {
+ it(`${token} is referenced in styles.css`, () => {
+ assert.ok(
+ referencedTokens.has(token),
+ `'${token}' is declared in tokens.css but never referenced in styles.css`,
+ );
+ });
+ }
+});
+
+// ── index.html — stylesheet integration ──────────────────────────────────────
+//
+// The page must load styles through the external stylesheet, never through an
+// inline