From 821a1060dac13a06235d67b21e433e4456830fa5 Mon Sep 17 00:00:00 2001 From: Krish Agarwal Date: Thu, 22 Oct 2026 19:04:55 +0530 Subject: [PATCH] fix mobile glitches --- src/app/globals.css | 15 ++++++-- src/app/opengraph-image.tsx | 2 +- src/components/innovision/HomeView.tsx | 9 +++-- src/components/innovision/Innovision.tsx | 38 +++++++++++++++---- src/components/innovision/Loader.tsx | 13 +++++-- src/components/innovision/PhoneModal.tsx | 23 +++++++++--- src/components/innovision/clickSound.ts | 48 ++++++++++++++++-------- src/lib/og-card.tsx | 6 ++- 8 files changed, 111 insertions(+), 43 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index c03c78e..d81c7f4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -192,7 +192,8 @@ body::after{content:'';position:fixed;inset:0;pointer-events:none;z-index:9999;o /* --d is the disc's diameter and --cy its centre. Rings, disc, orbiters, copy and astronaut all hang off the two, so the composition holds together at any screen shape. Wide screens keep the copy centred over the disc. */ .hero{--d:min(84vh,72vw);--cy:53%} -.hero-ring{position:absolute;left:calc(50% - var(--d) * var(--r));top:calc(var(--cy) - var(--d) * var(--r));width:calc(var(--d) * var(--r) * 2);height:calc(var(--d) * var(--r) * 2);border-radius:50%} +/* will-change: the entrance and leave animations scale the rings; without it each frame re-rasters them at the new scale. */ +.hero-ring{position:absolute;left:calc(50% - var(--d) * var(--r));top:calc(var(--cy) - var(--d) * var(--r));width:calc(var(--d) * var(--r) * 2);height:calc(var(--d) * var(--r) * 2);border-radius:50%;will-change:transform} .hero-disc{position:absolute;left:calc(50% - var(--d) / 2);top:calc(var(--cy) - var(--d) / 2);width:var(--d);height:var(--d)} /* The radar sweep lives in .hero-copy (so it can sit between the title and the rest of the copy) but covers the disc exactly. */ .hero-sweep{position:absolute;left:calc(50% - var(--d) / 2);top:calc(var(--cy) - var(--d) / 2);width:var(--d);height:var(--d)} @@ -211,7 +212,9 @@ body::after{content:'';position:fixed;inset:0;pointer-events:none;z-index:9999;o .hero-copy-disc{display:contents} /* --ls is the tracking; the same amount of left padding balances the trailing letter-space so the text centres. */ .hero-track{padding-left:var(--ls)} -.hero-kicker{--ls:.42em;gap:14px;font-size:clamp(11px,1.05vw,16px);letter-spacing:var(--ls)} +.hero-kicker{--ls:.42em;gap:.55em;font-size:clamp(11px,1.05vw,16px);letter-spacing:var(--ls)} +/* Second kicker line: smaller and lighter, so NIT ROURKELA leads. --ls is in em, so the centring pad shrinks with it. */ +.hero-kicker-sub{font-size:.72em;font-weight:500;opacity:.78} .hero-title{margin:clamp(10px,2vh,22px) 0 clamp(8px,1.4vh,16px);font-size:clamp(36px,12.4vw,224px)} .hero-sub{--ls:.32em;gap:18px;font-size:clamp(12px,1.35vw,21px);letter-spacing:var(--ls)} .hero-sub-rule{width:clamp(24px,4vw,64px);flex:none} @@ -232,7 +235,7 @@ body::after{content:'';position:fixed;inset:0;pointer-events:none;z-index:9999;o .hero-copy-disc{display:flex;flex-direction:column;align-items:center;justify-content:center;flex:none;height:var(--d)} /* .hero-copy starts at the disc's top edge here. */ .hero-sweep{top:0} - .hero-kicker{--ls:.26em;gap:8px;font-size:clamp(9.5px,calc(var(--d) * .033),15px)} + .hero-kicker{--ls:.26em;gap:.5em;font-size:clamp(9.5px,calc(var(--d) * .033),15px)} .hero-title{margin:calc(var(--d) * .03) 0 calc(var(--d) * .026);font-size:calc(var(--d) * .122)} .hero-sub{--ls:.18em;gap:10px;font-size:clamp(10px,calc(var(--d) * .034),17px)} .hero-sub-rule{width:calc(var(--d) * .05)} @@ -273,6 +276,9 @@ body::after{content:'';position:fixed;inset:0;pointer-events:none;z-index:9999;o /* ---------- compact menu ---------- */ .menu-panel{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden;background:#141312;color:#ECE8DF} +/* The panel opens and closes by animating its clip-path circle. With its contents on their own layers only the mask + changes each frame; otherwise the full-screen starfield and list were re-rastered every frame of the reveal. */ +.menu-panel>*{will-change:transform} .menu-top{position:relative;display:flex;align-items:center;justify-content:space-between;gap:16px;flex:none;padding:clamp(14px,1.8vw,26px) clamp(16px,2.6vw,44px)} .menu-brand{display:inline-flex;align-items:center;gap:10px;color:#ECE8DF;text-decoration:none} .menu-brand:hover{color:oklch(0.84 0.09 85)} @@ -496,6 +502,9 @@ body::after{content:'';position:fixed;inset:0;pointer-events:none;z-index:9999;o .hud-frost,.wn-pill{-webkit-backdrop-filter:none !important;backdrop-filter:none !important} .hud-frost{background:rgba(236,232,223,.97) !important} .wn-pill{background:rgba(236,232,223,.96) !important} + /* The bar keeps one height: easing its padding as it turns solid relaid and repainted it every frame, and the bar + visibly bounced mid-scroll. */ + .hud-bar{padding:clamp(14px,1.8vw,26px) clamp(16px,2.6vw,44px) !important;transition:none !important} } /* ---------- schedule ---------- */ diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx index 8fd45ac..280411c 100644 --- a/src/app/opengraph-image.tsx +++ b/src/app/opengraph-image.tsx @@ -9,5 +9,5 @@ export const size = OG_SIZE; export const contentType = 'image/png'; export default function OpengraphImage() { - return ogCard({ kicker: 'NIT ROURKELA SAC PRESENTS', title: 'INNOVISION', subtitle: '2026 · THE CELESTIAL ODYSSEY' }); + return ogCard({ kicker: 'NIT ROURKELA', kickerSub: 'STUDENT ACTIVITY CENTRE PRESENTS', title: 'INNOVISION', subtitle: '2026 · THE CELESTIAL ODYSSEY' }); } diff --git a/src/components/innovision/HomeView.tsx b/src/components/innovision/HomeView.tsx index 183c1c8..4cd88ee 100644 --- a/src/components/innovision/HomeView.tsx +++ b/src/components/innovision/HomeView.tsx @@ -55,11 +55,12 @@ export default function HomeView({ v }: { v: V }) {
{/* Wide screens: display contents, so copy and buttons share one centred column. Stacked screens: a box the size of the disc. - The page's one heading is the whole visible line-up: NIT ROURKELA SAC PRESENTS / INNOVISION / 2026 · THE CELESTIAL ODYSSEY. + The page's one heading is the whole visible line-up: NIT ROURKELA / STUDENT ACTIVITY CENTRE PRESENTS / INNOVISION / 2026 · THE CELESTIAL ODYSSEY. Its parts are spans (phrasing content); the spaces between them are not rendered between flex items but keep the words apart in the text. */} -

- - NIT ROURKELA SAC PRESENTS +

+ + NIT ROURKELA{" "} + STUDENT ACTIVITY CENTRE PRESENTS {" "} {v.heroChars.map((c, cI) => ( diff --git a/src/components/innovision/Innovision.tsx b/src/components/innovision/Innovision.tsx index 8ebbe50..aaaaeb1 100644 --- a/src/components/innovision/Innovision.tsx +++ b/src/components/innovision/Innovision.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/ban-ts-comment -- GSAP class component uses @ts-ignore extensively for dynamic internal state */ // @ts-nocheck "use client"; -import { CLICKABLE, preloadClick, unlockClick, playClick } from './clickSound'; +import { CLICKABLE, preloadClick, warmClick, unlockClick, playClick } from './clickSound'; /* eslint-disable @typescript-eslint/no-explicit-any -- view-model is dynamically constructed and consumed across many child views */ /* eslint-disable @typescript-eslint/no-unused-vars -- some destructured vars are kept for future use */ @@ -262,14 +262,17 @@ export class Innovision extends Component { try { this.hintSeen = localStorage.getItem(HINT_KEY) === '1'; } catch {} this.setState({ saved, compact: innerWidth < 1100, narrow: innerWidth < 720, coarse: matchMedia('(pointer: coarse)').matches }); if (lowPowerDevice()) this.goLowPower(); - // Resize work forces layout (title fit, map panels), so it runs at most once per frame. - let rz = 0; + // Resize work forces layout (title fit, map panels), so it runs at most once per frame. Both depend on the width + // only: a phone's toolbar or keyboard changes just the height, and re-tweening the map panels then cost a second of layout. + let rz = 0, rw = innerWidth; this.listen(window, 'resize', () => { if (rz) return; rz = requestAnimationFrame(() => { rz = 0; const c = innerWidth < 1100, n = innerWidth < 720; if (c !== this.state.compact || n !== this.state.narrow) this.setState({ compact: c, narrow: n }, () => { if (this.state.view === 'detail') this.setupDetailScroll(true); }); + if (innerWidth === rw) return; + rw = innerWidth; this.fitTitle(); if (this.ctx) this.openMap(this.mapOpen); }); @@ -666,6 +669,8 @@ export class Innovision extends Component { /** The only sound is the click (clickSound.ts): every button, link or tab clicked anywhere answers with a tick. */ sound() { preloadClick(); + // Created while the loader counts up, so that a first tap during the landing animation doesn't stall it either. + this.cleanups.push(this.idle(warmClick)); ['pointerdown', 'keydown'].forEach((ev) => this.listen(window, ev, unlockClick, { once: true, capture: true })); this.listen(document, 'click', (e) => { const t = (e.target as Element | null)?.closest?.(CLICKABLE); @@ -702,6 +707,12 @@ export class Innovision extends Component { // The overlay and view a visitor is most likely to open next are fetched ahead too (their own hover/tap also does). // The pages of their own too, so MERCH (and REGISTER while closed) open at once (a no-op on the dev server). this.cleanups.push(this.idle(() => { this.prefetch('auth'); this.prefetch('detail'); OWN_PAGES.forEach((p) => this.props.router?.prefetch(p)); })); + // The menu (where the top bar has a MENU button) and the register / log-in overlay are mounted, closed, ahead of + // time: mounting one on its first tap blocked a phone for ~100 ms just as its reveal began. One per idle period. + this.cleanups.push(this.idle(() => { + if (this.state.compact) void this.mount('menu'); + this.cleanups.push(this.idle(() => { void this.mount('auth'); })); + })); const queue = [...PRELOAD_DEFERRED]; let cancel = () => {}, live = true; const batch = () => { @@ -1528,7 +1539,9 @@ export class Innovision extends Component { this.pEls = v ? [...v.querySelectorAll('[data-depth]')].filter((el) => { const s = el.closest('[data-slide]'); return !s || +(s.dataset.slide || 0) === this.state.index; }) : []; } hover = (e: MouseEvent) => { - if (this.reduce || !this.ctx) return; + // Touch screens fire mouseenter on every tap: the scramble swaps in letters of other widths, so the tapped label + // (MENU, REGISTER) changed width for half a second and the top bar around it shook. Hover effects are for mice only. + if (this.reduce || !this.ctx || this.state.coarse) return; const sp = e.currentTarget.querySelector('[data-scr]'); if (!sp) return; sp.dataset.text = sp.dataset.text || sp.textContent || ''; @@ -1922,7 +1935,8 @@ export class Innovision extends Component { pagePush(z) { const sc = z ? String(1 + .12 * z) : '', o = this.authO, to = z && o ? o.x.toFixed(0) + 'px ' + o.y.toFixed(0) + 'px' : ''; // @ts-ignore - (this.page || []).forEach((el) => { el.style.scale = sc; el.style.transformOrigin = to; }); + // will-change while pushed: without it every frame of the zoom re-rastered the whole page (the home page is many screens tall). + (this.page || []).forEach((el) => { el.style.scale = sc; el.style.transformOrigin = to; el.style.willChange = sc ? 'transform' : ''; }); const veil = this.$ && this.$('[data-rift-veil]'); if (veil) veil.style.opacity = String(z * .85); } // @ts-ignore @@ -2038,13 +2052,14 @@ export class Innovision extends Component { return; } g.set(ins, { autoAlpha: 0, y: 26 }); g.set(planet, { autoAlpha: 0, scale: .7, yPercent: 16 }); g.set(ui, { scale: .94 }); + this.authLayers(true); const r = this._rift = this.riftNew(0, 0, 1, 0); this.riftDraw(); const k = this.speed(); this.warp(2.4 / k); // @ts-ignore this.authTl = g.timeline({ onUpdate: () => this.riftDraw(), onComplete: () => { - this._rift = null; sec.style.clipPath = 'none'; + this._rift = null; sec.style.clipPath = 'none'; this.authLayers(false); this.pagePush(0); this.authBusy = false; this.focusAuth(); } }) .fromTo(sec, { autoAlpha: fromDisc ? 0 : 1 }, { autoAlpha: 1, duration: fromDisc ? .35 : .01, ease: 'power1.out' }, 0) @@ -2065,7 +2080,7 @@ export class Innovision extends Component { this.page = this.livePage(); // @ts-ignore const done = () => { - this._rift = null; + this._rift = null; this.authLayers(false); g.set(root, { autoAlpha: 0, pointerEvents: 'none' }); g.set(sec, { autoAlpha: 1 }); sec.style.clipPath = 'none'; this.pagePush(0); this.authSpin(false); this.authClosing = false; this.authBusy = false; @@ -2073,6 +2088,7 @@ export class Innovision extends Component { if (after) after(); }; if (this.reduce) { g.to(sec, { autoAlpha: 0, duration: .3, onComplete: () => { g.set(sec, { autoAlpha: 1 }); done(); } }); return; } + this.authLayers(true); const r = this._rift = this.riftNew(1, 1, 0, 1); this.riftDraw(); // @ts-ignore @@ -2090,11 +2106,19 @@ export class Innovision extends Component { if (this.authTl) this.authTl.kill(); clearTimeout(this._wait); this.warpStop(); this._rift = null; const sec = this.$('[data-auth]'); if (sec) { sec.style.clipPath = 'none'; gsap.set(sec, { autoAlpha: 1 }); } + this.authLayers(false); gsap.set(this.$('[data-auth-root]'), { autoAlpha: 0, pointerEvents: 'none' }); this.pagePush(0); this.authSpin(false); this.authBusy = false; this.authClosing = false; this.setState({ auth: false, busyLbl: '', drag: '' }); } + /** + * Gives the form and its planet their own layers while the rift opens or closes: both scale, and the rift's clip + * changes every frame, which otherwise re-rastered them each frame. Dropped once still, so the form stays crisp. + */ + authLayers(on: boolean) { + this.$$('[data-a-ui],[data-a-planet-wrap]').forEach((el) => { el.style.willChange = on ? 'transform' : ''; }); + } // @ts-ignore authSpin(on) { // @ts-ignore diff --git a/src/components/innovision/Loader.tsx b/src/components/innovision/Loader.tsx index 8be60d1..0e0da32 100644 --- a/src/components/innovision/Loader.tsx +++ b/src/components/innovision/Loader.tsx @@ -11,6 +11,8 @@ const ORBS: [string, string, number, number, number, string][] = [ ['26%', '1px solid rgba(20,19,18,.3)', 0, 128, 16, 'planet-mars.webp'], ]; const DISC = 'min(58vmin, 540px, 100vh - 330px)'; +/** The rings scale in and out; their own layer keeps the conic gradients and masks from re-rastering every frame. */ +const RING = { position: "absolute", borderRadius: "50%", willChange: "transform" } as const; /** Orbital preloader. */ export default function Loader({ v }: { v: V }) { @@ -29,13 +31,13 @@ export default function Loader({ v }: { v: V }) {
MMXXVI
-
-
+
+
{ORBS.map(([inset, border, i, a0, size, img]) => ( -
+
@@ -60,7 +62,10 @@ export default function Loader({ v }: { v: V }) {
-
NIT ROURKELA SAC PRESENTS
+
+ NIT ROURKELA + STUDENT ACTIVITY CENTRE PRESENTS +
INNOVISION 2026
diff --git a/src/components/innovision/PhoneModal.tsx b/src/components/innovision/PhoneModal.tsx index 13f4066..d1af4ce 100644 --- a/src/components/innovision/PhoneModal.tsx +++ b/src/components/innovision/PhoneModal.tsx @@ -45,11 +45,15 @@ export default function PhoneModal({ isOpen, onSave, onClose, userEmail }: Phone inset: 0, zIndex: 90, display: 'grid', + // One column the width of the screen: an auto column grew to the card's content (the input's default width), + // so under ~375px the card ran off the right edge and took the close button with it. + gridTemplateColumns: 'minmax(0, 1fr)', placeItems: 'center', + overflowY: 'auto', background: 'rgba(7, 6, 5, 0.88)', backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - padding: '20px', + padding: 'clamp(12px, 4vw, 20px)', }} >
-
+ {/* Right padding keeps the label clear of the close button on narrow screens. */} +
{userEmail && ( -

+

Logged in as: {userEmail}

)} @@ -134,8 +139,13 @@ export default function PhoneModal({ isOpen, onSave, onClose, userEmail }: Phone aria-label="Close" style={{ position: 'absolute', - top: '20px', - right: '20px', + top: '8px', + right: '8px', + display: 'grid', + placeItems: 'center', + width: '44px', + height: '44px', + padding: 0, background: 'transparent', border: 0, color: 'rgba(236,232,223,0.6)', @@ -178,6 +188,7 @@ export default function PhoneModal({ isOpen, onSave, onClose, userEmail }: Phone }} style={{ flex: 1, + minWidth: 0, height: '52px', padding: '0 16px', borderRadius: 0, diff --git a/src/components/innovision/clickSound.ts b/src/components/innovision/clickSound.ts index 10493aa..4b576a5 100644 --- a/src/components/innovision/clickSound.ts +++ b/src/components/innovision/clickSound.ts @@ -4,9 +4,10 @@ import { A } from './data'; * The site's only sound: a short tick when a button, link or tab is clicked. * * The 8 kB sample (beep.mp3, a 50 ms tick) is fetched right after boot and decoded through Web Audio, so a click - * never waits on the network. Browsers block audio until the first gesture, so the AudioContext is created on the - * first pointer or key press (unlockClick). Until the sample is decoded, or if it fails to load, a synthesized tick - * plays instead, so every click is heard. + * never waits on the network. Browsers block audio until the first gesture: the AudioContext is created ahead, while + * the page is idle (warmClick; it starts suspended), and the first pointer or key press only resumes it (unlockClick). + * Creating it inside that first tap stalled a phone for ~25-90 ms just as the tapped menu or form began to animate. + * Until the sample is decoded, or if it fails to load, a synthesized tick plays instead, so every click is heard. */ /** What counts as a button: anything a click on should answer with the tick. */ @@ -19,6 +20,8 @@ let ctx: AudioContext | null = null; let out: GainNode | null = null; let buf: AudioBuffer | null = null; let bytes: Promise | null = null; +/** A gesture has woken the context (unlockClick ran). */ +let unlocked = false; /** Time of the last tick: a click on a