Skip to content

Commit 0d528e8

Browse files
dorlugasigalCopilot
andcommitted
feat(ui): polished wordmark animation + touchbar shadow fix
- New Wordmark component renders TermBeam as 8 pre-baked Montserrat ExtraBold SVG paths with per-letter stroke-draw → fill animation and jittered timing for an organic reveal. Avoids the multi-subpath artifacts that <text> + stroke-dasharray produces on B/e/a/m glyphs. - Splash component with useMinDuration(1500) gate so the wordmark animation always plays through even when auth resolves in <100ms. - Cleaner SessionsHub header: dropped the >_ icon, version inline next to the wordmark. - App-wide animation primitives (lift-in, pop-in, accent-flash) + reduced-motion safety net + universal button micro-press. - TouchBar: removed overflow:hidden from .row and bumped bottom padding 4px → 8px so per-key box-shadows render properly instead of being clipped against the iOS keyboard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 884d937 commit 0d528e8

26 files changed

Lines changed: 819 additions & 122 deletions

src/frontend/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@dnd-kit/core": "^6.3.1",
1717
"@dnd-kit/sortable": "^10.0.0",
1818
"@dnd-kit/utilities": "^3.2.2",
19+
"@fontsource-variable/montserrat": "^5.2.8",
1920
"@radix-ui/react-dialog": "^1.1.6",
2021
"@use-gesture/react": "^10.3.1",
2122
"@xterm/addon-canvas": "^0.7.0",

src/frontend/src/App.tsx

Lines changed: 27 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { useState, useEffect } from 'react';
22
import { useAuth } from '@/hooks/useAuth';
3+
import { useMinDuration } from '@/hooks/useMinDuration';
34
import LoginPage from '@/components/LoginPage/LoginPage';
45
import SessionsHub from '@/components/SessionsHub/SessionsHub';
56
import { TerminalApp } from '@/components/TerminalApp/TerminalApp';
67
import CodeViewer from '@/components/CodeViewer/CodeViewer';
8+
import { Splash } from '@/components/common/Splash';
9+
import splashStyles from '@/components/common/Splash.module.css';
710
import { useThemeStore } from '@/stores/themeStore';
811
import { usePreferencesStore } from '@/stores/preferencesStore';
912
import { THEMES } from '@/themes/terminalThemes';
@@ -51,6 +54,15 @@ export default function App() {
5154
const { authenticated, passwordRequired, login, loading } = useAuth();
5255
const [path, setPath] = useState(getPath);
5356

57+
/*
58+
* Hold the splash screen for at least 1500ms on cold load so the
59+
* per-letter Keynote-bloom animation (last letter starts at 0.55s,
60+
* 0.85s duration = 1.40s end) plays through with a 100ms beat to read
61+
* the settled wordmark. Without this gate, auth on localhost resolves
62+
* in ~50ms and the user never sees the animation.
63+
*/
64+
const splashElapsed = useMinDuration(1500);
65+
5466
// Hydrate user preferences from the server once we're authenticated. The
5567
// store seeds itself synchronously from localStorage on import so the first
5668
// paint already uses cached prefs; this fetch reconciles with the server.
@@ -81,60 +93,27 @@ export default function App() {
8193
const isTerminalScreen = path === '/terminal' && !codeSessionId;
8294
useChromeColor(isTerminalScreen ? 'terminal' : 'main');
8395

84-
// Still checking auth
85-
if (authenticated === null) {
86-
return (
87-
<div
88-
style={{
89-
display: 'flex',
90-
alignItems: 'center',
91-
justifyContent: 'center',
92-
height: '100vh',
93-
background: 'var(--bg)',
94-
color: 'var(--text)',
95-
}}
96-
>
97-
<div className="spinner" />
98-
</div>
99-
);
96+
// Still checking auth, OR auth done but we haven't yet hit the minimum
97+
// splash duration. The status text changes once auth resolves so the
98+
// splash feels like a real loading sequence instead of a fixed timer.
99+
if (authenticated === null || (authenticated && !splashElapsed)) {
100+
const status = authenticated ? 'Connected' : 'Establishing link';
101+
return <Splash status={status} />;
100102
}
101103

102104
if (!authenticated) {
103105
// No-password mode: server is unreachable — show reconnecting UI instead of login
104106
if (!passwordRequired) {
105107
return (
106-
<div
107-
style={{
108-
display: 'flex',
109-
flexDirection: 'column',
110-
alignItems: 'center',
111-
justifyContent: 'center',
112-
height: '100vh',
113-
gap: '16px',
114-
background: 'var(--bg)',
115-
color: 'var(--text)',
116-
}}
117-
>
118-
<div className="spinner" />
119-
<p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>
120-
Reconnecting to server…
121-
</p>
122-
<button
123-
onClick={() => window.location.reload()}
124-
style={{
125-
marginTop: '8px',
126-
padding: '8px 20px',
127-
background: 'var(--accent)',
128-
color: '#fff',
129-
border: 'none',
130-
borderRadius: '6px',
131-
cursor: 'pointer',
132-
fontSize: '13px',
133-
}}
134-
>
135-
Retry
136-
</button>
137-
</div>
108+
<Splash
109+
size="md"
110+
status="Reconnecting to server"
111+
action={
112+
<button onClick={() => window.location.reload()} className={splashStyles.action}>
113+
Retry
114+
</button>
115+
}
116+
/>
138117
);
139118
}
140119
return <LoginPage onLogin={login} loading={loading} />;

src/frontend/src/components/LoginPage/LoginPage.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, type FormEvent } from 'react';
2+
import { Wordmark } from '@/components/common/Wordmark';
23
import styles from './LoginPage.module.css';
34

45
interface LoginPageProps {
@@ -32,10 +33,7 @@ export default function LoginPage({ onLogin, loading }: LoginPageProps) {
3233
return (
3334
<div className={styles.backdrop}>
3435
<div className={styles.card}>
35-
<div className={styles.logo}>
36-
<span className={styles.logoIcon}>📡</span>
37-
TermBeam
38-
</div>
36+
<Wordmark size="md" />
3937

4038
<form className={styles.form} onSubmit={handleSubmit}>
4139
<input

src/frontend/src/components/SessionsHub/SessionCard.module.css

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
position: relative;
33
overflow: hidden;
44
border-radius: 12px;
5+
/*
6+
* Staggered mount entrance. SessionsHub passes `--stagger-i` (capped at 8)
7+
* so cards cascade in over a max of ~360ms. The animation only runs once
8+
* per card lifecycle — polled list updates don't re-trigger because the
9+
* key (session.id) is stable.
10+
*/
11+
animation: lift-in 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
12+
animation-delay: calc(var(--stagger-i, 0) * 45ms);
513
}
614

715
.deleteBackground {
@@ -44,11 +52,23 @@
4452
user-select: none;
4553
cursor: pointer;
4654
z-index: 1;
47-
transition: border-color 0.15s;
48-
}
49-
50-
.card:hover {
51-
border-color: var(--accent);
55+
transition:
56+
border-color 0.15s,
57+
transform 0.15s ease-out,
58+
box-shadow 0.15s ease-out;
59+
}
60+
61+
/*
62+
* Hover lift only on devices with a real pointer. On touch devices the
63+
* inline `style.transform` set by the swipe handler must own this property,
64+
* and a pseudo-hover from a tap would visually fight the swipe.
65+
*/
66+
@media (hover: hover) and (pointer: fine) {
67+
.card:hover {
68+
border-color: var(--accent);
69+
transform: translateY(-1px);
70+
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
71+
}
5272
}
5373

5474
/* Top row: dot + name + PID */

src/frontend/src/components/SessionsHub/SessionCard.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ interface SessionCardProps {
99
onDelete: (id: string) => void;
1010
revealedId: string | null;
1111
onRevealChange: (id: string | null) => void;
12+
/**
13+
* Position of the card within the list. Drives the staggered mount
14+
* entrance so cards cascade in instead of appearing all at once.
15+
*/
16+
index?: number;
1217
}
1318

1419
function formatActivity(lastActivity: string | number): string {
@@ -90,6 +95,7 @@ export default function SessionCard({
9095
onDelete,
9196
revealedId,
9297
onRevealChange,
98+
index = 0,
9399
}: SessionCardProps) {
94100
const cardRef = useRef<HTMLDivElement>(null);
95101
const touchStartX = useRef(0);
@@ -206,7 +212,10 @@ export default function SessionCard({
206212
const isClean = git?.status?.clean === true;
207213

208214
return (
209-
<div className={styles.wrapper}>
215+
<div
216+
className={styles.wrapper}
217+
style={{ ['--stagger-i' as string]: Math.min(index, 8) }}
218+
>
210219
<button
211220
className={styles.deleteBackground}
212221
onClick={handleDeleteClick}

src/frontend/src/components/SessionsHub/SessionsHub.module.css

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,15 @@
2424

2525
.brand {
2626
display: flex;
27-
flex-direction: column;
28-
align-items: flex-start;
27+
align-items: baseline;
2928
min-width: 0;
3029
flex: 1;
3130
}
3231

3332
.title {
3433
display: flex;
35-
align-items: center;
36-
gap: 8px;
34+
align-items: baseline;
35+
gap: 10px;
3736
font-size: 20px;
3837
font-weight: 700;
3938
color: var(--text);
@@ -45,34 +44,8 @@
4544
max-width: 100%;
4645
}
4746

48-
.brandIcon {
49-
display: inline-flex;
50-
align-items: center;
51-
justify-content: center;
52-
height: 24px;
53-
padding: 0 6px;
54-
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
55-
font-size: 13px;
56-
font-weight: 700;
57-
letter-spacing: -0.02em;
58-
color: var(--accent);
59-
background: color-mix(in srgb, var(--accent) 12%, transparent);
60-
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
61-
border-radius: 6px;
62-
line-height: 1;
63-
}
64-
65-
.accent {
66-
color: var(--accent);
67-
}
68-
69-
.brandName {
70-
white-space: nowrap;
71-
}
72-
7347
.version {
7448
display: inline-block;
75-
margin-top: 2px;
7649
font-size: 11px;
7750
font-weight: 500;
7851
color: var(--text-dim);

src/frontend/src/components/SessionsHub/SessionsHub.tsx

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Session } from '@/types';
77
import UpdateBanner from '@/components/common/UpdateBanner';
88
import TunnelBanner from '@/components/common/TunnelBanner';
99
import SessionCard from './SessionCard';
10+
import { Wordmark } from '@/components/common/Wordmark';
1011
import NewSessionModal from './NewSessionModal';
1112
import ResumeBrowser from '@/components/ResumeBrowser/ResumeBrowser';
1213
import WorkspaceLauncher from '@/components/WorkspaceLauncher/WorkspaceLauncher';
@@ -173,18 +174,13 @@ export default function SessionsHub() {
173174
<header className={styles.header}>
174175
<div className={styles.brand}>
175176
<h1 className={styles.title}>
176-
<span className={styles.brandIcon} aria-hidden="true">
177-
{'>_'}
178-
</span>
179-
<span className={styles.brandName}>
180-
Term<span className={styles.accent}>Beam</span>
181-
</span>
177+
<Wordmark size="sm" animated={false} />
178+
{version ? (
179+
<span className={styles.version} data-testid="hub-version">
180+
v{version}
181+
</span>
182+
) : null}
182183
</h1>
183-
{version ? (
184-
<span className={styles.version} data-testid="hub-version">
185-
v{version}
186-
</span>
187-
) : null}
188184
</div>
189185

190186
<div className={styles.headerActions}>
@@ -234,7 +230,7 @@ export default function SessionsHub() {
234230
</div>
235231
) : sessions.length === 0 ? (
236232
<div className={styles.emptyState} data-testid="empty-state">
237-
<span className={styles.emptyIcon}>📡</span>
233+
<Wordmark size="md" />
238234
<span className={styles.emptyText}>No active sessions</span>
239235
<span className={styles.emptyHint}>
240236
Tap &quot;+ New Session&quot; to create a new terminal session
@@ -261,14 +257,15 @@ export default function SessionsHub() {
261257
data-testid="sessions-list"
262258
data-filter-active={filterActive || undefined}
263259
>
264-
{visibleSessions.map((session) => (
260+
{visibleSessions.map((session, i) => (
265261
<SessionCard
266262
key={session.id}
267263
session={session}
268264
onSelect={navigateToSession}
269265
onDelete={handleDelete}
270266
revealedId={revealedId}
271267
onRevealChange={setRevealedId}
268+
index={i}
272269
/>
273270
))}
274271
</div>

src/frontend/src/components/TabBar/SortableTab.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22
import { useSortable } from '@dnd-kit/sortable';
33
import { CSS } from '@dnd-kit/utilities';
44
import type { ManagedSession } from '@/stores/sessionStore';
@@ -42,6 +42,25 @@ export function SortableTab({
4242
});
4343
const pointerStart = useRef<{ x: number; y: number } | null>(null);
4444

45+
/*
46+
* Signature "connected" beat: when the session transitions from
47+
* disconnected → connected we briefly add `tabFlash` so the tab pulses
48+
* with an accent halo. Skipped on the very first render (initial mount
49+
* already has `pop-in` from the wrapper) and on disconnect transitions.
50+
*/
51+
const prevConnected = useRef(session.connected);
52+
const [flash, setFlash] = useState(false);
53+
54+
useEffect(() => {
55+
if (!prevConnected.current && session.connected) {
56+
setFlash(true);
57+
const t = window.setTimeout(() => setFlash(false), 340);
58+
return () => window.clearTimeout(t);
59+
}
60+
prevConnected.current = session.connected;
61+
return undefined;
62+
}, [session.connected]);
63+
4564
const style: React.CSSProperties = {
4665
transform: CSS.Transform.toString(transform),
4766
transition,
@@ -54,7 +73,7 @@ export function SortableTab({
5473
<div
5574
ref={setNodeRef}
5675
style={style}
57-
className={`${styles.tab} ${isActive ? styles.tabActive : ''} ${isSplit ? styles.tabSplit : ''}`}
76+
className={`${styles.tab} ${isActive ? styles.tabActive : ''} ${isSplit ? styles.tabSplit : ''} ${flash ? styles.tabFlash : ''}`}
5877
data-testid="session-tab"
5978
{...(isActive ? { 'data-active': 'true' } : {})}
6079
{...attributes}

0 commit comments

Comments
 (0)