Site Kits
Add a suite of features to a website you don't own (a button, a badge, a sidebar, commands) that looks like the site built it, and keeps working when the site changes.
A Site Kit is a set of features installed on one website: a badge in a pull request's header, a note beside each page, commands in a palette, a compact view of a diff. If you have written a userscript, you know the shape of it. A kit is that, with the parts a userscript leaves to you done: it finds the place it belongs even after the site changes a class name, it cleans up when you navigate, it looks like the site built it, and it tells you when it breaks.
A kit is a group in your Library with a site, so it has a folder, a version and macros beside it like any group. Safari and Chrome covers connecting the browser; this page assumes you have. For the same idea around a Mac app (an unread count as a value, buttons pinned to its window, menu shortcuts, settings presets), see App Kits.
The folder #
A group with a Site (for example github.com/*) is a Site Kit. Its kit folder holds:
kit/
kit.json id, version, anchors, settings, permissions, palette, sidebar, theme
features/*.js each file is a feature: routes, commands, response hooks
lib/*.js shared modules, used with require("name")
styles/*.css the kit's own layout, injected on every matching page
html/*.html fragments a feature places
snapshots/*.html saved pages for the health check
Open the group and choose the Kit tab to see the files, open one in your editor, or start a feature from a template. Save a file and every open tab updates at once: the old CSS goes, the features unmount and mount again, and a toast says GitHub+ reloaded. If the new code doesn't compile, the old version keeps running and the Kit tab shows the file and line.
JavaScript is plain CommonJS. Use require("util") for a file in lib/, not import; the compiler tells you the line if you forget.
kit.json #
kit.json lists every key. The ones you start with:
{
"id": "kit.github-plus",
"version": "1.0.0",
"permissions": { "mainWorld": true, "responses": ["/*/*/commits/checks-statuses-rollups"] },
"anchors": {
"prHeader": {
"selectors": [".gh-header-actions", "role=toolbar[name=\"Pull request\"]"],
"routes": ["/:owner/:repo/pull/:id"],
"required": true
}
},
"settings": { "compact": { "type": "bool", "default": false, "label": "Compact diff" } },
"palette": { "hotkey": "⌥⌘K" },
"sidebar": { "title": "PR notes" }
}
Routes and features #
Most sites are single-page apps: the address changes and the page rebuilds without loading. A feature says which addresses it is for, and Switchboard mounts it when you arrive and unmounts it when you leave:
kit.route("/:owner/:repo/pull/:id", {
async mount(ctx) {
const header = await ctx.anchor("prHeader");
ctx.place('<sb-badge tone="success">Checks passed</sb-badge>', header, "append");
header.addEventListener("click", onClick, { signal: ctx.signal });
},
});
A route is an address pattern:
:parammatches one piece,*matches anything.kit.route("*", …)is every page of the site.ctx.placeputs HTML where you say (before,after,prepend,appendorreplace) andctx.signalremoves your listeners. When the route unmounts, everything you placed goes, listeners too; you write no cleanup for those.Going from pull request 12 to 13 unmounts and mounts again, with the new
ctx.params.If
mountthrows three times in a row, only that feature turns itself off, with a note in Activity. The rest of the kit keeps working. Turn Off Switchboard on This Site in the browser's toolbar menu is the last resort: every kit and macro for that site stops until you turn it back on.
Anchors: finding the place #
An anchor is a name for a place in the page, with several selectors in order of preference. await ctx.anchor("prHeader") tries each in turn, waits up to five seconds for the page to build it, and throws AnchorMissing if none turns up. Put the fallbacks a person would use to find it: a role= selector, or visible text (text=Files changed), not a class that looks generated.
Every result is reported: hit (the first selector), fallback (a later one) or miss. See kit.json for the selector forms.
Placing content so it looks built in #
You don't style a control to match the site. You place a component and the kit makes it match:
ctx.place('<sb-button>Make a to-do</sb-button>', header, "append");
sb-button, sb-badge, sb-menu, sb-popover, sb-panel, sb-field, sb-toggle, sb-tabs, sb-list, sb-tooltip, sb-spinner, sb-skeleton, sb-empty and sb-toast are drawn from a small set of tokens (--sb-text, --sb-surface, --sb-accent, --sb-radius, --sb-control-height, --sb-font and the rest) with every state designed: rest, hover, press, focus, disabled, loading. They move on their own, at the site's pace, and stop moving when the person asked their Mac for reduced motion.
The tokens come from the first of these that applies:
The site's own variables. For GitHub, Gmail, Slack, Linear, Notion, YouTube, Reddit, Jira, Figma and X, Switchboard ships a profile that points each token at the site's own CSS variable, so the kit follows the site's light, dark and dimmed themes by itself.
A sibling.
ctx.place(html, anchor, "append", { like: "sibling" })reads the element beside the spot (or any selector you name) and copies its height, radius, weight, colours and hover. A button placed among buttons does this without being asked.Sampling. With neither, the kit samples the page's body, links, buttons, inputs and cards.
Contrast is checked at 4.5 to 1 and the kit's copy of a colour is nudged until it passes. Your styles/*.css is for the kit's own layout. If it sets color, background or font-size on an sb-* element, or uses a hex colour where a token exists, Lint says "use --sb-accent, so it follows the site's theme". Check Fit in the Kit tab then compares every placed control with its neighbour and lists the differences ("prBadge: 2 px shorter than its neighbour; radius 6 vs 8"). "theme": "switchboard" gives a kit that is its own tool the system look instead.
HTML you build yourself becomes components with kit.ui.upgrade(root).
Reading the site's data #
The site often has the answer before it shows it. kit.onResponse hands your code the JSON of a request the page makes, before the page draws it:
kit.onResponse("/*/*/commits/checks-statuses-rollups", (body, info) => {
/* body is parsed JSON; info is {url, status, method, at} */
}, { as: "text" });
List the path in
permissions.responsesinkit.json; a pattern that isn't listed throws "not in this kit's permissions". The kit needs"mainWorld": trueand, in Chrome, Allow User Scripts.Responses are observed, never changed or delayed. Bodies over 2 MB are skipped.
A macro in the same kit gets the same data from the A kit reads the site's data trigger.
Store and settings #
Store. kit.store keeps records in Switchboard, not in the site, so they survive reloads, updates and turning the kit off, and a note saved in Chrome shows in an open Safari tab:
await kit.store.put("notes", "octo/repo/pull/7", { text: "after Friday" });
const note = await kit.store.get("notes", "octo/repo/pull/7");
kit.store.watch("notes", (key, value) => { /* another tab or macro changed it */ });
A value is JSON up to 1 MB; a collection holds up to 10,000 keys. Macros read and write the same records with Keep a record in the kit.
Settings. settings in kit.json becomes a form in the Kit tab and behind the ⚙ at the top of the kit's sidebar. A feature reads ctx.settings.compact in mount; when the person changes a value the open tabs hear at once and every mounted feature mounts again with the new value. Use kit.settings.on(fn) for code outside a route. A macro in the kit reads {{ kit.setting.compact }}.
The palette and the sidebar #
Palette. ⌥⌘K (change it with palette.hotkey) opens a list of commands in the page, drawn in the site's own look. kit.command("Copy PR link", fn, { keys: "⌥⌘L" }) adds one while its route is mounted. The kit's macros that have Callable from pages on are listed too and run with the page's address, title and selection as input; a person can edit them or give them a shortcut. The hotkey avoids ⌘K, which GitHub, Slack and Linear use for their own.
Sidebar. kit.sidebar.set(tree) draws a panel from a tree of stacks, text, fields, toggles, buttons, lists and tables, and kit.sidebar.on(id, fn) hears what the person does in it. In Chrome it is the browser's side panel, drawn like Chrome's own. In Safari, and in a Chrome without the side panel, the same tree is a drawer at the right edge of the page. Kit code never runs in the panel itself.
When the site changes #
Every kit has a health note. When a required anchor misses, or falls back three loads in a row, the group in the sidebar gets a warning badge, Activity gets one row for the kit ("GitHub+: 2 of 9 anchors stopped matching"), and the Kit tab lists each anchor with its last result, address and time.
Repair with AI. On the Activity row and in the Kit tab. It takes a snapshot of the page, compares it with the saved one, proposes new selectors, and outlines the element it found in the tab. Nothing is written until you press Save.
Save Page for Checks. In the Kit tab it stores the front tab's page (redacted like a trace) as a snapshot. Switchboard checks every anchor against those snapshots each day, with no browser open, so a change is reported before you open the page.
switchboard kit check <kit>runs the same check from Terminal. See switchboard kit.
Importing userscripts and styles #
File ▸ Import… (or dropping the file on the editor) reads a Tampermonkey or Violentmonkey .user.js, a Stylus .user.css and a Stylus JSON export. Each becomes a kit that is off until you turn it on, and the review lists what came across and what didn't.
A userscript becomes one kit. The script itself, header comment included, runs unchanged inside features/main.js.
| In the script | In the kit |
|---|---|
@name, @version, @namespace | the group's name, version (1.4 becomes 1.4.0; none is 0.0.0) and the id (kit. plus the namespace's host reversed, then the name) |
@match, @include | the group's site: *://*.example.com/* becomes *.example.com/*, <all_urls> and *://*/* become *. A regular expression in @include can't be a site pattern, so it runs everywhere and the review says so: narrow the site before you turn it on |
@exclude, @exclude-match | the site's exclude list |
@run-at | when the script starts: document-start at once, document-end after the page's DOM is ready, document-idle (also the default, as in Tampermonkey) after it has loaded |
@require | downloaded once into lib/, with // from <url> on its first line, and loaded before the script. Libraries that attach themselves to window (jQuery, lodash) work; one that expects this to be the window doesn't |
@resource | downloaded once into html/<name>.<ext>, untouched, and read by GM_getResourceText |
unsafeWindow | the kit's mainWorld permission, so the script can see the page's own objects |
@connect, @inject-into, @noframes | noted in the review and otherwise ignored |
GM_addStyle, GM_getValue, GM_setValue, GM_deleteValue, GM_listValues, GM_registerMenuCommand, GM_notification, GM_setClipboard, GM_openInTab, GM_getResourceText, GM_getResourceURL and GM_info work, in both the GM_x() and GM.x() spellings. Stored values live in the kit's store, not in the browser.
Not supported: GM_xmlhttpRequest, GM_download, GM_cookie, GM_webRequest, GM_getTab, GM_getTabs, GM_saveTab and GM_addValueChangeListener. The script still installs and the rest of it works; a call to one of these throws an error that names it, and the review lists each with the line numbers that use it. GM_xmlhttpRequest stays out on purpose: it lets a script from any page make a request to any other site with your cookies, which a kit can't be allowed to do. If a script needs data from another site, fetch it in a macro in the same kit and have the feature call that macro.
A file that can't be downloaded (@require, @resource) is left out and the review says which; fix the address and import again.
A style (.user.css or a Stylus export) becomes a kit of styles/<n>.css files, one per @-moz-document block or Stylus section. The CSS applies on the kit's sites by itself; there is no feature file.
domain("a.com")becomes*.a.com/*;url()andurl-prefix()stay as written; aregexp()can't be a site pattern, so that section runs on every site and the review says so.Sections for different sites become separate kits (one per distinct list of sites), so one site's CSS never lands on another. Sections for the same site share a kit.
UserCSS
@varvariables are set to their defaults, by replacing/*[[name]]*/and by declaring the custom property forvar(--name). The review lists them. Styles that use the Less or Stylus preprocessor are imported as written and flagged, because they aren't compiled.
Sharing and updating a kit #
Export. Right-click the kit's group and choose Share Kit… (also in the Group inspector). The sheet shows exactly what leaves your Mac: every kit file with its size, the group's macros (and any macro they call from another group), what was left out and why, and what the other person will be asked to trust. Export File… writes one .switchboard file.
Not shared, ever: the kit's settings values and everything it keeps with
kit.store. Those stay on your Mac.Saved pages (the kit's
snapshotsfolder) can hold what was on your screen, so they stay out unless you tick Include saved pages. The plain Export… on a group (a zip of the group) includes the kit the same way, and asks in the Save panel.Secrets, personal paths and screen positions in the macros are removed the way they are for a single macro.
Importing a kit. Open the file with File ▸ Import…, or drop it on the window. The review has a Trust section for the kit, in plain words:
Runs on the sites the kit applies to.
Runs JavaScript inside the page, when the kit uses the page's own world.
Reads responses matching the patterns the kit may read.
Pages can run the macros a page may call.
Keeps data, when the kit stores records.
Macros use the steps that leave the browser: shell, scripts, http, files, Keychain.
A kit always installs turned off (the button reads Install Turned Off). Read the Trust section, then turn it on from its Kit tab.
Updating. When the file holds a kit whose id you already have, the review shows an Update block instead: the old and new version, how many files changed, were added or were removed (open it for the paths), and any permission the new version adds, in bold ("Now runs in the page's world"). Then choose:
Update (the default) writes the new files into the kit you have. Its settings, its stored data and whether it is turned on stay exactly as they are, and ⌘Z puts the old files back.
Keep Both installs the new version as a second kit, turned off, with its own id (the original's id with
-copyadded) and the name "… copy". Your kit is untouched.
Updating from a web address waits for the gallery.
Trying it #
The Examples group has GitHub+, a real kit with a checks badge in the pull request header, commands (Copy PR link, Open in editor), a private note per pull request in the sidebar, and a compact diff setting. Read its files to see each part above in use.
To try a kit without touching your browser profile, run the dev script from the Switchboard source with the test kit Fixture+ and a small app it runs against:
mac/scripts/browser-dev.sh --kit mac/Tests/Fixtures/kits/fixture-plus
It starts a separate browser and engine with the kit installed and opens the app. Change a file in the kit and watch the tab reload.
When something doesn't work #
Nothing appears. Open the Kit tab: a red anchor means the page changed; Repair with AI.
A feature disappeared. It failed three times and turned itself off; Activity says why. Fix the file and save.
onResponsethrows "not in this kit's permissions". Add the exact pattern topermissions.responses, and checkmainWorld.A control looks different from its neighbour. Run Check Fit and read the differences.