// type-clean.jsx — Direction D: Type · Clean
// Minimal, typography-first. No boxes, no borders, no decoration —
// just type, whitespace, and a single restrained accent.
//
// Asset paths use window.ASSETS_BASE if set (so the WordPress theme can
// inject the theme dir URL) and fall back to relative './assets/'.
const __ASSETS = (typeof window !== 'undefined' && window.ASSETS_BASE) || 'assets/';
const typeCleanTokens = {
bg: '#f4f1ea',
ink: '#1b1b1a',
muted: 'rgba(27,27,26,0.5)',
faint: 'rgba(27,27,26,0.22)',
accent: '#ff5a1f',
};
function TypeCleanArtboard({ tweaks }) {
const rootRef = React.useRef(null);
// Initial language: server-injected (PHP geo-detect), else cookie, else navigator.language.
// Italians get 'it', everyone else gets 'en'.
const [lang, setLangState] = React.useState(() => {
if (typeof window === 'undefined') return 'it';
if (window.INITIAL_LANG === 'it' || window.INITIAL_LANG === 'en') return window.INITIAL_LANG;
try {
const m = document.cookie.match(/(?:^|; )bf_lang=([^;]+)/);
if (m && (m[1] === 'it' || m[1] === 'en')) return m[1];
} catch (e) {}
const nav = (navigator && navigator.language ? navigator.language : 'en').toLowerCase();
return nav.startsWith('it') ? 'it' : 'en';
});
const setLang = React.useCallback((next) => {
setLangState(next);
try { document.cookie = 'bf_lang=' + next + ';path=/;max-age=31536000;samesite=lax'; } catch (e) {}
}, []);
// Keep in sync so screen readers / search engines reflect the visible copy.
React.useEffect(() => {
if (typeof document !== 'undefined') { document.documentElement.lang = lang; }
}, [lang]);
const c = COPY[lang];
// Detect touch / coarse-pointer devices once — we suppress the custom
// cursor entirely on phones/tablets (it was rendering as a non-round
// shape on mobile and lingering during scroll).
const isTouch = React.useMemo(() => {
if (typeof window === 'undefined') return false;
try {
return window.matchMedia('(hover: none), (pointer: coarse)').matches
|| ('ontouchstart' in window);
} catch (e) { return false; }
}, []);
const Cursor = useArtboardCursor(rootRef, { color: '#ffffff', mixBlend: 'difference' });
// Mobile drawer (hamburger menu)
const [menuOpen, setMenuOpen] = React.useState(false);
React.useEffect(() => {
if (!menuOpen) return undefined;
const onKey = (e) => { if (e.key === 'Escape') setMenuOpen(false); };
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [menuOpen]);
// Hide cursor while scrolling, and hide the topbar when scrolling down
// (revealing it again when the user scrolls back up). Native browser-chrome
// feel — keeps reading distractions out of the way.
const [showTopbar, setShowTopbar] = React.useState(true);
React.useEffect(() => {
let lastY = window.scrollY;
let t = 0;
const onScroll = () => {
const y = window.scrollY;
const dy = y - lastY;
const root = rootRef.current;
if (root && !isTouch) {
root.dataset.scrolling = '1';
clearTimeout(t);
t = setTimeout(() => { if (rootRef.current) rootRef.current.dataset.scrolling = '0'; }, 220);
}
// Topbar visibility — small dead-zone so micro-scrolls don't flicker
if (Math.abs(dy) > 6) {
if (y < 80) setShowTopbar(true);
else if (dy > 0) setShowTopbar(false);
else setShowTopbar(true);
lastY = y;
}
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => { window.removeEventListener('scroll', onScroll); clearTimeout(t); };
}, [isTouch]);
// Smooth scroll helper for in-page anchors (drawer + nav)
const scrollToId = React.useCallback((id) => {
const el = document.getElementById(id);
if (!el) return;
const top = window.scrollY + el.getBoundingClientRect().top - 8;
try { window.scrollTo({ top, behavior: 'smooth' }); }
catch (e) { window.scrollTo(0, top); }
}, []);
const scrollToTop = React.useCallback(() => {
setMenuOpen(false);
try { window.scrollTo({ top: 0, behavior: 'smooth' }); }
catch (e) { window.scrollTo(0, 0); }
}, []);
// Nav-item → section id mapping (parallel to c.nav)
const NAV_IDS = ['work', 'philosophy', 'services', 'faq', 'contact'];
const heroVisual = tweaks?.cleanHero === 'visual';
const pal = tweaks?.cleanPalette || [typeCleanTokens.bg, typeCleanTokens.ink, typeCleanTokens.accent];
const [bg, ink, accent] = pal;
const displayFont = tweaks?.cleanDisplay || '"Instrument Serif", Georgia, serif';
const sansFont = tweaks?.cleanSans || '"Space Grotesk", system-ui, sans-serif';
const monoFont = '"JetBrains Mono", monospace';
const muted = `color-mix(in oklab, ${ink} 50%, transparent)`;
const faint = `color-mix(in oklab, ${ink} 18%, transparent)`;
// hover-anim ref for case list
const [openCase, setOpenCase] = React.useState(null);
const [openFaq, setOpenFaq] = React.useState(0);
const [openStack, setOpenStack] = React.useState(false);
const [contactOpen, setContactOpen] = React.useState(false);
const [activePhase, setActivePhase] = React.useState(0);
const phasesRef = React.useRef(null);
// Compute active phase from scroll position. The section is sized so each
// phase gets one viewport of scroll; while pinned, the active index advances.
React.useEffect(() => {
const onScroll = () => {
const sec = phasesRef.current;
if (!sec) return;
const rect = sec.getBoundingClientRect();
const total = sec.offsetHeight - window.innerHeight;
if (total <= 0) { setActivePhase(0); return; }
const progress = Math.max(0, Math.min(1, -rect.top / total));
const n = c.phases.length;
const idx = Math.max(0, Math.min(n - 1, Math.floor(progress * n)));
setActivePhase(idx);
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => window.removeEventListener('scroll', onScroll);
}, [c.phases.length]);
const contactRef = React.useRef(null);
// When the contact drawer opens, scroll it into view so the user lands
// on the new content (instead of having it appear off-screen below the hero).
React.useEffect(() => {
if (!contactOpen || !contactRef.current) return;
const id = setTimeout(() => {
if (!contactRef.current) return;
const rect = contactRef.current.getBoundingClientRect();
const top = window.scrollY + rect.top - 8;
try { window.scrollTo({ top, behavior: 'smooth' }); }
catch (e) { window.scrollTo(0, top); }
}, 120);
return () => clearTimeout(id);
}, [contactOpen]);
// simulated "now" time for the meta strip
const [now, setNow] = React.useState(() => new Date());
React.useEffect(() => {
const t = setInterval(() => setNow(new Date()), 60000);
return () => clearInterval(t);
}, []);
const timeStr = now.toLocaleTimeString(lang === 'it' ? 'it-IT' : 'en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Rome' });
return (
{/* TOP META BAR -- sticky, hides on scroll-down, returns on scroll-up.
Single-row layout on desktop: logo · nav · LinkedIn · IT/EN. */}
bartofactory
{/* Desktop inline nav */}
{c.nav.map((n, i) => (
{ e.preventDefault(); scrollToId(NAV_IDS[i]); }}
className="tc-link hoverable"
style={{
color: ink, fontFamily: sansFont, fontSize: 14, fontWeight: 400,
letterSpacing: '-0.005em', textTransform: 'none',
cursor: isTouch ? 'pointer' : 'none', textDecoration: 'none', whiteSpace: 'nowrap',
}}>{n}
))}
{/* Mobile: LinkedIn text + Menu text — same mono style as desktop link */}
LinkedIn
setMenuOpen(o => !o)}>
{menuOpen ? (lang === 'it' ? 'Chiudi' : 'Close') : 'Menu'}
{/* MOBILE DRAWER — fullscreen, drops from top.
Shares the same topbar layout so the page logo never appears twice. */}
bartofactory
LinkedIn
setMenuOpen(false)}>
{lang === 'it' ? 'Chiudi' : 'Close'}
{/* HERO ---------------------------------------------------------- */}
{/* Fullscreen gradient background — subtle moving blobs in brand colors */}
{!heroVisual ? (
<>
{/* Personal hello — "Ciao, sono Bart" */}
{c.heroHello}{' '}
{c.heroHelloName}
.
{/* Eyebrow */}
{c.heroEyebrow}
{/* Main hero — minimal, two lines, lots of breathing room */}
{c.heroLine1}
{c.heroLine2}
{/* Sub */}
{c.heroSub}
setContactOpen(o => !o)}
aria-expanded={contactOpen}>
{c.cta}
{c.ctaSub}
>
) : (
// Visual variant — name as the artwork
<>
{c.heroEyebrow}
Bart.
{c.heroLine1} {c.heroLine2}
{c.heroSubShort}
setContactOpen(o => !o)}
aria-expanded={contactOpen}
style={{
display: 'inline-flex', alignItems: 'center', gap: 12,
fontSize: 18, fontWeight: 500, color: ink, textDecoration: 'none', cursor: 'none',
padding: '12px 0', background: 'transparent', border: 0, borderRadius: 0,
borderBottom: `1.5px solid ${ink}`, fontFamily: sansFont,
}}>{c.cta}
→
>
)}
{/* Scroll cue */}
↓ {lang === 'it' ? 'scorri' : 'scroll'}
{/* CONTACT DRAWER — si apre dalla CTA hero ---------------------- */}
{lang === 'it' ? 'Sentiamoci' : 'Get in touch'}
{c.contactDrawerTitle}
{c.contactDrawerBody}
setContactOpen(false)}
style={{
background: 'transparent', border: `1px solid ${faint}`, borderRadius: 999,
width: 38, height: 38, cursor: isTouch ? 'pointer' : 'none', display: 'inline-flex',
alignItems: 'center', justifyContent: 'center', color: ink,
transition: 'background .2s, border-color .2s', flexShrink: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.background = ink; e.currentTarget.style.color = bg; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = ink; }}>
{/* PHILOSOPHY — single quote + portrait ---------------------- */}
{c.philosophyKicker.replace(/^\d+\s*—\s*/, '')}
{lang === 'it' ? (
<>Vengo da un mondo dove se il sistema sbaglia,
si ferma qualcosa di vero. Non da slide.>
) : (
<>I come from a world where if the system fails,
something real stops. Not from slides.>
)}
{c.philosophyP1}
{c.philosophyP2}
{/* Foto Bart — placeholder coerente: ritratto B/N, da sostituire */}
FIG. 01 RITRATTO · B/N
[ drop · foto Bart al lavoro ]
luce naturale, B/N, non in giacca
Bartolomeo Tiralongo · aka Bart
{/* PATH — logos del percorso. Scorrono come marquee. -------------- */}
{c.pathKicker}
{[...c.pathLogos, ...c.pathLogos].map((p, i) => (
))}
{/* PHASES — sticky scroll: 4 fasi raccontate come capitoli editoriali --- */}
{/* Title + sub */}
{c.phasesTitle}{' '}
{c.phasesTitleAccent}.
{c.phasesSub}
{/* Strip — typografica, niente icone. Linea sottile + 4 markers cliccabili. */}
{/* Base line */}
{/* Active fill */}
1 ? (activePhase / (c.phases.length - 1)) : 0})`,
height: 1, background: accent, zIndex: 0,
transition: 'width .5s cubic-bezier(.2,.7,.3,1)',
}} />
{c.phases.map((p, i) => {
const isActive = i === activePhase;
const isPast = i < activePhase;
return (
{
const sec = phasesRef.current; if (!sec) return;
const total = sec.offsetHeight - window.innerHeight;
const targetProgress = (i + 0.5) / c.phases.length;
const top = window.scrollY + sec.getBoundingClientRect().top + total * targetProgress;
try { window.scrollTo({ top, behavior: 'smooth' }); } catch (e) { window.scrollTo(0, top); }
}}
className="hoverable"
style={{
background: 'transparent', border: 0, padding: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14,
position: 'relative', zIndex: 1, cursor: isTouch ? 'pointer' : 'none',
}}>
{p.when}
{p.t}
);
})}
{/* Editorial content — no borders, type-led. Cards stacked, only active visible. */}
{c.phases.map((p, i) => {
const offset = i - activePhase;
const isActive = offset === 0;
return (
{/* Left column — phase number + time */}
{/* Right column — title + body + bullets */}
{p.t}.
{p.d}
{p.bullets.map((b, j) => (
0{j + 1}
{b}
))}
);
})}
{/* SERVICES — three pillars (Decisioni / Architettura / Persone) -- */}
{c.servicesKicker.replace(/^\d+\s*—\s*/, '')}
{c.servicesIntro}
{c.servicePillars.map((pillar, pi) => (
— 0{pi + 1} {lang === 'it' ? 'pilastro' : 'pillar'}
{pillar.label}.
{pillar.gloss}
{pillar.services.map((s, si) => (
{ e.currentTarget.style.paddingLeft = '16px'; }}
onMouseLeave={(e) => { e.currentTarget.style.paddingLeft = '0px'; }}>
{s.t}
{s.d}
))}
))}
{/* WORK — long list, hover-reveal detail ----------------------- */}
{c.workKicker.replace(/^\d+\s*—\s*/, '')}
{c.workIntro}
{c.cases.filter(cs => !cs.minor).map((cs, i, arr) => {
const isOpen = openCase === i;
return (
setOpenCase(isOpen ? null : i)}
style={{
padding: '40px 0', borderTop: `1px solid ${faint}`,
borderBottom: i === arr.length - 1 ? `1px solid ${faint}` : 'none',
cursor: 'none',
}}>
/{String(i + 1).padStart(2, '0')}
{cs.client.split('·')[0].trim()}
{cs.client.includes('·') && (
· {cs.client.split('·')[1].trim()}
)}
{cs.role}
{[[cs.stat1, cs.stat1l], [cs.stat2, cs.stat2l], [cs.stat3, cs.stat3l]].map(([v, l], j) => (
))}
{cs.client.startsWith('BRUM') && (
BRUM Patenti — {lang === 'it' ? 'piattaforma live' : 'live platform'} · brumpatenti.it
→ {lang === 'it' ? 'screenshot dell\'app in arrivo' : 'app screenshots coming soon'}
)}
);
})}
{/* Altri progetti recenti */}
{c.cases.some(cs => cs.minor) && (
{lang === 'it' ? 'Altri progetti recenti —' : 'Other recent projects —'}
{c.cases.filter(cs => cs.minor).map((cs, i) => (
{cs.client}
({cs.title.replace(/\.$/, '')})
))}
)}
{/* TESTIMONIALS — nascosti per ora finché non arrivano i testi reali ---- */}
{false && (
{c.testimonialsKicker.replace(/^\d+\s*—\s*/, '')}
{c.testimonials.map((t, i) => {
const cleaned = t.quote.replace(/^\[|\]$/g, '');
return (
“
{cleaned}
”
{t.author}
— {t.role}, {t.company}
{t.placeholder && (
{lang === 'it' ? 'placeholder — da raccogliere' : 'placeholder — to collect'}
)}
);
})}
)}
{/* FAQ — disinnesca obiezioni ----------------------------------- */}
{c.faqKicker.replace(/^\d+\s*—\s*/, '')}
{c.faqIntro}
{c.faqs.map((f, i) => {
const isOpen = openFaq === i;
return (
setOpenFaq(isOpen ? -1 : i)}
style={{
borderTop: `1px solid ${faint}`,
borderBottom: i === c.faqs.length - 1 ? `1px solid ${faint}` : 'none',
padding: '28px 0',
cursor: 'none',
transition: 'padding-left .35s cubic-bezier(.2,.7,.3,1)',
}}
onMouseEnter={(e) => { e.currentTarget.style.paddingLeft = '16px'; }}
onMouseLeave={(e) => { e.currentTarget.style.paddingLeft = '0px'; }}>
{String(i + 1).padStart(2, '0')}
{f.q}
+
);
})}
{c.footerAvailability}
{/* CTA --------------------------------------------------------- */}
{/* FOOTER */}
bartofactory
{c.footerLine}© 2026
{!isTouch &&
}
);
}
window.TypeCleanArtboard = TypeCleanArtboard;