Skip to content

Commit e512d57

Browse files
PrateekJannuclaude
andcommitted
feat(ui): denser sidebar, footer theme switcher, fixed-sidebar layout
Sidebar/nav polish across web/desktop + mobile, plus fixes from an adversarial edge-case pass: - Tighter nav rhythm (item gap 4px -> 2px) — items read as one dense list. - Cleaner "delegate" icon (sparkles); Machines gets a distinct stacked-server glyph so it no longer collides with the theme switcher's monitor. - Footer redesigned: a clean full-width System/Light/Dark icon-segmented theme switcher (collapses to a cycle button in the rail) + an account row (email + sign-out icon). Theme control removed from Settings (footer is the single source); ThemeSwitch unit-tested for default/persist/OS-follow/invalid/cycle. - Layout: the shell is locked to the viewport (height 100vh/100dvh, overflow hidden) and only the main column scrolls (.app-main is the scroll container, content in .app-main__inner) — the sidebar + footer are always on screen regardless of content length. Mobile WalletScreen body now scrolls too. - Active item legibility: the pill ring uses --input (not --border) so it is clearly visible in light mode and distinct from a hovered neighbour. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 441b086 commit e512d57

9 files changed

Lines changed: 293 additions & 98 deletions

File tree

apps/mobile/src/screens/WalletScreen.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Wallet: Coasty balance + this month's open-cowork spend, plus sign-out.
33
*/
44
import { useCallback, useEffect, useState } from 'react';
5-
import { StyleSheet, Text, View } from 'react-native';
5+
import { ScrollView, StyleSheet, Text, View } from 'react-native';
66
import { api, ApiError, type WalletDto } from '../api';
77
import { AppButton, ErrorNote, Loading, ScreenTitle } from '../components';
88
import { useAuth } from '../auth';
@@ -33,7 +33,7 @@ export function WalletScreen() {
3333
<ScreenTitle title="Wallet" />
3434
{error !== null ? <ErrorNote message={error} onRetry={() => void load()} /> : null}
3535
{wallet ? (
36-
<View style={styles.body}>
36+
<ScrollView style={styles.scroll} contentContainerStyle={styles.body}>
3737
<View style={styles.card}>
3838
<Text style={styles.cardLabel}>Coasty balance</Text>
3939
<Text style={styles.balance}>{formatCents(wallet.balanceCents)}</Text>
@@ -50,7 +50,7 @@ export function WalletScreen() {
5050
label="Sign out"
5151
onPress={signOut}
5252
/>
53-
</View>
53+
</ScrollView>
5454
) : null}
5555
</View>
5656
);
@@ -60,7 +60,10 @@ const { fontSize, fontWeight } = typography;
6060

6161
const styles = StyleSheet.create({
6262
root: { backgroundColor: colors.bg, flex: 1 },
63-
body: { gap: spacing.md, paddingHorizontal: spacing.lg },
63+
scroll: { flex: 1 },
64+
// Scroll content: keep the gutters/gap, with room at the bottom so the last
65+
// control clears the fixed tab bar on short or landscape viewports.
66+
body: { gap: spacing.md, paddingHorizontal: spacing.lg, paddingBottom: spacing.xl },
6467
card: {
6568
backgroundColor: colors.surface,
6669
borderColor: colors.border,

apps/web/src/App.tsx

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Component, useState, type ReactNode } from 'react';
22
import { Navigate, NavLink, Route, Routes, useLocation } from 'react-router-dom';
33
import {
4-
Button,
54
ErrorState,
65
Icon,
76
Logo,
@@ -12,6 +11,7 @@ import {
1211
} from '@open-cowork/ui';
1312
import type { IconName } from '@open-cowork/ui';
1413
import { useAuth } from './store';
14+
import { ThemeSwitch } from './components/ThemeSwitch';
1515
import { useGlobalFeed } from './useGlobalFeed';
1616
import { LoginPage } from './pages/LoginPage';
1717
import { HomePage } from './pages/HomePage';
@@ -32,10 +32,12 @@ class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error |
3232
if (this.state.error) {
3333
return (
3434
<div role="main" className="app-main">
35-
<ErrorState
36-
message={`Something went wrong: ${this.state.error.message}`}
37-
onRetry={() => this.setState({ error: null })}
38-
/>
35+
<div className="app-main__inner">
36+
<ErrorState
37+
message={`Something went wrong: ${this.state.error.message}`}
38+
onRetry={() => this.setState({ error: null })}
39+
/>
40+
</div>
3941
</div>
4042
);
4143
}
@@ -100,23 +102,35 @@ function Shell({ children }: { children: ReactNode }) {
100102
}
101103
footer={
102104
collapsed ? (
103-
<button
104-
type="button"
105-
className="oc-sidebar__toggle"
106-
onClick={logout}
107-
aria-label="Sign out"
108-
title="Sign out"
109-
>
110-
<Icon name="logout" />
111-
</button>
105+
<>
106+
<ThemeSwitch collapsed />
107+
<button
108+
type="button"
109+
className="oc-sidebar__icon-btn"
110+
onClick={logout}
111+
aria-label="Sign out"
112+
title="Sign out"
113+
>
114+
<Icon name="logout" size={18} />
115+
</button>
116+
</>
112117
) : (
113118
<>
114-
<Text variant="caption" className="oc-sidebar__email">
115-
{user?.email}
116-
</Text>
117-
<Button variant="ghost" size="sm" onClick={logout}>
118-
Sign out
119-
</Button>
119+
<ThemeSwitch />
120+
<div className="oc-sidebar__account">
121+
<Text variant="caption" className="oc-sidebar__email">
122+
{user?.email}
123+
</Text>
124+
<button
125+
type="button"
126+
className="oc-sidebar__icon-btn"
127+
onClick={logout}
128+
aria-label="Sign out"
129+
title="Sign out"
130+
>
131+
<Icon name="logout" size={18} />
132+
</button>
133+
</div>
120134
</>
121135
)
122136
}
@@ -140,9 +154,11 @@ function Shell({ children }: { children: ReactNode }) {
140154
))}
141155
</Sidebar>
142156
<main className="app-main">
143-
<OfflineBanner offline={offline} />
144-
{banner}
145-
{children}
157+
<div className="app-main__inner">
158+
<OfflineBanner offline={offline} />
159+
{banner}
160+
{children}
161+
</div>
146162
</main>
147163
</div>
148164
);

apps/web/src/app.css

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,22 @@
55
.app-shell {
66
display: grid;
77
grid-template-columns: auto 1fr;
8-
min-height: 100vh;
8+
/* Lock the shell to the viewport so the sidebar is always on screen; only the
9+
main column scrolls (see .app-main). dvh tracks the *visible* viewport so
10+
the bottom edge isn't hidden under a mobile browser's URL bar. */
11+
height: 100vh;
12+
height: 100dvh;
13+
overflow: hidden;
914
}
1015
.app-main {
16+
/* The single scroll container. Centres content but scrolls full-width so the
17+
scrollbar sits at the viewport edge, not against the 1100px column. */
18+
min-width: 0;
19+
height: 100%;
20+
overflow-y: auto;
1121
padding: var(--space-5);
22+
}
23+
.app-main__inner {
1224
max-width: var(--layout-max-width);
1325
width: 100%;
1426
margin: 0 auto;
@@ -129,6 +141,7 @@
129141
display: grid;
130142
place-items: center;
131143
min-height: 100vh;
144+
min-height: 100dvh;
132145
padding: var(--space-4);
133146
}
134147
.login-form {
@@ -151,11 +164,6 @@
151164
justify-content: space-between;
152165
gap: var(--space-3);
153166
}
154-
.segmented {
155-
display: inline-flex;
156-
gap: var(--space-2);
157-
flex-wrap: wrap;
158-
}
159167
.metric {
160168
display: inline-flex;
161169
align-items: center;
@@ -177,6 +185,43 @@
177185
font-size: var(--font-size-sm);
178186
}
179187

188+
/* Sidebar-footer theme switcher: a clean full-width icon-segmented control. */
189+
.theme-switch {
190+
display: flex;
191+
gap: 2px;
192+
padding: 2px;
193+
border: 1px solid var(--color-border);
194+
border-radius: var(--radius-md);
195+
background: var(--color-bg);
196+
}
197+
.theme-switch__btn {
198+
flex: 1;
199+
display: inline-flex;
200+
align-items: center;
201+
justify-content: center;
202+
height: 28px;
203+
border: none;
204+
border-radius: calc(var(--radius-md) - 3px);
205+
background: transparent;
206+
color: var(--color-text-muted);
207+
cursor: pointer;
208+
transition:
209+
background-color var(--oc-duration) var(--oc-ease),
210+
color var(--oc-duration) var(--oc-ease);
211+
}
212+
.theme-switch__btn:hover {
213+
color: var(--color-text);
214+
}
215+
.theme-switch__btn[data-active='true'] {
216+
background: var(--secondary);
217+
color: var(--color-text);
218+
box-shadow: inset 0 0 0 1px var(--input);
219+
}
220+
.theme-switch__btn:focus-visible {
221+
outline: var(--focus-ring-width, 2px) solid var(--ring);
222+
outline-offset: 1px;
223+
}
224+
180225
/* Responsive tiers: laptop (default 2-col), tablet (single column for the
181226
side-by-side run grid), phone (sidebar collapses), small phone (tighter
182227
gutters). Breakpoints can't be CSS variables, so they live here. */
@@ -193,6 +238,11 @@
193238
grid-template-columns: 1fr;
194239
grid-template-rows: auto 1fr;
195240
}
241+
/* The theme switcher is a desktop/tablet footer affordance — it would crowd
242+
the phone top bar (and mobile is dark-first anyway). */
243+
.theme-switch {
244+
display: none;
245+
}
196246
}
197247
@media (max-width: 560px) {
198248
.app-main {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useState } from 'react';
2+
import { Icon, type IconName } from '@open-cowork/ui';
3+
import { getThemePref, setThemePref, type ThemePref } from '../theme';
4+
5+
const OPTIONS: ReadonlyArray<{ value: ThemePref; icon: IconName; label: string }> = [
6+
{ value: 'system', icon: 'monitor', label: 'System' },
7+
{ value: 'light', icon: 'sun', label: 'Light' },
8+
{ value: 'dark', icon: 'moon', label: 'Dark' },
9+
];
10+
11+
/**
12+
* Theme control for the sidebar footer. Expanded: a compact icon-segmented
13+
* group (System / Light / Dark). Collapsed rail: a single button cycling
14+
* through the three, showing the current mode's icon. Persists via theme.ts.
15+
*/
16+
export function ThemeSwitch({ collapsed = false }: { collapsed?: boolean }) {
17+
const [pref, setPref] = useState<ThemePref>(() => getThemePref());
18+
const choose = (next: ThemePref) => {
19+
setPref(next);
20+
setThemePref(next);
21+
};
22+
23+
if (collapsed) {
24+
const idx = OPTIONS.findIndex((o) => o.value === pref);
25+
const current = OPTIONS[idx] ?? OPTIONS[0]!;
26+
const next = OPTIONS[(idx + 1) % OPTIONS.length]!;
27+
return (
28+
<button
29+
type="button"
30+
className="oc-sidebar__icon-btn"
31+
onClick={() => choose(next.value)}
32+
aria-label={`Theme: ${current.label}. Switch to ${next.label}.`}
33+
title={`Theme: ${current.label}`}
34+
>
35+
<Icon name={current.icon} size={18} />
36+
</button>
37+
);
38+
}
39+
40+
return (
41+
<div className="theme-switch" role="group" aria-label="Theme">
42+
{OPTIONS.map((o) => (
43+
<button
44+
key={o.value}
45+
type="button"
46+
className="theme-switch__btn"
47+
data-active={pref === o.value}
48+
aria-pressed={pref === o.value}
49+
aria-label={o.label}
50+
title={o.label}
51+
onClick={() => choose(o.value)}
52+
>
53+
<Icon name={o.icon} size={16} />
54+
</button>
55+
))}
56+
</div>
57+
);
58+
}

apps/web/src/pages/SettingsPage.tsx

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
import { useEffect, useState } from 'react';
22
import { Button, Card, ErrorState, Field, Icon, Spinner, Heading, Text } from '@open-cowork/ui';
33
import { getClient, useAuth } from '../store';
4-
import { getThemePref, setThemePref, type ThemePref } from '../theme';
5-
6-
const THEME_OPTIONS: ReadonlyArray<{ value: ThemePref; label: string }> = [
7-
{ value: 'system', label: 'System' },
8-
{ value: 'light', label: 'Light' },
9-
{ value: 'dark', label: 'Dark' },
10-
];
114

125
export function SettingsPage() {
136
const client = getClient();
@@ -19,12 +12,6 @@ export function SettingsPage() {
1912
const [error, setError] = useState<string | null>(null);
2013
const [pending, setPending] = useState(false);
2114
const [saved, setSaved] = useState(false);
22-
const [theme, setTheme] = useState<ThemePref>(() => getThemePref());
23-
24-
const chooseTheme = (pref: ThemePref) => {
25-
setTheme(pref);
26-
setThemePref(pref);
27-
};
2815

2916
useEffect(() => {
3017
void (async () => {
@@ -97,25 +84,6 @@ export function SettingsPage() {
9784
) : null}
9885
</div>
9986
</Card>
100-
<Card>
101-
<Heading level={4}>Appearance</Heading>
102-
<Text variant="muted" as="p">
103-
Theme follows your operating system when set to System.
104-
</Text>
105-
<div className="segmented" role="group" aria-label="Theme">
106-
{THEME_OPTIONS.map((opt) => (
107-
<Button
108-
key={opt.value}
109-
variant={theme === opt.value ? 'primary' : 'secondary'}
110-
size="sm"
111-
aria-pressed={theme === opt.value}
112-
onClick={() => chooseTheme(opt.value)}
113-
>
114-
{opt.label}
115-
</Button>
116-
))}
117-
</div>
118-
</Card>
11987
</>
12088
);
12189
}

apps/web/test/runsAndSettings.test.tsx

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/**
22
* RunsPage (list with local/cloud markers, empty state, error+retry) and
3-
* SettingsPage (loads me, saves the budget via the /api/me/budget PATCH fetch,
4-
* theme toggle flips data-theme).
3+
* SettingsPage (loads me, saves the budget via the /api/me/budget PATCH fetch).
54
*/
65
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
76
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
@@ -182,19 +181,4 @@ describe('SettingsPage', () => {
182181
);
183182
expect(await screen.findByRole('alert')).toHaveTextContent('me failed');
184183
});
185-
186-
it('theme control sets and persists the document data-theme', async () => {
187-
setClientForTests(stubClient());
188-
render(
189-
<MemoryRouter>
190-
<SettingsPage />
191-
</MemoryRouter>,
192-
);
193-
await userEvent.click(await screen.findByRole('button', { name: /^light$/i }));
194-
expect(document.documentElement.dataset.theme).toBe('light');
195-
expect(localStorage.getItem('oc-theme')).toBe('light');
196-
await userEvent.click(screen.getByRole('button', { name: /^dark$/i }));
197-
expect(document.documentElement.dataset.theme).toBe('dark');
198-
expect(localStorage.getItem('oc-theme')).toBe('dark');
199-
});
200184
});

0 commit comments

Comments
 (0)