From 4b0b07c8d4ac3e310cdfca17f3170059c27d991c Mon Sep 17 00:00:00 2001 From: chungyeong Date: Sat, 16 May 2026 22:30:51 +0900 Subject: [PATCH] =?UTF-8?q?polish(my-deepagent):=20rebuild=20Web=20GUI=20v?= =?UTF-8?q?isual=20design=20=E2=80=94=20cards,=20pill=20badges,=208px=20gr?= =?UTF-8?q?id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of static/*.html + style.css was functional but visually bare. Rewriting with a modern dev-tool dashboard aesthetic (Linear / Vercel / Resend palette), still vanilla CSS — no framework, no build system (DR-3 / plan.md D3 constraint kept). Changes - `static/style.css`: full rewrite (192 → ~580 lines). Adds: - CSS custom-property design tokens: surface 0/1/2/3, accent/success/ warning/danger/info each with a matching `*-bg` rgba. - Type system: Inter / Pretendard / Apple SD Gothic Neo / Noto Sans KR stack with tabular-nums + system features cv05/ss01. - 8 px spacing grid, refined border-radius scale (sm/md/lg). - `.card` surface with subtle inner highlight + low shadow. - `.badge` pill component with state-* modifiers and an animated dot for in-progress states (running / executing / validating / awaiting_artifact). - `.meta-panel` + `.meta-row` for key/value run detail. - `.budget-card` with embedded usage bar (ok/warn/over color states). - `.events` log with monospace, hover background, per-event-type accent color (run.completed green, run.failed red, etc.) and themed scrollbar. - `.chips` row for per-role persona override input. - Buttons with `primary` / `danger` variants and subtle press animation. - Compact responsive break at 720 px (single-column meta rows / form-grid / chips). - `static/index.html`: page-title row + `.card` wrapper for runs table + `.budget-grid` for budget cards. Active nav highlight. - `static/new.html`: form rebuilt inside a card with form-grid layout (repo path / branch side-by-side), `.chips` rows for per-role override. - `static/run.html`: page-title with state badge + `.meta-panel` for Run ID / Repo / Worktree / Final report + action bar + cards for phases and live events. - `static/app.js`: redesigned rendering helpers to match new markup: - New `badge(state)` helper returning a pill element. - `emptyCell(colspan, text, ctaHref, ctaText)` for empty-state tables. - Runs list: short hash + arrow link, basename for repo with full path in `title`, ISO timestamps trimmed to `YYYY-MM-DD HH:MM:SS`. - Budget cards: usage bar fill % computed from spent/cap, status class (ok / warn / over) flows to both the amount color and the bar color. - New event line uses two-column grid (`.ts` + `.body`), event-line class derived from event type for per-type accent coloring. - EventSource singleton to prevent stacking on re-renders. XSS policy unchanged: textContent only, innerHTML/insertAdjacentHTML/ outerHTML still forbidden. The hardcoded comment at the top of `app.js` is preserved (and the static test that asserts it). Gates - ruff check + mypy --strict: PASS (120 source files) - pytest 16 API tests (read+write+sse+static): all PASS Co-Authored-By: Claude Opus 4.7 (1M context) --- my-deepagent/static/app.js | 300 +++++++++---- my-deepagent/static/index.html | 43 +- my-deepagent/static/new.html | 62 +-- my-deepagent/static/run.html | 75 ++-- my-deepagent/static/style.css | 792 ++++++++++++++++++++++++++++----- 5 files changed, 991 insertions(+), 281 deletions(-) diff --git a/my-deepagent/static/app.js b/my-deepagent/static/app.js index dba9629..a25f4f7 100644 --- a/my-deepagent/static/app.js +++ b/my-deepagent/static/app.js @@ -1,4 +1,4 @@ -/* my-deepagent Web GUI — vanilla JS for v0.2 PR #3. +/* my-deepagent Web GUI — v0.2 PR #3 (polish pass). * * SECURITY (XSS policy): * All user-controlled strings MUST be inserted via element.textContent. @@ -7,6 +7,7 @@ */ const API = window.location.origin + "/api"; +const TERMINAL_STATES = new Set(["completed", "failed", "aborted"]); function $(sel) { return document.querySelector(sel); } function $$(sel) { return Array.from(document.querySelectorAll(sel)); } @@ -29,12 +30,47 @@ function setError(msg) { if (!el) return; if (msg) { el.textContent = msg; - el.style.display = "block"; + el.style.display = "flex"; } else { el.style.display = "none"; } } +function badge(state) { + const span = document.createElement("span"); + span.className = `badge state-${state}`; + span.textContent = state; + return span; +} + +function emptyCell(colspan, text, ctaHref, ctaText) { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = colspan; + const empty = document.createElement("div"); + empty.className = "empty"; + const icon = document.createElement("div"); + icon.className = "empty-icon"; + icon.textContent = "◌"; + const body = document.createElement("div"); + body.textContent = text; + empty.appendChild(icon); + empty.appendChild(body); + if (ctaHref && ctaText) { + const cta = document.createElement("div"); + cta.className = "cta"; + const a = document.createElement("a"); + a.className = "button"; + a.href = ctaHref; + a.textContent = ctaText; + cta.appendChild(a); + empty.appendChild(cta); + } + td.appendChild(empty); + tr.appendChild(td); + return tr; +} + // =============== index.html =============== async function renderRunsList() { @@ -49,38 +85,48 @@ async function renderRunsList() { const tbody = $("#runs tbody"); tbody.replaceChildren(); if (runs.length === 0) { - const tr = document.createElement("tr"); - const td = document.createElement("td"); - td.colSpan = 6; - td.className = "empty"; - td.textContent = "아직 실행된 run이 없습니다. 신규 run을 시작해 보세요."; - tr.appendChild(td); - tbody.appendChild(tr); + tbody.appendChild( + emptyCell(6, "아직 실행된 run이 없습니다.", "/new.html", "새 Run 시작 →") + ); return; } for (const r of runs) { const tr = document.createElement("tr"); - const cells = [ - ["id", r.id.slice(0, 8) + "…", { href: `/run.html?id=${r.id}` }], - ["state", r.state, { state: r.state }], - ["repo", r.repo_path], - ["branch", r.base_branch], - ["created", (r.created_at || "").slice(0, 19)], - ["ended", (r.ended_at || "—").slice(0, 19)], - ]; - for (const [_, v, opts] of cells) { - const td = document.createElement("td"); - if (opts && opts.state) td.className = `state-${opts.state}`; - if (opts && opts.href) { - const a = document.createElement("a"); - a.href = opts.href; - a.textContent = v; - td.appendChild(a); - } else { - td.textContent = v; - } - tr.appendChild(td); - } + + // Run ID cell — short hash + arrow + const idTd = document.createElement("td"); + const idLink = document.createElement("a"); + idLink.href = `/run.html?id=${r.id}`; + idLink.className = "mono"; + idLink.textContent = r.id.slice(0, 8) + "…"; + idTd.appendChild(idLink); + + // State cell — pill badge + const stateTd = document.createElement("td"); + stateTd.appendChild(badge(r.state)); + + // Repo cell — basename only, full path on title + const repoTd = document.createElement("td"); + const repoSpan = document.createElement("span"); + repoSpan.className = "mono"; + repoSpan.textContent = (r.repo_path || "").split("/").pop() || r.repo_path; + repoSpan.title = r.repo_path; + repoTd.appendChild(repoSpan); + + // Branch + const branchTd = document.createElement("td"); + branchTd.textContent = r.base_branch; + + // Timestamps + const createdTd = document.createElement("td"); + createdTd.className = "mono"; + createdTd.textContent = (r.created_at || "").slice(0, 19).replace("T", " "); + + const endedTd = document.createElement("td"); + endedTd.className = "mono"; + endedTd.textContent = r.ended_at ? r.ended_at.slice(0, 19).replace("T", " ") : "—"; + + tr.append(idTd, stateTd, repoTd, branchTd, createdTd, endedTd); tbody.appendChild(tr); } } @@ -91,35 +137,52 @@ async function renderBudgetSummary() { container.replaceChildren(); try { const summary = await jsonFetch("/budget"); - function line(scope, spent, cap, warn) { - const div = document.createElement("div"); - div.className = "budget-line"; - const left = document.createElement("span"); - left.className = "scope"; - left.textContent = scope; - const right = document.createElement("span"); - right.className = "amount"; - const capStr = cap != null ? ` / $${cap.toFixed(2)}` : ""; - right.textContent = `$${spent.toFixed(4)}${capStr}`; - if (cap != null && spent >= cap) right.classList.add("over"); - else if (warn != null && spent >= warn) right.classList.add("warn"); - div.appendChild(left); - div.appendChild(right); - container.appendChild(div); + function card(scope, spent, cap, warn) { + const c = document.createElement("div"); + c.className = "budget-card"; + const s = document.createElement("div"); + s.className = "scope"; + s.textContent = scope; + s.title = scope; + const a = document.createElement("div"); + a.className = "amount"; + const capText = cap != null ? ` / $${cap.toFixed(2)}` : ""; + a.textContent = `$${spent.toFixed(4)}`; + const capSpan = document.createElement("span"); + capSpan.className = "cap"; + capSpan.textContent = capText; + a.appendChild(capSpan); + // Status colour + let pct = 0; + let status = "ok"; + if (cap != null && cap > 0) { + pct = Math.min(100, (spent / cap) * 100); + if (spent >= cap) { status = "over"; a.classList.add("over"); } + else if (warn != null && spent >= warn) { status = "warn"; a.classList.add("warn"); } + } + const bar = document.createElement("div"); + bar.className = `bar ${status === "ok" ? "" : status}`; + const fill = document.createElement("div"); + fill.style.width = `${pct}%`; + bar.appendChild(fill); + c.append(s, a, bar); + container.appendChild(c); } - if (summary.day) line(summary.day.scope, summary.day.spent_usd, summary.day.cap_usd, summary.day.warn_usd); - for (const r of summary.runs) line(r.scope, r.spent_usd, r.cap_usd, r.warn_usd); - for (const p of summary.personas) line(p.scope, p.spent_usd, p.cap_usd, p.warn_usd); + if (summary.day) card(summary.day.scope, summary.day.spent_usd, summary.day.cap_usd, summary.day.warn_usd); + for (const r of summary.runs) card(r.scope, r.spent_usd, r.cap_usd, r.warn_usd); + for (const p of summary.personas) card(p.scope, p.spent_usd, p.cap_usd, p.warn_usd); if (!summary.day && summary.runs.length === 0 && summary.personas.length === 0) { const empty = document.createElement("div"); empty.className = "empty"; empty.textContent = "지출 기록이 없습니다."; + empty.style.gridColumn = "1 / -1"; container.appendChild(empty); } } catch (e) { const err = document.createElement("div"); err.className = "empty"; err.textContent = `budget 정보를 불러오지 못했습니다: ${e.message}`; + err.style.gridColumn = "1 / -1"; container.appendChild(err); } } @@ -131,14 +194,10 @@ async function renderNewRunForm() { const tplSelect = $("#template"); const overrideContainer = $("#override-fields"); let workflows = []; - let personas = []; try { - [workflows, personas] = await Promise.all([ - jsonFetch("/workflows"), - jsonFetch("/personas"), - ]); + workflows = await jsonFetch("/workflows"); } catch (e) { - setError(`workflow / persona 목록을 불러오지 못했습니다: ${e.message}`); + setError(`workflow 목록을 불러오지 못했습니다: ${e.message}`); return; } for (const w of workflows) { @@ -151,15 +210,33 @@ async function renderNewRunForm() { tplSelect.addEventListener("change", () => { overrideContainer.replaceChildren(); const roles = JSON.parse(tplSelect.selectedOptions[0].dataset.roles || "[]"); + if (roles.length === 0) { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.style.padding = "20px 16px"; + empty.textContent = "이 워크플로우엔 역할 정의가 없습니다."; + overrideContainer.appendChild(empty); + return; + } for (const role of roles) { - const label = document.createElement("label"); - label.textContent = `${role} (선택 사항: persona 이름)`; + const row = document.createElement("div"); + row.className = "chips"; + const label = document.createElement("div"); + label.className = "role"; + const roleName = document.createElement("span"); + roleName.textContent = role; + label.appendChild(roleName); + const hint = document.createElement("span"); + hint.className = "hint"; + hint.textContent = "비우면 자동 선택"; + label.appendChild(hint); const input = document.createElement("input"); + input.type = "text"; input.dataset.role = role; input.className = "override-input"; input.placeholder = "openrouter-deepseek-spec-writer@1"; - overrideContainer.appendChild(label); - overrideContainer.appendChild(input); + row.append(label, input); + overrideContainer.appendChild(row); } }); if (tplSelect.options.length > 0) { @@ -211,33 +288,50 @@ async function renderRunDetail() { $("#run-id").textContent = runId; try { const detail = await jsonFetch(`/runs/${runId}`); - $("#run-state").textContent = detail.run.state; - $("#run-state").className = `state-${detail.run.state}`; + + const stateBadgeHost = $("#run-state-badge"); + stateBadgeHost.replaceChildren(badge(detail.run.state)); + $("#run-repo").textContent = `${detail.run.repo_path} @ ${detail.run.base_branch}`; $("#run-worktree").textContent = detail.run.worktree_root; $("#run-report").textContent = detail.run.final_report_path || "—"; const phaseTbody = $("#phases tbody"); phaseTbody.replaceChildren(); - for (const p of detail.phases) { - const tr = document.createElement("tr"); - const cells = [ - p.phase_key, - p.state, - String(p.attempts), - (p.started_at || "—").slice(0, 19), - (p.ended_at || "—").slice(0, 19), - ]; - for (let i = 0; i < cells.length; i++) { - const td = document.createElement("td"); - td.textContent = cells[i]; - if (i === 1) td.className = `state-${p.state}`; - tr.appendChild(td); + if (detail.phases.length === 0) { + phaseTbody.appendChild(emptyCell(5, "phase 기록이 없습니다.", null, null)); + } else { + for (const p of detail.phases) { + const tr = document.createElement("tr"); + + const keyTd = document.createElement("td"); + keyTd.className = "mono"; + keyTd.textContent = p.phase_key; + + const stateTd = document.createElement("td"); + stateTd.appendChild(badge(p.state)); + + const attTd = document.createElement("td"); + attTd.textContent = String(p.attempts); + + const stTd = document.createElement("td"); + stTd.className = "mono"; + stTd.textContent = p.started_at + ? p.started_at.slice(0, 19).replace("T", " ") + : "—"; + + const endTd = document.createElement("td"); + endTd.className = "mono"; + endTd.textContent = p.ended_at + ? p.ended_at.slice(0, 19).replace("T", " ") + : "—"; + + tr.append(keyTd, stateTd, attTd, stTd, endTd); + phaseTbody.appendChild(tr); } - phaseTbody.appendChild(tr); } - const isTerminal = ["completed", "failed", "aborted"].includes(detail.run.state); + const isTerminal = TERMINAL_STATES.has(detail.run.state); $("#resume-btn").disabled = isTerminal; $("#abort-btn").disabled = isTerminal; } catch (e) { @@ -248,39 +342,55 @@ async function renderRunDetail() { startEventStream(runId); } +function appendEventLine(eventsContainer, data) { + const line = document.createElement("div"); + const cls = (data.type || "").replace(/\./g, "-"); + line.className = `event-line ${cls}`; + const ts = document.createElement("div"); + ts.className = "ts"; + ts.textContent = (data.ts || "").slice(11, 19); + const body = document.createElement("div"); + body.className = "body"; + const type = document.createElement("span"); + type.className = "type"; + type.textContent = data.type; + body.appendChild(type); + if (data.payload && Object.keys(data.payload).length > 0) { + const payload = document.createElement("span"); + payload.className = "payload"; + payload.textContent = JSON.stringify(data.payload); + body.appendChild(payload); + } + line.appendChild(ts); + line.appendChild(body); + eventsContainer.appendChild(line); + eventsContainer.scrollTop = eventsContainer.scrollHeight; +} + +let _eventSource = null; + function startEventStream(runId) { + if (_eventSource) { + _eventSource.close(); + } const eventsContainer = $("#events"); eventsContainer.replaceChildren(); const src = new EventSource(`${API}/runs/${runId}/events`); + _eventSource = src; src.addEventListener("event", (ev) => { try { const data = JSON.parse(ev.data); - const line = document.createElement("div"); - line.className = "event-line"; - const ts = document.createElement("span"); - ts.className = "ts"; - ts.textContent = (data.ts || "").slice(0, 19); - const type = document.createElement("span"); - type.className = "type"; - type.textContent = ` ${data.type}`; - line.appendChild(ts); - line.appendChild(type); - if (data.payload && Object.keys(data.payload).length > 0) { - const payload = document.createElement("span"); - payload.textContent = " " + JSON.stringify(data.payload); - line.appendChild(payload); - } - eventsContainer.appendChild(line); - eventsContainer.scrollTop = eventsContainer.scrollHeight; - } catch (_) { /* ignore */ } + appendEventLine(eventsContainer, data); + } catch (_) { /* ignore parse errors */ } }); src.addEventListener("done", () => { src.close(); - // Refresh the detail panel one last time to pick up final state. + if (_eventSource === src) _eventSource = null; setTimeout(() => renderRunDetail(), 300); }); src.onerror = () => { src.close(); + if (_eventSource === src) _eventSource = null; }; } diff --git a/my-deepagent/static/index.html b/my-deepagent/static/index.html index 5280b8d..39406b6 100644 --- a/my-deepagent/static/index.html +++ b/my-deepagent/static/index.html @@ -10,29 +10,36 @@

my-deepagent

-

최근 run 50개

- - - - - - - - - - - - -
run idstaterepobranchcreatedended
-

예산 (현재)

-
+
+

최근 Runs

+ 최신 50개 +
+ +
+ + + + + + + + + + + + +
RunStateRepoBranchCreatedEnded
+
+ +

예산 (현재)

+
diff --git a/my-deepagent/static/new.html b/my-deepagent/static/new.html index db20916..324230e 100644 --- a/my-deepagent/static/new.html +++ b/my-deepagent/static/new.html @@ -3,47 +3,55 @@ - my-deepagent · 새 run + my-deepagent · 새 Run
-

my-deepagent · 새 run 시작

+

my-deepagent

-
-
- - +
+

새 Run 시작

+ 워크플로우 + repo + 요구사항 +
+ + +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
-
- - -
- -
- - -
- -
- - -
- -

persona override (선택)

-

비워두면 자동 선택. 값은 persona-name@버전 형식.

-
+

Persona 오버라이드 (선택, 비우면 자동 선택)

+
- + 취소
diff --git a/my-deepagent/static/run.html b/my-deepagent/static/run.html index c4660b4..eb71e71 100644 --- a/my-deepagent/static/run.html +++ b/my-deepagent/static/run.html @@ -3,47 +3,66 @@ - my-deepagent · run 상세 + my-deepagent · Run 상세
-

my-deepagent · run 상세

+

my-deepagent

-

요약

-
run id
-
state
-
repo
-
worktree
-
final report
- -
- - +
+

Run 상세

+
-

phases

- - - - - - - - - - - -
keystateattemptsstartedended
+
+
+ Run ID + +
+
+ Repo + +
+
+ Worktree + +
+
+ Final report + +
+
-

실시간 이벤트 (SSE)

+
+ + +
+ +

Phases

+
+ + + + + + + + + + + +
KeyStateAttemptsStartedEnded
+
+ +

Live events — SSE 0.5s polling

diff --git a/my-deepagent/static/style.css b/my-deepagent/static/style.css index 7a1abe6..9d4b5e7 100644 --- a/my-deepagent/static/style.css +++ b/my-deepagent/static/style.css @@ -1,192 +1,758 @@ -/* my-deepagent Web GUI — v0.2 PR #3 - * Vanilla CSS. No framework. Single dark-friendly theme tuned for - * data-heavy tables. +/* my-deepagent Web GUI — v0.2 PR #3 polish pass. + * + * Visual reference: modern dev-tool dashboards (Linear / Vercel / + * Resend / Railway). Goals: + * - Refined dark palette (deeper background, soft surfaces) + * - Card-based layout with clear hierarchy + * - Pill badges for run / phase state + * - Tabular numbers for metrics, monospace for IDs + * - 8 px spacing grid, generous padding + * - Subtle hover / transition without animation chaos + * + * Vanilla CSS only. No build system. No CSS-in-JS. */ -* { +/* ---------- Reset + tokens ---------- */ + +*, +*::before, +*::after { box-sizing: border-box; } -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Apple SD Gothic Neo", - "Noto Sans KR", "Helvetica Neue", Arial, sans-serif; - margin: 0; - background: #0f1115; - color: #e6e7eb; - line-height: 1.5; +:root { + /* Surface (background → cards → elevated) */ + --bg: #0a0b0e; + --surface-1: #13141a; + --surface-2: #181a22; + --surface-3: #1f222c; + --surface-hover: #232732; + + /* Borders */ + --border: #262834; + --border-strong: #313548; + + /* Text */ + --text-primary: #f5f6f9; + --text-secondary: #b6b9c4; + --text-muted: #6c707f; + --text-faint: #4a4d59; + + /* Accents */ + --accent: #7c9eff; + --accent-hover: #93b1ff; + --accent-bg: rgba(124, 158, 255, 0.12); + + --success: #6cdba2; + --success-bg: rgba(108, 219, 162, 0.12); + --warning: #f5cc73; + --warning-bg: rgba(245, 204, 115, 0.14); + --danger: #ef7a7a; + --danger-bg: rgba(239, 122, 122, 0.14); + --info: #8a9cc7; + --info-bg: rgba(138, 156, 199, 0.12); + + /* Type */ + --font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Pretendard", + "SF Pro Text", "Apple SD Gothic Neo", "Noto Sans KR", "Segoe UI", + Helvetica, Arial, sans-serif; + --font-mono: "SF Mono", "JetBrains Mono", "Cascadia Code", "Menlo", + "Monaco", "Consolas", monospace; + + /* Geometry */ + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + + --shadow-sm: 0 1px 0 rgba(255, 255, 255, 0.03) inset; + --shadow-card: 0 1px 0 rgba(255, 255, 255, 0.03) inset, + 0 1px 12px rgba(0, 0, 0, 0.2); } +/* ---------- Base ---------- */ + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: "cv05", "ss01"; + font-variant-numeric: tabular-nums; +} + +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +a { + color: var(--accent); + text-decoration: none; + transition: color 0.15s ease; +} + +a:hover { + color: var(--accent-hover); +} + +code, +kbd, +.mono { + font-family: var(--font-mono); + font-size: 0.85em; + letter-spacing: -0.01em; +} + +::selection { + background: var(--accent-bg); + color: var(--text-primary); +} + +/* ---------- Header / nav ---------- */ + header { - background: #1a1d24; - padding: 1rem 2rem; - border-bottom: 1px solid #2a2d36; + background: var(--surface-1); + border-bottom: 1px solid var(--border); + padding: 16px 32px; display: flex; justify-content: space-between; align-items: center; + gap: 24px; } header h1 { margin: 0; - font-size: 1.25rem; + font-size: 15px; font-weight: 600; - color: #f5f6f9; + color: var(--text-primary); + letter-spacing: -0.01em; + display: flex; + align-items: center; + gap: 10px; +} + +header h1::before { + content: ""; + width: 8px; + height: 8px; + background: linear-gradient(135deg, var(--accent), var(--success)); + border-radius: 2px; +} + +header nav { + display: flex; + gap: 4px; } header nav a { - color: #8db4ff; - margin-left: 1rem; - text-decoration: none; + color: var(--text-secondary); + padding: 6px 12px; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 500; + transition: all 0.15s ease; } header nav a:hover { - text-decoration: underline; + color: var(--text-primary); + background: var(--surface-hover); } +header nav a.active { + color: var(--accent); + background: var(--accent-bg); +} + +/* ---------- Main ---------- */ + main { - max-width: 1200px; + flex: 1; + max-width: 1280px; + width: 100%; margin: 0 auto; - padding: 2rem; + padding: 32px; +} + +.page-title { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + +.page-title h2 { + margin: 0; + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; +} + +.page-subtitle { + color: var(--text-muted); + font-size: 13px; } h2 { - font-size: 1.1rem; - margin: 1.5rem 0 0.75rem; - color: #f5f6f9; + margin: 28px 0 14px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); +} + +h2.section-title { + display: flex; + align-items: center; + gap: 10px; +} + +h2.section-title::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} + +/* ---------- Cards / tables ---------- */ + +.card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + overflow: hidden; } table { width: 100%; - border-collapse: collapse; - background: #161922; - font-size: 0.9rem; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; } -th, td { +th, +td { text-align: left; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid #232633; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +tbody tr:last-child td { + border-bottom: none; +} + +tbody tr { + transition: background 0.12s ease; +} + +tbody tr:hover { + background: var(--surface-2); } th { - background: #1f2230; - color: #c4c6d0; + background: var(--surface-2); + color: var(--text-muted); + font-weight: 500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding-top: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-strong); +} + +td .mono { + color: var(--text-secondary); +} + +td a { font-weight: 500; } -td.state-completed { color: #8ee084; } -td.state-running, td.state-executing { color: #f5d674; } -td.state-failed, td.state-aborted { color: #f08585; } -td.state-pending, td.state-created { color: #8db4ff; } +/* ---------- State badges (pill) ---------- */ -a { - color: #8db4ff; +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + border: 1px solid transparent; } -button, .button { - background: #2c3145; - color: #e6e7eb; - border: 1px solid #3a3f55; - border-radius: 4px; - padding: 0.4rem 0.9rem; - font-size: 0.9rem; - cursor: pointer; +.badge::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.badge.state-completed, +.badge.state-ok { + color: var(--success); + background: var(--success-bg); +} + +.badge.state-running, +.badge.state-executing, +.badge.state-validating, +.badge.state-awaiting_artifact, +.badge.state-awaiting_approval { + color: var(--warning); + background: var(--warning-bg); +} + +.badge.state-failed, +.badge.state-aborted { + color: var(--danger); + background: var(--danger-bg); +} + +.badge.state-pending, +.badge.state-created, +.badge.state-bound, +.badge.state-planning, +.badge.state-paused, +.badge.state-skipped { + color: var(--info); + background: var(--info-bg); +} + +/* Animated dot for in-progress states */ +.badge.state-running::before, +.badge.state-executing::before, +.badge.state-validating::before, +.badge.state-awaiting_artifact::before { + animation: pulse 1.6s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.85); } +} + +/* ---------- Buttons ---------- */ + +button, +.button { + appearance: none; + background: var(--surface-3); + color: var(--text-primary); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 8px 14px; font-family: inherit; + font-size: 13px; + font-weight: 500; + letter-spacing: -0.005em; + cursor: pointer; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 6px; + text-decoration: none; } -button:hover, .button:hover { - background: #353a54; +button:hover:not(:disabled), +.button:hover { + background: var(--surface-hover); + border-color: var(--border-strong); + color: var(--text-primary); +} + +button:active:not(:disabled) { + transform: translateY(0.5px); +} + +button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: #0a0b0e; + font-weight: 600; +} + +button.primary:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); } button.danger { - background: #4a2a2a; - border-color: #5e3535; + background: transparent; + color: var(--danger); + border-color: rgba(239, 122, 122, 0.3); } -button.danger:hover { - background: #5e3535; +button.danger:hover:not(:disabled) { + background: var(--danger-bg); + border-color: var(--danger); } -input, textarea, select { - width: 100%; - background: #1a1d24; - color: #e6e7eb; - border: 1px solid #2a2d36; - border-radius: 4px; - padding: 0.5rem 0.75rem; - font-family: inherit; - font-size: 0.95rem; -} +/* ---------- Forms ---------- */ label { display: block; - margin: 0.75rem 0 0.25rem; - color: #c4c6d0; - font-size: 0.85rem; + margin: 0 0 6px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.01em; +} + +label .hint { + color: var(--text-muted); + font-weight: 400; + margin-left: 6px; +} + +input[type="text"], +input[type="number"], +textarea, +select { + width: 100%; + background: var(--surface-1); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 9px 12px; + font-family: inherit; + font-size: 13px; + line-height: 1.5; + transition: border-color 0.15s ease, background 0.15s ease; +} + +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--accent); + background: var(--surface-2); +} + +input::placeholder, +textarea::placeholder { + color: var(--text-faint); +} + +textarea { + resize: vertical; + min-height: 100px; + font-family: var(--font-mono); + font-size: 12px; +} + +select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23b6b9c4' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 32px; } .form-row { - margin-bottom: 1rem; + margin-bottom: 16px; } -.empty { - color: #6c7080; - font-style: italic; - padding: 1rem; +.form-row.compact { + margin-bottom: 8px; } -pre { - background: #161922; - border: 1px solid #232633; - border-radius: 4px; - padding: 0.75rem; - overflow-x: auto; - font-size: 0.8rem; - font-family: "SF Mono", "Monaco", "Cascadia Code", monospace; +.form-grid { + display: grid; + grid-template-columns: 1fr 200px; + gap: 16px; } -.events { - max-height: 60vh; - overflow-y: auto; - background: #161922; - border: 1px solid #232633; - border-radius: 4px; - padding: 0.75rem; - font-family: "SF Mono", "Monaco", "Cascadia Code", monospace; - font-size: 0.8rem; +.action-bar { + display: flex; + gap: 8px; + margin: 24px 0 0; + padding-top: 16px; + border-top: 1px solid var(--border); } -.event-line { - margin-bottom: 0.25rem; - white-space: pre-wrap; +.action-bar.no-top-border { + border-top: none; + padding-top: 0; +} + +/* ---------- Meta panel (key/value lists) ---------- */ + +.meta-panel { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px 0; +} + +.meta-row { + display: grid; + grid-template-columns: 140px 1fr; + gap: 16px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + align-items: center; +} + +.meta-row:last-child { + border-bottom: none; +} + +.meta-row .key { + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.01em; + text-transform: uppercase; +} + +.meta-row .value { + color: var(--text-primary); + font-size: 13px; word-break: break-all; } -.event-line .ts { color: #6c7080; } -.event-line .type { color: #8db4ff; font-weight: 500; } - -.action-bar { - margin: 1rem 0; - display: flex; - gap: 0.5rem; +.meta-row .value.mono { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); } -.budget-line { - display: flex; - justify-content: space-between; - padding: 0.25rem 0.5rem; - font-size: 0.85rem; +.meta-row .value.dim { + color: var(--text-muted); } -.budget-line .scope { color: #c4c6d0; } -.budget-line .amount { color: #8ee084; } -.budget-line .amount.warn { color: #f5d674; } -.budget-line .amount.over { color: #f08585; } +/* ---------- Budget summary cards ---------- */ + +.budget-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.budget-card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; +} + +.budget-card .scope { + color: var(--text-muted); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + margin-bottom: 6px; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.budget-card .amount { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--text-primary); +} + +.budget-card .amount.warn { color: var(--warning); } +.budget-card .amount.over { color: var(--danger); } + +.budget-card .cap { + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + margin-left: 6px; +} + +.budget-card .bar { + height: 4px; + background: var(--surface-3); + border-radius: 2px; + margin-top: 10px; + overflow: hidden; +} + +.budget-card .bar > div { + height: 100%; + background: var(--success); + transition: width 0.3s ease; +} + +.budget-card .bar.warn > div { background: var(--warning); } +.budget-card .bar.over > div { background: var(--danger); } + +/* ---------- Event log (SSE) ---------- */ + +.events { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius); + max-height: 60vh; + overflow-y: auto; + padding: 8px 0; + font-family: var(--font-mono); + font-size: 12px; +} + +.event-line { + display: grid; + grid-template-columns: 80px 1fr; + gap: 12px; + padding: 5px 16px; + border-bottom: 1px solid transparent; +} + +.event-line:hover { + background: var(--surface-2); +} + +.event-line .ts { + color: var(--text-faint); + font-size: 11px; +} + +.event-line .body .type { + color: var(--accent); + font-weight: 500; +} + +.event-line .body .payload { + color: var(--text-muted); + margin-left: 8px; + font-size: 11px; + word-break: break-all; +} + +.event-line.run-completed .body .type { color: var(--success); } +.event-line.run-failed .body .type, +.event-line.run-aborted .body .type { color: var(--danger); } +.event-line.run-resumed .body .type { color: var(--warning); } + +.events::-webkit-scrollbar { + width: 8px; +} + +.events::-webkit-scrollbar-track { + background: transparent; +} + +.events::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 4px; +} + +/* ---------- Empty / error states ---------- */ + +.empty { + padding: 48px 24px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.empty .empty-icon { + font-size: 28px; + margin-bottom: 8px; + opacity: 0.4; +} + +.empty .cta { + margin-top: 16px; +} .error-banner { - background: #4a2a2a; - border: 1px solid #5e3535; - border-radius: 4px; - padding: 0.75rem 1rem; - margin: 1rem 0; - color: #f4c1c1; + background: var(--danger-bg); + border: 1px solid rgba(239, 122, 122, 0.3); + border-radius: var(--radius); + padding: 12px 16px; + margin-bottom: 16px; + color: var(--danger); + font-size: 13px; + display: flex; + align-items: center; + gap: 10px; +} + +.error-banner::before { + content: "!"; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + background: var(--danger); + color: #0a0b0e; + border-radius: 50%; + font-weight: 700; + font-size: 11px; + flex-shrink: 0; +} + +/* ---------- Tag chip (per-role override input) ---------- */ + +.chips { + display: grid; + grid-template-columns: 140px 1fr; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + align-items: center; +} + +.chips:last-child { + border-bottom: none; +} + +.chips .role { + font-size: 12px; + color: var(--text-secondary); + font-weight: 500; +} + +.chips .role .hint { + display: block; + font-size: 11px; + color: var(--text-muted); + font-weight: 400; + margin-top: 2px; +} + +.chips input { + font-family: var(--font-mono); + font-size: 12px; +} + +/* ---------- Responsive niceties (desktop-focused, but readable narrower) ---------- */ + +@media (max-width: 720px) { + main { padding: 20px 16px; } + header { padding: 14px 16px; } + .form-grid { grid-template-columns: 1fr; } + .meta-row { grid-template-columns: 1fr; gap: 4px; padding: 12px 14px; } + .event-line { grid-template-columns: 1fr; gap: 2px; } + .chips { grid-template-columns: 1fr; gap: 6px; } }