<script setup lang="ts">
import { computed, ref } from 'vue'
export type ChangeType = 'new' | 'improved' | 'fixed' | 'security'
export interface Change {
type: ChangeType
text: string
}
export interface Release {
version: string
/** ISO date, e.g. "2026-09-24". */
date: string
title: string
summary?: string
changes: Change[]
}
const props = withDefaults(defineProps<{
releases?: Release[]
heading?: string
subheading?: string
/** Show filter chips for change types. */
filterable?: boolean
/** BCP 47 locale used to format dates. */
locale?: string
}>(), {
heading: 'Changelog',
subheading: 'New features, improvements and fixes to Beacon, shipped every two weeks.',
filterable: true,
locale: 'en-US',
releases: () => [
{
version: '3.4.0', date: '2026-09-24', title: 'Saved views and a faster search',
summary: 'Save any combination of filters as a view and share it with your team. Search now returns results as you type.',
changes: [
{ type: 'new', text: 'Saved views with per-team sharing and a default view per project.' },
{ type: 'improved', text: 'Search is up to 4x faster on workspaces with more than 50,000 items.' },
{ type: 'fixed', text: 'Keyboard shortcuts no longer fire while typing in a comment.' },
],
},
{
version: '3.3.2', date: '2026-09-10', title: 'Security and stability',
changes: [
{ type: 'security', text: 'Session tokens are now rotated after a password change on every device.' },
{ type: 'fixed', text: 'Exports with more than 10,000 rows no longer time out.' },
{ type: 'fixed', text: 'Due dates respect the workspace time zone in email reminders.' },
],
},
{
version: '3.3.0', date: '2026-08-27', title: 'Recurring tasks',
summary: 'Tasks can repeat daily, weekly or on a custom schedule, and each occurrence keeps its own history.',
changes: [
{ type: 'new', text: 'Recurring tasks with flexible schedules and skip-next.' },
{ type: 'new', text: 'Webhook events for task.recurred and task.skipped.' },
{ type: 'improved', text: 'The calendar view loads a month in a single request.' },
],
},
{
version: '3.2.0', date: '2026-08-13', title: 'Dark mode everywhere',
changes: [
{ type: 'new', text: 'Dark mode for the web app, emails and the embeddable widget.' },
{ type: 'improved', text: 'Higher contrast status colours that pass WCAG AA in both themes.' },
],
},
],
})
const types: { value: ChangeType, label: string, badge: string, dot: string }[] = [
{ value: 'new', label: 'New', badge: 'bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-400/10 dark:text-orange-300 dark:ring-orange-400/25', dot: 'bg-orange-500' },
{ value: 'improved', label: 'Improved', badge: 'bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-400/10 dark:text-sky-300 dark:ring-sky-400/25', dot: 'bg-sky-500' },
{ value: 'fixed', label: 'Fixed', badge: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-400/10 dark:text-emerald-300 dark:ring-emerald-400/25', dot: 'bg-emerald-500' },
{ value: 'security', label: 'Security', badge: 'bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-400/10 dark:text-rose-300 dark:ring-rose-400/25', dot: 'bg-rose-500' },
]
const typeInfo = Object.fromEntries(types.map(t => [t.value, t])) as Record<ChangeType, (typeof types)[number]>
const active = ref<Set<ChangeType>>(new Set())
function toggleType(t: ChangeType) {
const next = new Set(active.value)
if (next.has(t)) next.delete(t)
else next.add(t)
active.value = next
}
const visible = computed(() => props.releases
.map((release, index) => ({
release,
latest: index === 0,
changes: active.value.size ? release.changes.filter(c => active.value.has(c.type)) : release.changes,
}))
.filter(r => r.changes.length > 0))
const formatter = computed(() => new Intl.DateTimeFormat(props.locale, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }))
const formatDate = (iso: string) => formatter.value.format(new Date(`${iso}T00:00:00Z`))
</script>
<template>
<section class="px-4 py-16 sm:px-6 sm:py-20">
<div class="mx-auto max-w-4xl">
<header class="max-w-2xl">
<h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl dark:text-white">{{ heading }}</h2>
<p class="mt-3 text-lg text-pretty text-gray-600 dark:text-gray-400">{{ subheading }}</p>
</header>
<div v-if="filterable" class="mt-8 flex flex-wrap items-center gap-2" role="group" aria-label="Filter by change type">
<button
v-for="t in types"
:key="t.value"
type="button"
:aria-pressed="active.has(t.value)"
class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-medium ring-1 transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600 dark:focus-visible:outline-orange-400"
:class="active.has(t.value)
? 'bg-gray-900 text-white ring-gray-900 dark:bg-white dark:text-gray-900 dark:ring-white'
: 'bg-white text-gray-700 ring-gray-200 hover:bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-gray-800'"
@click="toggleType(t.value)"
>
<span class="size-2 rounded-full" :class="t.dot" aria-hidden="true" />
{{ t.label }}
</button>
<button
v-if="active.size"
type="button"
class="rounded-full px-3 py-1.5 text-sm font-medium text-gray-500 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-orange-600 dark:text-gray-400 dark:hover:text-white"
@click="active = new Set()"
>Clear</button>
</div>
<ol class="relative mt-10 space-y-12 before:absolute before:top-2 before:bottom-2 before:left-[5px] before:w-px before:bg-gray-200 md:before:left-[11.5rem] dark:before:bg-white/10">
<li v-for="{ release, latest, changes } in visible" :key="release.version" class="relative grid gap-3 pl-8 md:grid-cols-[10rem_1fr] md:gap-12 md:pl-0">
<span
aria-hidden="true"
class="absolute top-1.5 left-0 size-[11px] rounded-full ring-4 md:left-[calc(11.5rem-5px)]"
:class="latest ? 'bg-orange-500 ring-orange-100 dark:ring-orange-500/20' : 'bg-gray-300 ring-white dark:bg-gray-600 dark:ring-gray-950'"
/>
<div class="flex items-center gap-3 md:flex-col md:items-end md:gap-2 md:pt-0.5 md:text-right">
<span
class="rounded-md px-2 py-0.5 font-mono text-xs font-semibold ring-1"
:class="latest
? 'bg-orange-600 text-white ring-orange-600 dark:bg-orange-500 dark:text-gray-950 dark:ring-orange-500'
: 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10'"
>v{{ release.version }}</span>
<time :datetime="release.date" class="text-sm text-gray-500 dark:text-gray-400">{{ formatDate(release.date) }}</time>
</div>
<article class="md:pl-4">
<h3 class="flex flex-wrap items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ release.title }}
<span v-if="latest" class="rounded-full bg-orange-50 px-2 py-0.5 text-xs font-semibold text-orange-700 ring-1 ring-orange-600/20 dark:bg-orange-400/10 dark:text-orange-300 dark:ring-orange-400/25">Latest</span>
</h3>
<p v-if="release.summary" class="mt-2 text-base/7 text-pretty text-gray-600 dark:text-gray-400">{{ release.summary }}</p>
<ul class="mt-4 space-y-2.5">
<li v-for="change in changes" :key="change.text" class="flex items-start gap-3 text-sm/6 text-gray-700 dark:text-gray-300">
<span class="mt-0.5 w-20 shrink-0 rounded-md px-2 py-0.5 text-center text-xs font-semibold ring-1" :class="typeInfo[change.type].badge">
{{ typeInfo[change.type].label }}
</span>
<span>{{ change.text }}</span>
</li>
</ul>
<slot name="release" :release="release" />
</article>
</li>
</ol>
<p v-if="!visible.length" class="mt-10 text-sm text-gray-500 dark:text-gray-400">No changes match the selected filters.</p>
</div>
</section>
</template>
<script setup lang="ts">
import ChangelogTimeline from './Component.vue'
</script>
<template>
<main class="min-h-screen bg-white dark:bg-gray-950">
<ChangelogTimeline>
<template #release="{ release }">
<a
:href="`#release-${release.version}`"
class="mt-4 inline-flex items-center gap-1 rounded text-sm font-semibold text-orange-700 hover:text-orange-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600 dark:text-orange-400 dark:hover:text-orange-300"
>
Release notes for v{{ release.version }} <span aria-hidden="true">→</span>
</a>
</template>
</ChangelogTimeline>
</main>
</template>