From 6b74767388f5d4f2737ee866f4651933f6381d87 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 2 May 2026 02:33:53 -0400 Subject: [PATCH] fix(frontend): short-circuit CapabilityGate and extract shared LockCard (#873) CapabilityGate previously rendered the gated children behind a blur filter and overlay pill when the active node lacked the required capability. Combined with the lazy-loaded views from the recent splitting work, this meant the chunk for FleetView, AuditLogView, HostConsole, etc. fetched on click even when the user could not use the feature, defeating the click-time IP protection the lazy split was meant to deliver. CapabilityGate was the only always-leaking gate in the app. Replace the blurred-children render with a clean glass lock card that explains the version mismatch ("Fleet Management is not available on this node. is running v0.42.0. Upgrade the node to use this feature."). Children are no longer rendered, so the lazy children never mount and no chunk fetch happens. All 13 consumers (5 full-page views, 7 settings sections, 1 inline panel in ResourcesView) get the new behavior automatically; no consumer-side changes required. Extract a shared LockCard primitive used by both CapabilityGate and the existing TierLockedCard inside settings/SectionGate. The two were 95% identical (same glass-card chrome, same icon framing, same text hierarchy) and would have drifted as the design evolved. The shared primitive accepts an icon, title, and body, with an optional className for layout overrides; the inner geometry is fixed so every lock state in the app shares the same visual rhythm. PaidGate and AdmiralGate still fall through to "blurred preview with small pill" after a user clicks Dismiss on their full-page upsell (24h localStorage window). That post-dismissal click-time leak is intentionally out of scope here; closing it would require either removing the dismissal flow or replacing the blurred-children render with a static placeholder, both UX decisions deserving their own PR. --- frontend/src/components/CapabilityGate.tsx | 33 +++++++++------- .../src/components/settings/SectionGate.tsx | 14 +------ frontend/src/components/ui/LockCard.tsx | 39 +++++++++++++++++++ 3 files changed, 61 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/ui/LockCard.tsx diff --git a/frontend/src/components/CapabilityGate.tsx b/frontend/src/components/CapabilityGate.tsx index 1e683758..34f82a47 100644 --- a/frontend/src/components/CapabilityGate.tsx +++ b/frontend/src/components/CapabilityGate.tsx @@ -3,6 +3,7 @@ import { Unplug } from 'lucide-react'; import { useNodes } from '@/context/NodeContext'; import type { Capability } from '@/lib/capabilities'; import { isValidVersion } from '@/lib/version'; +import { LockCard } from './ui/LockCard'; interface CapabilityGateProps { capability: Capability; @@ -10,6 +11,18 @@ interface CapabilityGateProps { children: ReactNode; } +/** + * Renders children only when the active node advertises the required + * capability. When it does not, returns a clean lock card explaining the + * version mismatch instead of rendering the gated UI. + * + * Short-circuiting is load-bearing: callers wrap CapabilityGate around + * lazy-loaded views, and rendering the gated children to "blur and + * overlay" them would still trigger the chunk fetch (and ship the JSX + * to anyone who opens DevTools). The lock card has no children + * dependency and adds no chunk weight, so a node that lacks the + * capability never downloads the gated module. + */ export function CapabilityGate({ capability, featureName = 'This feature', children }: CapabilityGateProps) { const { hasCapability, activeNode, activeNodeMeta } = useNodes(); @@ -17,20 +30,14 @@ export function CapabilityGate({ capability, featureName = 'This feature', child const nodeName = activeNode?.name ?? 'this node'; const versionHint = isValidVersion(activeNodeMeta?.version) - ? `${nodeName} is running v${activeNodeMeta.version}` - : `${nodeName} does not support this capability`; + ? `${nodeName} is running v${activeNodeMeta.version}.` + : `${nodeName} does not advertise this capability.`; return ( -
-
- {children} -
-
-
- - {featureName} is not available: {versionHint} -
-
-
+ ); } diff --git a/frontend/src/components/settings/SectionGate.tsx b/frontend/src/components/settings/SectionGate.tsx index 8e1f087d..c65af47f 100644 --- a/frontend/src/components/settings/SectionGate.tsx +++ b/frontend/src/components/settings/SectionGate.tsx @@ -6,6 +6,7 @@ import { useNodes } from '@/context/NodeContext'; import { getSettingsItem, isItemVisible, isItemLocked } from './registry'; import type { VisibilityContext } from './registry'; import type { SectionId } from './types'; +import { LockCard } from '../ui/LockCard'; interface TierLockedCardProps { tier: 'skipper' | 'admiral'; @@ -13,19 +14,8 @@ interface TierLockedCardProps { function TierLockedCard({ tier }: TierLockedCardProps) { const title = tier === 'admiral' ? 'Admiral feature' : 'Skipper feature'; - return ( -
-
-
- -
-
-

{title}

-

Upgrade to unlock more features.

-
-
-
+ ); } diff --git a/frontend/src/components/ui/LockCard.tsx b/frontend/src/components/ui/LockCard.tsx new file mode 100644 index 00000000..2661ceff --- /dev/null +++ b/frontend/src/components/ui/LockCard.tsx @@ -0,0 +1,39 @@ +import { type LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * Centered glass-card lock state shared by tier gates and capability + * gates. Both gate variants short-circuit before mounting their gated + * children, so this card lives in the "no children rendered" branch and + * cannot trigger any lazy chunk fetches. + * + * Sized via `min-h-[280px]` so the card looks reasonable in inline + * contexts (e.g. a settings section panel) where the parent has no + * explicit height. Full-page contexts with explicit height stretch the + * card via `flex-1`. Override the outer layout with `className` when a + * specific consumer needs different proportions; the inner card box is + * intentionally fixed so all lock states across the app share the same + * geometry. + */ +interface LockCardProps { + icon: LucideIcon; + title: string; + body: string; + className?: string; +} + +export function LockCard({ icon: Icon, title, body, className }: LockCardProps) { + return ( +
+
+
+ +
+
+

{title}

+

{body}

+
+
+
+ ); +}