diff --git a/src/App.tsx b/src/App.tsx index f9b245db8..980bd95fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -301,7 +301,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 230228d02..6d611b1d2 100644 --- a/src/components/features/onboarding/DebateOwlIntro.tsx +++ b/src/components/features/onboarding/DebateOwlIntro.tsx @@ -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 && } + {/* 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' &&