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. <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.
This commit is contained in:
Anso
2026-05-02 02:33:53 -04:00
committed by GitHub
parent a8d1a9d461
commit 6b74767388
3 changed files with 61 additions and 25 deletions
+20 -13
View File
@@ -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 (
<div className="relative">
<div className="opacity-40 pointer-events-none select-none blur-[2px]">
{children}
</div>
<div className="absolute inset-0 flex items-start justify-center pt-8">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs">
<Unplug className="w-3 h-3" strokeWidth={1.5} />
{featureName} is not available: {versionHint}
</div>
</div>
</div>
<LockCard
icon={Unplug}
title={`${featureName} is not available on this node`}
body={`${versionHint} Upgrade the node to use this feature.`}
/>
);
}
@@ -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 (
<div className="flex flex-1 items-center justify-center p-8">
<div className="flex flex-col items-center gap-4 rounded-xl border border-glass-border bg-glass px-10 py-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-glass-border bg-glass">
<Lock className="h-5 w-5 text-stat-subtitle" />
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-stat-value">{title}</p>
<p className="text-sm text-stat-subtitle">Upgrade to unlock more features.</p>
</div>
</div>
</div>
<LockCard icon={Lock} title={title} body="Upgrade to unlock more features." />
);
}
+39
View File
@@ -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 (
<div className={cn('flex flex-1 items-center justify-center p-8 min-h-[280px]', className)}>
<div className="flex flex-col items-center gap-4 rounded-xl border border-glass-border bg-glass px-10 py-8 text-center max-w-md">
<div className="flex items-center justify-center w-12 h-12 rounded-full border border-glass-border bg-glass">
<Icon className="w-5 h-5 text-stat-subtitle" strokeWidth={1.5} />
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-stat-value">{title}</p>
<p className="text-sm text-stat-subtitle">{body}</p>
</div>
</div>
</div>
);
}