// ui.jsx — ALL UI PRIMITIVES, THEME TOKENS, AND SHARED COMPONENTS
// Pure presentational components. No game logic.
// Exposed as window.UI for global access.
// Depends on: window.D (for faction colours), window.L (for rep labels)
window.UI = window.UI || {};
(() => {
const { FACTIONS } = window.D;
const L = window.L;
const G = window.G;
const { useState, useRef, useLayoutEffect, useMemo } = React;
const T = {
bg: "#0a1622", // deep navy (replaces #14110d)
bgDeep: "#060e14", // very dark navy for shadows/gradients (replaces #0e0b08)
bgAlt: "#0d1824", // subtle lighter navy for separation (replaces #1a1610)
panel: "#221d16",
panelAlt: "#1a1510",
border: "#5a4a32",
borderFaint: "#3e3222",
borderBr: "#7a6440",
text: "#e2d6be",
textDim: "#b0a48c",
textFaint: "#706050",
gold: "#c9aa6e",
goldDim: "#96784a",
goldBr: "#dfc080",
green: "#6a9a5a",
greenBr: "#7ab868",
greenBg: "#0e1a0c",
greenDim: "#4a7a3e",
red: "#b85a4a",
redBr: "#d06a58",
redBg: "#1a0c08",
redDim: "#8a3a2a",
blue: "#5a8aaa",
blueBr: "#6a9aba",
blueBg: "#0c1420",
blueDim: "#3a6a8a",
purple: "#8a5a9a",
purpleBr: "#9a6aaa",
purpleBg: "#14081a",
yellow: "#c8a840",
yellowBr: "#d8b850",
riskColor: { low: "#6a9a5a", medium: "#c8a840", high: "#b85a4a" },
font: "Georgia, 'Times New Roman', serif",
fontMono: "'Courier New', monospace",
fontSize: 'max(11px, min(1.2vw, 14px))',
btnMinHeight: 44,
narrativeLineHeight: 1.55,
captionFontSize: 10, // fine print, gossip details, small hints
metadataFontSize: 11, // costs, dates, faction labels
narrativefontSize: 12, // body text, flavour, mission descriptions
heading3FontSize: 14, // enemy name, active mission title, crew member name
heading2FontSize: 16, // section headings, encounter title, battle round
heading1FontSize: 18, // screen titles, port name, victory/defeat text
spacing: {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
},
};
const panelStyle = (overrides = {}) => {
const variant = overrides.variant || "default";
const variantStyles = {
default: { border: `1px solid ${T.borderBr}` },
danger: { border: `1px solid ${T.redBr}` },
gold: { border: `1px solid ${T.gold}` },
subtle: { border: `1px solid ${T.borderFaint}` },
};
const { variant: _, ...rest } = overrides;
return {
background: T.panel,
borderRadius: 2,
padding: T.spacing.md,
color: T.text,
boxSizing: 'border-box',
//filter: "url(#rough-l2)",
...variantStyles[variant],
...rest,
};
};
const Btn = ({ children, onClick, disabled, v = "default", sm = false, style = {}, className = "", glowColor, ...rest }) => {
const variants = {
default: { bg: "linear-gradient(180deg, #3a3024, #2a221a)", border: T.border, color: T.text },
gold: { bg: "linear-gradient(180deg, #4a3926, #32271c)", border: T.gold, color: T.gold },
ghost: { bg: T.panel, border: T.gold, color: T.gold },
green: { bg: "linear-gradient(180deg, #2a3a22, #1e2a18)", border: T.greenBr, color: T.greenBr },
red: { bg: "linear-gradient(180deg, #3a2220, #2a1a18)", border: T.redBr, color: T.redBr },
blue: { bg: "linear-gradient(180deg, #243948, #1d2d38)", border: T.blueBr, color: T.blueBr },
};
const { bg, border, color } = variants[v] || variants.default;
const glow = glowColor || T.gold;
// ── Rough border (single‑stroke, like Pill) ──────────────────────
const [dims, setDims] = React.useState({ w: 0, h: 0 });
const ref = React.useRef(null);
const jitter = React.useRef(null);
if (!jitter.current) {
jitter.current = Array.from({ length: 8 }, () => (Math.random() - 0.5) * 2.2);
}
const j = (i) => jitter.current[i];
React.useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new ResizeObserver(() => {
setDims({ w: el.offsetWidth, h: el.offsetHeight });
});
obs.observe(el);
return () => obs.disconnect();
}, []);
const { w, h } = dims;
const path = w > 0 && h > 0 ? [
`M ${j(0)} ${j(1)}`,
`L ${w + j(2)} ${j(3)}`,
`L ${w + j(4)} ${h + j(5)}`,
`L ${j(6)} ${h + j(7)}`,
`Z`
].join(' ') : '';
return (
{path && (
)}
{children}
);
};
window.__pulsedButtons = window.__pulsedButtons || {};
const PulseBtn = ({ visible, children, pulseKey, ...btnProps }) => {
const [pulse, setPulse] = React.useState(false);
React.useEffect(() => {
if (visible && pulseKey && !window.__pulsedButtons[pulseKey]) {
window.__pulsedButtons[pulseKey] = true;
setPulse(true);
const t = setTimeout(() => setPulse(false), 1500);
return () => clearTimeout(t);
}
}, [visible, pulseKey]);
if (!visible) return null;
return React.createElement(Btn, { ...btnProps, className: pulse ? 'btn-pulse' : '' }, children);
};
// ── Juice hook: flashes CSS class when value changes ──────────
const useFlashOnChange = (value, options = {}) => {
const { direction, customClass, invert = false } = options;
const prev = React.useRef(value);
const [flashClass, setFlashClass] = React.useState('');
React.useEffect(() => {
if (prev.current !== value) {
let cls = customClass || '';
if (!cls) {
if (direction === 'up') cls = invert ? 'flash-red' : 'flash-green';
else if (direction === 'down') cls = invert ? 'flash-green' : 'flash-red';
else if (value > prev.current) cls = invert ? 'flash-red' : 'flash-green';
else if (value < prev.current) cls = invert ? 'flash-green' : 'flash-red';
}
if (cls) {
setFlashClass(cls);
const timer = setTimeout(() => setFlashClass(''), 600);
prev.current = value;
return () => clearTimeout(timer);
}
prev.current = value;
}
}, [value]);
return flashClass;
};
const Bar = ({ value, max, color = T.greenBr, h = 7 }) => (
);
// Handdrawed rectangle, All-Side Borders Matching Text Color)
const Pill = ({ label, color = T.textDim, style = {} }) => {
const { useState, useRef, useLayoutEffect } = React;
const jitter = useRef(null);
if (!jitter.current) {
// 8 values: 4 corners × 2 coords, each offset within ±3px (range 0..6 after scaling)
jitter.current = Array.from({ length: 8 }, () => (Math.random() - 0.5) * 3);
}
const j = (i) => jitter.current[i];
const [dims, setDims] = useState({ w: 0, h: 0 });
const ref = useRef(null);
const rafRef = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new ResizeObserver(() => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setDims({ w: el.offsetWidth, h: el.offsetHeight });
});
});
obs.observe(el);
return () => { obs.disconnect(); cancelAnimationFrame(rafRef.current); };
}, []);
const { w, h } = dims;
let path = "";
if (w > 0 && h > 0) {
// Corner offsets — subtle, at most ±1.5px from true corner
const tl = { x: j(0) * 0.5, y: j(1) * 0.5 };
const tr = { x: w + j(2) * 0.5, y: j(3) * 0.5 };
const br = { x: w + j(4) * 0.5, y: h + j(5) * 0.5 };
const bl = { x: j(6) * 0.5, y: h + j(7) * 0.5 };
// Midpoints — barely deviated, used only to smooth the curve
const top1 = { x: w * 0.35 + j(0) * 0.2, y: j(1) * 0.2 };
const top2 = { x: w * 0.65 + j(2) * 0.2, y: j(3) * 0.2 };
const right1 = { x: w + j(2) * 0.2, y: h * 0.35 + j(3) * 0.2 };
const right2 = { x: w + j(4) * 0.2, y: h * 0.65 + j(5) * 0.2 };
const bottom1 = { x: w * 0.65 + j(4) * 0.2, y: h + j(5) * 0.2 };
const bottom2 = { x: w * 0.35 + j(6) * 0.2, y: h + j(7) * 0.2 };
const left1 = { x: j(6) * 0.2, y: h * 0.65 + j(7) * 0.2 };
const left2 = { x: j(0) * 0.2, y: h * 0.35 + j(1) * 0.2 };
path = [
`M ${tl.x} ${tl.y}`,
`C ${top1.x} ${top1.y} ${top2.x} ${top2.y} ${tr.x} ${tr.y}`,
`C ${right1.x} ${right1.y} ${right2.x} ${right2.y} ${br.x} ${br.y}`,
`C ${bottom1.x} ${bottom1.y} ${bottom2.x} ${bottom2.y} ${bl.x} ${bl.y}`,
`C ${left1.x} ${left1.y} ${left2.x} ${left2.y} ${tl.x} ${tl.y}`,
`Z`,
].join(" ");
}
return (
);
};
const Panel = ({
children,
color,
variant, // "default" | "danger" | "gold" | "subtle"
padding,
style = {},
className = "",
...rest // ← forwards onClick, onMouseEnter, etc.
}) => {
const { useState, useRef, useLayoutEffect } = React;
// Resolve border color from variant (same logic as panelStyle)
const borderColor = color || {
danger: T.redBr, gold: T.gold, subtle: T.borderFaint,
}[variant] || T.border;
// Two independent jitter arrays for double‑stroke effect
const jitter1 = useRef(null);
const jitter2 = useRef(null);
if (!jitter1.current) {
jitter1.current = Array.from({ length: 16 }, () => (Math.random() - 0.5) * 10);
jitter2.current = Array.from({ length: 16 }, () => (Math.random() - 0.5) * 10);
}
const j1 = (i) => jitter1.current[i];
const j2 = (i) => jitter2.current[i];
const [dims, setDims] = useState({ w: 0, h: 0 });
const containerRef = useRef(null);
const rafRef = useRef(null);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const obs = new ResizeObserver(() => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setDims({ w: el.offsetWidth, h: el.offsetHeight });
});
});
obs.observe(el);
return () => { obs.disconnect(); cancelAnimationFrame(rafRef.current); };
}, []);
const { w, h } = dims;
// Build a smooth cubic‑Bézier path with gentle corner offsets and mid‑side control points.
const buildPath = (w, h, j) => {
if (w <= 0 || h <= 0) return "";
const tl = { x: j(0) * 0.4, y: j(1) * 0.4 };
const tr = { x: w + j(2) * 0.4, y: j(3) * 0.4 };
const br = { x: w + j(4) * 0.4, y: h + j(5) * 0.4 };
const bl = { x: j(6) * 0.4, y: h + j(7) * 0.4 };
const top1 = { x: w * 0.35 + j(8) * 0.25, y: j(9) * 0.25 };
const top2 = { x: w * 0.65 + j(10) * 0.25, y: j(11) * 0.25 };
const right1 = { x: w + j(10) * 0.25, y: h * 0.35 + j(9) * 0.25 };
const right2 = { x: w + j(12) * 0.25, y: h * 0.65 + j(13) * 0.25 };
const bottom1 = { x: w * 0.65 + j(12) * 0.25, y: h + j(13) * 0.25 };
const bottom2 = { x: w * 0.35 + j(14) * 0.25, y: h + j(15) * 0.25 };
const left1 = { x: j(14) * 0.25, y: h * 0.65 + j(15) * 0.25 };
const left2 = { x: j(8) * 0.25, y: h * 0.35 + j(9) * 0.25 };
return [
`M ${tl.x} ${tl.y}`,
`C ${top1.x} ${top1.y} ${top2.x} ${top2.y} ${tr.x} ${tr.y}`,
`C ${right1.x} ${right1.y} ${right2.x} ${right2.y} ${br.x} ${br.y}`,
`C ${bottom1.x} ${bottom1.y} ${bottom2.x} ${bottom2.y} ${bl.x} ${bl.y}`,
`C ${left1.x} ${left1.y} ${left2.x} ${left2.y} ${tl.x} ${tl.y}`,
`Z`,
].join(" ");
};
const path1 = buildPath(w, h, j1);
const path2 = buildPath(w, h, j2);
return (
{(path1 || path2) && (
{path1 && (
)}
{path2 && (
)}
)}
{children}
);
};
// ── SubPanel (block-level rough border, like Pill but for children) ──
const SubPanel = ({ children, color = T.border, style = {}, ...rest }) => {
const { useState, useRef, useLayoutEffect } = React;
const jitter = useRef(null);
if (!jitter.current) {
jitter.current = Array.from({ length: 8 }, () => (Math.random() - 0.5) * 3);
}
const j = (i) => jitter.current[i];
const [dims, setDims] = useState({ w: 0, h: 0 });
const ref = useRef(null);
const rafRef = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new ResizeObserver(() => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
// Use offsetWidth/offsetHeight to include padding
setDims({ w: el.offsetWidth, h: el.offsetHeight });
});
});
obs.observe(el);
return () => { obs.disconnect(); cancelAnimationFrame(rafRef.current); };
}, []);
const { w, h } = dims;
const pad = 4;
const path = w > 0 && h > 0 ? [
`M ${j(0)} ${j(1)}`,
`L ${w + j(2)} ${j(3)}`,
`L ${w + j(4)} ${h + j(5)}`,
`L ${j(6)} ${h + j(7)}`,
`Z`
].join(' ') : '';
return (
);
};
const StatBlock = ({ label, value, color }) => (
{label}
{value}
);
const SectionTitle = ({ children, action }) => (
{children}{action}
);
const ScreenHeader = ({ title, onBack }) => (
{onBack && ← Back }
{title}
);
const NarrativePanel = ({ title, icon, variant = "neutral", children, style = {} }) => {
const variants = {
neutral: { border: T.border, titleColor: T.gold, bg: T.panel },
gossip: { border: T.gold, titleColor: T.gold, bg: T.bgDeep, bgStyle: { backgroundImage: `radial-gradient(ellipse at 30% 20%, rgba(180,160,120,0.06) 0%, transparent 60%),
linear-gradient(180deg, rgba(30,24,18,0.4) 0%, transparent 30%, transparent 70%, rgba(30,24,18,0.4) 100%)`,
},},
danger: { border: T.redBr, titleColor: T.redBr, bg: T.panel },
crew: { border: T.blueBr, titleColor: T.blueBr, bg: T.panel },
discovery:{ border: T.greenBr, titleColor: T.greenBr, bg: T.panel },
trade: { border: T.gold, titleColor: T.gold, bg: T.panel },
};
const v = variants[variant] || variants.neutral;
return (
{title &&
{icon && {icon} }{title}
}
{children}
);
};
const NarrativeLine = ({ children, style = {} }) => (
{children}
);
const TutorialPopup = ({ title, children, onDismiss }) => {
const [dontShowAgain, setDontShowAgain] = React.useState(false);
return React.createElement('div', {
style: { position: "fixed", inset: 0, zIndex: 200, background: "rgba(0,0,0,0.7)",
display: "flex", alignItems: "center", justifyContent: "center" }
}, React.createElement(Panel, {
variant: "gold",
style: { maxWidth: 460, width: "90%" }
},
React.createElement('div', { style: { color: T.gold, fontSize: T.heading2FontSize, fontWeight: "bold", marginBottom: 10 } }, title),
React.createElement('div', { style: { color: T.text, fontSize: T.narrativeFontSize, lineHeight: 1.6, marginBottom: 16 } }, children),
React.createElement('div', { style: { display: "flex", justifyContent: "space-between", alignItems: "center" } },
React.createElement('label', { style: { color: T.textDim, fontSize: 11, cursor: "pointer" } },
React.createElement('input', { type: "checkbox", checked: dontShowAgain,
onChange: e => setDontShowAgain(e.target.checked), style: { marginRight: 6 } }),
"Don't show tutorial hints again"
),
React.createElement(Btn, { v: "gold", onClick: () => onDismiss(dontShowAgain) }, "Got it!")
)
));
};
const LogList = ({ entries, maxEntries = 20 }) => {
let lastDay = null;
const LOG_ICONS = window.UI.LOG_ICONS || {};
// Strip a leading emoji + whitespace (covers ⚓ ⚔ 💨 ☠ ⚠ 👥 etc.)
const stripLeadingEmoji = (s) =>
s.replace(/^[\u2600-\u27BF\u2B00-\u2BFF\uD83C-\uDBFF\uDC00-\uDFFF\uFE0F]+\s*/, '');
return (
{entries.slice(-maxEntries).map((entry, i) => {
const dayMatch = entry && entry.match(/^\[(\d+)\]\s*(.*)/);
const day = dayMatch ? parseInt(dayMatch[1], 10) : null;
const rawText = dayMatch ? dayMatch[2] : entry;
const showDay = day !== null && day !== lastDay;
if (day !== null) lastDay = day;
const categoryKey = window.L.classifyLogLine(rawText);
const IconComponent = categoryKey ? LOG_ICONS[categoryKey] : null;
const displayText = stripLeadingEmoji(rawText);
return (
{IconComponent && (
)}
{displayText}
{showDay && (
Day {day}
)}
);
})}
);
};
// ── Good icon lookup ──────────────────────────────────────
// Maps a resource key (e.g. "rum", "sugar") to the corresponding icon component.
// Built lazily because icons.jsx loads after ui.jsx — at IIFE execution time
// the Icon components don't exist yet, so we resolve them on demand.
const GOOD_ICON_KEYS = {
food: 'IconFood',
water: 'IconWater',
rum: 'IconRhum',
sugar: 'IconSugar',
timber: 'IconTimber',
cloth: 'IconCloth',
spices: 'IconSpice',
silk: 'IconSilk',
coffee: 'IconCoffee',
cocoa: 'IconCocoa',
tobacco: 'IconTobacco',
weapons: 'IconSpear',
silver: 'IconGoblet',
slaves: 'IconPerson',
};
const getGoodIcon = (good, opts = {}) => {
const iconName = GOOD_ICON_KEYS[good];
if (!iconName) return null;
const IconComponent = window.UI[iconName];
if (!IconComponent) return null;
return React.createElement(IconComponent, {
size: opts.size ?? 14,
color: opts.color ?? T.textDim,
style: opts.style ?? { marginRight: 6 },
});
};
const Divider = ({ style = {} }) => (
);
const EmptyState = ({ message, style = {} }) => (
{message}
);
const FactionPill = ({ faction }) => {
const f = FACTIONS[faction];
return ;
};
const RepPill = ({ rep }) => {
const color = rep >= 60 ? T.greenBr : rep >= 30 ? T.gold : T.redBr;
return ;
};
const ShipSprite = ({ type, size = 40 }) => {
const scale = size / 40;
return (
);
};
const ShipSideSprite = ({
type,
faction = null,
equipment = [],
width = 300,
height = 210,
facing = "left",
}) => {
const containerRef = React.useRef(null);
// Stringify equipment array for stable dependency comparison
const equipKey = Array.isArray(equipment) ? equipment.join(",") : "";
React.useEffect(() => {
if (!containerRef.current) return;
if (!window.ShipSprite || typeof window.ShipSprite.render !== "function") {
containerRef.current.innerHTML = "";
return;
}
const svg = window.ShipSprite.render(type, {
faction,
equipment: Array.isArray(equipment) ? equipment : [],
width,
height,
facing,
});
containerRef.current.innerHTML = "";
if (svg) {
containerRef.current.appendChild(svg);
}
}, [type, faction, equipKey, width, height, facing]);
return (
);
};
const BackButton = ({ dispatch, screen = "port", label = "← Back to Port" }) => (
React.createElement(Btn, {
v: "ghost",
onClick: () => dispatch({ type: window.E.A.NAVIGATE, screen }),
style: { alignSelf: "flex-start", marginBottom: 10 }
}, label)
);
const Tooltip = ({ text, children }) => {
const [tooltip, setTooltip] = React.useState(null);
const triggerRef = React.useRef(null);
const handleMouseEnter = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const tooltipWidth = 280;
let left = rect.left + rect.width / 2 - tooltipWidth / 2;
// Keep within horizontal viewport
if (left < 4) left = 4;
if (left + tooltipWidth > window.innerWidth - 4) left = window.innerWidth - tooltipWidth - 4;
const top = rect.top - 8; // tooltip will appear above the trigger
setTooltip({ text, left, top });
};
const handleMouseLeave = () => setTooltip(null);
return React.createElement('div', {
ref: triggerRef,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
style: { display: "inline-block" }
},
children,
tooltip && React.createElement('div', {
style: {
position: "fixed",
left: tooltip.left,
bottom: `calc(100vh - ${tooltip.top}px)`,
maxWidth: 280,
background: T.panel,
border: `1px solid ${T.border}`,
borderRadius: 2,
padding: "4px 8px",
fontSize: 10,
color: T.textDim,
zIndex: 1000,
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
whiteSpace: "normal",
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
pointerEvents: "none",
}
}, tooltip.text)
);
};
const TransferLayout = ({
leftTitle, leftContent, leftFooter,
rightTitle, rightContent, rightFooter,
style = {}
}) => (
{/* Left panel */}
{leftTitle && {leftTitle} }
{leftContent}
{leftFooter}
{/* Right panel */}
{rightTitle && {rightTitle} }
{rightContent}
{rightFooter}
);
const PortSilhouette = ({ portKey }) => {
const port = window.D.PORTS?.[portKey];
const factionKey = port?.faction;
if (!factionKey) return null;
const src = `port-${factionKey}.svg`;
return (
{
e.currentTarget.style.display = "none";
}}
/>
);
};
// ── PortCard ────────────────────────────────────────────────────────
function PortCard({ portKey, state, label, distance, unreachableReason, isCurrent }) {
const port = D.PORTS[portKey];
if (!port) return null;
const rep = state.reputation[portKey] ?? 50;
const heat = state.factionAlerts?.[port.faction] ?? 0;
const profile = L.getPortTradeProfile(portKey);
const goodDeals = (profile.goodDeals || []).filter(g => g !== "food" && g !== "water");
const inDemand = (profile.inDemand || []).slice(0, 6);
const services = port.services || [];
const fColor = FACTIONS[port.faction]?.color ?? T.textDim;
// Get market goods for this port (if not current, generate)
const market = portKey === state.currentPort
? state.portMarket
: G.generatePortMarket(portKey, state);
// Available goods — only show for current port
const availableGoods = useMemo(() => {
if (!market || !isCurrent) return [];
return Object.entries(market.goods)
.filter(([good, data]) => data.available > 0 && good !== "food" && good !== "water")
.map(([good, data]) => ({
name: D.RESOURCES[good]?.name || good,
qty: data.available,
}))
.slice(0, 8);
}, [market, isCurrent]);
// Illegal goods present (shown for both)
const illegalGoods = useMemo(() => {
if (!market) return [];
return Object.entries(market.goods)
.filter(([good, data]) => data.available > 0 && D.RESOURCES[good]?.illegal)
.map(([good]) => D.RESOURCES[good]?.name || good);
}, [market]);
return (
{label && (
{label}
)}
{port.name}
{heat > 0 && (
Heat {heat}
)}
{/* Stats row — distance only */}
{unreachableReason ? (
⚠ {unreachableReason}
) : (
)}
{/* Goods & Trade */}
Goods & Trade
{goodDeals.length > 0 && (
Local deals:
{goodDeals.map(g => (
))}
)}
{inDemand.length > 0 && (
In demand:
{inDemand.map(g => (
))}
)}
{isCurrent && availableGoods.length > 0 && (
Available:
{availableGoods.map(({ name, qty }) => (
))}
)}
{illegalGoods.length > 0 && (
Illegal trade:
{illegalGoods.map(good => (
))}
)}
);
}
// ── PortModal ────────────────────────────────────────────────────────
function PortModal({ targetPortKey, state, dispatch, onClose }) {
const [showComparison, setShowComparison] = React.useState(false);
const currentPortKey = state.currentPort;
const targetPort = D.PORTS[targetPortKey];
const currentPort = D.PORTS[currentPortKey];
const isAtSea = state.sailingDaysLeft > 0 && state.destination;
if (!targetPort || !currentPort) return null;
const distance = L.travelDays(currentPortKey, targetPortKey, state);
const isReachable = L.canReach(state, targetPortKey) && targetPortKey !== currentPortKey;
const unreachableReason = isReachable ? null : L.getUnreachableReason(state, targetPortKey);
const tradeOpp = isAtSea ? null : L.getTradeOpportunity(state, currentPortKey, targetPortKey);
const isDinghy = state.ship.type === "dinghy";
const minCrew = L.getMinViableCrew(state.ship.type);
const isHullBlocked = state.ship.hull === 0;
const isCrewBlocked = !isDinghy && state.crew.roster.length < minCrew;
const sailDisabled = isHullBlocked || isCrewBlocked || !isReachable;
const handleSetSail = () => {
if (sailDisabled) return;
dispatch({ type: window.E.A.SAIL_TO, port: targetPortKey });
onClose();
};
const IconBarChart = window.UI.IconBarChart;
const IconGoldBag = window.UI.IconGoldBag;
return (
e.stopPropagation()}
>
← Back to Map
{sailDisabled ? "Cannot Sail" : "Set Sail →"}
{/* Mission target badge */}
{state.activeMission?.targetPort === targetPortKey && (
Mission target: {state.activeMission.name}
)}
{tradeOpp && (
{IconGoldBag && React.createElement(IconGoldBag, { size: 14, color: T.gold })}
Trade Tip
{tradeOpp.goodName} is {Math.round(tradeOpp.profitPct * 100)}% cheaper here.
Buy for {tradeOpp.buyPrice}g ,
sell for {tradeOpp.sellPrice}g
→ +{tradeOpp.profit}g per unit.
{tradeOpp.isInDemand && It's in high demand there! }
{tradeOpp.isIllegal && (
⚠ This is contraband — patrols may inspect you.
)}
{tradeOpp.availableQty > 0 && (
Available: {tradeOpp.availableQty} units in current port.
)}
)}
{!isAtSea && (
setShowComparison(!showComparison)}
style={{
cursor: "pointer",
color: T.gold,
fontSize: T.narrativeFontSize,
fontWeight: "bold",
padding: "6px 0",
borderTop: `1px solid ${T.borderFaint}`,
paddingTop: 12,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
{showComparison ? "▾" : "▸"}
{IconBarChart && React.createElement(IconBarChart, { size: 14, color: T.gold })}
Compare with {currentPort.name}
{showComparison && (
)}
)}
);
}
// ── Attach all public primitives to window.UI (icons live in icons.jsx) ──
Object.assign(window.UI, {
T, panelStyle, Btn, PulseBtn, Bar, Pill, Panel, SubPanel, StatBlock, SectionTitle, ScreenHeader,
TutorialPopup, NarrativePanel, NarrativeLine, LogList, Divider, EmptyState,
FactionPill, RepPill, ShipSprite, ShipSideSprite, BackButton, useFlashOnChange,
Tooltip,getGoodIcon,TransferLayout,PortSilhouette,PortCard,PortModal,
});
})();