Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,8 @@ export function RouteContent({ children }: { children: ReactNode }) {
<PageLabelProvider value={setPageLabel}>
<LocalizedRouteErrorBoundary key={location.pathname}>
<Suspense fallback={<PendingRoute />}>
{children}
{/* App pages rise in once as they open. Public pages keep their own reveals and fixed bars. */}
{location.pathname.startsWith('/app') ? <div className="page-enter">{children}</div> : children}
</Suspense>
</LocalizedRouteErrorBoundary>
</PageLabelProvider>
Expand Down
181 changes: 139 additions & 42 deletions src/components/brand/Owl.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState, type CSSProperties } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import point from '@/assets/owl/owl-point.svg';
Expand All @@ -20,8 +21,67 @@ import sleepy from '@/assets/owl/owl-sleepy.svg';
*/
export type OwlPose = 'point' | 'idea' | 'skeptical' | 'surprised' | 'celebrating' | 'thinking' | 'reading' | 'timekeeper' | 'arguing' | 'coach' | 'sleepy';

/** A one-shot move the owl plays when it arrives or changes pose (index.css, "The owl's motion"). */
export type OwlGesture = 'wave' | 'cheer' | 'hop';

const POSES: Record<OwlPose, string> = { point, idea, skeptical, surprised, celebrating, thinking, reading, timekeeper, arguing, coach, sleepy };

const GESTURES: Partial<Record<OwlPose, OwlGesture>> = {
point: 'wave',
coach: 'wave',
arguing: 'wave',
celebrating: 'cheer',
surprised: 'hop',
idea: 'hop',
};

/** Each eye's outline as [left, top, width, height] in % of the square; sleepy's are already shut. */
type EyeBox = readonly [number, number, number, number];
const EYES: Partial<Record<OwlPose | 'mark', readonly [EyeBox, EyeBox]>> = {
arguing: [[34.4, 22.2, 12.8, 14], [56.6, 22.2, 11.8, 14]],
celebrating: [[31.3, 20, 11.7, 12.8], [50.9, 19.4, 10.9, 12.8]],
coach: [[37.7, 23.9, 13, 14.4], [60.4, 23.9, 12.1, 14.4]],
idea: [[41.7, 23.5, 13.4, 14.7], [64.3, 24, 12.5, 14.7]],
point: [[35.5, 22.7, 13.5, 14.8], [59, 22.7, 12.5, 14.8]],
reading: [[33, 25.9, 12.8, 14], [55.2, 25.9, 11.8, 14]],
skeptical: [[29.3, 27.8, 12.2, 11.8], [50.6, 27.4, 11.5, 11.8]],
surprised: [[38.3, 17, 13, 14.3], [60.4, 17.5, 12.1, 14.3]],
thinking: [[31.2, 25.9, 12.8, 14], [53.3, 25.9, 11.8, 14]],
timekeeper: [[30.1, 22.2, 12.8, 14], [52.3, 22.2, 11.8, 14]],
mark: [[27.3, 36.3, 18, 19.5], [54.7, 36.3, 18, 19.5]],
};

// Below this the lids are a pixel or two and the blink reads as flicker.
const MIN_BLINK_SIZE = 28;

/**
* Every owl blinks on its own clock: a random period and phase, fixed for the owl's lifetime
* so a re-render (a streaming reply re-renders often) never restarts it.
*/
function useBlinkClock(): CSSProperties {
const [clock] = useState(() => ({
'--owl-blink-period': `${(4.6 + Math.random() * 2.4).toFixed(2)}s`,
'--owl-blink-delay': `${(-Math.random() * 6).toFixed(2)}s`,
}));
return clock as CSSProperties;
}

/** Two lids over the owl's eyes. They sit flat (scaleY 0) and close briefly on the blink keyframes. */
function OwlLids({ eyes, size }: { eyes: readonly [EyeBox, EyeBox]; size: number }) {
const lash = `${Math.max(1, size * 0.012).toFixed(1)}px`;
return (
<>
{eyes.map(([left, top, width, height], index) => (
<span
key={index}
className="owl-lid"
style={{ left: `${left}%`, top: `${top}%`, width: `${width}%`, height: `${height}%`, '--owl-lash': lash } as CSSProperties}
/>
))}
</>
);
}

// A pose change crossfades with a small pop on the design system's `pop` spring. With reduced
// motion, App's MotionConfig drops the scale and travel and only the fade remains.
const POSE_TRANSITION = { type: 'spring', stiffness: 420, damping: 22, mass: 1, opacity: { duration: 0.16 } } as const;
Expand All @@ -35,35 +95,56 @@ interface OwlProps {
label?: string;
/** Above the fold: load at once instead of when scrolled near. */
priority?: boolean;
/** The move played on arrival and on each pose change; false keeps it still. Defaults by pose. */
gesture?: OwlGesture | false;
/** A slow breath while idle. Off on the landing page, where nothing moves on its own. */
breathe?: boolean;
}

/** The owl, full body. Changing `pose` animates from one pose to the next. */
export function Owl({ pose, size, className, label, priority = false }: OwlProps) {
/**
* The owl, full body. It blinks, breathes, plays its pose's gesture when it arrives, and hops when
* hovered. Changing `pose` animates from one pose to the next. All of it is CSS keyframes, so
* reduced motion (index.css) stops every move and leaves the owl still.
*/
export function Owl({ pose, size, className, label, priority = false, gesture, breathe = true }: OwlProps) {
const clock = useBlinkClock();
const move = gesture === undefined ? GESTURES[pose] : gesture;
const eyes = size >= MIN_BLINK_SIZE ? EYES[pose] : undefined;
return (
<span
className={cn('relative inline-block shrink-0', className)}
style={{ width: size, height: size }}
className={cn('owl relative inline-block shrink-0', className)}
style={{ width: size, height: size, ...clock }}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
>
<AnimatePresence initial={false}>
<motion.img
key={pose}
src={POSES[pose]}
width={size}
height={size}
alt=""
draggable={false}
decoding="async"
loading={priority ? 'eager' : 'lazy'}
className="absolute inset-0 h-full w-full select-none object-contain"
initial={{ opacity: 0, scale: 0.9, y: 6 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 0 }}
transition={POSE_TRANSITION}
/>
</AnimatePresence>
<span className={cn('owl-body absolute inset-0', breathe && 'owl-breathe')}>
<AnimatePresence initial={false}>
<motion.span
key={pose}
className="absolute inset-0"
initial={{ opacity: 0, scale: 0.9, y: 6 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 0 }}
transition={POSE_TRANSITION}
>
{/* Keyed with the pose, so the gesture replays on every change. */}
<span className={cn('absolute inset-0', move && `owl-gesture-${move}`)}>
<img
src={POSES[pose]}
width={size}
height={size}
alt=""
draggable={false}
decoding="async"
loading={priority ? 'eager' : 'lazy'}
className="absolute inset-0 h-full w-full select-none object-contain"
/>
{eyes && <OwlLids eyes={eyes} size={size} />}
</span>
</motion.span>
</AnimatePresence>
</span>
</span>
);
}
Expand All @@ -74,52 +155,68 @@ interface OwlAvatarProps {
size?: number;
className?: string;
label?: string;
/**
* Blink now and then. Off by default: a transcript renders one avatar per message, and only
* the one that is present (a header, a working indicator) should look alive.
*/
blink?: boolean;
}

/** The owl's head and shoulders in a tinted circle: the Ask coach and the Sparring opponent. */
export function OwlAvatar({ pose, size = 40, className, label }: OwlAvatarProps) {
export function OwlAvatar({ pose, size = 40, className, label, blink = false }: OwlAvatarProps) {
const clock = useBlinkClock();
// The avatar zooms 1.6x onto the head, so its lids are big enough from a smaller size.
const eyes = blink && size * 1.6 >= MIN_BLINK_SIZE ? EYES[pose] : undefined;
return (
<span
className={cn(
'relative inline-flex shrink-0 overflow-hidden rounded-full bg-plume-100 ring-2 ring-white dark:bg-plume-900 dark:ring-ink-900',
className,
)}
style={{ width: size, height: size }}
style={{ width: size, height: size, ...clock }}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
>
<AnimatePresence initial={false}>
<motion.img
<motion.span
key={pose}
src={POSES[pose]}
width={size}
height={size}
alt=""
draggable={false}
decoding="async"
// The full pose, zoomed onto the head and shoulders.
className="absolute inset-0 h-full w-full origin-[52%_32%] scale-[1.6] select-none object-contain"
// The full pose, zoomed onto the head and shoulders; the lids zoom with it.
className="absolute inset-0 origin-[52%_32%] scale-[1.6]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
/>
>
<img
src={POSES[pose]}
width={size}
height={size}
alt=""
draggable={false}
decoding="async"
className="absolute inset-0 h-full w-full select-none object-contain"
/>
{eyes && <OwlLids eyes={eyes} size={size * 1.6} />}
</motion.span>
</AnimatePresence>
</span>
);
}

/** The app mark: the owl's head on its sky tile (public/owl-mark.svg, also the favicon source). */
/**
* The app mark: the owl's head on its sky tile (public/owl-mark.svg, also the favicon source).
* It blinks now and then wherever it is big enough to see.
*/
export function OwlMark({ size = 36, className, alt = '' }: { size?: number; className?: string; alt?: string }) {
const clock = useBlinkClock();
return (
<img
src="/owl-mark.svg"
alt={alt}
width={size}
height={size}
decoding="async"
className={cn('shrink-0 rounded-[28%] shadow-[0_2px_0_var(--do-plume-200)] dark:shadow-[0_2px_0_var(--do-plume-950)]', className)}
/>
<span
className={cn('relative block shrink-0 overflow-hidden rounded-[28%] shadow-[0_2px_0_var(--do-plume-200)] dark:shadow-[0_2px_0_var(--do-plume-950)]', className)}
style={{ width: size, height: size, ...clock }}
>
<img src="/owl-mark.svg" alt={alt} width={size} height={size} decoding="async" className="block h-full w-full" />
{size >= MIN_BLINK_SIZE && EYES.mark && <span aria-hidden="true"><OwlLids eyes={EYES.mark} size={size} /></span>}
</span>
);
}
4 changes: 2 additions & 2 deletions src/components/features/ask/AskChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ export function AskChat({ messages, streamingMessage, isLoading, error, onDismis
{/* Wraps on a phone: the Hungarian title and two buttons do not fit one 375 px row. */}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<div className="flex min-w-0 items-center gap-3">
<OwlAvatar pose="coach" size={44} label={t('askChat.coachAvatarAlt', 'The owl, your debate coach')} />
<OwlAvatar pose="coach" size={44} blink label={t('askChat.coachAvatarAlt', 'The owl, your debate coach')} />
<div className="min-w-0">
<CardTitle>{t('askChat.title')}</CardTitle>
<CardDescription>{t('askChat.subtitle')}</CardDescription>
Expand Down Expand Up @@ -619,7 +619,7 @@ export function AskChat({ messages, streamingMessage, isLoading, error, onDismis
// One true status line: what is happening, and how long it has taken once that matters.
<p className="flex items-center gap-2.5 text-sm font-bold text-muted-foreground motion-safe:animate-in fade-in-0 duration-200" role="status" aria-live="polite">
{/* Decorative: a label here would be announced with every status update. */}
<OwlAvatar pose="coach" size={32} />
<OwlAvatar pose="coach" size={32} blink />
<span className="inline-flex items-center gap-2 rounded-[20px] rounded-bl-md border-2 border-border bg-card px-3.5 py-2.5">
{searchingWeb ? t('askChat.searchingWeb', 'Searching the web…') : t('askChat.working', 'Working…')}
<ThinkingDots className="text-primary" />
Expand Down
6 changes: 4 additions & 2 deletions src/components/features/attack/AttackResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AlertCircle, AlertTriangle, Info, Scale, Lightbulb, MessageSquare, Flam
import { Badge } from '@/components/ui/badge';
import { SectionCard } from '@/components/ui/section-card';
import { Progress } from '@/components/ui/progress';
import { CountUp } from '@/components/ui/count-up';
import { Separator } from '@/components/ui/separator';
import { Markdown } from '@/components/ui/markdown';
import type { AttackOutput, Weakness } from '@/lib/ai/schemas/attack';
Expand Down Expand Up @@ -192,16 +193,17 @@ export function AttackResults({ result, discoveredSources, onDiscuss, onPractice
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">{t('attackResults.caseStrength')}</span>
<span className={cn(
'text-lg font-bold',
'text-lg font-bold tabular-nums',
overallAssessment.score >= 70 ? 'text-primary' :
overallAssessment.score >= 50 ? 'text-amber-600 dark:text-amber-400' : 'text-destructive'
)}>
{overallAssessment.score}/100
<CountUp value={overallAssessment.score} />/100
</span>
</div>
<Progress
value={overallAssessment.score}
className="h-3"
reveal
/>
</div>

Expand Down
9 changes: 5 additions & 4 deletions src/components/features/factoids/FactoidsResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { SectionCard } from '@/components/ui/section-card';
import { Progress } from '@/components/ui/progress';
import { CountUp } from '@/components/ui/count-up';
import type { FactoidsOutput, Factoid } from '@/lib/ai/schemas/factoids';
import { cn } from '@/lib/utils';
import { validateUrl } from '@/lib/validation/url';
Expand Down Expand Up @@ -79,7 +80,7 @@ function FactoidCard({ factoid, onSave }: { factoid: Factoid, onSave?: FactoidsR
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Progress value={factoid.relevanceScore} className="w-16 h-2" />
<Progress value={factoid.relevanceScore} className="w-16 h-2" reveal />
{onSave && (
<Button
variant="ghost"
Expand Down Expand Up @@ -235,10 +236,10 @@ export function FactoidsResults({ result, discoveredSources, onDiscuss, onSave,
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<Progress value={(verifiedCount / totalCount) * 100} className="h-3" />
<Progress value={(verifiedCount / totalCount) * 100} className="h-3" reveal />
</div>
<span className="text-sm font-medium text-foreground">
{Math.round((verifiedCount / totalCount) * 100)}%
<span className="text-sm font-medium tabular-nums text-foreground">
<CountUp value={Math.round((verifiedCount / totalCount) * 100)} />%
</span>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/components/features/onboarding/DebateOwlIntro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ function Line({ shown, icon, className, children }: {
animate={shown ? { opacity: 1, y: 0 } : { opacity: 0, y: 6 }}
transition={{ duration: 0.32, ease: EASE_OUT }}
>
{icon && <span className="mt-1 flex h-4 w-5 shrink-0 items-center justify-center" aria-hidden="true">{icon}</span>}
{/* The icon pops in as its line lands; the check draws itself. */}
{icon && <span className={cn('mt-1 flex h-4 w-5 shrink-0 items-center justify-center', shown && 'success-pop [--success-delay:120ms]')} aria-hidden="true">{icon}</span>}
<span>{children}</span>
</motion.li>
);
Expand Down
6 changes: 3 additions & 3 deletions src/components/features/sparring/SparringRound.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ export function SparringRound({
// What you're answering stays in view above the composer, not below it in the transcript.
<article className="rounded-lg border-2 border-border bg-card p-4 sm:p-5" aria-labelledby="sparring-latest-opponent">
<div className="flex items-center gap-2.5">
<OwlAvatar pose="arguing" size={32} label={t('sparring.opponentAvatarAlt', 'The owl, your AI opponent')} />
<OwlAvatar pose="arguing" size={32} blink label={t('sparring.opponentAvatarAlt', 'The owl, your AI opponent')} />
<h2 id="sparring-latest-opponent" className="text-sm font-extrabold text-heading">{t('sparring.round.latestOpponent', 'Opponent’s last speech')}</h2>
<span className="truncate text-xs text-muted-foreground">{t(`sparring.turns.${latestOpponentSpeech.turnId}`, latestOpponentSpeech.role)}</span>
</div>
Expand Down Expand Up @@ -304,7 +304,7 @@ export function SparringRound({
// The opponent's speech as it is written, in the place it will be read.
<article data-testid="sparring-live-speech" className="rounded-lg border-2 border-brand-tint-border bg-card p-4 motion-safe:animate-in fade-in-0 duration-200 sm:p-5" aria-busy="true" aria-labelledby="sparring-live-opponent">
<div className="flex items-center gap-2.5">
<OwlAvatar pose="arguing" size={32} label={t('sparring.opponentAvatarAlt', 'The owl, your AI opponent')} />
<OwlAvatar pose="arguing" size={32} blink label={t('sparring.opponentAvatarAlt', 'The owl, your AI opponent')} />
<h2 id="sparring-live-opponent" className="text-sm font-extrabold text-heading">{t('sparring.round.opponentSpeaking', 'Opponent is speaking')}</h2>
{currentTurn && <span className="truncate text-xs text-muted-foreground">{t(`sparring.turns.${currentTurn.id}`, currentTurn.label)}</span>}
</div>
Expand Down Expand Up @@ -389,7 +389,7 @@ export function SparringRound({
return (
<div key={turn.id} className={cn('flex items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors duration-300', current && 'bg-brand-tint text-foreground', complete && 'text-muted-foreground', !current && !complete && 'text-muted-foreground')}>
<span className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-full border-2 text-xs font-bold tabular-nums transition-colors duration-300', complete ? 'border-success bg-success text-success-foreground' : current ? 'border-brand bg-brand text-white shadow-[0_2px_0_var(--do-primary-ledge)]' : 'border-border-strong text-muted-foreground')}>
{complete ? <Check className="h-3.5 w-3.5 motion-safe:animate-in zoom-in-50 duration-300 ease-overshoot" strokeWidth={3.5} aria-label={t('sparring.round.completeStatus', 'Complete')} /> : index + 1}
{complete ? <Check className="success-pop h-3.5 w-3.5" strokeWidth={3.5} aria-label={t('sparring.round.completeStatus', 'Complete')} /> : index + 1}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-bold">{t(`sparring.turns.${turn.id}`, turn.label)}</span>
Expand Down
Loading
Loading