diff --git a/apps/web/app/app-theme.css b/apps/web/app/app-theme.css
index db8534f03..aa0e30b77 100644
--- a/apps/web/app/app-theme.css
+++ b/apps/web/app/app-theme.css
@@ -9,22 +9,27 @@
* Palette matches the public landing page: paper cream, deep navy ink, and a
* vibrant blue accent, with teal / terracotta / violet for agent identities.
* Legacy token names (`--codev-gold-*`, `--codev-black-*`) are kept as slots
- * so existing rules did not all need renaming.
+ * so existing rules did not all need renaming -- but the actual color values
+ * live in theme-tokens.css now, not here. Every `--codev-*` name below is a
+ * thin alias onto a `--brand-*` raw swatch, so this file never needs its own
+ * light/dark switch: it inherits whichever swatch theme-tokens.css is
+ * currently serving. See that file for the dark-mode ("Midnight Blueprint")
+ * values and rationale.
*/
:root {
- --codev-black-950: #f2e9d6;
- --codev-black-900: #fffdf7;
- --codev-black-800: #e8dcc0;
- --codev-black-700: #ddd0b0;
- --codev-beige-100: #0e2f7e;
- --codev-beige-200: #3d5a9e;
- --codev-stone-300: #5a73a8;
- --codev-stone-500: #5b4fa8;
- --codev-gold-500: #1b63b3;
- --codev-gold-400: #2a7fd4;
- --codev-on-gold: #f7f3e8;
- --codev-terracotta: #9e4a2a;
- --codev-teal: #2a6d62;
+ --codev-black-950: var(--brand-paper);
+ --codev-black-900: var(--brand-paper-bright);
+ --codev-black-800: var(--brand-paper-tan);
+ --codev-black-700: var(--brand-paper-deep);
+ --codev-beige-100: var(--brand-ink);
+ --codev-beige-200: var(--brand-ink-2);
+ --codev-stone-300: var(--brand-ink-muted);
+ --codev-stone-500: var(--brand-violet);
+ --codev-gold-500: var(--brand-accent);
+ --codev-gold-400: var(--brand-accent-bright);
+ --codev-on-gold: var(--brand-on-accent);
+ --codev-terracotta: var(--brand-orange);
+ --codev-teal: var(--brand-teal);
/* Compatibility aliases for older token names. */
--codev-forest-950: var(--codev-black-950);
@@ -35,24 +40,32 @@
--codev-sage-500: var(--codev-stone-500);
--codev-orange-500: var(--codev-terracotta);
--codev-orange-400: var(--codev-terracotta);
+
+ /* Local names for the shared RGB triplets, kept so the many rgba() call
+ sites elsewhere in this file and in globals.css / mission-control.css /
+ team-chat.css don't need renaming. Values come from theme-tokens.css. */
+ --paper-rgb: var(--brand-paper-rgb);
+ --ink-rgb: var(--brand-ink-rgb);
+ --accent-rgb: var(--brand-accent-rgb);
+ --teal-rgb: var(--brand-teal-rgb);
}
.app-page,
.auth-page {
--ink: var(--codev-beige-100);
--muted: var(--codev-stone-300);
- --muted-2: #7a8bb0;
- --line: rgba(14, 47, 126, 0.16);
- --line-strong: rgba(14, 47, 126, 0.28);
+ --muted-2: var(--brand-ink-faint);
+ --line: rgba(var(--ink-rgb), 0.16);
+ --line-strong: rgba(var(--ink-rgb), 0.28);
--surface: var(--codev-black-950);
--surface-2: var(--codev-black-900);
--surface-3: var(--codev-black-800);
--navy: var(--codev-beige-100);
--gold: var(--codev-gold-500);
--gold-bright: var(--codev-gold-400);
- --gold-soft: rgba(27, 99, 179, 0.12);
+ --gold-soft: rgba(var(--accent-rgb), 0.12);
--teal: var(--codev-teal);
- --teal-soft: rgba(42, 109, 98, 0.12);
+ --teal-soft: rgba(var(--teal-rgb), 0.12);
--orange: var(--codev-terracotta);
--violet: var(--codev-stone-500);
min-height: 100dvh;
@@ -64,24 +77,24 @@
),
radial-gradient(
circle at 82% 0,
- rgba(27, 99, 179, 0.12),
+ rgba(var(--accent-rgb), 0.12),
transparent 28rem
),
radial-gradient(
circle at 8% 15%,
- rgba(42, 109, 98, 0.08),
+ rgba(var(--teal-rgb), 0.08),
transparent 32rem
),
var(--surface);
color: var(--ink);
- color-scheme: light;
+ color-scheme: var(--brand-color-scheme, light);
font-family: var(--font-geist-sans), Arial, sans-serif;
font-feature-settings: "ss01" on;
}
.app-page .app-nav {
border-color: var(--line);
- background: rgba(255, 253, 247, 0.78);
+ background: rgba(var(--paper-rgb), 0.78);
color: var(--ink);
backdrop-filter: blur(14px) saturate(160%);
}
@@ -93,8 +106,7 @@
}
.app-page .brand-image {
- filter: brightness(0) saturate(100%) invert(16%) sepia(80%) saturate(2200%)
- hue-rotate(210deg) brightness(0.95) contrast(1.05);
+ filter: var(--brand-logo-filter);
}
.auth-page {
@@ -108,12 +120,12 @@
),
radial-gradient(
circle at 78% 10%,
- rgba(27, 99, 179, 0.14),
+ rgba(var(--accent-rgb), 0.14),
transparent 26rem
),
radial-gradient(
circle at 12% 42%,
- rgba(42, 109, 98, 0.1),
+ rgba(var(--teal-rgb), 0.1),
transparent 30rem
),
var(--surface);
@@ -124,8 +136,8 @@
inset: 0;
background-image: repeating-linear-gradient(
115deg,
- rgba(14, 47, 126, 0.035) 0,
- rgba(14, 47, 126, 0.035) 1px,
+ rgba(var(--ink-rgb), 0.035) 0,
+ rgba(var(--ink-rgb), 0.035) 1px,
transparent 1px,
transparent 7px
);
@@ -137,7 +149,7 @@
position: relative;
z-index: 1;
border-color: var(--line);
- background: rgba(255, 253, 247, 0.78);
+ background: rgba(var(--paper-rgb), 0.78);
color: var(--ink);
backdrop-filter: blur(14px) saturate(160%);
}
@@ -148,8 +160,7 @@
}
.auth-page .brand-image {
- filter: brightness(0) saturate(100%) invert(16%) sepia(80%) saturate(2200%)
- hue-rotate(210deg) brightness(0.95) contrast(1.05);
+ filter: var(--brand-logo-filter);
}
.auth-page .auth-card {
@@ -158,8 +169,8 @@
border-color: var(--line-strong);
background: linear-gradient(
145deg,
- rgba(255, 253, 247, 0.94),
- rgba(255, 253, 247, 0.82)
+ rgba(var(--paper-rgb), 0.94),
+ rgba(var(--paper-rgb), 0.82)
);
box-shadow: 0 28px 90px rgba(14, 47, 126, 0.12);
}
@@ -299,6 +310,53 @@
box-shadow: 0 18px 50px rgba(14, 47, 126, 0.14);
}
+/* Theme toggle: a standalone icon button next to the profile menu, not a
+ menu item -- it should be visible without opening anything. Sized and
+ shaped like .profile-menu-trigger so the two sit together as one group. */
+.theme-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ border: 1px solid transparent;
+ border-radius: 999px;
+ background: none;
+ color: var(--muted);
+ cursor: pointer;
+}
+
+.theme-toggle:hover {
+ border-color: var(--line-strong);
+ background: var(--surface-3);
+ color: var(--ink);
+}
+
+.theme-toggle:focus-visible {
+ outline: 2px solid var(--gold);
+ outline-offset: 1px;
+}
+
+.app-sidebar-footer .theme-toggle {
+ width: 100%;
+ height: auto;
+ padding: 8px 6px;
+ border-radius: 8px;
+ justify-content: flex-start;
+ gap: 10px;
+}
+
+.app-sidebar-footer .theme-toggle::after {
+ content: "Theme";
+ font-size: 13px;
+}
+
+.auth-nav-actions {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
.app-page .profile-menu-heading strong,
.app-page .profile-menu-link:hover,
.app-page .profile-menu-action:hover {
@@ -335,8 +393,8 @@
z-index: 0;
background-image: repeating-linear-gradient(
0deg,
- rgba(14, 47, 126, 0.04) 0,
- rgba(14, 47, 126, 0.04) 1px,
+ rgba(var(--ink-rgb), 0.04) 0,
+ rgba(var(--ink-rgb), 0.04) 1px,
transparent 1px,
transparent 14px
);
@@ -358,7 +416,7 @@
top: 0;
padding: 20px 12px;
border-right: 1px solid var(--line);
- background: rgba(255, 253, 247, 0.82);
+ background: rgba(var(--paper-rgb), 0.82);
backdrop-filter: blur(14px) saturate(160%);
overflow-y: auto;
}
@@ -390,12 +448,12 @@
}
.app-sidebar-link:hover {
- background: rgba(14, 47, 126, 0.06);
+ background: rgba(var(--ink-rgb), 0.06);
color: var(--ink);
}
.app-sidebar-link.is-active {
- background: rgba(27, 99, 179, 0.12);
+ background: rgba(var(--accent-rgb), 0.12);
color: var(--codev-gold-500);
}
@@ -426,7 +484,7 @@
}
.app-sidebar-footer .profile-menu-trigger:hover {
- background: rgba(14, 47, 126, 0.06);
+ background: rgba(var(--ink-rgb), 0.06);
color: var(--ink);
}
@@ -460,11 +518,11 @@
overflow: hidden;
border-color: var(--line);
background:
- linear-gradient(118deg, rgba(27, 99, 179, 0.1), transparent 38%),
+ linear-gradient(118deg, rgba(var(--accent-rgb), 0.1), transparent 38%),
linear-gradient(
145deg,
- rgba(255, 253, 247, 0.94),
- rgba(255, 253, 247, 0.82)
+ rgba(var(--paper-rgb), 0.94),
+ rgba(var(--paper-rgb), 0.82)
);
box-shadow: 0 20px 48px rgba(14, 47, 126, 0.1);
}
@@ -478,8 +536,8 @@
border-radius: 50%;
background: radial-gradient(
circle,
- rgba(42, 127, 212, 0.22) 0%,
- rgba(27, 99, 179, 0.1) 30%,
+ rgba(var(--accent-rgb), 0.22) 0%,
+ rgba(var(--accent-rgb), 0.1) 30%,
transparent 70%
);
content: "";
@@ -500,8 +558,8 @@
}
.home-badge {
- border-color: rgba(27, 99, 179, 0.32);
- background: rgba(27, 99, 179, 0.12);
+ border-color: rgba(var(--accent-rgb), 0.32);
+ background: rgba(var(--accent-rgb), 0.12);
color: var(--codev-gold-500);
}
@@ -524,7 +582,7 @@
.stat-card {
min-width: 145px;
border-color: var(--line);
- background: rgba(255, 253, 247, 0.72);
+ background: rgba(var(--paper-rgb), 0.72);
}
.stat-value-active {
@@ -535,7 +593,7 @@
padding: clamp(20px, 3vw, 32px);
border: 1px solid var(--line);
border-radius: 18px;
- background: rgba(255, 253, 247, 0.72);
+ background: rgba(var(--paper-rgb), 0.72);
box-shadow: 0 18px 44px rgba(14, 47, 126, 0.08);
}
@@ -577,9 +635,9 @@
}
.new-workspace-plus {
- border-color: rgba(27, 99, 179, 0.4);
+ border-color: rgba(var(--accent-rgb), 0.4);
background:
- linear-gradient(135deg, rgba(27, 99, 179, 0.12), transparent 60%),
+ linear-gradient(135deg, rgba(var(--accent-rgb), 0.12), transparent 60%),
var(--surface-3);
color: var(--codev-gold-500);
}
@@ -588,7 +646,11 @@
min-height: 108px;
padding: 18px 16px;
border-bottom: 1px solid var(--line);
- background: linear-gradient(135deg, rgba(27, 99, 179, 0.1), transparent 66%);
+ background: linear-gradient(
+ 135deg,
+ rgba(var(--accent-rgb), 0.1),
+ transparent 66%
+ );
}
.workspace-card-presence-label {
@@ -709,7 +771,7 @@
.workspace-card-access {
border-color: var(--line);
- background: rgba(27, 99, 179, 0.1);
+ background: rgba(var(--accent-rgb), 0.1);
}
.workspace-create-overlay,
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 9f631c8d8..a663414bd 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -25,6 +25,54 @@
color-scheme: light;
}
+/* Dark-mode fallback for the semantic tokens above -- app-theme.css (loaded
+ after this file) re-declares the same tokens on `.app-page`/`.auth-page`
+ with a matching dark override and always wins there, so this block only
+ matters for the rare consumer of these tokens outside those two classes.
+ Kept in lockstep with app-theme.css's dark values; --workspace-* stays
+ untouched since the embedded IDE surface is intentionally dark already. */
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --ink: #f2e9d6;
+ --muted: #96918a;
+ --muted-2: #726d64;
+ --line: rgba(242, 233, 214, 0.14);
+ --line-strong: rgba(242, 233, 214, 0.24);
+ --surface: #070c1a;
+ --surface-2: #0b1226;
+ --surface-3: #101a35;
+ --navy: #f2e9d6;
+ --gold: #3d8fe0;
+ --gold-bright: #5aa6ec;
+ --gold-soft: rgba(61, 143, 224, 0.17);
+ --teal: #3fae9c;
+ --teal-soft: rgba(63, 174, 156, 0.16);
+ --orange: #e08a54;
+ --violet: #a08cf0;
+ color-scheme: dark;
+ }
+}
+
+:root[data-theme="dark"] {
+ --ink: #f2e9d6;
+ --muted: #96918a;
+ --muted-2: #726d64;
+ --line: rgba(242, 233, 214, 0.14);
+ --line-strong: rgba(242, 233, 214, 0.24);
+ --surface: #070c1a;
+ --surface-2: #0b1226;
+ --surface-3: #101a35;
+ --navy: #f2e9d6;
+ --gold: #3d8fe0;
+ --gold-bright: #5aa6ec;
+ --gold-soft: rgba(61, 143, 224, 0.17);
+ --teal: #3fae9c;
+ --teal-soft: rgba(63, 174, 156, 0.16);
+ --orange: #e08a54;
+ --violet: #a08cf0;
+ color-scheme: dark;
+}
+
/* Shared product theme. App shell is light cream/navy; workspace IDE stays dark. */
.app-page,
@@ -727,8 +775,8 @@
.workspace-chat-bubble-error {
align-self: flex-start;
- border: 1px solid rgba(27, 99, 179, 0.35);
- background: rgba(27, 99, 179, 0.08);
+ border: 1px solid rgba(var(--accent-rgb), 0.35);
+ background: rgba(var(--accent-rgb), 0.08);
color: var(--workspace-ink);
}
@@ -835,7 +883,7 @@
background:
radial-gradient(
circle at 82% 0,
- rgba(27, 99, 179, 0.15),
+ rgba(var(--accent-rgb), 0.15),
transparent 25rem
),
var(--surface);
@@ -879,7 +927,7 @@
}
.auth-card::before {
- border-color: rgba(27, 99, 179, 0.18);
+ border-color: rgba(var(--accent-rgb), 0.18);
}
.auth-card > p:not(.eyebrow) {
@@ -895,8 +943,8 @@
.setup-panel,
.inline-alert {
- border-color: rgba(27, 99, 179, 0.24);
- background: rgba(27, 99, 179, 0.08);
+ border-color: rgba(var(--accent-rgb), 0.24);
+ background: rgba(var(--accent-rgb), 0.08);
}
.setup-panel p {
@@ -945,7 +993,7 @@ select,
}
.workspace-card:hover {
- border-color: rgba(27, 99, 179, 0.42);
+ border-color: rgba(var(--accent-rgb), 0.42);
background: var(--surface-2);
}
@@ -1586,14 +1634,14 @@ select,
}
html {
- background: #f2e9d6;
+ background: var(--surface);
scroll-behavior: smooth;
}
body {
min-width: 320px;
margin: 0;
- background: #f2e9d6;
+ background: var(--surface);
color: var(--ink);
font-family: var(--font-geist-sans), Arial, sans-serif;
text-rendering: optimizeLegibility;
@@ -1626,7 +1674,7 @@ button {
}
::selection {
- background: rgba(27, 99, 179, 0.24);
+ background: rgba(var(--accent-rgb), 0.24);
}
/* Authenticated product surfaces */
@@ -1637,7 +1685,7 @@ button {
background:
radial-gradient(
circle at 76% 0,
- rgba(27, 99, 179, 0.07),
+ rgba(var(--accent-rgb), 0.07),
transparent 28rem
),
var(--surface);
@@ -1861,7 +1909,7 @@ button {
border: 1px solid var(--gold);
border-radius: 999px;
background: var(--gold);
- color: #f7f3e8;
+ color: var(--codev-on-gold);
font-weight: 700;
text-decoration: none;
cursor: pointer;
@@ -1940,9 +1988,9 @@ button {
.inline-alert {
margin-top: 26px;
padding: 16px;
- border: 1px solid rgba(27, 99, 179, 0.22);
+ border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: 8px;
- background: rgba(27, 99, 179, 0.07);
+ background: rgba(var(--accent-rgb), 0.07);
}
.setup-panel p {
@@ -1970,7 +2018,7 @@ button {
height: 7px;
border-radius: 50%;
background: var(--teal);
- box-shadow: 0 0 9px rgba(27, 99, 179, 0.55);
+ box-shadow: 0 0 9px rgba(var(--accent-rgb), 0.55);
}
.dot-muted {
@@ -2021,7 +2069,7 @@ button {
}
.secondary-button:hover {
- border-color: rgba(27, 99, 179, 0.35);
+ border-color: rgba(var(--accent-rgb), 0.35);
color: var(--ink);
}
@@ -2454,7 +2502,7 @@ button:disabled {
}
.workspace-card:hover {
- border-color: rgba(27, 99, 179, 0.28);
+ border-color: rgba(var(--accent-rgb), 0.28);
background: var(--surface-2);
}
@@ -2522,7 +2570,7 @@ button:disabled {
padding: 17px;
border: 1px solid var(--line);
border-radius: 8px;
- background: rgba(27, 99, 179, 0.035);
+ background: rgba(var(--accent-rgb), 0.035);
}
.security-callout strong {
@@ -2548,7 +2596,7 @@ button:disabled {
padding: 10px;
align-items: center;
gap: 12px;
- border: 1px solid rgba(27, 99, 179, 0.18);
+ border: 1px solid rgba(var(--accent-rgb), 0.18);
border-radius: 7px;
background: var(--teal-soft);
}
@@ -2772,7 +2820,7 @@ button:disabled {
border: 1px solid var(--gold);
border-radius: 999px;
background: var(--gold);
- color: #f7f3e8;
+ color: var(--codev-on-gold);
font-size: 13px;
font-weight: 670;
transition:
@@ -2782,7 +2830,7 @@ button:disabled {
.primary-button:hover {
transform: translateY(-2px);
- box-shadow: 0 10px 30px rgba(27, 99, 179, 0.22);
+ box-shadow: 0 10px 30px rgba(var(--accent-rgb), 0.22);
}
.hero-note {
@@ -3918,9 +3966,9 @@ footer > span {
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.05em;
- background: var(--gold-soft, rgba(27, 99, 179, 0.12));
+ background: var(--gold-soft, rgba(var(--accent-rgb), 0.12));
color: var(--gold, #1b63b3);
- border: 1px solid rgba(27, 99, 179, 0.28);
+ border: 1px solid rgba(var(--accent-rgb), 0.28);
margin-bottom: 10px;
}
@@ -3956,8 +4004,8 @@ footer > span {
gap: 4px;
padding: 12px 18px;
border-radius: 12px;
- background: rgba(255, 253, 247, 0.72);
- border: 1px solid rgba(14, 47, 126, 0.16);
+ background: rgba(var(--paper-rgb), 0.72);
+ border: 1px solid rgba(var(--ink-rgb), 0.16);
min-width: 130px;
}
@@ -3983,9 +4031,9 @@ footer > span {
border-radius: 999px;
font-size: 13.5px;
font-weight: 650;
- color: #f7f3e8;
+ color: var(--codev-on-gold);
background: var(--gold, #1b63b3);
- box-shadow: 0 4px 12px rgba(27, 99, 179, 0.22);
+ box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.22);
transition:
transform 0.15s ease,
background-color 0.15s ease;
@@ -3994,8 +4042,8 @@ footer > span {
.resume-primary-button:hover {
transform: translateY(-1px);
- background: #2a7fd4;
- color: #f7f3e8;
+ background: var(--gold-bright);
+ color: var(--codev-on-gold);
}
.home-section-header {
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index e0b929145..6f811af41 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -3,11 +3,13 @@ import type { Metadata } from "next";
import { Inter_Tight, JetBrains_Mono } from "next/font/google";
import { Analytics } from "@vercel/analytics/next";
+import "./theme-tokens.css";
import "./globals.css";
import "./app-theme.css";
import "./team-chat.css";
import { VisitTracker } from "@/components/landing/visit-tracker";
+import { THEME_INIT_SCRIPT } from "@/components/shell/theme-toggle";
const sans = Inter_Tight({
variable: "--font-geist-sans",
@@ -54,6 +56,13 @@ export default function RootLayout({
}>) {
return (
+
+ {/* Stamps an explicit stored theme choice before first paint, so a
+ returning dark-mode visitor never sees a light flash. Nothing to
+ do when no choice is stored -- the CSS `prefers-color-scheme`
+ query already handles that case without JS. */}
+
+
{children}
diff --git a/apps/web/app/mission-control.css b/apps/web/app/mission-control.css
index 4d5e7126f..acb085087 100644
--- a/apps/web/app/mission-control.css
+++ b/apps/web/app/mission-control.css
@@ -6,14 +6,14 @@
*/
.mc-root {
- --mc-line: rgba(14, 47, 126, 0.14);
- --mc-line-lit: rgba(27, 99, 179, 0.34);
- --mc-panel: rgba(255, 253, 247, 0.72);
- --mc-panel-lit: rgba(255, 253, 247, 0.92);
+ --mc-line: rgba(var(--ink-rgb, 14, 47, 126), 0.14);
+ --mc-line-lit: rgba(var(--accent-rgb, 27, 99, 179), 0.34);
+ --mc-panel: rgba(var(--paper-rgb, 255, 253, 247), 0.72);
+ --mc-panel-lit: rgba(var(--paper-rgb, 255, 253, 247), 0.92);
--mc-ink: var(--codev-beige-100, #0e2f7e);
--mc-muted: var(--codev-stone-300, #5a73a8);
--mc-orange: var(--codev-gold-500, #1b63b3);
- --mc-green: #2a6d62;
+ --mc-green: var(--codev-teal, #2a6d62);
--mc-amber: #86590b;
min-height: 100vh;
@@ -22,7 +22,7 @@
background:
radial-gradient(
1100px 620px at 12% -8%,
- rgba(27, 99, 179, 0.12),
+ rgba(var(--accent-rgb), 0.12),
transparent 62%
),
radial-gradient(
@@ -63,16 +63,16 @@
height: 7px;
border-radius: 50%;
background: var(--mc-orange);
- box-shadow: 0 0 0 0 rgba(27, 99, 179, 0.6);
+ box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.6);
animation: mc-pulse 2s infinite;
}
@keyframes mc-pulse {
70% {
- box-shadow: 0 0 0 9px rgba(27, 99, 179, 0);
+ box-shadow: 0 0 0 9px rgba(var(--accent-rgb), 0);
}
100% {
- box-shadow: 0 0 0 0 rgba(27, 99, 179, 0);
+ box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0);
}
}
@@ -142,7 +142,7 @@
font-weight: 700;
letter-spacing: 0.02em;
color: #fff;
- border: 1.5px solid rgba(255, 253, 247, 0.9);
+ border: 1.5px solid rgba(var(--paper-rgb), 0.9);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
@@ -255,8 +255,8 @@
.mc-phase-editing,
.mc-phase-planning {
color: var(--mc-orange);
- background: rgba(27, 99, 179, 0.13);
- border-color: rgba(27, 99, 179, 0.3);
+ background: rgba(var(--accent-rgb), 0.13);
+ border-color: rgba(var(--accent-rgb), 0.3);
}
.mc-phase-editing .mc-phase-dot,
@@ -305,7 +305,7 @@
margin: 0;
padding: 10px 11px;
border-radius: 10px;
- background: rgba(255, 253, 247, 0.72);
+ background: rgba(var(--paper-rgb), 0.72);
border: 1px solid var(--mc-line);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
@@ -375,7 +375,7 @@
height: 3px;
flex: 1;
border-radius: 2px;
- background: rgba(14, 47, 126, 0.12);
+ background: rgba(var(--ink-rgb), 0.12);
}
.mc-progress-seg.is-done {
@@ -450,7 +450,7 @@
.mc-provider-claude {
color: #e8a882;
- background: rgba(27, 99, 179, 0.16);
+ background: rgba(var(--accent-rgb), 0.16);
}
.mc-provider-codex {
@@ -506,7 +506,7 @@
padding: 7px 15px;
border-radius: 8px;
border: 1px solid var(--mc-line-lit);
- background: rgba(27, 99, 179, 0.14);
+ background: rgba(var(--accent-rgb), 0.14);
color: #f0c3a4;
font-size: 12.5px;
font-weight: 650;
@@ -518,7 +518,7 @@
.mc-step-in:hover {
background: var(--mc-orange);
- color: #f7f3e8;
+ color: var(--codev-on-gold);
}
/* ---------- drawer ---------- */
@@ -526,6 +526,8 @@
.mc-scrim {
position: fixed;
inset: 0;
+ /* A dimming scrim, not an ink wash -- stays dark in both themes rather
+ than flipping to a light glow when --ink-rgb becomes cream. */
background: rgba(14, 47, 126, 0.45);
backdrop-filter: blur(2px);
z-index: 40;
@@ -657,7 +659,7 @@
width: 9px;
height: 9px;
border-radius: 50%;
- border: 1.5px solid rgba(14, 47, 126, 0.24);
+ border: 1.5px solid rgba(var(--ink-rgb), 0.24);
flex: none;
}
@@ -697,7 +699,7 @@
gap: 12px;
padding: 8px 11px;
border-radius: 9px;
- background: rgba(255, 253, 247, 0.7);
+ background: rgba(var(--paper-rgb), 0.7);
border: 1px solid var(--mc-line);
font-size: 12px;
}
@@ -747,7 +749,7 @@
gap: 7px;
padding: 12px 12px 14px;
border-radius: 11px;
- background: rgba(14, 47, 126, 0.45);
+ background: rgba(var(--ink-rgb), 0.45);
scroll-behavior: smooth;
border: 1px solid var(--mc-line);
scrollbar-width: thin;
@@ -789,7 +791,7 @@
.mc-log-line.is-steer .mc-log-text {
padding: 3px 8px;
border-radius: 6px;
- background: rgba(27, 99, 179, 0.15);
+ background: rgba(var(--accent-rgb), 0.15);
}
.mc-log-line.is-warn {
@@ -849,7 +851,7 @@
padding: 11px 13px;
border-radius: 10px;
border: 1px solid var(--mc-line-lit);
- background: rgba(255, 253, 247, 0.88);
+ background: rgba(var(--paper-rgb), 0.88);
color: var(--mc-ink);
font-size: 13.5px;
outline: none;
@@ -861,7 +863,7 @@
.mc-steer-input:focus {
border-color: var(--mc-orange);
- box-shadow: 0 0 0 3px rgba(27, 99, 179, 0.16);
+ box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.16);
}
.mc-steer-send,
@@ -877,7 +879,7 @@
.mc-steer-send {
background: var(--mc-orange);
- color: #f7f3e8;
+ color: var(--codev-on-gold);
}
.mc-steer-send:disabled {
diff --git a/apps/web/app/product-theme.css b/apps/web/app/product-theme.css
index ccf7061e8..2f59d2858 100644
--- a/apps/web/app/product-theme.css
+++ b/apps/web/app/product-theme.css
@@ -2,8 +2,18 @@
* Shared Tailwind theme for product surfaces that use shadcn primitives
* (Rooms, dashboard, and anything else that opts in). Scoped under
* `.product-scope` / `.rooms-scope` so utilities cannot leak into the plain
- * CSS shell, IDE workspace, or landing page. Palette matches landing.css /
- * app-theme.css: paper cream, deep navy ink, vibrant blue accent.
+ * CSS shell, IDE workspace, or landing page.
+ *
+ * Every --color-* value below is a var() reference into theme-tokens.css's
+ * shared --brand-* swatches rather than its own literal -- that file is now
+ * the only place an actual hex/rgba value for this palette is written, and
+ * dark mode Just Works here because the swatches it references already flip
+ * (no separate dark-mode block needed in this file at all). Translucent
+ * card/secondary/accent/muted values are this file's own design choice --
+ * Rooms and the dashboard have an animated gradient backdrop that glass
+ * panels should let show through, unlike Settings' opaque cards in
+ * settings/orca-theme.css -- so they stay expressed as alpha washes over the
+ * shared paper/ink swatches rather than forced to match Settings exactly.
*/
@layer reset, theme, utilities;
@@ -20,35 +30,35 @@
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.35);
- --color-background: #f2e9d6;
- --color-background-2: #e8dcc0;
- --color-foreground: #0e2f7e;
- --color-card: rgba(255, 253, 247, 0.62);
- --color-card-foreground: #0e2f7e;
- --color-popover: rgba(255, 253, 247, 0.94);
- --color-popover-foreground: #0e2f7e;
- --color-primary: #1b63b3;
- --color-primary-foreground: #f7f3e8;
- --color-secondary: rgba(255, 253, 247, 0.9);
- --color-secondary-foreground: #0e2f7e;
- --color-muted: rgba(14, 47, 126, 0.08);
- --color-muted-foreground: rgba(14, 47, 126, 0.65);
- --color-accent: rgba(255, 253, 247, 0.9);
- --color-accent-foreground: #0e2f7e;
- --color-destructive: #b3261e;
- --color-border: rgba(14, 47, 126, 0.16);
- --color-input: rgba(14, 47, 126, 0.28);
- --color-ring: #1b63b3;
+ --color-background: var(--brand-paper);
+ --color-background-2: var(--brand-paper-tan);
+ --color-foreground: var(--brand-ink);
+ --color-card: rgba(var(--brand-paper-rgb), 0.62);
+ --color-card-foreground: var(--brand-ink);
+ --color-popover: rgba(var(--brand-paper-rgb), 0.94);
+ --color-popover-foreground: var(--brand-ink);
+ --color-primary: var(--brand-accent);
+ --color-primary-foreground: var(--brand-on-accent);
+ --color-secondary: rgba(var(--brand-paper-rgb), 0.9);
+ --color-secondary-foreground: var(--brand-ink);
+ --color-muted: rgba(var(--brand-ink-rgb), 0.08);
+ --color-muted-foreground: rgba(var(--brand-ink-rgb), 0.65);
+ --color-accent: rgba(var(--brand-paper-rgb), 0.9);
+ --color-accent-foreground: var(--brand-ink);
+ --color-destructive: var(--brand-destructive);
+ --color-border: rgba(var(--brand-ink-rgb), 0.16);
+ --color-input: rgba(var(--brand-ink-rgb), 0.28);
+ --color-ring: var(--brand-accent);
- --color-teal: #2a6d62;
- --color-orange: #9e4a2a;
- --color-violet: #5b4fa8;
+ --color-teal: var(--brand-teal);
+ --color-orange: var(--brand-orange);
+ --color-violet: var(--brand-violet);
}
@layer reset {
.product-scope,
.rooms-scope {
- color-scheme: light;
+ color-scheme: var(--brand-color-scheme);
background:
radial-gradient(
120% 60% at 30% -10%,
@@ -59,7 +69,6 @@
color: var(--color-foreground);
font-family: var(--font-sans);
}
-
.product-scope ul,
.product-scope ol,
.rooms-scope ul,
diff --git a/apps/web/app/settings/orca-theme.css b/apps/web/app/settings/orca-theme.css
index ea42776b0..5d58de449 100644
--- a/apps/web/app/settings/orca-theme.css
+++ b/apps/web/app/settings/orca-theme.css
@@ -6,8 +6,17 @@
* outside a route that opts in by importing this file โ the rest of the app
* stays on its own plain CSS. A component that uses these utilities must
* render under one of those routes; the activity feed spent a release
- * unstyled because it did not. Color tokens match the product cream/navy
- * palette so settings reads as the same surface as the dashboard.
+ * unstyled because it did not.
+ *
+ * Every --color-* value below is a var() reference into theme-tokens.css's
+ * shared --brand-* swatches, not its own literal -- that file is the only
+ * place an actual hex/rgba value for this palette is written, and dark mode
+ * Just Works here with no separate dark-mode block in this file at all.
+ * Settings uses solid, opaque surfaces (no gradient backdrop behind it to
+ * let show through, unlike Rooms/the dashboard in product-theme.css), so its
+ * card/secondary/muted/accent map onto the plain paper/ink swatches directly
+ * rather than an alpha wash -- a real, deliberate difference from
+ * product-theme.css, not drift to fix.
*/
/* Declares layer order up front: reset < theme < utilities. Without this,
the plain (unlayered) reset rules below would beat every Tailwind utility
@@ -29,29 +38,29 @@
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
- --color-background: #f2e9d6;
- --color-foreground: #0e2f7e;
- --color-card: #fffdf7;
- --color-card-foreground: #0e2f7e;
- --color-primary: #1b63b3;
- --color-primary-foreground: #f7f3e8;
- --color-secondary: #e8dcc0;
- --color-secondary-foreground: #0e2f7e;
- --color-muted: #e8dcc0;
- --color-muted-foreground: #5a73a8;
- --color-accent: #e8dcc0;
- --color-accent-foreground: #0e2f7e;
- --color-destructive: #b3261e;
- --color-border: rgb(14 47 126 / 0.16);
- --color-input: rgb(14 47 126 / 0.28);
- --color-ring: #1b63b3;
+ --color-background: var(--brand-paper);
+ --color-foreground: var(--brand-ink);
+ --color-card: var(--brand-paper-bright);
+ --color-card-foreground: var(--brand-ink);
+ --color-primary: var(--brand-accent);
+ --color-primary-foreground: var(--brand-on-accent);
+ --color-secondary: var(--brand-paper-tan);
+ --color-secondary-foreground: var(--brand-ink);
+ --color-muted: var(--brand-paper-tan);
+ --color-muted-foreground: var(--brand-ink-muted);
+ --color-accent: var(--brand-paper-tan);
+ --color-accent-foreground: var(--brand-ink);
+ --color-destructive: var(--brand-destructive);
+ --color-border: rgba(var(--brand-ink-rgb), 0.16);
+ --color-input: rgba(var(--brand-ink-rgb), 0.28);
+ --color-ring: var(--brand-accent);
- --color-worktree-sidebar: #fffdf7;
- --color-worktree-sidebar-foreground: #0e2f7e;
- --color-worktree-sidebar-accent: #e8dcc0;
- --color-worktree-sidebar-accent-foreground: #0e2f7e;
- --color-worktree-sidebar-border: rgb(14 47 126 / 0.16);
- --color-worktree-sidebar-ring: #1b63b3;
+ --color-worktree-sidebar: var(--brand-paper-bright);
+ --color-worktree-sidebar-foreground: var(--brand-ink);
+ --color-worktree-sidebar-accent: var(--brand-paper-tan);
+ --color-worktree-sidebar-accent-foreground: var(--brand-ink);
+ --color-worktree-sidebar-border: rgba(var(--brand-ink-rgb), 0.16);
+ --color-worktree-sidebar-ring: var(--brand-accent);
}
/* Scope root: the settings layout wraps its content in this class instead
@@ -61,7 +70,7 @@
classes like Button's `text-primary-foreground`. */
@layer reset {
.orca-settings-scope {
- color-scheme: light;
+ color-scheme: var(--brand-color-scheme);
background: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
diff --git a/apps/web/app/settings/personal/environment/page.tsx b/apps/web/app/settings/personal/environment/page.tsx
index 2b59817d8..62af269db 100644
--- a/apps/web/app/settings/personal/environment/page.tsx
+++ b/apps/web/app/settings/personal/environment/page.tsx
@@ -1,10 +1,10 @@
import { EnvironmentVariablesPanel } from "@/components/settings/environment-variables-panel";
import {
- OrcaCard,
OrcaPageHeader,
OrcaPageShell,
OrcaSubsectionHeader,
} from "@/components/settings/orca-style";
+import { Card } from "@/components/ui/card";
import { listUserEnvironmentVariables } from "@/lib/providers/user-environment";
import { requireUser } from "@/lib/auth/session";
@@ -18,13 +18,13 @@ export default async function PersonalEnvironmentPage() {
description="Store encrypted key/value pairs for your personal CoDev workflows."
title="Environment Variables"
/>
-
+
-
+
);
}
diff --git a/apps/web/app/settings/personal/profile/page.tsx b/apps/web/app/settings/personal/profile/page.tsx
index 74dbb1eca..0737e2a17 100644
--- a/apps/web/app/settings/personal/profile/page.tsx
+++ b/apps/web/app/settings/personal/profile/page.tsx
@@ -6,12 +6,12 @@ import { Button } from "@/components/ui/button";
import { GithubMark } from "@/components/settings/github-mark";
import { GoogleMark } from "@/components/settings/google-mark";
import {
- OrcaCard,
OrcaPageHeader,
OrcaPageShell,
OrcaSubsectionHeader,
} from "@/components/settings/orca-style";
import { SetPasswordForm } from "@/components/settings/set-password-form";
+import { Card } from "@/components/ui/card";
import { getConnectedAccounts } from "@/lib/auth/identity";
import { requireUser } from "@/lib/auth/session";
import { cn } from "@/lib/platform/utils";
@@ -99,7 +99,7 @@ export default async function PersonalProfilePage({
title="Profile"
/>
-
+
{initials(user.name, user.email)}
@@ -112,9 +112,9 @@ export default async function PersonalProfilePage({
{user.email || "No email on file"}
-
+
-
+
) : null}
-
+
{connectedAccounts.hasPassword ? null : (
-
+
>
)}
-
+
)}
);
diff --git a/apps/web/app/sign-in/page.tsx b/apps/web/app/sign-in/page.tsx
index 331bfad88..50ec6c19e 100644
--- a/apps/web/app/sign-in/page.tsx
+++ b/apps/web/app/sign-in/page.tsx
@@ -8,6 +8,7 @@ import { isGitHubAuthConfigured, isGoogleAuthConfigured } from "@codev/config";
import { auth, signIn } from "@/auth";
import { Brand } from "@/components/shell/app-chrome";
+import { ThemeToggle } from "@/components/shell/theme-toggle";
import { CredentialsSignInForm } from "@/components/auth/credentials-sign-in-form";
import {
assertCanRegister,
@@ -115,7 +116,10 @@ export default async function SignInPage({
Sign in
diff --git a/apps/web/app/team-chat.css b/apps/web/app/team-chat.css
index d155aced7..997901dd3 100644
--- a/apps/web/app/team-chat.css
+++ b/apps/web/app/team-chat.css
@@ -12,7 +12,7 @@
.team-rail {
--team-line: rgba(237, 238, 240, 0.09);
--team-line-strong: rgba(237, 238, 240, 0.16);
- --team-accent: #1b63b3;
+ --team-accent: var(--gold, #1b63b3);
--team-online: #3fbf7a;
display: flex;
flex-direction: column;
@@ -57,7 +57,7 @@
.team-rail-expand:hover {
border-color: var(--team-accent);
- background: rgba(27, 99, 179, 0.16);
+ background: rgba(var(--accent-rgb), 0.16);
}
.team-rail-expand-badge {
@@ -68,7 +68,7 @@
padding: 0 4px;
border-radius: 999px;
background: var(--team-accent);
- color: #f7f3e8;
+ color: var(--codev-on-gold);
font-size: 10px;
font-weight: 700;
line-height: 16px;
@@ -135,9 +135,9 @@
.team-rail-error {
margin: 0;
padding: 6px 8px;
- border: 1px solid rgba(27, 99, 179, 0.35);
+ border: 1px solid rgba(var(--accent-rgb), 0.35);
border-radius: 7px;
- background: rgba(27, 99, 179, 0.1);
+ background: rgba(var(--accent-rgb), 0.1);
color: #f2c3a6;
font-size: 11px;
}
@@ -221,7 +221,7 @@
.team-status-emoji-option.is-selected {
border-color: var(--team-accent);
- background: rgba(27, 99, 179, 0.18);
+ background: rgba(var(--accent-rgb), 0.18);
}
.team-status-row {
@@ -246,7 +246,7 @@
.team-composer textarea:focus-visible {
border-color: var(--team-accent);
outline: none;
- box-shadow: 0 0 0 2px rgba(27, 99, 179, 0.2);
+ box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.2);
}
.team-status-save {
@@ -331,9 +331,9 @@
place-items: center;
width: 26px;
height: 26px;
- border: 1px solid rgba(27, 99, 179, 0.4);
+ border: 1px solid rgba(var(--accent-rgb), 0.4);
border-radius: 50%;
- background: rgba(27, 99, 179, 0.16);
+ background: rgba(var(--accent-rgb), 0.16);
color: #f0b28c;
}
@@ -437,8 +437,8 @@
}
.team-channel.is-active {
- border-color: rgba(27, 99, 179, 0.45);
- background: rgba(27, 99, 179, 0.14);
+ border-color: rgba(var(--accent-rgb), 0.45);
+ background: rgba(var(--accent-rgb), 0.14);
}
.team-channel-name {
@@ -465,7 +465,7 @@
padding: 0 5px;
border-radius: 999px;
background: var(--team-accent);
- color: #f7f3e8;
+ color: var(--codev-on-gold);
font-size: 9.5px;
font-weight: 700;
line-height: 15px;
@@ -540,7 +540,7 @@
border: none;
border-radius: 6px;
background: var(--team-accent);
- color: #f7f3e8;
+ color: var(--codev-on-gold);
cursor: pointer;
font-size: 10px;
font-weight: 700;
@@ -562,7 +562,7 @@
.team-rail-foot code {
padding: 0 3px;
border-radius: 4px;
- background: rgba(27, 99, 179, 0.18);
+ background: rgba(var(--accent-rgb), 0.18);
color: #f0b28c;
font-size: 9.5px;
}
@@ -639,9 +639,9 @@
align-items: center;
gap: 4px;
padding: 1px 6px;
- border: 1px solid rgba(27, 99, 179, 0.35);
+ border: 1px solid rgba(var(--accent-rgb), 0.35);
border-radius: 999px;
- background: rgba(27, 99, 179, 0.12);
+ background: rgba(var(--accent-rgb), 0.12);
color: #f0b28c;
font-size: 9.5px;
font-weight: 600;
@@ -687,7 +687,7 @@
.team-conversation-notice code {
padding: 0 3px;
border-radius: 4px;
- background: rgba(27, 99, 179, 0.18);
+ background: rgba(var(--accent-rgb), 0.18);
color: #f0b28c;
}
@@ -729,7 +729,7 @@
.team-message-tag {
padding: 0 5px;
border-radius: 999px;
- background: rgba(27, 99, 179, 0.2);
+ background: rgba(var(--accent-rgb), 0.2);
color: #f0b28c;
font-size: 8.5px;
font-weight: 700;
@@ -747,9 +747,9 @@
.team-message.is-agent .team-message-body {
padding: 7px 9px;
- border-left: 2px solid rgba(27, 99, 179, 0.5);
+ border-left: 2px solid rgba(var(--accent-rgb), 0.5);
border-radius: 0 8px 8px 0;
- background: rgba(27, 99, 179, 0.08);
+ background: rgba(var(--accent-rgb), 0.08);
}
.team-mention {
@@ -761,7 +761,7 @@
}
.team-mention.is-agent {
- background: rgba(27, 99, 179, 0.22);
+ background: rgba(var(--accent-rgb), 0.22);
color: #f4bd99;
}
@@ -810,9 +810,9 @@
align-items: center;
gap: 5px;
padding: 4px 8px;
- border: 1px solid rgba(27, 99, 179, 0.35);
+ border: 1px solid rgba(var(--accent-rgb), 0.35);
border-radius: 999px;
- background: rgba(27, 99, 179, 0.1);
+ background: rgba(var(--accent-rgb), 0.1);
color: #f0b28c;
cursor: pointer;
font-size: 10.5px;
@@ -820,7 +820,7 @@
}
.team-composer-mention:hover {
- background: rgba(27, 99, 179, 0.2);
+ background: rgba(var(--accent-rgb), 0.2);
}
.team-composer-send {
@@ -831,7 +831,7 @@
border: none;
border-radius: 8px;
background: var(--team-accent);
- color: #f7f3e8;
+ color: var(--codev-on-gold);
cursor: pointer;
transition: opacity 0.15s ease;
}
diff --git a/apps/web/app/theme-tokens.css b/apps/web/app/theme-tokens.css
new file mode 100644
index 000000000..bf918cc44
--- /dev/null
+++ b/apps/web/app/theme-tokens.css
@@ -0,0 +1,125 @@
+/*
+ * CoDev's single source of truth for color. Every other product theme file
+ * -- app-theme.css (plain CSS custom properties for gen2/sign-in/AppChrome),
+ * product-theme.css (Tailwind @theme for Rooms + dashboard), and
+ * settings/orca-theme.css (Tailwind @theme for Settings) -- derives its
+ * tokens from the raw swatches declared here via var(), instead of each
+ * holding its own copy of the same hex codes under its own role names. That
+ * duplication is exactly what let three separate redesign passes drift out
+ * of sync and left Rooms stuck in light mode after everything else went
+ * dark: fixing the shared source here is what makes that class of bug
+ * impossible to reintroduce, rather than just fixing today's instance of it.
+ *
+ * Names are raw swatches, not roles -- each consumer still decides which
+ * swatch plays which role for its own surface (e.g. Settings uses opaque
+ * cards while Rooms/dashboard use translucent glass cards over a gradient
+ * backdrop; both are legitimate, so both stay expressed differently even
+ * though they now share the same underlying color values).
+ *
+ * Deliberately NOT covered here: `.workspace-page` in globals.css (the
+ * embedded IDE surface, permanently dark by design, its own palette) and
+ * `.lp-page` in landing.css (the marketing page, its own richer multi-hue
+ * palette, not meant to follow light/dark at all). Both stay independent on
+ * purpose -- this file is the authenticated product's shared palette only.
+ *
+ * Dark mode ("Midnight Blueprint") applies automatically via
+ * prefers-color-scheme; [data-theme="dark"] / [data-theme="light"] are
+ * prepared for a manual toggle (see components/shell/theme-toggle.tsx).
+ */
+:root {
+ --brand-paper: #f2e9d6;
+ --brand-paper-bright: #fffdf7;
+ --brand-paper-tan: #e8dcc0;
+ --brand-paper-deep: #ddd0b0;
+ --brand-ink: #0e2f7e;
+ --brand-ink-2: #3d5a9e;
+ --brand-ink-muted: #5a73a8;
+ --brand-ink-faint: #7a8bb0;
+ --brand-accent: #1b63b3;
+ --brand-accent-bright: #2a7fd4;
+ --brand-on-accent: #f7f3e8;
+ --brand-teal: #2a6d62;
+ --brand-orange: #9e4a2a;
+ --brand-violet: #5b4fa8;
+ --brand-destructive: #b3261e;
+
+ /* RGB channel triplets (no alpha), for rgba()/rgb() call sites that need
+ to flip a translucent wash or glass-panel tint with the theme -- borders,
+ hover states, badges. Never used for box-shadows, which stay a dark
+ navy wash in both themes rather than inverting to a light glow when
+ --brand-ink-rgb becomes cream. --brand-paper-rgb is --brand-paper-bright
+ as channels, since that's the tier every glass panel tints from. */
+ --brand-paper-rgb: 255, 253, 247;
+ --brand-ink-rgb: 14, 47, 126;
+ --brand-accent-rgb: 27, 99, 179;
+ --brand-teal-rgb: 42, 109, 98;
+
+ /* A var, not just a bare `color-scheme: light` here, so every consumer
+ (app-theme.css, product-theme.css, orca-theme.css) can write
+ `color-scheme: var(--brand-color-scheme);` on its own scoped element
+ and inherit the flip, instead of each needing its own dark override
+ just for this one property. */
+ --brand-color-scheme: light;
+ color-scheme: light;
+
+ /* The wordmark logo asset is recolored from a black source via filter
+ rather than a mask, so it needs its own chain per theme instead of
+ inheriting a color directly. Approximate; may want a visual tweak once
+ seen against the real asset. */
+ --brand-logo-filter: brightness(0) saturate(100%) invert(16%) sepia(80%)
+ saturate(2200%) hue-rotate(210deg) brightness(0.95) contrast(1.05);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --brand-paper: #070c1a;
+ --brand-paper-bright: #0b1226;
+ --brand-paper-tan: #101a35;
+ --brand-paper-deep: #172244;
+ --brand-ink: #f2e9d6;
+ --brand-ink-2: #c9ba95;
+ --brand-ink-muted: #96918a;
+ --brand-ink-faint: #726d64;
+ --brand-accent: #3d8fe0;
+ --brand-accent-bright: #5aa6ec;
+ --brand-on-accent: #070c1a;
+ --brand-teal: #3fae9c;
+ --brand-orange: #e08a54;
+ --brand-violet: #a08cf0;
+ --brand-destructive: #e0574a;
+ --brand-paper-rgb: 16, 26, 53;
+ --brand-ink-rgb: 242, 233, 214;
+ --brand-accent-rgb: 61, 143, 224;
+ --brand-teal-rgb: 63, 174, 156;
+ --brand-color-scheme: dark;
+ color-scheme: dark;
+ --brand-logo-filter: brightness(0) saturate(100%) invert(92%) sepia(24%)
+ saturate(320%) hue-rotate(345deg) brightness(1.02) contrast(0.94);
+ }
+}
+
+:root[data-theme="dark"] {
+ --brand-paper: #070c1a;
+ --brand-paper-bright: #0b1226;
+ --brand-paper-tan: #101a35;
+ --brand-paper-deep: #172244;
+ --brand-ink: #f2e9d6;
+ --brand-ink-2: #c9ba95;
+ --brand-ink-muted: #96918a;
+ --brand-ink-faint: #726d64;
+ --brand-accent: #3d8fe0;
+ --brand-accent-bright: #5aa6ec;
+ --brand-on-accent: #070c1a;
+ --brand-teal: #3fae9c;
+ --brand-orange: #e08a54;
+ --brand-violet: #a08cf0;
+ --brand-destructive: #e0574a;
+ --brand-paper-rgb: 16, 26, 53;
+ --brand-ink-rgb: 242, 233, 214;
+ --brand-accent-rgb: 61, 143, 224;
+ --brand-teal-rgb: 63, 174, 156;
+ --brand-color-scheme: dark;
+ color-scheme: dark;
+ --brand-logo-filter: brightness(0) saturate(100%) invert(92%) sepia(24%)
+ saturate(320%) hue-rotate(345deg) brightness(1.02) contrast(0.94);
+}
diff --git a/apps/web/app/workspaces/[workspaceId]/activity/layout.tsx b/apps/web/app/workspaces/[workspaceId]/activity/layout.tsx
index 9d63030dd..5840efaae 100644
--- a/apps/web/app/workspaces/[workspaceId]/activity/layout.tsx
+++ b/apps/web/app/workspaces/[workspaceId]/activity/layout.tsx
@@ -2,8 +2,9 @@ import "../../../settings/orca-theme.css";
/**
* The activity feed is built entirely from the Orca design-system components
- * (`OrcaPageShell`, `OrcaPageHeader`, `OrcaCard`) and the Tailwind utilities
- * they expect โ but it lives under `/workspaces`, which is plain-CSS territory
+ * (`OrcaPageShell`, `OrcaPageHeader`) plus the shared `Card` from
+ * components/ui/card and the Tailwind utilities they expect โ but it lives
+ * under `/workspaces`, which is plain-CSS territory
* and loads none of them. Every utility class on the page was inert, so it
* rendered as unstyled markup at default browser sizes.
*
diff --git a/apps/web/app/workspaces/[workspaceId]/session-imports/page.tsx b/apps/web/app/workspaces/[workspaceId]/session-imports/page.tsx
index f6a9ab760..c4483bf02 100644
--- a/apps/web/app/workspaces/[workspaceId]/session-imports/page.tsx
+++ b/apps/web/app/workspaces/[workspaceId]/session-imports/page.tsx
@@ -14,7 +14,8 @@ import {
SessionImportTranscript,
sessionMessagePreview,
} from "@/components/workspace/session-import-transcript";
-import { OrcaCard, OrcaPageShell } from "@/components/settings/orca-style";
+import { OrcaPageShell } from "@/components/settings/orca-style";
+import { Card } from "@/components/ui/card";
import {
listStoredSessionImports,
readStoredSessionImportView,
@@ -125,10 +126,10 @@ export default async function WorkspaceSessionImportsPage({
Recent imports
{imports.length === 0 ? (
-
+
No imported sessions yet. Choose a session file above to get
started.
-
+
) : (
{imports.map((item) => (
@@ -321,9 +322,9 @@ export default async function WorkspaceSessionImportsPage({
>
) : (
-
+
Select an import to review its conversation and continue from it.
-
+
)}
diff --git a/apps/web/components/settings/SettingsSidebar.tsx b/apps/web/components/settings/SettingsSidebar.tsx
index 677e788c0..0332a75c0 100644
--- a/apps/web/components/settings/SettingsSidebar.tsx
+++ b/apps/web/components/settings/SettingsSidebar.tsx
@@ -99,10 +99,13 @@ export function SettingsSidebar() {
{filtered.map((row) => (
-
@@ -75,7 +75,7 @@ export function IntegrationsList({ rows }: { rows: IntegrationRow[] }) {
{row.action}
-
+
))}
)}
diff --git a/apps/web/components/settings/orca-style.tsx b/apps/web/components/settings/orca-style.tsx
index ab14fcffb..e014917ef 100644
--- a/apps/web/components/settings/orca-style.tsx
+++ b/apps/web/components/settings/orca-style.tsx
@@ -51,25 +51,6 @@ export function OrcaPageHeader({
);
}
-export function OrcaCard({
- children,
- className,
-}: {
- children: ReactNode;
- className?: string;
-}) {
- return (
-
- {children}
-
- );
-}
-
export function OrcaSubsectionHeader({
title,
description,
diff --git a/apps/web/components/settings/provider-account-card.tsx b/apps/web/components/settings/provider-account-card.tsx
index 355aa26d0..ef5f5336f 100644
--- a/apps/web/components/settings/provider-account-card.tsx
+++ b/apps/web/components/settings/provider-account-card.tsx
@@ -11,10 +11,10 @@ import {
import { Check, ChevronDown, Copy, KeyRound, Terminal } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ClaudeHostedConnect } from "@/components/settings/claude-hosted-connect";
import { CodexHostedConnect } from "@/components/settings/codex-hosted-connect";
-import { OrcaCard } from "@/components/settings/orca-style";
import type {
ClaudeCliTokenRecord,
CliSubscriptionRecord,
@@ -512,7 +512,7 @@ export function ProviderAccountCard({
: "Connect with an API key or the CoDev CLI below";
return (
-
+
{logo}
@@ -797,6 +797,6 @@ export function ProviderAccountCard({
{message}
) : null}
-
+
);
}
diff --git a/apps/web/components/shell/app-chrome.tsx b/apps/web/components/shell/app-chrome.tsx
index 52e9d914f..48ba01159 100644
--- a/apps/web/components/shell/app-chrome.tsx
+++ b/apps/web/components/shell/app-chrome.tsx
@@ -7,6 +7,7 @@ import { isGitHubAuthConfigured } from "@codev/config";
import { AppSidebarNav } from "@/components/shell/app-sidebar-nav";
import { FeedbackWidget } from "@/components/shell/feedback-widget";
import { ProfileMenu } from "@/components/shell/profile-menu";
+import { ThemeToggle } from "@/components/shell/theme-toggle";
import { isUserAdmin } from "@/lib/admin/admin";
import { isAdminHostname, publicAppHref } from "@/lib/platform/site-hosts";
@@ -59,6 +60,7 @@ export async function AppChrome({
+
+
{
+ it("keeps every actual color literal in one file: theme-tokens.css", () => {
+ // app-theme.css, product-theme.css and settings/orca-theme.css each used
+ // to hold their own copy of this palette under their own token names,
+ // which is exactly what let Rooms stay light after everything else went
+ // dark. Now only this file has a real hex/rgba value; everything else is
+ // a var() reference into it, checked in the tests below.
+ expect(themeTokens).toContain("--brand-paper: #f2e9d6;");
+ expect(themeTokens).toContain("--brand-ink: #0e2f7e;");
+ expect(themeTokens).toContain("--brand-accent: #1b63b3;");
+ expect(themeTokens).toContain("@media (prefers-color-scheme: dark) {");
+ expect(themeTokens).toContain('[data-theme="dark"] {');
+ expect(themeTokens).toContain("--brand-paper: #070c1a;");
+ expect(themeTokens).toContain("--brand-ink: #f2e9d6;");
+ expect(themeTokens).toContain("--brand-accent: #3d8fe0;");
+ });
+
it("sets one cream/navy palette for every AppChrome product page", () => {
expect(appTheme).toContain(".app-page,\n.auth-page {");
expect(appTheme).toContain("--surface: var(--codev-black-950);");
expect(appTheme).toContain("--ink: var(--codev-beige-100);");
- expect(appTheme).toContain("--codev-gold-500: #1b63b3;");
- expect(appTheme).toContain("--codev-black-950: #f2e9d6;");
- expect(appTheme).toContain("--codev-beige-100: #0e2f7e;");
- expect(appTheme).toContain("color-scheme: light;");
+ expect(appTheme).toContain("--codev-gold-500: var(--brand-accent);");
+ expect(appTheme).toContain("--codev-black-950: var(--brand-paper);");
+ expect(appTheme).toContain("--codev-beige-100: var(--brand-ink);");
+ expect(appTheme).toContain(
+ "color-scheme: var(--brand-color-scheme, light);",
+ );
expect(appTheme).toContain("--orange: var(--codev-terracotta);");
+ // No hex/rgba literal for this palette should remain in app-theme.css --
+ // everything routes through theme-tokens.css's --brand-* swatches now.
+ expect(appTheme).not.toContain("#f2e9d6");
+ expect(appTheme).not.toContain("#0e2f7e");
+ expect(appTheme).not.toContain("#1b63b3");
});
it("uses the same light surfaces for the dashboard workspace browser", () => {
expect(appTheme).toContain(".workspace-browser {");
- expect(appTheme).toContain("background: rgba(255, 253, 247, 0.72);");
+ // The glass-panel translucency is a --paper-rgb reference (itself an
+ // alias onto theme-tokens.css's --brand-paper-rgb) rather than a bare
+ // literal, so it flips with the theme instead of staying stuck light
+ // everywhere else .workspace-browser is used.
+ expect(appTheme).toContain("background: rgba(var(--paper-rgb), 0.72);");
expect(appTheme).toContain(".workspace-card:hover {");
});
it("ships a shared shadcn product theme for Rooms and the dashboard", () => {
- const productTheme = readCss("product-theme.css");
expect(productTheme).toContain(".product-scope,");
expect(productTheme).toContain(".rooms-scope {");
- expect(productTheme).toContain("--color-background: #f2e9d6;");
- expect(productTheme).toContain("--color-primary: #1b63b3;");
+ expect(productTheme).toContain("--color-background: var(--brand-paper);");
+ expect(productTheme).toContain("--color-primary: var(--brand-accent);");
+ // Glass panels over the animated gradient backdrop, unlike Settings'
+ // opaque cards -- a real design difference, kept as its own alpha wash
+ // rather than forced to match settings/orca-theme.css.
+ expect(productTheme).toContain(
+ "--color-card: rgba(var(--brand-paper-rgb), 0.62);",
+ );
+ // No separate dark-mode block needed: every value above already flips
+ // because theme-tokens.css's swatches do.
+ expect(productTheme).not.toContain("@media (prefers-color-scheme: dark)");
+ expect(productTheme).not.toContain("#070c1a");
+ });
+
+ it("gives Settings the same shared theme, with its own opaque surfaces", () => {
+ expect(orcaTheme).toContain(".orca-settings-scope {");
+ expect(orcaTheme).toContain("--color-background: var(--brand-paper);");
+ expect(orcaTheme).toContain("--color-primary: var(--brand-accent);");
+ // Solid surfaces, not glass -- Settings has no gradient backdrop behind
+ // it for a translucent card to let show through.
+ expect(orcaTheme).toContain("--color-card: var(--brand-paper-bright);");
+ expect(orcaTheme).not.toContain("@media (prefers-color-scheme: dark)");
+ expect(orcaTheme).not.toContain("#070c1a");
});
it("keeps every profile menu action legible on the light product surface", () => {
diff --git a/apps/web/components/shell/theme-toggle.tsx b/apps/web/components/shell/theme-toggle.tsx
new file mode 100644
index 000000000..15d4626a4
--- /dev/null
+++ b/apps/web/components/shell/theme-toggle.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Moon, Sun } from "lucide-react";
+
+export const THEME_STORAGE_KEY = "codev-theme";
+
+/**
+ * Inline, blocking script for the root layout's . Reads a stored
+ * explicit preference and stamps `data-theme` before first paint, so a
+ * returning visitor who chose dark never sees a light flash. When nothing is
+ * stored (the common case), this does nothing and the CSS's own
+ * `prefers-color-scheme` media query decides -- that path is already
+ * synchronous with paint and needs no script at all.
+ */
+export const THEME_INIT_SCRIPT = `(function(){try{var t=localStorage.getItem(${JSON.stringify(
+ THEME_STORAGE_KEY,
+)});if(t==="light"||t==="dark"){document.documentElement.setAttribute("data-theme",t);}}catch(e){}})();`;
+
+type Theme = "light" | "dark";
+
+function readInitialTheme(): Theme {
+ const stamped = document.documentElement.getAttribute("data-theme");
+ if (stamped === "light" || stamped === "dark") return stamped;
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+}
+
+/**
+ * A visible light/dark switch, not a menu item -- discoverability was the
+ * point, so it sits in the nav next to the profile menu rather than inside
+ * it. Once clicked it pins an explicit choice to `data-theme` + localStorage;
+ * until then, the theme just follows the OS via CSS alone.
+ */
+export function ThemeToggle({ compact = false }: { compact?: boolean }) {
+ // Starts `null` so the server-rendered and first-client-render markup
+ // match (the real theme is only knowable in the browser); the real icon
+ // appears a tick later without ever showing the wrong one.
+ const [theme, setTheme] = useState(null);
+
+ useEffect(() => {
+ // Reads `document`/`window`, which don't exist during SSR -- the value
+ // can only ever be known once mounted in the browser.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setTheme(readInitialTheme());
+ }, []);
+
+ function toggle() {
+ const next: Theme = theme === "dark" ? "light" : "dark";
+ document.documentElement.setAttribute("data-theme", next);
+ try {
+ localStorage.setItem(THEME_STORAGE_KEY, next);
+ } catch {
+ /* Private browsing or a blocked store -- the toggle still works for
+ this page load, it just won't be remembered next visit. */
+ }
+ setTheme(next);
+ }
+
+ const label =
+ theme === null
+ ? "Toggle color theme"
+ : theme === "dark"
+ ? "Switch to light theme"
+ : "Switch to dark theme";
+
+ return (
+
+ {theme === "dark" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/workspace/workspace-activity-feed.tsx b/apps/web/components/workspace/workspace-activity-feed.tsx
index ad1be5bf6..840c4eff8 100644
--- a/apps/web/components/workspace/workspace-activity-feed.tsx
+++ b/apps/web/components/workspace/workspace-activity-feed.tsx
@@ -15,10 +15,10 @@ import {
} from "lucide-react";
import {
- OrcaCard,
OrcaPageHeader,
OrcaPageShell,
} from "@/components/settings/orca-style";
+import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import type { ActivityAuditSnapshot } from "@/lib/workspaces/activity-audit-server";
import {
@@ -218,7 +218,7 @@ export function WorkspaceActivityFeed({
) : null}
-
+
@@ -231,8 +231,8 @@ export function WorkspaceActivityFeed({
{cursor == null ? "Complete history loaded" : "Most recent events"}
-
-
+
+
@@ -245,8 +245,8 @@ export function WorkspaceActivityFeed({
{categories} activity {categories === 1 ? "area" : "areas"}
-
-
+
+
@@ -263,10 +263,10 @@ export function WorkspaceActivityFeed({
? `${latestEvent.actor} ยท ${activityAction(latestEvent)}`
: "Waiting for the first event"}
-
+
-
+
@@ -410,7 +410,7 @@ export function WorkspaceActivityFeed({
That’s the beginning of this workspace’s history.
)}
-
+
);
}