diff --git a/frontend/icons/ultimo-splash.html b/frontend/icons/ultimo-splash.html
new file mode 100644
index 0000000..aee1669
--- /dev/null
+++ b/frontend/icons/ultimo-splash.html
@@ -0,0 +1,237 @@
+
+
+
+
+
+Ultimo Splash
+
+
+
+
+
+
+
+
+
+
+
+
+ Your website content
+
+
+
+
+
+
diff --git a/frontend/index.html b/frontend/index.html
index fda1954..883e442 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -13,8 +13,99 @@
Ultimo Email
+
+
+
+
+
diff --git a/frontend/public/splash.js b/frontend/public/splash.js
new file mode 100644
index 0000000..f186a24
--- /dev/null
+++ b/frontend/public/splash.js
@@ -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();
+})();