Skip to content

Instantly share code, notes, and snippets.

@bojanrajkovic
Last active March 21, 2026 21:56
Show Gist options
  • Select an option

  • Save bojanrajkovic/3e9aa4de8a4051c63e5d41a4620d79d0 to your computer and use it in GitHub Desktop.

Select an option

Save bojanrajkovic/3e9aa4de8a4051c63e5d41a4620d79d0 to your computer and use it in GitHub Desktop.
ATC — Action Traffic Control UI Explorer
<!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