feat(collab-web): align shell theme tokens
This commit is contained in:
@@ -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-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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user