Merge remote-tracking branch 'origin/main' into feature/integrations-page-redesign

This commit is contained in:
Matthew Meszaros
2026-10-03 09:24:34 -07:00
58 changed files with 1951 additions and 179 deletions
+26
View File
@@ -0,0 +1,26 @@
---
title: "Appearance"
description: "Light, dark, or matching your device: how the dashboard theme is chosen and what it changes."
---
The dashboard comes in a light and a dark theme. **Light** is the default. **System** follows your device's light or dark setting and switches with it, so a laptop that goes dark at sunset takes the dashboard along without a reload.
## Changing the theme
There are three places to pick one, and they all do the same thing:
- **User menu.** Open the menu under your name at the bottom of the sidebar and use the three-way switch next to **Theme**.
- **Settings.** **Settings > Profile > Appearance** shows a preview of each option.
- **Command palette.** Press **⌘K** (**Ctrl K** on Windows and Linux) and type `dark`, `light` or `system`.
The choice is saved in this browser, so a work laptop and a phone can each keep their own. Every open tab of the dashboard switches together.
## What the dark theme leaves light
A few things are shown the way someone else will see them, whatever theme you use:
- **Designed email.** In the unibox, a plain reply is drawn in the dashboard's dark colours. A message that brings its own colours or backgrounds (a newsletter, a branded signature, most mail written in Outlook) sits on a white sheet instead, so it looks the way its sender designed it.
- **Forms.** The form builder previews your form with its own design, as visitors will see it on the published page.
- **Shared images.** An analytics card exported as an image always uses the light design.
Mail you send is not affected by your theme. Recipients see exactly what you wrote.
+1
View File
@@ -47,6 +47,7 @@
"billing",
"analytics",
"notifications",
"appearance",
"security",
"workspace-export-import",
"team-roles",
+20
View File
@@ -10,17 +10,37 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Warmbly</title>
<meta name="apple-mobile-web-app-title" content="Warmbly" />
<!-- Runtime config: the production image overwrites this with values from
container env so one build serves any deployment. Empty in dev. Loads
before the app module so window.__WARMBLY_ENV__ is set first. -->
<script src="/config.js"></script>
<!-- Theme before first paint, so a dark dashboard never flashes white.
Same key and rules as src/lib/theme.ts. -->
<script>
(function () {
var dark = false;
try {
var t = localStorage.getItem("theme");
dark = t === "dark" || (t === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
} catch (e) {}
var root = document.documentElement;
root.classList.toggle("dark", dark);
root.style.colorScheme = dark ? "dark" : "light";
var meta = document.createElement("meta");
meta.name = "theme-color";
meta.content = dark ? "#070708" : "#f5f6f8";
document.head.appendChild(meta);
})();
</script>
<style>
/* Prevent a flash of unstyled content: paint the app background
immediately and keep #root hidden until the stylesheet has applied
(main.tsx adds .app-ready on the first frame after render). */
html { background-color: #ffffff; }
html.dark { background-color: #070708; }
#root { opacity: 0; }
#root.app-ready { opacity: 1; transition: opacity 120ms ease-out; }
</style>
@@ -338,7 +338,7 @@ function RevealStep({ apiKey, onClose }: { apiKey: APIKeyWithSecret; onClose: ()
Copy the secret now. We hash it on the server and can never show it again.
</p>
<div className="rounded-md border border-slate-200 bg-slate-950 overflow-hidden">
<div className="rounded-md border border-slate-200 dark:border-slate-800 bg-slate-950 overflow-hidden">
<div className="px-3 py-2 flex items-center gap-2 border-b border-slate-800/60">
<KeyIcon className="w-3 h-3 text-slate-400" />
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-slate-400">
+1 -1
View File
@@ -277,7 +277,7 @@ function CodeSnippet({ prefix }: { prefix: string }) {
);
}
return (
<div className="rounded-md border border-slate-200 bg-slate-950 overflow-hidden">
<div className="rounded-md border border-slate-200 dark:border-slate-800 bg-slate-950 overflow-hidden">
<div className="h-8 px-3 flex items-center gap-2 border-b border-slate-800/60">
<div className="size-1.5 rounded-full bg-red-400/70" />
<div className="size-1.5 rounded-full bg-amber-400/70" />
+2 -1
View File
@@ -95,6 +95,7 @@ import buildError from "@/lib/helper/buildError";
import TaskTypePicker from "@/components/app/crm/TaskTypePicker";
import { taskTypeColor } from "@/components/app/crm/taskTypes";
import { Checkbox } from "@/components/ui/checkbox";
import { labelInk } from "@/lib/utils";
const PRIORITIES: { id: CRMTaskPriority; label: string; dot: string; text: string }[] = [
{ id: "urgent", label: "Urgent", dot: "bg-red-500", text: "text-red-700" },
@@ -1447,7 +1448,7 @@ function TypeFacet({
/>
}
>
<span style={{ color: t.color || "#94a3b8" }}>
<span style={{ color: labelInk(t.color || "#94a3b8") }}>
{t.name}
</span>
</PopoverMenuItem>
+2 -1
View File
@@ -119,6 +119,7 @@ import AdvisorSummaryBar from "@/components/app/advisor/AdvisorSummaryBar";
import { useAdvisorEntityIndex } from "@/lib/api/hooks/app/advisor/useAdvisor";
import type { AdvisorFinding } from "@/lib/api/models/app/advisor/Advisor";
import { Checkbox } from "@/components/ui/checkbox";
import { labelInk } from "@/lib/utils";
export default function AddressesPage() {
const p = useUserProfile();
@@ -890,7 +891,7 @@ function MailboxRow({
<span
key={t.id}
className="hidden lg:inline-flex items-center gap-1 h-4 px-1.5 rounded-full text-[9.5px] font-medium shrink-0"
style={{ backgroundColor: `${t.color}1a`, color: t.color }}
style={{ backgroundColor: `${t.color}1a`, color: labelInk(t.color) }}
>
<span className="size-1.5 rounded-full" style={{ backgroundColor: t.color }} />
{t.title}
@@ -81,7 +81,7 @@ export default function ProviderGlyph({
return (
<div
className={cn(
"rounded-md ring-1 ring-slate-200 bg-white inline-flex items-center justify-center shrink-0",
"rounded-md ring-1 ring-slate-200 bg-white dark:bg-[#e6e8eb] inline-flex items-center justify-center shrink-0",
tileDim,
)}
>
@@ -101,7 +101,7 @@ export default function ProviderGlyph({
return (
<div
className={cn(
"rounded-md ring-1 ring-slate-200 bg-white inline-flex items-center justify-center shrink-0",
"rounded-md ring-1 ring-slate-200 bg-white dark:bg-[#e6e8eb] inline-flex items-center justify-center shrink-0",
tileDim,
)}
>
@@ -11,6 +11,7 @@ import {
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
import { roleColor } from "./RoleSelect";
import { labelInk } from "@/lib/utils";
export function RoleChips({ roles }: { roles: MemberRole[] }) {
if (roles.length === 0) {
@@ -22,7 +23,7 @@ export function RoleChips({ roles }: { roles: MemberRole[] }) {
<span
key={r.id}
className="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] font-medium border"
style={{ backgroundColor: `${r.color || "#64748b"}14`, borderColor: `${r.color || "#64748b"}55`, color: r.color || "#475569" }}
style={{ backgroundColor: `${r.color || "#64748b"}14`, borderColor: `${r.color || "#64748b"}55`, color: labelInk(r.color || "#475569") }}
>
<span className="size-1.5 rounded-full" style={{ backgroundColor: r.color || "#64748b" }} />
{r.name}
@@ -38,7 +38,7 @@ export function SectionShell({
<div>
<div className="px-4 pt-5 pb-4 md:px-8 md:pt-7 md:pb-5 flex flex-wrap md:flex-nowrap items-start gap-4 border-b border-slate-200/70">
<div className="min-w-0 flex-1 basis-48 md:basis-0">
<h2 className="text-[16px] font-semibold text-slate-900 tracking-tight">
<h2 className="wb-title text-[16px] font-semibold text-slate-900 tracking-tight">
{title}
</h2>
{description && (
+9 -1
View File
@@ -14,6 +14,7 @@ import { useAutosave, type AutosaveStatus } from "@/hooks/useAutosave";
import { useRegisterUnsaved } from "@/hooks/context/unsaved";
import { PERSON_NAME_MAX, nameError, normalizeName } from "@/lib/displayName";
import TimezonesSection from "./TimezonesSection";
import { ThemeCards } from "@/components/app/theme/ThemePicker";
// Header indicator priority when two autosaves share one SaveStatus.
function combineStatus(a: AutosaveStatus, b: AutosaveStatus): AutosaveStatus {
@@ -85,7 +86,7 @@ export default function ProfileSettingsPage() {
return (
<SectionShell
title="Profile"
description="Used in emails sent on your behalf, the sidebar avatar, and any invitation you send out."
description="Used in emails sent on your behalf, the sidebar avatar, and any invitation you send out, plus how the dashboard looks to you."
actions={<SaveStatus status={status} onRetry={retry} />}
>
<Section eyebrow="Identity" description="Names appear on outgoing emails.">
@@ -131,6 +132,13 @@ export default function ProfileSettingsPage() {
</Row>
</Section>
<Section
eyebrow="Appearance"
description="System follows your device and switches with it. Saved in this browser."
>
<ThemeCards />
</Section>
<TimezonesSection />
<Section
@@ -157,7 +157,7 @@ function SlideView({ slide }: { slide: Slide }) {
{/* Full-width card so a feature is always on screen as it slides in */}
<div className="w-full">{slide.mock}</div>
</div>
<div className="pt-4 lg:pt-6">
<div className="theme-solid pt-4 lg:pt-6">
<div className="mb-1 flex items-center gap-2">
<Icon className="size-4 text-sky-200" />
<h3 className="text-[15px] font-bold tracking-tight text-white">{slide.title}</h3>
+1 -1
View File
@@ -92,7 +92,7 @@ export default function AuthLayout({
</div>
{/* Footer — mobile, on the sky below the card */}
<div className="mt-5 flex items-center justify-center gap-3 text-[12px] text-white/70 lg:hidden">
<div className="theme-solid mt-5 flex items-center justify-center gap-3 text-[12px] text-white/70 lg:hidden">
{brand.terms_url && <>
<a href={brand.terms_url} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">Terms</a>
<span className="text-white/40">·</span>
+3 -3
View File
@@ -154,7 +154,7 @@ function CLIAuthInner() {
</div>
</motion.div>
<div className="mt-5 flex items-center justify-center gap-3 text-[12px] text-white/70">
<div className="theme-solid mt-5 flex items-center justify-center gap-3 text-[12px] text-white/70">
<Link to="/app/emails" className="hover:text-white transition-colors">Back to dashboard</Link>
<span className="text-white/40">·</span>
<a href="https://docs.warmbly.com/api/cli/" target="_blank" rel="noreferrer" className="hover:text-white transition-colors">About the CLI</a>
@@ -181,8 +181,8 @@ function Steps({ current }: { current: "code" | "review" | "done" }) {
<div className="flex items-center gap-2">
<motion.span
animate={{
backgroundColor: state === "todo" ? "#f1f5f9" : "#0284c7",
color: state === "todo" ? "#94a3b8" : "#ffffff",
backgroundColor: state === "todo" ? "var(--color-slate-100)" : "#0284c7",
color: state === "todo" ? "var(--color-slate-400)" : "#ffffff",
}}
className="size-6 rounded-full inline-flex items-center justify-center text-[11px] font-semibold"
>
+3 -3
View File
@@ -152,7 +152,7 @@ function ConnectInner() {
</div>
</motion.div>
<div className="mt-5 flex items-center justify-center gap-3 text-[12px] text-white/70">
<div className="theme-solid mt-5 flex items-center justify-center gap-3 text-[12px] text-white/70">
<Link to="/app/emails" className="hover:text-white transition-colors">Back to dashboard</Link>
<span className="text-white/40">·</span>
<a href="https://docs.warmbly.com/guides/warmbly-cloud/" target="_blank" rel="noreferrer" className="hover:text-white transition-colors">How linking works</a>
@@ -179,8 +179,8 @@ function Steps({ current }: { current: "code" | "review" | "done" }) {
<div className="flex items-center gap-2">
<motion.span
animate={{
backgroundColor: state === "todo" ? "#f1f5f9" : "#0284c7",
color: state === "todo" ? "#94a3b8" : "#ffffff",
backgroundColor: state === "todo" ? "var(--color-slate-100)" : "#0284c7",
color: state === "todo" ? "var(--color-slate-400)" : "#ffffff",
}}
className="size-6 rounded-full inline-flex items-center justify-center text-[11px] font-semibold"
>
+2 -1
View File
@@ -20,6 +20,7 @@ import { useAppStore } from "@/stores";
import { Logo } from "@/components/svg";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { labelInk } from "@/lib/utils";
export default function InviteAcceptPage() {
const [params] = useSearchParams();
@@ -140,7 +141,7 @@ export default function InviteAcceptPage() {
<span
key={r.id}
className="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] font-medium border"
style={{ backgroundColor: `${r.color || "#64748b"}14`, borderColor: `${r.color || "#64748b"}55`, color: r.color || "#475569" }}
style={{ backgroundColor: `${r.color || "#64748b"}14`, borderColor: `${r.color || "#64748b"}55`, color: labelInk(r.color || "#475569") }}
>
<span className="size-1.5 rounded-full" style={{ backgroundColor: r.color || "#64748b" }} />
{r.name}
+6 -3
View File
@@ -1,4 +1,5 @@
import RippleProvider from "@/hooks/RippleProvider";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { Outlet } from "react-router-dom";
@@ -8,8 +9,10 @@ export default function RootLayout() {
useDocumentTitle();
return (
<RippleProvider>
<Outlet />
</RippleProvider>
<ThemeProvider>
<RippleProvider>
<Outlet />
</RippleProvider>
</ThemeProvider>
);
}
+1 -1
View File
@@ -135,7 +135,7 @@ function SelectOrgPageInner() {
const noWorkspaces = !loading && orgList.length === 0 && inviteList.length === 0;
return (
<div className="min-h-dvh bg-[#f5f6f8] flex items-center justify-center px-4 py-12">
<div className="min-h-dvh bg-[#f5f6f8] dark:bg-[var(--wb-chrome)] flex items-center justify-center px-4 py-12">
<div className="w-full max-w-[480px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.08)] overflow-hidden">
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-2.5">
<Logo className="w-4 text-slate-900 shrink-0" />
@@ -171,6 +171,8 @@ const StatsShareCard = React.forwardRef<HTMLDivElement, { data: ShareCardData; a
return (
<div
ref={ref}
// The exported image is the light design whichever theme the exporter uses.
className="theme-light"
style={{ width, height, background: SKY_BASE, position: "relative", overflow: "hidden", borderRadius: 0 }}
>
<SkyBackdrop />
@@ -560,9 +560,9 @@ function ConvergeEdge({
<div
className="rounded border px-1 py-px text-[10px]"
style={{
borderColor: (labelBgStyle as { stroke?: string } | undefined)?.stroke ?? "#e2e8f0",
background: (labelBgStyle as { fill?: string } | undefined)?.fill ?? "#fff",
color: (labelStyle as { fill?: string } | undefined)?.fill ?? "#475569",
borderColor: (labelBgStyle as { stroke?: string } | undefined)?.stroke ?? "light-dark(#e2e8f0, #2e2f34)",
background: (labelBgStyle as { fill?: string } | undefined)?.fill ?? "light-dark(#fff, #1a1b1e)",
color: (labelStyle as { fill?: string } | undefined)?.fill ?? "light-dark(#475569, #b4bac3)",
}}
>
{label}
@@ -612,7 +612,7 @@ function whenToHandle(w?: string): string {
function styledEdge(id: string, source: string, target: string, sourceHandle: string, when: When): Edge {
const aiLabel = when.startsWith("label:") ? when.slice("label:".length) : "";
const color =
when === "true" ? "#0ea5e9" : when === "false" ? "#94a3b8" : when === "error" ? "#f43f5e" : aiLabel ? "#a855f7" : "#cbd5e1";
when === "true" ? "var(--wb-edge-sky)" : when === "false" ? "var(--wb-edge-muted)" : when === "error" ? "var(--wb-edge-rose)" : aiLabel ? "var(--wb-edge-violet)" : "var(--wb-edge-faint)";
return {
id,
source,
@@ -624,7 +624,7 @@ function styledEdge(id: string, source: string, target: string, sourceHandle: st
markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
style: { stroke: color, strokeWidth: 1.5 },
labelStyle: { fill: color },
labelBgStyle: { fill: "#fff", stroke: color },
labelBgStyle: { fill: "light-dark(#fff, #1a1b1e)", stroke: color },
};
}
@@ -1499,7 +1499,7 @@ export default function AutomationFlow({
fitView
proOptions={{ hideAttribution: true }}
>
<Background color="#e9eef5" gap={24} size={1} />
<Background color="light-dark(#e9eef5, #25262a)" gap={24} size={1} />
<Controls showInteractive={false} />
<CanvasSelections selections={live.selections} />
<CanvasCursors cursors={live.cursors} />
@@ -17,6 +17,7 @@ import { useUserProfile } from "@/hooks/context/user";
import useClickOutside from "@/hooks/useClickOutside";
import useFlipPlacement from "@/hooks/useFlipPlacement";
import useEmails from "@/lib/api/hooks/app/emails/useEmails";
import { labelInk } from "@/lib/utils";
// A health dot color from the mailbox status, mirroring InboxDetails.statusTone.
function healthDot(status: string): string {
@@ -126,7 +127,7 @@ export default function SenderSelector({
className="inline-flex items-center gap-1 h-5 pl-1.5 pr-1 rounded text-[11px] font-medium"
style={{
backgroundColor: hexToRgba(t.color, 0.12),
color: t.color,
color: labelInk(t.color),
border: `1px solid ${hexToRgba(t.color, 0.25)}`,
}}
>
@@ -928,9 +928,9 @@ function ConvergeEdge({
className="nodrag nopan pointer-events-none absolute rounded border px-1 py-px text-[10px]"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
borderColor: (labelBgStyle as { stroke?: string } | undefined)?.stroke ?? "#e2e8f0",
background: (labelBgStyle as { fill?: string } | undefined)?.fill ?? "#fff",
color: (labelStyle as { fill?: string } | undefined)?.fill ?? "#475569",
borderColor: (labelBgStyle as { stroke?: string } | undefined)?.stroke ?? "light-dark(#e2e8f0, #2e2f34)",
background: (labelBgStyle as { fill?: string } | undefined)?.fill ?? "light-dark(#fff, #1a1b1e)",
color: (labelStyle as { fill?: string } | undefined)?.fill ?? "light-dark(#475569, #b4bac3)",
}}
>
{label}
@@ -1538,7 +1538,7 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
const ifMeta: Record<string, IfMeta> = {};
const flowEdges: Edge[] = [];
const edgeStyle = (cond: boolean) =>
cond ? { stroke: "#0ea5e9", strokeWidth: 2 } : { stroke: "#94a3b8" };
cond ? { stroke: "var(--wb-edge-sky)", strokeWidth: 2 } : { stroke: "var(--wb-edge-muted)" };
sequences.forEach((s) => {
const branches = ordered(s.conditions?.branches ?? []);
@@ -1561,9 +1561,9 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
target: b.target_step_id ?? STOP_ID,
label: wt ? `${name} · ${wt}` : name,
reconnectable: true,
style: { stroke: "#a855f7", strokeWidth: 2 },
labelStyle: { fill: "#7e22ce", fontSize: 10 },
labelBgStyle: { fill: "#fff", stroke: "#e9d5ff" },
style: { stroke: "var(--wb-edge-violet)", strokeWidth: 2 },
labelStyle: { fill: "light-dark(#7e22ce, #d8b4fe)", fontSize: 10 },
labelBgStyle: { fill: "light-dark(#fff, #1a1b1e)", stroke: "light-dark(#e9d5ff, rgb(168 85 247 / 0.4))" },
labelBgPadding: [5, 3],
labelBgBorderRadius: 5,
data: { sourceId: s.id, branchId: b.branch_id },
@@ -1606,8 +1606,8 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
target: nid,
label: "else",
style: edgeStyle(false),
labelStyle: { fill: "#94a3b8", fontSize: 10 },
labelBgStyle: { fill: "#fff", stroke: "#e2e8f0" },
labelStyle: { fill: "light-dark(#94a3b8, #62666d)", fontSize: 10 },
labelBgStyle: { fill: "light-dark(#fff, #1a1b1e)", stroke: "light-dark(#e2e8f0, #2e2f34)" },
labelBgPadding: [4, 2],
labelBgBorderRadius: 5,
data: { sourceId: s.id, branchId: b.branch_id },
@@ -1638,8 +1638,8 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
label: wt || undefined,
reconnectable: true,
style: edgeStyle(true),
labelStyle: { fill: "#0369a1", fontSize: 10 },
labelBgStyle: { fill: "#fff", stroke: "#bae6fd" },
labelStyle: { fill: "light-dark(#0369a1, #7dd3fc)", fontSize: 10 },
labelBgStyle: { fill: "light-dark(#fff, #1a1b1e)", stroke: "light-dark(#bae6fd, rgb(56 189 248 / 0.35))" },
labelBgPadding: [5, 3],
labelBgBorderRadius: 5,
data: { sourceId: s.id, branchId: b.branch_id },
@@ -1660,8 +1660,8 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
label: wt ? `else · ${wt}` : "else",
reconnectable: true,
style: edgeStyle(false),
labelStyle: { fill: "#475569", fontSize: 10 },
labelBgStyle: { fill: "#fff", stroke: "#e2e8f0" },
labelStyle: { fill: "light-dark(#475569, #b4bac3)", fontSize: 10 },
labelBgStyle: { fill: "light-dark(#fff, #1a1b1e)", stroke: "light-dark(#e2e8f0, #2e2f34)" },
labelBgPadding: [5, 3],
labelBgBorderRadius: 5,
data: { sourceId: s.id, branchId: uncond.branch_id },
@@ -1676,8 +1676,8 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
label: wt || undefined,
reconnectable: true,
style: edgeStyle(false),
labelStyle: { fill: "#475569", fontSize: 10 },
labelBgStyle: { fill: "#fff", stroke: "#e2e8f0" },
labelStyle: { fill: "light-dark(#475569, #b4bac3)", fontSize: 10 },
labelBgStyle: { fill: "light-dark(#fff, #1a1b1e)", stroke: "light-dark(#e2e8f0, #2e2f34)" },
labelBgPadding: [5, 3],
labelBgBorderRadius: 5,
data: { sourceId: s.id, branchId: uncond.branch_id },
@@ -1708,7 +1708,7 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
target: sequences[0].id,
deletable: false,
reconnectable: false,
style: { stroke: "#c4b5fd", strokeWidth: 2 },
style: { stroke: "var(--wb-edge-violet-soft)", strokeWidth: 2 },
});
}
@@ -1747,7 +1747,7 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
type: MarkerType.ArrowClosed,
width: 16,
height: 16,
color: (e.style as { stroke?: string } | undefined)?.stroke ?? "#94a3b8",
color: (e.style as { stroke?: string } | undefined)?.stroke ?? "var(--wb-edge-muted)",
},
};
});
@@ -2040,7 +2040,7 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
fitView
proOptions={{ hideAttribution: true }}
>
<Background color="#e9eef5" gap={24} size={1} />
<Background color="light-dark(#e9eef5, #25262a)" gap={24} size={1} />
<Controls showInteractive={false} />
<CanvasSelections selections={live.selections} />
<CanvasCursors cursors={live.cursors} />
@@ -100,9 +100,9 @@ export const FormLinkNode = TiptapNode.create({
// styles because the base look comes from `.tiptap-body .tpl-var` in global.css
// and a plain class would lose that specificity fight.
const WARN_STYLE: React.CSSProperties = {
borderColor: "#fde68a", // amber-200
background: "#fffbeb", // amber-50
color: "#b45309", // amber-700
borderColor: "light-dark(#fde68a, rgb(251 191 36 / 0.35))", // amber-200
background: "light-dark(#fffbeb, rgb(245 158 11 / 0.14))", // amber-50
color: "light-dark(#b45309, #fcd34d)", // amber-700
};
// Compact chip showing the target form's name, with a click-to-edit popover to
@@ -27,7 +27,7 @@ import type Contact from "@/lib/api/models/app/contacts/Contact";
import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { cn, hexToRgba } from "@/lib/utils";
import { cn, hexToRgba, labelInk } from "@/lib/utils";
import type ContactSelection from "@/lib/api/models/app/contacts/ContactSelection";
import * as rowSelection from "./selection";
import type { RowSelection } from "./selection";
@@ -343,7 +343,7 @@ export default function AddFromContactsDialog({ open, onClose, campaign: campaig
className="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10.5px] font-medium truncate"
style={{
backgroundColor: hexToRgba(cat.color, 0.12),
color: cat.color,
color: labelInk(cat.color),
}}
>
<span className="size-1.5 rounded-full shrink-0" style={{ backgroundColor: cat.color }} />
@@ -27,6 +27,7 @@ import useCreateCategory from "@/lib/api/hooks/app/categories/useCreateCategory"
import type Category from "@/lib/api/models/app/Category";
import { TagMeaningTooltip } from "@/components/ui/tag-meaning-tooltip";
import { errorMessage } from "@/lib/errors/message";
import { labelInk } from "@/lib/utils";
interface Props {
// Selected ids — kept as ids so the consumer can store them in the
@@ -232,7 +233,7 @@ export function CategoryChip({
className={`inline-flex items-center gap-1 min-w-0 ${compact ? "h-4 pl-1 pr-1 text-[10px]" : "h-5 pl-1.5 pr-1 text-[11px]"} rounded font-medium`}
style={{
backgroundColor: hexToRgba(category.color, 0.12),
color: category.color,
color: labelInk(category.color),
border: `1px solid ${hexToRgba(category.color, 0.25)}`,
}}
>
@@ -31,6 +31,7 @@ import { sourceLabel } from "./ActivityTab";
import { ContactSegmentsSection } from "./ContactSegmentsSection";
import VerificationCard from "./VerificationCard";
import OriginBadge from "@/components/app/engagement/OriginBadge";
import { labelInk } from "@/lib/utils";
export default function OverviewTab({
contact,
@@ -221,7 +222,7 @@ export default function OverviewTab({
className="inline-flex h-4 items-center px-1.5 rounded text-[10.5px] font-medium"
style={{
backgroundColor: `${c.color}1a`,
color: c.color,
color: labelInk(c.color),
}}
>
{c.title}
@@ -7,6 +7,6 @@ export default function AddBoxStepButton({ next = false, tt, loading = false, on
{!next && <RiArrowLeftSLine className="w-4.5" />}
<div>{!tt ? next ? "Next" : "Back" : tt}</div>
{next && <RiArrowRightSLine className="w-4.5" />}
</> : <Loading className={`h-6 ${next ? "text-white" : "text-black"}`} />}
</> : <Loading className={`h-6 ${next ? "text-white" : "text-slate-900"}`} color={next ? "white" : "currentColor"} />}
</button>
}
@@ -101,7 +101,7 @@ export default function PasskeyEnrollPrompt() {
type="button"
onClick={handleCreate}
disabled={busy}
className="h-9 px-4 rounded-md text-[13px] font-semibold text-white bg-gradient-to-b from-sky-500 to-sky-600 hover:from-sky-500 hover:to-sky-700 shadow-sm inline-flex items-center justify-center gap-2 disabled:opacity-60 disabled:pointer-events-none"
className="theme-solid h-9 px-4 rounded-md text-[13px] font-semibold text-white bg-gradient-to-b from-sky-500 to-sky-600 hover:from-sky-500 hover:to-sky-700 shadow-sm inline-flex items-center justify-center gap-2 disabled:opacity-60 disabled:pointer-events-none"
>
{busy && <Loading className="!w-4 h-4 text-white" />}
Set up passkey
@@ -229,7 +229,7 @@ export function RateTrend({ days, height = 120, windowDays = 7 }: { days: DayVie
{segments.map((d, i) => (
<path key={i} d={d} fill="none" stroke="currentColor" strokeWidth={1.6} vectorEffect="non-scaling-stroke" strokeLinejoin="round" className={tone.text} />
))}
{hover != null && <line x1={x(hover)} x2={x(hover)} y1={0} y2={100} stroke="#cbd5e1" strokeWidth={1} vectorEffect="non-scaling-stroke" />}
{hover != null && <line x1={x(hover)} x2={x(hover)} y1={0} y2={100} style={{ stroke: "light-dark(#cbd5e1, #35373c)" }} strokeWidth={1} vectorEffect="non-scaling-stroke" />}
</svg>
{hover != null && values[hover] != null && (
<span
@@ -285,7 +285,7 @@ export function RateSpark({ values, width = 96, height = 22 }: { values: (number
const tone = BAND[bandForRate(last)].text;
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn("block shrink-0", tone)}>
<line x1={0} x2={width} y1={py(90)} y2={py(90)} stroke="#e2e8f0" strokeDasharray="2 2" />
<line x1={0} x2={width} y1={py(90)} y2={py(90)} style={{ stroke: "light-dark(#e2e8f0, #2e2f34)" }} strokeDasharray="2 2" />
<path d={d} fill="none" stroke="currentColor" strokeWidth={1.3} strokeLinejoin="round" strokeLinecap="round" />
<circle cx={px(lastIndex)} cy={py(last)} r={1.8} fill="currentColor" />
</svg>
@@ -18,6 +18,7 @@ import {
PopoverMenuContent,
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
import { labelInk } from "@/lib/utils";
export default function TagSelector({
onAdd,
@@ -74,7 +75,7 @@ export default function TagSelector({
className="inline-flex items-center gap-1 h-5 pl-1.5 pr-1 rounded text-[11px] font-medium"
style={{
backgroundColor: hexToRgba(t.color, 0.12),
color: t.color,
color: labelInk(t.color),
border: `1px solid ${hexToRgba(t.color, 0.25)}`,
}}
>
@@ -0,0 +1,170 @@
// The appearance choice in two sizes: preview cards for Settings and a
// three-way switch for the user menu. Both are radio groups, and both start the
// new theme from wherever it was picked.
import React from "react";
import { CheckIcon } from "lucide-react";
import { useAppStore } from "@/stores";
import type { Theme, ThemeOrigin } from "@/lib/theme";
import { cn } from "@/lib/utils";
import { THEME_OPTIONS, originOf } from "./themeOptions";
// Arrow keys move the choice, as in any radio group.
function useRadioKeys(value: Theme, choose: (t: Theme, origin: ThemeOrigin) => void) {
return (e: React.KeyboardEvent<HTMLElement>) => {
const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
if (!step) return;
e.preventDefault();
// Inside a menu, the arrows belong to this group rather than the menu's item focus.
e.stopPropagation();
const i = THEME_OPTIONS.findIndex((o) => o.value === value);
const next = THEME_OPTIONS[(i + step + THEME_OPTIONS.length) % THEME_OPTIONS.length];
const group = e.currentTarget;
const button = group.querySelector<HTMLElement>(`[data-theme-option="${next.value}"]`);
button?.focus();
const r = button?.getBoundingClientRect();
choose(next.value, r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : { x: 0, y: 0 });
};
}
/** Compact segmented switch, sized for a menu row. */
export function ThemeSwitch({ className }: { className?: string }) {
const theme = useAppStore((s) => s.theme);
const setTheme = useAppStore((s) => s.setTheme);
const onKeyDown = useRadioKeys(theme, setTheme);
return (
<div
role="radiogroup"
aria-label="Theme"
onKeyDown={onKeyDown}
className={cn("inline-flex items-center gap-0.5 rounded-md bg-slate-100 p-0.5", className)}
>
{THEME_OPTIONS.map(({ value, label, icon: Icon }) => {
const active = theme === value;
return (
<button
key={value}
type="button"
role="radio"
aria-checked={active}
aria-label={label}
title={label}
tabIndex={active ? 0 : -1}
data-theme-option={value}
onClick={(e) => {
e.stopPropagation();
setTheme(value, originOf(e));
}}
className={cn(
"inline-flex h-6 w-7 items-center justify-center rounded-[5px] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-sky-200",
active
? "bg-white text-slate-900 shadow-[0_1px_2px_rgba(15,23,42,0.08)] ring-1 ring-slate-200/70"
: "text-slate-400 hover:text-slate-700",
)}
>
<Icon className="size-3.5" />
</button>
);
})}
</div>
);
}
/** Settings cards, each a miniature of the dashboard in that theme. */
export function ThemeCards() {
const theme = useAppStore((s) => s.theme);
const resolved = useAppStore((s) => s.resolvedTheme);
const setTheme = useAppStore((s) => s.setTheme);
const onKeyDown = useRadioKeys(theme, setTheme);
return (
<div role="radiogroup" aria-label="Theme" onKeyDown={onKeyDown} className="grid grid-cols-1 gap-3 min-[460px]:grid-cols-3 max-w-[600px]">
{THEME_OPTIONS.map(({ value, label, icon: Icon }) => {
const active = theme === value;
return (
<button
key={value}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
data-theme-option={value}
onClick={(e) => setTheme(value, originOf(e))}
className={cn(
"group rounded-lg border p-1.5 text-left outline-none transition-[border-color,box-shadow] focus-visible:ring-2 focus-visible:ring-sky-200",
active
? "border-sky-500 ring-2 ring-sky-100"
: "border-slate-200 hover:border-slate-300",
)}
>
<ThemeThumb variant={value} />
<span className="flex items-center gap-1.5 px-1 pb-0.5 pt-2 text-[12.5px]">
<Icon className={cn("size-3.5", active ? "text-sky-600" : "text-slate-400")} />
<span className={active ? "font-medium text-slate-900" : "text-slate-700"}>{label}</span>
{value === "system" && (
<span className="text-[11px] text-slate-400">({resolved === "dark" ? "dark" : "light"} now)</span>
)}
<span
aria-hidden
className={cn(
"ml-auto inline-flex size-4 items-center justify-center rounded-full transition-[opacity,transform] duration-200",
active ? "scale-100 bg-sky-600 opacity-100" : "scale-75 opacity-0",
)}
>
<CheckIcon className="size-2.5 text-white" strokeWidth={3.5} />
</span>
</span>
</button>
);
})}
</div>
);
}
// Fixed colours, not palette classes: a preview shows its own theme whichever one is on.
const THUMB = {
light: { chrome: "#f5f6f8", panel: "#ffffff", line: "#e2e8f0", strong: "#cbd5e1", text: "#94a3b8", head: "#334155", accent: "#0284c7" },
dark: { chrome: "#070708", panel: "#111214", line: "#26272b", strong: "#35373c", text: "#62666d", head: "#d0d6e0", accent: "#0284c7" },
} as const;
function Miniature({ c }: { c: (typeof THUMB)[keyof typeof THUMB] }) {
return (
<div className="absolute inset-0 flex" style={{ background: c.chrome }}>
<div className="flex w-[26%] flex-col gap-1.5 px-2 pt-3">
<span className="mb-1 h-1.5 w-3/4 rounded-full" style={{ background: c.strong }} />
<span className="h-1 w-full rounded-full" style={{ background: c.line }} />
<span className="h-1 w-4/5 rounded-full" style={{ background: c.line }} />
<span className="h-1 w-full rounded-full" style={{ background: c.line }} />
</div>
<div
className="mt-2 flex flex-1 flex-col gap-1.5 rounded-tl-md border-l border-t px-2.5 pt-2.5"
style={{ background: c.panel, borderColor: c.line }}
>
<div className="flex items-center justify-between">
<span className="h-1.5 w-1/3 rounded-full" style={{ background: c.head }} />
<span className="h-2.5 w-6 rounded-[3px]" style={{ background: c.accent }} />
</div>
{[0.9, 0.7, 0.8].map((w, i) => (
<div key={i} className="flex items-center gap-1.5 border-t pt-1.5" style={{ borderColor: c.line }}>
<span className="size-1.5 shrink-0 rounded-full" style={{ background: i === 1 ? "#10b981" : c.strong }} />
<span className="h-1 rounded-full" style={{ width: `${w * 70}%`, background: c.text }} />
</div>
))}
</div>
</div>
);
}
function ThemeThumb({ variant }: { variant: Theme }) {
return (
<span aria-hidden className="relative block h-[84px] overflow-hidden rounded-md ring-1 ring-black/5">
<Miniature c={variant === "dark" ? THUMB.dark : THUMB.light} />
{variant === "system" && (
<span className="absolute inset-0" style={{ clipPath: "polygon(58% 0, 100% 0, 100% 100%, 42% 100%)" }}>
<Miniature c={THUMB.dark} />
</span>
)}
</span>
);
}
@@ -0,0 +1,16 @@
import type React from "react";
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
import type { Theme, ThemeOrigin } from "@/lib/theme";
export const THEME_OPTIONS: { value: Theme; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ value: "light", label: "Light", icon: SunIcon },
{ value: "dark", label: "Dark", icon: MoonIcon },
{ value: "system", label: "System", icon: MonitorIcon },
];
// A keyboard activation has no pointer position, so the reveal starts at the control.
export function originOf(e: React.MouseEvent | React.KeyboardEvent): ThemeOrigin {
if ("clientX" in e && (e.clientX || e.clientY)) return { x: e.clientX, y: e.clientY };
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
@@ -44,7 +44,7 @@ import {
PopoverMenuSeparator,
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
import { cn } from "@/lib/utils";
import { cn, labelInk } from "@/lib/utils";
import { nameFromAddr } from "@/lib/helper/emailAddress";
import { Checkbox } from "@/components/ui/checkbox";
@@ -604,8 +604,8 @@ function LabelChip({ title, color }: { title: string; color: string }) {
<span
className="inline-flex items-center gap-1 h-4 px-1.5 rounded-sm text-[10px] font-medium overflow-hidden max-w-[110px]"
style={{
color: color || "#475569",
backgroundColor: color ? `${color}14` : "rgb(241 245 249)",
color: labelInk(color || "#475569"),
backgroundColor: color ? `${color}14` : "var(--color-slate-100)",
}}
title={title}
>
+29 -7
View File
@@ -17,6 +17,8 @@
import React from "react";
import { ImageOffIcon, MoreHorizontalIcon } from "lucide-react";
import { hasRemoteContent, plainToDisplayHtml } from "@/lib/email/body";
import { useAppStore } from "@/stores";
import { cn } from "@/lib/utils";
interface EmailBodyProps {
html?: string | null;
@@ -111,24 +113,38 @@ const DOCUMENT_CSS = `
pre { white-space: pre-wrap; }
`;
// The dark theme reads unstyled mail in the app's own colours. Anything that
// sets a colour or background of its own was designed on white, so it keeps
// its light document and sits on a white sheet instead.
const DARK_DOCUMENT_CSS = `
:root { color-scheme: dark; }
body { color: #d0d6e0; }
a { color: #7dd3fc; }
blockquote { border-left-color: #2e2f34; color: #8a8f98; }
`;
const DESIGNED = /<style[\s>]|<[^>]*\s(?:bgcolor|color|text)\s*=|\b(?:background|color)\s*:/i;
const isDesignedEmail = (body: string) => DESIGNED.test(body);
// A body that is already a whole document (a campaign written in HTML mode, a
// designed newsletter) must not be nested inside another one: the doctype and
// the <head> would land in the body, and the frame would preview something the
// recipient will never see. Its own <head> gets our shell instead.
const DOCUMENT_ROOT = /^\s*(?:<!--[\s\S]*?-->\s*)*(?:<!doctype\s+html|<html[\s>])/i;
function shell(csp: string | null): string {
function shell(csp: string | null, dark: boolean): string {
// The policy must precede everything else in the head to govern it.
const policy = csp ? `<meta http-equiv="Content-Security-Policy" content="${csp}">` : "";
return (
policy +
`<meta charset="utf-8"><meta name="referrer" content="no-referrer">` +
`<base target="_blank"><style>${DOCUMENT_CSS}</style>`
`<base target="_blank"><style>${DOCUMENT_CSS}${dark ? DARK_DOCUMENT_CSS : ""}</style>`
);
}
function buildDocument(body: string, csp: string | null): string {
const SHELL = shell(csp);
function buildDocument(body: string, csp: string | null, dark: boolean): string {
const SHELL = shell(csp, dark);
if (DOCUMENT_ROOT.test(body)) {
// Our shell goes FIRST in the parsed document's real head, so the
// message's own stylesheet comes after it and wins on everything but
@@ -165,9 +181,12 @@ export default function EmailBody({ html, plain, blockRemote = false }: EmailBod
const hasQuote = quotes.hasQuote;
const remote = React.useMemo(() => blockRemote && hasRemoteContent(body), [blockRemote, body]);
const csp = blockRemote ? (remoteLoaded ? CSP_LOADED : CSP_BLOCKED) : null;
const darkTheme = useAppStore((s) => s.resolvedTheme === "dark");
const designed = React.useMemo(() => isDesignedEmail(body), [body]);
const darkDocument = darkTheme && !designed;
const srcDoc = React.useMemo(
() => (body ? buildDocument(showQuoted ? quotes.expanded : quotes.collapsed, csp) : ""),
[body, quotes, showQuoted, csp],
() => (body ? buildDocument(showQuoted ? quotes.expanded : quotes.collapsed, csp, darkDocument) : ""),
[body, quotes, showQuoted, csp, darkDocument],
);
// Late-loading remote images change the document height after onLoad, so
@@ -231,7 +250,10 @@ export default function EmailBody({ html, plain, blockRemote = false }: EmailBod
// (plus escape-to-normal-context) is what lets a link actually open.
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
referrerPolicy="no-referrer"
className="w-full border-0 block"
className={cn(
"w-full border-0 block",
darkTheme && designed && "theme-light box-content w-[calc(100%-1.5rem)] rounded-md bg-white p-3",
)}
style={{ height: height ? `${height}px` : "80px" }}
/>
{hasQuote && (
+1 -1
View File
@@ -1370,7 +1370,7 @@ function RailRowItem({
transition={transition}
whileDrag={{
scale: 1.02,
backgroundColor: "rgb(255,255,255)",
backgroundColor: "var(--color-white)",
boxShadow: "0 8px 20px -6px rgba(15,23,42,0.18), 0 2px 6px rgba(15,23,42,0.08)",
}}
className="relative rounded-md"
+1 -1
View File
@@ -6,7 +6,7 @@ export default function AuthButton({ children, loading }: { children: React.Reac
<button
type="submit"
disabled={loading}
className="w-full h-11 rounded-lg bg-gradient-to-b from-sky-500 to-sky-600 text-white font-semibold text-[15px] shadow-[0_1px_2px_rgba(0,0,0,0.06),0_4px_12px_rgba(14,165,233,0.35)] hover:shadow-[0_1px_2px_rgba(0,0,0,0.06),0_8px_20px_rgba(14,165,233,0.45)] active:shadow-[0_1px_2px_rgba(0,0,0,0.06),0_2px_6px_rgba(14,165,233,0.25)] transition-shadow duration-200 disabled:opacity-50 disabled:pointer-events-none cursor-pointer flex items-center justify-center"
className="theme-solid w-full h-11 rounded-lg bg-gradient-to-b from-sky-500 to-sky-600 text-white font-semibold text-[15px] shadow-[0_1px_2px_rgba(0,0,0,0.06),0_4px_12px_rgba(14,165,233,0.35)] hover:shadow-[0_1px_2px_rgba(0,0,0,0.06),0_8px_20px_rgba(14,165,233,0.45)] active:shadow-[0_1px_2px_rgba(0,0,0,0.06),0_2px_6px_rgba(14,165,233,0.25)] transition-shadow duration-200 disabled:opacity-50 disabled:pointer-events-none cursor-pointer flex items-center justify-center"
>
{!loading ? children : (
<Loading className="!w-5 h-5 text-white" />
+1 -1
View File
@@ -125,7 +125,7 @@ export function AppHeader({ onMenu }: { onMenu?: () => void }) {
<span
style={{ fontFamily: "var(--font-display)" }}
className={cn(
"font-extrabold text-[15.5px] tracking-tight text-slate-900",
"wb-title font-extrabold text-[15.5px] tracking-tight text-slate-900",
navCollapsed ? "hidden" : "hidden md:inline",
)}
>
+3 -8
View File
@@ -1,13 +1,8 @@
// Thin wrapper: pulls in the theme provider and renders the new shell.
// All the actual layout lives in AppShell — sidebar + header + content.
// Thin wrapper around the shell. All the actual layout lives in AppShell:
// sidebar + header + content. The theme is applied app-wide in RootLayout.
import { AppShell } from "./AppShell";
import { ThemeProvider } from "./ThemeProvider";
export function AppLayout() {
return (
<ThemeProvider>
<AppShell />
</ThemeProvider>
);
return <AppShell />;
}
+1 -1
View File
@@ -360,7 +360,7 @@ function NavRow({ item, collapsed = false }: { item: NavItem; collapsed?: boolea
className={cn(
rowClass(collapsed),
active
? cn("bg-slate-200/70 text-slate-900", !collapsed && "font-medium")
? cn("wb-nav-active bg-slate-200/70 text-slate-900", !collapsed && "font-medium")
: locked
? "text-slate-400 hover:text-slate-700 hover:bg-slate-200/40"
: "text-slate-600 hover:text-slate-900 hover:bg-slate-200/40",
+5 -1
View File
@@ -75,7 +75,11 @@ export function AppShell() {
corner is softened (rounded-tl-2xl) only on >=md, where
the sidebar sits beside it; on mobile the panel is
full-bleed with just a top hairline. */}
<main className="flex-1 min-w-0 bg-white overflow-hidden border-t border-slate-200/70 md:rounded-tl-2xl md:border-l">
<main className="wb-panel flex-1 min-w-0 bg-white overflow-hidden border-t border-slate-200/70 md:rounded-tl-2xl md:border-l">
{/* Dark theme only: a soft light from the top edge, laid over every page whatever it paints. */}
<div aria-hidden className="pointer-events-none relative z-20 hidden h-0 dark:block">
<div className="wb-panel-light absolute inset-x-0 top-0 h-[420px]" />
</div>
<GlobalCursorsProvider scrollRef={scrollRef}>
<div ref={scrollRef} className="h-full overflow-auto">
<RouteBoundary>
+2 -2
View File
@@ -75,7 +75,7 @@ export function OrgSwitcher() {
>
<span
className={`size-6 rounded-md ring-1 ring-slate-200 flex items-center justify-center shrink-0 overflow-hidden ${
orgAvatar ? "bg-white p-0.5" : "bg-slate-900"
orgAvatar ? "bg-white dark:bg-transparent p-0.5" : "bg-slate-900"
}`}
>
{orgAvatar ? (
@@ -113,7 +113,7 @@ export function OrgSwitcher() {
disabled={switchOrgMutation.isPending}
selected={org.id === currentOrganization?.id}
icon={
<span className={`size-5 rounded-md ring-1 ring-slate-200 flex items-center justify-center shrink-0 overflow-hidden ${avatar ? "bg-white p-px" : "bg-slate-900"}`}>
<span className={`size-5 rounded-md ring-1 ring-slate-200 flex items-center justify-center shrink-0 overflow-hidden ${avatar ? "bg-white dark:bg-transparent p-px" : "bg-slate-900"}`}>
{avatar ? (
<img
src={avatar}
+1 -1
View File
@@ -66,7 +66,7 @@ export function PageTopbar({
className={cn(
// Single 48px row on >=md; on mobile it wraps so action
// clusters drop to a second line instead of widening the page.
"min-h-12 md:h-12 px-5 py-1.5 md:py-0 border-b border-slate-200 flex flex-wrap md:flex-nowrap items-center gap-3 gap-y-1.5 shrink-0 bg-white sticky top-0 z-10",
"min-h-12 md:h-12 px-5 py-1.5 md:py-0 border-b border-slate-200 flex flex-wrap md:flex-nowrap items-center gap-3 gap-y-1.5 shrink-0 bg-white dark:bg-[rgb(17_18_20/0.72)] dark:backdrop-blur-md sticky top-0 z-10",
className,
)}
>
+7 -5
View File
@@ -23,13 +23,15 @@ export function SkyChrome() {
{/* Base tint — clean neutral, a half-step darker than white.
Avoid blue-leaning here; the only colour cue is the soft
clouds above. */}
<div className="absolute inset-0 bg-[#f5f6f8]" />
<div className="absolute inset-0 bg-[#f5f6f8] dark:bg-[var(--wb-chrome)]" />
<div className="wb-chrome-light absolute inset-0 hidden dark:block" />
{/* Faint cloud blobs. Positioned to suggest a softly clouded
{/* Faint cloud blobs, light theme only: white haze on the dark
chrome reads as fog, not sky. Positioned to suggest a softly clouded
sky in the upper half. Generous blur radius + low alpha
makes them ambient rather than figurative. */}
<div
className="absolute"
className="absolute dark:hidden"
style={{
top: "-80px",
left: "-60px",
@@ -42,7 +44,7 @@ export function SkyChrome() {
}}
/>
<div
className="absolute"
className="absolute dark:hidden"
style={{
top: "10%",
right: "8%",
@@ -55,7 +57,7 @@ export function SkyChrome() {
}}
/>
<div
className="absolute"
className="absolute dark:hidden"
style={{
top: "32%",
left: "26%",
@@ -0,0 +1,41 @@
import { act, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/stores'
import { THEME_STORAGE_KEY } from '@/lib/theme'
import { ThemeProvider } from './ThemeProvider'
// setupTests stubs localStorage with mocks, so the stored value is driven through them.
const stored = (value: string | null) => vi.mocked(localStorage.getItem).mockImplementation((k) => (k === THEME_STORAGE_KEY ? value : null))
describe('ThemeProvider', () => {
afterEach(() => {
vi.mocked(localStorage.getItem).mockReset()
vi.mocked(localStorage.setItem).mockClear()
useAppStore.setState({ theme: 'light', resolvedTheme: 'light' })
document.documentElement.classList.remove('dark')
})
it("applies another tab's choice from storage without writing it back", () => {
render(<ThemeProvider><div /></ThemeProvider>)
stored('dark')
vi.mocked(localStorage.setItem).mockClear()
// A queued event can carry a value that is no longer current.
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: THEME_STORAGE_KEY, newValue: 'light' }))
})
expect(useAppStore.getState().theme).toBe('dark')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(localStorage.setItem).not.toHaveBeenCalledWith(THEME_STORAGE_KEY, expect.anything())
})
it('falls back to light when nothing is stored', () => {
useAppStore.setState({ theme: 'dark', resolvedTheme: 'dark' })
render(<ThemeProvider><div /></ThemeProvider>)
stored(null)
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: THEME_STORAGE_KEY, newValue: null }))
})
expect(useAppStore.getState().theme).toBe('light')
expect(document.documentElement.classList.contains('dark')).toBe(false)
})
})
+18 -7
View File
@@ -1,17 +1,28 @@
import { useEffect } from 'react'
import { useAppStore } from '@/stores'
import { onSystemThemeChange, readStoredTheme, resolveTheme, THEME_STORAGE_KEY } from '@/lib/theme'
// The dashboard is light-only today. This used to mirror the OS preference
// onto a `.dark` root class, but only the CSS-variable components (command
// palette, toasts) have dark styles, so on a dark-mode OS the palette went
// dark inside an all-white app. Force light until a real dark theme ships.
// Keeps the painted theme in step with the preference: the OS when it is
// "system", and the choice made in another tab of the dashboard.
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const theme = useAppStore((state) => state.theme)
const setResolvedTheme = useAppStore((state) => state.setResolvedTheme)
useEffect(() => {
document.documentElement.classList.remove('dark')
setResolvedTheme('light')
}, [setResolvedTheme])
setResolvedTheme(resolveTheme(theme))
if (theme !== 'system') return
return onSystemThemeChange((t) => setResolvedTheme(t))
}, [theme, setResolvedTheme])
useEffect(() => {
// Read the key now (a queued event's newValue can be stale) and never write it back.
const onStorage = (e: StorageEvent) => {
if (e.key !== THEME_STORAGE_KEY && e.key !== null) return
useAppStore.setState({ theme: readStoredTheme() })
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
return <>{children}</>
}
+1 -1
View File
@@ -846,7 +846,7 @@ function LogPanel({ lines }: { lines: string[] }) {
return (
<pre
ref={ref}
className="max-h-56 overflow-auto rounded-md border border-slate-200 bg-slate-950 p-3 text-[11px] leading-relaxed text-slate-200"
className="max-h-56 overflow-auto rounded-md border border-slate-200 dark:border-slate-800 bg-slate-950 p-3 text-[11px] leading-relaxed text-slate-200"
>
{lines.length > 0 ? lines.join("\n") : "Waiting for output"}
</pre>
+1 -1
View File
@@ -425,7 +425,7 @@ function Hero({
transition: { duration: 0.4, ease: EASE, delay },
};
return (
<div className="relative overflow-hidden px-5 pt-9 pb-8 md:px-10 md:pt-12 md:pb-10 text-center text-white">
<div className="theme-solid relative overflow-hidden px-5 pt-9 pb-8 md:px-10 md:pt-12 md:pb-10 text-center text-white">
<div className="absolute inset-0" aria-hidden="true">
<div className="sky-base" />
<div className="sky-breathe" />
+6
View File
@@ -22,6 +22,7 @@ import {
PopoverMenuTrigger,
} from "@/components/ui/popover-menu";
import { cn } from "@/lib/utils";
import { ThemeSwitch } from "@/components/app/theme/ThemePicker";
export function UserNav({ collapsed = false }: { collapsed?: boolean }) {
const navigate = useNavigate();
@@ -98,6 +99,11 @@ export function UserNav({ collapsed = false }: { collapsed?: boolean }) {
</div>
</div>
<PopoverMenuSeparator />
<div className="flex h-8 items-center justify-between gap-3 pl-3 pr-1.5">
<span className="text-[12.5px] text-slate-700">Theme</span>
<ThemeSwitch />
</div>
<PopoverMenuSeparator />
<PopoverMenuItem
onSelect={() => navigate("/app/settings")}
icon={<SettingsIcon className="w-3 h-3" />}
+4 -2
View File
@@ -1,5 +1,6 @@
import { Logo } from "@/components/svg";
import useBrand from "@/hooks/useBrand";
import { cn } from "@/lib/utils";
/**
* The wordmark on the sky: sign-in, the CLI authorization page, the cloud
@@ -18,9 +19,10 @@ export default function BrandMark({ className }: { className: string }) {
<span className="font-extrabold text-[18px] tracking-tight text-white">{brand.name}</span>
</>
);
if (!brand.website_url) return <div className={className}>{inner}</div>;
// Always drawn on the sky, so it keeps its white in either theme.
if (!brand.website_url) return <div className={cn("theme-solid", className)}>{inner}</div>;
return (
<a href={brand.website_url} className={className}>
<a href={brand.website_url} className={cn("theme-solid", className)}>
{inner}
</a>
);
@@ -12,6 +12,7 @@ import {
KeyIcon,
SettingsIcon,
CreditCardIcon,
CheckIcon,
} from 'lucide-react'
import {
CommandDialog,
@@ -23,6 +24,7 @@ import {
CommandSeparator,
} from '@/components/ui/command'
import { useAppStore } from '@/stores'
import { THEME_OPTIONS } from '@/components/app/theme/themeOptions'
interface CommandItem {
icon: React.ComponentType<{ className?: string }>
@@ -36,6 +38,8 @@ export function CommandPalette() {
const open = useAppStore((state) => state.commandPaletteOpen)
const setOpen = useAppStore((state) => state.setCommandPaletteOpen)
const setShortcutsModalOpen = useAppStore((state) => state.setShortcutsModalOpen)
const theme = useAppStore((state) => state.theme)
const setTheme = useAppStore((state) => state.setTheme)
const runCommand = (command: () => void) => {
setOpen(false)
@@ -96,6 +100,22 @@ export function CommandPalette() {
<span className="ml-auto text-xs text-muted-foreground">?</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Theme">
{THEME_OPTIONS.map(({ value, label, icon: Icon }) => (
<CommandItem
key={value}
value={`theme ${label} ${value === 'dark' ? 'dark mode night' : value === 'light' ? 'light mode day' : 'system auto device'}`}
onSelect={() => runCommand(() => setTheme(value))}
>
<Icon className="mr-2 size-4" />
<span>{value === 'system' ? 'Match system theme' : `${label} theme`}</span>
{theme === value && <CheckIcon className="ml-auto size-3.5 text-muted-foreground" />}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
)
+17 -1
View File
@@ -1,3 +1,4 @@
import { useId, type CSSProperties } from "react";
export const Google = ({ className }: { className: string }) => {
return <>
<svg className={className} viewBox="-3 0 262 262" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027" fill="#4285F4" /><path d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1" fill="#34A853" /><path d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782" fill="#FBBC05" /><path d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251" fill="#EB4335" /></svg>
@@ -42,8 +43,23 @@ export const Outlook = ({ className }: { className: string }) => {
}
export const Logo = ({ className }: { className: string }) => {
// The dark theme paints the mark with this gradient; the light theme keeps currentColor.
const gradient = `wb-logo-${useId().replace(/:/g, "")}`;
return (
<svg className={className} viewBox="0 0 746 764" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
className={`wb-logo ${className}`}
style={{ "--wb-logo-fill": `url(#${gradient})` } as CSSProperties}
viewBox="0 0 746 764"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient id={gradient} x1="186" y1="108" x2="704" y2="645" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#ffffff" />
<stop offset="0.5" stopColor="#d6d9de" />
<stop offset="1" stopColor="#8b9098" />
</linearGradient>
</defs>
<path d="M222.805 644.772L186.274 108.881L704.5 451.158L484.5 451.158L245.5 196.158L444 463.5L222.805 644.772Z" fill="currentColor" />
</svg>
)
+44 -21
View File
@@ -11,6 +11,7 @@
import React from "react";
import { useReducedMotion } from "framer-motion";
import { useAppStore } from "@/stores";
// Ordered 8x8 Bayer matrix: 64 threshold levels, so density ramps render as
// smooth halftone gradients instead of the harsh stripe patterns a 4x4 gives.
@@ -43,6 +44,20 @@ const TONES = {
} as const;
export type DitherTone = keyof typeof TONES;
// The same tones drawn for a dark ground: lighter and calmer, so dots glow
// softly instead of buzzing. Charts take the theme as an input so a switch repaints them.
const DARK_TONES: Record<DitherTone, readonly [number, number, number]> = {
sky: [96, 174, 226],
amber: [226, 176, 92],
rose: [228, 118, 136],
emerald: [84, 192, 150],
violet: [162, 142, 236],
slate: [150, 155, 165],
};
const toneRgb = (tone: DitherTone, dark: boolean): readonly [number, number, number] =>
dark ? DARK_TONES[tone] : TONES[tone];
const useDarkTheme = () => useAppStore((s) => s.resolvedTheme === "dark");
// Dither dots blend into the page instead of sitting on it; lines and
// hovered/selected elements stay fully opaque for definition.
const FILL_ALPHA = 215;
@@ -112,6 +127,7 @@ export function DitherBarChart({
selected?: number | null;
onSelect?: (i: number) => void;
}) {
const dark = useDarkTheme();
const reduced = useReducedMotion();
const [wrapRef, { w }] = useMeasure<HTMLDivElement>();
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
@@ -129,7 +145,7 @@ export function DitherBarChart({
const H = el.height;
const img = ctx.createImageData(W, H);
const buf = img.data;
const [cr, cg, cb] = TONES[tone];
const [cr, cg, cb] = toneRgb(tone, dark);
const n = data.length;
if (n > 0) {
const slot = W / n;
@@ -173,7 +189,7 @@ export function DitherBarChart({
}
}
ctx.putImageData(img, 0, 0);
}, [data, w, height, tone, hover, ghost, selected]);
}, [data, w, height, tone, hover, ghost, selected, dark]);
const paintRef = React.useRef(paint);
React.useEffect(() => {
@@ -598,6 +614,7 @@ export function DitherAreaChart({
className?: string;
renderTooltip?: (d: DitherBarDatum) => React.ReactNode;
}) {
const dark = useDarkTheme();
const reduced = useReducedMotion();
const [wrapRef, { w }] = useMeasure<HTMLDivElement>();
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
@@ -630,7 +647,7 @@ export function DitherAreaChart({
const vals = disp.current[0] ?? data.map((d) => d.value);
const ys = vals.map((v) => (yCssFor(v) / height) * rows);
const curve = sampleCurve(ys, monotoneTangents(ys), cols);
const rgb = TONES[tone];
const rgb = toneRgb(tone, dark);
const revealX = reveal.current * cols;
const lift = intensity.current;
for (let x = 0; x < cols; x++) {
@@ -646,14 +663,14 @@ export function DitherAreaChart({
[{ ys: vals.map((v) => yCssFor(v) * dpr), rgb }],
reveal.current,
);
}, [data, w, height, tone, yCssFor, disp, reveal, intensity]);
}, [data, w, height, tone, yCssFor, disp, reveal, intensity, dark]);
React.useEffect(() => {
paintRef.current = paint;
paint();
}, [paint]);
const tones = React.useMemo(() => [TONES[tone]] as const, [tone]);
const tones = React.useMemo(() => [toneRgb(tone, dark)] as const, [tone, dark]);
useSparkles(starRef, geomRef, tones, reveal, intensity, reduced);
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
@@ -665,7 +682,7 @@ export function DitherAreaChart({
const d = hover !== null ? data[hover] : null;
const hoverX = hover !== null && data.length > 1 ? (hover / (data.length - 1)) * w : w / 2;
const [cr, cg, cb] = TONES[tone];
const [cr, cg, cb] = toneRgb(tone, dark);
return (
<div
@@ -740,6 +757,7 @@ export function DitherMultiAreaChart({
className?: string;
renderTooltip?: (i: number) => React.ReactNode;
}) {
const dark = useDarkTheme();
const reduced = useReducedMotion();
const [wrapRef, { w }] = useMeasure<HTMLDivElement>();
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
@@ -778,7 +796,7 @@ export function DitherMultiAreaChart({
const vals = disp.current[s] ?? series[s].values;
const ys = vals.map((v) => (yCssFor(v) / height) * rows);
curves.push(sampleCurve(ys, monotoneTangents(ys), cols));
rgbs.push(TONES[series[s].tone]);
rgbs.push(toneRgb(series[s].tone, dark));
}
const tops = new Array<number>(S);
for (let x = 0; x < cols; x++) {
@@ -794,18 +812,18 @@ export function DitherMultiAreaChart({
height,
series.map((s, si) => ({
ys: (disp.current[si] ?? s.values).map((v) => yCssFor(v) * dpr),
rgb: TONES[s.tone],
rgb: toneRgb(s.tone, dark),
})),
reveal.current,
);
}, [labels, series, w, height, yCssFor, disp, reveal, intensity]);
}, [labels, series, w, height, yCssFor, disp, reveal, intensity, dark]);
React.useEffect(() => {
paintRef.current = paint;
paint();
}, [paint]);
const tones = React.useMemo(() => series.map((s) => TONES[s.tone]), [series]);
const tones = React.useMemo(() => series.map((s) => toneRgb(s.tone, dark)), [series, dark]);
useSparkles(starRef, geomRef, tones, reveal, intensity, reduced);
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
@@ -848,7 +866,7 @@ export function DitherMultiAreaChart({
style={{ left: hoverX }}
/>
{series.map((s) => {
const [cr, cg, cb] = TONES[s.tone];
const [cr, cg, cb] = toneRgb(s.tone, dark);
return (
<div
key={s.label}
@@ -873,7 +891,7 @@ export function DitherMultiAreaChart({
<>
<div className="font-medium text-slate-900 whitespace-nowrap">{labels[hover]}</div>
{series.map((s) => {
const [cr, cg, cb] = TONES[s.tone];
const [cr, cg, cb] = toneRgb(s.tone, dark);
return (
<div key={s.label} className="flex items-center gap-1.5 whitespace-nowrap">
<span
@@ -909,6 +927,7 @@ export function DitherMeter({
animate?: boolean;
className?: string;
}) {
const dark = useDarkTheme();
const reduced = useReducedMotion();
const [wrapRef, { w }] = useMeasure<HTMLDivElement>();
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
@@ -925,7 +944,7 @@ export function DitherMeter({
const H = el.height;
const img = ctx.createImageData(W, H);
const buf = img.data;
const [cr, cg, cb] = TONES[tone];
const [cr, cg, cb] = toneRgb(tone, dark);
const fillEnd = cur * W;
const ramp = Math.min(6 * dpr, W);
for (let y = 0; y < H; y++) {
@@ -950,7 +969,7 @@ export function DitherMeter({
}
ctx.putImageData(img, 0, 0);
},
[w, height, tone],
[w, height, tone, dark],
);
React.useEffect(() => {
@@ -998,6 +1017,7 @@ export function DitherColumns({
className?: string;
onHover?: (i: number | null) => void;
}) {
const dark = useDarkTheme();
const reduced = useReducedMotion();
const [wrapRef, { w }] = useMeasure<HTMLDivElement>();
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
@@ -1032,7 +1052,7 @@ export function DitherColumns({
const y1 = H - (acc / max) * p * H;
acc += v;
const y0 = H - (acc / max) * p * H;
const rgb = TONES[tones[s] ?? "sky"];
const rgb = toneRgb(tones[s] ?? "sky", dark);
for (let x = x0; x < x1 && x < W; x++) {
for (let y = Math.max(0, Math.floor(y0)); y < Math.min(H, Math.ceil(y1)); y++) {
const a = hot ? 1 : 0.85;
@@ -1048,7 +1068,7 @@ export function DitherColumns({
}
}
ctx.putImageData(img, 0, 0);
}, [data, tones, w, height, hover]);
}, [data, tones, w, height, hover, dark]);
const paintRef = React.useRef(paint);
React.useEffect(() => {
@@ -1119,6 +1139,7 @@ export function DitherStack({
height?: number;
className?: string;
}) {
const dark = useDarkTheme();
const reduced = useReducedMotion();
const [wrapRef, { w }] = useMeasure<HTMLDivElement>();
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
@@ -1140,7 +1161,7 @@ export function DitherStack({
let acc = 0;
for (const s of segments) {
acc += clamp01(s.frac);
bounds.push({ end: Math.min(1, acc) * W * reveal, rgb: TONES[s.tone] });
bounds.push({ end: Math.min(1, acc) * W * reveal, rgb: toneRgb(s.tone, dark) });
}
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
@@ -1159,7 +1180,7 @@ export function DitherStack({
}
}
ctx.putImageData(img, 0, 0);
}, [segments, w, height]);
}, [segments, w, height, dark]);
const paintRef = React.useRef(paint);
React.useEffect(() => {
@@ -1209,6 +1230,7 @@ export function DitherRing({
className?: string;
children?: React.ReactNode;
}) {
const dark = useDarkTheme();
const reduced = useReducedMotion();
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const curRef = React.useRef(0);
@@ -1223,7 +1245,7 @@ export function DitherRing({
const S = el.width;
const img = ctx.createImageData(S, S);
const buf = img.data;
const [cr, cg, cb] = TONES[tone];
const [cr, cg, cb] = toneRgb(tone, dark);
const c = S / 2;
const rOut = c;
const rIn = c - thickness * dpr;
@@ -1256,7 +1278,7 @@ export function DitherRing({
}
ctx.putImageData(img, 0, 0);
},
[size, thickness, tone],
[size, thickness, tone, dark],
);
React.useEffect(() => {
@@ -1309,9 +1331,10 @@ export function DitherSlider({
className?: string;
label?: string;
}) {
const dark = useDarkTheme();
const trackRef = React.useRef<HTMLDivElement | null>(null);
const frac = max > min ? clamp01((value - min) / (max - min)) : 0;
const [cr, cg, cb] = TONES[tone];
const [cr, cg, cb] = toneRgb(tone, dark);
const snap = (raw: number) => Math.max(min, Math.min(max, Math.round(raw / step) * step));
+55 -30
View File
@@ -1,6 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap');
@import "tailwindcss";
@import "tw-animate-css";
@import "./styles/theme-dark.css";
@custom-variant dark (&:is(.dark *));
@@ -46,37 +47,37 @@
}
.dark {
--background: oklch(0.13 0.01 260);
--foreground: oklch(0.95 0.005 260);
--card: oklch(0.16 0.01 260);
--card-foreground: oklch(0.95 0.005 260);
--popover: oklch(0.16 0.01 260);
--popover-foreground: oklch(0.95 0.005 260);
--primary: oklch(0.85 0.005 260);
--primary-foreground: oklch(0.13 0.01 260);
--secondary: oklch(0.22 0.01 260);
--secondary-foreground: oklch(0.95 0.005 260);
--muted: oklch(0.20 0.01 260);
--muted-foreground: oklch(0.60 0.01 260);
--accent: oklch(0.22 0.01 260);
--accent-foreground: oklch(0.95 0.005 260);
--background: #111214;
--foreground: #f7f8f8;
--card: #1a1b1e;
--card-foreground: #f7f8f8;
--popover: #1a1b1e;
--popover-foreground: #f7f8f8;
--primary: #f7f8f8;
--primary-foreground: #111214;
--secondary: #25262a;
--secondary-foreground: #f7f8f8;
--muted: #1c1d20;
--muted-foreground: #8a8f98;
--accent: #25262a;
--accent-foreground: #f7f8f8;
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.26 0.01 260);
--input: oklch(0.26 0.01 260);
--ring: oklch(0.60 0.01 260);
--chart-1: oklch(0.60 0.15 250);
--chart-2: oklch(0.55 0.12 160);
--chart-3: oklch(0.58 0.10 30);
--chart-4: oklch(0.52 0.14 300);
--chart-5: oklch(0.55 0.08 200);
--sidebar: oklch(0.14 0.01 260);
--sidebar-foreground: oklch(0.95 0.005 260);
--sidebar-primary: oklch(0.85 0.005 260);
--sidebar-primary-foreground: oklch(0.13 0.01 260);
--sidebar-accent: oklch(0.22 0.01 260);
--sidebar-accent-foreground: oklch(0.95 0.005 260);
--sidebar-border: oklch(0.26 0.01 260);
--sidebar-ring: oklch(0.60 0.01 260);
--border: #26272b;
--input: #2e2f34;
--ring: oklch(74.6% 0.16 232.661 / 0.5);
--chart-1: oklch(0.70 0.15 240);
--chart-2: oklch(0.70 0.14 165);
--chart-3: oklch(0.72 0.13 60);
--chart-4: oklch(0.68 0.16 300);
--chart-5: oklch(0.70 0.10 200);
--sidebar: #070708;
--sidebar-foreground: #f7f8f8;
--sidebar-primary: #f7f8f8;
--sidebar-primary-foreground: #111214;
--sidebar-accent: #1c1d20;
--sidebar-accent-foreground: #f7f8f8;
--sidebar-border: #26272b;
--sidebar-ring: oklch(74.6% 0.16 232.661 / 0.5);
}
@theme inline {
@@ -280,6 +281,9 @@ svg.loading circle {
.progress-sweep {
animation: progress-sweep 1.1s ease-in-out infinite;
}
.dark .skeleton-shimmer {
background-image: linear-gradient(90deg, #1c1d20 0%, #1c1d20 35%, #26272b 50%, #1c1d20 65%, #1c1d20 100%);
}
@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer, .progress-sweep { animation: none; }
}
@@ -636,6 +640,27 @@ svg.loading circle {
color: #0369a1; /* sky-700 */
}
/* The editor surface in the dark theme. A block that paints a light background of its
own (a template's white table cell or banner) keeps dark text, as the recipient sees it. */
.dark .tiptap-body h2, .dark .tiptap-body h3 { color: #f7f8f8; }
.dark .tiptap-body a { color: #7dd3fc; }
.dark .tiptap-body td, .dark .tiptap-body th { border-color: #2e2f34; }
.dark .tiptap-body th { background: #16171a; }
.dark .tiptap-body :is(
[style*="background-color: #f" i], [style*="background-color:#f" i], [style*="background: #f" i], [style*="background:#f" i],
[style*="background-color: white" i], [style*="background: white" i],
[style*="rgb(255, 255, 255)"], [style*="rgb(255,255,255)"], [bgcolor^="#f" i], [bgcolor="white" i]
) { color: #0f172a; }
.dark .tiptap-body .tpl-var { border-color: #2e2f34; background: #1c1d20; color: #d0d6e0; }
.dark .tiptap-body .tpl-if { border-color: #3b3d42; background: #25262a; color: #e6e8eb; }
.dark .tiptap-body .tpl-ai { border-color: oklch(74.6% 0.16 232.661 / 0.35); background: oklch(68.5% 0.169 237.323 / 0.14); color: #7dd3fc; }
.dark .tiptap-body :is(.tpl-var, .tpl-if, .tpl-ai):hover,
.dark .tiptap-body :is(.tpl-var.tpl-var-active, .tpl-if.tpl-if-active, .tpl-ai.tpl-ai-active) {
border-color: oklch(74.6% 0.16 232.661 / 0.6);
background: oklch(68.5% 0.169 237.323 / 0.2);
color: #bae6fd;
}
/* Campaign flow canvas — smoothly glide nodes when the layout recomputes
(adding/deleting a step, "Tidy up"). The transition is killed while dragging
so node drag stays 1:1 with the cursor. Scoped to .campaign-flow so other
+116
View File
@@ -0,0 +1,116 @@
// Theme preference, its resolution against the OS, and the switch itself.
// index.html applies the stored choice before the first paint with the same
// rules; keep STORAGE_KEY and the resolution there in step with this file.
export type Theme = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";
export const THEME_STORAGE_KEY = "theme";
// Browser chrome on phones (address bar, PWA title bar) matches the sidebar.
const CHROME_COLOR: Record<ResolvedTheme, string> = { light: "#f5f6f8", dark: "#070708" };
const DARK_QUERY = "(prefers-color-scheme: dark)";
export const isTheme = (v: unknown): v is Theme => v === "light" || v === "dark" || v === "system";
// Light until someone picks otherwise; "system" is an explicit choice.
export const DEFAULT_THEME: Theme = "light";
export function readStoredTheme(): Theme {
if (typeof window === "undefined") return DEFAULT_THEME;
try {
const v = localStorage.getItem(THEME_STORAGE_KEY);
return isTheme(v) ? v : DEFAULT_THEME;
} catch {
return DEFAULT_THEME;
}
}
export function storeTheme(theme: Theme) {
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Private mode or a full quota: the choice still applies to this tab.
}
}
export function systemTheme(): ResolvedTheme {
if (typeof window === "undefined" || !window.matchMedia) return "light";
return window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
}
export const resolveTheme = (theme: Theme): ResolvedTheme => (theme === "system" ? systemTheme() : theme);
export function onSystemThemeChange(cb: (t: ResolvedTheme) => void): () => void {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mq = window.matchMedia(DARK_QUERY);
const handler = () => cb(mq.matches ? "dark" : "light");
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}
function paint(resolved: ResolvedTheme) {
const root = document.documentElement;
root.classList.toggle("dark", resolved === "dark");
root.style.colorScheme = resolved;
let meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
if (!meta) {
meta = document.createElement("meta");
meta.name = "theme-color";
document.head.appendChild(meta);
}
meta.content = CHROME_COLOR[resolved];
}
const prefersReducedMotion = () =>
typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
/** Where a switch was asked for, so the new theme can grow out of that point. */
export type ThemeOrigin = { x: number; y: number };
type ViewTransitionDocument = Document & {
startViewTransition?: (cb: () => void) => { ready: Promise<void>; finished: Promise<void> };
};
/**
* Paints a resolved theme. With an origin the new theme spreads from it as a
* circle where View Transitions exist; otherwise the swap is instant, with every
* transition held for a frame so hundreds of hover fades do not run at once.
*/
// The theme last asked for. A view transition paints a frame later, so the DOM
// alone would let a second call in the same tick repaint under the snapshot.
let target: ResolvedTheme | null = null;
export function applyTheme(resolved: ResolvedTheme, origin?: ThemeOrigin) {
if (typeof document === "undefined") return;
const root = document.documentElement;
const current = target ?? (root.classList.contains("dark") ? "dark" : "light");
if (current === resolved && root.style.colorScheme) return;
target = resolved;
const doc = document as ViewTransitionDocument;
if (origin && doc.startViewTransition && !prefersReducedMotion()) {
root.classList.add("theme-switching");
const t = doc.startViewTransition(() => paint(resolved));
t.ready
.then(() => {
const r = Math.hypot(
Math.max(origin.x, window.innerWidth - origin.x),
Math.max(origin.y, window.innerHeight - origin.y),
);
root.animate(
{ clipPath: [`circle(0px at ${origin.x}px ${origin.y}px)`, `circle(${r}px at ${origin.x}px ${origin.y}px)`] },
{ duration: 520, easing: "cubic-bezier(0.22, 1, 0.36, 1)", pseudoElement: "::view-transition-new(root)" },
);
})
.catch(() => {});
t.finished.finally(() => root.classList.remove("theme-switching"));
return;
}
root.classList.add("theme-instant");
paint(resolved);
// Two frames: one for the new colours to compute, one to paint them.
requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove("theme-instant")));
}
+7
View File
@@ -15,3 +15,10 @@ export function hexToRgba(hex: string, alpha: number): string {
if (full.length !== 6 || Number.isNaN(n)) return `rgba(100,116,139,${alpha})`
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${alpha})`
}
// Text in a user-chosen colour. Picked colours are tuned for white, so the dark
// theme lifts them toward white to stay legible; light-dark() follows the
// element's color-scheme, so a light island keeps the colour as chosen.
export function labelInk(color: string): string {
return `light-dark(${color}, color-mix(in oklab, ${color} 55%, white))`
}
+16 -25
View File
@@ -1,6 +1,7 @@
import type { StateCreator } from 'zustand'
import { applyTheme, readStoredTheme, resolveTheme, storeTheme, type ResolvedTheme, type Theme, type ThemeOrigin } from '@/lib/theme'
export type Theme = 'light' | 'dark' | 'system'
export type { Theme } from '@/lib/theme'
// Unibox list-column bounds. These are the preference's bounds; what the column
// can actually render is additionally capped against the viewport at the drag
@@ -66,7 +67,7 @@ export interface UISlice {
// Theme
theme: Theme
resolvedTheme: 'light' | 'dark'
resolvedTheme: ResolvedTheme
// Modals
tagsModalOpen: boolean
@@ -102,8 +103,9 @@ export interface UISlice {
setUniboxRailFavorites: (favorites: UniboxRailFavorite[]) => void
// Actions - Theme
setTheme: (theme: Theme) => void
setResolvedTheme: (theme: 'light' | 'dark') => void
// origin is where the switch was asked for; the new theme spreads from it.
setTheme: (theme: Theme, origin?: ThemeOrigin) => void
setResolvedTheme: (theme: ResolvedTheme, origin?: ThemeOrigin) => void
// Actions - Modals
setTagsModalOpen: (open: boolean) => void
@@ -120,11 +122,6 @@ export interface UISlice {
setUniboxContactRailOpen: (open: boolean) => void
}
const getInitialTheme = (): Theme => {
if (typeof window === 'undefined') return 'system'
return (localStorage.getItem('theme') as Theme) || 'system'
}
// Rehydration bypasses the setter, so a stored value that is not a map of
// booleans (older build, hand edit) falls back to everything expanded.
export const sanitizeNavCollapsedSections = (v: unknown): Record<string, boolean> => {
@@ -185,14 +182,6 @@ export const applyRailOrder = (defaults: string[], stored: string[] | undefined)
return out
}
// The dashboard is light-only today: every surface is styled on white, so a
// resolved dark theme would flip only the CSS-variable components (command
// palette, toasts) and look broken. 'dark'/'system' are accepted but resolve
// to light until a real dark theme ships.
const getResolvedTheme = (_theme: Theme): 'light' | 'dark' => {
return 'light'
}
export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get) => ({
// Sidebar
navCollapsed: false,
@@ -206,8 +195,8 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
uniboxRailOwner: null,
// Theme
theme: getInitialTheme(),
resolvedTheme: getResolvedTheme(getInitialTheme()),
theme: readStoredTheme(),
resolvedTheme: resolveTheme(readStoredTheme()),
// Modals
tagsModalOpen: false,
@@ -269,15 +258,17 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
setUniboxRailFavorites: (favorites) => set({ uniboxRailFavorites: sanitizeUniboxRailFavorites(favorites) }),
// Actions - Theme
setTheme: (theme) => {
setTheme: (theme, origin) => {
if (get().theme === theme) return
localStorage.setItem('theme', theme)
const resolvedTheme = getResolvedTheme(theme)
document.documentElement.classList.remove('dark')
storeTheme(theme)
const resolvedTheme = resolveTheme(theme)
applyTheme(resolvedTheme, origin)
set({ theme, resolvedTheme })
},
setResolvedTheme: (resolvedTheme) =>
set((state) => (state.resolvedTheme === resolvedTheme ? state : { resolvedTheme })),
setResolvedTheme: (resolvedTheme, origin) => {
applyTheme(resolvedTheme, origin)
set((state) => (state.resolvedTheme === resolvedTheme ? state : { resolvedTheme }))
},
// Actions - Modals
setTagsModalOpen: (tagsModalOpen) =>
+3 -1
View File
@@ -55,11 +55,13 @@ export const useAppStore = create<AppStore>()(
uniboxRailSectionOrder: sanitizeUniboxRailHidden(p.uniboxRailSectionOrder),
uniboxRailFavorites: sanitizeUniboxRailFavorites(p.uniboxRailFavorites),
uniboxRailOwner: typeof p.uniboxRailOwner === 'string' ? p.uniboxRailOwner : null,
// The theme lives under its own key, which index.html reads before first paint.
theme: current.theme,
resolvedTheme: current.resolvedTheme,
}
},
partialize: (state) => ({
// Only persist UI preferences
theme: state.theme,
navCollapsed: state.navCollapsed,
navCollapsedSections: state.navCollapsedSections,
// Assistant panel layout (edge + width + floating window geometry)
File diff suppressed because it is too large Load Diff