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.
What the app needs from a build
Section titled “What the app needs from a build”- An HTML page at
view/index.html. The build writes the page there, and the page’s scripts and styles beside it inview/. - 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.jswith 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:
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 |
|
{ "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" } ]}A deploy the agent is about to run.
- servicestringrequired
What is being deployed
1–80 chars
- versionstringrequired
The version going out, such as v2.4.1
1–40 chars
- environmentstringrequired
Where it goes, such as production
1–40 chars
changesobject[]required
What goes out with it
at most 50 items
- titlestringrequired
1–200 chars
- riskyboolean
Worth a second look: a migration, a flag flip
default
false
checksobject[]required
How the checks before it went
at most 50 items
- namestringrequired
1–120 chars
- passedbooleanrequired
- detailstring
A word on the result, such as the numbers
at most 200 chars
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Ship it? payload", "description": "A deploy the agent is about to run.", "type": "object", "required": [ "service", "version", "environment", "changes", "checks" ], "additionalProperties": false, "properties": { "service": { "type": "string", "minLength": 1, "maxLength": 80, "description": "What is being deployed" }, "version": { "type": "string", "minLength": 1, "maxLength": 40, "description": "The version going out, such as v2.4.1" }, "environment": { "type": "string", "minLength": 1, "maxLength": 40, "description": "Where it goes, such as production" }, "changes": { "type": "array", "maxItems": 50, "description": "What goes out with it", "items": { "type": "object", "required": [ "title" ], "additionalProperties": false, "properties": { "title": { "type": "string", "minLength": 1, "maxLength": 200 }, "risky": { "type": "boolean", "default": false, "description": "Worth a second look: a migration, a flag flip" } } } }, "checks": { "type": "array", "maxItems": 50, "description": "How the checks before it went", "items": { "type": "object", "required": [ "name", "passed" ], "additionalProperties": false, "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 120 }, "passed": { "type": "boolean" }, "detail": { "type": "string", "maxLength": 200, "description": "A word on the result, such as the numbers" } } } } }}- verdictenumrequired
ship: go ahead with the deploy. hold: do not deploy.
"ship""hold" - notestring
A word for the agent: why hold, or what to watch
at most 2000 chars
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Ship it? decision", "type": "object", "required": [ "verdict" ], "additionalProperties": false, "properties": { "verdict": { "enum": [ "ship", "hold" ], "description": "ship: go ahead with the deploy. hold: do not deploy." }, "note": { "type": "string", "maxLength": 2000, "description": "A word for the agent: why hold, or what to watch" } }}1. Create the folder
Section titled “1. Create the folder”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:
pinrail plugins new push_check --template react # or --template typescriptThis page builds Ship it? instead. Choose a framework, and every example on this page follows it:
{ "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" }}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 }, },});{ "name": "pinrail-example-ship-it-react", "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" }, "dependencies": { "react": "^19", "react-dom": "^19", "lucide-react": "^1" }, "devDependencies": { "pinrail-sdk": "file:../../../../sdk", "@playwright/test": "^1.56", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^5", "typescript": "^5", "vite": "^7" }}import react from "@vitejs/plugin-react";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: "./", plugins: [react()], build: { outDir: "../view", emptyOutDir: true, assetsDir: "assets", target: "es2022", modulePreload: { polyfill: false }, },});{ "name": "pinrail-example-ship-it-vue", "private": true, "version": "0.1.0", "scripts": { "build": "vue-tsc --noEmit && vite build", "watch": "vite build --watch", "dev": "pinrail-sdk dev", "test": "npm run build && playwright test" }, "dependencies": { "vue": "^3.5", "@lucide/vue": "^1" }, "devDependencies": { "pinrail-sdk": "file:../../../../sdk", "@playwright/test": "^1.56", "@vitejs/plugin-vue": "^6", "typescript": "^5", "vite": "^7", "vue-tsc": "^3" }}import vue from "@vitejs/plugin-vue";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: "./", plugins: [vue()], build: { outDir: "../view", emptyOutDir: true, assetsDir: "assets", target: "es2022", modulePreload: { polyfill: false }, },});{ "name": "pinrail-example-ship-it-svelte", "private": true, "version": "0.1.0", "scripts": { "build": "svelte-check --fail-on-warnings && 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", "@sveltejs/vite-plugin-svelte": "^6", "svelte": "^5", "svelte-check": "^4", "typescript": "^5", "vite": "^7" }, "dependencies": { "@lucide/svelte": "^1" }}import { svelte } from "@sveltejs/vite-plugin-svelte";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: "./", plugins: [svelte()], 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:
{ "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" } ]}2. The page
Section titled “2. The page”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:
<!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><!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.tsx"></script></body></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><!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>3. The view
Section titled “3. The view”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:
// 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");});// The view connects to the app once, here where the page starts, and draws// the review each time the app hands it over. React may mount a component// more than once, and a second connection would look to the app like// another page in the view's place.import { StrictMode } from "react";import { createRoot } from "react-dom/client";import { App, view, type Decision, type Payload } from "./App";
const { Pinrail } = window;const root = createRoot(document.getElementById("app")!);// each init (the first, and another when the review ends while it is open)// draws the view afreshlet inits = 0;
const plugin = Pinrail.connect<Payload, Decision>({ onInit: (init) => root.render( <StrictMode> <App key={++inits} plugin={plugin} init={init} /> </StrictMode>, ), // the app's hand-over button, or ⌘/Ctrl+Enter: the view's decision onCollect: () => view.collect(),});// Ship it? in React. main.tsx connects to the app and renders this with what// the app handed over (the review, whether it is read-only, the draft). The// app's hand-over button asks for the decision, and `view` answers with it.// The SDK is on the window from the script tag in index.html; the types come// from the package.import { CircleCheck, CircleX, Hand, Rocket } from "lucide-react";import { useCallback, useEffect, useRef, useState } from "react";import type { Init, Plugin } from "pinrail-sdk/types";
export type Payload = { service: string; version: string; environment: string; changes: { title: string; risky?: boolean }[]; checks: { name: string; passed: boolean; detail?: string }[];};type Verdict = "ship" | "hold";export 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 };
/** What the connection in main.tsx asks of the view on screen. */export const view = { /** the decision, or nothing while there is no verdict to hand over */ collect: (): Decision | undefined => undefined,};
/** A draft kept by an earlier release may have another shape: use only what * reads as this one's. */function draftOf(kept: unknown): Draft { const d = kept && typeof kept === "object" ? (kept as Partial<Draft>) : {}; return { verdict: d.verdict === "ship" || d.verdict === "hold" ? d.verdict : null, note: typeof d.note === "string" ? d.note : "", };}
export function App({ plugin, init }: { plugin: Plugin<Payload, Decision>; init: Init<Payload, Decision> }) { // the app closes the view once the decision is accepted: what it shows // is the review as init handed it over const { review, readonly } = init; const [draft, setDraft] = useState<Draft>(() => draftOf(init.draft)); const [error, setError] = useState(""); // the connection calls `view` at any time, so it reads the draft from here const latest = useRef(draft); latest.current = draft;
view.collect = () => { const { verdict, note } = latest.current; if (!verdict) { setError("Choose ship or hold first."); return; } return note.trim() ? { verdict, note: note.trim() } : { verdict }; };
// the same function across renders, since the key listener below calls it const choose = useCallback( (verdict: Verdict) => { const next = { ...latest.current, verdict }; setDraft(next); setError(""); plugin.draft(next); }, [plugin], );
function writeNote(note: string) { const next = { ...latest.current, note }; setDraft(next); plugin.draft(next); }
// what the app's hand-over button says follows the choice useEffect(() => { if (readonly) return; const label = draft.verdict === "ship" ? `Ship ${review.payload.version}` : draft.verdict === "hold" ? "Hold the deploy" : "Choose ship or hold"; plugin.handOverLabel(label); }, [plugin, review, readonly, draft.verdict]);
// 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. useEffect(() => { const onKey = (e: KeyboardEvent) => { const typing = e.target instanceof Element && e.target.closest("textarea, input"); if (readonly || e.metaKey || e.ctrlKey || e.altKey || typing) return; if (e.key === "s") choose("ship"); if (e.key === "h") choose("hold"); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [readonly, choose]);
const { payload } = review; const decided = review.decision?.data; return ( <main className="pinrail-content ship"> <p className="pinrail-eyebrow">Deploy to {payload.environment}</p> <h1> {payload.service} <span className="pinrail-chip">{payload.version}</span> </h1> <section> <h2 className="pinrail-eyebrow">Changes</h2> <ul aria-label="Changes"> {payload.changes.map((c) => ( <li key={c.title}> {c.title} {c.risky ? ( <> {" "} <span className="pinrail-tone pinrail-tone-warning">risky</span> </> ) : null} </li> ))} </ul> </section> <section> <h2 className="pinrail-eyebrow">Checks</h2> <ul aria-label="Checks"> {payload.checks.map((c) => ( <li key={c.name} data-passed={String(c.passed)}> {c.passed ? <CircleCheck /> : <CircleX />} {c.name} {c.detail ? ( <> {" "} <span className="detail">{c.detail}</span> </> ) : null} </li> ))} </ul> </section> {readonly ? ( <p className="decided"> <b>{decided?.verdict === "ship" ? "Shipped" : "Held"}</b> {decided?.note ? `: ${decided.note}` : null} </p> ) : ( <> <div className="choice" role="group" aria-label="Verdict"> <button type="button" className="pinrail-btn" aria-pressed={draft.verdict === "ship"} onClick={() => choose("ship")} > <Rocket /> Ship <kbd>s</kbd> </button> <button type="button" className="pinrail-btn" aria-pressed={draft.verdict === "hold"} onClick={() => choose("hold")} > <Hand /> Hold <kbd>h</kbd> </button> </div> <textarea className="pinrail-note" aria-label="Note to the agent" placeholder="A note for the agent (optional)" value={draft.note} onChange={(e) => writeNote(e.target.value)} /> <div className="pinrail-errors" role="alert"> {error} </div> </> )} </main> );}// The view connects to the app once, here where the page starts, and mounts// the component afresh each time the app hands the review over (the first// time, and again when the review ends while it is open). A component may// mount more than once, and a second connection would look to the app like// another page in the view's place.import { createApp, type App as VueApp } from "vue";import App, { type Decision, type Payload, type View } from "./App.vue";
const { Pinrail } = window;// what the view on screen fills in, for the connection to callconst view: View = { collect: () => undefined };let app: VueApp | null = null;
const plugin = Pinrail.connect<Payload, Decision>({ onInit(init) { app?.unmount(); app = createApp(App, { plugin, init, view }); app.mount("#app"); }, // the app's hand-over button, or ⌘/Ctrl+Enter: the view's decision onCollect: () => view.collect(),});<!-- Ship it? in Vue. main.ts connects to the app and mounts this with what the app handed over (the review, whether it is read-only, the draft), and the `view` it fills in. The app's hand-over button asks for the decision, and `view` answers with it. The SDK is on the window from the script tag in index.html; the types come from the package. --><script lang="ts">
export type Payload = { service: string; version: string; environment: string; changes: { title: string; risky?: boolean }[]; checks: { name: string; passed: boolean; detail?: string }[];};type Verdict = "ship" | "hold";export 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 };
/** What the connection in main.ts asks of the view on screen. */export type View = { /** the decision, or nothing while there is no verdict to hand over */ collect: () => Decision | undefined;};
/** A draft kept by an earlier release may have another shape: use only what * reads as this one's. */function draftOf(kept: unknown): Draft { const d = kept && typeof kept === "object" ? (kept as Partial<Draft>) : {}; return { verdict: d.verdict === "ship" || d.verdict === "hold" ? d.verdict : null, note: typeof d.note === "string" ? d.note : "", };}</script>
<script setup lang="ts">import { CircleCheck, CircleX, Hand, Rocket } from "@lucide/vue";import { onBeforeUnmount, onMounted, ref, watchEffect } from "vue";import type { Init, Plugin } from "pinrail-sdk/types";
const props = defineProps<{ plugin: Plugin<Payload, Decision>; init: Init<Payload, Decision>; view: View }>();// the app closes the view once the decision is accepted: what it shows is// the review as init handed it overconst { review, readonly } = props.init;const draft = ref<Draft>(draftOf(props.init.draft));const error = ref("");
props.view.collect = () => { const { verdict, note } = draft.value; if (!verdict) { error.value = "Choose ship or hold first."; return; } return note.trim() ? { verdict, note: note.trim() } : { verdict };};
onMounted(() => document.addEventListener("keydown", onKey));onBeforeUnmount(() => document.removeEventListener("keydown", onKey));
function choose(verdict: Verdict) { draft.value = { ...draft.value, verdict }; error.value = ""; props.plugin.draft(draft.value);}
function writeNote(event: Event) { draft.value = { ...draft.value, note: (event.target as HTMLTextAreaElement).value }; props.plugin.draft(draft.value);}
// 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.function onKey(e: KeyboardEvent) { const typing = e.target instanceof Element && e.target.closest("textarea, input"); if (readonly || e.metaKey || e.ctrlKey || e.altKey || typing) return; if (e.key === "s") choose("ship"); if (e.key === "h") choose("hold");}
// what the app's hand-over button says follows the choicewatchEffect(() => { if (readonly) return; const verdict = draft.value.verdict; const label = verdict === "ship" ? `Ship ${review.payload.version}` : verdict === "hold" ? "Hold the deploy" : "Choose ship or hold"; props.plugin.handOverLabel(label);});
const decided = review.decision?.data;</script>
<template> <main class="pinrail-content ship"> <p class="pinrail-eyebrow">Deploy to {{ review.payload.environment }}</p> <h1>{{ review.payload.service }} <span class="pinrail-chip">{{ review.payload.version }}</span></h1> <section> <h2 class="pinrail-eyebrow">Changes</h2> <ul aria-label="Changes"> <li v-for="c in review.payload.changes" :key="c.title"> {{ c.title }}<template v-if="c.risky"> <span class="pinrail-tone pinrail-tone-warning">risky</span></template> </li> </ul> </section> <section> <h2 class="pinrail-eyebrow">Checks</h2> <ul aria-label="Checks"> <li v-for="c in review.payload.checks" :key="c.name" :data-passed="String(c.passed)"> <CircleCheck v-if="c.passed" /><CircleX v-else /> {{ c.name }}<template v-if="c.detail"> <span class="detail">{{ c.detail }}</span></template> </li> </ul> </section> <p v-if="readonly" class="decided"> <b>{{ decided?.verdict === "ship" ? "Shipped" : "Held" }}</b><template v-if="decided?.note">: {{ decided.note }}</template> </p> <template v-else> <div class="choice" role="group" aria-label="Verdict"> <button type="button" class="pinrail-btn" :aria-pressed="draft.verdict === 'ship'" @click="choose('ship')"> <Rocket /> Ship <kbd>s</kbd> </button> <button type="button" class="pinrail-btn" :aria-pressed="draft.verdict === 'hold'" @click="choose('hold')"> <Hand /> Hold <kbd>h</kbd> </button> </div> <textarea class="pinrail-note" aria-label="Note to the agent" placeholder="A note for the agent (optional)" :value="draft.note" @input="writeNote" ></textarea> <div class="pinrail-errors" role="alert">{{ error }}</div> </template> </main></template>// The view connects to the app once, here where the page starts, and mounts// the component afresh each time the app hands the review over (the first// time, and again when the review ends while it is open). A component may// mount more than once, and a second connection would look to the app like// another page in the view's place.import { mount, unmount } from "svelte";import App, { type Decision, type Payload, type View } from "./App.svelte";
const { Pinrail } = window;const target = document.getElementById("app")!;// what the view on screen fills in, for the connection to callconst view: View = { collect: () => undefined };let app: Record<string, unknown> | null = null;
const plugin = Pinrail.connect<Payload, Decision>({ onInit(init) { if (app) void unmount(app); app = mount(App, { target, props: { plugin, init, view } }); }, // the app's hand-over button, or ⌘/Ctrl+Enter: the view's decision onCollect: () => view.collect(),});<!-- Ship it? in Svelte. main.ts connects to the app and mounts this with what the app handed over (the review, whether it is read-only, the draft), and the `view` it fills in. The app's hand-over button asks for the decision, and `view` answers with it. The SDK is on the window from the script tag in index.html; the types come from the package. --><script lang="ts" module>
export type Payload = { service: string; version: string; environment: string; changes: { title: string; risky?: boolean }[]; checks: { name: string; passed: boolean; detail?: string }[]; }; type Verdict = "ship" | "hold"; export 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 };
/** What the connection in main.ts asks of the view on screen. */ export type View = { /** the decision, or nothing while there is no verdict to hand over */ collect: () => Decision | undefined; };
/** A draft kept by an earlier release may have another shape: use only * what reads as this one's. */ function draftOf(kept: unknown): Draft { const d = kept && typeof kept === "object" ? (kept as Partial<Draft>) : {}; return { verdict: d.verdict === "ship" || d.verdict === "hold" ? d.verdict : null, note: typeof d.note === "string" ? d.note : "", }; }</script>
<script lang="ts"> import { CircleCheck, CircleX, Hand, Rocket } from "@lucide/svelte"; import { untrack } from "svelte"; import type { Init, Plugin } from "pinrail-sdk/types";
let { plugin, init, view }: { plugin: Plugin<Payload, Decision>; init: Init<Payload, Decision>; view: View } = $props();
// the component is mounted afresh for each init, so it starts from it once // the app closes the view once the decision is accepted: what it shows // is the review as init handed it over const { review, readonly } = untrack(() => init); let draft = $state<Draft>(untrack(() => draftOf(init.draft))); let error = $state(""); // the object main.ts calls, kept for the life of the page: filled in once const handlers = untrack(() => view);
handlers.collect = () => { const { verdict, note } = draft; if (!verdict) { error = "Choose ship or hold first."; return; } return note.trim() ? { verdict, note: note.trim() } : { verdict }; };
function choose(verdict: Verdict) { draft.verdict = verdict; error = ""; plugin.draft(draft); }
function writeNote(event: Event) { draft.note = (event.target as HTMLTextAreaElement).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. function onKey(e: KeyboardEvent) { const typing = e.target instanceof Element && e.target.closest("textarea, input"); if (readonly || e.metaKey || e.ctrlKey || e.altKey || typing) return; if (e.key === "s") choose("ship"); if (e.key === "h") choose("hold"); }
// what the app's hand-over button says follows the choice $effect(() => { if (readonly) return; const label = draft.verdict === "ship" ? `Ship ${review.payload.version}` : draft.verdict === "hold" ? "Hold the deploy" : "Choose ship or hold"; plugin.handOverLabel(label); });
const decided = review.decision?.data;</script>
<svelte:document onkeydown={onKey} />
{#if review} <main class="pinrail-content ship"> <p class="pinrail-eyebrow">Deploy to {review.payload.environment}</p> <h1>{review.payload.service} <span class="pinrail-chip">{review.payload.version}</span></h1> <section> <h2 class="pinrail-eyebrow">Changes</h2> <ul aria-label="Changes"> {#each review.payload.changes as c (c.title)} <li>{c.title}{#if c.risky}{" "}<span class="pinrail-tone pinrail-tone-warning">risky</span>{/if}</li> {/each} </ul> </section> <section> <h2 class="pinrail-eyebrow">Checks</h2> <ul aria-label="Checks"> {#each review.payload.checks as c (c.name)} <li data-passed={String(c.passed)}> {#if c.passed}<CircleCheck />{:else}<CircleX />{/if} {c.name}{#if c.detail}{" "}<span class="detail">{c.detail}</span>{/if} </li> {/each} </ul> </section> {#if readonly} <p class="decided"><b>{decided?.verdict === "ship" ? "Shipped" : "Held"}</b>{#if decided?.note}: {decided.note}{/if}</p> {:else} <div class="choice" role="group" aria-label="Verdict"> <button type="button" class="pinrail-btn" aria-pressed={draft.verdict === "ship"} onclick={() => choose("ship")}> <Rocket /> Ship <kbd>s</kbd> </button> <button type="button" class="pinrail-btn" aria-pressed={draft.verdict === "hold"} onclick={() => choose("hold")}> <Hand /> Hold <kbd>h</kbd> </button> </div> <textarea class="pinrail-note" aria-label="Note to the agent" placeholder="A note for the agent (optional)" value={draft.note} oninput={writeNote}></textarea> <div class="pinrail-errors" role="alert">{error}</div> {/if} </main>{/if}4. Try it and test it
Section titled “4. Try it and test it”npm installnpm run watch # rebuilds view/ as you save…npx pinrail-sdk dev # …and shows it in a browser, on the fixturespinrail plugins check . # what the app would say of the foldernpm test # build, then the tests under the harnessA 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:
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);});5. Install it
Section titled “5. Install it”npm run buildpinrail plugins install . --linkA 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.
Things to know
Section titled “Things to know”- A forwarded key has no element as its target. The app forwards a manifest shortcut pressed outside the frame as a
keydownon your document, so checkevent.target instanceof Elementbefore callingcloseston it. - Hand the SDK your state as it is. Vue’s refs and Svelte’s
$stateare 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.connectin the entry module, not in a component: a framework may mount a component more than once, and a second call throws. Render the component whenonInitarrives. 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.