From c2b8829b419ddb651e60a2c87017b5f781ebe888 Mon Sep 17 00:00:00 2001 From: Strider Wilson Date: Tue, 24 Mar 2026 08:54:18 -0400 Subject: [PATCH 1/4] Devtools brand update --- next-env.d.ts | 3 +- package.json | 4 +- .../console/ConsoleActionsRow.module.css | 2 +- .../components/console/ConsoleRoot.module.css | 6 +- .../console/ConsoleSearch.module.css | 4 +- .../console/filters/EventsList.module.css | 2 +- .../console/filters/FilterText.module.css | 2 +- .../elements/ElementsPanel.module.css | 2 +- .../components/errors/SupportForm.module.css | 4 +- .../search-files/SearchFiles.module.css | 6 +- .../sources/SourceSearch.module.css | 4 +- packages/replay-next/variables.css | 116 +-- packages/shared/theme/replayTheme.ts | 57 ++ packages/shared/user-data/GraphQL/UserData.ts | 47 +- pages/_document.tsx | 9 +- public/icons/logout.svg | 6 + .../tests/devtools-object-preview.ts | 2 +- src/base.css | 7 +- src/design-global.css | 425 +++++++++++ .../debugger/src/components/WelcomeBox.tsx | 77 +- src/devtools/client/themes/common.css | 6 +- src/global-css.ts | 1 + src/image/icon.css | 4 + src/ui/components/App.tsx | 40 +- src/ui/components/Header/Header.module.css | 38 +- src/ui/components/Header/ShareButton.tsx | 10 +- src/ui/components/Header/UserOptions.css | 57 +- .../components/Header/UserOptions.module.css | 4 +- src/ui/components/Header/UserOptions.tsx | 97 ++- src/ui/components/Header/ViewToggle.css | 49 +- src/ui/components/Header/ViewToggle.tsx | 3 +- src/ui/components/LoginButton.tsx | 9 +- .../NetworkMonitor/TestFilterRow.module.css | 2 +- .../SecondaryToolbox/SecondaryToolbox.css | 19 +- .../components/Search.module.css | 2 +- .../redux-devtools/ActionFilter.module.css | 2 +- src/ui/components/Toolbar.module.css | 30 + src/ui/components/Toolbar.tsx | 283 ++++--- src/ui/components/Toolbox.css | 53 +- .../components/UploadScreen/ReplayTitle.tsx | 2 +- src/ui/components/UploadScreen/Sharing.tsx | 2 +- src/ui/components/UploadScreen/TeamSelect.tsx | 2 +- src/ui/components/shared/APIKeys.tsx | 120 +-- src/ui/components/shared/Dropdown.tsx | 5 +- src/ui/components/shared/Forms/Checkbox.tsx | 2 +- src/ui/components/shared/Forms/SelectMenu.tsx | 2 +- .../shared/Forms/TextInput.module.css | 61 +- src/ui/components/shared/Forms/TextInput.tsx | 5 +- .../shared/SettingsModal/SettingsBody.css | 13 +- .../shared/SettingsModal/SettingsBody.tsx | 25 +- .../shared/SettingsModal/SettingsModal.css | 31 +- .../SettingsModal/SettingsNavigation.css | 71 +- .../SettingsModal/SettingsNavigation.tsx | 29 +- .../shared/SharingModal/ReplayLink.tsx | 31 +- .../SharingModal/SharingModal.module.css | 37 - .../shared/SharingModal/SharingModal.tsx | 77 +- src/ui/components/shared/ThemeSwitch.tsx | 92 +++ .../components/BooleanPreference.tsx | 6 +- .../components/EnumPreference.tsx | 6 +- .../UserSettingsModal/panels/Advanced.tsx | 6 +- .../shared/UserSettingsModal/panels/Legal.tsx | 46 +- .../UserSettingsModal/panels/Personal.tsx | 22 +- .../UserSettingsModal/panels/Preferences.tsx | 68 +- .../UserSettingsModal/panels/Support.tsx | 50 +- .../UserSettingsModal/titles/Advanced.tsx | 10 +- src/ui/setup/index.ts | 8 +- tailwind.config.js | 688 +++++++++++++++++- yarn.lock | 491 ++++++++++--- 68 files changed, 2654 insertions(+), 848 deletions(-) create mode 100644 packages/shared/theme/replayTheme.ts create mode 100644 public/icons/logout.svg create mode 100644 src/design-global.css delete mode 100644 src/ui/components/shared/SharingModal/SharingModal.module.css create mode 100644 src/ui/components/shared/ThemeSwitch.tsx diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03dc6c..254b73c165d 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index ae00dbce99e..d2ee2ea9958 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,9 @@ "@jridgewell/trace-mapping": "^0.3.14", "@next/bundle-analyzer": "^12.2.0", "@replayio/replay": "^0.22.4", + "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.0", + "@tailwindcss/typography": "^0.5.19", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^5.16.3", "@testing-library/react": "^16", @@ -191,7 +193,7 @@ "style-loader": "^3.3.1", "stylelint": "^14.16.0", "stylelint-config-prettier": "^9.0.5", - "tailwindcss": "^3.2.4", + "tailwindcss": "^3.4.17", "ts-node": "^10.9.2", "tsconfig-paths": "^3.14.1", "tsx": "^4.19.2", diff --git a/packages/replay-next/components/console/ConsoleActionsRow.module.css b/packages/replay-next/components/console/ConsoleActionsRow.module.css index adffac5cbe1..af296a177cc 100644 --- a/packages/replay-next/components/console/ConsoleActionsRow.module.css +++ b/packages/replay-next/components/console/ConsoleActionsRow.module.css @@ -18,7 +18,7 @@ } .MenuToggleButton:focus, .DeleteTerminalExpressionButton:focus { - border-color: var(--primary-accent); + border-color: var(--focus-ring-input); } .MenuToggleButtonIcon, diff --git a/packages/replay-next/components/console/ConsoleRoot.module.css b/packages/replay-next/components/console/ConsoleRoot.module.css index b5339773a8c..d9e87f6827e 100644 --- a/packages/replay-next/components/console/ConsoleRoot.module.css +++ b/packages/replay-next/components/console/ConsoleRoot.module.css @@ -1,8 +1,8 @@ .ConsoleRoot { flex: 1 1 auto; overflow: hidden; - border-bottom-left-radius: 0.5rem; - border-bottom-right-radius: 0.5rem; + border-bottom-left-radius: var(--radius-lg, 0.625rem); + border-bottom-right-radius: var(--radius-lg, 0.625rem); background-color: var(--body-bgcolor); word-break: break-all; font-size: var(--font-size-regular); @@ -28,6 +28,7 @@ overflow: auto; height: 100%; border-top: 1px solid var(--theme-splitter-color); + background-color: var(--tab-bgcolor-alt-subtle); } .PanelResizeHandleContainer { @@ -70,6 +71,7 @@ display: flex; flex-direction: column; border-top: 1px solid var(--theme-splitter-color); + background-color: var(--body-bgcolor); } .ConsoleSearchRow { diff --git a/packages/replay-next/components/console/ConsoleSearch.module.css b/packages/replay-next/components/console/ConsoleSearch.module.css index be1f25e68ae..63db234dbc2 100644 --- a/packages/replay-next/components/console/ConsoleSearch.module.css +++ b/packages/replay-next/components/console/ConsoleSearch.module.css @@ -8,7 +8,7 @@ overflow: hidden; } .ContainerFocused { - border-color: var(--primary-accent); + border-color: var(--focus-ring-input); } .Icon { @@ -61,7 +61,7 @@ outline: none; } .ResultsIconButton:focus { - border-color: var(--primary-accent); + border-color: var(--focus-ring-input); } .ResultsIconButton:hover:not(.ResultsIconButton:disabled):not(.ResultsIconButtonActive) { color: var(--color-default); diff --git a/packages/replay-next/components/console/filters/EventsList.module.css b/packages/replay-next/components/console/filters/EventsList.module.css index 9db3ddaa989..e708d38c372 100644 --- a/packages/replay-next/components/console/filters/EventsList.module.css +++ b/packages/replay-next/components/console/filters/EventsList.module.css @@ -25,7 +25,7 @@ font-size: var(--font-size-regular); } .FilterInput:focus { - border-color: var(--primary-accent); + border-color: var(--focus-ring-input); } .FilterInput:disabled { opacity: 0.5; diff --git a/packages/replay-next/components/console/filters/FilterText.module.css b/packages/replay-next/components/console/filters/FilterText.module.css index 22d52a08505..989d7a87a91 100644 --- a/packages/replay-next/components/console/filters/FilterText.module.css +++ b/packages/replay-next/components/console/filters/FilterText.module.css @@ -10,5 +10,5 @@ outline: none; } .Input:focus { - border-color: var(--primary-accent); + border-color: var(--focus-ring-input); } diff --git a/packages/replay-next/components/elements/ElementsPanel.module.css b/packages/replay-next/components/elements/ElementsPanel.module.css index be2f7d3f267..ef646fabbfa 100644 --- a/packages/replay-next/components/elements/ElementsPanel.module.css +++ b/packages/replay-next/components/elements/ElementsPanel.module.css @@ -36,7 +36,7 @@ border-radius: 0.25rem; } .SearchIconAndInput:focus-within { - border-color: var(--primary-accent); + border-color: var(--focus-ring-input); } .AdvancedButton { diff --git a/packages/replay-next/components/errors/SupportForm.module.css b/packages/replay-next/components/errors/SupportForm.module.css index 6d0b8c4d33c..4aed69b898b 100644 --- a/packages/replay-next/components/errors/SupportForm.module.css +++ b/packages/replay-next/components/errors/SupportForm.module.css @@ -19,8 +19,8 @@ .TextArea:focus { box-shadow: none; outline-offset: 0; - outline: 1px solid var(--primary-accent); - border: 1px solid var(--primary-accent); + outline: 1px solid var(--focus-ring-input); + border: 1px solid var(--focus-ring-input); } .TextArea::placeholder { color: var(--color-dimmer); diff --git a/packages/replay-next/components/search-files/SearchFiles.module.css b/packages/replay-next/components/search-files/SearchFiles.module.css index eeb0db89937..6b59268b608 100644 --- a/packages/replay-next/components/search-files/SearchFiles.module.css +++ b/packages/replay-next/components/search-files/SearchFiles.module.css @@ -34,7 +34,7 @@ border-radius: 0.5rem; } .InputWrapper:focus-within { - border-color: var(--primary-accent); + border-color: var(--focus-ring-input); } .InputWrapper[data-error], .InputWrapper[data-error]:focus-within { @@ -86,8 +86,8 @@ } .SelectedSearchFilterButton { - background-color: var(--color-hit-counts-label-background-3); - border-color: var(--primary-accent); + background-color: var(--theme-base-100); + border-color: var(--focus-ring-input); } .SearchFilterIcon { diff --git a/packages/replay-next/components/sources/SourceSearch.module.css b/packages/replay-next/components/sources/SourceSearch.module.css index 11821fbc808..c3134b72ef6 100644 --- a/packages/replay-next/components/sources/SourceSearch.module.css +++ b/packages/replay-next/components/sources/SourceSearch.module.css @@ -14,7 +14,7 @@ display: flex; } .ContainerFocused { - border-color: var(--primary-accent); + border-color: var(--focus-ring-input); } .InputAndResults { @@ -82,7 +82,7 @@ outline: none; } .ResultsIconButton:focus { - border-color: var(--primary-accent); + border-color: var(--focus-ring-input); } .ResultsIconButton:hover:not(.ResultsIconButton:disabled):not(.ResultsIconButtonActive) { color: var(--color-default); diff --git a/packages/replay-next/variables.css b/packages/replay-next/variables.css index b9b42fe1583..2a4af147602 100644 --- a/packages/replay-next/variables.css +++ b/packages/replay-next/variables.css @@ -45,11 +45,11 @@ /* The photon animation curve */ --animation-curve: cubic-bezier(0.07, 0.95, 0, 1); - --primary-accent: #01acfd; - --primary-accent-hover: #0194ff; + --primary-accent: #f43f5e; + --primary-accent-hover: #e11d48; - --secondary-accent: #d72451; - --secondary-accent-hover: #c61351; + --secondary-accent: #e11d48; + --secondary-accent-hover: #be123c; --secondary-accent-stroke: var(--secondary-accent); /* used in our codebase, to be refactored */ @@ -134,18 +134,18 @@ --color-transparent: rgba(0, 0, 0, 0); --primary-accent-foreground-text: #fff; - --primary-accent-dimmed: #163e58; - --primary-accent-dimmed-hover: #12374f; + --primary-accent-dimmed: #3f1724; + --primary-accent-dimmed-hover: #4c1d2a; --primary-accent-dimmed-foreground-text: var(--body-color); - /* Color ramp (Dark) */ - --theme-base-100: #000; /* background chrome */ - --theme-base-95: var(--grey-90); /* tab bars */ - --theme-base-90: var(--grey-70); /* editor and selected tab */ - --theme-base-85: var(--grey-60); /* textfields */ - --theme-base-80: #797d81; - --theme-base-70: #a2a6a8; - --theme-base-60: #c3c6c8; + /* Color ramp (Dark) — aligned with replay-dashboard surfaces */ + --theme-base-100: #0a0a0a; + --theme-base-95: #141414; + --theme-base-90: #1a1a1a; + --theme-base-85: #262626; + --theme-base-80: #404040; + --theme-base-70: #737373; + --theme-base-60: #a3a3a3; /* Tabs (Dark) */ --tab-bgcolor: var(--theme-base-100); @@ -157,13 +157,15 @@ --tab-standard-color: rgba(255, 255, 255, 0.5); /* Core classes (Dark) */ - --body-bgcolor: var(--theme-base-95); + --body-bgcolor: #0e0e0e; --body-color: var(--color-default); --body-sub-color: var(--grey-40); --buttontext-color: #f4f8fa; --checkbox-border: var(--input-border); --checkbox: var(--theme-base-80); - --chrome: #081120; /* bg app */ + /* Checkmark on checked box (stroke contrasts with --color-default fill) */ + --checkbox-checked-mark-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230a0a0a' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"); + --chrome: #0e0e0e; /* bg app */ --icon-color: var(--body-color); --modal-bgcolor: var(--theme-base-100); --modal-border: #222; @@ -171,6 +173,8 @@ --tab-bgcolor-alt-subtle: var(--theme-base-95); /* info, events */ --tab-selected-bgcolor: var(--theme-base-95); --input-border: var(--theme-base-85); + /* Neutral focus for text/search fields (not brand primary) */ + --focus-ring-input: var(--theme-base-70); --theme-border: var(--theme-base-100); --theme-focuser-bgcolor: var(--primary-accent); --theme-sidebar-background: var(--body-bgcolor); @@ -553,11 +557,11 @@ /* DevTools left nav (Dark) */ --toolbarbutton-focus-bgcolor: var(--theme-base-95); - /* Toolbar (Dark) */ - --theme-tab-toolbar-background: var(--grey-90); - --theme-toolbar-background-alt: var(--grey-85); - --theme-toolbar-background-hover: #232327; - --theme-toolbar-background: #18181a; + /* Toolbar (Dark) — lifted surfaces + visible splits (dashboard-style) */ + --theme-tab-toolbar-background: var(--theme-base-95); + --theme-toolbar-background-alt: var(--theme-base-90); + --theme-toolbar-background-hover: var(--theme-base-90); + --theme-toolbar-background: var(--theme-base-95); --theme-toolbar-color: var(--body-color); --theme-toolbar-hover-active: #252526; --theme-toolbar-hover: #232327; @@ -574,8 +578,8 @@ --theme-button-background: rgba(249, 249, 250, 0.1); /* Accordion headers (Dark) */ - --theme-accordion-header-background: #232327; - --theme-accordion-header-hover: #2a2a2e; + --theme-accordion-header-background: var(--theme-base-95); + --theme-accordion-header-hover: var(--theme-base-90); /* Selection (Dark) */ --theme-selection-background-hover: #353b48; @@ -586,9 +590,9 @@ --theme-table-selection-background-hover: var(--theme-toolbar-background); /* Border color that splits the toolbars/panels/headers. (Dark) */ - --theme-emphasized-splitter-color-hover: var(--grey-50); - --theme-emphasized-splitter-color: var(--grey-60); - --theme-splitter-color: black; + --theme-emphasized-splitter-color-hover: var(--theme-base-70); + --theme-emphasized-splitter-color: var(--theme-base-85); + --theme-splitter-color: #2e2e2e; /* Icon colors (Dark) */ --theme-icon-checked-color: var(--blue-30); @@ -666,13 +670,13 @@ --focus-mode-capsule-bgcolor: var(--body-bgcolor); --focus-mode-loaded-indexed-color: var(--primary-accent); --focus-mode-loading-color: #00567f; - --focus-mode-popout-icon-background-color: white; + --focus-mode-popout-icon-background-color: #fafafa; --focus-mode-popout-icon-color: var(--theme-base-100); - --focus-mode-popout-text-input-background-color: var(--theme-base-100); - --focus-mode-popout-text-input-ring: var(--theme-base-90); - --focus-mode-popout-background-color: rgba(67, 71, 73, 0.6); - --focus-mode-popout-background-color-no-transparency: rgba(0, 0, 0, 1); - --focus-mode-popout-color: white; + --focus-mode-popout-text-input-background-color: #161616; + --focus-mode-popout-text-input-ring: var(--theme-base-85); + --focus-mode-popout-background-color: rgba(14, 14, 14, 0.72); + --focus-mode-popout-background-color-no-transparency: #161616; + --focus-mode-popout-color: #f2f2f2; /* Miscellaneous (Dark) */ --jellyfish-bgcolor: rgba(40, 40, 40, 0.8); /* Tailwind class used in upload, sharing, team */ @@ -684,18 +688,18 @@ --color-transparent: rgba(255, 255, 255, 0); --primary-accent-foreground-text: #fff; - --primary-accent-dimmed: #cceeff; - --primary-accent-dimmed-hover: #c5e9fa; + --primary-accent-dimmed: #ffe4e6; + --primary-accent-dimmed-hover: #fecdd3; --primary-accent-dimmed-foreground-text: var(--body-color); - /* Color ramp (Light) */ - --theme-base-100: hsl(0, 0%, 100%); - --theme-base-95: var(--grey-10); - --theme-base-90: var(--grey-20); - --theme-base-85: var(--grey-30); - --theme-base-80: hsl(0, 0%, 80%); - --theme-base-70: hsl(0, 0%, 70%); - --theme-base-60: hsl(0, 0%, 60%); + /* Color ramp (Light) — replay-dashboard neutrals */ + --theme-base-100: #ffffff; + --theme-base-95: #fafafa; + --theme-base-90: #f5f5f5; + --theme-base-85: #e5e5e5; + --theme-base-80: #d4d4d4; + --theme-base-70: #a3a3a3; + --theme-base-60: #737373; /* Tabs (Light) */ --tab-bgcolor: var(--theme-base-90); @@ -713,7 +717,8 @@ --buttontext-color: #f4f8fa; --checkbox-border: var(--input-border); --checkbox: var(--theme-base-100); - --chrome: var(--theme-base-95); /* bg app */ + --checkbox-checked-mark-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"); + --chrome: #fafafa; /* bg app */ --icon-color: var(--body-color); --modal-bgcolor: rgba(255, 255, 255, 0.98); --modal-border: none; @@ -721,7 +726,8 @@ --tab-bgcolor-alt-subtle: var(--theme-base-100); /* info, events */ --tab-selected-bgcolor: var(--theme-base-100); /* current tab */ --input-border: var(--grey-20); - --theme-border: #d2d5da; + --focus-ring-input: var(--theme-base-60); + --theme-border: #e5e5e5; --theme-focuser-bgcolor: var(--primary-accent); --theme-sidebar-background: var(--theme-base-100); --theme-text-field-bgcolor-hover: var(--theme-base-95); @@ -781,7 +787,7 @@ --color-brand-react: #8c65d0; --color-contrast: var(--theme-base-100); --color-current: var(--secondary-accent); - --color-default: #223344; + --color-default: #0a0a0a; --color-dim: rgba(135, 135, 137, 0.9); --color-dimmer: #aaaaaa; --color-disabled-button: var(--color-default); @@ -1110,11 +1116,11 @@ /* DevTools left nav (Light) */ --toolbarbutton-focus-bgcolor: var(--theme-base-90); - /* Toolbar */ - --theme-tab-toolbar-background: var(--grey-20); - --theme-toolbar-background-alt: #f5f5f5; - --theme-toolbar-background-hover: rgba(221, 225, 228, 0.66); - --theme-toolbar-background: var(--grey-10); + /* Toolbar (Light) — softer chrome */ + --theme-tab-toolbar-background: var(--theme-base-95); + --theme-toolbar-background-alt: var(--theme-base-90); + --theme-toolbar-background-hover: var(--theme-base-90); + --theme-toolbar-background: var(--theme-base-100); --theme-toolbar-color: var(--body-color); --theme-toolbar-hover-active: var(--grey-20); --theme-toolbar-hover: var(--theme-base-100); @@ -1131,8 +1137,8 @@ --theme-button-background: rgba(12, 12, 13, 0.05); /* Accordion headers (Light) */ - --theme-accordion-header-background: var(--theme-toolbar-background); - --theme-accordion-header-hover: var(--theme-toolbar-hover); + --theme-accordion-header-background: var(--theme-base-95); + --theme-accordion-header-hover: var(--theme-base-90); /* Selection (Light) */ --theme-selection-background-hover: #f0f9fe; @@ -1143,9 +1149,9 @@ --theme-table-selection-background-hover: var(--theme-toolbar-background); /* Border color that splits the toolbars/panels/headers. */ - --theme-emphasized-splitter-color-hover: var(--grey-40); - --theme-emphasized-splitter-color: var(--grey-30); - --theme-splitter-color: #e0e0e2; + --theme-emphasized-splitter-color-hover: var(--theme-base-70); + --theme-emphasized-splitter-color: var(--theme-base-85); + --theme-splitter-color: var(--theme-base-85); /* Icon colors (Light) */ --theme-icon-checked-color: var(--blue-60); diff --git a/packages/shared/theme/replayTheme.ts b/packages/shared/theme/replayTheme.ts new file mode 100644 index 00000000000..1df2f2bede9 --- /dev/null +++ b/packages/shared/theme/replayTheme.ts @@ -0,0 +1,57 @@ +import type { Theme } from "./types"; + +/** Same key as replay-dashboard `src/lib/theme.ts` — keeps theme in sync when embedded next to the dashboard. */ +export const REPLAY_THEME_STORAGE_KEY = "replay_theme"; + +export const DEFAULT_THEME: Theme = "system"; + +export function readLocalStorageTheme(): Theme | null { + if (typeof window === "undefined") { + return null; + } + const v = localStorage.getItem(REPLAY_THEME_STORAGE_KEY); + if (v === "light" || v === "dark" || v === "system") { + return v; + } + return null; +} + +export function storeTheme(theme: Theme) { + if (typeof window === "undefined") { + return; + } + localStorage.setItem(REPLAY_THEME_STORAGE_KEY, theme); +} + +export function getEffectiveTheme(theme: Theme): "dark" | "light" { + if (typeof window === "undefined") { + return "dark"; + } + if (theme === "system") { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + if (theme === "light" || theme === "dark") { + return theme; + } + return "dark"; +} + +export function applyThemeToDOM(effectiveTheme: "dark" | "light") { + if (typeof document === "undefined") { + return; + } + const root = document.documentElement; + root.setAttribute("data-theme", effectiveTheme); + // Never assign className — Next/React may set classes on (hydration, fonts, etc.). + root.classList.remove("theme-light", "theme-dark", "dark"); + root.classList.add(effectiveTheme === "dark" ? "theme-dark" : "theme-light"); + if (effectiveTheme === "dark") { + root.classList.add("dark"); + } +} + +/** + * Inline in _document to reduce theme flash before React + UserData run. + * Reads `replay_theme`, then `Replay:UserPreferences.global_theme`, then system. + */ +export const themeInitScript = `(function(){try{var k='${REPLAY_THEME_STORAGE_KEY}';var u='Replay:UserPreferences';var t=localStorage.getItem(k);if(t!=='light'&&t!=='dark'&&t!=='system'){try{var raw=localStorage.getItem(u);if(raw){var p=JSON.parse(raw);if(p.global_theme==='light'||p.global_theme==='dark'||p.global_theme==='system')t=p.global_theme;}}catch(e){}}if(t!=='light'&&t!=='dark'&&t!=='system')t='system';var eff=t;if(t==='system')eff=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';var r=document.documentElement;r.setAttribute('data-theme',eff);r.classList.remove('theme-light','theme-dark','dark');r.classList.add('theme-'+eff);if(eff==='dark')r.classList.add('dark');}catch(e){}})();`; diff --git a/packages/shared/user-data/GraphQL/UserData.ts b/packages/shared/user-data/GraphQL/UserData.ts index 4be16498ad8..404db45cf8d 100644 --- a/packages/shared/user-data/GraphQL/UserData.ts +++ b/packages/shared/user-data/GraphQL/UserData.ts @@ -7,6 +7,12 @@ import { UpdateUserPreferences, UpdateUserPreferencesVariables, } from "shared/graphql/generated/UpdateUserPreferences"; +import { + REPLAY_THEME_STORAGE_KEY, + readLocalStorageTheme, + storeTheme, +} from "shared/theme/replayTheme"; +import type { Theme } from "shared/theme/types"; import { config } from "shared/user-data/GraphQL/config"; import { LOCAL_STORAGE_KEY } from "shared/user-data/GraphQL/constants"; import { GET_USER_PREFERENCES, UPDATE_USER_PREFERENCES } from "shared/user-data/GraphQL/queries"; @@ -50,9 +56,31 @@ class UserData implements GraphQLService { this.subscriberMap = new Map(); try { - const raw = localStorage.getItem(LOCAL_STORAGE_KEY); - if (raw !== null) { - this.cachedUserPreferences = JSON.parse(raw) as any; + if (typeof localStorage !== "undefined") { + const raw = localStorage.getItem(LOCAL_STORAGE_KEY); + if (raw !== null) { + this.cachedUserPreferences = JSON.parse(raw) as any; + } + } + } catch (error) {} + + try { + const dash = readLocalStorageTheme(); + if (dash !== null) { + const prev = this.cachedUserPreferences.global_theme; + this.cachedUserPreferences = { ...this.cachedUserPreferences, global_theme: dash }; + if (prev !== dash) { + this.saveLocal(this.cachedUserPreferences); + } + } else { + const ud: Theme = + this.cachedUserPreferences.global_theme ?? config.global_theme.defaultValue; + if ( + typeof localStorage !== "undefined" && + localStorage.getItem(REPLAY_THEME_STORAGE_KEY) === null + ) { + storeTheme(ud); + } } } catch (error) {} @@ -110,6 +138,15 @@ class UserData implements GraphQLService { } } + try { + const dash = readLocalStorageTheme(); + if (dash !== null && this.cachedUserPreferences.global_theme !== dash) { + this.cachedUserPreferences = { ...this.cachedUserPreferences, global_theme: dash }; + this.saveLocal(this.cachedUserPreferences); + this.notifySubscribers("global_theme", dash); + } + } catch (error) {} + this.initialized = true; } @@ -193,6 +230,10 @@ class UserData implements GraphQLService { return; } + if (key === "global_theme") { + storeTheme(value as Theme); + } + this.cachedUserPreferences = { ...this.cachedUserPreferences, [key]: value, diff --git a/pages/_document.tsx b/pages/_document.tsx index 428e4b410a5..c97eea1f1fa 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -4,6 +4,7 @@ import Document, { Head, Html, Main, NextScript } from "next/document"; import React from "react"; import { setErrorHandler } from "protocol/utils"; +import { themeInitScript } from "shared/theme/replayTheme"; import { isDevelopment } from "shared/utils/environment"; import { getAuthHost } from "ui/utils/auth"; @@ -21,6 +22,7 @@ const cspHashOf = (text: string) => { }; const isDev = process.env.NODE_ENV !== "production"; +const themeInitCspHash = cspHashOf(themeInitScript); const csp = (props: any) => { const hash = cspHashOf(NextScript.getInlineScriptSource(props)); const authHost = getAuthHost(); @@ -34,7 +36,7 @@ const csp = (props: any) => { }`, `frame-src replay: https://js.stripe.com https://hooks.stripe.com https://${authHost} https://www.loom.com/`, // Required by some of our external services - `script-src 'self' 'unsafe-eval' https://cdn.lr-ingest.io https://cdn.lr-in.com https://js.stripe.com ${hash}`, + `script-src 'self' 'unsafe-eval' https://cdn.lr-ingest.io https://cdn.lr-in.com https://js.stripe.com ${hash} ${themeInitCspHash}`, `form-action https://${authHost}`, // From vercel's CSP config and Google fonts @@ -58,14 +60,15 @@ const csp = (props: any) => { export default class MyDocument extends Document { render() { return ( - + {/* nosemgrep typescript.react.security.audit.react-http-leak.react-http-leak */} +