mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 00:02:15 +00:00
Merge remote-tracking branch 'origin/main' into feature/integrations-page-redesign
This commit is contained in:
@@ -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.
|
||||
@@ -47,6 +47,7 @@
|
||||
"billing",
|
||||
"analytics",
|
||||
"notifications",
|
||||
"appearance",
|
||||
"security",
|
||||
"workspace-export-import",
|
||||
"team-roles",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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}</>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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))`
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user