fix
This commit is contained in:
+22
-7
@@ -1,5 +1,20 @@
|
||||
// Client for the belt's HTTP API.
|
||||
|
||||
/**
|
||||
* The project used to be called "wh". Carry over anything an earlier visit stored
|
||||
* under the old prefix (the access key, drafts, choices), so nobody is logged out.
|
||||
*/
|
||||
export function migrateStorage() {
|
||||
try {
|
||||
for (const k of Object.keys(localStorage)) {
|
||||
if (!k.startsWith('wh.')) continue;
|
||||
const next = `halcyon.${k.slice(3)}`;
|
||||
if (localStorage.getItem(next) === null) localStorage.setItem(next, localStorage.getItem(k));
|
||||
localStorage.removeItem(k);
|
||||
}
|
||||
} catch { /* storage unavailable: nothing to carry over */ }
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
@@ -7,7 +22,7 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const KEY = 'wh.key';
|
||||
const KEY = 'halcyon.key';
|
||||
export const keyStore = {
|
||||
get() {
|
||||
try { return localStorage.getItem(KEY); } catch { return null; }
|
||||
@@ -64,8 +79,12 @@ export const api = {
|
||||
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`),
|
||||
// Launch a docked ship, flying the program in code slot `slot`.
|
||||
launch: (id, slot) => call('POST', `/ships/${id}/launch`, { json: { slot } }),
|
||||
slots: () => call('GET', '/slots'),
|
||||
slot: (n) => call('GET', `/slots/${n}`),
|
||||
saveSlot: (n, name, source) => call('PUT', `/slots/${n}`, { json: { name, source } }),
|
||||
deleteSlot: (n) => call('DELETE', `/slots/${n}`),
|
||||
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 });
|
||||
@@ -79,10 +98,6 @@ export const api = {
|
||||
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) {
|
||||
|
||||
+12
-16
@@ -1,8 +1,8 @@
|
||||
// Halcyon Flight Control: application shell and routing.
|
||||
|
||||
import { api, keyStore, onUnauthorized } from './api.js';
|
||||
import { api, keyStore, migrateStorage, onUnauthorized } from './api.js';
|
||||
import { h, clear, toast } from './dom.js';
|
||||
import { startStarfield } from './starfield.js';
|
||||
import { startBackdrop } from './backdrop.js';
|
||||
import { statusOf } from './status.js';
|
||||
import { authView } from './view-auth.js';
|
||||
import { fleetView } from './view-fleet.js';
|
||||
@@ -18,9 +18,8 @@ const TABS = [
|
||||
['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' };
|
||||
const state = { info: null, me: null, ships: [], selected: null, tab: 'fleet' };
|
||||
let shell = null; // { top, nav, select, main }
|
||||
|
||||
const tabFromHash = () => {
|
||||
@@ -36,7 +35,7 @@ const ctx = {
|
||||
},
|
||||
selectShip(id, tab) {
|
||||
state.selected = id;
|
||||
try { localStorage.setItem('wh.ship', String(id)); } catch { /* ignore */ }
|
||||
try { localStorage.setItem('halcyon.ship', String(id)); } catch { /* ignore */ }
|
||||
renderNav();
|
||||
if (tab) ctx.go(tab); else renderMain();
|
||||
},
|
||||
@@ -49,9 +48,9 @@ const ctx = {
|
||||
};
|
||||
|
||||
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'));
|
||||
const [me, ships, info] = await Promise.all([api.me(), api.ships(), api.info().catch(() => state.info)]);
|
||||
Object.assign(state, { me, ships, info });
|
||||
const remembered = Number(safeGet('halcyon.ship'));
|
||||
if (!ships.some((s) => s.id === state.selected)) {
|
||||
state.selected = ships.some((s) => s.id === remembered) ? remembered : (ships[0]?.id ?? null);
|
||||
}
|
||||
@@ -98,22 +97,18 @@ function buildShell() {
|
||||
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'),
|
||||
h('footer', { class: 'foot' }, 'HALCYON INSTRUMENT & CONTROL'),
|
||||
);
|
||||
}
|
||||
|
||||
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')];
|
||||
const { me, info } = state;
|
||||
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() ?? ''),
|
||||
@@ -134,7 +129,7 @@ function renderNav() {
|
||||
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('a', { class: 'tab', href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'MANUAL'),
|
||||
),
|
||||
h('label', { class: 'shipsel' }, 'ACTIVE SHIP', select),
|
||||
);
|
||||
@@ -160,7 +155,8 @@ document.addEventListener('visibilitychange', () => {
|
||||
|
||||
// ---- boot -------------------------------------------------------------------
|
||||
|
||||
startStarfield(document.getElementById('stars'));
|
||||
migrateStorage(); // before anything reads the key
|
||||
startBackdrop(document.getElementById('backdrop'));
|
||||
onUnauthorized(() => logout('SESSION REJECTED: ACCESS KEY NOT RECOGNISED'));
|
||||
api.info().then((i) => { state.info = i; }).catch(() => {});
|
||||
if (keyStore.get()) enter(); else showAuth();
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// The animated backdrop, all on one canvas: drifting, twinkling stars with the
|
||||
// odd shooting star, the striped sun, and a perspective grid floor sliding
|
||||
// towards you. One canvas is one composited layer: no CSS 3D transforms, masks or
|
||||
// animations, which is what made the old CSS floor flicker in mobile Safari.
|
||||
//
|
||||
// Built to survive mobile browsers: star positions are stored as fractions of
|
||||
// the viewport so a resize (iOS fires them constantly as the toolbar collapses)
|
||||
// never re-scatters them; a frame that throws cannot kill the loop; and the loop
|
||||
// is restarted if it is ever found dead (dropped frame, back/forward cache,
|
||||
// throttled tab), so the animation cannot silently stop.
|
||||
|
||||
const STAR_COUNT = 320;
|
||||
const TINTS = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0'];
|
||||
|
||||
export function startBackdrop(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
// Positions are fractions of the viewport (nx, ny in [0, 1)).
|
||||
const stars = Array.from({ length: STAR_COUNT }, () => ({
|
||||
nx: Math.random(),
|
||||
ny: Math.random(),
|
||||
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)],
|
||||
}));
|
||||
let shooters = [];
|
||||
let nextShot = 4000;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
let dpr = 1;
|
||||
let horizonY = 0; // where the floor starts
|
||||
let floorH = 0;
|
||||
let staticLayer = null; // sun + floor base, painted once per size
|
||||
let staticTop = 0; // the layer is empty above this row, so only the part below it is drawn
|
||||
let fade = null; // gradient that dissolves the grid into the horizon
|
||||
let shown = 0; // how many stars fit the current viewport
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
let lastFrame = performance.now();
|
||||
let resizeTimer = 0;
|
||||
|
||||
function resize() {
|
||||
// The canvas is a fixed box of 100vh anchored to the bottom (see the CSS), so
|
||||
// it does not change size when a phone's toolbar slides in and out.
|
||||
const nw = canvas.clientWidth;
|
||||
const nh = canvas.clientHeight;
|
||||
if (!nw || !nh) return;
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
if (nw !== w || nh !== h || canvas.width !== Math.round(nw * dpr)) {
|
||||
w = nw;
|
||||
h = nh;
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
floorH = Math.round(h * 0.17);
|
||||
horizonY = h - floorH;
|
||||
staticLayer = buildStatic();
|
||||
fade = ctx.createLinearGradient(0, horizonY, 0, horizonY + floorH * 0.55);
|
||||
fade.addColorStop(0, 'rgba(34,9,63,1)');
|
||||
fade.addColorStop(1, 'rgba(34,9,63,0)');
|
||||
}
|
||||
shown = Math.min(STAR_COUNT, Math.round((w * h) / 5500));
|
||||
draw(performance.now());
|
||||
}
|
||||
|
||||
/** The parts that never move, painted once: the striped sun and the floor's base. */
|
||||
function buildStatic() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = canvas.width;
|
||||
c.height = canvas.height;
|
||||
const g = c.getContext('2d');
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const S = Math.max(180, Math.min(400, 0.34 * Math.min(w, h)));
|
||||
const R = S / 2;
|
||||
const cx = w / 2;
|
||||
const cy = horizonY - 0.16 * S; // its lower third is hidden behind the floor
|
||||
staticTop = Math.max(0, Math.floor(cy - R - 60)); // top of the sun's halo
|
||||
|
||||
const halo = g.createRadialGradient(cx, cy, R, cx, cy, R + 60);
|
||||
halo.addColorStop(0, 'rgba(255,58,216,0.30)');
|
||||
halo.addColorStop(1, 'rgba(255,58,216,0)');
|
||||
g.fillStyle = halo;
|
||||
g.beginPath();
|
||||
g.arc(cx, cy, R + 60, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
|
||||
const t = document.createElement('canvas'); // the disc, with its stripes cut out
|
||||
t.width = t.height = Math.ceil(S * dpr);
|
||||
const tg = t.getContext('2d');
|
||||
tg.scale(dpr, dpr);
|
||||
const sun = tg.createLinearGradient(0, 0, 0, S);
|
||||
sun.addColorStop(0, '#fff27a');
|
||||
sun.addColorStop(0.32, '#ffb62e');
|
||||
sun.addColorStop(0.72, '#ff3ad8');
|
||||
sun.addColorStop(1, '#8a2cff');
|
||||
tg.fillStyle = sun;
|
||||
tg.beginPath();
|
||||
tg.arc(R, R, R, 0, Math.PI * 2);
|
||||
tg.fill();
|
||||
tg.globalCompositeOperation = 'destination-out';
|
||||
for (const [from, to] of [[0.46, 0.49], [0.56, 0.6], [0.67, 0.72], [0.79, 0.85], [0.91, 0.98]]) {
|
||||
tg.fillRect(0, from * S, S, (to - from) * S);
|
||||
}
|
||||
g.drawImage(t, cx - R, cy - R, S, S);
|
||||
|
||||
const base = g.createLinearGradient(0, horizonY, 0, h);
|
||||
base.addColorStop(0, '#22093f');
|
||||
base.addColorStop(1, '#0c0420');
|
||||
g.fillStyle = base;
|
||||
g.fillRect(0, horizonY, w, floorH);
|
||||
const glow = g.createLinearGradient(0, horizonY - 28, 0, horizonY);
|
||||
glow.addColorStop(0, 'rgba(40,245,255,0)');
|
||||
glow.addColorStop(1, 'rgba(40,245,255,0.45)');
|
||||
g.fillStyle = glow;
|
||||
g.fillRect(0, horizonY - 28, w, 28);
|
||||
g.fillStyle = '#28f5ff';
|
||||
g.fillRect(0, horizonY - 1, w, 2);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** The grid: lines at even steps in depth, projected onto the floor. */
|
||||
function drawGrid(now) {
|
||||
const Z_FAR = 26;
|
||||
const phase = reduce.matches ? 0 : ((now / 1000) * 0.42) % 1; // depth steps per second
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, horizonY, w, floorH);
|
||||
ctx.clip();
|
||||
ctx.strokeStyle = '#ff3ad8';
|
||||
|
||||
// lines across, sliding towards the viewer
|
||||
for (let j = 0; j < Z_FAR; j++) {
|
||||
const z = 1 + j - phase;
|
||||
const y = horizonY + floorH / z;
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.lineWidth = 0.7 + 1.5 / Math.max(z, 1);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// lines running into the distance, meeting at the vanishing point
|
||||
const spacing = floorH * 0.55;
|
||||
const n = Math.ceil((w / 2 / spacing) * 6);
|
||||
const cx = w / 2;
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (let i = -n; i <= n; i++) {
|
||||
ctx.moveTo(cx + (i * spacing) / Z_FAR, horizonY + floorH / Z_FAR);
|
||||
ctx.lineTo(cx + (i * spacing) / 0.4, horizonY + floorH / 0.4);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// dissolve everything into the horizon so distant lines cannot shimmer
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = fade;
|
||||
ctx.fillRect(0, horizonY, w, floorH * 0.55);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function draw(now) {
|
||||
if (!staticLayer) return;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const s = stars[i];
|
||||
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.nx * w, s.ny * h, 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.max(0, 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;
|
||||
// sun and floor over the stars; only the part of the layer that has anything in it
|
||||
ctx.drawImage(staticLayer, 0, staticTop * dpr, canvas.width, canvas.height - staticTop * dpr, 0, staticTop, w, h - staticTop);
|
||||
drawGrid(now);
|
||||
}
|
||||
|
||||
function update(dt) {
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const s = stars[i];
|
||||
s.nx -= (s.z * 0.012 * dt) / w;
|
||||
if (s.nx < 0) {
|
||||
s.nx += 1;
|
||||
s.ny = Math.random();
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
function frame(now) {
|
||||
raf = 0;
|
||||
try {
|
||||
const dt = Math.max(0, Math.min(64, now - last));
|
||||
last = now;
|
||||
update(dt);
|
||||
draw(now);
|
||||
} catch (e) {
|
||||
console.error('starfield frame failed; continuing', e);
|
||||
}
|
||||
lastFrame = performance.now();
|
||||
schedule();
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (raf || reduce.matches || document.visibilityState !== 'visible') return;
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
/** (Re)start the loop from a clean state. Safe to call at any time. */
|
||||
function restart() {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
last = lastFrame = performance.now();
|
||||
if (reduce.matches) draw(last);
|
||||
else schedule();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(resize, 120);
|
||||
});
|
||||
window.addEventListener('orientationchange', () => setTimeout(resize, 250));
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') restart(); });
|
||||
window.addEventListener('pageshow', restart); // back/forward cache restore
|
||||
window.addEventListener('focus', restart);
|
||||
reduce.addEventListener('change', restart);
|
||||
|
||||
// Watchdog: a visible page whose loop has gone quiet gets restarted.
|
||||
setInterval(() => {
|
||||
if (!reduce.matches && document.visibilityState === 'visible' && performance.now() - lastFrame > 1500) restart();
|
||||
}, 1000);
|
||||
|
||||
resize();
|
||||
restart();
|
||||
}
|
||||
@@ -52,6 +52,33 @@ export function confirmDialog({ title, body, confirmText = 'CONFIRM', cancelText
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A modal dialog around caller-built content (form fields and so on). Returns
|
||||
* { result, confirm, close }: `result` resolves true on the confirm button and
|
||||
* false on cancel or Escape; the caller can enable/disable `confirm` as it
|
||||
* validates, and reads its own fields after `result` resolves.
|
||||
*/
|
||||
export function customDialog({ title, content, confirmText = 'OK', cancelText = 'CANCEL', danger = false }) {
|
||||
const dlg = document.getElementById('dialog');
|
||||
let resolve;
|
||||
const result = new Promise((r) => { resolve = r; });
|
||||
const finish = (v) => {
|
||||
if (dlg.open) dlg.close();
|
||||
resolve(v);
|
||||
};
|
||||
const confirm = h('button', { class: `btn ${danger ? 'danger' : 'primary'}`, type: 'button', onclick: () => finish(true) }, confirmText);
|
||||
clear(dlg).append(
|
||||
h('div', { class: `panel dialog ${danger ? 'danger' : ''}` },
|
||||
h('div', { class: 'panel-title' }, title),
|
||||
h('div', { class: 'panel-body stack' }, ...content),
|
||||
h('div', { class: 'actions' }, h('button', { class: 'btn', type: 'button', onclick: () => finish(false) }, cancelText), confirm),
|
||||
),
|
||||
);
|
||||
dlg.oncancel = () => resolve(false);
|
||||
dlg.showModal();
|
||||
return { result, confirm, close: finish };
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
+73
-30
@@ -31,50 +31,89 @@ export function describe(bytes, i) {
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
// Row layout, in character cells: offset, gap, hex, gap, ASCII.
|
||||
const BYTES_PER_ROW = [16, 8, 4]; // widest first
|
||||
const GAP = 1.2;
|
||||
const rowChars = (n) => 4 + GAP * 2 + n * 3 + (n === 16 ? 1 : 0) + n;
|
||||
|
||||
/**
|
||||
* Render a hex dump. Returns { el, inspector } where inspector is the element
|
||||
* that shows the decoded value of the hovered/selected byte.
|
||||
* Render a hex dump that always fits its container: it picks 16, 8 or 4 bytes
|
||||
* per row and sizes the font so a whole row fits without sideways scrolling, and
|
||||
* re-fits whenever the container changes size (rotation, window resize).
|
||||
* Returns { el, inspector }; the inspector shows the decoded hovered/selected byte.
|
||||
*
|
||||
* Options: maxBytes (show only the first N bytes), maxFont / minFont (px).
|
||||
*/
|
||||
export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
|
||||
export function hexView(bytes, { maxBytes = Infinity, maxFont = 15, minFont = 9.5 } = {}) {
|
||||
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' });
|
||||
const total = Math.min(bytes.length, maxBytes);
|
||||
let pinned = null;
|
||||
const cells = new Map();
|
||||
let cells = new Map();
|
||||
let perRow = 0;
|
||||
let ratio = 0; // width of one character as a fraction of the font size
|
||||
|
||||
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;
|
||||
function build(n) {
|
||||
perRow = n;
|
||||
cells = new Map();
|
||||
const rows = [];
|
||||
for (let start = 0; start < total; start += n) {
|
||||
const hexCells = [];
|
||||
const ascCells = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
const i = start + k;
|
||||
if (i >= total) {
|
||||
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 (n === 16 && k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' '));
|
||||
}
|
||||
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' }, ' '));
|
||||
rows.push(
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
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),
|
||||
),
|
||||
);
|
||||
body.replaceChildren(...rows);
|
||||
if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.add('sel'));
|
||||
}
|
||||
|
||||
function measure() {
|
||||
if (ratio) return ratio;
|
||||
const probe = h('span', { class: 'hx-probe' }, 'M'.repeat(20));
|
||||
body.append(probe);
|
||||
ratio = probe.getBoundingClientRect().width / 20 / 100;
|
||||
probe.remove();
|
||||
return ratio || 0.6;
|
||||
}
|
||||
|
||||
function layout() {
|
||||
const cs = getComputedStyle(body);
|
||||
const avail = body.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
if (!(avail > 0)) return; // not laid out yet
|
||||
const r = measure();
|
||||
const fontFor = (n) => avail / (rowChars(n) * r);
|
||||
// The widest layout that still gives a readable font; keep the current one a
|
||||
// little longer than that, so a scrollbar appearing cannot make it flap.
|
||||
let n = BYTES_PER_ROW.find((c) => fontFor(c) >= minFont - (c === perRow ? 0.6 : 0)) ?? 4;
|
||||
body.style.fontSize = `${Math.min(maxFont, fontFor(n)).toFixed(2)}px`;
|
||||
if (n !== perRow) build(n);
|
||||
}
|
||||
|
||||
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'));
|
||||
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);
|
||||
@@ -87,6 +126,10 @@ export function hexView(bytes, { rows: maxRows = Infinity } = {}) {
|
||||
body.addEventListener('mouseleave', () => { if (pinned === null) select(null, false); });
|
||||
body.addEventListener('click', (e) => { const i = target(e); if (i !== null) select(i, true); });
|
||||
|
||||
build(16);
|
||||
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(layout).observe(body); // fires once attached, before first paint
|
||||
else window.addEventListener('resize', layout);
|
||||
|
||||
return { el: h('div', { class: 'hex' }, body, inspector), inspector };
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,7 @@
|
||||
<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>
|
||||
<canvas id="backdrop" aria-hidden="true"></canvas>
|
||||
<div class="crt" aria-hidden="true"></div>
|
||||
|
||||
<div id="app">
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
+63
-49
@@ -26,7 +26,7 @@ body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%) fixed;
|
||||
background: #07030f;
|
||||
font-family: var(--mono);
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
@@ -35,50 +35,21 @@ body {
|
||||
|
||||
/* ---- 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;
|
||||
/* The whole backdrop is one canvas. It is a fixed box of 100vh anchored to the
|
||||
bottom of the screen, so it never resizes when a phone's toolbar comes and goes
|
||||
and the floor always sits at the visible bottom. */
|
||||
#backdrop {
|
||||
position: fixed; left: 0; bottom: 0; width: 100%; height: 100vh; height: 100lvh; z-index: 0;
|
||||
background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%);
|
||||
}
|
||||
|
||||
.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; } }
|
||||
|
||||
/* Static scanlines and vignette. (They used to flicker; that was the flicker.) */
|
||||
.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; }
|
||||
|
||||
@@ -120,20 +91,23 @@ a:hover { color: var(--mag); }
|
||||
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);
|
||||
background: rgba(7, 3, 15, 0.96);
|
||||
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); }
|
||||
/* Callsigns can be 40 characters: truncate them, never the buttons beside them. */
|
||||
.top-user { min-width: 0; max-width: 100%; }
|
||||
.callsign { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.top-user .btn { flex: none; }
|
||||
|
||||
.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; }
|
||||
.tabs { display: flex; flex-wrap: nowrap; gap: 0.3rem; min-width: 0; }
|
||||
.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);
|
||||
@@ -233,9 +207,28 @@ textarea { resize: vertical; }
|
||||
|
||||
.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; }
|
||||
|
||||
/* Button bars in the code editor: one control height and style throughout. */
|
||||
.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 1rem; margin: 0.8rem 0; }
|
||||
.bar-label { flex: none; width: 4.2rem; font-size: 0.72rem; letter-spacing: 0.24em; color: var(--dim); }
|
||||
.bar-buttons { display: flex; flex: 1; flex-wrap: wrap; gap: 0.5rem; min-width: 0; }
|
||||
.bar .btn, .bar select {
|
||||
height: 2.5rem; margin: 0; padding: 0 1.1rem; font-size: 0.82rem; line-height: 1; white-space: nowrap;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.bar .push { margin-left: auto; }
|
||||
.bar select { min-width: 0; max-width: 100%; text-overflow: ellipsis; }
|
||||
.stack-lg > * { min-width: 0; }
|
||||
/* The example chooser looks like the buttons beside it. */
|
||||
.bar select {
|
||||
-webkit-appearance: none; appearance: none; width: auto; cursor: pointer;
|
||||
font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--cyan);
|
||||
background: rgba(40, 245, 255, 0.06) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2328f5ff' stroke-width='1.6'/%3E%3C/svg%3E") no-repeat right 0.9rem center / 0.65rem;
|
||||
border: 1px solid var(--cyan); box-shadow: 0 0 10px rgba(40, 245, 255, 0.25); padding-right: 2.4rem;
|
||||
}
|
||||
.bar select:hover { background-color: rgba(40, 245, 255, 0.16); }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.panel-title .dim { min-width: 0; text-align: right; overflow-wrap: anywhere; }
|
||||
|
||||
.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); }
|
||||
@@ -315,15 +308,13 @@ textarea { resize: vertical; }
|
||||
.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-row { display: flex; gap: 1.2ch; 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; }
|
||||
@@ -333,6 +324,7 @@ textarea { resize: vertical; }
|
||||
.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-probe { position: absolute; visibility: hidden; font-size: 100px; white-space: pre; }
|
||||
.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; }
|
||||
@@ -360,6 +352,12 @@ 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 select, .dialog input[type="text"] { width: 100%; }
|
||||
.dialog select { color: var(--cyan); border-color: var(--line); }
|
||||
.launch-status { min-height: 1.5em; letter-spacing: 0.04em; }
|
||||
.launch-status.ok { color: var(--lime); }
|
||||
.launch-status.bad { color: var(--red); }
|
||||
.slot-status { margin: 0.2rem 0 0.7rem; }
|
||||
.dialog .actions { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0 1rem 1rem; }
|
||||
|
||||
/* ---- small screens ------------------------------------------------------- */
|
||||
@@ -372,8 +370,15 @@ dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px);
|
||||
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; }
|
||||
/* phones: a tidy two-column grid; the chooser and the primary action span the row */
|
||||
.bar { flex-direction: column; align-items: stretch; gap: 0.3rem; }
|
||||
.bar-label { width: auto; }
|
||||
.bar-buttons { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.bar select { width: 100%; }
|
||||
.bar .span2 { grid-column: 1 / -1; }
|
||||
.bar .push { margin-left: 0; }
|
||||
.bar .btn, .bar select { padding: 0 0.6rem; }
|
||||
.panel-title { letter-spacing: 0.16em; flex-wrap: wrap; row-gap: 0.15rem; }
|
||||
.auth { padding-bottom: 10rem; }
|
||||
.top-user { margin-left: 0; }
|
||||
.manifest thead { display: none; }
|
||||
@@ -382,10 +387,19 @@ dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px);
|
||||
.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; }
|
||||
.navbar { flex-direction: column; align-items: stretch; gap: 0.6rem; }
|
||||
.tabs { width: 100%; gap: 0.2rem; }
|
||||
.tab { flex: 1 1 auto; min-width: 0; padding: 0.55rem 0.2rem; font-size: 0.68rem; letter-spacing: 0.05em; text-align: center; white-space: nowrap; }
|
||||
.shipsel { justify-content: space-between; }
|
||||
.hx-body { padding: 0.6rem 0.5rem; }
|
||||
.editor { height: 52vh; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.bar .btn, .bar select { font-size: 0.7rem; letter-spacing: 0.05em; padding: 0 0.4rem; }
|
||||
.tab { font-size: 0.62rem; letter-spacing: 0.02em; padding-left: 0.1rem; padding-right: 0.1rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
+173
-63
@@ -1,27 +1,37 @@
|
||||
// Code editor: write assembly, check it, download it, seal it into a ship.
|
||||
// Code editor with sixteen saved slots. Slots 1-3 are read-only examples; 4-16
|
||||
// are the player's own. Launching a ship (Fleet tab) picks which slot it flies.
|
||||
|
||||
import { api, b64ToBytes } from './api.js';
|
||||
import { h, clear, toast, confirmDialog, download } from './dom.js';
|
||||
import { h, clear, toast, confirmDialog, customDialog, download, pad } 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 */ } };
|
||||
const DEFAULT_SLOT = 2; // the telemetry example, a working program to start from
|
||||
|
||||
const readJSON = (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } };
|
||||
const writeJSON = (k, v) => { try { localStorage.setItem(k, JSON.stringify(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';
|
||||
// The unsaved working copy survives reloads, per account and per browser.
|
||||
const draftKey = `halcyon.editor.${ctx.state.me?.name ?? ''}`;
|
||||
|
||||
const cursor = h('span', { class: 'dim' }, 'LN 1 COL 1');
|
||||
let slots = []; // [{ slot, name, builtin, empty }] from the server
|
||||
let current = { slot: DEFAULT_SLOT, name: '', builtin: true, saved: '' }; // what the editor is bound to
|
||||
let ready = false;
|
||||
let timer = 0;
|
||||
|
||||
const info = (n) => slots.find((s) => s.slot === n) ?? { slot: n, name: '', builtin: false, empty: true };
|
||||
const slotLabel = (s) => `${pad(s.slot, 2)} ${s.builtin ? `${s.name} (EXAMPLE)` : s.empty ? '(EMPTY)' : s.name}`;
|
||||
|
||||
const cursor = h('span', { class: 'dim nowrap' }, 'LN 1 COL 1');
|
||||
const editor = createEditor({
|
||||
value: load(key) ?? '',
|
||||
onInput: (v) => { clearTimeout(timer); timer = setTimeout(() => save(key, v), 300); },
|
||||
value: '',
|
||||
onInput: (v) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => writeJSON(draftKey, { slot: current.slot, text: v }), 150);
|
||||
renderStatus();
|
||||
},
|
||||
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' },
|
||||
@@ -33,14 +43,13 @@ export function codeView(ctx) {
|
||||
try {
|
||||
const r = await api.assemble(editor.value);
|
||||
editor.setError(null);
|
||||
const bytes = b64ToBytes(r.program);
|
||||
assembled = { bytes, ...r };
|
||||
assembled = { bytes: b64ToBytes(r.program), ...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) })),
|
||||
h('div', { class: 'fill' })),
|
||||
);
|
||||
output.querySelector('.fill')?.style.setProperty('width', `${pct}%`);
|
||||
return assembled;
|
||||
@@ -52,50 +61,138 @@ export function codeView(ctx) {
|
||||
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' });
|
||||
// ---- slots -------------------------------------------------------------
|
||||
const btn = (label, onclick, opts = {}) =>
|
||||
h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
|
||||
|
||||
const toolbar = h('div', { class: 'toolbar' },
|
||||
exampleSel,
|
||||
const slotSel = h('select', { id: 'slot', class: 'span2', 'aria-label': 'Code slot', disabled: true });
|
||||
const status = h('p', { class: 'hint slot-status', 'aria-live': 'polite' }, 'LOADING YOUR SLOTS...');
|
||||
const saveBtn = btn('SAVE', () => save(), { disabled: true });
|
||||
const saveAsBtn = btn('SAVE AS...', () => saveAs(), { disabled: true });
|
||||
const clearBtn = btn('CLEAR SLOT', () => clearSlot(), { cls: 'span2', disabled: true });
|
||||
|
||||
const dirty = () => editor.value !== current.saved;
|
||||
|
||||
function renderSelect() {
|
||||
slotSel.replaceChildren(...slots.map((s) => h('option', { value: s.slot, selected: s.slot === current.slot }, slotLabel(s))));
|
||||
slotSel.value = String(current.slot);
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
if (!ready) return;
|
||||
const s = info(current.slot);
|
||||
status.textContent = `SLOT ${current.slot}: ${s.builtin ? `${s.name} (EXAMPLE, READ-ONLY)` : s.empty ? 'EMPTY' : s.name}${dirty() ? ' // UNSAVED CHANGES' : ''}`;
|
||||
status.classList.toggle('warn', dirty());
|
||||
saveBtn.disabled = current.builtin;
|
||||
saveBtn.title = current.builtin ? 'The examples are read-only: use SAVE AS to keep a copy in your own slot' : `Save in slot ${current.slot}`;
|
||||
saveAsBtn.disabled = false;
|
||||
clearBtn.disabled = current.builtin || s.empty;
|
||||
}
|
||||
|
||||
async function openSlot(n, { keepText = null } = {}) {
|
||||
const s = await api.slot(n);
|
||||
current = { slot: n, name: s.name, builtin: s.builtin, saved: s.source };
|
||||
editor.value = keepText ?? s.source;
|
||||
writeJSON(draftKey, { slot: n, text: editor.value });
|
||||
renderSelect();
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
slotSel.addEventListener('change', async () => {
|
||||
const n = Number(slotSel.value);
|
||||
if (dirty() && !(await confirmDialog({
|
||||
title: 'DISCARD UNSAVED CHANGES?',
|
||||
body: `YOU HAVE CHANGES IN SLOT ${current.slot} THAT ARE NOT SAVED. OPENING SLOT ${n} REPLACES THEM.`,
|
||||
confirmText: 'DISCARD', danger: true,
|
||||
}))) { slotSel.value = String(current.slot); return; }
|
||||
try { await openSlot(n); say(h('p', { class: 'dim' }, `OPENED SLOT ${n}.`)); } catch (e) { toast(e.message, 'err'); slotSel.value = String(current.slot); }
|
||||
});
|
||||
|
||||
async function put(n, name) {
|
||||
try {
|
||||
await api.saveSlot(n, name, editor.value);
|
||||
slots = slots.map((s) => (s.slot === n ? { slot: n, name, builtin: false, empty: false } : s));
|
||||
current = { slot: n, name, builtin: false, saved: editor.value };
|
||||
writeJSON(draftKey, { slot: n, text: editor.value });
|
||||
renderSelect();
|
||||
renderStatus();
|
||||
toast(`SAVED IN SLOT ${n}: ${name}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast(`SAVE FAILED: ${e.message}`, 'err');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (current.builtin) return saveAs();
|
||||
if (!current.name) return saveAs();
|
||||
return put(current.slot, current.name);
|
||||
}
|
||||
|
||||
async function saveAs() {
|
||||
const writable = slots.filter((s) => !s.builtin);
|
||||
const firstFree = writable.find((s) => s.empty);
|
||||
const target = current.builtin ? (firstFree ?? writable[0]).slot : current.slot;
|
||||
const sel = h('select', { id: 'sa-slot' }, writable.map((s) => h('option', { value: s.slot, selected: s.slot === target }, slotLabel(s))));
|
||||
const name = h('input', { id: 'sa-name', type: 'text', maxlength: '40', value: current.name || 'MY PROGRAM', spellcheck: 'false' });
|
||||
const warn = h('p', { class: 'warn', 'aria-live': 'polite' });
|
||||
const dlg = customDialog({
|
||||
title: 'SAVE IN A SLOT',
|
||||
content: [h('label', { for: 'sa-slot' }, 'SLOT'), sel, h('label', { for: 'sa-name' }, 'NAME'), name, warn],
|
||||
confirmText: 'SAVE',
|
||||
});
|
||||
const check = () => {
|
||||
const s = info(Number(sel.value));
|
||||
warn.textContent = s.empty || s.slot === current.slot ? '' : `THIS REPLACES "${s.name}".`;
|
||||
dlg.confirm.disabled = !name.value.trim();
|
||||
};
|
||||
sel.addEventListener('change', check);
|
||||
name.addEventListener('input', check);
|
||||
name.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault(); // otherwise the same keystroke lands on the SAVE AS button that regains focus, and reopens this dialog
|
||||
if (!dlg.confirm.disabled) dlg.close(true);
|
||||
});
|
||||
check();
|
||||
name.select();
|
||||
if (await dlg.result) await put(Number(sel.value), name.value.trim());
|
||||
}
|
||||
|
||||
async function clearSlot() {
|
||||
const s = info(current.slot);
|
||||
if (!(await confirmDialog({ title: `CLEAR SLOT ${s.slot}?`, body: [`"${s.name}" WILL BE DELETED FROM THE SLOT.`, 'THE TEXT NOW IN THE EDITOR STAYS UNTIL YOU CHANGE SLOT.'], confirmText: 'CLEAR SLOT', danger: true }))) return;
|
||||
try {
|
||||
await api.deleteSlot(s.slot);
|
||||
slots = slots.map((x) => (x.slot === s.slot ? { slot: s.slot, name: '', builtin: false, empty: true } : x));
|
||||
current = { slot: s.slot, name: '', builtin: false, saved: '' };
|
||||
renderSelect();
|
||||
renderStatus();
|
||||
toast(`SLOT ${s.slot} CLEARED`);
|
||||
} catch (e) { toast(`CLEAR FAILED: ${e.message}`, 'err'); }
|
||||
}
|
||||
|
||||
// ---- bars --------------------------------------------------------------
|
||||
const bar = (label, ...buttons) =>
|
||||
h('div', { class: 'bar', role: 'group', 'aria-label': label }, h('span', { class: 'bar-label' }, label), h('div', { class: 'bar-buttons' }, ...buttons));
|
||||
|
||||
const slotBar = bar('SLOT', slotSel, saveBtn, saveAsBtn, clearBtn);
|
||||
const buildBar = bar('BUILD',
|
||||
btn('ASSEMBLE', assemble),
|
||||
btn('DOWNLOAD .BIN', async () => { const r = await bytesOrNull(); if (r) download(`slot-${current.slot}-program.bin`, r.bytes); }),
|
||||
);
|
||||
const fileBar = bar('FILE',
|
||||
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')),
|
||||
btn('DOWNLOAD .S', () => download(`slot-${current.slot}.s`, editor.value, 'text/plain')),
|
||||
);
|
||||
|
||||
// ---- reference ---------------------------------------------------------
|
||||
@@ -104,36 +201,49 @@ export function codeView(ctx) {
|
||||
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();
|
||||
// Appendix E of the manual source is one fenced block: the quick reference card.
|
||||
const txt = await (await fetch('/manual.md')).text();
|
||||
const open = txt.indexOf('```', txt.indexOf('# Appendix E'));
|
||||
const close = txt.indexOf('```', open + 3);
|
||||
card.textContent = txt.slice(txt.indexOf('\n', open) + 1, close).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')),
|
||||
h('p', { class: 'hint pad' }, h('a', { href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL (PDF)')),
|
||||
);
|
||||
|
||||
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,
|
||||
h('p', { class: 'hint' }, 'YOU HAVE 16 CODE SLOTS: 1-3 HOLD EXAMPLES, 4-16 ARE YOURS. TO FLY A PROGRAM, LAUNCH A SHIP FROM THE FLEET TAB AND CHOOSE ITS SLOT.'),
|
||||
slotBar,
|
||||
status,
|
||||
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.'),
|
||||
h('p', { class: 'hint' }, 'YOUR UNSAVED TEXT IS KEPT IN THIS BROWSER. TAB INDENTS; ESC THEN TAB LEAVES THE EDITOR.'),
|
||||
buildBar,
|
||||
fileBar,
|
||||
output,
|
||||
),
|
||||
),
|
||||
ref,
|
||||
);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
slots = await api.slots();
|
||||
const draft = readJSON(draftKey);
|
||||
const start = draft && slots.some((s) => s.slot === draft.slot) ? draft.slot : DEFAULT_SLOT;
|
||||
ready = true;
|
||||
slotSel.disabled = false;
|
||||
await openSlot(start, { keepText: draft && draft.slot === start && typeof draft.text === 'string' ? draft.text : null });
|
||||
} catch (e) {
|
||||
status.textContent = `COULD NOT LOAD YOUR SLOTS: ${e.message}`;
|
||||
status.classList.add('bad');
|
||||
}
|
||||
})();
|
||||
|
||||
queueMicrotask(() => editor.render());
|
||||
return view;
|
||||
}
|
||||
|
||||
+59
-19
@@ -1,26 +1,70 @@
|
||||
// Fleet manifest with launch controls.
|
||||
|
||||
import { api } from './api.js';
|
||||
import { h, toast, confirmDialog } from './dom.js';
|
||||
import { h, toast, customDialog, pad } 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.',
|
||||
let slots;
|
||||
try { slots = await api.slots(); } catch (e) { toast(`COULD NOT LOAD YOUR CODE SLOTS: ${e.message}`, 'err'); return; }
|
||||
const slotLabel = (s) => `${pad(s.slot, 2)} ${s.builtin ? `${s.name} (EXAMPLE)` : s.empty ? '(EMPTY)' : s.name}`;
|
||||
let remembered = 0;
|
||||
try { remembered = Number(localStorage.getItem('halcyon.launchSlot')); } catch { /* ignore */ }
|
||||
const usable = slots.filter((s) => !s.empty);
|
||||
const first = usable.find((s) => s.slot === remembered) ?? usable.find((s) => !s.builtin) ?? usable[0];
|
||||
|
||||
const sel = h('select', { id: 'launch-slot' },
|
||||
slots.map((s) => h('option', { value: s.slot, disabled: s.empty, selected: s.slot === first.slot }, slotLabel(s))));
|
||||
const status = h('p', { class: 'launch-status', role: 'status', 'aria-live': 'polite' }, 'CHECKING...');
|
||||
const dlg = customDialog({
|
||||
title: `LAUNCH SHIP #${ship.id}`,
|
||||
content: [
|
||||
h('p', null, 'CHOOSE THE CODE SLOT THIS SHIP WILL FLY. IT IS ASSEMBLED NOW AND SEALED INTO THE SHIP.'),
|
||||
h('label', { for: 'launch-slot' }, 'CODE SLOT'),
|
||||
sel,
|
||||
status,
|
||||
h('p', { class: 'warn' }, 'AT LAUNCH THE PROGRAM IS FIXED. IT CANNOT BE CHANGED, RECALLED OR RESTARTED.'),
|
||||
],
|
||||
confirmText: 'LAUNCH',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// Assemble the chosen slot as soon as it is chosen, so problems show before launch.
|
||||
let seq = 0;
|
||||
const check = async () => {
|
||||
const mine = ++seq;
|
||||
const n = Number(sel.value);
|
||||
dlg.confirm.disabled = true;
|
||||
status.className = 'launch-status';
|
||||
status.textContent = 'ASSEMBLING...';
|
||||
let text;
|
||||
let good = false;
|
||||
try {
|
||||
const src = await api.slot(n);
|
||||
const r = await api.assemble(src.source);
|
||||
if (r.size === 0) text = 'THIS SLOT HAS NO CODE.';
|
||||
else if (!r.fits) text = `TOO LARGE: ${r.size} BYTES, THE LIMIT IS ${r.limit}.`;
|
||||
else { good = true; text = `${r.size} BYTES / ${r.instructions} INSTRUCTIONS: READY TO LAUNCH.`; }
|
||||
} catch (e) {
|
||||
text = `DOES NOT ASSEMBLE: ${e.message}`;
|
||||
}
|
||||
if (mine !== seq) return; // a newer choice superseded this one
|
||||
status.className = `launch-status ${good ? 'ok' : 'bad'}`;
|
||||
status.textContent = text;
|
||||
dlg.confirm.disabled = !good;
|
||||
};
|
||||
sel.addEventListener('change', check);
|
||||
check();
|
||||
|
||||
if (!(await dlg.result)) return;
|
||||
const n = Number(sel.value);
|
||||
try {
|
||||
await api.launch(ship.id);
|
||||
toast(`SHIP #${ship.id} CLEARED FOR LAUNCH. IT FLIES ON THE NEXT DAILY RUN.`);
|
||||
const r = await api.launch(ship.id, n);
|
||||
try { localStorage.setItem('halcyon.launchSlot', String(n)); } catch { /* ignore */ }
|
||||
toast(`SHIP #${ship.id} CLEARED FOR LAUNCH WITH SLOT ${n} (${r?.program_bytes ?? '?'} BYTES). IT FLIES ON THE NEXT DAILY RUN.`);
|
||||
await ctx.refresh();
|
||||
} catch (e) {
|
||||
toast(`LAUNCH FAILED: ${e.message}`, 'err');
|
||||
@@ -34,11 +78,7 @@ export function fleetView(ctx) {
|
||||
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',
|
||||
}));
|
||||
out.push(btn('LAUNCH', () => launch(s), { cls: 'primary', title: 'Choose a code slot and 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')));
|
||||
@@ -72,13 +112,13 @@ export function fleetView(ctx) {
|
||||
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('1', 'WRITE', 'a flight program in the CODE editor and save it in one of your 16 code slots. Your ship cannot be flown by hand.'),
|
||||
step('2', 'TEST', 'it: assemble it in the editor, and improve it in as many slots as you like.'),
|
||||
step('3', 'LAUNCH', 'the ship and choose which slot it flies. 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')),
|
||||
h('p', { class: 'hint pad' }, 'The belt runs once a day. Ore delivered to the station earns credits.'),
|
||||
h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL (PDF)')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function uplinkView(ctx) {
|
||||
send.disabled = !open || over || b.length === 0;
|
||||
clear(preview);
|
||||
if (b.length) {
|
||||
preview.append(hexView(b, { rows: 8 }).el);
|
||||
preview.append(hexView(b, { maxBytes: 128, maxFont: 13 }).el);
|
||||
if (b.length > 128) preview.append(h('p', { class: 'hint' }, `SHOWING THE FIRST 128 OF ${b.length} BYTES.`));
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function uplinkView(ctx) {
|
||||
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.'),
|
||||
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) AT THE START OF EACH DAY.'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+38
-7
@@ -6,11 +6,12 @@ import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"wh/docs"
|
||||
"wh/examples"
|
||||
"halcyon/docs"
|
||||
"halcyon/examples"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
@@ -18,15 +19,18 @@ 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 {
|
||||
//
|
||||
// manualPath is the rendered PDF manual; it is opened on every request, so a
|
||||
// new render is served as soon as it is renamed into place, without a restart.
|
||||
func Handler(manualPath string) 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 /manual.md", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Write(docs.Markdown)
|
||||
})
|
||||
mux.HandleFunc("GET /examples/index.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
entries, _ := fs.ReadDir(examples.FS, ".")
|
||||
@@ -41,7 +45,34 @@ func Handler() http.Handler {
|
||||
json.NewEncoder(w).Encode(names)
|
||||
})
|
||||
mux.Handle("GET /examples/", http.StripPrefix("/examples/", http.FileServerFS(examples.FS)))
|
||||
return secure(mux)
|
||||
|
||||
top := http.NewServeMux()
|
||||
top.HandleFunc("GET /manual.pdf", manualPDF(manualPath)) // not under the site's CSP: PDF viewers need their own resources
|
||||
top.Handle("/", secure(mux))
|
||||
return top
|
||||
}
|
||||
|
||||
// manualPDF serves the manual rendered separately from the app.
|
||||
func manualPDF(path string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
http.Error(w, "The PDF manual has not been rendered yet (run scripts/render-manual.sh). "+
|
||||
"The Markdown source is at /manual.md.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, "cannot read the PDF manual", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="halcyon-hc33-manual.pdf"`)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Cache-Control", "no-cache") // revalidate, so a fresh render shows up
|
||||
http.ServeContent(w, r, "manual.pdf", st.ModTime(), f) // ranges and If-Modified-Since
|
||||
}
|
||||
}
|
||||
|
||||
// secure adds headers appropriate to a page that loads only its own assets.
|
||||
|
||||
Reference in New Issue
Block a user