Last active
March 21, 2026 21:56
-
-
Save bojanrajkovic/3e9aa4de8a4051c63e5d41a4620d79d0 to your computer and use it in GitHub Desktop.
ATC — Action Traffic Control UI Explorer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>ATC — Action Traffic Control UI Explorer</title> | |
| <style> | |
| :root { | |
| --bg: #0a0e14; | |
| --bg2: #131820; | |
| --bg3: #1a2030; | |
| --border: #2a3040; | |
| --text: #c8d0dc; | |
| --text-dim: #6a7588; | |
| --accent: #4a9eff; | |
| --queued: #4a9eff; | |
| --running: #e8b820; | |
| --success: #2ecc71; | |
| --failed: #e74c3c; | |
| --cancelled: #6a7588; | |
| --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | |
| --mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| } | |
| .light { | |
| --bg: #f0f2f5; | |
| --bg2: #ffffff; | |
| --bg3: #e8ecf0; | |
| --border: #d0d5dc; | |
| --text: #1a2030; | |
| --text-dim: #6a7588; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: var(--font); | |
| background: var(--bg); | |
| color: var(--text); | |
| display: flex; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| /* === CONTROLS PANEL === */ | |
| #controls { | |
| width: 280px; | |
| min-width: 280px; | |
| background: var(--bg2); | |
| border-right: 1px solid var(--border); | |
| overflow-y: auto; | |
| padding: 16px; | |
| } | |
| #controls h1 { | |
| font-size: 16px; | |
| font-weight: 700; | |
| margin-bottom: 4px; | |
| color: var(--accent); | |
| font-family: var(--mono); | |
| letter-spacing: 1px; | |
| } | |
| #controls .subtitle { | |
| font-size: 11px; | |
| color: var(--text-dim); | |
| margin-bottom: 16px; | |
| } | |
| .control-group { | |
| margin-bottom: 16px; | |
| padding-bottom: 16px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .control-group:last-child { border-bottom: none; } | |
| .control-group h3 { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| color: var(--text-dim); | |
| margin-bottom: 10px; | |
| } | |
| .control-row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 8px; | |
| } | |
| .control-row label { | |
| font-size: 12px; | |
| color: var(--text); | |
| } | |
| select, input[type="range"] { | |
| background: var(--bg3); | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| border-radius: 4px; | |
| padding: 4px 8px; | |
| font-size: 12px; | |
| font-family: var(--font); | |
| } | |
| select { cursor: pointer; } | |
| input[type="range"] { width: 100px; } | |
| .toggle { | |
| position: relative; | |
| width: 36px; | |
| height: 20px; | |
| background: var(--bg3); | |
| border-radius: 10px; | |
| cursor: pointer; | |
| border: 1px solid var(--border); | |
| transition: background 0.2s; | |
| } | |
| .toggle.on { background: var(--accent); border-color: var(--accent); } | |
| .toggle::after { | |
| content: ''; | |
| position: absolute; | |
| top: 2px; | |
| left: 2px; | |
| width: 14px; | |
| height: 14px; | |
| background: white; | |
| border-radius: 50%; | |
| transition: transform 0.2s; | |
| } | |
| .toggle.on::after { transform: translateX(16px); } | |
| .btn { | |
| background: var(--bg3); | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| padding: 6px 12px; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| cursor: pointer; | |
| font-family: var(--font); | |
| transition: background 0.15s; | |
| } | |
| .btn:hover { background: var(--border); } | |
| .btn-row { | |
| display: flex; | |
| gap: 6px; | |
| margin-top: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .preset-btn { | |
| flex: 1; | |
| min-width: 70px; | |
| text-align: center; | |
| font-size: 10px; | |
| padding: 5px 8px; | |
| } | |
| .preset-btn.active { | |
| background: var(--accent); | |
| border-color: var(--accent); | |
| color: white; | |
| } | |
| /* === MAIN AREA === */ | |
| #main { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| /* === RUNNER BAR === */ | |
| #runner-bar { | |
| background: var(--bg2); | |
| border-bottom: 1px solid var(--border); | |
| padding: 10px 16px; | |
| display: flex; | |
| gap: 16px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .runway { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 11px; | |
| } | |
| .runway-label { | |
| font-family: var(--mono); | |
| font-size: 10px; | |
| color: var(--text-dim); | |
| min-width: 120px; | |
| } | |
| .runway-bar-bg { | |
| width: 80px; | |
| height: 8px; | |
| background: var(--bg3); | |
| border-radius: 4px; | |
| overflow: hidden; | |
| } | |
| .runway-bar-fill { | |
| height: 100%; | |
| border-radius: 4px; | |
| transition: width 0.3s, background 0.3s; | |
| } | |
| .runway-count { | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| min-width: 50px; | |
| } | |
| .runway-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| } | |
| /* === PREVIEW AREA === */ | |
| #preview { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 16px; | |
| } | |
| /* === KANBAN LAYOUT === */ | |
| .kanban { | |
| display: flex; | |
| gap: 12px; | |
| height: 100%; | |
| } | |
| .kanban-col { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| min-width: 0; | |
| } | |
| .kanban-header { | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| color: var(--text-dim); | |
| padding: 8px 12px; | |
| border-bottom: 2px solid var(--border); | |
| margin-bottom: 8px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .kanban-header .count { | |
| background: var(--bg3); | |
| border-radius: 10px; | |
| padding: 1px 8px; | |
| font-size: 10px; | |
| font-family: var(--mono); | |
| } | |
| .kanban-items { | |
| flex: 1; | |
| overflow-y: auto; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| /* === LIST LAYOUT === */ | |
| .list-view { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| /* === TREEMAP LAYOUT === */ | |
| .treemap { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| height: 100%; | |
| } | |
| .treemap-group { | |
| flex: 1; | |
| min-height: 0; | |
| } | |
| .treemap-group-label { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| color: var(--text-dim); | |
| padding: 0 4px 6px; | |
| font-family: var(--mono); | |
| } | |
| .treemap-grid { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px; | |
| height: calc(100% - 24px); | |
| align-content: stretch; | |
| } | |
| /* === JOB CARD === */ | |
| .job-card { | |
| background: var(--bg2); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 10px 12px; | |
| position: relative; | |
| overflow: hidden; | |
| transition: all 0.3s; | |
| } | |
| .job-card::before { | |
| content: ''; | |
| position: absolute; | |
| left: 0; | |
| top: 0; | |
| bottom: 0; | |
| width: 3px; | |
| } | |
| .job-card.status-queued::before { background: var(--queued); } | |
| .job-card.status-running::before { background: var(--running); } | |
| .job-card.status-success::before { background: var(--success); } | |
| .job-card.status-failed::before { background: var(--failed); } | |
| .job-card.status-cancelled::before { background: var(--cancelled); } | |
| /* Pulsating halo for running */ | |
| .job-card.status-running { | |
| animation: pulse-border 2s ease-in-out infinite; | |
| } | |
| @keyframes pulse-border { | |
| 0%, 100% { box-shadow: 0 0 0 0 rgba(232, 184, 32, 0); } | |
| 50% { box-shadow: 0 0 8px 2px rgba(232, 184, 32, 0.25); } | |
| } | |
| .job-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 4px; | |
| } | |
| .job-status-icon { | |
| font-size: 14px; | |
| flex-shrink: 0; | |
| width: 18px; | |
| text-align: center; | |
| } | |
| .job-status-icon.queued { color: var(--queued); } | |
| .job-status-icon.running { color: var(--running); } | |
| .job-status-icon.success { color: var(--success); } | |
| .job-status-icon.failed { color: var(--failed); } | |
| .job-status-icon.cancelled { color: var(--cancelled); } | |
| .job-name { | |
| font-size: 13px; | |
| font-weight: 600; | |
| flex: 1; | |
| min-width: 0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .job-duration { | |
| font-size: 11px; | |
| font-family: var(--mono); | |
| color: var(--text-dim); | |
| flex-shrink: 0; | |
| } | |
| .job-meta { | |
| font-size: 10px; | |
| color: var(--text-dim); | |
| margin-bottom: 6px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .job-progress { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 4px; | |
| } | |
| .job-progress-label { | |
| font-size: 10px; | |
| color: var(--text-dim); | |
| font-family: var(--mono); | |
| flex-shrink: 0; | |
| } | |
| .job-progress-bar { | |
| flex: 1; | |
| height: 4px; | |
| background: var(--bg3); | |
| border-radius: 2px; | |
| overflow: hidden; | |
| } | |
| .job-progress-fill { | |
| height: 100%; | |
| border-radius: 2px; | |
| transition: width 0.3s; | |
| } | |
| .status-queued .job-progress-fill { background: var(--queued); } | |
| .status-running .job-progress-fill { background: var(--running); } | |
| .status-success .job-progress-fill { background: var(--success); } | |
| .status-failed .job-progress-fill { background: var(--failed); } | |
| .job-runner { | |
| font-size: 10px; | |
| color: var(--text-dim); | |
| font-family: var(--mono); | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .job-runner .runner-icon { margin-right: 4px; } | |
| /* === COMPACT CARD === */ | |
| .job-card.compact { | |
| padding: 6px 10px; | |
| } | |
| .job-card.compact .job-meta, | |
| .job-card.compact .job-progress, | |
| .job-card.compact .job-runner { display: none; } | |
| .job-card.compact .job-name { font-size: 12px; } | |
| /* === TREEMAP CARD === */ | |
| .job-card.treemap-card { | |
| padding: 12px; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| text-align: center; | |
| min-width: 80px; | |
| min-height: 60px; | |
| flex-grow: 1; | |
| } | |
| .job-card.treemap-card .job-header { | |
| flex-direction: column; | |
| gap: 4px; | |
| width: 100%; | |
| } | |
| .job-card.treemap-card .job-status-icon { font-size: 32px; } | |
| .job-card.treemap-card.treemap-lg .job-status-icon { font-size: 48px; } | |
| .job-card.treemap-card.treemap-sm .job-status-icon { font-size: 20px; } | |
| .job-card.treemap-card .job-name { | |
| font-size: 12px; | |
| white-space: normal; | |
| line-height: 1.3; | |
| word-break: break-word; | |
| } | |
| .job-card.treemap-card.treemap-lg .job-name { font-size: 14px; } | |
| .job-card.treemap-card.treemap-sm .job-name { font-size: 9px; } | |
| .job-card.treemap-card .job-duration { | |
| font-size: 11px; | |
| margin-top: 2px; | |
| } | |
| .job-card.treemap-card.treemap-lg .job-duration { font-size: 14px; } | |
| .job-card.treemap-card.treemap-sm .job-duration { font-size: 9px; } | |
| .job-card.treemap-card .job-meta { | |
| display: block; | |
| font-size: 9px; | |
| margin-bottom: 0; | |
| } | |
| .job-card.treemap-card.treemap-sm .job-meta { display: none; } | |
| .job-card.treemap-card .job-progress { display: none; } | |
| .job-card.treemap-card .job-runner { display: none; } | |
| /* === PROMPT OUTPUT === */ | |
| #prompt-area { | |
| background: var(--bg2); | |
| border-top: 1px solid var(--border); | |
| padding: 12px 16px; | |
| max-height: 120px; | |
| overflow-y: auto; | |
| } | |
| #prompt-area .prompt-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 6px; | |
| } | |
| #prompt-area .prompt-header span { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| color: var(--text-dim); | |
| } | |
| #prompt-text { | |
| font-size: 12px; | |
| line-height: 1.5; | |
| color: var(--text); | |
| } | |
| .copy-btn { | |
| background: var(--accent); | |
| border: none; | |
| color: white; | |
| padding: 4px 12px; | |
| border-radius: 4px; | |
| font-size: 10px; | |
| cursor: pointer; | |
| font-family: var(--font); | |
| transition: opacity 0.15s; | |
| } | |
| .copy-btn:hover { opacity: 0.85; } | |
| /* === SCROLLBAR === */ | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="controls"> | |
| <h1>⟐ ATC</h1> | |
| <div class="subtitle">Action Traffic Control — UI Explorer</div> | |
| <div class="control-group"> | |
| <h3>Presets</h3> | |
| <div class="btn-row"> | |
| <button class="btn preset-btn active" onclick="applyPreset('radar')">Radar</button> | |
| <button class="btn preset-btn" onclick="applyPreset('cockpit')">Cockpit</button> | |
| <button class="btn preset-btn" onclick="applyPreset('wall')">Wall</button> | |
| <button class="btn preset-btn" onclick="applyPreset('minimal')">Minimal</button> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <h3>Layout</h3> | |
| <div class="control-row"> | |
| <label>View</label> | |
| <select id="layout" onchange="s('layout',this.value);updateAll()"> | |
| <option value="kanban">Kanban</option> | |
| <option value="list">List</option> | |
| <option value="treemap">Treemap (HD)</option> | |
| </select> | |
| </div> | |
| <div class="control-row"> | |
| <label>Card density</label> | |
| <select id="density" onchange="s('compact',this.value==='compact');updateAll()"> | |
| <option value="expanded">Expanded</option> | |
| <option value="compact">Compact</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <h3>Theme</h3> | |
| <div class="control-row"> | |
| <label>Dark mode</label> | |
| <div class="toggle on" id="theme-toggle" onclick="toggleTheme()"></div> | |
| </div> | |
| <div class="control-row"> | |
| <label>Halo animation</label> | |
| <div class="toggle on" id="halo-toggle" onclick="toggleHalo()"></div> | |
| </div> | |
| <div class="control-row"> | |
| <label>Show runners</label> | |
| <div class="toggle on" id="runner-toggle" onclick="toggleRunners()"></div> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <h3>Card style</h3> | |
| <div class="control-row"> | |
| <label>Border radius</label> | |
| <input type="range" id="radius" min="0" max="16" value="6" oninput="s('radius',+this.value);updateAll()"> | |
| </div> | |
| <div class="control-row"> | |
| <label>Left accent bar</label> | |
| <div class="toggle on" id="accent-toggle" onclick="toggleAccent()"></div> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <h3>Runners</h3> | |
| <div class="control-row"> | |
| <label>Linux runners</label> | |
| <input type="range" id="linux-runners" min="1" max="10" value="5" oninput="s('linuxRunners',+this.value);updateAll()"> | |
| </div> | |
| <div class="control-row"> | |
| <label>macOS runners</label> | |
| <input type="range" id="macos-runners" min="1" max="5" value="2" oninput="s('macosRunners',+this.value);updateAll()"> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <h3>Simulation</h3> | |
| <div class="btn-row"> | |
| <button class="btn" onclick="addJob('queued')">+ Queued</button> | |
| <button class="btn" onclick="addJob('running')">+ Running</button> | |
| <button class="btn" onclick="addJob('failed')">+ Failed</button> | |
| </div> | |
| <div class="btn-row" style="margin-top:6px"> | |
| <button class="btn" onclick="advanceJobs()">Advance all</button> | |
| <button class="btn" onclick="resetJobs()">Reset</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="main"> | |
| <div id="runner-bar"></div> | |
| <div id="preview"></div> | |
| <div id="prompt-area"> | |
| <div class="prompt-header"> | |
| <span>Design prompt</span> | |
| <button class="copy-btn" onclick="copyPrompt()">Copy</button> | |
| </div> | |
| <div id="prompt-text"></div> | |
| </div> | |
| </div> | |
| <script> | |
| const SYMBOLS = { queued: '◐', running: '▶', success: '✓', failed: '✗', cancelled: '⊘' }; | |
| const COLORS = { queued: '#4a9eff', running: '#e8b820', success: '#2ecc71', failed: '#e74c3c', cancelled: '#6a7588' }; | |
| const REPOS = ['loupe-app/loupe', 'loupe-app/issue-backend', 'coderinserepeat-com/coderinserepeat.com', 'loupe-app/design-system', 'coderinserepeat-com/home.coderinserepeat.com']; | |
| const WORKFLOWS = ['CI', 'Deploy', 'Release', 'Lint & Test', 'Build & Publish']; | |
| const BRANCHES = ['main', 'feat/oauth-flow', 'fix/websocket-reconnect', 'chore/deps', 'feat/atc-dashboard']; | |
| const STEPS = ['Set up job', 'Run actions/checkout@v4', 'Install dependencies', 'Run pnpm install', 'Run lint', 'Run type-check', 'Run unit tests', 'Run integration tests', 'Run playwright tests', 'Build', 'Upload artifacts', 'Deploy to staging', 'Post cleanup']; | |
| const RUNNERS_LINUX = ['loupe-homelab-brajkovic-linux-vbb7l-runner-r86pc', 'loupe-homelab-brajkovic-linux-vbb7l-runner-wcgm5', 'loupe-homelab-brajkovic-linux-abc12-runner-k9x3f']; | |
| const RUNNERS_MACOS = ['loupe-homelab-brajkovic-macos-1', 'loupe-homelab-brajkovic-macos-2']; | |
| const DEFAULTS = { | |
| layout: 'kanban', compact: false, dark: true, halo: true, | |
| showRunners: true, radius: 6, accentBar: true, | |
| linuxRunners: 5, macosRunners: 2 | |
| }; | |
| let state = { ...DEFAULTS }; | |
| let jobs = []; | |
| let jobId = 0; | |
| function s(key, val) { state[key] = val; } | |
| function generateJobs() { | |
| jobs = []; | |
| // 2 queued | |
| for (let i = 0; i < 2; i++) jobs.push(makeJob('queued')); | |
| // 4 running | |
| for (let i = 0; i < 4; i++) jobs.push(makeJob('running')); | |
| // 3 success | |
| for (let i = 0; i < 3; i++) jobs.push(makeJob('success')); | |
| // 1 failed | |
| jobs.push(makeJob('failed')); | |
| // 1 cancelled | |
| jobs.push(makeJob('cancelled')); | |
| } | |
| function makeJob(status) { | |
| const repo = REPOS[Math.floor(Math.random() * REPOS.length)]; | |
| const workflow = WORKFLOWS[Math.floor(Math.random() * WORKFLOWS.length)]; | |
| const branch = BRANCHES[Math.floor(Math.random() * BRANCHES.length)]; | |
| const totalSteps = 8 + Math.floor(Math.random() * 5); | |
| let currentStep; | |
| if (status === 'queued') currentStep = 0; | |
| else if (status === 'running') currentStep = 1 + Math.floor(Math.random() * (totalSteps - 2)); | |
| else currentStep = totalSteps; | |
| const isLinux = Math.random() > 0.3; | |
| const runnerPool = isLinux ? RUNNERS_LINUX : RUNNERS_MACOS; | |
| const runner = status === 'queued' ? null : runnerPool[Math.floor(Math.random() * runnerPool.length)]; | |
| const duration = status === 'queued' ? Math.floor(Math.random() * 30) : | |
| status === 'running' ? 30 + Math.floor(Math.random() * 180) : | |
| 60 + Math.floor(Math.random() * 300); | |
| return { | |
| id: ++jobId, status, repo, workflow, branch, | |
| totalSteps, currentStep, runner, duration, | |
| stepName: STEPS[Math.min(currentStep, STEPS.length - 1)], | |
| labels: isLinux ? ['self-hosted', 'linux'] : ['self-hosted', 'macos', 'arm64'] | |
| }; | |
| } | |
| function addJob(status) { | |
| jobs.unshift(makeJob(status)); | |
| updateAll(); | |
| } | |
| function advanceJobs() { | |
| jobs.forEach(j => { | |
| if (j.status === 'queued') { | |
| j.status = 'running'; | |
| j.currentStep = 1; | |
| j.runner = (j.labels.includes('macos') ? RUNNERS_MACOS : RUNNERS_LINUX)[0]; | |
| } else if (j.status === 'running') { | |
| j.currentStep++; | |
| j.duration += 15; | |
| if (j.currentStep >= j.totalSteps) { | |
| j.status = Math.random() > 0.15 ? 'success' : 'failed'; | |
| j.currentStep = j.totalSteps; | |
| } | |
| j.stepName = STEPS[Math.min(j.currentStep, STEPS.length - 1)]; | |
| } | |
| }); | |
| updateAll(); | |
| } | |
| function resetJobs() { generateJobs(); updateAll(); } | |
| function fmtDuration(s) { | |
| if (s < 60) return `${s}s`; | |
| const m = Math.floor(s / 60), sec = s % 60; | |
| return sec > 0 ? `${m}m ${sec}s` : `${m}m`; | |
| } | |
| function renderCard(job) { | |
| const isTreemap = state.layout === 'treemap'; | |
| const cls = [ | |
| 'job-card', | |
| `status-${job.status}`, | |
| state.compact && !isTreemap ? 'compact' : '', | |
| isTreemap ? 'treemap-card' : '', | |
| !state.halo && job.status === 'running' ? 'no-halo' : '', | |
| !state.accentBar ? 'no-accent' : '' | |
| ].filter(Boolean).join(' '); | |
| const style = `border-radius:${state.radius}px;` + | |
| (!state.accentBar ? '--accent-w:0;' : '') + | |
| (!state.halo && job.status === 'running' ? 'animation:none;' : ''); | |
| const pct = job.totalSteps > 0 ? Math.round((job.currentStep / job.totalSteps) * 100) : 0; | |
| return `<div class="${cls}" style="${style}"> | |
| <div class="job-header"> | |
| <span class="job-status-icon ${job.status}">${SYMBOLS[job.status]}</span> | |
| <span class="job-name">${job.workflow}</span> | |
| <span class="job-duration">${fmtDuration(job.duration)}</span> | |
| </div> | |
| <div class="job-meta">${job.repo} · ${job.branch}</div> | |
| <div class="job-progress"> | |
| <span class="job-progress-label">${job.currentStep}/${job.totalSteps}</span> | |
| <div class="job-progress-bar"><div class="job-progress-fill" style="width:${pct}%"></div></div> | |
| <span class="job-progress-label">${job.stepName}</span> | |
| </div> | |
| ${job.runner ? `<div class="job-runner"><span class="runner-icon">⊞</span>${job.runner}</div>` : ''} | |
| </div>`; | |
| } | |
| function renderRunnerBar() { | |
| const bar = document.getElementById('runner-bar'); | |
| if (!state.showRunners) { bar.style.display = 'none'; return; } | |
| bar.style.display = 'flex'; | |
| const linuxUsed = jobs.filter(j => j.status === 'running' && j.labels.includes('linux')).length; | |
| const macosUsed = jobs.filter(j => j.status === 'running' && j.labels.includes('macos')).length; | |
| const linuxQueued = jobs.filter(j => j.status === 'queued' && j.labels.includes('linux')).length; | |
| const macosQueued = jobs.filter(j => j.status === 'queued' && j.labels.includes('macos')).length; | |
| const runners = [ | |
| { label: 'self-hosted/linux', used: linuxUsed, total: state.linuxRunners, queued: linuxQueued }, | |
| { label: 'self-hosted/macos', used: macosUsed, total: state.macosRunners, queued: macosQueued }, | |
| { label: 'github-hosted', used: 0, total: '∞', queued: 0, elastic: true } | |
| ]; | |
| bar.innerHTML = runners.map(r => { | |
| const pct = r.elastic ? 0 : Math.min((r.used / r.total) * 100, 100); | |
| const color = r.elastic ? COLORS.cancelled : | |
| pct >= 100 ? COLORS.failed : | |
| pct >= 70 ? COLORS.running : COLORS.success; | |
| const queueNote = r.queued > 0 ? ` (${r.queued} queued)` : ''; | |
| return `<div class="runway"> | |
| <div class="runway-dot" style="background:${color}"></div> | |
| <span class="runway-label">${r.label}</span> | |
| <div class="runway-bar-bg"><div class="runway-bar-fill" style="width:${pct}%;background:${color}"></div></div> | |
| <span class="runway-count" style="color:${color}">${r.used}/${r.total}${queueNote}</span> | |
| </div>`; | |
| }).join(''); | |
| } | |
| function renderPreview() { | |
| const preview = document.getElementById('preview'); | |
| const queued = jobs.filter(j => j.status === 'queued'); | |
| const running = jobs.filter(j => j.status === 'running'); | |
| const completed = jobs.filter(j => ['success', 'failed', 'cancelled'].includes(j.status)); | |
| if (state.layout === 'kanban') { | |
| preview.innerHTML = `<div class="kanban"> | |
| <div class="kanban-col"> | |
| <div class="kanban-header">Queued <span class="count">${queued.length}</span></div> | |
| <div class="kanban-items">${queued.map(renderCard).join('')}</div> | |
| </div> | |
| <div class="kanban-col"> | |
| <div class="kanban-header">Running <span class="count">${running.length}</span></div> | |
| <div class="kanban-items">${running.map(renderCard).join('')}</div> | |
| </div> | |
| <div class="kanban-col"> | |
| <div class="kanban-header">Completed <span class="count">${completed.length}</span></div> | |
| <div class="kanban-items">${completed.map(renderCard).join('')}</div> | |
| </div> | |
| </div>`; | |
| } else if (state.layout === 'list') { | |
| const sorted = [...jobs].sort((a, b) => { | |
| const order = { running: 0, queued: 1, failed: 2, cancelled: 3, success: 4 }; | |
| return (order[a.status] ?? 5) - (order[b.status] ?? 5); | |
| }); | |
| preview.innerHTML = `<div class="list-view">${sorted.map(renderCard).join('')}</div>`; | |
| } else { | |
| // Group by repo, size by duration | |
| const byRepo = {}; | |
| jobs.forEach(j => { | |
| if (!byRepo[j.repo]) byRepo[j.repo] = []; | |
| byRepo[j.repo].push(j); | |
| }); | |
| const maxDur = Math.max(...jobs.map(j => j.duration), 1); | |
| let html = '<div class="treemap">'; | |
| for (const [repo, repoJobs] of Object.entries(byRepo)) { | |
| html += `<div class="treemap-group">`; | |
| html += `<div class="treemap-group-label">${repo}</div>`; | |
| html += `<div class="treemap-grid">`; | |
| repoJobs.forEach(job => { | |
| const weight = Math.max(job.duration / maxDur, 0.15); | |
| const grow = Math.round(weight * 10); | |
| const sizeClass = weight > 0.6 ? 'treemap-lg' : weight < 0.25 ? 'treemap-sm' : ''; | |
| const cls = [ | |
| 'job-card', 'treemap-card', sizeClass, | |
| `status-${job.status}`, | |
| !state.halo && job.status === 'running' ? 'no-halo' : '', | |
| !state.accentBar ? 'no-accent' : '' | |
| ].filter(Boolean).join(' '); | |
| const style = `border-radius:${state.radius}px;flex-grow:${grow};` + | |
| (!state.halo && job.status === 'running' ? 'animation:none;' : ''); | |
| html += `<div class="${cls}" style="${style}"> | |
| <div class="job-header"> | |
| <span class="job-status-icon ${job.status}">${SYMBOLS[job.status]}</span> | |
| <span class="job-name">${job.workflow}</span> | |
| <span class="job-duration">${fmtDuration(job.duration)}</span> | |
| </div> | |
| <div class="job-meta">${job.branch}</div> | |
| </div>`; | |
| }); | |
| html += `</div></div>`; | |
| } | |
| html += '</div>'; | |
| preview.innerHTML = html; | |
| } | |
| } | |
| function updatePrompt() { | |
| const parts = []; | |
| parts.push(`Build an ATC (Action Traffic Control) dashboard using a **${state.layout}** layout`); | |
| if (state.compact) parts.push('with **compact** card density (icon + name + duration only)'); | |
| else parts.push('with **expanded** cards showing step progress, runner name, and repo/branch context'); | |
| if (!state.dark) parts.push('using a **light** theme'); | |
| else parts.push('using a **dark radar** theme'); | |
| if (state.radius !== 6) parts.push(`with ${state.radius}px border radius on cards`); | |
| if (!state.accentBar) parts.push('without left status accent bars'); | |
| if (state.halo) parts.push('with **pulsating halo animation** on running jobs (Concourse-inspired)'); | |
| if (state.showRunners) parts.push(`showing runner capacity bar (${state.linuxRunners} Linux, ${state.macosRunners} macOS runners)`); | |
| parts.push(`Status indicators use color + symbol duality: queued (blue ◐), running (amber ▶), success (green ✓), failed (red ✗), cancelled (gray ⊘)`); | |
| document.getElementById('prompt-text').textContent = parts.join('. ') + '.'; | |
| } | |
| function updateAll() { | |
| renderRunnerBar(); | |
| renderPreview(); | |
| updatePrompt(); | |
| syncControls(); | |
| } | |
| function syncControls() { | |
| document.getElementById('layout').value = state.layout; | |
| document.getElementById('density').value = state.compact ? 'compact' : 'expanded'; | |
| document.getElementById('radius').value = state.radius; | |
| document.getElementById('linux-runners').value = state.linuxRunners; | |
| document.getElementById('macos-runners').value = state.macosRunners; | |
| document.getElementById('theme-toggle').className = 'toggle' + (state.dark ? ' on' : ''); | |
| document.getElementById('halo-toggle').className = 'toggle' + (state.halo ? ' on' : ''); | |
| document.getElementById('runner-toggle').className = 'toggle' + (state.showRunners ? ' on' : ''); | |
| document.getElementById('accent-toggle').className = 'toggle' + (state.accentBar ? ' on' : ''); | |
| document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active')); | |
| } | |
| function toggleTheme() { state.dark = !state.dark; document.body.className = state.dark ? '' : 'light'; updateAll(); } | |
| function toggleHalo() { state.halo = !state.halo; updateAll(); } | |
| function toggleRunners() { state.showRunners = !state.showRunners; updateAll(); } | |
| function toggleAccent() { state.accentBar = !state.accentBar; updateAll(); } | |
| function applyPreset(name) { | |
| document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active')); | |
| event.target.classList.add('active'); | |
| const presets = { | |
| radar: { layout: 'kanban', compact: false, dark: true, halo: true, showRunners: true, radius: 6, accentBar: true }, | |
| cockpit: { layout: 'list', compact: false, dark: true, halo: true, showRunners: true, radius: 4, accentBar: true }, | |
| wall: { layout: 'treemap', compact: true, dark: true, halo: true, showRunners: true, radius: 8, accentBar: false }, | |
| minimal: { layout: 'kanban', compact: true, dark: false, halo: false, showRunners: false, radius: 4, accentBar: false } | |
| }; | |
| Object.assign(state, presets[name]); | |
| document.body.className = state.dark ? '' : 'light'; | |
| updateAll(); | |
| } | |
| function copyPrompt() { | |
| const text = document.getElementById('prompt-text').textContent; | |
| navigator.clipboard.writeText(text); | |
| const btn = document.querySelector('.copy-btn'); | |
| btn.textContent = 'Copied!'; | |
| setTimeout(() => btn.textContent = 'Copy', 1500); | |
| } | |
| // Init | |
| generateJobs(); | |
| updateAll(); | |
| // Tick running job durations | |
| setInterval(() => { | |
| jobs.forEach(j => { if (j.status === 'running') j.duration++; }); | |
| updateAll(); | |
| }, 1000); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment