// While² — landing page marketing sections
// Sans-serif (Geist) marketing chrome a la openclaw.ai. In-app screen
// internals stay DotGothic16 monospace.
const _C = '#5cd7ff';
const _INK = '#e8e6df';
const _DIM = 'rgba(232,230,223,0.6)';
const _FAINT = 'rgba(232,230,223,0.32)';
const _HAIR = 'rgba(232,230,223,0.12)';
const _SANS = '"Geist", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif';
const _MONO = '"Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace';
// Inject one-time dolphin float keyframes
if (typeof document !== 'undefined' && !document.getElementById('w2-dolphin-anim')) {
const s = document.createElement('style');
s.id = 'w2-dolphin-anim';
s.textContent = `
@keyframes w2-dolphin-float {
0% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-8px) rotate(-2deg); }
50% { transform: translateY(-14px) rotate(0deg); }
75% { transform: translateY(-8px) rotate(2deg); }
100% { transform: translateY(0) rotate(0deg); }
}
@keyframes w2-cmd-fade {
0% { opacity: 0; transform: translateY(6px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes w2-tab-progress {
0% { transform: scaleX(0); }
100% { transform: scaleX(1); }
}
@keyframes w2-shout-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-1.5px); }
75% { transform: translateX(1.5px); }
}
.w2-dolphin-float { animation: w2-dolphin-float 4.2s ease-in-out infinite; }
/* multi-part dolphin — independent loops per limb, kept subtle */
@keyframes w2-fin-flap {
0%, 100% { transform: rotate(-0.8deg); }
50% { transform: rotate(2.5deg); }
}
@keyframes w2-tail-sway {
0%, 100% { transform: rotate(0.8deg); }
50% { transform: rotate(-1.6deg); }
}
@keyframes w2-right-wave {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-1px) rotate(0.8deg); }
}
.w2-dol-part { position: absolute; inset: 0; width: 100%; height: 100%; }
/* group node = body float (everyone follows) */
.w2-dol-group { position: relative; width: 100%; height: 100%; animation: w2-dolphin-float 4.2s ease-in-out infinite; }
/* per-part overlays animate ON TOP of the group transform */
.w2-dol-tail { animation: w2-tail-sway 4.6s ease-in-out infinite; transform-origin: 70% 60%; }
.w2-dol-fin { animation: w2-fin-flap 3.6s ease-in-out infinite; transform-origin: 30% 80%; }
.w2-dol-right{ animation: w2-right-wave 5s ease-in-out infinite; transform-origin: 30% 50%; }
@keyframes w2-phrase-fade {
0% { opacity: 0; transform: translateY(4px); }
12% { opacity: 1; transform: translateY(0); }
88% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-4px); }
}
/* ── Responsive (≤ 720px) ───────────────────────────── */
@media (max-width: 720px) {
.w2-hero { padding: 64px 20px 28px !important; }
.w2-section-pad { padding: 16px 32px 56px !important; }
.w2-section-pad-y { padding: 56px 0 !important; }
.w2-section-pad-y > div { padding: 0 32px !important; }
.w2-download { padding: 120px 32px 48px !important; }
.w2-cmd-body { grid-template-columns: 1fr !important; }
.w2-cmd-screen { border-left: none !important; border-top: 1px solid rgba(232,230,223,0.12) !important; min-height: 320px !important; }
.w2-cmd-block .w2-cmd-body > div:first-child { padding: 22px 20px !important; }
.w2-footer-grid { grid-template-columns: 1fr !important; }
.w2-footer-socials { justify-content: flex-start !important; }
}
@media (max-width: 480px) {
.w2-hero { padding: 48px 16px 24px !important; }
.w2-section-pad { padding: 12px 28px 48px !important; }
.w2-section-pad-y > div { padding: 0 28px !important; }
.w2-download { padding: 96px 28px 40px !important; }
}
`;
document.head.appendChild(s);
}
// Patched-line pairs now live in copy.heroPatchedPairs (per-language).
// Layered, animated dolphin. 4 PNG parts stacked by suffix order. The whole
// group floats together (so fins/tail follow the body), and each part adds its
// own subtle micro-motion on top of the group transform.
const Dolphin = ({ size = 64, animate = false, style }) =>
;
function TopBar() {return null;}
// ── Hero ──────────────────────────────────────────────────────
function Hero({ copy }) {
const [shout, setShout] = React.useState(false);
return (
setShout(true)}
onMouseLeave={() => setShout(false)}
onTouchStart={() => setShout(s => !s)}
style={{ cursor: 'pointer', marginBottom: 22, lineHeight: 0 }}>
{/* OpenClaw-style wordmark — heavy sans, horizontal cyan gradient */}
while2
{/* FACT line — cyan gradient, OpenClaw-tagline style */}
{shout ? (
ALLOW! ALLOW!
) : (
{copy.heroFact}
)}
{/* TARGET line — who it's for */}
{copy.heroTarget}
);
}
// ScrambledText: per-char wave-jitter reveal a la ScrambledText.swift
const W2_GLYPH_POOL = '!<>-_\\/[]{}—=+*^?#·░▒▓█';
function useScramble(target, duration = 520) {
const [out, setOut] = React.useState(target);
const startRef = React.useRef(0);
const rafRef = React.useRef(0);
const targetRef = React.useRef(target);
const revealsRef = React.useRef([]);
React.useEffect(() => {
targetRef.current = target;
startRef.current = performance.now();
// per-char reveal time: (i/len*0.55 + rand*0.45) * duration
const len = target.length;
revealsRef.current = Array.from({ length: len }, (_, i) =>
(i / Math.max(1, len) * 0.55 + Math.random() * 0.45) * duration
);
const tick = (now) => {
const t = now - startRef.current;
let next = '';
let allDone = true;
for (let i = 0; i < target.length; i++) {
const reveal = revealsRef.current[i];
const ch = target[i];
if (t >= reveal || ch === ' ') {
next += ch;
} else {
allDone = false;
// pick a random glyph; refresh ~every frame
next += W2_GLYPH_POOL[Math.floor(Math.random() * W2_GLYPH_POOL.length)];
}
}
setOut(next);
if (!allDone) {
rafRef.current = setTimeout(() => tick(performance.now()), 16);
} else {
setOut(target);
}
};
rafRef.current = setTimeout(() => tick(performance.now()), 16);
return () => clearTimeout(rafRef.current);
}, [target, duration]);
return out;
}
function ScrambledSpan({ value, style }) {
const out = useScramble(value);
return (
{out}
);
}
function ScrambledPatchedLine({ copy }) {
const pairs = copy.heroPatchedPairs;
const tmpl = copy.heroPatchedTemplate;
const label = copy.heroPatchedLabel;
const [i, setI] = React.useState(0);
const measureRef = React.useRef(null);
const [shift, setShift] = React.useState(0);
const [isNarrow, setIsNarrow] = React.useState(
typeof window !== 'undefined' ? window.innerWidth <= 720 : false
);
React.useEffect(() => {
const onResize = () => setIsNarrow(window.innerWidth <= 720);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// reset to a fresh random index whenever language (pairs) changes
React.useEffect(() => {setI(0);}, [pairs]);
React.useEffect(() => {
const id = setInterval(() => {
setI((prev) => {
// never repeat the same pair
let next = prev;
while (next === prev && pairs.length > 1) {
next = Math.floor(Math.random() * pairs.length);
}
return next;
});
}, 5000);
return () => clearInterval(id);
}, [pairs]);
React.useLayoutEffect(() => {
if (!measureRef.current) return;
if (isNarrow) { setShift(0); return; }
const ro = new ResizeObserver(() => {
const w = measureRef.current.offsetWidth;
setShift(-w / 2);
});
ro.observe(measureRef.current);
const w = measureRef.current.offsetWidth;
setShift(-w / 2);
return () => ro.disconnect();
}, [i, tmpl, isNarrow]);
const [v1, v2] = pairs[i % pairs.length];
// Split the template at __A__ and __B__ so we render scrambled spans inline
// in the right position regardless of language word order.
// tmpl examples:
// "Remembering to __A__ when __B__."
// "__B__ときに __A__ ことを覚えていること。"
// We tokenize into a list of { kind: 'text'|'A'|'B', val? }.
const tokens = React.useMemo(() => {
const out = [];
const re = /__A__|__B__/g;
let last = 0,m;
while ((m = re.exec(tmpl)) !== null) {
if (m.index > last) out.push({ kind: 'text', val: tmpl.slice(last, m.index) });
out.push({ kind: m[0] === '__A__' ? 'A' : 'B' });
last = m.index + m[0].length;
}
if (last < tmpl.length) out.push({ kind: 'text', val: tmpl.slice(last) });
return out;
}, [tmpl]);
return (
{label}
{tokens.map((t, ti) => {
if (t.kind === 'text') {
// On narrow screens, encourage a break before " when " so the line
// doesn't overflow horizontally.
if (isNarrow) {
const parts = t.val.split(/(\s+when\s+)/);
return (
{parts.map((p, pi) => {
if (/^\s+when\s+$/.test(p)) {
return {' '} when{' '} ;
}
return {p} ;
})}
);
}
return {t.val} ;
}
const val = t.kind === 'A' ? v1 : v2;
return (
);
})}
);
}
function RotatingPatchedLine() {
const [i, setI] = React.useState(0);
const measureRef = React.useRef(null);
const [shift, setShift] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => {
setI((p) => (p + 1) % W2_PHRASES.length);
}, 3000);
return () => clearInterval(id);
}, []);
// Center the whole line by measuring its width and shifting half-of-difference.
React.useLayoutEffect(() => {
if (!measureRef.current) return;
const ro = new ResizeObserver(() => {
const w = measureRef.current.offsetWidth;
setShift(-w / 2);
});
ro.observe(measureRef.current);
const w = measureRef.current.offsetWidth;
setShift(-w / 2);
return () => ro.disconnect();
}, [i]);
return (
Patched:
“{W2_PHRASES[i]}”
);
}
function RotatingPhrase() {
const [i, setI] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => {
setI((p) => (p + 1) % W2_PHRASES.length);
}, 3000);
return () => clearInterval(id);
}, []);
return (
“{W2_PHRASES[i]}”
);
}
function AppStoreBtn({ store, copy, primary }) {
const isIos = store === 'ios';
// iOS is live → real App Store link. Mac is still coming-soon (non-interactive).
const Tag = isIos ? 'a' : 'div';
const interactiveProps = isIos
? { href: 'https://apps.apple.com/us/app/while/id6764445257', target: '_blank', rel: 'noopener noreferrer' }
: { 'aria-disabled': 'true' };
const ink = isIos ? '#fff' : 'rgba(255,255,255,0.55)';
return (
{/* Apple logo */}
{isIos
? (copy ? copy.downloadOnThe : 'Download on the')
: (copy ? copy.comingSoonTo : 'Coming soon to')}
{isIos ?
copy ? copy.downloadIos : 'App Store' :
copy ? copy.downloadMac : 'Mac App Store'}
);
}
function SectionHeader({ marker, title, sub }) {
return (
{marker}
{title}
{sub ?
{sub}
:
null}
);
}
// ── Use Cases (instant relatability) ──────────────────────────
function UseCasesSection({ copy }) {
return (
{copy.useCases.map((item, i) =>
{item}
)}
);
}
// ── Hidden Mode (terminal easter egg via shake) ──────────────
function HiddenModeSection({ copy }) {
return (
{/* visual hint — phone with shake motion arrows */}
);
}
// ── Dev Note (personal letter) ───────────────────────────────
function DevNoteSection({ copy }) {
return (
{copy.devNoteTitle}
{copy.devNote.map((p, i) =>
{p}
)}
);
}
// ── Reference (compact command docs) ─────────────────────────
function ReferenceSection({ copy }) {
const [active, setActive] = React.useState(0);
const cmds = copy.refCmds;
// Below this width we keep the tab+single-phone UX; above it we lay out all
// three phones in a row so the section doesn't feel sparse on wide screens.
const [isNarrow, setIsNarrow] = React.useState(
typeof window !== 'undefined' ? window.innerWidth <= 900 : false
);
React.useEffect(() => {
const onResize = () => setIsNarrow(window.innerWidth <= 900);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return (
{copy.refSectionTitle && (
{copy.refSectionTitle}
)}
{isNarrow ?
{/* prev / next arrows beside the phone */}
setActive((active - 1 + cmds.length) % cmds.length)}
aria-label="previous"
style={{
position: 'absolute', left: -16, top: 180,
width: 40, height: 40, borderRadius: 999,
background: 'rgba(0,0,0,0.4)', border: `1px solid ${_HAIR}`,
color: _INK, fontFamily: _MONO, fontSize: 18, lineHeight: 1,
cursor: 'pointer', zIndex: 5,
backdropFilter: 'blur(8px)',
transition: 'all 0.18s'
}}>‹
setActive((active + 1) % cmds.length)}
aria-label="next"
style={{
position: 'absolute', right: -16, top: 180,
width: 40, height: 40, borderRadius: 999,
background: 'rgba(0,0,0,0.4)', border: `1px solid ${_HAIR}`,
color: _INK, fontFamily: _MONO, fontSize: 18, lineHeight: 1,
cursor: 'pointer', zIndex: 5,
backdropFilter: 'blur(8px)',
transition: 'all 0.18s'
}}>›
{cmds.map((c, i) =>
)}
:
// Desktop: all three side by side, no tabs needed.
{cmds.map((c, i) =>
)}
}
);
}
function CommandBlock({ c, termPrefix, compact, lang }) {
// Map the legacy `screen` value to the screenshot name used in /assets/img/screens.
const screenName =
c.screen === 'home' ? 'list' :
c.screen === 'new' ? 'add' :
'config';
return (
{/* phone frame with the matching screenshot */}
{/* copy */}
);
}
// Real-device screenshot, sourced from per-language captures composited with
// a shared header overlay (so the status-bar / time matches across locales)
// and a cursor on/off overlay alternating frame to blink the terminal caret.
function ScreenshotPair({ name, lang }) {
const safeLang = lang || 'en';
const [frame, setFrame] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setFrame((f) => 1 - f), 530);
return () => clearInterval(id);
}, []);
return (
);
}
function PhoneFrame({ label, children }) {
// If children is a screenshot, skip the scale-down canvas; img fills the
// frame directly anchored to the top. Otherwise use the iPhone-resolution
// canvas (390×844 scaled) that the original JSX screen components expect.
const isScreenshot = children?.type === ScreenshotPair;
// Crop the bottom: phone is only ~2/3 visible. Bottom edge fades.
const PHONE_W = 260;
const VISIBLE_H = 380;
const fadeMask = 'linear-gradient(180deg, #000 0, #000 80%, transparent 100%)';
return (
{/* inner screen — black, holds the image */}
{!isScreenshot &&
}
{isScreenshot ?
children :
}
{label ?
{label}
:
null}
);
}
// ── Mobile (3 phone screens) ──────────────────────────────
function MobileSection({ copy }) {
// Track viewport — only true mobile width gets the carousel treatment.
const [isNarrow, setIsNarrow] = React.useState(
typeof window !== 'undefined' ? window.innerWidth <= 720 : false
);
React.useEffect(() => {
const onResize = () => setIsNarrow(window.innerWidth <= 720);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const screens = [
,
,
,
];
const [idx, setIdx] = React.useState(0);
// Swipe state — finger drag offset in px, null when not dragging.
const PHONE_W = 260;
const SWIPE_THRESHOLD = 50;
const startX = React.useRef(null);
const [drag, setDrag] = React.useState(0);
const [dragging, setDragging] = React.useState(false);
const onTouchStart = (e) => {
startX.current = e.touches[0].clientX;
setDragging(true);
setDrag(0);
};
const onTouchMove = (e) => {
if (startX.current == null) return;
setDrag(e.touches[0].clientX - startX.current);
};
const onTouchEnd = () => {
if (Math.abs(drag) > SWIPE_THRESHOLD) {
const n = screens.length;
if (drag < 0) setIdx((idx + 1) % n);
else if (drag > 0) setIdx((idx - 1 + n) % n);
}
startX.current = null;
setDragging(false);
setDrag(0);
};
return (
{isNarrow ? (
// Single phone, swipeable carousel
{screens.map((s, i) => {
// Wrap-around: place each screen at its shortest signed offset
// from the active one so the next/prev neighbour visually
// appears even at the ends of the array.
const n = screens.length;
let offset = i - idx;
if (offset > n / 2) offset -= n;
else if (offset < -n / 2) offset += n;
return (
);
})}
{/* dots */}
{screens.map((_, i) => (
setIdx(i)} aria-label={`screen ${i+1}`}
style={{
width: 7, height: 7, borderRadius: '50%', border: 'none',
padding: 0, cursor: 'pointer',
background: i === idx ? _C : 'rgba(232,230,223,0.25)',
boxShadow: i === idx ? `0 0 8px ${_C}` : 'none',
transition: 'background 0.2s, box-shadow 0.2s'
}} />
))}
) : (
// Side-by-side (desktop)
)}
);
}
// ── Download ────────────────────────────────────────────────
function DownloadSection({ copy }) {
return (
{/* radial halo behind the section */}
{/* concentric pulse rings behind the dolphin */}
{/* price callout — price tag + catchphrase */}
{/* price */}
{copy.priceTag}
{copy.priceTagSuffix}
{/* catchphrase */}
{copy.priceLine}
{copy.priceMicro &&
{copy.priceMicro.split('@daisandenki').map((seg, i, arr) =>
{seg}
{i < arr.length - 1 &&
@daisandenki
}
)}
}
{/* CTA button */}
{/* Share code block */}
);
}
function ShareCodeBlock({ copy }) {
const comments = copy.shareComments || [];
const cond = copy.shareCondition || 'you.want_to_help';
const [head, tail] = cond.includes('.') ? cond.split(/\.(.+)/) : [cond, ''];
// syntax colors
const COMMENT = 'rgba(126,170,156,0.72)';
const KW = '#ff8fc6';
const FN = '#f0c053';
const PROP = _INK;
const PUNCT = 'rgba(232,230,223,0.55)';
// On narrow viewports stack buttons vertically so the whole block stays
// narrow enough to be visually centered, instead of stretching to viewport.
const [isNarrow, setIsNarrow] = React.useState(
typeof window !== 'undefined' ? window.innerWidth <= 720 : false
);
React.useEffect(() => {
const onResize = () => setIsNarrow(window.innerWidth <= 720);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return (
{comments.map((c, i) => (
{`// ${c}`}
))}
if
(
{head}
{tail && <>
.
{tail.replace(/\(\)$/, '')}
()
>}
) {'{'}
{'}'}
);
}
function ShareOnXBtn({ copy }) {
const tagline = copy.shareTagline || '';
const text = `while² — cron jobs, for humans.${tagline ? `\n${tagline}` : ''}`;
const url = 'https://while2.com/';
const href = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`;
return (
{
e.currentTarget.style.background = 'rgba(255,255,255,0.09)';
e.currentTarget.style.borderColor = 'rgba(232,230,223,0.32)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
e.currentTarget.style.borderColor = 'rgba(232,230,223,0.18)';
}}
onMouseDown={(e) => { e.currentTarget.style.transform = 'translateY(1px)'; }}
onMouseUp={(e) => { e.currentTarget.style.transform = 'translateY(0)'; }}>
{copy.shareCta}
);
}
function CopyLinkBtn({ copy }) {
const tagline = copy.shareTagline || '';
const text = `while² — cron jobs, for humans.${tagline ? `\n${tagline}` : ''}\nhttps://while2.com/`;
const [copied, setCopied] = React.useState(false);
const onClick = async (e) => {
e.preventDefault();
try {
await navigator.clipboard.writeText(text);
} catch {
// Fallback for older browsers / non-secure contexts
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch {}
document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
return (
{
e.currentTarget.style.background = 'rgba(255,255,255,0.09)';
e.currentTarget.style.borderColor = 'rgba(232,230,223,0.32)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
e.currentTarget.style.borderColor = 'rgba(232,230,223,0.18)';
}}
onMouseDown={(e) => { e.currentTarget.style.transform = 'translateY(1px)'; }}
onMouseUp={(e) => { e.currentTarget.style.transform = 'translateY(0)'; }}>
{copied ? (
) : (
)}
{copied ? copy.shareCtaCopied : copy.shareCtaCopy}
);
}
function ShareOnThBtn({ copy }) {
const tagline = copy.shareTagline || '';
const text = `while² — cron jobs, for humans.${tagline ? `\n${tagline}` : ''}\nhttps://while2.com/`;
const href = `https://www.threads.net/intent/post?text=${encodeURIComponent(text)}`;
return (
{
e.currentTarget.style.background = 'rgba(255,255,255,0.09)';
e.currentTarget.style.borderColor = 'rgba(232,230,223,0.32)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
e.currentTarget.style.borderColor = 'rgba(232,230,223,0.18)';
}}
onMouseDown={(e) => { e.currentTarget.style.transform = 'translateY(1px)'; }}
onMouseUp={(e) => { e.currentTarget.style.transform = 'translateY(0)'; }}>
{copy.shareCtaTh}
);
}
// ── Footer ────────────────────────────────────────────────────
function Footer({ copy }) {
const linkStyle = {
color: 'inherit', textDecoration: 'none',
borderBottom: `1px solid ${_HAIR}`, paddingBottom: 1,
transition: 'color 0.15s, border-color 0.15s'
};
// copy.footerCopyright looks like "while² · 2026 · daisandenki" — turn the
// trailing "daisandenki" segment into an X link.
const parts = copy.footerCopyright.split('daisandenki');
return (
{copy.footerManifesto &&
{copy.footerManifesto}
}
);
}
// ── Mac (also-on-mac) ──────────────────────────────────────────
function MacSection({ copy }) {
return (
);
}
// ── AI Says (centered slideshow) ─────────────────────────────
function AISaysSection({ copy }) {
const cards = copy.aiSays;
const N = cards.length;
const [idx, setIdx] = React.useState(0);
const GAP = 24;
// Track viewport so we can keep the card narrow enough on phones for the
// adjacent cards to peek in from both sides.
const [cardW, setCardW] = React.useState(360);
React.useEffect(() => {
const update = () =>
setCardW(Math.min(360, Math.floor(window.innerWidth * 0.68)));
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
const STEP = cardW + GAP;
// Auto-advance: every 6s, move to the next card.
React.useEffect(() => {
const id = setInterval(() => setIdx((i) => (i + 1) % N), 6000);
return () => clearInterval(id);
}, [N]);
return (
{cards.map((r, i) => {
// Shortest-path wrap so the active card stays in view and the
// transition slides seamlessly when wrapping past the ends.
let offset = i - idx;
if (offset > N / 2) offset -= N;
else if (offset < -N / 2) offset += N;
const isActive = offset === 0;
const dist = Math.abs(offset);
return (
);
})}
);
}
function AIReviewCard({ r }) {
return (
“{r.review.split(/\*\*([^*]+)\*\*/).map((part, i) =>
i % 2 === 1 ?
{part} :
{part}
)}”
);
}
const W2_LANGS = [
{ code: 'en', label: 'EN', name: 'English' },
{ code: 'ja', label: 'JA', name: '日本語' },
{ code: 'zh-Hans', label: '中', name: '简体中文' },
{ code: 'zh-Hant', label: '繁', name: '繁體中文' },
{ code: 'ko', label: 'KO', name: '한국어' },
{ code: 'de', label: 'DE', name: 'Deutsch' },
{ code: 'fr', label: 'FR', name: 'Français' },
{ code: 'es', label: 'ES', name: 'Español' },
{ code: 'pt-BR', label: 'PT', name: 'Português' },
{ code: 'it', label: 'IT', name: 'Italiano' },
{ code: 'tr', label: 'TR', name: 'Türkçe' },
];
function LangSwitch({ lang, setLang }) {
const [open, setOpen] = React.useState(false);
const wrapRef = React.useRef(null);
// Close dropdown on outside click.
React.useEffect(() => {
if (!open) return;
const onDoc = (e) => {
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const current = W2_LANGS.find((l) => l.code === lang) || W2_LANGS[0];
return (
setOpen((o) => !o)}
style={{
fontFamily: _MONO, fontSize: 11,
letterSpacing: '0.06em', textTransform: 'uppercase',
padding: '7px 14px', cursor: 'pointer',
background: 'rgba(4,12,22,0.7)',
color: _C,
border: `1px solid ${_HAIR}`, borderRadius: 8,
backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', gap: 6
}}>
{current.label}
▾
{open && (
{W2_LANGS.map((l) =>
{ setLang(l.code); setOpen(false); }}
style={{
width: '100%', textAlign: 'left',
fontFamily: _MONO, fontSize: 12,
padding: '8px 12px', cursor: 'pointer',
background: lang === l.code ? 'rgba(92,215,255,0.12)' : 'transparent',
color: lang === l.code ? _C : _INK,
border: 'none', borderRadius: 6,
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
gap: 12,
transition: 'background 0.12s'
}}>
{l.name}
{l.label}
)}
)}
);
}
Object.assign(window, {
TopBar, Hero, UseCasesSection, HiddenModeSection,
DevNoteSection, ReferenceSection, AISaysSection,
MobileSection, MacSection, DownloadSection, Footer, LangSwitch
});