feat(collab-web): align shell theme tokens

This commit is contained in:
lycaon
2026-06-17 21:22:54 -06:00
parent 159b8a1c63
commit 67b680449e
8 changed files with 275 additions and 14 deletions
+4 -2
View File
@@ -3,8 +3,9 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0f0b14" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#faf7fc" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f0b14" />
<title>omp collab — live agent session in your browser</title>
<meta
@@ -69,6 +70,7 @@
</head>
<body>
<div id="root"></div>
<script>(function(){try{var s=localStorage.getItem("omp-collab-theme");var sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";var t=(s==="light"||s==="dark")?s:sys;document.documentElement.dataset.theme=t;document.documentElement.style.colorScheme=t;}catch(e){}})();</script>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
@@ -1,5 +1,6 @@
import type { FormEvent, ReactNode } from "react";
import { useState } from "react";
import { ThemeToggle } from "./ThemeToggle";
export interface ConnectScreenProps {
defaultName: string;
@@ -28,9 +29,14 @@ export function ConnectScreen({ defaultName, error, onConnect }: ConnectScreenPr
return (
<div className="sh-connect">
<form className="sh-connect-card" onSubmit={submit}>
<div className="sh-lockup">
<span className="sh-lockup-pi">π</span> omp collab
<div className="sh-connect-head">
<div className="sh-lockup">
<span className="sh-lockup-mark" aria-hidden="true" />
<span className="sh-lockup-pi">π</span> omp collab
</div>
<ThemeToggle />
</div>
<div className="sh-connect-sub">live agent session, in your browser</div>
<label className="sh-field">
<span className="sh-field-label">join link</span>
<input
@@ -43,6 +49,7 @@ export function ConnectScreen({ defaultName, error, onConnect }: ConnectScreenPr
autoComplete="off"
autoFocus
/>
<span className="sh-field-hint">paste a /collab link from any omp session</span>
</label>
<label className="sh-field">
<span className="sh-field-label">display name</span>
@@ -2,6 +2,7 @@ import { LogOut, PanelRight } from "lucide-react";
import type { ReactNode } from "react";
import type { GuestSnapshot } from "../../lib/client";
import { fmtPercent, shortenPath } from "../../lib/format";
import { ThemeToggle } from "./ThemeToggle";
export interface HeaderBarProps {
snapshot: GuestSnapshot;
@@ -69,6 +70,7 @@ export function HeaderBar({ snapshot, subCount, railOpen, onToggleRail, onLeave
</span>
)}
<span className={`sh-dot sh-dot-${phase}`} title={phase} />
<ThemeToggle />
<button
type="button"
className={railOpen ? "sh-btn sh-btn-icon sh-btn-on" : "sh-btn sh-btn-icon"}
@@ -0,0 +1,37 @@
import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react";
import { type ThemePreference, useThemePreference } from "../../lib/theme";
const NEXT_PREFERENCE: Record<ThemePreference, ThemePreference> = {
system: "light",
light: "dark",
dark: "system",
};
const PREFERENCE_ICON: Record<ThemePreference, LucideIcon> = {
system: Monitor,
light: Sun,
dark: Moon,
};
const PREFERENCE_LABEL: Record<ThemePreference, string> = {
system: "System theme",
light: "Light theme",
dark: "Dark theme",
};
export function ThemeToggle() {
const { preference, setPreference } = useThemePreference();
const Icon = PREFERENCE_ICON[preference];
return (
<button
type="button"
className="sh-theme-toggle"
onClick={() => setPreference(NEXT_PREFERENCE[preference])}
aria-label={`${PREFERENCE_LABEL[preference]} (click to switch)`}
title={`${PREFERENCE_LABEL[preference]} — click to switch`}
>
<Icon size={16} />
</button>
);
}
@@ -119,6 +119,33 @@
border-color: var(--border-strong);
}
/* ---- theme toggle (system / light / dark) ---- */
.sh-theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
flex-shrink: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-raised);
color: var(--fg-muted);
cursor: pointer;
transition: color 0.14s ease, background 0.14s ease, border-color 0.14s ease;
}
.sh-theme-toggle:hover {
color: var(--fg);
border-color: var(--border-strong);
}
@media (prefers-reduced-motion: reduce) {
.sh-theme-toggle {
transition: none;
}
}
.sh-badge {
position: absolute;
top: -5px;
@@ -469,8 +496,9 @@
.sh-connect-card {
width: 100%;
max-width: 360px;
max-width: 380px;
padding: 28px;
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
@@ -479,16 +507,45 @@
border-radius: var(--radius-lg);
}
.sh-connect-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sh-lockup {
display: inline-flex;
align-items: center;
gap: 8px;
font: 500 15px / 1.5 var(--font-mono);
letter-spacing: 0.02em;
margin-bottom: 6px;
margin: 0;
}
.sh-lockup-mark {
width: 16px;
height: 16px;
flex: none;
background: linear-gradient(135deg, #ed4abf 0%, #9b4dff 50%, #5ad8e6 100%);
border-radius: 4px;
}
.sh-lockup-pi {
color: var(--accent);
}
.sh-connect-sub {
font-size: 12px;
color: var(--fg-muted);
margin-top: -4px;
}
.sh-field-hint {
font-size: 11px;
color: var(--fg-faint);
}
.sh-field {
display: flex;
flex-direction: column;
+87
View File
@@ -0,0 +1,87 @@
import { useSyncExternalStore } from "react";
export type SystemTheme = "light" | "dark";
export type ThemePreference = "system" | "light" | "dark";
const STORAGE_KEY = "omp-collab-theme";
const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
function readStoredPreference(): ThemePreference {
if (typeof localStorage === "undefined") return "system";
const stored = localStorage.getItem(STORAGE_KEY);
return stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
}
function getSystemTheme(): SystemTheme {
if (typeof window === "undefined") return "dark";
return window.matchMedia(DARK_SCHEME_QUERY).matches ? "dark" : "light";
}
// Module-level store shared by the toggle (writer) and any reader so an explicit
// override and the system default resolve through one source.
let preference: ThemePreference = readStoredPreference();
let resolved: SystemTheme = preference === "system" ? getSystemTheme() : preference;
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of listeners) listener();
}
function applyResolvedTheme(): void {
resolved = preference === "system" ? getSystemTheme() : preference;
if (typeof document !== "undefined") {
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
}
}
if (typeof window !== "undefined") {
applyResolvedTheme();
window.matchMedia(DARK_SCHEME_QUERY).addEventListener("change", () => {
// System changes only move the needle while following the system.
if (preference === "system") {
applyResolvedTheme();
emit();
}
});
}
export function setThemePreference(next: ThemePreference): void {
preference = next;
if (typeof localStorage !== "undefined") localStorage.setItem(STORAGE_KEY, next);
applyResolvedTheme();
emit();
}
function subscribe(callback: () => void): () => void {
listeners.add(callback);
return () => listeners.delete(callback);
}
/** Reader for the active resolved theme. Reflects system default and overrides. */
export function useSystemTheme(): SystemTheme {
return useSyncExternalStore(
subscribe,
() => resolved,
() => "dark" as SystemTheme,
);
}
/** Reader + writer for the theme preference (powers the toggle). */
export function useThemePreference(): {
preference: ThemePreference;
resolved: SystemTheme;
setPreference: (next: ThemePreference) => void;
} {
const pref = useSyncExternalStore(
subscribe,
() => preference,
() => "system" as ThemePreference,
);
const res = useSyncExternalStore(
subscribe,
() => resolved,
() => "dark" as SystemTheme,
);
return { preference: pref, resolved: res, setPreference: setThemePreference };
}
+11 -3
View File
@@ -80,13 +80,21 @@ textarea {
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: var(--radius);
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 999px;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--fg-faint);
border: 2px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-track {
+66 -5
View File
@@ -1,5 +1,6 @@
/* Design tokens — dark-only devtool aesthetic on the omp brand palette:
* deep-purple surfaces, pink accent, cyan focus ring.
/* Design tokens — omp brand palette: deep-purple surfaces, pink accent, cyan
* focus ring. Dark is the default; `[data-theme="light"]` and the
* prefers-color-scheme light fallback flip to light surfaces.
* The ONLY file allowed to contain raw color values. */
:root {
color-scheme: dark;
@@ -8,7 +9,7 @@
--bg: oklch(0.16 0.02 307);
--bg-raised: oklch(0.19 0.022 307);
--bg-inset: oklch(0.13 0.016 307);
--bg-overlay: oklch(0.22 0.026 307);
--bg-overlay: oklch(0.235 0.026 307);
/* text */
--fg: oklch(0.92 0.01 307);
@@ -36,11 +37,71 @@
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;
/* radii */
--radius-sm: 2px;
--radius: 4px;
--radius-sm: 3px;
--radius: 6px;
--radius-lg: 8px;
/* non-pinned helpers (kept here so no raw colors leak outside this file) */
--shadow-overlay: 0 12px 32px oklch(0 0 0 / 45%), 0 2px 8px oklch(0 0 0 / 30%);
--backdrop: oklch(0.1 0.014 307 / 62%);
}
/* Light theme — explicit override (toggle writes data-theme="light"). */
[data-theme="light"] {
color-scheme: light;
--bg: oklch(0.985 0.004 307);
--bg-raised: oklch(1 0 0);
--bg-inset: oklch(0.95 0.006 307);
--bg-overlay: oklch(0.965 0.006 307);
--fg: oklch(0.26 0.03 307);
--fg-muted: oklch(0.46 0.03 307);
--fg-faint: oklch(0.58 0.025 307);
--accent: oklch(0.62 0.23 341);
--accent-fg: oklch(0.99 0.01 341);
--accent-muted: oklch(0.62 0.23 341 / 14%);
--ok: oklch(0.55 0.13 150);
--warn: oklch(0.6 0.13 85);
--err: oklch(0.55 0.19 25);
--border: oklch(0 0 0 / 10%);
--border-strong: oklch(0 0 0 / 15%);
--ring: oklch(0.58 0.13 230 / 70%);
--shadow-overlay: 0 12px 32px oklch(0 0 0 / 14%), 0 2px 8px oklch(0 0 0 / 10%);
--backdrop: oklch(0.55 0.02 307 / 28%);
}
/* Light theme — system fallback when no explicit preference is stored. */
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
color-scheme: light;
--bg: oklch(0.985 0.004 307);
--bg-raised: oklch(1 0 0);
--bg-inset: oklch(0.95 0.006 307);
--bg-overlay: oklch(0.965 0.006 307);
--fg: oklch(0.26 0.03 307);
--fg-muted: oklch(0.46 0.03 307);
--fg-faint: oklch(0.58 0.025 307);
--accent: oklch(0.62 0.23 341);
--accent-fg: oklch(0.99 0.01 341);
--accent-muted: oklch(0.62 0.23 341 / 14%);
--ok: oklch(0.55 0.13 150);
--warn: oklch(0.6 0.13 85);
--err: oklch(0.55 0.19 25);
--border: oklch(0 0 0 / 10%);
--border-strong: oklch(0 0 0 / 15%);
--ring: oklch(0.58 0.13 230 / 70%);
--shadow-overlay: 0 12px 32px oklch(0 0 0 / 14%), 0 2px 8px oklch(0 0 0 / 10%);
--backdrop: oklch(0.55 0.02 307 / 28%);
}
}