Launch interstitial pattern: inline splash in index.html
Applies to: ionic_frontend/index.html, ionic_frontend/src/main.ts (mount sequence), and anyone touching the native splash assets (android/app/src/main/res/drawable*/splash.png, ios/App/App/Assets.xcassets/Splash.imageset/).
Version: v4.4.6 · Last updated: 2026-08-21 (OBJ-2774)
Why this exists
On mobile cold launch, the OS-native splash screen dismisses the moment the WebView is ready — well before Vue actually mounts. main.ts's bootstrap() awaits a chain of async work first (the dynamic App.vue chunk fetch, router.isReady(), initializeNetworkStatus(), syncStore.hydrate()), and until app.mount('#app') runs, #app was a bare, unstyled <div> with no background — rendering WebView-default white. That gap (~0.5s) was a jarring blank-white flash between the branded native splash and the branded app (OBJ-1672).
How it works
index.html ships a branded interstitial inline — a scoped <style> block in <head> plus markup directly inside <div id="app">:
<div id="app">
<div class="launch-splash" role="status" aria-live="polite" data-testid="launch-splash">
<svg viewBox="0 0 100 100" aria-hidden="true">...</svg>
<span class="sr-only">Loading Objectuve</span>
</div>
</div>This paints on the WebView's first paint, with zero external dependency — no Vue component, no external CSS/JS request. As of OBJ-2774 it does carry a small inline <script> (see below) that hand-ports ObjectuveLogo.vue's sweep geometry, since the interstitial can't import that component before /src/main.ts loads. html/body also get a themed background (light #f8fafc, dark #0b111e via prefers-color-scheme) so there's no white sub-frame around the interstitial either.
No teardown wiring is needed. Vue's app.mount('#app') (main.ts) replaces #app's entire innerHTML on mount, which removes the interstitial markup automatically. The bootstrap-error fallback (main.ts's catch block, appEl.innerHTML = ...) does the same — it overwrites #app regardless of whether the interstitial or the real app was there, so a forced boot failure still shows the error card, not the splash.
The inline-only constraint
Because the interstitial must paint before any bundled JS runs, everything in it — background, logo, animation — has to be static HTML/CSS/inline-SVG plus, as of OBJ-2774, a small inline <script> with zero imports (it can't reference any Vue component; it's a from-scratch hand-port, not a simplified approximation). It cannot render a Vue component directly (ObjectuveLogo.vue, LoadingAnimation.vue are not usable here) and it carries no visible text (web fonts aren't loaded at first paint, so a text string would FOIT/flash in). The data-testid="launch-splash" hook exists specifically so tests can assert on its presence/removal without depending on styling.
This constraint is what makes the animation below load-bearing: the mark doesn't just have to look right, it has to be re-expressible with no external dependency — inline CSS and, since OBJ-2774, a self-contained inline script.
The animation: Journey sweep, ported from ObjectuveLogo.vue
Cold boot plays the exact same 900ms sweep-and-dot-pop motion as ObjectuveLogo.vue's animate="reveal" — not an approximation of it. As of OBJ-2774, index.html's inline <script> hand-ports ObjectuveLogo.vue's polarToXY/buildArcPath/buildTaperPath/easeOutQuint byte-for-byte and drives them on a requestAnimationFrame tick, recomputing the arc and taper <path> d attributes every frame — the same mechanism ObjectuveLogo.vue itself uses, just reproduced inline since index.html can't import the component. The gesture was originally proven on the marketing site (marketing_landing/about.html, .about-mark-animation, GSAP-driven) and first ported here as a pure-CSS @keyframes approximation (OBJ-2459); OBJ-2774 replaced that approximation with the rAF port because a CSS cubic-bezier curve and a per-frame-recomputed concave taper aren't reproducible with @keyframes alone.
Locked values, copied verbatim between index.html's inline <script>/<style> and ObjectuveLogo.vue (the single source of truth — ObjectuveLogoJourney.vue now delegates to it directly rather than holding its own copy). A drift test in ionic_frontend/tests/unit/components/ui/ObjectuveLogoJourney.spec.ts fails if index.html and ObjectuveLogo.vue disagree. Full rationale: Logo rationale § Journey Animation (Launch Sweep).
| Value | |
|---|---|
| Sweep duration | 900ms |
| Sweep easing | true easeOutQuint — 1 - Math.pow(1 - t, 5) on requestAnimationFrame, not a cubic-bezier CSS approximation (that was OBJ-2459's original mechanism; OBJ-2774 replaced it) |
| Dot pop delay | 792ms |
| Dot pop | 340ms cubic-bezier(0.34, 1.56, 0.64, 1) — still CSS @keyframes, unchanged by OBJ-2774 |
| Ambient pulse start | 1132ms (792 + 340, so it never overlaps the sweep). ObjectuveLogoJourney.vue/Storybook has no pulse — deliberate asymmetry, the pulse is boot-liveness signaling, not part of the reveal itself |
No rest track (removed by OBJ-2774). index.html previously painted a low-opacity copy of the completed mark (opacity: 0.16 light / 0.18 dark) under the live arc from frame 0, so the native-splash → interstitial handoff never showed an empty ring. Once the arc/taper became per-frame-recomputed <path> geometry (needed to reproduce ObjectuveLogo.vue's real concave taper, which grows in from a point rather than being a fixed rotated shape), maintaining a second static copy of that geometry stopped paying for itself. Accepted tradeoff: frame 0 (before the first requestAnimationFrame callback) is now empty. Reduced-motion users don't hit this — the inline script checks matchMedia('(prefers-reduced-motion: reduce)') before starting its rAF loop and paints the completed mark directly on the first frame if set, so animation: none-style base-state reasoning still applies to the CSS-driven dot and pulse, just not to the now-script-driven arc/taper.
Geometry gotcha: the arc is a filled donut-sector <path> built via buildArcPath, not a <circle> with transform="rotate(-90)" and not a stroked dasharray. #objectuve-gradient is userSpaceOnUse, so an element transform rotates the brand gradient along with the shape — the rotate() construction measured ~20.3% of pixels different from the shipped mark at 512×512 (gradient rotated 90° off); the <path> form measures ~1.5% (an antialiasing rim only). Per-frame d recomputation sidesteps the trap entirely by construction (there is no element transform to rotate the gradient). docs/ui-specs/mockups/logo-journey-splash/verify-rest-geometry.mjs is the regression check for the finished-mark rest state.
Full design contract, motion captures, and the sequencing decisions behind these values: docs/ui-specs/logo-journey-splash.md.
Keep it in sync with the native splash
If either side changes, check the other:
- Native splash assets:
android/app/src/main/res/drawable*/splash.png(all density/orientation variants) andios/App/App/Assets.xcassets/Splash.imageset/. - Web interstitial: the inline
<style>+ SVG inionic_frontend/index.html.
They're deliberately the same visual language (same mark, same surface-matched background per theme) so native splash → interstitial → app reads as one continuous launch, not three surfaces stitched together. Changing the brand mark or background token in one without the other reintroduces a visible seam at launch — including the now-empty frame-0 gap accepted by OBJ-2774 (see above), which relies on the sweep starting fast enough that the seam reads as brief, not broken. The native assets still need to match if the mark's geometry or color ever changes.
Gotcha: this breaks a smoke-test assertion if you change it carelessly
tests/smoke-playwright/specs/platform/01-healthy-boot.spec.ts guards the OBJECTUVE 2.1 "loads indefinitely" bootstrap regression — it must prove Vue actually mounted (or the error fallback rendered), not just that something is in #app. Before this interstitial existed, the test asserted #app was non-empty as a stand-in for "booted." Once the interstitial made #app non-empty from first paint, that assertion became meaningless — it resolved instantly whether or not Vue ever mounted.
The fix: assert on the interstitial's removal, then separately confirm the error fallback didn't render:
await expect(page.locator('[data-testid="launch-splash"]')).toHaveCount(0, { timeout: 20_000 })
await expect(page.locator('[data-testid="bootstrap-error"]')).toHaveCount(0)If you ever add another persistent, non-empty element inside #app before Vue mounts, re-check this test — #app-emptiness is no longer a valid boot-completion signal for this codebase.