2026-08-29 12:46:15 -04:00
|
|
|
import { t, tList } from '../../i18n/index.js';
|
|
|
|
|
|
2026-06-23 16:52:30 -04:00
|
|
|
// "Development Roadmap" — milestone timeline section.
|
|
|
|
|
// Translated from the Claude Design component (Roadmap.dc.html): a full-bleed
|
|
|
|
|
// dark band with a shipped-progress bar and an expandable, status-coded timeline.
|
2025-10-02 01:43:32 -04:00
|
|
|
function Roadmap() {
|
2026-06-23 16:52:30 -04:00
|
|
|
const [isMobile, setIsMobile] = React.useState(
|
|
|
|
|
typeof window !== 'undefined' && window.matchMedia('(max-width:767px)').matches
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const mq = window.matchMedia('(max-width:767px)');
|
|
|
|
|
const onChange = () => setIsMobile(mq.matches);
|
|
|
|
|
mq.addEventListener ? mq.addEventListener('change', onChange) : mq.addListener(onChange);
|
|
|
|
|
return () => {
|
|
|
|
|
mq.removeEventListener ? mq.removeEventListener('change', onChange) : mq.removeListener(onChange);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
|
|
|
|
|
const SANS = "'Manrope', system-ui, -apple-system, sans-serif";
|
|
|
|
|
|
|
|
|
|
const DATA = [
|
2026-08-29 12:46:15 -04:00
|
|
|
{ v: "v1.0", k: 'r1', status: "released" },
|
|
|
|
|
{ v: "v1.5", k: 'r2', status: "released" },
|
|
|
|
|
{ v: "v2.0", k: 'r3', status: "released" },
|
|
|
|
|
{ v: "v3.0", k: 'r4', status: "released" },
|
|
|
|
|
{ v: "v3.5", k: 'r5', status: "released" },
|
|
|
|
|
{ v: "v4.5", k: 'r6', status: "released" },
|
|
|
|
|
{ v: "v5.0", k: 'r7', status: "released" },
|
|
|
|
|
{ v: "v5.5", k: 'r8', status: "released" },
|
|
|
|
|
{ v: "v6.0", k: 'r9', status: "current" },
|
|
|
|
|
{ v: "v6.5", k: 'r10', status: "dev" },
|
|
|
|
|
{ v: "v7.0", k: 'r11', status: "planned" },
|
|
|
|
|
{ v: "v7.5", k: 'r12', status: "research" },
|
|
|
|
|
{ v: "v8.0", k: 'r13', status: "research" }
|
|
|
|
|
].map((d) => ({
|
|
|
|
|
...d,
|
|
|
|
|
// Version tag and status are identifiers, not copy; everything a reader
|
|
|
|
|
// actually reads comes from the locale file.
|
|
|
|
|
title: t(`roadmap.${d.k}.title`),
|
|
|
|
|
sub: t(`roadmap.${d.k}.sub`),
|
|
|
|
|
date: t(`roadmap.${d.k}.date`),
|
|
|
|
|
features: tList(`roadmap.${d.k}.features`)
|
|
|
|
|
}));
|
2026-06-23 16:52:30 -04:00
|
|
|
|
2026-09-23 23:33:33 -04:00
|
|
|
// The status of a milestone is shown by its marker on the timeline and the colour of
|
|
|
|
|
// its bullets, not by a word: the labels (Released, In development, ...) were taken
|
|
|
|
|
// out. `solid` is the dot colour, `line` the spine below it.
|
2026-06-23 16:52:30 -04:00
|
|
|
const META = {
|
2026-09-23 23:33:33 -04:00
|
|
|
released: { solid: "var(--sb-green-solid)", line: "rgba(var(--sb-green-rgb), 0.32)" },
|
|
|
|
|
current: { solid: "var(--sb-orange-solid)", line: "rgba(var(--sb-orange-rgb), 0.32)" },
|
|
|
|
|
dev: { solid: "var(--sb-yellow-2-solid)", line: "rgba(var(--sb-ink), 0.08)" },
|
|
|
|
|
planned: { solid: "var(--sb-text-7)", line: "rgba(var(--sb-ink), 0.08)" },
|
|
|
|
|
research: { solid: "var(--sb-text-9)", line: "rgba(var(--sb-ink), 0.08)" }
|
2026-06-23 16:52:30 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const [open, setOpen] = React.useState({});
|
|
|
|
|
const isOpen = (i) => (open[i] === undefined ? DATA[i].status === 'current' : open[i]);
|
|
|
|
|
const toggle = (i) => setOpen((s) => ({ ...s, [i]: !isOpen(i) }));
|
|
|
|
|
|
|
|
|
|
const total = DATA.length;
|
|
|
|
|
const shipped = DATA.filter((d) => d.status === 'released' || d.status === 'current').length;
|
|
|
|
|
const upcoming = total - shipped;
|
|
|
|
|
const shippedPct = (shipped / total * 100).toFixed(1) + '%';
|
|
|
|
|
|
2026-08-29 12:46:15 -04:00
|
|
|
// The shipped count is highlighted, so the sentence is split around it rather than
|
|
|
|
|
// interpolated whole. Passing only `total` leaves the {shipped} placeholder in the
|
|
|
|
|
// string, which is where the highlighted number goes — a translation is free to put
|
|
|
|
|
// it anywhere in the sentence, and the highlight follows it there.
|
|
|
|
|
const [progressBefore, progressAfter] = t('roadmap.progress', { total }).split('{shipped}');
|
|
|
|
|
|
2026-06-23 16:52:30 -04:00
|
|
|
const renderNode = (status) => {
|
|
|
|
|
if (status === 'released') {
|
|
|
|
|
return (
|
2026-09-04 17:38:41 -04:00
|
|
|
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(var(--sb-green-rgb), 0.16),rgba(var(--sb-green-rgb), 0.16)), var(--sb-bg)', border: '1px solid rgba(var(--sb-green-rgb), 0.4)', zIndex: 2 }}>
|
|
|
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--sb-green-solid)" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4 10-11" /></svg>
|
2026-06-23 16:52:30 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (status === 'current') {
|
|
|
|
|
return (
|
2026-09-04 17:38:41 -04:00
|
|
|
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(var(--sb-orange-rgb), 0.2),rgba(var(--sb-orange-rgb), 0.2)), var(--sb-bg)', border: '1px solid var(--sb-orange-solid)', zIndex: 2, animation: 'rmPulse 2.4s ease-out infinite' }}>
|
|
|
|
|
<span style={{ width: '9px', height: '9px', borderRadius: '50%', background: 'var(--sb-orange-solid)' }} />
|
2026-06-23 16:52:30 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (status === 'dev') {
|
|
|
|
|
return (
|
2026-09-04 17:38:41 -04:00
|
|
|
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(var(--sb-yellow-2-rgb), 0.15),rgba(var(--sb-yellow-2-rgb), 0.15)), var(--sb-bg)', border: '1px solid rgba(var(--sb-yellow-2-rgb), 0.4)', zIndex: 2 }}>
|
|
|
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--sb-yellow-2-solid)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3a9 9 0 1 0 9 9" /></svg>
|
2026-06-23 16:52:30 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// planned / research
|
|
|
|
|
return (
|
2026-09-04 17:38:41 -04:00
|
|
|
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'var(--sb-bg)', border: `1px ${status === 'research' ? 'dashed' : 'solid'} rgba(var(--sb-ink), 0.18)`, zIndex: 2 }}>
|
|
|
|
|
<span style={{ width: '7px', height: '7px', borderRadius: '50%', background: META[status].solid }} />
|
2026-06-23 16:52:30 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-09-04 17:38:41 -04:00
|
|
|
<section style={{ width: '100%', color: 'var(--sb-text-2)', fontFamily: SANS, padding: isMobile ? '48px 0' : '64px 0', background: 'radial-gradient(1200px 720px at 50% -8%, rgba(var(--sb-orange-rgb), 0.05), transparent 60%), var(--sb-bg)' }}>
|
|
|
|
|
<style dangerouslySetInnerHTML={{ __html: '@keyframes rmExp{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}@keyframes rmPulse{0%,100%{box-shadow:0 0 0 0 rgba(var(--sb-orange-rgb), 0.18)}60%{box-shadow:0 0 0 9px rgba(var(--sb-orange-rgb), 0)}}' }} />
|
2026-06-23 16:52:30 -04:00
|
|
|
|
|
|
|
|
<div style={{ maxWidth: '1040px', margin: '0 auto', padding: isMobile ? '0 18px' : '0 40px' }}>
|
|
|
|
|
|
|
|
|
|
{/* header */}
|
|
|
|
|
<div style={{ marginBottom: '30px' }}>
|
2026-09-04 17:38:41 -04:00
|
|
|
<div style={{ fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: 'var(--sb-text-9)', textTransform: 'uppercase', letterSpacing: '1.6px', marginBottom: '13px' }}>{t('roadmap.eyebrow')}</div>
|
|
|
|
|
<h2 style={{ margin: '0 0 14px', fontSize: isMobile ? '27px' : '34px', fontWeight: 800, letterSpacing: '-1px', lineHeight: 1.08, color: 'var(--sb-text-1)' }}>{t('roadmap.heading')}</h2>
|
|
|
|
|
<p style={{ margin: 0, fontSize: '15.5px', lineHeight: 1.6, color: 'var(--sb-text-7)', maxWidth: '660px' }}>{t('roadmap.subheading')}</p>
|
2026-06-23 16:52:30 -04:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* progress */}
|
2026-09-04 17:38:41 -04:00
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '18px', flexWrap: 'wrap', padding: '18px 22px', borderRadius: '14px', background: 'var(--sb-surface)', border: '1px solid rgba(var(--sb-ink), 0.06)', marginBottom: '36px' }}>
|
|
|
|
|
<div style={{ fontFamily: MONO, fontSize: '12px', fontWeight: 600, color: 'var(--sb-text-2)', whiteSpace: 'nowrap' }}>{progressBefore}<span style={{ color: 'var(--sb-green)' }}>{shipped}</span>{progressAfter}</div>
|
|
|
|
|
<div style={{ flex: '1 1 240px', minWidth: '200px', height: '8px', borderRadius: '99px', background: 'var(--sb-bg-deep)', border: '1px solid rgba(var(--sb-ink), 0.06)', overflow: 'hidden' }}>
|
|
|
|
|
<div style={{ height: '100%', width: shippedPct, background: 'linear-gradient(90deg, var(--sb-green), var(--sb-orange))' }} />
|
2025-10-02 01:43:32 -04:00
|
|
|
</div>
|
2026-09-04 17:38:41 -04:00
|
|
|
<div style={{ fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: 'var(--sb-text-9)', textTransform: 'uppercase', letterSpacing: '0.8px', whiteSpace: 'nowrap' }}>{t('roadmap.upcoming', { upcoming })}</div>
|
2026-06-23 16:52:30 -04:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* timeline */}
|
|
|
|
|
{DATA.map((d, i) => {
|
|
|
|
|
const meta = META[d.status];
|
|
|
|
|
const opened = isOpen(i);
|
|
|
|
|
const notLast = i < total - 1;
|
|
|
|
|
return (
|
|
|
|
|
<div key={i} style={{ position: 'relative', display: 'grid', gridTemplateColumns: '54px 1fr', marginBottom: '16px' }}>
|
|
|
|
|
|
|
|
|
|
{/* spine */}
|
|
|
|
|
<div style={{ position: 'relative' }}>
|
2026-08-29 18:05:20 -04:00
|
|
|
{notLast && <div style={{ position: 'absolute', insetInlineStart: '26px', top: '30px', height: 'calc(100% + 16px)', width: '2px', background: meta.line }} />}
|
2026-06-23 16:52:30 -04:00
|
|
|
{renderNode(d.status)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* card */}
|
2026-09-04 17:38:41 -04:00
|
|
|
<div style={{ borderRadius: '16px', background: 'var(--sb-surface)', border: `1px solid ${d.status === 'current' ? 'rgba(var(--sb-orange-rgb), 0.28)' : 'rgba(var(--sb-ink), 0.06)'}`, overflow: 'hidden' }}>
|
2026-06-23 16:52:30 -04:00
|
|
|
<div
|
|
|
|
|
onClick={() => toggle(i)}
|
|
|
|
|
style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '11px' : '16px', padding: isMobile ? '16px 16px' : '18px 22px', cursor: 'pointer', transition: 'background .18s ease' }}
|
2026-09-04 17:38:41 -04:00
|
|
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(var(--sb-ink), 0.018)'; }}
|
2026-06-23 16:52:30 -04:00
|
|
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
|
|
|
|
>
|
2026-09-04 17:38:41 -04:00
|
|
|
<div style={{ flex: 'none', minWidth: '52px', textAlign: 'center', padding: '7px 10px', borderRadius: '9px', background: 'var(--sb-bg-deep)', border: '1px solid rgba(var(--sb-ink), 0.07)', fontFamily: MONO, fontSize: '13px', fontWeight: 700, color: d.status === 'current' ? 'var(--sb-orange)' : 'var(--sb-text-4)' }}>{d.v}</div>
|
2026-06-23 16:52:30 -04:00
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
2026-09-04 17:38:41 -04:00
|
|
|
<div style={{ fontSize: isMobile ? '15.5px' : '17px', fontWeight: 800, letterSpacing: '-0.4px', color: 'var(--sb-text-1)' }}>{d.title}</div>
|
|
|
|
|
{!isMobile && <div style={{ marginTop: '3px', fontSize: '13.5px', color: 'var(--sb-text-6)' }}>{d.sub}</div>}
|
2026-06-23 16:52:30 -04:00
|
|
|
</div>
|
|
|
|
|
<div style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '14px' }}>
|
2026-09-04 17:38:41 -04:00
|
|
|
{!isMobile && <span style={{ fontFamily: MONO, fontSize: '12px', fontWeight: 500, color: 'var(--sb-text-7)', whiteSpace: 'nowrap', minWidth: '74px', textAlign: 'end' }}>{d.date}</span>}
|
|
|
|
|
<span style={{ color: 'var(--sb-text-9)', display: 'inline-flex', transition: 'transform .22s cubic-bezier(.2,.7,.3,1)', transform: opened ? 'rotate(180deg)' : 'rotate(0deg)' }}>
|
2026-06-23 16:52:30 -04:00
|
|
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6" /></svg>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{opened && (
|
|
|
|
|
<div style={{ padding: '4px 22px 22px 22px', animation: 'rmExp .24s cubic-bezier(.2,.7,.3,1)' }}>
|
2026-09-04 17:38:41 -04:00
|
|
|
<div style={{ fontFamily: MONO, fontSize: '10px', fontWeight: 600, color: 'var(--sb-text-faint)', textTransform: 'uppercase', letterSpacing: '1.2px', marginBottom: '14px', paddingTop: '14px', borderTop: '1px solid rgba(var(--sb-ink), 0.05)' }}>{t('roadmap.keyFeatures')}</div>
|
2026-06-23 16:52:30 -04:00
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: '11px 28px' }}>
|
|
|
|
|
{d.features.map((f, fi) => (
|
|
|
|
|
<div key={fi} style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
2026-09-04 17:38:41 -04:00
|
|
|
<span style={{ flex: 'none', marginTop: '7px', width: '5px', height: '5px', borderRadius: '50%', background: meta.solid }} />
|
|
|
|
|
<span style={{ fontSize: '13.5px', lineHeight: 1.5, color: 'var(--sb-text-4)' }}>{f}</span>
|
2026-06-23 16:52:30 -04:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.Roadmap = Roadmap;
|