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: "

x

" }, { kind: "html", html: "
", kits: ["issues"] }, { kind: "diff", patch: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b", layout: "unified" }, @@ -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: "

x

", kits: ["nope"] }], // unknown kit id (strict) @@ -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, @@ -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. @@ -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, @@ -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, diff --git a/test/kits.test.ts b/test/kits.test.ts index 369c6a7..c39b613 100644 --- a/test/kits.test.ts +++ b/test/kits.test.ts @@ -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 --- @@ -75,8 +75,8 @@ 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: "

x

", kits: ["issues", "slides"] }, ]); assert.equal(r.ok, true); @@ -84,20 +84,20 @@ test("validateSurfaceParts accepts an html part with known kits", async () => { assert.deepEqual(r.parts[0], { kind: "html", html: "

x

", kits: ["issues", "slides"] }); }); -test("validateSurfaceParts rejects an unknown kit id with the valid set", async () => { - const r = await validateSurfaceParts([{ kind: "html", html: "

x

", kits: ["bogus"] }]); +test("validateSurfaces rejects an unknown kit id with the valid set", async () => { + const r = await validateSurfaces([{ kind: "html", html: "

x

", 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: "

x

", kits: ["issues", "bogus"] }, ]); assert.deepEqual(parts, [{ kind: "html", html: "

x

", kits: ["issues"] }]); }); -test("coerceSurfaceParts drops an all-unknown kits field entirely", async () => { - const parts = await coerceSurfaceParts([{ kind: "html", html: "

x

", kits: ["nope"] }]); +test("coerceSurfaces drops an all-unknown kits field entirely", async () => { + const parts = await coerceSurfaces([{ kind: "html", html: "

x

", kits: ["nope"] }]); assert.deepEqual(parts, [{ kind: "html", html: "

x

" }]); }); 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 (
- +