/* global React, ReactDOM, TweaksPanel, useTweaks, TweakSection, TweakRadio, TweakColor */
const { useRef, useEffect, useMemo, useState } = React;
// --- Data --------------------------------------------------------------------
const ROLES = [
{ title: "Creative Director", url: "https://forms.fillout.com/t/6EmWVwzNf7us" },
{ title: "Editor", url: "https://forms.fillout.com/t/tAK6mUPqEmus" },
{ title: "Comedy Writer", url: "https://forms.fillout.com/t/51WDLRKiSrus" },
{ title: "Producer", url: "https://forms.fillout.com/t/ceyPxYsdAQus" },
{ title: "Videographer", url: "https://forms.fillout.com/t/2pBkNy8s8ius" }];
const PHOTO_POOL = [
"assets/photo2.jpg", "assets/photo3.jpg",
"assets/photo4.jpg", "assets/photo6.jpg",
"assets/photo7.jpg", "assets/photo8.jpg", "assets/photo9.jpg",
"assets/photo10.jpg", "assets/photo11.jpg", "assets/photo12.jpg",
"assets/photo13.jpg", "assets/photo14.jpg",
"assets/photo16.jpg", "assets/photo17.jpg", "assets/photo18.jpg",
"assets/photo19.jpg", "assets/photo20.jpg", "assets/photo21.jpg",
"assets/photo22.jpg", "assets/photo23.jpg", "assets/photo24.jpg",
"assets/photo25.jpg", "assets/photo26.jpg", "assets/photo27.jpg",
"assets/photo28.jpg", "assets/photo29.jpg", "assets/photo30.jpg",
"assets/photo31.jpg", "assets/photo32.jpg", "assets/photo33.jpg",
"assets/photo34.jpg", "assets/photo35.jpg", "assets/photo36.jpg",
"assets/photo37.jpg", "assets/photo38.jpg", "assets/photo39.jpg",
"assets/photo40.jpg", "assets/photo41.jpg", "assets/photo42.jpg",
"assets/photo43.jpg", "assets/photo44.jpg", "assets/photo45.jpg",
"assets/photo46.jpg", "assets/photo47.jpg", "assets/photo48.jpg",
"assets/photo49.jpg", "assets/photo50.jpg", "assets/photo51.jpg",
"assets/photo52.jpg", "assets/photo54.jpg",
"assets/photo55.jpg"];
// Photos per row in the hero band (then doubled for seamless loop).
const BAND_LEN = 22;
// --- Photo pool validation -------------------------------------------------
// Probes every URL in PHOTO_POOL with an Image() and keeps only the ones
// that actually load. Anything missing/broken is silently dropped so it
// never renders as a gray placeholder. Returns null until verification is
// done so callers can render an empty backdrop instead of broken cards.
function useVerifiedPhotos() {
const [verified, setVerified] = React.useState(null);
React.useEffect(() => {
let cancelled = false;
const results = PHOTO_POOL.map((src) =>
new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(src);
img.onerror = () => resolve(null);
img.src = src;
})
);
Promise.all(results).then((arr) => {
if (cancelled) return;
setVerified(arr.filter(Boolean));
});
return () => { cancelled = true; };
}, []);
return verified;
}
// Shuffle that avoids back-to-back duplicates. When the pool is at least
// as big as `total`, we sample WITHOUT repeats (Fisher–Yates on a copy of
// the pool, then slice); otherwise we cycle pool[i % len] like before so
// shorter pools still fill the requested length.
function shuffleNoAdjacent(pool, total) {
for (let attempt = 0; attempt < 20; attempt++) {
let arr;
if (pool.length >= total) {
const shuffled = pool.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
arr = shuffled.slice(0, total);
} else {
arr = [];
for (let i = 0; i < total; i++) arr.push(pool[i % pool.length]);
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
let ok = true;
for (let i = 1; i < arr.length; i++) {
if (arr[i] !== arr[i - 1]) continue;
let swapped = false;
for (let k = i + 1; k < arr.length; k++) {
const prev = arr[i - 1],next = arr[i + 1];
const kPrev = arr[k - 1],kNext = arr[k + 1];
if (arr[k] !== prev && arr[k] !== next &&
arr[i] !== kPrev && arr[i] !== kNext) {
[arr[i], arr[k]] = [arr[k], arr[i]];
swapped = true;
break;
}
}
if (!swapped) {ok = false;break;}
}
if (ok) return arr;
}
const fallback = [];
for (let i = 0; i < total; i++) fallback.push(pool[i % pool.length]);
return fallback;
}
// --- Polaroid (standalone, used in offset photos + footer) ------------------
function Polaroid({ src, caption, rotate = 0, w = 200, className = "", style = {}, ...rest }) {
return (
{caption && {caption}}
);
}
// --- Photo Band (full-bleed horizontal scrollers) ---------------------------
function BandRow({ photos, variant }) {
// Double so the keyframe -50% translate loops seamlessly.
const doubled = [...photos, ...photos];
return (
{doubled.map((src, i) => {
// Pseudo-random tilt in -3..+3 deg, deterministic per index.
const r = i * 41 % 7 - 3;
return (
);
})}
);
}
// Bottom band — sits directly below the hero, the single layer of
// polaroids scrolling by before the page breaks into the next sections.
function PhotoBandBottom() {
const row = useMemo(() => shuffleNoAdjacent(PHOTO_POOL, BAND_LEN), []);
return (
);
}
// --- Photo Table (top-down pile of polaroids dropping like snow) ----------
// Looking straight down at a table so covered in face-up polaroids the
// table is NEVER visible. On load a full mosaic drops in; then every ~1.2s
// one new UNIQUE polaroid is dropped onto a systematically-chosen cell,
// covering whatever was there. The covered (now-invisible) tile underneath
// is removed a beat later — so the pile constantly refreshes, never grows
// taller, never shows blank space, and never shows a duplicate.
// Columns by width; ROWS derived from the viewport aspect so the (portrait)
// tiles tile evenly instead of stacking deep vertically — this is what keeps
// mobile from burying photos under each other.
function gridDims() {
const w = window.innerWidth, h = window.innerHeight || 800;
const cols = w < 560 ? 3 : w < 900 ? 4 : w < 1300 ? 6 : 7;
let rows = Math.round(0.78 * cols * (h / w));
rows = Math.max(3, Math.min(8, rows));
return { cols, rows };
}
// Position + tilt for a tile centered in its grid cell (small jitter only,
// so neighbours always overlap and no gap can open up).
function tilePose(cellId, cols, rows) {
const c = cellId % cols, r = Math.floor(cellId / cols);
const cellW = 100 / cols, cellH = 100 / rows;
const x = (c + 0.5) * cellW + (Math.random() - 0.5) * cellW * 0.1;
const y = (r + 0.5) * cellH + (Math.random() - 0.5) * cellH * 0.1;
const rot = (Math.random() * 2 - 1) * 9;
return { x, y, rot };
}
// Pose for a refreshed cell: near the cell centre (so it always covers the
// cell — no gaps) but with a clearly different tilt + a small shift from the
// photo it replaces, so it never reads as the exact same placement.
function tilePoseFresh(cellId, cols, rows, prev) {
const c = cellId % cols, r = Math.floor(cellId / cols);
const cellW = 100 / cols, cellH = 100 / rows;
const baseX = (c + 0.5) * cellW, baseY = (r + 0.5) * cellH;
let rot = (Math.random() * 2 - 1) * 9;
if (prev) {
let tries = 0;
while (Math.abs(rot - prev.rot) < 8 && tries++ < 10) rot = (Math.random() * 2 - 1) * 9;
if (Math.abs(rot - prev.rot) < 8) rot = prev.rot + (prev.rot >= 0 ? -10 : 10);
}
const x = baseX + (Math.random() - 0.5) * cellW * 0.14;
const y = baseY + (Math.random() - 0.5) * cellH * 0.14;
return { x, y, rot };
}
// Measure the hero headline (and logo) so new polaroids can avoid landing
// on top of the big white text. Returns a normalized rect or null.
function measureAvoid() {
const el = document.querySelector(".hero-title");
if (!el) return null;
const r = el.getBoundingClientRect();
const W = window.innerWidth, H = window.innerHeight;
if (!r.width || !r.height) return null;
// Pad a touch so drops keep clear of the glyph edges too.
const padX = W * 0.035, padY = H * 0.02;
return {
x0: (r.left - padX) / W, x1: (r.right + padX) / W,
y0: (r.top - padY) / H, y1: (r.bottom + padY) / H };
}
// Which cells sit under the hero headline — these are kept out of the
// refresh rotation (their initial photo just stays quietly behind the text).
function computeAvoidSet(cols, rows, avoid) {
const s = new Set();
if (!avoid) return s;
for (let id = 0; id < cols * rows; id++) {
const nx = (id % cols + 0.5) / cols;
const ny = (Math.floor(id / cols) + 0.5) / rows;
if (nx >= avoid.x0 && nx <= avoid.x1 && ny >= avoid.y0 && ny <= avoid.y1) s.add(id);
}
return s;
}
// One self-contained tile. It stays INVISIBLE until its image has actually
// loaded, then runs the drop-in animation — so a half-loaded photo can never
// flash its (black) box. `leaving` fades it out gracefully on replacement.
function Tile({ t }) {
const [loaded, setLoaded] = useState(false);
const onReady = () => setLoaded(true);
return (
);
}
function PhotoTable() {
const verifiedPool = useVerifiedPhotos();
const [tiles, setTiles] = useState([]);
const [inView, setInView] = useState(true);
const tableRef = useRef(null);
const keyRef = useRef(1);
const zRef = useRef(1);
const dimsRef = useRef(gridDims());
const avoidRef = useRef(new Set());
const timersRef = useRef([]);
// Initial full mosaic (and rebuild on resize).
useEffect(() => {
if (!verifiedPool || verifiedPool.length === 0) return;
const build = () => {
const { cols, rows } = gridDims();
dimsRef.current = { cols, rows };
const n = cols * rows;
avoidRef.current = computeAvoidSet(cols, rows, measureAvoid());
// shuffled photo deck (unique per cell) for the starting mosaic
const deck = verifiedPool.slice();
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
// Reveal order = organized chaos. Start with a fully-visible edge photo,
// then WEAVE the centre (under-text) cells in gradually among the outer
// ones — so the centre never floods first and no obvious pattern shows.
const avoid = avoidRef.current;
const outer = [], center = [];
for (let cid = 0; cid < n; cid++) (avoid.has(cid) ? center : outer).push(cid);
const shuf = (a) => {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
};
shuf(outer); shuf(center);
// First photo: a left/right edge cell so it's clearly visible, not buried.
const edgeIdx = outer.findIndex((cid) => { const c = cid % cols; return c === 0 || c === cols - 1; });
if (edgeIdx > 0) { const [e] = outer.splice(edgeIdx, 1); outer.unshift(e); }
// Weave centre cells into the outer run, evenly spaced, never at the start.
const orderArr = [];
const gap = center.length ? outer.length / (center.length + 1) : Infinity;
let ci = 0, nextAt = Math.max(2, Math.round(gap));
for (let i = 0; i < outer.length; i++) {
orderArr.push(outer[i]);
if (ci < center.length && i + 1 >= nextAt) { orderArr.push(center[ci++]); nextAt += gap; }
}
while (ci < center.length) orderArr.push(center[ci++]);
const rank = {};
orderArr.forEach((cid, k) => { rank[cid] = k; });
const step = Math.max(28, Math.min(60, Math.round(1500 / n)));
const next = [];
for (let cellId = 0; cellId < n; cellId++) {
next.push({
key: keyRef.current++,
cell: cellId,
photo: deck[cellId % deck.length],
// z follows spiral order so each photo lands on TOP of the prior.
z: rank[cellId] + 1,
delay: rank[cellId] * step,
...tilePose(cellId, cols, rows) });
}
zRef.current = n + 1;
setTiles(next);
};
build();
let to;
// Only rebuild when the WIDTH changes. Mobile browsers fire `resize`
// on every scroll as the URL bar shows/hides (height changes), which
// was re-dumping the whole pile mid-scroll. Ignoring height keeps the
// initial reveal a one-time event.
let lastW = window.innerWidth;
const onResize = () => {
if (window.innerWidth === lastW) return;
lastW = window.innerWidth;
clearTimeout(to);
to = setTimeout(build, 250);
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
clearTimeout(to);
timersRef.current.forEach(clearTimeout);
timersRef.current = [];
};
}, [verifiedPool]);
// Pause the whole pile (the 2.5s drop cadence + its timers/animations)
// whenever page 1 is scrolled out of view, so it costs nothing while the
// user reads the rest of the page. Resumes when it scrolls back in.
useEffect(() => {
const el = tableRef.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const obs = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
{ rootMargin: "120px" });
obs.observe(el);
return () => obs.disconnect();
}, []);
// Every 2.5s refresh one cell: a fresh photo drops in (covering the cell)
// and the cell's existing photo CROSS-FADES out. One stable photo per cell
// means neighbours overlap only lightly (never a stable >60% double), and
// a fade — not a pop — hides the outgoing one.
const ready = !!verifiedPool && tiles.length > 0;
useEffect(() => {
if (!ready || !inView) return;
const id = setInterval(() => {
const { cols, rows } = dimsRef.current;
const n = cols * rows;
setTiles((prev) => {
// Current (non-leaving) top photo + its z per cell. z = recency, so
// a low z means that spot has been sitting untouched the longest.
const topByCell = {};
for (const t of prev) {
if (t.leaving) continue;
if (!topByCell[t.cell] || t.z > topByCell[t.cell].z) topByCell[t.cell] = t;
}
// Candidates = every cell except the ones under the headline. Sort by
// staleness (oldest visible photo first) and pick from the stalest
// ~30% — so we always refresh a long-untouched spot, never re-cover a
// freshly-placed one, and cycle through the whole pile before
// repeating. The random pick within the stale pool keeps it organic.
let cand = [];
for (let cid = 0; cid < n; cid++) if (!avoidRef.current.has(cid)) cand.push(cid);
if (!cand.length) cand = Array.from({ length: n }, (_, i) => i);
cand.sort((a, b) =>
(topByCell[a] ? topByCell[a].z : -1) - (topByCell[b] ? topByCell[b].z : -1));
const poolSize = Math.min(cand.length, Math.max(3, Math.round(cand.length * 0.3)));
const cellId = cand[Math.floor(Math.random() * poolSize)];
const visible = new Set(Object.values(topByCell).map((t) => t.photo));
const avail = verifiedPool.filter((p) => !visible.has(p));
const photo = (avail.length ? avail : verifiedPool)[
Math.floor(Math.random() * (avail.length ? avail.length : verifiedPool.length))];
const tile = {
key: keyRef.current++,
cell: cellId,
photo,
z: zRef.current++,
delay: 0,
...tilePoseFresh(cellId, cols, rows, topByCell[cellId]) };
// Cross-fade out the photo that was in this (stalest) cell.
const leavingKeys = prev.filter((t) => t.cell === cellId && !t.leaving).map((t) => t.key);
const arr = prev.map((t) => leavingKeys.includes(t.key) ? { ...t, leaving: true } : t);
arr.push(tile);
if (leavingKeys.length) {
const tm = setTimeout(() => {
setTiles((cur) => cur.filter((t) => !leavingKeys.includes(t.key)));
timersRef.current = timersRef.current.filter((x) => x !== tm);
}, 700);
timersRef.current.push(tm);
}
return arr;
});
}, 2500);
return () => clearInterval(id);
}, [ready, inView]);
const factor = (100 / dimsRef.current.cols * 1.3).toFixed(2);
return (
Family Friendly was created with the unwavering mission of
becoming the biggest comedy brand on the planet.
To accomplish this, we've centered our lives around one trait:
Obsession. Obsessed with perfecting every detail. Obsessed with
making people laugh. Obsessed with being the best.
If this sounds like you, hit apply now and let's talk.
);
}
function RoleRow({ role }) {
return (
{role.title}
Apply Now
);
}
function Roles({ rolesRef }) {
return (
Roles
Apply now.
{ROLES.map((r) => )}
);
}
function Footer() {
// The footer polaroid swings open to reveal a hidden CTA. Interaction
// model differs by device:
// • Desktop (hover-capable): hover triggers the swing, leaving the
// element returns it. NO click toggling.
// • Touch (mobile): click toggles revealed state — first tap opens,
// second tap closes.
const [revealed, setRevealed] = useState(false);
const [isTouch, setIsTouch] = useState(false);
useEffect(() => {
// (hover: none) OR (pointer: coarse) → treat as touch.
const mq = window.matchMedia("(hover: none), (pointer: coarse)");
const update = () => setIsTouch(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
// On desktop, CSS handles the swing via .foot-polaroid-wrap:hover. The
// `revealed` state only ever toggles for touch, so the JS class stays
// off on desktop and doesn't fight the hover transition.
const wrapClass =
"foot-polaroid-wrap" + (
isTouch && revealed ? " foot-polaroid-wrap--revealed" : "");
const polaroidClass =
"foot-polaroid" + (
isTouch && revealed ? " foot-polaroid--revealed" : "");
return (
);
}
// --- Tweaks -----------------------------------------------------------------
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#ff6a1a"
} /*EDITMODE-END*/;
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const rolesRef = useRef(null);
useEffect(() => {
document.documentElement.style.setProperty("--c-accent", t.accent);
}, [t]);
const scrollToRoles = () => {
const title = document.querySelector(".roles-title");
if (title) {
const top = title.getBoundingClientRect().top + window.scrollY - 32;
window.scrollTo({ top, behavior: "smooth" });
} else if (rolesRef.current) {
window.scrollTo({ top: rolesRef.current.offsetTop - 24, behavior: "smooth" });
}
};
return (
{/* Smooth transition between page 1 (black hero) and page 2
(orange body) — short gradient strip that fades black → dark
orange → page orange so the two pages blend. */}
setTweak("accent", v)}
options={["#ff6a1a", "#e85d1f", "#c1421a", "#ff8a3d"]} />
);
}
ReactDOM.createRoot(document.getElementById("root")).render();