From ceaf606af2a705f5e9942899d0f6a057fcca2ccc Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 27 Oct 2026 09:14:11 +0000 Subject: [PATCH] Ollie blinks, breathes and waves, and results land with motion The owl blinks on its own clock, breathes while idle, and plays a one-shot gesture when it arrives or changes pose: a wave for point, coach and arguing, a double jump for celebrating, a hop for surprised and idea. Hovering it makes it hop. The app mark blinks too. App pages rise in once as they open, Home's sections and tool rows follow each other in, result scores count up and their bars fill from empty, and success checks pop and draw themselves. Everything is a CSS transform or opacity keyframe, so reduced motion stops all of it and nothing shifts layout. Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 3 +- src/components/brand/Owl.tsx | 181 ++++++++++++++---- src/components/features/ask/AskChat.tsx | 4 +- .../features/attack/AttackResults.tsx | 6 +- .../features/factoids/FactoidsResults.tsx | 9 +- .../features/onboarding/DebateOwlIntro.tsx | 3 +- .../features/sparring/SparringRound.tsx | 6 +- .../features/sparring/SparringScorecard.tsx | 5 +- src/components/features/tools/live-draft.tsx | 2 +- .../features/tools/result-actions.tsx | 2 +- src/components/features/tools/tool-run.tsx | 2 +- src/components/landing/Hero.tsx | 2 +- src/components/ui/count-up.tsx | 16 ++ src/components/ui/loading-spinner.tsx | 2 +- src/components/ui/progress.tsx | 9 +- src/hooks/use-count-up.ts | 38 ++++ src/index.css | 178 ++++++++++++++++- src/pages/HomePage.tsx | 5 +- 18 files changed, 405 insertions(+), 68 deletions(-) create mode 100644 src/components/ui/count-up.tsx create mode 100644 src/hooks/use-count-up.ts diff --git a/src/App.tsx b/src/App.tsx index 1c3e0dd2c..a3ca0336c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -277,7 +277,8 @@ export function RouteContent({ children }: { children: ReactNode }) { }> - {children} + {/* App pages rise in once as they open. Public pages keep their own reveals and fixed bars. */} + {location.pathname.startsWith('/app') ?
{children}
: children}
diff --git a/src/components/brand/Owl.tsx b/src/components/brand/Owl.tsx index a8e6ee211..ffc664cee 100644 --- a/src/components/brand/Owl.tsx +++ b/src/components/brand/Owl.tsx @@ -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'; @@ -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 = { point, idea, skeptical, surprised, celebrating, thinking, reading, timekeeper, arguing, coach, sleepy }; +const GESTURES: Partial> = { + 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> = { + 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) => ( + + ))} + + ); +} + // 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; @@ -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 ( - - - + + + + {/* Keyed with the pose, so the gesture replays on every change. */} + + + {eyes && } + + + + ); } @@ -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 ( - + > + + {eyes && } + ); } -/** 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 ( - {alt} + + {alt} + {size >= MIN_BLINK_SIZE && EYES.mark && } + ); } diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx index 726e6898b..c6418b917 100644 --- a/src/components/features/ask/AskChat.tsx +++ b/src/components/features/ask/AskChat.tsx @@ -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. */}
- +
{t('askChat.title')} {t('askChat.subtitle')} @@ -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.

{/* Decorative: a label here would be announced with every status update. */} - + {searchingWeb ? t('askChat.searchingWeb', 'Searching the web…') : t('askChat.working', 'Working…')} diff --git a/src/components/features/attack/AttackResults.tsx b/src/components/features/attack/AttackResults.tsx index e79b76dba..2b6260b9a 100644 --- a/src/components/features/attack/AttackResults.tsx +++ b/src/components/features/attack/AttackResults.tsx @@ -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'; @@ -192,16 +193,17 @@ export function AttackResults({ result, discoveredSources, onDiscuss, onPractice

{t('attackResults.caseStrength')} = 70 ? 'text-primary' : overallAssessment.score >= 50 ? 'text-amber-600 dark:text-amber-400' : 'text-destructive' )}> - {overallAssessment.score}/100 + /100
diff --git a/src/components/features/factoids/FactoidsResults.tsx b/src/components/features/factoids/FactoidsResults.tsx index d77d31a7b..f30f209a8 100644 --- a/src/components/features/factoids/FactoidsResults.tsx +++ b/src/components/features/factoids/FactoidsResults.tsx @@ -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'; @@ -79,7 +80,7 @@ function FactoidCard({ factoid, onSave }: { factoid: Factoid, onSave?: FactoidsR
- + {onSave && (
diff --git a/src/components/features/onboarding/DebateOwlIntro.tsx b/src/components/features/onboarding/DebateOwlIntro.tsx index d96467540..2acec9d89 100644 --- a/src/components/features/onboarding/DebateOwlIntro.tsx +++ b/src/components/features/onboarding/DebateOwlIntro.tsx @@ -141,7 +141,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 && } + {/* The icon pops in as its line lands; the check draws itself. */} + {icon && } {children} ); diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx index 685ead930..b55fa27ec 100644 --- a/src/components/features/sparring/SparringRound.tsx +++ b/src/components/features/sparring/SparringRound.tsx @@ -255,7 +255,7 @@ export function SparringRound({ // What you're answering stays in view above the composer, not below it in the transcript.
- +

{t('sparring.round.latestOpponent', 'Opponent’s last speech')}

{t(`sparring.turns.${latestOpponentSpeech.turnId}`, latestOpponentSpeech.role)}
@@ -304,7 +304,7 @@ export function SparringRound({ // The opponent's speech as it is written, in the place it will be read.
- +

{t('sparring.round.opponentSpeaking', 'Opponent is speaking')}

{currentTurn && {t(`sparring.turns.${currentTurn.id}`, currentTurn.label)}}
@@ -389,7 +389,7 @@ export function SparringRound({ return (
- {complete ? : index + 1} + {complete ? : index + 1} {t(`sparring.turns.${turn.id}`, turn.label)} diff --git a/src/components/features/sparring/SparringScorecard.tsx b/src/components/features/sparring/SparringScorecard.tsx index 8cfd9caa2..c746edbb3 100644 --- a/src/components/features/sparring/SparringScorecard.tsx +++ b/src/components/features/sparring/SparringScorecard.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Owl, OwlAvatar } from '@/components/brand/Owl'; +import { CountUp } from '@/components/ui/count-up'; import { useTranslation } from 'react-i18next'; import { Award, BarChart3, Download, FileText, Lightbulb, Mic, RotateCcw, Sparkles, Target, UserRound, Check } from 'lucide-react'; import { PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart, ResponsiveContainer, Tooltip } from 'recharts'; @@ -74,7 +75,7 @@ export function SparringScorecard({ session, scorecard, onRestart, onExport, onE

{winnerText}

- {scorecard.winner === 'user' && + {scorecard.winner === 'user' &&