Compare commits

...

2 Commits

Author SHA1 Message Date
Emir Karabeg
49cd0beebc improvement: changelog 2026-03-06 12:52:59 -08:00
Noor Alam
be4ea4be05 improved changelog ui 2026-03-06 12:28:18 -08:00
29 changed files with 4549 additions and 712 deletions

View File

@@ -99,6 +99,19 @@ export default async function Layout({ children, params }: LayoutProps) {
</head>
<body className='flex min-h-screen flex-col font-sans'>
<Script src='https://assets.onedollarstats.com/stonks.js' strategy='lazyOnload' />
{process.env.REACT_GRAB_ENABLED === 'TRUE' && (
<Script
src='https://unpkg.com/react-grab/dist/index.global.js'
crossOrigin='anonymous'
strategy='beforeInteractive'
/>
)}
{process.env.REACT_GRAB_ENABLED === 'TRUE' && (
<Script
src='https://unpkg.com/@react-grab/cursor/dist/client.global.js'
strategy='lazyOnload'
/>
)}
<RootProvider i18n={provider(lang)}>
<Navbar />
<DocsLayout

View File

@@ -21,6 +21,13 @@ body {
.dark {
--color-fd-primary: #33c482;
--color-fd-background: #1c1c1c;
--color-fd-card: #1b1b1b;
--color-fd-muted: #1b1b1b;
--color-fd-secondary: #1b1b1b;
--color-fd-popover: #1b1b1b;
--color-fd-border: #2a2a2a;
--color-fd-accent: rgba(255, 255, 255, 0.08);
}
/* Font family utilities */
@@ -77,9 +84,9 @@ body {
/* Dark mode navbar and search styling */
:root.dark nav {
background-color: hsla(0, 0%, 7.04%, 0.92) !important;
backdrop-filter: blur(25px) saturate(180%) brightness(0.6) !important;
-webkit-backdrop-filter: blur(25px) saturate(180%) brightness(0.6) !important;
background-color: rgba(28, 28, 28, 0.92) !important;
backdrop-filter: blur(25px) saturate(180%) !important;
-webkit-backdrop-filter: blur(25px) saturate(180%) !important;
}
/* Floating sidebar appearance - remove background */
@@ -483,9 +490,9 @@ pre code {
/* Dark mode inline code */
.dark :not(pre) > code {
background-color: rgb(31 41 55);
background-color: #1b1b1b;
color: rgb(248 113 113);
border: 1px solid rgb(55 65 81);
border: 1px solid #2a2a2a;
}
/* Code block container improvements */

View File

@@ -0,0 +1,336 @@
'use client'
import { useEffect, useState } from 'react'
import Image from 'next/image'
import { useTheme } from 'next-themes'
/**
* Static block pattern SVG rects matching the hero page's color palette.
* These are arranged in a horizontal strip, similar to BlocksTopRightAnimated.
*/
const BLOCK_COLORS = ['#2ABBF8', '#00F701', '#FFCC02', '#FA4EDF'] as const
const RX = '2.59574'
/** Decorative background for the docs site (dark mode only).
* Renders card-left.svg, union-right.svg, and static block patterns. */
export function DocsBackground() {
const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted || resolvedTheme !== 'dark') return null
return (
<div aria-hidden='true' className='pointer-events-none fixed inset-0 z-0 overflow-hidden'>
{/* Card-left SVG — top left */}
<div className='absolute top-[-0.7vw] left-[-2.8vw] aspect-[344/328] w-[23.9vw] opacity-40'>
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
</div>
{/* Card-right SVG — top right */}
<div className='absolute top-[-2.8vw] right-[0vw] aspect-[471/470] w-[32.7vw] opacity-40'>
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
</div>
{/* Union-right SVG — bottom right */}
<div className='absolute right-[-20%] bottom-[-10%] w-[75%] rotate-90 opacity-60'>
<Image
src='/landing/union-right.svg'
alt=''
width={768}
height={768}
className='h-auto w-full'
/>
</div>
{/* Static block strip — top right area */}
<div className='absolute top-[10px] right-[13vw] w-[calc(140px_+_10.76vw)] max-w-[295px] opacity-60'>
<svg
width={295}
height={34}
viewBox='0 0 295 34'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
>
<rect opacity='0.6' width='85.3433' height='16.8626' rx={RX} fill='#2ABBF8' />
<rect opacity='1' width='16.8626' height='16.8626' rx={RX} fill='#2ABBF8' />
<rect opacity='0.6' x='34.2403' width='34.2403' height='33.7252' rx={RX} fill='#2ABBF8' />
<rect opacity='1' x='34.2403' width='16.8626' height='16.8626' rx={RX} fill='#2ABBF8' />
<rect
opacity='1'
x='51.6188'
y='16.8626'
width='16.8626'
height='16.8626'
rx={RX}
fill='#2ABBF8'
/>
<rect opacity='1' x='68.4812' width='54.6502' height='16.8626' rx={RX} fill='#00F701' />
<rect opacity='0.6' x='106.268' width='34.2403' height='33.7252' rx={RX} fill='#00F701' />
<rect opacity='0.6' x='106.268' width='51.103' height='16.8626' rx={RX} fill='#00F701' />
<rect
opacity='1'
x='123.6484'
y='16.8626'
width='16.8626'
height='16.8626'
rx={RX}
fill='#00F701'
/>
<rect opacity='0.6' x='157.371' width='34.2403' height='16.8626' rx={RX} fill='#FFCC02' />
<rect opacity='1' x='157.371' width='16.8626' height='16.8626' rx={RX} fill='#FFCC02' />
<rect opacity='0.6' x='208.993' width='68.4805' height='16.8626' rx={RX} fill='#FA4EDF' />
<rect opacity='0.6' x='209.137' width='16.8626' height='33.7252' rx={RX} fill='#FA4EDF' />
<rect opacity='0.6' x='243.233' width='34.2403' height='33.7252' rx={RX} fill='#FA4EDF' />
<rect opacity='1' x='243.233' width='16.8626' height='16.8626' rx={RX} fill='#FA4EDF' />
<rect opacity='0.6' x='260.096' width='34.04' height='16.8626' rx={RX} fill='#FA4EDF' />
<rect
opacity='1'
x='260.611'
y='16.8626'
width='16.8626'
height='16.8626'
rx={RX}
fill='#FA4EDF'
/>
</svg>
</div>
{/* Static block strip — top left area */}
<div className='absolute top-[10px] left-[16vw] w-[calc(140px_+_10.76vw)] max-w-[295px] opacity-60'>
<svg
width={295}
height={34}
viewBox='0 0 295 34'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
>
<rect opacity='0.6' width='85.3433' height='16.8626' rx={RX} fill='#00F701' />
<rect opacity='1' width='16.8626' height='16.8626' rx={RX} fill='#00F701' />
<rect opacity='0.6' x='34.2403' width='34.2403' height='33.7252' rx={RX} fill='#00F701' />
<rect opacity='1' x='34.2403' width='16.8626' height='16.8626' rx={RX} fill='#00F701' />
<rect
opacity='1'
x='51.6188'
y='16.8626'
width='16.8626'
height='16.8626'
rx={RX}
fill='#00F701'
/>
<rect opacity='1' x='68.4812' width='54.6502' height='16.8626' rx={RX} fill='#FFCC02' />
<rect opacity='0.6' x='106.268' width='34.2403' height='33.7252' rx={RX} fill='#FFCC02' />
<rect opacity='0.6' x='106.268' width='51.103' height='16.8626' rx={RX} fill='#FFCC02' />
<rect
opacity='1'
x='123.6484'
y='16.8626'
width='16.8626'
height='16.8626'
rx={RX}
fill='#FFCC02'
/>
<rect opacity='0.6' x='157.371' width='34.2403' height='16.8626' rx={RX} fill='#FA4EDF' />
<rect opacity='1' x='157.371' width='16.8626' height='16.8626' rx={RX} fill='#FA4EDF' />
<rect opacity='0.6' x='208.993' width='68.4805' height='16.8626' rx={RX} fill='#2ABBF8' />
<rect opacity='0.6' x='209.137' width='16.8626' height='33.7252' rx={RX} fill='#2ABBF8' />
<rect opacity='0.6' x='243.233' width='34.2403' height='33.7252' rx={RX} fill='#2ABBF8' />
<rect opacity='1' x='243.233' width='16.8626' height='16.8626' rx={RX} fill='#2ABBF8' />
<rect opacity='0.6' x='260.096' width='34.04' height='16.8626' rx={RX} fill='#2ABBF8' />
<rect
opacity='1'
x='260.611'
y='16.8626'
width='16.8626'
height='16.8626'
rx={RX}
fill='#2ABBF8'
/>
</svg>
</div>
{/* Vertical block strip — left edge */}
<div className='-translate-y-1/2 absolute top-[50%] left-0 w-[calc(16px_+_1.25vw)] max-w-[34px] opacity-60'>
<svg
width={34}
height={226}
viewBox='0 0 34 226.021'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
>
<rect
opacity='0.6'
width='34.240'
height='33.725'
rx={RX}
fill='#FA4EDF'
transform='matrix(0 1 1 0 0 0)'
/>
<rect
opacity='0.6'
width='16.8626'
height='68.480'
rx={RX}
fill='#FA4EDF'
transform='matrix(-1 0 0 1 33.727 0)'
/>
<rect
opacity='1'
width='16.8626'
height='16.8626'
rx={RX}
fill='#FA4EDF'
transform='matrix(-1 0 0 1 33.727 17.378)'
/>
<rect
opacity='0.6'
width='16.8626'
height='33.986'
rx={RX}
fill='#FA4EDF'
transform='matrix(0 1 1 0 0 51.616)'
/>
<rect
opacity='0.6'
width='16.8626'
height='140.507'
rx={RX}
fill='#00F701'
transform='matrix(-1 0 0 1 33.986 85.335)'
/>
<rect
opacity='0.4'
x='17.119'
y='136.962'
width='34.240'
height='16.8626'
rx={RX}
fill='#FFCC02'
transform='rotate(-90 17.119 136.962)'
/>
<rect
opacity='1'
x='17.119'
y='136.962'
width='16.8626'
height='16.8626'
rx={RX}
fill='#FFCC02'
transform='rotate(-90 17.119 136.962)'
/>
<rect
opacity='0.5'
width='34.240'
height='33.725'
rx={RX}
fill='#00F701'
transform='matrix(0 1 1 0 0.257 153.825)'
/>
<rect
opacity='1'
width='16.8626'
height='16.8626'
rx={RX}
fill='#00F701'
transform='matrix(0 1 1 0 0.257 153.825)'
/>
</svg>
</div>
{/* Vertical block strip — right edge */}
<div className='-translate-y-1/2 absolute top-[50%] right-0 w-[calc(16px_+_1.25vw)] max-w-[34px] opacity-60'>
<svg
width={34}
height={205}
viewBox='0 0 34 204.769'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className='h-auto w-full'
>
<rect
opacity='0.6'
width='16.8626'
height='33.726'
rx={RX}
fill='#FA4EDF'
transform='matrix(0 1 1 0 0 0)'
/>
<rect
opacity='0.6'
width='34.241'
height='16.8626'
rx={RX}
fill='#FA4EDF'
transform='matrix(0 1 1 0 16.891 0)'
/>
<rect
opacity='0.6'
width='16.8626'
height='68.482'
rx={RX}
fill='#FA4EDF'
transform='matrix(-1 0 0 1 33.739 16.888)'
/>
<rect
opacity='0.6'
width='16.8626'
height='33.726'
rx={RX}
fill='#FA4EDF'
transform='matrix(0 1 1 0 0 33.776)'
/>
<rect
opacity='1'
width='16.8626'
height='16.8626'
rx={RX}
fill='#FA4EDF'
transform='matrix(-1 0 0 1 33.739 34.272)'
/>
<rect
opacity='0.6'
width='16.8626'
height='33.726'
rx={RX}
fill='#FA4EDF'
transform='matrix(0 1 1 0 0.012 68.510)'
/>
<rect
opacity='0.6'
width='16.8626'
height='102.384'
rx={RX}
fill='#2ABBF8'
transform='matrix(-1 0 0 1 33.787 102.384)'
/>
<rect
opacity='0.4'
x='17.131'
y='153.859'
width='34.241'
height='16.8626'
rx={RX}
fill='#00F701'
transform='rotate(-90 17.131 153.859)'
/>
<rect
opacity='1'
x='17.131'
y='153.859'
width='16.8626'
height='16.8626'
rx={RX}
fill='#00F701'
transform='rotate(-90 17.131 153.859)'
/>
</svg>
</div>
</div>
)
}

View File

@@ -0,0 +1,3 @@
<svg width="344" height="328" viewBox="0 0 344 328" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M322.641 326.586L335.508 326.586C339.926 326.586 343.508 323.004 343.508 318.586V153.613C343.508 149.195 339.926 145.613 335.508 145.613H228.282C223.864 145.613 220.282 142.031 220.282 137.613V-50H190.282V137.613C190.282 142.031 186.7 145.613 182.282 145.613H-157V318.586C-157 323.004 -153.418 326.586 -149 326.586H322.641Z" fill="#1C1C1C" stroke="#323232" stroke-opacity="0.4" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 513 B

View File

@@ -0,0 +1,3 @@
<svg width="471" height="470" viewBox="0 0 471 470" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M471 94.274L471 124.274L365.88 124.274C361.462 124.274 357.88 127.856 357.88 132.274L357.88 225.495C357.88 229.913 354.298 233.495 349.88 233.495L219.5 233.495C215.082 233.495 211.5 237.077 211.5 241.495L211.5 461.5C211.5 465.918 207.918 469.5 203.5 469.5L8.5 469.5C4.082 469.5 0.5 465.918 0.5 461.5L0.5 157.274C0.5 152.856 4.082 149.274 8.5 149.274L184 149.274C188.418 149.274 192 145.692 192 141.274L192 102.274C192 97.856 195.582 94.274 200 94.274L471 94.274Z" fill="#1C1C1C" stroke="#323232" stroke-opacity="0.4" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 768.219 767.667" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Union">
<path d="M715.886 0.820573C744.399 1.18152 767.402 24.4083 767.403 53.0071V150.79C767.403 179.389 744.4 202.616 715.886 202.977L715.212 202.982H586.265C583.868 202.982 582.266 205.495 582.968 207.787C583.989 211.117 584.538 214.654 584.538 218.319V345.442C584.538 365.287 568.45 381.375 548.605 381.375H348.717C346.913 381.375 345.45 382.838 345.45 384.642V730.917C345.45 750.763 329.362 766.851 309.517 766.851H36.7503C16.9049 766.851 0.816756 750.763 0.816667 730.917V218.319C0.816667 198.473 16.9048 182.385 36.7503 182.385H164.698C166.503 182.385 167.965 180.922 167.965 179.118V53.0081C167.965 24.1843 191.332 0.817072 220.156 0.816667H715.212L715.886 0.820573ZM220.156 39.9602C212.95 39.9606 207.109 45.8024 207.109 53.0081V160.571C207.109 162.376 208.571 163.838 210.375 163.838H715.212C722.418 163.838 728.26 157.996 728.26 150.79V53.0071C728.26 45.8016 722.418 39.9604 715.212 39.9602H220.156Z" fill="var(--fill-0, #1C1C1C)"/>
<path d="M715.886 0.820573L715.896 0.00395244L715.891 0.00391996L715.886 0.820573ZM767.403 53.0071H768.219V53.0071L767.403 53.0071ZM715.886 202.977L715.892 203.793L715.896 203.793L715.886 202.977ZM715.212 202.982V203.798L715.218 203.798L715.212 202.982ZM584.538 345.442H585.355V345.442H584.538ZM548.605 381.375V382.192V382.192V381.375ZM345.45 730.917H346.267V730.917H345.45ZM309.517 766.851V767.667V767.667V766.851ZM0.816667 730.917H0V730.917H0.816667ZM167.965 53.0081L167.148 53.0081V53.0081H167.965ZM220.156 0.816667V0H220.156L220.156 0.816667ZM715.212 0.816667L715.217 0H715.212V0.816667ZM220.156 39.9602V39.1436H220.155L220.156 39.9602ZM207.109 53.0081L206.292 53.008V53.0081H207.109ZM715.212 163.838V164.655V164.655V163.838ZM728.26 53.0071H729.077V53.007L728.26 53.0071ZM715.212 39.9602V39.1436V39.1436V39.9602ZM582.968 207.787L583.749 207.548L582.968 207.787ZM715.886 0.820573L715.876 1.63717C743.943 1.99247 766.585 24.8559 766.586 53.0071L767.403 53.0071L768.219 53.0071C768.219 23.9608 744.856 0.370568 715.896 0.0039717L715.886 0.820573ZM767.403 53.0071H766.586V150.79H767.403H768.219V53.0071H767.403ZM767.403 150.79H766.586C766.586 178.942 743.943 201.805 715.876 202.16L715.886 202.977L715.896 203.793C744.856 203.427 768.219 179.837 768.219 150.79H767.403ZM715.886 202.977L715.88 202.16L715.206 202.165L715.212 202.982L715.218 203.798L715.892 203.793L715.886 202.977ZM715.212 202.982V202.165H586.265V202.982V203.798H715.212V202.982ZM582.968 207.787L582.188 208.026C583.184 211.28 583.722 214.736 583.722 218.319H584.538H585.355C585.355 214.572 584.793 210.955 583.749 207.548L582.968 207.787ZM584.538 218.319H583.722V345.442H584.538H585.355V218.319H584.538ZM584.538 345.442H583.722C583.722 364.836 567.999 380.559 548.605 380.559V381.375V382.192C568.901 382.192 585.355 365.738 585.355 345.442H584.538ZM548.605 381.375V380.559H348.717V381.375V382.192H548.605V381.375ZM345.45 384.642H344.634V730.917H345.45H346.267V384.642H345.45ZM345.45 730.917H344.634C344.634 750.312 328.911 766.034 309.517 766.034V766.851V767.667C329.813 767.667 346.267 751.214 346.267 730.917H345.45ZM309.517 766.851V766.034H36.7503V766.851V767.667H309.517V766.851ZM36.7503 766.851V766.034C17.3559 766.034 1.63342 750.312 1.63333 730.917H0.816667H0C9.16123e-05 751.214 16.4538 767.667 36.7503 767.667V766.851ZM0.816667 730.917H1.63333V218.319H0.816667H0V730.917H0.816667ZM0.816667 218.319H1.63333C1.63333 198.924 17.3559 183.202 36.7503 183.202V182.385V181.568C16.4538 181.568 0 198.022 0 218.319H0.816667ZM36.7503 182.385V183.202H164.698V182.385V181.568H36.7503V182.385ZM167.965 179.118H168.782V53.0081H167.965H167.148V179.118H167.965ZM167.965 53.0081L168.782 53.0081C168.782 24.6353 191.783 1.63373 220.156 1.63333L220.156 0.816667L220.156 0C190.881 0.00041157 167.149 23.7333 167.148 53.0081L167.965 53.0081ZM220.156 0.816667V1.63333H715.212V0.816667V0H220.156V0.816667ZM715.212 0.816667L715.207 1.63332L715.881 1.63723L715.886 0.820573L715.891 0.00391996L715.217 1.37091e-05L715.212 0.816667ZM220.156 39.9602L220.155 39.1436C212.499 39.144 206.292 45.3514 206.292 53.008L207.109 53.0081L207.925 53.0081C207.926 46.2534 213.401 40.7773 220.156 40.7769L220.156 39.9602ZM207.109 53.0081H206.292V160.571H207.109H207.925V53.0081H207.109ZM210.375 163.838V164.655H715.212V163.838V163.021H210.375V163.838ZM715.212 163.838V164.655C722.869 164.655 729.077 158.447 729.077 150.79H728.26H727.443C727.443 157.545 721.967 163.021 715.212 163.021V163.838ZM728.26 150.79H729.077V53.0071H728.26H727.443V150.79H728.26ZM728.26 53.0071L729.077 53.007C729.076 45.3505 722.869 39.1437 715.212 39.1436V39.9602V40.7769C721.967 40.7771 727.443 46.2527 727.443 53.0072L728.26 53.0071ZM715.212 39.9602V39.1436H220.156V39.9602V40.7769H715.212V39.9602ZM207.109 160.571H206.292C206.292 162.827 208.12 164.655 210.375 164.655V163.838V163.021C209.022 163.021 207.925 161.925 207.925 160.571H207.109ZM164.698 182.385V183.202C166.954 183.202 168.782 181.374 168.782 179.118H167.965H167.148C167.148 180.471 166.052 181.568 164.698 181.568V182.385ZM348.717 381.375V380.559C346.462 380.559 344.634 382.387 344.634 384.642H345.45H346.267C346.267 383.289 347.364 382.192 348.717 382.192V381.375ZM586.265 202.982V202.165C583.235 202.165 581.35 205.293 582.188 208.026L582.968 207.787L583.749 207.548C583.182 205.697 584.502 203.798 586.265 203.798V202.982Z" fill="var(--stroke-0, #323232)" fill-opacity="0.4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -50,6 +50,12 @@ const containerVariants: Variants = {
exit: { transition: { staggerChildren: EXIT_STAGGER } },
}
const containerVariantsReverseExit: Variants = {
hidden: {},
visible: { transition: { staggerChildren: ENTER_STAGGER } },
exit: { transition: { staggerChildren: EXIT_STAGGER, staggerDirection: -1 } },
}
const blockVariants: Variants = {
hidden: { opacity: 0, transition: { duration: 0 } },
visible: (targetOpacity: number) => ({
@@ -76,12 +82,14 @@ function AnimatedBlocksSvg({
viewBox,
rects,
animState = 'entering',
reverseExit = false,
}: {
width: number
height: number
viewBox: string
rects: readonly BlockRect[]
animState?: BlockAnimState
reverseExit?: boolean
}) {
return (
<motion.svg
@@ -93,7 +101,7 @@ function AnimatedBlocksSvg({
className='h-auto w-full'
initial='hidden'
animate={toAnimateValue(animState)}
variants={containerVariants}
variants={reverseExit ? containerVariantsReverseExit : containerVariants}
>
{rects.map((r, i) => (
<motion.rect
@@ -516,10 +524,14 @@ export function useBlockCycle(): Record<BlockPosition, BlockAnimState> {
interface AnimatedBlockProps {
animState?: BlockAnimState
reverseExit?: boolean
}
/** Two-row horizontal strip at the top-right of the hero. */
export function BlocksTopRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
export function BlocksTopRightAnimated({
animState = 'entering',
reverseExit,
}: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={295}
@@ -527,12 +539,13 @@ export function BlocksTopRightAnimated({ animState = 'entering' }: AnimatedBlock
viewBox='0 0 295 34'
rects={TOP_RIGHT_RECTS}
animState={animState}
reverseExit={reverseExit}
/>
)
}
/** Two-row horizontal strip at the top-left of the hero. */
export function BlocksTopLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
export function BlocksTopLeftAnimated({ animState = 'entering', reverseExit }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={295}
@@ -540,12 +553,13 @@ export function BlocksTopLeftAnimated({ animState = 'entering' }: AnimatedBlockP
viewBox='0 0 295 34'
rects={TOP_LEFT_RECTS}
animState={animState}
reverseExit={reverseExit}
/>
)
}
/** Two-column vertical strip on the left edge of the screenshot. */
export function BlocksLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
export function BlocksLeftAnimated({ animState = 'entering', reverseExit }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={34}
@@ -553,12 +567,16 @@ export function BlocksLeftAnimated({ animState = 'entering' }: AnimatedBlockProp
viewBox='0 0 34 226.021'
rects={LEFT_RECTS}
animState={animState}
reverseExit={reverseExit}
/>
)
}
/** Two-column vertical strip on the right edge of the screenshot. */
export function BlocksRightSideAnimated({ animState = 'entering' }: AnimatedBlockProps) {
export function BlocksRightSideAnimated({
animState = 'entering',
reverseExit,
}: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={34}
@@ -566,12 +584,13 @@ export function BlocksRightSideAnimated({ animState = 'entering' }: AnimatedBloc
viewBox='0 0 34 226.021'
rects={RIGHT_SIDE_RECTS}
animState={animState}
reverseExit={reverseExit}
/>
)
}
/** Two-column vertical strip at the far-right edge of the screen. */
export function BlocksRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
export function BlocksRightAnimated({ animState = 'entering', reverseExit }: AnimatedBlockProps) {
return (
<AnimatedBlocksSvg
width={34}
@@ -579,6 +598,7 @@ export function BlocksRightAnimated({ animState = 'entering' }: AnimatedBlockPro
viewBox='0 0 34 204.769'
rects={RIGHT_RECTS}
animState={animState}
reverseExit={reverseExit}
/>
)
}

View File

@@ -4,7 +4,6 @@ import {
Collaboration,
Enterprise,
Features,
Footer,
Hero,
Navbar,
Pricing,
@@ -12,6 +11,7 @@ import {
Templates,
Testimonials,
} from '@/app/(home)/components'
import { Footer } from '@/app/(landing)/components'
/**
* Landing page root component.
@@ -47,7 +47,7 @@ export default async function Landing() {
<Enterprise />
<Testimonials />
</main>
<Footer />
<Footer fullWidth={true} />
</div>
)
}

View File

@@ -63,7 +63,9 @@ export default function StatusIndicator() {
aria-label={`System status: ${message}`}
>
<StatusDotIcon status={status} className='h-[6px] w-[6px]' aria-hidden='true' />
<span>{message}</span>
<span className='font-[family-name:var(--font-martian-mono)] font-medium uppercase tracking-[-0.24px]'>
{message}
</span>
</Link>
)
}

View File

@@ -1,279 +1,231 @@
import Link from 'next/link'
import { inter } from '@/app/_styles/fonts/inter/inter'
import {
ComplianceBadges,
Logo,
SocialLinks,
StatusIndicator,
} from '@/app/(landing)/components/footer/components'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
import { SocialLinks, StatusIndicator } from '@/app/(landing)/components/footer/components'
import { FOOTER_BLOCKS, FOOTER_TOOLS } from '@/app/(landing)/components/footer/consts'
const VISIBLE_COUNT = 9 as const
const DOT_GRID_ROWS = 4 as const
const DOT_GRID_GAP = 8 as const
const LINK_CLASS =
'font-[family-name:var(--font-martian-mono)] text-[12px] font-medium uppercase tracking-[-0.24px] text-[#f6f6f0]/60 transition-colors hover:text-white' as const
interface FooterProps {
fullWidth?: boolean
}
export default function Footer({ fullWidth = false }: FooterProps) {
return (
<footer className={`${inter.className} relative w-full overflow-hidden bg-white`}>
<footer
className={`${martianMono.variable} ${season.variable} relative w-full overflow-hidden bg-[#1C1C1C]`}
>
{/* Dot grid separator */}
<div
aria-hidden='true'
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
style={{
display: 'grid',
gridTemplateColumns: 'repeat(120, 1fr)',
gap: 6,
placeItems: 'center',
}}
>
{Array.from({ length: 120 * DOT_GRID_ROWS }, (_, i) => (
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
))}
</div>
<div
className={
fullWidth
? 'px-4 pt-[40px] pb-[40px] sm:px-4 sm:pt-[34px] sm:pb-[340px]'
: 'px-4 pt-[40px] pb-[40px] sm:px-[50px] sm:pt-[34px] sm:pb-[340px]'
? 'mx-auto max-w-[1440px] px-10 py-[48px] sm:px-[120px] sm:py-[56px]'
: 'px-10 py-[48px] sm:px-[120px] sm:py-[56px]'
}
>
<div className={`flex gap-[80px] ${fullWidth ? 'justify-center' : ''}`}>
{/* Logo and social links */}
<div className='flex flex-col gap-[24px]'>
<Logo />
<SocialLinks />
<ComplianceBadges />
<StatusIndicator />
</div>
{/* Links section */}
<div>
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>More Sim</h2>
<div className='flex flex-col gap-[12px]'>
<Link
href='https://docs.sim.ai'
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Docs
</Link>
<Link
href='#pricing'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Pricing
</Link>
<Link
href='https://form.typeform.com/to/jqCO12pF'
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Enterprise
</Link>
<Link
href='/studio'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Sim Studio
</Link>
<Link
href='/changelog'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Changelog
</Link>
<Link
href='https://status.sim.ai'
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Status
</Link>
<Link
href='/careers'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Careers
</Link>
<Link
href='/privacy'
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Privacy Policy
</Link>
<Link
href='/terms'
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
Terms of Service
<div className='flex flex-col gap-[48px]'>
{/* Main content row */}
<div className='flex flex-col gap-[48px] sm:flex-row sm:justify-between'>
{/* Logo and status — left aligned */}
<div className='flex flex-col gap-[24px]'>
<Link href='/' aria-label='Sim home'>
<svg
width='71'
height='22'
viewBox='0 0 71 22'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g transform='scale(0.07483)'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M142.793 124.175C142.793 128.925 140.913 133.487 137.577 136.846L137.099 137.327C133.765 140.696 129.236 142.579 124.519 142.579H17.8063C7.97854 142.579 0 150.605 0 160.503V275.91C0 285.808 7.97854 293.834 17.8063 293.834H132.383C142.211 293.834 150.179 285.808 150.179 275.91V167.858C150.179 163.453 151.914 159.226 155.009 156.109C158.095 153.001 162.292 151.253 166.666 151.253H275.166C284.994 151.253 292.962 143.229 292.962 133.33V17.9231C292.962 8.02512 284.994 0 275.166 0H160.588C150.761 0 142.793 8.02512 142.793 17.9231V124.175ZM177.564 24.5671H258.181C263.925 24.5671 268.57 29.2545 268.57 35.0301V116.224C268.57 121.998 263.925 126.687 258.181 126.687H177.564C171.83 126.687 167.175 121.998 167.175 116.224V35.0301C167.175 29.2545 171.83 24.5671 177.564 24.5671Z'
fill='white'
/>
<path
d='M275.293 171.578H190.106C179.779 171.578 171.406 180.01 171.406 190.412V275.162C171.406 285.564 179.779 293.996 190.106 293.996H275.293C285.621 293.996 293.994 285.564 293.994 275.162V190.412C293.994 180.01 285.621 171.578 275.293 171.578Z'
fill='white'
/>
<path
d='M275.293 171.18H190.106C179.779 171.18 171.406 179.612 171.406 190.014V274.763C171.406 285.165 179.779 293.596 190.106 293.596H275.293C285.621 293.596 293.994 285.165 293.994 274.763V190.014C293.994 179.612 285.621 171.18 275.293 171.18Z'
fill='white'
fillOpacity='0.2'
/>
</g>
<path
d='M31.5718 15.845H34.1583C34.1583 16.5591 34.4169 17.1285 34.9342 17.5531C35.4515 17.9584 36.1508 18.1611 37.0321 18.1611C37.9901 18.1611 38.7277 17.9777 39.245 17.611C39.7623 17.225 40.021 16.7135 40.021 16.0766C40.021 15.6134 39.8773 15.2274 39.5899 14.9186C39.3217 14.6098 38.8235 14.3589 38.0955 14.1659L35.6239 13.5869C34.3786 13.2781 33.4494 12.8052 32.8363 12.1683C32.2423 11.5314 31.9454 10.6918 31.9454 9.64957C31.9454 8.78105 32.1657 8.02833 32.6064 7.39142C33.0662 6.7545 33.6889 6.26234 34.4744 5.91494C35.2791 5.56753 36.1987 5.39382 37.2333 5.39382C38.2679 5.39382 39.1588 5.57718 39.906 5.94389C40.6724 6.31059 41.2663 6.82206 41.6878 7.47827C42.1285 8.13449 42.3584 8.91615 42.3776 9.82327H39.7911C39.7719 9.08986 39.5324 8.52049 39.0726 8.11518C38.6128 7.70988 37.9709 7.50722 37.1471 7.50722C36.3041 7.50722 35.6527 7.69058 35.1929 8.05728C34.733 8.42399 34.5031 8.9258 34.5031 9.56272C34.5031 10.5084 35.1929 11.155 36.5723 11.5024L39.0439 12.1104C40.2317 12.3806 41.1226 12.8245 41.7166 13.4421C42.3105 14.0404 42.6075 14.8607 42.6075 15.9029C42.6075 16.7907 42.368 17.5724 41.889 18.2479C41.41 18.9041 40.749 19.4156 39.906 19.7823C39.0822 20.1297 38.1051 20.3034 36.9747 20.3034C35.327 20.3034 34.0146 19.8981 33.0375 19.0875C32.0603 18.2769 31.5718 17.196 31.5718 15.845Z'
fill='white'
/>
<path
d='M44.5096 19.956V5.79913C45.5868 6.19296 46.0617 6.19296 47.211 5.79913V19.956H44.5096ZM45.8316 4.86332C45.3526 4.86332 44.9311 4.68962 44.5671 4.34221C44.2222 3.9755 44.0498 3.55089 44.0498 3.06838C44.0498 2.56657 44.2222 2.14196 44.5671 1.79455C44.9311 1.44714 45.3526 1.27344 45.8316 1.27344C46.3297 1.27344 46.7512 1.44714 47.0961 1.79455C47.441 2.14196 47.6134 2.56657 47.6134 3.06838C47.6134 3.55089 47.441 3.9755 47.0961 4.34221C46.7512 4.68962 46.3297 4.86332 45.8316 4.86332Z'
fill='white'
/>
<path
d='M51.976 19.956H49.2746V5.79913H51.6887V8.18778C51.976 7.39647 52.5317 6.72555 53.298 6.20444C54.0835 5.66403 55.0319 5.39382 56.1432 5.39382C57.3885 5.39382 58.4231 5.73158 59.247 6.4071C60.0708 7.08261 60.6073 7.98008 60.8563 9.09951H60.3678C60.5594 7.98008 61.0862 7.08261 61.9484 6.4071C62.8106 5.73158 63.8739 5.39382 65.1384 5.39382C66.7478 5.39382 68.0123 5.86668 68.9319 6.8124C69.8516 7.75813 70.3114 9.05126 70.3114 10.6918V19.956H67.6674V11.3577C67.6674 10.2382 67.38 9.37936 66.8053 8.78105C66.2496 8.16344 65.4928 7.85463 64.5349 7.85463C63.8643 7.85463 63.2704 8.00903 62.7531 8.31784C62.2549 8.60735 61.8622 9.03196 61.5748 9.59167C61.2874 10.1514 61.1437 10.8076 61.1437 11.5603V19.956H58.471V11.3287C58.471 10.2093 58.1932 9.36006 57.6376 8.78105C57.082 8.18274 56.3252 7.88358 55.3672 7.88358C54.6966 7.88358 54.1027 8.03798 53.5854 8.34679C53.0873 8.6363 52.6945 9.06091 52.4071 9.62062C52.1197 10.161 51.976 10.8076 51.976 11.5603V19.956Z'
fill='white'
/>
</svg>
</Link>
<div className='[&_a:hover]:text-white [&_a]:text-[#808080]'>
<StatusIndicator />
</div>
</div>
</div>
{/* Blocks section */}
<div className='hidden sm:block'>
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>Blocks</h2>
<div className='flex flex-col gap-[12px]'>
{FOOTER_BLOCKS.map((block) => (
{/* Link columns — right aligned */}
<div className='flex flex-col gap-[48px] sm:flex-row sm:gap-[80px]'>
{/* Company links */}
<div>
<h2 className='mb-[24px] font-[family-name:var(--font-season)] font-medium text-[20px] text-white tracking-[-0.4px]'>
Company
</h2>
<div className='flex flex-col gap-[10px]'>
<Link
href='https://docs.sim.ai'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
Docs
</Link>
<Link href='#pricing' className={LINK_CLASS}>
Pricing
</Link>
<Link
href='https://form.typeform.com/to/jqCO12pF'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
Enterprise
</Link>
<Link href='/studio' className={LINK_CLASS}>
Sim Studio
</Link>
<Link href='/changelog' className={LINK_CLASS}>
Changelog
</Link>
<Link
href='https://status.sim.ai'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
Status
</Link>
<Link href='/careers' className={LINK_CLASS}>
Careers
</Link>
<Link
href='/privacy'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
Privacy Policy
</Link>
<Link
href='/terms'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
Terms of Service
</Link>
<Link
href='https://trust.delve.co/sim-studio'
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
Trust Center
</Link>
</div>
</div>
{/* Blocks section */}
<div className='hidden sm:block'>
<h2 className='mb-[24px] font-[family-name:var(--font-season)] font-medium text-[20px] text-white tracking-[-0.4px]'>
Blocks
</h2>
<div className='flex flex-col gap-[10px]'>
{FOOTER_BLOCKS.slice(0, VISIBLE_COUNT).map((block) => (
<Link
key={block}
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
{block}
</Link>
))}
</div>
<Link
key={block}
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
href='https://docs.sim.ai/blocks'
target='_blank'
rel='noopener noreferrer'
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
className='mt-[24px] inline-block font-[family-name:var(--font-season)] font-medium text-[14px] text-white tracking-[-0.28px] transition-opacity hover:opacity-80'
>
{block}
View all Blocks &rarr;
</Link>
))}
</div>
{/* Tools section */}
<div className='hidden sm:block'>
<h2 className='mb-[24px] font-[family-name:var(--font-season)] font-medium text-[20px] text-white tracking-[-0.4px]'>
Tools
</h2>
<div className='flex flex-col gap-[10px]'>
{FOOTER_TOOLS.slice(0, VISIBLE_COUNT).map((tool) => (
<Link
key={tool}
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
target='_blank'
rel='noopener noreferrer'
className={`whitespace-nowrap ${LINK_CLASS}`}
>
{tool}
</Link>
))}
</div>
<Link
href='https://docs.sim.ai/tools'
target='_blank'
rel='noopener noreferrer'
className='mt-[24px] inline-block font-[family-name:var(--font-season)] font-medium text-[14px] text-white tracking-[-0.28px] transition-opacity hover:opacity-80'
>
View all Tools &rarr;
</Link>
</div>
</div>
</div>
{/* Tools section - split into columns */}
<div className='hidden sm:block'>
<h2 className='mb-[16px] font-medium text-[14px] text-foreground'>Tools</h2>
<div className='flex gap-[80px]'>
{/* First column */}
<div className='flex flex-col gap-[12px]'>
{FOOTER_TOOLS.slice(0, Math.ceil(FOOTER_TOOLS.length / 4)).map((tool) => (
<Link
key={tool}
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
target='_blank'
rel='noopener noreferrer'
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
{tool}
</Link>
))}
</div>
{/* Second column */}
<div className='flex flex-col gap-[12px]'>
{FOOTER_TOOLS.slice(
Math.ceil(FOOTER_TOOLS.length / 4),
Math.ceil((FOOTER_TOOLS.length * 2) / 4)
).map((tool) => (
<Link
key={tool}
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
target='_blank'
rel='noopener noreferrer'
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
{tool}
</Link>
))}
</div>
{/* Third column */}
<div className='flex flex-col gap-[12px]'>
{FOOTER_TOOLS.slice(
Math.ceil((FOOTER_TOOLS.length * 2) / 4),
Math.ceil((FOOTER_TOOLS.length * 3) / 4)
).map((tool) => (
<Link
key={tool}
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
target='_blank'
rel='noopener noreferrer'
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
{tool}
</Link>
))}
</div>
{/* Fourth column */}
<div className='flex flex-col gap-[12px]'>
{FOOTER_TOOLS.slice(Math.ceil((FOOTER_TOOLS.length * 3) / 4)).map((tool) => (
<Link
key={tool}
href={`https://docs.sim.ai/tools/${tool.toLowerCase().replace(/\s+/g, '_')}`}
target='_blank'
rel='noopener noreferrer'
className='whitespace-nowrap text-[14px] text-muted-foreground transition-colors hover:text-foreground'
>
{tool}
</Link>
))}
</div>
</div>
{/* Social links — bottom */}
<div className='[&_a:hover]:text-white [&_a]:text-[#808080]'>
<SocialLinks />
</div>
</div>
</div>
{/* Large SIM logo at bottom - half cut off */}
<div className='-translate-x-1/2 pointer-events-none absolute bottom-[-240px] left-1/2 hidden sm:block'>
<svg
xmlns='http://www.w3.org/2000/svg'
width='1128'
height='550'
viewBox='0 0 1128 550'
fill='none'
>
<g filter='url(#filter0_dd_122_4989)'>
<path
d='M3 420.942H77.9115C77.9115 441.473 85.4027 457.843 100.385 470.051C115.367 481.704 135.621 487.53 161.147 487.53C188.892 487.53 210.255 482.258 225.238 471.715C240.22 460.617 247.711 445.913 247.711 427.601C247.711 414.283 243.549 403.185 235.226 394.307C227.457 385.428 213.03 378.215 191.943 372.666L120.361 356.019C84.2929 347.14 57.3802 333.545 39.6234 315.234C22.4215 296.922 13.8206 272.784 13.8206 242.819C13.8206 217.849 20.2019 196.208 32.9646 177.896C46.2822 159.584 64.3165 145.434 87.0674 135.446C110.373 125.458 137.008 120.464 166.973 120.464C196.938 120.464 222.74 125.735 244.382 136.278C266.578 146.821 283.779 161.526 295.987 180.393C308.75 199.259 315.409 221.733 315.964 247.813H241.052C240.497 226.727 233.561 210.357 220.243 198.705C206.926 187.052 188.337 181.225 164.476 181.225C140.06 181.225 121.194 186.497 107.876 197.04C94.5585 207.583 87.8997 222.01 87.8997 240.322C87.8997 267.512 107.876 286.101 147.829 296.09L219.411 313.569C253.815 321.337 279.618 334.1 296.82 351.857C314.022 369.059 322.622 392.642 322.622 422.607C322.622 448.132 315.686 470.606 301.814 490.027C287.941 508.894 268.797 523.599 244.382 534.142C220.521 544.13 192.221 549.124 159.482 549.124C111.76 549.124 73.7498 537.471 45.4499 514.165C17.15 490.86 3 459.785 3 420.942Z'
fill='#DCDCDC'
/>
<path
d='M377.713 539.136V132.117C408.911 143.439 422.667 143.439 455.954 132.117V539.136H377.713ZM416.001 105.211C402.129 105.211 389.921 100.217 379.378 90.2291C369.39 79.686 364.395 67.4782 364.395 53.6057C364.395 39.1783 369.39 26.9705 379.378 16.9823C389.921 6.9941 402.129 2 416.001 2C430.428 2 442.636 6.9941 452.625 16.9823C462.613 26.9705 467.607 39.1783 467.607 53.6057C467.607 67.4782 462.613 79.686 452.625 90.2291C442.636 100.217 430.428 105.211 416.001 105.211Z'
fill='#DCDCDC'
/>
<path
d='M593.961 539.136H515.72V132.117H585.637V200.792C593.961 178.041 610.053 158.752 632.249 143.769C655 128.232 682.467 120.464 714.651 120.464C750.72 120.464 780.685 130.174 804.545 149.596C822.01 163.812 835.016 181.446 843.562 202.5C851.434 181.446 864.509 163.812 882.786 149.596C907.757 130.174 938.554 120.464 975.177 120.464C1021.79 120.464 1058.41 134.059 1085.05 161.249C1111.68 188.439 1125 225.617 1125 272.784V539.136H1048.42V291.928C1048.42 259.744 1040.1 235.051 1023.45 217.849C1007.36 200.092 985.443 191.213 957.698 191.213C938.276 191.213 921.074 195.653 906.092 204.531C891.665 212.855 880.289 225.062 871.966 241.154C863.642 257.247 859.48 276.113 859.48 297.754V539.136H782.072V291.095C782.072 258.911 774.026 234.496 757.934 217.849C741.841 200.647 719.923 192.046 692.178 192.046C672.756 192.046 655.555 196.485 640.572 205.363C626.145 213.687 614.769 225.895 606.446 241.987C598.122 257.524 593.961 276.113 593.961 297.754V539.136Z'
fill='#DCDCDC'
/>
<path
d='M166.973 121.105C196.396 121.105 221.761 126.201 243.088 136.367L244.101 136.855L244.106 136.858C265.86 147.191 282.776 161.528 294.876 179.865L295.448 180.741L295.455 180.753C308.032 199.345 314.656 221.475 315.306 247.171H241.675C240.996 226.243 234.012 209.899 220.666 198.222C207.196 186.435 188.437 180.583 164.476 180.583C139.977 180.583 120.949 185.871 107.478 196.536C93.9928 207.212 87.2578 221.832 87.2578 240.322C87.2579 254.096 92.3262 265.711 102.444 275.127C112.542 284.524 127.641 291.704 147.673 296.712L147.677 296.713L219.259 314.192L219.27 314.195C253.065 321.827 278.469 334.271 295.552 351.48L296.358 352.304L296.365 352.311C313.42 369.365 321.98 392.77 321.98 422.606C321.98 448.005 315.082 470.343 301.297 489.646C287.502 508.408 268.456 523.046 244.134 533.55C220.369 543.498 192.157 548.482 159.481 548.482C111.864 548.482 74.0124 536.855 45.8584 513.67C17.8723 490.623 3.80059 459.948 3.64551 421.584H77.2734C77.4285 441.995 84.9939 458.338 99.9795 470.549L99.9854 470.553L99.9912 470.558C115.12 482.324 135.527 488.172 161.146 488.172C188.96 488.172 210.474 482.889 225.607 472.24L225.613 472.236L225.619 472.231C240.761 461.015 248.353 446.12 248.353 427.601C248.352 414.145 244.145 402.89 235.709 393.884C227.81 384.857 213.226 377.603 192.106 372.045L192.098 372.043L192.089 372.04L120.507 355.394C84.5136 346.533 57.7326 332.983 40.0908 314.794H40.0918C23.0227 296.624 14.4629 272.654 14.4629 242.819C14.4629 217.969 20.8095 196.463 33.4834 178.273C46.7277 160.063 64.6681 145.981 87.3252 136.034L87.3242 136.033C110.536 126.086 137.081 121.106 166.973 121.105ZM975.177 121.105C1021.66 121.105 1058.1 134.658 1084.59 161.698C1111.08 188.741 1124.36 225.743 1124.36 272.784V538.494H1049.07V291.928C1049.07 259.636 1040.71 234.76 1023.92 217.402H1023.91C1007.68 199.5 985.584 190.571 957.697 190.571C938.177 190.571 920.862 195.034 905.771 203.975C891.228 212.365 879.77 224.668 871.396 240.859C863.017 257.059 858.838 276.03 858.838 297.754V538.494H782.714V291.096C782.714 258.811 774.641 234.209 758.395 217.402C742.16 200.053 720.062 191.404 692.178 191.404C673.265 191.404 656.422 195.592 641.666 203.985L640.251 204.808C625.711 213.196 614.254 225.497 605.88 241.684C597.496 257.333 593.318 276.031 593.318 297.754V538.494H516.361V132.759H584.995V200.792L586.24 201.013C594.51 178.408 610.505 159.221 632.607 144.302L632.61 144.3C655.238 128.847 682.574 121.105 714.651 121.105C750.599 121.105 780.413 130.781 804.14 150.094C821.52 164.241 834.461 181.787 842.967 202.741L843.587 204.268L844.163 202.725C851.992 181.786 864.994 164.248 883.181 150.103C908.021 130.782 938.673 121.106 975.177 121.105ZM455.312 538.494H378.354V133.027C393.534 138.491 404.652 141.251 416.05 141.251C427.46 141.251 439.095 138.485 455.312 133.009V538.494ZM416.001 2.6416C430.262 2.6416 442.306 7.57157 452.171 17.4365C462.036 27.3014 466.965 39.3445 466.965 53.6055C466.965 67.3043 462.04 79.3548 452.16 89.7842C442.297 99.6427 430.258 104.569 416.001 104.569C402.303 104.569 390.254 99.6452 379.825 89.7676C369.957 79.3421 365.037 67.2967 365.037 53.6055C365.037 39.3444 369.966 27.3005 379.831 17.4355C390.258 7.56247 402.307 2.64163 416.001 2.6416Z'
stroke='#C1C1C1'
strokeWidth='1.28396'
/>
</g>
<defs>
<filter
id='filter0_dd_122_4989'
x='0'
y='0'
width='1128'
height='550'
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feFlood floodOpacity='0' result='BackgroundImageFix' />
<feColorMatrix
in='SourceAlpha'
type='matrix'
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
result='hardAlpha'
/>
<feMorphology
radius='1'
operator='erode'
in='SourceAlpha'
result='effect1_dropShadow_122_4989'
/>
<feOffset dy='1' />
<feGaussianBlur stdDeviation='1' />
<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0' />
<feBlend
mode='normal'
in2='BackgroundImageFix'
result='effect1_dropShadow_122_4989'
/>
<feColorMatrix
in='SourceAlpha'
type='matrix'
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
result='hardAlpha'
/>
<feOffset dy='1' />
<feGaussianBlur stdDeviation='1.5' />
<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0' />
<feBlend
mode='normal'
in2='effect1_dropShadow_122_4989'
result='effect2_dropShadow_122_4989'
/>
<feBlend
mode='normal'
in='SourceGraphic'
in2='effect2_dropShadow_122_4989'
result='shape'
/>
</filter>
</defs>
</svg>
</div>
</footer>
)
}

View File

@@ -10,7 +10,7 @@ export function BackLink() {
return (
<Link
href='/studio'
className='group flex items-center gap-1 text-gray-600 text-sm hover:text-gray-900'
className='group flex items-center gap-1 font-[430] font-season text-[#F6F6F0]/50 text-sm hover:text-[#F6F6F0]/80'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
@@ -21,7 +21,7 @@ export function BackLink() {
<ChevronLeft className='h-4 w-4' aria-hidden='true' />
)}
</span>
Back to Sim Studio
All posts
</Link>
)
}

View File

@@ -6,7 +6,6 @@ import { FAQ } from '@/lib/blog/faq'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BackLink } from '@/app/(landing)/studio/[slug]/back-link'
import { ShareButton } from '@/app/(landing)/studio/[slug]/share-button'
@@ -27,6 +26,21 @@ export async function generateMetadata({
export const revalidate = 86400
const PROSE_CLASSES = [
'prose prose-lg prose-invert max-w-none',
'prose-headings:font-season prose-headings:font-[430] prose-headings:text-white prose-headings:tracking-[-0.02em]',
'prose-p:text-[#F6F6F0]/80',
'prose-a:text-[#33C482] prose-a:no-underline hover:prose-a:text-[#33C482]/80',
'prose-strong:text-white',
'prose-blockquote:border-[#2A2A2A] prose-blockquote:text-[#F6F6F0]/60',
'prose-hr:border-[#2A2A2A]',
'prose-li:text-[#F6F6F0]/80',
'prose-img:rounded-[10px] prose-img:border prose-img:border-[#2A2A2A]',
'[&_code]:!bg-[#2A2A2A] [&_code]:!text-[#F6F6F0]/90 [&_code]:rounded [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.875em]',
'[&_pre]:!bg-[#222222] [&_pre]:border [&_pre]:border-[#2A2A2A] [&_pre]:rounded-[10px]',
'[&_pre_code]:!bg-transparent [&_pre_code]:p-0',
].join(' ')
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPostBySlug(slug)
@@ -36,11 +50,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
const related = await getRelatedPosts(slug, 3)
return (
<article
className={`${soehne.className} w-full`}
itemScope
itemType='https://schema.org/BlogPosting'
>
<article className='w-full' itemScope itemType='https://schema.org/BlogPosting'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
@@ -49,98 +59,81 @@ export default async function Page({ params }: { params: Promise<{ slug: string
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }}
/>
<header className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
<header className='mx-auto max-w-[1000px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
<div className='mb-6'>
<BackLink />
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
<div className='w-full flex-shrink-0 md:w-[450px]'>
<div className='relative w-full overflow-hidden rounded-lg'>
<Image
src={post.ogImage}
alt={post.title}
width={450}
height={360}
className='h-auto w-full'
sizes='(max-width: 768px) 100vw, 450px'
priority
itemProp='image'
unoptimized
/>
</div>
</div>
<div className='flex flex-1 flex-col justify-between'>
<h1
className='font-medium text-[36px] leading-tight tracking-tight sm:text-[48px] md:text-[56px] lg:text-[64px]'
itemProp='headline'
>
{post.title}
</h1>
<div className='mt-4 flex items-center justify-between'>
<div className='flex items-center gap-3'>
{(post.authors || [post.author]).map((a, idx) => (
<div key={idx} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-6'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
>
<span itemProp='name'>{a?.name}</span>
</Link>
</div>
))}
</div>
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
<div className='flex flex-col'>
<h1
className='font-[430] font-season text-[36px] text-white leading-tight tracking-[-0.02em] sm:text-[48px] md:text-[56px] lg:text-[64px]'
itemProp='headline'
>
{post.title}
</h1>
<p className='mt-4 font-[430] font-season text-[#F6F6F0]/80 text-[16px] leading-[1.5] sm:text-[18px] md:text-[22px]'>
{post.description}
</p>
<div className='mt-6 flex items-center justify-between'>
<div className='flex items-center gap-3'>
<time
className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[1.5] sm:text-[16px]'
dateTime={post.date}
itemProp='datePublished'
>
{new Date(post.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</time>
<meta itemProp='dateModified' content={post.updated ?? post.date} />
<span className='text-[#F6F6F0]/30'>·</span>
{(post.authors || [post.author]).map((a, idx) => (
<div key={idx} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-6'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[1.5] hover:text-[#F6F6F0]/80 sm:text-[16px]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
>
<span itemProp='name'>{a?.name}</span>
</Link>
</div>
))}
</div>
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
</div>
</div>
<hr className='mt-8 border-gray-200 border-t sm:mt-12' />
<div className='flex flex-col gap-6 py-8 sm:flex-row sm:items-start sm:justify-between sm:gap-8 sm:py-10'>
<div className='flex flex-shrink-0 items-center gap-4'>
<time
className='block text-[14px] text-gray-600 leading-[1.5] sm:text-[16px]'
dateTime={post.date}
itemProp='datePublished'
>
{new Date(post.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</time>
<meta itemProp='dateModified' content={post.updated ?? post.date} />
</div>
<div className='flex-1'>
<p className='m-0 block translate-y-[-4px] font-[400] text-[18px] leading-[1.5] sm:text-[20px] md:text-[26px]'>
{post.description}
</p>
</div>
</div>
<hr className='mt-8 border-[#2A2A2A] border-t sm:mt-12' />
</header>
<div className='mx-auto max-w-[900px] px-6 pb-20 sm:px-8 md:px-12' itemProp='articleBody'>
<div className='prose prose-lg max-w-none'>
<div
className='mx-auto max-w-[900px] px-6 py-10 pb-20 sm:px-8 md:px-12'
itemProp='articleBody'
>
<div className={PROSE_CLASSES}>
<Article />
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
</div>
</div>
{related.length > 0 && (
<div className='mx-auto max-w-[900px] px-6 pb-24 sm:px-8 md:px-12'>
<h2 className='mb-4 font-medium text-[24px]'>Related posts</h2>
<h2 className='mb-4 font-[430] font-season text-[24px] text-white tracking-[-0.02em]'>
Related posts
</h2>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
{related.map((p) => (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-gray-200'>
<div className='overflow-hidden rounded-[10px] border border-[#2A2A2A] bg-[#222222] transition-all hover:border-[#3A3A3A]'>
<Image
src={p.ogImage}
alt={p.title}
@@ -152,14 +145,16 @@ export default async function Page({ params }: { params: Promise<{ slug: string
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-gray-600 text-xs'>
<div className='mb-1 font-[430] font-season text-[#F6F6F0]/50 text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<div className='font-medium text-sm leading-tight'>{p.title}</div>
<div className='font-[430] font-season text-sm text-white leading-tight'>
{p.title}
</div>
</div>
</div>
</Link>

View File

@@ -2,64 +2,33 @@
import { useState } from 'react'
import { Share2 } from 'lucide-react'
import { Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
interface ShareButtonProps {
url: string
title: string
}
export function ShareButton({ url, title }: ShareButtonProps) {
const [open, setOpen] = useState(false)
export function ShareButton({ url }: ShareButtonProps) {
const [copied, setCopied] = useState(false)
const handleCopyLink = async () => {
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => {
setCopied(false)
setOpen(false)
}, 1000)
setTimeout(() => setCopied(false), 1500)
} catch {
setOpen(false)
/* noop */
}
}
const handleShareTwitter = () => {
const tweetUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
window.open(tweetUrl, '_blank', 'noopener,noreferrer')
setOpen(false)
}
const handleShareLinkedIn = () => {
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`
window.open(linkedInUrl, '_blank', 'noopener,noreferrer')
setOpen(false)
}
return (
<Popover
open={open}
onOpenChange={setOpen}
variant='secondary'
size='sm'
colorScheme='inverted'
<button
onClick={handleCopy}
className='flex items-center gap-1.5 font-[430] font-season text-[#F6F6F0]/50 text-sm hover:text-[#F6F6F0]/80'
aria-label='Copy link'
>
<PopoverTrigger asChild>
<button
className='flex items-center gap-1.5 text-gray-600 text-sm hover:text-gray-900'
aria-label='Share this post'
>
<Share2 className='h-4 w-4' />
<span>Share</span>
</button>
</PopoverTrigger>
<PopoverContent align='end' minWidth={140}>
<PopoverItem onClick={handleCopyLink}>{copied ? 'Copied!' : 'Copy link'}</PopoverItem>
<PopoverItem onClick={handleShareTwitter}>Share on X</PopoverItem>
<PopoverItem onClick={handleShareLinkedIn}>Share on LinkedIn</PopoverItem>
</PopoverContent>
</Popover>
<Share2 className='h-4 w-4' />
<span>{copied ? 'Copied!' : 'Share'}</span>
</button>
)
}

View File

@@ -1,7 +1,6 @@
import Image from 'next/image'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
export const revalidate = 3600
@@ -11,8 +10,10 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
const author = posts[0]?.author
if (!author) {
return (
<main className={`${soehne.className} mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12`}>
<h1 className='font-medium text-[32px]'>Author not found</h1>
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<h1 className='font-[430] font-season text-[32px] text-white tracking-[-0.02em]'>
Author not found
</h1>
</main>
)
}
@@ -25,7 +26,7 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
image: author.avatarUrl,
}
return (
<main className={`${soehne.className} mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12`}>
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
@@ -41,12 +42,14 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
unoptimized
/>
) : null}
<h1 className='font-medium text-[32px] leading-tight'>{author.name}</h1>
<h1 className='font-[430] font-season text-[32px] text-white leading-tight tracking-[-0.02em]'>
{author.name}
</h1>
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{posts.map((p) => (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-gray-200'>
<div className='overflow-hidden rounded-[10px] border border-[#2A2A2A] bg-[#222222] transition-all hover:border-[#3A3A3A]'>
<Image
src={p.ogImage}
alt={p.title}
@@ -56,14 +59,16 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-gray-600 text-xs'>
<div className='mb-1 font-[430] font-season text-[#F6F6F0]/50 text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<div className='font-medium text-sm leading-tight'>{p.title}</div>
<div className='font-[430] font-season text-sm text-white leading-tight'>
{p.title}
</div>
</div>
</div>
</Link>

View File

@@ -1,4 +1,7 @@
import { Footer, Nav } from '@/app/(landing)/components'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
import Navbar from '@/app/(home)/components/navbar/navbar'
import { Footer } from '@/app/(landing)/components'
export default function StudioLayout({ children }: { children: React.ReactNode }) {
const orgJsonLd = {
@@ -23,7 +26,8 @@ export default function StudioLayout({ children }: { children: React.ReactNode }
}
return (
<div className='flex min-h-screen flex-col'>
<div className={`${season.variable} ${martianMono.variable} relative min-h-screen`}>
<div className='-z-50 pointer-events-none fixed inset-0 bg-[#1C1C1C]' />
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
@@ -32,7 +36,7 @@ export default function StudioLayout({ children }: { children: React.ReactNode }
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
/>
<Nav hideAuthButtons={false} variant='landing' />
<Navbar />
<main className='relative flex-1'>{children}</main>
<Footer fullWidth={true} />
</div>

View File

@@ -1,6 +1,5 @@
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { PostGrid } from '@/app/(landing)/studio/post-grid'
export const revalidate = 3600
@@ -29,8 +28,7 @@ export default async function StudioIndex({
const totalPages = Math.max(1, Math.ceil(sorted.length / perPage))
const start = (pageNum - 1) * perPage
const posts = sorted.slice(start, start + perPage)
// Tag filter chips are intentionally disabled for now.
// const tags = await getAllTags()
const studioJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
@@ -40,52 +38,48 @@ export default async function StudioIndex({
}
return (
<main className={`${soehne.className} mx-auto max-w-[1200px] px-6 py-12 sm:px-8 md:px-12`}>
<div className='relative min-h-screen overflow-hidden'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(studioJsonLd) }}
/>
<h1 className='mb-3 font-medium text-[40px] leading-tight sm:text-[56px]'>Sim Studio</h1>
<p className='mb-10 text-[18px] text-gray-700'>
Announcements, insights, and guides for building AI agent workflows.
</p>
{/* Tag filter chips hidden until we have more posts */}
{/* <div className='mb-10 flex flex-wrap gap-3'>
<Link href='/studio' className={`rounded-full border px-3 py-1 text-sm ${!tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>All</Link>
{tags.map((t) => (
<Link key={t.tag} href={`/studio?tag=${encodeURIComponent(t.tag)}`} className={`rounded-full border px-3 py-1 text-sm ${tag === t.tag ? 'border-black bg-black text-white' : 'border-gray-300'}`}>
{t.tag} ({t.count})
</Link>
))}
</div> */}
<main className='relative z-10 mx-auto max-w-[1400px] px-4 py-16 sm:px-6 md:px-8 md:py-24'>
<h1 className='mt-6 font-[430] font-season text-4xl text-white tracking-[-0.02em] sm:text-5xl'>
Sim Studio
</h1>
<p className='mt-3 font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
Announcements, insights, and guides for building AI agent workflows.
</p>
{/* Grid layout for consistent rows */}
<PostGrid posts={posts} />
{totalPages > 1 && (
<div className='mt-10 flex items-center justify-center gap-3'>
{pageNum > 1 && (
<Link
href={`/studio?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded border px-3 py-1 text-sm'
>
Previous
</Link>
)}
<span className='text-gray-600 text-sm'>
Page {pageNum} of {totalPages}
</span>
{pageNum < totalPages && (
<Link
href={`/studio?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded border px-3 py-1 text-sm'
>
Next
</Link>
)}
<div className='mt-10'>
<PostGrid posts={posts} />
</div>
)}
</main>
{totalPages > 1 && (
<div className='mt-10 flex items-center justify-center gap-3'>
{pageNum > 1 && (
<Link
href={`/studio?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-3 py-1 font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
>
Previous
</Link>
)}
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[14px]'>
Page {pageNum} of {totalPages}
</span>
{pageNum < totalPages && (
<Link
href={`/studio?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
className='rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-3 py-1 font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
>
Next
</Link>
)}
</div>
)}
</main>
</div>
)
}

View File

@@ -1,8 +1,9 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
interface Author {
id: string
@@ -22,69 +23,78 @@ interface Post {
featured?: boolean
}
const INITIAL_VISIBLE = 9
export function PostGrid({ posts }: { posts: Post[] }) {
const [showAll, setShowAll] = useState(false)
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const visiblePosts = showAll ? posts : posts.slice(0, INITIAL_VISIBLE)
const hasMore = posts.length > INITIAL_VISIBLE
return (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3'>
{posts.map((p, index) => (
<Link key={p.slug} href={`/studio/${p.slug}`} className='group flex flex-col'>
<div className='flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 transition-colors duration-300 hover:border-gray-300'>
{/* Image container with fixed aspect ratio to prevent layout shift */}
<div className='relative aspect-video w-full overflow-hidden'>
<Image
src={p.ogImage}
alt={p.title}
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
unoptimized
priority={index < 6}
loading={index < 6 ? undefined : 'lazy'}
fill
style={{ objectFit: 'cover' }}
/>
</div>
<div className='flex flex-1 flex-col p-4'>
<div className='mb-2 text-gray-600 text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
<div className='flex flex-col gap-10'>
<div
className='grid grid-cols-1 gap-8 md:grid-cols-2 md:gap-10 lg:grid-cols-3'
onMouseLeave={() => setHoveredIndex(null)}
>
{visiblePosts.map((p, index) => {
const authors = p.authors && p.authors.length > 0 ? p.authors : [p.author]
const authorNames = authors.map((a) => a?.name).join(', ')
const isHovered = hoveredIndex === index
const isDimmed = hoveredIndex !== null && !isHovered
return (
<Link
key={p.slug}
href={`/studio/${p.slug}`}
className={cn(
'group flex flex-col overflow-hidden rounded-[10px] border border-[#2A2A2A] transition-[background-color] duration-200',
isDimmed ? 'bg-transparent' : 'bg-[#222222] hover:border-[#3A3A3A]'
)}
onMouseEnter={() => setHoveredIndex(index)}
>
<div className='relative aspect-video w-full overflow-hidden bg-[#1C1C1C]'>
<Image
src={p.ogImage}
alt={p.title}
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
unoptimized
priority={index < 6}
loading={index < 6 ? undefined : 'lazy'}
fill
style={{ objectFit: 'cover' }}
/>
</div>
<h3 className='shine-text mb-1 font-medium text-lg leading-tight'>{p.title}</h3>
<p className='mb-3 line-clamp-3 flex-1 text-gray-700 text-sm'>{p.description}</p>
<div className='flex items-center gap-2'>
<div className='-space-x-1.5 flex'>
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
.slice(0, 3)
.map((author, idx) => (
<Avatar key={idx} className='size-4 border border-white'>
<AvatarImage src={author?.avatarUrl} alt={author?.name} />
<AvatarFallback className='border border-white bg-gray-100 text-[10px] text-gray-600'>
{author?.name.slice(0, 2)}
</AvatarFallback>
</Avatar>
))}
</div>
<span className='text-gray-600 text-xs'>
{(p.authors && p.authors.length > 0 ? p.authors : [p.author])
.slice(0, 2)
.map((a) => a?.name)
.join(', ')}
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length > 2 && (
<>
{' '}
and {(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2}{' '}
other
{(p.authors && p.authors.length > 0 ? p.authors : [p.author]).length - 2 > 1
? 's'
: ''}
</>
)}
<div className='flex flex-1 flex-col gap-2 p-4'>
<h3 className='font-[430] font-season text-[17px] text-white leading-snug'>
{p.title}
</h3>
<span className='font-[430] font-season text-[#F6F6F0]/50 text-[12px]'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
<span className='mx-2'></span>
{authorNames}
</span>
</div>
</div>
</div>
</Link>
))}
</Link>
)
})}
</div>
{hasMore && !showAll && (
<div className='flex justify-center'>
<button
type='button'
onClick={() => setShowAll(true)}
className='rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-4 py-2 font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
>
Show more
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import {
BlocksLeftAnimated,
BlocksRightAnimated,
BlocksRightSideAnimated,
BlocksTopLeftAnimated,
BlocksTopRightAnimated,
useBlockCycle,
} from '@/app/(home)/components/hero/components/animated-blocks'
export function StudioBlocks() {
const blockStates = useBlockCycle()
return (
<>
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 right-[13.1vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
>
<BlocksTopRightAnimated animState={blockStates.topRight} />
</div>
<div
aria-hidden='true'
className='pointer-events-none absolute top-0 left-[16vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
>
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
</div>
<div
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-[50%] left-0 z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
>
<BlocksLeftAnimated animState={blockStates.left} />
</div>
<div
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-0 z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
>
<BlocksRightAnimated animState={blockStates.rightEdge} />
</div>
<div
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[3vw] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px] scale-x-[-1]'
>
<BlocksRightSideAnimated animState={blockStates.rightSide} />
</div>
</>
)
}

View File

@@ -5,16 +5,21 @@ export default async function TagsIndex() {
const tags = await getAllTags()
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<h1 className='mb-6 font-medium text-[32px] leading-tight'>Browse by tag</h1>
<h1 className='mb-6 font-[430] font-season text-[32px] text-white leading-tight tracking-[-0.02em]'>
Browse by tag
</h1>
<div className='flex flex-wrap gap-3'>
<Link href='/studio' className='rounded-full border border-gray-300 px-3 py-1 text-sm'>
<Link
href='/studio'
className='rounded-full border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-3 py-1 font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
>
All
</Link>
{tags.map((t) => (
<Link
key={t.tag}
href={`/studio?tag=${encodeURIComponent(t.tag)}`}
className='rounded-full border border-gray-300 px-3 py-1 text-sm'
className='rounded-full border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-3 py-1 font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
>
{t.tag} ({t.count})
</Link>

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
import { BookOpen, Github, Rss } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedLink } from '@/app/changelog/components/branded-link'
import { ChangelogBlocks } from '@/app/changelog/components/changelog-blocks'
import ChangelogList from '@/app/changelog/components/timeline-list'
import { Breaks } from '@/app/changelog/components/variants/breaks'
import { Scroll } from '@/app/changelog/components/variants/scroll'
export interface ChangelogEntry {
tag: string
@@ -14,77 +15,142 @@ export interface ChangelogEntry {
contributors?: string[]
}
export type ChangelogVariant = 'breaks' | 'scroll'
const VARIANTS = {
breaks: Breaks,
scroll: Scroll,
} as const
function extractMentions(body: string): string[] {
const matches = body.match(/@([A-Za-z0-9-]+)/g) ?? []
const uniq = Array.from(new Set(matches.map((m) => m.slice(1))))
return uniq
}
export default async function ChangelogContent() {
interface ChangelogContentProps {
variant?: ChangelogVariant
}
const RELEASES_PER_PAGE = 100
const MAX_RELEASE_PAGES = 10
interface GitHubRelease {
prerelease: boolean
tag_name: string
name: string | null
body: string | null
published_at: string
html_url: string
}
function mapReleasesToEntries(releases: GitHubRelease[]): ChangelogEntry[] {
return releases
.filter((release) => !release.prerelease)
.map((release) => ({
tag: release.tag_name,
title: release.name || release.tag_name,
content: String(release.body || ''),
date: release.published_at,
url: release.html_url,
contributors: extractMentions(String(release.body || '')),
}))
}
export default async function ChangelogContent({ variant }: ChangelogContentProps) {
let entries: ChangelogEntry[] = []
try {
const res = await fetch(
'https://api.github.com/repos/simstudioai/sim/releases?per_page=10&page=1',
{
headers: { Accept: 'application/vnd.github+json' },
next: { revalidate: 3600 },
}
)
const releases: any[] = await res.json()
entries = (releases || [])
.filter((r) => !r.prerelease)
.map((r) => ({
tag: r.tag_name,
title: r.name || r.tag_name,
content: String(r.body || ''),
date: r.published_at,
url: r.html_url,
contributors: extractMentions(String(r.body || '')),
}))
const allReleases: GitHubRelease[] = []
for (let page = 1; page <= MAX_RELEASE_PAGES; page += 1) {
const res = await fetch(
`https://api.github.com/repos/simstudioai/sim/releases?per_page=${RELEASES_PER_PAGE}&page=${page}`,
{
headers: { Accept: 'application/vnd.github+json' },
next: { revalidate: 3600 },
}
)
if (!res.ok) break
const releases: GitHubRelease[] = await res.json()
if (!Array.isArray(releases) || releases.length === 0) break
allReleases.push(...releases)
if (releases.length < RELEASES_PER_PAGE) break
}
entries = mapReleasesToEntries(allReleases)
} catch (err) {
entries = []
}
return (
<div className='min-h-screen bg-background'>
<div className='relative grid md:grid-cols-2'>
{/* Left intro panel */}
<div className='relative top-0 overflow-hidden border-border border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
<div className='absolute inset-0 bg-grid-pattern opacity-[0.03] dark:opacity-[0.06]' />
<div className='absolute inset-0 bg-gradient-to-tr from-background via-transparent to-background/60' />
if (variant) {
const Layout = VARIANTS[variant]
return <Layout entries={entries} />
}
<div className='relative mx-auto h-full max-w-xl md:flex md:flex-col md:justify-center'>
<h1
className={`${soehne.className} mt-6 font-semibold text-4xl tracking-tight sm:text-5xl`}
>
return (
<div className='min-h-screen bg-[#1C1C1C]'>
<div id='changelog-grid' className='relative grid md:grid-cols-2'>
{/* Left intro panel */}
<div className='relative top-0 overflow-hidden border-[#2A2A2A] border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
{/* Background card decoration */}
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw] opacity-40'
>
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
</div>
{/* Union decorative shape */}
<div
aria-hidden='true'
className='pointer-events-none absolute right-[-20%] bottom-[-10%] z-0 w-[75%] rotate-90 opacity-80'
>
<Image
src='/landing/union-right.svg'
alt=''
width={768}
height={768}
className='h-auto w-full'
/>
</div>
{/* Animated colored block decorations */}
<ChangelogBlocks />
<div className='relative z-10 mx-auto h-full max-w-xl md:flex md:flex-col md:justify-center'>
<h1 className='mt-6 font-[430] font-season text-4xl text-white tracking-[-0.02em] sm:text-5xl'>
Changelog
</h1>
<p className={`${inter.className} mt-4 text-muted-foreground text-sm`}>
<p className='mt-3 font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
Stay up-to-date with the latest features, improvements, and bug fixes in Sim. All
changes are documented here with detailed release notes.
</p>
<hr className='mt-6 border-border' />
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
<BrandedLink
<div className='mt-5 flex flex-wrap items-center gap-3'>
<Link
href='https://github.com/simstudioai/sim/releases'
target='_blank'
rel='noopener noreferrer'
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
>
<Github className='h-4 w-4' />
View on GitHub
</BrandedLink>
</Link>
<Link
href='https://docs.sim.ai'
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
>
<BookOpen className='h-4 w-4' />
Documentation
</Link>
<Link
href='/changelog.xml'
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
>
<Rss className='h-4 w-4' />
RSS Feed
@@ -95,7 +161,7 @@ export default async function ChangelogContent() {
{/* Right timeline */}
<div className='relative px-4 py-10 sm:px-6 md:px-8 md:py-12'>
<div className='relative max-w-2xl pl-8'>
<div className='relative max-w-2xl'>
<ChangelogList initialEntries={entries} />
</div>
</div>

View File

@@ -0,0 +1,414 @@
'use client'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'
const HEATMAP_COLORS = ['#2ABBF8', '#00F701', '#FFCC02', '#FA4EDF'] as const
const GREEN_BASE = '#39d353'
const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
] as const
const CELL_SIZE = 10
const CELL_GAP = 3
const CELL_RADIUS = 2
const DAY_LABEL_WIDTH = 28
const MONTH_ROW_HEIGHT = 16
const MAX_HEATMAP_WEEKS = 26
/** Cells (days) from the active date to the edge of the colored glow. */
const GLOW_RADIUS = 7
const EASING_K = 5
const SNAP_CELLS = 10
const SETTLE_THRESHOLD = 0.05
interface CellData {
date: string
weekIndex: number
dayIndex: number
seqIndex: number
traversalIndex: number
}
interface GridData {
cells: CellData[]
dates: Date[]
dateToSeqIndex: Map<string, number>
monthLabels: Array<{ label: string; weekIndex: number }>
dayLabels: Array<{ label: string; dayIndex: number }>
numWeeks: number
svgWidth: number
svgHeight: number
}
function formatDateToISO(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function greenOpacity(seed: number): number {
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453
return 0.06 + (x - Math.floor(x)) * 0.2
}
function pseudoRandom(seed: number): number {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
return x - Math.floor(x)
}
function cellPixel(cell: CellData): [number, number] {
return [
DAY_LABEL_WIDTH + cell.weekIndex * (CELL_SIZE + CELL_GAP),
MONTH_ROW_HEIGHT + cell.dayIndex * (CELL_SIZE + CELL_GAP),
]
}
function computeGrid(entries: ChangelogEntry[]): GridData | null {
if (!entries.length) return null
const earliestEntry = entries.reduce((earliest, entry) => {
const d = entry.date.split('T')[0]
return d < earliest ? d : earliest
}, entries[0].date.split('T')[0])
const earliestDate = new Date(`${earliestEntry}T00:00:00`)
earliestDate.setDate(earliestDate.getDate() - earliestDate.getDay())
const now = new Date()
const endSaturday = new Date(now)
endSaturday.setDate(endSaturday.getDate() + (6 - endSaturday.getDay()))
const maxWindowStart = new Date(endSaturday)
maxWindowStart.setDate(maxWindowStart.getDate() - (MAX_HEATMAP_WEEKS * 7 - 1))
const startDate = earliestDate > maxWindowStart ? earliestDate : maxWindowStart
const dates: Date[] = []
const current = new Date(startDate)
while (current <= endSaturday) {
dates.push(new Date(current))
current.setDate(current.getDate() + 1)
}
const numWeeks = Math.ceil(dates.length / 7)
const cells: CellData[] = dates.map((date, seqIndex) => {
const reverseSeq = dates.length - 1 - seqIndex
const columnFromRight = Math.floor(reverseSeq / 7)
const rowInColumn = reverseSeq % 7
const goingDown = columnFromRight % 2 === 0
const dayIndex = goingDown ? rowInColumn : 6 - rowInColumn
const weekIndex = numWeeks - 1 - columnFromRight
return {
date: formatDateToISO(date),
weekIndex,
dayIndex,
seqIndex,
traversalIndex: reverseSeq,
}
})
const earliestCellByWeek = new Map<number, CellData>()
for (const cell of cells) {
const existing = earliestCellByWeek.get(cell.weekIndex)
if (!existing || cell.seqIndex < existing.seqIndex) {
earliestCellByWeek.set(cell.weekIndex, cell)
}
}
const MIN_LABEL_GAP_WEEKS = 3
const rawLabels: Array<{ label: string; weekIndex: number }> = []
let prevMonth = -1
for (let w = 0; w < numWeeks; w++) {
const earliest = earliestCellByWeek.get(w)
if (!earliest) continue
const month = new Date(`${earliest.date}T00:00:00`).getMonth()
if (month !== prevMonth) {
rawLabels.push({ label: MONTH_NAMES[month], weekIndex: w })
prevMonth = month
}
}
const monthLabels: Array<{ label: string; weekIndex: number }> = []
for (let i = 0; i < rawLabels.length; i++) {
const cur = rawLabels[i]
const next = rawLabels[i + 1]
if (next && next.weekIndex - cur.weekIndex < MIN_LABEL_GAP_WEEKS) continue
if (
monthLabels.length > 0 &&
cur.weekIndex - monthLabels[monthLabels.length - 1].weekIndex < MIN_LABEL_GAP_WEEKS
) {
continue
}
monthLabels.push(cur)
}
const EST_LABEL_WIDTH = 24
while (
monthLabels.length > 0 &&
DAY_LABEL_WIDTH +
monthLabels[monthLabels.length - 1].weekIndex * (CELL_SIZE + CELL_GAP) +
EST_LABEL_WIDTH >
DAY_LABEL_WIDTH + numWeeks * (CELL_SIZE + CELL_GAP) - CELL_GAP
) {
monthLabels.pop()
}
const dateToSeqIndex = new Map<string, number>()
for (const cell of cells) {
dateToSeqIndex.set(cell.date, cell.seqIndex)
}
const gridWidth = numWeeks * (CELL_SIZE + CELL_GAP) - CELL_GAP
const gridHeight = 7 * (CELL_SIZE + CELL_GAP) - CELL_GAP
const svgWidth = DAY_LABEL_WIDTH + gridWidth + 16
const svgHeight = MONTH_ROW_HEIGHT + gridHeight
return {
cells,
dates,
dateToSeqIndex,
monthLabels,
dayLabels: [
{ label: 'Mon', dayIndex: 1 },
{ label: 'Wed', dayIndex: 3 },
{ label: 'Fri', dayIndex: 5 },
],
numWeeks,
svgWidth,
svgHeight,
}
}
export interface CommitHeatmapHandle {
setActiveDate: (date: string) => void
}
interface CommitHeatmapProps {
entries: ChangelogEntry[]
activeDate?: string
}
export const CommitHeatmap = forwardRef<CommitHeatmapHandle, CommitHeatmapProps>(
function CommitHeatmap({ entries, activeDate: activeDateProp }, ref) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const gridRef = useRef<GridData | null>(null)
const targetRef = useRef(-1)
const currentRef = useRef(-1)
const rafRef = useRef(0)
const lastTsRef = useRef(0)
const runningRef = useRef(false)
const grid = useMemo(() => computeGrid(entries), [entries])
gridRef.current = grid
const resolveSeqIndex = useCallback((date: string): number => {
const g = gridRef.current
if (!g || g.cells.length === 0) return -1
const idx = g.dateToSeqIndex.get(date)
if (idx !== undefined) return idx
const first = g.cells[0].date
const last = g.cells[g.cells.length - 1].date
if (date < first) return 0
if (date > last) return g.cells.length - 1
return -1
}, [])
const draw = useCallback(() => {
const canvas = canvasRef.current
const g = gridRef.current
if (!canvas || !g) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const { cells, monthLabels, dayLabels, svgWidth, svgHeight } = g
const activeSeq = currentRef.current
const dpr = window.devicePixelRatio || 1
const rect = canvas.getBoundingClientRect()
const w = Math.round(rect.width * dpr)
const h = Math.round(rect.height * dpr)
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w
canvas.height = h
}
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, canvas.width, canvas.height)
const scaleX = w / svgWidth
const scaleY = h / svgHeight
const scale = Math.min(scaleX, scaleY)
const tx = (w - svgWidth * scale) / 2
const ty = (h - svgHeight * scale) / 2
ctx.setTransform(scale, 0, 0, scale, tx, ty)
ctx.fillStyle = 'rgba(246,246,246,0.4)'
ctx.font = '9px var(--font-season), system-ui, sans-serif'
ctx.textBaseline = 'middle'
for (const { label, weekIndex: wi } of monthLabels) {
ctx.fillText(label, DAY_LABEL_WIDTH + wi * (CELL_SIZE + CELL_GAP), 11)
}
for (const { label, dayIndex } of dayLabels) {
const y = MONTH_ROW_HEIGHT + dayIndex * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 1
ctx.fillText(label, 4, y)
}
const fillRR = (rx: number, ry: number, rw: number, rh: number, r: number) => {
ctx.beginPath()
if (typeof ctx.roundRect === 'function') {
ctx.roundRect(rx, ry, rw, rh, r)
} else {
ctx.moveTo(rx + r, ry)
ctx.lineTo(rx + rw - r, ry)
ctx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + r)
ctx.lineTo(rx + rw, ry + rh - r)
ctx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - r, ry + rh)
ctx.lineTo(rx + r, ry + rh)
ctx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - r)
ctx.lineTo(rx, ry + r)
ctx.quadraticCurveTo(rx, ry, rx + r, ry)
}
ctx.fill()
}
for (const cell of cells) {
const [x, y] = cellPixel(cell)
ctx.fillStyle = GREEN_BASE
ctx.globalAlpha = greenOpacity(cell.seqIndex)
fillRR(x, y, CELL_SIZE, CELL_SIZE, CELL_RADIUS)
if (activeSeq >= 0) {
const dist = Math.abs(cell.seqIndex - activeSeq)
if (dist < GLOW_RADIUS) {
const t = 1 - dist / GLOW_RADIUS
const smooth = t * t * (3 - 2 * t)
const colorIdx = Math.floor(pseudoRandom(cell.seqIndex) * HEATMAP_COLORS.length)
const baseOpacity = 0.6 + pseudoRandom(cell.seqIndex + 100) * 0.4
const overlay = smooth * baseOpacity
if (overlay > 0.01) {
ctx.fillStyle = HEATMAP_COLORS[colorIdx]
ctx.globalAlpha = overlay
fillRR(x, y, CELL_SIZE, CELL_SIZE, CELL_RADIUS)
}
}
}
}
ctx.globalAlpha = 1
}, [])
const animate = useCallback(
(timestamp: number) => {
const g = gridRef.current
if (!g) {
runningRef.current = false
return
}
const target = targetRef.current
const current = currentRef.current
if (target < 0) {
currentRef.current = target
draw()
runningRef.current = false
lastTsRef.current = 0
return
}
const delta = target - current
const cellDelta = Math.abs(delta)
if (cellDelta < SETTLE_THRESHOLD) {
currentRef.current = target
draw()
runningRef.current = false
lastTsRef.current = 0
return
}
const dt =
lastTsRef.current > 0 ? Math.min((timestamp - lastTsRef.current) / 1000, 0.1) : 1 / 60
lastTsRef.current = timestamp
const k = cellDelta > SNAP_CELLS ? 20 : EASING_K
const factor = 1 - Math.exp(-k * dt)
currentRef.current = current + delta * factor
draw()
rafRef.current = requestAnimationFrame(animate)
},
[draw]
)
const startLoop = useCallback(() => {
if (runningRef.current) return
runningRef.current = true
lastTsRef.current = 0
rafRef.current = requestAnimationFrame(animate)
}, [animate])
const setActiveDate = useCallback(
(date: string) => {
const seqIdx = resolveSeqIndex(date)
if (seqIdx < 0) return
targetRef.current = seqIdx
if (currentRef.current < 0) currentRef.current = seqIdx
startLoop()
},
[resolveSeqIndex, startLoop]
)
useImperativeHandle(ref, () => ({ setActiveDate }), [setActiveDate])
useEffect(() => {
if (activeDateProp) setActiveDate(activeDateProp)
}, [activeDateProp, setActiveDate])
useEffect(() => {
if (grid) draw()
}, [grid, draw])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !grid) return
const ro = new ResizeObserver(() => draw())
ro.observe(canvas)
return () => ro.disconnect()
}, [grid, draw])
useEffect(
() => () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
},
[]
)
if (!entries.length || !grid) return null
return (
<div className='mt-8 w-full' style={{ contain: 'layout paint' }}>
<canvas
ref={canvasRef}
width={grid.svgWidth}
height={grid.svgHeight}
className='w-full'
style={{ display: 'block', width: '100%', height: 'auto' }}
role='img'
aria-label='Release activity heatmap'
/>
</div>
)
}
)

View File

@@ -4,10 +4,36 @@ import React from 'react'
import ReactMarkdown from 'react-markdown'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'
type Props = { initialEntries: ChangelogEntry[] }
interface ChangelogListProps {
initialEntries: ChangelogEntry[]
onEntriesChange?: (entries: ChangelogEntry[]) => void
onActiveEntryChange?: (activeTag: string | null) => void
onProgressChange?: (progress: number) => void
variant?: 'cards' | 'flat' | 'timeline'
/** When set, paginate client-side instead of fetching from API. */
pageSize?: number
}
function DotSeparator() {
return (
<div
aria-hidden='true'
className='border-[#2A2A2A] border-y bg-[#1C1C1C] p-[6px]'
style={{
display: 'grid',
gridTemplateColumns: 'repeat(60, 1fr)',
gap: '6px',
placeItems: 'center',
}}
>
{Array.from({ length: 60 }, (_, i) => (
<div key={i} className='h-[2px] w-[2px] rounded-full bg-[#2A2A2A]' />
))}
</div>
)
}
function sanitizeContent(body: string): string {
return body.replace(/&nbsp/g, '')
@@ -16,7 +42,7 @@ function sanitizeContent(body: string): string {
function stripContributors(body: string): string {
let output = body
output = output.replace(
/(^|\n)#{1,6}\s*Contributors\s*\n[\s\S]*?(?=\n\s*\n|\n#{1,6}\s|$)/gi,
/(^|\n)#{1,6}\s*(New\s+)?Contributors\b[^\n]*\n[\s\S]*?(?=\n\s*\n|\n#{1,6}\s|$)/gi,
'\n'
)
output = output.replace(
@@ -42,11 +68,28 @@ function stripPrReferences(body: string): string {
return body.replace(/\s*\(\s*\[#\d+\]\([^)]*\)\s*\)/g, '').replace(/\s*\(\s*#\d+\s*\)/g, '')
}
function stripChangelogFooter(body: string): string {
return body
.split('\n')
.filter((line) => {
const t = line.trim()
if (t.startsWith('**Full Changelog**')) return false
if (/^\[Full Changelog\]/i.test(t)) return false
if (/^View (all )?changes? on GitHub/i.test(t)) return false
if (/^\[View (all )?changes? on GitHub\]/i.test(t)) return false
if (t === '[') return false
return true
})
.join('\n')
.trimEnd()
}
function cleanMarkdown(body: string): string {
const sanitized = sanitizeContent(body)
const withoutContribs = stripContributors(sanitized)
const withoutPrs = stripPrReferences(withoutContribs)
return withoutPrs
const withoutFooter = stripChangelogFooter(withoutPrs)
return withoutFooter
}
function extractMentions(body: string): string[] {
@@ -54,11 +97,101 @@ function extractMentions(body: string): string[] {
return Array.from(new Set(matches.map((m) => m.slice(1))))
}
export default function ChangelogList({ initialEntries }: Props) {
/** Dot grid pattern matching Figma card header background */
const dotGridStyle: React.CSSProperties = {
backgroundImage: 'radial-gradient(circle, #2e2e2e 1px, transparent 1px)',
backgroundSize: '10px 10px',
}
export default function ChangelogList({
initialEntries,
onEntriesChange,
onActiveEntryChange,
onProgressChange,
variant = 'cards',
pageSize,
}: ChangelogListProps) {
const [entries, setEntries] = React.useState<ChangelogEntry[]>(initialEntries)
const [page, setPage] = React.useState<number>(1)
const [loading, setLoading] = React.useState<boolean>(false)
const [done, setDone] = React.useState<boolean>(false)
const [done, setDone] = React.useState<boolean>(!!pageSize)
const [visibleCount, setVisibleCount] = React.useState<number>(pageSize ?? initialEntries.length)
const [activeTag, setActiveTag] = React.useState<string | null>(initialEntries[0]?.tag ?? null)
const shouldTrackActiveEntry = Boolean(onActiveEntryChange)
const cardRefs = React.useRef<Array<HTMLDivElement | null>>([])
const containerRef = React.useRef<HTMLDivElement>(null)
const displayedEntries = pageSize ? entries.slice(0, visibleCount) : entries
const allRevealed = visibleCount >= entries.length
React.useEffect(() => {
onEntriesChange?.(entries)
}, [entries, onEntriesChange])
React.useEffect(() => {
if (!shouldTrackActiveEntry) return
onActiveEntryChange?.(activeTag)
}, [activeTag, onActiveEntryChange, shouldTrackActiveEntry])
React.useEffect(() => {
if (!shouldTrackActiveEntry) return
if (!displayedEntries.length) {
setActiveTag(null)
return
}
const halfVisible = new Set<number>()
const observer = new IntersectionObserver(
(observerEntries) => {
for (const observerEntry of observerEntries) {
const element = observerEntry.target as HTMLDivElement
const index = Number(element.dataset.entryIndex)
if (Number.isNaN(index)) continue
if (observerEntry.isIntersecting && observerEntry.intersectionRatio >= 0.5) {
halfVisible.add(index)
} else {
halfVisible.delete(index)
}
}
if (halfVisible.size === 0) return
const targetIdx = Math.min(...halfVisible)
const tag = displayedEntries[targetIdx]?.tag ?? null
setActiveTag((prev) => (prev === tag ? prev : tag))
if (onProgressChange) {
const total = Math.max(1, displayedEntries.length - 1)
onProgressChange(targetIdx / total)
}
},
{
root: null,
threshold: 0.5,
}
)
for (let index = 0; index < displayedEntries.length; index += 1) {
const card = cardRefs.current[index]
if (!card) continue
observer.observe(card)
}
return () => {
observer.disconnect()
halfVisible.clear()
}
}, [displayedEntries, shouldTrackActiveEntry, variant])
const handleShowMore = () => {
if (pageSize) {
setVisibleCount((c) => Math.min(c + pageSize, entries.length))
return
}
loadMore()
}
const loadMore = async () => {
if (loading || done) return
@@ -94,137 +227,316 @@ export default function ChangelogList({ initialEntries }: Props) {
}
}
const markdownComponents = {
h2: ({ children, ...props }: any) =>
isContributorsLabel(children) ? null : (
<h3
className='mt-5 mb-2 font-[430] font-season text-[#F6F6F6] text-[13px] tracking-[-0.02em] [&:first-child]:mt-0'
{...props}
>
{children}
</h3>
),
h3: ({ children, ...props }: any) =>
isContributorsLabel(children) ? null : (
<h4
className='mt-4 mb-1 font-[430] font-season text-[#F6F6F6] text-[13px] tracking-[-0.02em] [&:first-child]:mt-0'
{...props}
>
{children}
</h4>
),
ul: ({ children, ...props }: any) => (
<ul className='mt-2 mb-3 space-y-1.5' {...props}>
{children}
</ul>
),
li: ({ children, ...props }: any) => {
const text = String(children)
if (/^\s*contributors\s*:?\s*$/i.test(text)) return null
return (
<li
className='font-normal font-season text-[#F6F6F0]/50 text-[13px] leading-[125%] tracking-[0.02em]'
{...props}
>
{children}
</li>
)
},
p: ({ children, ...props }: any) =>
/^\s*contributors\s*:?\s*$/i.test(String(children)) ? null : (
<p
className='mb-3 font-normal font-season text-[#F6F6F0]/50 text-[13px] leading-[125%] tracking-[0.02em]'
{...props}
>
{children}
</p>
),
strong: ({ children, ...props }: any) => (
<strong className='font-medium text-[#F6F6F6]' {...props}>
{children}
</strong>
),
code: ({ children, ...props }: any) => (
<code
className='rounded bg-[#2A2A2A] px-1 py-0.5 font-mono text-[#F6F6F6] text-xs'
{...props}
>
{children}
</code>
),
pre: ({ children, ...props }: any) => (
<pre {...props} suppressHydrationWarning>
{children}
</pre>
),
img: () => null,
a: ({ className, ...props }: any) => (
<a {...props} className={`underline ${className ?? ''}`} target='_blank' rel='noreferrer' />
),
}
return (
<div className='space-y-10'>
{entries.map((entry) => (
<div key={entry.tag}>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-2'>
<div className={`${soehne.className} font-semibold text-[18px] tracking-tight`}>
{entry.tag}
</div>
{entry.contributors && entry.contributors.length > 0 && (
<div className='-space-x-2 flex'>
{entry.contributors.slice(0, 5).map((contributor) => (
<a
key={contributor}
href={`https://github.com/${contributor}`}
target='_blank'
rel='noreferrer noopener'
aria-label={`View @${contributor} on GitHub`}
title={`@${contributor}`}
className='block'
>
<Avatar className='size-6 ring-2 ring-background'>
<AvatarImage
src={`https://avatars.githubusercontent.com/${contributor}`}
alt={`@${contributor}`}
className='hover:z-10'
/>
<AvatarFallback>{contributor.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
</a>
))}
{entry.contributors.length > 5 && (
<div className='relative flex size-6 items-center justify-center rounded-full bg-muted text-[10px] text-foreground ring-2 ring-background hover:z-10'>
+{entry.contributors.length - 5}
</div>
)}
<div
ref={containerRef}
className={
variant === 'flat'
? 'flex flex-col'
: variant === 'timeline'
? 'relative flex flex-col'
: 'flex flex-col gap-4'
}
>
{displayedEntries.map((entry, index) => {
const setRef = (element: HTMLDivElement | null) => {
cardRefs.current[index] = element
}
if (variant === 'timeline') {
return (
<React.Fragment key={entry.tag}>
{index > 0 && (
<div className='-translate-x-1/2 relative left-1/2 w-[100vw] md:w-[50vw]'>
<DotSeparator />
</div>
)}
</div>
<div className={`${inter.className} text-muted-foreground text-xs`}>
{new Date(entry.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</div>
</div>
<div ref={setRef} data-entry-index={index} data-tag={entry.tag} className='py-8'>
<div className='mb-5 flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<a
href={entry.url}
target='_blank'
rel='noreferrer noopener'
className='group/tag font-[600] font-season text-[#fdfdf8] text-xl tracking-[-0.02em]'
>
<span className='relative'>
{entry.tag}
<span className='absolute bottom-0 left-0 h-[1px] w-0 bg-[#fdfdf8] transition-[width] duration-200 group-hover/tag:w-full' />
</span>
</a>
{entry.contributors && entry.contributors.length > 0 && (
<div className='-space-x-2 flex'>
{entry.contributors.slice(0, 5).map((contributor) => (
<a
key={contributor}
href={`https://github.com/${contributor}`}
target='_blank'
rel='noreferrer noopener'
aria-label={`View @${contributor} on GitHub`}
title={`@${contributor}`}
className='block'
>
<Avatar className='size-6 ring-2 ring-[#1C1C1C]'>
<AvatarImage
src={`https://avatars.githubusercontent.com/${contributor}`}
alt={`@${contributor}`}
className='hover:z-10'
/>
<AvatarFallback className='bg-[#2a2a2a] text-[#F6F6F6] text-[10px]'>
{contributor.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</a>
))}
{entry.contributors.length > 5 && (
<div className='relative flex size-6 items-center justify-center rounded-full bg-[#2A2A2A] text-[#F6F6F6] text-[10px] ring-2 ring-[#1C1C1C] hover:z-10'>
+{entry.contributors.length - 5}
</div>
)}
</div>
)}
</div>
<div className={`${inter.className} shrink-0 text-[#F6F6F6]/50 text-xs`}>
{new Date(entry.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</div>
</div>
<div className='prose prose-sm prose-invert max-w-none prose-a:text-brand-primary prose-a:no-underline hover:prose-a:underline'>
<ReactMarkdown components={markdownComponents}>
{cleanMarkdown(entry.content)}
</ReactMarkdown>
</div>
</div>
</React.Fragment>
)
}
if (variant === 'flat') {
return (
<React.Fragment key={entry.tag}>
{index > 0 && <DotSeparator />}
<div ref={setRef} data-entry-index={index} data-tag={entry.tag} className='py-8'>
<div className='mb-5 flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<a
href={entry.url}
target='_blank'
rel='noreferrer noopener'
className='group/tag font-[600] font-season text-[#fdfdf8] text-xl tracking-[-0.02em]'
>
<span className='relative'>
{entry.tag}
<span className='absolute bottom-0 left-0 h-[1px] w-0 bg-[#fdfdf8] transition-[width] duration-200 group-hover/tag:w-full' />
</span>
</a>
{entry.contributors && entry.contributors.length > 0 && (
<div className='-space-x-2 flex'>
{entry.contributors.slice(0, 5).map((contributor) => (
<a
key={contributor}
href={`https://github.com/${contributor}`}
target='_blank'
rel='noreferrer noopener'
aria-label={`View @${contributor} on GitHub`}
title={`@${contributor}`}
className='block'
>
<Avatar className='size-6 ring-2 ring-[#1C1C1C]'>
<AvatarImage
src={`https://avatars.githubusercontent.com/${contributor}`}
alt={`@${contributor}`}
className='hover:z-10'
/>
<AvatarFallback className='bg-[#2a2a2a] text-[#F6F6F6] text-[10px]'>
{contributor.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</a>
))}
{entry.contributors.length > 5 && (
<div className='relative flex size-6 items-center justify-center rounded-full bg-[#2A2A2A] text-[#F6F6F6] text-[10px] ring-2 ring-[#1C1C1C] hover:z-10'>
+{entry.contributors.length - 5}
</div>
)}
</div>
)}
</div>
<div className={`${inter.className} shrink-0 text-[#F6F6F6]/50 text-xs`}>
{new Date(entry.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</div>
</div>
<div className='prose prose-sm prose-invert max-w-none prose-a:text-brand-primary prose-a:no-underline hover:prose-a:underline'>
<ReactMarkdown components={markdownComponents}>
{cleanMarkdown(entry.content)}
</ReactMarkdown>
</div>
</div>
</React.Fragment>
)
}
return (
<div
className={`${inter.className} prose prose-sm dark:prose-invert max-w-none prose-headings:font-semibold prose-a:text-brand-primary prose-headings:text-foreground prose-p:text-muted-foreground prose-a:no-underline hover:prose-a:underline`}
key={entry.tag}
ref={setRef}
data-entry-index={index}
data-tag={entry.tag}
className='overflow-hidden rounded-[14px] border border-[#4d4d4d] bg-[#1b1b1b]'
>
<ReactMarkdown
components={{
h2: ({ children, ...props }) =>
isContributorsLabel(children) ? null : (
<h3
className={`${soehne.className} mt-5 mb-2 font-medium text-[13px] text-foreground tracking-tight`}
{...props}
>
{children}
</h3>
),
h3: ({ children, ...props }) =>
isContributorsLabel(children) ? null : (
<h4
className={`${soehne.className} mt-4 mb-1 font-medium text-[13px] text-foreground tracking-tight`}
{...props}
>
{children}
</h4>
),
ul: ({ children, ...props }) => (
<ul className='mt-2 mb-3 space-y-1.5' {...props}>
{children}
</ul>
),
li: ({ children, ...props }) => {
const text = String(children)
if (/^\s*contributors\s*:?\s*$/i.test(text)) return null
return (
<li className='text-[13px] text-muted-foreground leading-relaxed' {...props}>
{children}
</li>
)
},
p: ({ children, ...props }) =>
/^\s*contributors\s*:?\s*$/i.test(String(children)) ? null : (
<p
className='mb-3 text-[13px] text-muted-foreground leading-relaxed'
{...props}
>
{children}
</p>
),
strong: ({ children, ...props }) => (
<strong className='font-medium text-foreground' {...props}>
{children}
</strong>
),
code: ({ children, ...props }) => (
<code
className='rounded bg-muted px-1 py-0.5 font-mono text-foreground text-xs'
{...props}
>
{children}
</code>
),
img: () => null,
a: ({ className, ...props }: any) => (
<a
{...props}
className={`underline ${className ?? ''}`}
target='_blank'
rel='noreferrer'
/>
),
}}
{/* Card header with dot grid pattern */}
<div
className='relative flex items-center justify-between gap-4 border-[#2a2a2a] border-b px-5 py-4'
style={dotGridStyle}
>
{cleanMarkdown(entry.content)}
</ReactMarkdown>
</div>
</div>
))}
<div className='flex items-center gap-3'>
<a
href={entry.url}
target='_blank'
rel='noreferrer noopener'
className='group/tag font-[600] font-season text-[#fdfdf8] text-xl tracking-[-0.02em]'
>
<span className='relative'>
{entry.tag}
<span className='absolute bottom-0 left-0 h-[1px] w-0 bg-[#fdfdf8] transition-[width] duration-200 group-hover/tag:w-full' />
</span>
</a>
{entry.contributors && entry.contributors.length > 0 && (
<div className='-space-x-2 flex'>
{entry.contributors.slice(0, 5).map((contributor) => (
<a
key={contributor}
href={`https://github.com/${contributor}`}
target='_blank'
rel='noreferrer noopener'
aria-label={`View @${contributor} on GitHub`}
title={`@${contributor}`}
className='block'
>
<Avatar className='size-6 ring-2 ring-[#1C1C1C]'>
<AvatarImage
src={`https://avatars.githubusercontent.com/${contributor}`}
alt={`@${contributor}`}
className='hover:z-10'
/>
<AvatarFallback className='bg-[#2a2a2a] text-[#F6F6F6] text-[10px]'>
{contributor.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</a>
))}
{entry.contributors.length > 5 && (
<div className='relative flex size-6 items-center justify-center rounded-full bg-[#2A2A2A] text-[#F6F6F6] text-[10px] ring-2 ring-[#1C1C1C] hover:z-10'>
+{entry.contributors.length - 5}
</div>
)}
</div>
)}
</div>
<div className={`${inter.className} text-[#F6F6F6]/50 text-xs`}>
{new Date(entry.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</div>
</div>
{!done && (
{/* Card body */}
<div className='px-5 py-4'>
<div className='prose prose-sm prose-invert max-w-none prose-a:text-brand-primary prose-a:no-underline hover:prose-a:underline'>
<ReactMarkdown components={markdownComponents}>
{cleanMarkdown(entry.content)}
</ReactMarkdown>
</div>
</div>
</div>
)
})}
{!(pageSize ? allRevealed : done) && (
<div>
<button
type='button'
onClick={loadMore}
onClick={handleShowMore}
disabled={loading}
className='rounded-md border border-border px-3 py-1.5 text-[13px] hover:bg-muted disabled:opacity-60'
className='rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-3 py-1.5 text-[#F6F6F6] text-[13px] hover:bg-[rgba(246,246,240,0.1)] disabled:opacity-60'
>
{loading ? 'Loading…' : 'Show more'}
</button>

View File

@@ -0,0 +1,121 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { BookOpen, Github, Rss } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'
import { CommitHeatmap, type CommitHeatmapHandle } from '@/app/changelog/components/commit-heatmap'
import ChangelogList from '@/app/changelog/components/timeline-list'
interface BreaksProps {
entries: ChangelogEntry[]
}
export function Breaks({ entries }: BreaksProps) {
const [loadedEntries, setLoadedEntries] = useState<ChangelogEntry[]>(entries)
const [activeTag, setActiveTag] = useState<string | null>(entries[0]?.tag ?? null)
const heatmapRef = useRef<CommitHeatmapHandle>(null)
const entriesRef = useRef(loadedEntries)
entriesRef.current = loadedEntries
const handleActiveEntryChange = useCallback((tag: string | null) => {
setActiveTag(tag)
if (tag) {
const entry = entriesRef.current.find((e) => e.tag === tag)
if (entry) {
heatmapRef.current?.setActiveDate(entry.date.split('T')[0])
}
}
}, [])
const handleEntriesChange = useCallback((nextEntries: ChangelogEntry[]) => {
setLoadedEntries(nextEntries)
}, [])
return (
<div className='min-h-screen bg-[#1C1C1C]'>
<div id='changelog-grid' className='relative grid md:grid-cols-2'>
{/* Left intro panel */}
<div className='relative top-0 overflow-hidden border-[#2A2A2A] border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
{/* Background card decoration */}
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw] opacity-40'
>
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
</div>
{/* Union decorative shape */}
<div
aria-hidden='true'
className='pointer-events-none absolute right-[-20%] bottom-[-10%] z-0 w-[75%] rotate-90 opacity-80'
>
<Image
src='/landing/union-right.svg'
alt=''
width={768}
height={768}
className='h-auto w-full'
/>
</div>
<div className='relative z-10 mx-auto h-full max-w-xl md:flex md:flex-col md:justify-center'>
<h1 className='mt-6 font-[430] font-season text-4xl text-white tracking-[-0.02em] sm:text-5xl'>
Changelog
</h1>
<p className='mt-3 font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
Stay up-to-date with the latest features, improvements, and bug fixes in Sim. All
changes are documented here with detailed release notes.
</p>
<div className='mt-5 flex flex-wrap items-center gap-3'>
<Link
href='https://github.com/simstudioai/sim/releases'
target='_blank'
rel='noopener noreferrer'
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
>
<Github className='h-4 w-4' />
View on GitHub
</Link>
<Link
href='https://docs.sim.ai'
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
>
<BookOpen className='h-4 w-4' />
Documentation
</Link>
<Link
href='/changelog.xml'
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
>
<Rss className='h-4 w-4' />
RSS Feed
</Link>
</div>
<CommitHeatmap
ref={heatmapRef}
entries={loadedEntries}
activeDate={entries[0]?.date.split('T')[0]}
/>
</div>
</div>
{/* Right timeline */}
<div className='relative overflow-x-clip px-4 py-10 sm:px-6 md:px-8 md:py-12'>
<div className='relative mx-auto max-w-2xl'>
<ChangelogList
initialEntries={entries}
variant='timeline'
pageSize={10}
onEntriesChange={handleEntriesChange}
onActiveEntryChange={handleActiveEntryChange}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { BookOpen, Github, Rss } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { ChangelogBlocks } from '@/app/changelog/components/changelog-blocks'
import type { ChangelogEntry } from '@/app/changelog/components/changelog-content'
import ChangelogList from '@/app/changelog/components/timeline-list'
interface ScrollProps {
entries: ChangelogEntry[]
}
export function Scroll({ entries }: ScrollProps) {
return (
<div className='min-h-screen bg-[#1C1C1C]'>
<div id='changelog-grid' className='relative grid md:grid-cols-2'>
{/* Left intro panel */}
<div className='relative top-0 overflow-hidden border-[#2A2A2A] border-b px-6 py-16 sm:px-10 md:sticky md:h-dvh md:border-r md:border-b-0 md:px-12 md:py-24'>
{/* Background card decoration */}
<div
aria-hidden='true'
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw] opacity-40'
>
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
</div>
{/* Union decorative shape */}
<div
aria-hidden='true'
className='pointer-events-none absolute right-[-20%] bottom-[-10%] z-0 w-[75%] rotate-90 opacity-80'
>
<Image
src='/landing/union-right.svg'
alt=''
width={768}
height={768}
className='h-auto w-full'
/>
</div>
<ChangelogBlocks mode='scroll' />
<div className='relative z-10 mx-auto h-full max-w-xl md:flex md:flex-col md:justify-center'>
<h1 className='mt-6 font-[430] font-season text-4xl text-white tracking-[-0.02em] sm:text-5xl'>
Changelog
</h1>
<p className='mt-3 font-[430] font-season text-[#F6F6F0]/50 text-[14px] leading-[125%] tracking-[0.02em] sm:text-[16px]'>
Stay up-to-date with the latest features, improvements, and bug fixes in Sim. All
changes are documented here with detailed release notes.
</p>
<div className='mt-5 flex flex-wrap items-center gap-3'>
<Link
href='https://github.com/simstudioai/sim/releases'
target='_blank'
rel='noopener noreferrer'
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] text-black transition-[filter] hover:brightness-110'
>
<Github className='h-4 w-4' />
View on GitHub
</Link>
<Link
href='https://docs.sim.ai'
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
>
<BookOpen className='h-4 w-4' />
Documentation
</Link>
<Link
href='/changelog.xml'
className='inline-flex h-[32px] items-center gap-[6px] rounded-[5px] border border-[#2A2A2A] bg-[rgba(246,246,240,0.06)] px-[10px] font-[430] font-season text-[#F6F6F6] text-[14px] transition-all hover:bg-[rgba(246,246,240,0.1)]'
>
<Rss className='h-4 w-4' />
RSS Feed
</Link>
</div>
</div>
</div>
{/* Right timeline */}
<div className='relative px-4 py-10 sm:px-6 md:px-8 md:py-12'>
<div className='relative max-w-2xl'>
<ChangelogList initialEntries={entries} />
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,10 +1,12 @@
import Nav from '@/app/(landing)/components/nav/nav'
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
import { season } from '@/app/_styles/fonts/season/season'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default function ChangelogLayout({ children }: { children: React.ReactNode }) {
return (
<div className='relative min-h-screen text-foreground'>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
<Nav />
<div className={`${season.variable} ${martianMono.variable} relative min-h-screen`}>
<div className='-z-50 pointer-events-none fixed inset-0 bg-[#1C1C1C]' />
<Navbar />
{children}
</div>
)

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import type { ChangelogVariant } from '@/app/changelog/components/changelog-content'
import ChangelogContent from '@/app/changelog/components/changelog-content'
export const metadata: Metadata = {
@@ -11,6 +12,16 @@ export const metadata: Metadata = {
},
}
export default function ChangelogPage() {
return <ChangelogContent />
const VALID_VARIANTS: ChangelogVariant[] = ['breaks', 'scroll']
interface ChangelogPageProps {
searchParams: Promise<{ v?: string }>
}
export default async function ChangelogPage({ searchParams }: ChangelogPageProps) {
const { v } = await searchParams
const variant = VALID_VARIANTS.includes(v as ChangelogVariant)
? (v as ChangelogVariant)
: undefined
return <ChangelogContent variant={variant} />
}

View File

@@ -1,51 +1,17 @@
'use client'
import { useState } from 'react'
import { ArrowRight, ChevronRight } from 'lucide-react'
interface ContactButtonProps {
href: string
children: React.ReactNode
}
export function ContactButton({ href, children }: ContactButtonProps) {
const [isHovered, setIsHovered] = useState(false)
return (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
borderRadius: '10px',
background: 'linear-gradient(to bottom, #8357ff, #6f3dfa)',
border: '1px solid #6f3dfa',
boxShadow: 'inset 0 2px 4px 0 #9b77ff',
paddingTop: '6px',
paddingBottom: '6px',
paddingLeft: '12px',
paddingRight: '10px',
fontSize: '15px',
fontWeight: 500,
color: '#ffffff',
textDecoration: 'none',
opacity: isHovered ? 0.9 : 1,
transition: 'opacity 200ms',
}}
className='!text-black !no-underline inline-flex h-[32px] items-center gap-[8px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[10px] font-[430] font-season text-[14px] transition-[filter] hover:brightness-110'
>
{children}
<span style={{ display: 'inline-flex' }}>
{isHovered ? (
<ArrowRight style={{ height: '16px', width: '16px' }} aria-hidden='true' />
) : (
<ChevronRight style={{ height: '16px', width: '16px' }} aria-hidden='true' />
)}
</span>
</a>
)
}

View File

@@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 768.219 767.667" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Union">
<path d="M715.886 0.820573C744.399 1.18152 767.402 24.4083 767.403 53.0071V150.79C767.403 179.389 744.4 202.616 715.886 202.977L715.212 202.982H586.265C583.868 202.982 582.266 205.495 582.968 207.787C583.989 211.117 584.538 214.654 584.538 218.319V345.442C584.538 365.287 568.45 381.375 548.605 381.375H348.717C346.913 381.375 345.45 382.838 345.45 384.642V730.917C345.45 750.763 329.362 766.851 309.517 766.851H36.7503C16.9049 766.851 0.816756 750.763 0.816667 730.917V218.319C0.816667 198.473 16.9048 182.385 36.7503 182.385H164.698C166.503 182.385 167.965 180.922 167.965 179.118V53.0081C167.965 24.1843 191.332 0.817072 220.156 0.816667H715.212L715.886 0.820573ZM220.156 39.9602C212.95 39.9606 207.109 45.8024 207.109 53.0081V160.571C207.109 162.376 208.571 163.838 210.375 163.838H715.212C722.418 163.838 728.26 157.996 728.26 150.79V53.0071C728.26 45.8016 722.418 39.9604 715.212 39.9602H220.156Z" fill="var(--fill-0, #1C1C1C)"/>
<path d="M715.886 0.820573L715.896 0.00395244L715.891 0.00391996L715.886 0.820573ZM767.403 53.0071H768.219V53.0071L767.403 53.0071ZM715.886 202.977L715.892 203.793L715.896 203.793L715.886 202.977ZM715.212 202.982V203.798L715.218 203.798L715.212 202.982ZM584.538 345.442H585.355V345.442H584.538ZM548.605 381.375V382.192V382.192V381.375ZM345.45 730.917H346.267V730.917H345.45ZM309.517 766.851V767.667V767.667V766.851ZM0.816667 730.917H0V730.917H0.816667ZM167.965 53.0081L167.148 53.0081V53.0081H167.965ZM220.156 0.816667V0H220.156L220.156 0.816667ZM715.212 0.816667L715.217 0H715.212V0.816667ZM220.156 39.9602V39.1436H220.155L220.156 39.9602ZM207.109 53.0081L206.292 53.008V53.0081H207.109ZM715.212 163.838V164.655V164.655V163.838ZM728.26 53.0071H729.077V53.007L728.26 53.0071ZM715.212 39.9602V39.1436V39.1436V39.9602ZM582.968 207.787L583.749 207.548L582.968 207.787ZM715.886 0.820573L715.876 1.63717C743.943 1.99247 766.585 24.8559 766.586 53.0071L767.403 53.0071L768.219 53.0071C768.219 23.9608 744.856 0.370568 715.896 0.0039717L715.886 0.820573ZM767.403 53.0071H766.586V150.79H767.403H768.219V53.0071H767.403ZM767.403 150.79H766.586C766.586 178.942 743.943 201.805 715.876 202.16L715.886 202.977L715.896 203.793C744.856 203.427 768.219 179.837 768.219 150.79H767.403ZM715.886 202.977L715.88 202.16L715.206 202.165L715.212 202.982L715.218 203.798L715.892 203.793L715.886 202.977ZM715.212 202.982V202.165H586.265V202.982V203.798H715.212V202.982ZM582.968 207.787L582.188 208.026C583.184 211.28 583.722 214.736 583.722 218.319H584.538H585.355C585.355 214.572 584.793 210.955 583.749 207.548L582.968 207.787ZM584.538 218.319H583.722V345.442H584.538H585.355V218.319H584.538ZM584.538 345.442H583.722C583.722 364.836 567.999 380.559 548.605 380.559V381.375V382.192C568.901 382.192 585.355 365.738 585.355 345.442H584.538ZM548.605 381.375V380.559H348.717V381.375V382.192H548.605V381.375ZM345.45 384.642H344.634V730.917H345.45H346.267V384.642H345.45ZM345.45 730.917H344.634C344.634 750.312 328.911 766.034 309.517 766.034V766.851V767.667C329.813 767.667 346.267 751.214 346.267 730.917H345.45ZM309.517 766.851V766.034H36.7503V766.851V767.667H309.517V766.851ZM36.7503 766.851V766.034C17.3559 766.034 1.63342 750.312 1.63333 730.917H0.816667H0C9.16123e-05 751.214 16.4538 767.667 36.7503 767.667V766.851ZM0.816667 730.917H1.63333V218.319H0.816667H0V730.917H0.816667ZM0.816667 218.319H1.63333C1.63333 198.924 17.3559 183.202 36.7503 183.202V182.385V181.568C16.4538 181.568 0 198.022 0 218.319H0.816667ZM36.7503 182.385V183.202H164.698V182.385V181.568H36.7503V182.385ZM167.965 179.118H168.782V53.0081H167.965H167.148V179.118H167.965ZM167.965 53.0081L168.782 53.0081C168.782 24.6353 191.783 1.63373 220.156 1.63333L220.156 0.816667L220.156 0C190.881 0.00041157 167.149 23.7333 167.148 53.0081L167.965 53.0081ZM220.156 0.816667V1.63333H715.212V0.816667V0H220.156V0.816667ZM715.212 0.816667L715.207 1.63332L715.881 1.63723L715.886 0.820573L715.891 0.00391996L715.217 1.37091e-05L715.212 0.816667ZM220.156 39.9602L220.155 39.1436C212.499 39.144 206.292 45.3514 206.292 53.008L207.109 53.0081L207.925 53.0081C207.926 46.2534 213.401 40.7773 220.156 40.7769L220.156 39.9602ZM207.109 53.0081H206.292V160.571H207.109H207.925V53.0081H207.109ZM210.375 163.838V164.655H715.212V163.838V163.021H210.375V163.838ZM715.212 163.838V164.655C722.869 164.655 729.077 158.447 729.077 150.79H728.26H727.443C727.443 157.545 721.967 163.021 715.212 163.021V163.838ZM728.26 150.79H729.077V53.0071H728.26H727.443V150.79H728.26ZM728.26 53.0071L729.077 53.007C729.076 45.3505 722.869 39.1437 715.212 39.1436V39.9602V40.7769C721.967 40.7771 727.443 46.2527 727.443 53.0072L728.26 53.0071ZM715.212 39.9602V39.1436H220.156V39.9602V40.7769H715.212V39.9602ZM207.109 160.571H206.292C206.292 162.827 208.12 164.655 210.375 164.655V163.838V163.021C209.022 163.021 207.925 161.925 207.925 160.571H207.109ZM164.698 182.385V183.202C166.954 183.202 168.782 181.374 168.782 179.118H167.965H167.148C167.148 180.471 166.052 181.568 164.698 181.568V182.385ZM348.717 381.375V380.559C346.462 380.559 344.634 382.387 344.634 384.642H345.45H346.267C346.267 383.289 347.364 382.192 348.717 382.192V381.375ZM586.265 202.982V202.165C583.235 202.165 581.35 205.293 582.188 208.026L582.968 207.787L583.749 207.548C583.182 205.697 584.502 203.798 586.265 203.798V202.982Z" fill="var(--stroke-0, #323232)" fill-opacity="0.4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB