Skip to content

Design and styling

A view sits inside the app, between the review’s header and the hand-over button, so the person reads it as part of Pinrail. This page is how to make it look that way with little effort: the conventions the official plugins follow, and what the SDK stylesheet gives you.

What makes a view feel native, whatever it draws:

  • The app owns the hand-over. Do not draw a submit button, and do not repeat the review’s title. The app shows the title above the view and the hand-over button below it, in the same place for every plugin. A header of your own, such as the one Pinrail.layout() draws, can name what the view lists and show counts. Tell the hand-over button what it will do with plugin.handOverLabel, such as Hand over 3 of 5.
  • Dense and quiet. Text at 13px, one accent colour, lines rather than boxes. What matters is the work being reviewed, not the frame around it.
  • Keys for the common path. Most plugins move with J and K and give a verdict with one key each. Declare them in the manifest so the app lists them in its keyboard help (see Settings and keys).
  • Read-only is a full view. A decided review is read months later: render what was there and what was decided, without the controls.
  • A brand’s colours stay in the content. A plugin that shows something in its own palette, as the Logo plugin shows marks in the brand’s colours, paints that inside its content; the chrome around it keeps the app’s tokens.

A review screen has five parts. The app draws two of them around the view, and Pinrail.layout() draws the three inside it:

1Sentry triageacme-api · 5 minutes ago
24 items1 accepted3 undecidedAccept all
Your own sidebar, if the view needs one
3
The view's content

It scrolls; the parts above and below it stay in place.

43 left undecided. Hand over again to confirm.Keep deciding
5Add a note for the agent…Hand over with 3 undecided
The app draws 1 and 5. Pinrail.layout() draws 2, 3 and 4 inside the view's frame.
PartDrawn byWhat goes there
1Review barThe appThe review’s title and where it comes from. Do not repeat them in the view.
2HeaderPinrail.layout(), as .pinrail-headerWhat the view lists, its counts, and controls that act on all of it, such as Accept all. It stays in place while the body scrolls.
3BodyPinrail.layout(), as .pinrail-scroll and .pinrail-contentThe view’s content, which scrolls. A sidebar of the view’s own, such as the item list in the List and Code review plugins, goes beside it.
4Confirmation barview.confirmation(), as .pinrail-confirmationWhat the person must confirm before the next hand-over, such as items left undecided. It is hidden until the view asks for it.
5ComposerThe appThe note to the agent and the hand-over button. The view sets the button’s label with plugin.handOverLabel.

These positions are a convention, not a rule. The official plugins follow them so that every review reads the same way, and Pinrail.layout() builds them for you. A view that needs another arrangement can skip Pinrail.layout() and lay out its frame as it likes, or use the layout and restyle any part of it. Only parts 1 and 5 are fixed, because the app draws them outside the view.

<link rel="stylesheet" href="/sdk/v1/pinrail-plugin.css">

It sets the base (type, links, focus rings, code, tables, narrow scrollbars), the colour tokens below in both themes, and a few classes for the shapes most views need. It also styles what Pinrail.markdown renders: headings, lists, quotes, tables and code.

Every class it defines starts with pinrail-, every token with --pinrail-, and every rule is in the cascade layer pinrail. A component library’s own names, such as .btn or --border, therefore keep their meaning beside it. A view that brings such a library and wants only the palette links the tokens alone:

<link rel="stylesheet" href="/sdk/v1/tokens.css">

Style with the tokens rather than with colours, and your view follows the app in both themes with no palette of its own to keep in step.

TokenForLightDark
Surfaces
--pinrail-bgThe page#ffffff#18191b
--pinrail-bg-panelHeaders, items#ffffff#18191b
--pinrail-bg-raisedSomething above the page: a card, a popover#f6f7f9#202124
--pinrail-bg-hoverUnder the pointer#f3f4f7#232428
Lines
--pinrail-borderDividers and quiet outlines#e8e9ed#2b2d31
--pinrail-border-strongControls and fields#cdd0d7#44474e
Text
--pinrail-textWhat is read#24262c#ededef
--pinrail-dimSecondary text#72757f#999ba5
--pinrail-faintLabels, hints, ids#888b94#7b7d87
Accent
--pinrail-accentLinks, focus, what is selected#5965cb#a1a5ef
--pinrail-accent-bgBehind what is selected#f0f0fa#292a3c
--pinrail-button-bgThe one primary button#5965bc#6873d6
Tones
--pinrail-dangerRefused, destructive, a blocker#bc4655#e48c98
--pinrail-warningNeeds attention#99651b#d4ac6a
--pinrail-infoWorth knowing#5965cb#a1a5ef
--pinrail-successDone, accepted#28795c#7cc79e
--pinrail-neutralNeither#65738e#a0abc2
Diffs
--pinrail-add-bgAn added lineoklch(0.65 0.13 150 / 0.13)oklch(0.65 0.12 150 / 0.1)
--pinrail-add-gutIts gutteroklch(0.65 0.13 150 / 0.24)oklch(0.65 0.12 150 / 0.18)
--pinrail-del-bgA removed lineoklch(0.6 0.16 25 / 0.11)oklch(0.6 0.15 25 / 0.1)
--pinrail-del-gutIts gutteroklch(0.6 0.16 25 / 0.2)oklch(0.6 0.15 25 / 0.18)
.card {
background: var(--pinrail-bg-raised);
border: 1px solid var(--pinrail-border);
color: var(--pinrail-text);
}
.card .hint {
color: var(--pinrail-faint);
}

The five tones are the ones the manifest’s summary uses: --pinrail-danger, --pinrail-warning, --pinrail-info, --pinrail-success and --pinrail-neutral. --pinrail-sans is the app’s typeface stack, Inter first, and --pinrail-mono its monospace one.

Pinrail.layout() builds the skeleton the stylesheet expects: a header that stays put, a body that scrolls, and a confirmation bar under the body that stays hidden until the view asks for it. See The anatomy of a view.

const view = Pinrail.layout({ title: "5 tickets", meta: ["acme-api"], controls: [closeAll] });
view.content.innerHTML = rows; // re-render the body freely
view.title("4 tickets").meta(["acme-api"]); // the header keeps its listeners

When a hand-over needs a second look, such as items left undecided, hold the first one back, say why in the confirmation bar, and let the next hand-over through. Say on the hand-over button what will happen:

plugin.handOverLabel(`Hand over with ${n} undecided`);
view.confirmation({ text: `${n} left undecided. Hand over again to confirm.`, actions: [keepDeciding] });
view.confirmation(null); // nothing left to confirm

The bar is a warning unless you pass tone: "info" or tone: "danger". Its actions are buttons of your own, such as one that takes the person back to deciding.

A view that builds its own layout places the same bar itself, below the part that scrolls:

const bar = Pinrail.confirmationBar();
document.getElementById("app").append(bar.element);
bar.confirmation({ text: "2 left undecided. Hand over again to confirm." });
ClassWhat it is
.pinrail-layoutThe whole view: the header, then the scrolling body.
.pinrail-headerThe bar at the top, with .pinrail-title, .pinrail-meta and .pinrail-controls, pushed to the right.
.pinrail-scroll, .pinrail-contentThe body that scrolls, and its padded content.
.pinrail-confirmationThe bar under the body that view.confirmation() fills, with .pinrail-confirmation-warning, -info or -danger.
.pinrail-subheadA heading in the body that stays while its section is on screen.
.pinrail-spacerPushes what follows it to the end of a row.
ClassWhat it is
.pinrail-item, with .pinrail-item-head, -id, -title, -body and -controlsOne thing the person says yes or no to.
.pinrail-btn, with .pinrail-btn-primary, -danger or -ghostButtons. aria-pressed="true" marks the chosen one of a set.
.pinrail-field, .pinrail-noteInputs and text areas.
.pinrail-notice, with .pinrail-notice-success, -warning or -dangerSomething to tell the person.
.pinrail-tone, with .pinrail-tone-danger, -warning, -info, -success or -neutralA label in one of the five tones, such as a severity.
.pinrail-chipA small monospace chip: a branch, a line number, an id.
.pinrail-eyebrow, .pinrail-dim, .pinrail-faintA small label above a section, and quieter text.
.pinrail-emptyWhat to show when there is nothing to show.
.pinrail-errorsErrors to show as they came.
<div class="pinrail-item">
<div class="pinrail-item-head">
<span class="pinrail-item-id">#101</span>
<span class="pinrail-tone pinrail-tone-warning">major</span>
<span class="pinrail-item-title">Export times out past 50k rows</span>
</div>
<div class="pinrail-item-body">Seen in three tickets this week.</div>
<div class="pinrail-item-controls">
<button class="pinrail-btn" aria-pressed="true">Close</button>
<button class="pinrail-btn">Keep</button>
</div>
</div>

The app is dark or light, and the view follows it. Before the view paints, the SDK sets data-theme="dark" or data-theme="light" on your root element, from the frame’s address, so the first frame is already right. When the person switches, the app sends appearance and the SDK updates it again.

Styles written against the tokens need nothing more. For anything else, key it on the attribute:

.stage {
background: #101216;
}
[data-theme="light"] .stage {
background: #f4f5f7;
}

onAppearance(theme) tells your code when the theme changes, for a canvas or a chart that draws its own colours.

A plugin brings its own icons. A view without a build keeps them as SVG files in view/icons/, and Pinrail.icon(name) draws icons/<name>.svg.

button.innerHTML = `${Pinrail.icon("check")} Accept`;
Pinrail.icon("triangle-alert", { size: 16, label: "Warning" });

The app draws with Lucide, so its icons fit best: copy the ones you use from the lucide-static package, keeping the licence comment each file starts with. An icon takes the colour of the text around it, in either theme, drawn from its shapes alone. It follows the font size unless you give size; give label when the icon means something on its own, so screen readers say it.

A view with a build imports its icons from its framework’s Lucide package instead, as the React, Vue, Svelte and Vite templates do: lucide-react, @lucide/vue, @lucide/svelte, or lucide for plain TypeScript. The build keeps only the icons the view imports, and the SDK stylesheet sizes each svg.lucide to the text.

The manifest’s icon is an SVG file in the folder too, icon.svg in the scaffold, which the app shows wherever it names the plugin, drawn the same way. It is at most 32 KB. If the icon cannot be loaded, the app shows the plugin without an icon.

To render Markdown, load the SDK’s Markdown script after the SDK:

<script src="/sdk/v1/pinrail-plugin.js"></script>
<script src="/sdk/v1/markdown.js"></script>

Pinrail.markdown(text) returns CommonMark as HTML, with headings, tables, block quotes, nested lists and code, and Pinrail.markdownInline(text) renders one line without a paragraph around it. Raw HTML in the source is escaped rather than passed through, because a view’s frame runs inline scripts. A link whose scheme is not http, https or mailto keeps its text but loses its address, and the app asks the person before it opens any other link in their browser. The output has no classes, and the stylesheet styles its plain elements.

The app gives the view the whole height of the review panel, whatever the view holds, so the hand-over button is in the same place for every plugin. A view whose content is taller scrolls inside its frame. Pinrail.layout() builds a header that stays in place over a body that scrolls.

A view loads nothing from the network, but everything in the plugin folder is served beside it. Put fonts, stylesheets, scripts and images in the folder, in an assets/ folder for instance, and refer to them by relative path:

view/index.html
<link rel="stylesheet" href="/sdk/v1/pinrail-plugin.css">
<link rel="stylesheet" href="assets/view.css">
<script type="module" src="assets/view.js"></script>
view/assets/view.css
@font-face {
font-family: "Fraunces";
src: url("fonts/fraunces.woff2") format("woff2");
}
.title {
font-family: "Fraunces", var(--pinrail-sans);
}

A library goes in the same way: bundle it with your view, or copy its built file into the folder. A plugin with a build step, such as Vite, writes its output into the folder and declares the command as build in the manifest (see Building with a framework); the Markdown review plugin is built with Vite, which bundles markdown-it and Mermaid into its view.