feat(deck): mobile support — scrollable slides, swipe, desktop notice

Content-heavy slides were clipped on phones (fixed h-dvh + centered
overflow-hidden collided with the top/bottom bars). SlideFrame now
scrolls and top-aligns below md (clearing the header/controls), while
desktop keeps the centered, full-bleed look unchanged. Add horizontal
touch-swipe navigation (vertical scroll still works). Add a shadcn-vue
Alert component and show a mobile-only "Best viewed on desktop" notice
on the home slide; keyboard hint is desktop-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-08-01 00:01:28 +02:00
commit 5cdbb76ab7
7 changed files with 94 additions and 4 deletions

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { type AlertVariants, alertVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
variant?: AlertVariants['variant']
}>()
</script>
<template>
<div data-slot="alert" role="alert" :class="cn(alertVariants({ variant }), props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div data-slot="alert-description" :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<h5 data-slot="alert-title" :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
<slot />
</h5>
</template>

View file

@ -0,0 +1,23 @@
import { cva, type VariantProps } from 'class-variance-authority'
export { default as Alert } from './Alert.vue'
export { default as AlertTitle } from './AlertTitle.vue'
export { default as AlertDescription } from './AlertDescription.vue'
export const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type AlertVariants = VariantProps<typeof alertVariants>

View file

@ -49,6 +49,22 @@ const slides = [
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'))
// swipe navigation (touch) horizontal only, so vertical scrolling still works
let touchX = 0
let touchY = 0
function onTouchStart(e: TouchEvent) {
touchX = e.changedTouches[0].clientX
touchY = e.changedTouches[0].clientY
}
function onTouchEnd(e: TouchEvent) {
const dx = e.changedTouches[0].clientX - touchX
const dy = e.changedTouches[0].clientY - touchY
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
if (dx < 0) next()
else prev()
}
}
</script>
<template>
@ -75,7 +91,7 @@ const transitionName = computed(() => (direction.value === 1 ? 'slide-next' : 's
</header>
<!-- slide stage -->
<main class="h-full w-full">
<main class="h-full w-full" @touchstart.passive="onTouchStart" @touchend.passive="onTouchEnd">
<Transition :name="transitionName" mode="out-in">
<KeepAlive>
<component :is="current.component" :key="current.id" class="h-full w-full" />

View file

@ -10,8 +10,8 @@ defineProps<{
<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'"
class="mx-auto flex h-full w-full max-w-6xl flex-col overflow-y-auto px-5 pt-20 pb-24 md:overflow-visible md:px-14 md:py-14 lg:px-20"
:class="[center ? 'items-center text-center' : '', 'justify-start md:justify-center']"
>
<p
v-if="kicker"

View file

@ -1,6 +1,8 @@
<script setup lang="ts">
import { Monitor } from '@lucide/vue'
import SlideFrame from '../components/SlideFrame.vue'
import PlaceholderBadge from '../components/PlaceholderBadge.vue'
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { brand, surfaceEntities, accentText } from '../data'
</script>
@ -40,7 +42,16 @@ import { brand, surfaceEntities, accentText } from '../data'
Raising now · {{ brand.firstNode }}
</span>
<p class="mt-10 text-xs text-fg-muted">
<!-- mobile-only: this deck is built for desktop -->
<Alert class="mt-8 max-w-sm border-amber-400/40 bg-amber-400/10 text-left md:hidden">
<Monitor class="size-4" />
<AlertTitle>Best viewed on desktop</AlertTitle>
<AlertDescription class="text-fg-muted">
This investment deck is designed for a larger screen. Swipe to browse the highlights.
</AlertDescription>
</Alert>
<p class="mt-10 hidden text-xs text-fg-muted md:block">
Use <kbd class="rounded border border-line-2 px-1.5 py-0.5"></kbd> /
<kbd class="rounded border border-line-2 px-1.5 py-0.5"></kbd> to navigate
</p>