diff --git a/.changeset/viewer-post-surface-vocab.md b/.changeset/viewer-post-surface-vocab.md
new file mode 100644
index 0000000..cb4a381
--- /dev/null
+++ b/.changeset/viewer-post-surface-vocab.md
@@ -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`.
diff --git a/e2e/uploads.spec.ts b/e2e/uploads.spec.ts
index 0ea158f..a263363 100644
--- a/e2e/uploads.spec.ts
+++ b/e2e/uploads.spec.ts
@@ -9,7 +9,7 @@ import {
upload,
} from "./fixtures.ts";
-test("an image part renders an served from /a/:id", async ({ page, server }) => {
+test("an image surface renders an served from /a/:id", async ({ page, server }) => {
const asset = await upload(server.url, {
data: TINY_PNG_B64,
contentType: "image/png",
@@ -34,7 +34,7 @@ test("an image part renders an 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",
@@ -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,
}) => {
@@ -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",
@@ -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 }) => {
@@ -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,
}) => {
diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts
index bc91cfc..c9e297a 100644
--- a/e2e/viewer.spec.ts
+++ b/e2e/viewer.spec.ts
@@ -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) => {
@@ -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: "
x
", 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);
});
@@ -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”`,
);
}
});
diff --git a/server/app.ts b/server/app.ts
index f80a97b..4fdb4fb 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -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;
@@ -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);
};
@@ -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);
});
@@ -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;
}
diff --git a/server/kits.ts b/server/kits.ts
index e8b5b73..7dff9a0 100644
--- a/server/kits.ts
+++ b/server/kits.ts
@@ -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.
diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts
index 08dee2e..e6f960a 100644
--- a/server/mcpHttp.ts
+++ b/server/mcpHttp.ts
@@ -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 —
@@ -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/; the new post tools
diff --git a/server/surfaceParts.ts b/server/postSurfaces.ts
similarity index 99%
rename from server/surfaceParts.ts
rename to server/postSurfaces.ts
index 3823169..c793d1a 100644
--- a/server/surfaceParts.ts
+++ b/server/postSurfaces.ts
@@ -269,10 +269,10 @@ async function parseSurfaceParts(
return { parts, errors: [] };
}
-export const coerceSurfaceParts = (raw: unknown): Promise =>
+export const coerceSurfaces = (raw: unknown): Promise =>
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 });
diff --git a/server/types.ts b/server/types.ts
index fd31046..82c1a45 100644
--- a/server/types.ts
+++ b/server/types.ts
@@ -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 = [
diff --git a/test/assets.test.ts b/test/assets.test.ts
index dfb97f7..7beafd8 100644
--- a/test/assets.test.ts
+++ b/test/assets.test.ts
@@ -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 ---
@@ -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: "
" }]);
});
diff --git a/test/mcpSpec.test.ts b/test/mcpSpec.test.ts
index 6043827..c01ed1c 100644
--- a/test/mcpSpec.test.ts
+++ b/test/mcpSpec.test.ts
@@ -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`
@@ -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}`);
}
});
diff --git a/viewer/embed.d.ts b/viewer/embed.d.ts
index 24c6a0d..f2d1c63 100644
--- a/viewer/embed.d.ts
+++ b/viewer/embed.d.ts
@@ -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,
diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx
index a1d798d..dd6dafa 100644
--- a/viewer/src/App.tsx
+++ b/viewer/src/App.tsx
@@ -37,9 +37,9 @@ import {
setPillTarget,
setUnread,
setViewMode,
- standaloneSurface,
+ standalonePost,
streamLoading,
- surfaces,
+ posts,
toast,
toastShow,
toastText,
@@ -82,7 +82,7 @@ export default function App() {
});
onMount(() => {
- // Await the initial route resolution (the standalone surface fetch, or the
+ // Await the initial route resolution (the standalone post fetch, or the
// first session fetch), then mark the board decided and tell the host
// (onReady). Until then #onboard stays hidden, so neither the empty board
// nor a host's loading overlay flips to real content before we know what to
@@ -137,10 +137,10 @@ export default function App() {
// unseen activity badges the tab title — self-hosted only; an embedding host
// owns its own document title. The standalone page titles itself after the
- // surface instead (set below), so don't fight it here.
+ // post instead (set below), so don't fight it here.
createEffect(() => {
if (isShadow()) return;
- const solo = standaloneSurface();
+ const solo = standalonePost();
if (solo) document.title = solo.title ? `${solo.title} · sideshow` : "sideshow";
else document.title = unread().size ? `(${unread().size}) sideshow` : "sideshow";
});
@@ -155,7 +155,7 @@ export default function App() {
return (
@@ -257,26 +257,26 @@ export default function App() {
setPillTarget(null);
}}
>
- new surface ↓
+ new post ↓
>
}
>
- {(surface) => }
+ {(post) => }
);
}
-// The full-page view a bare /s/:id direct link lands on: just the one surface,
+// The full-page view a bare /s/:id direct link lands on: just the one post,
// no sidebar/session chrome/comments, with a small sideshow watermark beneath
-// it. The Card renders in `standalone` mode (title + parts only); its part
+// it. The Card renders in `standalone` mode (title + surfaces only); its
// iframes are sized by the same postMessage bridge the board uses (it resolves
// any registered card, so a standalone card sizes identically).
-function StandaloneView(props: { surface: Post }) {
+function StandaloneView(props: { post: Post }) {
return (