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:
parent
6f330d49d4
commit
da0161963a
25 changed files with 1304 additions and 3 deletions
|
|
@ -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>
|
||||
|
|
|
|||
95
src/features/deck/DeckView.vue
Normal file
95
src/features/deck/DeckView.vue
Normal 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>
|
||||
110
src/features/deck/components/AreaChart.vue
Normal file
110
src/features/deck/components/AreaChart.vue
Normal 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>
|
||||
38
src/features/deck/components/BarChart.vue
Normal file
38
src/features/deck/components/BarChart.vue
Normal 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>
|
||||
37
src/features/deck/components/SlideShell.vue
Normal file
37
src/features/deck/components/SlideShell.vue
Normal 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>
|
||||
33
src/features/deck/components/StackedBar.vue
Normal file
33
src/features/deck/components/StackedBar.vue
Normal 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>
|
||||
25
src/features/deck/components/StatCard.vue
Normal file
25
src/features/deck/components/StatCard.vue
Normal 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>
|
||||
99
src/features/deck/components/Waterfall.vue
Normal file
99
src/features/deck/components/Waterfall.vue
Normal 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
129
src/features/deck/data.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
41
src/features/deck/slides/01-Title.vue
Normal file
41
src/features/deck/slides/01-Title.vue
Normal 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>
|
||||
50
src/features/deck/slides/02-Problem.vue
Normal file
50
src/features/deck/slides/02-Problem.vue
Normal 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>
|
||||
85
src/features/deck/slides/03-Principles.vue
Normal file
85
src/features/deck/slides/03-Principles.vue
Normal 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>
|
||||
69
src/features/deck/slides/04-Project.vue
Normal file
69
src/features/deck/slides/04-Project.vue
Normal 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 we’ll 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>
|
||||
42
src/features/deck/slides/05-Investment.vue
Normal file
42
src/features/deck/slides/05-Investment.vue
Normal 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 +{{ money(100000) }}</p>
|
||||
<p class="text-sky-400">Equity:Investors −{{ 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>
|
||||
35
src/features/deck/slides/06-Ledger.vue
Normal file
35
src/features/deck/slides/06-Ledger.vue
Normal 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>
|
||||
57
src/features/deck/slides/07-Servers.vue
Normal file
57
src/features/deck/slides/07-Servers.vue
Normal 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>
|
||||
53
src/features/deck/slides/08-Balances.vue
Normal file
53
src/features/deck/slides/08-Balances.vue
Normal 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>
|
||||
29
src/features/deck/slides/09-Snapshot.vue
Normal file
29
src/features/deck/slides/09-Snapshot.vue
Normal 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>
|
||||
56
src/features/deck/slides/10-App.vue
Normal file
56
src/features/deck/slides/10-App.vue
Normal 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 you’re 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>
|
||||
46
src/features/deck/slides/11-Value.vue
Normal file
46
src/features/deck/slides/11-Value.vue
Normal 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>
|
||||
68
src/features/deck/slides/12-Fava.vue
Normal file
68
src/features/deck/slides/12-Fava.vue
Normal 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 & 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>
|
||||
34
src/features/deck/slides/index.ts
Normal file
34
src/features/deck/slides/index.ts
Normal 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 },
|
||||
]
|
||||
64
src/features/deck/useDeck.ts
Normal file
64
src/features/deck/useDeck.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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'),
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue