diff --git a/frontend/app/globals.css b/frontend/app/globals.css index f73e80c..0380153 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -266,6 +266,7 @@ background-image: inherit; opacity: 0.7; filter: blur(0.25rem); + pointer-events: none; } .aura-spin::before, diff --git a/frontend/components/best-practices/chat/ChatComposer.tsx b/frontend/components/best-practices/chat/ChatComposer.tsx index e599996..ab51b0e 100644 --- a/frontend/components/best-practices/chat/ChatComposer.tsx +++ b/frontend/components/best-practices/chat/ChatComposer.tsx @@ -25,15 +25,23 @@ export function ChatComposer({ el.style.height = `${Math.min(el.scrollHeight, 112)}px`; }, [question]); - // Focus the textarea after the FollowUpDrawer's slide-in animation completes - // (280ms + 40ms buffer). Replaces autoFocus which fires while the element is - // still off-screen mid-transform — the browser considers the focus attempt - // "done" before the animation finishes, so subsequent clicks don't register. + // Focus the textarea after the FollowUpDrawer's slide-in animation completes. + // Uses double-rAF to wait for the browser to paint the drawer in its final + // position before attempting focus — more reliable than a fixed setTimeout. useEffect(() => { - const timer = setTimeout(() => { - textareaRef.current?.focus({ preventScroll: true }); - }, 320); - return () => clearTimeout(timer); + let raf: number; + let timer: ReturnType; + raf = requestAnimationFrame(() => { + raf = requestAnimationFrame(() => { + timer = setTimeout(() => { + textareaRef.current?.focus({ preventScroll: true }); + }, 50); + }); + }); + return () => { + cancelAnimationFrame(raf); + clearTimeout(timer); + }; }, []); const submit = () => { @@ -42,9 +50,17 @@ export function ChatComposer({ }; return ( -
+
textareaRef.current?.focus()} + >
diff --git a/internal/email/email.go b/internal/email/email.go index 96b735c..a8d2463 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -44,11 +44,11 @@ const LogoDataURI = "data:image/svg+xml;charset=utf-8," + "%3Cpath d='M12 10h4l4 8-4 8h-4l4-8-4-8Z' fill='%23111727'/%3E%3C/svg%3E" func renderLogoHTML(logoURL template.URL) template.HTML { - base := `
` - if len(strings.TrimSpace(string(logoURL))) == 0 { - return template.HTML(base + `
`) + src := string(LogoDataURI) + if len(strings.TrimSpace(string(logoURL))) > 0 { + src = string(logoURL) } - return template.HTML(base + `Koder logo` + `
`) + return template.HTML(`
Koder logo
`) } // PasswordResetData is the data model for the password-reset email. @@ -223,6 +223,13 @@ const StarIconDataURI = "data:image/svg+xml;charset=utf-8," + "%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23D4AF37' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E" + "%3Cpolygon points='12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2'/%3E%3C/svg%3E" +// SmallHeartIconDataURI is a 12×12 inline SVG heart in amber for the like +// badge in the Top Solutions list. Replaces the ♥ HTML entity so the +// icon renders consistently across all email clients (including Outlook). +const SmallHeartIconDataURI = "data:image/svg+xml;charset=utf-8," + + "%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2392400E' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E" + + "%3Cpath d='M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z'/%3E%3C/svg%3E" + // DigestSolution holds the data for a single solution preview in the Best Practices email. type DigestSolution struct { UserName string @@ -282,6 +289,7 @@ func bestPracticesBodyWithIcons() string { s = strings.Replace(s, "{{__HEART_ICON__}}", HeartIconDataURI, 1) s = strings.Replace(s, "{{__SPARKLES_ICON__}}", SparklesIconDataURI, 1) s = strings.Replace(s, "{{__STAR_ICON__}}", StarIconDataURI, 1) + s = strings.Replace(s, "{{__SMALL_HEART_ICON__}}", SmallHeartIconDataURI, -1) return s } @@ -316,7 +324,7 @@ func bestPracticesBody() string { -
+

Introducing Best Practices

@@ -332,21 +340,25 @@ See how top developers solve real problems. Browse community solutions, get AI-p - + - - -
-
{{.SolutionCount}}
-
Solutions
+
+
{{.SolutionCount}}
+
Solutions
+
+
{{.DeveloperCount}}
+
Developers
-
{{.DeveloperCount}}
-
Developers
+
+
{{.TotalLikes}}
+
Likes
-
{{.TotalLikes}}
-
Likes
+
+
{{.GoCount}}
+
Go
-
{{.GoCount}} / {{.PythonCount}}
-
Go / Python
+
+
{{.PythonCount}}
+
Python
@@ -362,7 +374,7 @@ See how top developers solve real problems. Browse community solutions, get AI-p
-
+
Community Solutions
@@ -385,7 +397,7 @@ See how top developers solve real problems. Browse community solutions, get AI-p
-
+
AI-Powered Code Analysis
@@ -408,7 +420,7 @@ See how top developers solve real problems. Browse community solutions, get AI-p diff --git a/internal/email/email_test.go b/internal/email/email_test.go index e244280..33d0c51 100644 --- a/internal/email/email_test.go +++ b/internal/email/email_test.go @@ -103,8 +103,8 @@ func TestRenderPasswordReset_InlineLogoFallback(t *testing.T) { if err != nil { t.Fatalf("render failed: %v", err) } - if !strings.Contains(out, "background-image:url('data:image/svg+xml") { - t.Errorf("expected inline SVG logo fallback, got %q", out) + if !strings.Contains(out, LogoDataURI) { + t.Errorf("expected inline SVG logo data URI fallback in src") } } @@ -177,7 +177,8 @@ func TestRenderBestPractices_ContainsBrandAndStructure(t *testing.T) { "42", // solution count "18", // developer count "156", // total likes - "28 / 14", // Go / Python + "28", // Go count + "14", // Python count "Community Solutions", "AI-Powered Code Analysis", "How to Get Featured", @@ -200,9 +201,25 @@ func TestRenderBestPractices_ContainsBrandAndStructure(t *testing.T) { } } + // No emoji glyphs or heart entities anywhere in the output. if strings.ContainsAny(out, "😀🔒🤖🚀✨🔥") { t.Errorf("rendered email contains emoji characters") } + if strings.Contains(out, "♥") { + t.Errorf("rendered email contains raw ♥ entity (should use SVG img)") + } + + // Inline SVG icons must be present. + for _, icon := range []string{SmallHeartIconDataURI, TrophyIconDataURI, HeartIconDataURI, SparklesIconDataURI, StarIconDataURI} { + if !strings.Contains(out, icon) { + t.Errorf("rendered email missing inline SVG icon data URI (truncated: %s...)", icon[:60]) + } + } + + // Stats bar should have separate Go and Python cells, not "Go / Python". + if strings.Contains(out, "Go / Python") { + t.Errorf("rendered email still has combined 'Go / Python' stat (should be separate cells)") + } } func TestRenderBestPractices_FeatureCardsPresent(t *testing.T) {
-
+
How to Get Featured
@@ -439,7 +451,7 @@ See how top developers solve real problems. Browse community solutions, get AI-p
-♥ {{.Likes}} +{{.Likes}}