Skip to content

Commit a842181

Browse files
committed
feat(startup): add startup intro overlay and user preference toggle
- Introduce overlay shown on app launch - Integrate visibility controlled via setting - Add toggle in with immediate persistence and analytics event - Add shimmer overlay and brand text animation utilities to
1 parent 6775ee2 commit a842181

File tree

4 files changed

+227
-1
lines changed

4 files changed

+227
-1
lines changed

src/App.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { TabContent } from "@/components/TabContent";
2525
import { useTabState } from "@/hooks/useTabState";
2626
import { AnalyticsConsentBanner } from "@/components/AnalyticsConsent";
2727
import { useAppLifecycle, useTrackEvent } from "@/hooks";
28+
import { StartupIntro } from "@/components/StartupIntro";
2829

2930
type View =
3031
| "welcome"
@@ -486,11 +487,47 @@ function AppContent() {
486487
* Main App component - Wraps the app with providers
487488
*/
488489
function App() {
490+
const [showIntro, setShowIntro] = useState(() => {
491+
// Read cached preference synchronously to avoid any initial flash
492+
try {
493+
const cached = typeof window !== 'undefined'
494+
? window.localStorage.getItem('app_setting:startup_intro_enabled')
495+
: null;
496+
if (cached === 'true') return true;
497+
if (cached === 'false') return false;
498+
} catch (_ignore) {}
499+
return true; // default if no cache
500+
});
501+
502+
useEffect(() => {
503+
let timer: number | undefined;
504+
(async () => {
505+
try {
506+
const pref = await api.getSetting('startup_intro_enabled');
507+
const enabled = pref === null ? true : pref === 'true';
508+
if (enabled) {
509+
// keep intro visible and hide after duration
510+
timer = window.setTimeout(() => setShowIntro(false), 2000);
511+
} else {
512+
// user disabled intro: hide immediately to avoid any overlay delay
513+
setShowIntro(false);
514+
}
515+
} catch (err) {
516+
// On failure, show intro once to keep UX consistent
517+
timer = window.setTimeout(() => setShowIntro(false), 2000);
518+
}
519+
})();
520+
return () => {
521+
if (timer) window.clearTimeout(timer);
522+
};
523+
}, []);
524+
489525
return (
490526
<ThemeProvider>
491527
<OutputCacheProvider>
492528
<TabProvider>
493529
<AppContent />
530+
<StartupIntro visible={showIntro} />
494531
</TabProvider>
495532
</OutputCacheProvider>
496533
</ThemeProvider>

src/assets/shimmer.css

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@
5252
}
5353
}
5454

55+
/* Overlay variant: keeps the overlay text transparent and simply fades it out */
56+
@keyframes shimmer-overlay {
57+
0% {
58+
background-position: -150% center;
59+
opacity: 1;
60+
}
61+
100% {
62+
background-position: 150% center;
63+
opacity: 0;
64+
}
65+
}
66+
5567
@keyframes symbol-rotate {
5668
0% {
5769
content: '◐';
@@ -99,6 +111,38 @@
99111
animation: shimmer-text 1s ease-out forwards;
100112
}
101113

114+
/* Ensures text remains visible after shimmer completes on engines
115+
where -webkit-text-fill-color set in keyframes may not persist */
116+
.shimmer-fallback-visible {
117+
-webkit-text-fill-color: currentColor !important;
118+
background: none !important;
119+
}
120+
121+
/* Layered brand text: base solid text plus shimmering overlay to avoid flicker */
122+
.brand-text { position: relative; display: inline-block; }
123+
.brand-text-solid { position: relative; color: currentColor; }
124+
.brand-text-shimmer {
125+
position: absolute;
126+
inset: 0;
127+
color: transparent;
128+
-webkit-text-fill-color: transparent;
129+
background: linear-gradient(
130+
90deg,
131+
transparent 0%,
132+
transparent 47%,
133+
rgba(217, 119, 87, 0.35) 50%,
134+
transparent 53%,
135+
transparent 100%
136+
);
137+
background-size: 300% auto;
138+
background-position: -150% center;
139+
-webkit-background-clip: text;
140+
background-clip: text;
141+
pointer-events: none;
142+
will-change: background-position, opacity;
143+
animation: shimmer-overlay 1.1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
144+
}
145+
102146
.rotating-symbol {
103147
display: inline-block;
104148
color: #d97757;
@@ -120,6 +164,11 @@
120164
vertical-align: baseline;
121165
}
122166

167+
/* Allow pausing the rotating symbol via an extra class */
168+
.rotating-symbol.paused::before {
169+
animation: none !important;
170+
}
171+
123172
.shimmer-hover {
124173
position: relative;
125174
overflow: hidden;
@@ -153,4 +202,4 @@
153202

154203
.shimmer-hover:hover::before {
155204
animation: shimmer 1s ease-out;
156-
}
205+
}

src/components/Settings.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ export const Settings: React.FC<SettingsProps> = ({
9797

9898
// Tab persistence state
9999
const [tabPersistenceEnabled, setTabPersistenceEnabled] = useState(true);
100+
// Startup intro preference
101+
const [startupIntroEnabled, setStartupIntroEnabled] = useState(true);
100102

101103
// Load settings on mount
102104
useEffect(() => {
@@ -105,6 +107,11 @@ export const Settings: React.FC<SettingsProps> = ({
105107
loadAnalyticsSettings();
106108
// Load tab persistence setting
107109
setTabPersistenceEnabled(TabPersistenceService.isEnabled());
110+
// Load startup intro setting (default to true if not set)
111+
(async () => {
112+
const pref = await api.getSetting('startup_intro_enabled');
113+
setStartupIntroEnabled(pref === null ? true : pref === 'true');
114+
})();
108115
}, []);
109116

110117
/**
@@ -737,6 +744,35 @@ export const Settings: React.FC<SettingsProps> = ({
737744
}}
738745
/>
739746
</div>
747+
748+
{/* Startup Intro Toggle */}
749+
<div className="flex items-center justify-between">
750+
<div className="space-y-1">
751+
<Label htmlFor="startup-intro">Show Welcome Intro on Startup</Label>
752+
<p className="text-caption text-muted-foreground">
753+
Display a brief welcome animation when the app launches
754+
</p>
755+
</div>
756+
<Switch
757+
id="startup-intro"
758+
checked={startupIntroEnabled}
759+
onCheckedChange={async (checked) => {
760+
setStartupIntroEnabled(checked);
761+
try {
762+
await api.saveSetting('startup_intro_enabled', checked ? 'true' : 'false');
763+
trackEvent.settingsChanged('startup_intro_enabled', checked);
764+
setToast({
765+
message: checked
766+
? 'Welcome intro enabled'
767+
: 'Welcome intro disabled',
768+
type: 'success'
769+
});
770+
} catch (e) {
771+
setToast({ message: 'Failed to update preference', type: 'error' });
772+
}
773+
}}
774+
/>
775+
</div>
740776
</div>
741777
</div>
742778
</Card>

src/components/StartupIntro.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { AnimatePresence, motion } from "framer-motion";
2+
import claudiaLogo from "../../src-tauri/icons/icon.png";
3+
import type { CSSProperties } from "react";
4+
5+
/**
6+
* StartupIntro - a lightweight startup overlay shown on app launch.
7+
* - Non-interactive; auto-fades after parent hides it via the `visible` prop.
8+
* - Uses existing shimmer/rotating-symbol styles from shimmer.css.
9+
*/
10+
export function StartupIntro({ visible }: { visible: boolean }) {
11+
// Simple entrance animations only
12+
return (
13+
<AnimatePresence>
14+
{visible && (
15+
<motion.div
16+
initial={{ opacity: 1 }}
17+
animate={{ opacity: 1 }}
18+
exit={{ opacity: 0 }}
19+
transition={{ duration: 0.35 }}
20+
className="fixed inset-0 z-[60] flex items-center justify-center bg-background"
21+
aria-hidden="true"
22+
>
23+
{/* Ambient radial glow */}
24+
<motion.div
25+
className="absolute inset-0"
26+
initial={{ opacity: 0 }}
27+
animate={{ opacity: 1 }}
28+
transition={{ duration: 0.25 }}
29+
style={{
30+
background:
31+
"radial-gradient(800px circle at 50% 55%, var(--color-primary)/8, transparent 65%)",
32+
pointerEvents: "none",
33+
} as CSSProperties}
34+
/>
35+
36+
{/* Subtle vignette */}
37+
<div
38+
className="absolute inset-0 pointer-events-none"
39+
style={{
40+
background:
41+
"radial-gradient(1200px circle at 50% 40%, transparent 60%, rgba(0,0,0,0.25))",
42+
}}
43+
/>
44+
45+
{/* Content */}
46+
<motion.div
47+
initial={{ opacity: 0 }}
48+
animate={{ opacity: 1 }}
49+
transition={{ type: "spring", stiffness: 280, damping: 22 }}
50+
className="relative flex flex-col items-center justify-center gap-1"
51+
>
52+
53+
{/* Claudia logo slides left; brand text reveals to the right */}
54+
<div className="relative flex items-center justify-center">
55+
{/* Logo wrapper that gently slides left */}
56+
<motion.div
57+
className="relative z-10"
58+
initial={{ opacity: 0, scale: 1, x: 0 }}
59+
animate={{ opacity: 1, scale: 1, x: -14 }}
60+
transition={{ duration: 0.35, ease: "easeOut", delay: 0.2 }}
61+
>
62+
<motion.div
63+
className="absolute inset-0 rounded-full bg-primary/15 blur-2xl"
64+
initial={{ opacity: 0 }}
65+
animate={{ opacity: [0, 1, 0.9] }}
66+
transition={{ duration: 0.9, ease: "easeOut" }}
67+
/>
68+
<motion.img
69+
src={claudiaLogo}
70+
alt="Claudia"
71+
className="h-20 w-20 rounded-lg shadow-sm"
72+
transition={{ repeat: Infinity, repeatType: "loop", ease: "linear", duration: 0.5 }}
73+
/>
74+
</motion.div>
75+
76+
{/* Brand text reveals left-to-right in the freed space */}
77+
<motion.div
78+
initial={{ x: -35, opacity: 0, clipPath: "inset(0 100% 0 0)" }}
79+
animate={{ x: 2, opacity: 1, clipPath: "inset(0 0% 0 0)" }}
80+
transition={{ duration: 0.6, ease: "easeOut", delay: 0.1 }}
81+
style={{ willChange: "transform, opacity, clip-path" }}
82+
>
83+
<BrandText />
84+
</motion.div>
85+
</div>
86+
87+
88+
</motion.div>
89+
</motion.div>
90+
)}
91+
</AnimatePresence>
92+
);
93+
}
94+
95+
export default StartupIntro;
96+
97+
function BrandText() {
98+
return (
99+
<div className="text-5xl font-extrabold tracking-tight brand-text">
100+
<span className="brand-text-solid">Claudia</span>
101+
<span aria-hidden="true" className="brand-text-shimmer">Claudia</span>
102+
</div>
103+
);
104+
}

0 commit comments

Comments
 (0)