<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { categories, posts, type Category } from './data'
import BlogFooter from './components/BlogFooter.vue'
import BlogHeader from './components/BlogHeader.vue'
import FeaturedPost from './components/FeaturedPost.vue'
import NewsletterBox from './components/NewsletterBox.vue'
import PaginationNav from './components/PaginationNav.vue'
import PostCard from './components/PostCard.vue'
import PostFilters from './components/PostFilters.vue'
const PAGE_SIZE = 6
const featured = posts[0]!
const category = ref<Category | 'All'>('All')
const query = ref('')
const page = ref(1)
const isFiltering = computed(() => category.value !== 'All' || query.value.trim() !== '')
const counts = computed(() => {
const result: Record<string, number> = { All: posts.length }
for (const post of posts) result[post.category] = (result[post.category] ?? 0) + 1
return result
})
const results = computed(() => {
const q = query.value.trim().toLowerCase()
return posts.filter((post) => {
if (!isFiltering.value && post.id === featured.id) return false
if (category.value !== 'All' && post.category !== category.value) return false
if (!q) return true
return [post.title, post.excerpt, post.author.name, post.category].some(text => text.toLowerCase().includes(q))
})
})
const totalPages = computed(() => Math.max(1, Math.ceil(results.value.length / PAGE_SIZE)))
const visible = computed(() => results.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE))
watch([category, query], () => { page.value = 1 })
const postsSection = ref<HTMLElement>()
function goToPage(n: number) {
page.value = n
postsSection.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function reset() {
category.value = 'All'
query.value = ''
}
</script>
<template>
<div class="min-h-screen bg-white text-gray-900 dark:bg-gray-950 dark:text-white">
<BlogHeader />
<main class="mx-auto max-w-6xl px-4 sm:px-6">
<section class="pt-14 pb-10 sm:pt-20" aria-labelledby="blog-title">
<p class="text-sm font-semibold text-rose-600 dark:text-rose-400">The Basalt blog</p>
<h1 id="blog-title" class="mt-2 max-w-3xl text-4xl font-bold tracking-tight text-balance sm:text-5xl">Notes from the team building Basalt</h1>
<p class="mt-4 max-w-2xl text-lg text-gray-600 dark:text-gray-400">Deep dives on how we design, build and run a product used by thousands of teams, written by the engineers, designers and PMs who did the work.</p>
</section>
<FeaturedPost v-if="!isFiltering" :post="featured" />
<section id="posts" ref="postsSection" class="scroll-mt-24 pt-16 pb-16" aria-labelledby="posts-title">
<h2 id="posts-title" class="sr-only">All articles</h2>
<PostFilters v-model:category="category" v-model:query="query" :categories="categories" :counts="counts" />
<p class="mt-6 text-sm text-gray-500 dark:text-gray-400" aria-live="polite">
<template v-if="isFiltering">{{ results.length }} {{ results.length === 1 ? 'article' : 'articles' }} found</template>
<template v-else>Latest articles</template>
</p>
<div v-if="visible.length" class="mt-6 grid gap-x-8 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
<PostCard v-for="post in visible" :key="post.id" :post="post" />
</div>
<div v-else class="mt-6 rounded-2xl border border-dashed border-gray-300 px-6 py-16 text-center dark:border-white/15">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" class="mx-auto size-10 text-gray-400" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" /><path d="m16 16 4 4M8.5 11h5" />
</svg>
<p class="mt-4 font-semibold">No articles match “{{ query }}”</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try a different keyword or browse all categories.</p>
<button type="button" class="mt-5 rounded-lg px-3 py-2 text-sm font-semibold text-rose-600 hover:bg-rose-50 focus-visible:outline-2 focus-visible:outline-rose-500 dark:text-rose-400 dark:hover:bg-rose-500/10" @click="reset">Clear filters</button>
</div>
<PaginationNav class="mt-14" :model-value="page" :total-pages="totalPages" @update:model-value="goToPage" />
</section>
<NewsletterBox class="mb-20" />
</main>
<BlogFooter />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const links = [
{ label: 'Articles', href: '#posts', current: true },
{ label: 'Changelog', href: '#' },
{ label: 'Open source', href: '#' },
{ label: 'Careers', href: '#' },
]
const open = ref(false)
</script>
<template>
<header class="sticky top-0 z-30 border-b border-gray-200/80 bg-white/85 backdrop-blur dark:border-white/10 dark:bg-gray-950/85" @keydown.esc="open = false">
<nav class="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6" aria-label="Main">
<a href="#" class="flex items-center gap-2.5 rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-rose-500">
<span class="grid size-8 place-items-center rounded-lg bg-gray-900 dark:bg-white" aria-hidden="true">
<svg viewBox="0 0 24 24" class="size-5 fill-rose-500"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Zm0 2.3L18.6 8 12 11.7 5.4 8 12 4.3Z" /></svg>
</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">Basalt <span class="font-normal text-gray-500 dark:text-gray-400">Engineering</span></span>
</a>
<ul class="hidden items-center gap-1 md:flex">
<li v-for="link in links" :key="link.label">
<a
:href="link.href"
:aria-current="link.current ? 'page' : undefined"
class="rounded-md px-3 py-2 text-sm font-medium transition focus-visible:outline-2 focus-visible:outline-rose-500"
:class="link.current ? 'text-gray-900 dark:text-white' : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'"
>{{ link.label }}</a>
</li>
</ul>
<div class="hidden md:block">
<a href="#newsletter" class="rounded-lg bg-gray-900 px-3.5 py-2 text-sm font-semibold text-white transition hover:bg-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-500 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200">Subscribe</a>
</div>
<button
type="button"
class="-mr-2 grid size-10 place-items-center rounded-lg text-gray-700 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-rose-500 md:hidden dark:text-gray-300 dark:hover:bg-white/10"
:aria-expanded="open"
aria-controls="mobile-menu"
@click="open = !open"
>
<span class="sr-only">{{ open ? 'Close menu' : 'Open menu' }}</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" class="size-6" aria-hidden="true">
<path v-if="open" d="M6 6l12 12M18 6 6 18" />
<path v-else d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
</nav>
<div v-show="open" id="mobile-menu" class="border-t border-gray-200 px-4 pb-4 pt-2 md:hidden dark:border-white/10">
<ul class="space-y-1">
<li v-for="link in links" :key="link.label">
<a :href="link.href" class="block rounded-lg px-3 py-2 text-base font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10" @click="open = false">{{ link.label }}</a>
</li>
</ul>
<a href="#newsletter" class="mt-3 block rounded-lg bg-gray-900 px-3 py-2.5 text-center text-sm font-semibold text-white dark:bg-white dark:text-gray-900" @click="open = false">Subscribe</a>
</div>
</header>
</template>
<script setup lang="ts">
import type { CoverPalette, CoverPattern } from '../data'
defineProps<{
pattern: CoverPattern
palette: CoverPalette
}>()
const gradients: Record<CoverPalette, string> = {
rose: 'from-rose-500 via-pink-500 to-orange-400',
amber: 'from-amber-400 via-orange-400 to-rose-400',
violet: 'from-violet-600 via-purple-500 to-fuchsia-400',
sky: 'from-sky-500 via-cyan-500 to-teal-400',
emerald: 'from-emerald-500 via-teal-500 to-cyan-500',
slate: 'from-slate-800 via-slate-700 to-rose-500',
}
</script>
<template>
<div class="relative overflow-hidden bg-linear-to-br" :class="gradients[palette]" aria-hidden="true">
<svg viewBox="0 0 400 250" preserveAspectRatio="xMidYMid slice" class="absolute inset-0 size-full text-white">
<g v-if="pattern === 'waves'" fill="none" stroke="currentColor" stroke-width="2">
<path v-for="i in 9" :key="i" :d="`M-20 ${40 + i * 20} C 80 ${10 + i * 20}, 160 ${80 + i * 20}, 240 ${40 + i * 20} S 380 ${10 + i * 20}, 440 ${40 + i * 20}`" :opacity="0.12 + i * 0.06" />
</g>
<g v-else-if="pattern === 'grid'">
<g stroke="currentColor" stroke-opacity="0.18">
<line v-for="i in 12" :key="`v${i}`" :x1="i * 34" y1="0" :x2="i * 34" y2="250" />
<line v-for="i in 8" :key="`h${i}`" x1="0" :y1="i * 34" x2="400" :y2="i * 34" />
</g>
<rect x="136" y="68" width="102" height="68" rx="6" fill="currentColor" fill-opacity="0.9" />
<rect x="170" y="102" width="102" height="68" rx="6" fill="currentColor" fill-opacity="0.35" />
<circle cx="136" cy="68" r="5" fill="currentColor" />
<circle cx="272" cy="170" r="5" fill="currentColor" />
</g>
<g v-else-if="pattern === 'orbits'" fill="none" stroke="currentColor">
<circle cx="200" cy="125" r="34" fill="currentColor" fill-opacity="0.9" stroke="none" />
<ellipse cx="200" cy="125" rx="90" ry="40" stroke-opacity="0.55" stroke-width="2" transform="rotate(-18 200 125)" />
<ellipse cx="200" cy="125" rx="150" ry="66" stroke-opacity="0.35" stroke-width="2" transform="rotate(12 200 125)" />
<ellipse cx="200" cy="125" rx="210" ry="96" stroke-opacity="0.2" stroke-width="2" transform="rotate(-6 200 125)" />
<circle cx="284" cy="98" r="7" fill="currentColor" stroke="none" />
<circle cx="66" cy="152" r="5" fill="currentColor" fill-opacity="0.8" stroke="none" />
</g>
<g v-else-if="pattern === 'bars'" fill="currentColor">
<rect v-for="(h, i) in [60, 90, 74, 120, 104, 150, 132, 176]" :key="i" :x="62 + i * 36" :y="210 - h" width="22" :height="h" rx="4" :fill-opacity="0.3 + i * 0.09" />
<path d="M60 170 L 110 140 L 170 150 L 230 100 L 290 88 L 350 44" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
</g>
<g v-else-if="pattern === 'stack'" fill="currentColor">
<rect x="110" y="150" width="180" height="56" rx="10" fill-opacity="0.25" transform="skewY(-8)" />
<rect x="110" y="118" width="180" height="56" rx="10" fill-opacity="0.45" transform="skewY(-8)" />
<rect x="110" y="86" width="180" height="56" rx="10" fill-opacity="0.9" transform="skewY(-8)" />
<rect x="132" y="104" width="70" height="8" rx="4" class="text-black" fill-opacity="0.15" transform="skewY(-8)" />
<rect x="132" y="120" width="110" height="8" rx="4" class="text-black" fill-opacity="0.1" transform="skewY(-8)" />
</g>
<g v-else fill="currentColor">
<template v-for="row in 7" :key="row">
<circle v-for="col in 13" :key="col" :cx="col * 30 - 5" :cy="row * 32" :r="2 + ((row * col) % 5)" :fill-opacity="0.15 + ((row + col) % 4) * 0.18" />
</template>
</g>
</svg>
<div class="absolute inset-0 bg-linear-to-t from-black/15 to-transparent" />
<slot />
</div>
</template>
export type Category = 'Engineering' | 'Product' | 'Design' | 'Infrastructure' | 'Data'
export type CoverPattern = 'waves' | 'grid' | 'orbits' | 'bars' | 'stack' | 'dots'
export type CoverPalette = 'rose' | 'amber' | 'violet' | 'sky' | 'emerald' | 'slate'
export interface Author {
name: string
initials: string
role: string
}
export interface Post {
id: number
title: string
excerpt: string
category: Category
author: Author
/** ISO date, e.g. 2026-09-18 */
date: string
readMinutes: number
cover: { pattern: CoverPattern; palette: CoverPalette }
}
export const categories: Category[] = ['Engineering', 'Product', 'Design', 'Infrastructure', 'Data']
const authors = {
mara: { name: 'Mara Lindqvist', initials: 'ML', role: 'Staff Engineer' },
tobi: { name: 'Tobi Okafor', initials: 'TO', role: 'Product Designer' },
ines: { name: 'Inès Carvalho', initials: 'IC', role: 'Head of Product' },
dev: { name: 'Dev Raman', initials: 'DR', role: 'Platform Lead' },
hana: { name: 'Hana Ueda', initials: 'HU', role: 'Data Engineer' },
} satisfies Record<string, Author>
export const posts: Post[] = [
{
id: 1,
title: 'How we cut cold starts by 70% without touching the runtime',
excerpt: 'Our edge functions spent most of their first request parsing a dependency graph nobody needed. Here is the profiling trail, the lazy-loading change and the numbers from two weeks in production.',
category: 'Engineering',
author: authors.mara,
date: '2026-09-24',
readMinutes: 11,
cover: { pattern: 'orbits', palette: 'rose' },
},
{
id: 2,
title: 'Designing empty states that actually teach',
excerpt: 'An empty table is the first thing most new users see. We rewrote ours around one question: what is the smallest useful thing someone can do right now?',
category: 'Design',
author: authors.tobi,
date: '2026-09-19',
readMinutes: 6,
cover: { pattern: 'stack', palette: 'violet' },
},
{
id: 3,
title: 'Postgres row-level security in a multi-tenant app, one year later',
excerpt: 'What went well, the two policies that bit us, and why we now test every policy with a generated matrix of roles and tenants.',
category: 'Infrastructure',
author: authors.dev,
date: '2026-09-12',
readMinutes: 14,
cover: { pattern: 'grid', palette: 'sky' },
},
{
id: 4,
title: 'Shipping usage-based pricing in six weeks',
excerpt: 'Metering, invoices, proration and the internal dashboard that let support answer billing questions without paging an engineer.',
category: 'Product',
author: authors.ines,
date: '2026-09-05',
readMinutes: 9,
cover: { pattern: 'bars', palette: 'amber' },
},
{
id: 5,
title: 'A practical guide to event schemas that survive a rewrite',
excerpt: 'Version your events, never rename a field, and keep a human-readable changelog next to the schema. Three rules that saved our analytics pipeline twice.',
category: 'Data',
author: authors.hana,
date: '2026-08-29',
readMinutes: 8,
cover: { pattern: 'dots', palette: 'emerald' },
},
{
id: 6,
title: 'Why our design tokens live in the API repository',
excerpt: 'Moving tokens next to the code that renders emails, PDFs and the web app removed an entire class of "the colour is slightly off" bugs.',
category: 'Design',
author: authors.tobi,
date: '2026-08-22',
readMinutes: 5,
cover: { pattern: 'waves', palette: 'slate' },
},
{
id: 7,
title: 'Blue-green deploys for stateful websocket servers',
excerpt: 'Draining connections gracefully, replaying missed messages and the load balancer setting that made the whole thing possible.',
category: 'Infrastructure',
author: authors.dev,
date: '2026-08-14',
readMinutes: 12,
cover: { pattern: 'orbits', palette: 'sky' },
},
{
id: 8,
title: 'The roadmap is a list of bets, not promises',
excerpt: 'How we write, share and revisit our quarterly plan so customers know what is coming and the team knows what to drop first.',
category: 'Product',
author: authors.ines,
date: '2026-08-07',
readMinutes: 7,
cover: { pattern: 'stack', palette: 'amber' },
},
{
id: 9,
title: 'Type-safe feature flags with zero runtime cost',
excerpt: 'A tiny code generator turns our flag config into TypeScript unions, so removing a flag is a compile error everywhere it is still referenced.',
category: 'Engineering',
author: authors.mara,
date: '2026-07-30',
readMinutes: 10,
cover: { pattern: 'grid', palette: 'rose' },
},
{
id: 10,
title: 'Sampling traces without losing the interesting ones',
excerpt: 'Tail-based sampling keeps every slow or failing request and a small slice of the rest. Our collector config and the cost before and after.',
category: 'Data',
author: authors.hana,
date: '2026-07-21',
readMinutes: 9,
cover: { pattern: 'bars', palette: 'emerald' },
},
{
id: 11,
title: 'Keyboard-first: auditing every screen for focus order',
excerpt: 'We unplugged our mice for a week. Here is the checklist we ended up with and the three components we rebuilt from scratch.',
category: 'Design',
author: authors.tobi,
date: '2026-07-15',
readMinutes: 6,
cover: { pattern: 'dots', palette: 'violet' },
},
{
id: 12,
title: 'Migrating 40 million rows with no downtime',
excerpt: 'Dual writes, a backfill that could be paused at any moment and a verification job that compared both tables before we flipped the read path.',
category: 'Engineering',
author: authors.dev,
date: '2026-07-08',
readMinutes: 13,
cover: { pattern: 'waves', palette: 'sky' },
},
{
id: 13,
title: 'What we learned from 200 onboarding calls',
excerpt: 'Patterns from a quarter of sitting in on customer onboarding, and the four product changes that came out of it.',
category: 'Product',
author: authors.ines,
date: '2026-06-30',
readMinutes: 8,
cover: { pattern: 'orbits', palette: 'amber' },
},
{
id: 14,
title: 'Cheap, fast preview environments for every pull request',
excerpt: 'Branch databases, a shared cache and aggressive teardown keep our preview bill under the cost of one engineer lunch per month.',
category: 'Infrastructure',
author: authors.mara,
date: '2026-06-23',
readMinutes: 7,
cover: { pattern: 'stack', palette: 'slate' },
},
]
export function formatDate(iso: string): string {
return new Date(`${iso}T12:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}