diff --git a/.gitignore b/.gitignore index e57e0d8bd..8d7082d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts +# local UI sandbox (throwaway preview harness — never commit) +/src/app/sandbox/ + # config .local*config*.json .test-config.json @@ -53,4 +56,6 @@ playwright-report*.zip /public/ironrdp-pkg/ /public/netbird.wasm .idea -src/.local-config* \ No newline at end of file +src/.local-config* +# local mock data (throwaway preview — never commit) +/src/utils/mockData.ts diff --git a/src/app/(dashboard)/(cloud)/integrations/page.tsx b/src/app/(dashboard)/(cloud)/integrations/page.tsx index d33e956dc..9c3f1053d 100644 --- a/src/app/(dashboard)/(cloud)/integrations/page.tsx +++ b/src/app/(dashboard)/(cloud)/integrations/page.tsx @@ -60,7 +60,7 @@ export default function Integrations() { (!isNetBirdCloud() && (permission?.settings?.read ?? false)) } > -
+
diff --git a/src/app/(dashboard)/agent-network/configuration/page.tsx b/src/app/(dashboard)/agent-network/configuration/page.tsx index 2d32b87b6..0f47d2d15 100644 --- a/src/app/(dashboard)/agent-network/configuration/page.tsx +++ b/src/app/(dashboard)/agent-network/configuration/page.tsx @@ -87,7 +87,7 @@ export default function AgentNetworkConfigurationPage() { -
+
- + { >
{
@@ -741,7 +741,7 @@ function EditableValue({ {value} {canEdit && (
-

+

Account Created!

@@ -194,7 +194,7 @@ function InviteAcceptContent() {
-

+

Invite Expired

@@ -221,7 +221,7 @@ function InviteAcceptContent() {
-

+

Welcome to NetBird

@@ -235,7 +235,7 @@ function InviteAcceptContent() {

-
{inviteInfo.name}
+
{inviteInfo.name}
{inviteInfo.email} diff --git a/src/assets/netbird-full-light.svg b/src/assets/netbird-full-light.svg new file mode 100644 index 000000000..3457b50c6 --- /dev/null +++ b/src/assets/netbird-full-light.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/cloud/traffic-events/table/TrafficEventsDetailRow.tsx b/src/cloud/traffic-events/table/TrafficEventsDetailRow.tsx index a174a96e7..bf4b7820a 100644 --- a/src/cloud/traffic-events/table/TrafficEventsDetailRow.tsx +++ b/src/cloud/traffic-events/table/TrafficEventsDetailRow.tsx @@ -143,7 +143,7 @@ const PolicyListItem = ({ {policy?.name} diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index bd4951673..dea6990f2 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -17,21 +17,43 @@ const variants = cva("", { blue: [ "bg-sky-100 border-sky-500 text-sky-800 border border-transparent", ], - blueDark: ["bg-sky-900 border-sky-500 text-white border"], - "blue-darker": ["bg-sky-900 border-sky-500 text-white border"], - red: ["bg-red-950/40 border-red-500 border text-red-500"], - purple: ["bg-purple-950/50 border-purple-500 border text-purple-500"], - yellow: ["bg-yellow-950 border-yellow-500 border text-yellow-400"], - gray: ["bg-nb-gray-930/60 border-nb-gray-800/40 text-nb-gray-300 border"], - lightGray: ["bg-nb-gray-910 text-nb-gray-200 border border-nb-gray-900"], + blueDark: [ + "bg-sky-100 border-sky-500 text-sky-800 border dark:bg-sky-900 dark:text-white", + ], + "blue-darker": [ + "bg-sky-100 border-sky-500 text-sky-800 border dark:bg-sky-900 dark:text-white", + ], + red: [ + "bg-red-100 border-red-500 border text-red-700 dark:bg-red-950/40 dark:text-red-500", + ], + purple: [ + "bg-purple-100 border-purple-500 border text-purple-700 dark:bg-purple-950/50 dark:text-purple-500", + ], + yellow: [ + "bg-yellow-100 border-yellow-500 border text-yellow-700 dark:bg-yellow-950 dark:text-yellow-400", + ], + gray: [ + "bg-white border-nb-gray-700 text-nb-gray-300 border", + "dark:bg-nb-gray-930/60 dark:border-nb-gray-800/40", + ], + lightGray: [ + "bg-white border-nb-gray-700 text-nb-gray-200 border", + "dark:bg-nb-gray-910 dark:border-nb-gray-900", + ], grayer: [ - "bg-nb-gray-900/40 border-nb-gray-800/40 text-nb-gray-300 border", + "bg-white border-nb-gray-700 text-nb-gray-300 border", + "dark:bg-nb-gray-900/40 dark:border-nb-gray-800/40", ], "gray-ghost": [ - "bg-nb-gray-900 border-nb-gray-800 text-nb-gray-300 border border-nb-gray-800/50", + "bg-white border-nb-gray-700 text-nb-gray-300 border", + "dark:bg-nb-gray-900 dark:border-nb-gray-800/50", + ], + green: [ + "bg-green-100 border-green-500 border text-green-700 dark:bg-green-950 dark:text-green-400", + ], + netbird: [ + "bg-netbird-100 border-netbird-500 border text-netbird-700 dark:bg-netbird-950 dark:text-netbird-500", ], - green: ["bg-green-950 border-green-500 border text-green-400"], - netbird: ["bg-netbird-950 border-netbird-500 border text-netbird-500"], }, size: { default: "text-[0.75rem] py-1.5 px-3", @@ -40,17 +62,21 @@ const variants = cva("", { hover: { none: [], blue: ["hover:bg-sky-200"], - purple: ["hover:bg-purple-950/40"], - yellow: ["hover:bg-yellow-950/40"], - blueDark: ["hover:bg-sky-800"], - "blue-darker": ["hover:bg-sky-800"], - red: ["hover:bg-red-950/40"], - gray: ["hover:bg-nb-gray-900"], - lightGray: ["hover:bg-nb-gray-900"], - grayer: ["hover:bg-nb-gray-900"], - "gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"], - green: ["hover:bg-green-950/50"], - netbird: ["hover:bg-netbird-950/50"], + purple: ["hover:bg-purple-200 dark:hover:bg-purple-950/40"], + yellow: ["hover:bg-yellow-200 dark:hover:bg-yellow-950/40"], + blueDark: ["hover:bg-sky-200 dark:hover:bg-sky-800"], + "blue-darker": ["hover:bg-sky-200 dark:hover:bg-sky-800"], + red: ["hover:bg-red-200 dark:hover:bg-red-950/40"], + /* Light mode hovers bg-neutral-100 to match Button's secondary variant — + white-based chips and buttons sit side by side in table rows. */ + gray: ["hover:bg-neutral-100 dark:hover:bg-nb-gray-900"], + lightGray: ["hover:bg-neutral-100 dark:hover:bg-nb-gray-900"], + grayer: ["hover:bg-neutral-100 dark:hover:bg-nb-gray-900"], + "gray-ghost": [ + "hover:bg-neutral-100 dark:hover:bg-nb-gray-800 cursor-pointer", + ], + green: ["hover:bg-green-200 dark:hover:bg-green-950/50"], + netbird: ["hover:bg-netbird-200 dark:hover:bg-netbird-950/50"], }, }, }); diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 143a2b7cb..a83bc8c54 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -24,65 +24,65 @@ export const buttonVariants = cva( variants: { variant: { default: [ - "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "bg-white hover:text-black focus:ring-neutral-200/50 hover:bg-neutral-100 border-nb-gray-700 text-neutral-900", "dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50", ], primary: [ + "border-transparent", "dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80", "enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500", ], secondary: [ - "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "bg-white hover:text-black focus:ring-neutral-200/50 hover:bg-neutral-100 border-nb-gray-700 text-neutral-900", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ", "dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910", ], secondaryLighter: [ - "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "bg-white hover:text-black focus:ring-neutral-200/50 hover:bg-neutral-100 border-nb-gray-700 text-neutral-900", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ", "dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60", ], input: [ - "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900", + "bg-white hover:text-black focus:ring-neutral-200/50 hover:bg-neutral-100 border-nb-gray-700 text-neutral-900", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ", "dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80", ], dropdown: [ - "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900", + "bg-white hover:text-black focus:ring-neutral-200/50 hover:bg-neutral-100 border-nb-gray-700 text-neutral-900", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ", "dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50", ], dotted: [ - "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed", + "bg-white hover:text-black focus:ring-neutral-200/50 hover:bg-neutral-100 border-nb-gray-700 text-neutral-900 border-dashed", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ", "dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-nb-gray-900/50", ], tertiary: [ - "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "bg-white hover:text-black focus:ring-neutral-200/50 hover:bg-neutral-100 border-nb-gray-700 text-neutral-900", "dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", ], white: [ - "focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + "focus:ring-white/50 bg-white text-neutral-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", "disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900", ], outline: [ - "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "bg-white hover:text-black focus:ring-neutral-200/50 hover:bg-neutral-100 border-nb-gray-700 text-neutral-900", "dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30", ], "danger-outline": [ - "", // TODO - add danger button styles for light mode - "enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500", - "", + "bg-transparent text-red-600 border-red-300 hover:bg-red-50 hover:border-red-400 focus:ring-red-500/30", + "enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500 dark:border-transparent", ], "danger-text": [ - "dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm", + "bg-transparent text-red-600 hover:text-red-700 dark:text-red-500 dark:hover:text-red-600 border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 ring-offset-nb-gray-950/50 rounded-sm", ], "default-outline": [ - "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", - "dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50", - "data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50", + "ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20", + "bg-transparent text-nb-gray-400 border-transparent hover:text-nb-gray-50 hover:bg-nb-gray-900/30 hover:border-nb-gray-800/50", + "data-[state=open]:text-nb-gray-50 data-[state=open]:bg-nb-gray-900/30 data-[state=open]:border-nb-gray-800/50", ], danger: [ - "", // TODO - add danger button styles for light mode + "enabled:bg-red-600 enabled:text-white enabled:hover:bg-red-700 enabled:focus:bg-red-700 enabled:focus:ring-red-400/50 border-red-600 disabled:bg-red-300 disabled:text-white", "dark:focus:ring-red-700/20 dark:focus:bg-red-700 hover:dark:bg-red-700 dark:hover:border-red-800/50 dark:bg-red-600 dark:text-red-100", ], }, @@ -98,8 +98,11 @@ export const buttonVariants = cva( false: "", }, border: { - 0: "border", - 1: "border border-transparent", + 0: "border-0", + /* No border-transparent here: it compiles after the variants' border + colors and would override them, leaving light mode outline-less. + Variants without a border color set border-transparent themselves. */ + 1: "border", 2: "border border-t-0 border-b-0", }, }, diff --git a/src/components/Callout.tsx b/src/components/Callout.tsx index e5a327585..801999d6d 100644 --- a/src/components/Callout.tsx +++ b/src/components/Callout.tsx @@ -20,10 +20,12 @@ export const calloutVariants = cva( variants: { variant: { default: "bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300", - warning: "bg-netbird-500/10 border-netbird-400/20 text-netbird-150", - info: "bg-sky-400/10 border-sky-400/20 text-sky-100", - success: "bg-green-400/15 border-green-400/20 text-green-100", - error: "bg-red-500/10 border-red-400/20 text-red-100", + warning: + "bg-netbird-500/10 border-netbird-400/20 text-netbird-700 dark:text-netbird-150", + info: "bg-sky-400/10 border-sky-400/20 text-sky-700 dark:text-sky-100", + success: + "bg-green-400/15 border-green-400/20 text-green-700 dark:text-green-100", + error: "bg-red-500/10 border-red-400/20 text-red-700 dark:text-red-100", }, }, }, diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx index 02bf7745c..63dd729b9 100644 --- a/src/components/Checkbox.tsx +++ b/src/components/Checkbox.tsx @@ -35,12 +35,12 @@ const Checkbox = React.forwardRef< ref={ref} className={cn( variants({ variant }), - "border-neutral-900", + "border-nb-gray-700", "peer h-5 w-5 shrink-0 rounded-[4px] border", "ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2", - "disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 ", + "disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-netbird data-[state=checked]:border-netbird data-[state=checked]:text-neutral-50 ", "disabled:data-[state=unchecked]:border-dashed dark:disabled:data-[state=unchecked]:border-nb-gray-700", - "data-[state=indeterminate]:bg-neutral-900 data-[state=indeterminate]:text-neutral-50", + "data-[state=indeterminate]:bg-netbird data-[state=indeterminate]:border-netbird data-[state=indeterminate]:text-neutral-50", className, )} {...props} diff --git a/src/components/Code.tsx b/src/components/Code.tsx index ff7c928d1..8fb8292aa 100644 --- a/src/components/Code.tsx +++ b/src/components/Code.tsx @@ -44,7 +44,7 @@ export default function Code({ : "bg-gray-50 border-neutral-200 dark:border-nb-gray-700 hover:dark:bg-nb-gray-900/80 dark:bg-nb-gray-900", dark ? "text-nb-gray-300 hover:text-nb-gray-300" - : "dark:text-nb-gray-200 hover:dark:text-nb-gray-200", + : "text-nb-gray-200 hover:text-nb-gray-200", "overflow-x-auto relative z-0 w-full", className, )} diff --git a/src/components/Command.tsx b/src/components/Command.tsx index 7c353ea81..dd6669554 100644 --- a/src/components/Command.tsx +++ b/src/components/Command.tsx @@ -117,7 +117,7 @@ const CommandItem = React.forwardRef< ref={ref} className={cn( "text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md", - "bg-transparent dark:aria-selected:bg-nb-gray-910 group/command-item", + "bg-transparent aria-selected:bg-nb-gray-910 group/command-item", className, )} {...props} diff --git a/src/components/DatePickerWithRange.tsx b/src/components/DatePickerWithRange.tsx index 794d761b6..e7ab72abf 100644 --- a/src/components/DatePickerWithRange.tsx +++ b/src/components/DatePickerWithRange.tsx @@ -220,7 +220,7 @@ function CalendarButton({ className={cn( "py-1.5 leading-none px-2.5 rounded-md text-center text-xs transition-all flex gap-2", active - ? "bg-nb-gray-800 text-white" + ? "bg-nb-gray-800 text-nb-gray-100" : "bg-transparent text-nb-gray-300 hover:bg-nb-gray-900 hover:text-nb-gray-100", )} onClick={onClick} diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx index 5fa0a8200..51f659194 100644 --- a/src/components/DropdownMenu.tsx +++ b/src/components/DropdownMenu.tsx @@ -83,7 +83,7 @@ const menuItemVariants = cva("", { default: "dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-nb-gray-300 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50", danger: - "dark:focus:bg-red-900/20 dark:focus:text-red-500 dark:text-red-500", + "text-red-600 focus:bg-red-50 focus:text-red-600 dark:text-red-500 dark:focus:bg-red-900/20 dark:focus:text-red-500", }, }, }); diff --git a/src/components/FullTooltip.tsx b/src/components/FullTooltip.tsx index c67d81b4d..5799dd79e 100644 --- a/src/components/FullTooltip.tsx +++ b/src/components/FullTooltip.tsx @@ -74,7 +74,7 @@ export default function FullTooltip({
@@ -95,7 +95,7 @@ export default function FullTooltip({ align={align} side={side} > -
+
{content}
diff --git a/src/components/HelpText.tsx b/src/components/HelpText.tsx index 87555949a..b6737f5d1 100644 --- a/src/components/HelpText.tsx +++ b/src/components/HelpText.tsx @@ -14,7 +14,7 @@ export default function HelpText({ return ( ( ({ : "success" } className={classNames( - "h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0", + "h-8 w-8 shadow-sm flex items-center justify-center rounded-md shrink-0", loading - ? "bg-nb-gray-900" + ? "bg-nb-gray-900 text-nb-gray-100" : error - ? "bg-red-500" - : backgroundColor || "bg-green-500", + ? "bg-red-500 text-white" + : backgroundColor + ? `${backgroundColor} text-white` + : "bg-green-100 text-green-700 dark:bg-green-500 dark:text-white", )} > {loading ? ( diff --git a/src/components/Paragraph.tsx b/src/components/Paragraph.tsx index aefcdfb7c..e2b699d23 100644 --- a/src/components/Paragraph.tsx +++ b/src/components/Paragraph.tsx @@ -9,7 +9,7 @@ export default function Paragraph({ children, className }: Props) { return (

diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx index f8417d7c1..e51a4652e 100644 --- a/src/components/PeerGroupSelector.tsx +++ b/src/components/PeerGroupSelector.tsx @@ -598,7 +598,7 @@ export function PeerGroupSelector({ )}

@@ -135,7 +135,7 @@ export function PeerSelector({ @@ -230,7 +230,7 @@ export function PeerSelector({ className={cn( "font-medium flex items-center gap-1 font-mono text-[10px]", value && value.id == option.id - ? "text-white" + ? "text-nb-gray-100" : "text-nb-gray-300", !isSupported && "opacity-50", )} diff --git a/src/components/PinCodeInput.tsx b/src/components/PinCodeInput.tsx index 436ca4c40..083787325 100644 --- a/src/components/PinCodeInput.tsx +++ b/src/components/PinCodeInput.tsx @@ -108,8 +108,8 @@ const PinCodeInput = forwardRef(function PinCodeInput( disabled={disabled} className={cn( "w-[42px] h-[42px] text-center text-sm rounded-md", - "dark:bg-nb-gray-900 border dark:border-nb-gray-700", - "dark:placeholder:text-neutral-400/70", + "bg-nb-gray-900 border border-nb-gray-700", + "placeholder:text-nb-gray-400/70", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", "ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", "disabled:cursor-not-allowed disabled:opacity-40", diff --git a/src/components/Popover.tsx b/src/components/Popover.tsx index f5c251388..08116f36d 100644 --- a/src/components/Popover.tsx +++ b/src/components/Popover.tsx @@ -11,11 +11,11 @@ export const popoverVariants = cva([], { variants: { variant: { lighter: [ - "rounded-md border border-neutral-200 bg-white px-5 py-3 text-sm text-neutral-950 shadow-md", + "rounded-md border border-neutral-300 bg-white px-5 py-3 text-sm text-neutral-950 shadow-md", "dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:text-neutral-50", ], dark: [ - "rounded-md border border-neutral-200 bg-white px-5 py-3 text-sm text-neutral-950 shadow-md", + "rounded-md border border-neutral-300 bg-white px-5 py-3 text-sm text-neutral-950 shadow-md", "dark:border-nb-gray-900 dark:bg-nb-gray-940 dark:text-gray-50", ], }, diff --git a/src/components/PortSelector.tsx b/src/components/PortSelector.tsx index 159c399cd..84d375dba 100644 --- a/src/components/PortSelector.tsx +++ b/src/components/PortSelector.tsx @@ -160,7 +160,7 @@ export function PortSelector({ 0 && ( Port ranges requires NetBird client{" "} - v0.48 or higher. + v0.48 or higher. )} diff --git a/src/components/RadioGroup.tsx b/src/components/RadioGroup.tsx index 083045f04..115380ee4 100644 --- a/src/components/RadioGroup.tsx +++ b/src/components/RadioGroup.tsx @@ -46,9 +46,9 @@ export const RadioGroupItem = ({ variant === "default" && "text-nb-gray-500 hover:text-nb-gray-400 data-[state=checked]:bg-nb-gray-600 data-[state=checked]:text-nb-gray-100", variant === "red" && - "text-nb-gray-500 hover:text-nb-gray-400 data-[state=checked]:bg-red-800 data-[state=checked]:text-red-200", + "text-nb-gray-500 hover:text-nb-gray-400 data-[state=checked]:bg-red-100 data-[state=checked]:text-red-700 dark:data-[state=checked]:bg-red-800 dark:data-[state=checked]:text-red-200", variant === "green" && - "text-nb-gray-500 hover:text-nb-gray-400 data-[state=checked]:bg-green-800 data-[state=checked]:text-green-200", + "text-nb-gray-500 hover:text-nb-gray-400 data-[state=checked]:bg-green-100 data-[state=checked]:text-green-700 dark:data-[state=checked]:bg-green-800 dark:data-[state=checked]:text-green-200", "cursor-pointer relative transition-all w-full py-1.5 px-5 rounded-md h-full flex items-center text-sm gap-1 text-center justify-center", )} > diff --git a/src/components/Separator.tsx b/src/components/Separator.tsx index 6daa2c00a..988e801d5 100644 --- a/src/components/Separator.tsx +++ b/src/components/Separator.tsx @@ -1,3 +1,3 @@ export default function Separator() { - return ; + return ; } diff --git a/src/components/SquareIcon.tsx b/src/components/SquareIcon.tsx index dd3a125ee..4e7fcd725 100644 --- a/src/components/SquareIcon.tsx +++ b/src/components/SquareIcon.tsx @@ -7,15 +7,21 @@ const iconVariant = cva( { variants: { color: { - netbird: "bg-netbird-950 border-netbird text-netbird", - blue: "bg-sky-950 border-sky-500 text-sky-100", - "blue-darker": "bg-sky-950 border-sky-500 text-sky-500", - red: "bg-red-950 border-red-500 text-red-500", + netbird: + "bg-netbird-100 border-netbird text-netbird-700 dark:bg-netbird-950 dark:text-netbird", + blue: "bg-sky-100 border-sky-500 text-sky-700 dark:bg-sky-950 dark:text-sky-100", + "blue-darker": + "bg-sky-100 border-sky-500 text-sky-700 dark:bg-sky-950 dark:text-sky-500", + red: "bg-red-100 border-red-500 text-red-700 dark:bg-red-950 dark:text-red-500", gray: "bg-nb-gray-930 border-nb-gray-800 text-gray-500", - green: "bg-green-950 border-green-500 text-green-500", - purple: "bg-purple-950 border-purple-500 text-purple-500", - indigo: "bg-indigo-950 border-indigo-500 text-indigo-500", - yellow: "bg-yellow-950 border-yellow-400 text-yellow-400", + green: + "bg-green-100 border-green-500 text-green-700 dark:bg-green-950 dark:text-green-500", + purple: + "bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-950 dark:text-purple-500", + indigo: + "bg-indigo-100 border-indigo-500 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-500", + yellow: + "bg-yellow-100 border-yellow-400 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-400", }, size: { small: "w-8 h-8", diff --git a/src/components/Steps.tsx b/src/components/Steps.tsx index 5d18afef1..905853bb3 100644 --- a/src/components/Steps.tsx +++ b/src/components/Steps.tsx @@ -50,7 +50,7 @@ const Step = ({ {line && ( diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index 8a2aefd3e..56f13c1bc 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -63,7 +63,7 @@ const TabsList = React.forwardRef< > @@ -103,7 +103,7 @@ const TabsTrigger = React.forwardRef< "absolute w-full h-[1px] left-0 right-0 bottom-[0px] transition-all", currentValue === props.value ? "bg-netbird text-netbird" - : "dark:bg-nb-gray-900 bg-nb-gray-100 group-hover/trigger:dark:bg-nb-gray-700 group-hover/trigger:bg-nb-gray-200", + : "dark:bg-nb-gray-900 bg-neutral-200 group-hover/trigger:dark:bg-nb-gray-700 group-hover/trigger:bg-neutral-300", )} > diff --git a/src/components/Textarea.tsx b/src/components/Textarea.tsx index 34ecf7745..942798015 100644 --- a/src/components/Textarea.tsx +++ b/src/components/Textarea.tsx @@ -17,11 +17,11 @@ const inputVariants = cva("", { variants: { variant: { default: [ - "dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-nb-gray-700", + "dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-300 dark:border-nb-gray-700", "ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10", ], darker: [ - "dark:bg-nb-gray-900/40 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-nb-gray-900", + "dark:bg-nb-gray-900/40 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-300 dark:border-nb-gray-900", "ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10", ], error: [ diff --git a/src/components/ToggleSwitch.tsx b/src/components/ToggleSwitch.tsx index 526b802b5..b111b7ff1 100644 --- a/src/components/ToggleSwitch.tsx +++ b/src/components/ToggleSwitch.tsx @@ -16,7 +16,7 @@ const switchVariants = cva("", { variant: { default: [ "dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700", - "data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200", + "data-[state=checked]:bg-netbird data-[state=unchecked]:bg-neutral-200", ], "red-green": [ "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index b34b590de..f8525b078 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -23,13 +23,13 @@ export const tooltipVariants = cva( variant: { default: [ "bg-nb-gray-940", - "text-neutral-50", - "border-neutral-200 border-nb-gray-930", + "text-nb-gray-50", + "border-nb-gray-930", ], lighter: [ "bg-nb-gray-920", - "text-neutral-50", - "border-neutral-200 border-nb-gray-900", + "text-nb-gray-50", + "border-nb-gray-900", ], }, }, diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx index 7c762117b..dada740b6 100644 --- a/src/components/UserSelector.tsx +++ b/src/components/UserSelector.tsx @@ -106,7 +106,7 @@ export function UserSelector({ & ModalFooterProps) => (
({ @@ -99,7 +100,7 @@ export function TableFiltersButton({
diff --git a/src/components/table/filters/CheckboxListPicker.tsx b/src/components/table/filters/CheckboxListPicker.tsx index 44717a3eb..e488ea666 100644 --- a/src/components/table/filters/CheckboxListPicker.tsx +++ b/src/components/table/filters/CheckboxListPicker.tsx @@ -45,8 +45,8 @@ export function CheckboxListPicker({ tabIndex={0} className={cn( "flex items-center gap-2.5 px-2 py-1.5 rounded text-sm transition-colors cursor-pointer", - "text-nb-gray-300 hover:bg-nb-gray-900 hover:text-white", - isSelected && "text-white", + "text-nb-gray-300 hover:bg-nb-gray-900 hover:text-nb-gray-100", + isSelected && "text-nb-gray-100", )} onClick={() => toggle(option.value)} onKeyDown={(e) => { diff --git a/src/components/table/filters/RadioPicker.tsx b/src/components/table/filters/RadioPicker.tsx index dc40573ce..22a4bd33b 100644 --- a/src/components/table/filters/RadioPicker.tsx +++ b/src/components/table/filters/RadioPicker.tsx @@ -33,13 +33,14 @@ export function RadioPicker({ return ( - - - setTheme("light")} - className={"flex gap-2"} - disabled={true} - > - - Light - - setTheme("dark")} - className={"flex gap-2"} - > - - Dark - - setTheme("system")} - className={"flex gap-2"} - > - - System - - - - ) : null; + return ( +
+ Theme +
+ {OPTIONS.map(({ value, label, icon: Icon }) => ( + + ))} +
+
+ ); } diff --git a/src/components/ui/GetStartedTest.tsx b/src/components/ui/GetStartedTest.tsx index f5192a431..922178507 100644 --- a/src/components/ui/GetStartedTest.tsx +++ b/src/components/ui/GetStartedTest.tsx @@ -35,7 +35,7 @@ export default function GetStartedTest({ <>
NEW diff --git a/src/components/ui/HelpAndSupportButton.tsx b/src/components/ui/HelpAndSupportButton.tsx index b9b95673d..ffee549d4 100644 --- a/src/components/ui/HelpAndSupportButton.tsx +++ b/src/components/ui/HelpAndSupportButton.tsx @@ -39,7 +39,7 @@ export default function HelpAndSupportButton() { variant={"default-outline"} className={cn( "!rounded-full h-[38px] w-[38px] !p-0", - dropdownOpen && "text-white", + dropdownOpen && "text-nb-gray-100", )} > diff --git a/src/components/ui/LoginExpiredBadge.tsx b/src/components/ui/LoginExpiredBadge.tsx index 874438018..5b07a5a2e 100644 --- a/src/components/ui/LoginExpiredBadge.tsx +++ b/src/components/ui/LoginExpiredBadge.tsx @@ -15,7 +15,7 @@ export default function LoginExpiredBadge({ loginExpired }: Props) { -
+
This peer is offline and needs to be
re-authenticated because its login has expired.
diff --git a/src/components/ui/MultipleGroups.tsx b/src/components/ui/MultipleGroups.tsx index e739f50c1..ab5a2ee0e 100644 --- a/src/components/ui/MultipleGroups.tsx +++ b/src/components/ui/MultipleGroups.tsx @@ -80,7 +80,9 @@ export default function MultipleGroups({ useHover={true} className={cn( "px-3 gap-2 whitespace-nowrap", - permission.groups.update ? "group-hover:bg-nb-gray-800" : "", + permission.groups.update + ? "group-hover:bg-gray-100 dark:group-hover:bg-nb-gray-800" + : "", )} > {orderedGroups.length} Groups @@ -94,7 +96,7 @@ export default function MultipleGroups({ showNewBadge={true} className={ permission.groups.update - ? "group-hover:bg-nb-gray-800" + ? "group-hover:bg-gray-100 dark:group-hover:bg-nb-gray-800" : "" } /> @@ -108,7 +110,7 @@ export default function MultipleGroups({ showNewBadge={true} className={ permission.groups.update - ? "group-hover:bg-nb-gray-800" + ? "group-hover:bg-gray-100 dark:group-hover:bg-nb-gray-800" : "" } /> @@ -120,7 +122,7 @@ export default function MultipleGroups({ className={cn( "px-3 gap-2 whitespace-nowrap", permission.groups.update - ? "group-hover:bg-nb-gray-800" + ? "group-hover:bg-gray-100 dark:group-hover:bg-nb-gray-800" : "", )} > diff --git a/src/components/ui/SmallBadge.tsx b/src/components/ui/SmallBadge.tsx index de7ec0196..f9fa1c6db 100644 --- a/src/components/ui/SmallBadge.tsx +++ b/src/components/ui/SmallBadge.tsx @@ -5,12 +5,15 @@ import * as React from "react"; const smallBadgeVariants = cva("", { variants: { variant: { - green: "bg-green-900 border border-green-500/20 text-green-400", - blue: "bg-blue-900 border border-blue-500/20 text-blue-400", + green: + "bg-green-100 border border-green-300 text-green-700 dark:bg-green-900 dark:border-green-500/20 dark:text-green-400", + blue: "bg-blue-100 border border-blue-300 text-blue-700 dark:bg-blue-900 dark:border-blue-500/20 dark:text-blue-400", white: "bg-white/20 border border-white/10 text-white", - sky: "bg-sky-900 border border-sky-500/20 text-white", - netbird: "bg-netbird-900 border border-netbird-400 text-netbird-300", - yellow: "bg-yellow-900 border border-yellow-500/20 text-yellow-400", + sky: "bg-sky-100 border border-sky-300 text-sky-700 dark:bg-sky-900 dark:border-sky-500/20 dark:text-white", + netbird: + "bg-netbird-100 border border-netbird-400 text-netbird-700 dark:bg-netbird-900 dark:text-netbird-300", + yellow: + "bg-yellow-100 border border-yellow-400 text-yellow-700 dark:bg-yellow-900 dark:border-yellow-500/20 dark:text-yellow-400", }, size: { default: diff --git a/src/components/ui/TruncatedText.tsx b/src/components/ui/TruncatedText.tsx index 7a61d4153..64f17aaed 100644 --- a/src/components/ui/TruncatedText.tsx +++ b/src/components/ui/TruncatedText.tsx @@ -119,7 +119,7 @@ export default function TruncatedText({ className={cn(className, "px-3 py-1.5")} > {tooltipContent ?? ( -
+
{text}
diff --git a/src/components/ui/UserDropdown.tsx b/src/components/ui/UserDropdown.tsx index 1ebea567e..c398a0bb5 100644 --- a/src/components/ui/UserDropdown.tsx +++ b/src/components/ui/UserDropdown.tsx @@ -9,6 +9,7 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from "@components/DropdownMenu"; +import DarkModeToggle from "@components/ui/DarkModeToggle"; import { Modal } from "@components/modal/Modal"; import TextWithTooltip from "@components/ui/TextWithTooltip"; import { UserAvatar } from "@components/ui/UserAvatar"; @@ -125,6 +126,10 @@ export default function UserDropdown() { )} + + + + diff --git a/src/contexts/ThemeProvider.tsx b/src/contexts/ThemeProvider.tsx index 38dd6e37a..0f7a30f0a 100644 --- a/src/contexts/ThemeProvider.tsx +++ b/src/contexts/ThemeProvider.tsx @@ -1,108 +1,179 @@ "use client"; import "react-loading-skeleton/dist/skeleton.css"; -import React, { useCallback, useContext, useEffect, useState } from "react"; +import * as React from "react"; import { SkeletonTheme } from "react-loading-skeleton"; -export type Theme = "dark" | "light" | "system"; -export type ResolvedTheme = Exclude; +export type Theme = "light" | "dark" | "system"; -const STORAGE_KEY = "netbird-theme"; -const DEFAULT_THEME: ResolvedTheme = "dark"; -const THEME_CLASSES: ResolvedTheme[] = ["dark", "light"]; -const SKELETON_COLORS: Record< - ResolvedTheme, - { base: string; highlight: string } -> = { - dark: { base: "#25282d", highlight: "#33373e" }, - light: { base: "#e4e7e9", highlight: "#f4f6f7" }, +type ResolvedTheme = "light" | "dark"; + +type ThemeContextValue = { + theme: Theme; + resolvedTheme: ResolvedTheme; + setTheme: (theme: Theme) => void; }; -type Props = { - children: React.ReactNode; +const STORAGE_KEY = "netbird-theme"; +const DEFAULT_THEME: Theme = "dark"; +const SYSTEM_QUERY = "(prefers-color-scheme: dark)"; + +const ThemeContext = React.createContext(null); + +/* Production fallback when useTheme is called outside ThemeProvider — + in development the same misuse throws instead. */ +const FALLBACK_CONTEXT: ThemeContextValue = { + theme: DEFAULT_THEME, + resolvedTheme: "dark", + setTheme: () => undefined, }; -const ThemeContext = React.createContext( - {} as { - theme: Theme; - setTheme: (theme: Theme) => void; - }, -); +/* Both inputs are exposed as external stores so the server render and the + hydration pass agree on DEFAULT_THEME (the html element ships with the + `dark` class), and React then re-renders with the real client value. + Reading localStorage / matchMedia in a useState initializer instead made + the first client render disagree with the server HTML — e.g. + DarkModeToggle's aria-pressed — and trip hydration warnings. */ -function isTheme(value: string | null): value is Theme { - return value === "dark" || value === "light" || value === "system"; -} +const isTheme = (value: unknown): value is Theme => + value === "light" || value === "dark" || value === "system"; -function systemTheme(): ResolvedTheme { - return window.matchMedia("(prefers-color-scheme: light)").matches - ? "light" - : "dark"; -} +/* Fallback when localStorage is unavailable (e.g. blocked by browser + settings): the choice still applies for the session, it just won't persist. */ +let sessionTheme: Theme | null = null; +const themeListeners = new Set<() => void>(); -function resolveTheme(theme: Theme): ResolvedTheme { - return theme === "system" ? systemTheme() : theme; -} +const getStoredTheme = (): Theme => { + if (sessionTheme) return sessionTheme; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (isTheme(stored)) return stored; + } catch { + // fall through to the default + } + return DEFAULT_THEME; +}; + +const getServerTheme = (): Theme => DEFAULT_THEME; + +const subscribeTheme = (onChange: () => void) => { + themeListeners.add(onChange); + // Follow changes made in another tab as well. + window.addEventListener("storage", onChange); + return () => { + themeListeners.delete(onChange); + window.removeEventListener("storage", onChange); + }; +}; -// storedTheme reads the persisted preference. Access to localStorage can throw -// when the browser blocks storage, in which case we fall back to the default. -function storedTheme(): Theme { - if (typeof window === "undefined") return DEFAULT_THEME; +const writeTheme = (next: Theme) => { try { - const stored = window.localStorage.getItem(STORAGE_KEY); - return isTheme(stored) ? stored : DEFAULT_THEME; + localStorage.setItem(STORAGE_KEY, next); + sessionTheme = null; } catch { - return DEFAULT_THEME; + sessionTheme = next; } -} + themeListeners.forEach((listener) => listener()); +}; -export function ThemeProvider({ children }: Props) { - // The theme class ships on in the static export, so the initial paint - // never flashes. Reading storage here only matters when the stored preference - // differs from that default. - const [theme, setThemeState] = useState(storedTheme); - // Tracked separately so "system" keeps the skeleton palette in step with the - // OS preference, which can change while the app is open. - const [resolvedTheme, setResolvedTheme] = - useState(DEFAULT_THEME); - - const setTheme = useCallback((next: Theme) => { - setThemeState(next); - try { - window.localStorage.setItem(STORAGE_KEY, next); - } catch { - // Preference is not persisted when storage is unavailable. - } - }, []); - - useEffect(() => { - const apply = () => { - const resolved = resolveTheme(theme); - const root = document.documentElement; - root.classList.remove(...THEME_CLASSES.filter((c) => c !== resolved)); - root.classList.add(resolved); - root.style.colorScheme = resolved; - setResolvedTheme(resolved); - }; +const getSystemTheme = (): ResolvedTheme => + window.matchMedia(SYSTEM_QUERY).matches ? "dark" : "light"; + +const getServerSystemTheme = (): ResolvedTheme => "dark"; +const subscribeSystemTheme = (onChange: () => void) => { + const media = window.matchMedia(SYSTEM_QUERY); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); +}; + +/* Suspends CSS transitions for one frame so the whole page switches + theme at once instead of elements fading at different speeds. */ +const withTransitionsDisabled = (apply: () => void) => { + const style = document.createElement("style"); + style.appendChild( + document.createTextNode( + "*,*::before,*::after{transition:none!important}", + ), + ); + document.head.appendChild(style); + try { apply(); + } finally { + window.getComputedStyle(document.documentElement); + setTimeout(() => style.remove(), 1); + } +}; - if (theme !== "system") return; +/** + * Wraps the skeleton loader theme so its colors follow the active theme. + * The colours come from the `--skeleton-base` / `--skeleton-highlight` + * tokens in globals.css, which `:root` (light) and `.dark` each define, so + * they flip with the `.dark` class like the rest of the nb-gray ramp and + * stay in step with the desktop client's skeleton tokens. + */ +function ThemedSkeleton({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} - const query = window.matchMedia("(prefers-color-scheme: light)"); - query.addEventListener("change", apply); - return () => query.removeEventListener("change", apply); - }, [theme]); +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const theme = React.useSyncExternalStore( + subscribeTheme, + getStoredTheme, + getServerTheme, + ); + const systemTheme = React.useSyncExternalStore( + subscribeSystemTheme, + getSystemTheme, + getServerSystemTheme, + ); + + const resolvedTheme = theme === "system" ? systemTheme : theme; + + React.useEffect(() => { + const root = document.documentElement; + // The pre-paint script in AppLayout usually has this right already; + // skip the transition freeze when nothing needs to change. + if ( + root.classList.contains("dark") === (resolvedTheme === "dark") && + root.style.colorScheme === resolvedTheme + ) { + return; + } + withTransitionsDisabled(() => { + root.classList.toggle("dark", resolvedTheme === "dark"); + root.style.colorScheme = resolvedTheme; + }); + }, [resolvedTheme]); + + const setTheme = React.useCallback((next: Theme) => writeTheme(next), []); + + const value = React.useMemo( + () => ({ theme, resolvedTheme, setTheme }), + [theme, resolvedTheme, setTheme], + ); return ( - - - {children} - + + {children} ); } -export const useTheme = () => useContext(ThemeContext); +export const useTheme = (): ThemeContextValue => { + const ctx = React.useContext(ThemeContext); + if (!ctx) { + if (process.env.NODE_ENV !== "production") { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return FALLBACK_CONTEXT; + } + return ctx; +}; diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index 8582abfbc..981607701 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -43,8 +43,24 @@ export default function AppLayout({ useSignupSource(); return ( - + // suppressHydrationWarning: the pre-paint script below rewrites this + // element's class and color-scheme before React hydrates it. + + {/* Set the theme class and color-scheme before first paint to avoid + a flash. Mirrors ThemeProvider (storageKey "netbird-theme", default + "dark", system-aware) — required because the provider only applies + them in an effect, after hydration. */} +