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
78 changed files with 1797 additions and 2560 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>Archipelago — Investment Prospectus</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()"

View file

@ -1,5 +1,2 @@
# Approve dependencies allowed to run install scripts (pnpm 11).
# vue-demi's postinstall selects its Vue 2/3 entrypoint; without this,
# `pnpm dev` / `pnpm build` fail the pre-run ignored-builds check.
allowBuilds: allowBuilds:
vue-demi: true vue-demi: true

View file

@ -1,149 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { useDeck } from './useDeck'
import { brand } from './data'
import TitleSlide from './slides/TitleSlide.vue'
import VisionSlide from './slides/VisionSlide.vue'
import NetworkSlide from './slides/NetworkSlide.vue'
import TechSlide from './slides/TechSlide.vue'
import EntitiesSlide from './slides/EntitiesSlide.vue'
import SolLunarSlide from './slides/SolLunarSlide.vue'
import PartnershipSlide from './slides/PartnershipSlide.vue'
import TenantsSlide from './slides/TenantsSlide.vue'
import MembershipSlide from './slides/MembershipSlide.vue'
import SpacesSlide from './slides/SpacesSlide.vue'
import GallerySlide from './slides/GallerySlide.vue'
import EcosystemSlide from './slides/EcosystemSlide.vue'
import PnlSlide from './slides/PnlSlide.vue'
import RoadmapSlide from './slides/RoadmapSlide.vue'
import AskSlide from './slides/AskSlide.vue'
const slides = [
{ id: 'title', label: 'Title', component: TitleSlide },
{ id: 'vision', label: 'Vision', component: VisionSlide },
{ id: 'network', label: 'The network', component: NetworkSlide },
{ id: 'archipelago', label: 'Archipelago', component: TechSlide },
{ id: 'entities', label: 'Three experiences', component: EntitiesSlide },
{ id: 'sollunar', label: 'SolLunar Society', component: SolLunarSlide },
{ id: 'partnership', label: 'Ownership', component: PartnershipSlide },
{ id: 'tenants', label: 'Tenants', component: TenantsSlide },
{ id: 'membership', label: 'Memberships', component: MembershipSlide },
{ id: 'spaces', label: 'The building', component: SpacesSlide },
{ id: 'gallery', label: 'Gallery', component: GallerySlide },
{ id: 'ecosystem', label: 'One membership', component: EcosystemSlide },
{ id: 'pnl', label: 'The numbers', component: PnlSlide },
{ id: 'roadmap', label: 'Roadmap', component: RoadmapSlide },
{ id: 'ask', label: 'The ask', component: AskSlide },
]
const { index, direction, goto, next, prev, isFirst, isLast } = useDeck(slides.length)
const current = computed(() => slides[index.value])
const transitionName = computed(() => (direction.value === 1 ? 'slide-next' : 'slide-prev'))
</script>
<template>
<div class="deck-root relative h-dvh w-full overflow-hidden">
<!-- top bar -->
<header
class="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between px-6 py-5 text-sand-dim"
>
<span class="font-display text-sm font-semibold tracking-wide text-sand">
{{ brand.short }}
</span>
<span class="text-xs uppercase tracking-[0.2em]">{{ current.label }}</span>
</header>
<!-- slide stage -->
<main class="h-full w-full">
<Transition :name="transitionName" mode="out-in">
<KeepAlive>
<component :is="current.component" :key="current.id" class="h-full w-full" />
</KeepAlive>
</Transition>
</main>
<!-- controls -->
<div class="absolute inset-x-0 bottom-0 z-20 flex items-center justify-between gap-4 px-6 py-5">
<button
class="pointer-events-auto grid size-10 place-items-center rounded-full border border-white/10 bg-white/5 text-sand transition hover:bg-white/10 disabled:opacity-30"
:disabled="isFirst"
aria-label="Previous slide"
@click="prev"
>
<ChevronLeft class="size-5" />
</button>
<!-- progress dots -->
<nav class="flex flex-wrap items-center justify-center gap-2" aria-label="Slides">
<button
v-for="(s, i) in slides"
:key="s.id"
class="h-1.5 rounded-full transition-all"
:class="i === index ? 'w-6 bg-amber-400' : 'w-1.5 bg-white/20 hover:bg-white/40'"
:title="s.label"
:aria-label="`Go to ${s.label}`"
:aria-current="i === index"
@click="goto(i)"
/>
</nav>
<div class="flex items-center gap-3">
<span class="hidden text-xs tabular-nums text-sand-dim sm:inline">
{{ index + 1 }} / {{ slides.length }}
</span>
<button
class="pointer-events-auto grid size-10 place-items-center rounded-full border border-white/10 bg-white/5 text-sand transition hover:bg-white/10 disabled:opacity-30"
:disabled="isLast"
aria-label="Next slide"
@click="next"
>
<ChevronRight class="size-5" />
</button>
</div>
</div>
</div>
</template>
<style scoped>
.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
transition:
opacity 0.35s ease,
transform 0.35s ease;
}
.slide-next-enter-from {
opacity: 0;
transform: translateX(3%);
}
.slide-next-leave-to {
opacity: 0;
transform: translateX(-3%);
}
.slide-prev-enter-from {
opacity: 0;
transform: translateX(-3%);
}
.slide-prev-leave-to {
opacity: 0;
transform: translateX(3%);
}
@media (prefers-reduced-motion: reduce) {
.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
transition: opacity 0.2s ease;
}
.slide-next-enter-from,
.slide-next-leave-to,
.slide-prev-enter-from,
.slide-prev-leave-to {
transform: none;
}
}
</style>

View file

@ -1,77 +0,0 @@
<script setup lang="ts">
import SlideFrame from './SlideFrame.vue'
import Icon from './Icon.vue'
import PlaceholderBadge from './PlaceholderBadge.vue'
import { accentText, accentRing, accentDot, type Entity } from '../data'
const props = defineProps<{ entity: Entity }>()
const a = props.entity.accent
</script>
<template>
<SlideFrame :kicker="entity.kind">
<div class="flex flex-wrap items-center gap-3">
<h2 class="font-display text-4xl font-semibold text-sand lg:text-5xl">{{ entity.name }}</h2>
<span
class="rounded-full border px-3 py-0.5 text-xs uppercase tracking-wider"
:class="[accentRing[a], accentText[a]]"
>
{{ entity.role }}
</span>
<span v-if="entity.example" class="text-sm text-sand-dim">e.g. {{ entity.example }}</span>
<PlaceholderBadge label="name" />
</div>
<p class="mt-4 max-w-2xl text-lg text-sand-dim">{{ entity.blurb }}</p>
<div class="mt-8 grid gap-6 lg:grid-cols-3">
<!-- unique traits -->
<div class="lg:col-span-1">
<p class="text-xs font-semibold uppercase tracking-[0.2em]" :class="accentText[a]">
What makes it unique
</p>
<ul class="mt-4 space-y-3">
<li v-for="t in entity.traits" :key="t" class="flex items-start gap-3 text-sm text-sand-dim">
<span class="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full" :class="accentRing[a]">
<Icon name="Check" class="size-3" :class="accentText[a]" />
</span>
{{ t }}
</li>
</ul>
</div>
<!-- spaces -->
<div class="rounded-2xl border p-6" :class="accentRing[a]">
<p class="text-xs font-semibold uppercase tracking-[0.2em]" :class="accentText[a]">On site</p>
<div class="mt-4 grid grid-cols-1 gap-2">
<div
v-for="s in entity.spaces"
:key="s"
class="flex items-center gap-2 rounded-lg bg-white/[0.03] px-3 py-2 text-sm text-sand"
>
<span class="size-1.5 rounded-full" :class="accentDot[a]" />
{{ s }}
</div>
</div>
</div>
<!-- revenue + shared-stack reminder -->
<div class="flex flex-col gap-4">
<div class="rounded-2xl border p-6" :class="accentRing[a]">
<p class="text-xs font-semibold uppercase tracking-[0.2em]" :class="accentText[a]">
How it earns
</p>
<ul class="mt-3 space-y-1.5 text-sm text-sand-dim">
<li v-for="r in entity.revenue" :key="r" class="flex items-center gap-2">
<Icon name="Coins" class="size-3.5" :class="accentText[a]" />
{{ r }}
</li>
</ul>
</div>
<div class="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-xs text-sand-dim">
<Icon name="Layers" class="size-4 text-amber-300" />
Runs on Archipelago same membership, kiosk &amp; app as every node.
</div>
</div>
</div>
</SlideFrame>
</template>

View file

@ -1,78 +0,0 @@
<script setup lang="ts">
/** Small registry so data.ts can reference icons by string name. */
import {
Wifi,
Cpu,
Code,
Zap,
KeyRound,
Globe,
Building2,
Trees,
BedDouble,
Sparkles,
Mountain,
Palette,
PartyPopper,
Router,
CreditCard,
Origami,
ArrowRight,
Check,
Mail,
CalendarClock,
Layers,
Coins,
HeartHandshake,
Tent,
Ticket,
Sun,
Moon,
MapPin,
ChevronRight,
Images,
Expand,
type LucideProps,
} from '@lucide/vue'
import type { FunctionalComponent } from 'vue'
const registry: Record<string, FunctionalComponent<LucideProps>> = {
Wifi,
Cpu,
Code,
Zap,
KeyRound,
Globe,
Building2,
Trees,
BedDouble,
Sparkles,
Mountain,
Palette,
PartyPopper,
Router,
CreditCard,
Origami,
ArrowRight,
Check,
Mail,
CalendarClock,
Layers,
Coins,
HeartHandshake,
Tent,
Ticket,
Sun,
Moon,
MapPin,
ChevronRight,
Images,
Expand,
}
const props = defineProps<{ name: string }>()
</script>
<template>
<component :is="registry[props.name] ?? Layers" />
</template>

View file

@ -1,125 +0,0 @@
<script setup lang="ts">
/**
* Full-screen image lightbox, styled after the webapp marketplace viewer.
* Teleported to <body>; arrow keys / swipe navigate, ESC or backdrop closes.
*
* Keyboard is handled in the CAPTURE phase and stopPropagation'd while open,
* so the deck's own arrow-key slide navigation doesn't also fire.
*/
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { X, ChevronLeft, ChevronRight } from '@lucide/vue'
import type { GalleryImage } from '../gallery'
const props = defineProps<{ images: GalleryImage[] }>()
// null = closed; otherwise the active image index
const index = defineModel<number | null>({ required: true })
const current = computed(() =>
index.value != null ? (props.images[index.value] ?? null) : null,
)
const hasPrev = computed(() => index.value != null && index.value > 0)
const hasNext = computed(() => index.value != null && index.value < props.images.length - 1)
function close() {
index.value = null
}
function prev() {
if (hasPrev.value) index.value = (index.value as number) - 1
}
function next() {
if (hasNext.value) index.value = (index.value as number) + 1
}
function onKey(e: KeyboardEvent) {
if (index.value == null) return // closed let the deck handle keys
if (['ArrowRight', 'ArrowLeft', 'Escape', ' '].includes(e.key)) {
e.preventDefault()
e.stopPropagation()
}
if (e.key === 'ArrowRight' || e.key === ' ') next()
else if (e.key === 'ArrowLeft') prev()
else if (e.key === 'Escape') close()
}
// basic swipe support
let touchX = 0
function onTouchStart(e: TouchEvent) {
touchX = e.changedTouches[0].clientX
}
function onTouchEnd(e: TouchEvent) {
const dx = e.changedTouches[0].clientX - touchX
if (dx > 50) prev()
else if (dx < -50) next()
}
onMounted(() => window.addEventListener('keydown', onKey, true)) // capture
onUnmounted(() => window.removeEventListener('keydown', onKey, true))
</script>
<template>
<Teleport to="body">
<Transition name="lb">
<div
v-if="current"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-[hsl(160_28%_4%/0.94)] p-6 backdrop-blur-sm"
@click="close"
@touchstart.passive="onTouchStart"
@touchend.passive="onTouchEnd"
>
<img
:src="current.src"
:alt="current.name"
class="max-h-[90vh] max-w-[92vw] rounded-lg object-contain shadow-2xl"
@click.stop
/>
<!-- close -->
<button
class="absolute right-5 top-5 grid size-11 place-items-center rounded-full border border-white/15 bg-white/10 text-sand backdrop-blur transition hover:bg-white/20"
aria-label="Close"
@click.stop="close"
>
<X class="size-5" />
</button>
<!-- prev / next -->
<button
v-if="hasPrev"
class="absolute left-5 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-full border border-white/15 bg-white/10 text-sand backdrop-blur transition hover:bg-white/20"
aria-label="Previous image"
@click.stop="prev"
>
<ChevronLeft class="size-6" />
</button>
<button
v-if="hasNext"
class="absolute right-5 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-full border border-white/15 bg-white/10 text-sand backdrop-blur transition hover:bg-white/20"
aria-label="Next image"
@click.stop="next"
>
<ChevronRight class="size-6" />
</button>
<!-- counter + hint -->
<div
v-if="images.length > 1"
class="absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full border border-white/15 bg-white/10 px-4 py-1.5 text-sm text-sand backdrop-blur"
>
{{ (index ?? 0) + 1 }} / {{ images.length }}
<span class="ml-2 hidden text-sand-dim sm:inline"> to browse · Esc to close</span>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.lb-enter-active,
.lb-leave-active {
transition: opacity 0.2s ease;
}
.lb-enter-from,
.lb-leave-to {
opacity: 0;
}
</style>

View file

@ -1,408 +0,0 @@
<script setup lang="ts">
/**
* The core metaphor, as an animated SVG.
*
* Underground Archipelago = the shared backbone / custom software.
* Unseen, always working, connects every entity (the mycelium).
* Flowers the three experiences people actually engage with:
* SolLunar Punk Society (city center) a permanent bloom
* Pop-up Festivals (travelling) ephemeral cluster
* Chateau du Faune (farm center) a permanent bloom
* Ghost flowers once the mycelium runs, new locations & festivals sprout
* cheaply, anywhere in the world.
*
* Pure SVG + CSS so it scales crisply on a projector and respects
* prefers-reduced-motion.
*/
import Icon from './Icon.vue'
import { entityById } from '../data'
const GROUND = 300
// underground mycelium nodes (below the ground line)
const nodes: [number, number][] = [
[230, 340], // root · Archipelago
[500, 335], // root · SolLunar
[770, 340], // root · Chateau
[100, 340], // root · ghost
[900, 340], // root · ghost
[160, 430],
[330, 470],
[430, 420],
[560, 470],
[660, 430],
[840, 470],
[240, 560],
[430, 560],
[620, 560],
[810, 560],
]
// threads weaving the mycelium together (indices into `nodes`)
const edges: [number, number][] = [
[0, 6],
[0, 5],
[0, 7],
[0, 3],
[3, 5],
[5, 11],
[6, 11],
[6, 12],
[7, 12],
[7, 1],
[1, 8],
[8, 12],
[8, 13],
[1, 9],
[9, 13],
[9, 2],
[2, 10],
[10, 14],
[4, 10],
[9, 14],
[7, 8],
[2, 4],
]
function thread([a, b]: [number, number], i: number) {
const [ax, ay] = nodes[a]
const [bx, by] = nodes[b]
const mx = (ax + bx) / 2 + (i % 2 ? 24 : -24)
const my = (ay + by) / 2 + (i % 3 ? 18 : -14)
return `M ${ax} ${ay} Q ${mx} ${my} ${bx} ${by}`
}
const sol = entityById('sollunar') // city
const fest = entityById('festivals') // travelling
const cha = entityById('chateau') // farm
// surface flowers: the three experiences + two faint "future" ones.
// (Archipelago is the mycelium itself the network below, not a flower.)
const flowers = [
{ x: 230, kind: 'bloom', color: 'var(--color-indigo-400)', name: sol.name, tag: sol.role },
{ x: 500, kind: 'festival', color: 'var(--color-rose-400)', name: fest.name, tag: fest.role },
{ x: 770, kind: 'bloom', color: 'var(--color-clay-400)', name: cha.name, tag: cha.role },
{ x: 100, kind: 'ghost' },
{ x: 900, kind: 'ghost' },
] as const
// small pop-up caps for the ephemeral festival flower
const festivalCaps = [
{ dx: -18, dy: -58, r: 15 },
{ dx: 0, dy: -74, r: 20 },
{ dx: 20, dy: -60, r: 14 },
]
</script>
<template>
<div class="myc w-full">
<svg
viewBox="0 0 1000 600"
class="mx-auto h-auto w-full max-h-[50vh]"
role="img"
aria-label="Mycelial network diagram"
>
<defs>
<linearGradient id="skyGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="hsl(160 40% 10%)" stop-opacity="0" />
<stop offset="100%" stop-color="hsl(160 45% 12%)" stop-opacity="0.5" />
</linearGradient>
<linearGradient id="soilGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="hsl(28 40% 10%)" stop-opacity="0.55" />
<stop offset="100%" stop-color="hsl(28 45% 6%)" stop-opacity="0.15" />
</linearGradient>
<filter id="soft" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" />
</filter>
</defs>
<!-- soil band -->
<rect x="0" :y="GROUND" width="1000" :height="600 - GROUND" fill="url(#soilGrad)" />
<rect x="0" y="0" width="1000" :height="GROUND" fill="url(#skyGrad)" />
<line
x1="0"
:y1="GROUND"
x2="1000"
:y2="GROUND"
stroke="var(--color-pine-500)"
stroke-opacity="0.35"
stroke-dasharray="2 6"
/>
<!-- mycelium threads (glow underlay + crisp line + travelling pulse) -->
<g fill="none" stroke-linecap="round">
<path
v-for="(e, i) in edges"
:key="`glow-${i}`"
:d="thread(e, i)"
stroke="var(--color-pine-500)"
stroke-opacity="0.18"
stroke-width="6"
filter="url(#soft)"
/>
<path
v-for="(e, i) in edges"
:key="`line-${i}`"
:d="thread(e, i)"
stroke="var(--color-pine-400)"
stroke-opacity="0.5"
stroke-width="1.4"
/>
<path
v-for="(e, i) in edges"
:key="`pulse-${i}`"
class="myc-pulse"
:style="{ animationDelay: `${(i % 6) * 0.5}s` }"
:d="thread(e, i)"
stroke="var(--color-amber-300)"
stroke-width="2.2"
stroke-linecap="round"
/>
</g>
<!-- mycelium nodes -->
<g>
<circle
v-for="(n, i) in nodes"
:key="`node-${i}`"
class="myc-node"
:style="{ animationDelay: `${(i % 5) * 0.6}s` }"
:cx="n[0]"
:cy="n[1]"
r="4"
fill="var(--color-pine-300)"
/>
</g>
<!-- surface flowers -->
<g v-for="(f, i) in flowers" :key="`flower-${i}`">
<!-- permanent bloom (city / farm) -->
<template v-if="f.kind === 'bloom'">
<path
:d="`M ${f.x} ${GROUND} C ${f.x - 6} ${GROUND - 26}, ${f.x + 6} ${GROUND - 44}, ${f.x} ${GROUND - 70}`"
fill="none"
:stroke="f.color"
stroke-opacity="0.9"
stroke-width="5"
stroke-linecap="round"
/>
<path
:d="`M ${f.x - 42} ${GROUND - 70} Q ${f.x} ${GROUND - 116} ${f.x + 42} ${GROUND - 70} Q ${f.x} ${GROUND - 82} ${f.x - 42} ${GROUND - 70} Z`"
:fill="f.color"
class="myc-cap"
:style="{ animationDelay: `${i * 0.4}s` }"
/>
<circle :cx="f.x - 16" :cy="GROUND - 88" r="3" fill="hsl(0 0% 100% / 0.55)" />
<circle :cx="f.x + 12" :cy="GROUND - 92" r="2.4" fill="hsl(0 0% 100% / 0.45)" />
</template>
<!-- ephemeral festival cluster (SolLunar) -->
<template v-else-if="f.kind === 'festival'">
<path
:d="`M ${f.x} ${GROUND} C ${f.x - 6} ${GROUND - 20}, ${f.x + 6} ${GROUND - 34}, ${f.x} ${GROUND - 46}`"
fill="none"
:stroke="f.color"
stroke-opacity="0.8"
stroke-width="4"
stroke-linecap="round"
stroke-dasharray="3 5"
/>
<g class="myc-festival" :style="{ animationDelay: `${i * 0.3}s` }">
<template v-for="(c, ci) in festivalCaps" :key="ci">
<path
:d="`M ${f.x + c.dx - c.r} ${GROUND + c.dy} Q ${f.x + c.dx} ${GROUND + c.dy - c.r * 1.1} ${f.x + c.dx + c.r} ${GROUND + c.dy} Q ${f.x + c.dx} ${GROUND + c.dy - c.r * 0.28} ${f.x + c.dx - c.r} ${GROUND + c.dy} Z`"
:fill="f.color"
:fill-opacity="0.75 + ci * 0.08"
/>
</template>
<!-- solunar mark: sun + moon spark -->
<circle :cx="f.x" :cy="GROUND - 96" r="3.4" fill="var(--color-amber-300)" />
<circle :cx="f.x + 9" :cy="GROUND - 100" r="2" fill="var(--color-indigo-300)" />
</g>
</template>
<!-- ghost future flower -->
<template v-else>
<path
:d="`M ${f.x} ${GROUND} C ${f.x - 5} ${GROUND - 22}, ${f.x + 5} ${GROUND - 38}, ${f.x} ${GROUND - 60}`"
fill="none"
stroke="var(--color-sand-dim)"
stroke-opacity="0.3"
stroke-dasharray="3 5"
stroke-width="4"
stroke-linecap="round"
/>
<path
:d="`M ${f.x - 26} ${GROUND - 60} Q ${f.x} ${GROUND - 90} ${f.x + 26} ${GROUND - 60} Q ${f.x} ${GROUND - 68} ${f.x - 26} ${GROUND - 60} Z`"
fill="none"
stroke="var(--color-sand-dim)"
stroke-opacity="0.35"
stroke-dasharray="3 5"
stroke-width="1.6"
/>
<g class="myc-plus" :style="{ animationDelay: `${i * 0.7}s` }">
<circle :cx="f.x" :cy="GROUND - 76" r="9" fill="hsl(160 24% 9%)" stroke="var(--color-amber-400)" stroke-opacity="0.6" />
<path
:d="`M ${f.x - 4} ${GROUND - 76} h 8 M ${f.x} ${GROUND - 80} v 8`"
stroke="var(--color-amber-400)"
stroke-width="1.6"
stroke-linecap="round"
/>
</g>
</template>
<!-- labels for the three named entities -->
<template v-if="f.kind !== 'ghost'">
<text
:x="f.x"
:y="f.kind === 'festival' ? GROUND - 122 : GROUND - 132"
text-anchor="middle"
class="myc-label-name"
:fill="f.color"
>
{{ f.name }}
</text>
<text
:x="f.x"
:y="f.kind === 'festival' ? GROUND - 142 : GROUND - 152"
text-anchor="middle"
class="myc-label-tag"
>
{{ f.tag?.toUpperCase() }}
</text>
</template>
</g>
<!-- captions -->
<text x="500" y="34" text-anchor="middle" class="myc-zone">ABOVE GROUND · WHAT PEOPLE EXPERIENCE</text>
<text x="500" y="588" text-anchor="middle" class="myc-zone">ARCHIPELAGO · THE SHARED BACKBONE BENEATH EVERY NODE</text>
</svg>
<div class="mt-6 grid gap-4 text-sm sm:grid-cols-2">
<div class="flex items-start gap-3">
<span class="mt-0.5 text-pine-300"><Icon name="Layers" class="size-5" /></span>
<p class="text-sand-dim">
<span class="font-medium text-sand">Archipelago is the mycelium.</span>
One shared backbone runs unseen beneath every entity members feel it, they dont see it.
</p>
</div>
<div class="flex items-start gap-3">
<span class="mt-0.5 text-amber-300"><Icon name="Sparkles" class="size-5" /></span>
<p class="text-sand-dim">
<span class="font-medium text-sand">Flowers are cheap to grow.</span>
A city hub, a travelling festival, a farm and every new bloom is just another fruiting body.
</p>
</div>
</div>
</div>
</template>
<style scoped>
.myc-label-name {
font-family: var(--font-display);
font-size: 17px;
font-weight: 600;
}
.myc-label-tag {
font-size: 10px;
letter-spacing: 0.22em;
fill: var(--color-sand-dim);
}
.myc-zone {
font-size: 11px;
letter-spacing: 0.24em;
fill: var(--color-sand-dim);
opacity: 0.7;
}
.myc-pulse {
stroke-dasharray: 6 220;
animation: myc-flow 3.2s linear infinite;
}
@keyframes myc-flow {
from {
stroke-dashoffset: 226;
}
to {
stroke-dashoffset: 0;
}
}
.myc-node {
transform-box: fill-box;
transform-origin: center;
animation: myc-breathe 3s ease-in-out infinite;
}
@keyframes myc-breathe {
0%,
100% {
opacity: 0.5;
transform: scale(0.85);
}
50% {
opacity: 1;
transform: scale(1.25);
}
}
.myc-cap {
transform-box: fill-box;
transform-origin: bottom center;
animation: myc-sway 6s ease-in-out infinite;
}
@keyframes myc-sway {
0%,
100% {
transform: rotate(-1.5deg);
}
50% {
transform: rotate(1.5deg);
}
}
/* festival cluster gently floats + fades — it's ephemeral */
.myc-festival {
transform-box: fill-box;
transform-origin: bottom center;
animation: myc-float 4.5s ease-in-out infinite;
}
@keyframes myc-float {
0%,
100% {
transform: translateY(0);
opacity: 0.85;
}
50% {
transform: translateY(-4px);
opacity: 1;
}
}
.myc-plus {
transform-box: fill-box;
transform-origin: center;
animation: myc-pop 3.4s ease-in-out infinite;
}
@keyframes myc-pop {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.myc-pulse,
.myc-node,
.myc-cap,
.myc-festival,
.myc-plus {
animation: none;
}
.myc-pulse {
display: none;
}
}
</style>

View file

@ -1,13 +0,0 @@
<script setup lang="ts">
/** Marks invented figures/copy so nothing placeholder ships unnoticed. */
withDefaults(defineProps<{ label?: string }>(), { label: 'placeholder' })
</script>
<template>
<span
class="inline-flex items-center gap-1 rounded-full border border-amber-400/30 bg-amber-400/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-amber-300"
title="This value is a placeholder — edit src/deck/data.ts"
>
{{ label }}
</span>
</template>

View file

@ -1,39 +0,0 @@
<script setup lang="ts">
/** Shared slide chrome: consistent padding, kicker, title, subtitle + body slot. */
defineProps<{
kicker?: string
title?: string
subtitle?: string
center?: boolean
}>()
</script>
<template>
<section
class="mx-auto flex h-full w-full max-w-6xl flex-col px-8 py-14 sm:px-14 lg:px-20"
:class="center ? 'items-center justify-center text-center' : 'justify-center'"
>
<p
v-if="kicker"
class="mb-3 text-xs font-semibold uppercase tracking-[0.25em] text-amber-400"
>
{{ kicker }}
</p>
<h2
v-if="title"
class="font-display text-3xl font-semibold leading-tight text-sand sm:text-4xl lg:text-5xl"
>
{{ title }}
</h2>
<p
v-if="subtitle"
class="mt-4 max-w-2xl text-base text-sand-dim sm:text-lg"
:class="center ? 'mx-auto' : ''"
>
{{ subtitle }}
</p>
<div :class="[title || kicker ? 'mt-10' : '', 'w-full']">
<slot />
</div>
</section>
</template>

View file

@ -1,530 +0,0 @@
/**
* ARCHIPELAGO pitch deck content & figures.
*
* Single source of truth for the deck no slide hard-codes a number or name.
*
* The financials are REAL, from SolLunarSociety.md, and belong exclusively to
* the first node SolLunar Society (Austin). Archipelago (Lab 484), Pop-up
* Festivals and Chateau du Faune are narrative/vision only and have no
* standalone numbers. P&L totals are recomputed from line items; the doc's
* own summary totals didn't reconcile (kept in `pnlDocSummary`).
*
* Archipelago is the shared backbone open hardware + sovereign software (the
* "mycelium"). Three surface entities run on top of it:
* SolLunar Society city members' club, Austin (the funded first node)
* Pop-up Festivals travelling gatherings (e.g. Solarpunk Summit)
* Chateau du Faune regenerative farm center
*
* Currency: all figures are USD unless noted.
*/
export const PLACEHOLDER = true
export const brand = {
name: 'Archipelago',
short: 'Archipelago',
// a connected network of islands — one backbone, many places.
tagline: 'One network. Three ways to experience it.',
subtagline:
'A sovereign network on the Archipelago backbone. The first node — SolLunar Society, a members club in Austin — is live, modeled and raising now; the festival and the farm are next.',
firstNode: 'SolLunar Society · Austin, TX',
contactEmail: 'invest@sollunar.example', // PLACEHOLDER
// Seam for later: an LNbits instance + the founder's nostr npub power the
// "contribute" tiers. Left null until wired (see src/features/{lnbits,nostr}).
lnbitsBaseUrl: null as string | null,
nostrNpub: null as string | null,
}
/** Big-picture framing for the vision slide (from the SolLunar Society plan). */
export const problem = {
headline: 'Transformation fades without the infrastructure to sustain it.',
points: [
'People leave transformational events inspired — then return to environments that dont support the change.',
'Theres no lasting home for sovereignty-minded community: dreamers, creatives, technologists, regenerative living.',
'Peak experiences dont compound without ongoing relationships, education, and a place to practice.',
],
thesis:
'SolLunar Society is the missing infrastructure — a community hub where the work begun at transformational events continues through membership, tenants, events and regenerative living.',
}
export type Accent = 'pine' | 'indigo' | 'rose' | 'clay'
export type Entity = {
id: string
name: string
role: string // short label, e.g. "City center"
kind: string
accent: Accent
layer: 'backbone' | 'surface'
example?: string // for the generic festival entity
blurb: string
traits: string[] // what makes THIS entity unique
spaces: string[]
revenue: string[] // how this entity earns
icon: string
}
/**
* The entities. Archipelago is the backbone; the other three are the
* experiences that run on it. PLACEHOLDER names & details.
*/
export const entities: Entity[] = [
{
id: 'sollunar',
name: 'SolLunar Society',
role: 'City center · the first node',
kind: 'Members club & community hub · Austin, TX',
accent: 'indigo',
layer: 'surface',
blurb:
'The first node and the one were raising for: a members club in Austin where sovereignty-minded community — tenants, events, memberships and regenerative living — comes together year-round on the Archipelago stack.',
traits: [
'Live, financially-modeled first node (real P&L)',
'Five founding tenants + a three-tier membership',
'Bitcoin ATM, vending & the full member ecosystem on site',
],
spaces: ['Retail space', 'Event space', 'Private lounge', 'Co-working', 'Yellow House hostel & data center'],
revenue: ['Memberships', 'Tenants', 'Events & venue buyouts'],
icon: 'Building2',
},
{
id: 'festivals',
name: 'Pop-up Festivals',
role: 'Travelling gatherings',
kind: 'Travelling · ephemeral',
accent: 'rose',
layer: 'surface',
example: 'Solar Punk Summit',
blurb:
'The travelling node: a repeatable festival format — like Solar Punk Summit — that deploys the entire stack anywhere for a few days, then packs down and moves on.',
traits: [
'Deploys the full stack from a mobile kit — anywhere',
'Proves the network is portable, not tied to a building',
'Turns every festival into a member-onboarding event',
],
spaces: ['Main stage', 'Mobile exchange kiosk', 'Mesh & power rig', 'Camp & vendors', 'Workshop tents'],
revenue: ['Ticket sales', 'Vendor & bar revenue', 'Sponsorships'],
icon: 'Tent',
},
{
id: 'chateau',
name: 'Chateau du Faune',
role: 'Farm center',
kind: 'Off-grid · regenerative farm',
accent: 'clay',
layer: 'surface',
blurb:
'The rural node: an off-grid regenerative farm hosting retreats, artist & developer residencies, and the networks living lab — home to the alpacas.',
traits: [
'Proves the stack runs off-grid, anywhere',
'Premium revenue from retreats & residencies',
'Sponsor-an-alpaca connects small backers to the land',
],
spaces: ['Retreat lodging', 'Residency studios', 'Event barn', 'Alpaca paddocks', 'Regenerative gardens'],
revenue: ['Lodging & retreats', 'Residencies', 'Experiences & venue hire'],
icon: 'Trees',
},
{
id: 'archipelago',
name: 'Archipelago',
role: 'Shared backbone',
kind: 'Open hardware & sovereign software',
accent: 'pine',
layer: 'backbone',
blurb:
'The invisible foundation every entity runs on: open hardware and self-hosted software — the mycelium beneath the whole network. Build it once, and every place plugs in.',
traits: [
'Powers all three entities from one shared stack',
'Open bill of materials — replicable anywhere',
'Forkable and sovereign — no platform lock-in',
],
spaces: ['Edge servers', 'Mesh routers', 'Exchange kiosks', 'NFC / membership', 'Self-hosted apps'],
revenue: ['Network & kiosk fees', 'Payment fees', 'Replication licensing'],
icon: 'Cpu',
},
]
export const entityById = (id: string) => entities.find((e) => e.id === id)!
/** The three experiences (everything except the backbone). */
export const surfaceEntities = entities.filter((e) => e.layer === 'surface')
/**
* The user ecosystem every location shares the "full experience regardless
* of location" promise. This is what a member touches at any node.
*/
export const userEcosystem = [
{
title: 'One membership card',
body: 'A single NFC card is your identity, access and wallet everywhere — the society, a festival, the farm.',
icon: 'CreditCard',
},
{
title: 'Money exchange kiosk',
body: 'On-ramp cash to Bitcoin and back at any location. The same kiosk, everywhere.',
icon: 'Coins',
},
{
title: 'Pay by Lightning',
body: 'Tap to pay for drinks, tickets, rooms and experiences — instant, low-fee settlement.',
icon: 'Zap',
},
{
title: 'The Archipelago app',
body: 'Book spaces, buy tickets, find events and manage membership from one self-hosted app.',
icon: 'Sparkles',
},
{
title: 'Portable identity',
body: 'Your nostr identity and social graph travel with you between every node and beyond.',
icon: 'KeyRound',
},
{
title: 'Member perks',
body: 'Access, discounts and priority booking that follow you across the whole network.',
icon: 'HeartHandshake',
},
]
/** The Archipelago backbone / core stack (the infrastructure pillars). */
export const tech = [
{
title: 'Decentralized mesh network',
body: 'Community-owned routers and mesh kits blanket each location — connectivity no single provider can switch off.',
icon: 'Wifi',
},
{
title: 'Open hardware',
body: 'Servers, routers, exchange kiosks and NFC hardware from a documented, replicable bill of materials.',
icon: 'Cpu',
},
{
title: 'Sovereign software',
body: 'Self-hosted services on a stack we control — no platform lock-in, forkable by any future node.',
icon: 'Code',
},
{
title: 'Bitcoin & Lightning',
body: 'Instant, low-fee settlement at kiosks and NFC points. Value stays in the network, not the middleman.',
icon: 'Zap',
},
{
title: 'Nostr identity & comms',
body: 'Portable identity, messaging and social layer that travels with members across every node.',
icon: 'KeyRound',
},
{
title: 'Replicable blueprint',
body: 'Every node ships its build docs. Proven once, the whole stack redeploys anywhere in the world.',
icon: 'Globe',
},
]
/*
SolLunar Society the first node's REAL business (from
SolLunarSociety.md). All figures below are SolLunar Society's
only; Archipelago (Lab 484), Festivals and Chateau have no
standalone numbers yet. Lab 484's economics appear here as
revenue lines INTO SolLunar (~$16k/mo) don't double-count.
*/
/** Ownership & partnership of the SolLunar Society JV. */
export type Partner = {
name: string
short?: string
equity: number // 0..1
accent: Accent | 'amber'
role: string
responsibilities: string[]
note?: string
}
export const partnership = {
valuation: 4_000_000,
raiseAmount: 400_000,
equityOffered: 0.1,
partners: [
{
name: 'SolLunar Punk Society',
short: 'SPS',
equity: 0.45,
accent: 'indigo',
role: 'Space & community',
responsibilities: [
'Curate & decorate the space',
'Lease to tenants · insurance',
'Outbound payments · SOPs',
'Marketing & community management',
],
},
{
name: 'Lab 484',
equity: 0.45,
accent: 'pine',
role: 'Tech & operations',
responsibilities: [
'Operate the spaces & staff',
'All IT / infrastructure (Archipelago)',
'Inbound payments · bootcamps',
'Tenant & investor relations',
],
note: 'Builds & operates the Archipelago backbone — and is itself a tenant + event driver (~$16k/mo of the revenue below).',
},
{
name: 'Investors',
equity: 0.1,
accent: 'amber',
role: 'Capital',
responsibilities: ['$400,000 for 10% equity'],
},
] as Partner[],
}
/** The five founding tenants inside SolLunar Society. */
export const tenants = [
{
name: 'Lab 484',
icon: 'Cpu',
blurb:
'Research lab & startup cluster building a “second internet” — Bitcoin, mesh networks, Nostr, e-cash and P2P. User-owned hardware, software and data.',
},
{
name: 'Solarpunk Summit',
icon: 'Tent',
blurb:
'Transformational platform & community builder — immersive experiences, educational programming and regenerative culture.',
},
{
name: 'Noderunners Shop',
icon: 'Router',
blurb:
'Retail for AI computers, mesh gear, Bitcoin nodes and repair. Open-source, secure laptops, phones and servers.',
},
{
name: 'Divine Provisions',
icon: 'Coins',
blurb:
'A food co-op for low-cost, organic, local groceries — offered as a member service at the building entrance.',
},
{
name: 'Kitchen 484',
icon: 'Sparkles',
blurb:
'Community food truck — organic, seed-oil-free, locally sourced. Runs even when disconnected from the internet.',
},
]
/** Membership tiers (customer product / recurring revenue). */
export type Membership = {
name: string
price: number // monthly
target: number // member-count target
blurb: string
perks: string[]
featured?: boolean
}
export const memberships: Membership[] = [
{
name: 'Solarpunk Society',
price: 50,
target: 150,
blurb: 'Events access.',
perks: ['Member events', 'Amenities, vending & Bitcoin ATM', 'Access to tenants'],
},
{
name: 'Cypherpunk Commons',
price: 350,
target: 50,
featured: true,
blurb: 'Full access, all locations.',
perks: [
'Coworking with the Lab 484 team',
'All locations · tech support',
'Bitcoin ATM, vending, coffee & tea',
'Full tenant & amenity access',
],
},
{
name: 'Lunarpunk Locals',
price: 150,
target: 100,
blurb: 'Coworking days.',
perks: ['Coworking days at SolLunar', 'Access to tenants', 'Amenities, vending & Bitcoin ATM'],
},
]
/** Comparable Austin memberships, for pricing context. */
export const membershipComparables = [
{ name: 'Soho House', price: 350 },
{ name: 'Bond Collective', price: 300 },
{ name: 'Bathe', price: 200 },
{ name: 'Stardust Garage', price: 155 },
{ name: 'Submersive', price: 41 },
]
/**
* SolLunar Society monthly P&L. Line items are from the doc; category totals
* are RECOMPUTED from them (the doc's summary totals didn't reconcile see
* `pnlDocSummary`). Flagged oddity: Lunarpunk Locals income ($1,500) doesn't
* match its "100 × $150" annotation.
*/
export type PnlItem = { label: string; amount: number; note?: string }
export type PnlGroup = {
id: string
title: string
icon: string
accent: Accent
kind: 'revenue' | 'expense'
items: PnlItem[]
}
export const pnlGroups: PnlGroup[] = [
{
id: 'memberships',
title: 'Memberships',
icon: 'CreditCard',
accent: 'indigo',
kind: 'revenue',
items: [
{ label: 'Cypherpunk Commons', amount: 17_500, note: '50 × $350' },
{ label: 'Solarpunk Society', amount: 7_500, note: '150 × $50' },
{ label: 'Lunarpunk Locals', amount: 1_500, note: '$150/mo · count TBD' },
{ label: 'Vending machine sales', amount: 5_000 },
{ label: 'Promo / B2B network sales', amount: 5_000 },
],
},
{
id: 'events',
title: 'Events',
icon: 'PartyPopper',
accent: 'rose',
kind: 'revenue',
items: [
{ label: 'Lab 484 tech events', amount: 8_000 },
{ label: 'Lab 484 bootcamps', amount: 6_000 },
{ label: 'Venue buyouts', amount: 6_400, note: '$400/hr · 4hr min · 4×/mo' },
{ label: 'SolLunar Society events', amount: 1_500, note: '$50 weekend passes' },
],
},
{
id: 'tenants',
title: 'Tenants',
icon: 'Building2',
accent: 'pine',
kind: 'revenue',
items: [
{ label: 'Solarpunk Summit', amount: 2_000 },
{ label: 'Lab 484', amount: 2_000 },
{ label: 'Divine Provisions', amount: 1_500 },
{ label: 'Noderunners Shop', amount: 1_500 },
],
},
{
id: 'expenses',
title: 'Expenses',
icon: 'Coins',
accent: 'clay',
kind: 'expense',
items: [
{ label: 'Staff', amount: 22_500, note: '4 staff' },
{ label: 'Rent', amount: 6_500, note: '$4,500 + triple net' },
{ label: 'Overhead', amount: 3_000 },
{ label: 'Utilities', amount: 2_000, note: 'electric, water, trash' },
],
},
]
export const groupTotal = (g: PnlGroup) => g.items.reduce((s, i) => s + i.amount, 0)
const sumKind = (k: 'revenue' | 'expense') =>
pnlGroups.filter((g) => g.kind === k).reduce((s, g) => s + groupTotal(g), 0)
/** Recomputed monthly/yearly economics for SolLunar Society. */
export const pnl = {
monthlyRevenue: sumKind('revenue'),
monthlyExpenses: sumKind('expense'),
monthlyProfit: sumKind('revenue') - sumKind('expense'),
yearlyProfit: (sumKind('revenue') - sumKind('expense')) * 12,
}
/** What the source doc's summary table stated (did NOT reconcile with line items). */
export const pnlDocSummary = { revenue: 71_400, expenses: 38_500, profit: 32_900, yearlyProfit: 394_800 }
/** The building & spaces. */
export const buildings = [
{
name: 'SolLunar Society',
rooms: ['Retail space', 'Event space', 'Private office', 'Private lounge'],
},
{
name: 'Lab 484 · Yellow House 1',
rooms: ['Hostel room · $40/night', 'Healer room', 'Coworking', 'Meeting room', 'Outdoor'],
},
{
name: 'Lab 484 · Yellow House 2',
rooms: ['VIP guest room', 'Office', 'Data center', 'Outdoor'],
},
]
/** The investor ask. */
export const investorAsk = {
amount: 400_000,
equity: 0.1,
valuation: 4_000_000,
scenarios: [0.1, 0.25], // profit-share scenarios on the recomputed yearly profit
}
/** Roadmap / replication story. SolLunar Society first, then the network. */
export const roadmap = [
{
phase: 'Phase 0',
title: 'Raise & open SolLunar',
when: 'Now',
body: 'Close the $400k round, sign the lease, fit out the space and onboard the founding tenants.',
},
{
phase: 'Phase 1',
title: 'Operate & prove the model',
when: '~012 mo',
body: 'Run memberships, events and tenants in Austin; validate the P&L publicly on the Archipelago stack.',
},
{
phase: 'Phase 2',
title: 'Add the festival',
when: '~1218 mo',
body: 'Deploy the same stack to a travelling Solarpunk Summit — proving the network is portable, not tied to a building.',
},
{
phase: 'Phase 3',
title: 'Add the farm & replicate',
when: '18 mo+',
body: 'Open Chateau du Faune and publish the replication kit — new nodes plug into the same backbone anywhere.',
},
]
/** Formatting helpers used across slides. Millions render as $X.XXMM. */
export const fmtUsd = (n: number) => {
if (n >= 1_000_000)
return `$${(n / 1_000_000).toLocaleString('en-US', { maximumFractionDigits: 2 })}MM`
if (n >= 1000)
return `$${(n / 1000).toLocaleString('en-US', { maximumFractionDigits: n % 1000 === 0 ? 0 : 1 })}k`
return `$${n.toLocaleString('en-US')}`
}
export const fmtUsdFull = (n: number) => `$${n.toLocaleString('en-US')}`
/** Tailwind class helpers keyed by entity accent — keeps slides declarative. */
export const accentText: Record<Accent, string> = {
pine: 'text-pine-300',
indigo: 'text-indigo-300',
rose: 'text-rose-300',
clay: 'text-clay-400',
}
export const accentDot: Record<Accent, string> = {
pine: 'bg-pine-400',
indigo: 'bg-indigo-400',
rose: 'bg-rose-400',
clay: 'bg-clay-400',
}
export const accentRing: Record<Accent, string> = {
pine: 'border-pine-500/30 bg-pine-500/5',
indigo: 'border-indigo-500/30 bg-indigo-500/5',
rose: 'border-rose-500/30 bg-rose-500/5',
clay: 'border-clay-400/30 bg-clay-400/5',
}

View file

@ -1,82 +0,0 @@
/**
* Gallery auto-population.
*
* Drop image files into these folders and they appear in the deck gallery
* automatically no code changes, no manifest to edit:
*
* src/deck/gallery/city/ City (SolLunar Punk Society)
* src/deck/gallery/farm/ Farm (Chateau du Faune)
* src/deck/gallery/festival/ Festival (Pop-up Festivals)
*
* Supported: jpg jpeg png webp avif gif svg. Images show sorted by filename,
* so prefix with 01-, 02-, to control order. The seeded *-placeholder.svg
* files are just demo art delete them once you add real photos.
*
* Under the hood this uses Vite's import.meta.glob, which must take a literal
* pattern (no variables) hence one call per folder.
*/
import { entityById, type Accent } from './data'
type GlobMap = Record<string, string>
// NB: import.meta.glob is statically analysed by Vite — its options must be an
// inline object literal (not a shared const), so the object is repeated below.
const city = import.meta.glob('./gallery/city/*.{jpg,jpeg,png,webp,avif,gif,svg}', {
eager: true,
query: '?url',
import: 'default',
}) as GlobMap
const farm = import.meta.glob('./gallery/farm/*.{jpg,jpeg,png,webp,avif,gif,svg}', {
eager: true,
query: '?url',
import: 'default',
}) as GlobMap
const festival = import.meta.glob('./gallery/festival/*.{jpg,jpeg,png,webp,avif,gif,svg}', {
eager: true,
query: '?url',
import: 'default',
}) as GlobMap
export type GalleryImage = { src: string; name: string }
function toImages(map: GlobMap): GalleryImage[] {
return Object.entries(map)
.sort(([a], [b]) => a.localeCompare(b))
.map(([path, src]) => ({ src, name: path.split('/').pop()!.replace(/\.[^.]+$/, '') }))
}
export type GallerySection = {
id: 'city' | 'farm' | 'festival'
label: string
entityName: string
accent: Accent
folder: string
images: GalleryImage[]
}
export const gallerySections: GallerySection[] = [
{
id: 'city',
label: 'City',
entityName: entityById('sollunar').name,
accent: 'indigo',
folder: 'src/deck/gallery/city/',
images: toImages(city),
},
{
id: 'farm',
label: 'Farm',
entityName: entityById('chateau').name,
accent: 'clay',
folder: 'src/deck/gallery/farm/',
images: toImages(farm),
},
{
id: 'festival',
label: 'Festival',
entityName: entityById('festivals').name,
accent: 'rose',
folder: 'src/deck/gallery/festival/',
images: toImages(festival),
},
]

View file

@ -1,18 +0,0 @@
# Deck gallery — just drop images in
Each subfolder feeds one section of the Gallery slide. **Drop image files in and
they appear automatically** — no code to touch.
| Folder | Section | Entity |
| ----------- | -------- | --------------------- |
| `city/` | City | SolLunar Punk Society |
| `farm/` | Farm | Chateau du Faune |
| `festival/` | Festival | Pop-up Festivals |
- Supported formats: `jpg` `jpeg` `png` `webp` `avif` `gif` `svg`.
- Images display **sorted by filename** — prefix `01-`, `02-`, … to order them.
- The `*-placeholder.svg` files are demo art. **Delete them** once you add real photos.
- Wiring lives in `src/deck/gallery.ts` (Vite `import.meta.glob`). If you add a
brand-new format, extend the glob patterns there.
Recommended: web-optimized `.webp` under ~500 KB each for fast loading.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 891 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" width="800" height="800" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(256 78% 68%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="800" fill="hsl(160 28% 6%)"/>
<rect width="800" height="800" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(256 78% 68%)" opacity="0.12"/>
<circle cx="70" cy="730" r="90" fill="hsl(256 78% 68%)" opacity="0.10"/>
<text x="400" y="360" text-anchor="middle" fill="hsl(256 78% 68%)" font-size="46" font-weight="bold">Festival</text>
<text x="400" y="406" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 01</text>
<text x="400" y="444" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×800</text>
</svg>

Before

Width:  |  Height:  |  Size: 992 B

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 560" width="800" height="560" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(256 78% 68%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="560" fill="hsl(160 28% 6%)"/>
<rect width="800" height="560" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(256 78% 68%)" opacity="0.12"/>
<circle cx="70" cy="490" r="90" fill="hsl(256 78% 68%)" opacity="0.10"/>
<text x="400" y="240" text-anchor="middle" fill="hsl(256 78% 68%)" font-size="46" font-weight="bold">Festival</text>
<text x="400" y="286" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 02</text>
<text x="400" y="324" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×560</text>
</svg>

Before

Width:  |  Height:  |  Size: 992 B

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1040" width="800" height="1040" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(256 78% 68%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="1040" fill="hsl(160 28% 6%)"/>
<rect width="800" height="1040" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(256 78% 68%)" opacity="0.12"/>
<circle cx="70" cy="970" r="90" fill="hsl(256 78% 68%)" opacity="0.10"/>
<text x="400" y="480" text-anchor="middle" fill="hsl(256 78% 68%)" font-size="46" font-weight="bold">Festival</text>
<text x="400" y="526" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 03</text>
<text x="400" y="564" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×1040</text>
</svg>

Before

Width:  |  Height:  |  Size: 997 B

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 700" width="800" height="700" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(256 78% 68%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="700" fill="hsl(160 28% 6%)"/>
<rect width="800" height="700" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(256 78% 68%)" opacity="0.12"/>
<circle cx="70" cy="630" r="90" fill="hsl(256 78% 68%)" opacity="0.10"/>
<text x="400" y="310" text-anchor="middle" fill="hsl(256 78% 68%)" font-size="46" font-weight="bold">Festival</text>
<text x="400" y="356" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 04</text>
<text x="400" y="394" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×700</text>
</svg>

Before

Width:  |  Height:  |  Size: 992 B

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(256 78% 68%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="600" fill="hsl(160 28% 6%)"/>
<rect width="800" height="600" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(256 78% 68%)" opacity="0.12"/>
<circle cx="70" cy="530" r="90" fill="hsl(256 78% 68%)" opacity="0.10"/>
<text x="400" y="260" text-anchor="middle" fill="hsl(256 78% 68%)" font-size="46" font-weight="bold">Festival</text>
<text x="400" y="306" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 05</text>
<text x="400" y="344" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×600</text>
</svg>

Before

Width:  |  Height:  |  Size: 992 B

View file

@ -1,59 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import { brand, investorAsk, pnl, fmtUsd } from '../data'
const pct = (n: number) => Math.round(n * 100)
// annual profit-share per scenario, on the recomputed yearly profit
const shares = investorAsk.scenarios.map((s) => ({ equity: s, annual: Math.round(pnl.yearlyProfit * s) }))
</script>
<template>
<SlideFrame center>
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-amber-400">The ask</p>
<h2 class="mt-4 font-display text-4xl font-semibold text-sand sm:text-5xl">
{{ fmtUsd(investorAsk.amount) }} for {{ pct(investorAsk.equity) }}% of SolLunar Society
</h2>
<p class="mt-3 text-sand-dim">
{{ fmtUsd(investorAsk.valuation) }} valuation · the first node, open in Austin
</p>
<!-- profit-share scenarios -->
<div class="mt-8 flex flex-wrap items-stretch justify-center gap-4">
<div
v-for="s in shares"
:key="s.equity"
class="rounded-2xl border border-white/10 bg-white/[0.03] px-6 py-4"
>
<p class="text-xs uppercase tracking-wider text-sand-dim">{{ pct(s.equity) }}% share</p>
<p class="mt-1 font-display text-3xl font-bold text-sand">{{ fmtUsd(s.annual) }}</p>
<p class="text-xs text-sand-dim">/ yr profit</p>
</div>
<div class="rounded-2xl border border-pine-500/40 bg-pine-500/[0.07] px-6 py-4">
<p class="text-xs uppercase tracking-wider text-pine-300">Node profit</p>
<p class="mt-1 font-display text-3xl font-bold text-sand">{{ fmtUsd(pnl.yearlyProfit) }}</p>
<p class="text-xs text-sand-dim">/ yr (recomputed)</p>
</div>
</div>
<div class="mt-9 flex flex-col items-center gap-3 sm:flex-row">
<a
:href="`mailto:${brand.contactEmail}`"
class="inline-flex items-center gap-2 rounded-full bg-amber-400 px-6 py-3 font-semibold text-ink transition hover:bg-amber-300"
>
<Icon name="Mail" class="size-5" />
{{ brand.contactEmail }}
</a>
<button
class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-6 py-3 font-semibold text-sand transition hover:bg-white/10"
>
<Icon name="CalendarClock" class="size-5" />
Book an investor call
</button>
</div>
<p class="mt-9 max-w-xl text-sm text-sand-dim">
Prove the model in Austin, then replicate it on Archipelago the festival and the farm are next.
</p>
</SlideFrame>
</template>

View file

@ -1,32 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import { userEcosystem, surfaceEntities as entities } from '../data'
</script>
<template>
<SlideFrame
kicker="One membership, everywhere"
title="The same full experience, at every location"
subtitle="Wherever you are in the network — city, festival or farm — the ecosystem is identical. Your card, your money, your identity travel with you."
>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="item in userEcosystem"
:key="item.title"
class="rounded-2xl border border-white/10 bg-white/[0.03] p-5"
>
<span class="grid size-10 place-items-center rounded-xl bg-amber-400/10 text-amber-300">
<Icon :name="item.icon" class="size-5" />
</span>
<h3 class="mt-4 font-display text-lg font-semibold text-sand">{{ item.title }}</h3>
<p class="mt-1.5 text-sm leading-relaxed text-sand-dim">{{ item.body }}</p>
</div>
</div>
<div class="mt-6 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-xs text-sand-dim">
<span>Works identically at</span>
<span v-for="e in entities" :key="e.id" class="font-medium text-sand">{{ e.name }}</span>
</div>
</SlideFrame>
</template>

View file

@ -1,63 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import { surfaceEntities, entityById, accentText, accentRing } from '../data'
const archipelago = entityById('archipelago')
</script>
<template>
<SlideFrame
kicker="The collaboration"
title="Three experiences, one backbone"
subtitle="One is funded and opening now — SolLunar Society in Austin. The festival and the farm follow, on the same Archipelago stack."
>
<div class="grid gap-5 md:grid-cols-3">
<div
v-for="e in surfaceEntities"
:key="e.id"
class="relative flex flex-col rounded-2xl border p-6"
:class="e.id === 'sollunar' ? 'border-indigo-500/60 bg-indigo-500/[0.08]' : accentRing[e.accent]"
>
<span
v-if="e.id === 'sollunar'"
class="absolute -top-3 left-6 rounded-full bg-indigo-400 px-3 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink"
>
Raising now
</span>
<div class="flex items-center gap-3">
<span class="grid size-11 place-items-center rounded-xl bg-white/5" :class="accentText[e.accent]">
<Icon :name="e.icon" class="size-6" />
</span>
<div>
<h3 class="font-display text-lg font-semibold leading-tight text-sand">{{ e.name }}</h3>
<p class="text-xs uppercase tracking-wider" :class="accentText[e.accent]">{{ e.role }}</p>
</div>
</div>
<p class="mt-4 flex-1 text-sm leading-relaxed text-sand-dim">{{ e.blurb }}</p>
<div class="mt-5 flex items-center gap-2 border-t border-white/10 pt-4 text-xs text-sand-dim">
<Icon :name="e.id === 'sollunar' ? 'Check' : 'Layers'" class="size-4" :class="e.id === 'sollunar' ? 'text-indigo-300' : 'text-amber-300'" />
{{ e.id === 'sollunar' ? 'Live P&amp;L · this raise' : 'Vision · on the roadmap' }}
</div>
</div>
</div>
<!-- the shared backbone beneath all three -->
<div class="mt-5 flex items-center gap-4 rounded-2xl border p-5" :class="accentRing[archipelago.accent]">
<span class="grid size-11 shrink-0 place-items-center rounded-xl bg-white/5" :class="accentText[archipelago.accent]">
<Icon :name="archipelago.icon" class="size-6" />
</span>
<div class="min-w-0 flex-1">
<h3 class="font-display text-lg font-semibold text-sand">
{{ archipelago.name }}
<span class="ml-1 text-xs font-normal uppercase tracking-wider" :class="accentText[archipelago.accent]">
· {{ archipelago.role }}
</span>
</h3>
<p class="text-sm text-sand-dim">{{ archipelago.blurb }}</p>
</div>
</div>
</SlideFrame>
</template>

View file

@ -1,92 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import Lightbox from '../components/Lightbox.vue'
import { gallerySections } from '../gallery'
import { accentText, accentRing } from '../data'
const activeId = ref(gallerySections[0].id)
const active = computed(() => gallerySections.find((s) => s.id === activeId.value)!)
// lightbox index into the active section's images (null = closed)
const lightboxIndex = ref<number | null>(null)
function selectTab(id: (typeof gallerySections)[number]['id']) {
activeId.value = id
lightboxIndex.value = null
}
</script>
<template>
<SlideFrame
kicker="See it"
title="Gallery"
subtitle="Photography from each entity. Drop images into the matching folder and they appear here automatically."
>
<!-- section tabs -->
<div class="flex flex-wrap gap-2">
<button
v-for="s in gallerySections"
:key="s.id"
class="flex items-center gap-2 rounded-full border px-4 py-2 text-sm transition"
:class="
activeId === s.id
? [accentRing[s.accent], accentText[s.accent]]
: 'border-white/10 bg-white/[0.03] text-sand-dim hover:border-white/25 hover:text-sand'
"
:aria-pressed="activeId === s.id"
@click="selectTab(s.id)"
>
<span class="font-medium">{{ s.label }}</span>
<span class="text-xs opacity-70">{{ s.entityName }}</span>
<span class="rounded-full bg-white/10 px-1.5 text-[11px] tabular-nums">{{ s.images.length }}</span>
</button>
</div>
<!-- masonry grid (scrolls within the slide if tall) -->
<div class="mt-6 max-h-[56vh] overflow-y-auto pr-1 [scrollbar-width:thin]">
<div v-if="active.images.length" class="gap-3 [column-fill:_balance] columns-2 sm:columns-3 lg:columns-4">
<button
v-for="(img, i) in active.images"
:key="img.src"
class="group relative mb-3 block w-full break-inside-avoid overflow-hidden rounded-xl border border-white/10 bg-white/[0.03]"
:aria-label="`Open ${img.name}`"
@click="lightboxIndex = i"
>
<img
:src="img.src"
:alt="img.name"
loading="lazy"
class="w-full object-cover transition duration-300 group-hover:scale-[1.03] group-hover:brightness-110"
/>
<span
class="pointer-events-none absolute inset-0 flex items-end justify-end bg-gradient-to-t from-black/50 to-transparent p-2 opacity-0 transition group-hover:opacity-100"
>
<span class="grid size-8 place-items-center rounded-full bg-white/15 text-sand backdrop-blur">
<Icon name="Expand" class="size-4" />
</span>
</span>
</button>
</div>
<!-- empty state -->
<div
v-else
class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-white/15 px-6 py-16 text-center"
>
<span class="grid size-12 place-items-center rounded-xl bg-white/5" :class="accentText[active.accent]">
<Icon name="Images" class="size-6" />
</span>
<p class="mt-4 text-sand">No {{ active.label }} photos yet.</p>
<p class="mt-1 max-w-md text-sm text-sand-dim">
Drop images into
<code class="rounded bg-white/10 px-1.5 py-0.5 text-xs text-sand">{{ active.folder }}</code>
and theyll appear here automatically.
</p>
</div>
</div>
<Lightbox v-model="lightboxIndex" :images="active.images" />
</SlideFrame>
</template>

View file

@ -1,58 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import { memberships, membershipComparables, fmtUsd } from '../data'
// display low high price
const tiers = [...memberships].sort((a, b) => a.price - b.price)
</script>
<template>
<SlideFrame
kicker="SolLunar Society · memberships"
title="Three ways to belong"
subtitle="Recurring membership is the core of the model — priced right for the Austin market."
>
<div class="grid gap-5 md:grid-cols-3">
<div
v-for="t in tiers"
:key="t.name"
class="relative flex flex-col rounded-2xl border p-6"
:class="t.featured ? 'border-indigo-500/50 bg-indigo-500/[0.07]' : 'border-white/10 bg-white/[0.03]'"
>
<span
v-if="t.featured"
class="absolute -top-3 left-6 rounded-full bg-indigo-400 px-3 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink"
>
Flagship
</span>
<h3 class="font-display text-xl font-semibold text-sand">{{ t.name }}</h3>
<div class="mt-2 flex items-baseline gap-1">
<span class="font-display text-4xl font-bold text-sand">{{ fmtUsd(t.price) }}</span>
<span class="text-sm text-sand-dim">/mo</span>
</div>
<p class="mt-1 text-xs text-sand-dim">{{ t.blurb }} · target {{ t.target }} members</p>
<ul class="mt-5 flex-1 space-y-2">
<li v-for="p in t.perks" :key="p" class="flex items-start gap-2 text-sm text-sand-dim">
<Icon name="Check" class="mt-0.5 size-4 shrink-0 text-indigo-300" />
{{ p }}
</li>
</ul>
</div>
</div>
<!-- comparables -->
<div class="mt-6 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-sand-dim">
<span class="uppercase tracking-wider">Austin comparables</span>
<span
v-for="c in membershipComparables"
:key="c.name"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1"
>
{{ c.name }} <span class="text-sand">{{ fmtUsd(c.price) }}</span>
</span>
</div>
</SlideFrame>
</template>

View file

@ -1,14 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import MyceliumNetwork from '../components/MyceliumNetwork.vue'
</script>
<template>
<SlideFrame
kicker="The network"
title="A mycelial network"
subtitle="What you see are the locations. What makes them work is the network beneath them — and it grows the same way mycelium does: spread the network once, then fruit anywhere."
>
<MyceliumNetwork />
</SlideFrame>
</template>

View file

@ -1,78 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import { partnership, fmtUsd } from '../data'
const barBg: Record<string, string> = {
indigo: 'bg-indigo-500',
pine: 'bg-pine-500',
amber: 'bg-amber-400',
}
const text: Record<string, string> = {
indigo: 'text-indigo-300',
pine: 'text-pine-300',
amber: 'text-amber-300',
}
const ring: Record<string, string> = {
indigo: 'border-indigo-500/40 bg-indigo-500/[0.06]',
pine: 'border-pine-500/40 bg-pine-500/[0.06]',
amber: 'border-amber-400/40 bg-amber-400/[0.06]',
}
const pct = (e: number) => Math.round(e * 100)
</script>
<template>
<SlideFrame
kicker="Ownership"
title="A three-way partnership"
subtitle="SolLunar Society is a joint venture. Investors buy in alongside the two operating partners."
>
<!-- equity bar -->
<div class="flex h-5 w-full overflow-hidden rounded-full">
<div
v-for="p in partnership.partners"
:key="p.name"
class="flex h-full items-center justify-center text-[11px] font-semibold text-ink"
:class="barBg[p.accent]"
:style="{ width: `${pct(p.equity)}%` }"
>
{{ pct(p.equity) }}%
</div>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-3">
<div
v-for="p in partnership.partners"
:key="p.name"
class="flex flex-col rounded-2xl border p-5"
:class="ring[p.accent]"
>
<div class="flex items-baseline justify-between gap-2">
<h3 class="font-display text-lg font-semibold text-sand">{{ p.name }}</h3>
<span class="font-display text-2xl font-bold" :class="text[p.accent]">{{ pct(p.equity) }}%</span>
</div>
<p class="text-xs uppercase tracking-wider" :class="text[p.accent]">{{ p.role }}</p>
<ul class="mt-4 flex-1 space-y-1.5">
<li v-for="r in p.responsibilities" :key="r" class="flex items-start gap-2 text-sm text-sand-dim">
<Icon name="Check" class="mt-0.5 size-3.5 shrink-0" :class="text[p.accent]" />
{{ r }}
</li>
</ul>
<p v-if="p.note" class="mt-3 border-t border-white/10 pt-3 text-[11px] leading-relaxed text-sand-dim">
{{ p.note }}
</p>
</div>
</div>
<div class="mt-5 flex flex-wrap items-center justify-center gap-x-6 gap-y-1 text-sm text-sand-dim">
<span>The ask:</span>
<span class="font-display text-lg font-semibold text-sand">
{{ fmtUsd(partnership.raiseAmount) }} for {{ pct(partnership.equityOffered) }}%
</span>
<span>·</span>
<span>{{ fmtUsd(partnership.valuation) }} valuation</span>
</div>
</SlideFrame>
</template>

View file

@ -1,166 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import { pnlGroups, groupTotal, pnl, pnlDocSummary, fmtUsd } from '../data'
const barBg: Record<string, string> = {
pine: 'bg-pine-500',
indigo: 'bg-indigo-500',
rose: 'bg-rose-500',
clay: 'bg-clay-400',
}
const text: Record<string, string> = {
pine: 'text-pine-300',
indigo: 'text-indigo-300',
rose: 'text-rose-300',
clay: 'text-clay-400',
}
const ring: Record<string, string> = {
pine: 'border-pine-500/50 bg-pine-500/[0.06]',
indigo: 'border-indigo-500/50 bg-indigo-500/[0.06]',
rose: 'border-rose-500/50 bg-rose-500/[0.06]',
clay: 'border-clay-400/50 bg-clay-400/[0.06]',
}
const revenueGroups = pnlGroups.filter((g) => g.kind === 'revenue')
const selected = ref<string | null>(null)
const selectedGroup = computed(() => pnlGroups.find((g) => g.id === selected.value) ?? null)
function toggle(id: string) {
selected.value = selected.value === id ? null : id
}
const revPct = (id: string) =>
(groupTotal(pnlGroups.find((g) => g.id === id)!) / pnl.monthlyRevenue) * 100
</script>
<template>
<SlideFrame
kicker="SolLunar Society · unit economics"
title="The numbers"
subtitle="Real monthly P&L for the Austin node. Tap any category to break it down."
>
<!-- headline stats -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<p class="text-xs uppercase tracking-wider text-sand-dim">Revenue / mo</p>
<p class="mt-1 font-display text-2xl font-bold text-sand">{{ fmtUsd(pnl.monthlyRevenue) }}</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<p class="text-xs uppercase tracking-wider text-sand-dim">Expenses / mo</p>
<p class="mt-1 font-display text-2xl font-bold text-sand">{{ fmtUsd(pnl.monthlyExpenses) }}</p>
</div>
<div class="rounded-2xl border border-pine-500/40 bg-pine-500/[0.07] p-4">
<p class="text-xs uppercase tracking-wider text-pine-300">Profit / mo</p>
<p class="mt-1 font-display text-2xl font-bold text-sand">{{ fmtUsd(pnl.monthlyProfit) }}</p>
</div>
<div class="rounded-2xl border border-amber-400/40 bg-amber-400/[0.07] p-4">
<p class="text-xs uppercase tracking-wider text-amber-300">Profit / yr</p>
<p class="mt-1 font-display text-2xl font-bold text-sand">{{ fmtUsd(pnl.yearlyProfit) }}</p>
</div>
</div>
<div class="mt-6 grid items-start gap-8 lg:grid-cols-[1fr_1fr]">
<!-- revenue mix bar + live detail -->
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-sand-dim">Revenue mix</p>
<div class="flex h-4 w-full overflow-hidden rounded-full">
<button
v-for="g in revenueGroups"
:key="g.id"
class="h-full cursor-pointer transition-opacity"
:class="[barBg[g.accent], selected && selected !== g.id ? 'opacity-30' : 'opacity-100']"
:style="{ width: `${revPct(g.id)}%` }"
:title="`${g.title} · ${fmtUsd(groupTotal(g))}`"
@click="toggle(g.id)"
/>
</div>
<Transition name="fade" mode="out-in">
<div
v-if="selectedGroup"
:key="selectedGroup.id"
class="mt-5 rounded-2xl border p-5"
:class="ring[selectedGroup.accent]"
>
<div class="flex items-center gap-2">
<Icon :name="selectedGroup.icon" class="size-5" :class="text[selectedGroup.accent]" />
<h3 class="font-display text-base font-semibold text-sand">{{ selectedGroup.title }}</h3>
<span class="ml-auto font-display text-lg font-semibold" :class="text[selectedGroup.accent]">
{{ fmtUsd(groupTotal(selectedGroup)) }}
</span>
</div>
<ul class="mt-3 space-y-2">
<li
v-for="item in selectedGroup.items"
:key="item.label"
class="flex items-baseline justify-between gap-3 text-sm"
>
<span class="text-sand-dim">
{{ item.label }}
<span v-if="item.note" class="text-[11px] text-sand-dim/60">· {{ item.note }}</span>
</span>
<span class="shrink-0 tabular-nums text-sand">{{ fmtUsd(item.amount) }}</span>
</li>
</ul>
</div>
<div
v-else
key="hint"
class="mt-5 flex items-center gap-3 rounded-2xl border border-dashed border-white/15 p-5 text-sm text-sand-dim"
>
<Icon name="ArrowRight" class="size-4 shrink-0 text-amber-300" />
Tap a category to see its line items.
</div>
</Transition>
</div>
<!-- clickable category list -->
<div class="space-y-2.5">
<button
v-for="g in pnlGroups"
:key="g.id"
class="flex w-full items-center gap-4 rounded-2xl border p-4 text-left transition"
:class="
selected === g.id
? ring[g.accent]
: 'border-white/10 bg-white/[0.03] hover:border-white/25 hover:bg-white/[0.06]'
"
@click="toggle(g.id)"
>
<span class="grid size-10 shrink-0 place-items-center rounded-xl bg-white/5" :class="text[g.accent]">
<Icon :name="g.icon" class="size-5" />
</span>
<div class="min-w-0 flex-1">
<h3 class="font-display text-base font-semibold text-sand">{{ g.title }}</h3>
<p class="text-xs uppercase tracking-wider text-sand-dim">{{ g.kind }}</p>
</div>
<span class="shrink-0 font-display text-lg font-semibold" :class="text[g.accent]">
{{ fmtUsd(groupTotal(g)) }}
</span>
<Icon name="ChevronRight" class="size-4 shrink-0 text-sand-dim transition-transform" :class="selected === g.id ? 'rotate-90' : ''" />
</button>
</div>
</div>
<p class="mt-4 text-[11px] text-sand-dim/70">
Figures recomputed from the plan's line items. The source summary stated
{{ fmtUsd(pnlDocSummary.revenue) }} rev / {{ fmtUsd(pnlDocSummary.expenses) }} exp
being reconciled. Lunarpunk Locals count is TBD.
</p>
</SlideFrame>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(6px);
}
</style>

View file

@ -1,37 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import { roadmap } from '../data'
</script>
<template>
<SlideFrame
kicker="The replication play"
title="Prove it three ways, then grow it anywhere"
subtitle="The three entities aren't the endgame — they're the seed. The blueprint is the business."
>
<ol class="relative grid gap-6 md:grid-cols-4">
<!-- connecting line -->
<div class="absolute left-0 right-0 top-5 hidden h-px bg-gradient-to-r from-pine-500/40 via-amber-400/40 to-clay-400/40 md:block" />
<li v-for="(p, i) in roadmap" :key="p.phase" class="relative">
<div class="flex items-center gap-3 md:block">
<span
class="z-10 grid size-10 shrink-0 place-items-center rounded-full border border-white/15 bg-ink font-display text-sm font-bold text-amber-300"
>
{{ i + 1 }}
</span>
<span class="mt-3 block text-[11px] font-semibold uppercase tracking-wider text-pine-300">
{{ p.phase }} · {{ p.when }}
</span>
</div>
<h3 class="mt-2 font-display text-lg font-semibold text-sand">{{ p.title }}</h3>
<p class="mt-1 text-sm text-sand-dim">{{ p.body }}</p>
</li>
</ol>
<p class="mt-10 rounded-2xl border border-pine-500/20 bg-pine-500/5 p-5 text-center text-sand">
Once the mycelium runs, every new flower is
<span class="font-semibold text-pine-300">marginal cost, not a new company.</span>
</p>
</SlideFrame>
</template>

View file

@ -1,8 +0,0 @@
<script setup lang="ts">
import EntityDetail from '../components/EntityDetail.vue'
import { entityById } from '../data'
</script>
<template>
<EntityDetail :entity="entityById('sollunar')" />
</template>

View file

@ -1,41 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import { buildings } from '../data'
const icons = ['Building2', 'BedDouble', 'Cpu']
</script>
<template>
<SlideFrame
kicker="SolLunar Society · the building"
title="One address, three buildings"
subtitle="A members' society up front, two Yellow Houses behind — lodging, coworking and the data center that runs it all."
>
<div class="grid gap-4 md:grid-cols-3">
<div
v-for="(b, i) in buildings"
:key="b.name"
class="flex flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-5"
>
<div class="flex items-center gap-3">
<span class="grid size-10 place-items-center rounded-xl bg-indigo-500/10 text-indigo-300">
<Icon :name="icons[i] ?? 'Building2'" class="size-5" />
</span>
<h3 class="font-display text-base font-semibold leading-tight text-sand">{{ b.name }}</h3>
</div>
<ul class="mt-4 space-y-1.5">
<li v-for="r in b.rooms" :key="r" class="flex items-center gap-2 text-sm text-sand-dim">
<span class="size-1.5 rounded-full bg-indigo-400" />
{{ r }}
</li>
</ul>
</div>
</div>
<p class="mt-6 text-sm text-sand-dim">
The Yellow House data center makes SolLunar self-hosting the Archipelago stack runs on-site,
not in someone else's cloud.
</p>
</SlideFrame>
</template>

View file

@ -1,27 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import { tech } from '../data'
</script>
<template>
<SlideFrame
kicker="Archipelago · the shared backbone"
title="Open hardware, sovereign software"
subtitle="Archipelago is the stack under every entity — owned by the network, documented to be rebuilt anywhere. Build it once; every place plugs in."
>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="t in tech"
:key="t.title"
class="rounded-2xl border border-white/10 bg-white/[0.03] p-5 transition hover:border-pine-500/40 hover:bg-pine-500/5"
>
<span class="grid size-10 place-items-center rounded-xl bg-pine-500/10 text-pine-300">
<Icon :name="t.icon" class="size-5" />
</span>
<h3 class="mt-4 font-display text-lg font-semibold text-sand">{{ t.title }}</h3>
<p class="mt-1.5 text-sm leading-relaxed text-sand-dim">{{ t.body }}</p>
</div>
</div>
</SlideFrame>
</template>

View file

@ -1,32 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import { tenants } from '../data'
</script>
<template>
<SlideFrame
kicker="SolLunar Society · tenants"
title="Five founding tenants"
subtitle="The hub is anchored by businesses that are also the community — each one a reason to become a member."
>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="t in tenants"
:key="t.name"
class="flex flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-5 transition hover:border-indigo-500/40 hover:bg-indigo-500/5"
>
<span class="grid size-10 place-items-center rounded-xl bg-indigo-500/10 text-indigo-300">
<Icon :name="t.icon" class="size-5" />
</span>
<h3 class="mt-4 font-display text-lg font-semibold text-sand">{{ t.name }}</h3>
<p class="mt-1.5 text-sm leading-relaxed text-sand-dim">{{ t.blurb }}</p>
</div>
</div>
<p class="mt-6 text-sm text-sand-dim">
Tenants pay rent <span class="text-sand">and</span> drive events, retail and food traffic
the same footfall that fills memberships.
</p>
</SlideFrame>
</template>

View file

@ -1,49 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import PlaceholderBadge from '../components/PlaceholderBadge.vue'
import { brand, surfaceEntities, accentText } from '../data'
</script>
<template>
<SlideFrame center>
<div class="flex flex-col items-center">
<span class="mb-6 inline-flex items-center gap-2 rounded-full border border-pine-500/30 bg-pine-500/10 px-4 py-1.5 text-xs uppercase tracking-[0.3em] text-pine-300">
Investment prospectus
<PlaceholderBadge label="draft" />
</span>
<h1 class="font-display text-5xl font-bold tracking-tight text-sand sm:text-6xl lg:text-7xl">
{{ brand.name }}
</h1>
<p class="mt-6 max-w-2xl text-balance text-xl text-sand sm:text-2xl">
{{ brand.tagline }}
</p>
<!-- the three collaborating entities -->
<div class="mt-10 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 text-sm text-sand-dim">
<span class="uppercase tracking-[0.2em]">Three experiences, one backbone</span>
</div>
<div class="mt-3 flex flex-wrap items-center justify-center gap-3">
<template v-for="(e, i) in surfaceEntities" :key="e.id">
<span class="font-display text-lg font-semibold" :class="accentText[e.accent]">{{ e.name }}</span>
<span v-if="i < surfaceEntities.length - 1" class="text-sand-dim">·</span>
</template>
</div>
<p class="mt-8 max-w-xl text-balance text-sm text-sand-dim">
{{ brand.subtagline }}
</p>
<span class="mt-6 inline-flex items-center gap-2 rounded-full border border-indigo-500/40 bg-indigo-500/10 px-4 py-1.5 text-xs font-medium text-indigo-300">
<span class="size-1.5 rounded-full bg-indigo-400" />
Raising now · {{ brand.firstNode }}
</span>
<p class="mt-10 text-xs text-sand-dim">
Use <kbd class="rounded border border-white/15 px-1.5 py-0.5"></kbd> /
<kbd class="rounded border border-white/15 px-1.5 py-0.5"></kbd> to navigate
</p>
</div>
</SlideFrame>
</template>

View file

@ -1,34 +0,0 @@
<script setup lang="ts">
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import { problem } from '../data'
</script>
<template>
<SlideFrame kicker="The problem" :title="problem.headline">
<div class="grid gap-10 lg:grid-cols-[1.1fr_1fr]">
<ul class="space-y-5">
<li
v-for="(p, i) in problem.points"
:key="i"
class="flex items-start gap-4 text-lg text-sand-dim"
>
<span class="mt-1 grid size-6 shrink-0 place-items-center rounded-full bg-white/5 text-xs font-semibold text-amber-300">
{{ i + 1 }}
</span>
{{ p }}
</li>
</ul>
<div class="flex flex-col justify-center rounded-2xl border border-pine-500/20 bg-pine-500/5 p-8">
<Icon name="HeartHandshake" class="size-8 text-pine-300" />
<p class="mt-4 text-xs font-semibold uppercase tracking-[0.2em] text-pine-300">
Our thesis
</p>
<p class="mt-3 font-display text-2xl leading-snug text-sand">
{{ problem.thesis }}
</p>
</div>
</div>
</SlideFrame>
</template>

View file

@ -1,66 +0,0 @@
import { onMounted, onUnmounted, ref, computed } from 'vue'
/**
* Deck navigation: current slide index, next/prev/goto, keyboard control,
* and URL-hash sync so any slide is a shareable deep link (/#/7).
*/
export function useDeck(total: number) {
const clamp = (n: number) => Math.min(Math.max(n, 0), total - 1)
const readHash = () => {
const m = window.location.hash.match(/#\/(\d+)/)
return m ? clamp(parseInt(m[1], 10) - 1) : 0
}
// seed from the hash synchronously so deep links (…/#/7) land on the right
// slide at first paint — no flash of slide 1 and no on-load transition.
const index = ref(readHash())
const direction = ref<1 | -1>(1)
function goto(n: number) {
const next = clamp(n)
direction.value = next >= index.value ? 1 : -1
index.value = next
window.location.hash = `#/${next + 1}`
}
const next = () => goto(index.value + 1)
const prev = () => goto(index.value - 1)
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
e.preventDefault()
next()
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
e.preventDefault()
prev()
} else if (e.key === 'Home') {
goto(0)
} else if (e.key === 'End') {
goto(total - 1)
}
}
const onHash = () => {
index.value = readHash()
}
onMounted(() => {
if (!window.location.hash) window.location.hash = '#/1'
window.addEventListener('keydown', onKey)
window.addEventListener('hashchange', onHash)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('hashchange', onHash)
})
return {
index,
direction,
goto,
next,
prev,
isFirst: computed(() => index.value === 0),
isLast: computed(() => index.value === total - 1),
}
}

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

@ -4,7 +4,7 @@ const routes: RouteRecordRaw[] = [
{ {
path: '/', path: '/',
name: 'deck', name: 'deck',
component: () => import('@/views/DeckView.vue'), component: () => import('@/features/deck/DeckView.vue'),
}, },
] ]

View file

@ -73,43 +73,6 @@
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
} }
/*
ARCHIPELAGO pitch-deck brand palette (PLACEHOLDER swap when real
brand assets land). Regenerative-tech: pine green + warm amber
on a deep near-black ground. Used by the self-contained dark
deck; the shadcn tokens above are left intact for other pages.
*/
@theme {
--color-ink: hsl(160 28% 6%); /* deck ground */
--color-ink-2: hsl(160 24% 9%); /* raised panels */
--color-ink-3: hsl(158 20% 13%); /* cards / borders */
--color-pine-300: hsl(158 42% 62%);
--color-pine-400: hsl(159 45% 50%);
--color-pine-500: hsl(160 60% 40%);
--color-pine-600: hsl(162 66% 30%);
--color-amber-300: hsl(38 96% 70%);
--color-amber-400: hsl(36 95% 60%);
--color-amber-500: hsl(32 92% 52%);
/* SolLunar Punk Society (city) accent — lunarpunk indigo */
--color-indigo-300: hsl(255 85% 78%);
--color-indigo-400: hsl(256 78% 68%);
--color-indigo-500: hsl(258 70% 58%);
/* Pop-up Festivals accent — vibrant rave rose */
--color-rose-300: hsl(342 95% 76%);
--color-rose-400: hsl(344 85% 66%);
--color-rose-500: hsl(346 78% 56%);
--color-clay-400: hsl(14 72% 62%); /* farm / warmth accent */
--color-sand: hsl(44 30% 90%); /* primary deck text */
--color-sand-dim: hsl(150 8% 62%); /* muted deck text */
--font-display: 'Georgia', 'Times New Roman', serif;
}
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
@ -117,13 +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
/* Deck root: self-contained dark canvas independent of shadcn tokens */ (text, padding, gaps, max-width) is relative to this root font-size, so
.deck-root { bumping it enlarges the presentation uniformly to fill large screens. */
background: html {
radial-gradient(120% 80% at 80% -10%, hsl(162 60% 14% / 0.55), transparent 60%), font-size: clamp(16px, 0.85vw + 6px, 23px);
radial-gradient(90% 70% at -10% 110%, hsl(32 80% 18% / 0.35), transparent 55%),
var(--color-ink);
color: var(--color-sand);
} }
} }

View file

@ -1,7 +0,0 @@
<script setup lang="ts">
import Deck from '@/deck/Deck.vue'
</script>
<template>
<Deck />
</template>