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/e2e-tests/helpers/passport.ts b/packages/e2e-tests/helpers/passport.ts index 91e7050cd00..a32e9b6b4c4 100644 --- a/packages/e2e-tests/helpers/passport.ts +++ b/packages/e2e-tests/helpers/passport.ts @@ -1,13 +1,11 @@ import { Page } from "@playwright/test"; export async function showPassport(page: Page) { - const passportTab = page.locator(".toolbar-panel-button.passport"); + const passportTab = page.locator("button.toolbar-panel-button.passport"); await passportTab.waitFor(); - const classes = await passportTab.getAttribute("class"); - if (!classes?.includes("active")) { - const passportButton = passportTab.locator("button"); - await passportButton.isEnabled(); - await passportButton.click(); + const pressed = await passportTab.getAttribute("aria-pressed"); + if (pressed !== "true") { + await passportTab.click(); } } 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/ElementsListItem.module.css b/packages/replay-next/components/elements/ElementsListItem.module.css index 2c848777ecd..c084bc7e53d 100644 --- a/packages/replay-next/components/elements/ElementsListItem.module.css +++ b/packages/replay-next/components/elements/ElementsListItem.module.css @@ -22,7 +22,7 @@ color: var(--color-default); } .Node[data-selected] { - background-color: var(--primary-accent-dimmed); + background-color: var(--theme-list-selection-background); } .Node[data-selected], .Node[data-selected] .HtmlAttributeName, @@ -32,7 +32,8 @@ .Node[data-selected] .HTMLTag, .Node[data-selected] .Separator, .Node[data-selected] .Icon { - fill: var(--theme-selection-color); + color: var(--body-color); + fill: var(--body-color); } .Node[data-loading] { opacity: 0.5; 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..48dcae9a9fd 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 */ @@ -121,6 +121,8 @@ --z-index-8--dropdown: var(--z-index-8); /* The styles below are the same between themes */ + /* List/tree/source selection (see --theme-selection-background in theme blocks) */ + --theme-selection-accent: #01acfd; --support-form-color: var(--grey-90); --support-form-close-icon-color: #ffffff; --support-form-modal-background-color: #ffffff; @@ -134,18 +136,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 +159,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 +175,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 +559,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,21 +580,23 @@ --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; - --theme-selection-background: var(--primary-accent); + /* Selection (Dark) — --theme-selection-accent set in :root */ + --theme-selection-background-hover: #33bcfd; + --theme-selection-background: var(--theme-selection-accent); --theme-selection-color: #fff; --theme-selection-focus-background: var(--grey-60); --theme-selection-focus-color: var(--grey-30); --theme-table-selection-background-hover: var(--theme-toolbar-background); + /* Softer selected row (network, sub-panels) — blue, not --primary-accent-dimmed (rose) */ + --theme-list-selection-background: rgb(1 172 253 / 0.24); /* 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 +674,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 +692,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 +721,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 +730,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 +791,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 +1120,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,21 +1141,22 @@ --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; - --theme-selection-background: var(--primary-accent); - --theme-selection-color: var(--theme-base-100); + --theme-selection-background-hover: rgb(1 172 253 / 0.22); + --theme-selection-background: var(--theme-selection-accent); + --theme-selection-color: #fff; --theme-selection-focus-background: var(--toolbarbutton-hover-background); --theme-selection-focus-color: var(--grey-70); --theme-table-selection-background-hover: var(--theme-toolbar-background); + --theme-list-selection-background: rgb(1 172 253 / 0.14); /* 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.test.ts b/packages/shared/user-data/GraphQL/UserData.test.ts index 884e37233e7..750cfea81b5 100644 --- a/packages/shared/user-data/GraphQL/UserData.test.ts +++ b/packages/shared/user-data/GraphQL/UserData.test.ts @@ -1,7 +1,13 @@ +import { REPLAY_THEME_STORAGE_KEY } from "shared/theme/replayTheme"; import { ConsoleEventFilterPreferences, config } from "shared/user-data/GraphQL/config"; import { LOCAL_STORAGE_KEY } from "shared/user-data/GraphQL/constants"; import { PreferencesKey } from "shared/user-data/GraphQL/types"; +/** Align with dashboard `replay_theme` bootstrap so UserData does not call storeTheme/saveLocal for theme in tests. */ +function getReplayThemeFromMock(): string { + return "system"; +} + describe("UserData", () => { let localStorageMock: { clear: jest.Mock; @@ -65,9 +71,12 @@ describe("UserData", () => { it("should support URL overrides for boolean preferences", () => { localStorageMock.getItem.mockImplementation((key: string) => { switch (key) { + case REPLAY_THEME_STORAGE_KEY: + return getReplayThemeFromMock(); case LOCAL_STORAGE_KEY: return JSON.stringify({ layout_sidePanelCollapsed: false, + global_theme: "system", }); default: return null; @@ -88,9 +97,12 @@ describe("UserData", () => { it("should read/write values to localStorage", async () => { localStorageMock.getItem.mockImplementation((key: string) => { switch (key) { + case REPLAY_THEME_STORAGE_KEY: + return getReplayThemeFromMock(); case LOCAL_STORAGE_KEY: return JSON.stringify({ console_showFiltersByDefault: true, + global_theme: "system", }); default: return null; @@ -130,10 +142,13 @@ describe("UserData", () => { it("should read/write values to GraphQL for authenticated users", async () => { localStorageMock.getItem.mockImplementation((key: string) => { switch (key) { + case REPLAY_THEME_STORAGE_KEY: + return getReplayThemeFromMock(); case LOCAL_STORAGE_KEY: return JSON.stringify({ debugger_frameworkGroupingOn: false, console_showFiltersByDefault: true, + global_theme: "system", }); default: return null; @@ -243,6 +258,8 @@ describe("UserData", () => { it("should be migrated from useLocalStorage hook", () => { localStorageMock.getItem.mockImplementation((key: string) => { switch (key) { + case REPLAY_THEME_STORAGE_KEY: + return getReplayThemeFromMock(); case config.layout_sidePanelCollapsed.legacyKey: return JSON.stringify(true); default: @@ -258,6 +275,8 @@ describe("UserData", () => { it("should migrated from Mozilla preferences service", () => { localStorageMock.getItem.mockImplementation((key: string) => { switch (key) { + case REPLAY_THEME_STORAGE_KEY: + return getReplayThemeFromMock(); case `Services.prefs:${config.protocol_repaintEvaluations.legacyKey}`: return JSON.stringify({ hasUserValue: true, @@ -276,6 +295,8 @@ describe("UserData", () => { it("should memoize parsed legacy values", () => { localStorageMock.getItem.mockImplementation((key: string) => { switch (key) { + case REPLAY_THEME_STORAGE_KEY: + return getReplayThemeFromMock(); case config.console_eventFilters.legacyKey: return JSON.stringify({ keyboard: true, 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 */} +