feat(mobile): add the mobile app shell, capture and the /mobile/ build page

Five tabs (Now, Globe, Capture, Threads, You) over one persistent globe:
a live situation feed whose cards open into the world with a detented
brief, the lens dock and time ribbon, AR Look and signed Witness
capture (ECDSA P-256 in IndexedDB, SHA-256 hash chain, IMU and GPS
trace, eight-check verify), the Bhote Koshi reconstruction thread with
a live ledger, and hinge-aware two-pane layouts for foldables.

The production build now emits mobile/index.html beside the app; the
panel build keeps the app alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-18 05:40:33 +00:00
1 parent e532c5efb6
commit 52de186005
8 files changed
+183 -8

No files matched your search

+5 -1
View File
@@ -136,7 +136,11 @@ export function createFeed(
}
return all
.filter((s) => !isReconstruction(s) && !isAlwaysOn(s))
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
.sort(
(a, b) =>
(b.severity ?? 0) - (a.severity ?? 0) ||
new Date(b.updatedAt) - new Date(a.updatedAt),
);
}
function render() {
+85 -1
View File
@@ -96,6 +96,68 @@ export function createGlobeChrome({
onTime: (d) => globe.setTime(d),
});
/* ---------- HTML labels (engine draws none; ≤ 40, DESIGN.md §5) ---------- */
const labelLayer = h('<div class="gc-labels" aria-hidden="true"></div>');
chrome.prepend(labelLayer);
let labelRaf = 0;
const KIND_FAM_L = {
aircraft: 'sky',
helicopter: 'sky',
military: 'sky',
vessel: 'sea',
ship: 'sea',
satellite: 'orbit',
station: 'orbit',
launch: 'orbit',
earthquake: 'hazards',
quake: 'hazards',
fire: 'hazards',
cyclone: 'hazards',
camera: 'eyes',
cctv: 'eyes',
};
function drawLabels() {
labelRaf = 0;
if (document.hidden || getComputedStyle(chrome).visibility === 'hidden')
return;
const list = globe.labels(40);
const W = chrome.clientWidth;
const html = list
.map((l) => {
const flip = l.x > W - 9 * Math.min(24, l.title.length) - 40;
const tf = `translate(${Math.round(l.x)}px,${Math.round(l.y)}px)${flip ? ' translateX(calc(-100% - 16px))' : ''}`;
return `<span data-family="${KIND_FAM_L[l.kind] || 'infra'}" style="transform:${tf}"><i></i>${esc(l.title)}</span>`;
})
.join('');
if (labelLayer.innerHTML !== html) labelLayer.innerHTML = html;
}
const queueLabels = () => {
if (!labelRaf) labelRaf = requestAnimationFrame(drawLabels);
};
globe.on('camera', queueLabels);
globe.on('layers', queueLabels);
setInterval(queueLabels, 1000);
/** Provider credits flagged onScreen (e.g. Google 3D Tiles) must stay visible. */
let attrKey = '';
function refreshAttribution() {
const list = globe.credits() || [];
const onScreen = list
.filter((c) => c.onScreen)
.map((c) => c.text || String(c.html || '').replace(/<[^>]+>/g, ''));
const text = ['Esri · Maxar · Earthstar · NASA', ...onScreen].join(' · ');
if (text === attrKey) return;
attrKey = text;
attribution.textContent = text;
attribution.classList.toggle('has-onscreen', onScreen.length > 0);
}
let attrTimer = 0;
globe.on('camera', () => {
clearTimeout(attrTimer);
attrTimer = setTimeout(refreshAttribution, 300);
});
setTimeout(refreshAttribution, 500);
/* ---------- camera readout ---------- */
globe.on('camera', (c) => {
if (!c) return;
@@ -141,7 +203,7 @@ export function createGlobeChrome({
for (const l of layers) {
const s = st[l.family];
if (!s) continue;
s.total += 1;
if (l.available !== false) s.total += 1;
s.layers.push(l);
if (l.on) {
s.on += 1;
@@ -216,6 +278,12 @@ export function createGlobeChrome({
grid.on('close', () => (gridFamily = null));
function tileHtml(l) {
if (l.available === false)
return `<button class="tile soon" type="button" aria-disabled="true" data-soon="${esc(l.id)}" data-msg="${esc(l.message || 'Not in the prototype yet')}">
<span class="tile-ic">${icon(FAMILY_ICON[l.family] || 'layers')}</span>
<span class="tile-name">${esc(l.name)}</span>
<span class="tile-meta"><span class="tile-status">Coming soon</span></span>
</button>`;
return `<button class="tile ${l.on ? 'on' : ''}" type="button" role="switch" aria-checked="${!!l.on}" data-layer="${esc(l.id)}" data-status="${esc(l.status)}">
<span class="tile-ic">${icon(FAMILY_ICON[l.family] || 'layers')}</span>
<span class="tile-name">${esc(l.name)}</span>
@@ -241,6 +309,14 @@ export function createGlobeChrome({
}
grid.root.addEventListener('click', (e) => {
const soon = e.target.closest('[data-soon]');
if (soon) {
toast(
`${soon.querySelector('.tile-name').textContent} · ${soon.dataset.msg}`,
{ icon: icon('clock') },
);
return;
}
const t = e.target.closest('[data-layer]');
if (t) {
const on = t.getAttribute('aria-checked') !== 'true';
@@ -518,6 +594,14 @@ export function createGlobeChrome({
<p class="footnote"><button class="link-btn" type="button" data-act2="credits">Data &amp; attribution</button></p>`;
}
leadView.addEventListener('click', (e) => {
const soon = e.target.closest('[data-soon]');
if (soon) {
toast(
`${soon.querySelector('.tile-name').textContent} · ${soon.dataset.msg}`,
{ icon: icon('clock') },
);
return;
}
const t = e.target.closest('[data-layer]');
if (t) {
const on = t.getAttribute('aria-checked') !== 'true';
+12
View File
@@ -119,6 +119,13 @@ function guard(engine, kind) {
const v = call('credits', null);
return Array.isArray(v) ? v : null;
},
/** Screen-space label candidates (engine draws none; the shell renders them). */
labels: (max = 40) => {
const v = call('labels', [], max);
return Array.isArray(v) ? v : [];
},
project: (lon, lat, alt) => call('project', null, lon, lat, alt),
time: () => call('time', null),
};
}
@@ -130,6 +137,11 @@ export async function mountGlobe(container, opts = {}) {
const params = new URLSearchParams(location.search);
const forceStub = opts.preferStub || params.get('engine') === 'stub';
if (!forceStub) {
// vite-plugin-cesium serves Workers/Assets at /cesium/ and passes the base
// as a build-time define, which the pre-bundled dev dependency never sees;
// Cesium then resolves them next to node_modules/.vite/deps and gets HTML.
if (typeof window !== 'undefined' && !window.CESIUM_BASE_URL)
window.CESIUM_BASE_URL = '/cesium/';
try {
// A glob (not a bare dynamic import) so a checkout without the engine
// still builds; Vite re-evaluates it when mobile/globe/index.js appears.
+5 -2
View File
@@ -148,9 +148,12 @@ export function createWorld({
}
function contextEvidence(s) {
const span = Math.max(0.02, (s.camera?.height || 300e3) / 111e3 / 1.6);
const span = Math.min(
12,
Math.max(0.02, (s.camera?.height || 300e3) / 111e3 / 1.6),
);
const frames = [
{ t: 'Region', span: span * 6 },
{ t: 'Region', span: Math.min(40, span * 6) },
{ t: 'Area', span },
{ t: 'Close', span: span / 5 },
];