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}

+
+
+
+ ); +}