feat(analytics): show static nav chrome in session replays (#1412)

* feat(analytics): show static nav chrome in session replays

Replays previously masked every text node via maskTextSelector '*',
which made them unreadable: even sidebar labels and buttons were
asterisks. Add a fail-safe maskTextFn: text stays masked unless its
nearest tagged ancestor is data-ph-unmask, and data-ph-mask re-masks
user data nested inside an unmasked container. Untagged text stays
masked, so a forgotten tag can never leak user data.

Tags the four nav containers and the skip link; the notification
count bubbles inside them are re-masked. User menu, company switcher
and page titles stay masked on purpose: on detail pages the title is
user data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(analytics): use English domain terms in masking comment

CodeRabbit: comment used 'enskild firma' and 'personnummer'; repo
guideline is English for all comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-05 14:19:36 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6dcd401fb5
commit b1f71912a2
3 changed files with 39 additions and 14 deletions
+1
View File
@@ -303,6 +303,7 @@ export default async function DashboardLayout({
>
{/* Skip to content link for keyboard/screen reader users */}
<a
data-ph-unmask
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
>
+14 -8
View File
@@ -548,7 +548,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
: null
const countBubble = (badge: number) => (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
<span data-ph-mask className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
)
@@ -651,7 +651,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
cn('h-[17px] w-[17px]', active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'),
)}
{badge !== null && (
<span className="absolute -top-2 -right-2.5 min-w-[15px] h-[15px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
<span data-ph-mask className="absolute -top-2 -right-2.5 min-w-[15px] h-[15px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
{badge > 99 ? '99' : badge}
</span>
)}
@@ -727,7 +727,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
the aside width animates: no DOM swap, one continuous motion.
The inactive layer is absolute (no layout), faded, nudged
sideways and inert. */}
{/* data-ph-unmask: nav labels are static i18n chrome; count
bubbles inside carry data-ph-mask (nearest tag wins). */}
<nav
data-ph-unmask
aria-hidden={!collapsed}
inert={!collapsed ? true : undefined}
className={cn(
@@ -766,6 +769,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
})}
</nav>
<nav
data-ph-unmask
aria-hidden={collapsed}
inert={collapsed ? true : undefined}
className={cn(
@@ -899,7 +903,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</aside>
{/* Mobile bottom navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
<nav data-ph-unmask className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
<div className="flex items-center justify-around h-16 px-2">
{mobileNavItems.map((item) => {
const active = isActive(item.href)
@@ -913,7 +917,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
<div className="relative">
{renderNavIcon(item, cn('h-5 w-5 mb-1', active && 'text-primary'))}
{badge !== null && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
<span data-ph-mask className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
{badge > 99 ? '99+' : badge}
</span>
)}
@@ -1001,8 +1005,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</Button>
</div>
{/* Navigation */}
<div className="px-2">
{/* Navigation. data-ph-unmask: static i18n labels only; the
CompanySwitcher above stays outside so it remains masked,
and count bubbles inside carry data-ph-mask. */}
<div data-ph-unmask className="px-2">
{/* Top items (Hem, Assistent) */}
<div className="space-y-0.5">
{topItems.map((item) => {
@@ -1015,7 +1021,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
{renderNavIcon(item, cn('h-[18px] w-[18px] flex-shrink-0', active ? 'text-primary' : 'text-muted-foreground'))}
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
{decorBadge ? decorBadge : badge !== null && (
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
<span data-ph-mask className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
{badge > 99 ? '99+' : badge}
</span>
)}
@@ -1072,7 +1078,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
{decorBadge ? decorBadge : badge !== null && (
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
<span data-ph-mask className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
{badge > 99 ? '99+' : badge}
</span>
)}
+24 -6
View File
@@ -53,13 +53,24 @@ function tracingHosts(): string[] {
* `seenSurvey_*` flags straight to localStorage, bypassing this setting:
* that is functional UI state ("don't ask again"), not tracking.
*
* 3. `maskTextSelector: '*'` (PostHog's documented way to mask ALL text) on
* top of the default `maskAllInputs`. This is an accounting app: org
* numbers (which for an enskild firma ARE the owner's personnummer),
* customer names, balances and invoice amounts are rendered as ordinary
* text, and PostHog masks inputs but NOT text by default. Replays are for
* seeing WHERE a user gets stuck, never WHAT their books say.
* 3. Mask-by-default text masking. `maskTextSelector: '*'` routes EVERY text
* node through `maskTextFn`, which masks unless a `data-ph-unmask`
* ancestor opts the node back in. This is an accounting app: org numbers
* (which for a sole proprietorship ARE the owner's personal identity
* number), customer names, balances and invoice amounts are rendered as
* ordinary text, and PostHog masks inputs but NOT text by default.
* Replays are for seeing WHERE a user gets stuck, never WHAT their books
* say.
*
* `data-ph-unmask` is for static chrome only (nav labels, headings,
* button text from i18n). User data nested inside an unmasked container
* (active company name, user email, badge counts) gets `data-ph-mask`,
* which wins because `closest()` finds the NEAREST tagged ancestor.
* Anything untagged stays masked, so a forgotten tag fails safe.
*/
function maskText(text: string): string {
return text.replace(/\S/g, '*')
}
if (warnIfAnalyticsMisconfigured() && isAnalyticsEnabled()) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: '/rl',
@@ -74,6 +85,13 @@ if (warnIfAnalyticsMisconfigured() && isAnalyticsEnabled()) {
session_recording: {
maskAllInputs: true,
maskTextSelector: '*',
maskTextFn: (text: string, element?: HTMLElement): string => {
const tagged = element?.closest('[data-ph-unmask],[data-ph-mask]')
if (!tagged || tagged.hasAttribute('data-ph-mask')) {
return maskText(text)
}
return text
},
},
debug: process.env.NODE_ENV === 'development',
})