Skip to content

Commit 3ab7268

Browse files
committed
fix(viewer): collapse the sidebar into a drawer at phone widths
Below 700px the fixed 248px aside left ~120px for the stream and the layout broke. The sidebar now slides in as an off-canvas drawer behind a slim top bar (hamburger with an unread dot, brand, live indicator), the stream takes the full width, and hover-only actions (card open/delete, session delete) stay visible on narrow or touch screens. https://claude.ai/code/session_01SqNrCv6FbdqxLXeaNhjNe6
1 parent fbb0030 commit 3ab7268

3 files changed

Lines changed: 170 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ All notable user-visible changes to this project are documented in this file.
3333

3434
### Fixed
3535

36+
- The viewer layout no longer breaks at phone widths: below 700px the
37+
sidebar collapses into a drawer behind a slim top bar (hamburger toggle,
38+
unread dot), the stream takes the full width, and hover-only actions
39+
(card open/delete, session delete) stay visible on narrow or touch
40+
screens.
3641
- Comments not attached to a snippet (e.g. `sideshow comment` without
3742
`--snippet`) were stored and delivered to agents but never shown in the
3843
viewer; they now render in the session thread.

e2e/viewer.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,31 @@ test("session thread shows snippet-less comments and messages the agent", async
8686
await expect(page.locator("#stream > .card")).toHaveCount(3);
8787
});
8888

89+
test("at phone width the sidebar collapses into a drawer and actions stay visible", async ({
90+
page,
91+
server,
92+
}) => {
93+
await publish(server.url, { html: "<p>m</p>", title: "Mobile", agent: "e2e" });
94+
95+
await page.setViewportSize({ width: 375, height: 667 });
96+
await page.goto(server.url);
97+
98+
// the sidebar is off-canvas and the stream gets the full width
99+
const card = page.locator(".card:not(#sessionThread)");
100+
await expect(card).toBeVisible();
101+
await expect(page.locator("aside")).not.toBeInViewport();
102+
expect((await card.boundingBox())!.width).toBeGreaterThan(300);
103+
104+
// hover-only card actions are always visible at narrow widths
105+
await expect(card.locator(".act.open")).toHaveCSS("opacity", "1");
106+
107+
// the menu button opens the drawer; picking a session closes it again
108+
await page.locator("#menuBtn").click();
109+
await expect(page.locator("aside")).toBeInViewport();
110+
await page.locator(".sess").click();
111+
await expect(page.locator("aside")).not.toBeInViewport();
112+
});
113+
89114
test("version select appears live after an update", async ({ page, server }) => {
90115
const snippet = await publish(server.url, { html: "<p>v1</p>", title: "Doc", agent: "e2e" });
91116

viewer/index.html

Lines changed: 140 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,14 @@
7373
font-weight: 500;
7474
letter-spacing: 0.01em;
7575
}
76-
#live {
76+
.livedot {
7777
width: 7px;
7878
height: 7px;
7979
border-radius: 50%;
8080
background: var(--faint);
8181
transition: background 0.3s;
8282
}
83-
#live.on {
83+
.livedot.on {
8484
background: #4caf78;
8585
}
8686
#sessionList {
@@ -407,6 +407,123 @@
407407
color: var(--text);
408408
}
409409

410+
/* phone widths: the sidebar becomes an off-canvas drawer behind a slim
411+
top bar; the stream keeps the full width */
412+
.topbar {
413+
display: none;
414+
}
415+
#scrim {
416+
display: none;
417+
}
418+
@media (max-width: 700px) {
419+
#app {
420+
flex-direction: column;
421+
}
422+
.topbar {
423+
display: flex;
424+
align-items: center;
425+
gap: 4px;
426+
flex: none;
427+
padding: 8px 10px;
428+
background: var(--panel);
429+
border-bottom: 0.5px solid var(--border);
430+
}
431+
.topbar .brand {
432+
padding: 0;
433+
}
434+
.topbar .menu {
435+
position: relative;
436+
font-size: 17px;
437+
line-height: 1;
438+
color: var(--muted);
439+
background: none;
440+
border: none;
441+
border-radius: 8px;
442+
padding: 6px 9px;
443+
cursor: pointer;
444+
font-family: inherit;
445+
}
446+
.topbar .menu:hover {
447+
color: var(--text);
448+
background: var(--hover);
449+
}
450+
.topbar .menu .dot {
451+
position: absolute;
452+
top: 3px;
453+
right: 4px;
454+
width: 7px;
455+
height: 7px;
456+
border-radius: 50%;
457+
background: var(--accent);
458+
display: none;
459+
}
460+
.topbar .menu .dot.show {
461+
display: block;
462+
}
463+
aside {
464+
position: fixed;
465+
top: 0;
466+
bottom: 0;
467+
left: 0;
468+
z-index: 30;
469+
width: min(280px, 84vw);
470+
transform: translateX(-105%);
471+
transition: transform 0.2s ease;
472+
}
473+
body.nav-open aside {
474+
transform: none;
475+
box-shadow: 0 0 32px rgba(0, 0, 0, 0.25);
476+
}
477+
#scrim {
478+
display: block;
479+
position: fixed;
480+
inset: 0;
481+
z-index: 25;
482+
background: rgba(0, 0, 0, 0.35);
483+
opacity: 0;
484+
pointer-events: none;
485+
transition: opacity 0.2s;
486+
}
487+
body.nav-open #scrim {
488+
opacity: 1;
489+
pointer-events: auto;
490+
}
491+
main {
492+
min-height: 0;
493+
}
494+
.session-head {
495+
padding: 12px 16px 10px;
496+
}
497+
#stream {
498+
padding: 16px 14px 120px;
499+
}
500+
#onboard {
501+
padding: 40px 18px;
502+
}
503+
.card-title {
504+
min-width: 0;
505+
flex: 0 1 auto;
506+
white-space: nowrap;
507+
overflow: hidden;
508+
text-overflow: ellipsis;
509+
}
510+
}
511+
/* narrow or touch: hover-revealed actions must stay reachable */
512+
@media (max-width: 700px), (hover: none) {
513+
.card-head .act {
514+
opacity: 1;
515+
}
516+
.sess .x {
517+
opacity: 1;
518+
}
519+
.sess-title {
520+
padding-right: 44px;
521+
}
522+
.sess .dot {
523+
right: 32px;
524+
}
525+
}
526+
410527
#toast {
411528
position: fixed;
412529
left: 50%;
@@ -435,8 +552,14 @@
435552
</head>
436553
<body>
437554
<div id="app">
555+
<header class="topbar">
556+
<button class="menu" id="menuBtn" aria-label="Show sessions">
557+
<span class="dot" id="menuDot"></span>
558+
</button>
559+
<div class="brand"><span class="livedot"></span>sideshow</div>
560+
</header>
438561
<aside>
439-
<div class="brand"><span id="live"></span>sideshow</div>
562+
<div class="brand"><span class="livedot"></span>sideshow</div>
440563
<div id="sessionList"></div>
441564
<div class="aside-foot">
442565
<a href="/guide" target="_blank">design guide</a> &nbsp;·&nbsp;
@@ -479,6 +602,7 @@ <h2>or try it yourself</h2>
479602
</div>
480603
</main>
481604
</div>
605+
<div id="scrim"></div>
482606
<div id="toast" role="status" aria-live="polite"></div>
483607
<script>
484608
const $ = (id) => document.getElementById(id);
@@ -520,6 +644,15 @@ <h2>or try it yourself</h2>
520644
return s.title || s.agent + " session";
521645
}
522646

647+
function setLive(on) {
648+
for (const el of document.querySelectorAll(".livedot")) el.classList.toggle("on", on);
649+
}
650+
651+
// --- mobile drawer ---
652+
653+
$("menuBtn").onclick = () => document.body.classList.toggle("nav-open");
654+
$("scrim").onclick = () => document.body.classList.remove("nav-open");
655+
523656
// --- sidebar ---
524657

525658
function renderSidebar() {
@@ -577,6 +710,7 @@ <h2>or try it yourself</h2>
577710
}
578711
$("onboard").hidden = state.sessions.length > 0;
579712
$("sessionView").hidden = state.sessions.length === 0;
713+
$("menuDot").classList.toggle("show", state.unread.size > 0);
580714
}
581715

582716
async function refreshSessions() {
@@ -621,6 +755,7 @@ <h2>or try it yourself</h2>
621755
async function select(id) {
622756
state.selected = id;
623757
state.unread.delete(id);
758+
document.body.classList.remove("nav-open");
624759
renderSidebar();
625760
renderSessionHead();
626761
const stream = $("stream");
@@ -818,8 +953,8 @@ <h2>or try it yourself</h2>
818953

819954
function connect() {
820955
const es = new EventSource("/api/events");
821-
es.onopen = () => $("live").classList.add("on");
822-
es.onerror = () => $("live").classList.remove("on");
956+
es.onopen = () => setLive(true);
957+
es.onerror = () => setLive(false);
823958
es.onmessage = async (ev) => {
824959
const e = JSON.parse(ev.data);
825960
if (e.type.startsWith("session-")) {

0 commit comments

Comments
 (0)