mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
feat(updater): Windows native notifications and toast window fixes
- Use native OS Notification on Windows instead of transparent overlay toast window (transparent BrowserWindows are unreliable on Windows) - Set app user model ID on Windows for proper notification grouping - Make toast window opaque with solid background on Windows - Only set transparent body background on macOS for update-toast window - Refactor UpdateToastWindow to inline styles for Windows compatibility - Add primaryAction-based button rendering in toast UI - Add polling fallback for toast payload in UpdateToastWindow - Add global CSS variables for brand accent color
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
Notification,
|
||||
session,
|
||||
systemPreferences,
|
||||
Tray,
|
||||
@@ -26,11 +27,14 @@ import {
|
||||
downloadAvailableUpdate,
|
||||
deferUpdateReminder,
|
||||
getCurrentUpdateToastPayload,
|
||||
getUpdaterLogPath,
|
||||
getUpdateStatusSummary,
|
||||
installDownloadedUpdateNow,
|
||||
previewUpdateToast,
|
||||
skipAvailableUpdateVersion,
|
||||
setupAutoUpdates,
|
||||
} from "./updater";
|
||||
import type { UpdateToastPayload } from "./updater";
|
||||
import {
|
||||
createEditorWindow,
|
||||
createHudOverlayWindow,
|
||||
@@ -84,6 +88,8 @@ let tray: Tray | null = null;
|
||||
let selectedSourceName = "";
|
||||
let editorHasUnsavedChanges = false;
|
||||
let isForceClosing = false;
|
||||
let activeUpdateNotification: Notification | null = null;
|
||||
let activeUpdateNotificationKey: string | null = null;
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
@@ -317,7 +323,99 @@ function syncDockIcon() {
|
||||
}
|
||||
}
|
||||
|
||||
function getUpdateNotificationTitle(payload: UpdateToastPayload) {
|
||||
switch (payload.phase) {
|
||||
case "available":
|
||||
return `Recordly ${payload.version} is available`;
|
||||
case "downloading":
|
||||
return `Downloading Recordly ${payload.version}`;
|
||||
case "ready":
|
||||
return `Recordly ${payload.version} is ready`;
|
||||
case "error":
|
||||
return `Recordly ${payload.version} needs attention`;
|
||||
}
|
||||
}
|
||||
|
||||
function getUpdateNotificationBody(payload: UpdateToastPayload) {
|
||||
switch (payload.phase) {
|
||||
case "available":
|
||||
return "Click to download the update.";
|
||||
case "downloading":
|
||||
return "Recordly is downloading the update in the foreground.";
|
||||
case "ready":
|
||||
return "Click to install the downloaded update.";
|
||||
case "error":
|
||||
return "Click to retry checking for updates.";
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveUpdateNotification() {
|
||||
if (activeUpdateNotification) {
|
||||
activeUpdateNotification.close();
|
||||
activeUpdateNotification = null;
|
||||
}
|
||||
activeUpdateNotificationKey = null;
|
||||
}
|
||||
|
||||
function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknown) {
|
||||
if (process.platform !== "darwin") {
|
||||
if (!payload) {
|
||||
clearActiveUpdateNotification();
|
||||
return true;
|
||||
}
|
||||
|
||||
const updatePayload = payload as UpdateToastPayload;
|
||||
if (updatePayload.phase === "downloading") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const notificationKey = [updatePayload.phase, updatePayload.version, updatePayload.detail].join(":");
|
||||
if (activeUpdateNotificationKey === notificationKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
clearActiveUpdateNotification();
|
||||
const notification = new Notification({
|
||||
title: getUpdateNotificationTitle(updatePayload),
|
||||
body: getUpdateNotificationBody(updatePayload),
|
||||
icon: getAppImage("app-icons/recordly-128.png"),
|
||||
silent: false,
|
||||
});
|
||||
|
||||
notification.on("click", () => {
|
||||
focusOrCreateMainWindow();
|
||||
switch (updatePayload.phase) {
|
||||
case "available":
|
||||
void downloadAvailableUpdate(sendUpdateToastToWindows);
|
||||
break;
|
||||
case "ready":
|
||||
installDownloadedUpdateNow(sendUpdateToastToWindows);
|
||||
break;
|
||||
case "error":
|
||||
void checkForAppUpdates(getUpdateDialogWindow, { manual: true });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
notification.on("close", () => {
|
||||
if (activeUpdateNotification === notification) {
|
||||
activeUpdateNotification = null;
|
||||
activeUpdateNotificationKey = null;
|
||||
}
|
||||
});
|
||||
|
||||
notification.show();
|
||||
activeUpdateNotification = notification;
|
||||
activeUpdateNotificationKey = notificationKey;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
const existingWindow = getUpdateToastWindow();
|
||||
if (!existingWindow) {
|
||||
@@ -382,10 +480,19 @@ ipcMain.handle("get-current-update-toast-payload", () => {
|
||||
return getCurrentUpdateToastPayload();
|
||||
});
|
||||
|
||||
ipcMain.handle("get-update-status-summary", () => {
|
||||
return getUpdateStatusSummary();
|
||||
});
|
||||
|
||||
ipcMain.handle("preview-update-toast", () => {
|
||||
return { success: previewUpdateToast(sendUpdateToastToWindows) };
|
||||
});
|
||||
|
||||
ipcMain.handle("check-for-app-updates", async () => {
|
||||
await checkForAppUpdates(getUpdateDialogWindow, { manual: true });
|
||||
return { success: true, logPath: getUpdaterLogPath() };
|
||||
});
|
||||
|
||||
function updateTrayMenu(recording: boolean = false) {
|
||||
if (!tray) return;
|
||||
const trayIcon = recording ? getRecordingTrayIcon() : getDefaultTrayIcon();
|
||||
@@ -506,6 +613,10 @@ app.on("second-instance", () => {
|
||||
|
||||
// Register all IPC handlers when app is ready
|
||||
app.whenReady().then(async () => {
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("dev.recordly.app");
|
||||
}
|
||||
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
const allowed = ["media", "audioCapture", "microphone", "camera", "videoCapture"];
|
||||
return allowed.includes(permission);
|
||||
|
||||
+14
-4
@@ -326,6 +326,11 @@ export function getHudOverlayWindow(): BrowserWindow | null {
|
||||
|
||||
export function createUpdateToastWindow(): BrowserWindow {
|
||||
const initialBounds = getUpdateToastBounds();
|
||||
const parentWindow =
|
||||
process.platform === "darwin" && hudOverlayWindow && !hudOverlayWindow.isDestroyed()
|
||||
? hudOverlayWindow
|
||||
: undefined;
|
||||
const useTransparentToastWindow = process.platform !== "win32";
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: initialBounds.width,
|
||||
@@ -333,15 +338,15 @@ export function createUpdateToastWindow(): BrowserWindow {
|
||||
x: initialBounds.x,
|
||||
y: initialBounds.y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
transparent: useTransparentToastWindow,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
show: false,
|
||||
focusable: true,
|
||||
...(hudOverlayWindow && !hudOverlayWindow.isDestroyed() ? { parent: hudOverlayWindow } : {}),
|
||||
backgroundColor: "#00000000",
|
||||
...(parentWindow ? { parent: parentWindow } : {}),
|
||||
backgroundColor: useTransparentToastWindow ? "#00000000" : "#101418",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
@@ -382,7 +387,12 @@ export function showUpdateToastWindow(): BrowserWindow {
|
||||
const win = getUpdateToastWindow() ?? createUpdateToastWindow();
|
||||
positionUpdateToastWindow();
|
||||
if (!win.isVisible()) {
|
||||
win.showInactive();
|
||||
if (process.platform === "win32") {
|
||||
win.show();
|
||||
win.moveTop();
|
||||
} else {
|
||||
win.showInactive();
|
||||
}
|
||||
} else {
|
||||
win.moveTop();
|
||||
}
|
||||
|
||||
+2
-1
@@ -17,13 +17,14 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const type = params.get("windowType") || "";
|
||||
const isMacOS = /mac/i.test(navigator.platform);
|
||||
setWindowType(type);
|
||||
|
||||
if (
|
||||
type === "hud-overlay" ||
|
||||
type === "source-selector" ||
|
||||
type === "countdown" ||
|
||||
type === "update-toast"
|
||||
(type === "update-toast" && isMacOS)
|
||||
) {
|
||||
document.body.style.background = "transparent";
|
||||
document.documentElement.style.background = "transparent";
|
||||
|
||||
@@ -8,6 +8,7 @@ type UpdateToastPayload = {
|
||||
delayMs: number;
|
||||
isPreview?: boolean;
|
||||
progressPercent?: number;
|
||||
primaryAction?: "download-update" | "install-update" | "retry-check";
|
||||
};
|
||||
|
||||
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
@@ -34,16 +35,16 @@ function getToastTitle(payload: UpdateToastPayload) {
|
||||
}
|
||||
}
|
||||
|
||||
function getIcon(payload: UpdateToastPayload) {
|
||||
switch (payload.phase) {
|
||||
case "available":
|
||||
return <Download className="h-5 w-5" />;
|
||||
case "downloading":
|
||||
return <LoaderCircle className="h-5 w-5 animate-spin" />;
|
||||
case "ready":
|
||||
return <Rocket className="h-5 w-5" />;
|
||||
case "error":
|
||||
return <AlertCircle className="h-5 w-5" />;
|
||||
function getPrimaryActionLabel(payload: UpdateToastPayload) {
|
||||
switch (payload.primaryAction) {
|
||||
case "download-update":
|
||||
return "Download Update";
|
||||
case "install-update":
|
||||
return "Install Update";
|
||||
case "retry-check":
|
||||
return "Retry Check";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +63,7 @@ export function UpdateToastWindow() {
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => {
|
||||
if (mounted) {
|
||||
@@ -69,12 +71,25 @@ export function UpdateToastWindow() {
|
||||
}
|
||||
});
|
||||
|
||||
pollTimer = setInterval(() => {
|
||||
void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => {
|
||||
if (!mounted || !nextPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPayload((currentPayload) => currentPayload ?? nextPayload);
|
||||
});
|
||||
}, 750);
|
||||
|
||||
const dispose = window.electronAPI.onUpdateToastStateChanged((nextPayload) => {
|
||||
setPayload(nextPayload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
dispose();
|
||||
};
|
||||
}, []);
|
||||
@@ -88,11 +103,104 @@ export function UpdateToastWindow() {
|
||||
};
|
||||
}, [payload?.phase, payload?.version, payload?.progressPercent]);
|
||||
|
||||
const cardStyle = {
|
||||
background: "#0d1117",
|
||||
border: "1px solid rgba(125, 211, 252, 0.22)",
|
||||
boxShadow: "0 24px 48px rgba(0, 0, 0, 0.45)",
|
||||
borderRadius: 24,
|
||||
padding: 16,
|
||||
color: "#ffffff",
|
||||
width: "100%",
|
||||
maxWidth: 404,
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
alignItems: "flex-start",
|
||||
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
|
||||
} as const;
|
||||
const wrapperStyle = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: 8,
|
||||
boxSizing: "border-box",
|
||||
background: "transparent",
|
||||
} as const;
|
||||
const secondaryTextStyle = {
|
||||
color: "rgba(255, 255, 255, 0.74)",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.45,
|
||||
margin: "4px 0 0 0",
|
||||
} as const;
|
||||
const titleStyle = {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
margin: 0,
|
||||
color: "#ffffff",
|
||||
} as const;
|
||||
const iconBoxStyle = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
minWidth: 40,
|
||||
borderRadius: 16,
|
||||
background: "rgba(125, 211, 252, 0.15)",
|
||||
color: "#7dd3fc",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 2,
|
||||
} as const;
|
||||
const rowStyle = {
|
||||
display: "flex",
|
||||
flexWrap: "wrap" as const,
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
} as const;
|
||||
const subtleButtonStyle = {
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
color: "rgba(255, 255, 255, 0.92)",
|
||||
borderRadius: 12,
|
||||
padding: "8px 12px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
} as const;
|
||||
const primaryButtonStyle = {
|
||||
...subtleButtonStyle,
|
||||
background: "#7dd3fc",
|
||||
color: "#031a2c",
|
||||
border: "none",
|
||||
} as const;
|
||||
const ghostButtonStyle = {
|
||||
...subtleButtonStyle,
|
||||
background: "transparent",
|
||||
color: "rgba(255, 255, 255, 0.72)",
|
||||
border: "1px solid rgba(125, 211, 252, 0.16)",
|
||||
} as const;
|
||||
|
||||
if (!payload) {
|
||||
return <div className="h-full w-full bg-transparent" />;
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div style={{ ...cardStyle, alignItems: "center" }}>
|
||||
<div style={iconBoxStyle}>
|
||||
<LoaderCircle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p style={titleStyle}>Checking for updates</p>
|
||||
<p style={secondaryTextStyle}>
|
||||
Waiting for updater state from the main process.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedProgress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0)));
|
||||
const primaryActionLabel = getPrimaryActionLabel(payload);
|
||||
const swipeThreshold = 96;
|
||||
const handleSwipeDismiss = async () => {
|
||||
setDragOffsetX(0);
|
||||
@@ -104,11 +212,28 @@ export function UpdateToastWindow() {
|
||||
await window.electronAPI.dismissUpdateToast();
|
||||
};
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
switch (payload.primaryAction) {
|
||||
case "download-update":
|
||||
await window.electronAPI.downloadAvailableUpdate();
|
||||
return;
|
||||
case "install-update":
|
||||
await window.electronAPI.installDownloadedUpdate();
|
||||
return;
|
||||
case "retry-check":
|
||||
await window.electronAPI.checkForAppUpdates();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-transparent p-2">
|
||||
<div style={wrapperStyle}>
|
||||
<div
|
||||
className="pointer-events-auto flex w-full max-w-[404px] items-start gap-3 rounded-[24px] border border-sky-300/20 bg-[#0d1117]/95 p-4 text-white shadow-2xl shadow-black/45 backdrop-blur-xl transition-transform duration-150 ease-out select-none"
|
||||
className="pointer-events-auto select-none"
|
||||
style={{
|
||||
...cardStyle,
|
||||
transform: `translateX(${dragOffsetX}px) rotate(${dragOffsetX / 30}deg)`,
|
||||
opacity: Math.max(0.35, 1 - Math.min(1, Math.abs(dragOffsetX) / 180)),
|
||||
}}
|
||||
@@ -160,54 +285,42 @@ export function UpdateToastWindow() {
|
||||
setDragOffsetX(0);
|
||||
}}
|
||||
>
|
||||
<div className="mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-2xl bg-sky-400/15 text-sky-300">
|
||||
{getIcon(payload)}
|
||||
<div style={iconBoxStyle}>
|
||||
{payload.phase === "available" ? <Download size={20} /> : null}
|
||||
{payload.phase === "downloading" ? <LoaderCircle size={20} className="animate-spin" /> : null}
|
||||
{payload.phase === "ready" ? <Rocket size={20} /> : null}
|
||||
{payload.phase === "error" ? <AlertCircle size={20} /> : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold tracking-tight">{getToastTitle(payload)}</p>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<p style={titleStyle}>{getToastTitle(payload)}</p>
|
||||
{payload.isPreview ? (
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-sky-200">
|
||||
<span style={{ borderRadius: 999, border: "1px solid rgba(125, 211, 252, 0.2)", background: "rgba(125, 211, 252, 0.1)", padding: "2px 8px", fontSize: 10, fontWeight: 700, letterSpacing: "0.18em", textTransform: "uppercase", color: "#bae6fd" }}>
|
||||
Dev
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-5 text-white/70">{payload.detail}</p>
|
||||
<p style={secondaryTextStyle}>{payload.detail}</p>
|
||||
|
||||
{payload.phase === "downloading" ? (
|
||||
<div className="mt-3">
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ height: 8, overflow: "hidden", borderRadius: 999, background: "rgba(255, 255, 255, 0.1)" }}>
|
||||
<div
|
||||
className="h-full rounded-full bg-sky-300 transition-[width] duration-300"
|
||||
style={{ width: `${normalizedProgress}%` }}
|
||||
style={{ height: "100%", borderRadius: 999, background: "#7dd3fc", width: `${normalizedProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-medium text-sky-100/85">{normalizedProgress}% downloaded</p>
|
||||
<p style={{ margin: "8px 0 0 0", color: "rgba(224, 242, 254, 0.9)", fontSize: 12, fontWeight: 600 }}>{normalizedProgress}% downloaded</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{payload.phase === "available" || payload.phase === "error" ? (
|
||||
<div style={rowStyle}>
|
||||
{primaryActionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await window.electronAPI.downloadAvailableUpdate();
|
||||
}}
|
||||
className="rounded-xl bg-sky-400 px-3 py-2 text-xs font-semibold text-[#031a2c] transition-colors hover:bg-sky-300"
|
||||
onClick={handlePrimaryAction}
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
Download Update
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{payload.phase === "ready" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await window.electronAPI.installDownloadedUpdate();
|
||||
}}
|
||||
className="rounded-xl bg-sky-400 px-3 py-2 text-xs font-semibold text-[#031a2c] transition-colors hover:bg-sky-300"
|
||||
>
|
||||
Install Update
|
||||
{primaryActionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -217,7 +330,7 @@ export function UpdateToastWindow() {
|
||||
onClick={async () => {
|
||||
await window.electronAPI.dismissUpdateToast();
|
||||
}}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/85 transition-colors hover:bg-white/10"
|
||||
style={subtleButtonStyle}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
@@ -234,7 +347,7 @@ export function UpdateToastWindow() {
|
||||
|
||||
await window.electronAPI.deferDownloadedUpdate(payload.delayMs);
|
||||
}}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/85 transition-colors hover:bg-white/10"
|
||||
style={subtleButtonStyle}
|
||||
>
|
||||
Later ({formatDelayHours(payload.delayMs)})
|
||||
</button>
|
||||
@@ -251,7 +364,7 @@ export function UpdateToastWindow() {
|
||||
|
||||
await window.electronAPI.deferDownloadedUpdate(THREE_DAYS_MS);
|
||||
}}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/85 transition-colors hover:bg-white/10"
|
||||
style={subtleButtonStyle}
|
||||
>
|
||||
Later (3 days)
|
||||
</button>
|
||||
@@ -263,7 +376,7 @@ export function UpdateToastWindow() {
|
||||
onClick={async () => {
|
||||
await window.electronAPI.skipUpdateVersion();
|
||||
}}
|
||||
className="rounded-xl border border-sky-300/15 bg-transparent px-3 py-2 text-xs font-medium text-white/65 transition-colors hover:bg-white/5 hover:text-white"
|
||||
style={ghostButtonStyle}
|
||||
>
|
||||
Skip This Version
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--brand-accent: #2563eb;
|
||||
--brand-accent-rgb: 37, 99, 235;
|
||||
|
||||
Reference in New Issue
Block a user