izzicooki Claude Sonnet 4.6 commited on
Commit
fca46f5
·
1 Parent(s): b66c66f

feat(client): Task 5 — React frontend Chat UI + Onboarding for PC Pal

Browse files

Implements the complete elderly-friendly React frontend:
- globals.css: 18px+ fonts, high-contrast colors, large touch targets, CSS vars
- useChat.js: WebSocket hook with init/typing/response/error handling + auto-reconnect
- useUser.js: localStorage persistence, GET/POST/PUT user API calls
- ChatWindow.jsx: full-height layout, auto-scroll, typing indicator
- MessageBubble.jsx: user/assistant alignment, safety alert banner
- MessageInput.jsx: 56px+ input, Enter-to-send, auto-focus, disabled while typing
- OnboardingFlow.jsx: 3-step wizard (name, OS, comfort level), progress dots
- Header.jsx: branded header with user info display
- App.jsx: onboarding gate → chat UI routing via useUser
- main.jsx: imports globals.css, renders App in StrictMode
- index.css: stripped to minimal reset (globals.css handles styling)
- Removed Vite boilerplate: App.css, assets/react.svg, assets/vite.svg

Build verified: vite build completes with 0 errors (28 modules, 201KB JS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

client/src/App.css DELETED
@@ -1,184 +0,0 @@
1
- .counter {
2
- font-size: 16px;
3
- padding: 5px 10px;
4
- border-radius: 5px;
5
- color: var(--accent);
6
- background: var(--accent-bg);
7
- border: 2px solid transparent;
8
- transition: border-color 0.3s;
9
- margin-bottom: 24px;
10
-
11
- &:hover {
12
- border-color: var(--accent-border);
13
- }
14
- &:focus-visible {
15
- outline: 2px solid var(--accent);
16
- outline-offset: 2px;
17
- }
18
- }
19
-
20
- .hero {
21
- position: relative;
22
-
23
- .base,
24
- .framework,
25
- .vite {
26
- inset-inline: 0;
27
- margin: 0 auto;
28
- }
29
-
30
- .base {
31
- width: 170px;
32
- position: relative;
33
- z-index: 0;
34
- }
35
-
36
- .framework,
37
- .vite {
38
- position: absolute;
39
- }
40
-
41
- .framework {
42
- z-index: 1;
43
- top: 34px;
44
- height: 28px;
45
- transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
46
- scale(1.4);
47
- }
48
-
49
- .vite {
50
- z-index: 0;
51
- top: 107px;
52
- height: 26px;
53
- width: auto;
54
- transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
55
- scale(0.8);
56
- }
57
- }
58
-
59
- #center {
60
- display: flex;
61
- flex-direction: column;
62
- gap: 25px;
63
- place-content: center;
64
- place-items: center;
65
- flex-grow: 1;
66
-
67
- @media (max-width: 1024px) {
68
- padding: 32px 20px 24px;
69
- gap: 18px;
70
- }
71
- }
72
-
73
- #next-steps {
74
- display: flex;
75
- border-top: 1px solid var(--border);
76
- text-align: left;
77
-
78
- & > div {
79
- flex: 1 1 0;
80
- padding: 32px;
81
- @media (max-width: 1024px) {
82
- padding: 24px 20px;
83
- }
84
- }
85
-
86
- .icon {
87
- margin-bottom: 16px;
88
- width: 22px;
89
- height: 22px;
90
- }
91
-
92
- @media (max-width: 1024px) {
93
- flex-direction: column;
94
- text-align: center;
95
- }
96
- }
97
-
98
- #docs {
99
- border-right: 1px solid var(--border);
100
-
101
- @media (max-width: 1024px) {
102
- border-right: none;
103
- border-bottom: 1px solid var(--border);
104
- }
105
- }
106
-
107
- #next-steps ul {
108
- list-style: none;
109
- padding: 0;
110
- display: flex;
111
- gap: 8px;
112
- margin: 32px 0 0;
113
-
114
- .logo {
115
- height: 18px;
116
- }
117
-
118
- a {
119
- color: var(--text-h);
120
- font-size: 16px;
121
- border-radius: 6px;
122
- background: var(--social-bg);
123
- display: flex;
124
- padding: 6px 12px;
125
- align-items: center;
126
- gap: 8px;
127
- text-decoration: none;
128
- transition: box-shadow 0.3s;
129
-
130
- &:hover {
131
- box-shadow: var(--shadow);
132
- }
133
- .button-icon {
134
- height: 18px;
135
- width: 18px;
136
- }
137
- }
138
-
139
- @media (max-width: 1024px) {
140
- margin-top: 20px;
141
- flex-wrap: wrap;
142
- justify-content: center;
143
-
144
- li {
145
- flex: 1 1 calc(50% - 8px);
146
- }
147
-
148
- a {
149
- width: 100%;
150
- justify-content: center;
151
- box-sizing: border-box;
152
- }
153
- }
154
- }
155
-
156
- #spacer {
157
- height: 88px;
158
- border-top: 1px solid var(--border);
159
- @media (max-width: 1024px) {
160
- height: 48px;
161
- }
162
- }
163
-
164
- .ticks {
165
- position: relative;
166
- width: 100%;
167
-
168
- &::before,
169
- &::after {
170
- content: '';
171
- position: absolute;
172
- top: -4.5px;
173
- border: 5px solid transparent;
174
- }
175
-
176
- &::before {
177
- left: 0;
178
- border-left-color: var(--border);
179
- }
180
- &::after {
181
- right: 0;
182
- border-right-color: var(--border);
183
- }
184
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
client/src/App.jsx CHANGED
@@ -1,121 +1,62 @@
1
- import { useState } from 'react'
2
- import reactLogo from './assets/react.svg'
3
- import viteLogo from './assets/vite.svg'
4
- import heroImg from './assets/hero.png'
5
- import './App.css'
 
6
 
 
 
 
 
 
 
7
  function App() {
8
- const [count, setCount] = useState(0)
9
 
10
- return (
11
- <>
12
- <section id="center">
13
- <div className="hero">
14
- <img src={heroImg} className="base" width="170" height="179" alt="" />
15
- <img src={reactLogo} className="framework" alt="React logo" />
16
- <img src={viteLogo} className="vite" alt="Vite logo" />
17
- </div>
18
- <div>
19
- <h1>Get started</h1>
20
- <p>
21
- Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
22
- </p>
23
- </div>
24
- <button
25
- className="counter"
26
- onClick={() => setCount((count) => count + 1)}
27
- >
28
- Count is {count}
29
- </button>
30
- </section>
31
-
32
- <div className="ticks"></div>
33
 
34
- <section id="next-steps">
35
- <div id="docs">
36
- <svg className="icon" role="presentation" aria-hidden="true">
37
- <use href="/icons.svg#documentation-icon"></use>
38
- </svg>
39
- <h2>Documentation</h2>
40
- <p>Your questions, answered</p>
41
- <ul>
42
- <li>
43
- <a href="https://vite.dev/" target="_blank">
44
- <img className="logo" src={viteLogo} alt="" />
45
- Explore Vite
46
- </a>
47
- </li>
48
- <li>
49
- <a href="https://react.dev/" target="_blank">
50
- <img className="button-icon" src={reactLogo} alt="" />
51
- Learn more
52
- </a>
53
- </li>
54
- </ul>
55
- </div>
56
- <div id="social">
57
- <svg className="icon" role="presentation" aria-hidden="true">
58
- <use href="/icons.svg#social-icon"></use>
59
- </svg>
60
- <h2>Connect with us</h2>
61
- <p>Join the Vite community</p>
62
- <ul>
63
- <li>
64
- <a href="https://github.com/vitejs/vite" target="_blank">
65
- <svg
66
- className="button-icon"
67
- role="presentation"
68
- aria-hidden="true"
69
- >
70
- <use href="/icons.svg#github-icon"></use>
71
- </svg>
72
- GitHub
73
- </a>
74
- </li>
75
- <li>
76
- <a href="https://chat.vite.dev/" target="_blank">
77
- <svg
78
- className="button-icon"
79
- role="presentation"
80
- aria-hidden="true"
81
- >
82
- <use href="/icons.svg#discord-icon"></use>
83
- </svg>
84
- Discord
85
- </a>
86
- </li>
87
- <li>
88
- <a href="https://x.com/vite_js" target="_blank">
89
- <svg
90
- className="button-icon"
91
- role="presentation"
92
- aria-hidden="true"
93
- >
94
- <use href="/icons.svg#x-icon"></use>
95
- </svg>
96
- X.com
97
- </a>
98
- </li>
99
- <li>
100
- <a href="https://bsky.app/profile/vite.dev" target="_blank">
101
- <svg
102
- className="button-icon"
103
- role="presentation"
104
- aria-hidden="true"
105
- >
106
- <use href="/icons.svg#bluesky-icon"></use>
107
- </svg>
108
- Bluesky
109
- </a>
110
- </li>
111
- </ul>
112
- </div>
113
- </section>
114
 
115
- <div className="ticks"></div>
116
- <section id="spacer"></section>
117
- </>
118
- )
 
 
 
 
 
 
 
 
 
119
  }
120
 
121
- export default App
 
1
+ import React from 'react';
2
+ import { useUser } from './hooks/useUser';
3
+ import OnboardingFlow from './components/Onboarding/OnboardingFlow';
4
+ import Header from './components/Layout/Header';
5
+ import ChatWindow from './components/Chat/ChatWindow';
6
+ import './styles/globals.css';
7
 
8
+ /**
9
+ * App — root component for PC Pal.
10
+ *
11
+ * Shows the OnboardingFlow for new users.
12
+ * Once onboarded, shows the Header + ChatWindow.
13
+ */
14
  function App() {
15
+ const { user, isOnboarded, isLoading, createUser, completeOnboarding } = useUser();
16
 
17
+ // While checking localStorage / fetching user, show a loading screen
18
+ if (isLoading) {
19
+ return (
20
+ <div
21
+ style={{
22
+ minHeight: '100vh',
23
+ display: 'flex',
24
+ alignItems: 'center',
25
+ justifyContent: 'center',
26
+ fontSize: '22px',
27
+ color: 'var(--color-text-light)',
28
+ }}
29
+ role="status"
30
+ aria-live="polite"
31
+ >
32
+ Loading PC Pal...
33
+ </div>
34
+ );
35
+ }
 
 
 
 
36
 
37
+ // Not yet onboarded — show the wizard
38
+ if (!isOnboarded) {
39
+ return (
40
+ <OnboardingFlow
41
+ createUser={createUser}
42
+ completeOnboarding={completeOnboarding}
43
+ />
44
+ );
45
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
+ // Onboarded — show the main chat UI
48
+ return (
49
+ <div
50
+ style={{
51
+ display: 'flex',
52
+ flexDirection: 'column',
53
+ minHeight: '100vh',
54
+ }}
55
+ >
56
+ <Header user={user} />
57
+ <ChatWindow userId={user?.id} />
58
+ </div>
59
+ );
60
  }
61
 
62
+ export default App;
client/src/assets/react.svg DELETED
client/src/assets/vite.svg DELETED
client/src/components/Chat/ChatWindow.css ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ChatWindow styles */
2
+
3
+ .chat-window {
4
+ display: flex;
5
+ flex-direction: column;
6
+ flex: 1;
7
+ height: 0; /* allows flex children to scroll */
8
+ min-height: 0;
9
+ background-color: var(--color-bg);
10
+ position: relative;
11
+ }
12
+
13
+ /* Connection status banner */
14
+ .connection-banner {
15
+ background-color: var(--color-warning-light);
16
+ color: var(--color-warning);
17
+ border-bottom: 2px solid var(--color-warning);
18
+ font-size: 18px;
19
+ font-weight: 600;
20
+ text-align: center;
21
+ padding: 12px 20px;
22
+ flex-shrink: 0;
23
+ }
24
+
25
+ /* Scrollable message list */
26
+ .chat-messages {
27
+ flex: 1;
28
+ overflow-y: auto;
29
+ padding: 24px 20px;
30
+ display: flex;
31
+ flex-direction: column;
32
+ scroll-behavior: smooth;
33
+ }
34
+
35
+ /* Responsive max width for readability */
36
+ @media (min-width: 768px) {
37
+ .chat-messages {
38
+ padding: 28px 40px;
39
+ }
40
+ }
41
+
42
+ /* Empty state */
43
+ .chat-empty {
44
+ flex: 1;
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ text-align: center;
49
+ padding: 40px;
50
+ }
51
+
52
+ .chat-empty__text {
53
+ font-size: 22px;
54
+ color: var(--color-text-light);
55
+ line-height: 1.7;
56
+ max-width: 480px;
57
+ border: 2px dashed var(--color-border);
58
+ border-radius: var(--radius-lg);
59
+ padding: 32px 40px;
60
+ background-color: var(--color-white);
61
+ }
62
+
63
+ /* Typing indicator */
64
+ .typing-indicator {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 12px;
68
+ padding: 12px 0 4px;
69
+ margin-bottom: 4px;
70
+ }
71
+
72
+ .typing-indicator__dots {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 5px;
76
+ background-color: var(--color-white);
77
+ border: 1px solid var(--color-border);
78
+ border-radius: var(--radius-full);
79
+ padding: 10px 16px;
80
+ box-shadow: 0 1px 4px var(--color-shadow);
81
+ }
82
+
83
+ .typing-indicator__dots span {
84
+ display: block;
85
+ width: 10px;
86
+ height: 10px;
87
+ border-radius: 50%;
88
+ background-color: var(--color-primary);
89
+ animation: pulse 1.4s ease-in-out infinite;
90
+ }
91
+
92
+ .typing-indicator__dots span:nth-child(2) {
93
+ animation-delay: 0.2s;
94
+ }
95
+
96
+ .typing-indicator__dots span:nth-child(3) {
97
+ animation-delay: 0.4s;
98
+ }
99
+
100
+ .typing-indicator__label {
101
+ font-size: 17px;
102
+ color: var(--color-text-light);
103
+ font-style: italic;
104
+ }
105
+
106
+ @keyframes pulse {
107
+ 0%, 100% { opacity: 1; transform: scale(1); }
108
+ 50% { opacity: 0.3; transform: scale(0.8); }
109
+ }
client/src/components/Chat/ChatWindow.jsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { useChat } from '../../hooks/useChat';
3
+ import MessageBubble from './MessageBubble';
4
+ import MessageInput from './MessageInput';
5
+ import './ChatWindow.css';
6
+
7
+ /**
8
+ * ChatWindow — main chat container.
9
+ *
10
+ * - Full-height layout with scrollable message list above input
11
+ * - Auto-scrolls to the latest message
12
+ * - Shows "PC Pal is typing..." indicator
13
+ */
14
+ function ChatWindow({ userId }) {
15
+ const { messages, sendMessage, isConnected, isTyping } = useChat(userId);
16
+ const bottomRef = useRef(null);
17
+ const listRef = useRef(null);
18
+
19
+ // Auto-scroll to the newest message
20
+ useEffect(() => {
21
+ if (bottomRef.current) {
22
+ bottomRef.current.scrollIntoView({ behavior: 'smooth' });
23
+ }
24
+ }, [messages, isTyping]);
25
+
26
+ return (
27
+ <div className="chat-window">
28
+ {/* Connection status banner */}
29
+ {!isConnected && (
30
+ <div className="connection-banner" role="status">
31
+ Connecting to PC Pal... please wait.
32
+ </div>
33
+ )}
34
+
35
+ {/* Scrollable message list */}
36
+ <div className="chat-messages" ref={listRef} role="log" aria-live="polite" aria-label="Chat messages">
37
+ {messages.length === 0 && (
38
+ <div className="chat-empty">
39
+ <p className="chat-empty__text">
40
+ Hello! I'm PC Pal, your friendly tech helper.
41
+ <br />
42
+ Ask me anything about your computer!
43
+ </p>
44
+ </div>
45
+ )}
46
+
47
+ {messages.map((msg) => (
48
+ <MessageBubble key={msg.id} message={msg} />
49
+ ))}
50
+
51
+ {/* Typing indicator */}
52
+ {isTyping && (
53
+ <div className="typing-indicator" aria-live="polite" role="status">
54
+ <div className="typing-indicator__dots">
55
+ <span></span>
56
+ <span></span>
57
+ <span></span>
58
+ </div>
59
+ <span className="typing-indicator__label">PC Pal is typing...</span>
60
+ </div>
61
+ )}
62
+
63
+ {/* Invisible anchor to scroll to */}
64
+ <div ref={bottomRef} aria-hidden="true" />
65
+ </div>
66
+
67
+ {/* Input area */}
68
+ <MessageInput onSend={sendMessage} isTyping={isTyping} />
69
+ </div>
70
+ );
71
+ }
72
+
73
+ export default ChatWindow;
client/src/components/Chat/MessageBubble.css ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* MessageBubble styles */
2
+
3
+ .message-row {
4
+ display: flex;
5
+ align-items: flex-end;
6
+ gap: 10px;
7
+ margin-bottom: 20px;
8
+ animation: fadeIn 300ms ease forwards;
9
+ max-width: 100%;
10
+ }
11
+
12
+ .message-row--user {
13
+ flex-direction: row-reverse;
14
+ }
15
+
16
+ .message-row--assistant {
17
+ flex-direction: row;
18
+ }
19
+
20
+ /* Small avatar label for the assistant */
21
+ .message-avatar {
22
+ flex-shrink: 0;
23
+ width: 40px;
24
+ height: 40px;
25
+ border-radius: 50%;
26
+ background-color: var(--color-primary);
27
+ color: var(--color-white);
28
+ font-size: 13px;
29
+ font-weight: 700;
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ margin-bottom: 24px; /* align with bottom of bubble, above timestamp */
34
+ }
35
+
36
+ /* The bubble itself */
37
+ .bubble {
38
+ max-width: min(75%, 560px);
39
+ padding: 16px 20px;
40
+ border-radius: 18px;
41
+ word-break: break-word;
42
+ }
43
+
44
+ .bubble--user {
45
+ background-color: var(--color-primary);
46
+ color: var(--color-white);
47
+ border-bottom-right-radius: 4px;
48
+ }
49
+
50
+ .bubble--assistant {
51
+ background-color: var(--color-white);
52
+ color: var(--color-text);
53
+ border: 1px solid var(--color-border);
54
+ border-bottom-left-radius: 4px;
55
+ box-shadow: 0 1px 4px var(--color-shadow);
56
+ }
57
+
58
+ .bubble__text {
59
+ font-size: 20px;
60
+ line-height: 1.6;
61
+ margin: 0 0 8px;
62
+ white-space: pre-wrap;
63
+ }
64
+
65
+ .bubble__time {
66
+ font-size: 13px;
67
+ opacity: 0.7;
68
+ display: block;
69
+ }
70
+
71
+ .bubble--user .bubble__time {
72
+ color: var(--color-white);
73
+ text-align: right;
74
+ }
75
+
76
+ .bubble--assistant .bubble__time {
77
+ color: var(--color-text-light);
78
+ }
79
+
80
+ /* Safety alert banner */
81
+ .safety-alert {
82
+ display: flex;
83
+ align-items: flex-start;
84
+ gap: 10px;
85
+ background-color: var(--color-danger-light);
86
+ border: 2px solid var(--color-danger);
87
+ border-radius: var(--radius-md);
88
+ color: var(--color-danger);
89
+ font-size: 18px;
90
+ font-weight: 600;
91
+ padding: 14px 18px;
92
+ margin-bottom: 8px;
93
+ width: 100%;
94
+ max-width: min(75%, 600px);
95
+ box-shadow: 0 2px 8px rgba(220, 38, 38, 0.2);
96
+ }
97
+
98
+ .safety-alert__icon {
99
+ flex-shrink: 0;
100
+ width: 28px;
101
+ height: 28px;
102
+ border-radius: 50%;
103
+ background-color: var(--color-danger);
104
+ color: var(--color-white);
105
+ font-size: 16px;
106
+ font-weight: 900;
107
+ display: flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ }
111
+
112
+ /* Fade-in animation defined in globals but repeated here for safety */
113
+ @keyframes fadeIn {
114
+ from { opacity: 0; transform: translateY(8px); }
115
+ to { opacity: 1; transform: translateY(0); }
116
+ }
client/src/components/Chat/MessageBubble.jsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import './MessageBubble.css';
3
+
4
+ /**
5
+ * MessageBubble — displays a single chat message.
6
+ *
7
+ * User messages: right-aligned, blue background.
8
+ * Assistant messages: left-aligned, white background.
9
+ * Shows a red safety alert banner when safetyAlert is present.
10
+ */
11
+ function MessageBubble({ message }) {
12
+ const { role, text, timestamp, safetyAlert } = message;
13
+ const isUser = role === 'user';
14
+
15
+ const formattedTime = timestamp
16
+ ? new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
17
+ : '';
18
+
19
+ return (
20
+ <div className={`message-row ${isUser ? 'message-row--user' : 'message-row--assistant'}`}>
21
+ {/* Safety alert — prominent banner above the message */}
22
+ {safetyAlert && (
23
+ <div className="safety-alert" role="alert">
24
+ <span className="safety-alert__icon" aria-hidden="true">!</span>
25
+ <span>{safetyAlert}</span>
26
+ </div>
27
+ )}
28
+
29
+ <div className={`bubble ${isUser ? 'bubble--user' : 'bubble--assistant'}`}>
30
+ <p className="bubble__text">{text}</p>
31
+ <time className="bubble__time" dateTime={timestamp}>{formattedTime}</time>
32
+ </div>
33
+
34
+ {!isUser && (
35
+ <span className="message-avatar" aria-hidden="true">PC</span>
36
+ )}
37
+ </div>
38
+ );
39
+ }
40
+
41
+ export default MessageBubble;
client/src/components/Chat/MessageInput.css ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* MessageInput styles */
2
+
3
+ .message-input-bar {
4
+ display: flex;
5
+ align-items: center;
6
+ gap: 12px;
7
+ padding: 16px 20px;
8
+ background-color: var(--color-white);
9
+ border-top: 2px solid var(--color-border);
10
+ box-shadow: 0 -2px 8px var(--color-shadow);
11
+ }
12
+
13
+ .message-input-field {
14
+ flex: 1;
15
+ min-height: 56px;
16
+ font-size: 20px;
17
+ padding: 14px 20px;
18
+ border: 2px solid var(--color-border);
19
+ border-radius: var(--radius-md);
20
+ background-color: var(--color-white);
21
+ color: var(--color-text);
22
+ transition: border-color var(--transition), box-shadow var(--transition);
23
+ /* Override reset from globals */
24
+ margin: 0;
25
+ }
26
+
27
+ .message-input-field:focus {
28
+ outline: none;
29
+ border-color: var(--color-primary);
30
+ box-shadow: 0 0 0 3px var(--color-primary-light);
31
+ }
32
+
33
+ .message-input-field:disabled {
34
+ background-color: var(--color-surface-alt);
35
+ color: var(--color-text-light);
36
+ cursor: not-allowed;
37
+ }
38
+
39
+ .message-input-field::placeholder {
40
+ color: var(--color-text-light);
41
+ }
42
+
43
+ .message-send-btn {
44
+ flex-shrink: 0;
45
+ min-height: 56px;
46
+ padding: 14px 28px;
47
+ font-size: 20px;
48
+ font-weight: 700;
49
+ border-radius: var(--radius-md);
50
+ white-space: nowrap;
51
+ }
client/src/components/Chat/MessageInput.jsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useEffect } from 'react';
2
+ import './MessageInput.css';
3
+
4
+ /**
5
+ * MessageInput — text input + send button for the chat.
6
+ *
7
+ * - Large input field (min 56px height)
8
+ * - Prominent send button
9
+ * - Submits on Enter (Shift+Enter adds a newline)
10
+ * - Disabled while isTyping
11
+ * - Auto-focused on mount
12
+ */
13
+ function MessageInput({ onSend, isTyping }) {
14
+ const inputRef = useRef(null);
15
+ const valueRef = useRef('');
16
+
17
+ // Auto-focus on mount
18
+ useEffect(() => {
19
+ if (inputRef.current) {
20
+ inputRef.current.focus();
21
+ }
22
+ }, []);
23
+
24
+ // Re-focus after response arrives
25
+ useEffect(() => {
26
+ if (!isTyping && inputRef.current) {
27
+ inputRef.current.focus();
28
+ }
29
+ }, [isTyping]);
30
+
31
+ const handleSend = () => {
32
+ const text = inputRef.current?.value ?? '';
33
+ if (!text.trim() || isTyping) return;
34
+ onSend(text);
35
+ if (inputRef.current) {
36
+ inputRef.current.value = '';
37
+ valueRef.current = '';
38
+ }
39
+ };
40
+
41
+ const handleKeyDown = (e) => {
42
+ // Enter submits; Shift+Enter adds newline (for textarea)
43
+ if (e.key === 'Enter' && !e.shiftKey) {
44
+ e.preventDefault();
45
+ handleSend();
46
+ }
47
+ };
48
+
49
+ return (
50
+ <div className="message-input-bar">
51
+ <label htmlFor="chat-input" className="sr-only">
52
+ Type your message
53
+ </label>
54
+ <input
55
+ id="chat-input"
56
+ ref={inputRef}
57
+ type="text"
58
+ className="message-input-field"
59
+ placeholder="Type your question here..."
60
+ disabled={isTyping}
61
+ onKeyDown={handleKeyDown}
62
+ onChange={(e) => { valueRef.current = e.target.value; }}
63
+ autoComplete="off"
64
+ aria-label="Type your question here"
65
+ />
66
+ <button
67
+ className="message-send-btn btn-primary"
68
+ onClick={handleSend}
69
+ disabled={isTyping}
70
+ aria-label="Send message"
71
+ >
72
+ Send
73
+ </button>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ export default MessageInput;
client/src/components/Layout/Header.css ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Header styles */
2
+
3
+ .app-header {
4
+ display: flex;
5
+ align-items: center;
6
+ justify-content: space-between;
7
+ flex-wrap: wrap;
8
+ gap: 12px;
9
+ padding: 16px 24px;
10
+ background-color: var(--color-primary);
11
+ color: var(--color-white);
12
+ box-shadow: 0 2px 8px var(--color-shadow);
13
+ flex-shrink: 0;
14
+ }
15
+
16
+ .app-header__brand {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 14px;
20
+ }
21
+
22
+ .app-header__logo {
23
+ width: 52px;
24
+ height: 52px;
25
+ border-radius: 50%;
26
+ background-color: var(--color-white);
27
+ color: var(--color-primary);
28
+ font-size: 16px;
29
+ font-weight: 900;
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ flex-shrink: 0;
34
+ letter-spacing: 0.5px;
35
+ }
36
+
37
+ .app-header__title {
38
+ font-size: 26px;
39
+ font-weight: 800;
40
+ color: var(--color-white);
41
+ margin: 0;
42
+ line-height: 1.2;
43
+ }
44
+
45
+ .app-header__subtitle {
46
+ font-size: 15px;
47
+ color: rgba(255, 255, 255, 0.85);
48
+ margin: 0;
49
+ line-height: 1.3;
50
+ }
51
+
52
+ .app-header__user {
53
+ display: flex;
54
+ flex-direction: column;
55
+ align-items: flex-end;
56
+ gap: 2px;
57
+ }
58
+
59
+ .app-header__user-greeting {
60
+ font-size: 18px;
61
+ color: var(--color-white);
62
+ }
63
+
64
+ .app-header__user-os {
65
+ font-size: 14px;
66
+ color: rgba(255, 255, 255, 0.75);
67
+ background-color: rgba(255, 255, 255, 0.15);
68
+ border-radius: var(--radius-full);
69
+ padding: 2px 10px;
70
+ }
71
+
72
+ @media (max-width: 480px) {
73
+ .app-header {
74
+ padding: 12px 16px;
75
+ }
76
+
77
+ .app-header__title {
78
+ font-size: 22px;
79
+ }
80
+ }
client/src/components/Layout/Header.jsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import './Header.css';
3
+
4
+ /**
5
+ * Header — app-level header bar.
6
+ *
7
+ * Shows the PC Pal title and, when available, the logged-in user's name.
8
+ */
9
+ function Header({ user }) {
10
+ return (
11
+ <header className="app-header">
12
+ <div className="app-header__brand">
13
+ <span className="app-header__logo" aria-hidden="true">PC</span>
14
+ <div>
15
+ <h1 className="app-header__title">PC Pal</h1>
16
+ <p className="app-header__subtitle">Your Friendly Tech Helper</p>
17
+ </div>
18
+ </div>
19
+
20
+ {user && (
21
+ <div className="app-header__user">
22
+ <span className="app-header__user-greeting">
23
+ Hi, <strong>{user.name}</strong>!
24
+ </span>
25
+ {user.os_type && (
26
+ <span className="app-header__user-os">{user.os_type}</span>
27
+ )}
28
+ </div>
29
+ )}
30
+ </header>
31
+ );
32
+ }
33
+
34
+ export default Header;
client/src/components/Onboarding/OnboardingFlow.css ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* OnboardingFlow styles */
2
+
3
+ /* Full-screen backdrop */
4
+ .onboarding-backdrop {
5
+ min-height: 100vh;
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 50%, #f5f5f5 100%);
10
+ padding: 24px 16px;
11
+ }
12
+
13
+ /* Card container */
14
+ .onboarding-card {
15
+ background-color: var(--color-white);
16
+ border-radius: 20px;
17
+ box-shadow: 0 8px 32px rgba(26, 26, 46, 0.15);
18
+ padding: 40px 36px;
19
+ width: 100%;
20
+ max-width: 560px;
21
+ display: flex;
22
+ flex-direction: column;
23
+ gap: 28px;
24
+ }
25
+
26
+ @media (max-width: 480px) {
27
+ .onboarding-card {
28
+ padding: 28px 20px;
29
+ border-radius: 14px;
30
+ }
31
+ }
32
+
33
+ /* Progress dots */
34
+ .onboarding-progress {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 10px;
38
+ }
39
+
40
+ .onboarding-progress__dot {
41
+ width: 14px;
42
+ height: 14px;
43
+ border-radius: 50%;
44
+ background-color: var(--color-border);
45
+ transition: background-color var(--transition), transform var(--transition);
46
+ }
47
+
48
+ .onboarding-progress__dot--active {
49
+ background-color: var(--color-primary);
50
+ transform: scale(1.2);
51
+ }
52
+
53
+ .onboarding-progress__label {
54
+ font-size: 16px;
55
+ color: var(--color-text-light);
56
+ margin-left: auto;
57
+ }
58
+
59
+ /* Step headings */
60
+ .onboarding-heading {
61
+ font-size: 30px;
62
+ font-weight: 800;
63
+ color: var(--color-text);
64
+ margin-bottom: 10px;
65
+ line-height: 1.25;
66
+ }
67
+
68
+ .onboarding-intro {
69
+ font-size: 20px;
70
+ color: var(--color-text-light);
71
+ margin-bottom: 20px;
72
+ }
73
+
74
+ /* Label above inputs */
75
+ .onboarding-label {
76
+ display: block;
77
+ font-size: 22px;
78
+ font-weight: 600;
79
+ color: var(--color-text);
80
+ margin-bottom: 12px;
81
+ }
82
+
83
+ /* Large text input */
84
+ .onboarding-input {
85
+ font-size: 22px;
86
+ padding: 16px 20px;
87
+ min-height: 60px;
88
+ border-radius: var(--radius-md);
89
+ border: 2px solid var(--color-border);
90
+ width: 100%;
91
+ color: var(--color-text);
92
+ background-color: var(--color-white);
93
+ transition: border-color var(--transition), box-shadow var(--transition);
94
+ }
95
+
96
+ .onboarding-input:focus {
97
+ outline: none;
98
+ border-color: var(--color-primary);
99
+ box-shadow: 0 0 0 3px var(--color-primary-light);
100
+ }
101
+
102
+ /* OS selection grid */
103
+ .os-grid {
104
+ display: grid;
105
+ grid-template-columns: 1fr 1fr;
106
+ gap: 14px;
107
+ }
108
+
109
+ .os-btn {
110
+ min-height: 80px;
111
+ font-size: 22px;
112
+ font-weight: 700;
113
+ background-color: var(--color-surface-alt);
114
+ color: var(--color-text);
115
+ border: 3px solid var(--color-border);
116
+ border-radius: var(--radius-md);
117
+ cursor: pointer;
118
+ transition: background-color var(--transition), border-color var(--transition),
119
+ transform var(--transition);
120
+ }
121
+
122
+ .os-btn:hover:not(:disabled) {
123
+ border-color: var(--color-primary);
124
+ background-color: var(--color-primary-light);
125
+ color: var(--color-primary-dark);
126
+ }
127
+
128
+ .os-btn--selected {
129
+ background-color: var(--color-primary);
130
+ color: var(--color-white);
131
+ border-color: var(--color-primary);
132
+ box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
133
+ }
134
+
135
+ .os-btn--selected:hover {
136
+ background-color: var(--color-primary-dark);
137
+ border-color: var(--color-primary-dark);
138
+ }
139
+
140
+ /* Comfort level scale */
141
+ .comfort-scale {
142
+ display: flex;
143
+ flex-direction: column;
144
+ gap: 12px;
145
+ }
146
+
147
+ .comfort-btn {
148
+ display: flex;
149
+ align-items: center;
150
+ gap: 16px;
151
+ min-height: 64px;
152
+ padding: 12px 20px;
153
+ background-color: var(--color-surface-alt);
154
+ color: var(--color-text);
155
+ border: 3px solid var(--color-border);
156
+ border-radius: var(--radius-md);
157
+ cursor: pointer;
158
+ text-align: left;
159
+ transition: background-color var(--transition), border-color var(--transition);
160
+ justify-content: flex-start;
161
+ }
162
+
163
+ .comfort-btn:hover:not(:disabled) {
164
+ border-color: var(--color-primary);
165
+ background-color: var(--color-primary-light);
166
+ }
167
+
168
+ .comfort-btn--selected {
169
+ background-color: var(--color-primary);
170
+ color: var(--color-white);
171
+ border-color: var(--color-primary);
172
+ box-shadow: 0 3px 10px rgba(37, 99, 235, 0.25);
173
+ }
174
+
175
+ .comfort-btn__number {
176
+ font-size: 24px;
177
+ font-weight: 800;
178
+ width: 40px;
179
+ height: 40px;
180
+ border-radius: 50%;
181
+ background-color: rgba(255, 255, 255, 0.25);
182
+ display: flex;
183
+ align-items: center;
184
+ justify-content: center;
185
+ flex-shrink: 0;
186
+ }
187
+
188
+ .comfort-btn--selected .comfort-btn__number {
189
+ background-color: rgba(255, 255, 255, 0.3);
190
+ }
191
+
192
+ .comfort-btn__label {
193
+ font-size: 20px;
194
+ font-weight: 600;
195
+ }
196
+
197
+ /* Error message */
198
+ .onboarding-error {
199
+ background-color: var(--color-danger-light);
200
+ border: 2px solid var(--color-danger);
201
+ color: var(--color-danger);
202
+ border-radius: var(--radius-md);
203
+ padding: 14px 18px;
204
+ font-size: 18px;
205
+ font-weight: 600;
206
+ }
207
+
208
+ /* Navigation buttons */
209
+ .onboarding-nav {
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 14px;
213
+ justify-content: flex-end;
214
+ }
215
+
216
+ .onboarding-nav__back {
217
+ font-size: 18px;
218
+ min-height: 54px;
219
+ }
220
+
221
+ .onboarding-nav__next,
222
+ .onboarding-nav__submit {
223
+ font-size: 22px;
224
+ min-height: 58px;
225
+ padding: 14px 36px;
226
+ flex: 1;
227
+ max-width: 280px;
228
+ }
client/src/components/Onboarding/OnboardingFlow.jsx ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import './OnboardingFlow.css';
3
+
4
+ const TOTAL_STEPS = 3;
5
+
6
+ const OS_OPTIONS = [
7
+ { value: 'Windows', label: 'Windows' },
8
+ { value: 'Mac', label: 'Mac' },
9
+ { value: 'iPhone', label: 'iPhone' },
10
+ { value: 'Android', label: 'Android' },
11
+ ];
12
+
13
+ const COMFORT_LABELS = {
14
+ 1: "I'm brand new",
15
+ 2: 'I know a little',
16
+ 3: 'I know the basics',
17
+ 4: 'Getting confident',
18
+ 5: 'Pretty comfortable',
19
+ };
20
+
21
+ /**
22
+ * OnboardingFlow — multi-step wizard for new users.
23
+ *
24
+ * Step 1: Name
25
+ * Step 2: Device / OS type
26
+ * Step 3: Comfort level (1–5)
27
+ * Submit: calls createUser, then completeOnboarding
28
+ */
29
+ function OnboardingFlow({ createUser, completeOnboarding }) {
30
+ const [step, setStep] = useState(1);
31
+ const [name, setName] = useState('');
32
+ const [osType, setOsType] = useState('');
33
+ const [comfortLevel, setComfortLevel] = useState(3);
34
+ const [isSubmitting, setIsSubmitting] = useState(false);
35
+ const [error, setError] = useState(null);
36
+
37
+ const goNext = () => setStep((s) => Math.min(s + 1, TOTAL_STEPS));
38
+ const goBack = () => setStep((s) => Math.max(s - 1, 1));
39
+
40
+ const canGoNext = () => {
41
+ if (step === 1) return name.trim().length > 0;
42
+ if (step === 2) return osType.length > 0;
43
+ return true;
44
+ };
45
+
46
+ const handleSubmit = async () => {
47
+ setIsSubmitting(true);
48
+ setError(null);
49
+ try {
50
+ const newUser = await createUser({
51
+ name: name.trim(),
52
+ os_type: osType,
53
+ comfort_level: comfortLevel,
54
+ });
55
+ await completeOnboarding(newUser.id);
56
+ } catch (err) {
57
+ setError('Something went wrong. Please try again.');
58
+ console.error('Onboarding error:', err);
59
+ } finally {
60
+ setIsSubmitting(false);
61
+ }
62
+ };
63
+
64
+ return (
65
+ <div className="onboarding-backdrop">
66
+ <div className="onboarding-card" role="main">
67
+ {/* Progress indicator */}
68
+ <div className="onboarding-progress" aria-label={`Step ${step} of ${TOTAL_STEPS}`}>
69
+ {Array.from({ length: TOTAL_STEPS }, (_, i) => (
70
+ <div
71
+ key={i}
72
+ className={`onboarding-progress__dot ${i + 1 <= step ? 'onboarding-progress__dot--active' : ''}`}
73
+ aria-hidden="true"
74
+ />
75
+ ))}
76
+ <span className="onboarding-progress__label">Step {step} of {TOTAL_STEPS}</span>
77
+ </div>
78
+
79
+ {/* Step content */}
80
+ <div className="onboarding-step" key={step}>
81
+ {step === 1 && (
82
+ <div className="animate-slide-up">
83
+ <h1 className="onboarding-heading">Welcome to PC Pal!</h1>
84
+ <p className="onboarding-intro">
85
+ I'm here to help you with your computer — no question is too simple!
86
+ </p>
87
+ <label htmlFor="user-name" className="onboarding-label">
88
+ What's your name?
89
+ </label>
90
+ <input
91
+ id="user-name"
92
+ type="text"
93
+ className="onboarding-input"
94
+ value={name}
95
+ onChange={(e) => setName(e.target.value)}
96
+ placeholder="Enter your first name"
97
+ autoFocus
98
+ autoComplete="given-name"
99
+ onKeyDown={(e) => { if (e.key === 'Enter' && canGoNext()) goNext(); }}
100
+ />
101
+ </div>
102
+ )}
103
+
104
+ {step === 2 && (
105
+ <div className="animate-slide-up">
106
+ <h1 className="onboarding-heading">
107
+ {name ? `Nice to meet you, ${name}!` : 'Your Device'}
108
+ </h1>
109
+ <p className="onboarding-label">What kind of computer do you use?</p>
110
+ <div className="os-grid">
111
+ {OS_OPTIONS.map((opt) => (
112
+ <button
113
+ key={opt.value}
114
+ type="button"
115
+ className={`os-btn ${osType === opt.value ? 'os-btn--selected' : ''}`}
116
+ onClick={() => setOsType(opt.value)}
117
+ aria-pressed={osType === opt.value}
118
+ >
119
+ {opt.label}
120
+ </button>
121
+ ))}
122
+ </div>
123
+ </div>
124
+ )}
125
+
126
+ {step === 3 && (
127
+ <div className="animate-slide-up">
128
+ <h1 className="onboarding-heading">Almost done!</h1>
129
+ <p className="onboarding-label">
130
+ How comfortable are you with computers?
131
+ </p>
132
+ <div className="comfort-scale">
133
+ {[1, 2, 3, 4, 5].map((level) => (
134
+ <button
135
+ key={level}
136
+ type="button"
137
+ className={`comfort-btn ${comfortLevel === level ? 'comfort-btn--selected' : ''}`}
138
+ onClick={() => setComfortLevel(level)}
139
+ aria-pressed={comfortLevel === level}
140
+ aria-label={`${level} — ${COMFORT_LABELS[level]}`}
141
+ >
142
+ <span className="comfort-btn__number">{level}</span>
143
+ <span className="comfort-btn__label">{COMFORT_LABELS[level]}</span>
144
+ </button>
145
+ ))}
146
+ </div>
147
+ </div>
148
+ )}
149
+ </div>
150
+
151
+ {/* Error message */}
152
+ {error && (
153
+ <div className="onboarding-error" role="alert">
154
+ {error}
155
+ </div>
156
+ )}
157
+
158
+ {/* Navigation buttons */}
159
+ <div className="onboarding-nav">
160
+ {step > 1 && (
161
+ <button
162
+ type="button"
163
+ className="btn-secondary onboarding-nav__back"
164
+ onClick={goBack}
165
+ disabled={isSubmitting}
166
+ >
167
+ Back
168
+ </button>
169
+ )}
170
+
171
+ {step < TOTAL_STEPS ? (
172
+ <button
173
+ type="button"
174
+ className="btn-primary onboarding-nav__next"
175
+ onClick={goNext}
176
+ disabled={!canGoNext()}
177
+ >
178
+ Next
179
+ </button>
180
+ ) : (
181
+ <button
182
+ type="button"
183
+ className="btn-primary onboarding-nav__submit"
184
+ onClick={handleSubmit}
185
+ disabled={isSubmitting}
186
+ >
187
+ {isSubmitting ? 'Setting things up...' : "Let's get started!"}
188
+ </button>
189
+ )}
190
+ </div>
191
+ </div>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ export default OnboardingFlow;
client/src/hooks/useChat.js ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef, useCallback } from 'react';
2
+
3
+ /**
4
+ * useChat — WebSocket chat hook for PC Pal
5
+ *
6
+ * Connects to /ws (proxied to ws://localhost:3001/ws by Vite),
7
+ * sends an init message, and manages message state.
8
+ *
9
+ * @param {string|null} userId
10
+ * @returns {{ messages, sendMessage, isConnected, isTyping }}
11
+ */
12
+ export function useChat(userId) {
13
+ const [messages, setMessages] = useState([]);
14
+ const [isConnected, setIsConnected] = useState(false);
15
+ const [isTyping, setIsTyping] = useState(false);
16
+
17
+ const wsRef = useRef(null);
18
+ const reconnectTimeoutRef = useRef(null);
19
+ const messageIdRef = useRef(0);
20
+
21
+ const nextId = () => {
22
+ messageIdRef.current += 1;
23
+ return messageIdRef.current;
24
+ };
25
+
26
+ const connect = useCallback(() => {
27
+ if (!userId) return;
28
+
29
+ // Determine WS URL: use relative path so Vite proxy handles it
30
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
31
+ const wsUrl = `${protocol}//${window.location.host}/ws`;
32
+
33
+ const ws = new WebSocket(wsUrl);
34
+ wsRef.current = ws;
35
+
36
+ ws.onopen = () => {
37
+ setIsConnected(true);
38
+ // Send init message so the server knows who we are
39
+ ws.send(JSON.stringify({ type: 'init', userId }));
40
+ };
41
+
42
+ ws.onmessage = (event) => {
43
+ let data;
44
+ try {
45
+ data = JSON.parse(event.data);
46
+ } catch {
47
+ console.error('PC Pal: invalid JSON from server', event.data);
48
+ return;
49
+ }
50
+
51
+ switch (data.type) {
52
+ case 'init_ack':
53
+ // Server acknowledged the init — nothing extra needed
54
+ break;
55
+
56
+ case 'typing':
57
+ setIsTyping(true);
58
+ break;
59
+
60
+ case 'response':
61
+ setIsTyping(false);
62
+ setMessages((prev) => [
63
+ ...prev,
64
+ {
65
+ id: nextId(),
66
+ role: 'assistant',
67
+ text: data.text ?? '',
68
+ timestamp: new Date().toISOString(),
69
+ safetyAlert: data.safetyAlert ?? null,
70
+ },
71
+ ]);
72
+ break;
73
+
74
+ case 'error':
75
+ setIsTyping(false);
76
+ setMessages((prev) => [
77
+ ...prev,
78
+ {
79
+ id: nextId(),
80
+ role: 'assistant',
81
+ text: data.message ?? 'Something went wrong. Please try again.',
82
+ timestamp: new Date().toISOString(),
83
+ safetyAlert: null,
84
+ },
85
+ ]);
86
+ break;
87
+
88
+ default:
89
+ console.warn('PC Pal: unknown message type', data.type);
90
+ }
91
+ };
92
+
93
+ ws.onclose = () => {
94
+ setIsConnected(false);
95
+ setIsTyping(false);
96
+ // Attempt to reconnect after 3 seconds
97
+ reconnectTimeoutRef.current = setTimeout(connect, 3000);
98
+ };
99
+
100
+ ws.onerror = (err) => {
101
+ console.error('PC Pal WebSocket error', err);
102
+ ws.close();
103
+ };
104
+ }, [userId]);
105
+
106
+ useEffect(() => {
107
+ if (!userId) return;
108
+ connect();
109
+
110
+ return () => {
111
+ clearTimeout(reconnectTimeoutRef.current);
112
+ if (wsRef.current) {
113
+ // Remove the onclose handler before closing so we don't trigger reconnect
114
+ wsRef.current.onclose = null;
115
+ wsRef.current.close();
116
+ }
117
+ };
118
+ }, [userId, connect]);
119
+
120
+ /**
121
+ * Send a chat message.
122
+ * @param {string} text
123
+ */
124
+ const sendMessage = useCallback(
125
+ (text) => {
126
+ const trimmed = text.trim();
127
+ if (!trimmed) return;
128
+
129
+ // Optimistically add the user's message to the list
130
+ setMessages((prev) => [
131
+ ...prev,
132
+ {
133
+ id: nextId(),
134
+ role: 'user',
135
+ text: trimmed,
136
+ timestamp: new Date().toISOString(),
137
+ safetyAlert: null,
138
+ },
139
+ ]);
140
+
141
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
142
+ wsRef.current.send(JSON.stringify({ type: 'chat', text: trimmed }));
143
+ } else {
144
+ console.warn('PC Pal: WebSocket not open, message not sent');
145
+ setMessages((prev) => [
146
+ ...prev,
147
+ {
148
+ id: nextId(),
149
+ role: 'assistant',
150
+ text: 'Sorry, the connection was lost. Please refresh the page and try again.',
151
+ timestamp: new Date().toISOString(),
152
+ safetyAlert: null,
153
+ },
154
+ ]);
155
+ }
156
+ },
157
+ [],
158
+ );
159
+
160
+ return { messages, sendMessage, isConnected, isTyping };
161
+ }
client/src/hooks/useUser.js ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from 'react';
2
+
3
+ const USER_ID_KEY = 'pcpal_userId';
4
+
5
+ /**
6
+ * useUser — User profile hook for PC Pal
7
+ *
8
+ * Checks localStorage for an existing userId on mount.
9
+ * If found, loads the profile from the API.
10
+ * Exposes helpers to create a user and complete onboarding.
11
+ *
12
+ * @returns {{ user, isOnboarded, isLoading, createUser, completeOnboarding }}
13
+ */
14
+ export function useUser() {
15
+ const [user, setUser] = useState(null);
16
+ const [isOnboarded, setIsOnboarded] = useState(false);
17
+ const [isLoading, setIsLoading] = useState(true);
18
+
19
+ // On mount: check localStorage for a saved userId
20
+ useEffect(() => {
21
+ const savedId = localStorage.getItem(USER_ID_KEY);
22
+
23
+ if (!savedId) {
24
+ setIsLoading(false);
25
+ return;
26
+ }
27
+
28
+ // Fetch the user profile
29
+ fetch(`/api/users/${savedId}`)
30
+ .then((res) => {
31
+ if (!res.ok) {
32
+ // User not found on server — clear stale localStorage entry
33
+ localStorage.removeItem(USER_ID_KEY);
34
+ return null;
35
+ }
36
+ return res.json();
37
+ })
38
+ .then((data) => {
39
+ if (data) {
40
+ setUser(data);
41
+ setIsOnboarded(Boolean(data.is_onboarded ?? data.isOnboarded));
42
+ }
43
+ })
44
+ .catch((err) => {
45
+ console.error('PC Pal: failed to load user profile', err);
46
+ localStorage.removeItem(USER_ID_KEY);
47
+ })
48
+ .finally(() => {
49
+ setIsLoading(false);
50
+ });
51
+ }, []);
52
+
53
+ /**
54
+ * Create a new user account.
55
+ * @param {{ name: string, os_type: string, comfort_level: number }} data
56
+ * @returns {Promise<object>} the created user
57
+ */
58
+ const createUser = useCallback(async (data) => {
59
+ const res = await fetch('/api/users', {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify(data),
63
+ });
64
+
65
+ if (!res.ok) {
66
+ const err = await res.text();
67
+ throw new Error(`Failed to create user: ${err}`);
68
+ }
69
+
70
+ const newUser = await res.json();
71
+ localStorage.setItem(USER_ID_KEY, newUser.id);
72
+ setUser(newUser);
73
+ return newUser;
74
+ }, []);
75
+
76
+ /**
77
+ * Mark the user as having completed onboarding.
78
+ * @param {string|number} userId
79
+ * @returns {Promise<object>} the updated user
80
+ */
81
+ const completeOnboarding = useCallback(async (userId) => {
82
+ const res = await fetch(`/api/users/${userId}/onboard`, {
83
+ method: 'PUT',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ });
86
+
87
+ if (!res.ok) {
88
+ const err = await res.text();
89
+ throw new Error(`Failed to complete onboarding: ${err}`);
90
+ }
91
+
92
+ const updatedUser = await res.json();
93
+ setUser(updatedUser);
94
+ setIsOnboarded(true);
95
+ return updatedUser;
96
+ }, []);
97
+
98
+ return { user, isOnboarded, isLoading, createUser, completeOnboarding };
99
+ }
client/src/index.css CHANGED
@@ -1,111 +1,11 @@
1
- :root {
2
- --text: #6b6375;
3
- --text-h: #08060d;
4
- --bg: #fff;
5
- --border: #e5e4e7;
6
- --code-bg: #f4f3ec;
7
- --accent: #aa3bff;
8
- --accent-bg: rgba(170, 59, 255, 0.1);
9
- --accent-border: rgba(170, 59, 255, 0.5);
10
- --social-bg: rgba(244, 243, 236, 0.5);
11
- --shadow:
12
- rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
13
 
14
- --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
15
- --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
16
- --mono: ui-monospace, Consolas, monospace;
17
-
18
- font: 18px/145% var(--sans);
19
- letter-spacing: 0.18px;
20
- color-scheme: light dark;
21
- color: var(--text);
22
- background: var(--bg);
23
- font-synthesis: none;
24
- text-rendering: optimizeLegibility;
25
- -webkit-font-smoothing: antialiased;
26
- -moz-osx-font-smoothing: grayscale;
27
-
28
- @media (max-width: 1024px) {
29
- font-size: 16px;
30
- }
31
- }
32
-
33
- @media (prefers-color-scheme: dark) {
34
- :root {
35
- --text: #9ca3af;
36
- --text-h: #f3f4f6;
37
- --bg: #16171d;
38
- --border: #2e303a;
39
- --code-bg: #1f2028;
40
- --accent: #c084fc;
41
- --accent-bg: rgba(192, 132, 252, 0.15);
42
- --accent-border: rgba(192, 132, 252, 0.5);
43
- --social-bg: rgba(47, 48, 58, 0.5);
44
- --shadow:
45
- rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
46
- }
47
-
48
- #social .button-icon {
49
- filter: invert(1) brightness(2);
50
- }
51
- }
52
-
53
- body {
54
- margin: 0;
55
- }
56
-
57
- #root {
58
- width: 1126px;
59
- max-width: 100%;
60
- margin: 0 auto;
61
- text-align: center;
62
- border-inline: 1px solid var(--border);
63
- min-height: 100svh;
64
- display: flex;
65
- flex-direction: column;
66
  box-sizing: border-box;
67
  }
68
 
69
- h1,
70
- h2 {
71
- font-family: var(--heading);
72
- font-weight: 500;
73
- color: var(--text-h);
74
- }
75
-
76
- h1 {
77
- font-size: 56px;
78
- letter-spacing: -1.68px;
79
- margin: 32px 0;
80
- @media (max-width: 1024px) {
81
- font-size: 36px;
82
- margin: 20px 0;
83
- }
84
- }
85
- h2 {
86
- font-size: 24px;
87
- line-height: 118%;
88
- letter-spacing: -0.24px;
89
- margin: 0 0 8px;
90
- @media (max-width: 1024px) {
91
- font-size: 20px;
92
- }
93
- }
94
- p {
95
  margin: 0;
96
  }
97
-
98
- code,
99
- .counter {
100
- font-family: var(--mono);
101
- display: inline-flex;
102
- border-radius: 4px;
103
- color: var(--text-h);
104
- }
105
-
106
- code {
107
- font-size: 15px;
108
- line-height: 135%;
109
- padding: 4px 8px;
110
- background: var(--code-bg);
111
- }
 
1
+ /* index.css — minimal reset; all styles live in src/styles/globals.css */
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ *,
4
+ *::before,
5
+ *::after {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  box-sizing: border-box;
7
  }
8
 
9
+ body {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  margin: 0;
11
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
client/src/main.jsx CHANGED
@@ -1,10 +1,10 @@
1
- import { StrictMode } from 'react'
2
- import { createRoot } from 'react-dom/client'
3
- import './index.css'
4
- import App from './App.jsx'
5
 
6
  createRoot(document.getElementById('root')).render(
7
  <StrictMode>
8
  <App />
9
  </StrictMode>,
10
- )
 
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import './styles/globals.css';
4
+ import App from './App.jsx';
5
 
6
  createRoot(document.getElementById('root')).render(
7
  <StrictMode>
8
  <App />
9
  </StrictMode>,
10
+ );
client/src/styles/globals.css ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* PC Pal — Elderly-Friendly Global Styles */
2
+ /* Designed for maximum readability and ease of use */
3
+
4
+ :root {
5
+ /* Color palette */
6
+ --color-bg: #f5f5f5;
7
+ --color-text: #1a1a2e;
8
+ --color-text-light: #4a4a6a;
9
+ --color-primary: #2563eb;
10
+ --color-primary-dark: #1d4ed8;
11
+ --color-primary-light: #dbeafe;
12
+ --color-success: #16a34a;
13
+ --color-success-light: #dcfce7;
14
+ --color-warning: #d97706;
15
+ --color-warning-light: #fef3c7;
16
+ --color-danger: #dc2626;
17
+ --color-danger-light: #fee2e2;
18
+ --color-white: #ffffff;
19
+ --color-border: #d1d5db;
20
+ --color-surface: #ffffff;
21
+ --color-surface-alt: #f0f0f8;
22
+ --color-shadow: rgba(26, 26, 46, 0.12);
23
+
24
+ /* Typography */
25
+ --font-family: 'Segoe UI', system-ui, -apple-system, Arial, sans-serif;
26
+ --font-size-base: 18px;
27
+ --font-size-sm: 16px;
28
+ --font-size-md: 20px;
29
+ --font-size-lg: 24px;
30
+ --font-size-xl: 30px;
31
+ --font-size-2xl: 36px;
32
+ --line-height: 1.6;
33
+
34
+ /* Spacing */
35
+ --spacing-xs: 4px;
36
+ --spacing-sm: 8px;
37
+ --spacing-md: 16px;
38
+ --spacing-lg: 24px;
39
+ --spacing-xl: 32px;
40
+ --spacing-2xl: 48px;
41
+
42
+ /* Touch targets */
43
+ --touch-target: 48px;
44
+
45
+ /* Border radius */
46
+ --radius-sm: 6px;
47
+ --radius-md: 12px;
48
+ --radius-lg: 18px;
49
+ --radius-full: 9999px;
50
+
51
+ /* Transitions */
52
+ --transition: 200ms ease;
53
+ }
54
+
55
+ /* Reset */
56
+ *,
57
+ *::before,
58
+ *::after {
59
+ box-sizing: border-box;
60
+ margin: 0;
61
+ padding: 0;
62
+ }
63
+
64
+ html {
65
+ font-size: var(--font-size-base);
66
+ -webkit-text-size-adjust: 100%;
67
+ }
68
+
69
+ body {
70
+ font-family: var(--font-family);
71
+ font-size: var(--font-size-base);
72
+ line-height: var(--line-height);
73
+ color: var(--color-text);
74
+ background-color: var(--color-bg);
75
+ -webkit-font-smoothing: antialiased;
76
+ -moz-osx-font-smoothing: grayscale;
77
+ min-height: 100vh;
78
+ }
79
+
80
+ #root {
81
+ min-height: 100vh;
82
+ display: flex;
83
+ flex-direction: column;
84
+ }
85
+
86
+ /* Headings */
87
+ h1, h2, h3, h4, h5, h6 {
88
+ color: var(--color-text);
89
+ line-height: 1.3;
90
+ font-weight: 700;
91
+ }
92
+
93
+ h1 { font-size: var(--font-size-2xl); }
94
+ h2 { font-size: var(--font-size-xl); }
95
+ h3 { font-size: var(--font-size-lg); }
96
+ h4 { font-size: var(--font-size-md); }
97
+
98
+ p {
99
+ margin-bottom: var(--spacing-md);
100
+ font-size: var(--font-size-base);
101
+ line-height: var(--line-height);
102
+ }
103
+
104
+ /* Large, accessible buttons */
105
+ button {
106
+ font-family: var(--font-family);
107
+ font-size: var(--font-size-md);
108
+ font-weight: 600;
109
+ min-height: var(--touch-target);
110
+ min-width: var(--touch-target);
111
+ padding: var(--spacing-sm) var(--spacing-lg);
112
+ border: none;
113
+ border-radius: var(--radius-md);
114
+ cursor: pointer;
115
+ transition: background-color var(--transition), transform var(--transition),
116
+ box-shadow var(--transition);
117
+ display: inline-flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ gap: var(--spacing-sm);
121
+ line-height: 1.2;
122
+ }
123
+
124
+ button:focus-visible {
125
+ outline: 3px solid var(--color-primary);
126
+ outline-offset: 3px;
127
+ }
128
+
129
+ button:active {
130
+ transform: scale(0.98);
131
+ }
132
+
133
+ button:disabled {
134
+ opacity: 0.5;
135
+ cursor: not-allowed;
136
+ transform: none;
137
+ }
138
+
139
+ /* Primary button */
140
+ .btn-primary {
141
+ background-color: var(--color-primary);
142
+ color: var(--color-white);
143
+ }
144
+
145
+ .btn-primary:hover:not(:disabled) {
146
+ background-color: var(--color-primary-dark);
147
+ box-shadow: 0 4px 12px var(--color-shadow);
148
+ }
149
+
150
+ /* Secondary button */
151
+ .btn-secondary {
152
+ background-color: var(--color-white);
153
+ color: var(--color-text);
154
+ border: 2px solid var(--color-border);
155
+ }
156
+
157
+ .btn-secondary:hover:not(:disabled) {
158
+ background-color: var(--color-surface-alt);
159
+ border-color: var(--color-primary);
160
+ }
161
+
162
+ /* Large, readable input fields */
163
+ input[type="text"],
164
+ input[type="email"],
165
+ textarea {
166
+ font-family: var(--font-family);
167
+ font-size: var(--font-size-md);
168
+ color: var(--color-text);
169
+ background-color: var(--color-white);
170
+ border: 2px solid var(--color-border);
171
+ border-radius: var(--radius-md);
172
+ padding: var(--spacing-md) var(--spacing-lg);
173
+ width: 100%;
174
+ min-height: var(--touch-target);
175
+ transition: border-color var(--transition), box-shadow var(--transition);
176
+ line-height: var(--line-height);
177
+ }
178
+
179
+ input[type="text"]:focus,
180
+ input[type="email"]:focus,
181
+ textarea:focus {
182
+ outline: none;
183
+ border-color: var(--color-primary);
184
+ box-shadow: 0 0 0 3px var(--color-primary-light);
185
+ }
186
+
187
+ input::placeholder,
188
+ textarea::placeholder {
189
+ color: var(--color-text-light);
190
+ opacity: 0.8;
191
+ }
192
+
193
+ /* Links */
194
+ a {
195
+ color: var(--color-primary);
196
+ text-decoration: underline;
197
+ }
198
+
199
+ a:hover {
200
+ color: var(--color-primary-dark);
201
+ }
202
+
203
+ /* Utilities */
204
+ .sr-only {
205
+ position: absolute;
206
+ width: 1px;
207
+ height: 1px;
208
+ padding: 0;
209
+ margin: -1px;
210
+ overflow: hidden;
211
+ clip: rect(0, 0, 0, 0);
212
+ white-space: nowrap;
213
+ border: 0;
214
+ }
215
+
216
+ /* Animations — smooth, non-jarring */
217
+ @keyframes fadeIn {
218
+ from { opacity: 0; transform: translateY(8px); }
219
+ to { opacity: 1; transform: translateY(0); }
220
+ }
221
+
222
+ @keyframes pulse {
223
+ 0%, 100% { opacity: 1; }
224
+ 50% { opacity: 0.4; }
225
+ }
226
+
227
+ @keyframes slideUp {
228
+ from { opacity: 0; transform: translateY(20px); }
229
+ to { opacity: 1; transform: translateY(0); }
230
+ }
231
+
232
+ .animate-fade-in {
233
+ animation: fadeIn 300ms ease forwards;
234
+ }
235
+
236
+ .animate-slide-up {
237
+ animation: slideUp 350ms ease forwards;
238
+ }