Boot Indicator

The wordmark and 'Starting…' the mobile app paints from markup, before any code runs — and the failure notice for when the bundle itself cannot load.

The first painted frame is no longer blank: index.html paints a PraisonAI wordmark and Starting… from markup, and a guard reveals a failure notice when the bundle cannot load.

Diagram

Cold Start

loads & mounts

fails to load / throws

📱 Launcher tap

🎨 index.html paints boot indicator

⬇️ app.js in flight

✅ mount clears #root, appends chat

⚠️ boot-guard reveals failure notice

Quick Start

  1. See the boot indicator

    index.html paints the indicator inside #root on the first frame, before app.js runs.

    html
    <div class="boot" role="status">
      <div class="boot-mark">PraisonAI</div>
      <p class="boot-note">Starting…</p>
    </div>
  2. Load the guard before the bundle

    A classic boot-guard.js loads before the deferred app.js module, so its error listener is installed while the fetch is in flight.

    html
    <script src="./boot-guard.js"></script>
    <script type="module" src="./app.js"></script>
  3. Let the app retire the indicator

    mount() clears #root and appends the chat in the same statement pair, so the indicator is gone the instant the app paints.

    ts
    root.textContent = "";
    root.append(screen);

How It Works

boot-guard.js installs its error listener in the capture phase, then either steps aside when the app mounts or reveals the failure notice when the bundle cannot load.

Diagram
crash.tsapp.jsboot-guard.jsHTML ParserUsercrash.tsapp.jsboot-guard.jsHTML ParserUsernever installed (mount() never ran)launch → GET /paintrun (classic, before deferred module)addEventListener(“error”, …, capture=true)fetch app.js (deferred module)fetch fails / throws on entryhide .boot-note, reveal .boot-failed
StageWhat runsResult
Parseindex.html markupWordmark + Starting… painted
Guardboot-guard.js (classic)error listener armed in capture phase
Loadapp.js (deferred module)Mounts → indicator cleared
Failapp.js refused / throws.boot-failed notice revealed

What you see

A wordmark and one sentence tell the user the app is launching, not broken.

On a slow cold start the user sees PraisonAI and Starting…. If the bundle is refused, the note is replaced by: “PraisonAI could not start: its code could not be loaded. Close the app and open it again.” The whole point is that a slow launch can no longer be mistaken for a broken install.


Why it is not a skeleton

A skeleton would claim a layout the boot frame cannot yet know.

A fake topbar or grey message bubbles duplicate a view they have no state for. The boot indicator paints one wordmark and one sentence — nothing that pretends to be a real view.


Why it is static

A spinner would assert liveness nothing can make before app.js runs.

There is no download-progress event on <script>, and no way to ask how much of a module has parsed. The compositor keeps animating even when the main thread is dead — a moving spinner during a hung boot is a lie, so the indicator stays still.


How removal works

mount() clears and appends in the same statement pair, so there is no frame with both the indicator and the app, and none with neither.

ts
root.textContent = ""; // retires the boot indicator
root.append(screen);   // appends the chat screen

renderFatal clears the same way. On a boot fast enough for the first presented frame to already contain the app, the indicator is gone before it is ever painted.


Theme awareness

Every colour is a :root token, so a dark device gets a dark boot screen that hands over to a dark app.

--ground, --ink, --soft, and --bad drive the boot rules, so prefers-color-scheme: dark swaps the boot screen along with the app.


Safe-area insets

The .boot container pads with --inset-, falling back to env(safe-area-inset-), because nothing has run yet to write the shell’s real numbers.

This is why a landscape viewport or large font scale does not push the wordmark into a status bar or camera cutout.


The failure notice

boot-guard.js speaks only for a bundle that cannot load — every degraded-page failure is ignored.

Event targetActed on?Why
window (thrown exception)✅app.js parsed and ran but threw before mount() installed crash.ts
<script src="…/app.js"> failing✅The bundle itself couldn’t load
<script src="…/register-sw.js"> failing❌Worker declining to register is a degraded page, not a broken app
<script src="…/boot-guard.js"> failing❌Precached; and would be pointing the wrong way
<link rel="stylesheet"> failing❌Degraded page
<img> (icon) failing❌Degraded page
Any post-mount() error❌crash.ts owns it — the guard speaks only while the boot indicator is on screen

Resource errors do not bubble, so the guard listens for error in the capture phase.

js
window.addEventListener("error", handler, true);

Common Patterns

The guard hands off to crash.ts cleanly: once mount() runs, the boot indicator is gone and any later error belongs to the crash handler.

ts
// mount() — the same clear retires the indicator and the guard's job.
root.textContent = "";
root.append(screen);

A failed bundle never reaches crash.ts, because mount() never ran to install it — so the guard is the only thing that can speak.

js
// boot-guard.js — reveal the failure notice, retire "Starting…".
note.hidden = true;
failed.hidden = false;

Best Practices

Load the guard before the bundle, undeferred

<script type=module> is implicitly deferred. Put boot-guard.js first as a classic script so its error listener is armed before app.js is even fetched — a service worker can answer instantly and a late listener would miss the failure.

Listen in the capture phase

Resource error events do not bubble. addEventListener("error", handler, true) is the only way to hear a <script src> fail.

Ignore degraded-page failures

A stylesheet, an icon, or register-sw.js declining is a degraded page, not a broken app. Only app.js failing — or a throw whose target is window — reveals the failure notice.

Keep every boot colour a token

Drive the boot screen from :root tokens so a dark device gets a dark boot. A hardcoded colour becomes a flash at handoff.

Clear and append together

Retire the indicator with the same statement pair that appends the app, so no frame shows both or neither.


What this does not cover

The pre-WebView time is unreachable from the web layer.

The gap from am start to navigationStart is Android window setup, not something a painted frame can shorten — only a native window background or a splash on MainActivity‘s theme could cover it. The boot indicator owns everything from the first web frame onward, and nothing before it.


Related