[client] Add light mode with system, light, and dark theme options (#7344)

* desktop UI light mode

* Theme review fixes plus macOS window outline fix

* Windows runtime chrome re-theming plus apply serialization

* Windows chrome threading and theme event ordering fixes

* Darken toggle and setting sidebar text

* resolve theme appearance, apply on UI thread

* read theme once per window

* Re-assert Windows dark opt-in after SetTheme

* split app-wide GTK theming from per-window chrome

* Update Wails dependency and checksums

* KDE tray icon panel fix

* Five review fixes: theme ordering, cgo dedup, KDE panel resolution

* Path guard hardening, toggle contrast, windows comment

* non-vacuous escape tests

* Default view edits

* Polish settings nav, controls, borders, and disc

* Profiles settings boarder, modals, and buttons

* Additional edits based on feedback

* Switch colors away from slight blue hue

* Update missing lang

* Fix vertical tab active view
This commit is contained in:
Brandon Hopkins
2026-09-11 08:25:10 -07:00
committed by GitHub
parent f422c41654
commit ec0c36b0e7
68 changed files with 1853 additions and 259 deletions
@@ -20,6 +20,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible";
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
import * as Popover from "@radix-ui/react-popover";
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
import netbirdFullLogoLight from "@/assets/logos/netbird-full-light.svg";
enum ConnectionState {
Disconnected = "disconnected",
@@ -224,10 +225,16 @@ export const MainConnectionStatusSwitch = () => {
className={cn("flex h-full w-full flex-col items-center gap-4", "relative")}
style={{ top: contentTop("11.7rem") }}
>
<img
src={netbirdFullLogoLight}
alt={"NetBird"}
className={"wails-no-draggable mb-4 h-7 w-auto select-none dark:hidden"}
draggable={false}
/>
<img
src={netbirdFullLogo}
alt={"NetBird"}
className={"wails-no-draggable mb-4 h-7 w-auto select-none"}
className={"wails-no-draggable mb-4 hidden h-7 w-auto select-none dark:block"}
draggable={false}
/>
@@ -321,7 +328,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
className={cn(
"group relative inline-flex cursor-default items-center rounded-sm outline-none",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"transition-colors",
)}
>
@@ -395,7 +402,7 @@ const IpRow = ({ value }: { value: string }) => {
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
)}
>
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
@@ -156,14 +156,14 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
disabled={disabled}
className={cn(
"flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none",
"border border-nb-gray-920 bg-nb-gray-940",
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
"transition-colors duration-150",
"wails-no-draggable",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
disabled
? "cursor-not-allowed opacity-60"
: "cursor-default hover:border-nb-gray-900 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-900 data-[state=open]:bg-nb-gray-935",
: "cursor-default hover:border-nb-gray-700 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-700 data-[state=open]:bg-nb-gray-935 dark:hover:border-nb-gray-900 dark:data-[state=open]:border-nb-gray-900",
className,
)}
{...props}
@@ -74,7 +74,9 @@ export const MainHeader = () => {
<IconButton
icon={MoreVertical}
iconClassName={"text-nb-gray-200 wails-no-draggable"}
className={"select-none"}
className={
"select-none hover:bg-nb-gray-800 data-[state=open]:bg-nb-gray-800 dark:hover:bg-nb-gray-900 dark:data-[state=open]:bg-nb-gray-900"
}
aria-label={t("header.menu.open")}
aria-haspopup={"menu"}
aria-expanded={menuOpen}
@@ -108,7 +108,7 @@ export const Navigation = () => {
"outline-none transition-all",
isFirst && "rounded-tl-xl",
isLast && "rounded-tr-xl",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
)}
@@ -43,7 +43,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
"text-sm text-nb-gray-200",
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:pointer-events-none disabled:opacity-50",
"wails-no-draggable cursor-default",
)}
@@ -223,7 +223,7 @@ export const Networks = () => {
"text-xs font-medium text-nb-gray-100",
"border border-nb-gray-900 bg-nb-gray-920 hover:border-nb-gray-850 hover:bg-nb-gray-910",
"wails-no-draggable cursor-pointer outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
)}
>
{bulkLabel}
@@ -358,7 +358,7 @@ const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: Netwo
onKeyDown={handleKey}
className={cn(
"absolute inset-0 cursor-pointer outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
)}
/>
<ResourceIconBadge type={resourceTypeOf(n)} />
@@ -396,7 +396,8 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
aria-hidden={"true"}
className={cn(
"mt-[0.25rem] flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
"border border-nb-gray-900 bg-nb-gray-920 text-nb-gray-300",
"border border-nb-gray-800 bg-white text-nb-gray-300 transition-colors group-hover:border-nb-gray-700",
"dark:border-nb-gray-900 dark:bg-nb-gray-920 dark:group-hover:border-nb-gray-900",
)}
>
<Icon size={14} />
@@ -461,7 +462,7 @@ const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
keepOpenOnClick
contentClassName={cn(
"max-h-72 max-w-[18rem] overflow-auto",
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935",
"p-2 pr-4",
)}
>
@@ -54,9 +54,9 @@ const DASH = "-";
const dotClass = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "bg-green-400";
return "bg-green-500 dark:bg-green-400";
case "Connecting":
return "bg-yellow-300 animate-pulse-slow";
return "bg-yellow-500 animate-pulse-slow dark:bg-yellow-300";
default:
return "bg-nb-gray-500";
}
@@ -195,7 +195,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
"cursor-default outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable",
)}
>
@@ -235,7 +235,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
"cursor-default outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable",
"disabled:opacity-50 disabled:hover:bg-transparent",
)}
@@ -468,7 +468,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
"border border-nb-gray-900",
"py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300",
"wails-no-draggable cursor-default outline-none transition-all",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
)}
>
{networks.length}
@@ -530,7 +530,7 @@ const ResourceRow = ({ value }: { value: string }) => {
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
)}
>
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
@@ -43,7 +43,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
"text-sm text-nb-gray-200",
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:pointer-events-none disabled:opacity-50",
"wails-no-draggable cursor-default",
)}
@@ -22,9 +22,9 @@ const isOnline = (connStatus: string) => connStatus === "Connected";
const dotClass = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "bg-green-400";
return "bg-green-500 dark:bg-green-400";
case "Connecting":
return "bg-yellow-300 animate-pulse-slow";
return "bg-yellow-500 animate-pulse-slow dark:bg-yellow-300";
default:
return "bg-nb-gray-500";
}
@@ -287,7 +287,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
onKeyDown={handleKey}
className={cn(
"absolute inset-0 cursor-default outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
)}
/>
<Tooltip content={statusLabel} side={"left"}>
@@ -92,7 +92,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
listRef.current?.focus();
}}
className={cn(
"wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
"wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-800 bg-nb-gray-950 p-1 text-nb-gray-200 shadow-lg dark:border-nb-gray-900 dark:bg-nb-gray-935",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
@@ -211,11 +211,11 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
aria-haspopup={"listbox"}
className={cn(
"wails-no-draggable flex h-10 cursor-default select-none items-center gap-2 rounded-lg px-3 outline-none",
"text-nb-gray-200 hover:bg-nb-gray-900",
"data-[state=open]:bg-nb-gray-900",
"disabled:opacity-50 disabled:hover:bg-transparent",
"text-nb-gray-200 hover:bg-nb-gray-800 dark:hover:bg-nb-gray-900",
"data-[state=open]:bg-nb-gray-800 dark:data-[state=open]:bg-nb-gray-900",
"disabled:opacity-50 disabled:hover:bg-transparent dark:disabled:hover:bg-transparent",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable transition-colors duration-150",
className,
)}
@@ -177,7 +177,7 @@ export function ProfilesTab() {
<div
className={cn(
"overflow-hidden rounded-xl border border-nb-gray-900 bg-nb-gray-930/60",
"overflow-hidden rounded-xl border border-nb-gray-800 bg-nb-gray-930/60 dark:border-nb-gray-900",
)}
>
<ProfilesTable
@@ -411,7 +411,7 @@ const ProfileRow = ({
"outline-none",
isFirst && "rounded-t-xl",
isLast && "rounded-b-xl",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
)}
>
<td
@@ -560,7 +560,7 @@ const RowMoreMenu = ({
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
"transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-100",
)}
>
@@ -654,7 +654,7 @@ const ActionIconButton = ({
className={cn(
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
"transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
variant === "danger"
? "text-nb-gray-400 hover:bg-red-500/10 hover:text-red-500"
: "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react";
import netbirdFull from "@/assets/logos/netbird-full.svg";
import netbirdFullLight from "@/assets/logos/netbird-full-light.svg";
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
@@ -90,7 +91,16 @@ export function SettingsAbout() {
"mx-auto flex min-h-[calc(100vh-12rem)] max-w-2xl flex-col items-center justify-center gap-4"
}
>
<img src={netbirdFull} alt={t("common.netbird")} className={"h-7 w-auto"} />
<img
src={netbirdFullLight}
alt={t("common.netbird")}
className={"h-7 w-auto dark:hidden"}
/>
<img
src={netbirdFull}
alt={t("common.netbird")}
className={"hidden h-7 w-auto dark:block"}
/>
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<button
type={"button"}
@@ -139,7 +149,7 @@ export function SettingsAbout() {
tabIndex={0}
onClick={() => openUrl(url)}
className={
"inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
"inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
}
>
<Icon aria-hidden={"true"} className={iconClassName ?? "h-3.5 w-3.5"} />
@@ -157,7 +167,7 @@ export function SettingsAbout() {
tabIndex={0}
onClick={() => openUrl(link.url)}
className={
"rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
"rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
}
>
{link.label}
@@ -10,6 +10,7 @@ import { useAutostartSetting, useSettings } from "@/contexts/SettingsContext.tsx
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx";
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
import { ThemePicker } from "@/components/ThemePicker.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
@@ -36,6 +37,7 @@ export function SettingsGeneral() {
<>
<SectionGroup title={t("settings.general.section.general")}>
<LanguagePicker />
<ThemePicker />
<FancyToggleSwitch
value={!config.disableNotifications}
onChange={(v) => setField("disableNotifications", !v)}
@@ -16,7 +16,7 @@ export const SectionGroup = ({
{...(disabled ? { inert: "" } : {})}
className={cn(
"mb-8 rounded-md px-1 outline-none last:mb-1",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
disabled && "pointer-events-none opacity-30",
)}
>
@@ -33,7 +33,7 @@ export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
<div className={"absolute bottom-0 left-0 w-full"}>
<div
className={
"flex w-full justify-end gap-3 border-t border-nb-gray-920 bg-nb-gray-940 px-8 py-5"
"flex w-full justify-end gap-3 border-t border-nb-gray-800 bg-nb-gray-940 px-8 py-5 dark:border-nb-gray-920"
}
>
{children}
@@ -99,7 +99,7 @@ export function SettingsTroubleshooting() {
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
)}
>
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
@@ -277,7 +277,10 @@ function DoneResult({
};
return (
<CenteredPanel>
<SquareIcon icon={CircleCheckBig} className={"[&_svg]:text-green-500"} />
<SquareIcon
icon={CircleCheckBig}
className={"bg-white dark:bg-nb-gray-920 [&_svg]:text-green-500"}
/>
<div className={"flex max-w-sm flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>
@@ -326,7 +329,9 @@ function DoneResult({
<button
type={"button"}
onClick={onRevealPath}
className={"pointer-events-auto transition-all hover:text-white"}
className={
"pointer-events-auto transition-all hover:text-nb-gray-50"
}
aria-label={t("settings.troubleshooting.done.openFileLocation")}
>
<FolderOpen size={16} aria-hidden={"true"} />
@@ -339,7 +344,7 @@ function DoneResult({
<div
role={"alert"}
className={
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-700 dark:text-red-300"
}
>
{result.uploadFailureReason