detached 2b8397d

Merge pull request #1 from pdraim/staging

v0.2.0: Major text formatting, validation, and UI enhancement release
commit
2b8397da172d7a4e3ef092aea89ac26d5a1aa786
author
pdraim · · 2025-07-03 20:18 UTC
committer
GitHub · · 2025-07-03 20:18 UTC
merge of
d5464c3 + 1301099 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
54 files · +6288 -1121
M .gitignore +3 -1
@@ -22,4 +22,6 @@ Thumbs.db
2222 vite.config.js.timestamp-*
2323 vite.config.ts.timestamp-*
2424
25-docs/⏎̸
25+docs/
26+
27+.serena/⏎̸
M package.json +35 -26
@@ -1,7 +1,7 @@
11 {
22 "name": "pdraim",
33 "private": true,
4- "version": "0.1.5",
4+ "version": "0.2.0",
55 "type": "module",
66 "engines": {
77 "node": ">=22.13.0"
@@ -19,44 +19,53 @@
1919 "test": "npm run test:unit -- --run",
2020 "db:generate": "npx drizzle-kit generate",
2121 "db:push": "npx drizzle-kit push",
22+ "db:push-prod": "bash -c 'source .env && TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push'",
2223 "db:flush": "tsx utils/db-flush.ts"
2324 },
2425 "devDependencies": {
25- "@eslint/compat": "^1.2.6",
26- "@eslint/js": "^9.20.0",
27- "@sveltejs/adapter-cloudflare": "^5.0.3",
28- "@sveltejs/kit": "^2.17.2",
29- "@sveltejs/vite-plugin-svelte": "^5.0.3",
26+ "@eslint/compat": "^1.3.1",
27+ "@eslint/js": "^9.30.1",
28+ "@sveltejs/adapter-cloudflare": "^5.1.0",
29+ "@sveltejs/kit": "^2.22.2",
30+ "@sveltejs/vite-plugin-svelte": "^5.1.0",
3031 "@tailwindcss/container-queries": "^0.1.1",
3132 "@tailwindcss/forms": "^0.5.10",
3233 "@tailwindcss/typography": "^0.5.16",
33- "@types/node": "^22.13.4",
34- "autoprefixer": "^10.4.20",
35- "drizzle-kit": "^0.30.4",
36- "eslint": "^9.20.1",
37- "eslint-config-prettier": "^10.0.1",
34+ "@types/node": "^22.16.0",
35+ "autoprefixer": "^10.4.21",
36+ "drizzle-kit": "^0.30.6",
37+ "eslint": "^9.30.1",
38+ "eslint-config-prettier": "^10.1.5",
3839 "eslint-plugin-svelte": "^2.46.1",
3940 "globals": "^15.15.0",
40- "prettier": "^3.5.1",
41- "prettier-plugin-svelte": "^3.3.3",
42- "prettier-plugin-tailwindcss": "^0.6.11",
43- "svelte": "^5.20.1",
44- "svelte-check": "^4.1.4",
41+ "jsdom": "^26.1.0",
42+ "prettier": "^3.6.2",
43+ "prettier-plugin-svelte": "^3.4.0",
44+ "prettier-plugin-tailwindcss": "^0.6.13",
45+ "svelte": "^5.35.2",
46+ "svelte-check": "^4.2.2",
4547 "tailwindcss": "^3.4.17",
46- "terser": "^5.39.0",
47- "tsx": "^4.19.2",
48- "typescript": "^5.7.3",
49- "typescript-eslint": "^8.24.0",
50- "vite": "^6.1.0",
51- "vitest": "^3.0.5"
48+ "terser": "^5.43.1",
49+ "tsx": "^4.20.3",
50+ "typescript": "^5.8.3",
51+ "typescript-eslint": "^8.35.1",
52+ "vite": "^6.3.5",
53+ "vitest": "^3.2.4"
5254 },
5355 "dependencies": {
5456 "@libsql/client": "^0.14.0",
55- "dotenv": "^16.4.7",
57+ "dotenv": "^16.6.1",
5658 "drizzle-orm": "^0.39.3",
57- "pino": "^9.6.0",
59+ "pino": "^9.7.0",
5860 "pino-pretty": "^13.0.0",
59- "uuid": "^11.0.5",
60- "xp.css": "^0.2.6"
61+ "rehype": "^13.0.2",
62+ "rehype-sanitize": "^6.0.0",
63+ "rehype-stringify": "^10.0.1",
64+ "remark-parse": "^11.0.0",
65+ "remark-rehype": "^11.1.2",
66+ "unified": "^11.0.5",
67+ "uuid": "^11.1.0",
68+ "xp.css": "^0.2.6",
69+ "zod": "^3.25.71"
6170 }
6271 }
M pnpm-lock.yaml +1921 -874 generated file: collapsed
M src/app.css +445 -0
@@ -2,4 +2,449 @@
22 @import 'tailwindcss/components';
33 @import 'tailwindcss/utilities';
44 @import 'xp.css';
5+@import './lib/styles/fonts.css';
6+
7+/* Local font faces with 30-day cache */
8+@font-face {
9+ font-family: 'Arial';
10+ src: url('/fonts/Arial.TTF') format('truetype');
11+ font-display: swap;
12+ font-weight: normal;
13+ font-style: normal;
14+}
15+
16+@font-face {
17+ font-family: 'Arial';
18+ src: url('/fonts/Arialbd.TTF') format('truetype');
19+ font-display: swap;
20+ font-weight: bold;
21+ font-style: normal;
22+}
23+
24+@font-face {
25+ font-family: 'Arial';
26+ src: url('/fonts/Ariali.TTF') format('truetype');
27+ font-display: swap;
28+ font-weight: normal;
29+ font-style: italic;
30+}
31+
32+@font-face {
33+ font-family: 'Arial';
34+ src: url('/fonts/Arialbi.TTF') format('truetype');
35+ font-display: swap;
36+ font-weight: bold;
37+ font-style: italic;
38+}
39+
40+@font-face {
41+ font-family: 'Tahoma';
42+ src: url('/fonts/tahoma.ttf') format('truetype');
43+ font-display: swap;
44+ font-weight: 80;
45+ font-style: normal;
46+}
47+
48+@font-face {
49+ font-family: 'Tahoma';
50+ src: url('/fonts/tahomabd.ttf') format('truetype');
51+ font-display: swap;
52+ font-weight: 200;
53+ font-style: normal;
54+}
55+
56+@font-face {
57+ font-family: 'Verdana';
58+ src: url('/fonts/Verdana.TTF') format('truetype');
59+ font-display: swap;
60+ font-weight: normal;
61+ font-style: normal;
62+}
63+
64+@font-face {
65+ font-family: 'Verdana';
66+ src: url('/fonts/Verdanab.TTF') format('truetype');
67+ font-display: swap;
68+ font-weight: bold;
69+ font-style: normal;
70+}
71+
72+@font-face {
73+ font-family: 'Verdana';
74+ src: url('/fonts/Verdanai.TTF') format('truetype');
75+ font-display: swap;
76+ font-weight: normal;
77+ font-style: italic;
78+}
79+
80+@font-face {
81+ font-family: 'Verdana';
82+ src: url('/fonts/Verdanaz.TTF') format('truetype');
83+ font-display: swap;
84+ font-weight: bold;
85+ font-style: italic;
86+}
87+
88+@font-face {
89+ font-family: 'Comic Sans MS';
90+ src: url('/fonts/Comic.TTF') format('truetype');
91+ font-display: swap;
92+ font-weight: normal;
93+ font-style: normal;
94+}
95+
96+@font-face {
97+ font-family: 'Comic Sans MS';
98+ src: url('/fonts/Comicbd.TTF') format('truetype');
99+ font-display: swap;
100+ font-weight: bold;
101+ font-style: normal;
102+}
103+
104+@font-face {
105+ font-family: 'Times New Roman';
106+ src: url('/fonts/Times.TTF') format('truetype');
107+ font-display: swap;
108+ font-weight: normal;
109+ font-style: normal;
110+}
111+
112+@font-face {
113+ font-family: 'Times New Roman';
114+ src: url('/fonts/Timesbd.TTF') format('truetype');
115+ font-display: swap;
116+ font-weight: bold;
117+ font-style: normal;
118+}
119+
120+@font-face {
121+ font-family: 'Times New Roman';
122+ src: url('/fonts/Timesi.TTF') format('truetype');
123+ font-display: swap;
124+ font-weight: normal;
125+ font-style: italic;
126+}
127+
128+@font-face {
129+ font-family: 'Times New Roman';
130+ src: url('/fonts/Timesbi.TTF') format('truetype');
131+ font-display: swap;
132+ font-weight: bold;
133+ font-style: italic;
134+}
135+
136+@font-face {
137+ font-family: 'Courier New';
138+ src: url('/fonts/LiberationMono-Regular.ttf') format('truetype');
139+ font-display: swap;
140+ font-weight: normal;
141+ font-style: normal;
142+}
143+
144+@font-face {
145+ font-family: 'Courier New';
146+ src: url('/fonts/LiberationMono-Bold.ttf') format('truetype');
147+ font-display: swap;
148+ font-weight: bold;
149+ font-style: normal;
150+}
151+
152+@font-face {
153+ font-family: 'Courier New';
154+ src: url('/fonts/LiberationMono-Italic.ttf') format('truetype');
155+ font-display: swap;
156+ font-weight: normal;
157+ font-style: italic;
158+}
159+
160+@font-face {
161+ font-family: 'Courier New';
162+ src: url('/fonts/LiberationMono-BoldItalic.ttf') format('truetype');
163+ font-display: swap;
164+ font-weight: bold;
165+ font-style: italic;
166+}
167+
168+/* ===== RETRO FONT STYLING ===== */
169+/* Retro Windows fonts with proper fallbacks */
170+.retro-font-pixelated {
171+ font-family: "Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif;
172+}
173+
174+.retro-font-tahoma {
175+ font-family: Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif;
176+}
177+
178+.retro-font-verdana {
179+ font-family: Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif;
180+}
181+
182+.retro-font-comicSans {
183+ font-family: "Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif;
184+}
185+
186+.retro-font-timesNewRoman {
187+ font-family: "Times New Roman", Times, serif;
188+}
189+
190+.retro-font-arial {
191+ font-family: Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif;
192+}
193+
194+.retro-font-courierNew {
195+ font-family: "Courier New", "Perfect DOS VGA 437 Win", Courier, monospace;
196+}
197+
198+.retro-font-dos {
199+ font-family: "Perfect DOS VGA 437 Win", "Courier New", Courier, monospace;
200+}
201+
202+/* ===== GRADIENT TEXT EFFECTS ===== */
203+.gradient-text {
204+ background-size: 200% 200%;
205+ -webkit-background-clip: text;
206+ -webkit-text-fill-color: transparent;
207+ background-clip: text;
208+ animation: gradientShift 3s ease infinite;
209+}
210+
211+.gradient-text-static {
212+ /* Remove text-fill-color to allow inline color styles to work */
213+ /* Individual spans will have their own colors set inline */
214+ letter-spacing: normal;
215+ word-spacing: normal;
216+}
217+
218+/* Gradient text container uses flexbox */
219+.gradient-text-static {
220+ display: inline-flex !important;
221+ flex-wrap: wrap !important;
222+ align-items: baseline !important;
223+}
224+
225+/* Ensure gradient character spans don't add spacing */
226+.gradient-text-static > span {
227+ display: inline !important;
228+ margin: 0 !important;
229+ padding: 0 !important;
230+ letter-spacing: 0 !important;
231+ line-height: inherit !important;
232+ font-size: inherit !important;
233+}
234+
235+/* Ensure gradient character colors from inline styles are respected */
236+.gradient-text-static .gradient-char {
237+ -webkit-text-fill-color: initial !important;
238+}
239+
240+/* Gradient text in input fields - preserve normal input background */
241+input[style*="background-clip: text"] {
242+ /* Keep default input background while allowing gradient text */
243+}
244+
245+/* Gradient animation for AOL-style effects */
246+@keyframes gradientShift {
247+ 0% { background-position: 0% 50%; }
248+ 50% { background-position: 100% 50%; }
249+ 100% { background-position: 0% 50%; }
250+}
251+
252+/* Specific gradient presets */
253+.gradient-rainbow {
254+ background: linear-gradient(45deg,
255+ #FF0000, #FF8000, #FFFF00, #80FF00, #00FF00, #00FF80,
256+ #00FFFF, #0080FF, #0000FF, #8000FF, #FF00FF, #FF0080);
257+}
258+
259+.gradient-fire {
260+ background: linear-gradient(45deg, #FF0000, #FF4000, #FF8000, #FFBF00, #FFFF00);
261+}
262+
263+.gradient-ocean {
264+ background: linear-gradient(45deg, #000080, #0040FF, #0080FF, #00BFFF, #00FFFF);
265+}
266+
267+.gradient-sunset {
268+ background: linear-gradient(45deg, #FF4500, #FF6347, #FFD700, #FFFF00);
269+}
270+
271+.gradient-neon {
272+ background: linear-gradient(45deg, #FF00FF, #8000FF, #0080FF, #00FFFF);
273+}
274+
275+.gradient-forest {
276+ background: linear-gradient(45deg, #006400, #228B22, #32CD32, #90EE90);
277+}
278+
279+.gradient-royal {
280+ background: linear-gradient(45deg, #4B0082, #6A5ACD, #9370DB, #DDA0DD);
281+}
282+
283+/* ===== CHAT MESSAGE FORMATTING ===== */
284+.formatted-message {
285+ word-break: break-word;
286+ line-height: 1.4;
287+ max-width: 100%;
288+}
289+
290+.formatted-message strong {
291+ font-weight: bold;
292+}
293+
294+.formatted-message em {
295+ font-style: italic;
296+}
297+
298+.formatted-message u {
299+ text-decoration: underline;
300+}
301+
302+.formatted-message s {
303+ text-decoration: line-through;
304+}
305+
306+.formatted-message code {
307+ background: #f0f0f0;
308+ padding: 1px 3px;
309+ border-radius: 2px;
310+ font-family: 'Courier New', Courier, monospace;
311+ font-size: 0.9em;
312+}
313+
314+/* ===== INPUT STYLING FIXES ===== */
315+/* Fix letter spacing issues with gradient text in inputs */
316+input[style*="background-clip: text"] {
317+ letter-spacing: normal !important;
318+ word-spacing: normal !important;
319+}
320+
321+/* Ensure gradient inputs don't have extra padding */
322+input.styled-input {
323+ letter-spacing: 0;
324+ word-spacing: 0;
325+}
326+
327+/* Ensure proper font weights are used */
328+.retro-font-tahoma {
329+ font-weight: 80;
330+}
331+
332+/* ===== TEXT STYLE SELECTOR STYLING ===== */
333+.text-formatting-toolbar {
334+ background: #f0f0f0;
335+ border: 2px inset #dfdfdf;
336+ padding: 4px;
337+ margin-bottom: 8px;
338+ display: flex;
339+ flex-wrap: wrap;
340+ gap: 4px;
341+ align-items: center;
342+}
343+
344+.formatting-button {
345+ width: 24px;
346+ height: 24px;
347+ border: 1px outset #dfdfdf;
348+ background: #f0f0f0;
349+ cursor: pointer;
350+ display: flex;
351+ align-items: center;
352+ justify-content: center;
353+ font-size: 12px;
354+ font-weight: bold;
355+ transition: all 0.1s ease;
356+}
357+
358+.formatting-button:hover {
359+ background: #e0e0e0;
360+}
361+
362+.formatting-button:active,
363+.formatting-button.active {
364+ border: 1px inset #dfdfdf;
365+ background: #d0d0d0;
366+}
367+
368+.font-selector {
369+ padding: 2px 4px;
370+ border: 1px inset #dfdfdf;
371+ background: white;
372+ font-size: 11px;
373+ min-width: 80px;
374+}
375+
376+.size-slider {
377+ width: 60px;
378+}
379+
380+.color-indicator {
381+ width: 16px;
382+ height: 16px;
383+ border: 1px solid #000;
384+ cursor: pointer;
385+ display: inline-block;
386+}
387+
388+/* ===== ACCESSIBILITY IMPROVEMENTS ===== */
389+@media (prefers-reduced-motion: reduce) {
390+ .gradient-text {
391+ animation: none;
392+ }
393+}
394+
395+/* High contrast mode support */
396+@media (prefers-contrast: high) {
397+ .gradient-text {
398+ -webkit-text-fill-color: unset;
399+ background: none;
400+ color: inherit;
401+ }
402+
403+ .gradient-text-static {
404+ -webkit-text-fill-color: unset;
405+ background: none;
406+ color: inherit;
407+ }
408+}
409+
410+/* ===== RETRO STYLING ENHANCEMENTS ===== */
411+.retro-message {
412+ font-family: inherit;
413+ line-height: 1.3;
414+ word-wrap: break-word;
415+}
416+
417+.retro-message.style-bold {
418+ font-weight: bold;
419+}
420+
421+.retro-message.style-italic {
422+ font-style: italic;
423+}
424+
425+.retro-message.style-underline {
426+ text-decoration: underline;
427+}
428+
429+.retro-message.style-strikethrough {
430+ text-decoration: line-through;
431+}
432+
433+.retro-message.style-underline.style-strikethrough {
434+ text-decoration: underline line-through;
435+}
436+
437+/* ===== PERFORMANCE OPTIMIZATIONS ===== */
438+.gradient-text,
439+.gradient-text-static {
440+ will-change: background-position;
441+ contain: layout style paint;
442+}
443+
444+/* Limit gradient text length for performance */
445+.gradient-text-long {
446+ max-width: 500px;
447+ overflow: hidden;
448+ text-overflow: ellipsis;
449+}
5450
A src/lib/buddyListCache.ts +55 -0
@@ -0,0 +1,55 @@
1+import { createLogger } from './utils/logger.server';
2+import type { SafeUser } from './types/chat';
3+
4+const log = createLogger('buddy-list-cache');
5+
6+class BuddyListCache {
7+ private cache: SafeUser[] | null = null;
8+ private lastHash: string = '';
9+ private lastFetch: number = 0;
10+ private isDirty: boolean = true; // Force initial fetch
11+
12+ // Mark cache as needing refresh
13+ invalidate(): void {
14+ this.isDirty = true;
15+ log.debug('Buddy list cache invalidated');
16+ }
17+
18+ // Check if we need to fetch new data
19+ needsRefresh(): boolean {
20+ return this.isDirty || this.cache === null;
21+ }
22+
23+ // Update cache with new data
24+ update(users: SafeUser[]): boolean {
25+ const newHash = JSON.stringify(users);
26+ const hasChanged = newHash !== this.lastHash;
27+
28+ if (hasChanged) {
29+ this.cache = users;
30+ this.lastHash = newHash;
31+ this.lastFetch = Date.now();
32+ this.isDirty = false;
33+
34+ log.debug('Buddy list cache updated', {
35+ userCount: users.length,
36+ changed: hasChanged
37+ });
38+ }
39+
40+ return hasChanged;
41+ }
42+
43+ // Get cached data
44+ get(): SafeUser[] | null {
45+ return this.cache;
46+ }
47+
48+ // Get cache age in milliseconds
49+ getAge(): number {
50+ return this.cache ? Date.now() - this.lastFetch : Infinity;
51+ }
52+}
53+
54+// Export singleton instance
55+export const buddyListCache = new BuddyListCache();⏎̸
M src/lib/components/aim-login.svelte +50 -45
@@ -5,9 +5,10 @@
55 import type { User, SafeUser } from '$lib/types/chat';
66 import { invalidateAll } from '$app/navigation';
77 import { chatState } from '$lib/states/chat.svelte';
8- import Turnstile from './turnstile.svelte';
98 import { createSafeUser } from '$lib/types/chat';
109 import { onMount } from 'svelte';
10+ import PasswordStrengthIndicator from './password-strength-indicator.svelte';
11+ import { loginSchema, createRegistrationSchema, validatePasswordStrength, DEFAULT_PASSWORD_CONSTRAINTS, type PasswordConstraints } from '../validation/password';
1112
1213 interface $$Props {
1314 showAuth: boolean;
@@ -24,7 +25,6 @@
2425 let siPassword = $state('');
2526 let error = $state('');
2627 let loginStatus = $state('idle');
27- let siTurnstileToken = $state('');
2828
2929 // Sign Up state
3030 let suUsername = $state('');
@@ -35,6 +35,10 @@
3535 let signupStatus = $state('idle');
3636 let suTurnstileToken = $state('');
3737
38+ // Password validation state
39+ let passwordConstraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS;
40+ let showPasswordStrength = $state(false);
41+
3842 // Added dragging state and handler similar to chat-room
3943 let windowX = $state(0);
4044 let windowY = $state(0);
@@ -60,8 +64,11 @@
6064 async function handleSigninSubmit() {
6165 error = '';
6266 loginStatus = 'loading';
63- if (!siUsername || !siPassword || !siTurnstileToken) {
64- error = 'Please complete all fields including the security check';
67+
68+ // Validate login data
69+ const validationError = validateLoginData();
70+ if (validationError) {
71+ error = validationError;
6572 loginStatus = 'error';
6673 return;
6774 }
@@ -73,8 +80,7 @@
7380 headers: { 'Content-Type': 'application/json' },
7481 body: JSON.stringify({
7582 username: siUsername,
76- password: siPassword,
77- turnstileToken: siTurnstileToken
83+ password: siPassword
7884 })
7985 });
8086
@@ -107,41 +113,30 @@
107113 }
108114 }
109115
110- // New: Create a helper function for client-side validations based on server rules.
111- function validateSignupData(): string | null {
112- // Trim values
113- const username = suUsername.trim();
114- const password = suPassword.trim();
115- const confirmPasswordValue = suConfirmPassword.trim();
116-
117- if (!username || !password || !confirmPasswordValue) {
118- return 'All fields must be filled';
119- }
120-
121- if (username.length < 3) {
122- return 'Username must be at least 3 characters';
123- }
124-
125- if (username.length > 32) {
126- return 'Username must be at most 32 characters';
127- }
128-
129- // Only allow letters, numbers, underscores, and dashes in the username.
130- const usernameRegex = /^[a-zA-Z0-9_-]+$/;
131- if (!usernameRegex.test(username)) {
132- return 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.';
133- }
134-
135- if (password !== confirmPasswordValue) {
136- return 'Passwords do not match';
116+ // Validation using Zod schemas
117+ function validateLoginData() {
118+ try {
119+ loginSchema.parse({ username: siUsername.trim(), password: siPassword.trim() });
120+ return null;
121+ } catch (err: any) {
122+ return err.errors?.[0]?.message || 'Invalid input';
137123 }
124+ }
138125
139- if (password.length < 8 || password.length > 64) {
140- return 'Password must be between 8 and 64 characters';
126+ function validateSignupData() {
127+ try {
128+ const registrationSchema = createRegistrationSchema(passwordConstraints);
129+ registrationSchema.parse({
130+ suUsername: suUsername.trim(),
131+ suPassword: suPassword.trim(),
132+ suConfirmPassword: suConfirmPassword.trim(),
133+ captchaAnswer: captchaAnswer.trim(),
134+ turnstileToken: suTurnstileToken
135+ });
136+ return null;
137+ } catch (err: any) {
138+ return err.errors?.[0]?.message || 'Invalid input';
141139 }
142-
143- // All checks passed.
144- return null;
145140 }
146141
147142 async function handleSignupSubmit() {
@@ -156,10 +151,9 @@
156151 return;
157152 }
158153
159- // Additional check for captcha and Turnstile token,
160- // in case those fields are not filled.
161- if (!captchaAnswer || !suTurnstileToken) {
162- signupError = 'Please complete all fields including the security check';
154+ // Additional check for captcha
155+ if (!captchaAnswer) {
156+ signupError = 'Please complete all fields including the PDR question';
163157 signupStatus = 'error';
164158 return;
165159 }
@@ -271,7 +265,6 @@
271265 <label for="si-password">Mot de passe</label>
272266 <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" />
273267 </div>
274- <Turnstile onVerify={(token: string) => siTurnstileToken = token} />
275268 {#if error}
276269 <div class="error">{error}</div>
277270 {/if}
@@ -311,7 +304,20 @@
311304 </div>
312305 <div class="form-group">
313306 <label for="su-password">Mot de passe</label>
314- <input type="password" id="su-password" bind:value={suPassword} placeholder="Mot de passe" />
307+ <input
308+ type="password"
309+ id="su-password"
310+ bind:value={suPassword}
311+ placeholder="Mot de passe"
312+ onfocus={() => showPasswordStrength = true}
313+ />
314+ {#if showPasswordStrength}
315+ <PasswordStrengthIndicator
316+ password={suPassword}
317+ constraints={passwordConstraints}
318+ showDetails={true}
319+ />
320+ {/if}
315321 </div>
316322 <div class="form-group">
317323 <label for="su-confirm-password">Confirmer le mot de passe</label>
@@ -321,7 +327,6 @@
321327 <label for="captcha">Quelle est la signification de PDR ?</label>
322328 <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" />
323329 </div>
324- <Turnstile onVerify={(token: string) => suTurnstileToken = token} />
325330 {#if signupError}
326331 <div class="error">{signupError}</div>
327332 {/if}
M src/lib/components/chat-room.svelte +82 -15
@@ -11,8 +11,14 @@ import LoadingButton from './ui/button-loading.svelte';
1111 import Tooltip from './ui/tooltip.svelte';
1212 import LoadingDots from './ui/loading-dots.svelte';
1313 import AimLogin from './aim-login.svelte';
14+import FormattedMessage from './formatted-message.svelte';
15+import TextFormattingToolbar from './text-formatting-toolbar.svelte';
16+import { DEFAULT_TEXT_STYLE, type TextStyle, generateInputCSSStyle } from '../types/text-formatting';
1417 import { formatFrenchDateTime, formatFrenchRelativeTimeSafe } from '$lib/utils/date-format';
1518
19+// Props destructuring must come first
20+let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props();
21+
1622 // Initialize with default values for SSR
1723 let windowWidth = $state(400);
1824 let windowHeight = $state(500);
@@ -26,6 +32,14 @@ let isInitialLoading = $state(true);
2632 let showAuth = $state(false);
2733 let isMinimized = $state(false);
2834
35+// Text formatting state - initialize directly with merged initial style
36+let currentTextStyle = $state<TextStyle>({
37+ ...DEFAULT_TEXT_STYLE,
38+ ...initialTextStyle,
39+ color: initialTextStyle.color || '#000000' // Ensure we always have a valid color
40+});
41+let showFormattingToolbar = $state(true);
42+
2943 // Rate limiting state
3044 let cooldownEndTime = $state<number | null>(null);
3145 let cooldownProgress = $state(0);
@@ -60,8 +74,6 @@ function startCooldownTimer(retryAfter: number) {
6074 updateCooldownProgress();
6175 }
6276
63-let { showChatRoom = $bindable() } = $props();
64-
6577 function handleClose() {
6678 showChatRoom = false;
6779 }
@@ -239,7 +251,7 @@ async function handleSubmit() {
239251
240252 isSendingMessage = true;
241253 try {
242- const response = await chatState.sendMessage(currentMessage);
254+ const response = await chatState.sendMessage(currentMessage, 'chat', currentTextStyle);
243255 if (!response.success && response.isRateLimited && response.retryAfter) {
244256 startCooldownTimer(response.retryAfter);
245257 rateLimitWarning = 'Whoa there! You\'re sending messages too quickly. Take a breather...';
@@ -459,7 +471,7 @@ function handleLoginSuccess() {
459471 ondragmove={handleDragMove}
460472 >
461473 <div class="title-bar">
462- <div class="title-bar-text" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
474+ <div class="title-bar-text" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'MS Sans Serif', 'Pixelated MS Sans Serif', sans-serif;">
463475 Pdr Aim {#if currentUser} - {currentUser.nickname}{:else} - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if}
464476 {#if sseError && !isMinimized}
465477 <span class="connection-error">⚠️ Erreur de connexion</span>
@@ -563,21 +575,49 @@ function handleLoginSuccess() {
563575 }}>
564576 <span class="nickname pointer-events-none">{message.user.nickname}:</span>
565577 </Tooltip>
566- <span class="content">{message.content}</span>
578+ <FormattedMessage {message} allowFormatting={true} />
567579 {/if}
568580 </div>
569581 {/each}
570582 </div>
571583
584+ <!-- Text Formatting Toolbar -->
585+ {#if currentUser}
586+ <div style="margin-bottom: 0.25rem;">
587+ <TextFormattingToolbar
588+ bind:style={currentTextStyle}
589+ compact={true}
590+ showFontSelector={true}
591+ showGradients={true}
592+ />
593+ </div>
594+ {/if}
595+
596+
572597 <div class="field-row input-container" style="margin: 0;">
573- <input
574- type="text"
575- bind:value={currentMessage}
576- style="flex: 1;"
577- onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
578- placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
579- disabled={!currentUser || Boolean(cooldownEndTime)}
580- />
598+ {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}
599+ <div class="gradient-input-wrapper" style="flex: 1; position: relative; background: white;">
600+ <input
601+ type="text"
602+ bind:value={currentMessage}
603+ class="styled-input retro-font-{currentTextStyle.fontFamily}"
604+ style="width: 100%; background: transparent; {generateInputCSSStyle(currentTextStyle)}"
605+ onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
606+ placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
607+ disabled={!currentUser || Boolean(cooldownEndTime)}
608+ />
609+ </div>
610+ {:else}
611+ <input
612+ type="text"
613+ bind:value={currentMessage}
614+ class="styled-input retro-font-{currentTextStyle.fontFamily}"
615+ style="flex: 1; {generateInputCSSStyle(currentTextStyle)}"
616+ onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
617+ placeholder={cooldownEndTime ? `Patientez ${cooldownProgress.toFixed(1)}s...` : "Écrivez un message..."}
618+ disabled={!currentUser || Boolean(cooldownEndTime)}
619+ />
620+ {/if}
581621 <LoadingButton
582622 onclick={handleSubmit}
583623 disabled={!currentUser || Boolean(cooldownEndTime)}
@@ -632,9 +672,14 @@ function handleLoginSuccess() {
632672 {/if}
633673
634674 <style>
635- .chat-area, .users-list, input {
675+ .chat-area, .users-list {
636676 font-size: 1rem;
637- font-family: Arial, Verdana, Tahoma, sans-serif;
677+ font-family: "Pixelated MS Sans Serif", Arial, Verdana, Tahoma, sans-serif;
678+ }
679+
680+ input:not(.styled-input) {
681+ font-size: 1rem;
682+ font-family: "Pixelated MS Sans Serif", Arial, Verdana, Tahoma, sans-serif;
638683 }
639684
640685 .title-bar {
@@ -674,6 +719,16 @@ function handleLoginSuccess() {
674719 gap: 0.5rem;
675720 align-items: flex-start;
676721 }
722+
723+ /* Fix gradient text spacing in messages */
724+ .message :global(.gradient-text-static) {
725+ letter-spacing: normal !important;
726+ word-spacing: normal !important;
727+ }
728+
729+ .message :global(.gradient-text-static span) {
730+ letter-spacing: 0 !important;
731+ }
677732
678733 .message .nickname {
679734 font-weight: bold;
@@ -1036,4 +1091,16 @@ function handleLoginSuccess() {
10361091 .pointer-events-none {
10371092 pointer-events: none;
10381093 }
1094+
1095+ .gradient-input-wrapper {
1096+ border: 1px inset #dfdfdf;
1097+ padding: 2px;
1098+ }
1099+
1100+ .gradient-input-wrapper input {
1101+ border: none !important;
1102+ outline: none !important;
1103+ padding: 1px 3px;
1104+ }
1105+
10391106 </style>⏎̸
A src/lib/components/color-picker-256.svelte +453 -0
@@ -0,0 +1,453 @@
1+<script lang="ts">
2+import { generate256Colors, CLASSIC_COLORS } from '../types/text-formatting';
3+
4+// Props
5+let {
6+ selectedColor = $bindable('#000000'),
7+ selectedGradient = $bindable(undefined),
8+ onColorChange,
9+ onGradientChange,
10+ compact = true
11+} = $props<{
12+ selectedColor?: string;
13+ selectedGradient?: string[];
14+ onColorChange?: (color: string) => void;
15+ onGradientChange?: (gradient: string[]) => void;
16+ compact?: boolean;
17+}>();
18+
19+// State
20+let showPicker = $state(false);
21+let gradientColors = $state<string[]>([selectedColor || '#000000']);
22+let activeGradientSlot = $state(0);
23+
24+// Generate color palette
25+const colors256 = generate256Colors();
26+const classicColors = Object.values(CLASSIC_COLORS);
27+
28+// Initialize gradient colors from props
29+$effect(() => {
30+ if (selectedGradient && selectedGradient.length > 0) {
31+ gradientColors = [...selectedGradient];
32+ } else if (selectedColor) {
33+ gradientColors = [selectedColor];
34+ }
35+});
36+
37+// Get current display color/gradient
38+let displayStyle = $derived(() => {
39+ if (gradientColors.length > 1) {
40+ return `background: linear-gradient(45deg, ${gradientColors.join(', ')})`;
41+ }
42+ return `background: ${gradientColors[0] || '#000000'}`;
43+});
44+
45+// Handle color selection
46+function selectColor(color: string) {
47+ if (activeGradientSlot < gradientColors.length) {
48+ gradientColors[activeGradientSlot] = color;
49+ gradientColors = [...gradientColors]; // Trigger reactivity
50+
51+ if (gradientColors.length === 1) {
52+ // Single color mode
53+ selectedColor = color;
54+ selectedGradient = undefined;
55+ onColorChange?.(color);
56+ } else {
57+ // Gradient mode
58+ selectedGradient = [...gradientColors];
59+ selectedColor = gradientColors[0];
60+ onGradientChange?.(gradientColors);
61+ }
62+ }
63+}
64+
65+// Gradient management
66+function addGradientColor() {
67+ if (gradientColors.length < 3) {
68+ const newColor = gradientColors[gradientColors.length - 1] || '#FFFFFF';
69+ gradientColors = [...gradientColors, newColor];
70+ activeGradientSlot = gradientColors.length - 1;
71+ selectedGradient = [...gradientColors];
72+ onGradientChange?.(gradientColors);
73+ }
74+}
75+
76+function removeGradientColor(index: number) {
77+ if (gradientColors.length > 1) {
78+ gradientColors = gradientColors.filter((_, i) => i !== index);
79+ activeGradientSlot = Math.min(activeGradientSlot, gradientColors.length - 1);
80+
81+ if (gradientColors.length === 1) {
82+ // Back to single color mode
83+ selectedColor = gradientColors[0];
84+ selectedGradient = undefined;
85+ onColorChange?.(gradientColors[0]);
86+ } else {
87+ selectedGradient = [...gradientColors];
88+ onGradientChange?.(gradientColors);
89+ }
90+ }
91+}
92+
93+function setActiveSlot(index: number) {
94+ activeGradientSlot = index;
95+}
96+
97+// Close picker
98+function closePicker() {
99+ showPicker = false;
100+}
101+
102+// Handle click outside to close
103+function handleClickOutside(event: MouseEvent) {
104+ const target = event.target as HTMLElement;
105+ const colorPickerContainer = document.querySelector('.color-picker-container');
106+
107+ if (showPicker && colorPickerContainer && !colorPickerContainer.contains(target)) {
108+ closePicker();
109+ }
110+}
111+
112+// Add/remove click outside listener
113+$effect(() => {
114+ if (showPicker) {
115+ document.addEventListener('click', handleClickOutside);
116+ return () => {
117+ document.removeEventListener('click', handleClickOutside);
118+ };
119+ }
120+});
121+</script>
122+
123+<div class="color-picker-container">
124+ <!-- Color picker button -->
125+ <button
126+ onclick={(e) => {
127+ e.stopPropagation();
128+ showPicker = !showPicker;
129+ }}
130+ title={gradientColors.length > 1 ? 'Gradient Color' : 'Text Color'}
131+ style="width: 20px; height: 20px; padding: 0;"
132+ >
133+ <div
134+ class="color-indicator"
135+ style="{displayStyle()}; width: 12px; height: 12px; border: 1px solid #000;"
136+ ></div>
137+ </button>
138+
139+ <!-- Color picker dropdown -->
140+ {#if showPicker}
141+ <div class="picker-dropdown sunken-panel" onclick={(e) => e.stopPropagation()}>
142+ <!-- Gradient controls -->
143+ <div class="gradient-controls">
144+ <div class="gradient-slots">
145+ {#each gradientColors as color, index}
146+ <button
147+ class="gradient-slot"
148+ class:active={activeGradientSlot === index}
149+ style="background-color: {color} !important;"
150+ onclick={() => setActiveSlot(index)}
151+ title="Color {index + 1} - Click to select this color slot"
152+ >
153+ {#if gradientColors.length > 1}
154+ <span
155+ class="remove-slot"
156+ onclick={(e) => {
157+ e.stopPropagation();
158+ removeGradientColor(index);
159+ }}
160+ title="Remove this color"
161+ role="button"
162+ tabindex="0"
163+ >×</span>
164+ {/if}
165+ </button>
166+ {/each}
167+
168+ {#if gradientColors.length < 3}
169+ <button
170+ class="add-slot"
171+ onclick={addGradientColor}
172+ title="Add another color to create gradient"
173+ >+</button>
174+ {/if}
175+ </div>
176+
177+ {#if gradientColors.length > 1}
178+ <div class="gradient-preview" style="background: linear-gradient(90deg, {gradientColors.join(', ')});"></div>
179+ {/if}
180+ </div>
181+
182+ <!-- Classic colors (first row) -->
183+ <div class="color-section">
184+ <div class="section-title">Classic Colors</div>
185+ <div class="color-row">
186+ {#each classicColors as color}
187+ <button
188+ class="color-swatch classic-swatch"
189+ style="--swatch-color: {color}; background-color: {color} !important;"
190+ onclick={() => selectColor(color)}
191+ title={color}
192+ ></button>
193+ {/each}
194+ </div>
195+ </div>
196+
197+ <!-- 256-color palette -->
198+ <div class="color-section">
199+ <div class="section-title">Full Palette</div>
200+ <div class="color-grid">
201+ {#each colors256 as color}
202+ <button
203+ class="color-swatch"
204+ style="--swatch-color: {color}; background-color: {color} !important;"
205+ onclick={() => selectColor(color)}
206+ title={color}
207+ ></button>
208+ {/each}
209+ </div>
210+ </div>
211+
212+ <!-- Close button -->
213+ <div class="picker-footer">
214+ <button onclick={closePicker}>Close</button>
215+ </div>
216+ </div>
217+ {/if}
218+</div>
219+
220+<style>
221+ .color-picker-container {
222+ position: relative;
223+ display: inline-block;
224+ }
225+
226+ .picker-dropdown {
227+ position: absolute;
228+ top: 100%;
229+ right: 0; /* Changed from left: 0 to prevent cutoff */
230+ z-index: 1000;
231+ padding: 4px;
232+ margin-top: 2px;
233+ background: #ece9d8; /* Authentic XP dialog background */
234+ width: 420px;
235+ max-height: 380px;
236+ overflow-y: auto;
237+ border: 2px outset #c0c0c0;
238+ box-shadow: 2px 2px 4px rgba(0,0,0,0.3);
239+ box-sizing: border-box;
240+ }
241+
242+ /* Responsive positioning for mobile/small screens */
243+ @media (max-width: 768px) {
244+ .picker-dropdown {
245+ position: fixed;
246+ top: 50%;
247+ left: 50%;
248+ transform: translate(-50%, -50%);
249+ right: auto;
250+ width: 90vw;
251+ max-width: 420px;
252+ max-height: 85vh;
253+ }
254+ }
255+
256+ .gradient-controls {
257+ margin-bottom: 6px;
258+ padding: 4px;
259+ border: 1px inset #dfdfdf;
260+ background: #ece9d8;
261+ }
262+
263+ .gradient-slots {
264+ display: flex;
265+ gap: 4px;
266+ margin-bottom: 4px;
267+ align-items: center;
268+ flex-wrap: wrap;
269+ }
270+
271+ .gradient-slot {
272+ width: 24px;
273+ height: 24px;
274+ border: 2px outset #c0c0c0 !important;
275+ cursor: pointer;
276+ position: relative;
277+ padding: 0 !important;
278+ margin: 0 !important;
279+ background-image: none !important;
280+ min-width: 24px !important;
281+ min-height: 24px !important;
282+ max-width: 24px !important;
283+ max-height: 24px !important;
284+ box-sizing: border-box !important;
285+ flex-shrink: 0;
286+ transition: all 0.2s ease;
287+ }
288+
289+ .gradient-slot.active {
290+ border: 2px inset #c0c0c0 !important;
291+ box-shadow: inset 0 0 0 1px #000 !important;
292+ }
293+
294+ .gradient-slot:hover {
295+ transform: scale(1.1);
296+ }
297+
298+ .gradient-slot .remove-slot {
299+ position: absolute;
300+ top: -4px;
301+ right: -4px;
302+ width: 12px !important;
303+ height: 12px !important;
304+ min-width: 12px !important;
305+ min-height: 12px !important;
306+ max-width: 12px !important;
307+ max-height: 12px !important;
308+ background-color: #808080 !important;
309+ color: white !important;
310+ border: 1px solid #000 !important;
311+ font-size: 9px !important;
312+ line-height: 1 !important;
313+ cursor: pointer !important;
314+ display: inline-flex !important;
315+ align-items: center !important;
316+ justify-content: center !important;
317+ border-radius: 0 !important;
318+ padding: 0 !important;
319+ margin: 0 !important;
320+ font-weight: normal !important;
321+ font-family: 'MS Sans Serif', sans-serif !important;
322+ z-index: 10;
323+ opacity: 0;
324+ transition: opacity 0.15s ease;
325+ text-align: center !important;
326+ box-sizing: border-box !important;
327+ user-select: none;
328+ }
329+
330+ .gradient-slot:hover .remove-slot {
331+ opacity: 0.8;
332+ }
333+
334+ .gradient-slot .remove-slot:hover {
335+ background-color: #000 !important;
336+ opacity: 1;
337+ }
338+
339+ .add-slot {
340+ width: 24px;
341+ height: 24px;
342+ border: 2px dashed #666 !important;
343+ background: #f0f0f0 !important;
344+ cursor: pointer;
345+ font-weight: bold;
346+ padding: 0 !important;
347+ margin: 0 !important;
348+ min-width: 24px !important;
349+ min-height: 24px !important;
350+ max-width: 24px !important;
351+ max-height: 24px !important;
352+ background-image: none !important;
353+ box-sizing: border-box !important;
354+ flex-shrink: 0;
355+ display: flex !important;
356+ align-items: center !important;
357+ justify-content: center !important;
358+ font-size: 14px !important;
359+ color: #666 !important;
360+ transition: all 0.2s ease;
361+ }
362+
363+ .add-slot:hover {
364+ background: #e0e0e0 !important;
365+ border-color: #333 !important;
366+ transform: scale(1.05);
367+ }
368+
369+ .gradient-preview {
370+ height: 12px;
371+ border: 1px inset #dfdfdf;
372+ }
373+
374+ .color-section {
375+ margin-bottom: 6px;
376+ }
377+
378+ .section-title {
379+ font-size: 10px;
380+ font-weight: bold;
381+ margin-bottom: 2px;
382+ color: #000;
383+ }
384+
385+ .color-row {
386+ display: flex;
387+ flex-wrap: wrap;
388+ gap: 2px;
389+ margin-bottom: 4px;
390+ padding: 3px;
391+ background: #ece9d8;
392+ border: 1px inset #dfdfdf;
393+ }
394+
395+ .classic-swatch {
396+ width: 18px !important;
397+ height: 18px !important;
398+ min-width: 18px !important;
399+ min-height: 18px !important;
400+ }
401+
402+ .color-grid {
403+ display: grid;
404+ grid-template-columns: repeat(16, 1fr);
405+ gap: 2px;
406+ width: 100%;
407+ padding: 3px;
408+ background: #ece9d8;
409+ border: 1px inset #dfdfdf;
410+ box-sizing: border-box;
411+ }
412+
413+ .color-swatch {
414+ width: 18px !important;
415+ height: 18px !important;
416+ min-width: 18px !important;
417+ min-height: 18px !important;
418+ border: 1px solid #808080 !important; /* Override xp.css */
419+ cursor: pointer;
420+ padding: 0 !important;
421+ margin: 0 !important;
422+ box-sizing: border-box !important;
423+ outline: none !important;
424+ /* Remove any xp.css button styling */
425+ background-image: none !important;
426+ background-color: var(--swatch-color, #000000) !important;
427+ font-size: 0 !important; /* Hide any text */
428+ line-height: 0 !important;
429+ }
430+
431+ .color-swatch:hover {
432+ border: 2px solid #000000 !important;
433+ transform: scale(1.1) !important;
434+ z-index: 5;
435+ position: relative;
436+ }
437+
438+ .color-swatch:active {
439+ border: 2px solid #ffffff !important;
440+ transform: scale(0.95) !important;
441+ }
442+
443+ .picker-footer {
444+ text-align: center;
445+ padding-top: 4px;
446+ border-top: 1px solid #808080;
447+ margin-top: 4px;
448+ }
449+
450+ .color-indicator {
451+ border-radius: 1px;
452+ }
453+</style>⏎̸
A src/lib/components/formatted-message.svelte +249 -0
@@ -0,0 +1,249 @@
1+<script lang="ts">
2+import { onMount } from 'svelte';
3+import type { EnrichedMessage } from '../types/chat';
4+import type { TextStyle } from '../types/text-formatting';
5+import { formatText, createGradientText } from '../utils/text-formatter';
6+import { DEFAULT_TEXT_STYLE, generateCSSStyle, RETRO_FONTS } from '../types/text-formatting';
7+
8+// Props
9+let {
10+ message,
11+ allowFormatting = true
12+} = $props<{
13+ message: EnrichedMessage;
14+ allowFormatting?: boolean;
15+}>();
16+
17+// State
18+let formattedContent = $state('');
19+let isProcessing = $state(false);
20+let hasError = $state(false);
21+
22+// Process message formatting
23+async function processMessage() {
24+ if (!allowFormatting || !message.hasFormatting) {
25+ formattedContent = escapeHtml(message.content);
26+ return;
27+ }
28+
29+ isProcessing = true;
30+ hasError = false;
31+
32+ try {
33+ // Parse style data if available
34+ let style: TextStyle = DEFAULT_TEXT_STYLE;
35+ if (message.styleData) {
36+ try {
37+ style = JSON.parse(message.styleData);
38+ console.debug('Parsed message style:', style, 'for message:', message.content);
39+ } catch (parseError) {
40+ console.warn('Failed to parse message style data:', parseError);
41+ }
42+ }
43+
44+ // Check if this is a gradient message
45+ if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
46+ // Use character-by-character gradient for better compatibility
47+ console.debug('Creating gradient text with colors:', style.gradient, 'for text:', message.content);
48+ formattedContent = createGradientText(message.content, style.gradient, 'gradient-text-static', style);
49+ console.debug('Generated gradient HTML:', formattedContent);
50+ return;
51+ }
52+
53+ // Regular formatted text (no gradient)
54+ const styleWithoutGradient = { ...style, gradient: undefined };
55+ formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting);
56+
57+ } catch (error) {
58+ console.error('Message formatting error:', error);
59+ hasError = true;
60+ formattedContent = escapeHtml(message.content);
61+ } finally {
62+ isProcessing = false;
63+ }
64+}
65+
66+// Process message when it changes
67+$effect(() => {
68+ processMessage();
69+});
70+
71+// Utility function to escape HTML
72+function escapeHtml(text: string): string {
73+ const div = document.createElement('div');
74+ div.textContent = text;
75+ return div.innerHTML;
76+}
77+
78+// Generate inline styles for the message
79+function getMessageStyles(): string {
80+ if (!message.hasFormatting || !message.styleData) {
81+ return '';
82+ }
83+
84+ try {
85+ const style: TextStyle = JSON.parse(message.styleData);
86+ // For gradient messages, we still need some styles but not font-family (use class instead)
87+ if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
88+ // Return only non-color and non-font-family styles for gradient messages
89+ const styles: string[] = [];
90+ styles.push(`font-size: ${style.fontSize}px`);
91+ if (style.bold) styles.push(`font-weight: bold`);
92+ if (style.italic) styles.push(`font-style: italic`);
93+ const decorations: string[] = [];
94+ if (style.underline) decorations.push('underline');
95+ if (style.strikethrough) decorations.push('line-through');
96+ if (decorations.length > 0) {
97+ styles.push(`text-decoration: ${decorations.join(' ')}`);
98+ }
99+ return styles.join('; ');
100+ }
101+ // For non-gradient messages, exclude font-family (use class instead)
102+ const styles: string[] = [];
103+ styles.push(`font-size: ${style.fontSize}px`);
104+ if (style.color) styles.push(`color: ${style.color}`);
105+ if (style.bold) styles.push(`font-weight: bold`);
106+ if (style.italic) styles.push(`font-style: italic`);
107+ const decorations: string[] = [];
108+ if (style.underline) decorations.push('underline');
109+ if (style.strikethrough) decorations.push('line-through');
110+ if (decorations.length > 0) {
111+ styles.push(`text-decoration: ${decorations.join(' ')}`);
112+ }
113+ return styles.join('; ');
114+ } catch (error) {
115+ console.warn('Failed to generate message styles:', error);
116+ return '';
117+ }
118+}
119+
120+// Get CSS classes for the message
121+function getMessageClasses(): string {
122+ const classes = ['formatted-message'];
123+
124+ if (!message.hasFormatting || !message.styleData) {
125+ return classes.join(' ');
126+ }
127+
128+ try {
129+ const style: TextStyle = JSON.parse(message.styleData);
130+
131+ // Add font family class
132+ classes.push(`retro-font-${style.fontFamily}`);
133+
134+ // Add style classes
135+ if (style.bold) classes.push('style-bold');
136+ if (style.italic) classes.push('style-italic');
137+ if (style.underline) classes.push('style-underline');
138+ if (style.strikethrough) classes.push('style-strikethrough');
139+
140+ // Add gradient class if applicable
141+ if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
142+ classes.push('gradient-text-static');
143+ }
144+
145+ } catch (error) {
146+ console.warn('Failed to parse message style for classes:', error);
147+ }
148+
149+ return classes.join(' ');
150+}
151+</script>
152+
153+{#if isProcessing}
154+ <span class={getMessageClasses()} style={getMessageStyles()}>
155+ <span class="processing">...</span>
156+ </span>
157+{:else if hasError}
158+ <span class={getMessageClasses()} style={getMessageStyles()}>
159+ <span class="error" title="Formatting error - showing plain text">{message.content}</span>
160+ </span>
161+{:else}
162+ <!-- For gradient messages, render without wrapper to avoid conflicts -->
163+ {#if message.hasFormatting && message.styleData}
164+ {@const parsedStyle = (() => {
165+ try {
166+ return JSON.parse(message.styleData);
167+ } catch {
168+ return null;
169+ }
170+ })()}
171+ {#if parsedStyle?.gradient && Array.isArray(parsedStyle.gradient) && parsedStyle.gradient.length > 1}
172+ <!-- Gradient messages are already wrapped properly -->
173+ {@html formattedContent}
174+ {:else}
175+ <!-- Regular formatted messages -->
176+ <span class={getMessageClasses()} style={getMessageStyles()}>
177+ {@html formattedContent}
178+ </span>
179+ {/if}
180+ {:else}
181+ <!-- Plain messages -->
182+ <span class={getMessageClasses()}>
183+ {@html formattedContent}
184+ </span>
185+ {/if}
186+{/if}
187+
188+<style>
189+ .processing {
190+ color: #666;
191+ font-style: italic;
192+ animation: pulse 1s infinite;
193+ }
194+
195+ .error {
196+ color: #cc0000;
197+ border-bottom: 1px dotted #cc0000;
198+ }
199+
200+ @keyframes pulse {
201+ 0%, 100% { opacity: 1; }
202+ 50% { opacity: 0.5; }
203+ }
204+
205+ /* Ensure formatted messages don't break layout */
206+ :global(.formatted-message) {
207+ word-break: break-word;
208+ overflow-wrap: break-word;
209+ hyphens: auto;
210+ }
211+
212+ /* Improve gradient text readability */
213+ :global(.formatted-message.gradient-text-static) {
214+ font-weight: bold;
215+ }
216+
217+ /* Ensure gradient spans are visible and preserve their colors */
218+ :global(.gradient-text-static .gradient-char) {
219+ display: inline;
220+ opacity: 1 !important;
221+ margin: 0 !important;
222+ padding: 0 !important;
223+ letter-spacing: 0 !important;
224+ -webkit-text-fill-color: initial !important;
225+ }
226+
227+ /* Don't override inline styles for gradient chars - removed to let inline styles work */
228+
229+ /* Remove any blur or gray effects */
230+ :global(.formatted-message) {
231+ opacity: 1 !important;
232+ filter: none !important;
233+ }
234+
235+ /* Don't inherit color for gradient text spans */
236+ :global(.formatted-message:not(.gradient-text-static)) {
237+ color: inherit;
238+ }
239+
240+ /* Ensure accessibility for screen readers */
241+ :global(.formatted-message[aria-label]) {
242+ position: relative;
243+ }
244+
245+ /* Performance optimization for complex messages */
246+ :global(.formatted-message) {
247+ contain: layout style;
248+ }
249+</style>⏎̸
A src/lib/components/password-strength-indicator.svelte +165 -0
@@ -0,0 +1,165 @@
1+<script lang="ts">
2+ import { validatePasswordStrength, type PasswordConstraints, DEFAULT_PASSWORD_CONSTRAINTS } from '../validation/password';
3+
4+ interface $$Props {
5+ password: string;
6+ constraints?: PasswordConstraints;
7+ showDetails?: boolean;
8+ }
9+
10+ let {
11+ password = '',
12+ constraints = DEFAULT_PASSWORD_CONSTRAINTS,
13+ showDetails = true
14+ } = $props();
15+
16+ // Reactive validation result
17+ let validation = $derived(validatePasswordStrength(password, constraints));
18+
19+ // Get strength color and label
20+ function getStrengthInfo(strength: number) {
21+ if (strength < 30) return { color: '#ff4444', label: 'Weak', class: 'weak' };
22+ if (strength < 60) return { color: '#ffaa00', label: 'Fair', class: 'fair' };
23+ if (strength < 80) return { color: '#44aa44', label: 'Good', class: 'good' };
24+ return { color: '#00aa00', label: 'Strong', class: 'strong' };
25+ }
26+
27+ let strengthInfo = $derived(getStrengthInfo(validation.strength));
28+</script>
29+
30+<div class="password-strength-indicator">
31+ {#if password.length > 0}
32+ <!-- Strength bar -->
33+ <div class="strength-bar">
34+ <div class="strength-bar-bg">
35+ <div
36+ class="strength-bar-fill {strengthInfo.class}"
37+ style="width: {validation.strength}%; background-color: {strengthInfo.color};"
38+ ></div>
39+ </div>
40+ <span class="strength-label {strengthInfo.class}">{strengthInfo.label}</span>
41+ </div>
42+
43+ {#if showDetails}
44+ <!-- Requirements checklist -->
45+ <div class="requirements-list">
46+ {#each validation.requirements as requirement}
47+ <div class="requirement {requirement.met ? 'met' : 'unmet'}">
48+ <span class="requirement-icon">
49+ {requirement.met ? '✓' : '✗'}
50+ </span>
51+ <span class="requirement-text">{requirement.message}</span>
52+ </div>
53+ {/each}
54+ </div>
55+
56+ <!-- Error messages -->
57+ {#if validation.errors.length > 0}
58+ <div class="error-messages">
59+ {#each validation.errors as error}
60+ <div class="error-message">{error}</div>
61+ {/each}
62+ </div>
63+ {/if}
64+ {/if}
65+ {/if}
66+</div>
67+
68+<style>
69+ .password-strength-indicator {
70+ margin-top: 8px;
71+ font-size: 0.875rem;
72+ }
73+
74+ .strength-bar {
75+ display: flex;
76+ align-items: center;
77+ gap: 8px;
78+ margin-bottom: 8px;
79+ }
80+
81+ .strength-bar-bg {
82+ flex: 1;
83+ height: 6px;
84+ background-color: #ddd;
85+ border-radius: 3px;
86+ overflow: hidden;
87+ }
88+
89+ .strength-bar-fill {
90+ height: 100%;
91+ transition: width 0.3s ease, background-color 0.3s ease;
92+ border-radius: 3px;
93+ }
94+
95+ .strength-label {
96+ min-width: 50px;
97+ font-weight: bold;
98+ font-size: 0.75rem;
99+ }
100+
101+ .strength-label.weak { color: #ff4444; }
102+ .strength-label.fair { color: #ffaa00; }
103+ .strength-label.good { color: #44aa44; }
104+ .strength-label.strong { color: #00aa00; }
105+
106+ .requirements-list {
107+ display: flex;
108+ flex-direction: column;
109+ gap: 4px;
110+ margin-bottom: 8px;
111+ }
112+
113+ .requirement {
114+ display: flex;
115+ align-items: center;
116+ gap: 6px;
117+ font-size: 0.75rem;
118+ }
119+
120+ .requirement-icon {
121+ width: 12px;
122+ text-align: center;
123+ font-weight: bold;
124+ }
125+
126+ .requirement.met {
127+ color: #00aa00;
128+ }
129+
130+ .requirement.met .requirement-icon {
131+ color: #00aa00;
132+ }
133+
134+ .requirement.unmet {
135+ color: #888;
136+ }
137+
138+ .requirement.unmet .requirement-icon {
139+ color: #ff4444;
140+ }
141+
142+ .error-messages {
143+ display: flex;
144+ flex-direction: column;
145+ gap: 4px;
146+ }
147+
148+ .error-message {
149+ color: #ff4444;
150+ font-size: 0.75rem;
151+ }
152+
153+ /* Compact mode styles */
154+ :global(.compact) .password-strength-indicator {
155+ font-size: 0.75rem;
156+ }
157+
158+ :global(.compact) .requirements-list {
159+ display: none;
160+ }
161+
162+ :global(.compact) .strength-bar {
163+ margin-bottom: 4px;
164+ }
165+</style>⏎̸
A src/lib/components/text-formatting-toolbar.svelte +216 -0
@@ -0,0 +1,216 @@
1+<script lang="ts">
2+import {
3+ RETRO_FONTS,
4+ CLASSIC_COLORS,
5+ GRADIENT_PRESETS,
6+ DEFAULT_TEXT_STYLE,
7+ type TextStyle,
8+ type FontFamily
9+} from '../types/text-formatting';
10+import ColorPicker256 from './color-picker-256.svelte';
11+
12+// Props
13+let {
14+ style = $bindable(DEFAULT_TEXT_STYLE),
15+ onPreviewText,
16+ compact = true,
17+ showFontSelector = false,
18+ showGradients = true
19+} = $props<{
20+ style?: TextStyle;
21+ onPreviewText?: (text: string) => void;
22+ compact?: boolean;
23+ showFontSelector?: boolean;
24+ showGradients?: boolean;
25+}>();
26+
27+// State
28+let currentColor = $state(style.color || '#000000');
29+let currentGradient = $state(style.gradient);
30+
31+// Toggle functions
32+function toggleBold() {
33+ style = { ...style, bold: !style.bold };
34+}
35+
36+function toggleItalic() {
37+ style = { ...style, italic: !style.italic };
38+}
39+
40+function toggleUnderline() {
41+ style = { ...style, underline: !style.underline };
42+}
43+
44+function handleColorChange(color: string) {
45+ currentColor = color;
46+ style = { ...style, color, gradient: undefined };
47+}
48+
49+function handleGradientChange(gradient: string[]) {
50+ currentGradient = gradient;
51+ style = { ...style, gradient, color: gradient[0] };
52+}
53+
54+function selectFont(fontFamily: FontFamily) {
55+ style = { ...style, fontFamily };
56+}
57+
58+// Keyboard shortcuts handler
59+function handleKeydown(event: KeyboardEvent) {
60+ if (event.ctrlKey || event.metaKey) {
61+ switch (event.key.toLowerCase()) {
62+ case 'b':
63+ event.preventDefault();
64+ toggleBold();
65+ break;
66+ case 'i':
67+ event.preventDefault();
68+ toggleItalic();
69+ break;
70+ case 'u':
71+ event.preventDefault();
72+ toggleUnderline();
73+ break;
74+ }
75+ }
76+}
77+
78+// Preview text change
79+function previewTextChanged() {
80+ if (onPreviewText) {
81+ onPreviewText('Sample formatted text');
82+ }
83+}
84+
85+// Update internal state when style prop changes
86+$effect(() => {
87+ currentColor = style.color || '#000000';
88+ currentGradient = style.gradient;
89+});
90+</script>
91+
92+<svelte:window onkeydown={handleKeydown} />
93+
94+<div class="text-formatting-toolbar sunken-panel" class:compact>
95+
96+ <!-- Font selector (if enabled) -->
97+ {#if showFontSelector}
98+ <select
99+ value={style.fontFamily}
100+ onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)}
101+ title="Font Family"
102+ >
103+ {#each Object.entries(RETRO_FONTS) as [key, font]}
104+ <option value={key} style="font-family: {font.stack};">{font.name}</option>
105+ {/each}
106+ </select>
107+ {/if}
108+
109+ <!-- Size slider -->
110+ <div class="size-control">
111+ <input
112+ type="range"
113+ min="8"
114+ max="18"
115+ bind:value={style.fontSize}
116+ class="size-slider"
117+ title="Font Size: {style.fontSize}px"
118+ />
119+ <span class="size-display">{style.fontSize}</span>
120+ </div>
121+
122+ <!-- Style toggle buttons -->
123+ <div class="style-toggles">
124+ <button
125+ onclick={toggleBold}
126+ title="Bold (Ctrl+B)"
127+ style="width: 20px; height: 20px; padding: 0; font-size: 10px;"
128+ >
129+ <strong>B</strong>
130+ </button>
131+
132+ <button
133+ onclick={toggleItalic}
134+ title="Italic (Ctrl+I)"
135+ style="width: 20px; height: 20px; padding: 0; font-size: 10px;"
136+ >
137+ <em>I</em>
138+ </button>
139+
140+ <button
141+ onclick={toggleUnderline}
142+ title="Underline (Ctrl+U)"
143+ style="width: 20px; height: 20px; padding: 0; font-size: 10px;"
144+ >
145+ <u>U</u>
146+ </button>
147+ </div>
148+
149+ <!-- Color controls -->
150+ <div class="color-controls">
151+ <ColorPicker256
152+ bind:selectedColor={currentColor}
153+ bind:selectedGradient={currentGradient}
154+ onColorChange={handleColorChange}
155+ onGradientChange={handleGradientChange}
156+ {compact}
157+ />
158+ </div>
159+
160+
161+</div>
162+
163+<style>
164+ .text-formatting-toolbar {
165+ display: flex;
166+ align-items: center;
167+ gap: 4px;
168+ padding: 2px;
169+ font-size: 11px;
170+ position: relative;
171+ flex-wrap: wrap;
172+ }
173+
174+ .size-control {
175+ display: flex;
176+ align-items: center;
177+ gap: 2px;
178+ }
179+
180+ .size-slider {
181+ width: 50px;
182+ height: 12px;
183+ }
184+
185+ .size-display {
186+ font-size: 9px;
187+ color: #666;
188+ min-width: 18px;
189+ text-align: center;
190+ }
191+
192+ .style-toggles {
193+ display: flex;
194+ gap: 1px;
195+ }
196+
197+ .color-controls {
198+ display: flex;
199+ gap: 1px;
200+ }
201+
202+ /* Fix font dropdown text alignment */
203+ select {
204+ height: 20px;
205+ line-height: 1;
206+ vertical-align: middle;
207+ padding: 1px 4px;
208+ font-size: 11px;
209+ }
210+
211+ select option {
212+ padding: 2px 4px;
213+ line-height: normal;
214+ }
215+
216+</style>⏎̸
A src/lib/components/text-style-selector.svelte +323 -0
@@ -0,0 +1,323 @@
1+<script lang="ts">
2+import {
3+ RETRO_FONTS,
4+ CLASSIC_COLORS,
5+ DEFAULT_TEXT_STYLE,
6+ type TextStyle,
7+ type FontFamily,
8+ generateCSSStyle
9+} from '../types/text-formatting';
10+import ColorPicker256 from './color-picker-256.svelte';
11+import { formatText, createGradientText } from '../utils/text-formatter';
12+
13+// Props
14+let {
15+ style = $bindable(DEFAULT_TEXT_STYLE),
16+ previewText = "Sample text preview",
17+ showPreview = true,
18+ compact = false
19+} = $props<{
20+ style?: TextStyle;
21+ previewText?: string;
22+ showPreview?: boolean;
23+ compact?: boolean;
24+}>();
25+
26+// Local state
27+let currentColor = $state(style.color || '#000000');
28+let currentGradient = $state(style.gradient);
29+let formattedPreview = $state('');
30+let isProcessing = $state(false);
31+
32+// Update preview when style changes
33+$effect(() => {
34+ if (!showPreview) return;
35+
36+ async function updatePreview() {
37+ isProcessing = true;
38+ try {
39+ if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
40+ formattedPreview = createGradientText(previewText, style.gradient, 'preview-gradient');
41+ } else {
42+ formattedPreview = await formatText(previewText, style, false);
43+ }
44+ } catch (error) {
45+ console.error('Preview formatting error:', error);
46+ formattedPreview = previewText;
47+ } finally {
48+ isProcessing = false;
49+ }
50+ }
51+
52+ updatePreview();
53+});
54+
55+// Font family selection
56+function selectFont(fontFamily: FontFamily) {
57+ style = { ...style, fontFamily };
58+}
59+
60+// Color selection
61+function handleColorChange(color: string) {
62+ currentColor = color;
63+ style = { ...style, color, gradient: undefined };
64+}
65+
66+// Gradient selection
67+function handleGradientChange(gradient: string[]) {
68+ currentGradient = gradient;
69+ style = { ...style, gradient, color: gradient[0] };
70+}
71+
72+// Update internal state when style prop changes
73+$effect(() => {
74+ currentColor = style.color || '#000000';
75+ currentGradient = style.gradient;
76+});
77+
78+// Toggle style options
79+function toggleBold() {
80+ style = { ...style, bold: !style.bold };
81+}
82+
83+function toggleItalic() {
84+ style = { ...style, italic: !style.italic };
85+}
86+
87+function toggleUnderline() {
88+ style = { ...style, underline: !style.underline };
89+}
90+
91+function toggleStrikethrough() {
92+ style = { ...style, strikethrough: !style.strikethrough };
93+}
94+
95+// Font size adjustment
96+function updateFontSize(size: number) {
97+ style = { ...style, fontSize: Math.max(8, Math.min(18, size)) };
98+}
99+
100+</script>
101+
102+<div class="text-style-selector" class:compact>
103+ <!-- Font Family Selection -->
104+ <div class="style-group">
105+ <label>Font:</label>
106+ <select
107+ bind:value={style.fontFamily}
108+ onchange={(e) => selectFont((e.target as HTMLSelectElement).value as FontFamily)}
109+ >
110+ {#each Object.entries(RETRO_FONTS) as [key, font]}
111+ <option value={key} style="font-family: {font.stack}">
112+ {font.name}
113+ </option>
114+ {/each}
115+ </select>
116+ </div>
117+
118+ <!-- Font Size -->
119+ <div class="style-group">
120+ <label>Size:</label>
121+ <input
122+ type="range"
123+ min="8"
124+ max="18"
125+ bind:value={style.fontSize}
126+ oninput={(e) => updateFontSize(parseInt((e.target as HTMLInputElement).value))}
127+ />
128+ <span class="size-display">{style.fontSize}px</span>
129+ </div>
130+
131+ <!-- Style Toggles -->
132+ <div class="style-group toggles">
133+ <button
134+ class="style-toggle"
135+ class:active={style.bold}
136+ onclick={toggleBold}
137+ title="Bold (Ctrl+B)"
138+ >
139+ <strong>B</strong>
140+ </button>
141+ <button
142+ class="style-toggle"
143+ class:active={style.italic}
144+ onclick={toggleItalic}
145+ title="Italic (Ctrl+I)"
146+ >
147+ <em>I</em>
148+ </button>
149+ <button
150+ class="style-toggle"
151+ class:active={style.underline}
152+ onclick={toggleUnderline}
153+ title="Underline (Ctrl+U)"
154+ >
155+ <u>U</u>
156+ </button>
157+ <button
158+ class="style-toggle"
159+ class:active={style.strikethrough}
160+ onclick={toggleStrikethrough}
161+ title="Strikethrough"
162+ >
163+ <s>S</s>
164+ </button>
165+ </div>
166+
167+ <!-- Color & Gradient Selection -->
168+ <div class="style-group">
169+ <label>Color:</label>
170+ <div class="color-controls">
171+ <ColorPicker256
172+ bind:selectedColor={currentColor}
173+ bind:selectedGradient={currentGradient}
174+ onColorChange={handleColorChange}
175+ onGradientChange={handleGradientChange}
176+ compact={false}
177+ />
178+ </div>
179+ </div>
180+
181+
182+ <!-- Preview -->
183+ {#if showPreview}
184+ <div class="preview-section">
185+ <label>Preview:</label>
186+ <div
187+ class="preview-text"
188+ style={generateCSSStyle(style)}
189+ >
190+ {#if isProcessing}
191+ <span class="loading">Processing...</span>
192+ {:else}
193+ {@html formattedPreview}
194+ {/if}
195+ </div>
196+ </div>
197+ {/if}
198+</div>
199+
200+<style>
201+ .text-style-selector {
202+ background: #f0f0f0;
203+ border: 2px inset #dfdfdf;
204+ padding: 8px;
205+ font-family: 'MS Sans Serif', sans-serif;
206+ font-size: 11px;
207+ }
208+
209+ .text-style-selector.compact {
210+ padding: 4px;
211+ }
212+
213+ .style-group {
214+ display: flex;
215+ align-items: center;
216+ gap: 8px;
217+ margin-bottom: 8px;
218+ }
219+
220+ .style-group label {
221+ font-weight: bold;
222+ min-width: 50px;
223+ }
224+
225+ .style-group select {
226+ flex: 1;
227+ padding: 2px;
228+ border: 1px inset #dfdfdf;
229+ background: white;
230+ }
231+
232+ .style-group input[type="range"] {
233+ flex: 1;
234+ }
235+
236+ .size-display {
237+ min-width: 35px;
238+ font-size: 10px;
239+ color: #666;
240+ }
241+
242+ .toggles {
243+ gap: 4px;
244+ }
245+
246+ .style-toggle {
247+ width: 24px;
248+ height: 24px;
249+ border: 1px outset #dfdfdf;
250+ background: #f0f0f0;
251+ cursor: pointer;
252+ display: flex;
253+ align-items: center;
254+ justify-content: center;
255+ font-size: 12px;
256+ font-weight: bold;
257+ }
258+
259+ .style-toggle:hover {
260+ background: #e0e0e0;
261+ }
262+
263+ .style-toggle.active {
264+ border: 1px inset #dfdfdf;
265+ background: #d0d0d0;
266+ }
267+
268+ .color-controls {
269+ display: flex;
270+ gap: 4px;
271+ }
272+
273+ .preview-section {
274+ margin-top: 12px;
275+ padding-top: 8px;
276+ border-top: 1px solid #ccc;
277+ }
278+
279+ .preview-section label {
280+ display: block;
281+ margin-bottom: 4px;
282+ font-weight: bold;
283+ }
284+
285+ .preview-text {
286+ padding: 8px;
287+ border: 1px inset #dfdfdf;
288+ background: white;
289+ min-height: 24px;
290+ line-height: 1.4;
291+ }
292+
293+ .loading {
294+ color: #666;
295+ font-style: italic;
296+ }
297+
298+ /* Gradient text support */
299+ :global(.preview-gradient) {
300+ display: inline;
301+ }
302+
303+ /* Compact mode adjustments */
304+ .compact .style-group {
305+ margin-bottom: 4px;
306+ }
307+
308+ .compact .style-group label {
309+ min-width: 40px;
310+ font-size: 10px;
311+ }
312+
313+ .compact .style-toggle {
314+ width: 20px;
315+ height: 20px;
316+ font-size: 10px;
317+ }
318+
319+ .compact .color-button, .compact .gradient-button {
320+ font-size: 9px;
321+ padding: 1px 4px;
322+ }
323+</style>⏎̸
A src/lib/components/user-preferences-modal.svelte +492 -0
@@ -0,0 +1,492 @@
1+<script lang="ts">
2+import { onMount } from 'svelte';
3+import TextStyleSelector from './text-style-selector.svelte';
4+import {
5+ DEFAULT_USER_PREFERENCES,
6+ DEFAULT_TEXT_STYLE,
7+ type UserTextPreferences,
8+ type TextStyle
9+} from '../types/text-formatting';
10+
11+// Props
12+let {
13+ showModal = $bindable(false),
14+ onSave
15+} = $props<{
16+ showModal: boolean;
17+ onSave?: (preferences: UserTextPreferences) => void;
18+}>();
19+
20+// State
21+let preferences = $state<UserTextPreferences>({ ...DEFAULT_USER_PREFERENCES });
22+let currentStyle = $state<TextStyle>({ ...DEFAULT_TEXT_STYLE });
23+let isSaving = $state(false);
24+let hasChanges = $state(false);
25+
26+// Track changes
27+$effect(() => {
28+ hasChanges = JSON.stringify(preferences) !== JSON.stringify(DEFAULT_USER_PREFERENCES);
29+});
30+
31+// Update preferences when style changes
32+$effect(() => {
33+ preferences.defaultStyle = { ...currentStyle };
34+});
35+
36+// Load user preferences on mount
37+onMount(async () => {
38+ try {
39+ const response = await fetch('/api/user/text-preferences');
40+ if (response.ok) {
41+ const data = await response.json();
42+ if (data.success && data.preferences) {
43+ preferences = { ...DEFAULT_USER_PREFERENCES, ...data.preferences };
44+ currentStyle = { ...DEFAULT_TEXT_STYLE, ...preferences.defaultStyle };
45+ }
46+ }
47+ } catch (error) {
48+ console.error('Failed to load user preferences:', error);
49+ }
50+});
51+
52+// Save preferences
53+async function savePreferences() {
54+ if (!onSave) return;
55+
56+ isSaving = true;
57+ try {
58+ await onSave(preferences);
59+ showModal = false;
60+ } catch (error) {
61+ console.error('Failed to save preferences:', error);
62+ alert('Failed to save preferences. Please try again.');
63+ } finally {
64+ isSaving = false;
65+ }
66+}
67+
68+// Reset to defaults
69+function resetToDefaults() {
70+ preferences = { ...DEFAULT_USER_PREFERENCES };
71+ currentStyle = { ...DEFAULT_TEXT_STYLE };
72+}
73+
74+// Cancel changes
75+function cancel() {
76+ showModal = false;
77+}
78+
79+// Handle keyboard shortcuts
80+function handleKeydown(event: KeyboardEvent) {
81+ if (event.key === 'Escape') {
82+ cancel();
83+ } else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
84+ savePreferences();
85+ }
86+}
87+</script>
88+
89+{#if showModal}
90+ <!-- Modal backdrop -->
91+ <div
92+ class="modal-backdrop"
93+ onclick={cancel}
94+ onkeydown={handleKeydown}
95+ role="dialog"
96+ aria-modal="true"
97+ aria-labelledby="preferences-title"
98+ >
99+ <!-- Modal content -->
100+ <div class="modal-window window" onclick={(e) => e.stopPropagation()}>
101+ <div class="title-bar">
102+ <div class="title-bar-text" id="preferences-title">
103+ Text Formatting Preferences
104+ </div>
105+ <div class="title-bar-controls">
106+ <button onclick={cancel} aria-label="Close"></button>
107+ </div>
108+ </div>
109+
110+ <div class="window-body">
111+ <div class="preferences-content">
112+
113+ <!-- Enable/Disable Formatting -->
114+ <div class="preference-group">
115+ <label class="checkbox-label">
116+ <input
117+ type="checkbox"
118+ bind:checked={preferences.allowFormatting}
119+ />
120+ Enable text formatting in messages
121+ </label>
122+ <small class="help-text">
123+ When enabled, you can use formatting options like bold, italic, colors, and gradients.
124+ </small>
125+ </div>
126+
127+ <!-- Default Text Style -->
128+ <div class="preference-group">
129+ <h3>Default Text Style</h3>
130+ <p class="description">
131+ These settings will be used as defaults when you send new messages.
132+ </p>
133+
134+ <TextStyleSelector
135+ bind:style={currentStyle}
136+ previewText="This is how your messages will look by default"
137+ showPreview={true}
138+ compact={false}
139+ />
140+ </div>
141+
142+ <!-- Message Settings -->
143+ <div class="preference-group">
144+ <h3>Message Settings</h3>
145+
146+ <div class="setting-row">
147+ <label for="max-length">Maximum message length:</label>
148+ <input
149+ id="max-length"
150+ type="number"
151+ min="100"
152+ max="2000"
153+ bind:value={preferences.maxMessageLength}
154+ class="length-input"
155+ />
156+ <span class="unit">characters</span>
157+ </div>
158+
159+ <small class="help-text">
160+ Longer messages may take more time to process and display.
161+ </small>
162+ </div>
163+
164+ <!-- Format Help -->
165+ <div class="preference-group">
166+ <details class="format-help">
167+ <summary>Formatting Guide</summary>
168+ <div class="format-examples">
169+ <h4>BBCode Style:</h4>
170+ <ul>
171+ <li><code>[b]bold text[/b]</code> → <strong>bold text</strong></li>
172+ <li><code>[i]italic text[/i]</code> → <em>italic text</em></li>
173+ <li><code>[u]underlined[/u]</code> → <u>underlined</u></li>
174+ <li><code>[s]strikethrough[/s]</code> → <s>strikethrough</s></li>
175+ <li><code>[color=red]red text[/color]</code> → <span style="color: red">red text</span></li>
176+ </ul>
177+
178+ <h4>Markdown Style:</h4>
179+ <ul>
180+ <li><code>**bold text**</code> → <strong>bold text</strong></li>
181+ <li><code>*italic text*</code> → <em>italic text</em></li>
182+ <li><code>~~strikethrough~~</code> → <s>strikethrough</s></li>
183+ <li><code>`code text`</code> → <code>code text</code></li>
184+ </ul>
185+ </div>
186+ </details>
187+ </div>
188+
189+ </div>
190+
191+ <!-- Modal buttons -->
192+ <div class="modal-buttons">
193+ <button
194+ class="reset-button"
195+ onclick={resetToDefaults}
196+ disabled={isSaving}
197+ >
198+ Reset to Defaults
199+ </button>
200+
201+ <div class="main-buttons">
202+ <button
203+ onclick={cancel}
204+ disabled={isSaving}
205+ >
206+ Cancel
207+ </button>
208+
209+ <button
210+ class="save-button"
211+ onclick={savePreferences}
212+ disabled={isSaving || !hasChanges}
213+ >
214+ {#if isSaving}
215+ Saving...
216+ {:else}
217+ Save Changes
218+ {/if}
219+ </button>
220+ </div>
221+ </div>
222+ </div>
223+ </div>
224+ </div>
225+{/if}
226+
227+<style>
228+ .modal-backdrop {
229+ position: fixed;
230+ top: 0;
231+ left: 0;
232+ right: 0;
233+ bottom: 0;
234+ background: rgba(0, 0, 0, 0.5);
235+ display: flex;
236+ align-items: center;
237+ justify-content: center;
238+ z-index: 1000;
239+ padding: 20px;
240+ }
241+
242+ .modal-window {
243+ width: 100%;
244+ max-width: 600px;
245+ max-height: 90vh;
246+ overflow: hidden;
247+ box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3);
248+ }
249+
250+ .window-body {
251+ display: flex;
252+ flex-direction: column;
253+ height: 70vh;
254+ max-height: 600px;
255+ }
256+
257+ .preferences-content {
258+ flex: 1;
259+ overflow-y: auto;
260+ padding: 16px;
261+ }
262+
263+ .preference-group {
264+ margin-bottom: 24px;
265+ padding-bottom: 16px;
266+ border-bottom: 1px solid #ddd;
267+ }
268+
269+ .preference-group:last-child {
270+ border-bottom: none;
271+ margin-bottom: 0;
272+ }
273+
274+ .preference-group h3 {
275+ margin: 0 0 8px 0;
276+ font-size: 14px;
277+ font-weight: bold;
278+ color: #2d31a6;
279+ }
280+
281+ .description {
282+ margin: 0 0 12px 0;
283+ font-size: 12px;
284+ color: #666;
285+ }
286+
287+ .checkbox-label {
288+ display: flex;
289+ align-items: center;
290+ gap: 8px;
291+ font-size: 12px;
292+ cursor: pointer;
293+ }
294+
295+ .help-text {
296+ display: block;
297+ margin-top: 4px;
298+ font-size: 11px;
299+ color: #666;
300+ font-style: italic;
301+ }
302+
303+ .setting-row {
304+ display: flex;
305+ align-items: center;
306+ gap: 8px;
307+ margin-bottom: 8px;
308+ }
309+
310+ .setting-row label {
311+ font-size: 12px;
312+ min-width: 140px;
313+ }
314+
315+ .length-input {
316+ width: 80px;
317+ padding: 2px 4px;
318+ border: 1px inset #dfdfdf;
319+ background: white;
320+ font-size: 11px;
321+ }
322+
323+ .unit {
324+ font-size: 11px;
325+ color: #666;
326+ }
327+
328+ .format-help {
329+ margin-top: 8px;
330+ }
331+
332+ .format-help summary {
333+ font-size: 12px;
334+ font-weight: bold;
335+ cursor: pointer;
336+ padding: 4px 0;
337+ }
338+
339+ .format-examples {
340+ padding: 8px 0;
341+ font-size: 11px;
342+ }
343+
344+ .format-examples h4 {
345+ margin: 12px 0 4px 0;
346+ font-size: 11px;
347+ color: #2d31a6;
348+ }
349+
350+ .format-examples ul {
351+ margin: 0;
352+ padding-left: 16px;
353+ }
354+
355+ .format-examples li {
356+ margin-bottom: 2px;
357+ }
358+
359+ .format-examples code {
360+ background: #f0f0f0;
361+ padding: 1px 3px;
362+ border-radius: 2px;
363+ font-family: 'Courier New', monospace;
364+ font-size: 10px;
365+ }
366+
367+ .modal-buttons {
368+ display: flex;
369+ justify-content: space-between;
370+ align-items: center;
371+ padding: 12px 16px;
372+ border-top: 1px solid #ddd;
373+ background: #f8f8f8;
374+ }
375+
376+ .main-buttons {
377+ display: flex;
378+ gap: 8px;
379+ }
380+
381+ .reset-button {
382+ padding: 4px 12px;
383+ font-size: 11px;
384+ border: 1px outset #dfdfdf;
385+ background: #f0f0f0;
386+ cursor: pointer;
387+ }
388+
389+ .reset-button:hover {
390+ background: #e0e0e0;
391+ }
392+
393+ .reset-button:disabled {
394+ opacity: 0.6;
395+ cursor: not-allowed;
396+ }
397+
398+ .save-button {
399+ padding: 4px 16px;
400+ font-size: 11px;
401+ font-weight: bold;
402+ border: 1px outset #dfdfdf;
403+ background: #0078d4;
404+ color: white;
405+ cursor: pointer;
406+ }
407+
408+ .save-button:hover:not(:disabled) {
409+ background: #106ebe;
410+ }
411+
412+ .save-button:disabled {
413+ opacity: 0.6;
414+ cursor: not-allowed;
415+ background: #999;
416+ }
417+
418+ button {
419+ padding: 4px 12px;
420+ font-size: 11px;
421+ border: 1px outset #dfdfdf;
422+ background: #f0f0f0;
423+ cursor: pointer;
424+ }
425+
426+ button:hover:not(:disabled) {
427+ background: #e0e0e0;
428+ }
429+
430+ button:disabled {
431+ opacity: 0.6;
432+ cursor: not-allowed;
433+ }
434+
435+ /* Scrollbar styling */
436+ .preferences-content::-webkit-scrollbar {
437+ width: 12px;
438+ }
439+
440+ .preferences-content::-webkit-scrollbar-track {
441+ background: #f0f0f0;
442+ border: 1px inset #dfdfdf;
443+ }
444+
445+ .preferences-content::-webkit-scrollbar-thumb {
446+ background: #c0c0c0;
447+ border: 1px outset #dfdfdf;
448+ }
449+
450+ .preferences-content::-webkit-scrollbar-thumb:hover {
451+ background: #a0a0a0;
452+ }
453+
454+ /* Mobile responsive */
455+ @media (max-width: 768px) {
456+ .modal-backdrop {
457+ padding: 10px;
458+ }
459+
460+ .modal-window {
461+ max-width: none;
462+ max-height: 95vh;
463+ }
464+
465+ .window-body {
466+ height: 80vh;
467+ }
468+
469+ .preferences-content {
470+ padding: 12px;
471+ }
472+
473+ .setting-row {
474+ flex-direction: column;
475+ align-items: flex-start;
476+ gap: 4px;
477+ }
478+
479+ .setting-row label {
480+ min-width: auto;
481+ }
482+
483+ .modal-buttons {
484+ flex-direction: column;
485+ gap: 8px;
486+ }
487+
488+ .main-buttons {
489+ order: -1;
490+ }
491+ }
492+</style>⏎̸
M src/lib/db/schema.ts +19 -0
@@ -47,6 +47,23 @@ export const messages = table(
4747 content: t.text("content").notNull(),
4848 type: t.text("type").$type<MessageType>().notNull().default("chat"),
4949 timestamp: t.integer("timestamp").notNull(),
50+ styleData: t.text("style_data"), // JSON string of TextStyle object
51+ hasFormatting: t.integer("has_formatting", { mode: "boolean" }).default(false), // Boolean flag
52+ }
53+);
54+
55+export const userTextPreferences = table(
56+ "user_text_preferences",
57+ {
58+ userId: t.text("user_id").references(() => users.id).primaryKey(),
59+ defaultFontFamily: t.text("default_font_family").default("tahoma"),
60+ defaultFontSize: t.integer("default_font_size").default(14),
61+ defaultColor: t.text("default_color").default("black"),
62+ allowFormatting: t.integer("allow_formatting", { mode: "boolean" }).default(true),
63+ maxMessageLength: t.integer("max_message_length").default(500),
64+ stylePresets: t.text("style_presets"), // JSON array of saved styles
65+ createdAt: t.integer("created_at").notNull(),
66+ updatedAt: t.integer("updated_at").notNull(),
5067 }
5168 );
5269
@@ -55,6 +72,7 @@ export type Users = typeof users.$inferSelect;
5572 export type ChatRooms = typeof chatRooms.$inferSelect;
5673 export type Messages = typeof messages.$inferSelect;
5774 export type Sessions = typeof sessions.$inferSelect;
75+export type UserTextPreferences = typeof userTextPreferences.$inferSelect;
5876 // These type assertions will fail if the schema doesn't match the interfaces
5977 export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>;
6078 export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> & Omit<ChatRooms, keyof ChatRoom>;
@@ -100,6 +118,7 @@ const schema = {
100118 sessions,
101119 chatRooms,
102120 messages,
121+ userTextPreferences,
103122 userView,
104123 chatRoomView,
105124 messageView
A src/lib/sseConnectionTracker.ts +90 -0
@@ -0,0 +1,90 @@
1+import { createLogger } from './utils/logger.server';
2+
3+const log = createLogger('sse-connection-tracker');
4+
5+interface ConnectionInfo {
6+ userId: string;
7+ connectedAt: number;
8+ lastActivity: number;
9+}
10+
11+class SSEConnectionTracker {
12+ private connections = new Map<string, ConnectionInfo>();
13+
14+ addConnection(userId: string): boolean {
15+ const existing = this.connections.get(userId);
16+ if (existing) {
17+ log.debug('User already has active connection', { userId });
18+ // Update last activity but don't trigger status update
19+ existing.lastActivity = Date.now();
20+ return false; // Connection already exists
21+ }
22+
23+ this.connections.set(userId, {
24+ userId,
25+ connectedAt: Date.now(),
26+ lastActivity: Date.now()
27+ });
28+
29+ log.debug('New SSE connection tracked', {
30+ userId,
31+ totalConnections: this.connections.size
32+ });
33+ return true; // New connection
34+ }
35+
36+ removeConnection(userId: string): boolean {
37+ const deleted = this.connections.delete(userId);
38+ if (deleted) {
39+ log.debug('SSE connection removed', {
40+ userId,
41+ remainingConnections: this.connections.size
42+ });
43+ }
44+ return deleted;
45+ }
46+
47+ hasConnection(userId: string): boolean {
48+ return this.connections.has(userId);
49+ }
50+
51+ updateActivity(userId: string): void {
52+ const connection = this.connections.get(userId);
53+ if (connection) {
54+ connection.lastActivity = Date.now();
55+ }
56+ }
57+
58+ getActiveUserIds(): string[] {
59+ return Array.from(this.connections.keys());
60+ }
61+
62+ getConnectionCount(): number {
63+ return this.connections.size;
64+ }
65+
66+ // Clean up stale connections (e.g., if server didn't properly close them)
67+ cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] {
68+ const now = Date.now();
69+ const staleUserIds: string[] = [];
70+
71+ for (const [userId, info] of this.connections.entries()) {
72+ if (now - info.lastActivity > maxInactivityMs) {
73+ this.connections.delete(userId);
74+ staleUserIds.push(userId);
75+ }
76+ }
77+
78+ if (staleUserIds.length > 0) {
79+ log.info('Cleaned up stale connections', {
80+ count: staleUserIds.length,
81+ userIds: staleUserIds
82+ });
83+ }
84+
85+ return staleUserIds;
86+ }
87+}
88+
89+// Export singleton instance
90+export const sseConnectionTracker = new SSEConnectionTracker();⏎̸
M src/lib/states/chat.svelte.ts +6 -1
@@ -488,7 +488,7 @@ class ChatState {
488488 }
489489 }
490490
491- async sendMessage(content: string, type: Message['type'] = 'chat'): Promise<SendMessageResponse> {
491+ async sendMessage(content: string, type: Message['type'] = 'chat', textStyle?: any): Promise<SendMessageResponse> {
492492 const user = this.getCurrentUser();
493493 if (!user) {
494494 console.debug('Cannot send message: No current user');
@@ -505,6 +505,11 @@ class ChatState {
505505 userId: user.id,
506506 chatRoomId: DEFAULT_CHAT_ROOM_ID
507507 };
508+
509+ // Add styleData if textStyle is provided
510+ if (textStyle) {
511+ (payload as any).styleData = JSON.stringify(textStyle);
512+ }
508513
509514 console.debug('Sending message with payload:', payload);
510515
A src/lib/styles/fonts.css +100 -0
@@ -0,0 +1,100 @@
1+/* Windows Classic Font Fallbacks */
2+/*
3+ * These @font-face declarations ensure consistent font rendering
4+ * by providing proper fallbacks for classic Windows fonts
5+ */
6+
7+/* Tahoma - Primary UI font */
8+@font-face {
9+ font-family: 'Tahoma Web';
10+ src: local('Tahoma'), local('Tahoma Regular');
11+ font-weight: normal;
12+ font-style: normal;
13+}
14+
15+@font-face {
16+ font-family: 'Tahoma Web';
17+ src: local('Tahoma Bold');
18+ font-weight: bold;
19+ font-style: normal;
20+}
21+
22+/* Verdana - Secondary UI font */
23+@font-face {
24+ font-family: 'Verdana Web';
25+ src: local('Verdana'), local('Verdana Regular');
26+ font-weight: normal;
27+ font-style: normal;
28+}
29+
30+@font-face {
31+ font-family: 'Verdana Web';
32+ src: local('Verdana Bold');
33+ font-weight: bold;
34+ font-style: normal;
35+}
36+
37+/* Arial - General purpose */
38+@font-face {
39+ font-family: 'Arial Web';
40+ src: local('Arial'), local('Arial Regular');
41+ font-weight: normal;
42+ font-style: normal;
43+}
44+
45+@font-face {
46+ font-family: 'Arial Web';
47+ src: local('Arial Bold');
48+ font-weight: bold;
49+ font-style: normal;
50+}
51+
52+/* Times New Roman - Serif font */
53+@font-face {
54+ font-family: 'Times New Roman Web';
55+ src: local('Times New Roman'), local('Times New Roman Regular');
56+ font-weight: normal;
57+ font-style: normal;
58+}
59+
60+@font-face {
61+ font-family: 'Times New Roman Web';
62+ src: local('Times New Roman Bold');
63+ font-weight: bold;
64+ font-style: normal;
65+}
66+
67+/* Courier New - Monospace */
68+@font-face {
69+ font-family: 'Courier New Web';
70+ src: local('Courier New'), local('Courier New Regular');
71+ font-weight: normal;
72+ font-style: normal;
73+}
74+
75+@font-face {
76+ font-family: 'Courier New Web';
77+ src: local('Courier New Bold');
78+ font-weight: bold;
79+ font-style: normal;
80+}
81+
82+/* Comic Sans MS - Fun font */
83+@font-face {
84+ font-family: 'Comic Sans MS Web';
85+ src: local('Comic Sans MS'), local('Comic Sans MS Regular');
86+ font-weight: normal;
87+ font-style: normal;
88+}
89+
90+@font-face {
91+ font-family: 'Comic Sans MS Web';
92+ src: local('Comic Sans MS Bold');
93+ font-weight: bold;
94+ font-style: normal;
95+}
96+
97+/* Ensure font-display for better performance */
98+@font-face {
99+ font-display: swap;
100+}⏎̸
M src/lib/types/chat.ts +2 -0
@@ -27,6 +27,8 @@ export type UserStatus = 'offline' | 'online' | 'away' | string;
2727 content: string;
2828 type: MessageType; // Default is 'chat'
2929 timestamp: number; // Timestamp when the message was sent
30+ styleData?: string; // JSON string of TextStyle object for formatting
31+ hasFormatting?: boolean; // Flag to indicate if message has custom formatting
3032 }
3133
3234 // Safe user type for API responses - omits sensitive information
M src/lib/types/payloads.ts +1 -0
@@ -44,6 +44,7 @@ export interface SendMessageRequest {
4444 type?: Message['type'];
4545 userId: string;
4646 chatRoomId?: string;
47+ styleData?: string; // JSON stringified TextStyle
4748 }
4849
4950 export interface SendMessageResponseSuccess {
A src/lib/types/text-formatting.ts +273 -0
@@ -0,0 +1,273 @@
1+// Font and styling configuration for chat messages
2+
3+// Retro Windows fonts
4+// Primary font is Pixelated MS Sans Serif from xp.css which works for all users
5+// Secondary fonts use local system fonts with proper fallbacks
6+export const RETRO_FONTS = {
7+ pixelated: { name: 'Pixelated MS Sans Serif', stack: '"Pixelated MS Sans Serif", "Tahoma", Geneva, sans-serif' },
8+ tahoma: { name: 'Tahoma', stack: 'Tahoma, "Pixelated MS Sans Serif", Geneva, sans-serif' },
9+ verdana: { name: 'Verdana', stack: 'Verdana, "Pixelated MS Sans Serif", Geneva, sans-serif' },
10+ comicSans: { name: 'Comic Sans MS', stack: '"Comic Sans MS", "Pixelated MS Sans Serif", cursive, sans-serif' },
11+ timesNewRoman: { name: 'Times New Roman', stack: '"Times New Roman", Times, serif' },
12+ arial: { name: 'Arial', stack: 'Arial, "Pixelated MS Sans Serif", Helvetica, sans-serif' },
13+ courierNew: { name: 'Courier New', stack: '"Courier New", "Perfect DOS VGA 437 Win", Courier, monospace' },
14+ dos: { name: 'Perfect DOS VGA 437', stack: '"Perfect DOS VGA 437 Win", "Courier New", Courier, monospace' }
15+} as const;
16+
17+export type FontFamily = keyof typeof RETRO_FONTS;
18+
19+// Generate 256-color web-safe palette ordered by hue
20+export function generate256Colors(): string[] {
21+ const colors: string[] = [];
22+
23+ // Start with grayscale colors
24+ colors.push('#000000', '#333333', '#666666', '#999999', '#CCCCCC', '#FFFFFF');
25+
26+ // Then add colors in a more logical order by hue
27+ // Red hues
28+ colors.push('#330000', '#660000', '#990000', '#CC0000', '#FF0000');
29+ colors.push('#FF3333', '#FF6666', '#FF9999', '#FFCCCC');
30+
31+ // Orange hues
32+ colors.push('#663300', '#993300', '#CC3300', '#FF3300');
33+ colors.push('#FF6600', '#FF9900', '#FFCC00', '#FFCC33', '#FFCC66', '#FFCC99');
34+
35+ // Yellow hues
36+ colors.push('#666600', '#999900', '#CCCC00', '#FFFF00');
37+ colors.push('#FFFF33', '#FFFF66', '#FFFF99', '#FFFFCC');
38+
39+ // Green hues
40+ colors.push('#003300', '#006600', '#009900', '#00CC00', '#00FF00');
41+ colors.push('#33FF33', '#66FF66', '#99FF99', '#CCFFCC');
42+ colors.push('#336633', '#669966', '#99CC99', '#CCFFCC');
43+
44+ // Cyan hues
45+ colors.push('#006666', '#009999', '#00CCCC', '#00FFFF');
46+ colors.push('#33FFFF', '#66FFFF', '#99FFFF', '#CCFFFF');
47+ colors.push('#336666', '#669999', '#99CCCC');
48+
49+ // Blue hues
50+ colors.push('#000033', '#000066', '#000099', '#0000CC', '#0000FF');
51+ colors.push('#3333FF', '#6666FF', '#9999FF', '#CCCCFF');
52+ colors.push('#003366', '#006699', '#0099CC', '#3399FF', '#66CCFF');
53+
54+ // Purple hues
55+ colors.push('#330033', '#660066', '#990099', '#CC00CC', '#FF00FF');
56+ colors.push('#FF33FF', '#FF66FF', '#FF99FF', '#FFCCFF');
57+ colors.push('#663366', '#996699', '#CC99CC');
58+
59+ // Pink hues
60+ colors.push('#660033', '#990066', '#CC0099', '#FF0099');
61+ colors.push('#FF3399', '#FF66CC', '#FF99CC');
62+
63+ // Browns
64+ colors.push('#663333', '#996633', '#CC9966', '#996666');
65+
66+ // Now fill in any missing combinations to get full 216 web-safe colors
67+ const webSafeValues = ['00', '33', '66', '99', 'CC', 'FF'];
68+ const existingColors = new Set(colors);
69+
70+ for (const r of webSafeValues) {
71+ for (const g of webSafeValues) {
72+ for (const b of webSafeValues) {
73+ const color = `#${r}${g}${b}`.toUpperCase();
74+ if (!existingColors.has(color)) {
75+ colors.push(color);
76+ }
77+ }
78+ }
79+ }
80+
81+ return colors;
82+}
83+
84+// Classic retro colors for backward compatibility
85+export const CLASSIC_COLORS = {
86+ black: '#000000',
87+ darkBlue: '#000080',
88+ green: '#008000',
89+ teal: '#008080',
90+ darkRed: '#800000',
91+ purple: '#800080',
92+ brown: '#808000',
93+ silver: '#C0C0C0',
94+ gray: '#808080',
95+ blue: '#0000FF',
96+ lime: '#00FF00',
97+ aqua: '#00FFFF',
98+ red: '#FF0000',
99+ fuchsia: '#FF00FF',
100+ yellow: '#FFFF00',
101+ white: '#FFFFFF'
102+} as const;
103+
104+export type ClassicColor = keyof typeof CLASSIC_COLORS;
105+
106+// Gradient presets inspired by AOL faders
107+export const GRADIENT_PRESETS = {
108+ rainbow: ['#FF0000', '#FF8000', '#FFFF00', '#80FF00', '#00FF00', '#00FF80', '#00FFFF', '#0080FF', '#0000FF', '#8000FF', '#FF00FF', '#FF0080'],
109+ fire: ['#FF0000', '#FF4000', '#FF8000', '#FFBF00', '#FFFF00'],
110+ ocean: ['#000080', '#0040FF', '#0080FF', '#00BFFF', '#00FFFF'],
111+ sunset: ['#FF4500', '#FF6347', '#FFD700', '#FFFF00'],
112+ neon: ['#FF00FF', '#8000FF', '#0080FF', '#00FFFF'],
113+ forest: ['#006400', '#228B22', '#32CD32', '#90EE90'],
114+ royal: ['#4B0082', '#6A5ACD', '#9370DB', '#DDA0DD']
115+} as const;
116+
117+export type GradientPreset = keyof typeof GRADIENT_PRESETS;
118+
119+// Text styling options
120+export interface TextStyle {
121+ fontFamily: FontFamily;
122+ fontSize: number; // in pixels
123+ color?: string; // hex color string
124+ gradient?: string[]; // array of 1-3 hex colors for gradients
125+ bold: boolean;
126+ italic: boolean;
127+ underline: boolean;
128+ strikethrough: boolean;
129+}
130+
131+// Default text style
132+export const DEFAULT_TEXT_STYLE: TextStyle = {
133+ fontFamily: 'pixelated',
134+ fontSize: 14,
135+ color: '#000000',
136+ bold: false,
137+ italic: false,
138+ underline: false,
139+ strikethrough: false
140+};
141+
142+// User preferences for text formatting
143+export interface UserTextPreferences {
144+ defaultStyle: TextStyle;
145+ allowFormatting: boolean;
146+ maxMessageLength: number;
147+}
148+
149+// Default user preferences
150+export const DEFAULT_USER_PREFERENCES: UserTextPreferences = {
151+ defaultStyle: DEFAULT_TEXT_STYLE,
152+ allowFormatting: true,
153+ maxMessageLength: 500
154+};
155+
156+// Hex color validation
157+export function isValidHexColor(color: string): boolean {
158+ return /^#[0-9A-Fa-f]{6}$/.test(color);
159+}
160+
161+// Formatting validation
162+export function validateTextStyle(style: Partial<TextStyle>): TextStyle {
163+ return {
164+ fontFamily: style.fontFamily && style.fontFamily in RETRO_FONTS ? style.fontFamily : DEFAULT_TEXT_STYLE.fontFamily,
165+ fontSize: Math.max(8, Math.min(18, style.fontSize || DEFAULT_TEXT_STYLE.fontSize)),
166+ color: style.color && isValidHexColor(style.color) ? style.color : DEFAULT_TEXT_STYLE.color,
167+ gradient: style.gradient && Array.isArray(style.gradient) && style.gradient.length <= 3 && style.gradient.every(isValidHexColor) ? style.gradient : undefined,
168+ bold: Boolean(style.bold),
169+ italic: Boolean(style.italic),
170+ underline: Boolean(style.underline),
171+ strikethrough: Boolean(style.strikethrough)
172+ };
173+}
174+
175+// Generate CSS style string from TextStyle
176+export function generateCSSStyle(style: TextStyle): string {
177+ const styles: string[] = [];
178+
179+ // Font family
180+ styles.push(`font-family: ${RETRO_FONTS[style.fontFamily].stack}`);
181+
182+ // Font size
183+ styles.push(`font-size: ${style.fontSize}px`);
184+
185+ // Color or gradient
186+ if (style.gradient && style.gradient.length > 1) {
187+ const gradientStops = style.gradient.join(', ');
188+ styles.push(`background: linear-gradient(45deg, ${gradientStops})`);
189+ styles.push(`-webkit-background-clip: text`);
190+ styles.push(`-webkit-text-fill-color: transparent`);
191+ styles.push(`background-clip: text`);
192+ } else if (style.color) {
193+ styles.push(`color: ${style.color}`);
194+ }
195+
196+ // Font weight
197+ if (style.bold) {
198+ // Use correct weight for Tahoma (200) vs standard bold (700)
199+ if (style.fontFamily === 'tahoma') {
200+ styles.push(`font-weight: 200`);
201+ } else {
202+ styles.push(`font-weight: 700`);
203+ }
204+ }
205+
206+ // Font style
207+ if (style.italic) {
208+ styles.push(`font-style: italic`);
209+ }
210+
211+ // Text decoration
212+ const decorations: string[] = [];
213+ if (style.underline) decorations.push('underline');
214+ if (style.strikethrough) decorations.push('line-through');
215+ if (decorations.length > 0) {
216+ styles.push(`text-decoration: ${decorations.join(' ')}`);
217+ }
218+
219+ return styles.join('; ');
220+}
221+
222+// Generate CSS style string for input fields (without font-family which should be handled by classes)
223+export function generateInputCSSStyle(style: TextStyle): string {
224+ const styles: string[] = [];
225+
226+ // Font size
227+ styles.push(`font-size: ${style.fontSize}px`);
228+
229+ // Color handling for inputs
230+ if (style.gradient && style.gradient.length > 1) {
231+ // Apply gradient background with text clipping for gradient effect
232+ const gradientStops = style.gradient.join(', ');
233+ styles.push(`background: linear-gradient(to right, ${gradientStops})`);
234+ styles.push(`-webkit-background-clip: text`);
235+ styles.push(`-webkit-text-fill-color: transparent`);
236+ styles.push(`background-clip: text`);
237+ styles.push(`letter-spacing: 0`);
238+ styles.push(`word-spacing: 0`);
239+ } else if (style.gradient && style.gradient.length === 1) {
240+ // Single color from gradient array
241+ styles.push(`color: ${style.gradient[0]}`);
242+ } else if (style.color) {
243+ styles.push(`color: ${style.color}`);
244+ } else {
245+ // Fallback to black if no color is set
246+ styles.push(`color: #000000`);
247+ }
248+
249+ // Font weight
250+ if (style.bold) {
251+ // Use correct weight for Tahoma (200) vs standard bold (700)
252+ if (style.fontFamily === 'tahoma') {
253+ styles.push(`font-weight: 200`);
254+ } else {
255+ styles.push(`font-weight: 700`);
256+ }
257+ }
258+
259+ // Font style
260+ if (style.italic) {
261+ styles.push(`font-style: italic`);
262+ }
263+
264+ // Text decoration
265+ const decorations: string[] = [];
266+ if (style.underline) decorations.push('underline');
267+ if (style.strikethrough) decorations.push('line-through');
268+ if (decorations.length > 0) {
269+ styles.push(`text-decoration: ${decorations.join(' ')}`);
270+ }
271+
272+ return styles.join('; ');
273+}⏎̸
A src/lib/utils/security-tests.ts +373 -0
@@ -0,0 +1,373 @@
1+import { formatText, validateMessageContent, createGradientText } from './text-formatter';
2+import { validateTextStyle, DEFAULT_TEXT_STYLE } from '../types/text-formatting';
3+
4+// XSS Test Vectors
5+const XSS_TEST_VECTORS = [
6+ // Script injection
7+ '<script>alert("XSS")</script>',
8+ '<script src="malicious.js"></script>',
9+ '<img src="x" onerror="alert(\'XSS\')">',
10+ '<svg onload="alert(\'XSS\')">',
11+ '<iframe src="javascript:alert(\'XSS\')"></iframe>',
12+
13+ // Event handlers
14+ '<div onclick="alert(\'XSS\')">Click me</div>',
15+ '<input onfocus="alert(\'XSS\')" autofocus>',
16+ '<body onload="alert(\'XSS\')">',
17+ '<a href="javascript:alert(\'XSS\')">Link</a>',
18+
19+ // CSS injection
20+ '<style>body{background:url("javascript:alert(\'XSS\')")}</style>',
21+ '<div style="background-image:url(javascript:alert(\'XSS\'))">',
22+ '<div style="expression(alert(\'XSS\'))">',
23+
24+ // Data URIs
25+ '<img src="data:text/html,<script>alert(\'XSS\')</script>">',
26+ '<iframe src="data:text/html,<script>alert(\'XSS\')</script>">',
27+
28+ // HTML injection
29+ '<object data="malicious.swf">',
30+ '<embed src="malicious.swf">',
31+ '<applet code="malicious.class">',
32+
33+ // BBCode injection attempts
34+ '[script]alert("XSS")[/script]',
35+ '[img=javascript:alert("XSS")]',
36+ '[url=javascript:alert("XSS")]Link[/url]',
37+
38+ // Markdown injection attempts
39+ '![XSS](javascript:alert("XSS"))',
40+ '[XSS](javascript:alert("XSS"))',
41+
42+ // Unicode and encoding bypasses
43+ '<script>alert(&#34;XSS&#34;)</script>',
44+ '<script>alert(String.fromCharCode(88,83,83))</script>',
45+ '<img src="x" onerror="&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;">',
46+
47+ // CSS expression injection
48+ '<div style="width:expression(alert(\'XSS\'))">',
49+ '<div style="background:url(vbscript:alert(\'XSS\'))">',
50+
51+ // Mixed content attacks
52+ 'Normal text <script>alert("XSS")</script> more text',
53+ '[b]Bold <script>alert("XSS")</script> text[/b]',
54+ '**Bold <img src="x" onerror="alert(\'XSS\')"> text**'
55+];
56+
57+// CSS Injection Test Vectors
58+const CSS_INJECTION_VECTORS = [
59+ 'color: red; background: url(javascript:alert("XSS"))',
60+ 'font-family: "Arial"; } body { background: red; .fake {',
61+ 'font-size: expression(alert("XSS"))',
62+ 'background: url("data:text/html,<script>alert(\'XSS\')</script>")',
63+ 'color: red; } .injected { background: red',
64+ '@import url("malicious.css")',
65+ 'behavior: url("malicious.htc")'
66+];
67+
68+// Test Results Interface
69+interface SecurityTestResult {
70+ input: string;
71+ output: string;
72+ passed: boolean;
73+ vulnerabilities: string[];
74+ testType: string;
75+}
76+
77+interface SecurityTestSuite {
78+ xssTests: SecurityTestResult[];
79+ cssInjectionTests: SecurityTestResult[];
80+ validationTests: SecurityTestResult[];
81+ gradientTests: SecurityTestResult[];
82+ summary: {
83+ totalTests: number;
84+ passed: number;
85+ failed: number;
86+ vulnerabilities: string[];
87+ };
88+}
89+
90+// Check if output contains dangerous patterns
91+function detectVulnerabilities(output: string): string[] {
92+ const vulnerabilities: string[] = [];
93+
94+ // Script tags
95+ if (/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi.test(output)) {
96+ vulnerabilities.push('Script tag injection');
97+ }
98+
99+ // Event handlers
100+ if (/on\w+\s*=/gi.test(output)) {
101+ vulnerabilities.push('Event handler injection');
102+ }
103+
104+ // JavaScript URLs
105+ if (/javascript\s*:/gi.test(output)) {
106+ vulnerabilities.push('JavaScript URL injection');
107+ }
108+
109+ // Data URLs with HTML/JS
110+ if (/data\s*:\s*text\/html/gi.test(output)) {
111+ vulnerabilities.push('Data URL HTML injection');
112+ }
113+
114+ // CSS expressions
115+ if (/expression\s*\(/gi.test(output)) {
116+ vulnerabilities.push('CSS expression injection');
117+ }
118+
119+ // Dangerous CSS URLs
120+ if (/url\s*\(\s*['"]*javascript:/gi.test(output)) {
121+ vulnerabilities.push('CSS JavaScript URL injection');
122+ }
123+
124+ // Iframe with dangerous src
125+ if (/<iframe[^>]*src\s*=\s*['"]*(?:javascript:|data:)/gi.test(output)) {
126+ vulnerabilities.push('Dangerous iframe injection');
127+ }
128+
129+ // Object/embed tags
130+ if (/<(?:object|embed|applet)\b/gi.test(output)) {
131+ vulnerabilities.push('Dangerous object/embed tag');
132+ }
133+
134+ // Style tag injection
135+ if (/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi.test(output)) {
136+ vulnerabilities.push('Style tag injection');
137+ }
138+
139+ return vulnerabilities;
140+}
141+
142+// Run XSS tests
143+async function runXSSTests(): Promise<SecurityTestResult[]> {
144+ const results: SecurityTestResult[] = [];
145+
146+ for (const vector of XSS_TEST_VECTORS) {
147+ try {
148+ const output = await formatText(vector, DEFAULT_TEXT_STYLE, true);
149+ const vulnerabilities = detectVulnerabilities(output);
150+
151+ results.push({
152+ input: vector,
153+ output,
154+ passed: vulnerabilities.length === 0,
155+ vulnerabilities,
156+ testType: 'XSS'
157+ });
158+ } catch (error) {
159+ results.push({
160+ input: vector,
161+ output: `Error: ${error}`,
162+ passed: true, // Errors are acceptable for malicious input
163+ vulnerabilities: [],
164+ testType: 'XSS'
165+ });
166+ }
167+ }
168+
169+ return results;
170+}
171+
172+// Run CSS injection tests
173+async function runCSSInjectionTests(): Promise<SecurityTestResult[]> {
174+ const results: SecurityTestResult[] = [];
175+
176+ for (const vector of CSS_INJECTION_VECTORS) {
177+ try {
178+ const testStyle = { ...DEFAULT_TEXT_STYLE, color: 'red' as any };
179+ const output = await formatText(`Test with style`, testStyle, true);
180+ const vulnerabilities = detectVulnerabilities(output);
181+
182+ results.push({
183+ input: vector,
184+ output,
185+ passed: vulnerabilities.length === 0,
186+ vulnerabilities,
187+ testType: 'CSS Injection'
188+ });
189+ } catch (error) {
190+ results.push({
191+ input: vector,
192+ output: `Error: ${error}`,
193+ passed: true,
194+ vulnerabilities: [],
195+ testType: 'CSS Injection'
196+ });
197+ }
198+ }
199+
200+ return results;
201+}
202+
203+// Run validation tests
204+function runValidationTests(): SecurityTestResult[] {
205+ const results: SecurityTestResult[] = [];
206+
207+ const testCases = [
208+ 'Normal text',
209+ 'Text with <script>alert("XSS")</script>',
210+ 'Very long text '.repeat(200), // Test length limits
211+ '', // Empty text
212+ 'Text with\nnewlines\nand\ttabs',
213+ 'Special chars: <>&"\'`',
214+ 'Unicode: 你好世界 🌍 🎉',
215+ 'Mixed: Normal **bold** and <script>alert("XSS")</script>'
216+ ];
217+
218+ for (const testCase of testCases) {
219+ const validation = validateMessageContent(testCase);
220+ const vulnerabilities = detectVulnerabilities(validation.sanitizedContent);
221+
222+ results.push({
223+ input: testCase,
224+ output: validation.sanitizedContent,
225+ passed: validation.isValid && vulnerabilities.length === 0,
226+ vulnerabilities: [...validation.errors, ...vulnerabilities],
227+ testType: 'Validation'
228+ });
229+ }
230+
231+ return results;
232+}
233+
234+// Run gradient text security tests
235+function runGradientTests(): SecurityTestResult[] {
236+ const results: SecurityTestResult[] = [];
237+
238+ const testCases = [
239+ 'Normal gradient text',
240+ '<script>alert("XSS")</script>',
241+ 'Text with "quotes" and \'apostrophes\'',
242+ 'Very long gradient text '.repeat(50),
243+ 'Special chars: <>&\'"',
244+ ''
245+ ];
246+
247+ const testColors = ['#FF0000', '#00FF00', '#0000FF'];
248+
249+ for (const testCase of testCases) {
250+ try {
251+ const output = createGradientText(testCase, testColors);
252+ const vulnerabilities = detectVulnerabilities(output);
253+
254+ results.push({
255+ input: testCase,
256+ output,
257+ passed: vulnerabilities.length === 0,
258+ vulnerabilities,
259+ testType: 'Gradient'
260+ });
261+ } catch (error) {
262+ results.push({
263+ input: testCase,
264+ output: `Error: ${error}`,
265+ passed: true,
266+ vulnerabilities: [],
267+ testType: 'Gradient'
268+ });
269+ }
270+ }
271+
272+ return results;
273+}
274+
275+// Run complete security test suite
276+export async function runSecurityTestSuite(): Promise<SecurityTestSuite> {
277+ console.log('🔒 Running comprehensive security test suite...');
278+
279+ const xssTests = await runXSSTests();
280+ const cssInjectionTests = await runCSSInjectionTests();
281+ const validationTests = runValidationTests();
282+ const gradientTests = runGradientTests();
283+
284+ const allTests = [...xssTests, ...cssInjectionTests, ...validationTests, ...gradientTests];
285+ const passed = allTests.filter(test => test.passed).length;
286+ const failed = allTests.length - passed;
287+
288+ // Collect all unique vulnerabilities
289+ const allVulnerabilities = new Set<string>();
290+ allTests.forEach(test => {
291+ test.vulnerabilities.forEach(vuln => allVulnerabilities.add(vuln));
292+ });
293+
294+ const results: SecurityTestSuite = {
295+ xssTests,
296+ cssInjectionTests,
297+ validationTests,
298+ gradientTests,
299+ summary: {
300+ totalTests: allTests.length,
301+ passed,
302+ failed,
303+ vulnerabilities: Array.from(allVulnerabilities)
304+ }
305+ };
306+
307+ console.log(`✅ Security tests completed:`);
308+ console.log(` Total tests: ${results.summary.totalTests}`);
309+ console.log(` Passed: ${results.summary.passed}`);
310+ console.log(` Failed: ${results.summary.failed}`);
311+
312+ if (results.summary.vulnerabilities.length > 0) {
313+ console.warn(`⚠️ Vulnerabilities detected:`, results.summary.vulnerabilities);
314+ } else {
315+ console.log(`🛡️ No vulnerabilities detected!`);
316+ }
317+
318+ return results;
319+}
320+
321+// Quick security check function
322+export async function quickSecurityCheck(input: string): Promise<boolean> {
323+ try {
324+ const output = await formatText(input, DEFAULT_TEXT_STYLE, true);
325+ const vulnerabilities = detectVulnerabilities(output);
326+ return vulnerabilities.length === 0;
327+ } catch (error) {
328+ // Errors on malicious input are acceptable
329+ return true;
330+ }
331+}
332+
333+// Style validation tests
334+export function testStyleValidation(): boolean {
335+ const testCases = [
336+ // Valid styles
337+ { fontFamily: 'tahoma', fontSize: 14, color: 'red', bold: true },
338+ { fontFamily: 'verdana', fontSize: 16, color: 'blue', italic: true },
339+
340+ // Invalid styles (should be sanitized)
341+ { fontFamily: 'malicious-font', fontSize: 999, color: 'invalid-color' },
342+ { fontFamily: 'tahoma', fontSize: -10, color: 'red' },
343+ { fontFamily: 'tahoma', fontSize: 'invalid' as any, color: 'red' }
344+ ];
345+
346+ for (const testCase of testCases) {
347+ try {
348+ const validated = validateTextStyle(testCase as Partial<TextStyle>);
349+
350+ // Check that validated style has safe values
351+ if (!validated.fontFamily ||
352+ validated.fontSize < 8 ||
353+ validated.fontSize > 72) {
354+ console.error('Style validation failed for:', testCase);
355+ return false;
356+ }
357+ } catch (error) {
358+ console.error('Style validation error:', error);
359+ return false;
360+ }
361+ }
362+
363+ return true;
364+}
365+
366+// Export test utilities
367+export {
368+ XSS_TEST_VECTORS,
369+ CSS_INJECTION_VECTORS,
370+ detectVulnerabilities,
371+ type SecurityTestResult,
372+ type SecurityTestSuite
373+};⏎̸
A src/lib/utils/text-formatter.ts +354 -0
@@ -0,0 +1,354 @@
1+import { unified } from 'unified';
2+import remarkParse from 'remark-parse';
3+import remarkRehype from 'remark-rehype';
4+import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
5+import rehypeStringify from 'rehype-stringify';
6+import type { TextStyle } from '../types/text-formatting';
7+import { generateCSSStyle, validateTextStyle } from '../types/text-formatting';
8+
9+// Define allowed HTML tags and attributes for security
10+const ALLOWED_SCHEMA = {
11+ ...defaultSchema,
12+ tagNames: [
13+ ...(defaultSchema.tagNames || []),
14+ 'span',
15+ 'strong',
16+ 'em',
17+ 'u',
18+ 's',
19+ 'mark'
20+ ],
21+ attributes: {
22+ ...defaultSchema.attributes,
23+ span: ['style', 'class', 'data-gradient'],
24+ strong: ['style', 'class'],
25+ em: ['style', 'class'],
26+ u: ['style', 'class'],
27+ s: ['style', 'class'],
28+ mark: ['style', 'class']
29+ },
30+ protocols: {
31+ ...defaultSchema.protocols,
32+ style: []
33+ }
34+};
35+
36+// Custom rehype plugin to apply text styles
37+function rehypeApplyStyles(userStyle: TextStyle) {
38+ return function(tree: any) {
39+ // We don't need to apply styles here anymore
40+ // The formatted-message component handles all styling
41+ return tree;
42+ };
43+}
44+
45+// BBCode-style formatting patterns
46+const BB_CODE_PATTERNS: Array<{
47+ pattern: RegExp;
48+ replacement: string;
49+}> = [
50+ // Bold
51+ { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: '<strong>$1</strong>' },
52+
53+ // Italic
54+ { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: '<em>$1</em>' },
55+
56+ // Underline
57+ { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },
58+
59+ // Strikethrough
60+ { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' },
61+
62+ // Color
63+ { pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi, replacement: '<span style="color: $1">$2</span>' },
64+
65+ // Size
66+ { pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi, replacement: '<span style="font-size: $1px">$2</span>' },
67+
68+ // Font
69+ { pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi, replacement: '<span style="font-family: $1">$2</span>' }
70+];
71+
72+// Markdown-style formatting patterns
73+const MARKDOWN_PATTERNS: Array<{
74+ pattern: RegExp;
75+ replacement: string;
76+}> = [
77+ // Bold (double asterisk or double underscore)
78+ { pattern: /\*\*(.*?)\*\*/g, replacement: '<strong>$1</strong>' },
79+ { pattern: /__(.*?)__/g, replacement: '<strong>$1</strong>' },
80+
81+ // Italic (single asterisk or single underscore)
82+ { pattern: /\*(.*?)\*/g, replacement: '<em>$1</em>' },
83+ { pattern: /_(.*?)_/g, replacement: '<em>$1</em>' },
84+
85+ // Strikethrough
86+ { pattern: /~~(.*?)~~/g, replacement: '<s>$1</s>' },
87+
88+ // Inline code
89+ { pattern: /`([^`]+)`/g, replacement: '<code>$1</code>' }
90+];
91+
92+// Preprocess text to convert BBCode and Markdown to HTML
93+function preprocessFormatting(text: string): string {
94+ let processedText = text;
95+
96+ // Apply BBCode patterns first
97+ BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => {
98+ processedText = processedText.replace(pattern, replacement);
99+ });
100+
101+ // Apply Markdown patterns (but avoid conflicts with already processed BBCode)
102+ MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => {
103+ // Only apply if not already inside HTML tags
104+ processedText = processedText.replace(pattern, (match, content) => {
105+ // Simple check to avoid replacing inside existing HTML tags
106+ const beforeMatch = processedText.substring(0, processedText.indexOf(match));
107+ const openTags = (beforeMatch.match(/<[^/][^>]*>/g) || []).length;
108+ const closeTags = (beforeMatch.match(/<\/[^>]*>/g) || []).length;
109+
110+ // If we're inside an unclosed tag, don't apply markdown
111+ if (openTags > closeTags) {
112+ return match;
113+ }
114+
115+ return replacement.replace('$1', content);
116+ });
117+ });
118+
119+ return processedText;
120+}
121+
122+// Main text formatting function
123+export async function formatText(
124+ text: string,
125+ userStyle?: Partial<TextStyle>,
126+ allowRichFormatting: boolean = true
127+): Promise<string> {
128+ try {
129+ // If text is empty, return empty string
130+ if (!text) return '';
131+
132+ // Validate and apply user style
133+ const validatedStyle = validateTextStyle(userStyle || {});
134+
135+ // Preprocess the text for formatting if allowed
136+ let processedText = text;
137+ if (allowRichFormatting) {
138+ processedText = preprocessFormatting(text);
139+ }
140+
141+ // If no rich formatting, just return escaped text
142+ if (!allowRichFormatting && !processedText.includes('<')) {
143+ return escapeHtml(text);
144+ }
145+
146+ // Create the unified processor
147+ const processor = unified()
148+ .use(remarkParse, {
149+ fragment: true // Parse as fragment, not full document
150+ })
151+ .use(remarkRehype, {
152+ allowDangerousHtml: true // Allow preprocessed HTML to pass through
153+ })
154+ .use(rehypeSanitize, ALLOWED_SCHEMA) // Sanitize HTML
155+ .use(rehypeStringify);
156+
157+ // Process the text
158+ const result = await processor.process(processedText);
159+ let formattedHtml = String(result).trim();
160+
161+ // If we got an empty result, return the escaped text
162+ if (!formattedHtml || formattedHtml === '<p></p>' || formattedHtml === '') {
163+ return escapeHtml(text);
164+ }
165+
166+ // Remove wrapping <p> tags if it's just a single paragraph
167+ if (formattedHtml.startsWith('<p>') && formattedHtml.endsWith('</p>')) {
168+ const pCount = (formattedHtml.match(/<p>/g) || []).length;
169+ if (pCount === 1) {
170+ formattedHtml = formattedHtml.slice(3, -4).trim();
171+ }
172+ }
173+
174+ // Final check - if we ended up with empty content, return escaped text
175+ if (!formattedHtml) {
176+ return escapeHtml(text);
177+ }
178+
179+ return formattedHtml;
180+
181+ } catch (error) {
182+ console.error('Text formatting error:', error);
183+ // Return escaped plain text as fallback
184+ return escapeHtml(text);
185+ }
186+}
187+
188+// Utility function to escape HTML
189+function escapeHtml(text: string): string {
190+ const div = document.createElement('div');
191+ div.textContent = text;
192+ return div.innerHTML;
193+}
194+
195+// Generate CSS style string excluding font-family (which should be handled by classes)
196+function generateCSSStyleWithoutFont(style: TextStyle): string {
197+ const styles: string[] = [];
198+
199+ // Font size
200+ styles.push(`font-size: ${style.fontSize}px`);
201+
202+ // Color (not for gradients)
203+ if (style.color && !style.gradient) {
204+ styles.push(`color: ${style.color}`);
205+ }
206+
207+ // Font weight
208+ if (style.bold) {
209+ styles.push(`font-weight: bold`);
210+ }
211+
212+ // Font style
213+ if (style.italic) {
214+ styles.push(`font-style: italic`);
215+ }
216+
217+ // Text decoration
218+ const decorations: string[] = [];
219+ if (style.underline) decorations.push('underline');
220+ if (style.strikethrough) decorations.push('line-through');
221+ if (decorations.length > 0) {
222+ styles.push(`text-decoration: ${decorations.join(' ')}`);
223+ }
224+
225+ return styles.join('; ');
226+}
227+
228+// Create gradient text effect
229+export function createGradientText(
230+ text: string,
231+ colors: string[],
232+ className: string = 'gradient-text',
233+ style?: TextStyle
234+): string {
235+ console.debug('createGradientText called with:', { text, colors, className, style });
236+
237+ if (colors.length < 2) {
238+ const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
239+ const inlineStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : '';
240+ const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`;
241+ console.debug('Single color result:', result);
242+ return result;
243+ }
244+
245+ const chars = Array.from(text);
246+ const totalChars = chars.length;
247+
248+ if (totalChars === 0) return '';
249+
250+ // Create spans for each character with interpolated colors
251+ const gradientSpans = chars.map((char, index) => {
252+ if (char === ' ') {
253+ return '&nbsp;'; // Use non-breaking space to preserve spacing
254+ }
255+
256+ // Calculate color position (0 to 1)
257+ const position = totalChars === 1 ? 0 : index / (totalChars - 1);
258+
259+ // Find the two colors to interpolate between
260+ const colorIndex = position * (colors.length - 1);
261+ const lowerIndex = Math.floor(colorIndex);
262+ const upperIndex = Math.min(lowerIndex + 1, colors.length - 1);
263+ const t = colorIndex - lowerIndex;
264+
265+ // Interpolate between colors
266+ const color1 = hexToRgb(colors[lowerIndex]);
267+ const color2 = hexToRgb(colors[upperIndex]);
268+
269+ if (!color1 || !color2) {
270+ const fallbackStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined}) : '';
271+ return `<span class="${className}" style="${fallbackStyle}">${escapeHtml(char)}</span>`;
272+ }
273+
274+ const r = Math.round(color1.r + t * (color2.r - color1.r));
275+ const g = Math.round(color1.g + t * (color2.g - color1.g));
276+ const b = Math.round(color1.b + t * (color2.b - color1.b));
277+
278+ const interpolatedColor = `rgb(${r}, ${g}, ${b})`;
279+
280+ // Combine interpolated color with other styles
281+ let inlineStyle = `color: ${interpolatedColor}`;
282+ if (style) {
283+ // Get styles without font-family and color
284+ if (style.fontSize !== 14) inlineStyle += `; font-size: ${style.fontSize}px`;
285+ if (style.bold) inlineStyle += `; font-weight: bold`;
286+ if (style.italic) inlineStyle += `; font-style: italic`;
287+ const decorations: string[] = [];
288+ if (style.underline) decorations.push('underline');
289+ if (style.strikethrough) decorations.push('line-through');
290+ if (decorations.length > 0) {
291+ inlineStyle += `; text-decoration: ${decorations.join(' ')}`;
292+ }
293+ }
294+
295+ return `<span class="gradient-char" style="${inlineStyle}">${escapeHtml(char)}</span>`;
296+ });
297+
298+ // Wrap all spans in a container that uses flexbox to eliminate spacing
299+ const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
300+ const containerStyle = style ? generateCSSStyleWithoutFont({...style, gradient: undefined, color: undefined}) : '';
301+ const result = `<span class="${className}${fontClass}" style="display: inline-flex; flex-wrap: wrap; ${containerStyle}">${gradientSpans.join('')}</span>`;
302+ console.debug('Final gradient HTML:', result);
303+ return result;
304+}
305+
306+// Helper function to convert hex to RGB
307+function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
308+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
309+ return result ? {
310+ r: parseInt(result[1], 16),
311+ g: parseInt(result[2], 16),
312+ b: parseInt(result[3], 16)
313+ } : null;
314+}
315+
316+// Validate message content for security
317+export function validateMessageContent(content: string): {
318+ isValid: boolean;
319+ sanitizedContent: string;
320+ errors: string[];
321+} {
322+ const errors: string[] = [];
323+ let sanitizedContent = content;
324+
325+ // Check length
326+ if (content.length > 2000) {
327+ errors.push('Message too long (max 2000 characters)');
328+ sanitizedContent = content.substring(0, 2000);
329+ }
330+
331+ // Check for potential XSS patterns
332+ const dangerousPatterns = [
333+ /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
334+ /javascript:/gi,
335+ /vbscript:/gi,
336+ /onload\s*=/gi,
337+ /onerror\s*=/gi,
338+ /onclick\s*=/gi,
339+ /onmouseover\s*=/gi
340+ ];
341+
342+ dangerousPatterns.forEach(pattern => {
343+ if (pattern.test(content)) {
344+ errors.push('Potentially dangerous content detected');
345+ sanitizedContent = sanitizedContent.replace(pattern, '');
346+ }
347+ });
348+
349+ return {
350+ isValid: errors.length === 0,
351+ sanitizedContent,
352+ errors
353+ };
354+}⏎̸
A src/lib/validation/password.ts +199 -0
@@ -0,0 +1,199 @@
1+import { z } from 'zod';
2+
3+// Dynamic password constraints configuration
4+export interface PasswordConstraints {
5+ minLength: number;
6+ maxLength: number;
7+ requireUppercase: boolean;
8+ requireLowercase: boolean;
9+ requireNumbers: boolean;
10+ requireSpecialChars: boolean;
11+ forbiddenPatterns: string[];
12+}
13+
14+// Default password constraints
15+export const DEFAULT_PASSWORD_CONSTRAINTS: PasswordConstraints = {
16+ minLength: 8,
17+ maxLength: 64,
18+ requireUppercase: true,
19+ requireLowercase: true,
20+ requireNumbers: true,
21+ requireSpecialChars: false,
22+ forbiddenPatterns: ['password', '123456', 'qwerty', 'admin']
23+};
24+
25+// Validation error messages
26+export const PASSWORD_ERROR_MESSAGES = {
27+ minLength: (min: number) => `Password must be at least ${min} characters long`,
28+ maxLength: (max: number) => `Password must be at most ${max} characters long`,
29+ requireUppercase: 'Password must contain at least one uppercase letter',
30+ requireLowercase: 'Password must contain at least one lowercase letter',
31+ requireNumbers: 'Password must contain at least one number',
32+ requireSpecialChars: 'Password must contain at least one special character (!@#$%^&*)',
33+ forbiddenPattern: (pattern: string) => `Password cannot contain '${pattern}'`,
34+ common: 'Password is too common or weak'
35+};
36+
37+// Create dynamic password schema based on constraints
38+export function createPasswordSchema(constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) {
39+ let schema = z.string()
40+ .min(constraints.minLength, PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength))
41+ .max(constraints.maxLength, PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength));
42+
43+ // Add character type requirements
44+ if (constraints.requireUppercase) {
45+ schema = schema.regex(/[A-Z]/, PASSWORD_ERROR_MESSAGES.requireUppercase);
46+ }
47+
48+ if (constraints.requireLowercase) {
49+ schema = schema.regex(/[a-z]/, PASSWORD_ERROR_MESSAGES.requireLowercase);
50+ }
51+
52+ if (constraints.requireNumbers) {
53+ schema = schema.regex(/\d/, PASSWORD_ERROR_MESSAGES.requireNumbers);
54+ }
55+
56+ if (constraints.requireSpecialChars) {
57+ schema = schema.regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, PASSWORD_ERROR_MESSAGES.requireSpecialChars);
58+ }
59+
60+ // Add forbidden patterns check
61+ if (constraints.forbiddenPatterns.length > 0) {
62+ return schema.refine(
63+ (password) => {
64+ const lowerPassword = password.toLowerCase();
65+ return !constraints.forbiddenPatterns.some(pattern =>
66+ lowerPassword.includes(pattern.toLowerCase())
67+ );
68+ },
69+ {
70+ message: PASSWORD_ERROR_MESSAGES.common
71+ }
72+ );
73+ }
74+
75+ return schema;
76+}
77+
78+// Login validation schema
79+export const loginSchema = z.object({
80+ username: z.string()
81+ .min(3, 'Username must be at least 3 characters')
82+ .max(32, 'Username must be at most 32 characters')
83+ .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and dashes'),
84+ password: z.string().min(1, 'Password is required')
85+});
86+
87+// Registration validation schema (using dynamic password validation)
88+export function createRegistrationSchema(constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) {
89+ return z.object({
90+ suUsername: z.string()
91+ .min(3, 'Username must be at least 3 characters')
92+ .max(32, 'Username must be at most 32 characters')
93+ .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and dashes'),
94+ suPassword: createPasswordSchema(constraints),
95+ suConfirmPassword: z.string(),
96+ captchaAnswer: z.string().min(1, 'Please answer the PDR question'),
97+ turnstileToken: z.string().optional()
98+ }).refine(
99+ (data) => data.suPassword === data.suConfirmPassword,
100+ {
101+ message: 'Passwords do not match',
102+ path: ['suConfirmPassword']
103+ }
104+ );
105+}
106+
107+// Utility function to validate password strength and return detailed feedback
108+export function validatePasswordStrength(password: string, constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS) {
109+ const errors: string[] = [];
110+ const requirements: { met: boolean; message: string }[] = [];
111+
112+ // Length checks
113+ if (password.length < constraints.minLength) {
114+ errors.push(PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength));
115+ }
116+ requirements.push({
117+ met: password.length >= constraints.minLength,
118+ message: `At least ${constraints.minLength} characters`
119+ });
120+
121+ if (password.length > constraints.maxLength) {
122+ errors.push(PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength));
123+ }
124+
125+ // Character type checks
126+ if (constraints.requireUppercase) {
127+ const hasUppercase = /[A-Z]/.test(password);
128+ if (!hasUppercase) errors.push(PASSWORD_ERROR_MESSAGES.requireUppercase);
129+ requirements.push({
130+ met: hasUppercase,
131+ message: 'At least one uppercase letter'
132+ });
133+ }
134+
135+ if (constraints.requireLowercase) {
136+ const hasLowercase = /[a-z]/.test(password);
137+ if (!hasLowercase) errors.push(PASSWORD_ERROR_MESSAGES.requireLowercase);
138+ requirements.push({
139+ met: hasLowercase,
140+ message: 'At least one lowercase letter'
141+ });
142+ }
143+
144+ if (constraints.requireNumbers) {
145+ const hasNumbers = /\d/.test(password);
146+ if (!hasNumbers) errors.push(PASSWORD_ERROR_MESSAGES.requireNumbers);
147+ requirements.push({
148+ met: hasNumbers,
149+ message: 'At least one number'
150+ });
151+ }
152+
153+ if (constraints.requireSpecialChars) {
154+ const hasSpecialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
155+ if (!hasSpecialChars) errors.push(PASSWORD_ERROR_MESSAGES.requireSpecialChars);
156+ requirements.push({
157+ met: hasSpecialChars,
158+ message: 'At least one special character'
159+ });
160+ }
161+
162+ // Forbidden patterns check
163+ if (constraints.forbiddenPatterns.length > 0) {
164+ const lowerPassword = password.toLowerCase();
165+ const hasForbiddenPattern = constraints.forbiddenPatterns.some(pattern =>
166+ lowerPassword.includes(pattern.toLowerCase())
167+ );
168+ if (hasForbiddenPattern) {
169+ errors.push(PASSWORD_ERROR_MESSAGES.common);
170+ }
171+ requirements.push({
172+ met: !hasForbiddenPattern,
173+ message: 'Not a common or weak password'
174+ });
175+ }
176+
177+ return {
178+ isValid: errors.length === 0,
179+ errors,
180+ requirements,
181+ strength: calculatePasswordStrength(password, requirements)
182+ };
183+}
184+
185+// Calculate password strength score (0-100)
186+function calculatePasswordStrength(password: string, requirements: { met: boolean; message: string }[]): number {
187+ const metRequirements = requirements.filter(req => req.met).length;
188+ const totalRequirements = requirements.length;
189+
190+ if (totalRequirements === 0) return 0;
191+
192+ let baseScore = (metRequirements / totalRequirements) * 80;
193+
194+ // Bonus points for length beyond minimum
195+ if (password.length > 12) baseScore += 10;
196+ if (password.length > 16) baseScore += 10;
197+
198+ return Math.min(100, Math.round(baseScore));
199+}⏎̸
A src/lib/validation/text-formatting.ts +81 -0
@@ -0,0 +1,81 @@
1+import type { TextStyle } from '$lib/types/text-formatting';
2+import { RETRO_FONTS } from '$lib/types/text-formatting';
3+
4+const MIN_FONT_SIZE = 8;
5+const MAX_FONT_SIZE = 18;
6+const MAX_GRADIENT_COLORS = 3;
7+const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/;
8+
9+export function validateHexColor(color: string): boolean {
10+ return HEX_COLOR_REGEX.test(color);
11+}
12+
13+export function validateFontFamily(fontFamily: string): boolean {
14+ return fontFamily in RETRO_FONTS;
15+}
16+
17+export function validateFontSize(size: number): number {
18+ return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
19+}
20+
21+export function validateGradient(gradient: unknown): string[] | undefined {
22+ if (!Array.isArray(gradient)) return undefined;
23+ if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined;
24+
25+ const validColors = gradient.filter(color =>
26+ typeof color === 'string' && validateHexColor(color)
27+ );
28+
29+ return validColors.length === gradient.length ? validColors : undefined;
30+}
31+
32+export function validateTextStyle(style: unknown): TextStyle | null {
33+ if (!style || typeof style !== 'object') return null;
34+
35+ const rawStyle = style as Record<string, unknown>;
36+
37+ const validated: TextStyle = {
38+ fontFamily: 'pixelated',
39+ fontSize: 14,
40+ bold: false,
41+ italic: false,
42+ underline: false,
43+ strikethrough: false
44+ };
45+
46+ // Validate font family
47+ if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) {
48+ validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS;
49+ }
50+
51+ // Validate font size
52+ if (typeof rawStyle.fontSize === 'number') {
53+ validated.fontSize = validateFontSize(rawStyle.fontSize);
54+ }
55+
56+ // Validate color
57+ if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) {
58+ validated.color = rawStyle.color;
59+ }
60+
61+ // Validate gradient
62+ const validatedGradient = validateGradient(rawStyle.gradient);
63+ if (validatedGradient) {
64+ validated.gradient = validatedGradient;
65+ }
66+
67+ // Validate boolean properties
68+ validated.bold = Boolean(rawStyle.bold);
69+ validated.italic = Boolean(rawStyle.italic);
70+ validated.underline = Boolean(rawStyle.underline);
71+ validated.strikethrough = Boolean(rawStyle.strikethrough);
72+
73+ return validated;
74+}
75+
76+export function sanitizeStyleData(styleData: unknown): TextStyle | undefined {
77+ if (!styleData) return undefined;
78+
79+ const validated = validateTextStyle(styleData);
80+ return validated || undefined;
81+}⏎̸
A src/routes/+page.server.ts +63 -0
@@ -0,0 +1,63 @@
1+import type { PageServerLoad } from './$types';
2+import { validateSessionToken } from '$lib/api/session.server';
3+import db from '$lib/db/db.server';
4+import { userTextPreferences, users, messages } from '$lib/db/schema';
5+import { eq, desc } from 'drizzle-orm';
6+import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting';
7+
8+export const load: PageServerLoad = async ({ cookies }) => {
9+ const token = cookies.get('session');
10+
11+ if (!token) {
12+ return {
13+ user: null,
14+ lastTextStyle: DEFAULT_TEXT_STYLE
15+ };
16+ }
17+
18+ try {
19+ const { user } = await validateSessionToken(token);
20+
21+ if (!user) {
22+ return {
23+ user: null,
24+ lastTextStyle: DEFAULT_TEXT_STYLE
25+ };
26+ }
27+
28+ // Get user's last used text styling from their most recent message
29+ const lastMessage = await db.query.messages.findFirst({
30+ where: eq(messages.senderId, user.id),
31+ orderBy: [desc(messages.timestamp)],
32+ columns: {
33+ styleData: true
34+ }
35+ });
36+
37+ let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE;
38+
39+ if (lastMessage?.styleData) {
40+ try {
41+ const parsedStyle = JSON.parse(lastMessage.styleData);
42+ // Merge with default style to ensure all required properties
43+ lastTextStyle = {
44+ ...DEFAULT_TEXT_STYLE,
45+ ...parsedStyle
46+ };
47+ } catch (error) {
48+ console.warn('Failed to parse last text style:', error);
49+ }
50+ }
51+
52+ return {
53+ user,
54+ lastTextStyle
55+ };
56+ } catch (error) {
57+ console.error('Error loading page data:', error);
58+ return {
59+ user: null,
60+ lastTextStyle: DEFAULT_TEXT_STYLE
61+ };
62+ }
63+};⏎̸
M src/routes/+page.svelte +4 -1
@@ -2,7 +2,10 @@
22 import ChatRoom from '$lib/components/chat-room.svelte';
33 import DesktopIcons from '$lib/components/desktop-icons.svelte';
44 import AimLogin from '$lib/components/aim-login.svelte';
5+ import type { PageData } from './$types';
56
7+ let { data }: { data: PageData } = $props();
8+
69 let showChatRoom = $state(true);
710 let showAuth = $state(false);
811
@@ -15,7 +18,7 @@
1518 <DesktopIcons bind:showChatRoom bind:showAuth />
1619
1720 {#if showChatRoom}
18- <ChatRoom bind:showChatRoom />
21+ <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} />
1922 {/if}
2023
2124 {#if showAuth}
M src/routes/api/chat/messages/+server.ts +12 -2
@@ -15,6 +15,7 @@ import { chatRooms } from '$lib/db/schema';
1515 import { users } from '$lib/db/schema';
1616 import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';
1717 import { createLogger } from '$lib/utils/logger.server';
18+import { sanitizeStyleData } from '$lib/validation/text-formatting';
1819
1920 const log = createLogger('chat-server');
2021
@@ -83,7 +84,12 @@ export async function GET({ request, locals }) {
8384 .from(messages)
8485 .where(conditions)
8586 .orderBy(desc(messages.timestamp))
86- .limit(fetchLimit);
87+ .limit(fetchLimit)
88+ .then(rows => rows.map(row => ({
89+ ...row,
90+ styleData: row.styleData ?? undefined,
91+ hasFormatting: row.hasFormatting ?? false
92+ })));
8793
8894 const response: GetMessagesResponse = {
8995 success: true,
@@ -182,13 +188,17 @@ export async function POST({ request, locals }: { request: Request, locals: App.
182188 return new Response(JSON.stringify(errorResponse), { status: 404 });
183189 }
184190
191+ const validatedStyleData = sanitizeStyleData(data.styleData);
192+
185193 const newMessage: Message = {
186194 id: uuidv4(),
187195 chatRoomId,
188196 senderId: data.userId,
189197 content: data.content,
190198 type: data.type || 'chat',
191- timestamp: Date.now()
199+ timestamp: Date.now(),
200+ styleData: validatedStyleData,
201+ hasFormatting: Boolean(validatedStyleData)
192202 };
193203
194204 // Save to DB
M src/routes/api/register/+server.ts +15 -57
@@ -3,7 +3,7 @@ import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/
33 import db from '$lib/db/db.server';
44 import { users } from '$lib/db/schema';
55 import { hashPassword } from '$lib/utils/password';
6-import { validateTurnstileToken } from '$lib/utils/turnstile.server';
6+import { createRegistrationSchema, DEFAULT_PASSWORD_CONSTRAINTS } from '$lib/validation/password';
77 import { createLogger } from '$lib/utils/logger.server';
88 import { eq } from 'drizzle-orm/sql';
99
@@ -54,24 +54,22 @@ export const POST: RequestHandler = async ({ request }) => {
5454 turnstileToken?: string
5555 };
5656
57- if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || (!isDev && !turnstileToken)) {
58- log.warn('Missing required fields', {
59- hasUsername: Boolean(suUsername),
60- hasPassword: Boolean(suPassword),
61- hasConfirmPassword: Boolean(suConfirmPassword),
62- hasCaptcha: Boolean(captchaAnswer),
63- hasTurnstileToken: Boolean(turnstileToken),
64- isDev
57+ // Validate input using Zod schema
58+ try {
59+ const registrationSchema = createRegistrationSchema(DEFAULT_PASSWORD_CONSTRAINTS);
60+ registrationSchema.parse({
61+ suUsername,
62+ suPassword,
63+ suConfirmPassword,
64+ captchaAnswer,
65+ turnstileToken
6566 });
66- return new Response(JSON.stringify({ error: 'All fields are required' } as RegisterResponseError), { status: 400 });
67+ } catch (err: any) {
68+ const errorMessage = err.errors?.[0]?.message || 'Invalid input data';
69+ log.warn('Registration validation failed', { error: errorMessage });
70+ return new Response(JSON.stringify({ error: errorMessage } as RegisterResponseError), { status: 400 });
6771 }
6872
69- // Validate Turnstile token (will be automatically bypassed in dev mode)
70- const isValidTurnstile = await validateTurnstileToken(turnstileToken || '', ip);
71- if (!isValidTurnstile) {
72- log.warn('Invalid Turnstile token', { maskedIp });
73- return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as RegisterResponseError), { status: 400 });
74- }
7573
7674 // Validate PDR captcha
7775 const normalizedAnswer = captchaAnswer.trim().toLowerCase();
@@ -86,49 +84,9 @@ export const POST: RequestHandler = async ({ request }) => {
8684 // Reset captcha attempts on success
8785 captchaAttempts.delete(ip);
8886
89- // Destructure and validate required fields from the payload.
90- if (typeof suUsername !== 'string' || typeof suPassword !== 'string' ||
91- typeof suConfirmPassword !== 'string') {
92- log.warn('Missing or invalid input fields');
93- return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 });
94- }
95-
96- // Trim input values.
87+ // Trim input values (validation already done by Zod)
9788 const username = suUsername.trim();
9889 const password = suPassword.trim();
99- const confirmPassword = suConfirmPassword.trim();
100-
101- // Basic validations.
102- if (!username || !password || !confirmPassword) {
103- log.warn('All fields must be filled');
104- return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 });
105- }
106-
107- if (username.length < 3) {
108- log.warn('Username must be at least 3 characters');
109- return new Response(JSON.stringify({ error: 'Username must be at least 3 characters' } as RegisterResponseError), { status: 400 });
110- }
111-
112- if (username.length > 32) {
113- log.warn('Username must be at most 32 characters');
114- return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 });
115- }
116- // Only allow letters, numbers, underscores, and dashes in the username.
117- const usernameRegex = /^[a-zA-Z0-9_-]+$/;
118- if (!usernameRegex.test(username)) {
119- log.warn('Invalid username. Only letters, numbers, underscores, and dashes are allowed.');
120- return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 });
121- }
122-
123- if (password !== confirmPassword) {
124- log.warn('Passwords do not match');
125- return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 });
126- }
127-
128- if (password.length < 8 || password.length > 64) {
129- log.warn('Password must be between 8 and 64 characters');
130- return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 });
131- }
13290
13391 // Check if nickname already exists
13492 const existingUser = await db.select().from(users).where(eq(users.nickname, username));
M src/routes/api/rooms/[roomId]/+server.ts +3 -3
@@ -40,7 +40,7 @@ export async function GET({ params, url, locals }): Promise<Response> {
4040 // Process users to mark them as offline if they've timed out
4141 const processedUsers = normalizedUsers.map(
4242 (user: SafeUser & { lastSeen: number }) => {
43- const shouldBeOffline = user.status === 'online' && user.lastSeen < timeoutThreshold;
43+ const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold;
4444 return {
4545 ...user,
4646 status: shouldBeOffline ? 'offline' : user.status
@@ -48,13 +48,13 @@ export async function GET({ params, url, locals }): Promise<Response> {
4848 }
4949 );
5050
51- // Find users who were originally online but now marked offline
51+ // Find users who were originally non-offline but now marked offline
5252 const usersToUpdate = processedUsers.filter(
5353 (user: SafeUser & { lastSeen: number }) =>
5454 user.status === 'offline' &&
5555 normalizedUsers.find(
5656 (u: SafeUser & { lastSeen: number }) => u.id === user.id
57- )?.status === 'online'
57+ )?.status !== 'offline'
5858 );
5959
6060 if (usersToUpdate.length > 0) {
M src/routes/api/session/login/+server.ts +11 -48
@@ -8,10 +8,10 @@ import { generateSessionToken, createSession } from '$lib/api/session.server';
88 import { setSessionTokenCookie } from '$lib/api/session.cookie';
99 import { createSafeUser } from '$lib/types/chat';
1010 import { createLogger } from '$lib/utils/logger.server';
11-import { validateTurnstileToken } from '$lib/utils/turnstile.server';
11+import { loginSchema } from '$lib/validation/password';
12+import { z } from 'zod';
1213
1314 const log = createLogger('login-server');
14-const isDev = process.env.NODE_ENV === 'development';
1515
1616 // In-memory map to track failed login attempts per IP
1717 const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();
@@ -29,14 +29,6 @@ setInterval(() => {
2929 }
3030 }, 60 * 60 * 1000);
3131
32-// Helper to parse clearance cookie from headers
33-function getClearanceCookie(cookieHeader: string | null): string | null {
34- if (!cookieHeader) return null;
35- // Use a case-insensitive regex to match either "cf-clearance" or "cf_clearance"
36- const regex = /cf[-_]clearance=([^;]+)/i;
37- const match = cookieHeader.match(regex);
38- return match ? match[1] : null;
39-}
4032
4133 export const POST: RequestHandler = async ({ request, cookies }) => {
4234 log.debug('New login attempt received');
@@ -72,53 +64,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
7264 );
7365 }
7466
75- // Extract cookie header and clearance cookie BEFORE validating input.
76- const cookieHeader = request.headers.get('cookie');
77- const clearanceCookie = getClearanceCookie(cookieHeader);
78-
79- // Note: Mark turnstileToken as optional if a valid clearance cookie exists or in dev mode.
80- const { username, password, turnstileToken } = body as {
67+ const { username, password } = body as {
8168 username: string;
8269 password: string;
83- turnstileToken?: string;
8470 };
8571
86- if (
87- typeof username !== 'string' ||
88- typeof password !== 'string' ||
89- (!isDev && !clearanceCookie && typeof turnstileToken !== 'string')
90- ) {
91- log.warn('Missing or invalid input fields', {
92- hasUsername: typeof username === 'string',
93- hasPassword: typeof password === 'string',
94- hasTurnstileToken: typeof turnstileToken === 'string',
95- isDev
96- });
72+ // Validate input using Zod schema
73+ try {
74+ loginSchema.parse({ username, password });
75+ } catch (err) {
76+ const zodError = err as z.ZodError;
77+ const errorMessage = zodError.errors?.[0]?.message || 'Invalid input data';
78+ log.warn('Login validation failed', { error: errorMessage });
9779 return new Response(
98- JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError),
80+ JSON.stringify({ error: errorMessage } as LoginResponseError),
9981 { status: 400 }
10082 );
10183 }
10284
103- // Check for dev mode or clearance cookie before validating Turnstile token.
104- let isValidTurnstile = false;
105- if (isDev) {
106- log.debug('Development mode: bypassing token challenge.');
107- isValidTurnstile = true;
108- } else if (clearanceCookie) {
109- log.debug('Clearance cookie detected; bypassing token challenge.');
110- isValidTurnstile = true;
111- } else {
112- isValidTurnstile = await validateTurnstileToken(turnstileToken as string, ip);
113- }
114-
115- if (!isValidTurnstile) {
116- log.warn('Invalid Turnstile token', { maskedIp });
117- return new Response(
118- JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError),
119- { status: 400 }
120- );
121- }
12285
12386 // Find user by username
12487 const user = await db.query.users.findFirst({
M src/routes/api/sse/+server.ts +122 -48
@@ -8,9 +8,11 @@ import type { RequestHandler } from './$types';
88 import { sseEmitter } from '$lib/sseEmitter';
99 import db from '$lib/db/db.server';
1010 import { users } from '$lib/db/schema';
11-import { eq, and, lt } from 'drizzle-orm';
11+import { eq, and, lt, ne } from 'drizzle-orm';
1212 import { createSafeUser } from '$lib/types/chat';
1313 import { createLogger } from '$lib/utils/logger.server';
14+import { sseConnectionTracker } from '$lib/sseConnectionTracker';
15+import { buddyListCache } from '$lib/buddyListCache';
1416
1517 const log = createLogger('sse-server');
1618
@@ -52,7 +54,7 @@ function startTimeoutCheck() {
5254 })
5355 .where(
5456 and(
55- eq(users.status, 'online'), // Only update online users
57+ ne(users.status, 'offline'), // Update any non-offline users
5658 lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently
5759 )
5860 )
@@ -67,6 +69,9 @@ function startTimeoutCheck() {
6769 usersUpdated: result.length,
6870 timeoutThreshold: new Date(timeoutThreshold).toISOString()
6971 });
72+
73+ // Invalidate buddy list cache when users timeout
74+ buddyListCache.invalidate();
7075 }
7176 } catch (error) {
7277 log.error('Error during timeout check', { error });
@@ -84,19 +89,35 @@ function startBuddyListUpdateInterval() {
8489 const now = Date.now();
8590 // Only fetch and broadcast if 10 seconds have passed since last update
8691 if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) {
87- log.debug('Broadcasting buddy list update...');
88- // Query the complete buddy list from the database.
89- const buddyList = await db.select().from(users);
90- // Use createSafeUser to properly sanitize user data
91- const safeBuddyList = buddyList.map(user => createSafeUser(user));
92-
93- // Only broadcast if data has changed
94- if (JSON.stringify(safeBuddyList) !== JSON.stringify(lastBuddyListData)) {
95- lastBuddyListData = safeBuddyList;
96- sseEmitter.emit('sse', {
97- type: 'buddyListUpdate',
98- data: safeBuddyList
99- });
92+ // Only query database if cache is invalid or doesn't exist
93+ if (buddyListCache.needsRefresh()) {
94+ log.debug('Fetching buddy list from database (cache miss)...');
95+ // Query the complete buddy list from the database.
96+ const buddyList = await db.select().from(users);
97+ // Use createSafeUser to properly sanitize user data
98+ const safeBuddyList = buddyList.map(user => createSafeUser(user));
99+
100+ // Update cache and check if data changed
101+ const hasChanged = buddyListCache.update(safeBuddyList);
102+
103+ if (hasChanged) {
104+ log.debug('Broadcasting buddy list update (data changed)...');
105+ sseEmitter.emit('sse', {
106+ type: 'buddyListUpdate',
107+ data: safeBuddyList
108+ });
109+ }
110+ } else {
111+ // Use cached data
112+ const cachedData = buddyListCache.get();
113+ if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) {
114+ log.debug('Broadcasting buddy list update from cache...');
115+ lastBuddyListData = cachedData;
116+ sseEmitter.emit('sse', {
117+ type: 'buddyListUpdate',
118+ data: cachedData
119+ });
120+ }
100121 }
101122 lastBuddyListUpdate = now;
102123 }
@@ -106,10 +127,38 @@ function startBuddyListUpdateInterval() {
106127 }, 1000); // Check every second but only broadcast every 10 seconds
107128 }
108129
130+// Add periodic cleanup for stale connections
131+let connectionCleanupInterval: ReturnType<typeof setInterval>;
132+
133+function startConnectionCleanup() {
134+ if (connectionCleanupInterval) {
135+ clearInterval(connectionCleanupInterval);
136+ }
137+
138+ connectionCleanupInterval = setInterval(() => {
139+ const staleUserIds = sseConnectionTracker.cleanupStaleConnections();
140+ if (staleUserIds.length > 0) {
141+ // Mark stale connections as offline
142+ staleUserIds.forEach(async (userId) => {
143+ try {
144+ await db.update(users)
145+ .set({ status: 'offline', lastSeen: Date.now() })
146+ .where(eq(users.id, userId));
147+
148+ buddyListCache.invalidate();
149+ } catch (error) {
150+ log.error('Error updating stale user status', { userId, error });
151+ }
152+ });
153+ }
154+ }, 60000); // Run every minute
155+}
156+
109157 // <<< NEW: Ensure intervals are started only once >>>
110158 if (!globalThis.__sseIntervalsStarted) {
111159 startTimeoutCheck();
112160 startBuddyListUpdateInterval();
161+ startConnectionCleanup();
113162 globalThis.__sseIntervalsStarted = true;
114163 log.info('Started global intervals for timeout and buddy list updates');
115164 } else {
@@ -121,18 +170,21 @@ if (typeof process !== 'undefined') {
121170 process.on('beforeExit', () => {
122171 if (timeoutInterval) clearInterval(timeoutInterval);
123172 if (buddyListInterval) clearInterval(buddyListInterval);
173+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
124174 });
125175
126176 // Also handle SIGTERM and SIGINT
127177 process.on('SIGTERM', () => {
128178 if (timeoutInterval) clearInterval(timeoutInterval);
129179 if (buddyListInterval) clearInterval(buddyListInterval);
180+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
130181 process.exit(0);
131182 });
132183
133184 process.on('SIGINT', () => {
134185 if (timeoutInterval) clearInterval(timeoutInterval);
135186 if (buddyListInterval) clearInterval(buddyListInterval);
187+ if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
136188 process.exit(0);
137189 });
138190 }
@@ -160,29 +212,42 @@ export const GET: RequestHandler = async ({ request, locals, }) => {
160212
161213 const now = Date.now();
162214
163- try {
164- await db.update(users)
165- .set({
166- status: 'online',
167- lastSeen: now
168- })
169- .where(eq(users.id, userId));
170-
171- log.info('User status updated to online', {
172- userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,
173- timestamp: new Date(now).toISOString()
174- });
215+ // Only update status if this is a new connection
216+ const isNewConnection = sseConnectionTracker.addConnection(userId);
217+
218+ if (isNewConnection) {
219+ try {
220+ await db.update(users)
221+ .set({
222+ status: 'online',
223+ lastSeen: now
224+ })
225+ .where(eq(users.id, userId));
175226
176- sseEmitter.emit('sse', {
177- type: 'userStatusUpdate',
178- data: {
179- userId,
180- status: 'online',
181- lastSeen: now
182- }
227+ log.info('User status updated to online', {
228+ userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,
229+ timestamp: new Date(now).toISOString()
230+ });
231+
232+ // Invalidate buddy list cache when user status changes
233+ buddyListCache.invalidate();
234+
235+ sseEmitter.emit('sse', {
236+ type: 'userStatusUpdate',
237+ data: {
238+ userId,
239+ status: 'online',
240+ lastSeen: now
241+ }
242+ });
243+ } catch (error) {
244+ log.error('Error updating user status', { error });
245+ sseConnectionTracker.removeConnection(userId);
246+ }
247+ } else {
248+ log.debug('User already has active SSE connection, skipping status update', {
249+ userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`
183250 });
184- } catch (error) {
185- log.error('Error updating user status', { error });
186251 }
187252
188253 const stream = new ReadableStream({
@@ -225,19 +290,28 @@ export const GET: RequestHandler = async ({ request, locals, }) => {
225290 const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;
226291 log.info('Connection closed', { userId: maskedUserId, reason });
227292
228- const now = Date.now();
229- await db.update(users)
230- .set({ status: 'offline', lastSeen: now })
231- .where(eq(users.id, userId));
293+ // Remove from connection tracker
294+ const wasTracked = sseConnectionTracker.removeConnection(userId);
232295
233- sseEmitter.emit('sse', {
234- type: 'userStatusUpdate',
235- data: {
236- userId,
237- status: 'offline',
238- lastSeen: now
239- }
240- });
296+ // Only update database if this was the last connection
297+ if (wasTracked && !sseConnectionTracker.hasConnection(userId)) {
298+ const now = Date.now();
299+ await db.update(users)
300+ .set({ status: 'offline', lastSeen: now })
301+ .where(eq(users.id, userId));
302+
303+ // Invalidate buddy list cache when user goes offline
304+ buddyListCache.invalidate();
305+
306+ sseEmitter.emit('sse', {
307+ type: 'userStatusUpdate',
308+ data: {
309+ userId,
310+ status: 'offline',
311+ lastSeen: now
312+ }
313+ });
314+ }
241315 }
242316 });
243317
M src/routes/api/status/+server.ts +4 -0
@@ -5,6 +5,7 @@ import { validateSessionToken, generateSessionToken, createSession } from '$lib/
55 import { setSessionTokenCookie } from '$lib/api/session.cookie';
66 import { createSafeUser } from '$lib/types/chat';
77 import { createLogger } from '$lib/utils/logger.server';
8+import { buddyListCache } from '$lib/buddyListCache';
89 import type { RequestHandler } from './$types';
910
1011 const log = createLogger('status-server');
@@ -46,6 +47,9 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
4647 .returning()
4748 .get();
4849 log.debug('Database updated successfully', { userId: maskedUserId, status });
50+
51+ // Invalidate buddy list cache when status changes
52+ buddyListCache.invalidate();
4953
5054 // Renew session if status is online and session expires in less than 1 day
5155 if (status === 'online') {
A static/_headers +18 -0
@@ -0,0 +1,18 @@
1+# Cache configuration for static assets
2+
3+# Font files - cache for 1 year (31536000 seconds)
4+/fonts/*
5+ Cache-Control: public, max-age=31536000, immutable
6+ Access-Control-Allow-Origin: *
7+ Cross-Origin-Resource-Policy: cross-origin
8+
9+# General static assets - cache for 1 month (2592000 seconds)
10+/favicon.png
11+ Cache-Control: public, max-age=2592000
12+
13+# Icons and images - cache for 1 month
14+/aim/*
15+ Cache-Control: public, max-age=2592000
16+
17+/desktop/*
18+ Cache-Control: public, max-age=2592000⏎̸
A static/fonts/Arial.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Arialbd.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Arialbi.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Ariali.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Comic.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Comicbd.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/LiberationMono-Bold.ttf +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/LiberationMono-BoldItalic.ttf +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/LiberationMono-Italic.ttf +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/LiberationMono-Regular.ttf +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Times.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Timesbd.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Timesbi.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Timesi.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Verdana.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Verdanab.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Verdanai.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/Verdanaz.TTF +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/tahoma.ttf +0 -0 binary file: no text diff

binary file: no text diff

A static/fonts/tahomabd.ttf +0 -0 binary file: no text diff

binary file: no text diff

A test-gradient-fixes.md +49 -0
@@ -0,0 +1,49 @@
1+# Gradient and Text Formatting Fixes Summary
2+
3+## Issues Fixed:
4+
5+### 1. Color Picker Button Gradient Preview
6+**Problem**: The color picker button wasn't showing gradient preview correctly.
7+**Fix**: Updated `displayStyle` in `color-picker-256.svelte` to use consistent `background` property instead of `background-color`.
8+
9+### 2. Text Input Gradient Display
10+**Problem**: Text input only showed the first color of a gradient.
11+**Fix**: This is by design to preserve the input background. Added a visual gradient indicator next to the input field in `chat-room.svelte` when gradient mode is active.
12+
13+### 3. Chat Messages Formatting
14+**Problem**: Messages weren't displaying with user-specific fonts, styles and colors.
15+**Fixes**:
16+- Updated `sendMessage` function to accept and pass `textStyle` parameter
17+- Added `styleData` field to `SendMessageRequest` payload type
18+- Updated server-side message handler to save `styleData` and `hasFormatting` fields
19+- Fixed `formatted-message.svelte` to properly handle gradient styles
20+- Updated text formatter to apply styles correctly to paragraph elements
21+
22+## Files Modified:
23+
24+1. `/src/lib/components/color-picker-256.svelte` - Fixed gradient preview display
25+2. `/src/lib/components/chat-room.svelte` - Added textStyle to sendMessage call and gradient indicator
26+3. `/src/lib/components/formatted-message.svelte` - Fixed style application for gradients
27+4. `/src/lib/utils/text-formatter.ts` - Fixed style application to all text elements
28+5. `/src/lib/states/chat.svelte.ts` - Added textStyle parameter to sendMessage
29+6. `/src/lib/types/payloads.ts` - Added styleData to SendMessageRequest
30+7. `/src/routes/api/chat/messages/+server.ts` - Added styleData handling in message creation
31+
32+## How It Works Now:
33+
34+1. When a user selects colors/gradients and formatting options in the toolbar, the `currentTextStyle` state is updated
35+2. When sending a message, the style data is serialized and sent along with the message content
36+3. The server saves the style data with the message in the database
37+4. When messages are displayed, the `FormattedMessage` component applies the saved styles
38+5. Gradients are rendered character-by-character for the classic AOL effect
39+6. The input field shows a gradient indicator bar when gradient mode is active
40+
41+## Testing:
42+
43+To test the fixes:
44+1. Open the chat window
45+2. Select a gradient using the color picker (add 2-3 colors)
46+3. Choose a font and adjust size
47+4. Type a message - you should see the first gradient color in the input
48+5. Send the message - it should display with the full gradient effect
49+6. Other users should see your message with your chosen formatting⏎̸

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close