// Home — split-stage cosmos.
//
// · LEFT — editorial title block. Big "Center of everything." headline,
// descriptive paragraph, key stats inline, ambient cosmic backdrop.
// · RIGHT — 8 module buttons orbit a smaller centre circle. The centre
// circle holds the U monogram (no text), with the 10-floor apartment
// sketch animating behind it.
// · Tap a button → its icon JUMPS UP, FLIPS in the air (rotateY card-flip),
// then FLIES into the centre circle and lands with a flash. After landing
// the page transitions to that screen.
// · Cosmos: warm-gold dust particles drifting on a light substrate.
const HOME_KEYS_ID = 'uni-home-split-keys';
function ensureHomeKeys() {
if (typeof document === 'undefined') return;
if (document.getElementById(HOME_KEYS_ID)) return;
const s = document.createElement('style');
s.id = HOME_KEYS_ID;
s.textContent = `
/* The icon's flight: anticipation jump → flip → glide to centre.
Per-instance --dx/--dy are set on click for the centre vector. Uses
rotateZ (always visible) instead of rotateY (disappears edge-on),
and ends at scale 0.55 so the landing read clearly. */
@keyframes uniIconJumpFlipFly {
0% { transform: translate(0,0) translateY(0) scale(1) rotate(0deg);
filter: drop-shadow(0 0 0 transparent); opacity: 1; }
25% { transform: translate(0,0) translateY(-60px) scale(1.25) rotate(90deg);
filter: drop-shadow(0 18px 30px rgba(232,215,168,0.6))
drop-shadow(0 0 22px rgba(255,238,180,0.75)); opacity: 1; }
70% { transform: translate(calc(var(--dx,0) * 0.7), calc(var(--dy,0) * 0.7 - 30px)) scale(1.30) rotate(360deg);
filter: drop-shadow(0 0 42px rgba(255,238,180,1.0))
drop-shadow(0 0 22px rgba(255,238,180,0.9)); opacity: 1; }
100% { transform: translate(var(--dx,0), var(--dy,0)) scale(0.55) rotate(540deg);
filter: drop-shadow(0 0 20px rgba(255,238,180,0.6)); opacity: 0; }
}
/* gold halo disk that carries the icon during flight — keeps the
icon legible against any background */
@keyframes uniIconHalo {
0% { transform: scale(0); opacity: 0; }
18% { transform: scale(1); opacity: 0.95; }
85% { transform: scale(1); opacity: 0.85; }
100% { transform: scale(0.6); opacity: 0; }
}
@keyframes uniSatBlast {
0% { box-shadow: 0 18px 36px rgba(50,32,12,0.20),
0 8px 16px rgba(50,32,12,0.12),
inset 0 1px 0 rgba(255,246,224,0.16);
transform: scale(1); }
28% { box-shadow: 0 0 0 14px rgba(216,181,115,0.18),
0 0 80px 18px rgba(232,216,179,0.45),
inset 0 0 50px rgba(232,216,179,0.18);
transform: scale(1.06); }
62% { box-shadow: 0 0 0 22px rgba(216,181,115,0),
0 0 110px 26px rgba(232,216,179,0.18);
transform: scale(0.96); }
100% { transform: scale(1); }
}
@keyframes uniCenterFlash {
0% { background: radial-gradient(circle, rgba(255,238,180,0) 0%, rgba(232,215,168,0) 60%);
transform: scale(1); }
28% { background: radial-gradient(circle, rgba(255,238,180,0.95) 0%, rgba(232,215,168,0.55) 38%, rgba(232,215,168,0) 70%);
transform: scale(1.08); }
100% { background: radial-gradient(circle, rgba(255,238,180,0) 0%, rgba(232,215,168,0) 60%);
transform: scale(1.22); }
}
@keyframes uniCenterRing {
0% { transform: scale(0.55); opacity: 0.95; border-width: 3px; }
100% { transform: scale(2.6); opacity: 0; border-width: 1px; }
}
/* Settling wave — fired when arriving from /explore. A ring expands from
the centre out past the screen edges; subtle, not heavy. 3 instances
are rendered with staggered delays for depth. */
@keyframes uniSettleWave {
0% { transform: translate(-50%, -50%) scale(0.40);
opacity: 0.85; border-width: 3px;
box-shadow: 0 0 22px rgba(255,238,180,0.45); }
18% { opacity: 0.78; }
100% { transform: translate(-50%, -50%) scale(20);
opacity: 0; border-width: 0.4px;
box-shadow: 0 0 6px rgba(255,238,180,0); }
}
/* Cinematic text reveal — blur-to-clear + soft lift. */
@keyframes uniCinematicIn {
0% { opacity: 0; transform: translateY(14px); filter: blur(10px); }
100% { opacity: 1; transform: translateY(0); filter: blur(0); }
}
/* Mini-CRM panel — slides in from the left edge. */
@keyframes uniCrmPanelIn {
0% { opacity: 0; transform: translateX(-46px); }
100% { opacity: 1; transform: translateX(0); }
}
/* ── Building backdrop motion ──────────────────────────────
Reveal: the twin-tower drawing "inks in" from the ground up on load.
Float: a slow horizontal parallax drift (stays within the side margins
so the building is never cropped top/bottom). Sweep: a golden light
band passes across the towers. Glow: a faint contrast breathe. */
@keyframes uniBldgReveal { from { opacity:0; clip-path: inset(100% 0 0 0); } to { opacity:1; clip-path: inset(0 0 0 0); } }
@keyframes uniBldgFloat { 0%{ transform: translateX(0); } 50%{ transform: translateX(-22px); } 100%{ transform: translateX(0); } }
@keyframes uniBldgSweep { 0%{ transform: translateX(-140%); opacity:0; } 12%{ opacity:1; } 88%{ opacity:1; } 100%{ transform: translateX(360%); opacity:0; } }
@keyframes uniBldgGlow { 0%,100%{ opacity:0.52; } 50%{ opacity:0.62; } }
/* Constrain the 100x100 SVG glyphs inside their orbital button */
.uni-sat-icon svg { width: 100% !important; height: 100% !important; display: block; }
/* Mini-CRM scroll areas — slim gold scrollbar */
.uni-crm-scroll::-webkit-scrollbar { width: 7px; }
.uni-crm-scroll::-webkit-scrollbar-thumb { background: rgba(176,138,63,0.30); border-radius: 7px; }
.uni-crm-scroll::-webkit-scrollbar-track { background: transparent; }
.uni-crm-hscroll::-webkit-scrollbar { height: 7px; }
.uni-crm-hscroll::-webkit-scrollbar-thumb { background: rgba(176,138,63,0.30); border-radius: 7px; }
.uni-crm-hscroll::-webkit-scrollbar-track { background: transparent; }
`;
document.head.appendChild(s);
}
// ============================================================================
// Geometry — split layout
// ============================================================================
// LEFT block sits in the left half of the canvas; RIGHT cluster sits at the
// right. Sized so it reads strongly when the 2560×1600 canvas is scaled
// down to a typical tablet — tiles, monogram, and label type are all
// generous.
const RIGHT_CX = 1880;
const RIGHT_CY = 830;
const SAT_R = 460;
const TILE_SIZE = 172;
const CIRCLE_R = 200; // centre circle holds the U monogram
// Mini-CRM panel — occupies the LEFT 60% of the 2560-wide canvas; the centre
// circle (right 40%) stays visible as the "centre button" while it's open.
const CRM_PANEL_W = 1536; // 60% of 2560
const CRM_PAD = 56;
// Pixel delta between explore-CX (1280) and home-RIGHT_CX (1880) — used to
// glide the cluster from the explore-centre position to the home-right
// position when arriving via the from-explore handoff.
const HOME_SLIDE_X = 600;
function Home() {
ensureHomeKeys();
const t = useLoop();
const [hovered, setHovered] = React.useState(null);
const [jumping, setJumping] = React.useState(null);
const [exiting, setExiting] = React.useState(false);
const [centerLand, setCenterLand] = React.useState(0);
const [ripples, fireRipple] = useRipple(1200);
const [bursts, fireBurst] = useBurst(900);
const modules = PROJECT.modules;
// ── Canvas-height density. 0 on the primary 16:10 tablet (H=1600, Tab S7 —
// mathematically UNCHANGED) → 1 on iPad Pro 4:3 (H=1920). Everything below
// keys off `dens` so the cluster, type and the new bottom fact-bar grow to
// fill the taller 4:3 canvas instead of leaving a dead gap underneath.
const H = (typeof window !== 'undefined' && window.UNIVERSE_CANVAS && window.UNIVERSE_CANVAS.H) || 1600;
const dens = clamp((H - 1600) / 320);
// Drop the cluster centre down so it tracks the auto-centred left text block
// (which sits at 50% of H) instead of floating high. Grows the orbit radius,
// tile size and centre circle so the whole assembly reads bigger on iPad.
const RCY = Math.round(RIGHT_CY + dens * 122); // 830 → ~952
const SR = Math.round(SAT_R + dens * 46); // 460 → ~506
const TS = Math.round(TILE_SIZE + dens * 22); // 172 → ~194
const circleScale = 1 + dens * 0.12; // centre circle / monogram
const innerR = CIRCLE_R * circleScale + 18;
const outerR = SR - TS / 2 - 12;
const labelFS = Math.round(27 + dens * 4);
const hotR = Math.round(CIRCLE_R * circleScale);
// ── Hidden Sales-Desk mini-CRM. Triple-tapping the centre circle (3 taps
// within ~600ms) toggles it open. The centre circle has no single-tap
// action of its own, so a lone tap is harmlessly absorbed by the counter.
const [crmOpen, setCrmOpen] = React.useState(false);
const tapRef = React.useRef({ n: 0, timer: null });
const handleCenterTap = () => {
const s = tapRef.current;
s.n += 1;
if (s.timer) clearTimeout(s.timer);
if (s.n >= 3) {
s.n = 0;
setCrmOpen(o => !o);
return;
}
s.timer = setTimeout(() => { s.n = 0; }, 600);
};
// Three entry modes:
// fromExplore — arrived seamlessly from /explore. Orbital + circle are
// already in their final positions. A settling wave rolls
// out from the centre, then the left-block text reveals
// cinematically (blur-to-clear stagger).
// firstVisit — full cinematic from-zero entrance (legacy first-load).
// return — back from a sub-screen. Fast-forward.
const fromExplore = React.useRef(
typeof sessionStorage !== 'undefined' && sessionStorage.getItem('uni-from-explore') === '1'
).current;
const isFirstVisit = React.useRef(
typeof sessionStorage === 'undefined' || !sessionStorage.getItem('uni-home-seen')
).current;
React.useEffect(() => {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem('uni-home-seen', '1');
sessionStorage.removeItem('uni-from-explore');
}
}, []);
// Cluster slide: when fromExplore, paint at translateX(-HOME_SLIDE_X) on the
// first frame, then on the SECOND frame flip `slid` to true so the CSS
// transition kicks in. Double-rAF guarantees the browser commits the start
// position to the GPU before applying the end position.
const [slid, setSlid] = React.useState(!fromExplore);
// The settling wave (the "ripple") only mounts AFTER the slide finishes.
// Otherwise the rings sit visibly at the centre while the cluster is still
// sliding — which reads as a flicker right before they expand outward.
const [waveReady, setWaveReady] = React.useState(false);
React.useEffect(() => {
if (!fromExplore) return;
const r1 = requestAnimationFrame(() => {
const r2 = requestAnimationFrame(() => setSlid(true));
// store r2 cleanup via outer closure
return () => cancelAnimationFrame(r2);
});
// Slide is 820ms. Mount the wave a hair after to guarantee the cluster
// has fully settled before the rings begin expanding.
const wt = setTimeout(() => setWaveReady(true), 860);
return () => { cancelAnimationFrame(r1); clearTimeout(wt); };
}, []);
// Phase clocks — branch on entry mode.
let titleP, paraP, statsP, circleP, lineP, buildingP, orbitalStart, eT;
if (fromExplore) {
// Orbital + circle + building are already settled (matches how /explore
// left them). Only the LEFT text block needs to fade in — and we delay
// it slightly so the user feels the settling wave first.
eT = t + 2.6; // pretend the entry-time has elapsed
circleP = 1; // circle fully drawn
lineP = 1; // underline fully drawn
buildingP = 1; // apartment fully revealed
orbitalStart = -100; // tiles all show at e=1 from t=0
// Cinematic text — first wave plays t=0..0.9, then text from t=0.7
// Slide takes ~820ms; defer text reveal so it lands AFTER the cluster
// has finished gliding into place.
titleP = clamp((t - 0.95) / 1.05);
paraP = clamp((t - 1.35) / 0.95);
statsP = clamp((t - 1.80) / 0.95);
} else {
const tOffset = isFirstVisit ? 0 : 1.6;
eT = t + tOffset;
titleP = clamp((eT - 0.2) / 0.9);
paraP = clamp((eT - 0.9) / 0.7);
statsP = clamp((eT - 1.4) / 0.8);
circleP = clamp((eT - 0.8) / 1.0);
orbitalStart = 1.2;
buildingP = clamp((eT - 2.0) / 1.0);
lineP = clamp((eT - 1.6) / 0.7);
}
// Click handler — set --dx/--dy then fire the keyframe.
const handleSatelliteClick = (ev, m, i, x, y) => {
if (jumping) return;
const root = document.querySelector('.tablet-bezel').getBoundingClientRect();
const scale = root.width / 2560;
const tapX = (ev.clientX - root.left) / scale;
const tapY = (ev.clientY - root.top) / scale;
fireRipple(tapX, tapY);
fireBurst(x, y, { count: 11 });
const iconEl = ev.currentTarget.querySelector('.uni-sat-icon');
if (iconEl) {
iconEl.style.setProperty('--dx', (RIGHT_CX - x) + 'px');
iconEl.style.setProperty('--dy', (RCY - y) + 'px');
}
setJumping(m.id);
// Landing flash near the end of the icon flight (~440ms in)
setTimeout(() => setCenterLand(c => c + 1), 440);
setTimeout(() => setExiting(true), 520);
setTimeout(() => navigate(m.id === 'masterplan' ? 'masterplan-explorer' : m.id), 640);
};
return (
{/* The twin-tower drawing is painted at the VIEWPORT level (see index.html
HOME_BG) so it's edge-to-edge with no cut at any aspect. Here we only
add a soft left cream scrim so the "Center of everything." headline
stays crisp over the lighter parts of the drawing. */}
{/* === COSMIC DUST BACKDROP === */}
{/* warm radial overlay favours the right (where the cluster lives) */}
{/* === SETTLING WAVE — three concentric rings emanate from the centre
AFTER the cluster glides into place. We gate mounting on
`waveReady` (set ~860ms after mount) so the rings don't sit
visibly at the centre during the slide — they begin expanding
the instant they appear, no held-start-state flicker. */}
{fromExplore && waveReady && (
{[0, 1, 2].map(i => (
))}
)}
{/* === TOP BAR (hidden while the mini-CRM is open) === */}
{!crmOpen &&
}
{/* === LEFT BLOCK — editorial title + paragraph + stats (hidden while CRM open) === */}
{!crmOpen && }
{/* === BOTTOM FACT BAR — fills the extra 4:3 canvas height with a refined
row of real project facts. Fades in with `dens` so the primary 16:10
tablet (dens 0) is untouched; full presence on iPad Pro. === */}
{!crmOpen && }
{/* === CLUSTER WRAPPER ============================================
When arriving from /explore, the cluster (spokes + centre circle
+ 8 pieces) starts SHIFTED LEFT by 600px — the exact delta from
home's RIGHT_CX (1880) back to explore's CX (1280) — so it sits
in the same screen position the explore page just left it. After
mount we flip a state and CSS-transition translateX(0) over
820ms, gliding the cluster across to its home location. */}
{/* === CONSTELLATION SPOKES (hidden while the mini-CRM is open) === */}
{!crmOpen && }
{/* === RIGHT CLUSTER — centre circle (logo) always shown; it IS the
"centre button" that remains when the orbital tiles disappear === */}
{/* HIDDEN CRM TRIGGER — invisible circular hotspot over the centre
circle. Triple-tap toggles the Sales-Desk console. No visible
affordance; single taps do nothing (absorbed by the tap counter). */}
{!crmOpen && modules.map((m, i) => {
const angle = (i * Math.PI/4) - Math.PI/2;
const x = RIGHT_CX + SAT_R * Math.cos(angle);
const y = RIGHT_CY + SAT_R * Math.sin(angle);
const start = orbitalStart + i * 0.10;
const local = clamp((eT - start) / 0.7);
const e = ease.outBack(local);
const isHovered = hovered === m.id;
const isJumping = jumping === m.id;
const isOtherJumping = jumping && jumping !== m.id;
const dxIn = (RIGHT_CX - x) * (1 - e) * 0.42;
const dyIn = (RIGHT_CY - y) * (1 - e) * 0.42;
return (
setHovered(m.id)}
onMouseLeave={()=>setHovered(null)}
onClick={(ev)=>handleSatelliteClick(ev, m, i, x, y)}
/>
{/* compact label */}
);
}
// ============================================================================
// LEFT block — editorial headline + paragraph + stat grid + scan hint
// ============================================================================
function LeftTitleBlock({ t, eT, titleP, paraP, statsP }) {
const cursorOn = (Math.sin(t * 9) > 0) && titleP > 0.95 && titleP < 1;
// Personalised welcome — when a walk-in is selected in the mini-CRM the home
// greets them by name, so the tablet handed to the customer feels curated.
const cust = window.UNI_SESSION && window.UNI_SESSION.getCustomer();
return (
{/* eyebrow — greets the selected walk-in, else the standard locator line */}
)}
{/* headline — two lines, "everything" italic gold */}
Center of
everything.
{cursorOn && (
)}
{/* underline */}
{/* paragraph */}
7 acres in the gravitational pull of Ahmedabad, with everything that
matters orbiting at walking distance: the schools, the hospitals,
the highway, the airport, and the living rooms of friends you haven't
met yet.
{/* stats strip — single airy horizontal row, footnote band */}
{/* LANDING FLASH + ring expansion when an icon arrives */}
{centerLand > 0 && (
{[0,1,2].map(i => (
))}
)}
{/* The U monogram — focal mark */}
);
}
// ============================================================================
// SatelliteButton — round orbital tile
// ============================================================================
function SatelliteButton({ m, i, isHovered, isJumping, onClick, onMouseEnter, onMouseLeave, t }) {
const Glyph = UIcons[m.id] || UIcons.story;
const active = isHovered || isJumping;
return (
{/* ambient halo when active */}
{active && (
)}
{/* keystone tick */}
{/* number badge */}
0{i + 1}
{/* glyph — flies on tap (jump → spin → glide to centre).
A gold halo "coin" rides under the icon during flight so it
never disappears against a busy background. */}