Skip to content

Commit ddf46aa

Browse files
dorlugasigalCopilot
andcommitted
feat(ui): configurable splash + mobile tips on empty SessionsHub
Adds a 'Splash screen' toggle under Settings → Notifications & Feedback (default ON). When OFF the brand-animation min-duration gate is bypassed on cold load so the app shell paints immediately. When SessionsHub has zero sessions, a curated random Tip card now appears below the empty-state copy. Tips are aimed at mobile users: TouchBar customization, voice mic, pinch-to-zoom, sharing the tunnel URL, PWA install, workspaces, resume agents, kbd shortcuts, etc. A single tip is pinned per hub mount and a fresh one is picked on next visit. Schema additions: showSplash boolean on both client (preferencesStore) and server (preferences.js sanitize). Defaults to true. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bcd83ee commit ddf46aa

11 files changed

Lines changed: 271 additions & 8 deletions

File tree

packages/site/src/content/docs/customization.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ The **Theme picker** is rendered inline inside the **VIEW** section — tap the
2727

2828
Reach **Settings** from the Tools panel → **Settings…** (or via <kbd>Cmd+,</kbd> / <kbd>Ctrl+,</kbd>). The panel is non-blocking so theme, font and haptics changes are visible against the terminal as you make them.
2929

30-
| Section | What it controls |
31-
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
32-
| **Appearance** | Theme picker (live preview cards) and font-size slider. |
33-
| **Notifications & Feedback** | Browser notifications when a backgrounded command completes, and haptic feedback on TouchBar key presses. |
34-
| **Touch Bar** | Toggle "Start collapsed by default" and open the **Customize keys** editor. |
35-
| **New Session Defaults** | Default folder and default initial command pre-fills used by **+ New session** and by workspace sessions when they don't override. |
36-
| **Workspaces** | List of named workspaces. Mark one as the default startup workspace, edit per-workspace sessions, drag to reorder. |
30+
| Section | What it controls |
31+
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32+
| **Appearance** | Theme picker (live preview cards) and font-size slider. |
33+
| **Notifications & Feedback** | Browser notifications when a backgrounded command completes, haptic feedback on TouchBar key presses, and a **Splash screen** toggle to skip the cold-load brand animation. |
34+
| **Touch Bar** | Toggle "Start collapsed by default" and open the **Customize keys** editor. |
35+
| **New Session Defaults** | Default folder and default initial command pre-fills used by **+ New session** and by workspace sessions when they don't override. |
36+
| **Workspaces** | List of named workspaces. Mark one as the default startup workspace, edit per-workspace sessions, drag to reorder. |
3737

3838
All of the above is persisted to `~/.termbeam/prefs.json` (mode `0o600`) on the server. The browser keeps a `localStorage` cache (`termbeam-prefs`) for instant first paint and offline UX, but the server file is the source of truth.
3939

@@ -115,6 +115,7 @@ The schema is roughly:
115115
"fontSize": 13,
116116
"notifications": false,
117117
"haptics": true,
118+
"showSplash": true,
118119
"defaultFolder": "",
119120
"defaultInitialCommand": "",
120121
"touchBarCollapsed": true,

src/frontend/src/App.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,21 @@ function useChromeColor(screen: 'terminal' | 'main') {
5353
export default function App() {
5454
const { authenticated, passwordRequired, login, loading } = useAuth();
5555
const [path, setPath] = useState(getPath);
56+
const showSplash = usePreferencesStore((s) => s.prefs.showSplash);
5657

5758
/*
5859
* Hold the splash screen for at least 1500ms on cold load so the
5960
* per-letter Keynote-bloom animation (last letter starts at 0.55s,
6061
* 0.85s duration = 1.40s end) plays through with a 100ms beat to read
6162
* the settled wordmark. Without this gate, auth on localhost resolves
6263
* in ~50ms and the user never sees the animation.
64+
*
65+
* When the user has opted out via Settings → Startup → "Show splash
66+
* screen", `splashHoldMs` collapses to 0 so we skip straight through
67+
* to the app shell.
6368
*/
64-
const splashElapsed = useMinDuration(1500);
69+
const splashHoldMs = showSplash ? 1500 : 0;
70+
const splashElapsed = useMinDuration(splashHoldMs);
6571

6672
// Hydrate user preferences from the server once we're authenticated. The
6773
// store seeds itself synchronously from localStorage on import so the first

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,69 @@
138138
color: var(--text-primary);
139139
}
140140

141+
/* ──────────────────────────────────────────────────────────────────
142+
Empty-state Tip Card
143+
Shown only when there are zero sessions. Designed to feel like a
144+
helpful aside, not a banner — reads well on a phone in portrait.
145+
────────────────────────────────────────────────────────────────── */
146+
147+
.tipCard {
148+
position: relative;
149+
display: grid;
150+
grid-template-columns: auto 1fr;
151+
align-items: start;
152+
gap: 12px;
153+
margin-top: 1.5rem;
154+
padding: 14px 16px;
155+
max-width: 340px;
156+
width: 100%;
157+
background: var(--surface);
158+
border: 1px solid var(--border);
159+
border-radius: 12px;
160+
text-align: left;
161+
box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.02) inset;
162+
}
163+
164+
.tipBadge {
165+
position: absolute;
166+
top: -9px;
167+
left: 14px;
168+
padding: 1px 8px;
169+
font-size: 10px;
170+
font-weight: 600;
171+
letter-spacing: 0.06em;
172+
text-transform: uppercase;
173+
background: var(--bg);
174+
color: var(--accent, var(--text-dim));
175+
border: 1px solid var(--border);
176+
border-radius: 999px;
177+
}
178+
179+
.tipIcon {
180+
font-size: 22px;
181+
line-height: 1;
182+
margin-top: 2px;
183+
}
184+
185+
.tipBody {
186+
display: flex;
187+
flex-direction: column;
188+
gap: 4px;
189+
min-width: 0;
190+
}
191+
192+
.tipTitle {
193+
font-size: 0.9rem;
194+
font-weight: 600;
195+
color: var(--text);
196+
}
197+
198+
.tipText {
199+
font-size: 0.82rem;
200+
line-height: 1.4;
201+
color: var(--text-dim);
202+
}
203+
141204
.hubFooter {
142205
position: fixed;
143206
bottom: var(--safe-bottom);

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import NewSessionModal from './NewSessionModal';
1212
import ResumeBrowser from '@/components/ResumeBrowser/ResumeBrowser';
1313
import WorkspaceLauncher from '@/components/WorkspaceLauncher/WorkspaceLauncher';
1414
import FilterBar from './FilterBar';
15+
import { pickRandomTip } from './tips';
1516
import {
1617
EMPTY_FILTER,
1718
deriveFacets,
@@ -125,6 +126,11 @@ export default function SessionsHub() {
125126
);
126127
const filterActive = !isEmptyFilter(filter);
127128

129+
// Pin one tip for the lifetime of this hub mount so it doesn't shuffle
130+
// while the user is looking at the empty state. A fresh tip is picked
131+
// on the next visit / refresh.
132+
const tip = useMemo(() => pickRandomTip(), []);
133+
128134
function navigateToSession(id: string) {
129135
window.history.pushState(null, '', `/terminal?session=${id}`);
130136
window.dispatchEvent(new PopStateEvent('popstate'));
@@ -235,6 +241,17 @@ export default function SessionsHub() {
235241
<span className={styles.emptyHint}>
236242
Tap &quot;+ New Session&quot; to create a new terminal session
237243
</span>
244+
245+
<div className={styles.tipCard} data-testid="hub-tip">
246+
<span className={styles.tipBadge}>Tip</span>
247+
<span className={styles.tipIcon} aria-hidden="true">
248+
{tip.icon}
249+
</span>
250+
<div className={styles.tipBody}>
251+
<strong className={styles.tipTitle}>{tip.title}</strong>
252+
<span className={styles.tipText}>{tip.body}</span>
253+
</div>
254+
</div>
238255
</div>
239256
) : (
240257
<>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { HUB_TIPS, pickRandomTip } from '../tips';
3+
4+
describe('HUB_TIPS', () => {
5+
it('has at least 8 curated tips', () => {
6+
expect(HUB_TIPS.length).toBeGreaterThanOrEqual(8);
7+
});
8+
9+
it('every tip has icon, title, and body', () => {
10+
for (const tip of HUB_TIPS) {
11+
expect(tip.icon).toBeTruthy();
12+
expect(tip.title).toBeTruthy();
13+
expect(tip.body).toBeTruthy();
14+
expect(tip.body.length).toBeLessThanOrEqual(200);
15+
}
16+
});
17+
18+
it('tips have unique titles (no duplicates)', () => {
19+
const titles = HUB_TIPS.map((t) => t.title);
20+
expect(new Set(titles).size).toBe(titles.length);
21+
});
22+
});
23+
24+
describe('pickRandomTip', () => {
25+
it('returns a deterministic tip when given a seed', () => {
26+
expect(pickRandomTip(0)).toBe(HUB_TIPS[0]);
27+
// 0.9999 * length floors to length-1
28+
expect(pickRandomTip(0.9999)).toBe(HUB_TIPS[HUB_TIPS.length - 1]);
29+
});
30+
31+
it('always returns a valid tip object', () => {
32+
for (let seed = 0; seed < 1; seed += 0.05) {
33+
const tip = pickRandomTip(seed);
34+
expect(HUB_TIPS).toContain(tip);
35+
}
36+
});
37+
38+
it('without a seed returns one of the curated tips', () => {
39+
expect(HUB_TIPS).toContain(pickRandomTip());
40+
});
41+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Mobile-first tips shown in the SessionsHub when the user has zero
3+
* sessions. Curated list — every tip points at a feature that actually
4+
* exists today and is meaningful for someone driving TermBeam from a
5+
* phone or tablet on the go.
6+
*
7+
* Add new tips to the end of the array. Tips with leading icons are
8+
* fine; keep the body short enough to read in one breath.
9+
*/
10+
export interface Tip {
11+
/** 1-3 character emoji or symbol shown to the left. */
12+
icon: string;
13+
/** Short headline (≤ 40 chars works best on a phone). */
14+
title: string;
15+
/** Body. One sentence. No trailing period required. */
16+
body: string;
17+
}
18+
19+
export const HUB_TIPS: Tip[] = [
20+
{
21+
icon: '⌨️',
22+
title: 'TouchBar is your secret weapon',
23+
body: 'Esc, Tab, Ctrl, arrows — all live one tap away on the bar above the keyboard',
24+
},
25+
{
26+
icon: '🎤',
27+
title: 'Talk to your terminal',
28+
body: 'Drop a Mic key into the TouchBar to dictate commands hands-free',
29+
},
30+
{
31+
icon: '🤏',
32+
title: 'Pinch to resize',
33+
body: 'Two-finger pinch on the terminal scales the font — and we remember the size',
34+
},
35+
{
36+
icon: '↕️',
37+
title: 'Stack more keys',
38+
body: 'Drag the TouchBar handle upward to add up to 3 rows for vim, tmux, or your own combos',
39+
},
40+
{
41+
icon: '⚙️',
42+
title: 'Customize every key',
43+
body: 'Open Tools (▦) → Settings → Touch Bar → Customize to set what each key types',
44+
},
45+
{
46+
icon: '🔗',
47+
title: 'Beam this terminal anywhere',
48+
body: 'Hit the share button to copy a URL — open it on any device with the password',
49+
},
50+
{
51+
icon: '📱',
52+
title: 'Add to Home Screen',
53+
body: 'Install TermBeam as a PWA from your browser for full-screen, app-like access',
54+
},
55+
{
56+
icon: '🗂️',
57+
title: 'Save a workspace',
58+
body: 'Group your favorite sessions into a workspace from Tools → Save Workspace, then re-launch with one tap',
59+
},
60+
{
61+
icon: '↺',
62+
title: 'Pick up where you left off',
63+
body: 'Resume Agent reattaches to running Claude Code, Codex, or other agent sessions with full history',
64+
},
65+
{
66+
icon: '⌘K',
67+
title: 'Keyboard-shortcut everything',
68+
body: 'Cmd/Ctrl+K opens the Tools panel, Cmd/Ctrl+, opens Settings — handy with a Bluetooth keyboard',
69+
},
70+
{
71+
icon: '⏯',
72+
title: 'Hold an arrow to repeat',
73+
body: 'Long-press the arrow keys on the TouchBar for fast cursor movement, just like a real keyboard',
74+
},
75+
{
76+
icon: '🛰️',
77+
title: 'Cellular access on the go',
78+
body: 'A persistent dev tunnel lets you reach this terminal from your phone over LTE, no VPN needed',
79+
},
80+
];
81+
82+
/**
83+
* Returns a tip chosen at random from {@link HUB_TIPS}. The optional
84+
* `seed` lets tests pin a deterministic pick. In production we generate
85+
* a fresh seed once per render so the tip stays stable while the user
86+
* looks at it but rotates on the next visit.
87+
*/
88+
export function pickRandomTip(seed: number = Math.random()): Tip {
89+
const idx = Math.floor(seed * HUB_TIPS.length) % HUB_TIPS.length;
90+
return HUB_TIPS[idx]!;
91+
}

src/frontend/src/components/SettingsPanel/SettingsPanel.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,20 @@ export default function SettingsPanel() {
424424
onChange={(v) => setPreference('haptics', v)}
425425
/>
426426
</div>
427+
428+
<div className={styles.row}>
429+
<span className={styles.rowLabel}>
430+
Splash screen
431+
<span className={styles.rowHint}>
432+
Show the brand animation on cold load. Turn off to jump straight to the app.
433+
</span>
434+
</span>
435+
<Toggle
436+
on={prefs.showSplash}
437+
ariaLabel="Toggle splash screen"
438+
onChange={(v) => setPreference('showSplash', v)}
439+
/>
440+
</div>
427441
</section>
428442

429443
{/* ── Touch Bar ─────────────────────────────────────────── */}

src/frontend/src/stores/__tests__/preferencesStore.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,18 @@ describe('usePreferencesStore', () => {
7272
expect(raw).toBeTruthy();
7373
expect(JSON.parse(raw!).fontSize).toBe(18);
7474
});
75+
76+
it('seeds showSplash to true by default', () => {
77+
const { prefs } = usePreferencesStore.getState();
78+
expect(prefs.showSplash).toBe(true);
79+
expect(PREF_DEFAULTS.showSplash).toBe(true);
80+
});
81+
82+
it('round-trips showSplash through setPreference', () => {
83+
const { setPreference } = usePreferencesStore.getState();
84+
setPreference('showSplash', false);
85+
expect(usePreferencesStore.getState().prefs.showSplash).toBe(false);
86+
setPreference('showSplash', true);
87+
expect(usePreferencesStore.getState().prefs.showSplash).toBe(true);
88+
});
7589
});

src/frontend/src/stores/preferencesStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ export interface Preferences {
138138
fontSize: number;
139139
notifications: boolean;
140140
haptics: boolean;
141+
/** When false, the brand splash animation is skipped on cold load and the
142+
* user goes straight to the app shell. Default: true. */
143+
showSplash: boolean;
141144
defaultFolder: string;
142145
defaultInitialCommand: string;
143146
touchBarCollapsed: boolean;
@@ -159,6 +162,7 @@ export const PREF_DEFAULTS: Preferences = Object.freeze({
159162
// the Notifications toggle doesn't flip on first hydrate.
160163
notifications: false,
161164
haptics: true,
165+
showSplash: true,
162166
defaultFolder: '',
163167
defaultInitialCommand: '',
164168
touchBarCollapsed: false,
@@ -197,6 +201,7 @@ function normalize(input: unknown): Preferences {
197201
fontSize: clampFont(typeof p.fontSize === 'number' ? p.fontSize : PREF_DEFAULTS.fontSize),
198202
notifications: typeof p.notifications === 'boolean' ? p.notifications : PREF_DEFAULTS.notifications,
199203
haptics: typeof p.haptics === 'boolean' ? p.haptics : PREF_DEFAULTS.haptics,
204+
showSplash: typeof p.showSplash === 'boolean' ? p.showSplash : PREF_DEFAULTS.showSplash,
200205
defaultFolder: typeof p.defaultFolder === 'string' ? p.defaultFolder : '',
201206
defaultInitialCommand:
202207
typeof p.defaultInitialCommand === 'string' ? p.defaultInitialCommand : '',

src/server/preferences.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const DEFAULTS = Object.freeze({
1616
fontSize: 14,
1717
notifications: false,
1818
haptics: true,
19+
showSplash: true,
1920
defaultFolder: '',
2021
defaultInitialCommand: '',
2122
touchBarCollapsed: false,
@@ -188,6 +189,7 @@ function sanitize(prefs) {
188189
fontSize: clampNumber(src.fontSize, FONT_MIN, FONT_MAX, DEFAULTS.fontSize),
189190
notifications: asBool(src.notifications, DEFAULTS.notifications),
190191
haptics: asBool(src.haptics, DEFAULTS.haptics),
192+
showSplash: asBool(src.showSplash, DEFAULTS.showSplash),
191193
defaultFolder: asString(src.defaultFolder, DEFAULTS.defaultFolder, 1024),
192194
defaultInitialCommand: asString(
193195
src.defaultInitialCommand,

0 commit comments

Comments
 (0)