Compare commits

...

25 commits

Author SHA1 Message Date
Patrick Mulligan
b292b987c3 feat(deck): populate with real SolLunar Society business plan
Replace the invented financials/entities with the real content from
SolLunarSociety.md — the first node's actual business:

- data.ts: real problem statement; partnership (SPS 45 / Lab 484 45 /
  investors 10, $400k for 10%, $4M valuation); five tenants; three
  membership tiers + Austin comparables; recomputed monthly P&L
  ($65.4k rev / $34k exp / $31.4k profit / $376.8k yr) with the doc's
  non-reconciling summary kept in pnlDocSummary; buildings; investor ask.
- New slides: Partnership (with the Lab 484↔revenue overlap called out),
  Tenants, Memberships, The building, and an interactive P&L drill-down.
  Rewrite the Ask ($400k/10% + profit-share scenarios). Title/Entities
  flag SolLunar as the funded first node; roadmap = festival → farm.
- Remove the invented Revenue/CostBreakdown/Angel/Contributor/
  Investment-overview and the Festivals/Chateau deep-dive slides.
  Ground the vision: SolLunar carries the numbers, the rest stay narrative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 19:41:06 +02:00
Patrick Mulligan
b2d8153c62 perf(gallery): convert photos to WebP (q82, ≤2000px)
Convert all City & Farm raster photos (PNG/JPG) to WebP and cap the longest
side at 2000px. Total gallery photo weight drops ~21.3MB → ~5.7MB (~73%);
the two large phone shots go 7.2MB→1.1MB and 4.1MB→624KB. SVG placeholders
untouched; the import.meta.glob already matches webp so nothing else changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 18:40:52 +02:00
Patrick Mulligan
528502df8c chore(deck): set page title to Archipelago
Matches the umbrella/brand rename. (This blob was lost to the disk-sleep
git corruption; recommitting restores it.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 18:20:20 +02:00
Patrick Mulligan
40cb975291 content(gallery): real City & Farm photos; drop placeholders for populated folders
Replaces the City placeholder SVGs with 5 real photos and adds a Farm
photo (dropped in via the auto-populate workflow). Festival + remaining
Farm placeholders stay until real shots land.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 18:08:28 +02:00
Patrick Mulligan
0353665fb3 feat(deck): rework slides + registry for the 4-entity model
Title lists the three experiences on the Archipelago network; Tech slide
reframed as "Archipelago · the shared backbone"; new SolLunar (city) and
Festivals deep-dives replace the old Archipelago/Solunar slides;
EntitiesSlide shows 3 experience cards + an Archipelago backbone banner;
EntityDetail shows the festival's example (Solar Punk Summit); cost slides
use the pine/indigo/rose/clay accents. Deck reordered accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 18:07:43 +02:00
Patrick Mulligan
49ad78360a fix(deck): deep links land on the right slide without a flash
Seed the slide index from the URL hash synchronously at setup instead of
after mount, so opening …/#/7 renders slide 7 immediately (no flash of
slide 1 and no on-load transition).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 18:06:56 +02:00
Patrick Mulligan
10b3551668 feat(deck): mycelium + gallery reflect the 4-entity model
Mycelium flowers are now the three experiences (SolLunar indigo, Festivals
rose, Chateau clay) and the underground is named Archipelago, the shared
backbone. Gallery City/Farm/Festival sections repoint to the matching
entities and accents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 18:06:44 +02:00
Patrick Mulligan
c28dbd46de feat(theme): add rose accent for Pop-up Festivals
Vibrant rave-rose distinguishes festivals from SolLunar indigo (city),
clay (farm) and pine (Archipelago backbone).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 18:06:44 +02:00
Patrick Mulligan
002c121e83 refactor(deck): 4-entity model — Archipelago backbone + 3 experiences
Restructure per the new framing: Archipelago is the shared backbone (the
core stack / mycelium), with three experiences on top — SolLunar Punk
Society (city), Pop-up Festivals (travelling; e.g. Solar Punk Summit) and
Chateau du Faune (farm). Adds a 'layer' field + surfaceEntities helper, a
'rose' accent, an 'example' field, remaps the four cost categories 1:1 to
the entities, and sets the umbrella/brand to Archipelago.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 18:06:44 +02:00
Patrick Mulligan
85a42411de chore(deck): rename Solunar to SolLunar throughout
Display text only — the internal 'solunar' entity id and SolunarSlide
component/filename are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 19:51:25 +02:00
Patrick Mulligan
49b7a2920a feat(deck): drop-in image gallery with City/Farm/Festival sections
New Gallery slide (after the entity deep-dives) with a tabbed masonry grid
and a full-screen lightbox (prev/next, counter, ESC/arrows/swipe) styled
after the webapp marketplace viewer. Images auto-populate via
import.meta.glob — drop files into src/deck/gallery/{city,farm,festival}/
and they appear, sorted by filename, with a graceful empty state. Seeded
placeholder SVGs (clearly marked) so the gallery is populated in the demo.

Lightbox keys are handled in the capture phase so they don't trigger the
deck's slide navigation. Deck is now 17 slides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 19:09:59 +02:00
Patrick Mulligan
c645ca1e5f feat(deck): format millions as $X.XXMM in fmtUsd
The raise total now shows $1.85MM instead of $1,850k on the raise and ask
slides; sub-million figures (category subtotals) stay in $k.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:52:43 +02:00
Patrick Mulligan
847f08a975 feat(deck): make the raise slide an interactive cost drill-down
Click any of the four investment categories (or its bar segment) to expand
its line-item breakdown inline; the proportion bar highlights the selected
slice and dims the rest. Buttons are keyboard-focusable with aria-pressed;
respects prefers-reduced-motion. Register ChevronRight in the Icon map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:45:41 +02:00
Patrick Mulligan
7acc2b550d feat(deck): entity + ecosystem slides and 16-slide registry
Title becomes the 3-way collaboration; new EntitiesSlide (three partners),
per-entity deep-dives (Archipelago/Solunar/Chateau) via a shared
EntityDetail, and an EcosystemSlide selling the identical membership/kiosk/
app experience at every location. Deck registry reordered to 16 slides.
(Old Locations/City/Farm slides + LocationDetail were removed in the
preceding commit.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:35:15 +02:00
Patrick Mulligan
673d937abe feat(deck): 4-category costs + collaboration-wide copy
Cost overview & breakdown render four accented categories (shared stack +
one per entity); revenue, roadmap and ask copy updated for three entities;
page title set to Solunar Punk Society.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:34:36 +02:00
Patrick Mulligan
9e74000f9f feat(deck): show three entity flowers in the mycelium viz
City and farm render as permanent blooms; Solunar Society is an ephemeral
pop-up cluster (indigo, dashed stem, floats + fades). Underground caption
reframed as the shared core stack every entity runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:34:19 +02:00
Patrick Mulligan
2fbdada957 feat(theme): add indigo (lunarpunk) accent + festival icons
Indigo palette for Solunar Society's night/festival character alongside
pine (city) and clay (farm); register Tent/Ticket/Sun/Moon/MapPin icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:34:19 +02:00
Patrick Mulligan
b0a64502dc refactor(deck): model 3 collaborating entities under Solunar Punk Society
Reframe from one brand + two locations to a collaboration between
Archipelago (city center), Solunar Society (pop-up festivals) and
Chateau du Faune (farm center). Add entities[], the shared userEcosystem,
a 4th "shared core stack" cost category, and per-accent class helpers.
All figures remain placeholders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:34:19 +02:00
Patrick Mulligan
7ae2c75a79 feat(deck): route the deck at / and set page title
DeckView hosts the deck; router points / at it; index.html title
becomes "ARCHIPELAGO — Investment Prospectus".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:21:25 +02:00
Patrick Mulligan
cb4b845f90 feat(deck): add the 14 pitch slides
Title, vision, network, technology, two nodes, city, farm, revenue,
the raise, cost breakdown, angel tiers, contributor tiers, roadmap, ask.
Contributor CTAs are a presentational seam for LNbits/nostr later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:21:17 +02:00
Patrick Mulligan
fbbd4af50c feat(deck): add mycelial-network visualization
Animated SVG: unseen underground mycelium (the tech foundation) feeds
surface "flower" locations, with faint future flowers showing new nodes
sprout cheaply once the network runs. Respects prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:21:17 +02:00
Patrick Mulligan
adb6783a02 feat(deck): add deck shell, keyboard/hash navigation, and slide primitives
Deck.vue stage with progress dots, arrow controls and direction-aware
transitions; useDeck composable (arrow/space/Home/End + URL-hash deep
links); shared Icon, SlideFrame, PlaceholderBadge, LocationDetail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:21:17 +02:00
Patrick Mulligan
e73072318c feat(deck): add central content/data model
Single source of truth for the pitch: brand, cost categories + line
items, angel & contributor tiers, revenue streams, locations, roadmap.
All figures are clearly-marked placeholders to swap for real numbers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:20:59 +02:00
Patrick Mulligan
4a4b5498ec feat(theme): add Archipelago deck brand palette
Pine + amber on a deep near-black ground, self-contained dark deck
canvas (.deck-root), display font. shadcn tokens left intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:20:59 +02:00
Patrick Mulligan
d2c875bbde fix(pnpm): approve vue-demi build script so dev/build run
The shipped pnpm-workspace.yaml had a placeholder stub that failed
pnpm 11's ignored-builds gate, breaking `pnpm dev` and `pnpm build`.
Approve vue-demi's postinstall via allowBuilds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:20:59 +02:00
49 changed files with 2511 additions and 3 deletions

View file

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

5
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,5 @@
# 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:
vue-demi: true

149
src/deck/Deck.vue Normal file
View file

@ -0,0 +1,149 @@
<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

@ -0,0 +1,77 @@
<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

@ -0,0 +1,78 @@
<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

@ -0,0 +1,125 @@
<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

@ -0,0 +1,408 @@
<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

@ -0,0 +1,13 @@
<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

@ -0,0 +1,39 @@
<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>

530
src/deck/data.ts Normal file
View file

@ -0,0 +1,530 @@
/**
* 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',
}

82
src/deck/gallery.ts Normal file
View file

@ -0,0 +1,82 @@
/**
* 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

@ -0,0 +1,18 @@
# 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.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

View file

@ -0,0 +1,15 @@
<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>

After

Width:  |  Height:  |  Size: 992 B

View file

@ -0,0 +1,15 @@
<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>

After

Width:  |  Height:  |  Size: 992 B

View file

@ -0,0 +1,15 @@
<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>

After

Width:  |  Height:  |  Size: 997 B

View file

@ -0,0 +1,15 @@
<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>

After

Width:  |  Height:  |  Size: 992 B

View file

@ -0,0 +1,15 @@
<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>

After

Width:  |  Height:  |  Size: 992 B

View file

@ -0,0 +1,59 @@
<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

@ -0,0 +1,32 @@
<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

@ -0,0 +1,63 @@
<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

@ -0,0 +1,92 @@
<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

@ -0,0 +1,58 @@
<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

@ -0,0 +1,14 @@
<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

@ -0,0 +1,78 @@
<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

@ -0,0 +1,166 @@
<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

@ -0,0 +1,37 @@
<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

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

View file

@ -0,0 +1,41 @@
<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

@ -0,0 +1,27 @@
<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

@ -0,0 +1,32 @@
<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

@ -0,0 +1,49 @@
<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

@ -0,0 +1,34 @@
<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>

66
src/deck/useDeck.ts Normal file
View file

@ -0,0 +1,66 @@
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

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

View file

@ -73,6 +73,43 @@
--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 {
* {
@apply border-border;
@ -80,4 +117,13 @@
body {
@apply bg-background text-foreground;
}
/* Deck root: self-contained dark canvas independent of shadcn tokens */
.deck-root {
background:
radial-gradient(120% 80% at 80% -10%, hsl(162 60% 14% / 0.55), transparent 60%),
radial-gradient(90% 70% at -10% 110%, hsl(32 80% 18% / 0.35), transparent 55%),
var(--color-ink);
color: var(--color-sand);
}
}

7
src/views/DeckView.vue Normal file
View file

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