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:
parent
e73072318c
commit
adb6783a02
6 changed files with 385 additions and 0 deletions
147
src/deck/Deck.vue
Normal file
147
src/deck/Deck.vue
Normal 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>
|
||||||
62
src/deck/components/Icon.vue
Normal file
62
src/deck/components/Icon.vue
Normal 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>
|
||||||
59
src/deck/components/LocationDetail.vue
Normal file
59
src/deck/components/LocationDetail.vue
Normal 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>
|
||||||
13
src/deck/components/PlaceholderBadge.vue
Normal file
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
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>
|
||||||
65
src/deck/useDeck.ts
Normal file
65
src/deck/useDeck.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue