diff --git a/frontend/src/layout/GlobalModals.tsx b/frontend/src/layout/GlobalModals.tsx index 596b2980c326c..040510d4d532f 100644 --- a/frontend/src/layout/GlobalModals.tsx +++ b/frontend/src/layout/GlobalModals.tsx @@ -12,6 +12,7 @@ import { CreateProjectModal } from 'scenes/project/CreateProjectModal' import { SessionPlayerModal } from 'scenes/session-recordings/player/modal/SessionPlayerModal' import { inviteLogic } from 'scenes/settings/organization/inviteLogic' import { InviteModal } from 'scenes/settings/organization/InviteModal' +import { PreviewingCustomCssModal } from 'scenes/themes/PreviewingCustomCssModal' import { userLogic } from 'scenes/userLogic' import type { globalModalsLogicType } from './GlobalModalsType' @@ -70,6 +71,7 @@ export function GlobalModals(): JSX.Element { + {user && user.organization?.enforce_2fa && !user.is_2fa_enabled && (

diff --git a/frontend/src/layout/navigation-3000/themeLogic.ts b/frontend/src/layout/navigation-3000/themeLogic.ts index 765ed1c3c909d..b34468ebfcd5c 100644 --- a/frontend/src/layout/navigation-3000/themeLogic.ts +++ b/frontend/src/layout/navigation-3000/themeLogic.ts @@ -1,4 +1,4 @@ -import { actions, connect, events, kea, path, reducers, selectors } from 'kea' +import { actions, connect, events, kea, listeners, path, reducers, selectors } from 'kea' import { FEATURE_FLAGS } from 'lib/constants' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { sceneLogic } from 'scenes/sceneLogic' @@ -15,6 +15,9 @@ export const themeLogic = kea([ actions({ syncDarkModePreference: (darkModePreference: boolean) => ({ darkModePreference }), setTheme: (theme: string | null) => ({ theme }), + saveCustomCss: true, + setPersistedCustomCss: (css: string | null) => ({ css }), + setPreviewingCustomCss: (css: string | null) => ({ css }), }), reducers({ darkModeSystemPreference: [ @@ -30,6 +33,20 @@ export const themeLogic = kea([ setTheme: (_, { theme }) => theme, }, ], + persistedCustomCss: [ + null as string | null, + { persist: true }, + { + setPersistedCustomCss: (_, { css }) => css, + }, + ], + previewingCustomCss: [ + null as string | null, + { persist: true }, + { + setPreviewingCustomCss: (_, { css }) => css, + }, + ], }), selectors({ theme: [ @@ -43,6 +60,14 @@ export const themeLogic = kea([ ) }, ], + customCssEnabled: [ + (s) => [s.featureFlags], + (featureFlags): boolean => !!featureFlags[FEATURE_FLAGS.CUSTOM_CSS_THEMES], + ], + customCss: [ + (s) => [s.persistedCustomCss, s.previewingCustomCss], + (persistedCustomCss, previewingCustomCss): string | null => previewingCustomCss || persistedCustomCss, + ], isDarkModeOn: [ (s) => [s.themeMode, s.darkModeSystemPreference, sceneLogic.selectors.sceneConfig, s.theme], (themeMode, darkModeSystemPreference, sceneConfig, theme) => { @@ -70,6 +95,12 @@ export const themeLogic = kea([ }, ], }), + listeners(({ values, actions }) => ({ + saveCustomCss() { + actions.setPersistedCustomCss(values.previewingCustomCss) + actions.setPreviewingCustomCss(null) + }, + })), events(({ cache, actions }) => ({ afterMount() { cache.prefersColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)') diff --git a/frontend/src/lib/components/CommandPalette/commandPaletteLogic.tsx b/frontend/src/lib/components/CommandPalette/commandPaletteLogic.tsx index de960bb7b2648..d8afa8994a115 100644 --- a/frontend/src/lib/components/CommandPalette/commandPaletteLogic.tsx +++ b/frontend/src/lib/components/CommandPalette/commandPaletteLogic.tsx @@ -23,6 +23,7 @@ import { IconLive, IconNight, IconNotebook, + IconPalette, IconPeople, IconPeopleFilled, IconPieChart, @@ -858,6 +859,11 @@ export const commandPaletteLogic = kea([ actions.updateUser({ theme_mode: 'system' }) }, }, + { + icon: IconPalette, + display: 'Add custom CSS', + executor: () => push(urls.customCss()), + }, ], }), }, diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index d9d84f586c12e..3bc00eb8ddf34 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -231,6 +231,7 @@ export const FEATURE_FLAGS = { CUSTOM_CHANNEL_TYPE_RULES: 'custom-channel-type-rules', // owner: @robbie-c #team-web-analytics SELF_SERVE_CREDIT_OVERRIDE: 'self-serve-credit-override', // owner: @zach EXPERIMENTS_MIGRATION_DISABLE_UI: 'experiments-migration-disable-ui', // owner: @jurajmajerik #team-experiments + CUSTOM_CSS_THEMES: 'custom-css-themes', // owner: @daibhin } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/lib/hooks/useThemedHtml.ts b/frontend/src/lib/hooks/useThemedHtml.ts index 580596f8addb1..f09efd2659b32 100644 --- a/frontend/src/lib/hooks/useThemedHtml.ts +++ b/frontend/src/lib/hooks/useThemedHtml.ts @@ -6,16 +6,33 @@ import { sceneLogic } from 'scenes/sceneLogic' import { themeLogic } from '~/layout/navigation-3000/themeLogic' export function useThemedHtml(overflowHidden = true): void { - const { isDarkModeOn } = useValues(themeLogic) + const { isDarkModeOn, customCss } = useValues(themeLogic) const { sceneConfig } = useValues(sceneLogic) + const CUSTOM_THEME_STYLES_ID = 'ph-custom-theme-styles' + useEffect(() => { + const oldStyle = document.getElementById(CUSTOM_THEME_STYLES_ID) + if (oldStyle) { + document.head.removeChild(oldStyle) + } + document.body.setAttribute('theme', isDarkModeOn ? 'dark' : 'light') + + if (customCss) { + const newStyle = document.createElement('style') + newStyle.id = CUSTOM_THEME_STYLES_ID + newStyle.appendChild(document.createTextNode(customCss)) + document.head.appendChild(newStyle) + } + }, [isDarkModeOn, customCss]) + + useEffect(() => { // overflow-hidden since each area handles scrolling individually (e.g. navbar, scene, side panel) if (overflowHidden) { document.body.classList.add('overflow-hidden') } - }, [isDarkModeOn]) + }, [overflowHidden]) useEffect(() => { // Add a theme-color meta tag to the head to change the address bar color on browsers that support it diff --git a/frontend/src/scenes/appScenes.ts b/frontend/src/scenes/appScenes.ts index 4a8e63759e124..66a5fc9a78788 100644 --- a/frontend/src/scenes/appScenes.ts +++ b/frontend/src/scenes/appScenes.ts @@ -37,6 +37,7 @@ export const appScenes: Record any> = { [Scene.ErrorTrackingGroup]: () => import('./error-tracking/ErrorTrackingGroupScene'), [Scene.Surveys]: () => import('./surveys/Surveys'), [Scene.Survey]: () => import('./surveys/Survey'), + [Scene.CustomCss]: () => import('./themes/CustomCssScene'), [Scene.SurveyTemplates]: () => import('./surveys/SurveyTemplates'), [Scene.DataModel]: () => import('./data-model/DataModelScene'), [Scene.DataWarehouse]: () => import('./data-warehouse/external/DataWarehouseExternalScene'), diff --git a/frontend/src/scenes/sceneTypes.ts b/frontend/src/scenes/sceneTypes.ts index 2c0d227e5ba88..444370aae93da 100644 --- a/frontend/src/scenes/sceneTypes.ts +++ b/frontend/src/scenes/sceneTypes.ts @@ -26,6 +26,7 @@ export enum Scene { ReplaySingle = 'ReplaySingle', ReplayPlaylist = 'ReplayPlaylist', ReplayFilePlayback = 'ReplayFilePlayback', + CustomCss = 'CustomCss', PersonsManagement = 'PersonsManagement', Person = 'Person', PipelineNodeNew = 'PipelineNodeNew', diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index 9f6309458496a..160308878990f 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -131,6 +131,10 @@ export const sceneConfigurations: Record = { activityScope: ActivityScope.REPLAY, defaultDocsPath: '/docs/session-replay', }, + [Scene.CustomCss]: { + projectBased: true, + name: 'Custom CSS', + }, [Scene.ReplayPlaylist]: { projectBased: true, name: 'Replay playlist', @@ -539,6 +543,7 @@ export const routes: Record = { [urls.pipeline(':tab')]: Scene.Pipeline, [urls.pipelineNode(':stage', ':id', ':nodeTab')]: Scene.PipelineNode, [urls.pipelineNode(':stage', ':id')]: Scene.PipelineNode, + [urls.customCss()]: Scene.CustomCss, [urls.groups(':groupTypeIndex')]: Scene.PersonsManagement, [urls.group(':groupTypeIndex', ':groupKey', false)]: Scene.Group, [urls.group(':groupTypeIndex', ':groupKey', false, ':groupTab')]: Scene.Group, diff --git a/frontend/src/scenes/settings/user/ThemeSwitcher.tsx b/frontend/src/scenes/settings/user/ThemeSwitcher.tsx index 5dd8b1c3bf3f5..64d87c0f747b3 100644 --- a/frontend/src/scenes/settings/user/ThemeSwitcher.tsx +++ b/frontend/src/scenes/settings/user/ThemeSwitcher.tsx @@ -1,22 +1,39 @@ -import { IconDay, IconLaptop, IconNight } from '@posthog/icons' -import { LemonSelect, LemonSelectProps } from '@posthog/lemon-ui' +import { IconDay, IconLaptop, IconNight, IconPalette } from '@posthog/icons' +import { LemonSelect, LemonSelectOptions, LemonSelectProps } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' +import { router } from 'kea-router' +import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' +import { themeLogic } from '~/layout/navigation-3000/themeLogic' + export function ThemeSwitcher({ onlyLabel, ...props }: Partial> & { onlyLabel?: boolean }): JSX.Element { const { themeMode } = useValues(userLogic) const { updateUser } = useActions(userLogic) + const { customCssEnabled } = useValues(themeLogic) - return ( - = [ + { + options: [ { icon: , value: 'light', label: 'Light mode' }, { icon: , value: 'dark', label: 'Dark mode' }, { icon: , value: 'system', label: `Sync with system` }, - ]} + ], + }, + ] + + if (customCssEnabled) { + themeOptions.push({ + options: [{ icon: , value: 'custom', label: 'Edit custom CSS' }], + }) + } + + return ( + { const labelText = leaf ? leaf.label : 'Sync with system' @@ -31,7 +48,13 @@ export function ThemeSwitcher({ ) }} - onChange={(value) => updateUser({ theme_mode: value })} + onChange={(value) => { + if (value === 'custom') { + router.actions.push(urls.customCss()) + } else { + updateUser({ theme_mode: value }) + } + }} dropdownPlacement="right-start" dropdownMatchSelectWidth={false} {...props} diff --git a/frontend/src/scenes/themes/CustomCssScene.tsx b/frontend/src/scenes/themes/CustomCssScene.tsx new file mode 100644 index 0000000000000..affe0430cb67a --- /dev/null +++ b/frontend/src/scenes/themes/CustomCssScene.tsx @@ -0,0 +1,132 @@ +import { LemonButton, Link } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { router } from 'kea-router' +import { PageHeader } from 'lib/components/PageHeader' +import { CodeEditor } from 'lib/monaco/CodeEditor' +import { useEffect } from 'react' +import { SceneExport } from 'scenes/sceneTypes' + +import { themeLogic } from '~/layout/navigation-3000/themeLogic' + +import { urls } from '../urls' + +export const scene: SceneExport = { + component: CustomCssScene, +} + +const TRON_THEME = `:root { + --radius: 0px; +} + +body[theme=dark] { + --border: rgba(0, 255, 1, 0.5); + --link: #00FF01; + --border-bold: #00FF01; + --bg-3000: #111; + --glass-bg-3000: #111; + --bg-light: #222; + --bg-table: #222; + --muted-3000: #0EA70E; + --primary-3000: #00FF01; + --primary-3000-hover: #00FF01; + --primary-alt-highlight: rgba(0, 255, 1, 0.1); + --text-3000: #00FF01; + --accent-3000: #222; + --glass-border-3000: rgba(0,0,0,.3); + --font-title: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; + + --primary-3000-frame-bg-light: #00FF01; + --primary-3000-button-bg: #00FF01; + --primary-3000-button-border: #00FF01; + --text-secondary-3000: #00FF01; +} + +.TopBar3000__content { + border-bottom: solid 1px #00FF01; +}` + +const BARBIE_THEME = `:root { + --radius: 16px; +} + +body[theme=light] { + --border: rgba(255, 105, 180, 0.5); + --border-3000: #ff409f; + --link: #E306AD; + --border-bold: rgba(255, 105, 180, 0.8); + --bg-3000: #FED9E9; + --glass-bg-3000: rgba(255, 192, 203, 0.8); + --bg-light: #FFF0F5; + --bg-table: #F8BBD0; + --muted-3000: #E306AD; + --primary-3000: #FF69B4; + --primary-3000-hover: #FF1493; + --primary-alt-highlight: rgba(255, 105, 180, 0.1); + --text-3000: #ed3993; + --text-3000-light: #58003f; + --accent-3000: #FEBDE2; + --glass-border-3000: rgba(245, 145, 199, 0.3); + + --primary-3000-frame-bg-light: #F18DBC; + --primary-3000-button-bg: #FF69B4; + --primary-3000-button-border: #FF1493; + --primary-3000-button-border-hover: #db097b; + --text-secondary-3000: #FFB6C1; + + --secondary-3000-button-border: #FF1493; + --secondary-3000-frame-bg-light: #F7B9D7; + --secondary-3000-button-border-hover: #d40b76; +}` + +export function CustomCssScene(): JSX.Element { + const { persistedCustomCss, previewingCustomCss } = useValues(themeLogic) + const { saveCustomCss, setPreviewingCustomCss } = useActions(themeLogic) + + useEffect(() => { + setPreviewingCustomCss(previewingCustomCss || persistedCustomCss || '') + }, []) + + const onPreview = (): void => { + router.actions.push(urls.projectHomepage()) + } + + return ( +

+ + + Preview + + { + saveCustomCss() + router.actions.push(urls.projectHomepage()) + }} + > + Save and set + + + } + /> +

+ You can add custom CSS to change the style of your PostHog instance. If you need some inspiration try + our templates: setPreviewingCustomCss(TRON_THEME)}>Tron,{' '} + setPreviewingCustomCss(BARBIE_THEME)}>Barbie +

+ setPreviewingCustomCss(v ?? null)} + height={600} + options={{ + minimap: { + enabled: false, + }, + }} + /> +
+ ) +} diff --git a/frontend/src/scenes/themes/PreviewingCustomCssModal.tsx b/frontend/src/scenes/themes/PreviewingCustomCssModal.tsx new file mode 100644 index 0000000000000..dd2ed9d3dba28 --- /dev/null +++ b/frontend/src/scenes/themes/PreviewingCustomCssModal.tsx @@ -0,0 +1,52 @@ +import { LemonButton } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { router } from 'kea-router' +import { CodeEditor } from 'lib/monaco/CodeEditor' +import { useState } from 'react' +import { urls } from 'scenes/urls' + +import { themeLogic } from '~/layout/navigation-3000/themeLogic' + +export function PreviewingCustomCssModal(): JSX.Element | null { + const [editingInline, setEditingInline] = useState(false) + + const { previewingCustomCss } = useValues(themeLogic) + const { saveCustomCss, setPreviewingCustomCss } = useActions(themeLogic) + const { + location: { pathname }, + } = useValues(router) + + const isCustomCSSPage = pathname.includes(urls.customCss()) + const open = !isCustomCSSPage && !!previewingCustomCss + + return ( + + {editingInline && ( + setPreviewingCustomCss(v ?? null)} + height={600} + options={{ + minimap: { enabled: false }, + }} + /> + )} +
+

Custom CSS

+
+ setEditingInline(!editingInline)}> + {editingInline ? 'Minimize editor' : 'Edit'} + + + Save and close + +
+
+
+ ) +} diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index ccd4b7a482ff8..44a6618bfb4ed 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -169,6 +169,7 @@ export const urls = { /** @param id A UUID or 'new'. ':id' for routing. */ survey: (id: string): string => `/surveys/${id}`, surveyTemplates: (): string => '/survey_templates', + customCss: (): string => '/themes/custom-css', dataModel: (): string => '/data-model', dataWarehouse: (query?: string | Record): string => combineUrl(`/data-warehouse`, {}, query ? { q: typeof query === 'string' ? query : JSON.stringify(query) } : {}) diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss index 0910cb51bb757..59ed48b6988fb 100644 --- a/frontend/src/styles/global.scss +++ b/frontend/src/styles/global.scss @@ -424,6 +424,10 @@ body { overflow: auto; } + dialog { + background-color: var(--bg-3000); + } + .LemonButton, .Link { .text-link { diff --git a/tailwind.config.js b/tailwind.config.js index c6eac5bf2e52e..fc8ee1cac4c34 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -53,7 +53,7 @@ const config = { borderRadius: { none: '0', sm: '0.25rem', // Originally 0.125rem, but we're rounder - DEFAULT: '0.375rem', // Originally 0.25rem, but we're rounder - aligned with var(--radius) + DEFAULT: 'var(--radius)', lg: '0.5rem', full: '9999px', },