diff --git a/src/AppProviders.tsx b/src/AppProviders.tsx
index ea1f5fc9..6b81bd27 100644
--- a/src/AppProviders.tsx
+++ b/src/AppProviders.tsx
@@ -26,6 +26,7 @@ import {
import { resources } from '@/resources'
import { PostHogPageview } from '@/components/analytics/PostHogPageview'
import { PostHogIdentify } from '@/components/analytics/PostHogIdentify'
+import { CopyPasteTracking } from '@/components/analytics/CopyPasteTracking'
import { ErrorBoundary } from '@/components/analytics/ErrorBoundary'
import { SearchProvider } from '@/providers/search-provider'
@@ -99,6 +100,7 @@ export const AppProviders = ({ children }: { children: ReactNode }) => (
+
diff --git a/src/analytics/posthog.ts b/src/analytics/posthog.ts
index 4dd2c96a..84c67485 100644
--- a/src/analytics/posthog.ts
+++ b/src/analytics/posthog.ts
@@ -25,9 +25,11 @@ export const initPostHog = () => {
if (el?.type === 'password') {
return '*'.repeat(text.length)
}
+ // ph-no-mask is PostHog's standard class for opting inputs out of masking.
+ // Check the element and its ancestors so the class can be set on a wrapper.
if (
- el?.hasAttribute?.('data-posthog-unmask-search') ||
- el?.closest?.('[data-posthog-unmask-search]')
+ el?.classList?.contains('ph-no-mask') ||
+ el?.closest?.('.ph-no-mask')
) {
return text
}
@@ -109,3 +111,26 @@ export const resetUser = () => {
posthog.reset()
}
+export const setPersonProperties = (
+ properties: Record
+) => {
+ if (!isEnabled || !initialized) return
+ posthog.setPersonProperties(properties)
+}
+
+/**
+ * Reads browser-level accessibility and display preferences once per
+ * session and returns them as PostHog person properties. Called after
+ * the user is identified so the values are attached to the person record.
+ */
+export const getAccessibilityProps = (): Record => {
+ const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
+ return {
+ browser_font_size_px: rootFontSize,
+ font_size_increased: rootFontSize > 16,
+ device_pixel_ratio: window.devicePixelRatio ?? 1,
+ prefers_reduced_motion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
+ prefers_high_contrast: window.matchMedia('(prefers-contrast: more)').matches,
+ }
+}
+
diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx
index 52e01b21..32d8bb7c 100644
--- a/src/components/SearchModal.tsx
+++ b/src/components/SearchModal.tsx
@@ -86,7 +86,7 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => {
sx={{ fontSize: 15 }}
inputProps={{
'aria-label': 'Search',
- 'data-posthog-unmask-search': true,
+ className: 'ph-no-mask',
}}
endAdornment={
state.query ? (
diff --git a/src/components/analytics/CopyPasteTracking.tsx b/src/components/analytics/CopyPasteTracking.tsx
new file mode 100644
index 00000000..6399d36a
--- /dev/null
+++ b/src/components/analytics/CopyPasteTracking.tsx
@@ -0,0 +1,10 @@
+import { useCopyPasteTracking } from '@/hooks/useCopyPasteTracking'
+
+/**
+ * Renderless component that wires up copy/cut/paste event tracking for the
+ * entire app. Mount once inside AppProviders.
+ */
+export const CopyPasteTracking = () => {
+ useCopyPasteTracking()
+ return null
+}
diff --git a/src/components/analytics/PostHogIdentify.tsx b/src/components/analytics/PostHogIdentify.tsx
index 0837d4b0..e442d5c0 100644
--- a/src/components/analytics/PostHogIdentify.tsx
+++ b/src/components/analytics/PostHogIdentify.tsx
@@ -1,7 +1,7 @@
import { useEffect } from 'react'
import { useGetIdentity } from '@refinedev/core'
import { AuthentikIdentity } from '@/providers/authentik-provider'
-import { identifyUser, resetUser } from '@/analytics/posthog'
+import { identifyUser, resetUser, getAccessibilityProps, setPersonProperties } from '@/analytics/posthog'
export const PostHogIdentify = () => {
const { data: identity } = useGetIdentity()
@@ -9,6 +9,7 @@ export const PostHogIdentify = () => {
useEffect(() => {
if (identity?.id) {
identifyUser(identity.id, { name: identity.name, email: identity.email })
+ setPersonProperties(getAccessibilityProps())
} else {
resetUser()
}
diff --git a/src/hooks/useCopyPasteTracking.ts b/src/hooks/useCopyPasteTracking.ts
new file mode 100644
index 00000000..a228d84c
--- /dev/null
+++ b/src/hooks/useCopyPasteTracking.ts
@@ -0,0 +1,42 @@
+import { useEffect } from 'react'
+import { captureEvent } from '@/analytics/posthog'
+
+/**
+ * Attaches document-level listeners for copy, cut, and paste events and
+ * fires PostHog events for each. The actual copied or pasted text is never
+ * captured — only the page path and, for copy/cut, the selection length.
+ * Cut is tracked as text_cut, separate from text_copied.
+ */
+export const useCopyPasteTracking = () => {
+ useEffect(() => {
+ const handleCopy = () => {
+ captureEvent('text_copied', {
+ path: window.location.pathname,
+ selection_length: window.getSelection()?.toString().length ?? 0,
+ })
+ }
+
+ const handleCut = () => {
+ captureEvent('text_cut', {
+ path: window.location.pathname,
+ selection_length: window.getSelection()?.toString().length ?? 0,
+ })
+ }
+
+ const handlePaste = () => {
+ captureEvent('text_pasted', {
+ path: window.location.pathname,
+ })
+ }
+
+ document.addEventListener('copy', handleCopy)
+ document.addEventListener('cut', handleCut)
+ document.addEventListener('paste', handlePaste)
+
+ return () => {
+ document.removeEventListener('copy', handleCopy)
+ document.removeEventListener('cut', handleCut)
+ document.removeEventListener('paste', handlePaste)
+ }
+ }, [])
+}