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

+2
View File
@@ -26,6 +26,8 @@ export function panelBuildConfig(config) {
cssCodeSplit: false,
rollupOptions: {
...config.build?.rollupOptions,
// The panel is the app alone: one page, so one inlined script.
input: undefined,
output: { inlineDynamicImports: true },
},
},
+11 -1
View File
@@ -1,9 +1,16 @@
import { fileURLToPath } from 'node:url';
import { applicationHtmlPlugin } from './application-html.js';
import cesium from 'vite-plugin-cesium';
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
import { embedFramingPlugin } from './embed-framing.js';
import { panelBuildPlugin } from './panel.js';
/** HTML documents a production build emits: the app and its mobile shell. */
export const BROWSER_PAGES = Object.freeze({
main: fileURLToPath(new URL('../index.html', import.meta.url)),
mobile: fileURLToPath(new URL('../mobile/index.html', import.meta.url)),
});
/**
* Content-Security-Policy for every document the dev/preview server serves.
* No inline script is permitted, and the only foreign script origins are the
@@ -113,6 +120,9 @@ export function createBrowserViteConfig({
mapillaryToken ?? '',
),
},
build: { chunkSizeWarningLimit: 1500 },
build: {
chunkSizeWarningLimit: 1500,
rollupOptions: { input: { ...BROWSER_PAGES } },
},
};
}
+1 -1
View File
@@ -22,7 +22,7 @@
<meta name="apple-mobile-web-app-title" content="God's Eye" />
<meta name="format-detection" content="telephone=no" />
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/logo.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png" />
<link rel="preconnect" href="https://server.arcgisonline.com" crossorigin />
<link rel="stylesheet" href="./styles/tokens.css" />
<link rel="stylesheet" href="./styles/shell.css" />
+62 -2
View File
@@ -82,11 +82,65 @@
animation: view-in var(--dur-3) var(--spring) both;
}
.app.is-world .gc-readout,
.app.is-world .gc-attr {
.app.is-world .gc-readout {
visibility: hidden;
}
/* Attribution stays on screen whenever the globe is (provider terms). */
.app.is-world .gc-attr {
top: calc(var(--safe-t) + 60px);
}
.gc-attr.has-onscreen {
color: var(--ink-2);
}
.gc-labels {
position: absolute;
inset: 0;
pointer-events: none !important;
overflow: hidden;
z-index: 0;
}
.gc-labels span {
position: absolute;
left: 0;
top: 0;
display: inline-flex;
align-items: center;
gap: 4px;
margin: -9px 0 0 8px;
height: 18px;
padding: 0 6px 0 5px;
border-radius: 9px;
background: rgba(5, 7, 11, 0.58);
font-family: var(--mono);
font-size: 0.625rem;
font-weight: 600;
color: var(--ink);
white-space: nowrap;
will-change: transform;
}
.gc-labels i {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--fam, var(--ink-3));
}
.tile.soon {
opacity: 0.5;
background: transparent;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.tile.soon .tile-status {
font-style: normal;
color: var(--ink-4);
}
/* Lens popover */
.lens-pop {
position: absolute;
@@ -735,6 +789,12 @@
color: var(--fam);
}
/* The headline number belongs to the peek detent; taller detents show it in
the labelled metrics row instead of twice. */
.brief-sheet:not([data-detent='peek']) .peek-metric {
display: none;
}
.peek-metric small {
font-size: 0.75rem;
font-weight: 650;
+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 },
];