<script lang="ts">
import { onMount } from "svelte";
/*
* Where collaboration has lived, on one line: mailing lists, then GitHub, then
* the AT Protocol, each on top of Git. Git comes in first and stays put along the
* bottom; then the line draws left to right and each era's layer lands as the line
* reaches it, the last in the accent. Static until it's armed on hydration, so it
* reads the same without javascript or with reduced motion.
*/
const ERAS = [
{ year: "’05", layer: "Mail" },
{ year: "’09", layer: "GitHub" },
{ year: "’25", layer: "AT Protocol" }
];
let figure: HTMLElement;
let armed = $state(false);
let playing = $state(false);
onMount(() => {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
armed = true;
const io = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
playing = true;
io.disconnect();
},
{ threshold: 0.6 }
);
io.observe(figure);
return () => io.disconnect();
});
</script>
<figure
bind:this={figure}
class={["ft", armed && "armed", playing && "playing"]}
aria-label="A timeline: collaboration over mail in 2005, on GitHub in 2009, and on the AT Protocol in 2025, each on top of Git"
>
<ol aria-hidden="true">
{#each ERAS as era, i (era.year)}
<li style:--i={i} class={[i === ERAS.length - 1 && "now"]}>
<span class="year">{era.year}</span>
<span class="node"></span>
<span class="chip layer">{era.layer}</span>
<span class="plus">+</span>
<span class="chip git">Git</span>
</li>
{/each}
</ol>
</figure>
<style>
.ft {
/* one era's beat: the line crosses a column in this long */
--beat: 0.25s;
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
margin-block: var(--spacing-2xl, 2.5rem);
padding-block: 0.5rem;
}
ol {
display: grid;
grid-template-columns: repeat(3, 1fr);
margin: 0;
padding: 0;
list-style: none;
/* the line runs off both edges, as in the sketch, fading as it goes */
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
li {
position: relative;
display: grid;
justify-items: center;
row-gap: 0.5rem;
grid-template-rows: 1.25rem 0.75rem auto 0.75rem auto;
margin: 0;
padding: 0;
}
/* the line, one segment per column so it can draw left to right */
li::before {
content: "";
position: absolute;
top: calc(1.25rem + 0.5rem + 0.375rem);
left: 0;
right: 0;
height: 1px;
background: var(--color-border-strong);
transform-origin: left;
}
/* git, one rail under all three: the part that never changed */
li::after {
content: "";
position: absolute;
bottom: 0.875rem;
left: 0;
right: 0;
border-top: 1px dashed var(--color-border-default);
z-index: -1;
}
.year {
font-size: 0.8125rem;
line-height: 1.25rem;
font-variant-numeric: tabular-nums;
color: var(--color-foreground-subtle);
}
.node {
position: relative;
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
background: var(--color-background-default);
box-shadow: inset 0 0 0 1px var(--color-border-strong);
}
.now .node {
background: var(--color-background-info-emphasis);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-background-info-emphasis) 22%, transparent);
}
.chip {
padding: 0.125rem 0.625rem;
border: 1px solid var(--color-border-default);
border-radius: 999px;
background: var(--color-background-default);
font-size: 0.875rem;
line-height: 1.375rem;
white-space: nowrap;
color: var(--color-foreground-default);
}
.now .layer {
border-color: color-mix(in srgb, var(--color-background-info-emphasis) 55%, var(--color-border-default));
color: var(--color-foreground-info-strong);
}
.git {
color: var(--color-foreground-muted);
}
.plus {
font-size: 0.75rem;
line-height: 0.75rem;
color: var(--color-foreground-subtle);
}
/* armed: everything waits in its start pose until the figure is in view */
.armed li::before {
transform: scaleX(0);
}
.armed :is(.year, .node, .layer, .plus) {
opacity: 0;
}
.armed .layer {
translate: 0 0.375rem;
}
.armed .git,
.armed li::after {
opacity: 0;
}
/* git first, all at once; then the line, column by column, and each era as it arrives */
.playing .git,
.playing li::after {
animation: ft-fade 0.35s var(--ease) both;
}
.playing li::before {
animation: ft-draw var(--beat) linear calc(0.2s + var(--i) * var(--beat)) both;
}
.playing :is(.year, .node) {
animation: ft-fade 0.25s var(--ease) calc(0.2s + (var(--i) + 0.5) * var(--beat)) both;
}
.playing :is(.layer, .plus) {
animation: ft-rise 0.35s var(--ease) calc(0.25s + (var(--i) + 0.5) * var(--beat)) both;
}
@keyframes ft-draw {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes ft-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ft-rise {
from {
opacity: 0;
translate: 0 0.375rem;
}
to {
opacity: 1;
translate: 0 0;
}
}
</style>