Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ All notable user-visible changes to this project are documented in this file.
- After an SSE reconnect the viewer refetches the selected session, so
snippets and comments that arrived during the gap can no longer be
silently missing from a live-looking board.

- The viewer layout no longer breaks at phone widths: below 700px the
sidebar collapses into a drawer behind a slim top bar (hamburger toggle,
unread dot), the stream takes the full width, and hover-only actions
(card open/delete, session delete) stay visible on narrow or touch
screens.
- Comments not attached to a snippet (e.g. `sideshow comment` without
`--snippet`) were stored and delivered to agents but never shown in the
viewer; they now render in the session thread.
Expand Down
25 changes: 25 additions & 0 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,31 @@ test("activity in an unselected session badges the tab title until viewed", asyn
await expect(page).toHaveTitle("sideshow");
});

test("at phone width the sidebar collapses into a drawer and actions stay visible", async ({
page,
server,
}) => {
await publish(server.url, { html: "<p>m</p>", title: "Mobile", agent: "e2e" });

await page.setViewportSize({ width: 375, height: 667 });
await page.goto(server.url);

// the sidebar is off-canvas and the stream gets the full width
const card = page.locator(".card:not(#sessionThread)");
await expect(card).toBeVisible();
await expect(page.locator("aside")).not.toBeInViewport();
expect((await card.boundingBox())!.width).toBeGreaterThan(300);

// hover-only card actions are always visible at narrow widths
await expect(card.locator(".act.open")).toHaveCSS("opacity", "1");

// the menu button opens the drawer; picking a session closes it again
await page.locator("#menuBtn").click();
await expect(page.locator("aside")).toBeInViewport();
await page.locator(".sess").click();
await expect(page.locator("aside")).not.toBeInViewport();
});

test("version select appears live after an update", async ({ page, server }) => {
const snippet = await publish(server.url, { html: "<p>v1</p>", title: "Doc", agent: "e2e" });

Expand Down
145 changes: 140 additions & 5 deletions viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,14 @@
font-weight: 500;
letter-spacing: 0.01em;
}
#live {
.livedot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--faint);
transition: background 0.3s;
}
#live.on {
.livedot.on {
background: #4caf78;
}
#sessionList {
Expand Down Expand Up @@ -426,6 +426,123 @@
color: var(--text);
}

/* phone widths: the sidebar becomes an off-canvas drawer behind a slim
top bar; the stream keeps the full width */
.topbar {
display: none;
}
#scrim {
display: none;
}
@media (max-width: 700px) {
#app {
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
gap: 4px;
flex: none;
padding: 8px 10px;
background: var(--panel);
border-bottom: 0.5px solid var(--border);
}
.topbar .brand {
padding: 0;
}
.topbar .menu {
position: relative;
font-size: 17px;
line-height: 1;
color: var(--muted);
background: none;
border: none;
border-radius: 8px;
padding: 6px 9px;
cursor: pointer;
font-family: inherit;
}
.topbar .menu:hover {
color: var(--text);
background: var(--hover);
}
.topbar .menu .dot {
position: absolute;
top: 3px;
right: 4px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
display: none;
}
.topbar .menu .dot.show {
display: block;
}
aside {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 30;
width: min(280px, 84vw);
transform: translateX(-105%);
transition: transform 0.2s ease;
}
body.nav-open aside {
transform: none;
box-shadow: 0 0 32px rgba(0, 0, 0, 0.25);
}
#scrim {
display: block;
position: fixed;
inset: 0;
z-index: 25;
background: rgba(0, 0, 0, 0.35);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
body.nav-open #scrim {
opacity: 1;
pointer-events: auto;
}
main {
min-height: 0;
}
.session-head {
padding: 12px 16px 10px;
}
#stream {
padding: 16px 14px 120px;
}
#onboard {
padding: 40px 18px;
}
.card-title {
min-width: 0;
flex: 0 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
/* narrow or touch: hover-revealed actions must stay reachable */
@media (max-width: 700px), (hover: none) {
.card-head .act {
opacity: 1;
}
.sess .x {
opacity: 1;
}
.sess-title {
padding-right: 44px;
}
.sess .dot {
right: 32px;
}
}

#toast {
position: fixed;
left: 50%;
Expand Down Expand Up @@ -454,8 +571,14 @@
</head>
<body>
<div id="app">
<header class="topbar">
<button class="menu" id="menuBtn" aria-label="Show sessions">
☰<span class="dot" id="menuDot"></span>
</button>
<div class="brand"><span class="livedot"></span>sideshow</div>
</header>
<aside>
<div class="brand"><span id="live"></span>sideshow</div>
<div class="brand"><span class="livedot"></span>sideshow</div>
<div id="sessionList"></div>
<div class="aside-foot">
<a href="/guide" target="_blank">design guide</a> &nbsp;·&nbsp;
Expand Down Expand Up @@ -498,6 +621,7 @@ <h2>or try it yourself</h2>
</div>
</main>
</div>
<div id="scrim"></div>
<div id="toast" role="status" aria-live="polite"></div>
<button id="newPill" hidden>new snippet ↓</button>
<script>
Expand Down Expand Up @@ -540,6 +664,15 @@ <h2>or try it yourself</h2>
return s.title || s.agent + " session";
}

function setLive(on) {
for (const el of document.querySelectorAll(".livedot")) el.classList.toggle("on", on);
}

// --- mobile drawer ---

$("menuBtn").onclick = () => document.body.classList.toggle("nav-open");
$("scrim").onclick = () => document.body.classList.remove("nav-open");

// --- sidebar ---

function renderSidebar() {
Expand Down Expand Up @@ -598,6 +731,7 @@ <h2>or try it yourself</h2>
$("onboard").hidden = state.sessions.length > 0;
$("sessionView").hidden = state.sessions.length === 0;
document.title = state.unread.size ? `(${state.unread.size}) sideshow` : "sideshow";
$("menuDot").classList.toggle("show", state.unread.size > 0);
}

async function refreshSessions() {
Expand Down Expand Up @@ -643,6 +777,7 @@ <h2>or try it yourself</h2>
state.selected = id;
state.unread.delete(id);
$("newPill").hidden = true;
document.body.classList.remove("nav-open");
renderSidebar();
renderSessionHead();
const stream = $("stream");
Expand Down Expand Up @@ -874,13 +1009,13 @@ <h2>or try it yourself</h2>
const es = new EventSource("/api/events");
let everConnected = false;
es.onopen = async () => {
$("live").classList.add("on");
setLive(true);
// events that fired during a gap are gone for good — refetch so the
// board can't silently go stale while still looking live
if (everConnected) await resyncSelected();
everConnected = true;
};
es.onerror = () => $("live").classList.remove("on");
es.onerror = () => setLive(false);
es.onmessage = async (ev) => {
const e = JSON.parse(ev.data);
// activity the user isn't looking at — other session or hidden tab —
Expand Down
Loading