detached 5f5cd68

feat: add comprehensive text formatting system with gradients and retro fonts

commit
5f5cd68185f1f6e5e1dfa4780305c48e4d0a14fc
author
Kit · · 2025-07-03 19:55 UTC
parent
d5464c3
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
7 files · +1968 -0
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>⏎̸
@@ -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>⏎̸
@@ -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/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>⏎̸
@@ -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>⏎̸
@@ -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/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+}⏎̸
@@ -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+}⏎̸
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+}⏎̸
@@ -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/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+}⏎̸
@@ -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+}⏎̸

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 9

↑↓ moveenter openesc close