This commit is contained in:
root
2026-09-19 20:21:47 +02:00
commit 0798933b05
62 changed files with 7658 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
// Client for the belt's HTTP API.
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
const KEY = 'wh.key';
export const keyStore = {
get() {
try { return localStorage.getItem(KEY); } catch { return null; }
},
set(k) {
try { localStorage.setItem(KEY, k); } catch { /* private mode: session only */ }
memoryKey = k;
},
clear() {
try { localStorage.removeItem(KEY); } catch { /* ignore */ }
memoryKey = null;
},
};
let memoryKey = null;
const currentKey = () => memoryKey ?? keyStore.get();
let unauthorized = () => {};
export function onUnauthorized(cb) { unauthorized = cb; }
async function call(method, path, { json, body, type, auth = true, raw = false, key } = {}) {
const headers = {};
// An explicit key (used by login) is tried as given and never triggers the
// global "session rejected" handler.
const k = key ?? (auth ? currentKey() : null);
if (k) headers.Authorization = `Bearer ${k}`;
let payload = body;
if (json !== undefined) {
payload = JSON.stringify(json);
headers['Content-Type'] = 'application/json';
} else if (type) {
headers['Content-Type'] = type;
}
let res;
try {
res = await fetch(path, { method, headers, body: payload });
} catch {
throw new ApiError(0, 'LINK DOWN: cannot reach the server');
}
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
try { msg = (await res.json()).error ?? msg; } catch { /* not JSON */ }
if (res.status === 401 && auth && key === undefined) unauthorized();
throw new ApiError(res.status, msg);
}
if (raw) return res;
if ((res.headers.get('Content-Type') ?? '').includes('json')) return res.json();
return null;
}
export const api = {
info: () => call('GET', '/info', { auth: false }),
market: () => call('GET', '/market', { auth: false }),
register: (name) => call('POST', '/register', { json: { name }, auth: false }),
me: (key) => call('GET', '/me', { key }),
ships: () => call('GET', '/ships'),
assemble: (source) => call('POST', '/assemble', { body: source, type: 'text/plain', auth: false }),
setProgram: (id, bytes) => call('PUT', `/ships/${id}/program`, { body: bytes, type: 'application/octet-stream' }),
launch: (id) => call('POST', `/ships/${id}/launch`),
setUplink: (id, bytes) => call('PUT', `/ships/${id}/uplink`, { body: bytes, type: 'application/octet-stream' }),
async downlink(id) {
const res = await call('GET', `/ships/${id}/downlink`, { raw: true });
return {
bytes: new Uint8Array(await res.arrayBuffer()),
day: Number(res.headers.get('X-Downlink-Day')),
};
},
async example(name) {
const res = await fetch(`/examples/${name}.s`);
if (!res.ok) throw new ApiError(res.status, `example ${name} not found`);
return res.text();
},
async examples() {
const res = await fetch('/examples/index.json');
return res.ok ? res.json() : [];
},
};
export function b64ToBytes(b64) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
+166
View File
@@ -0,0 +1,166 @@
// Halcyon Flight Control: application shell and routing.
import { api, keyStore, onUnauthorized } from './api.js';
import { h, clear, toast } from './dom.js';
import { startStarfield } from './starfield.js';
import { statusOf } from './status.js';
import { authView } from './view-auth.js';
import { fleetView } from './view-fleet.js';
import { codeView } from './view-code.js';
import { uplinkView } from './view-uplink.js';
import { downlinkView } from './view-downlink.js';
const root = document.getElementById('app');
const TABS = [
['fleet', 'FLEET'],
['code', 'CODE'],
['uplink', 'UPLINK'],
['downlink', 'DOWNLINK'],
];
const VIEWS = { fleet: fleetView, code: codeView, uplink: uplinkView, downlink: downlinkView };
const ORE_TAGS = { iron: 'FE', nickel: 'NI', ice: 'H2O', platinum: 'PT' };
const state = { info: null, me: null, market: null, ships: [], selected: null, tab: 'fleet' };
let shell = null; // { top, nav, select, main }
const tabFromHash = () => {
const t = location.hash.replace(/^#\/?/, '');
return VIEWS[t] ? t : 'fleet';
};
const ctx = {
state,
get ship() { return state.ships.find((s) => s.id === state.selected) ?? null; },
go(tab) {
if (location.hash === `#/${tab}`) { state.tab = tab; renderMain(); } else location.hash = `#/${tab}`;
},
selectShip(id, tab) {
state.selected = id;
try { localStorage.setItem('wh.ship', String(id)); } catch { /* ignore */ }
renderNav();
if (tab) ctx.go(tab); else renderMain();
},
async refresh() {
await loadAccount();
renderTop();
renderNav();
renderMain();
},
};
async function loadAccount() {
const [me, ships, market, info] = await Promise.all([api.me(), api.ships(), api.market().catch(() => null), api.info().catch(() => state.info)]);
Object.assign(state, { me, ships, market, info });
const remembered = Number(safeGet('wh.ship'));
if (!ships.some((s) => s.id === state.selected)) {
state.selected = ships.some((s) => s.id === remembered) ? remembered : (ships[0]?.id ?? null);
}
}
function safeGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
function logout(message) {
keyStore.clear();
Object.assign(state, { me: null, ships: [], selected: null });
shell = null;
if (message) toast(message, 'err');
showAuth();
}
function showAuth() {
clear(root).append(authView({ onLogin: enter }));
}
async function enter() {
try {
await loadAccount();
} catch (e) {
if (e.status !== 401) toast(`ERROR: ${e.message}`, 'err');
return logout();
}
buildShell();
state.tab = tabFromHash();
renderTop();
renderNav();
renderMain();
}
// ---- shell ------------------------------------------------------------------
function buildShell() {
shell = {
top: h('header', { class: 'topbar' }),
nav: h('nav', { class: 'navbar', 'aria-label': 'Sections' }),
main: h('main', { id: 'main', class: 'main', tabindex: '-1' }),
};
clear(root).append(
h('a', { class: 'skip', href: '#main' }, 'SKIP TO CONTENT'),
shell.top,
shell.nav,
shell.main,
h('footer', { class: 'foot' }, 'HALCYON INSTRUMENT & CONTROL // WORMHOLE LINK 1 KB/DAY // THE BELT IS SIMULATED ONCE A DAY'),
);
}
function renderTop() {
if (!shell) return;
const { me, info, market } = state;
const prices = market && info?.ores
? info.ores.map((o, i) => h('span', { class: 'chip ore', title: `${o} price per kg` }, h('b', null, ORE_TAGS[o] ?? o.toUpperCase()), ` ${market[i]}`))
: [h('span', { class: 'chip dim', title: 'Prices appear after the first daily run' }, 'MARKET: NO DATA YET')];
clear(shell.top).append(
h('div', { class: 'top-brand' }, h('span', { class: 'logo sm' }, 'HALCYON'), h('span', { class: 'dim' }, 'FLIGHT CONTROL')),
h('div', { class: 'top-stats' },
h('span', { class: 'chip' }, 'DAY ', h('b', null, String(info?.day ?? me?.day ?? 0))),
h('span', { class: 'chip' }, 'CREDITS ', h('b', null, (me?.credits ?? 0).toLocaleString('en-US'))),
...prices,
),
h('div', { class: 'top-user' },
h('span', { class: 'callsign' }, me?.name?.toUpperCase() ?? ''),
h('button', { class: 'btn small', type: 'button', onclick: async () => { try { await ctx.refresh(); toast('DATA REFRESHED'); } catch (e) { toast(e.message, 'err'); } } }, 'REFRESH'),
h('button', { class: 'btn small', type: 'button', onclick: () => logout() }, 'LOGOUT'),
),
);
}
function renderNav() {
if (!shell) return;
const select = h('select', {
id: 'ship-select', 'aria-label': 'Active ship', disabled: state.ships.length === 0,
onchange: (e) => ctx.selectShip(Number(e.target.value)),
}, state.ships.length
? state.ships.map((s) => h('option', { value: s.id, selected: s.id === state.selected }, `#${s.id} ${statusOf(s.status).label}`))
: [h('option', null, 'NO SHIPS')]);
clear(shell.nav).append(
h('div', { class: 'tabs' },
TABS.map(([id, label]) => h('a', { class: `tab ${state.tab === id ? 'on' : ''}`, href: `#/${id}`, 'aria-current': state.tab === id ? 'page' : null }, label)),
h('a', { class: 'tab', href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'MANUAL'),
),
h('label', { class: 'shipsel' }, 'ACTIVE SHIP', select),
);
}
function renderMain() {
if (!shell) return;
clear(shell.main).append(VIEWS[state.tab](ctx));
}
window.addEventListener('hashchange', () => {
if (!shell) return;
state.tab = tabFromHash();
renderNav();
renderMain();
shell.main.focus({ preventScroll: true });
});
// A refresh when the tab regains focus, since the daily run happens while the page sits open.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && shell && state.tab === 'fleet') ctx.refresh().catch(() => {});
});
// ---- boot -------------------------------------------------------------------
startStarfield(document.getElementById('stars'));
onUnauthorized(() => logout('SESSION REJECTED: ACCESS KEY NOT RECOGNISED'));
api.info().then((i) => { state.info = i; }).catch(() => {});
if (keyStore.get()) enter(); else showAuth();
+70
View File
@@ -0,0 +1,70 @@
// Tiny DOM helpers: element builder, toasts and a confirm dialog.
/** Build an element. Props: class, on<Event> handlers, booleans, attributes. */
export function h(tag, props, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props ?? {})) {
if (v == null || v === false) continue;
if (k === 'class') el.className = v;
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
else if (v === true) el.setAttribute(k, '');
else el.setAttribute(k, v);
}
el.append(...kids.flat(Infinity).filter((c) => c != null && c !== false));
return el;
}
export function clear(el) {
el.replaceChildren();
return el;
}
export function toast(message, kind = 'info') {
const host = document.getElementById('toasts');
const t = h('div', { class: `toast ${kind}`, role: kind === 'err' ? 'alert' : null }, message);
host.append(t);
setTimeout(() => {
t.classList.add('out');
setTimeout(() => t.remove(), 400);
}, kind === 'err' ? 8000 : 4500);
}
/** Resolve true if the user confirms. */
export function confirmDialog({ title, body, confirmText = 'CONFIRM', cancelText = 'ABORT', danger = false }) {
const dlg = document.getElementById('dialog');
return new Promise((resolve) => {
const finish = (v) => {
dlg.close();
resolve(v);
};
clear(dlg).append(
h('div', { class: `panel dialog ${danger ? 'danger' : ''}` },
h('div', { class: 'panel-title' }, title),
h('div', { class: 'panel-body' }, ...[].concat(body).map((p) => h('p', null, p))),
h('div', { class: 'actions' },
h('button', { class: 'btn', type: 'button', onclick: () => finish(false) }, cancelText),
h('button', { class: `btn ${danger ? 'danger' : 'primary'}`, type: 'button', onclick: () => finish(true) }, confirmText),
),
),
);
dlg.oncancel = () => resolve(false);
dlg.showModal();
});
}
export function download(filename, bytes, type = 'application/octet-stream') {
const url = URL.createObjectURL(new Blob([bytes], { type }));
const a = h('a', { href: url, download: filename });
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function pad(n, width, ch = '0') {
return String(n).padStart(width, ch);
}
export function plural(n, one, many = `${one}S`) {
return `${n} ${n === 1 ? one : many}`;
}
+131
View File
@@ -0,0 +1,131 @@
// A small assembly editor: line numbers, syntax highlighting (a coloured <pre>
// underneath a transparent <textarea>), Tab-indents and error-line marking.
import { h } from './dom.js';
const MNEMONICS = new Set(
('nop yield halt ldi lui mov add sub mul div mod and or xor shl shr sar addi jmp beq bne blt bge ' +
'call ret push pop ldb ldh ldw stb sth stw in out li').split(' '),
);
const TOKEN = new RegExp(
[
'(\\.[A-Za-z_]\\w*)', // 1 directive
'([A-Za-z_]\\w*)(?=:)', // 2 label definition
'\\b(r(?:1[0-5]|\\d)|sp)\\b', // 3 register
'(-?\\b(?:0x[0-9a-fA-F]+|0b[01]+|\\d+)\\b)', // 4 number
'([A-Za-z_]\\w*)', // 5 identifier
'([\\[\\]+,:-])', // 6 punctuation
].join('|'),
'gi',
);
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const span = (cls, text) => `<span class="${cls}">${esc(text)}</span>`;
/** Highlight one line of assembly, returning HTML. Exported for testing. */
export function highlightLine(line) {
const c = line.search(/[;#]/);
const code = c < 0 ? line : line.slice(0, c);
const comment = c < 0 ? '' : line.slice(c);
let out = '';
let pos = 0;
TOKEN.lastIndex = 0;
for (let m; (m = TOKEN.exec(code)); ) {
out += esc(code.slice(pos, m.index));
pos = m.index + m[0].length;
if (m[1]) out += span('hl-dir', m[0]);
else if (m[2]) out += span('hl-lbl', m[0]);
else if (m[3]) out += span('hl-reg', m[0]);
else if (m[4]) out += span('hl-num', m[0]);
else if (m[5]) out += span(MNEMONICS.has(m[5].toLowerCase()) ? 'hl-mn' : 'hl-sym', m[0]);
else out += span('hl-pun', m[0]);
}
out += esc(code.slice(pos));
if (comment) out += span('hl-cmt', comment);
return out;
}
export function createEditor({ value = '', onInput = () => {}, onCursor = () => {} } = {}) {
const gutter = h('div', { class: 'ed-gutter', 'aria-hidden': 'true' });
const hl = h('div', { class: 'ed-hl', 'aria-hidden': 'true' });
const input = h('textarea', {
class: 'ed-input',
spellcheck: 'false',
autocapitalize: 'off',
autocomplete: 'off',
autocorrect: 'off',
wrap: 'off',
'aria-label': 'Assembly source code',
});
const el = h('div', { class: 'editor' }, gutter, h('div', { class: 'ed-body' }, hl, input));
let errorLine = null;
let escaped = false; // Escape pressed: let the next Tab leave the editor
function render() {
const lines = input.value.split('\n');
hl.innerHTML = lines
.map((l, i) => `<div class="ed-line${i + 1 === errorLine ? ' err' : ''}">${highlightLine(l) || ' '}</div>`)
.join('');
gutter.innerHTML = lines
.map((_, i) => `<div class="ed-num${i + 1 === errorLine ? ' err' : ''}">${i + 1}</div>`)
.join('');
syncScroll();
}
function syncScroll() {
hl.scrollTop = gutter.scrollTop = input.scrollTop;
hl.scrollLeft = input.scrollLeft;
}
function cursor() {
const upTo = input.value.slice(0, input.selectionStart);
const line = upTo.split('\n').length;
const col = upTo.length - upTo.lastIndexOf('\n');
onCursor({ line, col });
}
input.addEventListener('input', () => {
errorLine = null;
render();
onInput(input.value);
});
input.addEventListener('scroll', syncScroll);
for (const ev of ['keyup', 'click', 'focus']) input.addEventListener(ev, cursor);
input.addEventListener('blur', () => { escaped = false; });
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { escaped = true; return; }
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !escaped) {
e.preventDefault();
input.setRangeText(' ', input.selectionStart, input.selectionEnd, 'end');
input.dispatchEvent(new Event('input'));
}
});
input.value = value;
render();
return {
el,
get value() { return input.value; },
set value(v) { input.value = v; errorLine = null; render(); onInput(v); },
insertAtTop(text) {
input.value = text + input.value;
errorLine = null;
render();
onInput(input.value);
},
setError(line) {
errorLine = line;
render();
if (line) {
const lh = parseFloat(getComputedStyle(input).lineHeight) || 22;
input.scrollTop = Math.max(0, (line - 3) * lh);
syncScroll();
}
},
focus() { input.focus(); },
render, // re-measure after the element is attached
};
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffb62e"/><stop offset="1" stop-color="#ff3ad8"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="10" fill="#12072b"/>
<circle cx="32" cy="30" r="17" fill="url(#g)"/>
<rect x="12" y="35" width="40" height="2.5" fill="#12072b"/>
<rect x="12" y="41" width="40" height="3.5" fill="#12072b"/>
<path d="M6 50h52" stroke="#28f5ff" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 539 B

+103
View File
@@ -0,0 +1,103 @@
// Hex dump with an inspector: hover or click a byte to decode it.
import { h, pad } from './dom.js';
const hex2 = (b) => pad(b.toString(16).toUpperCase(), 2);
const printable = (b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '.');
/** Decode the bytes at offset i as little-endian integers. */
export function inspect(bytes, i) {
const b = (k) => (i + k < bytes.length ? bytes[i + k] : null);
const have = (n) => i + n <= bytes.length;
const out = { offset: i, u8: bytes[i] };
if (have(2)) {
const v = b(0) | (b(1) << 8);
out.u16 = v;
out.i16 = (v << 16) >> 16;
}
if (have(4)) {
const v = (b(0) | (b(1) << 8) | (b(2) << 16) | (b(3) << 24));
out.i32 = v;
out.u32 = v >>> 0;
}
return out;
}
export function describe(bytes, i) {
const d = inspect(bytes, i);
const parts = [`OFFSET 0x${pad(d.offset.toString(16).toUpperCase(), 4)} (${d.offset})`, `U8 ${d.u8}`];
if (d.i16 !== undefined) parts.push(`I16 ${d.i16}`);
if (d.i32 !== undefined) parts.push(`I32 ${d.i32}`, `U32 ${d.u32}`);
return parts.join(' | ');
}
/**
* Render a hex dump. Returns { el, inspector } where inspector is the element
* that shows the decoded value of the hovered/selected byte.
*/
export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
const inspector = h('div', { class: 'hx-inspector', 'aria-live': 'polite' }, 'HOVER OR CLICK A BYTE TO DECODE IT');
const body = h('div', { class: 'hx-body', tabindex: '0', role: 'group', 'aria-label': 'Hex dump' });
let pinned = null;
const cells = new Map();
const rowCount = Math.min(Math.ceil(bytes.length / 16), maxRows);
for (let r = 0; r < rowCount; r++) {
const start = r * 16;
const hexCells = [];
const ascCells = [];
for (let k = 0; k < 16; k++) {
const i = start + k;
if (i >= bytes.length) {
hexCells.push(h('span', { class: 'hx-b pad' }, ' '));
continue;
}
const v = bytes[i];
const cls = v === 0 ? 'hx-b z' : 'hx-b';
const a = h('span', { class: `${cls} a`, 'data-i': i }, printable(v));
const x = h('span', { class: cls, 'data-i': i }, hex2(v));
cells.set(i, [x, a]);
hexCells.push(x);
ascCells.push(a);
if (k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' '));
}
body.append(
h('div', { class: 'hx-row' },
h('span', { class: 'hx-off' }, pad(start.toString(16).toUpperCase(), 4)),
h('span', { class: 'hx-hex' }, hexCells),
h('span', { class: 'hx-asc' }, ascCells),
),
);
}
const select = (i, pin) => {
if (pin) {
if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.remove('sel'));
pinned = pinned === i ? null : i;
if (pinned !== null) cells.get(pinned).forEach((c) => c.classList.add('sel'));
}
const shown = pinned ?? i;
inspector.textContent = shown === null ? 'HOVER OR CLICK A BYTE TO DECODE IT' : describe(bytes, shown);
};
const target = (e) => {
const t = e.target.closest('[data-i]');
return t ? Number(t.dataset.i) : null;
};
body.addEventListener('mouseover', (e) => { const i = target(e); if (i !== null && pinned === null) select(i, false); });
body.addEventListener('mouseleave', () => { if (pinned === null) select(null, false); });
body.addEventListener('click', (e) => { const i = target(e); if (i !== null) select(i, true); });
return { el: h('div', { class: 'hex' }, body, inspector), inspector };
}
/** Parse hex text ("de ad 0xBE, ef") into bytes; throws on bad input. */
export function parseHex(text) {
const cleaned = text.replace(/0x/gi, ' ').replace(/[,\s]+/g, ' ').trim();
if (!cleaned) return new Uint8Array(0);
const out = [];
for (const tok of cleaned.split(' ')) {
if (!/^[0-9a-fA-F]+$/.test(tok) || tok.length % 2) throw new Error(`"${tok}" is not whole bytes of hex`);
for (let i = 0; i < tok.length; i += 2) out.push(parseInt(tok.slice(i, i + 2), 16));
}
return Uint8Array.from(out);
}
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Halcyon Flight Control</title>
<meta name="description" content="Program, launch and command your ship in the belt.">
<meta name="color-scheme" content="dark">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
<script type="module" src="app.js"></script>
</head>
<body>
<canvas id="stars" aria-hidden="true"></canvas>
<div class="horizon" aria-hidden="true">
<div class="sun"></div>
<div class="floor"></div>
</div>
<div class="crt" aria-hidden="true"></div>
<div id="app">
<p class="boot">INITIALISING TERMINAL...</p>
</div>
<dialog id="dialog"></dialog>
<div id="toasts" role="status" aria-live="polite"></div>
<noscript>
<p class="boot">THIS TERMINAL REQUIRES JAVASCRIPT.</p>
</noscript>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
// Slowly drifting, twinkling starfield with the odd shooting star.
export function startStarfield(canvas) {
const ctx = canvas.getContext('2d');
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
const tints = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0'];
let w = 0;
let h = 0;
let stars = [];
let shooters = [];
let nextShot = 4000;
let last = performance.now();
let raf = 0;
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
w = window.innerWidth;
h = window.innerHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const n = Math.min(320, Math.round((w * h) / 5500));
stars = Array.from({ length: n }, () => ({
x: Math.random() * w,
y: Math.random() * h,
z: 0.25 + Math.random() * 0.75,
phase: Math.random() * Math.PI * 2,
speed: 0.6 + Math.random() * 1.8,
tint: tints[Math.floor(Math.random() * tints.length)],
}));
draw(performance.now());
}
function draw(now) {
ctx.clearRect(0, 0, w, h);
for (const s of stars) {
const tw = 0.5 + 0.5 * Math.sin(now / 1000 * s.speed + s.phase);
ctx.globalAlpha = reduce.matches ? 0.5 * s.z : (0.25 + 0.75 * tw) * s.z;
ctx.fillStyle = s.tint;
const r = 0.4 + s.z * 1.3;
ctx.fillRect(s.x, s.y, r, r);
}
for (const s of shooters) {
const g = ctx.createLinearGradient(s.x, s.y, s.x - s.vx * 9, s.y - s.vy * 9);
g.addColorStop(0, 'rgba(255,255,255,0.95)');
g.addColorStop(1, 'rgba(255,58,216,0)');
ctx.globalAlpha = Math.min(1, s.life);
ctx.strokeStyle = g;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(s.x - s.vx * 9, s.y - s.vy * 9);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
function frame(now) {
const dt = Math.min(64, now - last);
last = now;
for (const s of stars) {
s.x -= s.z * 0.012 * dt;
if (s.x < -2) { s.x = w + 2; s.y = Math.random() * h; }
}
nextShot -= dt;
if (nextShot <= 0) {
nextShot = 7000 + Math.random() * 9000;
shooters.push({ x: Math.random() * w * 0.8 + w * 0.2, y: Math.random() * h * 0.4, vx: -(6 + Math.random() * 4), vy: 2 + Math.random() * 2, life: 1.4 });
}
for (const s of shooters) {
s.x += s.vx * dt / 16;
s.y += s.vy * dt / 16;
s.life -= dt / 900;
}
shooters = shooters.filter((s) => s.life > 0);
draw(now);
raf = requestAnimationFrame(frame);
}
function sync() {
cancelAnimationFrame(raf);
if (!reduce.matches) raf = requestAnimationFrame((t) => { last = t; frame(t); });
else draw(performance.now());
}
window.addEventListener('resize', resize);
reduce.addEventListener('change', sync);
resize();
sync();
}
+10
View File
@@ -0,0 +1,10 @@
// Ship status presentation shared by several views.
export const STATUS = {
inventory: { label: 'DOCKED', cls: 'amber', hint: 'On the dock. Can be programmed and launched.' },
launching: { label: 'LAUNCH PENDING', cls: 'mag', hint: 'Enters the belt on the next daily run.' },
active: { label: 'IN BELT', cls: 'cyan', hint: 'Flying its program.' },
destroyed: { label: 'LOST', cls: 'red', hint: 'Destroyed.' },
};
export const statusOf = (s) => STATUS[s] ?? { label: String(s).toUpperCase(), cls: '', hint: '' };
+391
View File
@@ -0,0 +1,391 @@
/* Halcyon Flight Control -- 1980s space terminal. */
:root {
--bg0: #07030f;
--bg1: #12072b;
--ink: #ece7ff;
--dim: #9a91c8;
--faint: #4a3f7a;
--cyan: #28f5ff;
--mag: #ff3ad8;
--amber: #ffb62e;
--lime: #7dff8a;
--red: #ff5577;
--violet: #b18cff;
--panel: rgba(13, 6, 34, 0.84);
--line: rgba(40, 245, 255, 0.5);
--mono: "VT323", "IBM Plex Mono", "SF Mono", "Cascadia Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
--display: Eurostile, "Microgramma D Extended", "Bank Gothic", Orbitron, "Avenir Next Condensed", "Arial Narrow", "Trebuchet MS", sans-serif;
}
* { box-sizing: border-box; }
html { color-scheme: dark; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%) fixed;
font-family: var(--mono);
font-size: 17px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ---- backdrop ------------------------------------------------------------ */
#stars { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 0; }
.horizon {
position: fixed; left: 0; right: 0; bottom: 0; height: 46vh;
z-index: 1; pointer-events: none; overflow: hidden;
--sun: clamp(180px, 34vmin, 400px);
--floor: 17vh;
}
.sun {
position: absolute; left: 50%; margin-left: calc(var(--sun) / -2);
bottom: calc(var(--floor) - var(--sun) * 0.34);
width: var(--sun); height: var(--sun); border-radius: 50%;
background: linear-gradient(180deg, #fff27a 0%, #ffb62e 32%, #ff3ad8 72%, #8a2cff 100%);
box-shadow: 0 0 70px 12px rgba(255, 58, 216, 0.5);
-webkit-mask-image: linear-gradient(#000 0 46%, transparent 46% 49%, #000 49% 56%, transparent 56% 60%, #000 60% 67%, transparent 67% 72%, #000 72% 79%, transparent 79% 85%, #000 85% 91%, transparent 91% 98%, #000 98%);
mask-image: linear-gradient(#000 0 46%, transparent 46% 49%, #000 49% 56%, transparent 56% 60%, #000 60% 67%, transparent 67% 72%, #000 72% 79%, transparent 79% 85%, #000 85% 91%, transparent 91% 98%, #000 98%);
}
.floor {
position: absolute; left: 0; right: 0; bottom: 0; height: var(--floor);
overflow: hidden; background: linear-gradient(#22093f, #0c0420);
border-top: 2px solid var(--cyan);
box-shadow: 0 -2px 26px rgba(40, 245, 255, 0.7);
}
.floor::before {
content: ""; position: absolute; left: -60%; right: -60%; top: 0; height: 400%;
transform-origin: 50% 0; transform: perspective(240px) rotateX(64deg);
background-image:
linear-gradient(rgba(255, 58, 216, 0.8) 2px, transparent 2px),
linear-gradient(90deg, rgba(255, 58, 216, 0.8) 2px, transparent 2px);
background-size: 64px 64px;
animation: grid-move 2.6s linear infinite;
}
@keyframes grid-move { to { background-position: 0 64px; } }
.crt {
position: fixed; inset: 0; z-index: 100; pointer-events: none;
background:
repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0) 0 2px, rgba(0, 0, 0, 0.17) 3px, rgba(0, 0, 0, 0) 4px),
radial-gradient(ellipse at center, transparent 58%, rgba(0, 0, 0, 0.5) 100%);
animation: flicker 7s infinite;
}
@keyframes flicker { 0%, 100% { opacity: 1; } 47% { opacity: 1; } 48% { opacity: 0.86; } 50% { opacity: 1; } 83% { opacity: 0.93; } 84% { opacity: 1; } }
#app, dialog, #toasts { position: relative; z-index: 10; }
/* ---- type ---------------------------------------------------------------- */
h1, h2, h3, p { margin: 0; }
.logo {
margin: 0; font-family: var(--display); font-style: italic; font-weight: 900;
letter-spacing: 0.16em; font-size: clamp(2.2rem, 9vw, 4.8rem); line-height: 1.05;
background: linear-gradient(180deg, #ffffff 0%, #d6fbff 36%, #ffb62e 50%, #ff3ad8 76%, #8a2cff 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 0 14px rgba(255, 58, 216, 0.55));
}
.logo.sm { font-size: 1.15rem; letter-spacing: 0.3em; filter: drop-shadow(0 0 8px rgba(255, 58, 216, 0.6)); }
.kicker { font-family: var(--display); letter-spacing: 0.5em; font-size: 0.8rem; color: var(--cyan); text-shadow: 0 0 8px var(--cyan); }
.tagline { letter-spacing: 0.3em; color: var(--dim); font-size: 0.95rem; margin-top: 0.4rem; }
.dim { color: var(--dim); }
.hint { color: var(--dim); font-size: 0.9rem; line-height: 1.4; }
.warn { color: var(--amber); }
.ok { color: var(--lime); }
.bad { color: var(--red); }
a { color: var(--cyan); }
a:hover { color: var(--mag); }
.boot { padding: 3rem 1rem; text-align: center; color: var(--lime); letter-spacing: 0.2em; }
.boot::after { content: "_"; animation: blink 1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.skip { position: absolute; left: -999px; top: 0; background: var(--amber); color: #000; padding: 0.5rem 1rem; z-index: 200; }
.skip:focus { left: 0; }
/* ---- layout -------------------------------------------------------------- */
.topbar {
position: sticky; top: 0; z-index: 20;
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1.4rem;
padding: 0.6rem 1.2rem;
background: rgba(7, 3, 15, 0.88); backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line); box-shadow: 0 2px 24px rgba(40, 245, 255, 0.12);
}
.top-brand { display: flex; align-items: baseline; gap: 0.9rem; letter-spacing: 0.25em; font-size: 0.85rem; }
.top-stats { display: flex; flex-wrap: wrap; gap: 0.5rem; flex: 1; }
.top-user { display: flex; align-items: center; gap: 0.6rem; margin-left: auto; }
.callsign { color: var(--lime); letter-spacing: 0.15em; text-shadow: 0 0 8px rgba(125, 255, 138, 0.6); }
.chip { border: 1px solid var(--faint); padding: 0.05rem 0.65rem; font-size: 0.9rem; letter-spacing: 0.08em; background: rgba(0, 0, 0, 0.3); }
.chip b { color: var(--amber); font-weight: 700; }
.chip.ore b { color: var(--cyan); }
.navbar { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.6rem; max-width: 1100px; margin: 1rem auto 0; padding: 0 1rem; }
.tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.tab {
font-family: var(--display); font-size: 0.85rem; letter-spacing: 0.22em; text-transform: uppercase; text-decoration: none;
padding: 0.6rem 1.2rem; color: var(--dim); background: rgba(13, 6, 34, 0.7);
border: 1px solid var(--faint); cursor: pointer;
}
button.tab { font-size: 0.85rem; }
.tab:hover { color: var(--ink); border-color: var(--mag); }
.tab.on { color: var(--bg0); background: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 16px rgba(40, 245, 255, 0.6); font-weight: 700; }
.shipsel { display: flex; align-items: center; gap: 0.6rem; font-size: 0.8rem; letter-spacing: 0.2em; color: var(--dim); }
.main { max-width: 1100px; margin: 0 auto; padding: 1rem 1rem 8rem; outline: none; }
.foot { position: relative; z-index: 10; width: max-content; max-width: 94vw; margin: 0 auto 1.5rem; padding: 0.35rem 1rem; text-align: center; color: var(--dim); font-size: 0.75rem; letter-spacing: 0.18em; background: rgba(7, 3, 15, 0.82); border: 1px solid var(--faint); }
.grid-2 { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 1.2rem; align-items: start; }
.stack { display: grid; gap: 0.8rem; }
.stack-lg { display: grid; gap: 1.2rem; }
.row { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.spacer { flex: 1; }
.pad { padding: 0.6rem 1rem; }
/* ---- panels -------------------------------------------------------------- */
.panel {
position: relative; background: var(--panel); border: 1px solid var(--line);
box-shadow: 0 0 26px rgba(40, 245, 255, 0.13), inset 0 0 36px rgba(255, 58, 216, 0.05);
}
.panel::before {
content: ""; position: absolute; inset: -3px; pointer-events: none;
--c: var(--mag);
background:
linear-gradient(var(--c), var(--c)) top left / 16px 2px,
linear-gradient(var(--c), var(--c)) top left / 2px 16px,
linear-gradient(var(--c), var(--c)) top right / 16px 2px,
linear-gradient(var(--c), var(--c)) top right / 2px 16px,
linear-gradient(var(--c), var(--c)) bottom left / 16px 2px,
linear-gradient(var(--c), var(--c)) bottom left / 2px 16px,
linear-gradient(var(--c), var(--c)) bottom right / 16px 2px,
linear-gradient(var(--c), var(--c)) bottom right / 2px 16px;
background-repeat: no-repeat;
}
.panel-title {
display: flex; align-items: center; gap: 0.8rem; padding: 0.55rem 1rem;
font-family: var(--display); font-size: 0.8rem; letter-spacing: 0.26em; text-transform: uppercase; color: var(--cyan);
text-shadow: 0 0 8px rgba(40, 245, 255, 0.7);
background: linear-gradient(90deg, rgba(255, 58, 216, 0.34), rgba(40, 245, 255, 0.08) 65%, transparent);
border-bottom: 1px solid var(--line);
}
summary.panel-title { cursor: pointer; list-style: none; }
summary.panel-title::-webkit-details-marker { display: none; }
summary.panel-title::before { content: "\25B6"; font-size: 0.7em; }
details[open] > summary.panel-title::before { content: "\25BC"; }
.panel-body { padding: 1rem; }
.panel-body.flush { padding: 0; }
.panel .dim.cursor, .panel-title .dim { text-shadow: none; letter-spacing: 0.1em; }
.empty { padding: 1.4rem 1rem; color: var(--dim); letter-spacing: 0.15em; }
/* ---- controls ------------------------------------------------------------ */
.btn {
font: inherit; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; text-decoration: none;
display: inline-block; text-align: center;
color: var(--cyan); background: rgba(40, 245, 255, 0.06);
border: 1px solid var(--cyan); padding: 0.5rem 1.1rem; cursor: pointer;
box-shadow: 0 0 10px rgba(40, 245, 255, 0.25);
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
}
.btn:hover:not(:disabled) { background: var(--cyan); color: var(--bg0); box-shadow: 0 0 20px var(--cyan); }
.btn.primary { color: #fff; border-color: var(--mag); background: rgba(255, 58, 216, 0.2); box-shadow: 0 0 12px rgba(255, 58, 216, 0.45); }
.btn.primary:hover:not(:disabled) { background: var(--mag); color: var(--bg0); box-shadow: 0 0 22px var(--mag); }
.btn.danger { color: #fff; border-color: var(--red); background: rgba(255, 85, 119, 0.2); box-shadow: 0 0 12px rgba(255, 85, 119, 0.45); }
.btn.danger:hover:not(:disabled) { background: var(--red); color: var(--bg0); box-shadow: 0 0 22px var(--red); }
.btn.small { padding: 0.2rem 0.7rem; font-size: 0.8rem; }
.btn.wide { width: 100%; }
.btn:disabled { opacity: 0.38; cursor: not-allowed; box-shadow: none; }
.btn:focus-visible, .tab:focus-visible, .linklike:focus-visible, a:focus-visible,
input:focus-visible, select:focus-visible, textarea:focus-visible, .keybox:focus-visible, .hx-body:focus-visible {
outline: 2px solid var(--amber); outline-offset: 2px;
}
.linklike { font: inherit; background: none; border: 0; color: var(--cyan); text-decoration: underline; cursor: pointer; padding: 0; }
label { display: block; font-size: 0.85rem; letter-spacing: 0.22em; color: var(--cyan); text-transform: uppercase; }
label.check { display: flex; align-items: center; gap: 0.6rem; letter-spacing: 0.12em; color: var(--ink); cursor: pointer; }
input[type="checkbox"] { accent-color: var(--mag); width: 1.1rem; height: 1.1rem; }
input[type="text"], input[type="password"], select, textarea {
width: 100%; font: inherit; color: var(--ink);
background: rgba(0, 0, 0, 0.5); border: 1px solid var(--faint); padding: 0.5rem 0.7rem;
caret-color: var(--amber); border-radius: 0;
}
select { width: auto; max-width: 100%; color: var(--cyan); border-color: var(--line); }
select option { background: var(--bg1); color: var(--ink); }
input:focus, select:focus, textarea:focus { border-color: var(--cyan); box-shadow: 0 0 14px rgba(40, 245, 255, 0.35); }
textarea { resize: vertical; }
.form-error { color: var(--red); min-height: 1.4em; letter-spacing: 0.06em; }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0.6rem 0; }
.toolbar.pad { margin: 0; }
.sealrow { justify-content: space-between; margin-top: 0.8rem; }
.sealrow .grow { flex: 1 1 260px; }
.panel-title .dim { white-space: nowrap; }
.badge { display: inline-block; padding: 0.05rem 0.7rem; font-size: 0.8rem; letter-spacing: 0.15em; border: 1px solid currentColor; text-shadow: 0 0 8px currentColor; white-space: nowrap; }
.badge.amber { color: var(--amber); }
.badge.mag { color: var(--mag); animation: pulse 1.6s ease-in-out infinite; }
.badge.cyan { color: var(--cyan); }
.badge.red { color: var(--red); }
@keyframes pulse { 50% { box-shadow: 0 0 14px var(--mag); } }
/* ---- fleet --------------------------------------------------------------- */
.manifest { width: 100%; border-collapse: collapse; }
.manifest th { text-align: left; font-weight: 400; font-size: 0.8rem; letter-spacing: 0.2em; color: var(--dim); padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--line); }
.manifest th, .manifest td { white-space: nowrap; }
.manifest td { padding: 0.7rem 0.9rem; border-bottom: 1px dashed var(--faint); vertical-align: middle; }
.manifest tr.sel td { background: rgba(255, 58, 216, 0.09); }
.manifest tr.sel td:first-child { box-shadow: inset 3px 0 var(--mag); }
.manifest td.actions { text-align: right; white-space: normal; }
.manifest td.actions .btn + .btn { margin-left: 0.4rem; }
.steps { list-style: none; margin: 0; padding: 0.8rem 1rem 0; display: grid; gap: 0.7rem; }
.steps li { display: flex; gap: 0.8rem; align-items: baseline; }
.steps .n { flex: none; width: 1.7rem; height: 1.7rem; display: grid; place-items: center; border: 1px solid var(--mag); color: var(--mag); font-weight: 700; }
.steps strong { color: var(--amber); letter-spacing: 0.12em; }
/* ---- editor -------------------------------------------------------------- */
.editor {
--lh: 22px;
display: flex; height: min(60vh, 540px); min-height: 300px; margin: 0.6rem 0;
background: #05020c; border: 1px solid var(--line);
font-family: var(--mono); font-size: 16px; line-height: var(--lh);
}
.ed-gutter { flex: none; width: 3.6em; overflow: hidden; padding: 8px 0.7em 8px 0; text-align: right; color: #7266b0; background: rgba(255, 58, 216, 0.06); border-right: 1px solid var(--faint); user-select: none; }
.ed-num { height: var(--lh); }
.ed-num.err { color: var(--red); font-weight: 700; }
.ed-body { position: relative; flex: 1; min-width: 0; }
.ed-hl, .ed-input {
position: absolute; inset: 0; margin: 0; padding: 8px 12px; border: 0;
font: inherit; line-height: var(--lh); letter-spacing: 0; white-space: pre; tab-size: 8;
}
.ed-hl { overflow: hidden; pointer-events: none; color: var(--ink); }
.ed-line { height: var(--lh); white-space: pre; }
.ed-line.err { background: rgba(255, 85, 119, 0.22); box-shadow: inset 3px 0 var(--red); }
.ed-input { overflow: auto; resize: none; background: transparent; color: transparent; caret-color: var(--amber); outline: none; box-shadow: none; width: auto; }
.ed-input:focus { box-shadow: none; }
.ed-input::selection { background: rgba(40, 245, 255, 0.35); }
.ed-body:focus-within { box-shadow: inset 0 0 0 1px var(--cyan), 0 0 14px rgba(40, 245, 255, 0.3); }
.hl-mn { color: var(--mag); font-weight: 700; }
.hl-reg { color: var(--cyan); }
.hl-num { color: var(--amber); }
.hl-lbl { color: var(--lime); }
.hl-dir { color: var(--violet); }
.hl-sym { color: #ffd6f7; }
.hl-cmt { color: #8278bd; font-style: italic; }
.hl-pun { color: #a89fd6; }
.console { background: #05020c; border: 1px solid var(--faint); padding: 0.7rem 1rem; min-height: 5.4rem; margin-top: 0.6rem; }
.console p + p { margin-top: 0.2rem; }
.meter { height: 12px; border: 1px solid var(--cyan); background: #05020c; margin-top: 0.5rem; }
.meter .fill { height: 100%; width: 0; background: repeating-linear-gradient(90deg, var(--cyan) 0 6px, transparent 6px 8px); box-shadow: 0 0 10px var(--cyan); }
.meter.over { border-color: var(--red); }
.meter.over .fill { background: repeating-linear-gradient(90deg, var(--red) 0 6px, transparent 6px 8px); box-shadow: 0 0 10px var(--red); }
.refcard { margin: 0; padding: 1rem; overflow-x: auto; font-size: 0.85rem; line-height: 1.3; color: var(--lime); background: #05020c; }
/* ---- uplink -------------------------------------------------------------- */
.drop {
display: grid; place-items: center; gap: 0.3rem; padding: 2rem 1rem; text-align: center; cursor: pointer;
border: 2px dashed var(--cyan); background: rgba(40, 245, 255, 0.04);
letter-spacing: 0.15em; color: var(--ink); font-size: 1rem;
}
.drop-big { font-family: var(--display); letter-spacing: 0.25em; color: var(--cyan); }
.drop:hover, .drop.over { background: rgba(40, 245, 255, 0.12); box-shadow: 0 0 22px rgba(40, 245, 255, 0.4); border-color: var(--mag); }
.drop.disabled { opacity: 0.4; cursor: not-allowed; border-color: var(--faint); }
.uplink-grid { grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); }
.fleet-grid { grid-template-columns: minmax(0, 1fr) 330px; }
.uplink-preview .hx-body { font-size: 13px; }
.uplink-preview .hx-row { gap: 0.9rem; }
.panel-body > .hint, .panel-body > .hx-inspector + .hint { margin-top: 0.7rem; }
.uplink-preview:empty::before { content: "NOTHING TO SEND YET."; color: var(--dim); letter-spacing: 0.15em; }
/* ---- hex viewer ---------------------------------------------------------- */
.hx-body { background: #05020c; border: 1px solid var(--faint); padding: 0.7rem 0.9rem; overflow-x: auto; font-size: 15px; line-height: 1.55; }
.hx-row { display: flex; gap: 1.4rem; white-space: pre; width: max-content; }
.hx-off { color: var(--amber); }
.hx-b { display: inline-block; width: 2ch; text-align: center; cursor: default; }
.hx-hex .hx-b { margin-right: 1ch; }
.hx-asc { color: var(--lime); }
.hx-asc .hx-b { width: 1ch; }
.hx-b.z { color: #5a4f96; }
.hx-b:hover { background: rgba(40, 245, 255, 0.4); color: #fff; }
.hx-b.sel { background: var(--mag); color: #fff; }
.hx-gap { display: inline-block; width: 1ch; }
.hx-inspector { margin-top: 0.7rem; padding: 0.5rem 0.9rem; min-height: 2.6rem; border: 1px solid var(--line); color: var(--cyan); font-size: 0.95rem; letter-spacing: 0.06em; overflow-wrap: anywhere; }
.nosignal { text-align: center; padding: 2.5rem 1rem; }
.nosignal .big { font-family: var(--display); font-size: 2.4rem; letter-spacing: 0.4em; color: var(--red); text-shadow: 0 0 16px var(--red); animation: blink 1.4s steps(1) infinite; }
/* ---- auth ---------------------------------------------------------------- */
.auth { min-height: 100vh; display: grid; place-content: center; justify-items: center; gap: 1.8rem; padding: 2rem 1rem 12rem; text-align: center; }
.auth-panel { width: min(460px, 92vw); text-align: left; }
.auth-panel .stack, .auth-panel .keyreveal { padding: 1.1rem; }
.auth-tabs { gap: 0; }
.auth-tabs .tab { flex: 1; text-align: center; border-top: 0; border-left: 0; border-right: 0; }
.keybox { display: block; padding: 0.9rem; font-size: 1.2rem; word-break: break-all; color: var(--amber); border: 1px solid var(--amber); background: rgba(255, 182, 46, 0.07); text-shadow: 0 0 8px rgba(255, 182, 46, 0.6); user-select: all; }
/* ---- toasts & dialog ----------------------------------------------------- */
#toasts { position: fixed; right: 1rem; bottom: 1rem; display: grid; gap: 0.6rem; z-index: 300; }
.toast { max-width: min(430px, 92vw); padding: 0.7rem 1rem; background: rgba(7, 3, 15, 0.96); border: 1px solid var(--cyan); box-shadow: 0 0 18px rgba(40, 245, 255, 0.4); letter-spacing: 0.05em; animation: slide-in 0.25s ease-out; }
.toast.err { border-color: var(--red); box-shadow: 0 0 18px rgba(255, 85, 119, 0.5); color: #ffd0d9; }
.toast.out { opacity: 0; transition: opacity 0.4s; }
@keyframes slide-in { from { transform: translateX(30px); opacity: 0; } }
dialog { background: transparent; border: 0; padding: 0; color: inherit; max-width: 100vw; }
dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px); }
.dialog { width: min(480px, 92vw); }
.dialog.danger { border-color: var(--red); }
.dialog .panel-body p + p { margin-top: 0.7rem; }
.dialog .actions { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0 1rem 1rem; }
/* ---- small screens ------------------------------------------------------- */
@media (max-width: 900px) {
.grid-2, .uplink-grid, .fleet-grid { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 720px) {
body { font-size: 16px; }
.topbar { position: static; }
.toolbar .btn { padding: 0.4rem 0.8rem; font-size: 0.9rem; letter-spacing: 0.08em; }
.sealrow .btn { width: 100%; }
.panel-title { letter-spacing: 0.16em; }
.auth { padding-bottom: 10rem; }
.top-user { margin-left: 0; }
.manifest thead { display: none; }
.manifest tr { display: block; padding: 0.6rem 0; border-bottom: 1px dashed var(--faint); }
.manifest td { display: flex; justify-content: space-between; align-items: center; gap: 1rem; border: 0; padding: 0.25rem 1rem; }
.manifest td::before { content: attr(data-label); color: var(--dim); font-size: 0.75rem; letter-spacing: 0.2em; }
.manifest td.actions { justify-content: flex-end; }
.manifest td.actions::before { content: none; }
.tab { padding: 0.5rem 0.8rem; letter-spacing: 0.12em; }
.editor { height: 52vh; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
+118
View File
@@ -0,0 +1,118 @@
// Login and registration.
import { api, keyStore } from './api.js';
import { h, clear, download, toast } from './dom.js';
export function authView({ onLogin }) {
let mode = 'login';
const host = h('section', { class: 'auth' });
const brand = h('header', { class: 'brand' },
h('p', { class: 'kicker' }, 'HALCYON INSTRUMENT & CONTROL'),
h('h1', { class: 'logo' }, 'FLIGHT CONTROL'),
h('p', { class: 'tagline' }, 'BELT UPLINK TERMINAL // MODEL HC-33'),
);
const panel = h('div', { class: 'panel auth-panel' });
host.append(brand, panel);
function tabs() {
const tab = (id, label) => h('button', {
class: `tab ${mode === id ? 'on' : ''}`, type: 'button', role: 'tab',
'aria-selected': mode === id ? 'true' : 'false',
onclick: () => { mode = id; render(); },
}, label);
return h('div', { class: 'tabs auth-tabs', role: 'tablist' }, tab('login', 'LOGIN'), tab('register', 'NEW ACCOUNT'));
}
function render() {
clear(panel).append(tabs(), mode === 'login' ? loginForm() : registerForm());
panel.querySelector('input')?.focus();
}
function loginForm() {
const err = h('p', { class: 'form-error', role: 'alert' });
const key = h('input', { id: 'key', type: 'password', autocomplete: 'current-password', required: true, spellcheck: 'false' });
const show = h('input', { type: 'checkbox', id: 'show', onchange: (e) => { key.type = e.target.checked ? 'text' : 'password'; } });
const btn = h('button', { class: 'btn primary wide', type: 'submit' }, 'ENGAGE LINK');
return h('form', {
class: 'stack',
onsubmit: async (e) => {
e.preventDefault();
const k = key.value.trim();
if (!k) return;
btn.disabled = true;
err.textContent = '';
try {
await api.me(k);
keyStore.set(k);
onLogin();
} catch (ex) {
err.textContent = ex.status === 401 ? 'ACCESS DENIED: KEY NOT RECOGNISED' : `ERROR: ${ex.message}`;
btn.disabled = false;
}
},
},
h('label', { for: 'key' }, 'ACCESS KEY'),
key,
h('label', { class: 'check', for: 'show' }, show, 'SHOW KEY'),
err,
btn,
h('p', { class: 'hint' }, 'Your access key was shown once, when you registered. There is no password: the key is the login.'),
);
}
function registerForm() {
const err = h('p', { class: 'form-error', role: 'alert' });
const name = h('input', { id: 'name', type: 'text', maxlength: '40', required: true, autocomplete: 'username', spellcheck: 'false' });
const btn = h('button', { class: 'btn primary wide', type: 'submit' }, 'REGISTER');
return h('form', {
class: 'stack',
onsubmit: async (e) => {
e.preventDefault();
const callsign = name.value.trim();
if (!callsign) { err.textContent = 'ENTER A CALLSIGN'; return; }
btn.disabled = true;
err.textContent = '';
try {
const res = await api.register(callsign);
showKey(callsign, res);
} catch (ex) {
err.textContent = ex.status === 409 ? 'CALLSIGN ALREADY IN USE' : `ERROR: ${ex.message}`;
btn.disabled = false;
}
},
},
h('label', { for: 'name' }, 'CALLSIGN'),
name,
h('p', { class: 'hint' }, '1 to 40 characters. You will be issued one command ship and a secret access key.'),
err,
btn,
);
}
function showKey(callsign, res) {
const enter = h('button', { class: 'btn primary wide', type: 'button', disabled: true }, 'ENTER FLIGHT CONTROL');
const ack = h('input', { type: 'checkbox', id: 'ack', onchange: (e) => { enter.disabled = !e.target.checked; } });
enter.addEventListener('click', () => { keyStore.set(res.api_key); onLogin(); });
clear(panel).append(
h('div', { class: 'panel-title' }, 'ACCOUNT CREATED'),
h('div', { class: 'stack keyreveal' },
h('p', null, `WELCOME, ${callsign.toUpperCase()}. COMMAND SHIP #${res.ship_id} IS WAITING ON THE DOCK.`),
h('p', { class: 'warn' }, 'THIS IS YOUR ACCESS KEY. IT IS SHOWN ONCE AND CANNOT BE RECOVERED.'),
h('code', { class: 'keybox', tabindex: '0' }, res.api_key),
h('div', { class: 'row' },
h('button', { class: 'btn', type: 'button', onclick: async () => {
try { await navigator.clipboard.writeText(res.api_key); toast('KEY COPIED TO CLIPBOARD'); } catch { toast('COPY FAILED: SELECT THE KEY AND COPY IT BY HAND', 'err'); }
} }, 'COPY'),
h('button', { class: 'btn', type: 'button', onclick: () => download(`halcyon-key-${callsign}.txt`, `${res.api_key}\n`, 'text/plain') }, 'SAVE AS FILE'),
),
h('label', { class: 'check', for: 'ack' }, ack, 'I HAVE STORED MY KEY SAFELY'),
enter,
),
);
}
render();
return host;
}
+139
View File
@@ -0,0 +1,139 @@
// Code editor: write assembly, check it, download it, seal it into a ship.
import { api, b64ToBytes } from './api.js';
import { h, clear, toast, confirmDialog, download } from './dom.js';
import { createEditor } from './editor.js';
const draftKey = (ship) => `wh.draft.${ship?.id ?? 0}`;
const load = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
const save = (k, v) => { try { localStorage.setItem(k, v); } catch { /* ignore */ } };
export function codeView(ctx) {
const ship = ctx.ship;
const limit = ctx.state.info?.program_bytes ?? 4096;
const key = draftKey(ship);
const canSeal = ship?.status === 'inventory';
const cursor = h('span', { class: 'dim' }, 'LN 1 COL 1');
let timer = 0;
const editor = createEditor({
value: load(key) ?? '',
onInput: (v) => { clearTimeout(timer); timer = setTimeout(() => save(key, v), 300); },
onCursor: ({ line, col }) => { cursor.textContent = `LN ${line} COL ${col}`; },
});
if (load(key) === null) api.example('telemetry').then((t) => { editor.value = t; }).catch(() => {});
// ---- console -----------------------------------------------------------
const output = h('div', { class: 'console', role: 'log', 'aria-live': 'polite' },
h('p', { class: 'dim' }, 'READY. ASSEMBLE TO CHECK YOUR PROGRAM.'));
const say = (...nodes) => clear(output).append(...nodes);
let assembled = null;
async function assemble() {
try {
const r = await api.assemble(editor.value);
editor.setError(null);
const bytes = b64ToBytes(r.program);
assembled = { bytes, ...r };
const pct = Math.min(100, Math.round((r.size / r.limit) * 100));
say(
h('p', { class: r.fits ? 'ok' : 'bad' }, r.fits ? 'ASSEMBLED OK' : (r.size === 0 ? 'NOTHING TO ASSEMBLE' : 'PROGRAM TOO LARGE FOR THIS BELT')),
h('p', null, `${r.size} BYTES / ${r.instructions} INSTRUCTIONS / LIMIT ${r.limit} BYTES`),
h('div', { class: `meter ${r.fits ? '' : 'over'}`, role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': String(r.limit), 'aria-valuenow': String(r.size), 'aria-label': 'Program size' },
h('div', { class: 'fill', style: null, 'data-pct': String(pct) })),
);
output.querySelector('.fill')?.style.setProperty('width', `${pct}%`);
return assembled;
} catch (e) {
assembled = null;
const m = /^line (\d+):/.exec(e.message);
if (m) editor.setError(Number(m[1]));
say(h('p', { class: 'bad' }, `ERROR: ${e.message}`));
return null;
}
}
// ---- toolbar -----------------------------------------------------------
const btn = (label, onclick, opts = {}) =>
h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
const exampleSel = h('select', { id: 'example', 'aria-label': 'Example program' }, h('option', { value: '' }, 'EXAMPLES...'));
api.examples().then((names) => {
for (const n of names.filter((n) => n !== 'ports')) exampleSel.append(h('option', { value: n }, n.toUpperCase()));
});
exampleSel.addEventListener('change', async () => {
const name = exampleSel.value;
exampleSel.value = '';
if (!name) return;
if (editor.value.trim() && !(await confirmDialog({ title: 'REPLACE EDITOR CONTENTS?', body: `LOAD THE ${name.toUpperCase()} EXAMPLE OVER YOUR CURRENT TEXT.`, confirmText: 'REPLACE' }))) return;
try { editor.value = await api.example(name); say(h('p', { class: 'dim' }, `LOADED ${name.toUpperCase()}.`)); } catch (e) { toast(e.message, 'err'); }
});
const bytesOrNull = async () => {
const r = await assemble();
if (r && r.size === 0) { toast('NOTHING TO ASSEMBLE', 'err'); return null; }
return r;
};
const seal = btn(ship ? `SEAL INTO SHIP #${ship.id}` : 'NO SHIP', async () => {
const r = await bytesOrNull();
if (!r) return;
if (!r.fits) { toast(`PROGRAM IS ${r.size} BYTES; THE LIMIT IS ${r.limit}`, 'err'); return; }
try {
await api.setProgram(ship.id, r.bytes);
toast(`PROGRAM SEALED IN SHIP #${ship.id} (${r.size} BYTES). READY TO LAUNCH.`);
await ctx.refresh();
} catch (e) { toast(`UPLOAD FAILED: ${e.message}`, 'err'); }
}, { cls: 'primary', disabled: !canSeal, title: canSeal ? 'Store this program in the ship' : 'Only docked ships can be reprogrammed' });
const toolbar = h('div', { class: 'toolbar' },
exampleSel,
btn('INSERT EQUATES', async () => {
if (/\bP_UPNEW\b/.test(editor.value)) { toast('EQUATES ALREADY PRESENT'); return; }
try { editor.insertAtTop(`${await api.example('ports')}\n`); } catch (e) { toast(e.message, 'err'); }
}),
h('span', { class: 'spacer' }),
btn('ASSEMBLE', assemble),
btn('DOWNLOAD .BIN', async () => { const r = await bytesOrNull(); if (r) download(`ship-${ship?.id ?? 'x'}-program.bin`, r.bytes); }),
btn('SAVE .S', () => download(`ship-${ship?.id ?? 'x'}.s`, editor.value, 'text/plain')),
);
// ---- reference ---------------------------------------------------------
const card = h('pre', { class: 'refcard' }, 'LOADING...');
const ref = h('details', { class: 'panel ref', onToggle: async (e) => {
if (!e.target.open || card.dataset.loaded) return;
card.dataset.loaded = '1';
try {
const txt = await (await fetch('/manual.txt')).text();
const a = txt.indexOf('APPENDIX E QUICK REFERENCE CARD');
const b = txt.indexOf('* * * END OF MANUAL');
card.textContent = txt.slice(a, b).split('\n').slice(3).join('\n').trim();
} catch { card.textContent = 'COULD NOT LOAD THE MANUAL.'; }
} },
h('summary', { class: 'panel-title' }, 'QUICK REFERENCE (HC-33 MANUAL, APPENDIX E)'),
card,
h('p', { class: 'hint pad' }, h('a', { href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL')),
);
const note = !ship
? 'YOU HAVE NO SHIP TO PROGRAM.'
: canSeal
? `PROGRAMMING SHIP #${ship.id}. SEALING REPLACES ANY EARLIER PROGRAM UNTIL LAUNCH.`
: `SHIP #${ship.id} IS ${ship.status.toUpperCase()}: ITS PROGRAM IS SEALED. YOU CAN STILL EDIT AND DOWNLOAD HERE.`;
const view = h('div', { class: 'stack-lg' },
h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'FLIGHT PROGRAM EDITOR', h('span', { class: 'spacer' }), cursor),
h('div', { class: 'panel-body' },
toolbar,
editor.el,
h('div', { class: 'toolbar sealrow' }, h('p', { class: 'hint grow' }, note), seal),
h('p', { class: 'hint' }, 'DRAFTS ARE KEPT IN THIS BROWSER ONLY. TAB INDENTS; ESC THEN TAB LEAVES THE EDITOR.'),
output,
),
),
ref,
);
queueMicrotask(() => editor.render());
return view;
}
+59
View File
@@ -0,0 +1,59 @@
// Downlink viewer: hex dump of the ship's transmit buffer, with download.
import { api } from './api.js';
import { h, clear, toast, download } from './dom.js';
import { hexView } from './hexview.js';
export function downlinkView(ctx) {
const ship = ctx.ship;
const body = h('div', { class: 'panel-body' }, h('p', { class: 'dim' }, ship ? 'ACQUIRING SIGNAL...' : 'YOU HAVE NO SHIP.'));
const meta = h('span', { class: 'dim' });
const dl = h('button', { class: 'btn', type: 'button', disabled: true }, 'DOWNLOAD .BIN');
const refresh = h('button', { class: 'btn', type: 'button', disabled: !ship }, 'REFRESH');
let current = null;
async function fetchIt() {
if (!ship) return;
refresh.disabled = true;
try {
const { bytes, day } = await api.downlink(ship.id);
current = { bytes, day };
const allZero = bytes.every((b) => b === 0);
meta.textContent = `SHIP #${ship.id} // FROM THE RUN OF DAY ${day} // ${bytes.length} BYTES`;
dl.disabled = false;
clear(body).append(
...(allZero ? [h('p', { class: 'warn' }, 'BUFFER IS ALL ZEROS: THE SHIP HAS NOT WRITTEN ANYTHING TO ITS DOWNLINK BUFFER.')] : []),
hexView(bytes).el,
h('p', { class: 'hint' }, 'OFFSETS ARE INTO THE 1 KB DOWNLINK BUFFER (SHIP RAM 1024-2047). MULTI-BYTE VALUES ARE LITTLE-ENDIAN.'),
);
} catch (e) {
current = null;
dl.disabled = true;
meta.textContent = '';
clear(body).append(
e.status === 404
? h('div', { class: 'nosignal' },
h('p', { class: 'big' }, 'NO SIGNAL'),
h('p', { class: 'dim' }, 'THIS SHIP HAS NOT TRANSMITTED YET. A SHIP\'S FIRST DOWNLINK ARRIVES AFTER ITS FIRST DAY IN THE BELT.'))
: h('p', { class: 'bad' }, `ERROR: ${e.message}`),
);
} finally {
refresh.disabled = false;
}
}
// Reloading the account too keeps the ship's status and the day current.
refresh.addEventListener('click', () => ctx.refresh().catch((e) => toast(e.message, 'err')));
dl.addEventListener('click', () => {
if (!current) return;
download(`ship-${ship.id}-day-${current.day}-downlink.bin`, current.bytes);
toast(`SAVED ${current.bytes.length} BYTES`);
});
fetchIt();
return h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'DOWNLINK RECEIVER', h('span', { class: 'spacer' }), meta),
h('div', { class: 'toolbar pad' }, refresh, h('span', { class: 'spacer' }), dl),
body,
);
}
+84
View File
@@ -0,0 +1,84 @@
// Fleet manifest with launch controls.
import { api } from './api.js';
import { h, toast, confirmDialog } from './dom.js';
import { statusOf } from './status.js';
export function fleetView(ctx) {
const { ships, selected } = ctx.state;
async function launch(ship) {
const ok = await confirmDialog({
title: `LAUNCH SHIP #${ship.id}?`,
body: [
'THE SHIP ENTERS THE BELT ON THE NEXT DAILY RUN.',
'ITS PROGRAM IS SEALED AT LAUNCH. IT CANNOT BE CHANGED, RECALLED OR RESTARTED.',
],
confirmText: 'LAUNCH',
danger: true,
});
if (!ok) return;
try {
await api.launch(ship.id);
toast(`SHIP #${ship.id} CLEARED FOR LAUNCH. IT FLIES ON THE NEXT DAILY RUN.`);
await ctx.refresh();
} catch (e) {
toast(`LAUNCH FAILED: ${e.message}`, 'err');
}
}
const btn = (label, onclick, opts = {}) =>
h('button', { class: `btn small ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
function actions(s) {
const out = [];
if (s.status === 'inventory') {
out.push(btn('CODE', () => ctx.selectShip(s.id, 'code')));
out.push(btn('LAUNCH', () => launch(s), {
cls: 'primary',
disabled: s.program_bytes === 0,
title: s.program_bytes === 0 ? 'Upload a program first' : 'Launch on the next daily run',
}));
}
if (s.status === 'launching' || s.status === 'active') out.push(btn('UPLINK', () => ctx.selectShip(s.id, 'uplink')));
if (s.downlink_day >= 0) out.push(btn('DOWNLINK', () => ctx.selectShip(s.id, 'downlink')));
return out;
}
const rows = ships.map((s) => {
const st = statusOf(s.status);
return h('tr', { class: s.id === selected ? 'sel' : '' },
h('td', { 'data-label': 'SHIP' }, h('button', { class: 'linklike', type: 'button', onclick: () => ctx.selectShip(s.id) }, `#${s.id}`)),
h('td', { 'data-label': 'STATUS' }, h('span', { class: `badge ${st.cls}`, title: st.hint }, st.label)),
h('td', { 'data-label': 'PROGRAM' }, s.program_bytes ? `${s.program_bytes} BYTES` : h('span', { class: 'dim' }, 'NONE')),
h('td', { 'data-label': 'LAST DOWNLINK' }, s.downlink_day >= 0 ? `DAY ${s.downlink_day}` : h('span', { class: 'dim' }, '--')),
h('td', { class: 'actions', 'data-label': '' }, actions(s)),
);
});
const step = (n, title, text) => h('li', null, h('span', { class: 'n' }, n), h('div', null, h('strong', null, title), ' ', text));
return h('div', { class: 'grid-2 fleet-grid' },
h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'FLEET MANIFEST'),
h('div', { class: 'panel-body flush' },
ships.length
? h('table', { class: 'manifest' },
h('thead', null, h('tr', null, ['SHIP', 'STATUS', 'PROGRAM', 'LAST DOWNLINK', ''].map((t) => h('th', { scope: 'col' }, t)))),
h('tbody', null, rows))
: h('p', { class: 'empty' }, 'NO SHIPS ON RECORD.'),
),
),
h('aside', { class: 'panel brief' },
h('div', { class: 'panel-title' }, 'MISSION BRIEF'),
h('ol', { class: 'steps' },
step('1', 'WRITE', 'a flight program in the CODE editor. Your ship cannot be flown by hand.'),
step('2', 'SEAL', 'it into a docked ship. You can replace it until launch.'),
step('3', 'LAUNCH', 'the ship. It enters the belt on the next daily run and the program is fixed for good.'),
step('4', 'TALK', 'to it once a day: 1 KB up, 1 KB down, over the wormhole link.'),
),
h('p', { class: 'hint pad' }, 'The belt is simulated once a day. Ore delivered to the station earns credits.'),
h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL')),
),
);
}
+101
View File
@@ -0,0 +1,101 @@
// Uplink form: choose a binary file (or type hex) and transmit it.
import { api } from './api.js';
import { h, clear, toast } from './dom.js';
import { hexView, parseHex } from './hexview.js';
export function uplinkView(ctx) {
const ship = ctx.ship;
const limit = ctx.state.info?.comm_bytes ?? 1024;
const open = ship && (ship.status === 'launching' || ship.status === 'active');
let bytes = null;
let label = '';
const preview = h('div', { class: 'uplink-preview' });
const meter = h('div', { class: 'meter', role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': String(limit), 'aria-label': 'Message size' }, h('div', { class: 'fill' }));
const sizeText = h('p', { class: 'dim' }, `NO MESSAGE LOADED. LIMIT ${limit} BYTES.`);
const err = h('p', { class: 'form-error', role: 'alert' });
const send = h('button', { class: 'btn primary', type: 'button', disabled: true }, ship ? `TRANSMIT TO SHIP #${ship.id}` : 'NO SHIP');
function set(b, from) {
bytes = b;
label = from;
err.textContent = '';
const over = b.length > limit;
meter.classList.toggle('over', over);
meter.setAttribute('aria-valuenow', String(b.length));
meter.querySelector('.fill').style.setProperty('width', `${Math.min(100, (b.length / limit) * 100)}%`);
sizeText.textContent = `${from}: ${b.length} BYTES OF ${limit}${over ? ' -- TOO LARGE' : ''}`;
if (over) err.textContent = `MESSAGE EXCEEDS THE ${limit}-BYTE LINK. REMOVE ${b.length - limit} BYTES.`;
else if (b.length === 0) err.textContent = 'MESSAGE IS EMPTY.';
send.disabled = !open || over || b.length === 0;
clear(preview);
if (b.length) {
preview.append(hexView(b, { rows: 8 }).el);
if (b.length > 128) preview.append(h('p', { class: 'hint' }, `SHOWING THE FIRST 128 OF ${b.length} BYTES.`));
}
}
// ---- file chooser / drop zone ------------------------------------------
const file = h('input', { type: 'file', id: 'uplink-file', class: 'sr-only' });
const drop = h('label', { class: `drop ${open ? '' : 'disabled'}`, for: 'uplink-file' },
h('span', { class: 'drop-big' }, 'DROP A BINARY FILE HERE'),
h('span', { class: 'dim' }, 'or click to choose one'),
);
file.disabled = !open;
const takeFile = async (f) => {
if (!f) return;
if (f.size > 1 << 20) { err.textContent = 'THAT FILE IS FAR TOO LARGE FOR THE LINK.'; return; }
set(new Uint8Array(await f.arrayBuffer()), f.name.toUpperCase());
};
file.addEventListener('change', () => takeFile(file.files[0]));
for (const ev of ['dragenter', 'dragover']) drop.addEventListener(ev, (e) => { e.preventDefault(); if (open) drop.classList.add('over'); });
for (const ev of ['dragleave', 'drop']) drop.addEventListener(ev, () => drop.classList.remove('over'));
drop.addEventListener('drop', (e) => { e.preventDefault(); if (open) takeFile(e.dataTransfer.files[0]); });
// ---- hex entry ---------------------------------------------------------
const hexIn = h('textarea', { id: 'hex-in', rows: '3', spellcheck: 'false', placeholder: 'DE AD BE EF 00 01 ...', disabled: !open, 'aria-label': 'Message as hexadecimal' });
const useHex = h('button', { class: 'btn small', type: 'button', disabled: !open, onclick: () => {
try { set(parseHex(hexIn.value), 'HEX ENTRY'); } catch (e) { err.textContent = `HEX ERROR: ${e.message}`; }
} }, 'USE HEX');
send.addEventListener('click', async () => {
send.disabled = true;
try {
await api.setUplink(ship.id, bytes);
toast(`UPLINK QUEUED FOR SHIP #${ship.id}: ${bytes.length} BYTES. DELIVERED AT THE START OF THE NEXT RUN.`);
} catch (e) {
err.textContent = `TRANSMISSION FAILED: ${e.message}`;
send.disabled = false;
}
});
const status = !ship
? 'YOU HAVE NO SHIP.'
: open
? `TARGET: SHIP #${ship.id}. A NEW UPLINK REPLACES ONE ALREADY QUEUED.`
: `SHIP #${ship.id} IS ${ship.status.toUpperCase()}. UPLINKS CAN ONLY BE SENT TO SHIPS THAT ARE LAUNCHING OR IN THE BELT.`;
return h('div', { class: 'grid-2 uplink-grid' },
h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'UPLINK TRANSMITTER'),
h('div', { class: 'panel-body stack' },
h('p', { class: open ? 'hint' : 'warn' }, status),
drop, file,
h('label', { for: 'hex-in' }, 'OR ENTER HEX'),
hexIn,
h('div', null, useHex),
meter, sizeText, err,
h('div', null, send),
),
),
h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'OUTGOING MESSAGE'),
h('div', { class: 'panel-body' },
preview,
h('p', { class: 'hint' }, 'THE MESSAGE IS OPAQUE BYTES. YOUR SHIP\'S PROGRAM DECIDES WHAT IT MEANS. IT ARRIVES IN THE UPLINK BUFFER (RAM 0-1023) BEFORE THE FIRST TICK OF THE NEXT DAILY RUN.'),
),
),
);
}
+56
View File
@@ -0,0 +1,56 @@
// Package web serves the browser front end, the manual and sample programs.
package web
import (
"embed"
"encoding/json"
"io/fs"
"net/http"
"sort"
"strings"
"wh/docs"
"wh/examples"
)
//go:embed static
var static embed.FS
// Handler serves the site. Mount it as the catch-all route; more specific API
// routes registered on the same mux take precedence.
func Handler() http.Handler {
sub, _ := fs.Sub(static, "static")
files := http.FileServerFS(sub)
mux := http.NewServeMux()
mux.Handle("GET /", files)
mux.HandleFunc("GET /manual.txt", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(docs.Manual)
})
mux.HandleFunc("GET /examples/index.json", func(w http.ResponseWriter, r *http.Request) {
entries, _ := fs.ReadDir(examples.FS, ".")
names := []string{}
for _, e := range entries {
if n, ok := strings.CutSuffix(e.Name(), ".s"); ok {
names = append(names, n)
}
}
sort.Strings(names)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(names)
})
mux.Handle("GET /examples/", http.StripPrefix("/examples/", http.FileServerFS(examples.FS)))
return secure(mux)
}
// secure adds headers appropriate to a page that loads only its own assets.
func secure(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hd := w.Header()
hd.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
hd.Set("X-Content-Type-Options", "nosniff")
hd.Set("Referrer-Policy", "no-referrer")
h.ServeHTTP(w, r)
})
}