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 (
+
+ )
+}
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',
},