feat(demo): replace blur with asterisk masking of user data via React context

This commit is contained in:
Offending Commit
2026-04-27 14:18:01 -05:00
parent 4f51ceae89
commit 8f9d806eef
11 changed files with 75 additions and 47 deletions

View File

@@ -7,6 +7,7 @@ import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/input"; import { Textarea } from "@/components/ui/input";
import { SectionHeading } from "@/components/ui/typography"; import { SectionHeading } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
interface Message { interface Message {
id: string; id: string;
@@ -15,6 +16,7 @@ interface Message {
} }
export function ChatPage() { export function ChatPage() {
const { mask } = useDemo();
const { workspaceId, peerId } = useParams({ strict: false }) as { const { workspaceId, peerId } = useParams({ strict: false }) as {
workspaceId: string; workspaceId: string;
peerId: string; peerId: string;
@@ -143,7 +145,7 @@ export function ChatPage() {
} }
} }
> >
<p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p> <p className="whitespace-pre-wrap leading-relaxed">{mask(msg.content)}</p>
</div> </div>
</motion.div> </motion.div>
))} ))}

View File

@@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button";
import { Input, Textarea } from "@/components/ui/input"; import { Input, Textarea } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Body, Caption, MonoCaption, Muted, PageTitle } from "@/components/ui/typography"; import { Body, Caption, MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
import { COLOR } from "@/lib/constants"; import { COLOR } from "@/lib/constants";
type Conclusion = components["schemas"]["Conclusion"]; type Conclusion = components["schemas"]["Conclusion"];
@@ -49,6 +50,7 @@ const itemVariants = {
}; };
export function ConclusionBrowser() { export function ConclusionBrowser() {
const { mask } = useDemo();
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string }; const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [sortField, setSortField] = useState("created_at"); const [sortField, setSortField] = useState("created_at");
@@ -233,7 +235,7 @@ export function ConclusionBrowser() {
}} }}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<Body className="whitespace-pre-wrap flex-1">{c.content}</Body> <Body className="whitespace-pre-wrap flex-1">{mask(c.content)}</Body>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -250,12 +252,12 @@ export function ConclusionBrowser() {
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Eye className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} /> <Eye className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
<MonoCaption>{c.observer_id}</MonoCaption> <MonoCaption>{mask(c.observer_id)}</MonoCaption>
</div> </div>
{c.observed_id && ( {c.observed_id && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Caption></Caption> <Caption></Caption>
<MonoCaption>{c.observed_id}</MonoCaption> <MonoCaption>{mask(c.observed_id)}</MonoCaption>
</div> </div>
)} )}
{c.session_id && ( {c.session_id && (
@@ -266,7 +268,7 @@ export function ConclusionBrowser() {
className="flex items-center gap-1 text-xs font-mono hover:underline" className="flex items-center gap-1 text-xs font-mono hover:underline"
style={{ color: "var(--accent-text)" }} style={{ color: "var(--accent-text)" }}
> >
{c.session_id} {mask(c.session_id)}
</Link> </Link>
)} )}
{c.created_at && ( {c.created_at && (

View File

@@ -36,9 +36,11 @@ import {
PageTitle, PageTitle,
SectionHeading, SectionHeading,
} from "@/components/ui/typography"; } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
import { COLOR } from "@/lib/constants"; import { COLOR } from "@/lib/constants";
export function PeerDetail() { export function PeerDetail() {
const { mask } = useDemo();
const { workspaceId, peerId } = useParams({ strict: false }) as { const { workspaceId, peerId } = useParams({ strict: false }) as {
workspaceId: string; workspaceId: string;
peerId: string; peerId: string;
@@ -209,12 +211,12 @@ export function PeerDetail() {
}} }}
> >
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<Badge variant="blue">{r.peer_id ?? peerId}</Badge> <Badge variant="blue">{mask(r.peer_id ?? peerId)}</Badge>
{r.created_at && ( {r.created_at && (
<Caption>{new Date(r.created_at).toLocaleString()}</Caption> <Caption>{new Date(r.created_at).toLocaleString()}</Caption>
)} )}
</div> </div>
<Body className="whitespace-pre-wrap">{r.content}</Body> <Body className="whitespace-pre-wrap">{mask(r.content)}</Body>
</div> </div>
)) ))
)} )}

View File

@@ -11,6 +11,7 @@ import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination"; import { Pagination } from "@/components/shared/Pagination";
import { SortControl, type SortDir } from "@/components/shared/SortControl"; import { SortControl, type SortDir } from "@/components/shared/SortControl";
import { MonoCaption, PageTitle } from "@/components/ui/typography"; import { MonoCaption, PageTitle } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
import { COLOR } from "@/lib/constants"; import { COLOR } from "@/lib/constants";
type Peer = components["schemas"]["Peer"]; type Peer = components["schemas"]["Peer"];
@@ -45,6 +46,7 @@ const item: Variants = {
}; };
export function PeerList() { export function PeerList() {
const { mask } = useDemo();
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string }; const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [sortField, setSortField] = useState("created_at"); const [sortField, setSortField] = useState("created_at");
@@ -246,7 +248,7 @@ export function PeerList() {
className="font-mono text-sm font-medium truncate" className="font-mono text-sm font-medium truncate"
style={{ color: COLOR.accentSoft }} style={{ color: COLOR.accentSoft }}
> >
{peer.id} {mask(peer.id)}
</span> </span>
<ChevronRight <ChevronRight
className="w-4 h-4 shrink-0 ml-2 opacity-30 group-hover:opacity-70 transition-opacity" className="w-4 h-4 shrink-0 ml-2 opacity-30 group-hover:opacity-70 transition-opacity"

View File

@@ -30,6 +30,7 @@ import {
PageTitle, PageTitle,
SectionHeading, SectionHeading,
} from "@/components/ui/typography"; } from "@/components/ui/typography";
import { useDemo } from "@/hooks/useDemo";
type Message = components["schemas"]["Message"]; type Message = components["schemas"]["Message"];
type SessionSummaries = components["schemas"]["SessionSummaries"]; type SessionSummaries = components["schemas"]["SessionSummaries"];
@@ -37,6 +38,7 @@ type Summary = components["schemas"]["Summary"];
type Tab = "messages" | "summaries" | "context" | "peers"; type Tab = "messages" | "summaries" | "context" | "peers";
export function SessionDetail() { export function SessionDetail() {
const { mask } = useDemo();
const { workspaceId, sessionId } = useParams({ strict: false }) as { const { workspaceId, sessionId } = useParams({ strict: false }) as {
workspaceId: string; workspaceId: string;
sessionId: string; sessionId: string;
@@ -207,8 +209,8 @@ export function SessionDetail() {
className="text-sm py-2" className="text-sm py-2"
style={{ borderBottom: "1px solid var(--border)", color: "var(--text-2)" }} style={{ borderBottom: "1px solid var(--border)", color: "var(--text-2)" }}
> >
{r.peer_id && <Badge variant="blue">{r.peer_id}</Badge>} {r.peer_id && <Badge variant="blue">{mask(r.peer_id)}</Badge>}
<p className="mt-1 whitespace-pre-wrap">{r.content}</p> <p className="mt-1 whitespace-pre-wrap">{mask(r.content)}</p>
</div> </div>
)) ))
)} )}
@@ -269,14 +271,14 @@ export function SessionDetail() {
> >
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="flex items-center gap-2 mb-2 flex-wrap">
<Badge variant={msg.peer_id ? "blue" : "default"}> <Badge variant={msg.peer_id ? "blue" : "default"}>
{msg.peer_id ?? "system"} {msg.peer_id ? mask(msg.peer_id) : "system"}
</Badge> </Badge>
{msg.token_count != null && <Caption>{msg.token_count} tokens</Caption>} {msg.token_count != null && <Caption>{msg.token_count} tokens</Caption>}
{msg.created_at && ( {msg.created_at && (
<Caption>{new Date(msg.created_at).toLocaleString()}</Caption> <Caption>{new Date(msg.created_at).toLocaleString()}</Caption>
)} )}
</div> </div>
<Body className="whitespace-pre-wrap">{msg.content}</Body> <Body className="whitespace-pre-wrap">{mask(msg.content)}</Body>
</div> </div>
))} ))}
</div> </div>
@@ -414,6 +416,7 @@ function SessionPeersTab({
} }
function SummaryCard({ label, summary }: { label: string; summary: Summary }) { function SummaryCard({ label, summary }: { label: string; summary: Summary }) {
const { mask } = useDemo();
return ( return (
<div <div
className="rounded-xl p-4" className="rounded-xl p-4"
@@ -436,7 +439,7 @@ function SummaryCard({ label, summary }: { label: string; summary: Summary }) {
)} )}
</div> </div>
</div> </div>
<Body className="whitespace-pre-wrap">{summary.content}</Body> <Body className="whitespace-pre-wrap">{mask(summary.content)}</Body>
</div> </div>
); );
} }

View File

@@ -0,0 +1,34 @@
import { createContext, type ReactNode, useContext, useEffect, useState } from "react";
import { applyDemoMode, getDemoMode, maskValue } from "@/lib/demo";
interface DemoContextValue {
demo: boolean;
toggle: () => void;
mask: (value: string) => string;
}
const DemoContext = createContext<DemoContextValue | null>(null);
export function DemoProvider({ children }: { children: ReactNode }) {
const [demo, setDemo] = useState<boolean>(() => getDemoMode());
useEffect(() => {
applyDemoMode(demo);
}, [demo]);
function toggle() {
setDemo((d) => !d);
}
function mask(value: string): string {
return demo ? maskValue(value) : value;
}
return <DemoContext.Provider value={{ demo, toggle, mask }}>{children}</DemoContext.Provider>;
}
export function useDemoContext(): DemoContextValue {
const ctx = useContext(DemoContext);
if (!ctx) throw new Error("useDemoContext must be used within DemoProvider");
return ctx;
}

View File

@@ -1,16 +1 @@
import { useEffect, useState } from "react"; export { useDemoContext as useDemo } from "@/context/DemoContext";
import { applyDemoMode, getDemoMode } from "@/lib/demo";
export function useDemo() {
const [demo, setDemo] = useState<boolean>(() => getDemoMode());
useEffect(() => {
applyDemoMode(demo);
}, [demo]);
function toggle() {
setDemo((d) => !d);
}
return { demo, toggle };
}

View File

@@ -5,12 +5,6 @@
@import "@fontsource/dm-sans/500.css"; @import "@fontsource/dm-sans/500.css";
@import "@fontsource/dm-sans/600.css"; @import "@fontsource/dm-sans/600.css";
/* Demo mode — blur main content, sidebar remains interactive */
[data-demo="true"] main {
filter: blur(6px);
user-select: none;
}
/* ─── Tailwind v4 theme bridge ─── */ /* ─── Tailwind v4 theme bridge ─── */
@theme inline { @theme inline {
--color-background: var(--bg); --color-background: var(--bg);

View File

@@ -8,3 +8,7 @@ export function applyDemoMode(enabled: boolean): void {
document.documentElement.setAttribute("data-demo", String(enabled)); document.documentElement.setAttribute("data-demo", String(enabled));
localStorage.setItem(DEMO_KEY, String(enabled)); localStorage.setItem(DEMO_KEY, String(enabled));
} }
export function maskValue(value: string): string {
return value.replace(/\S/g, "*");
}

View File

@@ -2,12 +2,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router"; import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { applyDemoMode, getDemoMode } from "@/lib/demo";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import "./index.css"; import "./index.css";
applyDemoMode(getDemoMode());
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {

View File

@@ -1,6 +1,7 @@
import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router"; import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { Sidebar } from "@/components/layout/Sidebar"; import { Sidebar } from "@/components/layout/Sidebar";
import { DemoProvider } from "@/context/DemoContext";
import { loadConfig } from "@/lib/config"; import { loadConfig } from "@/lib/config";
import { applyTheme, getStoredTheme } from "@/lib/theme"; import { applyTheme, getStoredTheme } from "@/lib/theme";
@@ -26,15 +27,17 @@ function RootLayout() {
if (!config) return null; if (!config) return null;
return ( return (
<div <DemoProvider>
className="flex h-screen w-full overflow-hidden" <div
style={{ background: "var(--bg)", position: "relative", zIndex: 1 }} className="flex h-screen w-full overflow-hidden"
> style={{ background: "var(--bg)", position: "relative", zIndex: 1 }}
<Sidebar /> >
<main className="flex-1 overflow-auto" style={{ position: "relative", zIndex: 1 }}> <Sidebar />
<Outlet /> <main className="flex-1 overflow-auto" style={{ position: "relative", zIndex: 1 }}>
</main> <Outlet />
</div> </main>
</div>
</DemoProvider>
); );
} }