Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

papers/index: single-image cover crossfade + per-image fade-in (no pop-in)

Reverts the layered front/back multiply composition: hero now shows ONE
slide at a time, cover-scaled to fully fill the bleed, simple crossfade
between adjacent slides. Pop-in fixes:

- preload <link> for all four hero images so they arrive ASAP
- per-image alpha ramp (IMG_FADE_IN=900ms) tracked by load timestamp, so a
slide that finishes loading mid-cycle dissolves in instead of slamming
to full alpha at its envelope peak
- kick the rAF loop on the FIRST decoded image (previously waited for ALL)
so the canvas starts painting sooner; later slides ride in via imgAlpha
- still paint one frame before flipping the .loaded class so the canvas
has real pixels the moment the CSS opacity ramp begins

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+52 -107
+52 -107
system/public/papers.aesthetic.computer/index.html
··· 15 15 <meta name="twitter:card" content="summary_large_image" /> 16 16 <meta name="twitter:image" content="https://papers.aesthetic.computer/papers-og.jpg" /> 17 17 <link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png" /> 18 + <link rel="preload" as="image" href="/papers-header.png" fetchpriority="high" /> 19 + <link rel="preload" as="image" href="/papers-header-v4.png" /> 20 + <link rel="preload" as="image" href="/papers-header-v3.png" /> 21 + <link rel="preload" as="image" href="/papers-header-v1.png" /> 18 22 <link rel="alternate" type="application/atom+xml" title="papers · Aesthetic Computer" href="/feed.xml" /> 19 23 <link rel="alternate" type="application/rss+xml" title="papers · Aesthetic Computer" href="/rss.xml" /> 20 24 <link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css"> ··· 862 866 { src: '/papers-header-v3.png', focal: { x: 0.50, y: 0.62 }, pan: { from: { s: 1.06, x: 0, y: 0 }, to: { s: 1.00, x: 0.02, y: -0.01 } } }, 863 867 { src: '/papers-header-v1.png', focal: { x: 0.46, y: 0.42 }, pan: { from: { s: 1.04, x: 0, y: 0 }, to: { s: 1.10, x: -0.01, y: 0.02 } } }, 864 868 ]; 865 - const SLIDE_DUR = 9000; 869 + const SLIDE_DUR = 7000; 870 + const FADE_DUR = 1800; 871 + const IMG_FADE_IN = 900; // per-image dissolve once it loads 866 872 const CYCLE = SLIDES.length * SLIDE_DUR; 867 873 868 - const images = SLIDES.map(s => { const i = new Image(); i.src = s.src; return i; }); 874 + const images = SLIDES.map(s => { 875 + const i = new Image(); 876 + i._loadedAt = 0; 877 + const stamp = () => { if (!i._loadedAt) i._loadedAt = performance.now(); }; 878 + i.addEventListener('load', stamp, { once: true }); 879 + i.addEventListener('error', stamp, { once: true }); 880 + i.src = s.src; 881 + if (i.complete && i.naturalWidth) i._loadedAt = performance.now(); 882 + return i; 883 + }); 869 884 870 885 let cssW = 0, cssH = 0; 871 886 function resize() { ··· 881 896 882 897 function easeInOutSine(t) { return -(Math.cos(Math.PI * t) - 1) / 2; } 883 898 884 - // mode: 'cover' fills the canvas (background layer), 885 - // 'contain-small' is a smaller in-front composition 886 - function drawSlide(img, focal, pan, progress, alpha, mode) { 899 + // Cover-fill the canvas with a single image; focal anchor keeps 900 + // the subject centered as the slow Ken Burns pan zooms in/out. 901 + function drawSlide(img, focal, pan, progress, alpha) { 887 902 if (!img.complete || !img.naturalWidth) return; 888 903 const t = easeInOutSine(progress); 889 904 const sc = pan.from.s + (pan.to.s - pan.from.s) * t; 890 905 const tx = pan.from.x + (pan.to.x - pan.from.x) * t; 891 906 const ty = pan.from.y + (pan.to.y - pan.from.y) * t; 892 - let baseScale; 893 - if (mode === 'cover') { 894 - baseScale = Math.max(cssW / img.naturalWidth, cssH / img.naturalHeight) * 1.18; 895 - } else { 896 - baseScale = Math.min(cssW / img.naturalWidth, cssH / img.naturalHeight) * 0.82; 897 - } 907 + const baseScale = Math.max(cssW / img.naturalWidth, cssH / img.naturalHeight); 898 908 const scale = baseScale * sc; 899 909 const dw = img.naturalWidth * scale; 900 910 const dh = img.naturalHeight * scale; ··· 906 916 ctx.restore(); 907 917 } 908 918 909 - // Triangular envelope peaking at center of each slide window — 910 - // every layer is always crossfading with its neighbours. 911 - function alphaFor(globalT, slotStart, slotDur) { 912 - const half = slotDur / 2; 913 - const d = Math.min( 914 - Math.abs(globalT - slotStart), 915 - Math.abs(globalT - slotStart - CYCLE), 916 - Math.abs(globalT - slotStart + CYCLE) 917 - ); 918 - if (d >= slotDur) return 0; 919 - const x = 1 - d / slotDur; 920 - return easeInOutSine(x); 919 + // Per-image fade-in so a slide that finishes loading mid-cycle 920 + // ramps from 0→1 over IMG_FADE_IN ms instead of popping at full 921 + // alpha the first frame after load. 922 + function imgAlpha(img, now) { 923 + if (!img._loadedAt) return 0; 924 + const t = (now - img._loadedAt) / IMG_FADE_IN; 925 + return Math.min(1, Math.max(0, t)); 921 926 } 922 927 923 - // Front-layer slide is offset by half the slide count so the 924 - // foreground image is *never* the same one currently dominating 925 - // the background. With 4 slides this means: while back blends 926 - // 0↔1, front shows 2↔3, etc. 927 - const FRONT_OFFSET = Math.floor(SLIDES.length / 2); 928 - // Half-cycle phase shift so the front layer is also peaking 929 - // between back-layer transitions instead of in lockstep. 930 - const FRONT_PHASE_SHIFT = (FRONT_OFFSET * SLIDE_DUR) + (SLIDE_DUR / 2); 931 - 932 928 let start = 0; 933 929 let lastFrame = 0; 934 930 function frame(now) { ··· 937 933 938 934 const elapsed = reduced ? 0 : (now - start); 939 935 const cyclePos = ((elapsed % CYCLE) + CYCLE) % CYCLE; 936 + const idx = Math.floor(cyclePos / SLIDE_DUR); 937 + const slideElapsed = cyclePos - idx * SLIDE_DUR; 938 + const slideProg = slideElapsed / SLIDE_DUR; 939 + const fadeStartProg = (SLIDE_DUR - FADE_DUR) / SLIDE_DUR; 940 940 941 - // Track which slide indices are active in BACK so we can 942 - // guarantee FRONT never picks the same one (defence in 943 - // depth — the offset already enforces this for 4 slides 944 - // but the check keeps the invariant if SLIDES grows). 945 - const backActive = new Set(); 946 - 947 - // BACK LAYER — covered, fills full bleed. Each slide gets a 948 - // triangular alpha envelope so neighbours always blend. 949 - for (let i = 0; i < SLIDES.length; i++) { 950 - const slotStart = i * SLIDE_DUR; 951 - const a = alphaFor(cyclePos, slotStart, SLIDE_DUR); 952 - if (a <= 0.001) continue; 953 - backActive.add(i); 954 - const localProg = ((cyclePos - slotStart + CYCLE) % CYCLE) / SLIDE_DUR; 955 - const prog = Math.max(0, Math.min(1, localProg)); 956 - drawSlide(images[i], SLIDES[i].focal, SLIDES[i].pan, prog, a, 'cover'); 941 + if (slideProg > fadeStartProg) { 942 + // End of the slot — crossfade current → next. 943 + const fadeT = (slideProg - fadeStartProg) / (1 - fadeStartProg); 944 + const nextIdx = (idx + 1) % SLIDES.length; 945 + const nextImg = images[nextIdx]; 946 + const curImg = images[idx]; 947 + drawSlide(nextImg, SLIDES[nextIdx].focal, SLIDES[nextIdx].pan, 0, imgAlpha(nextImg, now)); 948 + drawSlide(curImg, SLIDES[idx].focal, SLIDES[idx].pan, slideProg, (1 - fadeT) * imgAlpha(curImg, now)); 949 + } else { 950 + // Holding on the current slide — single image fills the canvas. 951 + const curImg = images[idx]; 952 + drawSlide(curImg, SLIDES[idx].focal, SLIDES[idx].pan, slideProg, imgAlpha(curImg, now)); 957 953 } 958 954 959 - // FRONT LAYER — smaller, multiply-blended so the cream paper 960 - // edges of the colored-pencil scans dissolve into the back 961 - // layer instead of stamping a hard rectangle. Slide indices 962 - // are remapped via FRONT_OFFSET so the foreground is always 963 - // a *different* image than the background. 964 - ctx.globalCompositeOperation = 'multiply'; 965 - const frontPhase = (cyclePos + FRONT_PHASE_SHIFT) % CYCLE; 966 - for (let i = 0; i < SLIDES.length; i++) { 967 - const slotStart = i * SLIDE_DUR; 968 - const a = alphaFor(frontPhase, slotStart, SLIDE_DUR); 969 - if (a <= 0.001) continue; 970 - const slideIdx = (i + FRONT_OFFSET) % SLIDES.length; 971 - if (backActive.has(slideIdx)) continue; // never duplicate 972 - const localProg = ((frontPhase - slotStart + CYCLE) % CYCLE) / SLIDE_DUR; 973 - const prog = Math.max(0, Math.min(1, localProg)); 974 - drawSlide( 975 - images[slideIdx], 976 - SLIDES[slideIdx].focal, 977 - SLIDES[slideIdx].pan, 978 - prog, 979 - a * 0.85, 980 - 'contain-small' 981 - ); 982 - } 983 - ctx.globalCompositeOperation = 'source-over'; 984 - 985 - // Soft side/top vignette — fades the canvas to transparent at 986 - // the corners so the purple letterbox bleeds in. The bottom 987 - // fade-to-page-bg is handled by .hero::after in CSS so it 988 - // hits the page background even when imagery is opaque here. 989 - const grad = ctx.createRadialGradient( 990 - cssW / 2, cssH / 2, Math.min(cssW, cssH) * 0.32, 991 - cssW / 2, cssH / 2, Math.max(cssW, cssH) * 0.78 992 - ); 993 - grad.addColorStop(0, 'rgba(0,0,0,0)'); 994 - grad.addColorStop(0.75, 'rgba(0,0,0,0.30)'); 995 - grad.addColorStop(1, 'rgba(0,0,0,0.85)'); 996 - ctx.globalCompositeOperation = 'destination-out'; 997 - ctx.fillStyle = grad; 998 - ctx.fillRect(0, 0, cssW, cssH); 999 - ctx.globalCompositeOperation = 'source-over'; 1000 - 1001 955 if (!reduced || lastFrame === 0) requestAnimationFrame(frame); 1002 956 lastFrame = now; 1003 957 } 1004 958 1005 - // Wait for ALL images before kicking off — prevents pop-in of 1006 - // late-arriving slides as they load mid-cycle. 959 + // Kick the loop on the FIRST decoded image so something appears 960 + // ASAP; later slides ride in on imgAlpha when they finish loading. 1007 961 let kicked = false; 1008 962 function kick() { 1009 963 if (kicked) return; 1010 964 kicked = true; 1011 - // Start the cycle clock *now* so slide 0 begins at its 1012 - // peak with the canvas still invisible — by the time the 1013 - // opacity fade-in starts, the bitmap is already fully 1014 - // painted, so the user sees a smooth alpha rise rather 1015 - // than content snapping in mid-transition. 1016 965 start = performance.now(); 1017 - // Paint one frame before triggering the CSS fade-in so the 1018 - // canvas has real pixels the moment opacity begins ramping. 966 + // Paint one frame so the canvas has real pixels before the 967 + // CSS opacity ramp begins — this prevents the perception of 968 + // a global "fade-in pop" from empty purple to content. 1019 969 frame(start); 1020 970 requestAnimationFrame(() => { 1021 971 canvas.classList.add('loaded'); 1022 972 requestAnimationFrame(frame); 1023 973 }); 1024 974 } 1025 - let pending = images.length; 1026 975 images.forEach(img => { 1027 - const done = () => { if (--pending <= 0) kick(); }; 1028 - if (img.complete && img.naturalWidth) done(); 1029 - else { 1030 - img.addEventListener('load', done, { once: true }); 1031 - img.addEventListener('error', done, { once: true }); 1032 - } 976 + if (img.complete && img.naturalWidth) kick(); 977 + else img.addEventListener('load', kick, { once: true }); 1033 978 }); 1034 - // Safety fallback — if something stalls, kick after 4s anyway. 979 + // Safety fallback — if all loads stall, still kick after 4s. 1035 980 setTimeout(kick, 4000); 1036 981 })(); 1037 982