Compare commits

...

7 commits

Author SHA1 Message Date
Patrick Mulligan
0cce0089d1 fix: correct accountant's name Rayan -> Ryan
Renames across the deck, README, brief, and the ledger's
Expenses:Salaries account. bean-check still passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 17:42:27 +02:00
Patrick Mulligan
a6f6b5bb15 feat(deck): add spend-breakdown slide and refine balances
Add a "Where the money went" slide after the cash-over-time chart, using a
bar chart of expenses by category (salaries, materials, operations,
contributed equity, fuel) sourced from the ledger — it answers the obvious
"where did the burn go?" question the cash curve leaves open.

Rework the balances slide into two labelled zones (Liabilities the
collective owes vs Equity claims on it) with a divider and larger type,
rather than a proportional chart that would render a $65 balance invisible
next to $100k.

Also fix BarChart value labels: an absent Vue Boolean prop casts to false,
so the old `showValues !== false` guard always hid the amounts — inverted to
`hideValues`. Rename Waterfall -> WaterfallChart for the multi-word lint rule
and enlarge chart type for projector readability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 20:54:30 +02:00
Patrick Mulligan
b7d8a94e97 docs: rewrite README for the presentation
Document the two deliverables (deck + ledger), how to run each, and the
headline figures. Original boilerplate stack notes preserved in
README.boilerplate.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 20:47:08 +02:00
Patrick Mulligan
da0161963a 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>
2026-07-30 20:47:08 +02:00
Patrick Mulligan
6f330d49d4 feat(ledger): add Project Lantern beancount ledger + fava launcher
A plain-text Beancount ledger tracking the mock project's first seven
months (seed round, monthly burn, a $5k capitalized server purchase, a
member reimbursement, a contributor equity grant, first pre-orders), plus
budget directives and milestone events. Passes `bean-check`.

`ledger/run-fava.sh` runs Fava from a local venv, working around the system
package's missing `beanquery` dependency; `.venv/` is gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 20:46:55 +02:00
Patrick Mulligan
da33c0eec7 chore: enable vue-demi build script in pnpm-workspace
The boilerplate shipped an invalid `allowBuilds: { vue-demi: "set this to
true or false" }` placeholder, which made pnpm's pre-run dependency check
fail with ERR_PNPM_IGNORED_BUILDS and blocked `pnpm dev` / `pnpm build`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 20:46:45 +02:00
Patrick Mulligan
61e5aa774d docs: add accounting presentation brief
The objective for the pitch: show an incubator collective the value of
accounting practices via an intro to principles + a worked mock project,
with a Beancount/Fava ledger backing the numbers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 20:46:45 +02:00
33 changed files with 1801 additions and 56 deletions

3
.gitignore vendored
View file

@ -17,4 +17,7 @@ dist-ssr
*.log *.log
pnpm-debug.log* pnpm-debug.log*
# python venv used to run Fava (see ledger/run-fava.sh)
.venv/
.claude/ .claude/

View file

@ -0,0 +1,24 @@
# Accounting Presentation
Our goal here is to present to an incubator collective the value of accounting practices which they currently are not using.
There are currently two members of the accounting team: Pat (software) and Ryan (accountant)
Our goal is to give a quick intro into accounting principals and then show a mock evolution of a project being done with proper accounting processes. That will be demonstrated using visualizations tracking the accounting books related to to a project:
Initial Investment money received and distributed to accounts as a function of budget described in investment pitch
In those accounts include cost of materials, salaries, equity, etc so that at any point in time someone may see what the state of the organization.
We want to visualize a common tech project trajectory and track a pretend ledger over time to show how accounting is beneficial. E.g., what does it look like if the hardware team needs $5,000 to purchase servers from an accounting perspective.
Because this is a collective, there are a lot of individuals who have balances with the entity who want to be able to see where they stand. Some of those balances with the organization could be liabilities of the organization to the user (e.g., expenses), some could be equity that gives the user claim to future profit, etc
We also provide the user an application. Each entity will have its own wallet and each entity will also have users/employees underneath it. Therefore when an employee needs to make a purchase, they either have direct access to the account allowing them to purchase what they need, or they know who to go to to ask for money and confirm that it can also be budgeted for. These employees will also have access to a local cash machine that works exclusively with the system for them to withdraw from the accounts to get cash (say somewhere on the order of $1,000) or they would use a company credit card (that system still has to be determined)
This software has a feature that allows people to also log expenses on behalf of the organization. E.g., let's say you are a member and you go out and buy gas for the organization car, you can easily log that expense so that you know you will be paid back
## Main value proposals to users/managers
- for the individual: ability to see where you stand
- for the team: efficient flow of money when and where it needs to go
# NOTE
We are using github.com/beancount/beancount and beancount/fava in our stack. Create mock data tracking the evolution of our mock project above so we can open it up in fava to show what the real reporting software provides to supplement the simplified visualizations in the pitch deck

48
README.boilerplate.md Normal file
View file

@ -0,0 +1,48 @@
# boilerplate-website
Opinionated Vue 3 starter — the base every new aiolabs website forks from.
(This project — the accounting pitch deck — was forked from it; see the main
[`README.md`](./README.md).)
## Stack
- **Vue 3.5** + **Vite 8** + **TypeScript 6**
- **shadcn-vue** (via `reka-ui` + `class-variance-authority` + `tailwind-merge`) — components.json pre-wired, run `pnpm dlx shadcn-vue@latest add <name>` to copy components in
- **Tailwind CSS 4** (via `@tailwindcss/vite` — no `tailwind.config.js`)
- **Pinia 3** — sample `useCounterStore` in `src/stores/`
- **Vue Router 5** — file in `src/router/index.ts`, lazy-loaded views in `src/views/`
- **Vue I18n 11**`src/i18n/locales/{en,es}.json`
- **vee-validate 4** + **zod 3** — form validation primitives (zod pinned to ^3 until `@vee-validate/zod` ships a v4-compatible resolver)
- **@lucide/vue** — icon set (`lucide-vue-next` is deprecated upstream)
- **ESLint 10** (flat config) + **Prettier 3**
## Quick start
```sh
pnpm install
pnpm dev # vite dev server
pnpm build # type-check + production build
pnpm preview # serve dist/
pnpm lint
pnpm format
```
## Optional features
Both nostr and LNbits live as **documentation-only** folders under
`src/features/`. The deps aren't installed by default — bundle stays small for
sites that don't need them. Each folder's README walks you through enabling.
- **`src/features/nostr/`** — connect to relays, sign/publish events, contact forms that DM the site owner's npub
- **`src/features/lnbits/`** — create invoices, accept Lightning payments via an LNbits instance
## Pulling boilerplate dep refreshes into a forked site
```sh
git remote add boilerplate forgejo@git.atitlan.io:aiolabs/boilerplate-website.git
git fetch boilerplate
git merge boilerplate/main
```
When a site diverges enough to no longer benefit from these merges, drop the
remote — it becomes its own thing.

127
README.md
View file

@ -1,74 +1,85 @@
# boilerplate-website # Accounting for the Collective — pitch deck
Opinionated Vue 3 starter — the base every new aiolabs website forks from. A slide presentation that pitches an incubator collective on adopting real
accounting practices, plus the **Beancount ledger** that backs every number in
it. Built on the aiolabs `boilerplate-website` (Vue 3 + Vite + Tailwind 4).
## Stack Presented by the accounting team: **Pat** (software) and **Ryan** (accountant).
- **Vue 3.5** + **Vite 8** + **TypeScript 6** ## Two deliverables, one story
- **shadcn-vue** (via `reka-ui` + `class-variance-authority` + `tailwind-merge`) — components.json pre-wired, run `pnpm dlx shadcn-vue@latest add <name>` to copy components in
- **Tailwind CSS 4** (via `@tailwindcss/vite` — no `tailwind.config.js`)
- **Pinia 3** — sample `useCounterStore` in `src/stores/`
- **Vue Router 5** — file in `src/router/index.ts`, lazy-loaded views in `src/views/`
- **Vue I18n 11**`src/i18n/locales/{en,es}.json`
- **vee-validate 4** + **zod 3** — form validation primitives (zod pinned to ^3 until `@vee-validate/zod` ships a v4-compatible resolver)
- **@lucide/vue** — icon set (`lucide-vue-next` is deprecated upstream)
- **ESLint 10** (flat config) + **Prettier 3**
## Quick start Both tell the story of **Project Lantern** — a mock hardware project inside the
collective — over its first seven months (JanJul 2026).
1. **The deck** (`src/features/deck/`) — a full-screen slide presentation with
simplified visualizations: budget allocation, cash-over-time, the
double-entry behind a $5,000 server purchase, who holds a balance with the
collective, and a live balance sheet.
2. **The ledger** (`ledger/lantern.beancount`) — the same story as a real
plain-text ledger you open in **Fava** to show the actual reporting software
behind the pitch. The deck's figures are pulled straight from it (see
`src/features/deck/data.ts`), so the two always agree.
## Run the deck
```sh ```sh
pnpm install pnpm install
pnpm dev # vite dev server pnpm dev # → http://localhost:5173
pnpm build # type-check + production build pnpm build # type-check + production build
pnpm preview # serve dist/
pnpm lint
pnpm format
``` ```
Navigate with **← / →** (or space), **Home/End** to jump, click the dots, or
deep-link a slide with a hash (e.g. `/#7`).
## Run the reporting (Fava)
The system Fava package is missing its `beanquery` dependency, so a helper
script spins up a local venv (reusing system packages) the first time:
```sh
./ledger/run-fava.sh # → http://localhost:5001
# or choose a port: ./ledger/run-fava.sh 5005
```
Validate the ledger directly with Beancount:
```sh
bean-check ledger/lantern.beancount # should print nothing = valid
```
In Fava, the interesting views are **Balance Sheet**, **Income Statement**,
**Budget** (the plan the seed round was raised against), the **Journal**, and
**Events** (project milestones).
## The numbers (as of 2026-07-30)
| | |
|---|---:|
| Seed round raised | $100,000 |
| Net worth today (total assets) | $48,885 |
| — Bank + petty cash | $43,885 |
| — Equipment (servers, capitalized) | $5,000 |
| Revenue to date (pre-orders) | $6,500 |
| Expenses to date | $60,615 |
Assets ($48,885) = Liabilities ($0) + Equity ($48,885). The books balance —
verified against Fava and `bean-check`.
## Layout ## Layout
``` ```
src/ ledger/
├─ App.vue # router-view shell ├─ lantern.beancount # the mock project ledger (open in Fava)
├─ main.ts # plugin wiring └─ run-fava.sh # launches Fava in a local venv
├─ style.css # tailwind + shadcn-vue CSS variables src/features/deck/
├─ lib/utils.ts # cn() — shadcn-vue's class merger ├─ DeckView.vue # slide shell: nav, progress, keyboard/hash routing
├─ router/index.ts ├─ useDeck.ts # navigation composable
├─ stores/counter.ts # example pinia store ├─ data.ts # canonical figures (mirror the ledger)
├─ i18n/ ├─ components/ # SlideShell, BarChart, StackedBar, AreaChart, StatCard
│ ├─ index.ts └─ slides/ # 01-Title … 12-Fava
│ └─ locales/{en,es}.json
├─ views/HomeView.vue # proof-of-wiring page (i18n + pinia + tailwind)
└─ features/
├─ nostr/README.md # opt-in: nostr-tools wiring (see file)
└─ lnbits/README.md # opt-in: LNbits payments wiring (see file)
``` ```
## Optional features ---
Both nostr and LNbits live as **documentation-only** folders. The deps Scaffolding stack notes from the boilerplate are preserved in
aren't installed by default — bundle stays small for sites that don't [`README.boilerplate.md`](./README.boilerplate.md).
need them. Each folder's README walks you through enabling.
- **`src/features/nostr/`** — connect to relays, sign/publish events, contact forms that DM the site owner's npub
- **`src/features/lnbits/`** — create invoices, accept Lightning payments via an LNbits instance
## Versioning strategy
The boilerplate's `main` branch is **the "tools wired, no content" baseline**. New site = clone (or fork on Forgejo) → start adding content immediately.
To pull dep refreshes from the boilerplate into an existing site:
```sh
git remote add boilerplate forgejo@git.atitlan.io:aiolabs/boilerplate-website.git
git fetch boilerplate
git merge boilerplate/main
```
When a site diverges enough to no longer benefit from these merges, drop the remote — it becomes its own thing.
## Keeping deps current
- The boilerplate gets dep bumps via PRs (Renovate / Dependabot configurable; otherwise periodic `pnpm update --latest` + manual review).
- The Vue ecosystem (vue, pinia, router, i18n, vee-validate) versions in lockstep — when one majors, the others usually follow within weeks.
- Tailwind 4 + shadcn-vue + reka-ui is the current modern combo. shadcn-vue uses tw-animate-css (not the deprecated tailwindcss-animate plugin).

View file

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

267
ledger/lantern.beancount Normal file
View file

@ -0,0 +1,267 @@
;; ============================================================================
;; Project Lantern — mock ledger for the accounting pitch deck
;; ----------------------------------------------------------------------------
;; A hardware project inside the Atitlán incubator collective. This ledger
;; tracks the project's first seven months (JanJul 2026) so it can be opened
;; in Fava (`fava ledger/lantern.beancount`) to show the "real" reporting that
;; sits behind the simplified visualizations in the slide deck.
;;
;; Story beats mirrored in the deck:
;; - Seed investment received and put to work as budgeted
;; - Salaries, rent, utilities, software — the monthly burn
;; - A $5,000 server purchase (capitalized as an ASSET, not an expense)
;; - A member fronts cash for fuel and is owed a reimbursement (a LIABILITY)
;; - A contributor earns equity instead of cash (a claim on future profit)
;; - First pre-order revenue starts coming in
;; ============================================================================
option "title" "Project Lantern — Atitlán Collective"
option "operating_currency" "USD"
;; ----------------------------------------------------------------------------
;; Chart of accounts
;; ----------------------------------------------------------------------------
2026-01-01 open Assets:Bank:Checking USD
2026-01-01 open Assets:Cash:PettyCash USD
2026-01-01 open Assets:Equipment:Servers USD
2026-01-01 open Liabilities:CreditCard USD
2026-01-01 open Liabilities:Reimbursements:Alice USD
2026-01-01 open Equity:Investors:Seed USD
2026-01-01 open Equity:Contributors:Bob USD
2026-01-01 open Income:PreOrders USD
2026-01-01 open Expenses:Salaries:Pat USD
2026-01-01 open Expenses:Salaries:Ryan USD
2026-01-01 open Expenses:Salaries:Sam USD
2026-01-01 open Expenses:Contributed:Engineering USD
2026-01-01 open Expenses:Materials USD
2026-01-01 open Expenses:Operations:Rent USD
2026-01-01 open Expenses:Operations:Utilities USD
2026-01-01 open Expenses:Operations:Software USD
2026-01-01 open Expenses:Travel:Fuel USD
;; ----------------------------------------------------------------------------
;; Budget — the plan the seed money was raised against (Fava reads these)
;; ----------------------------------------------------------------------------
2026-01-01 custom "budget" Expenses:Salaries:Pat "monthly" 2500.00 USD
2026-01-01 custom "budget" Expenses:Salaries:Ryan "monthly" 1500.00 USD
2026-01-01 custom "budget" Expenses:Salaries:Sam "monthly" 2000.00 USD
2026-01-01 custom "budget" Expenses:Materials "monthly" 2500.00 USD
2026-01-01 custom "budget" Expenses:Operations:Rent "monthly" 800.00 USD
2026-01-01 custom "budget" Expenses:Operations:Utilities "monthly" 200.00 USD
2026-01-01 custom "budget" Expenses:Operations:Software "monthly" 150.00 USD
;; ----------------------------------------------------------------------------
;; Milestones (shown on Fava's events page)
;; ----------------------------------------------------------------------------
2026-01-15 event "milestone" "Seed round closed — $100k in the bank"
2026-03-18 event "milestone" "Server cluster online (capital asset)"
2026-06-15 event "milestone" "First customer pre-orders received"
;; ============================================================================
;; JANUARY 2026 — the money arrives and is put to work
;; ============================================================================
2026-01-15 * "Atitlán Ventures" "Seed round — initial investment received"
Assets:Bank:Checking 100000.00 USD
Equity:Investors:Seed
2026-01-16 * "Coworking Tikal" "January workspace"
Expenses:Operations:Rent 800.00 USD
Assets:Bank:Checking
2026-01-16 * "Utilities co-op" "Power + internet"
Expenses:Operations:Utilities 200.00 USD
Assets:Bank:Checking
2026-01-16 * "SaaS bundle" "Design + CAD + hosting subscriptions"
Expenses:Operations:Software 150.00 USD
Assets:Bank:Checking
2026-01-31 * "Payroll" "January salaries"
Expenses:Salaries:Pat 2500.00 USD
Expenses:Salaries:Ryan 1500.00 USD
Expenses:Salaries:Sam 2000.00 USD
Assets:Bank:Checking
;; ============================================================================
;; FEBRUARY 2026 — petty cash float + first prototype parts on the card
;; ============================================================================
2026-02-02 * "Cash machine" "Withdraw petty-cash float for on-site purchases"
Assets:Cash:PettyCash 1000.00 USD
Assets:Bank:Checking
2026-02-03 * "Coworking Tikal" "February workspace"
Expenses:Operations:Rent 800.00 USD
Assets:Bank:Checking
2026-02-03 * "Utilities co-op" "Power + internet"
Expenses:Operations:Utilities 200.00 USD
Assets:Bank:Checking
2026-02-03 * "SaaS bundle" "Subscriptions"
Expenses:Operations:Software 150.00 USD
Assets:Bank:Checking
2026-02-10 * "Parts supplier" "Prototype components (company card)"
Expenses:Materials 3000.00 USD
Liabilities:CreditCard
2026-02-28 * "Payroll" "February salaries"
Expenses:Salaries:Pat 2500.00 USD
Expenses:Salaries:Ryan 1500.00 USD
Expenses:Salaries:Sam 2000.00 USD
Assets:Bank:Checking
;; ============================================================================
;; MARCH 2026 — settle the card, then the $5,000 server purchase
;; ============================================================================
2026-03-03 * "Coworking Tikal" "March workspace"
Expenses:Operations:Rent 800.00 USD
Assets:Bank:Checking
2026-03-03 * "Utilities co-op" "Power + internet"
Expenses:Operations:Utilities 200.00 USD
Assets:Bank:Checking
2026-03-03 * "SaaS bundle" "Subscriptions"
Expenses:Operations:Software 150.00 USD
Assets:Bank:Checking
2026-03-05 * "Company card" "Pay off February card balance"
Liabilities:CreditCard 3000.00 USD
Assets:Bank:Checking
;; The headline example from the deck: the hardware team needs $5,000 for
;; servers. It is NOT an expense — it buys a durable asset, so net worth is
;; unchanged the instant it happens: cash down $5k, equipment up $5k.
2026-03-18 * "Rack & Stack Ltd" "Server cluster for build/test farm — CAPITAL PURCHASE"
Assets:Equipment:Servers 5000.00 USD
Assets:Bank:Checking
2026-03-31 * "Payroll" "March salaries"
Expenses:Salaries:Pat 2500.00 USD
Expenses:Salaries:Ryan 1500.00 USD
Expenses:Salaries:Sam 2000.00 USD
Assets:Bank:Checking
;; ============================================================================
;; APRIL 2026 — a member fronts fuel money (owed back), more prototype parts
;; ============================================================================
2026-04-02 * "Coworking Tikal" "April workspace"
Expenses:Operations:Rent 800.00 USD
Assets:Bank:Checking
2026-04-02 * "Utilities co-op" "Power + internet"
Expenses:Operations:Utilities 200.00 USD
Assets:Bank:Checking
2026-04-02 * "SaaS bundle" "Subscriptions"
Expenses:Operations:Software 150.00 USD
Assets:Bank:Checking
;; Alice pays for fuel out of her own pocket. The org now OWES her — that debt
;; is a liability the moment it is logged, so she can see exactly where she
;; stands without waiting for anyone's memory.
2026-04-05 * "Alice" "Fuel for the org vehicle — fronted by member"
Expenses:Travel:Fuel 65.00 USD
Liabilities:Reimbursements:Alice
2026-04-12 * "Parts supplier" "Second prototype revision"
Expenses:Materials 4500.00 USD
Assets:Bank:Checking
;; Alice is paid back — the liability clears.
2026-04-20 * "Alice" "Reimburse fronted fuel expense"
Liabilities:Reimbursements:Alice 65.00 USD
Assets:Bank:Checking
2026-04-30 * "Payroll" "April salaries"
Expenses:Salaries:Pat 2500.00 USD
Expenses:Salaries:Ryan 1500.00 USD
Expenses:Salaries:Sam 2000.00 USD
Assets:Bank:Checking
;; ============================================================================
;; MAY 2026 — a contributor takes equity instead of cash
;; ============================================================================
2026-05-04 * "Coworking Tikal" "May workspace"
Expenses:Operations:Rent 800.00 USD
Assets:Bank:Checking
2026-05-04 * "Utilities co-op" "Power + internet"
Expenses:Operations:Utilities 200.00 USD
Assets:Bank:Checking
2026-05-04 * "SaaS bundle" "Subscriptions"
Expenses:Operations:Software 150.00 USD
Assets:Bank:Checking
;; Bob contributes engineering work but is paid in EQUITY rather than cash.
;; No money leaves the bank; instead Bob gains a claim on future profit.
2026-05-01 * "Bob" "Contributed engineering — settled as equity grant"
Expenses:Contributed:Engineering 3000.00 USD
Equity:Contributors:Bob
2026-05-20 * "Parts supplier" "Pre-production batch"
Expenses:Materials 6000.00 USD
Assets:Bank:Checking
2026-05-29 * "Payroll" "May salaries"
Expenses:Salaries:Pat 2500.00 USD
Expenses:Salaries:Ryan 1500.00 USD
Expenses:Salaries:Sam 2000.00 USD
Assets:Bank:Checking
;; ============================================================================
;; JUNE 2026 — the first revenue arrives
;; ============================================================================
2026-06-02 * "Coworking Tikal" "June workspace"
Expenses:Operations:Rent 800.00 USD
Assets:Bank:Checking
2026-06-02 * "Utilities co-op" "Power + internet"
Expenses:Operations:Utilities 200.00 USD
Assets:Bank:Checking
2026-06-02 * "SaaS bundle" "Subscriptions"
Expenses:Operations:Software 150.00 USD
Assets:Bank:Checking
2026-06-15 * "Early customers" "First pre-order deposits"
Assets:Bank:Checking 4000.00 USD
Income:PreOrders
2026-06-30 * "Payroll" "June salaries"
Expenses:Salaries:Pat 2500.00 USD
Expenses:Salaries:Ryan 1500.00 USD
Expenses:Salaries:Sam 2000.00 USD
Assets:Bank:Checking
;; ============================================================================
;; JULY 2026 — steady state; more pre-orders
;; ============================================================================
2026-07-02 * "Coworking Tikal" "July workspace"
Expenses:Operations:Rent 800.00 USD
Assets:Bank:Checking
2026-07-02 * "Utilities co-op" "Power + internet"
Expenses:Operations:Utilities 200.00 USD
Assets:Bank:Checking
2026-07-02 * "SaaS bundle" "Subscriptions"
Expenses:Operations:Software 150.00 USD
Assets:Bank:Checking
2026-07-10 * "Early customers" "Second pre-order batch"
Assets:Bank:Checking 2500.00 USD
Income:PreOrders

19
ledger/run-fava.sh Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Launch Fava for the Project Lantern ledger.
#
# The system Fava (pacman) is missing its `beanquery` dependency, so we run it
# from a local venv that reuses system site-packages and adds only what's
# missing. First run creates the venv; afterwards it's instant.
set -euo pipefail
cd "$(dirname "$0")/.."
PORT="${1:-5001}"
if [ ! -x .venv/bin/python ]; then
echo "Creating local venv for Fava tooling..."
python -m venv --system-site-packages .venv
.venv/bin/pip install --quiet "beanquery" "simplejson<4"
fi
exec .venv/bin/python -c "import sys; from fava.cli import main; \
sys.argv=['fava','-p','${PORT}','ledger/lantern.beancount']; main()"

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
allowBuilds:
vue-demi: true

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,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { palette, money } from '../data'
// Note: Vue casts an absent Boolean prop to `false`, so a "showValues" default
// of true isn't possible without a runtime default invert it to hideValues.
const props = defineProps<{
items: readonly { label: string; amount: number; color: string }[]
hideValues?: boolean
}>()
const max = computed(() => Math.max(...props.items.map((i) => i.amount), 1))
</script>
<template>
<div class="space-y-5">
<div
v-for="(item, i) in items"
:key="item.label"
class="grid grid-cols-[10rem_1fr] items-center gap-5"
>
<span class="truncate text-right text-base text-slate-300 sm:text-lg">{{ item.label }}</span>
<div class="flex items-center gap-4">
<div class="h-9 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="!hideValues"
class="w-28 shrink-0 text-right text-lg font-bold tabular-nums text-slate-100 sm:text-xl"
>
{{ 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: 'Ryan', 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 · Ryan · 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,37 @@
<script setup lang="ts">
import SlideShell from '../components/SlideShell.vue'
import BarChart from '../components/BarChart.vue'
import { expenses, money } from '../data'
</script>
<template>
<SlideShell kicker="Step 2 · The books over time" title="Where the money went">
<div class="grid gap-12 lg:grid-cols-[1.25fr_1fr] lg:items-center">
<div>
<p class="mb-8 text-lg text-slate-400">
Total spent to date: <span class="font-semibold text-slate-100">{{ money(60615) }}</span>
</p>
<BarChart :items="expenses" />
</div>
<div class="space-y-6">
<p class="text-xl leading-relaxed text-slate-300 sm:text-2xl">
The curve falling isn't the whole story
<span class="font-semibold text-slate-100">what it bought</span>
is.
</p>
<p class="text-lg leading-relaxed text-slate-300">
<span class="font-semibold text-sky-400">Salaries</span> are the biggest line this is a
team building something. <span class="font-semibold text-amber-400">Materials</span> come
next: the prototypes.
</p>
<div class="rounded-2xl border border-emerald-500/30 bg-emerald-500/10 p-5">
<p class="text-base leading-relaxed text-emerald-100 sm:text-lg">
Even the <span class="font-semibold">{{ money(65) }}</span> of fuel a member fronted is
on the books. Nothing is too small to see that's the point.
</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,82 @@
<script setup lang="ts">
import { ArrowDownLeft, ArrowUpRight } from '@lucide/vue'
import SlideShell from '../components/SlideShell.vue'
import { balances, money } from '../data'
const owed = balances.filter((b) => b.kind === 'liability')
const owned = balances.filter((b) => b.kind === 'equity')
</script>
<template>
<SlideShell kicker="Because it's a collective" title="Everyone holds a balance with the entity">
<p class="max-w-4xl text-xl leading-relaxed text-slate-300 sm:text-2xl">
In a collective, people aren't just staff they lend, spend, and own. Every balance is one of
two shapes, and the books tell them apart automatically.
</p>
<div
class="mt-10 grid gap-8 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,1.4fr)] lg:items-stretch"
>
<!-- LIABILITIES: what the collective owes -->
<div>
<div class="mb-4 flex items-center gap-3">
<div class="grid size-11 place-items-center rounded-xl bg-rose-500/15 text-rose-400">
<ArrowDownLeft class="size-6" />
</div>
<div>
<p class="text-xl font-bold text-rose-400 sm:text-2xl">Owed by the collective</p>
<p class="text-sm uppercase tracking-widest text-slate-500">
Liabilities · a debt to repay
</p>
</div>
</div>
<div
v-for="b in owed"
:key="b.name"
class="rounded-2xl border border-rose-500/40 bg-rose-500/10 p-6"
>
<div class="flex items-baseline justify-between">
<span class="text-2xl font-bold text-slate-50">{{ b.name }}</span>
<span class="text-3xl font-bold tabular-nums text-rose-300">{{ money(b.amount) }}</span>
</div>
<p class="mt-3 text-base text-slate-300">{{ b.story }}</p>
</div>
</div>
<!-- divider -->
<div
class="hidden w-px bg-gradient-to-b from-transparent via-slate-600 to-transparent lg:block"
/>
<!-- EQUITY: claims on the collective -->
<div>
<div class="mb-4 flex items-center gap-3">
<div class="grid size-11 place-items-center rounded-xl bg-sky-500/15 text-sky-400">
<ArrowUpRight class="size-6" />
</div>
<div>
<p class="text-xl font-bold text-sky-400 sm:text-2xl">Owned of the collective</p>
<p class="text-sm uppercase tracking-widest text-slate-500">
Equity · a claim on the upside
</p>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div
v-for="b in owned"
:key="b.name"
class="rounded-2xl border border-sky-500/40 bg-sky-500/10 p-6"
>
<div class="flex items-baseline justify-between gap-2">
<span class="text-2xl font-bold text-slate-50">{{ b.name }}</span>
<span class="text-2xl font-bold tabular-nums text-sky-300 sm:text-3xl">{{
money(b.amount)
}}</span>
</div>
<p class="mt-3 text-base text-slate-300">{{ b.story }}</p>
</div>
</div>
</div>
</div>
</SlideShell>
</template>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import SlideShell from '../components/SlideShell.vue'
import WaterfallChart, { type WaterfallBar } from '../components/WaterfallChart.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">
<WaterfallChart :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; Ryan 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,36 @@
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 SpendSlide from './07-Spend.vue'
import ServersSlide from './08-Servers.vue'
import BalancesSlide from './09-Balances.vue'
import SnapshotSlide from './10-Snapshot.vue'
import AppSlide from './11-App.vue'
import ValueSlide from './12-Value.vue'
import FavaSlide from './13-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: 'Where the money went', component: SpendSlide },
{ 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[] = [ const routes: RouteRecordRaw[] = [
{ {
path: '/', path: '/',
name: 'home', name: 'deck',
component: () => import('@/views/HomeView.vue'), component: () => import('@/features/deck/DeckView.vue'),
}, },
] ]

View file

@ -80,4 +80,10 @@
body { body {
@apply bg-background text-foreground; @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);
}
} }