Compare commits
7 commits
b292b987c3
...
0cce0089d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cce0089d1 | ||
|
|
a6f6b5bb15 | ||
|
|
b7d8a94e97 | ||
|
|
da0161963a | ||
|
|
6f330d49d4 | ||
|
|
da33c0eec7 | ||
|
|
61e5aa774d |
33 changed files with 1801 additions and 56 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
24
Accounting-Presentation.md
Normal file
24
Accounting-Presentation.md
Normal 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
48
README.boilerplate.md
Normal 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
127
README.md
|
|
@ -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 (Jan–Jul 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).
|
|
||||||
|
|
|
||||||
|
|
@ -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
267
ledger/lantern.beancount
Normal 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 (Jan–Jul 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
19
ledger/run-fava.sh
Executable 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
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
allowBuilds:
|
||||||
|
vue-demi: true
|
||||||
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>
|
||||||
40
src/features/deck/components/BarChart.vue
Normal file
40
src/features/deck/components/BarChart.vue
Normal 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>
|
||||||
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/WaterfallChart.vue
Normal file
99
src/features/deck/components/WaterfallChart.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: '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',
|
||||||
|
},
|
||||||
|
}
|
||||||
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 · Ryan · 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>
|
||||||
37
src/features/deck/slides/07-Spend.vue
Normal file
37
src/features/deck/slides/07-Spend.vue
Normal 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>
|
||||||
57
src/features/deck/slides/08-Servers.vue
Normal file
57
src/features/deck/slides/08-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>
|
||||||
82
src/features/deck/slides/09-Balances.vue
Normal file
82
src/features/deck/slides/09-Balances.vue
Normal 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>
|
||||||
29
src/features/deck/slides/10-Snapshot.vue
Normal file
29
src/features/deck/slides/10-Snapshot.vue
Normal 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>
|
||||||
56
src/features/deck/slides/11-App.vue
Normal file
56
src/features/deck/slides/11-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/12-Value.vue
Normal file
46
src/features/deck/slides/12-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/13-Fava.vue
Normal file
68
src/features/deck/slides/13-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 & 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>
|
||||||
36
src/features/deck/slides/index.ts
Normal file
36
src/features/deck/slides/index.ts
Normal 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 },
|
||||||
|
]
|
||||||
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[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'home',
|
name: 'deck',
|
||||||
component: () => import('@/views/HomeView.vue'),
|
component: () => import('@/features/deck/DeckView.vue'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue