feat(deck): make the raise slide an interactive cost drill-down

Click any of the four investment categories (or its bar segment) to expand
its line-item breakdown inline; the proportion bar highlights the selected
slice and dims the rest. Buttons are keyboard-focusable with aria-pressed;
respects prefers-reduced-motion. Register ChevronRight in the Icon map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-07-28 18:45:41 +02:00
commit 847f08a975
2 changed files with 138 additions and 13 deletions

View file

@ -29,6 +29,7 @@ import {
Sun, Sun,
Moon, Moon,
MapPin, MapPin,
ChevronRight,
type LucideProps, type LucideProps,
} from '@lucide/vue' } from '@lucide/vue'
import type { FunctionalComponent } from 'vue' import type { FunctionalComponent } from 'vue'
@ -62,6 +63,7 @@ const registry: Record<string, FunctionalComponent<LucideProps>> = {
Sun, Sun,
Moon, Moon,
MapPin, MapPin,
ChevronRight,
} }
const props = defineProps<{ name: string }>() const props = defineProps<{ name: string }>()

View file

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'
import SlideFrame from '../components/SlideFrame.vue' import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import PlaceholderBadge from '../components/PlaceholderBadge.vue' import PlaceholderBadge from '../components/PlaceholderBadge.vue'
@ -17,12 +18,30 @@ const text: Record<string, string> = {
indigo: 'text-indigo-300', indigo: 'text-indigo-300',
clay: 'text-clay-400', clay: 'text-clay-400',
} }
const ring: Record<string, string> = {
amber: 'border-amber-400/50 bg-amber-400/[0.06]',
pine: 'border-pine-500/50 bg-pine-500/[0.06]',
indigo: 'border-indigo-500/50 bg-indigo-500/[0.06]',
clay: 'border-clay-400/50 bg-clay-400/[0.06]',
}
// interactive drill-down: which category is expanded (null = overview)
const selected = ref<string | null>(null)
const selectedCat = computed(() => costCategories.find((c) => c.id === selected.value) ?? null)
function toggle(id: string) {
selected.value = selected.value === id ? null : id
}
const pct = (c: (typeof costCategories)[number]) => (categoryTotal(c) / raiseTotal) * 100
</script> </script>
<template> <template>
<SlideFrame kicker="The raise" title="Where the investment goes"> <SlideFrame
<div class="grid items-center gap-10 lg:grid-cols-[1fr_1.1fr]"> kicker="The raise"
<!-- headline number + proportion bar --> title="Where the investment goes"
subtitle="Tap any category to break down its costs."
>
<div class="grid items-start gap-10 lg:grid-cols-[1fr_1.05fr]">
<!-- left: headline number, proportion bar, live detail panel -->
<div> <div>
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
<span class="font-display text-6xl font-bold text-sand">{{ fmtUsd(raiseTotal) }}</span> <span class="font-display text-6xl font-bold text-sand">{{ fmtUsd(raiseTotal) }}</span>
@ -32,29 +51,99 @@ const text: Record<string, string> = {
One shared core stack, plus a launch for each of the three entities. One shared core stack, plus a launch for each of the three entities.
</p> </p>
<!-- proportion bar doubles as a selector -->
<div class="mt-8 flex h-4 w-full overflow-hidden rounded-full"> <div class="mt-8 flex h-4 w-full overflow-hidden rounded-full">
<div <button
v-for="c in costCategories" v-for="c in costCategories"
:key="c.id" :key="c.id"
:class="barBg[c.accent]" class="h-full cursor-pointer transition-opacity"
:style="{ width: `${(categoryTotal(c) / raiseTotal) * 100}%` }" :class="[barBg[c.accent], selected && selected !== c.id ? 'opacity-30' : 'opacity-100']"
class="h-full" :style="{ width: `${pct(c)}%` }"
:title="`${c.title} · ${fmtUsd(categoryTotal(c))}`"
:aria-label="`Show ${c.title} breakdown`"
@click="toggle(c.id)"
/> />
</div> </div>
<div class="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-sand-dim"> <div class="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-sand-dim">
<span v-for="c in costCategories" :key="c.id" class="flex items-center gap-2"> <span v-for="c in costCategories" :key="c.id" class="flex items-center gap-2">
<span class="size-2 rounded-full" :class="barBg[c.accent]" /> <span class="size-2 rounded-full" :class="barBg[c.accent]" />
{{ Math.round((categoryTotal(c) / raiseTotal) * 100) }}% {{ Math.round(pct(c)) }}%
</span> </span>
</div> </div>
<!-- detail panel: swaps between hint and the selected breakdown -->
<Transition name="detail" mode="out-in">
<div
v-if="selectedCat"
:key="selectedCat.id"
class="mt-6 rounded-2xl border p-5"
:class="ring[selectedCat.accent]"
>
<div class="flex items-center gap-3">
<span class="grid size-9 place-items-center rounded-lg bg-white/5" :class="text[selectedCat.accent]">
<Icon :name="selectedCat.icon" class="size-5" />
</span>
<div class="min-w-0 flex-1">
<h3 class="font-display text-base font-semibold leading-tight text-sand">
{{ selectedCat.title }}
</h3>
<p class="truncate text-xs text-sand-dim">{{ selectedCat.subtitle }}</p>
</div>
<button
class="grid size-7 shrink-0 place-items-center rounded-full border border-white/10 text-sand-dim transition hover:bg-white/10"
aria-label="Close breakdown"
@click="selected = null"
>
<Icon name="ArrowRight" class="size-4 rotate-180" />
</button>
</div>
<ul class="mt-4 space-y-2">
<li
v-for="item in selectedCat.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 class="mt-4 flex items-center justify-between border-t border-white/10 pt-3">
<span class="text-xs uppercase tracking-wider text-sand-dim">Subtotal</span>
<span class="font-display text-lg font-semibold" :class="text[selectedCat.accent]">
{{ fmtUsd(categoryTotal(selectedCat)) }}
</span>
</div>
</div>
<div
v-else
key="hint"
class="mt-6 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" />
Select a category to see its detailed line-item breakdown.
</div>
</Transition>
</div> </div>
<!-- four categories --> <!-- right: four selectable category cards -->
<div class="space-y-2.5"> <div class="space-y-2.5">
<div <button
v-for="c in costCategories" v-for="c in costCategories"
:key="c.id" :key="c.id"
class="flex items-center gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-4" class="flex w-full items-center gap-4 rounded-2xl border p-4 text-left transition"
:class="
selected === c.id
? ring[c.accent]
: 'border-white/10 bg-white/[0.03] hover:border-white/25 hover:bg-white/[0.06]'
"
:aria-pressed="selected === c.id"
@click="toggle(c.id)"
> >
<span class="grid size-10 shrink-0 place-items-center rounded-xl bg-white/5" :class="text[c.accent]"> <span class="grid size-10 shrink-0 place-items-center rounded-xl bg-white/5" :class="text[c.accent]">
<Icon :name="c.icon" class="size-5" /> <Icon :name="c.icon" class="size-5" />
@ -63,9 +152,43 @@ const text: Record<string, string> = {
<h3 class="font-display text-base font-semibold text-sand">{{ c.title }}</h3> <h3 class="font-display text-base font-semibold text-sand">{{ c.title }}</h3>
<p class="truncate text-xs text-sand-dim">{{ c.subtitle }}</p> <p class="truncate text-xs text-sand-dim">{{ c.subtitle }}</p>
</div> </div>
<span class="font-display text-lg font-semibold text-sand">{{ fmtUsd(categoryTotal(c)) }}</span> <span class="shrink-0 font-display text-lg font-semibold text-sand">
</div> {{ fmtUsd(categoryTotal(c)) }}
</span>
<Icon
name="ChevronRight"
class="size-4 shrink-0 text-sand-dim transition-transform"
:class="selected === c.id ? 'rotate-90' : ''"
/>
</button>
<p class="pt-1 text-center text-[11px] text-sand-dim/70">
{{ costCategories.length }} categories · {{ fmtUsd(raiseTotal) }} total
</p>
</div> </div>
</div> </div>
</SlideFrame> </SlideFrame>
</template> </template>
<style scoped>
.detail-enter-active,
.detail-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.detail-enter-from,
.detail-leave-to {
opacity: 0;
transform: translateY(6px);
}
@media (prefers-reduced-motion: reduce) {
.detail-enter-active,
.detail-leave-active {
transition: opacity 0.15s ease;
}
.detail-enter-from,
.detail-leave-to {
transform: none;
}
}
</style>