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:
parent
7acc2b550d
commit
847f08a975
2 changed files with 138 additions and 13 deletions
|
|
@ -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 }>()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- four categories -->
|
|
||||||
<div class="space-y-2.5">
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<!-- right: four selectable category cards -->
|
||||||
|
<div class="space-y-2.5">
|
||||||
|
<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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue