diff --git a/.changeset/wide-columns-stretch.md b/.changeset/wide-columns-stretch.md new file mode 100644 index 0000000..786ba1b --- /dev/null +++ b/.changeset/wide-columns-stretch.md @@ -0,0 +1,5 @@ +--- +"sideshow": minor +--- + +Add an opt-in wide layout: a toggle beside Stream/Timeline lets the feed column grow past 860px (up to 1600px) on wide windows so diffs, code, diagrams and html use the space, while markdown prose stays capped at its normal-column width (code blocks and tables inside markdown go full width), and long lines in markdown code blocks, code, terminal and diff surfaces soft-wrap instead of scrolling sideways. The choice is saved per workspace (`PUT /api/width` with `{"id":"wide"}`), and normal stays the default. diff --git a/e2e/width.spec.ts b/e2e/width.spec.ts new file mode 100644 index 0000000..038790c --- /dev/null +++ b/e2e/width.spec.ts @@ -0,0 +1,138 @@ +import { expect, publishParts, test } from "./fixtures.ts"; +import type { Page } from "@playwright/test"; +import { isSoftWrapSurfaceKind, SURFACE_FRAME_CLASSES, SURFACE_KINDS } from "../server/types.ts"; + +const SOFT_WRAP_KINDS = SURFACE_KINDS.filter(isSoftWrapSurfaceKind); + +// Wide mode (issue #223): a workspace setting that lets the feed column grow past +// its 860px cap on a wide window, with markdown kept at a readable measure. +const LONG = `const value = compute(${"x".repeat(130)});`; // ~150 chars +const PARTS = [ + { + kind: "markdown", + markdown: `${"Some prose that should keep a readable measure. ".repeat(12)}\n\n\`\`\`text\n${LONG}\n\`\`\``, + }, + { kind: "diff", patch: `--- a/x\n+++ b/x\n@@ -1 +1 @@\n-${LONG}\n+${LONG}y` }, +]; + +const streamWidth = (page: Page) => + page.locator("#stream").evaluate((el) => el.getBoundingClientRect().width); +// Rendered widths of the markdown prose paragraph and its code block. +const markdownWidths = (page: Page) => + page + .frameLocator(".card:not(#whatsNew) iframe.mdframe") + .locator("body") + .evaluate(() => ({ + prose: document.querySelector("p")!.getBoundingClientRect().width, + code: document.querySelector("pre")!.getBoundingClientRect().width, + })); +// Widest sideways scroll inside a surface frame (@pierre/diffs renders in shadow roots). +const frameOverflow = (page: Page, frame: string) => + page + .frameLocator(`.card:not(#whatsNew) iframe.${frame}`) + .locator("body") + .evaluate(() => { + let max = 0; + const walk = (root: Document | ShadowRoot) => { + for (const el of root.querySelectorAll("*")) { + if (/(auto|scroll)/.test(getComputedStyle(el).overflowX)) { + max = Math.max(max, el.scrollWidth - el.clientWidth); + } + if (el.shadowRoot) walk(el.shadowRoot); + } + }; + walk(document); + return max; + }) + // a width switch reloads the frame mid-poll; NaN fails both bounds, so poll again + .catch(() => Number.NaN); +const diffOverflow = (page: Page) => frameOverflow(page, "diffframe"); + +test.use({ viewport: { width: 1930, height: 1000 } }); + +test("the wide toggle widens the column, persists, and toggles back to normal", async ({ + page, + server, +}) => { + await publishParts(server.url, { title: "Wide", agent: "e2e", parts: PARTS }); + await page.goto(server.url); + const toggle = page.locator("#widthToggle"); + + // normal (the default): the classic 860px column; the long diff line scrolls + await expect(toggle).toHaveAttribute("aria-pressed", "false"); + expect(await streamWidth(page)).toBe(860); + await expect.poll(() => diffOverflow(page)).toBeGreaterThan(1); + const normalMarkdown = await markdownWidths(page); + expect(normalMarkdown.prose).toBe(normalMarkdown.code); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + await expect.poll(() => streamWidth(page)).toBe(1600); + // the diff now fits; markdown prose keeps its readable measure while its code + // block takes the full width + await expect.poll(() => diffOverflow(page)).toBeLessThanOrEqual(1); + await expect.poll(async () => (await markdownWidths(page)).code).toBeGreaterThan(1200); + expect((await markdownWidths(page)).prose).toBeLessThanOrEqual(780); + + // PUT /api/width persisted it for the workspace + await page.reload(); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + await expect.poll(() => streamWidth(page)).toBe(1600); + + await toggle.click(); + await expect.poll(() => streamWidth(page)).toBe(860); + await expect.poll(() => markdownWidths(page)).toEqual(normalMarkdown); +}); + +test("another open tab follows a width switch live", async ({ page, context, server }) => { + await publishParts(server.url, { title: "Wide", agent: "e2e", parts: PARTS }); + const other = await context.newPage(); + await page.goto(server.url); + await other.goto(server.url); + await expect(other.locator(".card:not(#whatsNew)")).toBeVisible(); + expect(await streamWidth(other)).toBe(860); + + await page.locator("#widthToggle").click(); + await expect.poll(() => streamWidth(other)).toBe(1600); +}); + +test("phone layout is unchanged in wide mode and hides the toggle", async ({ page, server }) => { + await publishParts(server.url, { title: "Wide", agent: "e2e", parts: PARTS }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(server.url); + await expect(page.locator(".card:not(#whatsNew)")).toBeVisible(); + const normal = await streamWidth(page); + + const res = await page.request.put(`${server.url}/api/width`, { data: { id: "wide" } }); + expect(res.ok()).toBe(true); + await page.reload(); + await expect(page.locator(".card:not(#whatsNew)")).toBeVisible(); + await expect(page.locator("#widthToggle")).toBeHidden(); + expect(await streamWidth(page)).toBe(normal); +}); + +test("wide mode soft-wraps lines longer than the column instead of scrolling", async ({ + page, + server, +}) => { + const huge = `${"word ".repeat(400)}end`; // ~2000 chars, wider than any column + await publishParts(server.url, { + title: "Wrap", + agent: "e2e", + parts: [ + { kind: "markdown", markdown: `\`\`\`text\n${huge}\n\`\`\`` }, + { kind: "code", code: `const s = "${huge}";`, language: "ts" }, + { kind: "terminal", text: huge }, + { kind: "diff", patch: `--- a/x\n+++ b/x\n@@ -1 +1 @@\n-${huge}\n+${huge}!` }, + ], + }); + await page.goto(server.url); + const frames = SOFT_WRAP_KINDS.map((kind) => SURFACE_FRAME_CLASSES[kind]!); + + // normal: the long lines scroll sideways, as before + for (const f of frames) await expect.poll(() => frameOverflow(page, f)).toBeGreaterThan(1); + + await page.locator("#widthToggle").click(); + await expect.poll(() => streamWidth(page)).toBe(1600); + for (const f of frames) await expect.poll(() => frameOverflow(page, f)).toBeLessThanOrEqual(1); +}); diff --git a/server/app.ts b/server/app.ts index 23478b4..fa85ab4 100644 --- a/server/app.ts +++ b/server/app.ts @@ -31,10 +31,13 @@ import { type CodeSurface, type Comment, type CommentAnchor, + DEFAULT_WIDTH, type DiffSurface, htmlSurface, isSandboxedSurfaceKind, + isSoftWrapSurfaceKind, reservedAgent, + LAYOUT_WIDTHS, type MarkdownSurface, MAX_ASSET_BYTES, surfacesByteLength, @@ -264,6 +267,7 @@ function isPublicReadAllowed(path: string, mode: PublicReadMode): boolean { if (path === "/api/comments") return true; if (path === "/api/events") return true; if (path === "/api/theme") return true; + if (path === "/api/width") return true; if (path === "/api/version") return true; if (path === "/api/kits") return true; return false; @@ -1038,6 +1042,22 @@ export function createApp({ return c.json({ id }); }); + // --- column width (one workspace-level setting, same shape as theme) --- + + app.get("/api/width", async (c) => { + const id = (await store.getSetting("width")) ?? DEFAULT_WIDTH; + return c.json({ id }); + }); + + app.put("/api/width", async (c) => { + const body = await c.req.json().catch(() => null); + const id = body?.id; + if (!LAYOUT_WIDTHS.includes(id)) return c.json({ error: "unknown width id" }, 400); + await store.setSetting("width", id); + bus.broadcast({ type: "width-changed", id }); + return c.json({ id }); + }); + // --- sessions --- app.get("/api/sessions", async (c) => { @@ -1612,12 +1632,15 @@ export function createApp({ const modeParam = c.req.query("mode"); const mode = modeParam === "light" || modeParam === "dark" ? modeParam : undefined; const origin = new URL(c.req.url).origin; + // Wrap: the viewer's wide mode asks text surfaces to soft-wrap long lines. + // Ignored for other kinds so a stray ?wrap=1 can't duplicate a cache entry. + const wrap = c.req.query("wrap") === "1" && isSoftWrapSurfaceKind(surface.kind); // Cache the finished document. The key pins everything the output depends // on; the resolved `version` makes it immutable, so a hit is always correct. // Versioned + themed requests (what the viewer always sends) are immutable, // so allow long-lived shared caching; an unpinned direct load is not. - const cacheKey = `${post.id}:${idx}:${version}:${themeId}:${mode ?? "os"}`; + const cacheKey = `${post.id}:${idx}:${version}:${themeId}:${mode ?? "os"}${wrap ? ":wrap" : ""}`; const immutable = c.req.query("ver") != null && c.req.query("theme") != null; if (immutable) c.header("Cache-Control", "public, max-age=31536000, immutable"); else c.header("Cache-Control", "private, no-cache"); @@ -1651,14 +1674,15 @@ export function createApp({ // way this optimization could break in production and nowhere else. const { renderCode, renderDiff, renderMarkdown, renderTerminal } = await import("./richRender.ts"); + const opts = { theme: themeId, mode, wrap }; const rendered = surface.kind === "markdown" - ? await renderMarkdown(surface as MarkdownSurface, { theme: themeId, mode }) + ? await renderMarkdown(surface as MarkdownSurface, opts) : surface.kind === "code" - ? await renderCode(surface as CodeSurface, { theme: themeId, mode }) + ? await renderCode(surface as CodeSurface, opts) : surface.kind === "terminal" - ? renderTerminal(surface as TerminalSurface) - : await renderDiff(surface as DiffSurface, { theme: themeId, mode }).catch((e) => ({ + ? renderTerminal(surface as TerminalSurface, opts) + : await renderDiff(surface as DiffSurface, opts).catch((e) => ({ body: `
${ansi}`;
- return { body, css: TERM_CSS };
+ const wrap = opts.wrap ? `.term-body { ${SOFT_WRAP} }` : "";
+ return { body, css: TERM_CSS + wrap };
}
// ---------------------------------------------------------------------------
@@ -300,6 +309,15 @@ pre.shiki code, pre.plain code { background: none; padding: 0; }
pre.plain { color: var(--text); }
`;
+// Wrapped lines hang under the code, not the line number: the indent pulls the
+// number back to the gutter the padding reserves.
+const CODE_WRAP_CSS = `
+.line {
+ ${SOFT_WRAP}
+ padding-left: calc(2.5em + 12px); text-indent: calc(-2.5em - 12px);
+}
+`;
+
function plainHtml(code: string): string {
const lines = code.split("\n");
return `${lines
@@ -346,7 +364,7 @@ export async function renderCode(
const head = hasHead ? `${filename}${langBadge}${copyBtn}` : copyBtn;
const codeJs = JSON.stringify(code).replace(/${head}${preWithStart}`;
- return { body, css: CODE_CSS + shikiSchemeCss(opts.mode) };
+ return { body, css: CODE_CSS + (opts.wrap ? CODE_WRAP_CSS : "") + shikiSchemeCss(opts.mode) };
}
// ---------------------------------------------------------------------------
@@ -408,6 +426,7 @@ export async function renderDiff(
theme: { dark: shiki.dark, light: shiki.light },
themeType: opts.mode ?? "system",
preferredHighlighter: "shiki-js",
+ ...(opts.wrap ? { overflow: "wrap" as const } : {}),
} as const;
const rendered = await Promise.all(
diffs.map((fileDiff) => preloadFileDiff({ fileDiff, options })),
diff --git a/server/types.ts b/server/types.ts
index 5ef3ceb..6f34812 100644
--- a/server/types.ts
+++ b/server/types.ts
@@ -56,18 +56,21 @@ export interface SurfaceKindMetadata {
sandboxed: boolean;
// Stable iframe selector hook for sandboxed kinds that need kind-specific CSS.
frameClass?: string;
+ // Server-rendered text kinds that soft-wrap long lines on request (?wrap=1,
+ // the viewer's wide mode) instead of scrolling sideways.
+ softWrap?: boolean;
}
export const SURFACE_KIND_METADATA = {
html: { contentField: "html", sandboxed: true },
- diff: { contentField: "patch", sandboxed: true, frameClass: "diffframe" },
+ diff: { contentField: "patch", sandboxed: true, frameClass: "diffframe", softWrap: true },
image: { sandboxed: false },
trace: { sandboxed: false },
- markdown: { contentField: "markdown", sandboxed: true, frameClass: "mdframe" },
- terminal: { contentField: "text", sandboxed: true, frameClass: "termframe" },
+ markdown: { contentField: "markdown", sandboxed: true, frameClass: "mdframe", softWrap: true },
+ terminal: { contentField: "text", sandboxed: true, frameClass: "termframe", softWrap: true },
mermaid: { contentField: "mermaid", sandboxed: true, frameClass: "mermaidframe" },
json: { contentField: "data", sandboxed: false },
- code: { contentField: "code", sandboxed: true, frameClass: "codeframe" },
+ code: { contentField: "code", sandboxed: true, frameClass: "codeframe", softWrap: true },
} as const satisfies Record;
export const SURFACE_KIND_LIST = SURFACE_KINDS.join(", ");
@@ -96,10 +99,20 @@ export function isSurfaceKind(kind: unknown): kind is SurfaceKind {
return typeof kind === "string" && Object.hasOwn(SURFACE_KIND_METADATA, kind);
}
+export function isSoftWrapSurfaceKind(kind: unknown): kind is SurfaceKind {
+ return isSurfaceKind(kind) && "softWrap" in SURFACE_KIND_METADATA[kind];
+}
+
export function isSandboxedSurfaceKind(kind: unknown): kind is SurfaceKind {
return isSurfaceKind(kind) && SURFACE_KIND_METADATA[kind].sandboxed;
}
+// Workspace column width (Store setting "width"): "normal" is the classic 860px
+// feed column, "wide" lets it grow with the window (viewer styles.css).
+export const LAYOUT_WIDTHS = ["normal", "wide"] as const;
+export type LayoutWidth = (typeof LAYOUT_WIDTHS)[number];
+export const DEFAULT_WIDTH: LayoutWidth = "normal";
+
export interface HtmlSurface {
kind: "html";
html: string;
diff --git a/test/api.test.ts b/test/api.test.ts
index 1cefc4d..bb5e3e2 100644
--- a/test/api.test.ts
+++ b/test/api.test.ts
@@ -498,6 +498,22 @@ test("post preview image URL changes with the workspace theme", async () => {
assert.doesNotMatch(after, /theme=github/);
});
+test("workspace width defaults to normal, persists, and broadcasts", async () => {
+ const events: unknown[] = [];
+ const app = makeApp(undefined, { onEvent: (e) => events.push(e) });
+
+ assert.deepEqual(await (await app.request("/api/width")).json(), { id: "normal" });
+ const put = await app.request("/api/width", { ...json({ id: "wide" }), method: "PUT" });
+ assert.equal(put.status, 200);
+ assert.deepEqual(await put.json(), { id: "wide" });
+ assert.deepEqual(await (await app.request("/api/width")).json(), { id: "wide" });
+ assert.deepEqual(events, [{ type: "width-changed", id: "wide" }]);
+
+ const bad = await app.request("/api/width", { ...json({ id: "huge" }), method: "PUT" });
+ assert.equal(bad.status, 400);
+ assert.deepEqual(await (await app.request("/api/width")).json(), { id: "wide" });
+});
+
test("/s served versioned + themed is cacheable; an unpinned load is not", async () => {
const app = makeApp();
const res = await app.request(
@@ -1335,6 +1351,7 @@ test("public read full mode allows unauthenticated GETs but not writes", async (
assert.equal((await app.request("/session/anything")).status, 200);
assert.equal((await app.request("/api/sessions")).status, 200);
assert.equal((await app.request("/api/theme")).status, 200);
+ assert.equal((await app.request("/api/width")).status, 200);
assert.equal((await app.request("/api/version")).status, 200);
const created = (await (
@@ -1345,6 +1362,10 @@ test("public read full mode allows unauthenticated GETs but not writes", async (
assert.equal((await app.request("/api/snippets", json({ html: "x
" }))).status, 401);
assert.equal((await app.request("/api/comments", json({ text: "hi" }))).status, 401);
+ assert.equal(
+ (await app.request("/api/width", { ...json({ id: "wide" }), method: "PUT" })).status,
+ 401,
+ );
});
test("public read session mode allows scoped reads and denies root/session list", async () => {
@@ -1353,6 +1374,7 @@ test("public read session mode allows scoped reads and denies root/session list"
assert.equal((await app.request("/")).status, 401);
assert.equal((await app.request("/api/sessions")).status, 401);
assert.equal((await app.request("/api/theme")).status, 200);
+ assert.equal((await app.request("/api/width")).status, 200);
assert.equal((await app.request("/api/version")).status, 200);
const created = (await (
diff --git a/test/richRender.test.ts b/test/richRender.test.ts
index 749a585..e51dbfa 100644
--- a/test/richRender.test.ts
+++ b/test/richRender.test.ts
@@ -157,3 +157,23 @@ test("renderTerminal: a title-less terminal defaults the bar title to 'terminal'
assert.match(body, /terminal<\/span>/);
assert.match(body, /plain output<\/pre>/);
});
+
+test("wrap: off leaves every renderer's output unchanged; on adds soft-wrap rules", async () => {
+ const md: MarkdownSurface = { kind: "markdown", markdown: "```text\nlong line\n```" };
+ const code: CodeSurface = { kind: "code", code: "const x = 1;", language: "ts" };
+ const term: TerminalSurface = { kind: "terminal", text: "output" };
+ const diff: DiffSurface = {
+ kind: "diff",
+ files: [{ filename: "f.ts", before: "const x = 1", after: "const x = 2" }],
+ };
+ assert.deepEqual(await renderMarkdown(md, { wrap: false }), await renderMarkdown(md));
+ assert.deepEqual(await renderCode(code, { wrap: false }), await renderCode(code));
+ assert.deepEqual(renderTerminal(term, { wrap: false }), renderTerminal(term));
+ assert.deepEqual(await renderDiff(diff, { wrap: false }), await renderDiff(diff));
+
+ assert.match((await renderMarkdown(md, { wrap: true })).css, /pre \{ white-space: pre-wrap;/);
+ assert.match((await renderCode(code, { wrap: true })).css, /\.line \{\s*white-space: pre-wrap;/);
+ assert.match(renderTerminal(term, { wrap: true }).css, /\.term-body \{ white-space: pre-wrap;/);
+ assert.match((await renderDiff(diff, { wrap: true })).body, /data-overflow="wrap"/);
+ assert.doesNotMatch((await renderDiff(diff)).body, /data-overflow="wrap"/);
+});
diff --git a/test/workerIntegration.integration.ts b/test/workerIntegration.integration.ts
index 2766f23..d1efcd5 100644
--- a/test/workerIntegration.integration.ts
+++ b/test/workerIntegration.integration.ts
@@ -260,6 +260,7 @@ test(
assert.deepEqual(new Uint8Array(await servedAsset.arrayBuffer()), bytes);
await expectJson(await worker.fetch("/api/theme", json({ id: "gruvbox" }, "PUT")), 200);
+ await expectJson(await worker.fetch("/api/width", json({ id: "wide" }, "PUT")), 200);
const pendingFeedback = worker.fetch(
`/api/comments?session=${post.sessionId}&author=user&wait=2`,
@@ -362,6 +363,11 @@ test(
200,
);
assert.equal(persistedTheme.id, "gruvbox");
+ const persistedWidth = await expectJson<{ id: string }>(
+ await worker.fetch("/api/width", { headers: AUTH }),
+ 200,
+ );
+ assert.equal(persistedWidth.id, "wide");
const persistedAsset = await worker.fetch(`/a/${asset.id}`, { headers: AUTH });
assert.equal(persistedAsset.status, 200);
diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx
index f20f7df..3e22c1b 100644
--- a/viewer/src/App.tsx
+++ b/viewer/src/App.tsx
@@ -26,6 +26,7 @@ import {
PlugIcon,
SunIcon,
SystemIcon,
+ WidthIcon,
} from "./icons.tsx";
import {
activeTheme,
@@ -36,6 +37,7 @@ import {
setTheme,
themeOptions,
} from "./theme.ts";
+import { initWidth, isWide, setWidth } from "./width.ts";
import {
applyRoute,
bootstrap,
@@ -167,6 +169,7 @@ export default function App() {
});
checkVersion();
void initTheme();
+ void initWidth();
const timer = setInterval(() => {
if (sessions.length > 0) refreshSessionsQuiet();
}, 45_000);
@@ -241,7 +244,7 @@ export default function App() {
keyed
fallback={
<>
-
+