// 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 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.border}` },
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',
...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; // default glow = gold
return (
);
};
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 }) => (
);
// Line-Edged Pill (Square, All-Side Borders Matching Text Color)
const Pill = ({ label, color = T.textDim, style = {} }) => (
{label}
);
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('div', {
style: { ...panelStyle({ maxWidth: 460, width: "90%" }), borderColor: T.gold }
},
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";
}}
/>
);
};
// ── Attach all public primitives to window.UI (icons live in icons.jsx) ──
Object.assign(window.UI, {
T, panelStyle, Btn, PulseBtn, Bar, Pill, StatBlock, SectionTitle, ScreenHeader,
TutorialPopup, NarrativePanel, NarrativeLine, LogList, Divider, EmptyState,
FactionPill, RepPill, ShipSprite, ShipSideSprite, BackButton, useFlashOnChange,
Tooltip,getGoodIcon,TransferLayout,PortSilhouette,
});
})();