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(); +})();