feat(brand): rebrand from Mission Control to Pipeline

- Project name: openclaw-mission-control → pipeline
- DB name: mission_control → pipeline
- Package names: pipeline-frontend, pipeline-backend
- UI text: all Mission Control references → Pipeline
- New PipelineIcon component replaces old brand mark
- Landing hero, sidebar, loading, auth, settings all rebranded
- ThemeToggle and ThemeProvider components added
- uv.lock updated for pipeline-backend package rename
This commit is contained in:
null 2026-05-19 01:48:38 -05:00
parent 0f50db1e9c
commit 827d62c05e
32 changed files with 457 additions and 176 deletions

View File

@ -6,7 +6,7 @@ FRONTEND_PORT=3000
BACKEND_PORT=8000
# --- database ---
POSTGRES_DB=mission_control
POSTGRES_DB=pipeline
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5432
@ -26,6 +26,6 @@ LOCAL_AUTH_TOKEN=
# --- frontend settings ---
# REQUIRED: Public URL used by the browser to reach the API.
# Use `auto` to target the same host currently serving Mission Control on port 8000.
# Use `auto` to target the same host currently serving Pipeline on port 8000.
# Example (explicit override): NEXT_PUBLIC_API_URL=https://mc.example.com
NEXT_PUBLIC_API_URL=auto

View File

@ -4,7 +4,7 @@ LOG_FORMAT=text
LOG_USE_UTC=false
REQUEST_LOG_SLOW_MS=1000
REQUEST_LOG_INCLUDE_HEALTH=false
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_control
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/pipeline
# For remote access, set this to your UI origin (e.g. http://<server-ip>:3000 or https://mc.example.com).
CORS_ORIGINS=http://localhost:3000
# REQUIRED for gateway provisioning/agent heartbeats. Must be reachable by gateway runtime.

View File

@ -12,7 +12,7 @@ REQUEST_LOG_SLOW_MS=1000
REQUEST_LOG_INCLUDE_HEALTH=false
# Local backend -> local Postgres (adjust host/port if needed)
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_control_test
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/pipeline_test
CORS_ORIGINS=http://localhost:3000
BASE_URL=http://localhost:8000

View File

@ -1,4 +1,4 @@
"""Application name and version constants."""
APP_NAME = "mission-control"
APP_NAME = "pipeline"
APP_VERSION = "0.1.0"

View File

@ -8,7 +8,7 @@ profile = "black"
line_length = 100
skip = [".venv", "migrations/versions"]
[project]
name = "openclaw-agency-backend"
name = "pipeline-backend"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [

View File

@ -698,7 +698,25 @@ wheels = [
]
[[package]]
name = "openclaw-agency-backend"
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "pipeline-backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
@ -767,24 +785,6 @@ requires-dist = [
]
provides-extras = ["dev"]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "platformdirs"
version = "4.5.1"

View File

@ -1,10 +1,10 @@
name: openclaw-mission-control
name: pipeline
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-mission_control}
POSTGRES_DB: ${POSTGRES_DB:-pipeline}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
@ -37,7 +37,7 @@ services:
- ./backend/.env
environment:
# Override localhost defaults for container networking
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control}
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-pipeline}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3030}
DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}
AUTH_MODE: ${AUTH_MODE}
@ -84,7 +84,7 @@ services:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control}
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-pipeline}
AUTH_MODE: ${AUTH_MODE}
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN}
BASE_URL: ${BASE_URL:-http://localhost:8000}

View File

@ -1,5 +1,5 @@
{
"name": "frontend",
"name": "pipeline-frontend",
"version": "0.1.0",
"private": true,
"scripts": {

View File

@ -861,7 +861,9 @@ export default function BoardDetailPage() {
const openedTaskIdFromUrlRef = useRef<string | null>(null);
const openedPanelFromUrlRef = useRef<string | null>(null);
const [comments, setComments] = useState<TaskComment[]>([]);
const [highlightedCommentId, setHighlightedCommentId] = useState<string | null>(null);
const [highlightedCommentId, setHighlightedCommentId] = useState<
string | null
>(null);
const [liveFeed, setLiveFeed] = useState<LiveFeedItem[]>([]);
const liveFeedRef = useRef<LiveFeedItem[]>([]);
const liveFeedFlashTimersRef = useRef<Record<string, number>>({});
@ -2407,9 +2409,12 @@ export default function BoardDetailPage() {
currentTaskIdFromUrl !== fullTask.id ||
currentCommentIdFromUrl !== targetCommentId
) {
router.replace(buildUrlWithTaskAndComment(fullTask.id, targetCommentId), {
scroll: false,
});
router.replace(
buildUrlWithTaskAndComment(fullTask.id, targetCommentId),
{
scroll: false,
},
);
}
selectedTaskIdRef.current = fullTask.id;
setSelectedTask(fullTask);
@ -4659,9 +4664,7 @@ export default function BoardDetailPage() {
</span>{" "}
to board chat.
</li>
<li>
Mission Control forwards it to all agents on this board.
</li>
<li>Pipeline forwards it to all agents on this board.</li>
</ul>
</div>

View File

@ -165,7 +165,7 @@ export default function EditGatewayPage() {
? `Edit gateway — ${resolvedName.trim()}`
: "Edit gateway"
}
description="Update connection settings for this OpenClaw gateway."
description="Update connection settings for this Pipeline gateway."
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can edit gateways."
>

View File

@ -116,7 +116,7 @@ export default function NewGatewayPage() {
forceRedirectUrl: "/gateways/new",
}}
title="Create gateway"
description="Configure an OpenClaw gateway for mission control."
description="Configure a Pipeline gateway."
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can create gateways."
>

View File

@ -95,7 +95,7 @@ export default function GatewaysPage() {
forceRedirectUrl: "/gateways",
}}
title="Gateways"
description="Manage OpenClaw gateway connections used by boards"
description="Manage Pipeline gateway connections used by boards"
headerActions={
isAdmin && gateways.length > 0 ? (
<Link
@ -125,7 +125,7 @@ export default function GatewaysPage() {
emptyState={{
title: "No gateways yet",
description:
"Create your first gateway to connect boards and start managing your OpenClaw connections.",
"Create your first gateway to connect boards and start managing your Pipeline connections.",
actionHref: "/gateways/new",
actionLabel: "Create your first gateway",
}}
@ -145,8 +145,8 @@ export default function GatewaysPage() {
title="Delete gateway?"
description={
<>
This removes the gateway connection from Mission Control. Boards
using it will need a new gateway assigned.
This removes the gateway connection from Pipeline. Boards using it
will need a new gateway assigned.
</>
}
errorMessage={deleteMutation.error?.message}

View File

@ -3,6 +3,29 @@
@tailwind utilities;
:root {
color-scheme: dark;
--bg: #070b12;
--surface: #0f1724;
--surface-muted: #162133;
--surface-strong: #223047;
--border: #263246;
--border-strong: #3a4860;
--text: #edf3fb;
--text-muted: #a7b4c7;
--text-quiet: #748195;
--accent: #60a5fa;
--accent-strong: #93c5fd;
--accent-soft: rgba(96, 165, 250, 0.18);
--success: #34d399;
--warning: #fbbf24;
--danger: #f87171;
--shadow-panel:
0 18px 50px rgba(0, 0, 0, 0.35), 0 1px 0 rgba(255, 255, 255, 0.04) inset;
--shadow-card:
0 12px 34px rgba(0, 0, 0, 0.28), 0 1px 0 rgba(255, 255, 255, 0.04) inset;
}
[data-theme="light"] {
color-scheme: light;
--bg: #f8fafc;
--surface: #ffffff;
@ -25,6 +48,10 @@
0 1px 2px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.06);
}
[data-theme="dark"] {
color-scheme: dark;
}
body {
@apply font-body;
background: var(--bg);
@ -121,8 +148,16 @@ body {
}
.bg-landing-grid {
background-image:
linear-gradient(to right, rgba(12, 17, 29, 0.08) 1px, transparent 1px),
linear-gradient(to bottom, rgba(12, 17, 29, 0.08) 1px, transparent 1px);
linear-gradient(
to right,
color-mix(in srgb, var(--text) 8%, transparent) 1px,
transparent 1px
),
linear-gradient(
to bottom,
color-mix(in srgb, var(--text) 8%, transparent) 1px,
transparent 1px
);
background-size: 120px 120px;
}
}

20
frontend/src/app/icon.svg Normal file
View File

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#60a5fa" />
<path
d="M18 21h15c7 0 13 6 13 13s-6 13-13 13H18"
fill="none"
stroke="#fff"
stroke-width="6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18 21v26M18 34h20"
fill="none"
stroke="#0f1724"
stroke-width="6"
stroke-linecap="round"
stroke-linejoin="round"
opacity=".95"
/>
</svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@ -69,7 +69,7 @@ function InviteContent() {
return (
<div className="min-h-screen bg-app text-strong">
<header className="border-b border-[color:var(--border)] bg-white">
<header className="border-b border-[color:var(--border)] bg-[color:var(--surface)]">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<BrandMark />
</div>
@ -82,7 +82,7 @@ function InviteContent() {
Organization Invite
</p>
<h1 className="text-2xl font-semibold text-strong">
Join your team in OpenClaw
Join your team in Pipeline
</h1>
<p className="text-sm text-muted">{helperText}</p>
</div>

View File

@ -7,10 +7,11 @@ import { DM_Serif_Display, IBM_Plex_Sans, Sora } from "next/font/google";
import { AuthProvider } from "@/components/providers/AuthProvider";
import { QueryProvider } from "@/components/providers/QueryProvider";
import { ThemeProvider } from "@/components/providers/ThemeProvider";
import { GlobalLoader } from "@/components/ui/global-loader";
export const metadata: Metadata = {
title: "OpenClaw Mission Control",
title: "Pipeline",
description: "A calm command center for every task.",
};
@ -35,18 +36,38 @@ const displayFont = DM_Serif_Display({
weight: ["400"],
});
const themeScript = `
(() => {
try {
const theme = localStorage.getItem("openclaw_theme") === "light" ? "light" : "dark";
const root = document.documentElement;
root.dataset.theme = theme;
root.classList.toggle("dark", theme === "dark");
root.style.colorScheme = theme;
} catch {
document.documentElement.dataset.theme = "dark";
document.documentElement.classList.add("dark");
}
})();
`;
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<html lang="en" data-theme="dark" className="dark" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body
className={`${bodyFont.variable} ${headingFont.variable} ${displayFont.variable} min-h-screen bg-app text-strong antialiased`}
>
<AuthProvider>
<QueryProvider>
<GlobalLoader />
{children}
</QueryProvider>
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<QueryProvider>
<GlobalLoader />
{children}
</QueryProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>
);

View File

@ -6,7 +6,7 @@ export default function Loading() {
>
<div className="flex flex-col items-center gap-3">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-slate-200 border-t-[var(--accent)]" />
<p className="text-sm text-slate-500">Loading mission control...</p>
<p className="text-sm text-slate-500">Loading pipeline...</p>
</div>
</div>
);

View File

@ -118,7 +118,7 @@ export default function OnboardingPage() {
<div className="w-full max-w-2xl rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 px-6 py-5">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
Mission Control profile
Pipeline profile
</h1>
<p className="mt-1 text-sm text-slate-600">
Sign in to configure your profile and timezone.
@ -141,10 +141,10 @@ export default function OnboardingPage() {
<section className="w-full max-w-2xl rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 px-6 py-5">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
Mission Control profile
Pipeline profile
</h1>
<p className="mt-1 text-sm text-slate-600">
Configure your mission control settings and preferences.
Configure your Pipeline settings and preferences.
</p>
</div>
<div className="px-6 py-6">

View File

@ -246,7 +246,7 @@ export default function SettingsPage() {
Delete account
</h2>
<p className="mt-1 text-sm text-rose-800">
This permanently removes your Mission Control account and related
This permanently removes your Pipeline account and related
personal data. This action cannot be undone.
</p>
<div className="mt-4">

View File

@ -1,15 +1,12 @@
import { PipelineIcon } from "./PipelineIcon";
export function BrandMark() {
return (
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-gradient-to-br from-blue-600 to-blue-700 text-xs font-semibold text-white shadow-sm">
<span className="font-heading tracking-[0.2em]">OC</span>
</div>
<PipelineIcon />
<div className="leading-tight">
<div className="font-heading text-sm uppercase tracking-[0.26em] text-strong">
OPENCLAW
</div>
<div className="text-[11px] font-medium text-quiet">
Mission Control
PIPELINE
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
import { Workflow } from "lucide-react";
type PipelineIconProps = {
className?: string;
};
export function PipelineIcon({ className }: PipelineIconProps) {
return (
<div
className={
className ??
"grid h-10 w-10 place-items-center rounded-lg bg-[color:var(--accent)] text-white shadow-sm"
}
aria-hidden="true"
>
<Workflow className="h-5 w-5" strokeWidth={2.4} />
</div>
);
}

View File

@ -3,7 +3,7 @@ import { HeroKicker } from "@/components/atoms/HeroKicker";
export function HeroCopy() {
return (
<div className="space-y-6">
<HeroKicker>OpenClaw Mission Control</HeroKicker>
<HeroKicker>Pipeline</HeroKicker>
<div className="space-y-4">
<h1 className="font-heading text-4xl font-semibold leading-tight text-strong sm:text-5xl lg:text-6xl">
Command autonomous work.

View File

@ -56,39 +56,36 @@ export function DashboardSidebar() {
: systemStatus === "unknown"
? "System status unavailable"
: "System degraded";
const navItemClass = (active: boolean) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-[color:var(--text-muted)] transition",
active
? "bg-[color:var(--accent-soft)] font-medium text-[color:var(--accent-strong)]"
: "hover:bg-[color:var(--surface-muted)] hover:text-[color:var(--text)]",
);
return (
<aside className="fixed inset-y-0 left-0 z-40 flex w-[280px] -translate-x-full flex-col border-r border-slate-200 bg-white pt-16 shadow-lg transition-transform duration-200 ease-in-out [[data-sidebar=open]_&]:translate-x-0 md:relative md:inset-auto md:z-auto md:w-[260px] md:translate-x-0 md:pt-0 md:shadow-none md:transition-none">
<aside className="fixed inset-y-0 left-0 z-40 flex w-[280px] -translate-x-full flex-col border-r border-[color:var(--border)] bg-[color:var(--surface)] pt-16 shadow-lg transition-transform duration-200 ease-in-out [[data-sidebar=open]_&]:translate-x-0 md:relative md:inset-auto md:z-auto md:w-[260px] md:translate-x-0 md:pt-0 md:shadow-none md:transition-none">
<div className="flex-1 px-3 py-4">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]">
Navigation
</p>
<nav className="mt-3 space-y-4 text-sm">
<div>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-quiet)]">
Overview
</p>
<div className="mt-1 space-y-1">
<Link
href="/dashboard"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname === "/dashboard"
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
className={navItemClass(pathname === "/dashboard")}
>
<BarChart3 className="h-4 w-4" />
Dashboard
</Link>
<Link
href="/activity"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/activity")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
className={navItemClass(pathname.startsWith("/activity"))}
>
<Activity className="h-4 w-4" />
Live feed
@ -97,54 +94,34 @@ export function DashboardSidebar() {
</div>
<div>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-quiet)]">
Boards
</p>
<div className="mt-1 space-y-1">
<Link
href="/board-groups"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/board-groups")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
className={navItemClass(pathname.startsWith("/board-groups"))}
>
<Folder className="h-4 w-4" />
Board groups
</Link>
<Link
href="/boards"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/boards")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
className={navItemClass(pathname.startsWith("/boards"))}
>
<LayoutGrid className="h-4 w-4" />
Boards
</Link>
<Link
href="/tags"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/tags")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
className={navItemClass(pathname.startsWith("/tags"))}
>
<Tags className="h-4 w-4" />
Tags
</Link>
<Link
href="/approvals"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/approvals")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
className={navItemClass(pathname.startsWith("/approvals"))}
>
<CheckCircle2 className="h-4 w-4" />
Approvals
@ -152,11 +129,8 @@ export function DashboardSidebar() {
{isAdmin ? (
<Link
href="/custom-fields"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/custom-fields")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
className={navItemClass(
pathname.startsWith("/custom-fields"),
)}
>
<Settings className="h-4 w-4" />
@ -169,18 +143,15 @@ export function DashboardSidebar() {
<div>
{isAdmin ? (
<>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-quiet)]">
Skills
</p>
<div className="mt-1 space-y-1">
<Link
href="/skills/marketplace"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
className={navItemClass(
pathname === "/skills" ||
pathname.startsWith("/skills/marketplace")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
pathname.startsWith("/skills/marketplace"),
)}
>
<Store className="h-4 w-4" />
@ -188,11 +159,8 @@ export function DashboardSidebar() {
</Link>
<Link
href="/skills/packs"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/skills/packs")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
className={navItemClass(
pathname.startsWith("/skills/packs"),
)}
>
<Boxes className="h-4 w-4" />
@ -204,18 +172,13 @@ export function DashboardSidebar() {
</div>
<div>
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-quiet)]">
Administration
</p>
<div className="mt-1 space-y-1">
<Link
href="/organization"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/organization")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
className={navItemClass(pathname.startsWith("/organization"))}
>
<Building2 className="h-4 w-4" />
Organization
@ -223,12 +186,7 @@ export function DashboardSidebar() {
{isAdmin ? (
<Link
href="/gateways"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/gateways")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
className={navItemClass(pathname.startsWith("/gateways"))}
>
<Network className="h-4 w-4" />
Gateways
@ -237,12 +195,7 @@ export function DashboardSidebar() {
{isAdmin ? (
<Link
href="/agents"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/agents")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
className={navItemClass(pathname.startsWith("/agents"))}
>
<Bot className="h-4 w-4" />
Agents
@ -252,14 +205,14 @@ export function DashboardSidebar() {
</div>
</nav>
</div>
<div className="border-t border-slate-200 p-4">
<div className="flex items-center gap-2 text-xs text-slate-500">
<div className="border-t border-[color:var(--border)] p-4">
<div className="flex items-center gap-2 text-xs text-[color:var(--text-muted)]">
<span
className={cn(
"h-2 w-2 rounded-full",
systemStatus === "operational" && "bg-emerald-500",
systemStatus === "degraded" && "bg-rose-500",
systemStatus === "unknown" && "bg-slate-300",
systemStatus === "unknown" && "bg-[color:var(--text-quiet)]",
)}
/>
{statusLabel}

View File

@ -34,7 +34,7 @@ export function LandingHero() {
<>
<section className="hero">
<div className="hero-content">
<div className="hero-label">OpenClaw Mission Control</div>
<div className="hero-label">Pipeline</div>
<h1>
Command <span className="hero-highlight">autonomous work.</span>
<br />

View File

@ -100,7 +100,7 @@ export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) {
Local Authentication
</h1>
<p className="text-sm text-muted">
Enter your access token to unlock Mission Control.
Enter your access token to unlock Pipeline.
</p>
</div>
</CardHeader>

View File

@ -0,0 +1,63 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ThemeProvider } from "@/components/providers/ThemeProvider";
import { ThemeToggle } from "./ThemeToggle";
const storage = new Map<string, string>();
function renderThemeToggle() {
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>,
);
}
describe("ThemeToggle", () => {
beforeEach(() => {
Object.defineProperty(window, "localStorage", {
configurable: true,
value: {
clear: () => storage.clear(),
getItem: (key: string) => storage.get(key) ?? null,
removeItem: (key: string) => storage.delete(key),
setItem: (key: string, value: string) => storage.set(key, value),
},
});
});
afterEach(() => {
storage.clear();
document.documentElement.dataset.theme = "dark";
document.documentElement.classList.add("dark");
});
it("defaults to dark mode", () => {
renderThemeToggle();
expect(
screen.getByRole("button", { name: "Switch to light mode" }),
).toBeInTheDocument();
expect(document.documentElement.dataset.theme).toBe("dark");
expect(document.documentElement).toHaveClass("dark");
});
it("stores the selected light mode", async () => {
const user = userEvent.setup();
renderThemeToggle();
await user.click(
screen.getByRole("button", { name: "Switch to light mode" }),
);
expect(storage.get("openclaw_theme")).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
expect(document.documentElement).not.toHaveClass("dark");
expect(
screen.getByRole("button", { name: "Switch to dark mode" }),
).toBeInTheDocument();
});
});

View File

@ -0,0 +1,37 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "@/components/providers/ThemeProvider";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const isDark = theme === "dark";
const label = isDark ? "Switch to light mode" : "Switch to dark mode";
const Icon = isDark ? Sun : Moon;
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
aria-pressed={isDark}
className="inline-flex h-9 w-9 items-center justify-center rounded-[10px] border border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] shadow-sm transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-muted)] hover:text-[color:var(--text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--surface)]"
onClick={toggleTheme}
>
<Icon className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@ -58,11 +58,11 @@ export function UserMenu({
type="button"
className={cn(
"group inline-flex h-9 items-center gap-2 rounded-[10px] bg-transparent px-1 py-1 transition",
"hover:bg-white/70",
"hover:bg-[color:var(--surface-muted)]",
// Avoid the default browser focus outline (often bright blue) on click.
// Keep a subtle, enterprise-looking focus ring for keyboard navigation.
"focus:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--neutral-300,var(--border-strong))] focus-visible:ring-offset-2 focus-visible:ring-offset-white",
"data-[state=open]:bg-white",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--neutral-300,var(--border-strong))] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--surface)]",
"data-[state=open]:bg-[color:var(--surface-muted)]",
className,
)}
aria-label="Open user menu"
@ -93,7 +93,7 @@ export function UserMenu({
<PopoverContent
align="end"
sideOffset={12}
className="w-80 overflow-hidden rounded-2xl border border-[color:var(--neutral-200,var(--border))] bg-white/95 p-0 shadow-[0_8px_32px_rgba(10,22,40,0.08)] backdrop-blur"
className="w-80 overflow-hidden rounded-2xl border border-[color:var(--neutral-200,var(--border))] bg-[color:var(--surface)]/95 p-0 shadow-[0_8px_32px_rgba(0,0,0,0.18)] backdrop-blur"
>
<div className="border-b border-[color:var(--neutral-200,var(--border))] px-4 py-3">
<div className="flex items-center gap-3">
@ -133,7 +133,7 @@ export function UserMenu({
<div className="grid grid-cols-2 gap-2">
<Link
href="/boards"
className="flex w-full items-center justify-center gap-2 rounded-xl border border-[color:var(--neutral-300,var(--border-strong))] bg-white px-3 py-2 text-sm font-semibold text-[color:var(--neutral-800,var(--text))] transition hover:border-[color:var(--primary-navy,var(--accent-strong))] hover:bg-[color:var(--neutral-100,var(--surface-muted))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-teal,var(--accent))] focus-visible:ring-offset-2"
className="flex w-full items-center justify-center gap-2 rounded-xl border border-[color:var(--neutral-300,var(--border-strong))] bg-[color:var(--surface)] px-3 py-2 text-sm font-semibold text-[color:var(--neutral-800,var(--text))] transition hover:border-[color:var(--primary-navy,var(--accent-strong))] hover:bg-[color:var(--neutral-100,var(--surface-muted))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-teal,var(--accent))] focus-visible:ring-offset-2"
onClick={() => setOpen(false)}
>
<Trello className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))]" />

View File

@ -0,0 +1,115 @@
"use client";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
type Theme = "dark" | "light";
type ThemeContextValue = {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
};
const THEME_STORAGE_KEY = "openclaw_theme";
const DEFAULT_THEME: Theme = "dark";
const ThemeContext = createContext<ThemeContextValue | null>(null);
function isTheme(value: string | null): value is Theme {
return value === "dark" || value === "light";
}
function applyTheme(theme: Theme) {
const root = document.documentElement;
root.dataset.theme = theme;
root.classList.toggle("dark", theme === "dark");
root.style.colorScheme = theme;
}
function getStoredTheme() {
try {
const storage = window.localStorage;
if (typeof storage.getItem !== "function") return null;
return storage.getItem(THEME_STORAGE_KEY);
} catch {
return null;
}
}
function storeTheme(theme: Theme) {
try {
const storage = window.localStorage;
if (typeof storage.setItem === "function") {
storage.setItem(THEME_STORAGE_KEY, theme);
}
} catch {
// The visual preference still applies even when persistence is unavailable.
}
}
function readStoredTheme(): Theme {
if (typeof window === "undefined") return DEFAULT_THEME;
const storedTheme = getStoredTheme();
return isTheme(storedTheme) ? storedTheme : DEFAULT_THEME;
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
useEffect(() => {
applyTheme(theme);
}, [theme]);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== THEME_STORAGE_KEY) return;
const nextTheme = isTheme(event.newValue)
? event.newValue
: DEFAULT_THEME;
setThemeState(nextTheme);
applyTheme(nextTheme);
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const setTheme = useCallback((nextTheme: Theme) => {
setThemeState(nextTheme);
applyTheme(nextTheme);
storeTheme(nextTheme);
}, []);
const toggleTheme = useCallback(() => {
setThemeState((currentTheme) => {
const nextTheme = currentTheme === "dark" ? "light" : "dark";
applyTheme(nextTheme);
storeTheme(nextTheme);
return nextTheme;
});
}, []);
const value = useMemo(
() => ({ theme, setTheme, toggleTheme }),
[setTheme, theme, toggleTheme],
);
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
}

View File

@ -66,11 +66,14 @@ export function DashboardPageLayout({
<DashboardSidebar />
<main
ref={mainRef}
className={cn("flex-1 overflow-y-auto bg-slate-50", mainClassName)}
className={cn(
"flex-1 overflow-y-auto bg-[color:var(--bg)]",
mainClassName,
)}
>
<div
className={cn(
"border-b border-slate-200 bg-white",
"border-b border-[color:var(--border)] bg-[color:var(--surface)]",
stickyHeader && "sticky top-0 z-30",
headerClassName,
)}
@ -79,11 +82,11 @@ export function DashboardPageLayout({
{headerActions ? (
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="font-heading text-2xl font-semibold tracking-tight text-slate-900">
<h1 className="font-heading text-2xl font-semibold tracking-tight text-[color:var(--text)]">
{title}
</h1>
{description ? (
<p className="mt-1 text-sm text-slate-500">
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
{description}
</p>
) : null}
@ -92,11 +95,13 @@ export function DashboardPageLayout({
</div>
) : (
<div>
<h1 className="font-heading text-2xl font-semibold tracking-tight text-slate-900">
<h1 className="font-heading text-2xl font-semibold tracking-tight text-[color:var(--text)]">
{title}
</h1>
{description ? (
<p className="mt-1 text-sm text-slate-500">{description}</p>
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
{description}
</p>
) : null}
</div>
)}

View File

@ -14,6 +14,7 @@ import {
} from "@/api/generated/users/users";
import { BrandMark } from "@/components/atoms/BrandMark";
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
import { ThemeToggle } from "@/components/organisms/ThemeToggle";
import { UserMenu } from "@/components/organisms/UserMenu";
import { isOnboardingComplete } from "@/lib/onboarding";
@ -22,7 +23,10 @@ export function DashboardShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { isSignedIn } = useAuth();
const isOnboardingPath = pathname === "/onboarding";
const [sidebarState, setSidebarState] = useState({ open: false, path: pathname });
const [sidebarState, setSidebarState] = useState({
open: false,
path: pathname,
});
// Close sidebar on navigation using React's "store info from previous
// renders" pattern — conditional setState during render resets immediately
// without extra commits, avoiding both set-state-in-effect and refs rules.
@ -86,25 +90,33 @@ export function DashboardShell({ children }: { children: ReactNode }) {
useEffect(() => {
if (!sidebarOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setSidebarState((prev) => ({ ...prev, open: false }));
if (e.key === "Escape")
setSidebarState((prev) => ({ ...prev, open: false }));
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [sidebarOpen]);
return (
<div className="min-h-screen bg-app text-strong" data-sidebar={sidebarOpen ? "open" : "closed"}>
<header className="sticky top-0 z-50 border-b border-slate-200 bg-white shadow-sm">
<div
className="min-h-screen bg-app text-strong"
data-sidebar={sidebarOpen ? "open" : "closed"}
>
<header className="sticky top-0 z-50 border-b border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm">
<div className="flex items-center py-3">
<div className="flex items-center px-4 md:px-6 md:w-[260px]">
{isSignedIn ? (
<button
type="button"
className="mr-3 rounded-lg p-2 text-slate-600 hover:bg-slate-100 md:hidden"
className="mr-3 rounded-lg p-2 text-[color:var(--text-muted)] hover:bg-[color:var(--surface-muted)] md:hidden"
onClick={toggleSidebar}
aria-label="Toggle navigation"
>
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
{sidebarOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</button>
) : null}
<BrandMark />
@ -119,11 +131,14 @@ export function DashboardShell({ children }: { children: ReactNode }) {
<SignedIn>
<div className="ml-auto flex items-center gap-3 px-4 md:px-6">
<div className="hidden text-right lg:block">
<p className="text-sm font-semibold text-slate-900">
<p className="text-sm font-semibold text-[color:var(--text)]">
{displayName}
</p>
<p className="text-xs text-slate-500">Operator</p>
<p className="text-xs text-[color:var(--text-muted)]">
Operator
</p>
</div>
<ThemeToggle />
<UserMenu displayName={displayName} displayEmail={displayEmail} />
</div>
</SignedIn>
@ -140,7 +155,7 @@ export function DashboardShell({ children }: { children: ReactNode }) {
/>
) : null}
<div className="grid min-h-[calc(100vh-64px)] grid-cols-1 md:grid-cols-[260px_1fr] bg-slate-50">
<div className="grid min-h-[calc(100vh-64px)] grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[260px_1fr]">
{children}
</div>
</div>

View File

@ -10,6 +10,7 @@ import {
isClerkEnabled,
} from "@/auth/clerk";
import { PipelineIcon } from "@/components/atoms/PipelineIcon";
import { UserMenu } from "@/components/organisms/UserMenu";
export function LandingShell({ children }: { children: ReactNode }) {
@ -19,13 +20,10 @@ export function LandingShell({ children }: { children: ReactNode }) {
<div className="landing-enterprise">
<nav className="landing-nav" aria-label="Primary navigation">
<div className="nav-container">
<Link href="/" className="logo-section" aria-label="OpenClaw home">
<div className="logo-icon" aria-hidden="true">
OC
</div>
<Link href="/" className="logo-section" aria-label="Pipeline home">
<PipelineIcon className="logo-icon" />
<div className="logo-text">
<div className="logo-name">OpenClaw</div>
<div className="logo-tagline">Mission Control</div>
<div className="logo-name">Pipeline</div>
</div>
</Link>
@ -89,7 +87,7 @@ export function LandingShell({ children }: { children: ReactNode }) {
<footer className="landing-footer">
<div className="footer-content">
<div className="footer-brand">
<h3>OpenClaw</h3>
<h3>Pipeline</h3>
<p>A calm command center for boards, agents, and approvals.</p>
<div className="footer-tagline">Realtime Execution Visibility</div>
</div>
@ -150,7 +148,7 @@ export function LandingShell({ children }: { children: ReactNode }) {
<div className="footer-bottom">
<div className="footer-copyright">
© {new Date().getFullYear()} OpenClaw. All rights reserved.
© {new Date().getFullYear()} Pipeline. All rights reserved.
</div>
<div className="footer-bottom-links">
<Link href="#capabilities">Capabilities</Link>