Skip to content

Building with a framework

A view is an HTML page the app loads. It does not matter how that page is made: by hand, or by React, Vue, Svelte or any other framework and its build tool. As long as the build writes an HTML page and its scripts into the plugin folder, the app serves it like any other.

This page builds one plugin, Ship it?, in four ways. An agent is about to deploy; the person sees what goes out and how the checks went, and ships or holds it with a note.

Ship it?: the changes going out, the checks, and the choice, with the hand-over button saying what it will do.
  • An HTML page at view/index.html. The build writes the page there, and the page’s scripts and styles beside it in view/.
  • A build you run before installing. Pinrail installs a plugin as it is and runs nothing, so run the build, such as npm run build, before you install or link the folder, and before you zip it for a release. The release workflow of Publishing a plugin runs it for you.
  • Relative paths. The app serves the plugin under a path of its own, so the build must refer to its files relatively: with Vite, base: "./".
  • The SDK from the app. Load /sdk/v1/pinrail-plugin.js, its stylesheet and, to render Markdown, /sdk/v1/markdown.js with tags in the page; don’t bundle them. The package gives your code the types: pinrail-sdk/types.
  • Everything else bundled. The frame loads nothing from the network, so the framework itself, fonts and images go into the build.

The plugin’s manifest, and the schemas every framework’s version shares:

nameship_it
version0.1.0
titleShip it?
descriptionA deploy waiting on a person: what goes out and how the checks went, to ship or to hold with a note.
use_whenYou are about to deploy and need a person to say ship or hold, having seen the changes going out and the checks.
shortcuts
  • SShip
  • HHold

Every version is a Vite project whose build writes view/. To start one of your own, pinrail plugins new writes a working plugin in React or TypeScript, a yes-or-no question to build on, with a sample to send. --playwright adds a first test. For Vue or Svelte, start from the TypeScript template and follow the Vue or Svelte version of this page:

Terminal window
pinrail plugins new push_check --template react # or --template typescript

This page builds Ship it? instead. Choose a framework, and every example on this page follows it:

package.json
{
"name": "pinrail-example-ship-it-vanilla",
"private": true,
"version": "0.1.0",
"scripts": {
"build": "tsc --noEmit && vite build",
"watch": "vite build --watch",
"dev": "pinrail-sdk dev",
"test": "npm run build && playwright test"
},
"devDependencies": {
"pinrail-sdk": "file:../../../../sdk",
"@playwright/test": "^1.56",
"typescript": "^5",
"vite": "^7"
},
"dependencies": {
"lucide": "^1"
}
}
vite.config.ts
import { defineConfig } from "vite";
// The build lands in view/: index.html, the page the app loads, with scripts
// and styles under view/assets/. Paths are relative, since the app serves the
// bundle under a path of its own. Only the build lives in view/, so it is
// emptied first.
export default defineConfig({
root: "src",
base: "./",
build: {
outDir: "../view",
emptyOutDir: true,
assetsDir: "assets",
target: "es2022",
modulePreload: { polyfill: false },
},
});

A plugin written by pinrail plugins new takes the SDK’s types from pinrail-plugin.d.ts in its folder, and needs the SDK package only for its tests. The examples on this page take the package from the Pinrail repository, because the SDK is not published to npm.

The manifest is the same for every framework. Its build is the command an install runs:

manifest.json
{
"name": "ship_it",
"version": "0.1.0",
"title": "Ship it?",
"description": "A deploy waiting on a person: what goes out and how the checks went, to ship or to hold with a note.",
"use_when": "You are about to deploy and need a person to say ship or hold, having seen the changes going out and the checks.",
"shortcuts": [
{
"keys": "s",
"does": "Ship"
},
{
"keys": "h",
"does": "Hold"
}
]
}

The page loads the SDK and its stylesheet from the app, and your code from the build. It is the same in every framework but for the script it loads:

src/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ship it?</title>
<link rel="stylesheet" href="/sdk/v1/pinrail-plugin.css">
<style>
/* The tokens, the base and the pinrail-btn, pinrail-note, pinrail-tone and pinrail-chip classes come
from the SDK stylesheet; these rules are all this view adds. */
.ship { max-width: 720px; }
.ship h1 { display: flex; align-items: baseline; gap: 8px; margin: 2px 0 16px; font-size: 18px; }
.ship h1 .pinrail-chip { font-size: 12px; }
.ship section { margin: 14px 0; }
.ship ul { list-style: none; display: grid; gap: 6px; margin: 6px 0 0; padding: 0; }
.ship li { display: flex; align-items: center; gap: 8px; }
.ship li[data-passed="true"] .pinrail-icon { color: var(--pinrail-success); }
.ship li[data-passed="false"] .pinrail-icon { color: var(--pinrail-danger); }
.ship .detail { color: var(--pinrail-dim); }
.ship .choice { display: flex; gap: 8px; margin: 20px 0 10px; }
.ship .choice .pinrail-btn { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; }
.ship textarea { display: block; width: 100%; max-width: 520px; min-height: 64px; }
.ship .pinrail-errors { min-height: 16px; margin-top: 8px; }
.ship .decided { margin-top: 20px; font-size: 13.5px; }
</style>
</head>
<body>
<div id="app"></div>
<script src="/sdk/v1/pinrail-plugin.js"></script>
<script type="module" src="./main.ts"></script>
</body>
</html>

The view connects to the app once, with Pinrail.connect, and draws the review from what onInit hands it. It keeps the person’s choice as a draft, tells the hand-over button what it will do with handOverLabel, and returns its decision from onCollect when the person hands over. It needs no other handler: the app lists a refused decision’s violations under the view, and closes the view once a decision is accepted. onViolations and onSubmitted remain for a view that marks the field at fault or redraws itself:

src/main.ts
// Ship it? in TypeScript and nothing else: the page is drawn from the
// payload with template strings, and drawn again whenever the choice changes.
// The SDK is on the window from the script tag in index.html; the types come
// from the package.
import type { Init } from "pinrail-sdk/types";
import { createElement, CircleCheck, CircleX, Hand, Rocket, type IconNode } from "lucide";
/** A Lucide icon as markup, for the HTML this view builds as a string; the
* class sizes it to the text, as the framework packages' icons are. */
const svg = (icon: IconNode) => createElement(icon, { class: "lucide", "aria-hidden": "true" }).outerHTML;
type Payload = {
service: string;
version: string;
environment: string;
changes: { title: string; risky?: boolean }[];
checks: { name: string; passed: boolean; detail?: string }[];
};
type Verdict = "ship" | "hold";
type Decision = { verdict: Verdict; note?: string };
/** what is kept while the person decides: a verdict may not be chosen yet */
type Draft = { verdict: Verdict | null; note: string };
const { Pinrail } = window;
const app = document.getElementById("app")!;
const esc = Pinrail.escape;
let draft: Draft = { verdict: null, note: "" };
let error = "";
const plugin = Pinrail.connect<Payload, Decision>({
onInit({ draft: kept }: Init<Payload, Decision>) {
if (kept) draft = kept as Draft;
render();
},
// the decision, or nothing while there is no verdict to hand over
onCollect() {
if (!draft.verdict) {
error = "Choose ship or hold first.";
render();
return;
}
const note = draft.note.trim();
return note ? { verdict: draft.verdict, note } : { verdict: draft.verdict };
},
});
function choose(verdict: Verdict) {
draft = { ...draft, verdict };
error = "";
plugin.draft(draft);
render();
}
function render() {
const { payload, decision } = plugin.review!;
const status =
draft.verdict === "ship"
? `Ship ${payload.version}`
: draft.verdict === "hold"
? "Hold the deploy"
: "Choose ship or hold";
if (!plugin.readonly) plugin.handOverLabel(status);
const decided = decision?.data;
app.innerHTML = `
<main class="pinrail-content ship">
<p class="pinrail-eyebrow">Deploy to ${esc(payload.environment)}</p>
<h1>${esc(payload.service)} <span class="pinrail-chip">${esc(payload.version)}</span></h1>
<section>
<h2 class="pinrail-eyebrow">Changes</h2>
<ul aria-label="Changes">
${payload.changes.map((c) => `<li>${esc(c.title)}${c.risky ? ' <span class="pinrail-tone pinrail-tone-warning">risky</span>' : ""}</li>`).join("")}
</ul>
</section>
<section>
<h2 class="pinrail-eyebrow">Checks</h2>
<ul aria-label="Checks">
${payload.checks.map((c) => `<li data-passed="${c.passed}">${svg(c.passed ? CircleCheck : CircleX)} ${esc(c.name)}${c.detail ? ` <span class="detail">${esc(c.detail)}</span>` : ""}</li>`).join("")}
</ul>
</section>
${
plugin.readonly
? `<p class="decided"><b>${decided?.verdict === "ship" ? "Shipped" : "Held"}</b>${decided?.note ? `: ${esc(decided.note)}` : ""}</p>`
: `<div class="choice" role="group" aria-label="Verdict">
<button type="button" class="pinrail-btn" data-verdict="ship" aria-pressed="${draft.verdict === "ship"}">${svg(Rocket)} Ship <kbd>s</kbd></button>
<button type="button" class="pinrail-btn" data-verdict="hold" aria-pressed="${draft.verdict === "hold"}">${svg(Hand)} Hold <kbd>h</kbd></button>
</div>
<textarea class="pinrail-note" aria-label="Note to the agent" placeholder="A note for the agent (optional)">${esc(draft.note)}</textarea>
<div class="pinrail-errors" role="alert">${esc(error)}</div>`
}
</main>`;
}
app.addEventListener("click", (e) => {
const button = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-verdict]");
if (button && !plugin.readonly) choose(button.dataset.verdict as Verdict);
});
app.addEventListener("input", (e) => {
const note = e.target as HTMLTextAreaElement;
if (note.tagName !== "TEXTAREA") return;
draft = { ...draft, note: note.value };
plugin.draft(draft);
});
// s and h decide. The app forwards them too when it has the focus, as a
// keydown on the document itself, so the target is not always an element.
document.addEventListener("keydown", (e) => {
const typing = e.target instanceof Element && e.target.closest("textarea, input");
if (plugin.readonly || e.metaKey || e.ctrlKey || e.altKey || typing) return;
if (e.key === "s") choose("ship");
if (e.key === "h") choose("hold");
});
Terminal window
npm install
npm run watch # rebuilds view/ as you save…
npx pinrail-sdk dev # …and shows it in a browser, on the fixtures
pinrail plugins check . # what the app would say of the folder
npm test # build, then the tests under the harness

A test mounts the built view alone and drives it the way a person would, as Testing a plugin describes. Because it looks only at what the person sees (text, roles and labels), the same test passes for every framework:

tests/ship_it.spec.ts
import { expect, test } from "@playwright/test";
import path from "node:path";
import { fixture, mountPlugin } from "pinrail-sdk/testing";
// The view alone, under the harness: no app, no CLI. The same test runs
// against every framework's build of this plugin, so it looks only at what
// the person sees: text, roles and labels.
const dir = path.resolve(__dirname, "..");
const deploy = () => fixture(path.join(dir, "fixtures", "deploy.json"));
test("shows what goes out and how the checks went", async ({ page }) => {
const plugin = await mountPlugin(page, dir, { review: deploy() });
const f = plugin.frame;
await expect(f.getByRole("heading", { level: 1 })).toHaveText("payments-api v2.4.1");
await expect(f.getByText("Deploy to production")).toBeVisible();
await expect(f.getByRole("list", { name: "Changes" }).getByRole("listitem")).toHaveCount(3);
await expect(f.getByRole("list", { name: "Changes" }).getByText("risky")).toHaveCount(1);
const checks = f.getByRole("list", { name: "Checks" }).getByRole("listitem");
await expect(checks).toHaveCount(4);
await expect(checks.filter({ hasText: "Canary" })).toHaveAttribute("data-passed", "false");
await expect(checks.filter({ hasText: "Canary" })).toContainText("p99 latency 180 ms over the baseline");
});
test("ship with a key, and a note, handed over when the app collects", async ({ page }) => {
const plugin = await mountPlugin(page, dir, { review: deploy() });
const f = plugin.frame;
await expect(f.getByRole("button", { name: /^Ship/ })).toBeVisible();
// a key pressed while the app has the focus, forwarded to the view as the app does
await plugin.sendKey("s");
await expect(f.getByRole("button", { name: /^Ship/ })).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => plugin.lastStatus()).toBe("Ship v2.4.1");
await f.getByLabel("Note to the agent").fill("Watch the canary for an hour");
await plugin.collect();
expect(await plugin.nextSubmit()).toEqual({ verdict: "ship", note: "Watch the canary for an hour" });
});
test("asks for a verdict first, and leaves a refused decision to the app", async ({ page }) => {
const plugin = await mountPlugin(page, dir, { review: deploy() });
const f = plugin.frame;
await expect(f.getByRole("button", { name: /^Hold/ })).toBeVisible();
await plugin.collect();
await expect(f.getByRole("alert")).toHaveText("Choose ship or hold first.");
await f.getByRole("button", { name: /^Hold/ }).click();
await expect.poll(() => plugin.lastStatus()).toBe("Hold the deploy");
await plugin.collect();
expect(await plugin.nextSubmit()).toEqual({ verdict: "hold" });
// the app lists a refused decision's violations under the view, so the
// view does not repeat them
await plugin.sendViolations([{ path: "/verdict", message: '"later" is not one of ["ship","hold"]' }]);
await page.waitForTimeout(200);
await expect(f.getByRole("alert")).toHaveText("");
});
test("a draft comes back as it was left", async ({ page }) => {
const plugin = await mountPlugin(page, dir, {
review: deploy(),
draft: { verdict: "hold", note: "Wait for the canary" },
});
const f = plugin.frame;
await expect(f.getByRole("button", { name: /^Hold/ })).toHaveAttribute("aria-pressed", "true");
await expect(f.getByLabel("Note to the agent")).toHaveValue("Wait for the canary");
await f.getByRole("button", { name: /^Ship/ }).click();
await expect.poll(() => plugin.lastDraft()).toEqual({ verdict: "ship", note: "Wait for the canary" });
});
test("a decided deploy renders read-only", async ({ page }) => {
const plugin = await mountPlugin(page, dir, {
review: fixture(path.join(dir, "fixtures", "deploy.decided.json")),
readonly: true,
});
const f = plugin.frame;
await expect(f.getByText("Held: Wait for the canary to settle")).toBeVisible();
await expect(f.getByRole("button", { name: /^Ship/ })).toHaveCount(0);
});
Terminal window
npm run build
pinrail plugins install . --link

A link follows the folder as it is, so rebuild as you change it, or keep npm run watch running. Installing without --link copies the built folder into the app, without its sources.

  • A forwarded key has no element as its target. The app forwards a manifest shortcut pressed outside the frame as a keydown on your document, so check event.target instanceof Element before calling closest on it.
  • Hand the SDK your state as it is. Vue’s refs and Svelte’s $state are proxies, which a message to the app cannot carry, so the SDK sends a plain copy of a draft or a decision.
  • Connect once, where the page starts. Call Pinrail.connect in the entry module, not in a component: a framework may mount a component more than once, and a second call throws. Render the component when onInit arrives. The connection’s callbacks are made once, so they call into the component on screen, which reads its latest state from somewhere they can reach, such as a React ref.
  • Leave the hand-over to the app. Draw no submit button; the app asks for the decision from its own. See Design and styling.

The four versions are in the repository under docs/examples/ship-it, each a whole plugin with its tests.