SatQuery / docs /FRONTEND.md
thundercode's picture
release: add docs/FRONTEND.md
6806bee verified
|
Raw
History Blame Contribute Delete
73.9 kB

SatQuery AI — Frontend

Chapter scope. This chapter documents the SatQuery AI web frontend end to end: the static tier and every page in it, the staging and deploy path that publishes it, the Analyze console in full depth (every DOM handle, every state, every event), the eight-event execution protocol and the trace bar it drives, the REAL-vs-PREVIEW driver split, the captured-run page, the Hugging Face header link, cache-busting, and the Cloudflare platform traps that shape all of the above.

Grounding. Every claim below is taken from a file that was read for this chapter. Where a claim comes from code, the file is cited inline, e.g. (frontend/assets/js/mission.js). Where a number is quoted it is a number that appears in a file; none is estimated. Where the evidence does not exist, the text says exactly: UNKNOWN — not established from the available evidence.

Status vocabulary follows release/DOCS_STYLE_GUIDE.md §2: IMPLEMENTED · VERIFIED · MEASURED · ATTEMPTED · NOT RUN · BLOCKED · DEFERRED · REJECTED · OPEN · RESOLVED · CLOSED.

Nothing in this chapter is a system-level accuracy claim. Per release/DOCS_STYLE_GUIDE.md §3 there is no end-to-end benchmark for SatQuery AI; the frontend is a client of the service, and the only system-level numbers quoted here are the ones the delivery documents themselves recorded (live validation 3 passes × 8 cases, 8/8 each, 24 runs, 0 mock nodes, trace fill 94.4444 %).


1. What the frontend is, and what it is not

SatQuery AI's frontend is a static site. It is HTML, CSS, and ES modules served from Cloudflare Pages. There is no build step that compiles application code, no bundler, no framework, no server rendering, and no runtime dependency on a Node process. The staging tool (scripts/stage_pages.mjs) copies a reference-closed subset of frontend/ into an output directory and then hands that directory to wrangler.

The site is hermetic except for one page. The staging tool computes and prints a reference integrity and external-dependency audit, and it reports HERMETIC when a page's reference closure contains zero network dependencies (scripts/stage_pages.mjs). The single deliberate exception is frontend/mission.html, the Analyze console, which carries a live API base in a <meta> tag and can call the deployed service. Everything else — the homepage, the essay, the atlas, the architecture tour, the benchmark and research pages, the captured-run page — is designed to render without any network call beyond its own assets.

Honesty note (drift recorded, not hidden). An older comment inside frontend/_headers claimed the site was "100% static, zero network calls". That claim is stale and is not repeated here as current truth: mission.html is a live-calling page, and mission.html is one of the eleven shipped pages. The correct current statement is: ten of eleven pages are hermetic; mission.html is the one live-calling page.

1.1 The design law the frontend was built under

frontend/HANDOFF.md is the governing design document for the frontend. Its §1 states the design law; §2 defines the token system as CSS custom properties on :root; §3–§10 lay out build phases A–G; §9 names the integration seam (SQ.run().ingest); §13 lists known hard limits; §14 lists the real SatQuery schema type names that the frontend is allowed to speak.

The practical consequences of that design law, as they appear in the shipped code:

  • No fabricated imagery is presented as real. Synthetic imagery produced at runtime carries a synthetic: true flag (frontend/assets/js/core.js, SQ.scene), and pages that use placeholder numbers say so in their own prose (e.g. frontend/atlas.html states its numbers are placeholders).
  • The eight-event vocabulary is fixed. The frontend may not invent event names; it emits exactly the eight names declared in SQ.EVENT_NAMES (frontend/assets/js/core.js).
  • The event stream is the seam. Any driver — mock, live, or a captured replay — talks to the UI only by calling ingest(type, payload). Nothing else may mutate the console.

2. The static tier: file layout

The shipped frontend is a flat set of pages plus three asset trees.

frontend/
  *.html                      top-level pages (the staging seed set)
  _headers                    Cloudflare Pages header rules (see §12)
  HANDOFF.md                  the frontend design/handoff document
  assets/
    css/                      stylesheets
    js/
      core.js                 SQ namespace: rng, scene synthesis, policy router,
                              event names, mock run driver, shared components
      live.js                 the real HTTP ingestion client (assets + infer)
      mission.js              the Analyze console driver (PREVIEW + LIVE)
      run.js                  the captured-run ("Anatomy of a Run") driver
      <page drivers>          per-page behaviour
    data/
      anatomy-run.js          the captured real ResultEnvelope (sanitized)
    img/                      real EO imagery (eo/…), plates, thumbnails
    video/                    the launch film and clips
    fonts/                    webfonts

Two facts about this layout matter for deployment:

  1. The staging seed is the set of top-level frontend/*.html files (scripts/stage_pages.mjs). Pages are discovered from HTML, and then their reference closure (CSS @import/url(), JS import/export … from, and dynamic imports) is walked so that only referenced assets ship.
  2. Because the closure is reference-driven, an asset that is not referenced by a reachable page does not ship. This is deliberate: it keeps the uploaded tree small and it makes dead assets visible (they simply do not appear in the staged tree report).

3. The eleven pages

Eleven HTML pages ship. Each was read for this chapter. The table gives the page's purpose and its data-view (the attribute each page's <body> carries, which the CSS uses to scope page-specific rules).

# File Purpose Notes
1 frontend/index.html Homepage / front door. Orbit hero, invitation form, open questions, essay film, discover/evidence/understand/measure/atlas sections. Carries the launch video and the delta pair.
2 frontend/mission.html The Analyze console. Query box, two upload widgets, intent panel, viewer, comparison, answer, evidence, confidence, provenance, trace bar, event drawer. The only live-calling page. Carries <meta name="satquery-api-base">.
3 frontend/architecture.html Architecture tour: how a query becomes an answer, stage by stage. Footer discloses that its transmission is driven by the prototype mock event stream.
4 frontend/run.html Anatomy of a Run — renders a real captured ResultEnvelope (run_d124d8b9adea). Driven by frontend/assets/js/run.js over frontend/assets/data/anatomy-run.js.
5 frontend/benchmark.html Benchmark page: measured results with an evidence-state legend. Legend vocabulary: VERIFIED / SUPPORTED / UNVERIFIED / BLOCKED / NOT RUN.
6 frontend/research.html Research notes: methods, calibration, honest caveats. Links into the measurement story.
7 frontend/journey.html The build journey / narrative page. Carries the HF + GitHub header links.
8 frontend/atlas.html Atlas of four real EO thumbnails. Page states its numbers are placeholders.
9 frontend/references.html References / citations page. —
10 frontend/video.html Video library: four clips, three planned shorts named. —
11 frontend/404.html Not-found page. Prose says "Ten pages exist" but links five — drift, recorded in §13.

3.1 Page-by-page detail

index.html (homepage). 424 lines. Structure: a navigation bar carrying the GitHub and Hugging Face links; an orbit hero using assets/img/eo/nile-wide.jpg; an invitation form whose action is mission.html; an "open questions" list containing three mission.html?q=… links (so a visitor can land in the Analyze console with a question pre-filled); an essay-film section using assets/video/satquery-launch-50s.mp4 with eight data-chapters markers; an "ask" section using assets/img/eo/delta-plain.jpg; a "discover" section that presents the delta-growth t0/t1 pair as a wipe slider (delta-growth-t0-720 / delta-growth-t1-720); an "evidence" section using delta-growth-t2-2075.jpg; an "understand" section listing six layers; a "measure" section with benchmark and research cards; and an "atlas" section with four real EO thumbnails. The footer notes name the event span QUERY_RECEIVED → RESULT_ASSEMBLED, i.e. the first and last of the eight events.

mission.html (Analyze console). 297 lines. This is the page this chapter spends most of its length on; see §5.

architecture.html. The architecture tour. It walks the reader from a natural-language query through routing, planning, specialists, evidence, confidence, and result assembly. Its footer makes an explicit honesty disclosure: the transmission shown on the page is driven by the prototype mock event stream, not by a live run. That disclosure is the page doing the right thing — the animation is real UI driven by the same eight-event seam, but the data behind it on this page is the mock driver's.

run.html ("Anatomy of a Run"). Renders a real captured envelope. See §9.

benchmark.html. Presents measured results. It carries an evidence-state legend whose vocabulary is VERIFIED / SUPPORTED / UNVERIFIED / BLOCKED / NOT RUN, and it notes the measured reliability curve. Per release/DOCS_STYLE_GUIDE.md §3, this page is the right place for the per-artifact numbers (grounding under two protocols, change IoU, optical-SAR accuracy-with-macro-F1, change-VQA two test sets, router validation-only), and it must never present them as a system-level score.

research.html. Research notes: method, calibration, and caveats. This is where the calibration result belongs, and per the style guide it must be stated correctly: ECE went 0.013755 → 0.014929 — worse, and the transform is retained only because it is in the frozen config.

journey.html. Narrative page for the build process.

atlas.html. Four real EO thumbnails. The page states in its own prose that its numbers are placeholders. That statement is correct and must be preserved: the atlas is a gallery, not a measurement.

references.html. Citations.

video.html. Lists four clips and names three planned shorts. The planned shorts are labelled as planned, not shipped.

404.html. The not-found page. Its prose says "Ten pages exist" while eleven do, and it links five. This is documentation drift inside a shipped page; it is recorded here and in §13 rather than silently corrected, because correcting it would be an edit outside this chapter's scope.

3.2 The Hugging Face header link — present on all eleven pages

Every one of the eleven pages carries, in its navigation, both:

  • a GitHub link to https://github.com/Anish-lab-blip/SatQuery-AI, and
  • a Hugging Face link to https://huggingface.co/thundercode/SatQuery.

This was verified by searching all frontend/*.html for huggingface.co and github.com and confirming a match in each of: index, journey, mission, 404, video, atlas, architecture, benchmark, run, research, references — eleven files, eleven matches each. docs/FINAL_DELIVERY_TODO.md records this as a post-handoff sprint outcome ("the HF link on all 11 pages").

The reason this is called out as its own subsection: the public release is GitHub + Hugging Face, and the requirement that the HF link appear on all pages (not just the homepage) is a delivery requirement, so it is stated as a verified fact with the method of verification.


4. Staging and deploy path

4.1 scripts/stage_pages.mjs — the reference-closed staging tool

scripts/stage_pages.mjs is 378 lines and is the tool that turns the working frontend/ directory into a deployable tree. It is deliberately conservative.

Constants.

Constant Value Meaning
PAGES_FILE_LIMIT 26214400 (25 MiB) Cloudflare Pages per-file hard limit.
BIG_WARN_BYTES 10485760 (10 MiB) Warn threshold for a large file.

Reference extraction. The tool uses a small set of regexes to find references inside each file type:

  • RE_HTML — HTML references (<script src>, <link href>, <img src>, etc.)
  • RE_CSS_IMPORT — CSS @import
  • RE_CSS_URL — CSS url(...)
  • RE_JS_IMPORT — JS import … from
  • RE_JS_EXPORT — JS export … from
  • RE_JS_DYN — JS dynamic import(...)

Supporting helpers: stripComments() (so a reference inside a comment does not become a false edge), extractRefs(), isExternal() (absolute URLs and protocol-relative URLs are not followed), stripQueryHash() (so app.js?v=2 resolves to app.js), and insideFrontend() (a guard so a reference cannot escape the frontend/ root).

Algorithm.

  1. Seed. Take the set of top-level frontend/*.html files.
  2. Closure walk. For each file in the frontier, extract its references, resolve each to a path inside frontend/, and add the new ones to the frontier. Repeat until the frontier is empty.
  3. Copy. Copy every file in the closure into the output root, preserving relative paths.
  4. Size gate. If any file exceeds PAGES_FILE_LIMIT (25 MiB), hard-fail with exit code 2. Files above BIG_WARN_BYTES (10 MiB) produce a warning.
  5. Verify. Re-walk the staged tree and confirm the closure is intact (no dangling reference). Failure is exit code 3.
  6. Report. Print four report blocks:
    • === STAGED TREE ===
    • === REFERENCE INTEGRITY ===
    • === EXTERNAL DEPENDENCY AUDIT === — prints HERMETIC when zero network dependencies are found.
    • === OPTIONS APPLIED ===
  7. Hint. Print the deploy command to run next: npx wrangler pages deploy "<OUT_ROOT>" --project-name <name>.

Why the exit codes matter. A staging run that silently produced an incomplete tree would deploy a broken site; a staging run that silently produced an over-limit tree would deploy a site that Cloudflare rejects. The tool therefore fails loudly before upload (exit 2 for size, exit 3 for integrity) rather than letting wrangler discover the problem.

Argument parsing. parseArgs() handles the CLI surface and usage() prints help. The tool is invoked as a Node script (node scripts/stage_pages.mjs …).

4.2 The deploy command

The tool's own final hint is the deploy step:

npx wrangler pages deploy "<OUT_ROOT>" --project-name <name>

Deployment is therefore: stage to a directory → wrangler pages deploy that directory. There is no compile step between the two. The deployed frontend HEAD recorded in the delivery documents is 2d7ae53b482d (docs/FINAL_DELIVERY_TODO.md, release/DOCS_STYLE_GUIDE.md §3).

Superseded-topology note. docs/DEPLOYMENT_ARCHITECTURE.md opens with a superseded-topology banner, and its body still names Railway / HF-Space hosts while the active topology is Render / Codespace (docs/DEPLOYMENT_TOPOLOGY.md). For the frontend specifically, the host is Cloudflare Pages in both readings; the drift concerns the backend hosts, not the static tier.


5. The Analyze console (frontend/mission.html) in depth

The Analyze console is the frontend's centre of gravity. This section documents its markup (every handle), its state machine, its two drivers, and its event rendering.

5.1 Markup and DOM handles

frontend/mission.html is 297 lines. Its <head> carries the live API base:

<meta name="satquery-api-base" content="https://<backend-host>">

That meta tag is the second entry in the API-base resolution order (see §5.4). The page loads three scripts, in order:

<script src="assets/js/core.js"></script>
<script src="assets/js/live.js"></script>
<script src="assets/js/mission.js"></script>

core.js defines the SQ namespace and the mock driver; live.js defines the real HTTP client; mission.js is the page driver that decides which of the two to use. The load order is significant: mission.js runs last because it consumes both.

The console's handles, by region:

Query and run.

Handle Role
#qtext The natural-language query input.
#btnRun The Run button.
#runid Displays the run identifier for the current run.

Observation / upload.

Handle Role
#obsTail The observation status tail. Its initial text content is none — i.e. no asset loaded yet.
#dropZone The drop target for a file.
#fileInput The primary file input (the single observation).
#obsNote The note under the observation widget.

Metadata.

Handle Role
#metaHost Container for the metadata readout.
#mFile Metadata: file name.
#mAcq Metadata: acquisition date (one of the #m* fields inside #metaHost).
#metaEmpty The empty-state placeholder for the metadata block.

Intent panel.

Handle Role
#intentTail Intent status tail.
#intentHost Container for the parsed intent (task, assets, route).
#pairTail Pair status tail (for the two-asset change tasks).
#pairNote Note under the pair widget.
#fileInputT0 The second file input — the t0 (before) image for paired tasks.

Viewer.

Handle Role
#vbtns Viewer mode buttons.
#viewerState Viewer state label.
#plate The plate container.
#plateImg The plate image; its src is assets/img/eo/reservoir-low.jpg.
#ev The evidence overlay layer on the plate.
#evNote Note under the evidence overlay.
#plateCreditLead Plate credit lead-in text.
#plateCredit Plate credit text.

Comparison (paired tasks).

Handle Role
#cmpWrap Comparison wrapper.
#cmpT0 The t0 pane.
#cmpT1 The t1 pane.
#cmpRange The comparison range/slider control.
#cmpCredit Comparison credit.
#cmpEmpty Comparison empty state.

Answer.

Handle Role
#answerHost Container for the rendered answer.
#ansTail Answer status tail.

Evidence.

Handle Role
#evHost Container for the evidence list.
#evEmpty Evidence empty state.
#evTail Evidence status tail.

Confidence.

Handle Role
#confHost Container for the confidence readout.
#confC The confidence value.
#confNote Note under the confidence value.

Provenance.

Handle Role
#provHost Container for provenance.
#pRun Provenance: run id.
#pPolicy Provenance: policy.
#pProtocol Provenance: protocol.
#pSchema Provenance: schema version.

Report and trace.

Handle Role
#btnReport The report button.
#ctrace The trace container.
#traceNow The "now" label on the trace bar.
#trace The trace bar (the element whose width is animated).
#traceNote Note under the trace bar.

Event drawer.

Handle Role
#drawer The event-log drawer.
#evlog The event log list.
#btnClose Close-drawer button.
#btnEvents Open-drawer button.

5.2 The intent panel

The intent panel (#intentHost, #intentTail) is rendered by renderIntent() in frontend/assets/js/mission.js. It shows the interpreted query: which task the router chose, which assets the task requires, and which route (live vs mock) will be taken.

The interpretation itself is interpret() in mission.js — a lexical router that runs in the browser. Its notable features, as read from the file:

  • a change stem /chang/ (no \b word boundary) so "change"/"changed"/"changes" all match;
  • a newAsChange rule so phrasing like "new …" can be read as a change request;
  • a caption regex for caption/describe phrasings.

interpret() is deliberately simple and deterministic. It exists so the console can show the user a reason for the task it is about to run, and so the console can decide which file inputs are relevant. It is not the server-side router: the server has its own deterministic policy planner (see the SERVING.md chapter and core/controller.py). The browser-side interpret() is a UI affordance; the authoritative routing decision is the server's, and the console renders what the server returns.

5.3 Task selection and asset requirements

mission.js maps the interpreted intent onto a server task name via ROUTE_TASK_TO_SERVER. The paired tasks are declared in PAIRED_TASKS:

PAIRED_TASKS = { change, change_vqa, optical_sar }

These three tasks need two assets (a before/after pair), which is why the console has a second file input (#fileInputT0) and a comparison region (#cmpWrap). When a paired task is selected but only one asset is available, the console falls back to a single-asset task via SINGLE_ASSET_FALLBACK = 'vqa'. This is a UI-level fallback: rather than failing the run, the console narrows the request to something one image can answer.

For optical-SAR there is a dedicated precondition check, validateOpticalSar(), because that task has modality-specific requirements. assetsForTask() assembles the asset list the chosen task needs.

5.4 API-base resolution

frontend/assets/js/live.js defines the resolution order for the API base in SQ.live.baseUrl():

  1. window.SATQUERY_API_BASE (a runtime override, useful for testing), then
  2. <meta name="satquery-api-base"> (the page's declared base — on mission.html this is https://<backend-host>), then
  3. the default /api (a same-origin path).

_normalizeBase() normalises trailing slashes, and SQ.live.url() composes the final URL. SQ.ENDPOINTS names the four endpoints the client talks to:

SQ.ENDPOINTS = { assets: '/assets', infer: '/infer', capabilities: '/capabilities', health: '/health' }

With the default /api base these resolve to /api/assets, /api/infer, /api/capabilities, and /api/health. On the deployed configuration the base is the Render orchestrator host, which is the /api/* mirror of the four-endpoint contract (see the SERVING.md chapter).

5.5 Upload widgets

Two file inputs exist: #fileInput (primary) and #fileInputT0 (the before image for paired tasks). Both are wired through handleFile() in mission.js, and both feed SQ.live.uploadAsset() in live.js.

live.js declares the accepted content types:

SQ.CONTENT_TYPES = { tif, tiff, png, jpg, jpeg }

and maps a file to its MIME type via SQ.contentTypeFor(). The upload is a raw-bytes POST with a Content-Type header — not a multipart form. This mirrors the server contract: POST /v1/assets takes the file as the request body with its content type in the header, and POST /v1/analyze takes JSON (multipart is explicitly not implemented — see docs/API_CONTRACT.md §2.4 and the SERVING.md chapter).

uploadAsset() asserts that the response contains an asset_id; uploadAssets() uploads a list sequentially (so the second upload cannot race the first). The returned asset_id is an opaque handle — the client never parses it, it only passes it back. The asset store's TTL and the fact that handles are ephemeral are documented in SERVING.md.

5.6 The observation tail: none → ready

#obsTail starts with text content none. When an asset is uploaded successfully, the tail is updated to a ready state. This is the console's way of making the precondition for a run visible: a query can be typed at any time, but a run that requires an asset cannot produce evidence until an asset is present. The #obsNote field carries the supporting note.

5.7 The Run button, #runid, and #answerHost

Pressing #btnRun calls runQuery() in mission.js. runQuery() decides between the two drivers (§6) and then dispatches. #runid is populated with the run identifier the service returns (run_…); #answerHost receives the rendered answer.

5.8 The trace bar and the eight events

The console's most load-bearing UI element is the trace bar. It is driven entirely by the eight execution events.

The eight event names are declared once, in frontend/assets/js/core.js:

SQ.EVENT_NAMES = [
  'QUERY_RECEIVED',
  'QUERY_UNDERSTOOD',
  'ROUTE_SELECTED',
  'SPECIALIST_STARTED',
  'SPECIALIST_COMPLETED',
  'EVIDENCE_GENERATED',
  'CONFIDENCE_COMPUTED',
  'RESULT_ASSEMBLED'
]

(Declared at core.js:616–620.) These names are the protocol between any driver and the UI. The architecture.html footer's disclosure — that its transmission is driven by the mock event stream — is a statement about which driver feeds these names, not about the names themselves.

The nine UI states. mission.js declares STATES (nine ControllerState values) and maps each event to a state via EVENT_TO_STATE, with per-state explanatory text in STATE_NOTE. Nine states over eight events is not an inconsistency: there is a state for "idle / not started" plus the eight event-driven states.

The fill formula. markState() sets the trace bar width with:

traceFill.style.width = ((traceProgress + 0.5) / STATES.length) * 100 + '%'

With eight events completed against nine states, the final fill is ((8 + 0.5) / 9) × 100 = 94.4444 %. This is why the delivery documents record the trace fill as 94.4444 %: it is the arithmetic consequence of the formula, not a measurement of a rendering. The + 0.5 means the bar advances half a step on entry to each state, so a completed eight-event run lands at 8.5/9 rather than 8/9 or 9/9. The remaining 5.5556 % corresponds to the ninth state, which a completed run does not enter.

buildTrace() constructs the trace bar's segments; logEvent() appends to the event log (#evlog); resetUI() clears the console back to its initial state (including resetting #obsTail to none).

5.9 The event drawer

#drawer is the event log, opened by #btnEvents and closed by #btnClose. #evlog is the list itself. Each event appended by logEvent() records the event type and its payload summary, so a reader can see the full ordered sequence rather than only the current state. The drawer is what makes the "0 mock nodes" / "9 preview nodes" distinction auditable by a human: the live driver's log contains no mock nodes; the preview driver's log contains nine.


6. REAL vs PREVIEW: two drivers, one event seam

mission.js opens with the comment "TWO DRIVERS, ONE EVENT SEAM". That is the whole design: two driver implementations, one ingest() seam, one UI.

6.1 The seam

SQ.run(opts) in core.js owns an ingest() switch (lines ~742–788) that dispatches each of the eight event types to the UI handlers. Any driver that wants to drive the console calls ingest(type, payload); it does not touch the DOM. The console boot sequence builds the engine with engine = SQ.run(...), then calls runMock(QUERY) to paint an initial state, then loadCapabilities() to fetch the service's capability block.

6.2 PREVIEW (runMock)

runMock() is the preview driver. Its properties, as read from mission.js and core.js:

  • It emits empty payloads — the payloads carry the shape of the data but not real values, because there is no real run behind it.
  • It labels the console as a preview (is-mock).
  • It emits nine mock nodes — the event log for a preview run contains nine mock nodes.
  • It drives the trace bar through the same markState() path, so the fill arithmetic is identical.

core.js's startMock() drives the sequence with setTimeout timings, so the preview is animated: each event arrives after a short delay, which is what makes the trace bar and the event drawer move.

What preview does not emit. The preview driver emits no specialist events — i.e. no SPECIALIST_STARTED / SPECIALIST_COMPLETED for a real specialist. This is the honest distinction between the two paths: the preview can show the envelope of a run, but it cannot show a specialist that actually ran, because no specialist ran.

6.3 REAL (runLive)

runLive() is the live driver. Its properties:

  • It makes real HTTP calls via SQ.live (live.js).
  • It sets a liveRun flag.
  • It reads two response headers from SQ.live.infer(): X-SatQuery-State and x-satquery-transport. The state header carries the controller's state (see the nine ControllerState values); the transport header records how the response was carried (the tunnel transport vs a direct/forwarded transport).
  • It translates failures with translateError() and, for upload/inference failures, SQ.live.describeFailure() / LiveError in live.js.
  • A live run shows 0 mock nodes — the event log contains no mock nodes at all.

6.4 Why the 0-vs-9 distinction is the honesty test

The delivery documents record that live validation produced 24 runs (3 passes × 8 cases, 8/8 each) with 0 mock nodes. That number is only meaningful because the preview path does produce mock nodes (nine of them). The console's event drawer therefore lets a reader distinguish, from the UI alone, whether what they are looking at is a real run or a preview. This is the frontend's contribution to the project's truthfulness discipline: the same eight-event vocabulary is used for both, and the drawer is what tells them apart.

6.5 loadCapabilities() and setMode()

loadCapabilities() calls SQ.live.capabilities() (i.e. GET /api/capabilities on the deployed base) and renders the capability block. setMode() switches the console between modes. Because capabilities are fetched live, the console can show which tasks are available right now on the deployed service — which matters because the deployed device is CPU and because some capabilities are gated on artifacts that may be absent (the SERVING.md chapter documents the capability adapter and the five-word vocabulary it emits).

6.6 The test hook

mission.js exposes window.SQ_MISSION as a test hook. It lets an automated harness drive the console (select a task, inject a file, press run) without synthesising DOM events. This is how the live validation runs in the delivery documents were executed against the page.

6.7 translateError()

translateError() maps a service error into human-readable text in the console. It is the frontend half of the error contract: the service returns a machine code and an HTTP status (docs/API_CONTRACT.md §5.1–§5.3; gateway/policy.py _CODE_STATUS), and the console turns that into a sentence a person can act on. The console does not invent codes; it renders the ones it receives. One consequence worth stating: a 422 from the service is not necessarily a validation failure of the user's data — see the G-1 annotation-scope defect in the SERVING.md chapter, where a 422 {"detail":[{"loc":["query","request"]}]} is a server-side bug that masquerades as a client validation error. translateError() will render it as an error; only the backend fix removes it.


7. The captured-run page: "Anatomy of a Run" (run.html)

frontend/run.html renders a real captured ResultEnvelope. This is the page that lets a reader inspect an actual run without running anything.

7.1 The captured envelope

The data lives in frontend/assets/data/anatomy-run.js (329 lines), assigned to window.SATQUERY_ANATOMY_RUN. Its header states the provenance:

  • _source: "Captured live 2026-09-25 … Sanitized".

The fields that matter, all read from the file:

Field Value
run_id run_d124d8b9adea
task grounding
query "Where is the reservoir?"
answer "[grounding] Located 3 candidate region(s) … Highest objectness 0.61."
config_hash 78f1e3700da15aa1
transport tunnel
intent.source forced
plan step_001 grounding, requires_assets
steps 8 steps, RECEIVE → RESPOND
selected_models ViT-B-32 (RemoteCLIP path) → GroundingHead (params=1052677)
evidence 4 items: 3 bounding_box + 1 statistic
regions 3 (region_1cd3973de749, …)
confidence raw 0.5231253252136926 / calibrated 0.5236623182649384 (temperature_scaling)
calibration component temperature: 0.9772731820958189, calibration_samples: 16441.0
timings step_001: 209.873
geospatial 730×730, has_crs false
warnings 2 — no CRS; contradictory spatial claims

7.2 How the page renders it

frontend/assets/js/run.js (380 lines) is the driver. It reads window.SATQUERY_ANATOMY_RUN and exposes the envelope through a set of named views: QUERY, TASK, RUN_ID, MODELS, EVIDENCE, CONF, TIMINGS, GEO, INTENT, PLAN, HASH, PLATE.

  • REGIONS is built from A.regions, so the three captured regions drive the plate overlays.
  • paintAll() loads the real plate image and clears the t0 and diff layers (this run has no before/after pair, so those layers are empty rather than faked).
  • buildEvidence() renders the four evidence items; evCandidates(), evLock(), and evConfirmed() render the three stages of the evidence story (candidates → locked → confirmed).
  • SPECIALISTS_FOR_TASK maps the task to the specialists that would run, so the page can show the specialist panel even though this run's only specialist is grounding.
  • buildLattice() builds the step lattice from the 8 captured steps.
  • DATA is a table of eight key/value views, one per stage, and each entry names the event that corresponds to that stage — i.e. the captured page is wired to the same eight-event vocabulary.
  • setStage(), resetEvidence(), and gotoStep() drive the page as the reader scrolls or uses the keyboard.

7.3 What the page proves, and what it does not

It proves: a real grounding run was captured, sanitized, and shipped with its full envelope — run id, task, query, answer, config hash, transport, intent source, plan, steps, selected models with parameter counts, evidence with types, regions, raw and calibrated confidence with the calibration component and sample count, timings, geospatial facts, and warnings. A reader can verify that the number shown as "confidence" on the page is a calibrated value with a documented temperature and a documented calibration-sample count.

It does not prove: any system-level accuracy. One captured run is one run. Per release/DOCS_STYLE_GUIDE.md §3 there is no end-to-end benchmark, and this page does not create one. The calibrated confidence 0.5236623182649384 is a per-run confidence, not an accuracy.

The two captured warnings are also part of the honest record: has_crs false (the imagery had no coordinate reference system) and "contradictory spatial claims". Both are shown rather than suppressed.


8. Benchmark, Research, and Lab pages

The frontend has a measurement-facing tier whose job is to present numbers with their status.

  • benchmark.html carries the evidence-state legend: VERIFIED / SUPPORTED / UNVERIFIED / BLOCKED / NOT RUN. It notes the measured reliability curve. This page is where the per-artifact results live, and per the style guide each must be stated with its correct qualification: grounding under two protocols (canonical 0.2838 / matched6 0.2566) and two decode variants (head_argmax 0.1215, zero-shot 0.0972) — never one alone; optical-SAR accuracy 0.931 with macro-F1 0.434161, ruling OPEN; change-VQA two test sets (test 0.697626/0.378373 and test2 0.651469/0.372309), ruling OPEN; router 0.965116 = validation, ungated, n = 86, test split NOT RUN; the VLM adapter usable (exact_match 0.963) but ACCEPTANCE-REJECTED.
  • research.html carries the method and caveat material, including the calibration result stated correctly: ECE 0.013755 → 0.014929 — worse.
  • The Lab page. The brief for this chapter names a "Lab" page. frontend/HANDOFF.md §12 gives the file map, and the eleven shipped pages are enumerated in §3 above. A page named "Lab" is not among the eleven HTML files read for this chapter. The nearest things are the Analyze console (mission.html) and the captured-run page (run.html), which are the pages where a reader can "do" or "inspect" work. Whether a page named "Lab" existed at any point and was renamed or dropped is UNKNOWN — not established from the available evidence.

9. The Hugging Face header link (delivery requirement)

Stated separately because it is a delivery requirement with a verification method. See §3.2: the Hugging Face link https://huggingface.co/thundercode/SatQuery and the GitHub link https://github.com/Anish-lab-blip/SatQuery-AI are present in the navigation of all eleven pages, verified by searching every frontend/*.html for both hostnames.


10. Cache-busting behaviour

The frontend uses URL-versioned assets plus header rules to control caching. The header rules live in frontend/_headers (a Cloudflare Pages file), and the versioning is visible in the markup.

10.1 The _headers rules

frontend/_headers declares:

Path pattern Rule
/* Baseline security headers: X-Content-Type-Options, Referrer-Policy, X-Frame-Options, Cross-Origin-Opener-Policy.
/ and /*.html Revalidate.
/assets/video/* max-age=604800 (7 days).
/assets/fonts/* max-age=31536000 (1 year).
/assets/css/* public, max-age=0, must-revalidate.
/assets/js/* public, max-age=0, must-revalidate.
/assets/img/* max-age=604800 (7 days).

10.2 The rule that matters for correctness

JS and CSS are served public, max-age=0, must-revalidate. That is the safe setting for code: the browser may cache a copy but must revalidate before using it. This is what makes a code change take effect without asking users to hard-refresh. Images, fonts, and video get long lifetimes because they are content, not logic — and because a changed image is given a new URL rather than overwriting the old one.

10.3 The delta-growth pair as the worked example

frontend/_headers itself carries a note that the delta-growth image pair was given new URLs when it changed (delta-growth-t0-720 / delta-growth-t1-720, referenced from index.html). That is the correct pattern for a long-cached asset: change the URL, keep the long max-age. The homepage's wipe slider uses that pair, and the "evidence" section uses delta-growth-t2-2075.jpg — a third URL in the same family.


11. Cloudflare platform traps

Two Cloudflare behaviours shape this frontend. Both are recorded because a future maintainer will hit them.

11.1 _headers rules CONCATENATE (they do not override)

This is the single most surprising Cloudflare Pages behaviour in this project, and frontend/_headers documents it verbatim in a comment inside the file. The rule is: when more than one _headers rule matches a path, Cloudflare concatenates the header values rather than letting the more specific rule override the more general one.

The practical consequence: if two rules both set Cache-Control, the client receives two Cache-Control values. Chromium honours the first max-age it sees. So a broad rule that sets max-age=0 and a specific rule that sets max-age=604800 do not "resolve" to the specific one — the client sees both, in order, and takes the first.

docs/FINAL_DELIVERY_TODO.md §1.7 lists this as known blocker item 9. The mitigation, as evidenced by the shipped _headers, is to scope the patterns so that they do not overlap where the value must be exact — i.e. write one rule per asset tree rather than a general rule plus an override. The /assets/js/* and /assets/css/* rules are separate from /assets/img/* precisely so that each tree has exactly one matching rule and there is nothing to concatenate.

11.2 The 308 .html → extensionless redirect

Cloudflare Pages issues a 308 redirect from a path that ends in .html to the extensionless path: a request for /run.html redirects to /run. A 308 preserves the method (unlike 301/302 in some clients), so a POST is not silently turned into a GET, but the redirect still happens and the final URL differs from the requested one.

The second, related trap is the trailing-slash 307: Starlette's redirect_slashes behaviour issues a 307 when a request's trailing slash does not match the route. This is documented for the API in docs/API_CONTRACT.md §5.1 as a footgun, and it matters to the frontend because the frontend is the caller: SQ.live.url() and _normalizeBase() exist partly to make the client's URL composition predictable so that the client is not relying on a redirect to reach an endpoint.

Both traps share a lesson: the frontend must link to the canonical URL. A page that links to /run (extensionless) never triggers the 308; a page that links to /run.html does.


12. Accessibility and UX caveats

This section states what can be established from the files read, and marks the rest.

12.1 What is established

  • Keyboard driving exists on the captured-run page. run.js supports keyboard input to move between steps (gotoStep() plus key handling), so run.html is operable without a mouse.
  • Reduced-motion and focus styling are governed by the token system in frontend/HANDOFF.md §2 (CSS custom properties on :root). The handoff document is the design authority for the token layer.
  • The event drawer is a named, focusable pair of controls (#btnEvents / #btnClose) with a labelled region (#drawer → #evlog), so the event log is not hover-only.
  • The upload widgets are real <input type="file"> elements (#fileInput, #fileInputT0), which are natively keyboard- and screen-reader-operable, and they are paired with a #dropZone for pointer drag-and-drop. Drag-and-drop is an addition to the file input, not a replacement.

12.2 What is not established

  • A formal accessibility audit (axe / Lighthouse / WCAG conformance level) has not been performed: UNKNOWN — not established from the available evidence.
  • Contrast ratios for the token palette: UNKNOWN — not established from the available evidence.
  • Screen-reader behaviour of the trace bar's animated width (whether a live region announces each state transition): UNKNOWN — not established from the available evidence. The trace bar is a visual affordance driven by markState(); whether its state changes are announced is not determinable from the code read.
  • Mobile/responsive breakpoints beyond what the CSS declares: UNKNOWN — not established from the available evidence.
  • The 404 page's page count is stale: 404.html says "Ten pages exist" and links five, while eleven ship. This is drift, recorded here and not silently repaired.

13. Documentation drift recorded (not propagated as current truth)

Per the project's practice (mirrored from P10-T02), drift found during this chapter's research is recorded honestly rather than smoothed over:

Location Stale claim Correct current statement
frontend/_headers comment "100% static, zero network calls" Ten of eleven pages are hermetic; mission.html calls the live service.
frontend/404.html prose "Ten pages exist" (links five) Eleven pages ship.
frontend/mission.html / _headers relationship (implicit) The live API base is declared in <meta name="satquery-api-base">, which is a live dependency the hermeticity audit must be read as exempting.

None of these is a code defect; each is a documentation statement inside a shipped file that no longer matches the tree. They are listed so a reader is not misled by them.


14. What the frontend does NOT do

Stated explicitly, because the depth of §5–§7 could otherwise imply more capability than exists:

  • No framework and no build step for application code. Pages are hand-written HTML plus ES modules; scripts/stage_pages.mjs copies, it does not compile.
  • No client-side model inference. The browser never runs a model. All inference happens on the service (POST /api/infer → the tunnel → the inference service).
  • No multipart upload. Uploads are raw-bytes POSTs with a Content-Type header, matching the server contract (docs/API_CONTRACT.md §2.4: multipart is not implemented).
  • No streaming. There is no server-sent-events or websocket channel. The eight events are client-side UI states; on a live run they are derived from the single inference response (plus the two response headers X-SatQuery-State and x-satquery-transport), not pushed from the server. (See the SERVING.md chapter: the service does not stream.)
  • No authentication UI. The service has no auth (docs/API_CONTRACT.md §7), so there is no login.
  • No persistence of runs. Nothing in the frontend stores a run; the console's state is in-memory, and the captured-run page reads a static data file.
  • No offline mode beyond the fact that ten pages need no network.

4.3 The staging tool in detail: closure algorithm and report format

This subsection expands §4.1 because the staging tool is the only build-like step in the frontend and its behaviour determines what ships.

4.3.1 Why a closure walk instead of "copy the directory"

Copying frontend/ wholesale would ship unreferenced assets: draft images, superseded JS, experiment files. A closure walk ships exactly the transitive set of files reachable from the eleven seed pages. The consequences are worth stating precisely:

  • Adding a page is a deliberate act. Because the seed set is frontend/*.html (top level only), a page placed in a subdirectory is not a seed. It ships only if a seed page references it.
  • Removing a reference removes a file from the deploy. If the last page that used assets/img/eo/old.jpg stops referencing it, that image silently stops shipping. This is a feature (smaller tree) and a hazard (an asset can disappear without an error) — which is exactly why the tool prints the staged tree and the integrity report, so the disappearance is visible in the build log rather than only in production.
  • Query strings and hashes are normalised away. stripQueryHash() means app.js?v=3 and app.js are the same edge, so versioned references do not create phantom files.
  • External URLs are not followed. isExternal() stops the walk at https://… and //…, which is why the external-dependency audit can report HERMETIC: any external URL that was followed would show up as a network dependency.
  • References cannot escape the root. insideFrontend() rejects a resolved path that leaves frontend/, so a stray ../../secret reference cannot pull a file from outside the tree.

4.3.2 The four report blocks

The tool prints four blocks. Reading them in order answers the four questions a deployer has.

  1. === STAGED TREE === — what will be uploaded? A listing of every file copied into the output root, with sizes. Files over BIG_WARN_BYTES (10 MiB) are flagged.
  2. === REFERENCE INTEGRITY === — is the closure complete? The staged tree is re-walked and every reference must resolve inside it. A dangling reference fails with exit code 3. This is the check that catches the case where a file was referenced but not copied (e.g. because of a case-sensitivity difference between the developer's filesystem and Linux).
  3. === EXTERNAL DEPENDENCY AUDIT === — is the site hermetic? External URLs found in the closure are listed. When the list is empty the block prints HERMETIC. This is the check that keeps the "ten of eleven pages are hermetic" claim honest: if a page gained a CDN script, the audit would stop printing HERMETIC.
  4. === OPTIONS APPLIED === — what flags were used? The effective options, so a build log is self-describing.

4.3.3 The two hard gates and their exit codes

Condition Exit code Why it is fatal
Any file exceeds PAGES_FILE_LIMIT (25 MiB) 2 Cloudflare Pages rejects a file over the limit; deploying would fail after upload. Failing before upload is cheaper and clearer.
Staged tree fails reference-integrity re-walk 3 A dangling reference means a broken page in production.

The deliberate design choice is fail before upload. Both gates run locally, on the staged tree, before wrangler is invoked. A non-zero exit stops a shell pipeline (&&) before the deploy command can run.

4.3.4 The deploy hint

The last thing the tool prints is the command to run:

npx wrangler pages deploy "<OUT_ROOT>" --project-name <name>

Note that the tool does not run the deploy itself. Staging and deploying are separate steps, which means a human (or CI) can inspect the staged tree between them. This is consistent with the project's general posture: make the artifact inspectable before it is published.


5.10 The nine console states

mission.js declares nine ControllerState values in STATES, an EVENT_TO_STATE map from the eight event names onto those states, and a STATE_NOTE table of human-readable text per state. markState() is the single function that advances the UI from one state to the next, and it is the only place the trace-bar width is written.

The relationship between the nine states and the eight events is:

  • One state is the idle / pre-run state — the state the console is in before QUERY_RECEIVED. resetUI() returns the console to it (and resets #obsTail to none).
  • The other eight states are entered by the eight events, in order. EVENT_TO_STATE is the mapping, so the console's state names and the protocol's event names are kept in one place rather than duplicated across if branches.

STATE_NOTE gives each state a sentence, which is what #traceNow and #traceNote display while the run progresses. The point of the separate state text is that the event name is protocol (SPECIALIST_STARTED) while the state text is human ("running the grounding specialist"). The console shows both: the event name in the drawer's log, the state text in the trace region.

5.10.1 Why the fill formula uses (traceProgress + 0.5) / STATES.length

The formula is:

traceFill.style.width = ((traceProgress + 0.5) / STATES.length) * 100 + '%'

Three observations about it:

  1. STATES.length is 9, not 8. The denominator is the number of states, which includes the idle state. So the maximum reachable fill from events alone is (8 + 0.5) / 9 = 94.4444 %.
  2. The + 0.5 is a half-step lead. Entering state n shows the bar at (n + 0.5)/9, i.e. the midpoint of that state's band. The bar therefore never sits exactly on a boundary, which reads better visually and means the bar is always "inside" a labelled state.
  3. The remaining 5.5556 % is the idle state's band. A completed run does not enter idle, so a completed run does not fill the bar. This is the arithmetic origin of the 94.4444 % figure the delivery documents record.

Stated as a status: the formula is IMPLEMENTED; the 94.4444 % figure is MEASURED as the arithmetic consequence of the formula against nine states, and it is corroborated by the live validation runs recorded in the delivery documents. It is not a claim about anything else.

5.10.2 onEvent() — the single funnel

onEvent() is the console's event handler: every event delivered through the ingest() seam passes through it. It is responsible for

  • appending to the event log via logEvent() (which writes to #evlog in the drawer),
  • advancing the state via markState() (which writes the trace bar),
  • routing the payload to the appropriate renderer (renderEvidence(), renderConfidence(), renderIntent(), the answer renderer into #answerHost, and the provenance writers into #pRun / #pPolicy / #pProtocol / #pSchema).

Having a single funnel is what makes the REAL/PREVIEW distinction safe: both drivers call the same onEvent(), so the rendering path is identical and only the payload source differs. It is also why the "0 mock nodes vs 9 mock nodes" property is checkable at one place — the drawer's contents are produced by one function.


5.11 The viewer and comparison regions

5.11.1 The viewer

The viewer is the plate at the top of the console's results area. Handles: #vbtns (mode buttons), #viewerState (state label), #plate (container), #plateImg (the image, src initially assets/img/eo/reservoir-low.jpg), #ev (evidence overlay), #evNote (overlay note), #plateCreditLead and #plateCredit (attribution).

The initial src is a real EO image (reservoir-low.jpg), not a synthetic one. That matters for honesty: before any run, the console shows a real image with a credit, so a visitor is never looking at invented imagery while the console is idle.

#vbtns selects a viewer mode and #viewerState names it. The evidence overlay #ev is where the grounding result's regions are drawn — for the captured grounding run there are three regions (region_1cd3973de749, …), which is why the overlay is a layer separate from the plate image rather than something painted into the image.

5.11.2 The comparison region

For paired tasks (change, change_vqa, optical_sar), the console shows a comparison region: #cmpWrap (wrapper), #cmpT0 (before pane), #cmpT1 (after pane), #cmpRange (the range/slider control), #cmpCredit (attribution), #cmpEmpty (empty state).

The two panes are fed from the two upload widgets (#fileInput for t1, #fileInputT0 for t0). When only one asset is available for a paired task, SINGLE_ASSET_FALLBACK = 'vqa' narrows the request so the console can still produce an answer instead of failing. #cmpEmpty is the state shown when there is nothing to compare.

The homepage uses the same visual idiom for its delta-growth wipe slider, which is a nice consistency: the idea of "two dates, one place, slide to compare" appears both as a landing-page illustration and as a functional control in the console.


5.12 Provenance and report controls

5.12.1 The provenance block

#provHost contains four fields that together answer "what exactly produced this?":

Handle Field Meaning
#pRun run id The run_… identifier.
#pPolicy policy The routing/planning policy that produced the plan.
#pProtocol protocol The protocol under which the result was produced.
#pSchema schema The schema version of the response.

The reason a provenance block is worth four fields: the project's measurement discipline depends on being able to say which protocol a number came from. The style guide's grounding rule — that grounding was measured under two protocols (canonical 0.2838 / matched6 0.2566) and two decode variants (head_argmax 0.1215, zero-shot 0.0972) — is exactly the kind of fact that a protocol field exists to disambiguate. A result rendered without its protocol is a result that cannot be compared to anything.

5.12.2 The report button and the event drawer

#btnReport triggers the console's report action. #btnEvents opens #drawer, whose #evlog contains the ordered event log; #btnClose closes it. The drawer is the console's audit surface: it is the one place where a reader can count events and check for mock nodes.


5.13 The mock data model inside core.js

core.js (1029 lines) is not only the event seam; it is also the source of everything the preview driver draws. Its internals, as read:

Utilities. SQ.util provides rnd (random), rng (a seeded random-number generator — which is what makes the preview deterministic across reloads), pad, and ms (formatting).

Raster synthesis. The synthetic imagery path:

Symbol Role
BIOMES The biome definitions the synthesised terrain is drawn from.
CANON {w: 900, h: 600} — the canonical raster size.
buildMasks() Builds the masks (land/water/etc.) the raster is composed from.
fbm Fractal Brownian motion — the noise function that gives the terrain texture.
SQ.scene Produces a scene; it sets a synthetic: true flag and fills placeholder gsd, aoi, and dates.

The synthetic: true flag is the load-bearing honesty mechanism: synthetic imagery is labelled as synthetic in the data, so any renderer can disclose it. The placeholder gsd (ground sample distance), aoi (area of interest), and dates are placeholders, not measurements — and the flag says so.

Imagery. SQ.imagery resolves which image to show.

Stages. SQ.STAGES is the eight-stage list from QUERY through ANSWER. This is the narrative stage list (what a human sees), distinct from the eight event names (the protocol). The two are aligned but not identical: SQ.STAGES is the visual progression; SQ.EVENT_NAMES is the wire vocabulary.

The deterministic policy. SQ.policy() is a deterministic router used by the preview. Its documented quirks: a where-first fix (a query containing "where" is routed to grounding before other rules are considered), the removal of a built keyword, and the newAsChange rule. Because it is deterministic and seeded, the same query produces the same preview every time — which is what makes the preview useful as a UI demo and useless as a measurement.

Answer material. SQ.ANSWER_BANK supplies canned answers for the preview; SQ.COMPONENTS lists seven components with their model strings, which is what the preview's model panel shows.

Shared components. SQ.frame, SQ.reliabilityPlot, and SQ.chip are reusable renderers. The SQ.reliabilityPlot is the component that draws the reliability curve referenced on benchmark.html.

The run engine. SQ.run(opts) is the engine; its ingest() switch (lines ~742–788) dispatches the eight event types to handlers. startMock() drives a preview run by calling ingest() on a schedule of setTimeout delays.

Honesty note on SQ.policy() vs the server router. The browser's SQ.policy() and mission.js's interpret() are UI-side interpretations. The authoritative router is server-side (core/controller.py, core/registry.py). The preview's routing can therefore differ from what the server would do, and that is acceptable precisely because the preview is labelled a preview and emits no specialist events. A reader must not read SQ.policy() as the routing specification.


6.8 live.js API surface reference

frontend/assets/js/live.js is 392 lines. Its header documents the end-to-end flow and three design rules. The module's public surface, as read:

Symbol Kind Behaviour
SQ.ENDPOINTS const { assets: '/assets', infer: '/infer', capabilities: '/capabilities', health: '/health' }.
SQ.CONTENT_TYPES const { tif, tiff, png, jpg, jpeg } — the accepted upload types.
SQ.contentTypeFor(file) fn Maps a file to its MIME type; used to set the upload Content-Type.
SQ.live.baseUrl() fn Resolution order: window.SATQUERY_API_BASE → <meta name="satquery-api-base"> → /api.
_normalizeBase(base) fn (internal) Normalises the base (trailing slashes).
SQ.live.url(endpoint) fn Composes the final URL from the normalised base and an endpoint.
LiveError class The client's error type, carrying enough detail for describeFailure().
describeFailure(err) fn Turns a LiveError into human-readable text.
SQ.live.uploadAsset(file) fn Raw-bytes POST with Content-Type; asserts the response contains asset_id.
SQ.live.uploadAssets(files) fn Uploads a list sequentially (no parallel uploads).
SQ.live.infer(request) fn POSTs the analysis request; reads X-SatQuery-State and x-satquery-transport from the response.
SQ.live.run(opts) fn Composes upload + infer into one run.
SQ.live.capabilities() fn GET the capability block (used by loadCapabilities()).

6.8.1 The three design rules (as stated in the file's header)

The file's header states three rules that govern the client. They are worth restating because they explain several behaviours that would otherwise look arbitrary:

  1. Raw bytes, not multipart. The upload is a body-with-content-type POST because that is what the service accepts (docs/API_CONTRACT.md §2.4: multipart is not implemented). A client that sent multipart would be rejected.
  2. Sequential uploads. uploadAssets() uploads one at a time because the server's asset store is a small ephemeral store with a file cap (SERVING.md: default _asset_max_files() = 32), and because a paired task's second upload depends on the first succeeding. Parallel uploads would make partial failure harder to reason about.
  3. The asset handle is opaque. The client asserts the handle exists and passes it back unexamined. The handle's shape (asset_<32 hex>) and its TTL are server facts; the client must not depend on either.

6.8.2 The two response headers

SQ.live.infer() reads two custom headers:

Header Meaning
X-SatQuery-State The controller state for the response (the same vocabulary as the console's STATES).
x-satquery-transport How the response was carried (the captured envelope records transport: "tunnel").

These two headers are how the console can display a state and a transport without a streaming channel. They are the reason the console can show a live run's progress truthfully: the state and the transport come from the server's own response, not from a client-side guess.


7.4 The captured envelope, field by field

This subsection expands §7.1 into a complete inventory, because the captured envelope is the frontend's single richest piece of real data and a reader should be able to reconstruct it.

Provenance and identity.

Field Value Note
_source "Captured live 2026-09-25 … Sanitized" The capture date and the fact that the payload was sanitized before shipping.
run_id run_d124d8b9adea The run identifier.
config_hash 78f1e3700da15aa1 The frozen config hash — the same value recorded in the style guide §3.

Request.

Field Value
query "Where is the reservoir?"
task grounding
intent.source forced (the task was forced rather than inferred).
transport tunnel

Plan.

Field Value
plan step_001, task grounding, requires_assets
steps 8 steps, RECEIVE → RESPOND
timings.step_001 209.873 (ms)

Models.

Field Value
selected_models ViT-B-32 (the RemoteCLIP path) → GroundingHead
GroundingHead params 1052677

Result.

Field Value
answer "[grounding] Located 3 candidate region(s) … Highest objectness 0.61."
evidence 4 items: 3 × bounding_box, 1 × statistic
regions 3, e.g. region_1cd3973de749

Confidence.

Field Value
raw 0.5231253252136926
calibrated 0.5236623182649384
method temperature_scaling
temperature 0.9772731820958189
calibration_samples 16441.0

The calibration_samples value 16441.0 is the size of the validation set the temperature was fitted on; docs/API_CONTRACT.md §4 records the same figure as 16,441 Val rows. The temperature 0.9772731820958189 is also recorded in docs/API_CONTRACT.md §4. This is a real cross-check: the number on the public page matches the number in the API contract.

Geospatial and warnings.

Field Value
geospatial 730 × 730, has_crs false
warnings 2 — no CRS; contradictory spatial claims

The presence of the warnings in the shipped envelope is itself a design statement: the capture was not cleaned up to look better than it was.

Why this page is important to the release. It is the one place where a reader can see a complete, real, sanitized result envelope — including its imperfections — rendered by the same event vocabulary the live console uses. It is a sample of one, and the page does not present it as more than that.


11.3 A worked path through the platform traps

The following Mermaid diagram shows where the two traps (§11.1, §11.2) bite. It is a description of the behaviours documented in frontend/_headers, docs/API_CONTRACT.md §5.1, and the Cloudflare redirect behaviour recorded in the delivery documents — not a measurement.

flowchart TD
    A["Browser requests /run.html"] --> B{"Cloudflare Pages"}
    B -->|"308 (method preserved)"| C["/run"]
    C --> D["run.html served from the staged tree"]

    E["Browser loads the page"] --> F{"Assets referenced"}
    F -->|"/assets/js/run.js"| G["Rule: /assets/js/*"]
    F -->|"/assets/img/…"| H["Rule: /assets/img/*"]
    G --> I["Cache-Control: public, max-age=0, must-revalidate"]
    H --> J["Cache-Control: max-age=604800"]

    K["If two rules matched one path"] --> L["Values CONCATENATE"]
    L --> M["Chromium honours the FIRST max-age"]
    M --> N["Mitigation: scope patterns so they do not overlap"]

Read together, the traps say: link to the canonical extensionless URL (so the 308 never fires for an internal navigation) and give each asset tree exactly one matching _headers rule (so there is nothing to concatenate).

The third, API-side trap — the Starlette trailing-slash 307 documented in docs/API_CONTRACT.md §5.1 — is the client's concern rather than the static tier's: _normalizeBase() and SQ.live.url() exist so the client composes a URL that matches the route exactly, rather than relying on a redirect to reach it.


14.1 What the frontend is a client of

Because this chapter documents a client, it is worth stating precisely what contract the client is written against, so a reader can follow the thread into the SERVING.md chapter.

  • The client posts raw bytes to /api/assets and receives an opaque asset_id (docs/API_CONTRACT.md §2.5).
  • The client posts JSON to /api/infer (docs/API_CONTRACT.md §2.4) and receives a ResultEnvelope.
  • The client reads GET /api/capabilities (docs/API_CONTRACT.md §2.2) to know which tasks are available now.
  • The client may read GET /api/health (docs/API_CONTRACT.md §2.1) for the service's health block.
  • The client reads two custom response headers (X-SatQuery-State, x-satquery-transport).
  • The client renders errors from the service's machine codes (docs/API_CONTRACT.md §5.2 — a 23-code taxonomy in core/errors.py, plus the gateway-origin rate_limited, mapped by gateway/policy.py _CODE_STATUS).
  • The client sends no credentials; the service has no auth (docs/API_CONTRACT.md §7). CORS is configured on the orchestrator (deploy/render/main.py _PRODUCTION_ORIGINS includes the Pages origin).

Every one of those six interactions is documented from the server side in the SERVING.md chapter, which is the other half of this pair.


15. Status summary

Subsystem Status
Static tier (11 pages, CSS, JS modules, assets) IMPLEMENTED
Staging tool scripts/stage_pages.mjs (closure, size gate, integrity gate, hermeticity report) IMPLEMENTED
Deploy path (wrangler pages deploy of the staged tree) IMPLEMENTED; deployed HEAD 2d7ae53b482d
Analyze console (mission.html + mission.js + live.js + core.js) IMPLEMENTED
Eight-event protocol + trace bar (94.4444 % fill) IMPLEMENTED; fill MEASURED as the arithmetic consequence of the formula
PREVIEW driver (runMock, 9 mock nodes, no specialist events) IMPLEMENTED
REAL driver (runLive, real HTTP, 0 mock nodes) IMPLEMENTED; exercised in the 24-run live validation recorded in the delivery docs
Captured-run page (run.html over anatomy-run.js) IMPLEMENTED; data is a real sanitized capture (run_d124d8b9adea)
Hugging Face + GitHub header links on all 11 pages VERIFIED by search across frontend/*.html
Cache-busting (_headers rules + URL versioning) IMPLEMENTED
_headers concatenation trap KNOWN (blocker item 9 in docs/FINAL_DELIVERY_TODO.md §1.7); mitigated by non-overlapping patterns
Accessibility audit NOT RUN

16. NOT RUN / OPEN / BLOCKED (frontend)

Per release/DOCS_STYLE_GUIDE.md §4, every doc ends with this list.

NOT RUN

  • No formal accessibility audit (axe / Lighthouse / WCAG conformance level).
  • No measured contrast-ratio audit of the token palette.
  • No screen-reader behaviour verification for the trace bar's state transitions.
  • No responsive-breakpoint verification beyond the CSS as written.
  • No end-to-end benchmark of the system (this is project-wide, per release/DOCS_STYLE_GUIDE.md §3 — it is not a frontend gap, it is a project-level fact that the frontend must not contradict).

OPEN

  • frontend/404.html prose says "Ten pages exist" while eleven ship — documentation drift, OPEN.
  • The _headers concatenation behaviour remains a known platform trap (blocker item 9); the shipped rules avoid overlap, but the underlying platform behaviour is unchanged and OPEN as a hazard.
  • A page named "Lab" is not among the eleven shipped pages; whether it existed is UNKNOWN — not established from the available evidence.
  • Accessibility conformance level: UNKNOWN — not established from the available evidence.

BLOCKED

  • Nothing in the frontend is blocked. The frontend's live path depends on the backend, and the backend's own blockers (e.g. B-07, tunnel gaps; patch prepared, NOT deployed) are recorded in the SERVING.md chapter and the delivery documents. A backend blocker surfaces in the console only as an error rendered by translateError().

17. Where the evidence lives

Claim area Evidence file(s)
Page inventory, purposes, data-view, section structure frontend/*.html (11 files, each read)
Homepage structure, video chapters, delta pair, open-question links frontend/index.html
Analyze console markup and every DOM handle frontend/mission.html
Console driver, interpret(), chooseTask(), assetsForTask(), validateOpticalSar(), translateError(), routeSpecialists(), renderIntent(), STATES/EVENT_TO_STATE/STATE_NOTE, markState() (fill formula), buildTrace(), logEvent(), resetUI(), renderEvidence(), renderConfidence(), onEvent(), runMock(), runLive(), runQuery(), loadCapabilities(), setMode(), handleFile(), window.SQ_MISSION frontend/assets/js/mission.js
SQ namespace, rng/util, synthetic scene flag, SQ.STAGES, SQ.EVENT_NAMES, SQ.policy(), SQ.run().ingest(), startMock(), SQ.COMPONENTS frontend/assets/js/core.js
Live client: SQ.ENDPOINTS, SQ.CONTENT_TYPES, SQ.contentTypeFor(), SQ.live.baseUrl(), _normalizeBase(), SQ.live.url(), LiveError, describeFailure(), uploadAsset(), uploadAssets(), infer() (header reads), run(), capabilities() frontend/assets/js/live.js
Captured-run driver: QUERY…PLATE, REGIONS, paintAll(), buildEvidence(), evCandidates()/evLock()/evConfirmed(), SPECIALISTS_FOR_TASK, buildLattice(), DATA (8 KV tables with event names), setStage(), resetEvidence(), gotoStep() frontend/assets/js/run.js
Captured envelope (run id, task, answer, config hash, transport, plan, steps, models, evidence, regions, confidence + calibration, timings, geospatial, warnings) frontend/assets/data/anatomy-run.js
Cache rules and the concatenation trap (verbatim comment) frontend/_headers
Design law, token system, phases A–G, file map, hard limits, schema types, ingest seam, 8 event names frontend/HANDOFF.md
Staging tool: constants, regexes, closure walk, exit codes 2/3, report blocks, HERMETIC, deploy hint scripts/stage_pages.mjs
Deployed frontend HEAD 2d7ae53b482d; HF link on all 11 pages; blocker item 9 docs/FINAL_DELIVERY_TODO.md
Live topology and per-component responsibilities docs/DEPLOYMENT_TOPOLOGY.md
Superseded-topology banner; entrypoint requirements; failure-mode table docs/DEPLOYMENT_ARCHITECTURE.md
API contract the client speaks (endpoints, enums, confidence, errors, no-auth, CORS, multipart-not-implemented, 307 footgun, 23-code taxonomy) docs/API_CONTRACT.md
Captured run ids per task; metrics table; blockers docs/FINAL_DELIVERY_REPORT.md
Style, grounding rules, status vocabulary, facts-that-must-not-be-wrong, 94.4444 % trace fill release/DOCS_STYLE_GUIDE.md