From f1e3ea5bd93367ff71200f7d59449d3c7176c1bb Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 3 Oct 2026 08:49:37 -0700 Subject: [PATCH 1/2] feat: add a Linear-style dark theme to the dashboard (web/) with Light, Dark and System options in the user menu, Settings > Profile > Appearance and the command palette, light by default, and an Appearance guide in docs --- docs/content/docs/guides/appearance.mdx | 26 + docs/content/docs/guides/meta.json | 1 + web/index.html | 20 + .../api-keys/_components/CreateKeyModal.tsx | 2 +- web/src/app/app/api-keys/page.tsx | 2 +- web/src/app/app/crm/tasks/page.tsx | 3 +- web/src/app/app/emails/page.tsx | 3 +- .../_components/ProviderGlyph.tsx | 4 +- .../settings/_components/RoleMultiSelect.tsx | 3 +- .../app/settings/_components/SectionShell.tsx | 2 +- web/src/app/app/settings/profile/page.tsx | 10 +- web/src/app/auth/_components/AuthShowcase.tsx | 2 +- web/src/app/auth/layout.tsx | 2 +- web/src/app/cli/page.tsx | 6 +- web/src/app/connect/page.tsx | 6 +- web/src/app/invite/page.tsx | 3 +- web/src/app/layout.tsx | 9 +- web/src/app/select-org/page.tsx | 2 +- .../app/analytics/StatsShareCard.tsx | 2 + .../app/automations/AutomationFlow.tsx | 12 +- .../campaigns/preferences/SenderSelector.tsx | 3 +- .../app/campaigns/sequences/CampaignFlow.tsx | 36 +- .../sequences/nodes/FormLinkNode.tsx | 6 +- .../app/contacts/AddFromContactsDialog.tsx | 4 +- .../app/contacts/CategoryPicker.tsx | 3 +- .../app/contacts/contact-edit/OverviewTab.tsx | 3 +- .../app/emails/add/AddBoxStepButton.tsx | 2 +- .../app/modals/PasskeyEnrollPrompt.tsx | 2 +- .../app/placement/PlacementCharts.tsx | 4 +- .../app/popup/select/TagSelector.tsx | 3 +- web/src/components/app/theme/ThemePicker.tsx | 170 +++ web/src/components/app/theme/themeOptions.ts | 16 + .../app/unibox/ConversationItem.tsx | 6 +- web/src/components/app/unibox/EmailBody.tsx | 36 +- web/src/components/app/unibox/ScopeRail.tsx | 2 +- web/src/components/auth/button.tsx | 2 +- web/src/components/layout/AppHeader.tsx | 2 +- web/src/components/layout/AppLayout.tsx | 11 +- web/src/components/layout/AppNav.tsx | 2 +- web/src/components/layout/AppShell.tsx | 6 +- web/src/components/layout/OrgSwitcher.tsx | 4 +- web/src/components/layout/Page.tsx | 2 +- web/src/components/layout/SkyChrome.tsx | 12 +- web/src/components/layout/ThemeProvider.tsx | 25 +- web/src/components/layout/UpdateDialog.tsx | 2 +- web/src/components/layout/UpgradeDialog.tsx | 2 +- web/src/components/layout/UserNav.tsx | 6 + web/src/components/shared/BrandMark.tsx | 6 +- web/src/components/shared/CommandPalette.tsx | 20 + web/src/components/svg.tsx | 18 +- web/src/components/ui/dither.tsx | 44 +- web/src/global.css | 81 +- web/src/lib/theme.ts | 116 ++ web/src/lib/utils.ts | 7 + web/src/stores/slices/uiSlice.ts | 41 +- web/src/stores/useAppStore.ts | 4 +- web/src/styles/theme-dark.css | 1235 +++++++++++++++++ 57 files changed, 1892 insertions(+), 172 deletions(-) create mode 100644 docs/content/docs/guides/appearance.mdx create mode 100644 web/src/components/app/theme/ThemePicker.tsx create mode 100644 web/src/components/app/theme/themeOptions.ts create mode 100644 web/src/lib/theme.ts create mode 100644 web/src/styles/theme-dark.css diff --git a/docs/content/docs/guides/appearance.mdx b/docs/content/docs/guides/appearance.mdx new file mode 100644 index 000000000..c9035876d --- /dev/null +++ b/docs/content/docs/guides/appearance.mdx @@ -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. diff --git a/docs/content/docs/guides/meta.json b/docs/content/docs/guides/meta.json index 65717bf11..c9564bd62 100644 --- a/docs/content/docs/guides/meta.json +++ b/docs/content/docs/guides/meta.json @@ -46,6 +46,7 @@ "billing", "analytics", "notifications", + "appearance", "security", "workspace-export-import", "team-roles", diff --git a/web/index.html b/web/index.html index 5e1715c5b..7ba5cbab3 100644 --- a/web/index.html +++ b/web/index.html @@ -10,17 +10,37 @@ + Warmbly + + diff --git a/web/src/app/app/api-keys/_components/CreateKeyModal.tsx b/web/src/app/app/api-keys/_components/CreateKeyModal.tsx index 329f6b323..99333680f 100644 --- a/web/src/app/app/api-keys/_components/CreateKeyModal.tsx +++ b/web/src/app/app/api-keys/_components/CreateKeyModal.tsx @@ -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.

-
+
diff --git a/web/src/app/app/api-keys/page.tsx b/web/src/app/app/api-keys/page.tsx index d8a67484c..2e94a4a45 100644 --- a/web/src/app/app/api-keys/page.tsx +++ b/web/src/app/app/api-keys/page.tsx @@ -277,7 +277,7 @@ function CodeSnippet({ prefix }: { prefix: string }) { ); } return ( -
+
diff --git a/web/src/app/app/crm/tasks/page.tsx b/web/src/app/app/crm/tasks/page.tsx index 84375f174..4eaafa541 100644 --- a/web/src/app/app/crm/tasks/page.tsx +++ b/web/src/app/app/crm/tasks/page.tsx @@ -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({ /> } > - + {t.name} diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index 098f27e40..37f45f29d 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -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({ {t.title} diff --git a/web/src/app/app/integrations/_components/ProviderGlyph.tsx b/web/src/app/app/integrations/_components/ProviderGlyph.tsx index ebba2994a..e3a074b70 100644 --- a/web/src/app/app/integrations/_components/ProviderGlyph.tsx +++ b/web/src/app/app/integrations/_components/ProviderGlyph.tsx @@ -81,7 +81,7 @@ export default function ProviderGlyph({ return (
@@ -101,7 +101,7 @@ export default function ProviderGlyph({ return (
diff --git a/web/src/app/app/settings/_components/RoleMultiSelect.tsx b/web/src/app/app/settings/_components/RoleMultiSelect.tsx index 358bdfbd0..611b1106f 100644 --- a/web/src/app/app/settings/_components/RoleMultiSelect.tsx +++ b/web/src/app/app/settings/_components/RoleMultiSelect.tsx @@ -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[] }) { {r.name} diff --git a/web/src/app/app/settings/_components/SectionShell.tsx b/web/src/app/app/settings/_components/SectionShell.tsx index 45d3a4fc6..5af4d13ae 100644 --- a/web/src/app/app/settings/_components/SectionShell.tsx +++ b/web/src/app/app/settings/_components/SectionShell.tsx @@ -38,7 +38,7 @@ export function SectionShell({
-

+

{title}

{description && ( diff --git a/web/src/app/app/settings/profile/page.tsx b/web/src/app/app/settings/profile/page.tsx index ba0cc1f9c..083d6bbaf 100644 --- a/web/src/app/app/settings/profile/page.tsx +++ b/web/src/app/app/settings/profile/page.tsx @@ -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 ( } >
@@ -131,6 +132,13 @@ export default function ProfileSettingsPage() {
+
+ +
+
{slide.mock}
-
+

{slide.title}

diff --git a/web/src/app/auth/layout.tsx b/web/src/app/auth/layout.tsx index 2a5ae9291..ef32be10a 100644 --- a/web/src/app/auth/layout.tsx +++ b/web/src/app/auth/layout.tsx @@ -92,7 +92,7 @@ export default function AuthLayout({
{/* Footer — mobile, on the sky below the card */} -
+
{brand.terms_url && <> Terms · diff --git a/web/src/app/cli/page.tsx b/web/src/app/cli/page.tsx index 861d11937..128d3b1c3 100644 --- a/web/src/app/cli/page.tsx +++ b/web/src/app/cli/page.tsx @@ -154,7 +154,7 @@ function CLIAuthInner() {
-
+
Back to dashboard · About the CLI @@ -181,8 +181,8 @@ function Steps({ current }: { current: "code" | "review" | "done" }) {
diff --git a/web/src/app/connect/page.tsx b/web/src/app/connect/page.tsx index c7cd82911..e37c4a723 100644 --- a/web/src/app/connect/page.tsx +++ b/web/src/app/connect/page.tsx @@ -152,7 +152,7 @@ function ConnectInner() {
-
+
Back to dashboard · How linking works @@ -179,8 +179,8 @@ function Steps({ current }: { current: "code" | "review" | "done" }) {
diff --git a/web/src/app/invite/page.tsx b/web/src/app/invite/page.tsx index 6efcdadf8..989bc693f 100644 --- a/web/src/app/invite/page.tsx +++ b/web/src/app/invite/page.tsx @@ -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() { {r.name} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 749ad4d6f..f81c3186b 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -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 ( - - - + + + + + ); } diff --git a/web/src/app/select-org/page.tsx b/web/src/app/select-org/page.tsx index b028dd810..367572f1d 100644 --- a/web/src/app/select-org/page.tsx +++ b/web/src/app/select-org/page.tsx @@ -135,7 +135,7 @@ function SelectOrgPageInner() { const noWorkspaces = !loading && orgList.length === 0 && inviteList.length === 0; return ( -
+
diff --git a/web/src/components/app/analytics/StatsShareCard.tsx b/web/src/components/app/analytics/StatsShareCard.tsx index f66a2b5ce..e0c65fcba 100644 --- a/web/src/components/app/analytics/StatsShareCard.tsx +++ b/web/src/components/app/analytics/StatsShareCard.tsx @@ -171,6 +171,8 @@ const StatsShareCard = React.forwardRef diff --git a/web/src/components/app/automations/AutomationFlow.tsx b/web/src/components/app/automations/AutomationFlow.tsx index 06147c066..8f51850d2 100644 --- a/web/src/components/app/automations/AutomationFlow.tsx +++ b/web/src/components/app/automations/AutomationFlow.tsx @@ -560,9 +560,9 @@ function ConvergeEdge({
{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 }} > - + diff --git a/web/src/components/app/campaigns/preferences/SenderSelector.tsx b/web/src/components/app/campaigns/preferences/SenderSelector.tsx index 2ead39f88..a6967b642 100644 --- a/web/src/components/app/campaigns/preferences/SenderSelector.tsx +++ b/web/src/components/app/campaigns/preferences/SenderSelector.tsx @@ -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)}`, }} > diff --git a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx index c98b29114..0ccc570cb 100644 --- a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx +++ b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx @@ -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 = {}; 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 }} > - + diff --git a/web/src/components/app/campaigns/sequences/nodes/FormLinkNode.tsx b/web/src/components/app/campaigns/sequences/nodes/FormLinkNode.tsx index 2455f1fdf..4c076e3a1 100644 --- a/web/src/components/app/campaigns/sequences/nodes/FormLinkNode.tsx +++ b/web/src/components/app/campaigns/sequences/nodes/FormLinkNode.tsx @@ -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 diff --git a/web/src/components/app/contacts/AddFromContactsDialog.tsx b/web/src/components/app/contacts/AddFromContactsDialog.tsx index be1b06392..4c564e7dc 100644 --- a/web/src/components/app/contacts/AddFromContactsDialog.tsx +++ b/web/src/components/app/contacts/AddFromContactsDialog.tsx @@ -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), }} > diff --git a/web/src/components/app/contacts/CategoryPicker.tsx b/web/src/components/app/contacts/CategoryPicker.tsx index dc8fe2767..47a32f4d2 100644 --- a/web/src/components/app/contacts/CategoryPicker.tsx +++ b/web/src/components/app/contacts/CategoryPicker.tsx @@ -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)}`, }} > diff --git a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx index 6c8c003aa..c10be5746 100644 --- a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx +++ b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx @@ -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} diff --git a/web/src/components/app/emails/add/AddBoxStepButton.tsx b/web/src/components/app/emails/add/AddBoxStepButton.tsx index 04cada809..563c3eb63 100644 --- a/web/src/components/app/emails/add/AddBoxStepButton.tsx +++ b/web/src/components/app/emails/add/AddBoxStepButton.tsx @@ -7,6 +7,6 @@ export default function AddBoxStepButton({ next = false, tt, loading = false, on {!next && }
{!tt ? next ? "Next" : "Back" : tt}
{next && } - : } + : } } diff --git a/web/src/components/app/modals/PasskeyEnrollPrompt.tsx b/web/src/components/app/modals/PasskeyEnrollPrompt.tsx index d893ec9d3..d13d3c25f 100644 --- a/web/src/components/app/modals/PasskeyEnrollPrompt.tsx +++ b/web/src/components/app/modals/PasskeyEnrollPrompt.tsx @@ -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 && } Set up passkey diff --git a/web/src/components/app/placement/PlacementCharts.tsx b/web/src/components/app/placement/PlacementCharts.tsx index 11f998687..4c3bf1221 100644 --- a/web/src/components/app/placement/PlacementCharts.tsx +++ b/web/src/components/app/placement/PlacementCharts.tsx @@ -229,7 +229,7 @@ export function RateTrend({ days, height = 120, windowDays = 7 }: { days: DayVie {segments.map((d, i) => ( ))} - {hover != null && } + {hover != null && } {hover != null && values[hover] != null && ( - + diff --git a/web/src/components/app/popup/select/TagSelector.tsx b/web/src/components/app/popup/select/TagSelector.tsx index bafc8837a..8ad2e424b 100644 --- a/web/src/components/app/popup/select/TagSelector.tsx +++ b/web/src/components/app/popup/select/TagSelector.tsx @@ -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)}`, }} > diff --git a/web/src/components/app/theme/ThemePicker.tsx b/web/src/components/app/theme/ThemePicker.tsx new file mode 100644 index 000000000..bcfbeb920 --- /dev/null +++ b/web/src/components/app/theme/ThemePicker.tsx @@ -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) => { + 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(`[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 ( +
+ {THEME_OPTIONS.map(({ value, label, icon: Icon }) => { + const active = theme === value; + return ( + + ); + })} +
+ ); +} + +/** 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 ( +
+ {THEME_OPTIONS.map(({ value, label, icon: Icon }) => { + const active = theme === value; + return ( + + ); + })} +
+ ); +} + +// 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 ( +
+
+ + + + +
+
+
+ + +
+ {[0.9, 0.7, 0.8].map((w, i) => ( +
+ + +
+ ))} +
+
+ ); +} + +function ThemeThumb({ variant }: { variant: Theme }) { + return ( + + + {variant === "system" && ( + + + + )} + + ); +} diff --git a/web/src/components/app/theme/themeOptions.ts b/web/src/components/app/theme/themeOptions.ts new file mode 100644 index 000000000..079eb8545 --- /dev/null +++ b/web/src/components/app/theme/themeOptions.ts @@ -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 }; +} diff --git a/web/src/components/app/unibox/ConversationItem.tsx b/web/src/components/app/unibox/ConversationItem.tsx index 6b2275ec3..210ee5733 100644 --- a/web/src/components/app/unibox/ConversationItem.tsx +++ b/web/src/components/app/unibox/ConversationItem.tsx @@ -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 }) { diff --git a/web/src/components/app/unibox/EmailBody.tsx b/web/src/components/app/unibox/EmailBody.tsx index 6a5100063..f1847718d 100644 --- a/web/src/components/app/unibox/EmailBody.tsx +++ b/web/src/components/app/unibox/EmailBody.tsx @@ -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 = /]|\bbgcolor\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 would land in the body, and the frame would preview something the // recipient will never see. Its own gets our shell instead. const DOCUMENT_ROOT = /^\s*(?:\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 ? `` : ""; return ( policy + `` + - `` + `` ); } -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 && ( diff --git a/web/src/components/app/unibox/ScopeRail.tsx b/web/src/components/app/unibox/ScopeRail.tsx index 98ad6dfb9..1b9812ddf 100644 --- a/web/src/components/app/unibox/ScopeRail.tsx +++ b/web/src/components/app/unibox/ScopeRail.tsx @@ -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" diff --git a/web/src/components/auth/button.tsx b/web/src/components/auth/button.tsx index 4dddf663e..4d4f2efbf 100644 --- a/web/src/components/auth/button.tsx +++ b/web/src/components/auth/button.tsx @@ -6,7 +6,7 @@ export default function AuthButton({ children, loading }: { children: React.Reac } diff --git a/web/src/components/app/unibox/EmailBody.tsx b/web/src/components/app/unibox/EmailBody.tsx index f1847718d..bc2bb3896 100644 --- a/web/src/components/app/unibox/EmailBody.tsx +++ b/web/src/components/app/unibox/EmailBody.tsx @@ -123,7 +123,7 @@ const DARK_DOCUMENT_CSS = ` blockquote { border-left-color: #2e2f34; color: #8a8f98; } `; -const DESIGNED = /]|\bbgcolor\s*=|\b(?:background|color)\s*:/i; +const DESIGNED = /]|<[^>]*\s(?:bgcolor|color|text)\s*=|\b(?:background|color)\s*:/i; const isDesignedEmail = (body: string) => DESIGNED.test(body); diff --git a/web/src/components/layout/ThemeProvider.test.tsx b/web/src/components/layout/ThemeProvider.test.tsx new file mode 100644 index 000000000..e1b9c286f --- /dev/null +++ b/web/src/components/layout/ThemeProvider.test.tsx @@ -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(
) + 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(
) + 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) + }) +}) diff --git a/web/src/components/layout/ThemeProvider.tsx b/web/src/components/layout/ThemeProvider.tsx index eed893cce..0cc429d1f 100644 --- a/web/src/components/layout/ThemeProvider.tsx +++ b/web/src/components/layout/ThemeProvider.tsx @@ -1,12 +1,11 @@ import { useEffect } from 'react' import { useAppStore } from '@/stores' -import { DEFAULT_THEME, isTheme, onSystemThemeChange, resolveTheme, THEME_STORAGE_KEY } from '@/lib/theme' +import { onSystemThemeChange, readStoredTheme, resolveTheme, THEME_STORAGE_KEY } from '@/lib/theme' // 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 setTheme = useAppStore((state) => state.setTheme) const setResolvedTheme = useAppStore((state) => state.setResolvedTheme) useEffect(() => { @@ -16,13 +15,14 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { }, [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) return - setTheme(isTheme(e.newValue) ? e.newValue : DEFAULT_THEME) + if (e.key !== THEME_STORAGE_KEY && e.key !== null) return + useAppStore.setState({ theme: readStoredTheme() }) } window.addEventListener('storage', onStorage) return () => window.removeEventListener('storage', onStorage) - }, [setTheme]) + }, []) return <>{children} } diff --git a/web/src/components/ui/dither.tsx b/web/src/components/ui/dither.tsx index dd5685f46..64a58b4c3 100644 --- a/web/src/components/ui/dither.tsx +++ b/web/src/components/ui/dither.tsx @@ -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. @@ -44,8 +45,7 @@ const TONES = { export type DitherTone = keyof typeof TONES; // The same tones drawn for a dark ground: lighter and calmer, so dots glow -// softly instead of buzzing. Read at paint time, so a chart picks up a theme -// switch on its next repaint. +// softly instead of buzzing. Charts take the theme as an input so a switch repaints them. const DARK_TONES: Record = { sky: [96, 174, 226], amber: [226, 176, 92], @@ -54,10 +54,9 @@ const DARK_TONES: Record = { violet: [162, 142, 236], slate: [150, 155, 165], }; -const toneRgb = (tone: DitherTone): readonly [number, number, number] => - typeof document !== "undefined" && document.documentElement.classList.contains("dark") - ? DARK_TONES[tone] - : TONES[tone]; +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. @@ -128,6 +127,7 @@ export function DitherBarChart({ selected?: number | null; onSelect?: (i: number) => void; }) { + const dark = useDarkTheme(); const reduced = useReducedMotion(); const [wrapRef, { w }] = useMeasure(); const canvasRef = React.useRef(null); @@ -145,7 +145,7 @@ export function DitherBarChart({ const H = el.height; const img = ctx.createImageData(W, H); const buf = img.data; - const [cr, cg, cb] = toneRgb(tone); + const [cr, cg, cb] = toneRgb(tone, dark); const n = data.length; if (n > 0) { const slot = W / n; @@ -189,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(() => { @@ -614,6 +614,7 @@ export function DitherAreaChart({ className?: string; renderTooltip?: (d: DitherBarDatum) => React.ReactNode; }) { + const dark = useDarkTheme(); const reduced = useReducedMotion(); const [wrapRef, { w }] = useMeasure(); const canvasRef = React.useRef(null); @@ -646,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 = toneRgb(tone); + const rgb = toneRgb(tone, dark); const revealX = reveal.current * cols; const lift = intensity.current; for (let x = 0; x < cols; x++) { @@ -662,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(() => [toneRgb(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) => { @@ -681,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] = toneRgb(tone); + const [cr, cg, cb] = toneRgb(tone, dark); return (
React.ReactNode; }) { + const dark = useDarkTheme(); const reduced = useReducedMotion(); const [wrapRef, { w }] = useMeasure(); const canvasRef = React.useRef(null); @@ -794,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(toneRgb(series[s].tone)); + rgbs.push(toneRgb(series[s].tone, dark)); } const tops = new Array(S); for (let x = 0; x < cols; x++) { @@ -810,18 +812,18 @@ export function DitherMultiAreaChart({ height, series.map((s, si) => ({ ys: (disp.current[si] ?? s.values).map((v) => yCssFor(v) * dpr), - rgb: toneRgb(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) => toneRgb(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) => { @@ -864,7 +866,7 @@ export function DitherMultiAreaChart({ style={{ left: hoverX }} /> {series.map((s) => { - const [cr, cg, cb] = toneRgb(s.tone); + const [cr, cg, cb] = toneRgb(s.tone, dark); return (
{labels[hover]}
{series.map((s) => { - const [cr, cg, cb] = toneRgb(s.tone); + const [cr, cg, cb] = toneRgb(s.tone, dark); return (
(); const canvasRef = React.useRef(null); @@ -941,7 +944,7 @@ export function DitherMeter({ const H = el.height; const img = ctx.createImageData(W, H); const buf = img.data; - const [cr, cg, cb] = toneRgb(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++) { @@ -966,7 +969,7 @@ export function DitherMeter({ } ctx.putImageData(img, 0, 0); }, - [w, height, tone], + [w, height, tone, dark], ); React.useEffect(() => { @@ -1014,6 +1017,7 @@ export function DitherColumns({ className?: string; onHover?: (i: number | null) => void; }) { + const dark = useDarkTheme(); const reduced = useReducedMotion(); const [wrapRef, { w }] = useMeasure(); const canvasRef = React.useRef(null); @@ -1048,7 +1052,7 @@ export function DitherColumns({ const y1 = H - (acc / max) * p * H; acc += v; const y0 = H - (acc / max) * p * H; - const rgb = toneRgb(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; @@ -1064,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(() => { @@ -1135,6 +1139,7 @@ export function DitherStack({ height?: number; className?: string; }) { + const dark = useDarkTheme(); const reduced = useReducedMotion(); const [wrapRef, { w }] = useMeasure(); const canvasRef = React.useRef(null); @@ -1156,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: toneRgb(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++) { @@ -1175,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(() => { @@ -1225,6 +1230,7 @@ export function DitherRing({ className?: string; children?: React.ReactNode; }) { + const dark = useDarkTheme(); const reduced = useReducedMotion(); const canvasRef = React.useRef(null); const curRef = React.useRef(0); @@ -1239,7 +1245,7 @@ export function DitherRing({ const S = el.width; const img = ctx.createImageData(S, S); const buf = img.data; - const [cr, cg, cb] = toneRgb(tone); + const [cr, cg, cb] = toneRgb(tone, dark); const c = S / 2; const rOut = c; const rIn = c - thickness * dpr; @@ -1272,7 +1278,7 @@ export function DitherRing({ } ctx.putImageData(img, 0, 0); }, - [size, thickness, tone], + [size, thickness, tone, dark], ); React.useEffect(() => { @@ -1325,9 +1331,10 @@ export function DitherSlider({ className?: string; label?: string; }) { + const dark = useDarkTheme(); const trackRef = React.useRef(null); const frac = max > min ? clamp01((value - min) / (max - min)) : 0; - const [cr, cg, cb] = toneRgb(tone); + const [cr, cg, cb] = toneRgb(tone, dark); const snap = (raw: number) => Math.max(min, Math.min(max, Math.round(raw / step) * step)); diff --git a/web/src/global.css b/web/src/global.css index 569249c33..d387335cb 100644 --- a/web/src/global.css +++ b/web/src/global.css @@ -640,13 +640,17 @@ svg.loading circle { color: #0369a1; /* sky-700 */ } -/* The editor surface in the dark theme. A block that paints its own background - (a template's table cell or banner) keeps dark text, as the recipient sees it. */ +/* 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 [style*="background"] { color: #0f172a; } +.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; }