const { useState, useEffect, useRef } = React;
// Reveal on scroll
function Reveal({ children, delay = 0, as: Tag = 'div', className = '', ...props }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) { setVisible(true); io.disconnect(); }
});
}, { threshold: 0.1, rootMargin: '0px 0px -60px 0px' });
io.observe(el);
return () => io.disconnect();
}, []);
return (
{children}
);
}
// Masked text reveal (letters come up from below a mask)
function RevealText({ children, delay = 0, className = '', as: Tag = 'span' }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) { setVisible(true); io.disconnect(); }
});
}, { threshold: 0.2 });
io.observe(el);
return () => io.disconnect();
}, []);
return (
{children}
);
}
// Infinite marquee — clones children to create seamless loop
function Marquee({ children, duration = 40, reverse = false, gap = 48 }) {
const items = React.Children.toArray(children);
return (
{items.map((c, i) => {c})}
{items.map((c, i) => {c})}
);
}
// Live clock
function LiveClock({ tz = 'America/New_York', label = 'NYC' }) {
const [time, setTime] = useState(() => fmt(tz));
useEffect(() => {
const id = setInterval(() => setTime(fmt(tz)), 1000);
return () => clearInterval(id);
}, [tz]);
function fmt(zone) {
return new Date().toLocaleTimeString('en-US', {
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false, timeZone: zone
});
}
return {label} {time};
}
// Content shared across directions
const SERVICES = [
{ num: '01', name: 'Brand Identity', desc: 'Names, marks, systems, and the thinking that makes them stick. From wordmarks to full visual languages.', tags: ['Logo', 'Guidelines', 'Naming'] },
{ num: '02', name: 'Art Direction', desc: 'The angle, the frame, the palette. We shape campaigns with taste and conviction.', tags: ['Campaign', 'Editorial', 'Photo'] },
{ num: '03', name: 'Digital Design', desc: 'Websites and products that behave like the brand. Kinetic, curious, honest.', tags: ['Web', 'Motion', 'Product'] },
{ num: '04', name: 'Strategy', desc: 'Positioning, voice, and the hard questions. We say no until the yes is worth saying.', tags: ['Positioning', 'Voice', 'Research'] },
{ num: '05', name: 'Motion & Film', desc: 'Title sequences, product films, the bits in between. Story first, frame rate second.', tags: ['Film', 'Titles', 'Animation'] },
];
const PROCESS = [
{ num: '01', name: 'Interrogate', desc: 'We ask the obvious questions and the awkward ones. Every project starts with a week of listening before a pixel moves.' },
{ num: '02', name: 'Narrow', desc: 'Options are cheap. Decisions are expensive. We cut ten ideas down to one we can actually defend in a room.' },
{ num: '03', name: 'Make', desc: 'Deliberately, often, and in public. Work gets reviewed weekly in a shared doc, not at a polished reveal meeting.' },
{ num: '04', name: 'Ship', desc: 'We hand off guidelines that humans can actually use, plus a first year of care so the work stays the work.' },
];
const JOURNAL = [
{ cat: 'Thinking', date: 'Apr 2026', title: 'Why the best creative briefs are one page long', read: '5 min', slug: 'one-page-briefs' },
{ cat: 'Case Study', date: 'Mar 2026', title: 'Rebrand as subtraction: a post-mortem on doing less', read: '12 min', slug: 'rebrand-as-subtraction' },
{ cat: 'Essay', date: 'Feb 2026', title: 'In praise of saying no, to clients and to ourselves', read: '8 min', slug: 'saying-no' },
];
const MARQUEE_WORDS = [
'Brand Identity', 'Art Direction', 'Digital Design', 'Strategy', 'Motion & Film', 'Editorial'
];
Object.assign(window, { Reveal, RevealText, Marquee, LiveClock, SERVICES, PROCESS, JOURNAL, MARQUEE_WORDS });