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
22 changes: 22 additions & 0 deletions .changeset/viewer-post-surface-vocab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"sideshow": minor
---

Adopt the **post / surface** vocabulary throughout the viewer engine and the
host contract. The canonical hierarchy is **workspace ▸ session ▸ post ▸
surface**: a **post** is the published artifact (an ordered list of surfaces),
and a **surface** is one block inside a post.

This is an internal rename of the viewer's local identifiers, component names,
props, CSS classes, and user-visible strings — behavior is unchanged and all
wire paths, query keys (`?part=`), SSE event types, and server-provided JSON
field names are kept byte-identical for compatibility. The block component
files were renamed (`ImagePart`→`ImageSurface`, `JsonPart`→`JsonSurface`,
`TracePart`→`TraceSurface`), and the server helper `surfaceParts.ts` is now
`postSurfaces.ts` (`coerceSurfaceParts`→`coerceSurfaces`,
`validateSurfaceParts`→`validateSurfaces`).

**Host-contract change (embedders must update):** the host identity key
`identity.accountSlug` is renamed to `identity.workspaceSlug`. Any embedder
passing `accountSlug` on the injected host's `identity` must rename it to
`workspaceSlug`.
16 changes: 8 additions & 8 deletions e2e/uploads.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
upload,
} from "./fixtures.ts";

test("an image part renders an <img> served from /a/:id", async ({ page, server }) => {
test("an image surface renders an <img> served from /a/:id", async ({ page, server }) => {
const asset = await upload(server.url, {
data: TINY_PNG_B64,
contentType: "image/png",
Expand All @@ -34,7 +34,7 @@ test("an image part renders an <img> served from /a/:id", async ({ page, server
await expect(page.locator(".asset-caption")).toHaveText("one pixel");
});

test("a trace part renders a step timeline with expandable detail", async ({ page, server }) => {
test("a trace surface renders a step timeline with expandable detail", async ({ page, server }) => {
await publishParts(server.url, {
title: "Run trace",
agent: "e2e",
Expand Down Expand Up @@ -62,7 +62,7 @@ test("a trace part renders a step timeline with expandable detail", async ({ pag
await expect(card.locator(".trace-detail")).toHaveText("opened the file at line 1");
});

test("a trace part backed by an uploaded file offers a download and renders steps", async ({
test("a trace surface backed by an uploaded file offers a download and renders steps", async ({
page,
server,
}) => {
Expand All @@ -88,7 +88,7 @@ test("a trace part backed by an uploaded file offers a download and renders step
await expect(card.locator(".trace-label").first()).toHaveText("step one");
});

test("a trace part stays readable on an iPhone-sized viewport", async ({ page, server }) => {
test("a trace surface stays readable on an iPhone-sized viewport", async ({ page, server }) => {
await publishParts(server.url, {
title: "Trace on mobile",
agent: "e2e",
Expand Down Expand Up @@ -128,7 +128,7 @@ test("a trace part stays readable on an iPhone-sized viewport", async ({ page, s

await expectNoHorizontalOverflow(page, "main");
await expectNoHorizontalOverflow(page, ".card");
await expectNoHorizontalOverflow(page, ".tracepart");
await expectNoHorizontalOverflow(page, ".trace-surface");
});

test("all native surface primitives fit the iPhone 14 Pro viewer", async ({ page, server }) => {
Expand Down Expand Up @@ -202,17 +202,17 @@ test("all native surface primitives fit the iPhone 14 Pro viewer", async ({ page
await expect(card.locator("iframe").first()).toBeVisible();
await expectIframesNoHorizontalOverflow(page, card);
await expect(card.locator(".asset-img")).toBeVisible();
await expect(card.locator(".jsonpart")).toContainText("primitives");
await expect(card.locator(".json-surface")).toContainText("primitives");
await expect(card.locator(".trace-step")).toHaveCount(1);
await card.locator(".trace-row.clickable").click();
await expect(card.locator(".trace-detail")).toBeVisible();

await expectNoHorizontalOverflow(page, "main");
await expectNoHorizontalOverflow(page, ".card");
await expectNoHorizontalOverflow(page, ".tracepart");
await expectNoHorizontalOverflow(page, ".trace-surface");
});

test("an uploaded image embeds by URL inside an html part under the CSP", async ({
test("an uploaded image embeds by URL inside an html surface under the CSP", async ({
page,
server,
}) => {
Expand Down
10 changes: 5 additions & 5 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,12 @@ test("snippet published over HTTP appears live via SSE, no reload", async ({ pag
await expect(page.locator(".sess-title")).toContainText("e2e session");
});

test("a part kind this viewer doesn't know shows a refresh hint, not a broken diff", async ({
test("a surface kind this viewer doesn't know shows a refresh hint, not a broken diff", async ({
page,
server,
}) => {
// Simulate a long-open tab that predates a newly shipped part type: the
// server returns a valid surface, but rewrite the part kind to one THIS
// Simulate a long-open tab that predates a newly shipped surface type: the
// server returns a valid surface, but rewrite the surface kind to one THIS
// viewer build has no Match for. It must degrade to a neutral hint, never
// the diff fallback.
await page.route(/\/api\/surfaces\/[^/?]+(\?|$)/, async (route) => {
Expand All @@ -90,7 +90,7 @@ test("a part kind this viewer doesn't know shows a refresh hint, not a broken di
await publish(server.url, { html: "<p>x</p>", title: "Future part", agent: "e2e" });

const card = page.locator(".card:not(#whatsNew)").first();
await expect(card.locator(".part-unsupported")).toBeVisible();
await expect(card.locator(".surface-unsupported")).toBeVisible();
await expect(card.locator(".diff-error")).toHaveCount(0);
});

Expand Down Expand Up @@ -160,7 +160,7 @@ test("a comment's copy button puts an agent-ready paste block on the clipboard",
await expect(page.locator("#toast")).toContainText("Copied");
if (browserName === "chromium") {
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
`sideshow comment on “Doc” (surface ${snippet.id}):\n“tighten the spacing”`,
`sideshow comment on “Doc” (post ${snippet.id}):\n“tighten the spacing”`,
);
}
});
Expand Down
10 changes: 5 additions & 5 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
type TerminalSurface,
type TraceStep,
} from "./types.ts";
import { validateSurfaceParts } from "./surfaceParts.ts";
import { validateSurfaces } from "./postSurfaces.ts";

const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
const MAX_WAIT_SECONDS = 300;
Expand Down Expand Up @@ -808,7 +808,7 @@ export function createApp({
if (!body || !Array.isArray(blocks)) {
return c.json({ error: 'body must include a "surfaces" (or legacy "parts") array' }, 400);
}
const parsed = await validateSurfaceParts(blocks);
const parsed = await validateSurfaces(blocks);
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
return publish(c, body, parsed.parts);
};
Expand All @@ -823,7 +823,7 @@ export function createApp({
if (!body || typeof body.html !== "string" || !body.html.trim()) {
return c.json({ error: 'body must include non-empty "html" string' }, 400);
}
const parsed = await validateSurfaceParts([htmlSurface(body.html, body.kits)]);
const parsed = await validateSurfaces([htmlSurface(body.html, body.kits)]);
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
return publish(c, body, parsed.parts);
});
Expand Down Expand Up @@ -860,11 +860,11 @@ export function createApp({
if (!Array.isArray(blocks)) {
return c.json({ error: '"surfaces" (or legacy "parts") must be an array' }, 400);
}
const parsed = await validateSurfaceParts(blocks);
const parsed = await validateSurfaces(blocks);
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
parts = parsed.parts;
} else if (typeof body.html === "string") {
const parsed = await validateSurfaceParts([htmlSurface(body.html, body.kits)]);
const parsed = await validateSurfaces([htmlSurface(body.html, body.kits)]);
if (!parsed.ok) return c.json({ error: parsed.error }, 400);
parts = parsed.parts;
}
Expand Down
2 changes: 1 addition & 1 deletion server/kits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// per part, not a frame every surface is locked into.
//
// Runtime-agnostic (no node imports): imported by surfacePage (server render),
// surfaceParts (id allowlist), and surfaced over HTTP/MCP for discovery. Every
// postSurfaces (id allowlist), and surfaced over HTTP/MCP for discovery. Every
// class resolves against the theme `--color-*` / `--font-*` / radius tokens, so
// kit output re-themes with the board like any other html part.

Expand Down
4 changes: 2 additions & 2 deletions server/mcpHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type Surface,
} from "./types.ts";
import { HTTP_MCP_TOOLS, MCP_INSTRUCTIONS, MCP_SERVER_INFO } from "./mcpSpec.ts";
import { coerceSurfaceParts } from "./surfaceParts.ts";
import { coerceSurfaces } from "./postSurfaces.ts";

// Stateless MCP over streamable HTTP: every request is self-contained, which
// is what a serverless deployment needs. Session continuity is explicit —
Expand Down Expand Up @@ -51,7 +51,7 @@ export interface McpDeps {
// Coerce loosely-typed tool args into validated SurfacePart[]. Unknown kinds
// and empty parts are dropped rather than rejected, so a slightly-off call
// still publishes what it can.
export const coerceParts = coerceSurfaceParts;
export const coerceParts = coerceSurfaces;

export function registerMcp(app: Hono, deps: McpDeps) {
// The view URL's path segment: legacy tools emit /s/<id>; the new post tools
Expand Down
4 changes: 2 additions & 2 deletions server/surfaceParts.ts → server/postSurfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,10 +269,10 @@ async function parseSurfaceParts(
return { parts, errors: [] };
}

export const coerceSurfaceParts = (raw: unknown): Promise<Surface[]> =>
export const coerceSurfaces = (raw: unknown): Promise<Surface[]> =>
parseSurfaceParts(raw).then((r) => r.parts);

export async function validateSurfaceParts(
export async function validateSurfaces(
raw: unknown,
): Promise<{ ok: true; parts: Surface[] } | { ok: false; error: string }> {
const result = await parseSurfaceParts(raw, { strict: true });
Expand Down
2 changes: 1 addition & 1 deletion server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface Session {
// build their `kind` enums from it, so a kind can't be added to the model
// without the MCP tier advertising it too (the gap that left `json`/`code`
// publishable over CLI/REST but invisible to MCP). The per-kind FIELD schemas
// in surfaceParts.ts and mcpSpec.ts are still hand-written; test/mcpSpec.test.ts
// in postSurfaces.ts and mcpSpec.ts are still hand-written; test/mcpSpec.test.ts
// guards that every kind here round-trips through both the MCP schema and the
// validator with its fields, so neither half can silently fall behind.
export const SURFACE_KINDS = [
Expand Down
30 changes: 15 additions & 15 deletions test/assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
selectEvictions,
type Surface,
} from "../server/types.ts";
import { validateSurfaceParts } from "../server/surfaceParts.ts";
import { validateSurfaces } from "../server/postSurfaces.ts";

// --- selectEvictions ---

Expand Down Expand Up @@ -78,8 +78,8 @@ test("surfacesByteLength counts image/trace surfaces without throwing", () => {

// --- SurfacePart validation/coercion ---

test("validateSurfaceParts accepts all supported part kinds", async () => {
const result = await validateSurfaceParts([
test("validateSurfaces accepts all supported part kinds", async () => {
const result = await validateSurfaces([
{ kind: "html", html: "<p>x</p>" },
{ kind: "html", html: "<div class=tree></div>", kits: ["issues"] },
{ kind: "diff", patch: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b", layout: "unified" },
Expand Down Expand Up @@ -118,7 +118,7 @@ test("validateSurfaceParts accepts all supported part kinds", async () => {
);
});

test("validateSurfaceParts rejects malformed parts", async () => {
test("validateSurfaces rejects malformed parts", async () => {
for (const parts of [
[{ kind: "html", html: 1 }],
[{ kind: "html", html: "<p>x</p>", kits: ["nope"] }], // unknown kit id (strict)
Expand All @@ -131,41 +131,41 @@ test("validateSurfaceParts rejects malformed parts", async () => {
[{ kind: "code" }], // missing code
[{ kind: "unknown" }],
]) {
const result = await validateSurfaceParts(parts);
const result = await validateSurfaces(parts);
assert.equal(result.ok, false, JSON.stringify(parts));
}
});

test("validateSurfaceParts rejects a diff patch with no parseable file content", async () => {
test("validateSurfaces rejects a diff patch with no parseable file content", async () => {
for (const patch of [
"not a patch at all",
"hello world\nfoo bar",
"@@ -1 +1 @@\n-a\n+b", // hunk with no --- /+++ file headers
]) {
const result = await validateSurfaceParts([{ kind: "diff", patch }]);
const result = await validateSurfaces([{ kind: "diff", patch }]);
assert.equal(result.ok, false, `patch ${JSON.stringify(patch)} should be rejected`);
if (!result.ok) assert.match(result.error, /did not parse to any file/);
}
});

test("validateSurfaceParts accepts real unified and git-style diff patches", async () => {
test("validateSurfaces accepts real unified and git-style diff patches", async () => {
for (const patch of [
"--- a/x.ts\n+++ b/x.ts\n@@ -1 +1 @@\n-a\n+b",
"diff --git a/x b/x\nindex 0..1 100644\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b",
"--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n a\n-b\n+c\n d\n--- a/y\n+++ b/y\n@@ -1 +1 @@\n-e\n+f", // multi-file
]) {
const result = await validateSurfaceParts([{ kind: "diff", patch }]);
const result = await validateSurfaces([{ kind: "diff", patch }]);
assert.equal(result.ok, true, `patch ${JSON.stringify(patch)} should be accepted`);
}
});

test("validateSurfaceParts accepts valid mermaid diagrams (supported types)", async () => {
test("validateSurfaces accepts valid mermaid diagrams (supported types)", async () => {
for (const mermaid of [
'pie title Pets\n "Dogs" : 386\n "Cats" : 85',
"gitGraph\n commit\n commit\n branch develop",
"architecture-beta\n group api(cloud)[API]",
]) {
const result = await validateSurfaceParts([{ kind: "mermaid", mermaid }]);
const result = await validateSurfaces([{ kind: "mermaid", mermaid }]);
assert.equal(
result.ok,
true,
Expand All @@ -174,7 +174,7 @@ test("validateSurfaceParts accepts valid mermaid diagrams (supported types)", as
}
});

test("validateSurfaceParts lets unsupported mermaid types through (Jison types)", async () => {
test("validateSurfaces lets unsupported mermaid types through (Jison types)", async () => {
// flowchart, sequence, class, state, er, gantt are still on Jison — the
// official parser doesn't cover them, so validation is skipped and the
// viewer's graceful fallback handles any render failure.
Expand All @@ -186,7 +186,7 @@ test("validateSurfaceParts lets unsupported mermaid types through (Jison types)"
"gantt\n title Project\n section Phase 1\n Task 1 :a1, 2024-01-01, 30d",
"classDiagram\n Animal <|-- Dog",
]) {
const result = await validateSurfaceParts([{ kind: "mermaid", mermaid }]);
const result = await validateSurfaces([{ kind: "mermaid", mermaid }]);
assert.equal(
result.ok,
true,
Expand All @@ -195,12 +195,12 @@ test("validateSurfaceParts lets unsupported mermaid types through (Jison types)"
}
});

test("validateSurfaceParts rejects invalid mermaid with a parse error (supported types)", async () => {
test("validateSurfaces rejects invalid mermaid with a parse error (supported types)", async () => {
for (const mermaid of [
'pie title Pets\n "Dogs" : broken !!@@',
"gitGraph\n commit\n !!bad syntax!!",
]) {
const result = await validateSurfaceParts([{ kind: "mermaid", mermaid }]);
const result = await validateSurfaces([{ kind: "mermaid", mermaid }]);
assert.equal(
result.ok,
false,
Expand Down
18 changes: 9 additions & 9 deletions test/kits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { isKnownKit, KIT_IDS, kitAssets, kitSummaries } from "../server/kits.ts";
import { renderHtmlPage } from "../server/surfacePage.ts";
import { coerceSurfaceParts, validateSurfaceParts } from "../server/surfaceParts.ts";
import { coerceSurfaces, validateSurfaces } from "../server/postSurfaces.ts";

// --- kitAssets ---

Expand Down Expand Up @@ -75,29 +75,29 @@ test("kitSummaries advertises each kit without leaking the css/js payload", () =

// --- validation: strict (REST) rejects, loose (MCP) filters ---

test("validateSurfaceParts accepts an html part with known kits", async () => {
const r = await validateSurfaceParts([
test("validateSurfaces accepts an html part with known kits", async () => {
const r = await validateSurfaces([
{ kind: "html", html: "<p>x</p>", kits: ["issues", "slides"] },
]);
assert.equal(r.ok, true);
if (r.ok)
assert.deepEqual(r.parts[0], { kind: "html", html: "<p>x</p>", kits: ["issues", "slides"] });
});

test("validateSurfaceParts rejects an unknown kit id with the valid set", async () => {
const r = await validateSurfaceParts([{ kind: "html", html: "<p>x</p>", kits: ["bogus"] }]);
test("validateSurfaces rejects an unknown kit id with the valid set", async () => {
const r = await validateSurfaces([{ kind: "html", html: "<p>x</p>", kits: ["bogus"] }]);
assert.equal(r.ok, false);
if (!r.ok) assert.match(r.error, /unknown kit "bogus".*issues/);
});

test("coerceSurfaceParts filters unknown kits rather than dropping the part", async () => {
const parts = await coerceSurfaceParts([
test("coerceSurfaces filters unknown kits rather than dropping the part", async () => {
const parts = await coerceSurfaces([
{ kind: "html", html: "<p>x</p>", kits: ["issues", "bogus"] },
]);
assert.deepEqual(parts, [{ kind: "html", html: "<p>x</p>", kits: ["issues"] }]);
});

test("coerceSurfaceParts drops an all-unknown kits field entirely", async () => {
const parts = await coerceSurfaceParts([{ kind: "html", html: "<p>x</p>", kits: ["nope"] }]);
test("coerceSurfaces drops an all-unknown kits field entirely", async () => {
const parts = await coerceSurfaces([{ kind: "html", html: "<p>x</p>", kits: ["nope"] }]);
assert.deepEqual(parts, [{ kind: "html", html: "<p>x</p>" }]);
});
4 changes: 2 additions & 2 deletions test/mcpSpec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { z } from "zod";
import { HTTP_MCP_TOOLS, STDIO_MCP_INPUT_SCHEMAS } from "../server/mcpSpec.ts";
import { validateSurfaceParts } from "../server/surfaceParts.ts";
import { validateSurfaces } from "../server/postSurfaces.ts";
import { SURFACE_KINDS, type Surface } from "../server/types.ts";

// This suite is the guard against the regression where `json` and `code`
Expand Down Expand Up @@ -69,7 +69,7 @@ test("the stdio publish schema rejects an unknown kind", () => {

test("the runtime validator accepts a minimal example of every kind", async () => {
for (const kind of SURFACE_KINDS) {
const result = await validateSurfaceParts([EXAMPLES[kind]]);
const result = await validateSurfaces([EXAMPLES[kind]]);
assert.ok(result.ok, `validator rejected kind "${kind}": ${result.ok ? "" : result.error}`);
}
});
2 changes: 1 addition & 1 deletion viewer/embed.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface SideshowHost {
basePath: string;
router: HostRouter;
/** The caller's own identity, when the host knows it. */
identity?: { login: string; accountSlug?: string; role?: string };
identity?: { login: string; workspaceSlug?: string; role?: string };
/**
* Layout the engine renders. "full" (default) shows the sidebar + stream;
* "stream" shows only the current session's stream — no sidebar, session list,
Expand Down
Loading
Loading