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 (
+
+ );
+}