feat(deck): build the accounting pitch deck

A full-screen slide presentation on the boilerplate: keyboard/hash
navigation, progress chrome, and 12 slides walking from accounting
principles through the mock project's ledger to the app concept and value
props. Numbers are sourced from a single data module that mirrors the
Beancount ledger.

Visualizations: budgeted-allocation stacked bar, cash-over-time area chart,
the $5k asset-swap, and a net-worth waterfall for the balance-sheet
snapshot. Slides fill the viewport via vertical centering and a
viewport-relative root font-size so the deck reads well when projected.

Routes `/` to the deck (HomeView left in place as boilerplate scaffolding).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-07-30 20:47:08 +02:00
commit da0161963a
25 changed files with 1304 additions and 3 deletions

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boilerplate Website</title>
<title>Accounting for the Collective — Project Lantern</title>
</head>
<body>
<div id="app"></div>

View file

@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { useDeck } from './useDeck'
import { slides } from './slides'
const { index, next, prev, go } = useDeck(slides.length)
const current = computed(() => slides[index.value])
const progress = computed(() => ((index.value + 1) / slides.length) * 100)
</script>
<template>
<div class="dark relative h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
<!-- ambient background -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute -left-40 -top-40 size-[36rem] rounded-full bg-emerald-500/10 blur-3xl" />
<div class="absolute -bottom-52 -right-40 size-[38rem] rounded-full bg-sky-500/10 blur-3xl" />
</div>
<!-- top progress bar -->
<div class="absolute inset-x-0 top-0 z-20 h-1 bg-slate-800">
<div
class="h-full bg-emerald-500 transition-[width] duration-500 ease-out"
:style="{ width: progress + '%' }"
/>
</div>
<!-- slide -->
<main class="relative z-10 h-full w-full">
<Transition name="slide" mode="out-in">
<component :is="current.component" :key="index" />
</Transition>
</main>
<!-- chrome: dots + counter -->
<footer
class="absolute inset-x-0 bottom-0 z-20 flex items-center justify-between px-6 py-4 sm:px-10"
>
<div class="flex items-center gap-2">
<button
v-for="(s, i) in slides"
:key="i"
class="h-1.5 rounded-full transition-all"
:class="i === index ? 'w-6 bg-emerald-400' : 'w-1.5 bg-slate-600 hover:bg-slate-400'"
:aria-label="`Go to slide ${i + 1}`"
:title="s.title"
@click="go(i)"
/>
</div>
<div class="flex items-center gap-4">
<span class="hidden text-xs text-slate-500 sm:inline">Use keys</span>
<span class="text-sm tabular-nums text-slate-400">
{{ String(index + 1).padStart(2, '0')
}}<span class="text-slate-600"> / {{ slides.length }}</span>
</span>
<div class="flex items-center gap-1.5">
<button
class="grid size-9 place-items-center rounded-full border border-slate-700 text-slate-300 transition hover:bg-slate-800 disabled:opacity-30"
:disabled="index === 0"
aria-label="Previous slide"
@click="prev"
>
<ChevronLeft class="size-5" />
</button>
<button
class="grid size-9 place-items-center rounded-full border border-slate-700 text-slate-300 transition hover:bg-slate-800 disabled:opacity-30"
:disabled="index === slides.length - 1"
aria-label="Next slide"
@click="next"
>
<ChevronRight class="size-5" />
</button>
</div>
</div>
</footer>
</div>
</template>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition:
opacity 0.28s ease,
transform 0.28s ease;
}
.slide-enter-from {
opacity: 0;
transform: translateX(24px);
}
.slide-leave-to {
opacity: 0;
transform: translateX(-24px);
}
</style>

View file

@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed } from 'vue'
import { money } from '../data'
const props = defineProps<{
points: readonly { month: string; cash: number }[]
}>()
// Fixed viewBox; we map data into it. Nice round headroom on the y-axis.
const W = 720
const H = 300
const padL = 56
const padR = 16
const padT = 16
const padB = 32
const yMax = computed(() => Math.ceil(Math.max(...props.points.map((p) => p.cash)) / 20000) * 20000)
const coords = computed(() =>
props.points.map((p, i) => {
const x = padL + (i / (props.points.length - 1)) * (W - padL - padR)
const y = padT + (1 - p.cash / yMax.value) * (H - padT - padB)
return { ...p, x, y }
}),
)
const linePath = computed(() =>
coords.value.map((c, i) => `${i === 0 ? 'M' : 'L'} ${c.x} ${c.y}`).join(' '),
)
const areaPath = computed(() => {
const c = coords.value
const base = H - padB
return (
`M ${c[0].x} ${base} ` +
c.map((p) => `L ${p.x} ${p.y}`).join(' ') +
` L ${c[c.length - 1].x} ${base} Z`
)
})
const gridLines = computed(() => {
const lines = []
for (let v = 0; v <= yMax.value; v += 20000) {
const y = padT + (1 - v / yMax.value) * (H - padT - padB)
lines.push({ y, label: money(v) })
}
return lines
})
</script>
<template>
<svg :viewBox="`0 0 ${W} ${H}`" class="w-full" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id="cashFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgb(16 185 129)" stop-opacity="0.35" />
<stop offset="100%" stop-color="rgb(16 185 129)" stop-opacity="0" />
</linearGradient>
</defs>
<!-- gridlines -->
<g>
<line
v-for="g in gridLines"
:key="g.label"
:x1="padL"
:x2="W - padR"
:y1="g.y"
:y2="g.y"
stroke="rgb(51 65 85)"
stroke-width="1"
stroke-dasharray="3 4"
/>
<text
v-for="g in gridLines"
:key="'t' + g.label"
:x="padL - 10"
:y="g.y + 4"
text-anchor="end"
class="fill-slate-500 text-[11px] tabular-nums"
>
{{ g.label }}
</text>
</g>
<!-- area + line -->
<path :d="areaPath" fill="url(#cashFill)" />
<path
:d="linePath"
fill="none"
stroke="rgb(16 185 129)"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- points + month labels -->
<g v-for="c in coords" :key="c.month">
<circle
:cx="c.x"
:cy="c.y"
r="4"
fill="rgb(16 185 129)"
stroke="rgb(2 6 23)"
stroke-width="2"
/>
<text :x="c.x" :y="H - 10" text-anchor="middle" class="fill-slate-400 text-[11px]">
{{ c.month }}
</text>
</g>
</svg>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
import { palette, money } from '../data'
const props = defineProps<{
items: readonly { label: string; amount: number; color: string }[]
showValues?: boolean
}>()
const max = computed(() => Math.max(...props.items.map((i) => i.amount), 1))
</script>
<template>
<div class="space-y-4">
<div
v-for="(item, i) in items"
:key="item.label"
class="grid grid-cols-[9rem_1fr] items-center gap-4"
>
<span class="truncate text-right text-sm text-slate-300">{{ item.label }}</span>
<div class="flex items-center gap-3">
<div class="h-7 flex-1 overflow-hidden rounded-md bg-slate-800/60">
<div
class="h-full rounded-md animate-in slide-in-from-left duration-700 fill-mode-both"
:class="palette[item.color]?.bar ?? 'bg-slate-500'"
:style="{ width: (item.amount / max) * 100 + '%', animationDelay: i * 90 + 'ms' }"
/>
</div>
<span
v-if="showValues !== false"
class="w-20 shrink-0 text-right text-sm font-semibold tabular-nums text-slate-200"
>
{{ money(item.amount) }}
</span>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
// `contentCenter` also centers the slide body within its own area (nice for
// sparse slides). The whole section is always vertically centered in the
// viewport so content fills the screen instead of hugging the top.
defineProps<{
kicker?: string
title?: string
contentCenter?: boolean
}>()
</script>
<template>
<section
class="mx-auto flex h-full w-full max-w-[88rem] flex-col justify-center px-10 pb-24 pt-12 sm:px-16 sm:pb-28 sm:pt-16"
>
<header v-if="kicker || title" class="shrink-0">
<p
v-if="kicker"
class="mb-4 text-sm font-semibold uppercase tracking-[0.25em] text-emerald-400 animate-in fade-in slide-in-from-bottom-2 duration-500"
>
{{ kicker }}
</p>
<h2
v-if="title"
class="text-4xl font-bold leading-[1.08] text-slate-50 sm:text-5xl lg:text-6xl animate-in fade-in slide-in-from-bottom-3 duration-500"
>
{{ title }}
</h2>
</header>
<div
:class="[kicker || title ? 'mt-12' : '', contentCenter ? 'flex flex-col justify-center' : '']"
>
<slot />
</div>
</section>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import { palette, money } from '../data'
const props = defineProps<{
items: readonly { label: string; amount: number; color: string }[]
}>()
const total = computed(() => props.items.reduce((s, i) => s + i.amount, 0))
</script>
<template>
<div class="space-y-5">
<div class="flex h-14 w-full overflow-hidden rounded-xl ring-1 ring-slate-700/60">
<div
v-for="(item, i) in items"
:key="item.label"
class="h-full animate-in slide-in-from-left duration-700 fill-mode-both"
:class="palette[item.color]?.bar ?? 'bg-slate-500'"
:style="{ width: (item.amount / total) * 100 + '%', animationDelay: i * 100 + 'ms' }"
/>
</div>
<div class="flex flex-wrap gap-x-8 gap-y-3">
<div v-for="item in items" :key="item.label" class="flex items-center gap-2.5">
<span class="size-3 rounded-sm" :class="palette[item.color]?.dot ?? 'bg-slate-500'" />
<span class="text-sm text-slate-300">{{ item.label }}</span>
<span class="text-sm font-semibold tabular-nums text-slate-100">{{
money(item.amount)
}}</span>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
defineProps<{
label: string
value: string
sub?: string
tone?: 'default' | 'positive' | 'negative'
}>()
</script>
<template>
<div class="rounded-xl border border-slate-700/60 bg-slate-800/40 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-slate-400">{{ label }}</p>
<p
class="mt-2 text-3xl font-bold tabular-nums"
:class="{
'text-slate-50': tone === 'default' || !tone,
'text-emerald-400': tone === 'positive',
'text-rose-400': tone === 'negative',
}"
>
{{ value }}
</p>
<p v-if="sub" class="mt-1 text-sm text-slate-400">{{ sub }}</p>
</div>
</template>

View file

@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue'
export interface WaterfallBar {
label: string
delta: string
from: number
to: number
kind: 'add' | 'sub' | 'total'
}
const props = defineProps<{ bars: WaterfallBar[]; yMax: number }>()
const fill = { add: 'bg-emerald-500', sub: 'bg-rose-500', total: 'bg-sky-500' }
const deltaColor = {
add: 'text-emerald-300',
sub: 'text-rose-300',
total: 'text-sky-300',
}
const pct = (v: number) => (v / props.yMax) * 100
// Dashed connectors join the top of each bar to the start of the next the
// hallmark of a waterfall that makes the running total easy to follow.
const connectors = computed(() => {
const n = props.bars.length
const step = 1000 / n
const segs = []
for (let i = 0; i < n - 1; i++) {
const y = (1 - props.bars[i].to / props.yMax) * 1000
const xb = (i + 1) * step
segs.push({ x1: xb - 80, x2: xb + 80, y })
}
return segs
})
</script>
<template>
<div class="w-full">
<div class="relative h-[40vh] min-h-72">
<!-- bars -->
<div class="flex h-full items-end">
<div v-for="(b, i) in bars" :key="i" class="relative h-full flex-1">
<!-- value above the bar -->
<div
class="absolute inset-x-0 text-center"
:style="{ bottom: `calc(${pct(Math.max(b.from, b.to))}% + 0.6rem)` }"
>
<span
class="text-xl font-bold tabular-nums sm:text-2xl lg:text-3xl"
:class="deltaColor[b.kind]"
>
{{ b.delta }}
</span>
</div>
<!-- the bar itself -->
<div
class="absolute inset-x-3 rounded-t-lg transition-all sm:inset-x-5"
:class="fill[b.kind]"
:style="{
bottom: pct(Math.min(b.from, b.to)) + '%',
height: pct(Math.abs(b.to - b.from)) + '%',
}"
/>
</div>
</div>
<!-- connectors overlay -->
<svg
class="pointer-events-none absolute inset-0 h-full w-full"
viewBox="0 0 1000 1000"
preserveAspectRatio="none"
>
<line
v-for="(c, i) in connectors"
:key="i"
:x1="c.x1"
:x2="c.x2"
:y1="c.y"
:y2="c.y"
stroke="rgb(100 116 139)"
stroke-dasharray="7 7"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
</svg>
<!-- baseline -->
<div class="absolute inset-x-0 bottom-0 h-px bg-slate-700" />
</div>
<!-- category labels -->
<div class="mt-5 flex">
<div v-for="(b, i) in bars" :key="i" class="flex-1 px-2 text-center">
<p class="text-base font-medium text-slate-200 sm:text-lg">{{ b.label }}</p>
</div>
</div>
</div>
</template>

129
src/features/deck/data.ts Normal file
View file

@ -0,0 +1,129 @@
// ---------------------------------------------------------------------------
// Canonical figures for Project Lantern.
// These mirror ledger/lantern.beancount exactly so the deck and Fava agree.
// (Verified against `bean-check` + Fava's own totals on the ledger — see README.)
// ---------------------------------------------------------------------------
export const money = (n: number) =>
n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
export const money2 = (n: number) =>
n.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2 })
// The plan the seed round was raised against (what the deck calls "the budget").
export const SEED = 100_000
export const budget = [
{ label: 'Salaries', amount: 50_000, color: 'sky' },
{ label: 'Hardware & Materials', amount: 30_000, color: 'amber' },
{ label: 'Operations', amount: 12_000, color: 'violet' },
{ label: 'Contingency', amount: 8_000, color: 'slate' },
] as const
// Cash on hand at the end of each month (Assets:Bank + Assets:Cash), from the
// ledger. Used for the "books over time" area chart.
export const cashByMonth = [
{ month: 'Jan', cash: 92_850 },
{ month: 'Feb', cash: 85_700 },
{ month: 'Mar', cash: 70_550 }, // card paid off + $5k servers capitalized out of cash
{ month: 'Apr', cash: 58_835 },
{ month: 'May', cash: 45_685 },
{ month: 'Jun', cash: 42_535 }, // first $4k revenue lands, burn nearly flat
{ month: 'Jul', cash: 43_885 }, // revenue > spend — the curve turns up
] as const
// Expenses to date, by category (cash + the one non-cash equity grant).
export const expenses = [
{ label: 'Salaries', amount: 36_000, color: 'sky' },
{ label: 'Materials', amount: 13_500, color: 'amber' },
{ label: 'Operations', amount: 8_050, color: 'violet' },
{ label: 'Contributed (equity)', amount: 3_000, color: 'emerald' },
{ label: 'Fuel', amount: 65, color: 'rose' },
] as const
// Balance-sheet snapshot as of 2026-07-30, straight from the ledger.
export const snapshot = {
assets: {
total: 48_885,
rows: [
{ label: 'Bank — Checking', amount: 42_885 },
{ label: 'Cash — Petty cash float', amount: 1_000 },
{ label: 'Equipment — Servers', amount: 5_000 },
],
},
liabilities: {
total: 0,
rows: [{ label: 'Outstanding reimbursements', amount: 0 }],
},
equity: {
total: 48_885,
rows: [
{ label: 'Investor capital (seed)', amount: 100_000 },
{ label: 'Contributor equity (Bob)', amount: 3_000 },
{ label: 'Retained earnings (to date)', amount: -54_115 },
],
},
revenueToDate: 6_500,
expensesToDate: 60_615,
}
// People with a balance against the collective — the two shapes it can take.
export const balances = [
{
name: 'Alice',
kind: 'liability' as const,
amount: 65,
story: 'Fronted her own cash for fuel in the org vehicle.',
meaning: 'The collective OWES Alice. A debt, cleared when she is paid back.',
},
{
name: 'Bob',
kind: 'equity' as const,
amount: 3_000,
story: 'Contributed engineering work, took equity instead of cash.',
meaning: 'Bob OWNS a slice — a claim on future profit, not a debt to repay.',
},
{
name: 'Atitlán Ventures',
kind: 'equity' as const,
amount: 100_000,
story: 'Wrote the seed cheque that started the project.',
meaning: 'The investor holds the largest claim on the upside.',
},
]
export const team = [
{ name: 'Pat', role: 'Software' },
{ name: 'Rayan', role: 'Accountant' },
]
// Tailwind class fragments keyed by our palette names (kept explicit so the
// JIT compiler always sees the full class string).
export const palette: Record<string, { bar: string; text: string; dot: string; soft: string }> = {
sky: { bar: 'bg-sky-500', text: 'text-sky-400', dot: 'bg-sky-500', soft: 'bg-sky-500/15' },
amber: {
bar: 'bg-amber-500',
text: 'text-amber-400',
dot: 'bg-amber-500',
soft: 'bg-amber-500/15',
},
violet: {
bar: 'bg-violet-500',
text: 'text-violet-400',
dot: 'bg-violet-500',
soft: 'bg-violet-500/15',
},
emerald: {
bar: 'bg-emerald-500',
text: 'text-emerald-400',
dot: 'bg-emerald-500',
soft: 'bg-emerald-500/15',
},
rose: { bar: 'bg-rose-500', text: 'text-rose-400', dot: 'bg-rose-500', soft: 'bg-rose-500/15' },
slate: {
bar: 'bg-slate-500',
text: 'text-slate-400',
dot: 'bg-slate-500',
soft: 'bg-slate-500/15',
},
}

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import { BookOpenCheck } from '@lucide/vue'
import { team } from '../data'
</script>
<template>
<div class="mx-auto flex h-full max-w-5xl flex-col justify-center px-8 sm:px-14">
<div
class="mb-8 inline-flex w-fit items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-4 py-1.5 text-sm text-emerald-300 animate-in fade-in slide-in-from-bottom-2 duration-500"
>
<BookOpenCheck class="size-4" />
Atitlán Collective · Accounting Team
</div>
<h1
class="text-5xl font-bold leading-[1.05] tracking-tight text-slate-50 sm:text-6xl lg:text-7xl animate-in fade-in slide-in-from-bottom-3 duration-700"
>
Know where<br />
<span class="text-emerald-400">every dollar</span> stands.
</h1>
<p
class="mt-7 max-w-2xl text-lg text-slate-300 sm:text-xl animate-in fade-in slide-in-from-bottom-4 duration-700 fill-mode-both"
style="animation-delay: 150ms"
>
A shared set of books for the collective so individuals can see their standing and money
reaches the right place at the right time.
</p>
<div
class="mt-12 flex flex-wrap items-center gap-x-8 gap-y-3 animate-in fade-in duration-700 fill-mode-both"
style="animation-delay: 300ms"
>
<span class="text-sm uppercase tracking-widest text-slate-500">Presented by</span>
<div v-for="p in team" :key="p.name" class="flex items-baseline gap-2">
<span class="text-lg font-semibold text-slate-100">{{ p.name }}</span>
<span class="text-sm text-slate-400"> {{ p.role }}</span>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,50 @@
<script setup lang="ts">
import { HelpCircle, Shuffle, EyeOff } from '@lucide/vue'
import SlideShell from '../components/SlideShell.vue'
const pains = [
{
icon: HelpCircle,
title: '"Where do I stand?"',
body: 'Members front cash, contribute work, or hold a stake — but no one can answer what the collective owes them or owns of it.',
},
{
icon: Shuffle,
title: 'Money moves by memory',
body: 'Who approved that purchase? Was it budgeted? Reimbursements live in chat threads and good intentions.',
},
{
icon: EyeOff,
title: 'No shared source of truth',
body: 'Every project tracks its own spend differently, so the collective can never see the whole picture at once.',
},
]
</script>
<template>
<SlideShell kicker="Why we're here" title="Today, the collective flies blind">
<div class="grid gap-5 sm:grid-cols-3">
<div
v-for="(p, i) in pains"
:key="p.title"
class="rounded-2xl border border-slate-700/60 bg-slate-800/40 p-6 animate-in fade-in slide-in-from-bottom-4 fill-mode-both duration-500"
:style="{ animationDelay: 150 + i * 120 + 'ms' }"
>
<div class="mb-4 grid size-11 place-items-center rounded-xl bg-rose-500/15 text-rose-400">
<component :is="p.icon" class="size-6" />
</div>
<h3 class="text-lg font-semibold text-slate-100">{{ p.title }}</h3>
<p class="mt-2 text-sm leading-relaxed text-slate-400">{{ p.body }}</p>
</div>
</div>
<p
class="mt-10 text-lg text-slate-300 animate-in fade-in fill-mode-both duration-500"
style="animation-delay: 560ms"
>
None of this needs new invention. It needs
<span class="font-semibold text-emerald-400">accounting</span> a practice five centuries
old, that the collective simply isn't using yet.
</p>
</SlideShell>
</template>

View file

@ -0,0 +1,85 @@
<script setup lang="ts">
import SlideShell from '../components/SlideShell.vue'
const accounts = [
{
name: 'Assets',
desc: 'what we own',
example: 'cash, gear',
color: 'text-emerald-400 border-emerald-500/40 bg-emerald-500/10',
},
{
name: 'Liabilities',
desc: 'what we owe',
example: 'reimbursements',
color: 'text-rose-400 border-rose-500/40 bg-rose-500/10',
},
{
name: 'Equity',
desc: 'the owners stake',
example: 'investor, sweat',
color: 'text-sky-400 border-sky-500/40 bg-sky-500/10',
},
{
name: 'Income',
desc: 'money earned',
example: 'sales',
color: 'text-violet-400 border-violet-500/40 bg-violet-500/10',
},
{
name: 'Expenses',
desc: 'money used up',
example: 'rent, salaries',
color: 'text-amber-400 border-amber-500/40 bg-amber-500/10',
},
]
</script>
<template>
<SlideShell kicker="Accounting in 90 seconds" title="Five buckets and one rule">
<div class="grid gap-5 lg:grid-cols-2 lg:items-center">
<!-- five account types -->
<div class="space-y-3">
<div
v-for="(a, i) in accounts"
:key="a.name"
class="flex items-center gap-4 rounded-xl border p-4 animate-in fade-in slide-in-from-left-4 fill-mode-both duration-500"
:class="a.color"
:style="{ animationDelay: 120 + i * 90 + 'ms' }"
>
<span class="w-28 shrink-0 text-lg font-bold">{{ a.name }}</span>
<span class="flex-1 text-sm text-slate-300">{{ a.desc }}</span>
<span class="hidden text-sm text-slate-500 sm:inline">e.g. {{ a.example }}</span>
</div>
</div>
<!-- the equation + double entry -->
<div class="space-y-6 lg:pl-6">
<div
class="rounded-2xl border border-slate-700/60 bg-slate-800/40 p-7 text-center animate-in fade-in fill-mode-both duration-700"
style="animation-delay: 300ms"
>
<p class="mb-4 text-xs uppercase tracking-widest text-slate-500">
The books always balance
</p>
<p class="text-2xl font-bold sm:text-3xl">
<span class="text-emerald-400">Assets</span>
<span class="text-slate-500"> = </span>
<span class="text-rose-400">Liabilities</span>
<span class="text-slate-500"> + </span>
<span class="text-sky-400">Equity</span>
</p>
</div>
<p
class="text-lg leading-relaxed text-slate-300 animate-in fade-in fill-mode-both duration-700"
style="animation-delay: 480ms"
>
Every event is a <span class="font-semibold text-slate-100">balanced move</span> between
buckets money always comes <em>from</em> somewhere and goes <em>to</em> somewhere.
That's <span class="font-semibold text-emerald-400">double-entry</span>, and it's what
makes the numbers impossible to fudge.
</p>
</div>
</div>
</SlideShell>
</template>

View file

@ -0,0 +1,69 @@
<script setup lang="ts">
import { Cpu, Users, Calendar } from '@lucide/vue'
import SlideShell from '../components/SlideShell.vue'
import { money } from '../data'
const facts = [
{ icon: Cpu, label: 'A hardware project', value: 'Project Lantern' },
{ icon: Users, label: 'Core team', value: 'Pat · Rayan · Sam' },
{ icon: Calendar, label: 'Timeline well walk', value: 'Jan → Jul 2026' },
]
</script>
<template>
<SlideShell kicker="A worked example" title="Let's follow one project through its books">
<div class="grid gap-8 lg:grid-cols-[1.1fr_1fr] lg:items-center">
<div>
<p class="text-lg leading-relaxed text-slate-300">
<span class="font-semibold text-slate-100">Project Lantern</span> is a hardware build
inside the collective. It raised a
<span class="font-semibold text-emerald-400">{{ money(100000) }}</span> seed round and
spent the next seven months turning that money into prototypes, a team, and the first
customers.
</p>
<p class="mt-5 text-lg leading-relaxed text-slate-300">
We'll watch its books evolve and at any moment, anyone can see exactly what the project
<em>owns</em>, <em>owes</em>, and <em>is worth</em>.
</p>
<div class="mt-8 space-y-3">
<div
v-for="(f, i) in facts"
:key="f.label"
class="flex items-center gap-4 animate-in fade-in slide-in-from-bottom-3 fill-mode-both duration-500"
:style="{ animationDelay: 200 + i * 120 + 'ms' }"
>
<div class="grid size-10 place-items-center rounded-lg bg-slate-800 text-emerald-400">
<component :is="f.icon" class="size-5" />
</div>
<div>
<p class="text-xs uppercase tracking-wider text-slate-500">{{ f.label }}</p>
<p class="font-semibold text-slate-100">{{ f.value }}</p>
</div>
</div>
</div>
</div>
<!-- stylised "project" card -->
<div
class="rounded-3xl border border-slate-700/60 bg-gradient-to-br from-slate-800/60 to-slate-900 p-8 animate-in fade-in zoom-in-95 fill-mode-both duration-700"
style="animation-delay: 250ms"
>
<div class="flex items-center justify-between">
<span class="text-sm uppercase tracking-widest text-slate-500">Ledger</span>
<span
class="rounded-full bg-emerald-500/15 px-3 py-1 text-xs font-medium text-emerald-300"
>live</span
>
</div>
<p class="mt-6 text-sm text-slate-400">Net worth of the project</p>
<p class="mt-1 text-5xl font-bold tabular-nums text-slate-50">{{ money(100000) }}</p>
<p class="mt-1 text-sm text-slate-500">day one all cash, nothing spent yet</p>
<div class="mt-8 h-px bg-slate-700/60" />
<p class="mt-6 text-sm leading-relaxed text-slate-400">
Follow along: each slide is a real entry in a real ledger you can open afterwards.
</p>
</div>
</div>
</SlideShell>
</template>

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
import SlideShell from '../components/SlideShell.vue'
import StackedBar from '../components/StackedBar.vue'
import { budget, money } from '../data'
</script>
<template>
<SlideShell
kicker="Step 1 · The money arrives"
:title="`${money(100000)} in — and a plan for it`"
>
<div class="grid gap-10 lg:grid-cols-[1fr_1.1fr] lg:items-center">
<div>
<p class="text-lg leading-relaxed text-slate-300">
The seed cheque lands as <span class="font-semibold text-emerald-400">cash</span> (an
asset) matched by <span class="font-semibold text-sky-400">investor equity</span> (their
stake). The books balance from the very first entry.
</p>
<div
class="mt-6 rounded-xl border border-slate-700/60 bg-slate-900/60 p-5 font-mono text-sm"
>
<p class="text-slate-500">2026-01-15 · Seed round</p>
<p class="mt-2 text-emerald-400">Assets:Bank &nbsp;&nbsp;&nbsp;+{{ money(100000) }}</p>
<p class="text-sky-400">Equity:Investors &nbsp;{{ money(100000) }}</p>
</div>
<p class="mt-6 text-lg leading-relaxed text-slate-300">
But cash with no plan gets spent badly. The pitch promised a
<span class="font-semibold text-slate-100">budget</span> so we split it into buckets
before a single dollar moves.
</p>
</div>
<div>
<p class="mb-5 text-sm uppercase tracking-widest text-slate-500">Budgeted allocation</p>
<StackedBar :items="budget" />
<p class="mt-6 text-sm text-slate-400">
Now "can we afford this?" has an answer <em>before</em> the money is spent not after.
</p>
</div>
</div>
</SlideShell>
</template>

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
import SlideShell from '../components/SlideShell.vue'
import AreaChart from '../components/AreaChart.vue'
import { cashByMonth, money } from '../data'
</script>
<template>
<SlideShell kicker="Step 2 · The books over time" title="Watch the money work">
<div class="grid gap-8 lg:grid-cols-[1.4fr_1fr] lg:items-center">
<div class="rounded-2xl border border-slate-700/60 bg-slate-900/40 p-6">
<p class="mb-2 text-sm text-slate-400">Cash on hand, month by month</p>
<AreaChart :points="cashByMonth" />
</div>
<div class="space-y-5">
<p class="text-lg leading-relaxed text-slate-300">
Salaries, rent, and materials draw the balance down each month a predictable
<span class="font-semibold text-slate-100">burn</span> of roughly
<span class="font-semibold text-amber-400">{{ money(9000) }}</span
>/month.
</p>
<p class="text-lg leading-relaxed text-slate-300">
Then in June the curve <span class="font-semibold text-emerald-400">turns</span>: the
first pre-orders arrive and money starts flowing <em>in</em>.
</p>
<div class="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-5">
<p class="text-sm text-emerald-200">
The books don't just record the past they show the trajectory. Anyone can see the
runway and when it starts to reverse.
</p>
</div>
</div>
</div>
</SlideShell>
</template>

View file

@ -0,0 +1,57 @@
<script setup lang="ts">
import { ArrowRight, Server, Banknote } from '@lucide/vue'
import SlideShell from '../components/SlideShell.vue'
import { money } from '../data'
</script>
<template>
<SlideShell kicker="Spotlight" title="“The hardware team needs $5,000 for servers.”">
<div class="grid gap-10 lg:grid-cols-[1.15fr_1fr] lg:items-center">
<!-- the movement -->
<div>
<p class="mb-6 text-lg leading-relaxed text-slate-300">
Intuition says spending {{ money(5000) }} makes the project {{ money(5000) }} poorer.
Accounting says otherwise and that difference is the whole point.
</p>
<div class="flex items-center gap-4">
<div class="flex-1 rounded-2xl border border-rose-500/30 bg-rose-500/10 p-5">
<Banknote class="mb-3 size-7 text-rose-400" />
<p class="text-sm text-slate-300">Cash</p>
<p class="text-2xl font-bold tabular-nums text-rose-400">{{ money(5000) }}</p>
</div>
<ArrowRight class="size-8 shrink-0 text-slate-500" />
<div class="flex-1 rounded-2xl border border-emerald-500/30 bg-emerald-500/10 p-5">
<Server class="mb-3 size-7 text-emerald-400" />
<p class="text-sm text-slate-300">Equipment</p>
<p class="text-2xl font-bold tabular-nums text-emerald-400">+{{ money(5000) }}</p>
</div>
</div>
<p class="mt-6 font-mono text-sm text-slate-400">
2026-03-18 · one asset became another not an expense.
</p>
</div>
<!-- the insight -->
<div class="space-y-6">
<div class="rounded-2xl border border-slate-700/60 bg-slate-800/40 p-7">
<p class="text-sm uppercase tracking-widest text-slate-500">
Net worth after the purchase
</p>
<p class="mt-2 text-4xl font-bold text-slate-50">unchanged</p>
<p class="mt-3 text-sm leading-relaxed text-slate-400">
The servers are a <span class="font-semibold text-emerald-400">capital asset</span>
they still belong to the project. Cash went down; owned equipment went up by the same
amount.
</p>
</div>
<p class="text-base leading-relaxed text-slate-300">
Contrast with a salary or the electric bill: that money is <em>used up</em> a true
<span class="font-semibold text-amber-400">expense</span> that does lower net worth.
Knowing which is which is how you tell <em>investing</em> from <em>burning</em>.
</p>
</div>
</div>
</SlideShell>
</template>

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
import SlideShell from '../components/SlideShell.vue'
import { balances, money } from '../data'
</script>
<template>
<SlideShell kicker="Because it's a collective" title="Everyone holds a balance with the entity">
<p class="max-w-3xl text-lg leading-relaxed text-slate-300">
In a collective, people aren't just staff — they lend, spend, and own. A person's balance
takes one of two shapes, and the books tell them apart automatically.
</p>
<div class="mt-8 grid gap-5 sm:grid-cols-3">
<div
v-for="(b, i) in balances"
:key="b.name"
class="rounded-2xl border p-6 animate-in fade-in slide-in-from-bottom-4 fill-mode-both duration-500"
:class="
b.kind === 'liability'
? 'border-rose-500/40 bg-rose-500/10'
: 'border-sky-500/40 bg-sky-500/10'
"
:style="{ animationDelay: 150 + i * 120 + 'ms' }"
>
<div class="flex items-center justify-between">
<span class="text-lg font-bold text-slate-100">{{ b.name }}</span>
<span
class="rounded-full px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wide"
:class="
b.kind === 'liability' ? 'bg-rose-500/20 text-rose-300' : 'bg-sky-500/20 text-sky-300'
"
>
{{ b.kind }}
</span>
</div>
<p class="mt-4 text-3xl font-bold tabular-nums text-slate-50">{{ money(b.amount) }}</p>
<p class="mt-3 text-sm text-slate-300">{{ b.story }}</p>
<p class="mt-3 border-t border-slate-100/10 pt-3 text-sm text-slate-400">{{ b.meaning }}</p>
</div>
</div>
<div class="mt-8 flex flex-wrap gap-x-10 gap-y-2 text-sm">
<p class="text-slate-300">
<span class="font-semibold text-rose-400">Liability</span> = the collective owes you. A debt
to be repaid.
</p>
<p class="text-slate-300">
<span class="font-semibold text-sky-400">Equity</span> = you own a slice. A claim on future
profit.
</p>
</div>
</SlideShell>
</template>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import SlideShell from '../components/SlideShell.vue'
import Waterfall, { type WaterfallBar } from '../components/Waterfall.vue'
import { money } from '../data'
const MINUS = ''
// How today's net worth was built: capital in, revenue earned, expenses spent.
const bars: WaterfallBar[] = [
{ label: 'Investor capital', delta: '+' + money(100000), from: 0, to: 100000, kind: 'add' },
{ label: 'Contributor equity', delta: '+' + money(3000), from: 100000, to: 103000, kind: 'add' },
{ label: 'Revenue to date', delta: '+' + money(6500), from: 103000, to: 109500, kind: 'add' },
{ label: 'Expenses to date', delta: MINUS + money(60615), from: 109500, to: 48885, kind: 'sub' },
{ label: 'Net worth today', delta: money(48885), from: 0, to: 48885, kind: 'total' },
]
</script>
<template>
<SlideShell kicker="As of 30 Jul 2026" title="How every dollar adds up today">
<Waterfall :bars="bars" :y-max="116000" />
<p class="mt-8 text-center text-lg text-slate-300 sm:text-xl">
Raised <span class="font-semibold text-sky-300">{{ money(103000) }}</span
>, spent <span class="font-semibold text-rose-300">{{ money(54115) }}</span> net building it
<span class="font-semibold text-slate-100">{{ money(48885) }}</span> net worth today, and the
books balance to the cent.
</p>
</SlideShell>
</template>

View file

@ -0,0 +1,56 @@
<script setup lang="ts">
import { Wallet, BadgeCheck, Banknote, ReceiptText } from '@lucide/vue'
import SlideShell from '../components/SlideShell.vue'
import { money } from '../data'
const features = [
{
icon: Wallet,
title: 'A wallet per entity',
body: 'Every project holds its own funds, with its people underneath it. The books and the money live in one place.',
},
{
icon: BadgeCheck,
title: 'Spend with a guardrail',
body: 'Need to buy something? Either you have direct access, or you ask — and the request is checked against the budget before it clears.',
},
{
icon: Banknote,
title: 'Cash when you need it',
body: `A local cash machine tied to the system dispenses up to ${money(1000)} against your account — every withdrawal booked automatically.`,
},
{
icon: ReceiptText,
title: 'Log expenses on the go',
body: 'Bought gas for the org car? Log it in seconds. The system records what youre owed so a reimbursement never gets forgotten.',
},
]
</script>
<template>
<SlideShell kicker="The application" title="The books, turned into a tool people use">
<div class="grid gap-5 sm:grid-cols-2">
<div
v-for="(f, i) in features"
:key="f.title"
class="flex gap-5 rounded-2xl border border-slate-700/60 bg-slate-800/40 p-6 animate-in fade-in slide-in-from-bottom-4 fill-mode-both duration-500"
:style="{ animationDelay: 120 + i * 110 + 'ms' }"
>
<div
class="grid size-12 shrink-0 place-items-center rounded-xl bg-emerald-500/15 text-emerald-400"
>
<component :is="f.icon" class="size-6" />
</div>
<div>
<h3 class="text-lg font-semibold text-slate-100">{{ f.title }}</h3>
<p class="mt-1.5 text-sm leading-relaxed text-slate-400">{{ f.body }}</p>
</div>
</div>
</div>
<p class="mt-7 text-sm text-slate-500">
Every one of these actions is just a double-entry booking underneath the app is the friendly
face on the ledger.
</p>
</SlideShell>
</template>

View file

@ -0,0 +1,46 @@
<script setup lang="ts">
import { User, Users } from '@lucide/vue'
import SlideShell from '../components/SlideShell.vue'
</script>
<template>
<SlideShell kicker="Why it matters" title="Two promises, kept by the same books">
<div class="grid gap-6 lg:grid-cols-2">
<div
class="rounded-3xl border border-sky-500/30 bg-gradient-to-br from-sky-500/10 to-transparent p-8 animate-in fade-in slide-in-from-left-4 fill-mode-both duration-600"
>
<div class="mb-5 grid size-12 place-items-center rounded-xl bg-sky-500/20 text-sky-300">
<User class="size-6" />
</div>
<h3 class="text-2xl font-bold text-slate-50">For the individual</h3>
<p class="mt-3 text-lg leading-relaxed text-slate-300">
See exactly where you stand what you're owed, what you own, what's coming. No more
guessing, no more chasing.
</p>
</div>
<div
class="rounded-3xl border border-emerald-500/30 bg-gradient-to-br from-emerald-500/10 to-transparent p-8 animate-in fade-in slide-in-from-right-4 fill-mode-both duration-600"
style="animation-delay: 120ms"
>
<div
class="mb-5 grid size-12 place-items-center rounded-xl bg-emerald-500/20 text-emerald-300"
>
<Users class="size-6" />
</div>
<h3 class="text-2xl font-bold text-slate-50">For the team</h3>
<p class="mt-3 text-lg leading-relaxed text-slate-300">
Money flows to where it's needed, when it's needed budgeted, approved, and recorded
without the friction that slows real work down.
</p>
</div>
</div>
<p
class="mt-9 text-center text-xl font-medium text-slate-200 animate-in fade-in fill-mode-both duration-700"
style="animation-delay: 300ms"
>
Trust, at the speed the collective actually moves.
</p>
</SlideShell>
</template>

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import { Terminal, ArrowUpRight } from '@lucide/vue'
import SlideShell from '../components/SlideShell.vue'
import { money } from '../data'
</script>
<template>
<SlideShell
kicker="This is real, not a mockup"
title="Every number tonight came from a real ledger"
>
<div class="grid gap-10 lg:grid-cols-[1.1fr_1fr] lg:items-center">
<div>
<p class="text-lg leading-relaxed text-slate-300">
The whole Project Lantern story is a plain-text ledger in
<span class="font-semibold text-emerald-400">Beancount</span> the same open-source
engine used to run real businesses. Open it in
<span class="font-semibold text-emerald-400">Fava</span> and you get balance sheets,
income statements, budgets, and every transaction, instantly.
</p>
<div
class="mt-7 rounded-xl border border-slate-700/60 bg-slate-950/70 p-5 font-mono text-sm"
>
<p class="flex items-center gap-2 text-slate-500">
<Terminal class="size-4" /> run the real reporting
</p>
<p class="mt-3 text-emerald-300">$ fava ledger/lantern.beancount</p>
<p class="mt-1 text-slate-500"># open http://localhost:5000</p>
</div>
<p class="mt-6 text-sm text-slate-400">
The simplified visuals in this deck are the pitch. Fava is the proof underneath nothing
here is hand-waved.
</p>
</div>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="rounded-2xl border border-slate-700/60 bg-slate-800/40 p-5">
<p class="text-xs uppercase tracking-wider text-slate-500">Seed raised</p>
<p class="mt-1 text-2xl font-bold text-slate-50">{{ money(100000) }}</p>
</div>
<div class="rounded-2xl border border-slate-700/60 bg-slate-800/40 p-5">
<p class="text-xs uppercase tracking-wider text-slate-500">Net worth today</p>
<p class="mt-1 text-2xl font-bold text-emerald-400">{{ money(48885) }}</p>
</div>
</div>
<div
class="rounded-3xl border border-emerald-500/30 bg-gradient-to-br from-emerald-500/10 to-transparent p-7"
>
<h3 class="text-2xl font-bold text-slate-50">Let's give the collective its books.</h3>
<p class="mt-3 text-slate-300">
Pat &amp; Rayan can have Project Lantern and the next project running on this within
weeks.
</p>
<a
href="#1"
class="mt-5 inline-flex items-center gap-1.5 text-sm font-semibold text-emerald-300 hover:text-emerald-200"
>
Restart the walkthrough <ArrowUpRight class="size-4" />
</a>
</div>
</div>
</div>
</SlideShell>
</template>

View file

@ -0,0 +1,34 @@
import type { Component } from 'vue'
import TitleSlide from './01-Title.vue'
import ProblemSlide from './02-Problem.vue'
import PrinciplesSlide from './03-Principles.vue'
import ProjectSlide from './04-Project.vue'
import InvestmentSlide from './05-Investment.vue'
import LedgerSlide from './06-Ledger.vue'
import ServersSlide from './07-Servers.vue'
import BalancesSlide from './08-Balances.vue'
import SnapshotSlide from './09-Snapshot.vue'
import AppSlide from './10-App.vue'
import ValueSlide from './11-Value.vue'
import FavaSlide from './12-Fava.vue'
export interface Slide {
title: string
component: Component
}
export const slides: Slide[] = [
{ title: 'Accounting for the Collective', component: TitleSlide },
{ title: 'The problem', component: ProblemSlide },
{ title: 'Accounting in 90 seconds', component: PrinciplesSlide },
{ title: 'Meet Project Lantern', component: ProjectSlide },
{ title: 'The money arrives', component: InvestmentSlide },
{ title: 'The books over time', component: LedgerSlide },
{ title: 'The $5,000 servers', component: ServersSlide },
{ title: 'Everyone has a balance', component: BalancesSlide },
{ title: 'Where we stand today', component: SnapshotSlide },
{ title: 'The application', component: AppSlide },
{ title: 'Why it matters', component: ValueSlide },
{ title: 'This is real, not a mockup', component: FavaSlide },
]

View file

@ -0,0 +1,64 @@
import { onMounted, onUnmounted, ref } from 'vue'
// Small, self-contained deck controller: current slide index, next/prev, and
// keyboard + URL-hash sync so any slide is directly linkable (#3).
export function useDeck(total: number) {
const index = ref(clampFromHash(total))
function clamp(n: number) {
return Math.max(0, Math.min(total - 1, n))
}
function go(n: number) {
index.value = clamp(n)
window.location.hash = String(index.value + 1)
}
const next = () => go(index.value + 1)
const prev = () => go(index.value - 1)
function onKey(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowRight':
case 'PageDown':
case ' ':
e.preventDefault()
next()
break
case 'ArrowLeft':
case 'PageUp':
e.preventDefault()
prev()
break
case 'Home':
e.preventDefault()
go(0)
break
case 'End':
e.preventDefault()
go(total - 1)
break
}
}
function onHashChange() {
index.value = clampFromHash(total)
}
onMounted(() => {
window.addEventListener('keydown', onKey)
window.addEventListener('hashchange', onHashChange)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('hashchange', onHashChange)
})
return { index, next, prev, go }
}
function clampFromHash(total: number) {
const n = parseInt(window.location.hash.replace('#', ''), 10)
if (Number.isNaN(n)) return 0
return Math.max(0, Math.min(total - 1, n - 1))
}

View file

@ -3,8 +3,8 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
name: 'deck',
component: () => import('@/features/deck/DeckView.vue'),
},
]

View file

@ -80,4 +80,10 @@
body {
@apply bg-background text-foreground;
}
/* Scale the whole deck with the viewport. Every Tailwind rem-based size
(text, padding, gaps, max-width) is relative to this root font-size, so
bumping it enlarges the presentation uniformly to fill large screens. */
html {
font-size: clamp(16px, 0.85vw + 6px, 23px);
}
}