Compare commits
25 commits
0cce0089d1
...
b292b987c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b292b987c3 | ||
|
|
b2d8153c62 | ||
|
|
528502df8c | ||
|
|
40cb975291 | ||
|
|
0353665fb3 | ||
|
|
49ad78360a | ||
|
|
10b3551668 | ||
|
|
c28dbd46de | ||
|
|
002c121e83 | ||
|
|
85a42411de | ||
|
|
49b7a2920a | ||
|
|
c645ca1e5f | ||
|
|
847f08a975 | ||
|
|
7acc2b550d | ||
|
|
673d937abe | ||
|
|
9e74000f9f | ||
|
|
2fbdada957 | ||
|
|
b0a64502dc | ||
|
|
7ae2c75a79 | ||
|
|
cb4b845f90 | ||
|
|
fbbd4af50c | ||
|
|
adb6783a02 | ||
|
|
e73072318c | ||
|
|
4a4b5498ec | ||
|
|
d2c875bbde |
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
77
src/deck/components/EntityDetail.vue
Normal 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 & app as every node.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SlideFrame>
|
||||
</template>
|
||||
78
src/deck/components/Icon.vue
Normal 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>
|
||||
125
src/deck/components/Lightbox.vue
Normal 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>
|
||||
408
src/deck/components/MyceliumNetwork.vue
Normal 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 don’t 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>
|
||||
13
src/deck/components/PlaceholderBadge.vue
Normal 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>
|
||||
39
src/deck/components/SlideFrame.vue
Normal 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
|
|
@ -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 don’t support the change.',
|
||||
'There’s no lasting home for sovereignty-minded community: dreamers, creatives, technologists, regenerative living.',
|
||||
'Peak experiences don’t 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 we’re 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 network’s 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: '~0–12 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: '~12–18 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
|
|
@ -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),
|
||||
},
|
||||
]
|
||||
18
src/deck/gallery/README.md
Normal 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.
|
||||
BIN
src/deck/gallery/city/1024-580.webp
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
src/deck/gallery/city/1024-581.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/deck/gallery/city/1024-768.webp
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
src/deck/gallery/city/1024-769.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
src/deck/gallery/city/1024-770.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
src/deck/gallery/farm/1536-2048-max.webp
Normal file
|
After Width: | Height: | Size: 484 KiB |
BIN
src/deck/gallery/farm/2048-1536-max (1).webp
Normal file
|
After Width: | Height: | Size: 669 KiB |
BIN
src/deck/gallery/farm/2048-1536-max (2).webp
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
src/deck/gallery/farm/2048-1536-max (3).webp
Normal file
|
After Width: | Height: | Size: 891 KiB |
BIN
src/deck/gallery/farm/2048-1536-max.webp
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
src/deck/gallery/farm/PXL_20250907_134308735.webp
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
src/deck/gallery/farm/PXL_20251121_064432019.webp
Normal file
|
After Width: | Height: | Size: 620 KiB |
BIN
src/deck/gallery/farm/garden.webp
Normal file
|
After Width: | Height: | Size: 423 KiB |
15
src/deck/gallery/festival/01-placeholder.svg
Normal 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 |
15
src/deck/gallery/festival/02-placeholder.svg
Normal 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 |
15
src/deck/gallery/festival/03-placeholder.svg
Normal 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 |
15
src/deck/gallery/festival/04-placeholder.svg
Normal 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 |
15
src/deck/gallery/festival/05-placeholder.svg
Normal 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 |
59
src/deck/slides/AskSlide.vue
Normal 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>
|
||||
32
src/deck/slides/EcosystemSlide.vue
Normal 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>
|
||||
63
src/deck/slides/EntitiesSlide.vue
Normal 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&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>
|
||||
92
src/deck/slides/GallerySlide.vue
Normal 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 they’ll appear here automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Lightbox v-model="lightboxIndex" :images="active.images" />
|
||||
</SlideFrame>
|
||||
</template>
|
||||
58
src/deck/slides/MembershipSlide.vue
Normal 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>
|
||||
14
src/deck/slides/NetworkSlide.vue
Normal 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>
|
||||
78
src/deck/slides/PartnershipSlide.vue
Normal 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>
|
||||
166
src/deck/slides/PnlSlide.vue
Normal 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>
|
||||
37
src/deck/slides/RoadmapSlide.vue
Normal 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>
|
||||
8
src/deck/slides/SolLunarSlide.vue
Normal 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>
|
||||
41
src/deck/slides/SpacesSlide.vue
Normal 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>
|
||||
27
src/deck/slides/TechSlide.vue
Normal 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>
|
||||
32
src/deck/slides/TenantsSlide.vue
Normal 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>
|
||||
49
src/deck/slides/TitleSlide.vue
Normal 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>
|
||||
34
src/deck/slides/VisionSlide.vue
Normal 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
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import Deck from '@/deck/Deck.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Deck />
|
||||
</template>
|
||||