From 3e8daba3116794e7281e5c88916b8a1481ef008c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:54:37 +0000 Subject: [PATCH] fix(ux): homepage UX improvements for issue #115 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove static hero-explainer tagline (duplicated chat greeting) - Remove name-initials avatar; keep avatar only when a photo is provided - Remove JSON Resume credit from footer - Remove "clear conversation" button from ChatPanel - Reduce MAX_RESPONSE_CHARS 2000→800 for conversational brevity - Slow DRIP_TICK_MS 18→40ms for comfortable reading pace - Add visualViewport resize/scroll listener writing --keyboard-inset CSS variable so iOS Safari keyboard no longer hides the chat input - Update global.css .resume-viewport height to respect --keyboard-inset - Update tests: Hero (explainer + initials), ChatPanel (clear button + truncation), App (explainer) — 105 tests passing Co-Authored-By: Claude Sonnet 4.6 --- web/src/__tests__/App.test.tsx | 1 - web/src/__tests__/ChatPanel.test.tsx | 44 +++++----------------------- web/src/__tests__/Hero.test.tsx | 8 ++--- web/src/components/ChatPanel.css | 20 ------------- web/src/components/ChatPanel.tsx | 38 +++++++++++------------- web/src/components/Footer.tsx | 5 ---- web/src/components/Hero.css | 1 - web/src/components/Hero.tsx | 20 ++++--------- web/src/styles/global.css | 2 +- 9 files changed, 34 insertions(+), 105 deletions(-) diff --git a/web/src/__tests__/App.test.tsx b/web/src/__tests__/App.test.tsx index d8b9908..a96b6aa 100644 --- a/web/src/__tests__/App.test.tsx +++ b/web/src/__tests__/App.test.tsx @@ -29,7 +29,6 @@ describe('App landing', () => { it('renders Hero and ChatPanel, not ResumeTheme or GithubActivity', async () => { render(); await waitFor(() => expect(screen.getByText('Verky Yi')).toBeInTheDocument()); - expect(screen.getByText(/This page is an agent/i)).toBeInTheDocument(); expect(screen.getByLabelText('Chat')).toBeInTheDocument(); expect(screen.queryByTestId('resume-theme')).not.toBeInTheDocument(); expect(screen.queryByTestId('github-activity')).not.toBeInTheDocument(); diff --git a/web/src/__tests__/ChatPanel.test.tsx b/web/src/__tests__/ChatPanel.test.tsx index 5ad69f1..6474d13 100644 --- a/web/src/__tests__/ChatPanel.test.tsx +++ b/web/src/__tests__/ChatPanel.test.tsx @@ -188,21 +188,6 @@ describe('ChatPanel — inline default state', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('reset link appears only when messages exist', async () => { - vi.stubEnv('VITE_CHAT_PROXY_URL', 'https://proxy.example'); - sessionStorage.setItem( - 'agentfolio.chat.anthropic-fde-nyc', - JSON.stringify([{ role: 'assistant', segments: [{ kind: 'text', text: 'hi again' }] }]), - ); - render(); - expect(screen.getByRole('button', { name: /clear conversation/i })).toBeInTheDocument(); - }); - - it('reset link is hidden when there are no messages', () => { - vi.stubEnv('VITE_CHAT_PROXY_URL', 'https://proxy.example'); - render(); - expect(screen.queryByRole('button', { name: /clear conversation/i })).not.toBeInTheDocument(); - }); }); describe('ChatPanel — streaming send', () => { @@ -310,18 +295,6 @@ describe('ChatPanel — persistence + reset', () => { expect(screen.getByText('Welcome back')).toBeInTheDocument(); }); - it('reset clears messages and sessionStorage', async () => { - vi.stubEnv('VITE_CHAT_PROXY_URL', 'https://proxy.example'); - sessionStorage.setItem( - 'agentfolio.chat.anthropic-fde-nyc', - JSON.stringify([{ role: 'assistant', segments: [{ kind: 'text', text: 'old' }] }]), - ); - const user = userEvent.setup(); - render(); - await user.click(screen.getByRole('button', { name: /clear conversation/i })); - expect(screen.queryByText('old')).not.toBeInTheDocument(); - expect(sessionStorage.getItem('agentfolio.chat.anthropic-fde-nyc')).toBeNull(); - }); }); describe('ChatPanel — error handling', () => { @@ -355,11 +328,10 @@ describe('ChatPanel — UX optimizations', () => { it('caps response length and appends a truncation indicator', async () => { vi.stubEnv('VITE_CHAT_PROXY_URL', 'https://proxy.example'); - // Stream > 2000 chars across two deltas so truncation fires mid-stream. - const huge = 'x'.repeat(1500); + // Stream > 800 chars across two deltas so truncation fires mid-stream. const fetchMock = vi.fn(async () => sseResponse([ - `event: text\ndata: ${JSON.stringify({ delta: huge })}\n\n`, - `event: text\ndata: ${JSON.stringify({ delta: huge })}\n\n`, + `event: text\ndata: ${JSON.stringify({ delta: 'x'.repeat(900) })}\n\n`, + `event: text\ndata: ${JSON.stringify({ delta: 'x'.repeat(100) })}\n\n`, 'event: done\ndata: {}\n\n', ])); vi.stubGlobal('fetch', fetchMock); @@ -369,15 +341,15 @@ describe('ChatPanel — UX optimizations', () => { await user.click(screen.getByRole('button', { name: /send/i })); // Drip animation finishes and the truncation suffix appears. - await screen.findByText(/response truncated/i, {}, { timeout: 4000 }); + await screen.findByText(/response truncated/i, {}, { timeout: 10000 }); const body = document.querySelector( '.chatp-msg.assistant:not(.chatp-greeting) .chatp-msg-body', ) as HTMLElement | null; expect(body).not.toBeNull(); - // 2000 x's + suffix. Never more than 2000 x's. - expect(body!.textContent!.length).toBeLessThanOrEqual(2000 + ' … (response truncated)'.length); - expect(body!.textContent).toMatch(/^x{2000}/); - }); + // 800 x's + suffix. Never more than 800 x's. + expect(body!.textContent!.length).toBeLessThanOrEqual(800 + ' … (response truncated)'.length); + expect(body!.textContent).toMatch(/^x{800}/); + }, 12000); it('shows a streaming class on the active assistant bubble and drops it when done', async () => { vi.stubEnv('VITE_CHAT_PROXY_URL', 'https://proxy.example'); diff --git a/web/src/__tests__/Hero.test.tsx b/web/src/__tests__/Hero.test.tsx index a8a2692..643e5e2 100644 --- a/web/src/__tests__/Hero.test.tsx +++ b/web/src/__tests__/Hero.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { Hero } from '../components/Hero'; describe('Hero', () => { - it('renders name, tagline, and explainer', () => { + it('renders name and tagline', () => { render( { ); expect(screen.getByText('Verky Yi')).toBeInTheDocument(); expect(screen.getByText(/Product engineer building AI-native tools/)).toBeInTheDocument(); - expect(screen.getByText(/This page is an agent/i)).toBeInTheDocument(); }); - it('falls back to initials when no image', () => { + it('avatar is absent when no image', () => { render(); - expect(screen.getByTestId('hero-avatar')).toHaveTextContent('VY'); + expect(screen.queryByTestId('hero-avatar')).not.toBeInTheDocument(); }); it('renders without tagline', () => { render(); expect(screen.getByText('Verky Yi')).toBeInTheDocument(); - expect(screen.getByText(/This page is an agent/i)).toBeInTheDocument(); }); }); diff --git a/web/src/components/ChatPanel.css b/web/src/components/ChatPanel.css index 8930e55..339e830 100644 --- a/web/src/components/ChatPanel.css +++ b/web/src/components/ChatPanel.css @@ -20,26 +20,6 @@ } } -.chatp-header { - display: flex; - justify-content: flex-end; - align-items: center; - font-size: 10px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-muted); -} -.chatp-clear { - font-family: inherit; - font-size: 10px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-muted); - padding: 0; - border-bottom: 1px dashed transparent; -} -.chatp-clear:hover { color: var(--accent-green); border-bottom-color: var(--accent-green); } - .chatp-messages { flex: 1; overflow-y: auto; diff --git a/web/src/components/ChatPanel.tsx b/web/src/components/ChatPanel.tsx index 8b88cef..98c9bfa 100644 --- a/web/src/components/ChatPanel.tsx +++ b/web/src/components/ChatPanel.tsx @@ -32,9 +32,9 @@ const DEFAULT_SUGGESTIONS = [ ]; // UX tuning for streamed responses. -const MAX_RESPONSE_CHARS = 2000; +const MAX_RESPONSE_CHARS = 800; const TRUNCATION_SUFFIX = '… (response truncated)'; -const DRIP_TICK_MS = 18; +const DRIP_TICK_MS = 40; const DRIP_BASE_CHARS_PER_TICK = 1; // Keep visible lag bounded: if the buffer runs ahead, reveal extra chars per tick. const DRIP_BACKLOG_DIVISOR = 40; @@ -157,6 +157,21 @@ export function ChatPanel({ slug, ownerName, tagline, email, profiles, greeting, abortRef.current?.abort(); if (dripRef.current !== null) window.clearInterval(dripRef.current); }, []); + useEffect(() => { + const vv = window.visualViewport; + if (!vv) return; + const update = () => { + const inset = Math.max(0, window.innerHeight - (vv.offsetTop + vv.height)); + document.documentElement.style.setProperty('--keyboard-inset', inset + 'px'); + }; + vv.addEventListener('resize', update); + vv.addEventListener('scroll', update); + return () => { + vv.removeEventListener('resize', update); + vv.removeEventListener('scroll', update); + document.documentElement.style.removeProperty('--keyboard-inset'); + }; + }, []); useEffect(() => { if (messages.length === 0) sessionStorage.removeItem(storageKey); else sessionStorage.setItem(storageKey, JSON.stringify(messages)); @@ -189,17 +204,6 @@ export function ChatPanel({ slug, ownerName, tagline, email, profiles, greeting, ); } - function reset() { - abortRef.current?.abort(); - if (dripRef.current !== null) { - window.clearInterval(dripRef.current); - dripRef.current = null; - } - setMessages([]); - setStatus('idle'); - sessionStorage.removeItem(storageKey); - } - function pickSuggestion(text: string) { setDraft(text); inputRef.current?.focus(); @@ -331,14 +335,6 @@ export function ChatPanel({ slug, ownerName, tagline, email, profiles, greeting, return (
- {messages.length > 0 && ( -
- -
- )} -
> diff --git a/web/src/components/Footer.tsx b/web/src/components/Footer.tsx index ab226c7..0ead332 100644 --- a/web/src/components/Footer.tsx +++ b/web/src/components/Footer.tsx @@ -34,11 +34,6 @@ export function Footer() { AgentFolio - {' · '} - Resume schema:{' '} - - JSON Resume - ); } diff --git a/web/src/components/Hero.css b/web/src/components/Hero.css index 1f387b7..32b9e57 100644 --- a/web/src/components/Hero.css +++ b/web/src/components/Hero.css @@ -24,4 +24,3 @@ .hero-avatar img { width: 100%; height: 100%; object-fit: cover; } .hero-name { margin: 0; font-size: 22px; font-weight: 600; } .hero-tagline { margin: 4px 0 0; font-size: 14px; opacity: 0.72; } -.hero-explainer { margin: 12px 0 0; font-size: 13px; opacity: 0.6; max-width: 420px; } diff --git a/web/src/components/Hero.tsx b/web/src/components/Hero.tsx index 0ec53e2..55ac968 100644 --- a/web/src/components/Hero.tsx +++ b/web/src/components/Hero.tsx @@ -6,26 +6,16 @@ export interface HeroProps { image?: string; } -function initials(name: string): string { - return name - .split(/\s+/) - .filter(Boolean) - .map((p) => p[0]?.toUpperCase() ?? '') - .join('') - .slice(0, 2); -} - export function Hero({ name, tagline, image }: HeroProps) { return (
-
- {image ? {name} : {initials(name)}} -
+ {image && ( +
+ {name} +
+ )}

{name}

{tagline &&

{tagline}

} -

- This page is an agent — ask it anything about my background, projects, or fit for a role. -

); } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index abe4c36..4f54f64 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -62,7 +62,7 @@ body { .resume-viewport { height: 100vh; /* fallback */ - height: 100dvh; + height: calc(100dvh - var(--keyboard-inset, 0px)); overflow: hidden; display: flex; flex-direction: column;