From 6b7a8bd20cbc6eed4858d1b3ee0946b05d6446b3 Mon Sep 17 00:00:00 2001 From: Ashish Huddar Date: Mon, 23 Mar 2026 09:58:24 +0530 Subject: [PATCH] =?UTF-8?q?chore(web):=20design=20audit=20fixes=20?= =?UTF-8?q?=E2=80=94=20rename=20column,=20refine=20styles,=20fix=20termina?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename "Merge Ready" kanban column to "Ready" - Refine card design system and globals.css theme tokens - Fix terminal scrollbar and theme handling in DirectTerminal - Update SessionDetail layout - Update tests to match component changes - Add .gstack/ to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + .../web/src/__tests__/components.test.tsx | 21 +- .../web/src/app/dev/card-showcase/page.tsx | 416 -------------- packages/web/src/app/globals.css | 514 ++++++++++++++---- packages/web/src/components/AttentionZone.tsx | 2 +- .../web/src/components/DirectTerminal.tsx | 66 ++- .../web/src/components/ProjectSidebar.tsx | 14 +- packages/web/src/components/SessionCard.tsx | 15 +- packages/web/src/components/SessionDetail.tsx | 494 +++++++---------- .../__tests__/DirectTerminal.test.ts | 35 +- 10 files changed, 723 insertions(+), 855 deletions(-) delete mode 100644 packages/web/src/app/dev/card-showcase/page.tsx diff --git a/.gitignore b/.gitignore index f92763788..7647c5715 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ CLAUDE.md AGENTS.md .claude/ .opencode/ +.gstack/ diff --git a/packages/web/src/__tests__/components.test.tsx b/packages/web/src/__tests__/components.test.tsx index ee97f4200..67f323801 100644 --- a/packages/web/src/__tests__/components.test.tsx +++ b/packages/web/src/__tests__/components.test.tsx @@ -247,7 +247,7 @@ describe("SessionCard", () => { }); const session = makeSession({ status: "mergeable", activity: "idle", pr }); render(); - expect(screen.getByText("Merge PR")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /merge/i })).toBeInTheDocument(); }); it("calls onMerge when merge button is clicked", () => { @@ -265,7 +265,7 @@ describe("SessionCard", () => { }); const session = makeSession({ status: "mergeable", activity: "idle", pr }); render(); - fireEvent.click(screen.getByText("Merge PR")); + fireEvent.click(screen.getByRole("button", { name: /merge/i })); expect(onMerge).toHaveBeenCalledWith(42); }); @@ -415,21 +415,16 @@ describe("SessionCard", () => { expect(screen.getByText("ask to fix")).toBeInTheDocument(); }); - it("expands detail panel on click", () => { + it("shows issue details in the compact card footer", () => { const session = makeSession({ id: "test-1", issueId: "INT-100", pr: null }); - const { container } = render(); - expect(screen.queryByText("INT-100")).not.toBeInTheDocument(); - // Click the card (not a button/link) - fireEvent.click(container.firstElementChild!); - expect(screen.getByText("INT-100")).toBeInTheDocument(); - expect(screen.getByText("No PR associated with this session.")).toBeInTheDocument(); + render(); + expect(screen.getAllByText("INT-100")).toHaveLength(2); }); - it("shows terminate button in expanded view", () => { + it("shows icon-only terminate button in the footer", () => { const session = makeSession({ pr: null }); - const { container } = render(); - fireEvent.click(container.firstElementChild!); - expect(screen.getByText("terminate")).toBeInTheDocument(); + render(); + expect(screen.getByRole("button", { name: /terminate session/i })).toBeInTheDocument(); }); }); diff --git a/packages/web/src/app/dev/card-showcase/page.tsx b/packages/web/src/app/dev/card-showcase/page.tsx deleted file mode 100644 index 35da60054..000000000 --- a/packages/web/src/app/dev/card-showcase/page.tsx +++ /dev/null @@ -1,416 +0,0 @@ -"use client"; - -import { AttentionZone } from "@/components/AttentionZone"; -import { SessionCard } from "@/components/SessionCard"; -import { ThemeToggle } from "@/components/ThemeToggle"; -import { - getAttentionLevel, - type AttentionLevel, - type DashboardPR, - type DashboardSession, -} from "@/lib/types"; - -export const dynamic = "force-dynamic"; - -function makePR(overrides: Partial = {}): DashboardPR { - return { - number: 125, - url: "https://github.com/composio/agent-orchestrator/pull/125", - title: "feat(web): redesign dashboard, session detail, and orchestrator terminal", - owner: "composio", - repo: "agent-orchestrator", - branch: "feat/dashboard-redesign", - baseBranch: "main", - isDraft: false, - state: "open", - additions: 3730, - deletions: 697, - ciStatus: "passing", - ciChecks: [ - { name: "build", status: "passed", url: "https://github.com/composio/agent-orchestrator/actions" }, - { name: "typecheck", status: "passed", url: "https://github.com/composio/agent-orchestrator/actions" }, - ], - reviewDecision: "approved", - mergeability: { - mergeable: true, - ciPassing: true, - approved: true, - noConflicts: true, - blockers: [], - }, - unresolvedThreads: 0, - unresolvedComments: [], - ...overrides, - }; -} - -function makeSession(overrides: Partial = {}): DashboardSession { - return { - id: "ao-58", - projectId: "agent-orchestrator", - status: "working", - activity: "idle", - branch: "feat/dashboard-redesign", - issueId: "https://linear.app/composio/issue/AO-557", - issueUrl: "https://linear.app/composio/issue/AO-557", - issueLabel: "#557", - issueTitle: "Dashboard UI: Phase 8 - Multi-Project Sidebar Redesign", - summary: "Dashboard UI: Phase 8 - Multi-Project Sidebar Redesign", - summaryIsFallback: false, - createdAt: new Date().toISOString(), - lastActivityAt: new Date().toISOString(), - pr: null, - metadata: {}, - ...overrides, - }; -} - -const noop = () => {}; - -const showcaseCards: Array<{ title: string; note: string; session: DashboardSession }> = [ - { - title: "Working", - note: "Default in-flight work with no PR pressure.", - session: makeSession({ - id: "ao-41", - activity: "active", - status: "working", - branch: "feat/parallel-agents", - issueLabel: "#541", - issueTitle: "Improve worker session orchestration", - summary: "Ship runtime orchestration improvements across worker sessions", - }), - }, - { - title: "Respond", - note: "Human input required with all ask-to action tags present.", - session: makeSession({ - id: "ao-58", - status: "needs_input", - activity: "waiting_input", - branch: "session/ao-58", - pr: makePR({ - ciStatus: "failing", - ciChecks: [ - { name: "CI", status: "failed", url: "https://github.com/composio/agent-orchestrator/actions/runs/1" }, - ], - reviewDecision: "pending", - mergeability: { - mergeable: false, - ciPassing: false, - approved: false, - noConflicts: true, - blockers: ["CI failing", "Review required"], - }, - unresolvedThreads: 3, - unresolvedComments: [ - { - url: "https://github.com/composio/agent-orchestrator/pull/125#discussion_r1", - path: "packages/web/src/components/Dashboard.tsx", - author: "reviewer", - body: "Resolve before landing", - }, - { - url: "https://github.com/composio/agent-orchestrator/pull/125#discussion_r2", - path: "packages/web/src/components/SessionCard.tsx", - author: "reviewer", - body: "Tighten state handling", - }, - { - url: "https://github.com/composio/agent-orchestrator/pull/125#discussion_r3", - path: "packages/web/src/app/globals.css", - author: "reviewer", - body: "Spacing polish", - }, - ], - }), - }), - }, - { - title: "Review", - note: "Changes requested state with review pressure.", - session: makeSession({ - id: "ao-63", - status: "changes_requested", - activity: "blocked", - branch: "feat/pr-review-flow", - issueLabel: "#563", - issueTitle: "Improve review routing for agent sessions", - summary: "Address reviewer feedback and route sessions back into the queue", - pr: makePR({ - number: 131, - url: "https://github.com/composio/agent-orchestrator/pull/131", - additions: 842, - deletions: 119, - ciStatus: "passing", - reviewDecision: "changes_requested", - mergeability: { - mergeable: false, - ciPassing: true, - approved: false, - noConflicts: true, - blockers: ["Changes requested"], - }, - }), - }), - }, - { - title: "Pending", - note: "Nothing to do yet; waiting on CI and reviewers.", - session: makeSession({ - id: "ao-77", - status: "review_pending", - activity: "idle", - branch: "feat/slack-notifier", - issueLabel: "#577", - issueTitle: "Add Slack notification escalation", - pr: makePR({ - number: 141, - url: "https://github.com/composio/agent-orchestrator/pull/141", - additions: 201, - deletions: 48, - ciStatus: "pending", - reviewDecision: "pending", - mergeability: { - mergeable: false, - ciPassing: false, - approved: false, - noConflicts: true, - blockers: ["CI pending", "Review pending"], - }, - }), - }), - }, - { - title: "Merge Ready", - note: "Clean PR with green CI and approval.", - session: makeSession({ - id: "ao-88", - status: "approved", - activity: "ready", - branch: "feat/merge-automation", - issueLabel: "#588", - issueTitle: "Automate merge-ready session handling", - pr: makePR({ - number: 155, - url: "https://github.com/composio/agent-orchestrator/pull/155", - additions: 129, - deletions: 22, - }), - }), - }, - { - title: "Rate Limited", - note: "PR data degraded by GitHub API rate limiting.", - session: makeSession({ - id: "ao-92", - status: "working", - activity: "idle", - branch: "feat/github-cache", - issueLabel: "#592", - issueTitle: "Add PR cache fallback for rate limits", - pr: makePR({ - number: 161, - url: "https://github.com/composio/agent-orchestrator/pull/161", - ciStatus: "failing", - reviewDecision: "pending", - mergeability: { - mergeable: false, - ciPassing: false, - approved: false, - noConflicts: false, - blockers: ["API rate limited or unavailable"], - }, - }), - }), - }, - { - title: "Done", - note: "Completed / merged card variant.", - session: makeSession({ - id: "ao-99", - status: "merged", - activity: "exited", - branch: "feat/card-showcase", - issueLabel: "#599", - issueTitle: "Create card showcase page", - pr: makePR({ - number: 170, - url: "https://github.com/composio/agent-orchestrator/pull/170", - state: "merged", - }), - }), - }, -]; - -export default function CardShowcasePage() { - const boardSessions = showcaseCards.map((entry) => entry.session); - const grouped: Record = { - merge: [], - respond: [], - review: [], - pending: [], - working: [], - done: [], - }; - - for (const session of boardSessions) { - grouped[getAttentionLevel(session)].push(session); - } - - const stats = { - total: boardSessions.length, - active: boardSessions.filter((session) => session.activity && session.activity !== "exited") - .length, - prs: boardSessions.filter((session) => session.pr?.state === "open").length, - review: grouped.review.length, - }; - - return ( -
-
-
- Dev Showcase -
- -
-
-
-
-
-
-

agent-orchestrator

-

- Static dashboard replica with mocked lane states and card variants for design - review. -

-
-
-
-
- {stats.total} - Fleet -
-
- - {stats.active} - - Active -
-
- {stats.prs} - PRs -
-
- - {stats.review} - - Review -
-
-
- - -
-
- -
-
-
-

Attention Board

-

- Full mocked dashboard board for visual review. -

-
-
- - - Human action - - - - Review queue - - - - Ready to land - -
-
- -
-
- - - - - -
-
-
- -
-
- Card Library -
-
- {showcaseCards.map(({ title, note, session }) => ( -
-
-

{title}

-

- {note} -

-
- { - console.log("showcase send", message); - }} - onKill={noop} - onMerge={noop} - onRestore={noop} - /> -
- ))} -
-
-
-
- ); -} diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index cae11b236..d1f758bdc 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -108,9 +108,9 @@ --card-merge-bg: #f6fbf8; --card-expanded-bg: #f7f7f8; --card-shadow: none; - --card-shadow-hover: 0 1px 3px rgba(0, 0, 0, 0.06); + --card-shadow-hover: 0 2px 6px rgba(0, 0, 0, 0.08); --card-inset: none; - --card-border: var(--color-border-subtle); + --card-border: #d5d7de; /* Done card surfaces */ --card-done-bg: #fafafa; @@ -140,7 +140,7 @@ --btn-inset: none; /* Kanban column */ - --color-column-bg: rgba(0, 0, 0, 0.02); + --color-column-bg: #f4f5f7; --color-column-header: transparent; /* Alert colors */ @@ -250,6 +250,7 @@ --card-shadow: 0 18px 36px rgba(2, 6, 12, 0.24); --card-shadow-hover: 0 24px 54px rgba(2, 6, 12, 0.34); --card-inset: inset 0 1px 0 rgba(255, 255, 255, 0.04); + --card-border: rgba(166, 190, 226, 0.18); /* Done card surfaces */ --card-done-bg: linear-gradient(180deg, rgba(18, 24, 33, 0.72) 0%, rgba(14, 19, 27, 0.72) 100%); @@ -279,7 +280,7 @@ --btn-inset: inset 0 1px 0 rgba(255, 255, 255, 0.08); /* Kanban column */ - --color-column-bg: rgba(255, 255, 255, 0.03); + --color-column-bg: #0d1218; --color-column-header: transparent; /* Alert colors — Linear-muted */ @@ -460,7 +461,7 @@ a:hover { /* Dark — invisible by default, visible on hover */ .xterm .xterm-viewport::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.0); + background: rgba(255, 255, 255, 0); border-radius: 2.5px; transition: background 0.3s ease; } @@ -477,7 +478,7 @@ a:hover { /* Light — same pattern, darker thumb */ html.light .xterm .xterm-viewport::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.0); + background: rgba(0, 0, 0, 0); transition: background 0.3s ease; } html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb, @@ -488,7 +489,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.28); } html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { - background: rgba(0, 0, 0, 0.40); + background: rgba(0, 0, 0, 0.4); } /* ── Animations ──────────────────────────────────────────────────────── */ @@ -542,6 +543,46 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { } } +@keyframes ready-sheen { + 0% { + transform: translateX(-160%); + opacity: 0; + } + 18% { + opacity: 0.28; + } + 100% { + transform: translateX(220%); + opacity: 0; + } +} + +@keyframes ready-rail-breathe { + 0%, + 100% { + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 4%, transparent), + 0 18px 34px color-mix(in srgb, var(--color-status-ready) 10%, transparent); + } + 50% { + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 4%, transparent), + 0 18px 38px color-mix(in srgb, var(--color-status-ready) 14%, transparent); + } +} + +@keyframes ready-dot-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 0.95; + } + 50% { + transform: scale(1.22); + opacity: 1; + } +} + /* ── Kanban card entrance ─────────────────────────────────────────────── */ .kanban-card-enter { @@ -600,51 +641,22 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { content: ""; position: absolute; inset: 0 0 auto 0; - height: 360px; + height: 140px; pointer-events: none; - background: - radial-gradient( - circle at 8% 4%, - color-mix(in srgb, var(--color-accent) 12%, transparent) 0%, - transparent 26% - ), - linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, transparent 100%); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.015) 0%, transparent 100%); } .dashboard-hero { position: relative; - overflow: hidden; + overflow: visible; border: 1px solid var(--color-border-default); border-radius: 0; - background: - radial-gradient( - circle at top left, - color-mix(in srgb, var(--color-accent) 16%, transparent) 0%, - transparent 36% - ), - radial-gradient( - circle at 88% 18%, - color-mix(in srgb, var(--color-status-working) 10%, transparent) 0%, - transparent 24% - ), - linear-gradient( - 180deg, - color-mix(in srgb, var(--color-bg-elevated) 86%, black 14%) 0%, - color-mix(in srgb, var(--color-bg-surface) 96%, black 4%) 100% - ); - box-shadow: 0 24px 60px rgba(2, 6, 12, 0.12); + background: var(--color-bg-surface); + box-shadow: 0 10px 24px rgba(2, 6, 12, 0.06); } .dashboard-hero__backdrop { - position: absolute; - inset: 0; - pointer-events: none; - opacity: 0.6; - background-image: - linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px); - background-size: 28px 28px; - mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.85), transparent); + display: none; } .dashboard-hero__content { @@ -653,15 +665,16 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { flex-wrap: wrap; align-items: flex-start; justify-content: space-between; - gap: 18px; - padding: 18px 22px; + gap: 14px; + padding: 14px 18px; } .dashboard-hero__primary { display: flex; flex: 1 1 720px; - flex-direction: column; - gap: 12px; + align-items: flex-start; + justify-content: space-between; + gap: 18px; } .dashboard-hero__heading { @@ -669,6 +682,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { flex-direction: column; gap: 8px; max-width: 760px; + min-width: 0; } .dashboard-eyebrow { @@ -695,17 +709,17 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { } .dashboard-title { - font-size: clamp(24px, 3.2vw, 38px); - line-height: 0.98; + font-size: clamp(22px, 2.8vw, 32px); + line-height: 1; letter-spacing: -0.05em; font-weight: 600; color: var(--color-text-primary); } .dashboard-subtitle { - margin-top: 4px; + margin-top: 3px; max-width: 56ch; - font-size: 12px; + font-size: 11px; line-height: 1.4; color: var(--color-text-muted); } @@ -713,37 +727,34 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .dashboard-hero__meta { display: flex; flex: 0 0 auto; - flex-direction: column; + flex-direction: row; align-items: center; - gap: 12px; + gap: 10px; margin-left: auto; } .dashboard-stat-cards { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + display: flex; + flex-wrap: wrap; + flex-shrink: 0; + align-items: center; gap: 8px; - max-width: 720px; + max-width: none; + margin-left: auto; } .dashboard-stat-card { - display: flex; - min-height: 64px; - flex-direction: column; - justify-content: center; - gap: 1px; + display: inline-flex; + min-height: 0; + min-width: 0; + flex-direction: row; + align-items: baseline; + gap: 6px; border: 1px solid color-mix(in srgb, var(--color-border-default) 88%, transparent); border-radius: 0; - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--color-bg-base) 25%, transparent) 0%, - color-mix(in srgb, var(--color-bg-subtle) 80%, transparent) 100% - ); - padding: 9px 11px 8px; - box-shadow: - inset 0 1px 0 color-mix(in srgb, white 6%, transparent), - 0 8px 18px rgba(0, 0, 0, 0.05); + background: color-mix(in srgb, var(--color-bg-base) 72%, transparent); + padding: 6px 9px; + box-shadow: none; } .dashboard-stat-card--empty { @@ -751,7 +762,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { } .dashboard-stat-card__value { - font-size: clamp(18px, 2vw, 24px); + font-size: 18px; line-height: 1; font-weight: 600; letter-spacing: -0.05em; @@ -759,9 +770,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { } .dashboard-stat-card__label { - font-size: 9px; + font-size: 10px; font-weight: 700; - letter-spacing: 0.12em; + letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-secondary); } @@ -781,6 +792,117 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .detail-card { background: var(--detail-card-bg); box-shadow: var(--detail-card-shadow); + border-radius: 0; +} + +.session-detail-page { + background: + radial-gradient( + circle at top right, + color-mix(in srgb, var(--color-accent) 6%, transparent), + transparent 28% + ), + var(--color-bg-base); +} + +.session-page-header { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 10px; + border: 1px solid var(--color-border-default); + padding: 14px 16px; + background: var(--detail-card-bg); + box-shadow: var(--detail-card-shadow); +} + +@media (min-width: 900px) { + .session-page-header { + gap: 12px; + } +} + +.session-page-header__crumbs { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding-bottom: 10px; + border-bottom: 1px solid var(--color-border-subtle); +} + +.session-page-header__mode { + padding: 2px 8px; + border: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent); + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 10%, transparent); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.05em; +} + +.session-page-header__main { + display: grid; + gap: 12px; +} + +.session-page-header__identity { + min-width: 0; +} + +.session-page-header__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.session-page-header__side { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +@media (min-width: 1100px) { + .session-page-header__main { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + } + + .session-page-header__side { + justify-content: flex-end; + } +} + +.session-detail-link-pill { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--color-border-subtle); + border-radius: 0; + background: color-mix(in srgb, var(--color-bg-elevated) 88%, transparent); + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 500; +} + +.session-detail-link-pill { + transition: + border-color 0.14s ease, + color 0.14s ease, + background 0.14s ease; +} + +.session-detail-link-pill:hover { + border-color: var(--color-border-strong); + color: var(--color-text-primary); +} + +.session-detail-link-pill--accent { + color: var(--color-accent); + border-color: color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle)); + background: color-mix(in srgb, var(--color-accent) 8%, transparent); } .dark .detail-card { @@ -793,11 +915,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .session-card { background: var(--card-bg); - border-color: var(--color-border-subtle); + border-color: var(--card-border); border-radius: 0; box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent), - 0 12px 26px rgba(0, 0, 0, 0.06); + 0 10px 20px rgba(0, 0, 0, 0.08); transition: transform 0.12s ease, border-color 0.12s ease, @@ -832,6 +954,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { --color-text-secondary: #9898a0; --color-text-muted: #5c5c66; --color-text-tertiary: #5c5c66; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 14px 30px rgba(0, 0, 0, 0.34); } /* ── Dark mode weight reduction ──────────────────────────────────────── @@ -916,39 +1041,159 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .session-card__terminate { border-radius: 0; line-height: 1; + padding-left: 8px; + padding-right: 8px; } .session-card__merge-control { - border-color: color-mix(in srgb, var(--color-status-ready) 40%, transparent); - background: color-mix(in srgb, var(--color-status-ready) 8%, transparent); + border-color: color-mix(in srgb, var(--color-status-ready) 68%, transparent); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-status-ready) 34%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 18%, transparent) 100% + ); color: var(--color-status-ready); text-decoration: none; + font-weight: 600; + letter-spacing: 0.01em; + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 26%, transparent), + 0 8px 18px color-mix(in srgb, var(--color-status-ready) 16%, transparent); } .session-card__merge-control:hover { border-color: var(--color-status-ready); - background: color-mix(in srgb, var(--color-status-ready) 14%, transparent); - text-decoration: underline; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-status-ready) 46%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 24%, transparent) 100% + ); + text-decoration: none; + transform: translateY(-1px); + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 30%, transparent), + 0 10px 22px color-mix(in srgb, var(--color-status-ready) 20%, transparent); +} + +.session-card__merge-control .session-card__control-icon { + transition: transform 0.14s ease; +} + +.session-card__merge-control:hover .session-card__control-icon { + transform: translateX(2px); } .session-card.card-merge-ready { position: relative; - background: var(--card-merge-bg); - border-color: color-mix(in srgb, var(--color-status-ready) 24%, var(--color-border-default)); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-status-ready) 10%, var(--card-merge-bg)) 0%, + color-mix(in srgb, var(--color-status-ready) 6%, var(--card-merge-bg)) 28%, + color-mix(in srgb, var(--color-status-ready) 3%, var(--card-merge-bg)) 100% + ); + border-color: color-mix(in srgb, var(--color-status-ready) 30%, var(--color-border-default)); box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent), - 0 18px 34px color-mix(in srgb, var(--color-status-ready) 10%, transparent); + 0 22px 40px color-mix(in srgb, var(--color-status-ready) 18%, transparent); + animation: ready-rail-breathe 3.8s ease-in-out infinite; } .session-card.card-merge-ready::before { display: none; } +.session-card.card-merge-ready::after { + content: ""; + position: absolute; + inset: -14px -10px -18px -10px; + pointer-events: none; + z-index: 0; + background: + radial-gradient( + ellipse at 50% 100%, + color-mix(in srgb, var(--color-status-ready) 18%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 8%, transparent) 42%, + transparent 72% + ), + radial-gradient( + ellipse at 50% 0%, + color-mix(in srgb, var(--color-status-ready) 10%, transparent) 0%, + transparent 58% + ); + filter: blur(10px); + opacity: 0.9; +} + +.session-card.card-merge-ready > * { + position: relative; + z-index: 1; +} + +.dark .session-card.card-merge-ready::after { + background: + radial-gradient( + ellipse at 50% 100%, + color-mix(in srgb, var(--color-status-ready) 24%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 10%, transparent) 44%, + transparent 72% + ), + radial-gradient( + ellipse at 50% 0%, + color-mix(in srgb, var(--color-status-ready) 12%, transparent) 0%, + transparent 58% + ); + opacity: 0.75; +} + +.session-card.card-merge-ready:hover::after { + opacity: 1; + filter: blur(12px); + background: + radial-gradient( + ellipse at 50% 100%, + color-mix(in srgb, var(--color-status-ready) 24%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 10%, transparent) 44%, + transparent 74% + ), + radial-gradient( + ellipse at 50% 0%, + color-mix(in srgb, var(--color-status-ready) 12%, transparent) 0%, + transparent 58% + ); +} + +.dark .session-card.card-merge-ready:hover::after { + background: + radial-gradient( + ellipse at 50% 100%, + color-mix(in srgb, var(--color-status-ready) 28%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 12%, transparent) 44%, + transparent 74% + ), + radial-gradient( + ellipse at 50% 0%, + color-mix(in srgb, var(--color-status-ready) 14%, transparent) 0%, + transparent 58% + ); +} + +.session-card.card-merge-ready::after { + transition: + opacity 0.18s ease, + filter 0.18s ease, + background 0.18s ease; +} + .session-card.card-merge-ready:hover { - border-color: color-mix(in srgb, var(--color-status-ready) 34%, var(--color-border-strong)); + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--color-status-ready) 42%, var(--color-border-strong)); box-shadow: inset 0 1px 0 color-mix(in srgb, white 6%, transparent), - 0 22px 40px color-mix(in srgb, var(--color-status-ready) 12%, transparent); + 0 28px 52px color-mix(in srgb, var(--color-status-ready) 22%, transparent); +} + +.session-card.card-merge-ready .session-card__header > span:first-child > span:first-child { + animation: ready-dot-pulse 2.8s ease-in-out infinite; } /* ── Done cards — compact, de-emphasized ─────────────────────────────── */ @@ -1329,26 +1574,55 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { } .project-sidebar__collapsed-project { + position: relative; display: inline-flex; - width: 30px; - height: 30px; + width: 36px; + height: 36px; align-items: center; justify-content: center; - border: 1px solid transparent; - background: transparent; + border: 1px solid var(--color-border-subtle); + border-radius: 8px; + background: var(--color-bg-surface); + cursor: pointer; transition: - border-color 0.12s ease, - background 0.12s ease; + border-color 0.15s ease, + background 0.15s ease, + box-shadow 0.15s ease; } .project-sidebar__collapsed-project:hover { - border-color: var(--color-border-subtle); - background: var(--color-hover-overlay); + border-color: var(--color-border-default); + background: var(--color-bg-elevated); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } .project-sidebar__collapsed-project--active { - border-color: color-mix(in srgb, var(--color-accent) 22%, var(--color-border-subtle)); - background: color-mix(in srgb, var(--color-accent) 10%, transparent); + border-color: color-mix(in srgb, var(--color-accent) 35%, var(--color-border-default)); + background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-elevated)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent) 12%, transparent); +} + +.project-sidebar__avatar { + font-size: 13px; + font-weight: 600; + line-height: 1; + color: var(--color-text-secondary); + user-select: none; +} + +.project-sidebar__collapsed-project--active .project-sidebar__avatar { + color: var(--color-accent); +} + +.project-sidebar__health-indicator { + position: absolute; + top: -2px; + right: -2px; + width: 7px; + height: 7px; + border-radius: 50%; + border: 1.5px solid var(--color-bg-surface); + box-sizing: content-box; } /* ── Kanban board ────────────────────────────────────────────────────── */ @@ -1419,18 +1693,36 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { max-width: 420px; display: flex; flex-direction: column; - border: 1px solid var(--color-border-subtle); + border: 1px solid var(--color-border-default); border-radius: 0; - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--color-bg-elevated) 92%, transparent) 0%, - color-mix(in srgb, var(--color-bg-surface) 96%, transparent) 100% - ); + background: var(--color-column-bg); padding: 4px; box-shadow: - inset 0 1px 0 color-mix(in srgb, white 3%, transparent), - 0 24px 50px rgba(0, 0, 0, 0.06); + inset 0 1px 0 color-mix(in srgb, white 2%, transparent), + 0 16px 34px rgba(0, 0, 0, 0.05); +} + +.kanban-column[data-level="merge"] { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-status-ready) 4%, var(--color-column-bg)) 0%, + var(--color-column-bg) 100% + ); +} + +.dark .kanban-column { + border-color: rgba(170, 195, 230, 0.14); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.02), + 0 18px 42px rgba(0, 0, 0, 0.24); +} + +.dark .kanban-column[data-level="merge"] { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-status-ready) 7%, var(--color-column-bg)) 0%, + var(--color-column-bg) 100% + ); } .kanban-column__header { @@ -1525,17 +1817,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { @media (max-width: 960px) { .dashboard-hero__content { - padding: 16px 18px; + padding: 12px 14px; + } + + .dashboard-hero__primary { + width: 100%; + flex-direction: column; + gap: 10px; } .dashboard-hero__meta { width: 100%; align-items: flex-start; + justify-content: space-between; } .dashboard-stat-cards { - grid-template-columns: repeat(2, minmax(0, 1fr)); - max-width: none; + width: 100%; } .kanban-board { @@ -1558,11 +1856,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { @media (max-width: 640px) { .dashboard-title { - font-size: 32px; + font-size: 26px; } .dashboard-stat-cards { - grid-template-columns: minmax(0, 1fr); + gap: 6px; } .kanban-column { diff --git a/packages/web/src/components/AttentionZone.tsx b/packages/web/src/components/AttentionZone.tsx index 52d2a57cb..420b95744 100644 --- a/packages/web/src/components/AttentionZone.tsx +++ b/packages/web/src/components/AttentionZone.tsx @@ -22,7 +22,7 @@ const zoneConfig: Record< } > = { merge: { - label: "Merge Ready", + label: "Ready", color: "var(--color-status-ready)", caption: "Cleared to land", }, diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index 904c551cd..d6c468bdd 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -9,8 +9,7 @@ import { cn } from "@/lib/cn"; import "xterm/css/xterm.css"; // Dynamically import xterm types for TypeScript -import type { Terminal as TerminalType } from "xterm"; -import type { ITheme } from "xterm"; +import type { ITheme, Terminal as TerminalType } from "xterm"; import type { FitAddon as FitAddonType } from "@xterm/addon-fit"; interface DirectTerminalProps { @@ -42,8 +41,16 @@ interface DirectTerminalWsUrlOptions { type TerminalVariant = "agent" | "orchestrator"; export function buildTerminalThemes(variant: TerminalVariant): { dark: ITheme; light: ITheme } { - const agentAccent = { cursor: "#5b7ef8", selDark: "rgba(91, 126, 248, 0.30)", selLight: "rgba(91, 126, 248, 0.25)" }; - const orchAccent = { cursor: "#a371f7", selDark: "rgba(163, 113, 247, 0.25)", selLight: "rgba(130, 80, 223, 0.20)" }; + const agentAccent = { + cursor: "#5b7ef8", + selDark: "rgba(91, 126, 248, 0.30)", + selLight: "rgba(91, 126, 248, 0.25)", + }; + const orchAccent = { + cursor: "#a371f7", + selDark: "rgba(163, 113, 247, 0.25)", + selLight: "rgba(130, 80, 223, 0.20)", + }; const accent = variant === "orchestrator" ? orchAccent : agentAccent; const dark: ITheme = { @@ -74,28 +81,28 @@ export function buildTerminalThemes(variant: TerminalVariant): { dark: ITheme; l const light: ITheme = { background: "#fafafa", - foreground: "#383a42", + foreground: "#24292f", cursor: accent.cursor, cursorAccent: "#fafafa", selectionBackground: accent.selLight, selectionInactiveBackground: "rgba(128, 128, 128, 0.15)", - // ANSI colors — One Light inspired, tuned for #fafafa background - black: "#383a42", - red: "#e45649", - green: "#50a14f", - yellow: "#c18401", - blue: "#4078f2", - magenta: "#a626a4", - cyan: "#0184bc", - white: "#a0a1a7", - brightBlack: "#696c77", - brightRed: "#ca1243", - brightGreen: "#2da44e", - brightYellow: "#986801", - brightBlue: "#4078f2", - brightMagenta: "#a626a4", - brightCyan: "#0070a8", - brightWhite: "#fafafa", + // ANSI colors — darkened for legibility on #fafafa terminal background + black: "#24292f", + red: "#b42318", + green: "#1f7a3d", + yellow: "#8a5a00", + blue: "#175cd3", + magenta: "#8e24aa", + cyan: "#0b7285", + white: "#4b5563", + brightBlack: "#374151", + brightRed: "#912018", + brightGreen: "#176639", + brightYellow: "#6f4a00", + brightBlue: "#1d4ed8", + brightMagenta: "#7b1fa2", + brightCyan: "#155e75", + brightWhite: "#374151", }; return { dark, light }; @@ -242,8 +249,12 @@ export function DirectTerminal({ const terminal = new Terminal({ cursorBlink: true, fontSize: 13, - fontFamily: 'var(--font-jetbrains-mono), "JetBrains Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace', + fontFamily: + 'var(--font-jetbrains-mono), "JetBrains Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace', theme: activeTheme, + // Light mode needs an explicit contrast floor because agent UIs often emit + // dim/faint ANSI sequences that become unreadable on a near-white background. + minimumContrastRatio: isDark ? 1 : 7, scrollback: 10000, allowProposedApi: true, fastScrollModifier: "alt", @@ -504,6 +515,7 @@ export function DirectTerminal({ if (!terminal) return; const isDark = resolvedTheme !== "light"; terminal.options.theme = isDark ? terminalThemes.dark : terminalThemes.light; + terminal.options.minimumContrastRatio = isDark ? 1 : 7; }, [resolvedTheme, terminalThemes]); // Re-fit terminal when fullscreen changes @@ -620,7 +632,7 @@ export function DirectTerminal({ return (
{/* XDA clipboard badge */} {reloading ? ( <> @@ -695,7 +707,7 @@ export function DirectTerminal({ ); })} diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index 8ef0b6721..eb9ac2af5 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -550,7 +550,11 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio strokeWidth="2" viewBox="0 0 24 24" > - + + + + + merge @@ -560,7 +564,8 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio e.stopPropagation(); onKill?.(session.id); }} - className="session-card__control session-card__terminate inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 border border-[color-mix(in_srgb,var(--color-status-error)_35%,transparent)] px-2.5 py-1 text-[11px] text-[var(--color-status-error)] transition-colors hover:border-[var(--color-status-error)] hover:bg-[var(--color-tint-red)] hover:underline" + aria-label="Terminate session" + className="session-card__control session-card__terminate inline-flex shrink-0 cursor-pointer items-center justify-center border border-[color-mix(in_srgb,var(--color-status-error)_35%,transparent)] px-2.5 py-1 text-[11px] text-[var(--color-status-error)] transition-colors hover:border-[var(--color-status-error)] hover:bg-[var(--color-tint-red)]" > - + + + + - terminate )}
diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 3e3e33a92..2d941b276 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, type ReactNode } from "react"; import { useSearchParams } from "next/navigation"; import { type DashboardSession, type DashboardPR, isPRMergeReady } from "@/lib/types"; import { CI_STATUS } from "@composio/ao-core/types"; @@ -43,18 +43,8 @@ function humanizeStatus(status: string): string { .replace(/\b\w/g, (c) => c.toUpperCase()); } -function relativeTime(iso: string): string { - const ms = new Date(iso).getTime(); - if (!iso || isNaN(ms)) return "unknown"; - const diff = Date.now() - ms; - const seconds = Math.floor(diff / 1000); - if (seconds < 60) return "just now"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; +function getSessionHeadline(session: DashboardSession): string { + return session.issueTitle ?? session.summary ?? session.id; } function cleanBugbotComment(body: string): { title: string; description: string } { @@ -75,8 +65,97 @@ function buildGitHubBranchUrl(pr: DashboardPR): string { return `https://github.com/${pr.owner}/${pr.repo}/tree/${pr.branch}`; } -function buildGitHubRepoUrl(pr: DashboardPR): string { - return `https://github.com/${pr.owner}/${pr.repo}`; +function SessionTopStrip({ + headline, + activityLabel, + activityColor, + branch, + pr, + isOrchestrator = false, + rightSlot, +}: { + headline: string; + activityLabel: string; + activityColor: string; + branch: string | null; + pr: DashboardPR | null; + isOrchestrator?: boolean; + rightSlot?: ReactNode; +}) { + return ( +
+
+ + + + + Orchestrator + + / + + {headline} + + {isOrchestrator ? orchestrator : null} +
+
+
+

+ {headline} +

+
+
+ + + {activityLabel} + +
+ {branch ? ( + pr ? ( + + {branch} + + ) : ( + + {branch} + + ) + ) : null} + {pr ? ( + + PR #{pr.number} + + ) : null} +
+
+ {rightSlot ?
{rightSlot}
: null} +
+
+ ); } async function askAgentToFix( @@ -106,9 +185,19 @@ async function askAgentToFix( function OrchestratorStatusStrip({ zones, createdAt, + headline, + activityLabel, + activityColor, + branch, + pr, }: { zones: OrchestratorZones; createdAt: string; + headline: string; + activityLabel: string; + activityColor: string; + branch: string | null; + pr: DashboardPR | null; }) { const [uptime, setUptime] = useState(""); @@ -137,52 +226,61 @@ function OrchestratorStatusStrip({ zones.merge + zones.respond + zones.review + zones.working + zones.pending + zones.done; return ( -
-
- {/* Total count */} -
- - {total} - - agents -
- -
- - {/* Per-zone pills */} - {stats.length > 0 ? ( - stats.map((s) => ( -
- - {s.value} - - - {s.label} +
+ +
+ + {total} + agents
- )) - ) : ( - no active agents - )} - {uptime && ( - - up {uptime} - - )} -
+
+ + {/* Per-zone pills */} + {stats.length > 0 ? ( + stats.map((s) => ( +
+ + {s.value} + + + {s.label} + +
+ )) + ) : ( + + no active agents + + )} + + {uptime && ( + + up {uptime} + + )} +
+ } + />
); } @@ -201,11 +299,12 @@ export function SessionDetail({ label: session.activity ?? "unknown", color: "var(--color-text-muted)", }; + const headline = getSessionHeadline(session); const accentColor = "var(--color-accent)"; const terminalVariant = isOrchestrator ? "orchestrator" : "agent"; - const terminalHeight = isOrchestrator ? "calc(100vh - 240px)" : "max(440px, calc(100vh - 440px))"; + const terminalHeight = isOrchestrator ? "clamp(560px, 76vh, 920px)" : "clamp(520px, 72vh, 860px)"; const isOpenCodeSession = session.metadata["agent"] === "opencode"; const opencodeSessionId = typeof session.metadata["opencodeSessionId"] === "string" && @@ -217,233 +316,62 @@ export function SessionDetail({ : undefined; return ( -
- {/* Nav bar — glass effect */} - - - {/* Orchestrator status strip */} +
{isOrchestrator && orchestratorZones && ( - + )} -
- {/* ── Header card ─────────────────────────────────────────── */} -
-
-
-
-

- {session.id} -

- {/* Activity badge */} -
- - - {activity.label} - -
-
- - {session.summary && ( -

- {session.summary} -

- )} - - {/* Meta chips */} -
- {session.projectId && ( - <> - {pr ? ( - - {session.projectId} - - ) : ( - - {session.projectId} - - )} - · - - )} - - {pr && ( - <> - - PR #{pr.number} - - {(session.branch || session.issueUrl) && ( - · - )} - - )} - - {session.branch && ( - <> - {pr ? ( - - {session.branch} - - ) : ( - - {session.branch} - - )} - {session.issueUrl && ( - · - )} - - )} - - {session.issueUrl && ( - - {session.issueLabel || session.issueUrl} - - )} -
- - -
-
-
- - {/* ── PR Card ─────────────────────────────────────────────── */} - {pr && } - - {/* ── Terminal ─────────────────────────────────────────────── */} -
-
-
+
+ {!isOrchestrator && ( + - - Terminal - -
- -
+ )} + +
+
+
+ + Live Terminal + +
+ +
+ + {pr ? ( +
+ +
+ ) : null} +
); } -// ── Client-side timestamps ──────────────────────────────────────────── - -function ClientTimestamps({ - status, - createdAt, - lastActivityAt, -}: { - status: string; - createdAt: string; - lastActivityAt: string; -}) { - const [created, setCreated] = useState(null); - const [lastActive, setLastActive] = useState(null); - - useEffect(() => { - setCreated(relativeTime(createdAt)); - setLastActive(relativeTime(lastActivityAt)); - }, [createdAt, lastActivityAt]); - - return ( -
- - {humanizeStatus(status)} - - {created && ( - <> - · - created {created} - - )} - {lastActive && ( - <> - · - active {lastActive} - - )} -
- ); -} - // ── PR Card ─────────────────────────────────────────────────────────── function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { @@ -527,7 +455,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { : "var(--color-border-default)"; return ( -
+
{/* Title row */}
· Merged @@ -567,7 +495,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
{/* Ready-to-merge banner */} {allGreen ? ( -
+
Unresolved Comments {pr.unresolvedThreads} @@ -612,7 +540,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { const { title, description } = cleanBugbotComment(c.body); return (
- + handleAskAgentToFix(c)} disabled={sendingComments.has(c.url)} className={cn( - "mt-1.5 rounded-[4px] px-3 py-1 text-[11px] font-semibold transition-all", + "mt-1.5 px-3 py-1 text-[11px] font-semibold transition-all", sentComments.has(c.url) ? "bg-[var(--color-status-ready)] text-white" : errorComments.has(c.url) diff --git a/packages/web/src/components/__tests__/DirectTerminal.test.ts b/packages/web/src/components/__tests__/DirectTerminal.test.ts index d77a22c46..9b3f63027 100644 --- a/packages/web/src/components/__tests__/DirectTerminal.test.ts +++ b/packages/web/src/components/__tests__/DirectTerminal.test.ts @@ -68,6 +68,32 @@ const ANSI_KEYS = [ "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite", ] as const; +function hexToRgb(hex: string): [number, number, number] { + return [ + Number.parseInt(hex.slice(1, 3), 16), + Number.parseInt(hex.slice(3, 5), 16), + Number.parseInt(hex.slice(5, 7), 16), + ]; +} + +function toLinear(channel: number): number { + const normalized = channel / 255; + return normalized <= 0.04045 + ? normalized / 12.92 + : Math.pow((normalized + 0.055) / 1.055, 2.4); +} + +function relativeLuminance(hex: string): number { + const [r, g, b] = hexToRgb(hex); + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); +} + +function contrastRatio(a: string, b: string): number { + const lighter = Math.max(relativeLuminance(a), relativeLuminance(b)); + const darker = Math.min(relativeLuminance(a), relativeLuminance(b)); + return (lighter + 0.05) / (darker + 0.05); +} + describe("buildTerminalThemes", () => { it("dark theme has valid hex colors for bg, fg, and all ANSI slots", () => { const { dark } = buildTerminalThemes("agent"); @@ -81,12 +107,19 @@ describe("buildTerminalThemes", () => { it("light theme has valid hex colors for bg, fg, and all ANSI slots", () => { const { light } = buildTerminalThemes("agent"); expect(light.background).toBe("#fafafa"); - expect(light.foreground).toBe("#383a42"); + expect(light.foreground).toBe("#24292f"); for (const key of ANSI_KEYS) { expect(light[key]).toMatch(HEX_RE); } }); + it("light theme ANSI colors maintain readable contrast on the terminal background", () => { + const { light } = buildTerminalThemes("agent"); + for (const key of ANSI_KEYS) { + expect(contrastRatio(light.background!, light[key]!)).toBeGreaterThanOrEqual(4); + } + }); + it("dark theme background is #0a0a0f", () => { const { dark } = buildTerminalThemes("agent"); expect(dark.background).toBe("#0a0a0f");