diff --git a/src/dock-ui.js b/src/dock-ui.js
new file mode 100644
index 0000000..29675c6
--- /dev/null
+++ b/src/dock-ui.js
@@ -0,0 +1,372 @@
+const dispatchAction = (action, extra = {}) => {
+ window.dispatchEvent(new CustomEvent('airship:action', {
+ detail: { action, ...extra },
+ }));
+};
+
+const overlay = document.createElement('div');
+overlay.id = 'dock-overlay';
+overlay.hidden = true;
+overlay.innerHTML = `
+
+
+
+
+
+ COIN 0
+ CARGO 0/0
+ SALVAGE 0
+ MISSION NONE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
CONTRACT BOARDOne active mission at a time.
+
+
+
+
+
+
+
+
+
MARKETPrices vary by port exports and demand.
+
+
+
+
+
+
+
+
+`;
+document.body.appendChild(overlay);
+
+const style = document.createElement('style');
+style.textContent = `
+ #dock-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 430;
+ color: #f0d9a0;
+ font-family: Georgia, 'Times New Roman', serif;
+ }
+ #dock-overlay[hidden] { display: none; }
+ .dock-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(2, 10, 15, .9);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ }
+ .dock-shell {
+ position: relative;
+ width: min(980px, calc(100vw - 18px));
+ max-height: calc(100dvh - 18px);
+ min-height: min(720px, calc(100dvh - 18px));
+ margin: max(9px, env(safe-area-inset-top)) auto;
+ padding: 14px;
+ overflow: auto;
+ border: 1px solid rgba(196, 140, 54, .64);
+ border-radius: 14px;
+ background: radial-gradient(circle at 50% 0%, rgba(20, 72, 84, .33), transparent 36%), #06161e;
+ box-shadow: 0 18px 60px rgba(0,0,0,.62);
+ overscroll-behavior: contain;
+ }
+ .dock-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 8px 10px 13px;
+ border-bottom: 1px solid rgba(196, 140, 54, .34);
+ }
+ .dock-kicker { color: #adcfca; font: 700 10px/1 system-ui, sans-serif; letter-spacing: .18em; }
+ .dock-header h1 { margin: 3px 0 2px; color: #fff0bd; font-size: clamp(26px, 5vw, 46px); line-height: .95; }
+ .dock-header p { margin: 0; color: #9eb2b9; font: 600 11px/1.2 system-ui, sans-serif; }
+ .dock-leave,
+ .dock-nav button,
+ .dock-section-head button {
+ min-height: 44px;
+ border: 1px solid #9a6a2b;
+ border-radius: 9px;
+ background: linear-gradient(#174a50, #0a2930);
+ color: #ffe5a7;
+ font: 700 11px/1 Georgia, serif;
+ touch-action: manipulation;
+ }
+ .dock-leave { min-width: 112px; padding: 8px 14px; }
+ .dock-resources {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 7px;
+ padding: 10px 0;
+ }
+ .dock-resources span {
+ padding: 6px 11px;
+ border: 1px solid rgba(196, 140, 54, .38);
+ border-radius: 999px;
+ background: rgba(3, 20, 27, .82);
+ font-size: 11px;
+ }
+ .dock-resources strong { color: #fff0bd; }
+ .dock-nav {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ margin: 2px 0 12px;
+ }
+ .dock-nav button { min-height: 52px; font-size: 13px; }
+ .dock-content { min-height: 420px; }
+ .dock-home {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ }
+ .dock-service {
+ min-height: 250px;
+ padding: 18px;
+ border: 1px solid rgba(194, 142, 61, .56);
+ border-radius: 12px;
+ background: linear-gradient(180deg, rgba(13, 63, 72, .86), rgba(5, 25, 32, .96));
+ color: #f0d9a0;
+ text-align: left;
+ touch-action: manipulation;
+ }
+ .dock-service strong { display:block; margin-bottom:8px; color:#fff1bc; font-size:22px; }
+ .dock-service span { color:#aac0c6; font:600 12px/1.45 system-ui,sans-serif; }
+ .dock-panel { display: grid; gap: 10px; }
+ .dock-section-head { display:flex; align-items:center; justify-content:space-between; gap:10px; }
+ .dock-section-head > div { display:grid; gap:2px; }
+ .dock-section-head strong { color:#fff0bd; font-size:18px; }
+ .dock-section-head span { color:#9fb3b9; font:600 10px/1.2 system-ui,sans-serif; }
+ .dock-section-head button { min-width:76px; padding:7px 10px; }
+ .dock-list { display:grid; gap:8px; }
+ .dock-card,
+ .dock-active-mission {
+ display:grid;
+ gap:5px;
+ padding:11px 12px;
+ border:1px solid rgba(190, 139, 59, .42);
+ border-radius:10px;
+ background:rgba(3, 21, 28, .88);
+ }
+ .dock-active-mission:empty { display:none; }
+ .dock-card-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
+ .dock-card strong { color:#fff0bd; }
+ .dock-card small,
+ .dock-card p { margin:0; color:#9fb5bb; font:600 10px/1.35 system-ui,sans-serif; }
+ .dock-card-actions { display:flex; flex-wrap:wrap; gap:7px; margin-top:3px; }
+ .dock-card button,
+ .dock-active-mission button {
+ min-height:38px;
+ padding:6px 12px;
+ border:1px solid #9a6a2b;
+ border-radius:8px;
+ background:#103b3d;
+ color:#f5dda0;
+ font:700 10px/1 system-ui,sans-serif;
+ touch-action:manipulation;
+ }
+ .dock-card button:disabled,
+ .dock-active-mission button:disabled { opacity:.36; }
+ .dock-market-row {
+ display:grid;
+ grid-template-columns:minmax(120px,1.2fr) .8fr .8fr auto;
+ gap:8px;
+ align-items:center;
+ }
+ .dock-price { color:#fff0bd; font:700 11px/1 system-ui,sans-serif; }
+ .dock-tag { color:#98d3ab; font:700 9px/1 system-ui,sans-serif; }
+ .dock-message { min-height:1.3em; padding-top:9px; color:#fff0bd; text-align:center; font:700 11px/1.2 system-ui,sans-serif; }
+ body.dock-open #mobile-controls { visibility:hidden; }
+
+ @media (max-width: 720px) {
+ .dock-shell {
+ width: calc(100vw - 8px);
+ max-height: calc(100dvh - 8px);
+ min-height: calc(100dvh - 8px);
+ margin: 4px auto;
+ padding: 8px;
+ border-radius: 9px;
+ }
+ .dock-header h1 { font-size: clamp(24px, 8vw, 34px); }
+ .dock-home { grid-template-columns:1fr; }
+ .dock-service { min-height:105px; padding:13px; }
+ .dock-service strong { font-size:18px; }
+ .dock-content { min-height:0; }
+ .dock-nav { position:sticky; top:-8px; z-index:2; padding:6px 0; background:#06161e; }
+ .dock-market-row { grid-template-columns:1fr 1fr; }
+ .dock-market-row .dock-card-actions { grid-column:1 / -1; }
+ }
+`;
+document.head.appendChild(style);
+
+const fields = Object.fromEntries(
+ [...overlay.querySelectorAll('[data-dock]')].map((el) => [el.dataset.dock, el]),
+);
+const panels = [...overlay.querySelectorAll('[data-panel]')];
+let latestState = null;
+
+const setPanel = (name = 'home') => {
+ panels.forEach((panel) => { panel.hidden = panel.dataset.panel !== name; });
+};
+
+const actionButton = (label, action, extra = {}, disabled = false) => {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.textContent = label;
+ button.disabled = disabled;
+ button.addEventListener('click', () => dispatchAction(action, extra));
+ return button;
+};
+
+const renderMission = (state) => {
+ const dock = state?.dock || {};
+ const active = dock.activeMission;
+ fields['active-mission'].replaceChildren();
+ if (active) {
+ const title = document.createElement('strong');
+ title.textContent = `ACTIVE: ${active.title}`;
+ const progress = document.createElement('small');
+ progress.textContent = `${active.progressText} • Reward ¢${active.reward}`;
+ fields['active-mission'].append(title, progress);
+ if (active.ready) {
+ fields['active-mission'].append(actionButton('CLAIM REWARD', 'complete-mission'));
+ }
+ }
+
+ fields['mission-list'].replaceChildren();
+ for (const mission of dock.missionOffers || []) {
+ const card = document.createElement('article');
+ card.className = 'dock-card';
+ const head = document.createElement('div');
+ head.className = 'dock-card-head';
+ const title = document.createElement('strong');
+ title.textContent = mission.title;
+ const reward = document.createElement('span');
+ reward.className = 'dock-price';
+ reward.textContent = `¢${mission.reward}`;
+ head.append(title, reward);
+ const desc = document.createElement('p');
+ desc.textContent = mission.description;
+ const actions = document.createElement('div');
+ actions.className = 'dock-card-actions';
+ actions.append(actionButton(
+ active ? 'MISSION ACTIVE' : 'ACCEPT',
+ 'accept-mission',
+ { missionId: mission.id },
+ Boolean(active),
+ ));
+ card.append(head, desc, actions);
+ fields['mission-list'].append(card);
+ }
+};
+
+const renderMarket = (state) => {
+ const dock = state?.dock || {};
+ fields['market-list'].replaceChildren();
+ for (const item of dock.market || []) {
+ const card = document.createElement('article');
+ card.className = 'dock-card dock-market-row';
+ const name = document.createElement('div');
+ const strong = document.createElement('strong');
+ strong.textContent = item.name;
+ const tag = document.createElement('small');
+ tag.className = 'dock-tag';
+ tag.textContent = item.exported ? 'LOCAL EXPORT' : item.imported ? 'HIGH DEMAND' : 'MARKET';
+ name.append(strong, tag);
+ const buy = document.createElement('span');
+ buy.className = 'dock-price';
+ buy.textContent = `BUY ¢${item.buyPrice}`;
+ const sell = document.createElement('span');
+ sell.className = 'dock-price';
+ sell.textContent = `SELL ¢${item.sellPrice} • HOLD ${item.held || 0}`;
+ const actions = document.createElement('div');
+ actions.className = 'dock-card-actions';
+ actions.append(
+ actionButton('BUY 1', 'buy-trade-good', { goodId: item.goodId }, !item.canBuy),
+ actionButton('SELL 1', 'sell-trade-good', { goodId: item.goodId }, !item.canSell),
+ );
+ card.append(name, buy, sell, actions);
+ fields['market-list'].append(card);
+ }
+};
+
+const render = (state) => {
+ if (!state?.dock) return;
+ latestState = state;
+ const dock = state.dock;
+ fields.port.textContent = dock.portName || 'DOCK';
+ fields['port-meta'].textContent = `${String(dock.portType || 'trade').toUpperCase()} • ${dock.portExports?.join(', ') || 'mixed goods'}`;
+ fields.credits.textContent = state.progression?.credits ?? 0;
+ fields.cargo.textContent = `${dock.cargoUsed || 0}/${dock.cargoCapacity || 0}`;
+ fields.salvage.textContent = state.progression?.inventoryCount || 0;
+ fields.mission.textContent = dock.activeMission ? dock.activeMission.type.toUpperCase() : 'NONE';
+ fields.message.textContent = String(state.status || '').replace(/\n/g, ' ');
+ renderMission(state);
+ renderMarket(state);
+};
+
+const show = (state) => {
+ render(state);
+ setPanel('home');
+ overlay.hidden = false;
+ document.body.classList.add('dock-open');
+};
+const hide = () => {
+ overlay.hidden = true;
+ document.body.classList.remove('dock-open');
+};
+
+for (const button of overlay.querySelectorAll('[data-dock-tab]')) {
+ button.addEventListener('click', () => setPanel(button.dataset.dockTab));
+}
+for (const button of overlay.querySelectorAll('[data-action="open-shipyard"]')) {
+ button.addEventListener('click', () => dispatchAction('open-shipyard'));
+}
+overlay.querySelector('.dock-leave').addEventListener('click', () => dispatchAction('close-dock'));
+overlay.querySelector('.dock-backdrop').addEventListener('click', () => dispatchAction('close-dock'));
+
+window.addEventListener('airship:dock-open', (event) => show(event.detail));
+window.addEventListener('airship:dock-state', (event) => render(event.detail));
+window.addEventListener('airship:dock-hide', () => hide());
+window.addEventListener('airship:dock-close', () => hide());
+window.addEventListener('airship:dock-return', (event) => show(event.detail || latestState));
+window.addEventListener('airship:hud-state', (event) => {
+ latestState = event.detail;
+ if (!overlay.hidden) render(event.detail);
+});
+window.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape' && !overlay.hidden) dispatchAction('close-dock');
+});