feat(deck): add deck shell, keyboard/hash navigation, and slide primitives

Deck.vue stage with progress dots, arrow controls and direction-aware
transitions; useDeck composable (arrow/space/Home/End + URL-hash deep
links); shared Icon, SlideFrame, PlaceholderBadge, LocationDetail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-07-28 18:21:17 +02:00
commit adb6783a02
6 changed files with 385 additions and 0 deletions

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

@ -0,0 +1,147 @@
<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 LocationsSlide from './slides/LocationsSlide.vue'
import CitySlide from './slides/CitySlide.vue'
import FarmSlide from './slides/FarmSlide.vue'
import RevenueSlide from './slides/RevenueSlide.vue'
import InvestmentOverviewSlide from './slides/InvestmentOverviewSlide.vue'
import CostBreakdownSlide from './slides/CostBreakdownSlide.vue'
import AngelSlide from './slides/AngelSlide.vue'
import ContributorSlide from './slides/ContributorSlide.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: 'tech', label: 'Technology', component: TechSlide },
{ id: 'locations', label: 'Two nodes', component: LocationsSlide },
{ id: 'city', label: 'City node', component: CitySlide },
{ id: 'farm', label: 'Farm node', component: FarmSlide },
{ id: 'revenue', label: 'Revenue', component: RevenueSlide },
{ id: 'invest-overview', label: 'The raise', component: InvestmentOverviewSlide },
{ id: 'cost-breakdown', label: 'Cost breakdown', component: CostBreakdownSlide },
{ id: 'angel', label: 'Angel investment', component: AngelSlide },
{ id: 'contribute', label: 'Contribute', component: ContributorSlide },
{ 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.name }}
</span>
<span class="text-xs uppercase tracking-[0.2em]">{{ current.label }}</span>
</header>
<!-- slide stage -->
<main class="h-full w-full">
<Transition :name="transitionName" mode="out-in">
<KeepAlive>
<component :is="current.component" :key="current.id" class="h-full w-full" />
</KeepAlive>
</Transition>
</main>
<!-- controls -->
<div class="absolute inset-x-0 bottom-0 z-20 flex items-center justify-between gap-4 px-6 py-5">
<button
class="pointer-events-auto grid size-10 place-items-center rounded-full border border-white/10 bg-white/5 text-sand transition hover:bg-white/10 disabled:opacity-30"
:disabled="isFirst"
aria-label="Previous slide"
@click="prev"
>
<ChevronLeft class="size-5" />
</button>
<!-- progress dots -->
<nav class="flex flex-wrap items-center justify-center gap-2" aria-label="Slides">
<button
v-for="(s, i) in slides"
:key="s.id"
class="h-1.5 rounded-full transition-all"
:class="i === index ? 'w-6 bg-amber-400' : 'w-1.5 bg-white/20 hover:bg-white/40'"
:title="s.label"
:aria-label="`Go to ${s.label}`"
:aria-current="i === index"
@click="goto(i)"
/>
</nav>
<div class="flex items-center gap-3">
<span class="hidden text-xs tabular-nums text-sand-dim sm:inline">
{{ index + 1 }} / {{ slides.length }}
</span>
<button
class="pointer-events-auto grid size-10 place-items-center rounded-full border border-white/10 bg-white/5 text-sand transition hover:bg-white/10 disabled:opacity-30"
:disabled="isLast"
aria-label="Next slide"
@click="next"
>
<ChevronRight class="size-5" />
</button>
</div>
</div>
</div>
</template>
<style scoped>
.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
transition:
opacity 0.35s ease,
transform 0.35s ease;
}
.slide-next-enter-from {
opacity: 0;
transform: translateX(3%);
}
.slide-next-leave-to {
opacity: 0;
transform: translateX(-3%);
}
.slide-prev-enter-from {
opacity: 0;
transform: translateX(-3%);
}
.slide-prev-leave-to {
opacity: 0;
transform: translateX(3%);
}
@media (prefers-reduced-motion: reduce) {
.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
transition: opacity 0.2s ease;
}
.slide-next-enter-from,
.slide-next-leave-to,
.slide-prev-enter-from,
.slide-prev-leave-to {
transform: none;
}
}
</style>

View file

@ -0,0 +1,62 @@
<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,
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,
}
const props = defineProps<{ name: string }>()
</script>
<template>
<component :is="registry[props.name] ?? Layers" />
</template>

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
import SlideFrame from './SlideFrame.vue'
import Icon from './Icon.vue'
import PlaceholderBadge from './PlaceholderBadge.vue'
import type { Location } from '../data'
const props = defineProps<{ location: Location; accent: 'pine' | 'clay' }>()
const tone =
props.accent === 'pine'
? { text: 'text-pine-300', chip: 'bg-pine-500/10 text-pine-300', ring: 'border-pine-500/30' }
: { text: 'text-clay-400', chip: 'bg-clay-400/10 text-clay-400', ring: 'border-clay-400/30' }
</script>
<template>
<SlideFrame :kicker="location.kind">
<div class="flex items-center gap-2">
<h2 class="font-display text-4xl font-semibold text-sand lg:text-5xl">{{ location.name }}</h2>
<PlaceholderBadge label="name" />
</div>
<p class="mt-4 max-w-2xl text-lg text-sand-dim">{{ location.blurb }}</p>
<div class="mt-10 grid gap-8 lg:grid-cols-[1fr_1fr]">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em]" :class="tone.text">
Why it matters
</p>
<ul class="mt-4 space-y-3">
<li
v-for="h in location.highlights"
:key="h"
class="flex items-start gap-3 text-sand-dim"
>
<span class="mt-1 grid size-5 shrink-0 place-items-center rounded-full" :class="tone.chip">
<Icon name="Check" class="size-3" />
</span>
{{ h }}
</li>
</ul>
</div>
<div class="rounded-2xl border p-6" :class="tone.ring">
<p class="text-xs font-semibold uppercase tracking-[0.2em]" :class="tone.text">
Spaces on site
</p>
<div class="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
<div
v-for="s in location.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="accent === 'pine' ? 'bg-pine-400' : 'bg-clay-400'" />
{{ s }}
</div>
</div>
</div>
</div>
</SlideFrame>
</template>

View file

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

View file

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

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

@ -0,0 +1,65 @@
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
}
const index = ref(0)
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(() => {
index.value = readHash()
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),
}
}