Skip to content

Commit f5e89d7

Browse files
benvinegarclaude
andauthored
feat(viewer): full-page standalone view for surface direct links (#132)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4822f77 commit f5e89d7

6 files changed

Lines changed: 332 additions & 200 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"sideshow": minor
3+
---
4+
5+
Direct links to a surface open a full-page standalone view again. Visiting a bare `/s/:id` URL now shows just that one surface — its title and parts, no sidebar, session feed, or comment thread — with a small "made with sideshow" watermark beneath it, instead of resolving the link into its session's stream. The parts still render in the same sandboxed iframes the board uses (sized by the same resize bridge), and the link keeps its canonical `/s/:id` URL. Link-preview metadata from the bare route is unchanged.

e2e/url-routing.spec.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -161,24 +161,26 @@ test("scrolling through surfaces updates the URL", async ({ page, server }) => {
161161
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}/s/${s1.id}$`));
162162
});
163163

164-
test("/s/:id bare surface route opens the viewer focused on that surface", async ({
164+
test("/s/:id bare surface route shows the standalone full-page surface", async ({
165165
page,
166166
server,
167167
}) => {
168168
const s = await publish(server.url, { html: "<h2>Standalone</h2>", title: "Solo" });
169169
await page.goto(`${server.url}/s/${s.id}`);
170170

171-
// Bare share links are now trusted viewer pages (with link-preview metadata),
172-
// not top-level sandboxed surface documents.
173-
await expect(page.locator("#sessionList")).toHaveCount(1);
174-
await expect(page.locator(`#sessionList .sess[data-id="${s.sessionId}"]`)).toHaveClass(/sel/);
171+
// A bare direct link is the full-page standalone view: just that one surface,
172+
// no sidebar / session feed / comments, with a sideshow watermark beneath.
173+
await expect(page.locator("#standalone")).toHaveCount(1);
174+
await expect(page.locator("#sessionList")).toHaveCount(0);
175+
await expect(page.locator("#standalone .card[data-id]")).toHaveCount(1);
175176
await expect(page.locator(`.card[data-id="${s.id}"] .card-title`)).toHaveText("Solo");
176-
await expect(page.locator("body > h2")).toHaveCount(0);
177+
// No comment thread chrome in standalone mode.
178+
await expect(page.locator(".card .thread")).toHaveCount(0);
179+
await expect(page.locator(".standalone-foot a")).toHaveAttribute("href", "https://sideshow.sh");
177180

178-
// The viewer may replace the canonical share URL with the session-scoped deep
179-
// link once it resolves the owning session, but the target surface remains in
180-
// the route and focused in the stream.
181-
await expect(page).toHaveURL(new RegExp(`/(?:s/${s.id}|session/${s.sessionId}/s/${s.id})$`));
181+
// It stays on the canonical share URL — it does not rewrite into a
182+
// session-scoped deep link the way the in-feed deep link does.
183+
await expect(page).toHaveURL(new RegExp(`/s/${s.id}$`));
182184

183185
// The authored HTML is still rendered only inside the sandboxed part iframe.
184186
await expect(page.frameLocator(`.card[data-id="${s.id}"] iframe`).locator("h2")).toHaveText(

viewer/src/App.tsx

Lines changed: 147 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
22
import { AgentMark } from "./agentMarks.tsx";
3-
import { api, isReadonly, layoutMode, relTime, sessionLabel, type SessionRow } from "./api.ts";
3+
import {
4+
api,
5+
isReadonly,
6+
layoutMode,
7+
relTime,
8+
sessionLabel,
9+
type SessionRow,
10+
type Surface,
11+
} from "./api.ts";
412
import { host, isShadow, navHostEl, root, SLOTS } from "./host.ts";
513
import { applyFrameHeight, Card, cardEls, frameForSource } from "./Card.tsx";
614
import { renderNotes } from "./notes.ts";
715
import { SessionTimeline } from "./SessionTimeline.tsx";
816
import { activeTheme, initTheme, setTheme, themeOptions } from "./theme.ts";
917
import {
1018
applyRoute,
19+
bootstrap,
1120
checkVersion,
1221
connect,
1322
dismissUpdate,
@@ -18,7 +27,6 @@ import {
1827
navOpen,
1928
nearBottom,
2029
pillTarget,
21-
refreshSessions,
2230
refreshSessionsQuiet,
2331
select,
2432
selectAdjacent,
@@ -29,6 +37,7 @@ import {
2937
setPillTarget,
3038
setUnread,
3139
setViewMode,
40+
standaloneSurface,
3241
streamLoading,
3342
surfaces,
3443
toast,
@@ -73,12 +82,13 @@ export default function App() {
7382
});
7483

7584
onMount(() => {
76-
// Await the first session fetch, then mark the board decided and tell the
77-
// host (onReady). Until then #onboard stays hidden, so neither the empty
78-
// board nor a host's loading overlay flips to real content before we know
79-
// what to show. .catch keeps it unblocking — a failed fetch still resolves
80-
// to the (empty) onboarding board, and the host overlay still clears.
81-
void refreshSessions(host().router.get().surfaceId)
85+
// Await the initial route resolution (the standalone surface fetch, or the
86+
// first session fetch), then mark the board decided and tell the host
87+
// (onReady). Until then #onboard stays hidden, so neither the empty board
88+
// nor a host's loading overlay flips to real content before we know what to
89+
// show. .catch keeps it unblocking — a failed fetch still resolves to the
90+
// (empty) onboarding board, and the host overlay still clears.
91+
void bootstrap()
8292
.catch(() => {})
8393
.finally(() => {
8494
setInitialLoaded(true);
@@ -126,9 +136,13 @@ export default function App() {
126136
});
127137

128138
// unseen activity badges the tab title — self-hosted only; an embedding host
129-
// owns its own document title.
139+
// owns its own document title. The standalone page titles itself after the
140+
// surface instead (set below), so don't fight it here.
130141
createEffect(() => {
131-
if (!isShadow()) document.title = unread().size ? `(${unread().size}) sideshow` : "sideshow";
142+
if (isShadow()) return;
143+
const solo = standaloneSurface();
144+
if (solo) document.title = solo.title ? `${solo.title} · sideshow` : "sideshow";
145+
else document.title = unread().size ? `(${unread().size}) sideshow` : "sideshow";
132146
});
133147
// the mobile drawer slides in via a class on the host element (see styles.css
134148
// `body.nav-open`; self-hosted that element is <body>)
@@ -140,108 +154,136 @@ export default function App() {
140154
const sessionGroups = createMemo(() => groupSessions(sessions, new Date()));
141155

142156
return (
143-
<>
144-
<div id="app">
145-
<header class="topbar">
146-
<Show when={!streamMode()}>
147-
<button
148-
class="menu"
149-
id="menuBtn"
150-
aria-label="Show sessions"
151-
onClick={() => setNavOpen(!navOpen())}
152-
>
153-
<span class="dot" id="menuDot" classList={{ show: unread().size > 0 }}></span>
154-
</button>
155-
</Show>
156-
<Brand />
157-
</header>
158-
<Show when={!streamMode()}>
159-
<aside>
160-
<Brand />
161-
<UpdateBanner />
162-
<div id="sessionList">
163-
<For each={sessionGroups()}>
164-
{(group) => (
165-
<>
166-
<div class="sess-group">{group.label}</div>
167-
<For each={group.sessions}>{(s) => <SessionItem session={s} />}</For>
168-
</>
169-
)}
170-
</For>
171-
</div>
172-
<div class="aside-foot">
173-
{/* ThemePicker is a generic feature, not deployment-specific
174-
guidance — it stays engine-owned and works under any host. */}
175-
<Show when={!isReadonly()}>
176-
<ThemePicker />
157+
<Show
158+
when={standaloneSurface()}
159+
keyed
160+
fallback={
161+
<>
162+
<div id="app">
163+
<header class="topbar">
164+
<Show when={!streamMode()}>
165+
<button
166+
class="menu"
167+
id="menuBtn"
168+
aria-label="Show sessions"
169+
onClick={() => setNavOpen(!navOpen())}
170+
>
171+
<span class="dot" id="menuDot" classList={{ show: unread().size > 0 }}></span>
172+
</button>
177173
</Show>
178-
{/* Host-overridable region (SLOTS.asideFoot): the footer's
174+
<Brand />
175+
</header>
176+
<Show when={!streamMode()}>
177+
<aside>
178+
<Brand />
179+
<UpdateBanner />
180+
<div id="sessionList">
181+
<For each={sessionGroups()}>
182+
{(group) => (
183+
<>
184+
<div class="sess-group">{group.label}</div>
185+
<For each={group.sessions}>{(s) => <SessionItem session={s} />}</For>
186+
</>
187+
)}
188+
</For>
189+
</div>
190+
<div class="aside-foot">
191+
{/* ThemePicker is a generic feature, not deployment-specific
192+
guidance — it stays engine-owned and works under any host. */}
193+
<Show when={!isReadonly()}>
194+
<ThemePicker />
195+
</Show>
196+
{/* Host-overridable region (SLOTS.asideFoot): the footer's
179197
instructional links/actions. An embedder projects
180198
deployment-appropriate ones here; the children below are the
181199
self-hosted fallback — shown verbatim when nothing is projected
182200
(and outside a shadow root, where <slot> just renders them). */}
183-
<slot name={SLOTS.asideFoot}>
184-
<a href="/guide" target="_blank">
185-
design guide
186-
</a>{" "}
187-
&nbsp;·&nbsp;{" "}
188-
<a href="/setup" target="_blank">
189-
agent setup
190-
</a>{" "}
191-
<Show when={!isReadonly()}>
192-
&nbsp;·&nbsp;{" "}
193-
<a
194-
href="#"
195-
onClick={(e) => {
196-
e.preventDefault();
197-
setConnectOpen(true);
198-
}}
199-
>
200-
connect Claude Code
201-
</a>
202-
</Show>
203-
</slot>
204-
</div>
205-
</aside>
206-
</Show>
207-
<main
208-
onScroll={() => {
209-
if (nearBottom()) setPillTarget(null);
210-
}}
211-
>
212-
{/* Host-overridable main pane (SLOTS.main). Fallback is the normal
201+
<slot name={SLOTS.asideFoot}>
202+
<a href="/guide" target="_blank">
203+
design guide
204+
</a>{" "}
205+
&nbsp;·&nbsp;{" "}
206+
<a href="/setup" target="_blank">
207+
agent setup
208+
</a>{" "}
209+
<Show when={!isReadonly()}>
210+
&nbsp;·&nbsp;{" "}
211+
<a
212+
href="#"
213+
onClick={(e) => {
214+
e.preventDefault();
215+
setConnectOpen(true);
216+
}}
217+
>
218+
connect Claude Code
219+
</a>
220+
</Show>
221+
</slot>
222+
</div>
223+
</aside>
224+
</Show>
225+
<main
226+
onScroll={() => {
227+
if (nearBottom()) setPillTarget(null);
228+
}}
229+
>
230+
{/* Host-overridable main pane (SLOTS.main). Fallback is the normal
213231
board; an embedder projects a `slot="ss:main"` child to take over the
214232
pane (e.g. a cloud Settings page) while the sidebar stays. */}
215-
<slot name={SLOTS.main}>
216-
<Show when={!streamMode()}>
217-
<Onboard />
218-
</Show>
219-
<SessionView />
220-
</slot>
221-
</main>
222-
</div>
223-
<Show when={!streamMode()}>
224-
<div id="scrim" onClick={() => setNavOpen(false)}></div>
225-
</Show>
226-
<Show when={connectOpen()}>
227-
<ConnectModal onClose={() => setConnectOpen(false)} />
228-
</Show>
229-
<div id="toast" role="status" aria-live="polite" classList={{ show: toastShow() }}>
230-
{toastText()}
231-
</div>
232-
<button
233-
id="newPill"
234-
hidden={pillTarget() === null}
235-
onClick={() => {
236-
const target = pillTarget();
237-
if (target)
238-
cardEls.get(target)?.card.scrollIntoView({ behavior: "smooth", block: "start" });
239-
setPillTarget(null);
240-
}}
241-
>
242-
new surface ↓
243-
</button>
244-
</>
233+
<slot name={SLOTS.main}>
234+
<Show when={!streamMode()}>
235+
<Onboard />
236+
</Show>
237+
<SessionView />
238+
</slot>
239+
</main>
240+
</div>
241+
<Show when={!streamMode()}>
242+
<div id="scrim" onClick={() => setNavOpen(false)}></div>
243+
</Show>
244+
<Show when={connectOpen()}>
245+
<ConnectModal onClose={() => setConnectOpen(false)} />
246+
</Show>
247+
<div id="toast" role="status" aria-live="polite" classList={{ show: toastShow() }}>
248+
{toastText()}
249+
</div>
250+
<button
251+
id="newPill"
252+
hidden={pillTarget() === null}
253+
onClick={() => {
254+
const target = pillTarget();
255+
if (target)
256+
cardEls.get(target)?.card.scrollIntoView({ behavior: "smooth", block: "start" });
257+
setPillTarget(null);
258+
}}
259+
>
260+
new surface ↓
261+
</button>
262+
</>
263+
}
264+
>
265+
{(surface) => <StandaloneView surface={surface} />}
266+
</Show>
267+
);
268+
}
269+
270+
// The full-page view a bare /s/:id direct link lands on: just the one surface,
271+
// no sidebar/session chrome/comments, with a small sideshow watermark beneath
272+
// it. The Card renders in `standalone` mode (title + parts only); its part
273+
// iframes are sized by the same postMessage bridge the board uses (it resolves
274+
// any registered card, so a standalone card sizes identically).
275+
function StandaloneView(props: { surface: Surface }) {
276+
return (
277+
<div id="standalone">
278+
<main class="standalone-main">
279+
<Card surface={props.surface} standalone />
280+
<footer class="standalone-foot">
281+
<a href="https://sideshow.sh" target="_blank" rel="noopener">
282+
made with <strong>sideshow</strong>
283+
</a>
284+
</footer>
285+
</main>
286+
</div>
245287
);
246288
}
247289

0 commit comments

Comments
 (0)