Loading splash: animated logo over a dark overlay on desktop

index.html carries the overlay markup and styles inline so it paints before
any script; public/splash.js (external, for the CSP) plays the logo
animation from icons/ultimo-splash.html, holds, fades out after ~3.25 s and
removes itself. Skipped at phone sizes (same query as useIsMobile) and in
pop-out compose windows; a click skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 17:23:56 +01:00
co-authored by Claude Opus 5.5
parent 05db376b89
commit f82c7eb444
3 changed files with 448 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
// Loading splash: the animated Ultimo logo over a dark overlay while the app
// starts (markup and styles inline in index.html, so it paints before any script
// runs; this file is separate because the CSP allows no inline scripts). About
// 3.25 s in all: ~2.45 s to the finished logo, a short hold, then a .35 s fade.
// Desktop and tablet only; a click skips it. Source: frontend/icons/ultimo-splash.html.
(function () {
var SPEED = 1; // 1 = ~2.6 s to final logo; 0.8 = faster, 1.2 = slower
var HOLD_MS = 450; // how long the finished logo stays before the splash fades
var root = document.getElementById('ultimo-splash');
if (!root) return;
// Not on a phone (same query as useIsMobile), and not in a pop-out compose window.
var PHONE = '(max-width: 767px), (pointer: coarse) and (max-height: 500px)';
if ((window.matchMedia && matchMedia(PHONE).matches) || location.pathname.indexOf('/compose/') === 0) {
root.parentNode.removeChild(root);
return;
}
var $ = function (id) { return document.getElementById(id); };
var env = $('ul-env'), rest = $('ul-rest'), fl = $('ul-fl'), fr = $('ul-fr'), fb = $('ul-fb'), ft = $('ul-ft');
var stroke = $('ul-stroke'), glow = $('ul-glow'), fin = $('ul-final');
var ug = $('ul-ug'), under = $('ul-under');
var ftg = $('ul-ftg'), fto = $('ul-fto'), dr = $('ul-dr'), dg = $('ul-dg'), db = $('ul-db');
// Envelope body edges (logo coordinates) and the four flaps.
var L = 10.093, R = 35.296, T = 11.066, B = 32.13, CX = 22.69, CY = 21.6;
var FLAPS = {
l: { axis: 'x', h: L, a: T, b: B, mid: CY, depth: 12.2, dir: -1, r: 3.2 },
r: { axis: 'x', h: R, a: T, b: B, mid: CY, depth: 12.2, dir: 1, r: 3.2 },
b: { axis: 'y', h: B, a: L, b: R, mid: CX, depth: 14.0, dir: 1, r: 3.6 }
};
var END = 2450; // ms (at SPEED 1) when the logo is fully formed
function clamp(x) { return x < 0 ? 0 : x > 1 ? 1 : x; }
function seg(t, a, b) { return clamp((t - a) / (b - a)); }
function ease(x) { return x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; }
function easeOut(x) { return 1 - Math.pow(1 - x, 3); }
function n(v) { return Math.round(v * 1000) / 1000; }
// Flap outline: base fixed exactly on the body edge, apex swings over the hinge, tip rounded.
function flapD(f, p) {
var s = Math.cos(Math.PI * p), ax, ay, A, C;
if (f.axis === 'x') { ax = f.h + f.dir * f.depth * s; ay = f.mid; A = [f.h, f.a]; C = [f.h, f.b]; }
else { ax = f.mid; ay = f.h + f.dir * f.depth * s; A = [f.a, f.h]; C = [f.b, f.h]; }
function toward(P) {
var dx = P[0] - ax, dy = P[1] - ay, l = Math.hypot(dx, dy) || 1, r = Math.min(f.r, l * 0.45);
return n(ax + dx * r / l) + ',' + n(ay + dy * r / l);
}
return 'M' + A[0] + ',' + A[1] + ' L' + toward(A) + ' Q' + n(ax) + ',' + n(ay) + ' ' + toward(C) +
' L' + C[0] + ',' + C[1] + ' Z';
}
// Paper shading: inner face (grey) before the flip, outer face (white) after it.
function faceC(p) {
var lift = Math.sin(Math.PI * p);
var c = Math.round(p < .5 ? 230 - 42 * lift : 250 - 36 * lift);
return 'rgb(' + c + ',' + c + ',' + (c + 3) + ')';
}
function setFlap(el, f, p) {
el.setAttribute('d', flapD(f, p));
el.setAttribute('fill', faceC(p));
}
function render(ms) {
var intro = easeOut(seg(ms, 0, 220));
setFlap(fl, FLAPS.l, ease(seg(ms, 180, 560)));
setFlap(fr, FLAPS.r, ease(seg(ms, 240, 620)));
setFlap(fb, FLAPS.b, ease(seg(ms, 560, 920)));
var pt = ease(seg(ms, 880, 1260)), st = -Math.cos(Math.PI * pt);
if (Math.abs(st) < 0.001) st = 0.001;
// Top flap: white inside while open; once it flips past vertical its outside (logo colours) shows
ftg.setAttribute('transform', 'translate(0 ' + T + ') scale(1 ' + n(st) + ') translate(0 ' + (-T) + ')');
ft.setAttribute('fill', faceC(pt));
ft.setAttribute('opacity', pt >= 0.5 ? 0 : 1); // white inside face only while it faces the viewer
fto.setAttribute('opacity', pt >= 0.5 ? 1 : 0);
var slope = n(1 - 0.35 * Math.sin(Math.PI * pt));
dr.setAttribute('slope', slope); dg.setAttribute('slope', slope); db.setAttribute('slope', slope);
var draw = ease(seg(ms, 1180, 1800));
var fade = ease(seg(ms, 2150, 2450)); // white envelope (all but the top flap) vanishes after the logo has formed
var dis = ease(seg(ms, 1800, 2150)); // dissolve: U stroke + top flap -> final logo
var sc = 0.9 + 0.1 * intro;
env.setAttribute('transform', 'translate(22.5 22) scale(' + sc + ') translate(-22.5 -22)');
env.setAttribute('opacity', intro);
rest.setAttribute('opacity', 1 - fade);
ftg.setAttribute('opacity', 1 - fade); // coloured flap sits under the logo until the envelope goes
stroke.setAttribute('stroke-dashoffset', 1 - draw);
// Stroke stays solid under the logo while it dissolves in, then is removed once fully covered
ug.setAttribute('opacity', draw > 0 && dis < 1 ? 1 : 0);
under.setAttribute('opacity', draw > 0 ? 1 : 0);
fin.setAttribute('opacity', dis);
glow.setAttribute('opacity', 0.35 * Math.sin(Math.PI * seg(ms, 1900, 2450)));
}
var raf = 0, start = 0, hideTimer = 0;
function finish() {
cancelAnimationFrame(raf);
render(99999);
root.classList.add('is-done');
// Gone from the page once faded out (the .35 s opacity transition).
setTimeout(function () { if (root.parentNode) root.parentNode.removeChild(root); }, 400);
}
function tick(now) {
if (!start) start = now;
var ms = (now - start) / SPEED;
render(ms);
if (ms < END) raf = requestAnimationFrame(tick);
else hideTimer = setTimeout(finish, HOLD_MS);
}
function play() {
cancelAnimationFrame(raf); clearTimeout(hideTimer);
root.classList.remove('is-done');
start = 0; render(0);
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) {
render(99999); hideTimer = setTimeout(finish, 700); return;
}
raf = requestAnimationFrame(tick);
}
root.addEventListener('click', finish); // a click skips the intro
play();
})();